User Flow

Complete fan journey from landing to order confirmation with multi-candidate selection and multi-item cart

End-to-End Journeys

Store vs Experience: Same Middle Steps, Different Landing

Store mode, Experience mode, and the Face leaderboard all share the core selection, generation, and checkout steps (B1 → B2 → C → D1 → D2 → E). Only the entry landing screen differs. Like the Superstore, the Face leaderboard (face.zooly.ai) is a separate top-level entry point that picks a team's store and hands off directly into the shared steps — skipping the store landing (A1) and dropping straight into Design/Product Selection (B1):

flowchart TD A1[A1 — Store landing — pick a sub-store] A2[A2 — Experience landing — e.g. Start into the story] A3[A3 — Face leaderboard — pick a team store] B1[B1 — Choose a design] B2[B2 — Choose a product] C[C — Details] D1[D1 — Take a selfie] D2[D2 — Generate — create results, regenerate, pick the best output] E[E — Purchase — checkout] A1 --> B1 A2 --> B1 A3 -.skips A1.-> B1 B1 --> B2 --> C --> D1 --> D2 --> E

The Merch system supports three primary user-flow variants based on the product mode and entry context:

1. Personalized Merch Flow (Standard)

graph TD Landing["1. Landing Page (A)"] -->|Take Selfie| Camera["2a. Camera (D1)"] Landing -->|Upload Photo| Upload["2b. Upload (D1)"] Camera --> Review["3. Review"] Upload --> Review Upload -->|Celebrity/Quality fail| UploadVerify["Upload Verify"] UploadVerify -->|Retry| Upload Review --> MerchSelect["4. Product Select (B2)"] MerchSelect -->|Multiple designs| DesignSelect["5. Design Select (B1)"] MerchSelect -->|One design| SizeOrPlaque["6. Details (C)"] DesignSelect --> SizeOrPlaque SizeOrPlaque -->|Apparel| Size["6a. Size Select"] SizeOrPlaque -->|Plaque| PlaquePerson["6b. Plaque Personalize"] Size --> Create["7. Create Page (D2)<br/>(loading → art → cart)"] PlaquePerson --> Create Create -->|Regenerate| Create Create -->|Add Another| MerchSelect Create -->|BUY from cart| Shipping["8. Shipping"] Shipping --> Payment["9. Payment"] Payment --> Confirm["10. Confirmation (E)"] Confirm -->|Shop Again| Landing Confirm -->|Share| Share["Share Page"]

2. Branded Merch / E-commerce Flow (No Selfie/AI)

graph TD Landing["1. Landing Page (A)"] -->|Standard Shop Entrance| MerchSelect["2. Product Select (B2)"] MerchSelect -->|Multiple designs| DesignSelect["3. Design Select (B1)"] MerchSelect -->|One design| SizeOrPlaque["4. Details (C)"] DesignSelect --> SizeOrPlaque SizeOrPlaque -->|Apparel| Size["4a. Size Select"] SizeOrPlaque -->|Plaque| PlaquePerson["4b. Plaque Personalize"] Size --> Create["5. Cart View (no AI generation)"] PlaquePerson --> Create Create -->|Add Another| MerchSelect Create -->|BUY from cart| Shipping["6. Shipping"] Shipping --> Payment["7. Payment"] Payment --> Confirm["8. Confirmation (E)"]

3. Multishop / Superstore Journey Flow

The Superstore is a top-level SPA route at /merch/superstore/:slug — it lives inside the same Vite SPA as sub-stores. The journey session travels in Zustand in-memory state rather than a ?s= URL parameter.

When entering from the Superstore:

  1. The shopper visits /merch/superstore/:slug (a fresh SPA load, empty memory → new journey).
  2. The shopper taps a store card. startSuperstoreJourney(slug) is called, which creates a campaign-null journey session (recording /merch/superstore/{slug} as startPage) and stores the resulting journeySessionId in Zustand (merchStore).
  3. In-SPA navigation to /{talent}/{campaign}?utm=superstore-{slug} (no ?s=). The session id is already in memory.
  4. The sub-store landing page reads journeySessionId from the store and adopts the session (preserving identity — selfie, email, shipping — while clearing design-specific scratch) and auto-advances/skips the store landing (A1) entirely, dropping straight into Design/Product Selection.
  5. When "Add Another" is clicked after adding an item to the cart, the system does an in-SPA navigate('/superstore/:slug') using journeySuperstoreSlug from the store — the journeySessionId is still in memory, so startSuperstoreJourney immediately reuses it (no new session created).
  6. The shopper picks a different store and the adoption repeats — same session, new campaign, identity preserved.

