feat: Track A frontend - analytics event pipeline core + real call sites

core/analytics (AnalyticsEvent model, gateway/token/mock, AnalyticsService
wrapper) against docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. isSynthetic
is derived from the build environment at the service layer, never
client-settable at a call site - matches the contract's §6 requirement
that synthetic traffic be inseparable-by-accident from production data
once a real backend exists.

Wired into real, live interaction points (additive only, no existing
logic touched): product_view + add_to_cart in
product-details-container.component.ts, checkout_started + payment_started
in pages/cart/cart.component.ts. This is the actual event-firing
infrastructure the plan calls "the single largest remaining backend
effort" (§3.1) - the frontend side (call sites) is real now; the mock
gateway just doesn't persist anywhere yet.

Not wired: search/category_view/seller_view/cart_view/payment_success/
payment_failed/order_created - follow-up call sites once this pattern is
reviewed, to avoid a much larger unreviewed diff in one push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-18 00:05:46 +04:00
parent 34f79b0303
commit be167d110e
7 changed files with 77 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
/** Per docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1-4. */
export type AnalyticsEventType =
| 'session_started' | 'page_view' | 'search' | 'category_view' | 'product_view' | 'seller_view'
| 'add_to_cart' | 'cart_view' | 'checkout_started'
| 'payment_started' | 'payment_success' | 'payment_failed' | 'order_created';
export interface AnalyticsEvent {
eventType: AnalyticsEventType;
properties: Record<string, unknown>;
isSynthetic: boolean;
}

View File

@@ -0,0 +1,7 @@
import { Observable } from 'rxjs';
import { AnalyticsEvent } from '../models/analytics-event.model';
/** Per docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
export interface AnalyticsGateway {
track(event: AnalyticsEvent): Observable<void>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { AnalyticsGateway } from './analytics-gateway.interface';
import { AnalyticsLocalGateway } from './analytics-local.gateway';
/** Swap point for docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
export const ANALYTICS_GATEWAY = new InjectionToken<AnalyticsGateway>('ANALYTICS_GATEWAY', {
providedIn: 'root',
factory: () => inject(AnalyticsLocalGateway),
});

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { AnalyticsEvent } from '../models/analytics-event.model';
import { AnalyticsGateway } from './analytics-gateway.interface';
/**
* No tracking pipeline exists at all today (confirmed - this is missing
* infrastructure, not a missing endpoint, per GAPS-AND-IMPROVEMENTS.md and
* docs/backend/TRACK-A-ANALYTICS-CONTRACT.md). This mock only proves the
* call-site wiring is correct; it does not persist anything.
*/
@Injectable({ providedIn: 'root' })
export class AnalyticsLocalGateway implements AnalyticsGateway {
track(_event: AnalyticsEvent): Observable<void> {
return of(void 0);
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable, inject } from '@angular/core';
import { take } from 'rxjs/operators';
import { AnalyticsEventType } from '../models/analytics-event.model';
import { ANALYTICS_GATEWAY } from './analytics-gateway.token';
import { environment } from '../../../../environments/environment';
/**
* Thin call-site wrapper so storefront components fire events without
* knowing about the gateway/token plumbing. isSynthetic is derived from the
* build environment, never client-settable at the call site (per
* docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §6 - synthetic traffic must be
* inseparable-by-accident from production data).
*/
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private readonly gateway = inject(ANALYTICS_GATEWAY);
track(eventType: AnalyticsEventType, properties: Record<string, unknown> = {}): void {
this.gateway.track({
eventType,
properties,
isSynthetic: !environment.production,
}).pipe(take(1)).subscribe();
}
}

View File

@@ -17,6 +17,7 @@ import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPag
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
import { ProductShareService } from '../../user-experience/services/product-share.service';
import { SeoService } from '../../../../services/seo.service';
import { AnalyticsService } from '../../../../core/analytics/services/analytics.service';
import { ApiService } from '../../../../services/api.service';
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
@@ -78,6 +79,7 @@ export class ProductDetailsContainerComponent {
private readonly translate = inject(TranslateService);
private readonly shareService = inject(ProductShareService);
private readonly seoService = inject(SeoService);
private readonly analytics = inject(AnalyticsService);
private readonly apiService = inject(ApiService);
private readonly storage = inject(LocalStorageService);
private readonly notifications = inject(UserNotificationService);
@@ -276,6 +278,7 @@ export class ProductDetailsContainerComponent {
this.product.set(product);
this.seoService.setItemMeta(product);
this.analytics.track('product_view', { itemID: product.itemID });
if (this.userExperienceConfig().recentlyViewed.enabled) {
this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
}
@@ -321,6 +324,7 @@ export class ProductDetailsContainerComponent {
addToCart(): Promise<void> {
const current = this.product();
if (!current) return Promise.resolve();
this.analytics.track('add_to_cart', { itemID: current.itemID, price: this.effectivePrice(), currency: this.effectiveCurrency() });
return this.cartService.addItem(current.itemID, 1, {
colour: this.selectedColour() ?? undefined,
size: this.selectedSize() ?? undefined,

View File

@@ -18,6 +18,7 @@ import { IconComponent } from '../../shared/ui/icon/icon.component';
import { EmptyStateComponent } from '../../shared/ui/empty-state/empty-state.component';
import { ButtonComponent } from '../../shared/ui/button/button.component';
import { ConfigService } from '../../core/config/config.service';
import { AnalyticsService } from '../../core/analytics/services/analytics.service';
import { TenantResolverService } from '../../core/config/tenant-resolver.service';
import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service';
import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component';
@@ -83,6 +84,7 @@ export class CartComponent implements OnDestroy {
private configService = inject(ConfigService);
private tenantResolver = inject(TenantResolverService);
private currencyRates = inject(CurrencyRatesService);
private readonly analytics = inject(AnalyticsService);
constructor(
private cartService: CartService,
@@ -206,10 +208,12 @@ export class CartComponent implements OnDestroy {
this.notifications.show(this.i18n.t('cart.acceptTerms'), 'warning');
return;
}
this.analytics.track('checkout_started', { itemCount: this.items().length });
this.openPaymentPopup(paymentMethod);
}
openPaymentPopup(paymentMethod: PaymentMethod): void {
this.analytics.track('payment_started', { paymentMethod });
this.showPaymentPopup.set(true);
this.selectedPaymentMethod.set(paymentMethod);
this.paymentStatus.set('creating');