import { classifyTryOnError } from './tryOnErrors'; 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, purgeLegacyHistory, getVisitorId, isStorageAvailable, hasConsent, } from './storage'; import { fetchImageAsBase64, compressGarmentImage, UndecodableImageError } from './imageUtils'; import { getTranslations } from './i18n'; import { trackSizeCompleted, 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 { SizeFlow } from './components/SizeFlow'; import { SizeAPI } from './sizeApi'; import { addRecommendedVariationToCart } from './woocommerceCart'; 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 { isSizeEligible } from './sizeEligibility'; import { buildWooCommerceProductUrl } from './productUrl'; 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 ); const [ entryMode, setEntryMode ] = useState<'tryon' | 'size'>( 'tryon' ); /** * 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 [ startedAt, setStartedAt ] = useState( 0 ); const [ wantsNewPhoto, setWantsNewPhoto ] = useState( false ); const [ resultImage, setResultImage ] = useState( null ); const [ clothingImage, setClothingImage ] = useState( null ); const [ errorMessage, setErrorMessage ] = useState( '' ); const [ canRetry, setCanRetry ] = useState( true ); 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 ); const [ selectedVariationId, setSelectedVariationId ] = useState( config.variantId || null ); // 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 ] ); const sizeApi = useMemo( () => new SizeAPI( config.sizeApiUrl || `${(config.brainUrl || '').replace(/\/$/, '')}/api/storefront/size`, config.sizePhotoQcUrl || `${(config.brainUrl || '').replace(/\/$/, '')}/api/storefront/photo-qc`, config.publishableKey, visitorId, config.customerId ), [ config.sizeApiUrl, config.sizePhotoQcUrl, config.brainUrl, config.publishableKey, config.customerId, visitorId ] ); const sizeEligible = useMemo( () => isSizeEligible( config.size, config.productCategories ?? [], config.productId, config.productVariants ?? [] ), [ config.productCategories, config.productId, config.productVariants, config.size ] ); // 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; setSelectedVariationId( detail?.variationId ? String( detail.variationId ) : null ); if ( detail?.productImage ) { productImageRef.current = detail.productImage; } }; document.addEventListener( 'aisthetix:variation-changed', handler ); return () => document.removeEventListener( 'aisthetix:variation-changed', handler ); }, [] ); const shareProductUrl = useMemo( () => buildWooCommerceProductUrl( config.productUrl, selectedVariationId, config.variationSelections ), [ config.productUrl, config.variationSelections, selectedVariationId ] ); const handleShareIntent = useCallback( ( properties: Record ) => { trackProductEvent( 'tryon_share_intent', properties ); }, [] ); const handleSizeCompleted = useCallback( ( variantId: string | null ) => { trackSizeCompleted( config, config.productId, variantId ); }, [ config ] ); 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'; setStartedAt( Date.now() ); 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 ); // 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, config.productId ); stage = 'render'; const resultImageData = `data:image/png;base64,${ result.image }`; setResultImage( resultImageData ); 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 keeps the attribution event out of product // analytics, so this envelope is the one `tryon_completed` emitter // and carries timing only the client 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: buildWooCommerceProductUrl( config.productUrl, selectedVariationId, config.variationSelections ) ?? undefined, merchantName: config.siteName, merchantDomain: config.siteUrl, 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 ) && classifyTryOnError( error ).retryable, 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 ) ); setCanRetry( classifyTryOnError( error ).retryable ); 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, selectedVariationId, 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. if ( entryMode === 'tryon' ) { 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 ); if ( entryMode === 'size' ) { setWidgetState( 'size-only' ); phaseRef.current = 'size'; } else { startTryOnWithAvatar( imageData ); } }, [ avatar?.imageData, config.productId, entryMode, 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 = () => { setWantsNewPhoto( false ); setEntryMode( 'tryon' ); 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(); setAvatar( currentAvatar ); setWidgetState( 'avatar-upload' ); }; const onSizeTriggerRef = useRef< ( trigger: HTMLElement ) => void >( () => {} ); onSizeTriggerRef.current = ( trigger ) => { if ( ! sizeEligible ) return; setEntryMode( 'size' ); setWantsNewPhoto( false ); setIsModalOpen( true ); runIdRef.current = trigger.dataset.aisthetixSizeRunId || newRunId(); phaseRef.current = 'size'; trackProductEvent( 'size_offer_accepted', { productId: config.productId, runId: runIdRef.current, } ); const currentAvatar = getAvatar(); setAvatar( currentAvatar ); setWidgetState( currentAvatar && hasConsent() ? 'size-only' : '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' ); const sizeTriggers = Array.from( document.querySelectorAll( '[data-aisthetix-size-trigger]' ) ); if ( ! triggerButton && sizeTriggers.length === 0 ) { return; } const handleClick = () => onTriggerRef.current(); const handleSizeClick = ( event: Event ) => onSizeTriggerRef.current( event.currentTarget as HTMLElement ); triggerButton?.addEventListener( 'click', handleClick ); for ( const sizeTrigger of sizeTriggers ) sizeTrigger.addEventListener( 'click', handleSizeClick ); window.AISTHETIX_WIDGET_READY = true; const shareLanding = window.AISTHETIX_SHARE_LANDING; delete window.AISTHETIX_SHARE_LANDING; if ( shareLanding && triggerButton && ! triggerButton.closest( '[hidden]' ) ) onTriggerRef.current(); return () => { triggerButton?.removeEventListener( 'click', handleClick ); for ( const sizeTrigger of sizeTriggers ) sizeTrigger.removeEventListener( 'click', handleSizeClick ); 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( () => { setWantsNewPhoto( true ); setResultImage( null ); setWidgetState( 'avatar-upload' ); }, [] ); const handleRecommendedAddToCart = useCallback( async ( variantId: string, _size: string ) => { const selection = config.variationSelections?.[ variantId ]; const added = await addRecommendedVariationToCart( variantId, selection ); if ( ! added ) return false; if ( entryMode === 'tryon' ) { trackProductEvent( 'tryon_added_to_cart', { productId: config.productId, variantId, recommendedSizeUsed: true, requestId: requestIdRef.current, runId: runIdRef.current, } ); } return true; }, [ config.productId, config.variationSelections, entryMode ] ); 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 ( entryMode === 'tryon' && 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, } ); } if ( entryMode === 'tryon' ) { 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 ); setWidgetState( 'idle' ); setStartedAt( Date.now() ); setResultImage( null ); setClothingImage( null ); setErrorMessage( '' ); resultShownAtRef.current = 0; phaseRef.current = 'idle'; }, [ config.productId, entryMode ] ); const getErrorMessage = ( error: unknown ): string => { if ( error instanceof UndecodableImageError ) return t.errors.imageLoad; const { messageKey } = classifyTryOnError( error ); return messageKey === 'unreadableImage' ? t.upload.unreadableImage : t.errors[ messageKey ]; }; const getModalTitle = () => { switch ( widgetState ) { case 'avatar-upload': return t.modal.uploadPhoto; case 'processing': return t.modal.creatingTryOn; case 'completed': return t.modal.yourTryOn; case 'size-only': case 'size-unavailable': return t.modal.sizeEstimation; 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.result.viralShare } onShareIntent={ handleShareIntent } /> ) } { demo && widgetState === 'lead-gate' && ( ) } { widgetState === 'quota-exceeded' && ( ) } { widgetState === 'visitor-rate-limited' && ( ) } { widgetState === 'avatar-upload' && ( ) } { widgetState === 'size-unavailable' && (

{ t.size.unavailable }

) } { widgetState === 'size-only' && avatar && ( ) } { widgetState === 'processing' && ( ) } { widgetState === 'completed' && resultImage && ! demo && ( <> ) } { widgetState === 'completed' && resultImage && demo && ( ) } { widgetState === 'error' && ( ) }
); }