Widget States

Widget States

The storefront modal is a state machine driven by setState(state) in tryvio-modal.js. currentState is one of the primary values below; several overlay/sub-states layer on top without replacing the primary state.

Related: Widget Components · Try-On Pipeline.


Visibility — sold-out hiding (B17)

Before any state, the widget may not render at all. The per-store setting Hide try-on on sold-out products (store_widget_config.hide_on_sold_out, default on, surfaced in the proxy config as hideOnSoldOut) hides the Try it on button for a product whose every variant is sold out. If at least one variant is available the button stays — and the widget always operates on the shopper’s selected variant (never a silent swap; a sold-out selected variant is the in-modal B2 case).

  • Product pagetryvio-theme.js reads hideOnSoldOut, live-fetches /products/<handle>.js, and hides the trigger when all variants are unavailable (no flash — the trigger is opacity:0 until resolved).
  • Collection / home cardstryvio-collection.js makes one proxy call (first card handle) to learn the setting, then resolves each card’s availability lazily (IntersectionObserver, cached per handle) and skips injecting the button on fully sold-out cards.
  • Fail open — unknown availability / fetch error ⇒ show the button (B2 still protects the cart in-modal).
  • Canonical rule: apps/web/src/lib/widget/widget-visibility.tsshouldHideWidget. Availability is read live from Shopify, not the synced catalog.

Primary states

Gating

Shown on the very first open when no consent record exists in localStorage key tryvio_consent_v1 (value { [shopDomain]: true }). Accepting writes that record and advances to the capture entry state.

ElementCopy / detail
Title”Before we start your try-on”
Body”We need your permission to process your photo so we can generate your virtual try-on.”
Checkbox”I accept the Terms of Service [and Privacy Policy]“
CTA”Accept” — disabled until the checkbox is checked
Note”You can close this window anytime before generating.”

limit_reached.tryvio-limit

Shown when the per-shopper rate limit returns remaining === 0 (loaded by loadRateLimitStatus via the rate-limit-status endpoint, not from proxy config).

VariantTriggerExtra element
Plainremaining === 0, no email gateReset line: “Your next try-ons unlock {time}.”
Email-gaterequiresEmail: true and config.captureEmailUrl is set.tryvio-limit__cta button: “Unlock {N} more try-ons” (N = rateLimits.emailGateBonusLimit, default 5)

Title: “You have reached your try-on limit”. There is no purchase/packages flow.

