| 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 (compositing) + 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/db/src/ (Database)
schema/merchTables.ts 14 tables
schema/merchEnums.ts 17 enums
access/merch/ 11 access files
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 |
|---|---|
| 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 |