Merch App Architecture

Stack, dependencies, infrastructure, and design decisions

Stack

LayerTechnologyLocation
FrontendVite + React 18 + React Router v7apps/zooly-merch/ (thin wrapper)
Client logicReact components, hooks, pagespackages/merch/client/src/
Backend APINext.js 16 API routesapps/zooly-app/app/api/merch/
Server logicTypeScript servicespackages/merch/srv/src/
Payment integrationStripe via @zooly/srv-stripe-paymentpackages/srv-stripe-payment/
DatabasePostgreSQL + Drizzle ORMpackages/db/src/schema/merch*.ts
Access layerTyped functionspackages/db/src/access/merch/
TypesShared interfaces/enumspackages/types/src/types/Merch.ts
Payments (client)Stripe PaymentElementVia @stripe/react-stripe-js
Image processingSharp catalog renderer + FAL.AI generationpackages/merch/srv/src/
EmailSendGridpackages/merch/srv/src/send-email.ts

Architecture Diagram

apps/zooly-merch/ (Vite SPA — thin wrapper)
  src/
    main.tsx             ReactDOM entry point
    styles/              CSS + Tailwind @source directive

packages/merch/client/ (Client Package — @zooly/merch-client)
  src/
    app/pages/           15 page components (lazy-loaded)
    components/          22 reusable components
    context/             MerchContext provider
    hooks/               6 custom hooks
    lib/                 api client, types, event logging
    styles/              CSS custom properties + utilities

apps/zooly-app/app/api/merch/ (Next.js API — thin routes)
  20 route files + cors.ts

packages/merch/srv/ (Server Package — @zooly/merch-srv)
  25 service files (AI, image processing, orders, email, lifecycle)

packages/srv-stripe-payment/ (Payment Service — @zooly/srv-stripe-payment)
  createMerchPaymentIntent.ts   Merch-specific PI creation
  getProductByPayForId.ts       MERCH case for product resolution

packages/merch-admin/ (Admin Builder — @zooly/merch-admin)
  client/src/
    components/builder-v2/     Campaign builder V2 (Zustand state, URL-routed tabs)
      tabs/setup/              Setup: branding, products, shipping, studio, legal
      tabs/design/             Design: 3-level hierarchy (design → product variation → demographic)
      tabs/simulation/         Simulation runner
      tabs/translation/        i18n key/value editor
    app/pages/benchmark-*.tsx  Benchmark tab (mounts @zooly/merch-benchmark-client)

packages/merch-benchmark/ (Benchmark Tool)
  client/                      Dashboard, results grid, session detail (@zooly/merch-benchmark-client)
  srv/                         Session creation + run-generation pipeline (@zooly/merch-benchmark-srv)

packages/merch/img-gen/ (@zooly/merch-img-gen)
  Shared AI generation + image processing extracted from merch-srv
  Used by production /api/merch/ai/generate and benchmark run-generation

packages/db/src/ (Database)
  schema/merchTables.ts        Core + satellite tables (branding, studio, legal, config, copy, audit)
  schema/merchDesignTables.ts   3 tables (merch_design, merch_design_config, merch_session_generation)
  schema/merchEnums.ts         19 enums
  access/merch/                13 access files + merch-design.ts (3-level resolution)

Why a Standalone Vite SPA?

The merch app is not a Next.js page or an enum-based screen like the main zooly-app. It's a standalone Vite SPA because:

  1. Separate deployment at merch.zooly.ai via reverse proxy
  2. No SSR needed for the fan-facing flow
  3. Campaign-specific theming via --merch-* CSS custom properties and primaryColor injection
  4. DM Sans font (not the Inter/Outfit used by the main app)
  5. Frontend code in @zooly/merch-client — app is a thin wrapper that mounts the client package

The pattern follows apps/zooly-ops/ (Vite + React, client-only), not apps/zooly-app/ (Next.js).

Frontend Dependencies

Runtime

PackagePurpose
react-router 7.13Client-side routing
@stripe/react-stripe-js ^3.1Stripe PaymentElement
@stripe/stripe-js ^5.5Stripe.js loader
@radix-ui/react-dialogLightbox dialog
@radix-ui/react-selectCountry/state dropdowns
class-variance-authorityButton/component variants
clsx + tailwind-mergeClass merging
lucide-reactIcons
motionFramer Motion animations
sonnerToast notifications

Dev

PackagePurpose
vite 6.3Build tool
tailwindcss 4.1Utility CSS (via Vite plugin)
@playwright/testE2E testing

State Management

ToolLocationPurpose
MerchContextpackages/merch/client/src/context/merch-context.tsxCampaign config + session state
useCart hookpackages/merch/client/src/hooks/use-cart.tsMulti-item cart operations (add/remove/clear)
sessionStoragemerch_session_{slug}Session persistence across page refreshes

MerchContext uses optimistic updates with a 500ms debounced PATCH to the backend. Pending fields are accumulated in a ref and synced in batch. The useCart hook wraps cart-specific logic on top of the context, managing cartItems in the session with support for adding, removing, and clearing items, as well as tracking the "add another" flow state.

CORS Configuration

