Frontend Reference

Pages, components, hooks, context, and CSS system

File Structure

The merch frontend follows the monorepo pattern: apps/zooly-merch/ is a thin Vite wrapper that mounts the client package.

apps/zooly-merch/ (Thin Wrapper)
  src/
    main.tsx                     ReactDOM.createRoot entry
    styles/tailwind.css          Tailwind config + @source directive

packages/merch/client/src/ (Client Package — @zooly/merch-client)
  app/
    App.tsx                    RouterProvider + Sonner Toaster
    routes.ts                  React Router v7 (16 routes, lazy-loaded)
    campaign-layout.tsx        MerchProvider wrapper + responsive shell
    pages/                     16 page components
  components/                  22 reusable components
  context/
    merch-context.tsx          Campaign + session state provider
  hooks/                       6 custom hooks
  lib/
    api.ts                     merchApi() fetch wrapper
    types.ts                   Re-exported types
    merch-events.ts            117 journey event constants
    log-merch-event.ts         Fire-and-forget event logger
    assets.ts                  Asset URL helpers
  styles/
    index.css                  CSS custom properties + utility classes

Route Map

/                                    campaigns-page
/history                             history-page
/superstore/:slug                    superstore-page
/:talentSlug/:campaignSlug           campaign-layout (MerchProvider)
  index                              campaign-landing-page
  /camera                            camera-page
  /studio                            studio-page
  /upload-verify                     upload-verify-page
  /review                            review-page
  /merch                             merch-selection-page
  /design                            design-selection-page
  /size                              size-page
  /plaque-personalize                plaque-personalize-page
  /create                            create-page
  /shipping                          shipping-page
  /payment                           payment-page
  /confirm                           confirm-page
  /share/:shareId                    share-page

MerchContext

Provider in packages/merch/client/src/context/merch-context.tsx wraps all campaign routes. Manages campaign config and session state.

interface MerchContextValue {
  campaign: MerchCampaignConfig | null;
  isCampaignLoading: boolean;
  campaignError: string | null;

  session: MerchSession | null;
  sessionId: string | null;
  isSessionLoading: boolean;
  sessionError: string | null;

  createSession: (campaignSlug: string, extra?: CreateSessionExtra) => Promise<string>;
  resetSession: () => void;
  updateSession: (fields: Partial<MerchSession>) => void;
  syncSession: () => void;
  refreshCampaign: () => Promise<void>;
}

Zustand Store (useMerchStore): Under the hood, the client manages global shopping session actions via a Zustand store (packages/merch/client/src/lib/merch-session-store.ts), including:

  • createSession(campaignSlug, extra): creates a session.
  • startSuperstoreJourney(slug, opts): creates a campaign-null journey session on first call for a given superstore slug, or reuses the existing in-memory journeySessionId on subsequent calls (the "add another" case). Stores journeySessionId and journeySuperstoreSlug in the module-level Zustand state — no ?s= URL param needed.
  • adoptSession(sessionId, campaignSlug): hydrates an existing session (e.g. from a multishop journey) and POSTs to /session/update with campaignSlug to associate the session with the current sub-store.

Session persistence: merch_session_{campaignSlug} in sessionStorage. Hydrates on mount, clears on 404. For superstore journeys the provider reads journeySessionId from the Zustand store (populated by startSuperstoreJourney on the /merch/superstore/:slug page) instead of from a ?s= URL param.

Optimistic updates: updateSession() applies locally immediately, accumulates pending fields in a ref, and triggers a 500ms debounced PATCH to the backend. Failed syncs re-queue fields for retry.

Reset: resetSession() clears sessionStorage, React state (session, sessionId, sessionError), pending fields, and cancels active sync timers.

Hooks

useMerch()

Thin useContext(MerchContext) wrapper. Throws if used outside MerchProvider.

useMerchNavigation()

Navigation helpers using useNavigate() + useParams(). Each method updates session analytics (currentStep, lastRoute). goToCreate() replaces the former goToLoading(), goToResult(), and goToReselfie() (two fewer methods on the hook).

MethodPathStep
goToCamera()/cameracamera
goToStudio()/studiostudio
goToReview()/reviewreview
goToMerchSelection()/merchmerch-selection
goToSize()/sizesize
goToPlaquePersonalize()/plaque-personalizeplaque-personalize
goToCreate()/createcreate
goToShipping()/shippingshipping
goToPayment()/paymentpayment
goToConfirm()/confirmconfirm

useLifecyclePolling(campaignSlug)

Polls GET /campaign/{slug}/lifecycle every 30 seconds. Returns { lifecycle, isLoading }. Auto-stops at terminal states (ENDED/EMERGENCY_CLOSE).

useCheckoutBlocked(currentStep)

