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:
11
src/app/core/analytics/models/analytics-event.model.ts
Normal file
11
src/app/core/analytics/models/analytics-event.model.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
17
src/app/core/analytics/services/analytics-local.gateway.ts
Normal file
17
src/app/core/analytics/services/analytics-local.gateway.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/app/core/analytics/services/analytics.service.ts
Normal file
25
src/app/core/analytics/services/analytics.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPag
|
|||||||
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
|
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
|
||||||
import { ProductShareService } from '../../user-experience/services/product-share.service';
|
import { ProductShareService } from '../../user-experience/services/product-share.service';
|
||||||
import { SeoService } from '../../../../services/seo.service';
|
import { SeoService } from '../../../../services/seo.service';
|
||||||
|
import { AnalyticsService } from '../../../../core/analytics/services/analytics.service';
|
||||||
import { ApiService } from '../../../../services/api.service';
|
import { ApiService } from '../../../../services/api.service';
|
||||||
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||||
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
|
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
|
||||||
@@ -78,6 +79,7 @@ export class ProductDetailsContainerComponent {
|
|||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly shareService = inject(ProductShareService);
|
private readonly shareService = inject(ProductShareService);
|
||||||
private readonly seoService = inject(SeoService);
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly analytics = inject(AnalyticsService);
|
||||||
private readonly apiService = inject(ApiService);
|
private readonly apiService = inject(ApiService);
|
||||||
private readonly storage = inject(LocalStorageService);
|
private readonly storage = inject(LocalStorageService);
|
||||||
private readonly notifications = inject(UserNotificationService);
|
private readonly notifications = inject(UserNotificationService);
|
||||||
@@ -276,6 +278,7 @@ export class ProductDetailsContainerComponent {
|
|||||||
|
|
||||||
this.product.set(product);
|
this.product.set(product);
|
||||||
this.seoService.setItemMeta(product);
|
this.seoService.setItemMeta(product);
|
||||||
|
this.analytics.track('product_view', { itemID: product.itemID });
|
||||||
if (this.userExperienceConfig().recentlyViewed.enabled) {
|
if (this.userExperienceConfig().recentlyViewed.enabled) {
|
||||||
this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
|
this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
|
||||||
}
|
}
|
||||||
@@ -321,6 +324,7 @@ export class ProductDetailsContainerComponent {
|
|||||||
addToCart(): Promise<void> {
|
addToCart(): Promise<void> {
|
||||||
const current = this.product();
|
const current = this.product();
|
||||||
if (!current) return Promise.resolve();
|
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, {
|
return this.cartService.addItem(current.itemID, 1, {
|
||||||
colour: this.selectedColour() ?? undefined,
|
colour: this.selectedColour() ?? undefined,
|
||||||
size: this.selectedSize() ?? undefined,
|
size: this.selectedSize() ?? undefined,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { IconComponent } from '../../shared/ui/icon/icon.component';
|
|||||||
import { EmptyStateComponent } from '../../shared/ui/empty-state/empty-state.component';
|
import { EmptyStateComponent } from '../../shared/ui/empty-state/empty-state.component';
|
||||||
import { ButtonComponent } from '../../shared/ui/button/button.component';
|
import { ButtonComponent } from '../../shared/ui/button/button.component';
|
||||||
import { ConfigService } from '../../core/config/config.service';
|
import { ConfigService } from '../../core/config/config.service';
|
||||||
|
import { AnalyticsService } from '../../core/analytics/services/analytics.service';
|
||||||
import { TenantResolverService } from '../../core/config/tenant-resolver.service';
|
import { TenantResolverService } from '../../core/config/tenant-resolver.service';
|
||||||
import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service';
|
import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service';
|
||||||
import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||||
@@ -83,6 +84,7 @@ export class CartComponent implements OnDestroy {
|
|||||||
private configService = inject(ConfigService);
|
private configService = inject(ConfigService);
|
||||||
private tenantResolver = inject(TenantResolverService);
|
private tenantResolver = inject(TenantResolverService);
|
||||||
private currencyRates = inject(CurrencyRatesService);
|
private currencyRates = inject(CurrencyRatesService);
|
||||||
|
private readonly analytics = inject(AnalyticsService);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private cartService: CartService,
|
private cartService: CartService,
|
||||||
@@ -206,10 +208,12 @@ export class CartComponent implements OnDestroy {
|
|||||||
this.notifications.show(this.i18n.t('cart.acceptTerms'), 'warning');
|
this.notifications.show(this.i18n.t('cart.acceptTerms'), 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.analytics.track('checkout_started', { itemCount: this.items().length });
|
||||||
this.openPaymentPopup(paymentMethod);
|
this.openPaymentPopup(paymentMethod);
|
||||||
}
|
}
|
||||||
|
|
||||||
openPaymentPopup(paymentMethod: PaymentMethod): void {
|
openPaymentPopup(paymentMethod: PaymentMethod): void {
|
||||||
|
this.analytics.track('payment_started', { paymentMethod });
|
||||||
this.showPaymentPopup.set(true);
|
this.showPaymentPopup.set(true);
|
||||||
this.selectedPaymentMethod.set(paymentMethod);
|
this.selectedPaymentMethod.set(paymentMethod);
|
||||||
this.paymentStatus.set('creating');
|
this.paymentStatus.set('creating');
|
||||||
|
|||||||
Reference in New Issue
Block a user