import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Modal } from './components/Modal'; import { AvatarUpload } from './components/AvatarUpload'; import { ProcessingView } from './components/ProcessingView'; import { ResultView } from './components/ResultView'; import { ErrorView } from './components/ErrorView'; import { QuotaBlockedView } from './components/QuotaBlockedView'; import { VirtualTryOnAPI, QuotaExhaustedError, VisitorRateLimitedError } from './api'; import { getAvatar, saveAvatar, clearAvatar, purgeLegacyHistory, getVisitorId, isStorageAvailable, hasConsent, } from './storage'; import { fetchImageAsBase64, compressGarmentImage, UndecodableImageError } from './imageUtils'; import { getTranslations } from './i18n'; import { trackTryOnCompleted } from './events'; import { isRetryableTryonReason, newRunId, productAnalyticsAllowed, replaySettings, trackProductEvent, tryonFailureReason, } from './analytics'; import { applyConsentDecision, modalIsMounted, startReplay, stopReplay } from './replay'; import { isDemo, demoTryOnLimit } from './demo/config'; import { ModalToolbar } from './demo/ModalToolbar'; import { GalleryView } from './demo/GalleryView'; import { DemoResultView } from './demo/DemoResultView'; import { LeadGateView } from './demo/LeadGateView'; import { demoCounterLabel } from './demo/quotaLabel'; import { countDemoTryOns, recordDemoTryOn, remainingDemoTryOns } from './demo/demoCounter'; import { rememberLead, shouldAskForEmail, submitLead } from './demo/leadStore'; import { listTryOns, saveTryOn, deleteTryOn, countTryOns } from './demo/tryOnStore'; import type { WidgetConfig, WidgetState, AvatarData, TryOnHistoryItem } from './types'; interface AppProps { config: WidgetConfig; } export function App( { config }: AppProps ) { const t = useMemo( () => getTranslations( config.locale ), [ config.locale ] ); const [ isModalOpen, setIsModalOpen ] = useState( false ); /** * One id per modal open, so a shopper's two try-ons are two funnels rather * than one confused one. In a ref because every emitter reads it from a * closure, and a stale render's value would silently merge the two. */ const runIdRef = useRef< string >( '' ); const openCountRef = useRef( 0 ); const openedAtRef = useRef( 0 ); const requestIdRef = useRef< string >( '' ); const requestStartedAtRef = useRef( 0 ); const attemptRef = useRef( 0 ); const resultShownAtRef = useRef( 0 ); const lastFailureReasonRef = useRef< string >( 'unexpected' ); /** The phase the modal was in when it closed — the drop-off point. */ const phaseRef = useRef< string >( 'idle' ); /** * Read inside async callbacks that outlive a render. `isModalOpen` captured * in a closure is the value at closure time, and starting a replay on that * would record a modal the shopper has already closed. */ const [ widgetState, setWidgetState ] = useState( 'idle' ); const [ avatar, setAvatar ] = useState( null ); const [ progress, setProgress ] = useState( 0 ); const [ resultImage, setResultImage ] = useState( null ); const [ clothingImage, setClothingImage ] = useState( null ); const [ errorMessage, setErrorMessage ] = useState( '' ); const [ , setIsSubmitting ] = useState( false ); const isSubmittingRef = useRef( false ); const [ cooldownMinutes, setCooldownMinutes ] = useState( undefined ); // --- Demo-only state. Inert on every merchant store (see demo/config.ts). --- const demo = isDemo( config ); const tryOnLimit = useMemo( () => demoTryOnLimit( config ), [ config ] ); const [ galleryItems, setGalleryItems ] = useState( [] ); const [ galleryCount, setGalleryCount ] = useState( 0 ); const [ demoRemaining, setDemoRemaining ] = useState( () => demo ? remainingDemoTryOns( demoTryOnLimit( config ) ) : 0 ); // The photo the visitor was about to try on when the gate interrupted, so // giving the email resumes exactly that try-on instead of starting over. const gatedAvatarRef = useRef( null ); /** * Two count reads can be in flight at once (one from opening the modal, one * from a try-on that just finished) and IndexedDB does not promise to answer * them in order. Without this, the stale "0" from the open can land after the * fresh "1" and quietly hide the gallery the shopper just filled. */ const countSeqRef = useRef( 0 ); const refreshGalleryCount = useCallback( () => { const seq = ++countSeqRef.current; void countTryOns().then( ( total ) => { if ( seq === countSeqRef.current ) { setGalleryCount( total ); } } ); }, [] ); // Track the current garment image; the bootstrap mutates config.productImage // on variation change and dispatches `aisthetix:variation-changed`. const productImageRef = useRef( config.productImage ); // Resolve the per-visitor rate-limit id (X-Aisthetix-Visitor). Prefer the // logged-in customer, then the durable first-party visitorKey (the plugin's // aisthetix_vid cookie — survives ITP), and finally the localStorage anon id. const visitorId = useMemo( () => { if ( config.customerId ) { return `customer_${ config.customerId }`; } if ( config.visitorKey ) { return `vid_${ config.visitorKey }`; } return getVisitorId(); }, [ config.customerId, config.visitorKey ] ); const api = useMemo( () => new VirtualTryOnAPI( config.apiBaseUrl, config.publishableKey, visitorId ), [ config.apiBaseUrl, config.publishableKey, visitorId ] ); // Load avatar on mount. Older widgets left megabytes of unread try-on history // in localStorage, which is what made saving the avatar start failing — clear // it out before we touch storage for anything else. useEffect( () => { if ( isStorageAvailable() ) { purgeLegacyHistory(); setAvatar( getAvatar() ); } }, [] ); /** * Replay configuration, from the injected config. * * Served by the plugin rather than baked into the bundle, so replay can be * turned off without a plugin release and a wordpress.org review cycle. */ const replayConfig = useMemo( () => replaySettings( config ), [ config ] ); // The consent subscription is set up once; a ref is what lets the listener // read the current settings without resubscribing, and resubscribing would // drop a decision collected in the gap. const replayConfigRef = useRef( replayConfig ); replayConfigRef.current = replayConfig; /** * Start the recorder once the modal is actually on the page. * * An effect rather than a line in the trigger handler, and the difference is * the whole bug: `setIsModalOpen( true )` does not take effect synchronously, * so a ref assigned during render still says "closed" for the rest of that * handler. `startReplay` read it, refused, and — for a shopper whose CMP had * already granted, which is most of them — nothing ever asked again. No * consent-change event follows an unchanged decision. * * It also puts the start AFTER the overlay is in the DOM. Child effects run * before parent effects, so by the time this runs Modal has appended it, and * rrweb's opening snapshot has the element the recording is confined to. * * Still loaded no earlier than this: fetching the SDK is itself a request a * non-consenting shopper never agreed to, and one who never opens the modal * must cost nothing at all. */ useEffect( () => { if ( ! isModalOpen ) return; void startReplay( replayConfig, () => modalIsMounted() && productAnalyticsAllowed() ); }, [ isModalOpen, replayConfig ] ); /** * Consent, for the whole session. * * The bootstrap watches each supported CMP's own change event and announces * a decision on `aisthetix:consent-changed`. Subscribing is what makes a * withdrawal take effect at once — for a replay that is already recording, * that is the difference between honouring it and ignoring it. */ useEffect( () => { const onConsentChanged = ( e: Event ) => { // Both directions. A withdrawal has to stop the recorder at once — // but a shopper who opened the modal before the banner was answered // and then accepted has to START being recorded, and nothing else in // the widget will ever ask again: the start path runs on modal open // and has already decided by then. const allowed = ( e as CustomEvent ).detail?.productAnalytics === true; void applyConsentDecision( allowed, replayConfigRef.current, () => modalIsMounted() && productAnalyticsAllowed() ); }; document.addEventListener( 'aisthetix:consent-changed', onConsentChanged ); return () => { document.removeEventListener( 'aisthetix:consent-changed', onConsentChanged ); stopReplay(); }; }, [] ); // Keep the garment image fresh when the shopper switches variation. useEffect( () => { const handler = ( e: Event ) => { const detail = ( e as CustomEvent ).detail; if ( detail?.productImage ) { productImageRef.current = detail.productImage; } }; document.addEventListener( 'aisthetix:variation-changed', handler ); return () => document.removeEventListener( 'aisthetix:variation-changed', handler ); }, [] ); const startTryOnWithAvatar = useCallback( async ( avatarImageData: string, gateAlreadyPassed = false ) => { // Consent gate: this is the single funnel into generation (trigger-button // click on a returning visitor, retry, and the post-upload path all land // here), so it must be enforced here rather than only inside AvatarUpload. // Without this, a browser with a saved avatar from before consent existed // (avatar present, hasConsent() === false) could reach AI generation of a // person's photo without ever seeing the consent checkbox. Route back to // the AvatarUpload screen instead; the shopper must actively check the // box (which persists consent synchronously) before generation can proceed. if ( ! hasConsent() ) { setWidgetState( 'avatar-upload' ); return; } // Demo-only: the first try-on is free, then we ask for the email. Sits // here for the same reason as the consent gate above: this is the single // funnel into generation, so a returning visitor with a saved photo // cannot walk around it. if ( ! gateAlreadyPassed && shouldAskForEmail( config, countDemoTryOns() ) ) { gatedAvatarRef.current = avatarImageData; setWidgetState( 'lead-gate' ); return; } // Prevent duplicate submissions across closures. if ( isSubmittingRef.current ) { return; } isSubmittingRef.current = true; setIsSubmitting( true ); setWidgetState( 'processing' ); phaseRef.current = 'processing'; setProgress( 0 ); setErrorMessage( '' ); // One request id per attempt, shared by requested/completed/failed so // the three line up into a funnel without a join on time. requestIdRef.current = newRunId(); requestStartedAtRef.current = Date.now(); attemptRef.current += 1; trackProductEvent( 'tryon_requested', { productId: config.productId, requestId: requestIdRef.current, attempt: attemptRef.current, runId: runIdRef.current, } ); // Tracked rather than inferred from an error message: which step failed // is knowable here and guessable at best from a string. let stage: 'photo' | 'inference' | 'render' = 'photo'; try { // Prefer the live (possibly variation-updated) image, then // normalize it client-side so it's never refused for size. const garmentUrl = productImageRef.current || config.productImage; const fetchedGarment = await fetchImageAsBase64( garmentUrl ); const clothingImageBase64 = await compressGarmentImage( fetchedGarment ); setClothingImage( clothingImageBase64 ); setProgress( 10 ); // Comma-joined category slugs so the brain can route to a // category-aware provider (e.g. swimwear); empty when uncategorized. const productCategory = ( config.productCategories ?? [] ).join( ',' ); stage = 'inference'; // The brain gateway enforces quota/ledger and proxies to the engine. const result = await api.tryOnSync( avatarImageData, clothingImageBase64, productCategory ); stage = 'render'; const resultImageData = `data:image/png;base64,${ result.image }`; setResultImage( resultImageData ); setProgress( 100 ); setWidgetState( 'completed' ); // Attribution: a completed try-on is the funnel's try-on anchor // (try-on -> productId only). Fire-and-forget; never blocks the UI. trackTryOnCompleted( config, config.productId ); // Product analytics, on its own stricter gate and its own endpoint // shape. Separate from the attribution call above, not merged into // it: the two answer to different consent decisions, and merging // them would make it possible to lose that distinction in a // refactor. The brain maps the attribution event once for PostHog, // so this is `tryon_completed` reported by the client with the // timing the client alone knows. trackProductEvent( 'tryon_completed', { productId: config.productId, requestId: requestIdRef.current, durationMs: Date.now() - requestStartedAtRef.current, provider: 'unknown', cached: false, runId: runIdRef.current, } ); resultShownAtRef.current = Date.now(); phaseRef.current = 'completed'; trackProductEvent( 'tryon_result_viewed', { productId: config.productId, requestId: requestIdRef.current, timeToViewMs: Date.now() - requestStartedAtRef.current, runId: runIdRef.current, } ); // Demo-only: count the spend and file the render in the shopper's own // browser. Not awaited on purpose, so a slow (or refused) IndexedDB // never delays the picture they are waiting for. if ( demo ) { recordDemoTryOn(); setDemoRemaining( remainingDemoTryOns( tryOnLimit ) ); void saveTryOn( { resultImage: resultImageData, clothingImage: clothingImageBase64, productTitle: config.productTitle, productId: config.productId, productUrl: config.productUrl, createdAt: new Date().toISOString(), } ).then( refreshGalleryCount ); } } catch ( error ) { // The reason is mapped to a contract enum; the message the shopper // sees is never the value reported. A fetch failure's message can // carry a signed URL, and a provider's can carry its own prompt. const reason = tryonFailureReason( error ); lastFailureReasonRef.current = reason; trackProductEvent( 'tryon_failed', { productId: config.productId, requestId: requestIdRef.current, reason, stage, retryable: isRetryableTryonReason( reason ), durationMs: Date.now() - requestStartedAtRef.current, runId: runIdRef.current, } ); // Server-side hard stops map to the dedicated blocked views. if ( error instanceof QuotaExhaustedError ) { setWidgetState( 'quota-exceeded' ); phaseRef.current = 'blocked'; } else if ( error instanceof VisitorRateLimitedError ) { setCooldownMinutes( error.cooldownMinutesRemaining ); setWidgetState( 'visitor-rate-limited' ); phaseRef.current = 'blocked'; } else { setErrorMessage( getErrorMessage( error ) ); setWidgetState( 'error' ); phaseRef.current = 'error'; } } finally { isSubmittingRef.current = false; setIsSubmitting( false ); } }, // visitorId flows into the brain via `api` (X-Aisthetix-Visitor header). [ api, config, config.productImage, config.productTitle, config.productId, demo, tryOnLimit, refreshGalleryCount ] ); const handleAvatarSaved = useCallback( ( imageData: string ) => { // `source` is the only thing reported about the photo — whether it was // freshly chosen or the one already in this browser. The photo itself // never leaves the shopper's device except to the try-on endpoint. trackProductEvent( 'tryon_photo_selected', { source: avatar?.imageData === imageData ? 'saved' : 'upload', productId: config.productId, runId: runIdRef.current, } ); const newAvatar: AvatarData = { imageData, createdAt: new Date().toISOString(), }; saveAvatar( imageData ); setAvatar( newAvatar ); startTryOnWithAvatar( imageData ); }, [ avatar?.imageData, config.productId, startTryOnWithAvatar ] ); // --- Demo-only handlers. None of this is reachable on a merchant store. --- /** * The gallery reads IndexedDB only when the modal is opened, so a product * page the shopper never touches pays nothing for it. */ const refreshGallery = useCallback( async () => { const seq = ++countSeqRef.current; const items = await listTryOns(); setGalleryItems( items ); if ( seq === countSeqRef.current ) { setGalleryCount( items.length ); } }, [] ); /** Where "Back" returns to, so the gallery never strands the shopper. */ const galleryReturnRef = useRef( 'avatar-upload' ); const handleOpenGallery = useCallback( async () => { galleryReturnRef.current = widgetState; await refreshGallery(); setWidgetState( 'gallery' ); }, [ refreshGallery, widgetState ] ); const handleCloseGallery = useCallback( () => { setWidgetState( galleryReturnRef.current ); }, [] ); const handleDeleteTryOn = useCallback( async ( id: string ) => { await deleteTryOn( id ); await refreshGallery(); }, [ refreshGallery ] ); const handleLeadSubmit = useCallback( async ( email: string ): Promise => { if ( ! config.demoLeadUrl ) { return false; } const saved = await submitLead( config.demoLeadUrl, { email, consent: true, productId: config.productId, locale: config.locale, } ); if ( ! saved ) { return false; } rememberLead( email ); const pending = gatedAvatarRef.current; gatedAvatarRef.current = null; if ( pending ) { void startTryOnWithAvatar( pending, true ); } else { setWidgetState( 'avatar-upload' ); } return true; }, [ config.demoLeadUrl, config.productId, config.locale, startTryOnWithAvatar ] ); // What the trigger does, kept in a ref so the listener below can stay bound // for the life of the widget. See the effect for why that matters. const onTriggerRef = useRef< () => void >( () => {} ); onTriggerRef.current = () => { setIsModalOpen( true ); // A fresh run id per open: a shopper who tries two garments produces two // funnels, not one that appears to loop. runIdRef.current = newRunId(); openCountRef.current += 1; openedAtRef.current = Date.now(); attemptRef.current = 0; phaseRef.current = 'avatar_upload'; const savedPhoto = getAvatar(); trackProductEvent( 'tryon_modal_opened', { productId: config.productId, hasSavedPhoto: savedPhoto !== null, placement: 'auto', runId: runIdRef.current, openIndex: openCountRef.current, } ); if ( demo ) { setDemoRemaining( remainingDemoTryOns( tryOnLimit ) ); refreshGalleryCount(); } const currentAvatar = getAvatar(); if ( currentAvatar ) { startTryOnWithAvatar( currentAvatar.imageData ); } else { setWidgetState( 'avatar-upload' ); } }; // Bind to the trigger button rendered by the plugin, exactly once. // // This deliberately has no dependencies. bootstrap.js replays the cold first // click by calling trigger.click() the moment AISTHETIX_WIDGET_READY turns // true; if this effect re-ran, it would remove the listener and clear that // flag for an instant, and a replay landing in that gap is dropped with no // trace. The shopper sees a button that does nothing on the first press. So // the listener goes on once and reads the current behaviour through a ref. useEffect( () => { const triggerButton = document.getElementById( 'aisthetix-tryon-trigger' ); if ( ! triggerButton ) { return; } const handleClick = () => onTriggerRef.current(); triggerButton.addEventListener( 'click', handleClick ); window.AISTHETIX_WIDGET_READY = true; return () => { triggerButton.removeEventListener( 'click', handleClick ); window.AISTHETIX_WIDGET_READY = false; }; }, [] ); const handleRetry = useCallback( () => { trackProductEvent( 'tryon_retry_requested', { reason: lastFailureReasonRef.current, attempt: attemptRef.current + 1, productId: config.productId, runId: runIdRef.current, } ); if ( avatar ) { startTryOnWithAvatar( avatar.imageData ); } else { setWidgetState( 'avatar-upload' ); } }, [ avatar, config.productId, startTryOnWithAvatar ] ); const handleTryAnother = useCallback( () => { clearAvatar(); setAvatar( null ); setResultImage( null ); setWidgetState( 'avatar-upload' ); }, [] ); const handleClose = useCallback( () => { // Emitted BEFORE the state reset below: the phase the shopper left from // is the whole point of the event, and the reset erases it. if ( resultShownAtRef.current > 0 && requestIdRef.current ) { trackProductEvent( 'tryon_result_dismissed', { requestId: requestIdRef.current, dwellMs: Date.now() - resultShownAtRef.current, phase: phaseRef.current, productId: config.productId, runId: runIdRef.current, } ); } trackProductEvent( 'tryon_modal_closed', { phase: phaseRef.current, dwellMs: openedAtRef.current > 0 ? Date.now() - openedAtRef.current : 0, productId: config.productId, runId: runIdRef.current, openIndex: openCountRef.current, } ); // The recorder stops with the modal, not with the page: everything // outside this modal belongs to the merchant's storefront, not to us. stopReplay(); setIsModalOpen( false ); setTimeout( () => { setWidgetState( 'idle' ); setProgress( 0 ); setResultImage( null ); setClothingImage( null ); setErrorMessage( '' ); resultShownAtRef.current = 0; phaseRef.current = 'idle'; }, 300 ); }, [ config.productId ] ); const getErrorMessage = ( error: unknown ): string => { if ( error instanceof UndecodableImageError ) { return t.errors.imageLoad; } if ( ! ( error instanceof Error ) ) { return t.errors.unexpected; } const message = error.message.toLowerCase(); if ( message.includes( 'failed to fetch' ) || message.includes( 'cors' ) ) { return t.errors.network; } if ( message.includes( 'failed to fetch image' ) || message.includes( 'could not load image' ) ) { return t.errors.imageLoad; } if ( message.includes( 'timed out' ) ) { return t.errors.timeout; } if ( message.includes( 'api error' ) || message.includes( '401' ) || message.includes( '403' ) ) { return t.errors.server; } return t.errors.unexpected; }; const getModalTitle = () => { switch ( widgetState ) { case 'avatar-upload': return t.modal.uploadPhoto; case 'processing': return t.modal.creatingTryOn; case 'completed': return t.modal.yourTryOn; case 'error': return t.modal.oops; case 'quota-exceeded': case 'visitor-rate-limited': return t.modal.virtualTryOn; case 'gallery': return t.gallery.title; case 'lead-gate': return t.lead.title; default: return t.modal.virtualTryOn; } }; // The counter is deliberately on every screen, including the upload and the // wait: the point of it is that running out is never a surprise. const quotaLabel = demo ? demoCounterLabel( demoRemaining, tryOnLimit, t ) : null; return ( { demo && widgetState !== 'gallery' && ( void handleOpenGallery() } t={ t.gallery } /> ) } { demo && widgetState === 'gallery' && ( void handleDeleteTryOn( id ) } t={ t.gallery } shareT={ t.share } /> ) } { demo && widgetState === 'lead-gate' && ( ) } { widgetState === 'quota-exceeded' && ( ) } { widgetState === 'visitor-rate-limited' && ( ) } { widgetState === 'avatar-upload' && ( ) } { widgetState === 'processing' && ( ) } { widgetState === 'completed' && resultImage && ! demo && ( ) } { widgetState === 'completed' && resultImage && demo && ( ) } { widgetState === 'error' && ( ) } ); }