Determines if the current step is blocked by lifecycle state. Returns { isBlocked, reason, minutesRemaining }.

Post-selection steps blocked by SOFT_CLOSE: size, plaque-personalize, create, shipping, payment, confirm.

useCart()

Encapsulates multi-item cart logic on top of useMerch() context. Manages cartItems in the session with support for add, remove, clear, and "add another" flow state.

interface UseCartReturn {
  cartItems: MerchLineItem[];
  cartTotal: number;
  isMultiItem: boolean;
  expandedCartIndex: number;
  setExpandedCartIndex: (index: number) => void;
  isAddingAnother: boolean;
  addToCart: (item: MerchLineItem) => void;
  removeFromCart: (index: number) => void;
  clearCart: () => void;
  startAddAnother: () => void;
  cancelAddAnother: () => void;
}

addToCart replaces an existing item with the same productId or appends. When isAddingAnother is true, always appends. removeFromCart adjusts expandedCartIndex to keep the correct item focused. Cart state is persisted to the session via updateSession.

Product rendering

Catalog-backed product renders are requested through POST /api/merch/render. The route returns media filenames for the watermarked preview and clean output; client pages resolve those filenames through the media proxy and store them on cart items as imageKey / cleanImageKey.

Components

ComponentPurpose
ArtSelectionSectionMulti-candidate art selection carousel with score-sorted cards
BrandingBarPartner logo + Zooly logo header bar
CartResultViewMulti-item cart display with hero card, compact rows, and actions
EmailCaptureFormEmail input with validation for create-page loading-phase delayed notification
EmailConfirmationBannerSuccess banner after email submission
LegalDrawerBottom sheet for terms/privacy/cookie policies
MerchPhotoPreviewPhoto preview with retake/continue actions and error display
OrderTotalBreakdownLine items + subtotal + shipping + total (uses formatMinorUnitToDisplay with campaign currency)
SelfieActionButtonsTake selfie / upload photo / cancel action buttons
ShippingFormFull address form with country/state dropdowns
SoftCloseCountdownBannerCountdown timer for SOFT_CLOSE mode
StoreUnavailableStateFull-screen block for ENDED/EMERGENCY_CLOSE
StudioCarouselTouch-swipeable image carousel with dot indicators
ZoolyLogoSVG infinity logo
MerchButtonCVA button (primary/secondary/ghost, sm/md/lg)
MerchChipToggle chip with aria-pressed
MerchLightboxPinch-zoom lightbox on Radix Dialog
MerchLoadingOverlaySpinner + rotating messages
MerchProductCardProduct/design selection card with mockup or display image (prices formatted via formatMinorUnitToDisplay with campaign currency)
MerchStepIndicatorStep progress indicator (currently unused; step progress is tracked only as an analytics field)

CSS System

packages/merch/client/src/styles/index.css

Font

DM Sans (300-800 weights) via Google Fonts. Applied to html, body.

Design Tokens

--merch-bg: #f4f4f4;
--merch-surface: #ffffff;
--merch-on-surface: #1a1b1d;
--merch-on-surface-var: #969696;
--merch-outline: #e0e0e0;
--merch-primary: #1a1b1d;
--merch-on-primary: #ffffff;
--merch-error: #c0392b;
--merch-dur-fast: 150ms;
--merch-dur-med: 220ms;
--merch-spring: cubic-bezier(0.34, 1.56, 0.64, 1);

Key CSS Classes

ClassPurpose
.merch-btn-gradientPrimary CTA (dark gradient, uppercase, pill shape)
.merch-btn-outline-v2Secondary CTA (white, bordered, pill shape)
.merch-type-headline22px/700 centered heading
.merch-type-body14px/400 body text
.merch-v2-size-btnSize selection button (80px, 3-col grid)
.merch-v2-progress-trackLoading progress bar track
.merch-v2-progress-fillLoading progress bar fill (gradient)
.merch-carousel-*Studio carousel styles
.merch-field-inputForm input (rounded, focus ring)
.merch-v2-form-inputShipping form input variant
.merch-action-bar-wrapSticky footer (fixed mobile, static desktop)
.legal-drawer-*Legal bottom sheet styles

Animations

KeyframePurpose
merch-pulseAnalyzing state text pulse
merch-fade-inGeneric fade in
merch-slide-upLegal drawer slide up
merch-spinSpinner rotation
merch-v2-wiggleCreate page loading-phase icon wiggle
merch-v2-pulse-checkConfirm page check icon
merch-flash-fadeCamera flash effect

Mobile Fixes

  • Safe-area inset padding for notch devices
  • overscroll-behavior: contain on scroll containers
  • -webkit-tap-highlight-color: transparent on buttons
  • Fixed viewport for camera page
  • iOS-specific scroll behavior via @supports (-webkit-touch-callout: none)