User Flow

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

End-to-End Journey

graph TB Landing["1. Landing Page"] -->|Take Selfie| Camera["2a. Camera"] Landing -->|Upload Photo| Upload["2b. Upload"] Camera --> Review["3. Review"] Upload --> Review Upload -->|Celebrity/Quality fail| UploadVerify["Upload Verify"] UploadVerify -->|Retry| Upload Review --> MerchSelect["4. Product Select"] MerchSelect -->|Apparel| Size["5a. Size Select"] MerchSelect -->|Plaque| PlaqueStyle["5b. Plaque Style"] PlaqueStyle --> PlaquePerson["5b. Plaque Personalize"] Size --> Create["6–7. Create Page<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"] Confirm -->|Shop Again| Landing Confirm -->|Share| Share["Share Page"]

Step-by-Step

1. Landing Page

Route: /:talentSlug/:campaignSlug

Fan visits the campaign URL. The page:

  • Checks campaign lifecycle (ENDED/EMERGENCY_CLOSE shows StoreUnavailableState)
  • Creates a session via POST /api/merch/session/create
  • Records tracking visit via POST /api/merch/tracking/record
  • 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

4. 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 cartItems (appends to cart if ?add=1 query param is present, otherwise replaces)
  • Fires POST /api/ai/generate early (background, before size selection)
  • Navigates to size page (apparel) or plaque style page (plaque)

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

Routes: /plaque-style then /plaque-personalize

  1. Style: Choose Standard or Deluxe variant (different pricing and images)
  2. Personalize: Enter fan name (max 24 chars, auto-uppercase) with live preview. Submit calls POST /api/plaque/render then navigates to /create.

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/ai/generate runs parallel attempts, scores by character consistency, returns candidates sorted by score. 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