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
/ 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
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.
Thin useContext(MerchContext) wrapper. Throws if used outside MerchProvider.
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).
| Method | Path | Step |
|---|---|---|
goToCamera() | /camera | camera |
goToStudio() | /studio | studio |
goToReview() | /review | review |
goToMerchSelection() | /merch | merch-selection |
goToSize() | /size | size |
goToPlaquePersonalize() | /plaque-personalize | plaque-personalize |
goToCreate() | /create | create |
goToShipping() | /shipping | shipping |
goToPayment() | /payment | payment |
goToConfirm() | /confirm | confirm |
Polls GET /campaign/{slug}/lifecycle every 30 seconds. Returns { lifecycle, isLoading }. Auto-stops at terminal states (ENDED/EMERGENCY_CLOSE).
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.
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.
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.
| Component | Purpose |
|---|---|
ArtSelectionSection | Multi-candidate art selection carousel with score-sorted cards |
BrandingBar | Partner logo + Zooly logo header bar |
CartResultView | Multi-item cart display with hero card, compact rows, and actions |
EmailCaptureForm | Email input with validation for create-page loading-phase delayed notification |
EmailConfirmationBanner | Success banner after email submission |
LegalDrawer | Bottom sheet for terms/privacy/cookie policies |
MerchPhotoPreview | Photo preview with retake/continue actions and error display |
OrderTotalBreakdown | Line items + subtotal + shipping + total (uses formatMinorUnitToDisplay with campaign currency) |
SelfieActionButtons | Take selfie / upload photo / cancel action buttons |
ShippingForm | Full address form with country/state dropdowns |
SoftCloseCountdownBanner | Countdown timer for SOFT_CLOSE mode |
StoreUnavailableState | Full-screen block for ENDED/EMERGENCY_CLOSE |
StudioCarousel | Touch-swipeable image carousel with dot indicators |
ZoolyLogo | SVG infinity logo |
MerchButton | CVA button (primary/secondary/ghost, sm/md/lg) |
MerchChip | Toggle chip with aria-pressed |
MerchLightbox | Pinch-zoom lightbox on Radix Dialog |
MerchLoadingOverlay | Spinner + rotating messages |
MerchProductCard | Product/design selection card with mockup or display image (prices formatted via formatMinorUnitToDisplay with campaign currency) |
MerchStepIndicator | Step progress indicator (currently unused; step progress is tracked only as an analytics field) |
packages/merch/client/src/styles/index.css
DM Sans (300-800 weights) via Google Fonts. Applied to html, body.
--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);
| Class | Purpose |
|---|---|
.merch-btn-gradient | Primary CTA (dark gradient, uppercase, pill shape) |
.merch-btn-outline-v2 | Secondary CTA (white, bordered, pill shape) |
.merch-type-headline | 22px/700 centered heading |
.merch-type-body | 14px/400 body text |
.merch-v2-size-btn | Size selection button (80px, 3-col grid) |
.merch-v2-progress-track | Loading progress bar track |
.merch-v2-progress-fill | Loading progress bar fill (gradient) |
.merch-carousel-* | Studio carousel styles |
.merch-field-input | Form input (rounded, focus ring) |
.merch-v2-form-input | Shipping form input variant |
.merch-action-bar-wrap | Sticky footer (fixed mobile, static desktop) |
.legal-drawer-* | Legal bottom sheet styles |
| Keyframe | Purpose |
|---|---|
merch-pulse | Analyzing state text pulse |
merch-fade-in | Generic fade in |
merch-slide-up | Legal drawer slide up |
merch-spin | Spinner rotation |
merch-v2-wiggle | Create page loading-phase icon wiggle |
merch-v2-pulse-check | Confirm page check icon |
merch-flash-fade | Camera flash effect |
overscroll-behavior: contain on scroll containers-webkit-tap-highlight-color: transparent on buttons@supports (-webkit-touch-callout: none)