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
/ 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
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.
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 |
goToPlaque() | /plaque-style | plaque-style |
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-style, 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.
Calls POST /api/merch/mockup/generate. Returns { generateMockup, mockupUrl, isGenerating, error }.
| 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 |
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 card with mockup image and size display |
MerchStepIndicator | Linear progress bar or dot indicators |
MerchStepperBar | Step progress bar for the merch flow |
MerchStepperWrapper | Wrapper layout for stepper-based pages |
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)