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, addToHistory, getVisitorId, isStorageAvailable, hasConsent, } from './storage'; import { fetchImageAsBase64, compressGarmentImage, UndecodableImageError } from './imageUtils'; import { getTranslations } from './i18n'; import { trackTryOnCompleted } from './events'; import type { WidgetConfig, WidgetState, AvatarData } from './types'; interface AppProps { config: WidgetConfig; } export function App( { config }: AppProps ) { const t = useMemo( () => getTranslations( config.locale ), [ config.locale ] ); const [ isModalOpen, setIsModalOpen ] = useState( false ); 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 ); // 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. useEffect( () => { if ( isStorageAvailable() ) { setAvatar( getAvatar() ); } }, [] ); // 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 ) => { // 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; } // Prevent duplicate submissions across closures. if ( isSubmittingRef.current ) { return; } isSubmittingRef.current = true; setIsSubmitting( true ); setWidgetState( 'processing' ); setProgress( 0 ); setErrorMessage( '' ); 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( ',' ); // The brain gateway enforces quota/ledger and proxies to the engine. const result = await api.tryOnSync( avatarImageData, clothingImageBase64, productCategory ); 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 ); addToHistory( { resultImage: resultImageData, clothingImage: clothingImageBase64, productTitle: config.productTitle, createdAt: new Date().toISOString(), } ); } catch ( error ) { // Server-side hard stops map to the dedicated blocked views. if ( error instanceof QuotaExhaustedError ) { setWidgetState( 'quota-exceeded' ); } else if ( error instanceof VisitorRateLimitedError ) { setCooldownMinutes( error.cooldownMinutesRemaining ); setWidgetState( 'visitor-rate-limited' ); } else { setErrorMessage( getErrorMessage( error ) ); setWidgetState( '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 ] ); const handleAvatarSaved = useCallback( ( imageData: string ) => { const newAvatar: AvatarData = { imageData, createdAt: new Date().toISOString(), }; saveAvatar( imageData ); setAvatar( newAvatar ); startTryOnWithAvatar( imageData ); }, [ startTryOnWithAvatar ] ); // Bind to the trigger button rendered by the plugin. useEffect( () => { const triggerButton = document.getElementById( 'aisthetix-tryon-trigger' ); if ( ! triggerButton ) { return; } const handleClick = () => { setIsModalOpen( true ); const currentAvatar = getAvatar(); if ( currentAvatar ) { startTryOnWithAvatar( currentAvatar.imageData ); } else { setWidgetState( 'avatar-upload' ); } }; triggerButton.addEventListener( 'click', handleClick ); // Tell bootstrap.js the trigger is now wired, so it can replay a cold // first click instead of guessing with a fixed delay (which raced this // effect and silently dropped the very first click). window.AISTHETIX_WIDGET_READY = true; return () => { triggerButton.removeEventListener( 'click', handleClick ); window.AISTHETIX_WIDGET_READY = false; }; }, [ startTryOnWithAvatar ] ); const handleRetry = useCallback( () => { if ( avatar ) { startTryOnWithAvatar( avatar.imageData ); } else { setWidgetState( 'avatar-upload' ); } }, [ avatar, startTryOnWithAvatar ] ); const handleTryAnother = useCallback( () => { clearAvatar(); setAvatar( null ); setResultImage( null ); setWidgetState( 'avatar-upload' ); }, [] ); const handleClose = useCallback( () => { setIsModalOpen( false ); setTimeout( () => { setWidgetState( 'idle' ); setProgress( 0 ); setResultImage( null ); setClothingImage( null ); setErrorMessage( '' ); }, 300 ); }, [] ); 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; default: return t.modal.virtualTryOn; } }; return ( { widgetState === 'quota-exceeded' && ( ) } { widgetState === 'visitor-rate-limited' && ( ) } { widgetState === 'avatar-upload' && ( ) } { widgetState === 'processing' && ( ) } { widgetState === 'completed' && resultImage && ( ) } { widgetState === 'error' && ( ) } ); }