| Layer | Technology | Location |
|---|---|---|
| Frontend | Vite + React 18 + React Router v7 | apps/zooly-merch/ (thin wrapper) |
| Client logic | React components, hooks, pages | packages/merch/client/src/ |
| Backend API | Next.js 16 API routes | apps/zooly-app/app/api/merch/ |
| Server logic | TypeScript services | packages/merch/srv/src/ |
| Payment integration | Stripe via @zooly/srv-stripe-payment | packages/srv-stripe-payment/ |
| Database | PostgreSQL + Drizzle ORM | packages/db/src/schema/merch*.ts |
| Access layer | Typed functions | packages/db/src/access/merch/ |
| Types | Shared interfaces/enums | packages/types/src/types/Merch.ts |
| Payments (client) | Stripe PaymentElement | Via @stripe/react-stripe-js |
| Image processing | Sharp catalog renderer + FAL.AI generation | packages/merch/srv/src/ |
| SendGrid | packages/merch/srv/src/send-email.ts |
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)
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:
merch.zooly.ai via reverse proxy--merch-* CSS custom properties and primaryColor injection@zooly/merch-client — app is a thin wrapper that mounts the client packageThe pattern follows apps/zooly-ops/ (Vite + React, client-only), not apps/zooly-app/ (Next.js).
| Package | Purpose |
|---|---|
react-router 7.13 | Client-side routing |
@stripe/react-stripe-js ^3.1 | Stripe PaymentElement |
@stripe/stripe-js ^5.5 | Stripe.js loader |
@radix-ui/react-dialog | Lightbox dialog |
@radix-ui/react-select | Country/state dropdowns |
class-variance-authority | Button/component variants |
clsx + tailwind-merge | Class merging |
lucide-react | Icons |
motion | Framer Motion animations |
sonner | Toast notifications |
| Package | Purpose |
|---|---|
vite 6.3 | Build tool |
tailwindcss 4.1 | Utility CSS (via Vite plugin) |
@playwright/test | E2E testing |
| Tool | Location | Purpose |
|---|---|---|
| MerchContext | packages/merch/client/src/context/merch-context.tsx | Campaign config + session state |
| useCart hook | packages/merch/client/src/hooks/use-cart.ts | Multi-item cart operations (add/remove/clear) |
| sessionStorage | merch_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.
All merch API routes are public (no auth). Cross-origin requests are handled by:
cors.ts (route-level): validates origin against ALLOWED_DOMAINS_CORS env varmiddleware.ts (global): intercepts all /api/* requests for preflight OPTIONSAllowed origins: localhost:3008 (dev) and merch.zooly.ai (prod).
| Decision | Rationale |
|---|---|
| Admin V2 builder with URL routing | Zustand store + React Router tabs (Setup, Design, Simulation, Translation, Benchmark) — each sub-tab is URL-addressable |
| Catalog-backed products | Global catalog products attach to shops; designs associate to catalog products through catalogProductIds, so product type is derived from the catalog row |
| 3-level design hierarchy | Design (base) → Catalog Product Variation → Demographic Variation; config merging at each level reduces duplication |
| Campaign normalization into satellite tables | branding/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-payment | Audit 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 properties | Campaign-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 select | AI generation starts before size selection |
| Lifecycle polling every 30s | Balance between freshness and API cost |
| Optimistic updates with re-queue on failure | Responsive UI even with network issues |
| Multi-candidate art selection | Fan picks from all generation candidates, not just the best |
| Multi-item cart via useCart hook | Session-persisted cart with add/remove/clear/add-another |
Reselfie as inline overlay on /create | Re-capture selfie without a separate route or leaving generation/selection context |
?add=1 query param for add-another | Cross-page state for multi-item cart flow |
| Campaign-agnostic journey session | Decouples 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 navigation | Sub-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 removal | Replaced discrete bottom stepper with percentage-based progress bar and pure analytics step tracking to support non-linear store flows |
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.
Each product type defines its behavior by returning a set of static and dynamic capabilities:
requiresSize, requiresShipping, isDigitalskipsDetailSteps (e.g. for general products), requiresText (personalization fields)supportsBackPrint, supportsSyntheticMockupcardImageStrategy (static | designArt | renderedPreview) — how the selection card image is sourcedcardImagePadding (normal | tight) — margins around selection preview imagescardImageBackground (default | dark) — container theme backgroundsThe registry includes five canonical subclasses:
TshirtProductType: Choice of apparel size, physical shipping, rendered previews, and back-print support.HoodieProductType: Choice of apparel size, physical shipping, rendered previews, and back-print support.PlaqueProductType: Captures fan personalization text, static previews with tight padding, and physical shipping.DigitalImageProductType: Instant digital delivery, no shipping, dark image backgrounds, and skips details steps.GeneralProductType: General physical merch (e.g., posters, hats) that skips details and size selection but has physical shipping and standard mockups.The registry completely encapsulates product type variations. To add a new product type, follow this recipe:
MerchProductTypeDefinition under packages/merch/product-types/src/types/ and export it.packages/merch/product-types/src/registry.ts.MERCH_PRODUCT_TYPES in @zooly/types (packages/types/src/types/Merch.ts).merch_product_type in packages/db/src/schema/merchEnums.ts and run a migration (ALTER TYPE merch_product_type ADD VALUE 'new-type').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.