Manual visit (or page refresh) to /merch/superstore/:slug boots a fresh SPA with empty memory → new journey, satisfying the "manual visit restarts" rule.


Step-by-Step

1. Landing Page / Session Adoption

Route: /:talentSlug/:campaignSlug

Fan visits the campaign URL. The page:

  • Checks campaign lifecycle (ENDED/EMERGENCY_CLOSE shows StoreUnavailableState)
  • Direct Entry: Creates a fresh session via POST /api/merch/session/create
  • Superstore / Multishop Entry (presence of utm=superstore-* and a journeySessionId in the Zustand store): Adopts the existing in-memory journey session, sets the sub-store's campaignId on the session via POST /api/merch/session/update (which resets campaign scratch fields but keeps email, selfie, shipping), and skips the landing page entirely to auto-advance into Design/Product Selection.
  • Face Leaderboard Entry (face.zooly.ai?bt=<boardTeamId>): The Face leaderboard is a separate top-level entry point. Tapping a team navigates to that team's campaign store with ?bt=<boardTeamId>; the landing page links the BoardTeam to the session (POST /api/merch/leaderboard/link) and, like the Superstore, skips the store landing (A1) to drop straight into Design/Product Selection.
  • Records tracking visit via POST /api/merch/tracking/record
  • Direct entry presents two CTAs: "Take a Selfie" and "Upload Photo"
  • Shows legal footer with terms/privacy/cookie links

Role detection: ?mode=preview query param sets role to tester for demo mode.

2. Capture (Camera or Upload)

Camera (/camera): Requests getUserMedia, shows face guide overlay, captures JPEG at 0.92 quality with mirror flip for front-facing camera. Includes flash animation on capture.

Upload (from landing or studio page): File picker triggers S3 upload + Gemini vision verification.

Both paths run:

  1. POST /api/merch/upload (S3 upload)
  2. POST /api/merch/upload/verify (Gemini AI verification)

Verification outcomes:

ResultNavigation
No face detectedReview page with yellow warning overlay
Celebrity detected/upload-verify?reason=celebrity
Nudity or blur/upload-verify?reason=quality
SuccessReview page

3. Review Page

Route: /review

Shows the captured/uploaded photo. If no face was detected, a yellow warning overlay appears. Two actions:

  • Continue: updates session, navigates to product selection
  • Change Photo: navigates back to studio

4a. Product Selection

Route: /merch

Grid of product cards (apparel first, plaques split into Standard/Deluxe variants). If campaign.enableLiveMockups is true, mockups generate on mount for each product.

Selecting a product:

  • Updates session with a draft cart item (appends to cart if ?add=1 query param is present, otherwise replaces)
  • Filters campaign.designs to designs associated with the selected catalog product
  • Auto-selects the design when exactly one design matches
  • Navigates to design selection when multiple designs match
  • Falls back to the next product-specific step when no design metadata is present

4b. Design Selection

Route: /design

Grid of design cards using each design's displayImageUrl. This is currently product-first: the product picked on /merch determines which designs are shown. The data model also supports a future design-first flow by reversing the same product/design association.

Selecting a design updates the active cart item with designId and designName, then navigates to size selection for apparel or plaque personalization for plaques. The selected designId is sent to AI generation so the backend resolves the correct template image, prompt, model, and overrides.

5a. Size Selection (Apparel)

Route: /size

3-column grid of size buttons from product.sizes. Selecting a size updates the last item in session.cartItems (supporting the multi-item add-another flow) and auto-navigates to the create page after 300ms.

5b. Plaque Flow

Route: /plaque-personalize

Plaque products now come from the campaign's attached catalog products. There is no separate plaque-style route; each plaque style is its own catalog product. The shopper enters the fan name (max 24 chars, auto-uppercase) with live preview, then continues to /create. Product rendering happens through the catalog renderer (POST /api/merch/render) when the generated art is available.

6–7. Create Page

Route: /create

One URL hosts the former loading, result, and reselfie experiences as internal UI states (no route changes between them).

