From be167d110ec5bfc989ecaea34d702c6e55f6b5f0 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Tue, 18 Aug 2026 00:05:46 +0400 Subject: [PATCH] feat: Track A frontend - analytics event pipeline core + real call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../analytics/models/analytics-event.model.ts | 11 ++++++++ .../services/analytics-gateway.interface.ts | 7 ++++++ .../services/analytics-gateway.token.ts | 9 +++++++ .../services/analytics-local.gateway.ts | 17 +++++++++++++ .../analytics/services/analytics.service.ts | 25 +++++++++++++++++++ .../product-details-container.component.ts | 4 +++ src/app/pages/cart/cart.component.ts | 4 +++ 7 files changed, 77 insertions(+) create mode 100644 src/app/core/analytics/models/analytics-event.model.ts create mode 100644 src/app/core/analytics/services/analytics-gateway.interface.ts create mode 100644 src/app/core/analytics/services/analytics-gateway.token.ts create mode 100644 src/app/core/analytics/services/analytics-local.gateway.ts create mode 100644 src/app/core/analytics/services/analytics.service.ts diff --git a/src/app/core/analytics/models/analytics-event.model.ts b/src/app/core/analytics/models/analytics-event.model.ts new file mode 100644 index 0000000..009b6a7 --- /dev/null +++ b/src/app/core/analytics/models/analytics-event.model.ts @@ -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; + isSynthetic: boolean; +} diff --git a/src/app/core/analytics/services/analytics-gateway.interface.ts b/src/app/core/analytics/services/analytics-gateway.interface.ts new file mode 100644 index 0000000..82a32c4 --- /dev/null +++ b/src/app/core/analytics/services/analytics-gateway.interface.ts @@ -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; +} diff --git a/src/app/core/analytics/services/analytics-gateway.token.ts b/src/app/core/analytics/services/analytics-gateway.token.ts new file mode 100644 index 0000000..11d1df2 --- /dev/null +++ b/src/app/core/analytics/services/analytics-gateway.token.ts @@ -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('ANALYTICS_GATEWAY', { + providedIn: 'root', + factory: () => inject(AnalyticsLocalGateway), +}); diff --git a/src/app/core/analytics/services/analytics-local.gateway.ts b/src/app/core/analytics/services/analytics-local.gateway.ts new file mode 100644 index 0000000..a7467ca --- /dev/null +++ b/src/app/core/analytics/services/analytics-local.gateway.ts @@ -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 { + return of(void 0); + } +} diff --git a/src/app/core/analytics/services/analytics.service.ts b/src/app/core/analytics/services/analytics.service.ts new file mode 100644 index 0000000..7ca03a3 --- /dev/null +++ b/src/app/core/analytics/services/analytics.service.ts @@ -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 = {}): void { + this.gateway.track({ + eventType, + properties, + isSynthetic: !environment.production, + }).pipe(take(1)).subscribe(); + } +} diff --git a/src/app/features/website/product/containers/product-details-container.component.ts b/src/app/features/website/product/containers/product-details-container.component.ts index c65abb5..10dcdcf 100644 --- a/src/app/features/website/product/containers/product-details-container.component.ts +++ b/src/app/features/website/product/containers/product-details-container.component.ts @@ -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 { 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, diff --git a/src/app/pages/cart/cart.component.ts b/src/app/pages/cart/cart.component.ts index 3bf6226..b23b19b 100644 --- a/src/app/pages/cart/cart.component.ts +++ b/src/app/pages/cart/cart.component.ts @@ -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');