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 (15 routes, lazy-loaded)
    campaign-layout.tsx        MerchProvider wrapper + responsive shell
    pages/                     15 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
/: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
  /size                              size-page
  /plaque-style                      plaque-style-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: (slug: string, extra?) => Promise<string>
  resetSession: () => void
  updateSession: (fields: Partial<MerchSession>) => void
  syncSession: () => void
}

Session persistence: merch_session_{campaignSlug} in sessionStorage. Hydrates on mount, clears on 404.

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
goToPlaque()/plaque-styleplaque-style
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-style, 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.

useMockupGenerator()

Calls POST /api/merch/mockup/generate. Returns { generateMockup, mockupUrl, isGenerating, error }.

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
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 card with mockup image and size display
MerchStepIndicatorLinear progress bar or dot indicators
MerchStepperBarStep progress bar for the merch flow
MerchStepperWrapperWrapper layout for stepper-based pages

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)