limit_reached — store overage-cap variant (storeCapReached, #62 “Option B”)

A second trigger for the same limit_reached state. The proxy config carries a top-level tryOnLimitReached: true flag (from stores.overage_cap_reached, cron-cached) — when present it takes precedence over the shopper rate limit at modal open, and no rate-limit session is created.

ElementCopy / detail
Title”Virtual try-on is temporarily paused”
BodyInvites the shopper to tell the brand
CTA”Tell the brand you want this” — shown only when storefront.demandSignalUrl is present in the config

Demand-signal CTA — tapping it POSTs JSON { shopDomain, productHandle, productTitle } to demandSignalUrl (POST /api/storefront/demand-signal; a 429 rateLimited response is treated as already-recorded). Busy-disabled while in flight (guarantees a single POST); success shows “Thanks — the brand has been notified.”, failure shows an inline error and re-enables for retry. One-shot per shop+product via sessionStorage key tryvio_demand_sent_<shop>_<handle>; the server additionally caps 10 signals/hour per shopper fingerprint.

Mid-session freshness — a 402 with code: "overage_cap_reached" from the try-on POST routes to this same screen mid-session (the config flag can lag the cron). An in-flight resumed generation is never pre-empted by the cap screen.

Config threading — proxy config → minified tryvio-theme.js U literal (tryOnLimitReached read from the top level, demandSignalUrl from storefront) → runModal(U)openStorefrontModal(config) → passed to every createModal call as storeCapReached / demandSignalUrl.

New translation keys (en+bg, lib/i18n/translations.ts): widget.limit_cap_title / _body / _cta / _sending / _sent / _error.

Test coverage: e2e-suite scenarios 8b–8g, storybook states 08-limit-cap / 09-limit-cap-sent, committed proof playwright-debug/proof-62-limit-cap.js (12 checks), server contract pinned by api/proxy/route.test.ts + api/storefront/demand-signal/route.test.ts.

Email-capture modal — .tryvio-modal__panel--email-capture

Opened by showEmailCaptureModal when the shopper clicks the limit CTA (email-gate variant only). It renders on top of the limit_reached state; limit_reached remains the primary state underneath.

ElementCopy / detail
IconLock
HeadlinerateLimits.leadCaptureHeadline (default “Unlock more try-ons”)
Body”You’ve reached your free try-on limit. Enter your email to unlock N more.”
Email input
Marketing-consent checkboxChecked by default
Terms checkboxUnchecked by default
Note”*Your email will never be shared with third parties.”
CTA”Unlock N More Try-Ons”

Capture

Entry rule: if sessionStorage key tryvio_photo_v1 holds a photo → go straight to preview (reuse); else on desktop → upload, on mobile → camera.

upload.tryvio-modal__dropzone

Sub-states:

Sub-stateTriggerVisual
EmptyDefault on desktop entryDropzone label “Drop photo here or click to browse”; hint “JPG, PNG, WebP or iPhone photos · Max 25MB”
Recent striplocalStorage key tryvio_upload_history_v1 is non-empty (array of {dataUrl, fileName, timestamp}, max 5)renderUploadHistory shows a “Recent” thumbnail strip; tapping a thumb → preview. Each thumb has a corner X → in-thumb confirm bubble → removeUploadHistoryItem
Drag-overFile dragged over the dropzone.tryvio-modal__dropzone--dragover class applied

camera

Camera tab. Uses getUserMedia. On permission failure shows an inline “Camera access denied…” message. Controls: capture / switch / mirror. See Try-On Pipeline § Camera lifecycle for the iOS/Android rules.

preview (new photo)

After file selection or camera capture. Actions: Retake + Generate.

preview (reuse)

enterDefaultCaptureState finds a photo in sessionStorage tryvio_photo_v1 and enters preview directly — one-tap reuse, no re-upload prompt.


Generating

generating.tryvio-modal__loading

Active while the provider task is in flight. Elements:

  • Progress track + animated fill
  • Text: “Analyzing your photo…”
  • Estimate: “Usually takes 30 to 90 seconds.”
  • Numeric percent

See Try-On Pipeline for the async callback + poll flow.


Result

result (single item)

Product card (.tryvio-modal__product-card): title, variant, price, compare-at price, offer code. Action buttons: Add to cart (.tryvio-modal__btn--cart), Download, Share, New photo (retakeBtn).

result + discount card

config.discount with showTiming: "post_result". Config keys: headline (supports {percent} placeholder), body, percent, ctaText, successText, optional code.

showDiscountCard renders .tryvio-discount as a separate card only when there is no code (shouldRenderSeparateDiscountCard). When a code is present it appears in the product-card offer field instead.

result + bundle (“Complete the look”)

config.bundle (discount + complement). Renders .tryvio-modal__bundle-card. Tapping checks the complement item → left button changes to “Try together”; the combined result sets data-mode="merged" on the panel. See the bundle/Complete-the-look feature spec.

Controls hidden / zoom

Tapping the result image fades controls via setResultControlsVisible. Pinch applies a CSS transform to zoom the result image.

Share banner — .tryvio-modal__share-banner

Triggered by opening the storefront URL with ?tryvio_open=1&tryvio_share=<token>. The widget fetches config.shareUrl/<token> and renders a banner “A friend shared their try-on — now try it yourself”. The retake button relabels to “Try it on yourself”.


Overlay / sub-states

These layer on top of a primary state without replacing it.

Results are saved to localStorage key tryvio_gallery_v1 by saveGalleryItem. Each item: { sessionId, productTitle, productHandle, resultImageUrl, variantId, variantTitle, price, compareAtPrice, variantImageUrl, productImageUrl, product, timestamp, isBundle?, bundleComplement? }. Max 5 items in upload history; gallery has no configured hard cap.

The toggle .tryvio-gallery__toggle (“My Try-Ons (N)”) opens .tryvio-gallery__drawer with result thumbnails. Each thumb has cart, compare, and delete (corner X → in-thumb confirm bubble → removeGalleryItem, re-renders with the drawer kept open) action buttons. Items are displayed within the configured output-retention window.

The delete X + confirm bubble is one shared component (attachThumbDelete / showThumbDeleteConfirm, classes .tryvio-thumb-del-btn / .tryvio-thumb-confirm) reused by both the gallery and the Recent strip.

Activating a gallery item makes that saved item’s product the active modal product context. A primary-only saved result clears any page-scoped config.bundle; it must not inherit the product page’s “Complete the look” complement. A saved bundle result carries its own bundleComplements and restores only those complements.

Compare

panel.__ttnEnterCompareMode(left, right) — the two items must have distinct resultImageUrl. Renders a split-slider comparison view.

storefront.relatedProductsUrl + config.storeId → “Similar items” toggle/drawer. Each card has a “Try on” button → startRelatedProductTryOn, which prompts “My current photo” (primary, if a photo is available) or “Upload new photo” (secondary).

Bundle pending

After tapping “Try together”, panel[data-bundle-pending="true"] is set and a banner .tryvio-modal__bundle-capture-banner appears: “Trying on together · Save N%”.

Inline error — .tryvio-modal__inline-error

A try-on response with { status: 'failed', error } triggers showInlineError. Example message: “Something went wrong while creating your try-on. Please try again.” with a hint line below.


Configs that affect which states render

Config / dataEffect
config.discountAdds discount card or offer code to result
config.bundleAdds “Complete the look” card
config.captureEmailUrl + requiresEmail: trueActivates email-gate variant of limit_reached
tryOnLimitReached (top-level)Activates the store overage-cap variant of limit_reached; takes precedence over the shopper rate limit
storefront.demandSignalUrlShows the “Tell the brand you want this” CTA on the store-cap screen
rateLimits.emailGateBonusLimitN in “Unlock N more try-ons” (default 5)
rateLimits.leadCaptureHeadlineEmail-capture modal headline
Past try-ons in tryvio_gallery_v1Gallery toggle visible
Photo in sessionStorage tryvio_photo_v1Skips upload/camera, enters preview directly
Desktop vs mobileDesktop entry → upload; mobile entry → camera
Locale (en / bg)All visible copy strings

Animations

NameApplied toSpec
ttn-panel-inPanel entrancetranslateY(16px) scale(.97) → identity, 280 ms cubic-bezier(.22,1,.36,1)
ttn-fade-in-up (animateIn)Preview / result enterFade + upward translate
Loading-fill progress.tryvio-modal__loading fill trackWidth animated to current percent
Gallery drawerOpen / closeSlide transition
Result controls fade.tryvio-modal__close + actionsOpacity 0 on image tap; restored on next tap
Pinch zoomResult imageCSS transform: scale(…)

Screenshots — the current design

Captured from the REAL widget via the Playwright harness (shopify-app/playwright-debug/widget-storybook.js

  • capture-*.js). These are the actual current states — click any screenshot to open it full-size in a new tab.

Gating

consent — terms checkbox + Accept
consent — terms checkbox + Accept
limit reached — reset time, no purchase
limit reached — reset time, no purchase
limit + email-gate — unlock 5 more
limit + email-gate — unlock 5 more
email-capture modal
email-capture modal

Capture

upload — empty dropzone
upload — empty dropzone
upload — Recent strip
upload — Recent strip
upload — drag-over
upload — drag-over
camera (shows access-denied in headless capture)
camera (shows access-denied in headless capture)
preview — new photo
preview — new photo
preview — reuse a prior photo
preview — reuse a prior photo

Mobile variants (entry differs — mobile defaults to camera):

upload empty (mobile)
upload empty (mobile)
camera (mobile)
camera (mobile)
preview (mobile)
preview (mobile)

Result

result — single product
result — single product
+ discount card (no code)
+ discount card (no code)
+ discount with code (in product card)
+ discount with code (in product card)
+ bundle suggest — complete the look
+ bundle suggest — complete the look
bundle checked — Try together
bundle checked — Try together
merged — both products
merged — both products
controls hidden — tap the image
controls hidden — tap the image
a friend shared — banner
a friend shared — banner
gallery toggle — My Try-Ons
gallery toggle — My Try-Ons
gallery drawer open
gallery drawer open
compare slider
compare slider
Similar items toggle
Similar items toggle
Similar items drawer — Try on
Similar items drawer — Try on
bundle pending — trying on together
bundle pending — trying on together
inline error
inline error