Loading state — Progress and generation:

  • Phase 1 — Email Capture: After a short delay, EmailCaptureForm offers "email me when ready"; "I'm happy to wait" skips to Phase 2.
  • Phase 2 — Waiting: Logarithmic progress bar (TAU=21,700ms, ~90% at ~50s) with engagement carousel (messages every ~4s). EmailConfirmationBanner or a secondary email CTA as before.
  • If generation already completed (has aiArtKey), the UI moves on without treating this as a separate page navigation.

Generation: POST /api/merch/ai/generate runs parallel attempts, scores by character consistency, returns candidates sorted by score. The request includes the selected catalog product, its catalog product type, and design ID when available, and cached generation is keyed by selfie + product + design. Default aiArtKey plus full session.aiArtCandidates.

Art selection state — When generation completes (still on /create):

  • Multi-candidate: ArtSelectionSection with cards sorted by likeness; selection calls POST /api/merch/ai/select to set aiArtKey.
  • Single candidate (fallback): Simple product card.
  • BUY: Adds the line to the cart and switches to cart view on the same page (does not go to shipping yet).
  • Try a different selfie: Opens the reselfie overlay (not a new route).

Cart view state — After at least one item is in the cart from this page:

  • BUY: Navigates to /shipping.
  • + ADD ANOTHER ITEM: Sets add-another flow and navigates to /merch?add=1.
  • Regenerate Image: Clears aiArtKey and returns to loading state on the same page (single-item case as applicable).
  • Delete item / Delete All: Cart management as before.

Reselfie overlay — Inline on /create:

  • SelfieActionButtons (take selfie / upload / cancel).
  • Capture or upload → S3 (uploadToS3) → verification (verifyImageWithAI) → MerchPhotoPreview.
  • Continue: updates session (selfieKey, clears aiArtKey and aiArtCandidates), returns to loading state for re-generation.
  • Cancel: closes overlay and returns to the previous create-page view.

8. Shipping

Route: /shipping

Full shipping form with country/state dropdowns, email validation. Shows SoftCloseCountdownBanner if campaign is in SOFT_CLOSE mode. Back navigation returns to /create (create page). Submit saves shippingInfo to session and navigates to payment.

9. Payment

Route: /payment

Self-contained page that:

  1. Fetches pricing via POST /api/merch/calc-price
  2. Creates PaymentIntent via POST /api/merch/create-payment-intent — before creating the payment, the route calls the auth service (POST /api/users/get-or-create) with the buyer's email to get or create a guest user identity. The returned user_id is stored on the session and passed to createMerchPaymentIntent as buyerUserId. Then createMerchPaymentIntent from @zooly/srv-stripe-payment creates a stripe_payment DB record (with buyerUserId set to the guest user_id) and a Stripe PI with an idempotency key (merch-pi-{paymentId}). The internal payment ID is stored on the session as stripePaymentDbId.
  3. Renders Stripe PaymentElement with collapsible order breakdown

Demo mode (tester role): "Skip payment" checkbox available when allowSkipPayment is true. Calls POST /api/merch/order/complete directly without Stripe.

Real payment: stripe.confirmPayment() then navigates to confirm page.

10. Confirmation

Route: /confirm

On mount:

  1. Completes order via POST /api/merch/order/complete — for real payments this calls completePaymentFromClient(stripePaymentDbId) from @zooly/srv-stripe-payment, which verifies the PI at Stripe, creates share tracking records, and marks the payment as SUCCEEDED. Then creates the merch order from session data, including the guest user_id from the session.
  2. Creates share link via POST /api/merch/share/create
  3. Sends confirmation email (non-fatal, never blocks order)

The Stripe webhook (charge.succeeded) acts as a fallback — if the client crashes after payment, the webhook completes the payment via the same idempotent completePayment() function.

Shows PAID badge, order summary, save/share action buttons. "SHOP AGAIN" in sticky footer calls resetSession() (clears both sessionStorage and React state) then navigates to landing.

Lifecycle Guards

ConditionBehaviorPages Affected
ENDEDFull-screen StoreUnavailableStateAll pages
EMERGENCY_CLOSEFull-screen StoreUnavailableStateAll pages
SOFT_CLOSE (post-selection steps)useCheckoutBlocked blocks accesssize, plaque-*, create, shipping, payment, confirm
SOFT_CLOSE (active)Countdown banner onlyshipping, payment
GuardAction
Size page: no product selectedRedirect to /merch
Plaque pages: no plaque in cartRedirect to /merch
Review page: no photoShow error, nav to studio
Landing: role mismatchClear session, reload