Complete fan journey from landing to order confirmation with multi-candidate selection and multi-item cart
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):
The Merch system supports three primary user-flow variants based on the product mode and entry context:
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:
/merch/superstore/:slug (a fresh SPA load, empty memory → new journey).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)./{talent}/{campaign}?utm=superstore-{slug} (no ?s=). The session id is already in memory.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.navigate('/superstore/:slug') using journeySuperstoreSlug from the store — the journeySessionId is still in memory, so startSuperstoreJourney immediately reuses it (no new session created).Manual visit (or page refresh) to /merch/superstore/:slug boots a fresh SPA with empty memory → new journey, satisfying the "manual visit restarts" rule.
Route: /:talentSlug/:campaignSlug
Fan visits the campaign URL. The page:
StoreUnavailableState)POST /api/merch/session/createutm=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.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.POST /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:
?add=1 query param is present, otherwise replaces)campaign.designs to designs associated with the selected catalog productRoute: /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.
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.
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.
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/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):
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 |
On This Page
End-to-End JourneysStore vs Experience: Same Middle Steps, Different Landing1. Personalized Merch Flow (Standard)2. Branded Merch / E-commerce Flow (No Selfie/AI)3. Multishop / Superstore Journey FlowStep-by-Step1. Landing Page / Session Adoption2. Capture (Camera or Upload)3. Review Page4a. Product Selection4b. Design Selection5a. Size Selection (Apparel)5b. Plaque Flow6–7. Create Page8. Shipping9. Payment10. ConfirmationLifecycle GuardsNavigation Guards