Complete fan journey from landing to order confirmation with multi-candidate selection and multi-item cart
Route: /:talentSlug/:campaignSlug
Fan visits the campaign URL. The page:
StoreUnavailableState)POST /api/merch/session/createPOST /api/merch/tracking/recordRole detection: ?mode=preview query param sets role to tester for demo mode.
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:
POST /api/merch/upload (S3 upload)POST /api/merch/upload/verify (Gemini AI verification)Verification outcomes:
| Result | Navigation |
|---|---|
| No face detected | Review page with yellow warning overlay |
| Celebrity detected | /upload-verify?reason=celebrity |
| Nudity or blur | /upload-verify?reason=quality |
| Success | Review page |
Route: /review
Shows the captured/uploaded photo. If no face was detected, a yellow warning overlay appears. Two actions:
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:
cartItems (appends to cart if ?add=1 query param is present, otherwise replaces)POST /api/ai/generate early (background, before size selection)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.
Routes: /plaque-style then /plaque-personalize
POST /api/plaque/render then navigates to /create.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:
EmailCaptureForm offers "email me when ready"; "I'm happy to wait" skips to Phase 2.EmailConfirmationBanner or a secondary email CTA as before.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):
ArtSelectionSection with cards sorted by likeness; selection calls POST /api/merch/ai/select to set aiArtKey.Cart view state — After at least one item is in the cart from this page:
/shipping./merch?add=1.aiArtKey and returns to loading state on the same page (single-item case as applicable).Reselfie overlay — Inline on /create:
SelfieActionButtons (take selfie / upload / cancel).uploadToS3) → verification (verifyImageWithAI) → MerchPhotoPreview.selfieKey, clears aiArtKey and aiArtCandidates), returns to loading state for re-generation.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.
Route: /payment
Self-contained page that:
POST /api/merch/calc-pricePOST /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.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.
Route: /confirm
On mount:
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.POST /api/merch/share/createThe 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.
| Condition | Behavior | Pages Affected |
|---|---|---|
| ENDED | Full-screen StoreUnavailableState | All pages |
| EMERGENCY_CLOSE | Full-screen StoreUnavailableState | All pages |
| SOFT_CLOSE (post-selection steps) | useCheckoutBlocked blocks access | size, plaque-*, create, shipping, payment, confirm |
| SOFT_CLOSE (active) | Countdown banner only | shipping, payment |
| Guard | Action |
|---|---|
| Size page: no product selected | Redirect to /merch |
| Plaque pages: no plaque in cart | Redirect to /merch |
| Review page: no photo | Show error, nav to studio |
| Landing: role mismatch | Clear session, reload |