All merch API routes are public (no auth). Cross-origin requests are handled by:

  1. cors.ts (route-level): validates origin against ALLOWED_DOMAINS_CORS env var
  2. middleware.ts (global): intercepts all /api/* requests for preflight OPTIONS

Allowed origins: localhost:3008 (dev) and merch.zooly.ai (prod).

Key Design Decisions

DecisionRationale
Admin V2 builder with URL routingZustand store + React Router tabs (Setup, Design, Simulation, Translation, Benchmark) — each sub-tab is URL-addressable
Catalog-backed productsGlobal catalog products attach to shops; designs associate to catalog products through catalogProductIds, so product type is derived from the catalog row
3-level design hierarchyDesign (base) → Catalog Product Variation → Demographic Variation; config merging at each level reduces duplication
Campaign normalization into satellite tablesbranding/studio/legal/config/copy broken out from monolithic merch_campaign for cleaner admin editing
React Router v7 (not enum screens)Multi-page flow with URL state, shareable links
Single /create route (was /loading, /result, /reselfie)One URL for generation → art pick → cart; fewer navigations, reselfie as overlay, goToCreate() replaces three helpers
Stripe PaymentElement (not individual card fields)Modern Stripe best practice, handles Apple/Google Pay
Payment via @zooly/srv-stripe-paymentAudit trail, idempotency, webhook fallback, revenue distribution
Client in packages/merch/client/App is thin wrapper; logic lives in a reusable package
Server in packages/merch/srv/Same separation — business logic outside the Next.js app
--merch-* CSS custom propertiesCampaign-specific theming via primaryColor override
Debounced session sync (500ms)Reduces API calls during rapid interactions
Logarithmic progress (TAU=21700)Realistic feel: fast start, slow approach to 90%
startGenerationEarly on product selectAI generation starts before size selection
Lifecycle polling every 30sBalance between freshness and API cost
Optimistic updates with re-queue on failureResponsive UI even with network issues
Multi-candidate art selectionFan picks from all generation candidates, not just the best
Multi-item cart via useCart hookSession-persisted cart with add/remove/clear/add-another
Reselfie as inline overlay on /createRe-capture selfie without a separate route or leaving generation/selection context
?add=1 query param for add-anotherCross-page state for multi-item cart flow
Campaign-agnostic journey sessionDecouples sessions from fixed campaigns; allows multishop superstore shoppers to reuse identity fields (email, selfie, shipping) across sub-stores while clearing campaign scratch fields
Same-tab sub-store navigationSub-stores in multishop journeys always open in the same tab to carry the session ID, avoiding popup blockers and maintaining the shared shopping cart
Stepper removalReplaced discrete bottom stepper with percentage-based progress bar and pure analytics step tracking to support non-linear store flows

Product Type Registry

To completely decouple the storefront flow, state management, and admin UI from hardcoded product types, the system uses an object-oriented Product Type Registry (@zooly/merch-product-types).

Instead of scattered switch statements or conditional checks on type strings (type === 'plaque'), product behavior is defined by subclassing the abstract base class MerchProductTypeDefinition.

Key Capabilities

Each product type defines its behavior by returning a set of static and dynamic capabilities:

  • Size and Shipping Options: requiresSize, requiresShipping, isDigital
  • Flow Steps: skipsDetailSteps (e.g. for general products), requiresText (personalization fields)
  • Design Options: supportsBackPrint, supportsSyntheticMockup
  • UI Hint Configuration:
    • cardImageStrategy (static | designArt | renderedPreview) — how the selection card image is sourced
    • cardImagePadding (normal | tight) — margins around selection preview images
    • cardImageBackground (default | dark) — container theme backgrounds

Subclasses and Implementation

The registry includes five canonical subclasses:

  1. TshirtProductType: Choice of apparel size, physical shipping, rendered previews, and back-print support.
  2. HoodieProductType: Choice of apparel size, physical shipping, rendered previews, and back-print support.
  3. PlaqueProductType: Captures fan personalization text, static previews with tight padding, and physical shipping.
  4. DigitalImageProductType: Instant digital delivery, no shipping, dark image backgrounds, and skips details steps.
  5. GeneralProductType: General physical merch (e.g., posters, hats) that skips details and size selection but has physical shipping and standard mockups.

Adding a New Product Type

The registry completely encapsulates product type variations. To add a new product type, follow this recipe:

  1. Create Subclass: Add a new subclass extending MerchProductTypeDefinition under packages/merch/product-types/src/types/ and export it.
  2. Register: Register the subclass in packages/merch/product-types/src/registry.ts.
  3. Type Union: Add the product type key to MERCH_PRODUCT_TYPES in @zooly/types (packages/types/src/types/Merch.ts).
  4. Database Enum: Add the value to merch_product_type in packages/db/src/schema/merchEnums.ts and run a migration (ALTER TYPE merch_product_type ADD VALUE 'new-type').
  5. i18n Keys: Define the translated labels under default-merch-strings.ts (e.g., product_card_new_type).

No other storefront, checkout, or admin list files need to be modified. The 3-way drift-guard test (registry.test.ts) automatically validates that the registry, the TypeScript union, and the PostgreSQL enum are perfectly in sync.