/** * Product analytics for the WooCommerce widget. * * Everything goes through the bootstrap's `aisthetixTrackProductEvent`, which * owns the consent gate and the transport. There is deliberately no fallback * path here, unlike `events.ts`'s attribution fallback: attribution can fall * back to a direct POST because the merchant's own opt-in is a legitimate basis * for it, but product analytics needs the shopper's CMP grant, and the only * code that can read the CMP is the bootstrap. A fallback would be a way to * emit without ever consulting it. * * Nothing here can throw into a caller. A widget that breaks because analytics * broke is a worse outcome than no analytics. */ import type { WidgetConfig } from './types'; declare global { interface Window { aisthetixTrackProductEvent?: ( event: string, props?: Record< string, unknown > ) => boolean; aisthetixProductAnalyticsAllowed?: () => boolean; } } /** * Whether product analytics may run at all right now. * * Absent bootstrap means no: the widget can be loaded without it (the bundle is * injected by a script tag), and without the bootstrap there is nothing that * has read the merchant's CMP. */ export function productAnalyticsAllowed(): boolean { try { return window.aisthetixProductAnalyticsAllowed?.() === true; } catch { return false; } } /** * Emit one product-analytics event. * * @param event - A contract event name * @param props - Event properties; undeclared ones are dropped by the bootstrap * @returns Whether it was sent */ export function trackProductEvent( event: string, props: Record< string, unknown > = {} ): boolean { try { return window.aisthetixTrackProductEvent?.( event, props ) === true; } catch { return false; } } /** * A correlation id for one modal run. * * Regenerated every time the modal opens, so a shopper's two try-ons are two * funnels rather than one that appears to loop. Not persisted and not an * identity: it lasts as long as the modal and is gone. */ export function newRunId(): string { try { if ( window.crypto && 'randomUUID' in window.crypto ) { return window.crypto.randomUUID(); } } catch { /* fall through */ } return `r${ Date.now().toString( 36 ) }${ Math.random().toString( 36 ).slice( 2, 10 ) }`; } /** * Map a caught try-on failure to a contract reason. * * The widget catches Errors whose messages come from `fetch`, from the theme * and from our own API client — free text, written by whoever threw it. None of * it may become an analytics property, so the failure becomes an enum member * and the message goes no further than the shopper's own screen. * * Ordered most-specific first: "failed to fetch image" also contains "fetch", * and reporting a garment image the theme could not serve as a network failure * would send us looking at the wrong system. */ export function tryonFailureReason( error: unknown ): string { const raw = error instanceof Error ? error.message.toLowerCase() : String( error ?? '' ).toLowerCase(); if ( ! raw ) return 'unexpected'; if ( raw.includes( 'failed to fetch image' ) || raw.includes( 'image load' ) ) return 'image_load_failed'; if ( raw.includes( 'abort' ) || raw.includes( 'timed out' ) || raw.includes( 'timeout' ) ) return 'timeout'; if ( raw.includes( 'failed to fetch' ) || raw.includes( 'network' ) || raw.includes( 'cors' ) ) return 'network'; if ( raw.includes( '413' ) || raw.includes( 'too large' ) ) return 'payload_too_large'; if ( raw.includes( '429' ) || raw.includes( 'rate limit' ) ) return 'rate_limited'; if ( raw.includes( '502' ) || raw.includes( '503' ) || raw.includes( '504' ) || raw.includes( 'unavailable' ) ) { return 'engine_unavailable'; } if ( raw.includes( 'invalid photo' ) || raw.includes( 'unsupported image' ) ) return 'invalid_photo'; if ( raw.includes( 'refused' ) || raw.includes( 'safety' ) ) return 'provider_refused'; if ( raw.includes( 'job failed' ) || raw.includes( 'api error' ) ) return 'provider_error'; return 'unexpected'; } /** Which reasons are worth offering a retry for. */ export function isRetryableTryonReason( reason: string ): boolean { return ( reason === 'network' || reason === 'timeout' || reason === 'engine_unavailable' || reason === 'provider_error' || reason === 'rate_limited' ); } /** Replay configuration, as the plugin serves it. */ export interface ReplaySettings { key: string; host: string; enabled: boolean; environment: string; store: string; } /** * Read replay settings out of the injected config. * * Absent or incomplete means replay never runs — and, because the SDK is * fetched lazily, means no network request is made either. */ export function replaySettings( config: WidgetConfig ): ReplaySettings | null { const analytics = ( config as unknown as { analytics?: Record< string, unknown > } ).analytics; if ( ! analytics ) return null; const key = typeof analytics.replayKey === 'string' ? analytics.replayKey : ''; const host = typeof analytics.ingestHost === 'string' ? analytics.ingestHost : ''; const store = typeof analytics.storeId === 'string' ? analytics.storeId : ''; if ( ! key || ! host || analytics.replayEnabled !== true ) return null; // A recording that names no store is one nobody can find in PostHog and // nobody can attribute to a merchant — worse than no recording, because it // was still taken from a shopper. The plugin already refuses to emit a block // without one; this is the second net. if ( ! store ) return null; return { key, host, enabled: true, environment: typeof analytics.environment === 'string' ? analytics.environment : 'production', store, }; }