Compare commits

..

8 Commits

Author SHA1 Message Date
36cbbc0aa4 feat(host): editorRegistry — dispatch editor surfaces by product.editorKind
Increment 1 (goods, zero behavior change): mirror template-manager's
editor-neutrality seams on the host so a second editor (e.g. sign-studio)
can be added as an adapter instead of by re-touching mount sites.

- src/editors/editorRegistry.js — getEditorAdapter(editorKind); NO goods
  fallback for an unregistered kind (mirrors the CMS's validator rule).
- src/editors/goods/GoodsCustomizer.jsx — the customer ApparelDesigner
  mount, extracted from CustomizeRoute (goods branding + view/entry
  derivation moved into the adapter).
- src/editors/goods/GoodsAdminEditor.jsx — the former AppRouter
  AdminEditorCanvas, moved verbatim.
- CustomizeRoute + AppRouter renderCanvas now dispatch on
  product.editorKind (default 'goods'). App.jsx confirmed dead (main.jsx
  renders AppRouter), so only 2 live mounts change.

Verified: vite build clean; the /customize customer surface renders and
is interactive on an alternate-port smoke, no React errors. The admin
surface is a verbatim move (build/import-verified; its interactive render
sits behind admin SSO). editorKind resolves to 'goods' until the CMS
surfaces it on the public template/variant response (Increment 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-05 04:29:08 -05:00
khalid@traclabs.com
5c21188df1 server: thread auth into mountEditorApi + apply ALL goods-editor migrations
Closes the coverage-gap audit's sharpest item (H8/D-auth): mountAuth's
return is captured (authGuards) and design-route ownership is passed
explicitly to mountEditorApi (customerId/isAdmin off req.customer/
req.admin — matches the module defaults but survives default drift);
/api/fonts/registered is admin-gated by a host-level requireAdmin
registered ahead of the module route (auth.middleware can't do this:
the module applies it to the whole /designs group, which would lock
guests out of design persistence). mockupsDir/storageDir now threaded
so design-route thumbnails get the mockup backdrop. Dev harness
unchanged: without DATABASE_URL the roster stays open and ownership
resolves to the guest model.

Also fixes a real bug the boot test surfaced: the host hardcoded
001_designs.sql, silently skipping 002_design_family.sql — the store
SELECTs design_family_id unconditionally, so every GET /api/designs
500'd ('column does not exist'). Now applies every *.sql in the
module's migrations dir in filename order (all idempotent).

Verified by booting both modes: auth ON → roster 403 admin_required,
designs unknown-id 404 (was 500), both migrations applied on a fresh
db; auth OFF → roster 200 with the font list, warning logged. NOTE:
goods-editor's ./server export resolves to dist/, which was stale
(built Jul 7 — predating the module's auth option, roster route, and
Aug security fixes); rebuilt from the branch source. Any deploy must
run the module's build, not just npm install the file: dep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 00:20:15 -05:00
khalid@traclabs.com
26f30ce388 admin.css: style the v0.8 inventory admin surfaces
Closes the audit's unstyled-classes item: 48 emitted-but-unstyled
classes across InventoryListPanel (tm-admin-inventory__*, mirroring the
tm-admin-list chrome; stock badge ok/low/out on the success/warning/
danger tokens), InventoryDetailPanel (tm-inventory-detail__* — stock
hero with state-colored count, tx history table with signed deltas,
danger zone) and BulkReceiveModal (tm-bulk-receive__* + the wide modal
card). Class-inventory header updated per the file's own convention;
verified by diffing emitted class names against stylesheet selectors
(only template-literal modifier stems and the tm-meta__field-riding
tm-facet-tags wrapper remain, both intentional).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 00:20:15 -05:00
khalid@traclabs.com
840a5dbcb3 v0.8: auth integration, design persistence (#23), protected sweep (#24)
Server (spans the v0.6→v0.8 arc in one file):
- template-manager mounted with the productsStore catalog, PNG-only
  mockup multer, facet personalization config (§R10, FACET_KEY-gated,
  inert when unset), and the marketing-preview drainer (2s tick, one
  render per tick, .unref()).
- auth-manager bootstrap: DATABASE_URL gates the pg Pool, migrations,
  and mountAuth (magic-link customer + Google SSO admin + facet);
  absent DATABASE_URL logs a warning and the host boots without auth.
- goods-editor design persistence (#23): idempotent 001_designs.sql
  applied at boot, db handle passed to mountEditorApi so /api/designs
  routes mount; without Postgres the client falls back to
  cart-without-design_id.
- reference-protected TTL sweep (#24): before each pass, every
  /uploads/ basename referenced by template-manager JSON or persisted
  designs is collected and protected, and exports/designs (db-driven
  lifecycle, unrecoverable sources) is skipped entirely. Sweep kickoff
  moves below the bootstrap (pgPool TDZ). sweep-worker.mjs is the
  standalone process variant for production.

Client: main.jsx wraps the tree in AuthProvider (single /api/auth/me
fetch shared by useCustomer/useAdmin, degrades on 404) and mounts
AppRouter. eslint bumps server.js to ecmaVersion 2022 (top-level
await); .env.example documents the full v0.8 env surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:29:16 -05:00
khalid@traclabs.com
dfa1f90a16 v0.8: self-hosted fonts replace the Google Fonts CDN link
index.html now loads /api/fonts/fonts.css, synthesized at request time
by goods-editor's mountEditorApi from the host's fonts/ directory
(populated via fetch-fonts in goods-editor-module — the host-local
fetch script shrinks to a pointer). Shipped synchronously in <head>
on purpose: the module's runtime injector races first paint and the
editor's text measurement gives wrong answers against fallback
metrics until real fonts load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:29:04 -05:00
khalid@traclabs.com
6599a5e68f v0.7: shop storefront + routed app
AppRouter replaces the single-page App as the mounted root: shop routes
(/ and /shop → ShopHome, /shop/:productSlug → ProductPage, /cart →
CartStub, /customize → CustomizeRoute) plus the template-manager admin
surfaces (/admin/templates|products|featured|trash, v0.8 adds
/admin/inventory + RequireAdmin wrapping). App.jsx stays on disk one
release as the documented legacy fallback — nothing imports it.

New shop UI: components/shop/ (ShopHome/Hero/ProductGrid/ProductCard/
ProductPage/ProductGallery/ProductInfo/ProductBuyPanel/CartStub/
ShopHeader/TemplateGalleryForProduct), hooks (useShopProducts/
useShopProduct/useCart/useFacetCatalog), and shop.css + admin.css.

Header gains the module-gated Clear button (onCanClearChange /
onElementCountChange mirror the editor module's history-commit
signals); i18n keys follow. vite proxies /products to the Node server
(template-manager serves product imagery — without it the SPA fallback
returns index.html as an image) and adds a template-manager chunk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:28:58 -05:00
khalid@traclabs.com
aba2dbedfb v0.6: template-manager owns the product catalog
The host no longer carries a runtime product catalog. products.js
shrinks to a legacy single-product stub; the canonical catalog lives in
template-manager's productsStore, seeded ONCE on first boot from the
new seedProducts.js (after that the store under templates/ is
authoritative and the seed is ignored — intentional, so restarts never
rewrite an admin-managed catalog). phoneModels.js and
templateCategories.js are the supporting constant sets, and
docs/template-schema.md documents the v4 schema.

templates/ is the admin-managed store itself (_products_index.json +
products/<id>/product.json, JSON only) — committed so the configured
catalog (sizes, pricing, options built up in the admin UI) survives a
fresh checkout rather than regressing to the seed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:28:48 -05:00
khalid@traclabs.com
b5b8808251 deps: local module workspace + v0.8 runtime deps
goods-editor moves from the git+ssh tag to file:../goods-editor-module,
joined by file: deps on template-manager and @khalidadil/auth-manager
(scoped name — published to the Gitea registry as 0.2.0; the file:
protocol stays for local development, the key must match the scoped
name). pg lands for the auth/design-persistence Postgres pool and
react-router-dom for the v0.7 routed app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:28:39 -05:00
52 changed files with 9573 additions and 1165 deletions

View File

@@ -34,6 +34,140 @@ NODE_ENV=development
# FILE_TTL_MS=86400000 # 24 hours
# CLEANUP_INTERVAL_MS=3600000 # 1 hour
# ==================================================================
# auth-manager (v0.8) -- runtime
# ==================================================================
#
# auth-manager mounts customer magic-link + admin Google SSO + the
# account shell. If DATABASE_URL is NOT set, auth-manager is disabled
# gracefully and the rest of the host still boots -- useful when
# working on goods-editor / template-manager features without
# provisioning Postgres locally.
# Postgres connection string. Required to enable auth-manager.
# Examples:
# Local Docker: postgres://postgres:postgres@localhost:5432/apparel
# Dokploy: postgres://<user>:<pass>@<service>:5432/<db>
# DATABASE_URL=postgres://postgres:postgres@localhost:5432/apparel
# Fully-qualified base used to build:
# - the magic-link URL we email to customers (and print to console)
# - the Google OAuth redirect URI for admin SSO
# Must match the redirect URI registered in Google Cloud Console.
# In dev, set to http://localhost:3001. In prod, your storefront URL.
# Falls back to http://localhost:${PORT} if unset (dev only -- won't
# work behind a reverse proxy).
# PUBLIC_BASE_URL=http://localhost:3001
# Comma-separated allowlist of Google accounts that can sign in via
# admin SSO. Two or more entries strongly recommended for break-glass
# recovery (locked decision #4 in v08_Auth_v1.md).
# ADMIN_EMAILS=you@gmail.com,backup@gmail.com
# Google OAuth 2.0 Web client credentials. Required for admin SSO.
# Create in Google Cloud Console -> APIs & Services -> Credentials ->
# Create credentials -> OAuth client ID -> Web application. Authorized
# redirect URI must be ${PUBLIC_BASE_URL}/auth/admin/google/callback.
# GOOGLE_CLIENT_ID=...apps.googleusercontent.com
# GOOGLE_CLIENT_SECRET=...
# Optional: SMTP relay for magic-link emails. If unset, magic links
# ONLY print to the server's stdout (still fully functional for dev
# -- just copy/paste the link from your terminal).
#
# Recommended dev setup: Mailpit (https://mailpit.axllent.org).
# docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit
# then visit http://localhost:8025 to read sent mail.
#
# Resend integration is deferred to v2 -- see v08_Auth_v1.md
# "Email delivery in v1" section for rationale.
# SMTP_HOST=localhost
# SMTP_PORT=1025
# SMTP_USER=
# SMTP_PASS=
# SMTP_FROM=no-reply@localhost
# Optional: cookie domain for cross-subdomain session sharing
# (e.g. '.yourdomain.com' so checkout.yourdomain.com sees the same
# session as yourdomain.com). Usually leave unset -- SureCart's WP
# sidecar uses its own customer identity (passed via metadata at
# checkout creation), not auth-manager's session.
# COOKIE_DOMAIN=
# ==================================================================
# Personalization facet (Project doc §R10) -- runtime
# ==================================================================
#
# Generic "customers filter the shop by a facet" feature. For THIS
# deployment the facet is "Pet Type" (dog/cat/reptile/…); other shops
# configure a different facet, or none. The facet VALUES are managed by
# an admin inside template-manager; only the DEFINITION is set here.
#
# FACET_KEY is the master switch. Leave it UNSET to run a plain,
# unfiltered shop: no sign-in prompt, no template filtering, the editor
# shows all stickers, and the account/admin facet UIs stay hidden.
# The facet's stable key. Setting it turns the feature ON. Becomes the
# facet_key on customer_facet_values rows + the audienceTags scope.
# FACET_KEY=pet_type
# Display label shown in the UI ("Choose your {LABEL}", account heading,
# admin section title).
# FACET_LABEL=Pet Type
# Selection mode: `multi` (default) lets a customer pick several values
# (e.g. dog + cat); `single` restricts to one.
# FACET_SELECTION_MODE=multi
# Require an admin to categorize every template before saving it
# (>=1 facet value, or marked General/universal). Default false. Turn
# this ON whenever the shop filters strictly, so an untagged template
# is never hidden silently. THIS shop: true.
# REQUIRE_TEMPLATE_CATEGORY=true
# Require a signed-in customer to choose at least one value before
# browsing. Default false. THIS shop: false (the prompt nudges but is
# skippable; choosing nothing shows everything).
# REQUIRE_FACET_SELECTION=false
# ==================================================================
# Image upscale service (goods-editor, 2026-06-10) -- runtime
# ==================================================================
#
# Optional REST service that upscales low-resolution customer photos
# before they hit the print render (goods-editor's /api/export
# pipeline). goods-editor reads these variables itself
# (imageUpscalerFromEnv inside mountEditorApi) -- no server.js wiring
# needed; setting UPSCALE_SERVICE_URL is the master switch. Unset =
# feature fully dormant, renders byte-identical to before.
#
# The editor independently FLAGS low-res images to customers/admins
# in the UI regardless of this setting -- the service only improves
# what actually prints. Upscaling is best-effort: if the service is
# down or slow, the render proceeds with the original pixels.
#
# Expected contract (adapt goods-editor's src/server/lib/
# imageUpscaler.js if your service differs -- it's the single point
# of adaptation):
# POST $UPSCALE_SERVICE_URL with JSON
# { "image": "<base64>", "mime": "image/png", "scale": 2 }
# -> 200 with an image/* binary body, OR JSON { "image": "<base64>" }
# Master switch. e.g. a Real-ESRGAN container in the same Dokploy
# project: http://upscaler:5000/upscale
# UPSCALE_SERVICE_URL=
# Optional credential. Sent as 'Authorization: Bearer <key>' unless
# UPSCALE_SERVICE_AUTH_HEADER names a custom header (e.g. X-Api-Key),
# in which case the bare key is sent in that header.
# UPSCALE_SERVICE_API_KEY=
# UPSCALE_SERVICE_AUTH_HEADER=Authorization
# Per-request abort + the service's integer scale ceiling.
# UPSCALE_SERVICE_TIMEOUT_MS=30000
# UPSCALE_SERVICE_MAX_SCALE=4
# ──────────────────────────────────────────────────────────────────
# Build-time — values the `docker compose build` step uses
# ──────────────────────────────────────────────────────────────────

View File

@@ -2,6 +2,12 @@
This document describes the template structure for creating custom t-shirt design templates.
> **Note (2026-07-06):** this doc predates the goods-editor extraction and the
> v4 multi-view template schema. The authoritative element schema lives in
> `goods-editor-module/CLAUDE.md` ("Element schema") and the accepted payload
> shape in `template-manager/src/server/lib/validation.js`. The property
> tables below are still accurate for the fields they list.
## Template Structure
```json
@@ -42,6 +48,27 @@ This document describes the template structure for creating custom t-shirt desig
| `fontFamily`| string| No | Google Font name (default: "DM Sans") |
| `fill` | string | No | Hex color (default: "#0f172a") |
| `rotation` | number | No | Rotation in degrees (default: 0) |
| `arc` | number | No | Curve amount (default: 0 = flat). `> 0` arcs up (rainbow), `< 0` arcs down (smile). See "Curved text" below. |
| `align` | string | No | `left` \| `center` \| `right` (default: `center`; multi-line) |
| `lineHeight` | number | No | Line-box ratio of fontSize (default: 1.2) |
| `letterSpacing` | number | No | Absolute tracking in canvas units (default: 0; suspended while curved) |
#### Curved text (`arc`)
- The editor's Curve slider writes values in **−100..100**; hand-authored
templates should stay in that range.
- The stored value up to **±200** is honored by the renderer and the print
exporter (`ARC_MODEL_MAX` in the goods-editor module, 2026-07-06): the
editor itself *derives* values above 100 when preserving an authored
curve on long customer text whose font size auto-shrank. Don't hand-author
above ±100 — the headroom exists for derived values.
- What is visually preserved across customer edits is the curve's
**density** (`arc · fontSize / width`), not the raw number: the editor
rescales `arc` as text, font, or size changes so the perceived sweep the
template author designed stays constant. Expect saved customer designs to
carry arc values that differ from the authored one.
- `letterSpacing` has no effect while `|arc| > 0.5` (suspension rule); the
stored value survives and reapplies when the text goes flat.
### Image Element

View File

@@ -91,7 +91,9 @@ export default defineConfig([
files: ['server.js'],
extends: [js.configs.recommended],
languageOptions: {
ecmaVersion: 2020,
// 2022 — server.js uses top-level await (auth-manager bootstrap,
// designs migration); 2020 made eslint fail at the parse step.
ecmaVersion: 2022,
globals: globals.node,
sourceType: 'module',
},

View File

@@ -6,9 +6,25 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#fdf2f4" />
<title>Pawfectly Yours — Customize Your Shirt</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700&family=Bebas+Neue&family=Caveat:wght@400;600;700&family=DM+Sans:wght@400;500;700&family=Fira+Sans:wght@400;500;600;700&family=Lato:wght@400;700&family=Lora:wght@400;500;600;700&family=Merriweather:wght@400;700&family=Montserrat:wght@400;600;700&family=Nunito:wght@400;500;600;700;800&family=Open+Sans:wght@400;600;700&family=Oswald:wght@400;500;700&family=Pacifico&family=Playfair+Display:wght@400;600;700&family=Poppins:wght@400;500;600;700&family=Raleway:wght@400;600;700&family=Roboto:wght@400;500;700&family=Roboto+Condensed:wght@400;700&family=Roboto+Slab:wght@400;700&family=Rubik:wght@400;500;600;700&family=Source+Sans+3:wght@400;600;700&family=Space+Mono:wght@400;700&family=Ubuntu:wght@400;500;700&family=Work+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<!--
Fonts are served by the goods-editor module from the host's fonts/
directory (populated by `npm run fetch-fonts` in goods-editor-module).
mountEditorApi synthesizes the @font-face rules at request time
based on whatever .ttf/.otf/.woff2/.woff files are present, so
adding a font is a "drop file + restart" workflow with no code
change here. See goods-editor-module/src/server/fontRegistry.js
for the generator.
We deliberately ship this synchronously in <head> rather than
letting the module inject a <link> at mount time: the module's
runtime injector races first paint, and the editor's font-
dependent text measurement (`measureTextWidth`) gives wrong
answers against fallback metrics until the fonts load. The
host pays a tiny extra TTFB to make first paint correct.
<ApparelDesigner loadFonts={false}> in App.jsx / CustomizeRoute
tells the module not to also inject its own runtime tag.
-->
<link rel="stylesheet" href="/api/fonts/fonts.css">
</head>
<body>
<div id="root"></div>

1751
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,9 @@
"test": "echo 'Editor tests live in the goods-editor module. Run them from there.' && exit 0"
},
"dependencies": {
"goods-editor": "git+ssh://git@git.kadil.dev:222/khalidadil/goods-editor-module.git#v0.2.0-alpha.0",
"goods-editor": "file:../goods-editor-module",
"template-manager": "file:../template-manager",
"@khalidadil/auth-manager": "file:../auth-manager",
"@huggingface/transformers": "^3.4.0",
"canvas": "^3.0.0",
"cors": "^2.8.5",
@@ -22,10 +24,12 @@
"express-rate-limit": "^7.4.0",
"konva": "^10.0.0",
"multer": "^2.0.0",
"pg": "^8.13.0",
"pino": "^9.5.0",
"pino-http": "^10.3.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.1.0",
"react-filerobot-image-editor": "^4.8.1",
"react-konva": "^19.2.3",
"sharp": "^0.33.2",

View File

@@ -1,145 +1,28 @@
#!/usr/bin/env node
/**
* Downloads TTF font files for the Google Fonts used in the editor and saves
* them under ./fonts/. server.js scans that directory at startup and calls
* registerFont() for each file.
* DEPRECATED — fonts now live with goods-editor-module.
*
* Why we do this:
* node-canvas does not pick up Google Fonts automatically. It only knows
* about fonts that are either (a) registered explicitly via registerFont()
* or (b) installed system-wide and discoverable via fontconfig. Without
* this script, the server falls back to whatever Cairo can find for
* "Roboto", "Bebas Neue", etc., which is usually nothing — exports get
* substituted to a generic sans-serif and look nothing like the preview.
* The host used to fetch TTFs into its own fonts/ directory and
* register them at server boot. After the v0.8 module reorg, font
* ownership moved into goods-editor-module:
*
* Source:
* We pull TTFs from the Fontsource jsDelivr CDN. URLs look like
* https://cdn.jsdelivr.net/fontsource/fonts/<slug>@latest/latin-400-normal.ttf
* Slugs are the lowercase, hyphenated family name. The script asks for two
* weights per family (regular + bold) and falls through gracefully if a
* weight isn't published.
* • The TTF files live under goods-editor-module/fonts/.
* • Fetch them with `npm run fetch-fonts` inside goods-editor-module.
* • mountEditorApi (called from the host's server.js) registers them
* with node-canvas at boot and serves them to the browser via
* /api/fonts/fonts.css.
* • The host's index.html loads that stylesheet directly — no more
* Google Fonts CDN, no more host-side TTFs.
*
* Usage:
* npm run fetch-fonts
*
* Re-running is cheap: existing files are skipped unless --force is passed.
* This file is kept as a tombstone so anyone who looks for it
* understands what happened. Safe to `rm scripts/fetch-fonts.mjs`
* in the next housekeeping commit; nothing references it anymore
* (package.json's scripts block was already trimmed during the
* module-split work in May 2026).
*/
import { mkdir, writeFile, access } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const FONTS_DIR = join(ROOT, 'fonts');
const FORCE = process.argv.includes('--force');
// Families used by the editor (FONTS in src/constants/fonts.js + the two
// CSS-level defaults). Keep this list in sync with that file.
const FAMILIES = [
'Roboto',
'Open Sans',
'Lato',
'Montserrat',
'Oswald',
'Raleway',
'Poppins',
'Roboto Condensed',
'Source Sans 3',
'Roboto Slab',
'Merriweather',
'Ubuntu',
'Playfair Display',
'Nunito',
'Rubik',
'Work Sans',
'Lora',
'Fira Sans',
'Barlow',
'Bebas Neue',
'DM Sans',
'Space Mono',
// Display + handwritten faces used by the Pawfectly Yours theme. Pacifico is
// the brand logo face; Caveat is a friendly script fallback exposed in the
// text-tab font picker so users can match the brand voice in their designs.
'Pacifico',
'Caveat',
];
const WEIGHTS = [
{ weight: 400, suffix: 'Regular' },
{ weight: 700, suffix: 'Bold' },
];
const familyToSlug = (family) => family.toLowerCase().replace(/\s+/g, '-');
// Filename convention used by server.js's registration scanner:
// "Family_Name-Variant.ttf" → family = "Family Name", variant = "Variant".
// Underscore-vs-space is so the file system stays portable.
const familyToFileBase = (family) => family.replace(/\s+/g, '_');
async function fileExists(path) {
try {
await access(path, fsConstants.R_OK);
return true;
} catch {
return false;
}
}
async function fetchOne(family, weight, suffix) {
const slug = familyToSlug(family);
const url = `https://cdn.jsdelivr.net/fontsource/fonts/${slug}@latest/latin-${weight}-normal.ttf`;
const dest = join(FONTS_DIR, `${familyToFileBase(family)}-${suffix}.ttf`);
if (!FORCE && await fileExists(dest)) {
return { family, weight, status: 'skipped', dest };
}
const res = await fetch(url);
if (!res.ok) {
return { family, weight, status: 'missing', dest, code: res.status };
}
const buf = Buffer.from(await res.arrayBuffer());
await writeFile(dest, buf);
return { family, weight, status: 'fetched', dest, bytes: buf.length };
}
async function main() {
await mkdir(FONTS_DIR, { recursive: true });
let fetched = 0, skipped = 0, missing = 0;
for (const family of FAMILIES) {
for (const { weight, suffix } of WEIGHTS) {
try {
const r = await fetchOne(family, weight, suffix);
if (r.status === 'fetched') {
fetched++;
console.log(`✓ ${family} ${weight} → ${r.dest} (${(r.bytes / 1024).toFixed(0)} KB)`);
} else if (r.status === 'skipped') {
skipped++;
console.log(`· ${family} ${weight} (already present)`);
} else {
missing++;
console.log(`! ${family} ${weight} HTTP ${r.code} — variant not published`);
}
} catch (err) {
missing++;
console.log(`! ${family} ${weight} ${err.message}`);
}
}
}
console.log(`\nDone. Fetched ${fetched}, skipped ${skipped}, missing ${missing}.`);
if (missing > 0) {
console.log('Missing variants are not fatal — the server will fall back to other registered weights.');
}
}
main().catch((err) => {
console.error('Font fetch failed:', err);
console.error(
'[fetch-fonts] This script has moved to goods-editor-module.\n' +
' Run: (cd ../goods-editor-module && npm run fetch-fonts)'
);
process.exit(1);
});

113
scripts/sweep-worker.mjs Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env node
/**
* Sweep worker. Runs runSweep on a tick — trash hard-delete, asset
* orphans, audit log rotation, marketing preview queue drain.
*
* Usage:
* node scripts/sweep-worker.mjs # one-shot
* node scripts/sweep-worker.mjs --loop # run on interval
*
* Production: run as a separate process (PM2, systemd, etc.) so the
* main API server doesn't lose responsiveness when sweep does heavy
* work (especially preview rendering, which can take seconds per
* template at scale).
*
* For v1 the sweep interval is 60 minutes when run with --loop.
* Adjust via the --interval=<minutes> flag.
*/
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
runSweep,
marketingPreviewRenderer,
createTemplateIndex,
} from 'template-manager/server';
import { PRODUCT_TYPES } from '../src/constants/products.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const STORAGE_PATH = join(__dirname, '..', 'templates');
const args = process.argv.slice(2);
const LOOP = args.includes('--loop');
const intervalArg = args.find((a) => a.startsWith('--interval='));
const INTERVAL_MIN = intervalArg ? parseInt(intervalArg.split('=')[1], 10) : 60;
async function buildRenderer() {
try {
const { renderDesignComposite } = await import('goods-editor/server');
const canvasMod = await import('canvas');
const sharpMod = await import('sharp').catch(() => null);
const factory = marketingPreviewRenderer({
editorRenderer: async (args) => renderDesignComposite({
...args,
canvas: canvasMod.default || canvasMod,
sharp: sharpMod ? (sharpMod.default || sharpMod) : null,
mockupsDir: join(__dirname, '..', 'mockups'),
uploadsDir: join(__dirname, '..', 'uploads'),
stickersDir: join(__dirname, '..', 'public', 'stickers'),
storageDir: STORAGE_PATH,
}),
});
// The factory returns a renderer expecting context. We don't have
// the in-memory marketingPreviewQueue here — that lives in the
// running API server's process. The worker reconciles via the
// metadata.autoStaleSince field: any template with that field
// set is a regen candidate.
return async (templateId) => {
// Skip if no audit log handle in this script — that's mounted
// server-side. The renderer logs failures via the audit log
// when one is provided; without one, errors silently retry on
// the next sweep.
const { createAuditLog } = await import('template-manager/server/lib/auditLog.js').catch(() => ({}));
const auditLog = createAuditLog ? createAuditLog(STORAGE_PATH) : null;
return factory({
templateId,
storagePath: STORAGE_PATH,
variantCatalog: PRODUCT_TYPES,
auditLog,
});
};
} catch (err) {
console.warn('⚠ Renderer not available; sweep will skip preview regen:', err.message);
return null;
}
}
async function runOnce(renderPreview) {
const templateIndex = createTemplateIndex(STORAGE_PATH);
const summary = await runSweep({
storagePath: STORAGE_PATH,
templateIndex,
renderPreview,
// marketingPreviewQueue is intentionally absent — the in-process
// queue lives in the API server. Worker process reconciles via
// metadata.autoStaleSince instead.
marketingPreviewQueue: null,
});
console.log('Sweep:', JSON.stringify(summary, null, 2));
}
async function main() {
const renderPreview = await buildRenderer();
if (!LOOP) {
await runOnce(renderPreview);
process.exit(0);
}
console.log(`▶ Sweep worker loop (interval: ${INTERVAL_MIN} min)`);
const tick = async () => {
try { await runOnce(renderPreview); }
catch (err) { console.error('Sweep tick failed:', err); }
};
await tick();
setInterval(tick, INTERVAL_MIN * 60 * 1000);
}
main().catch((err) => {
console.error('Sweep worker failed:', err);
process.exit(1);
});

402
server.js
View File

@@ -16,7 +16,16 @@
// • POST /api/export — the canvas+sharp render pipeline
// • GET /api/download/:filename — serve exported PNGs
// • GET /api/health — health probe
// • Font registration
// • Font registration with node-canvas, plus GET
// /api/fonts/fonts.css and GET /api/fonts/<filename>
// for browser-side delivery. index.html loads the .css
// directly; the Google Fonts CDN link the host used to
// ship is gone with v0.8.
// • POST/GET/DELETE /api/designs/* — design persistence,
// mounted only when a Postgres `db` handle is passed to
// mountEditorApi. Wired (#23): we pass pgPool when
// DATABASE_URL is set; without it the routes don't mount
// and the client falls back to cart-without-design_id.
import express from 'express';
import cors from 'cors';
@@ -28,10 +37,17 @@ import rateLimit from 'express-rate-limit';
import pino from 'pino';
import pinoHttp from 'pino-http';
import { fileURLToPath } from 'url';
import { dirname, join, resolve, sep } from 'path';
import { mkdirSync, existsSync, readdirSync, statSync, rmSync } from 'fs';
import { basename, dirname, join, resolve } from 'path';
import { mkdirSync, existsSync, readdirSync, readFileSync, statSync, rmSync } from 'fs';
import { mountEditorApi } from 'goods-editor/server';
import { renderDesignComposite } from 'goods-editor/server';
import { mountTemplateStore } from 'template-manager/server';
import { marketingPreviewRenderer } from 'template-manager/server';
import { mountAuth, runMigrations } from '@khalidadil/auth-manager/server';
import pkg from 'pg';
const { Pool } = pkg;
import { SEED_PRODUCTS } from './src/constants/seedProducts.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -53,13 +69,19 @@ app.use(pinoHttp({
// ── Filesystem layout ──────────────────────────────────────────────
const uploadsDir = join(__dirname, 'uploads');
const exportsDir = join(__dirname, 'exports');
const templatesStorageDir = join(__dirname, 'templates');
const fontsDir = join(__dirname, 'node_modules', 'goods-editor', 'fonts');
const stickersDir = IS_PRODUCTION
? join(__dirname, 'dist', 'stickers')
: join(__dirname, 'public', 'stickers');
const uploadsRoot = resolve(uploadsDir);
// (uploadsRoot + `sep` removed — dead since the path-guarded routes
// moved into goods-editor/template-manager; eslint now parses this
// file (ecmaVersion 2022) and flagged them.)
[uploadsDir, exportsDir].forEach((dir) => {
// v0.6 — mockupsDir is gone (variant mockups removed). Product
// mockups live under templatesStorageDir/products/<id>/sizes/<sid>/
// and are managed entirely by template-manager.
[uploadsDir, exportsDir, templatesStorageDir].forEach((dir) => {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
});
@@ -67,36 +89,121 @@ const uploadsRoot = resolve(uploadsDir);
const FILE_TTL_MS = parseInt(process.env.FILE_TTL_MS || '', 10) || 24 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = parseInt(process.env.CLEANUP_INTERVAL_MS || '', 10) || 60 * 60 * 1000;
function cleanupOldFiles(dir, ttlMs) {
if (!existsSync(dir)) return 0;
// Referenced-file exemption (#24) — template JSONs (element srcs,
// slot previewSrc, entry-config imagery) and persisted designs
// (Postgres designs.design_json) reference files as `/uploads/<name>`
// (or `/uploads/preview/<name>`). Those references are long-lived;
// sweeping the files at 24h breaks template renders and design
// re-renders. Before each sweep pass we collect every referenced
// basename (uploads filenames are UUID-based, so basename matching
// is collision-free) and skip deleting those files. A simple regex
// over raw JSON text keeps this robust to schema drift.
const UPLOADS_REF_RE = /\/uploads\/([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)/g;
function collectUploadRefs(text, out) {
UPLOADS_REF_RE.lastIndex = 0;
let m;
while ((m = UPLOADS_REF_RE.exec(text)) !== null) {
out.add(basename(m[1]));
}
}
// Recursively scan a directory's *.json files for /uploads/ references.
// Covers template-manager's whole storage tree: templates/<id>/
// metadata.json + versions/*.json, products/<id>/product.json, and
// the _*.json index docs.
function scanDirForUploadRefs(dir, out) {
if (!existsSync(dir)) return;
let entries = [];
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
const full = join(dir, entry.name);
try {
if (entry.isDirectory()) {
scanDirForUploadRefs(full, out);
} else if (entry.isFile() && entry.name.endsWith('.json')) {
collectUploadRefs(readFileSync(full, 'utf8'), out);
}
} catch { /* unreadable entry — skip */ }
}
}
// Built fresh once per sweep pass (templates/designs change between
// passes; a single Set per pass is cache enough).
async function collectReferencedUploads() {
const referenced = new Set();
try { scanDirForUploadRefs(templatesStorageDir, referenced); } catch { /* never block the sweep */ }
// Persisted designs — only when Postgres is wired. Any db error
// (table not migrated yet, connection down) skips silently: the
// sweep must never crash the host.
if (pgPool) {
try {
const { rows } = await pgPool.query('SELECT design_json FROM designs WHERE deleted_at IS NULL');
for (const row of rows) {
collectUploadRefs(JSON.stringify(row.design_json), referenced);
}
} catch { /* db unavailable — protect what templates reference */ }
}
return referenced;
}
function cleanupOldFiles(dir, ttlMs, protectedNames = null, skipDirs = null) {
if (!existsSync(dir)) return { removed: 0, protectedCount: 0 };
const now = Date.now();
let removed = 0;
let protectedCount = 0;
let entries = [];
try { entries = readdirSync(dir); } catch { return 0; }
try { entries = readdirSync(dir); } catch { return { removed, protectedCount }; }
for (const entry of entries) {
const full = join(dir, entry);
try {
const stat = statSync(full);
if (stat.isFile() && now - stat.mtimeMs > ttlMs) {
if (protectedNames && protectedNames.has(entry)) {
protectedCount++;
} else {
rmSync(full);
removed++;
}
} else if (stat.isDirectory()) {
removed += cleanupOldFiles(full, ttlMs);
if (skipDirs && skipDirs.has(resolve(full))) continue;
const sub = cleanupOldFiles(full, ttlMs, protectedNames, skipDirs);
removed += sub.removed;
protectedCount += sub.protectedCount;
}
} catch { /* skip */ }
}
return removed;
return { removed, protectedCount };
}
function runCleanup() {
const u = cleanupOldFiles(uploadsDir, FILE_TTL_MS);
const e = cleanupOldFiles(exportsDir, FILE_TTL_MS);
if (u > 0 || e > 0) {
logger.info({ removedUploads: u, removedExports: e, ttlMs: FILE_TTL_MS }, 'Cleanup pass complete');
// exports/designs is goods-editor's designsDir (mountEditorApi default:
// <exportsDir>/designs). It holds persisted design artifacts — source
// images, thumbnails, print renders — whose lifecycle is db-driven
// (designs.deleted_at soft-delete, 30-day guest TTL), not this sweep's
// 24h TTL. Source images in particular are unrecoverable, so the sweep
// skips the whole subtree.
const designsArtifactsDir = resolve(join(exportsDir, 'designs'));
async function runCleanup() {
try {
const referenced = await collectReferencedUploads();
const u = cleanupOldFiles(uploadsDir, FILE_TTL_MS, referenced);
const e = cleanupOldFiles(exportsDir, FILE_TTL_MS, null, new Set([designsArtifactsDir]));
if (u.removed > 0 || e.removed > 0 || u.protectedCount > 0) {
logger.info({
removedUploads: u.removed,
removedExports: e.removed,
protectedUploads: u.protectedCount,
ttlMs: FILE_TTL_MS,
}, 'Cleanup pass complete');
}
} catch (err) {
logger.warn({ err: err.message }, 'Cleanup pass failed; will retry next interval');
}
}
runCleanup();
setInterval(runCleanup, CLEANUP_INTERVAL_MS).unref();
// NOTE: the initial run + interval start AFTER the auth-manager
// bootstrap below — collectReferencedUploads reads `pgPool`, which is
// declared there (TDZ until then).
// ── App-level middleware (CORS, JSON, rate limits) ─────────────────
const corsOptions = IS_PRODUCTION
@@ -106,6 +213,116 @@ app.use(cors(corsOptions));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// ── Facet personalization config (Project doc §R10) ──────────────
// Env-driven, host-injected into both module mounts. FACET_KEY is the
// master switch — unset ⇒ the feature is inert (facet-config reports
// disabled, the account routes no-op, templates are unfiltered, and the
// editor shows all stickers). The facet *values* (dog/cat/…) are
// admin-managed inside template-manager; only the definition is here.
const envBool = (v, dflt = false) => (
v === undefined || v === null || v === ''
? dflt
: /^(1|true|yes|on)$/i.test(String(v).trim())
);
const facetConfig = {
key: (process.env.FACET_KEY || '').trim() || null,
label: (process.env.FACET_LABEL || '').trim() || null,
selectionMode: process.env.FACET_SELECTION_MODE === 'single' ? 'single' : 'multi',
requireSelection: envBool(process.env.REQUIRE_FACET_SELECTION, false),
};
const requireTemplateCategory = envBool(process.env.REQUIRE_TEMPLATE_CATEGORY, false);
if (facetConfig.key) {
logger.info({
key: facetConfig.key,
label: facetConfig.label,
selectionMode: facetConfig.selectionMode,
requireSelection: facetConfig.requireSelection,
requireTemplateCategory,
}, 'Personalization facet enabled');
}
// ── auth-manager bootstrap ─────────────────────────────────────────
// Runs migrations + mounts auth routes if DATABASE_URL is set. If
// it isn't, we log a warning and continue without auth so a developer
// can still iterate on goods-editor / template-manager pieces without
// provisioning Postgres locally.
let pgPool = null;
// mountAuth's returned guards (requireAdmin etc.) — captured so they can
// be threaded into other module mounts below. Null when auth is disabled.
let authGuards = null;
if (process.env.DATABASE_URL) {
pgPool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
await runMigrations(pgPool, { logger });
authGuards = mountAuth(app, {
db: pgPool,
apiBase: '/api',
callbackBaseUrl: process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`,
smtp: process.env.SMTP_HOST ? {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
from: process.env.SMTP_FROM,
} : undefined,
fromAddress: process.env.SMTP_FROM || 'no-reply@example.com',
appName: 'Pawfectly Yours',
googleClientId: process.env.GOOGLE_CLIENT_ID,
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
adminEmails: (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim()).filter(Boolean),
cookieDomain: process.env.COOKIE_DOMAIN || undefined,
isProduction: IS_PRODUCTION,
// Facet personalization (Project doc §R10) — scopes the
// customer_facet_values rows + adds facetValues to /me. Inert
// when facetConfig.key is null.
facet: facetConfig,
logger,
});
} catch (err) {
logger.error({ err: err.message }, 'auth-manager bootstrap failed; auth disabled');
try { await pgPool.end(); } catch { /* ignore */ }
pgPool = null;
authGuards = null;
}
} else {
logger.warn('DATABASE_URL not set — auth-manager disabled. Set DATABASE_URL in .env to enable customer accounts and admin SSO.');
}
// ── goods-editor designs migrations (#23) ──────────────────────────
// The module documents that the HOST applies its designs schema before
// passing `db` to mountEditorApi. Every file in its migrations/ dir is
// idempotent (CREATE TABLE/ADD COLUMN IF NOT EXISTS), so we apply ALL
// of them in filename order — hardcoding 001 silently skipped
// 002_design_family.sql when the module grew it (the store SELECTs
// design_family_id unconditionally → every GET /api/designs 500'd).
// With the file: dep the SQL is reachable through node_modules; a
// packed install ships dist/ only, so a missing dir logs a warning
// instead of crashing — the /designs routes still mount and fail
// loudly until the schema is applied by hand.
if (pgPool) {
const designsMigrationsDir = join(
__dirname, 'node_modules', 'goods-editor', 'src', 'server', 'migrations',
);
try {
if (existsSync(designsMigrationsDir)) {
const files = readdirSync(designsMigrationsDir).filter((f) => f.endsWith('.sql')).sort();
for (const file of files) {
await pgPool.query(readFileSync(join(designsMigrationsDir, file), 'utf8'));
}
logger.info({ files }, 'goods-editor designs migrations applied');
} else {
logger.warn({ path: designsMigrationsDir }, 'goods-editor migrations dir not found — apply its *.sql manually before using design persistence');
}
} catch (err) {
logger.error({ err: err.message }, 'goods-editor designs migrations failed — POST /api/designs will error until the schema is applied');
}
}
// TTL sweep kickoff (#24) — here rather than at the definition site
// because collectReferencedUploads reads pgPool.
runCleanup();
setInterval(runCleanup, CLEANUP_INTERVAL_MS).unref();
const uploadLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 30,
@@ -149,6 +366,26 @@ const upload = multer({
limits: { fileSize: 20 * 1024 * 1024 },
});
// Separate multer instance for variant mockup uploads. The template-
// manager's variants route calls `.single('file')` on this and then
// renames `req.file.path` to <mockupsPath>/<productType>/<variant>.png
// inside the route handler. We use disk storage with a temp filename
// in the host's uploadsDir; the rename moves it to its final mockups
// location. Restricted to PNG since that's the file extension the
// route writes — a JPEG/WebP source would end up with a misleading
// .png extension on disk.
const mockupUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, _file, cb) => cb(null, `mockup-${uuidv4()}.tmp`),
}),
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'image/png') cb(null, true);
else cb(new Error('Only PNG mockups are accepted.'));
},
limits: { fileSize: 10 * 1024 * 1024 },
});
app.post('/api/upload', uploadLimiter, upload.single('image'), async (req, res) => {
try {
if (!req.file) {
@@ -184,6 +421,23 @@ app.post('/api/upload', uploadLimiter, upload.single('image'), async (req, res)
});
// ── Module-owned: editor API (export, download, health) ────────────
// v0.8 (#23) — `db` gates the design-persistence routes (POST/GET/
// DELETE /api/designs…). pgPool is null without DATABASE_URL (or when
// the auth bootstrap failed), in which case the routes simply don't
// mount and the client's persistCurrentDesign 404s into its graceful
// cart-without-design_id fallback.
//
// Font-registration roster: ops-diagnosis data, admin-only when auth is
// live. Registered BEFORE mountEditorApi so this guard runs ahead of the
// module's own GET /api/fonts/registered handler (non-admins 403, admins
// fall through). We deliberately do NOT pass auth.middleware into
// mountEditorApi for this — the module applies that same middleware to
// the whole /designs group, and a requireAdmin there would lock guests
// out of design persistence (guest rows are a designed-in feature).
if (authGuards) {
app.use('/api/fonts/registered', authGuards.requireAdmin);
}
mountEditorApi(app, {
canvas,
sharp,
@@ -191,10 +445,124 @@ mountEditorApi(app, {
fontsDir,
stickersDir,
uploadsDir,
// Coverage-gap audit items: the design routes' thumbnail generator
// wants the mockup/template roots (previously only passed to
// renderDesignComposite for marketing previews, not here).
mockupsDir: templatesStorageDir,
storageDir: templatesStorageDir,
basePath: '/api',
logger,
db: pgPool,
// Design-route ownership (coverage-gap audit, sharpest item): owned
// rows 404 for non-owners, admins bypass. These match the module's
// documented defaults but are passed explicitly so the enforcement is
// visible here and survives any future change to those defaults.
// req.customer / req.admin are populated by auth-manager's
// authMiddleware, which mountAuth app.use()s ABOVE this mount; when
// auth is disabled both resolve null/false = the open dev harness.
auth: {
customerId: (req) => req.customer?.id ?? null,
isAdmin: (req) => !!req.admin,
},
});
// Template store (admin + public). adminAuth pass-through for v1
// per the plan; v1.5 swaps in a real middleware via server/lib/auth.js.
//
// v0.6 — the host no longer passes a variantCatalog or phoneModels.
// template-manager owns the catalog (productsStore) and accepts a
// seedProducts array used on first boot only. After the first boot,
// the catalog lives in `<templatesStorageDir>/_products_index.json`
// + `<templatesStorageDir>/products/<id>/product.json` and the seed
// is ignored even if it changes (intentional: prevents unintended
// catalog rewrites on server restart).
const templateStore = mountTemplateStore(app, {
storagePath: templatesStorageDir,
uploadsPath: uploadsDir,
adminAuth: (req, res, next) => next(),
seedProducts: SEED_PRODUCTS,
multer: mockupUpload,
logger,
basePath: '/api',
// Facet personalization (Project doc §R10) — server-enforced template
// categorization gate + the facet definition exposed via
// GET /api/public/facet-config. Both inert when facetConfig.key null.
requireTemplateCategory,
facet: facetConfig,
onTemplateChange: (templateId, kind) => {
logger.info({ templateId, kind }, 'Template changed');
// TODO (v0.4): trigger SSG rebuild for the affected template's
// shop page. v1 no-op; client re-fetches anyway.
},
});
// ── Marketing preview drainer ──────────────────────────────
//
// Periodically pulls templateIds off the marketing-preview queue and
// renders preview.png for each. The queue is in-memory and populated
// by (a) version creates, (b) variant mockup uploads, and (c) explicit
// regenerate clicks from the admin UI.
//
// Without this drainer, those enqueues accumulate silently — the
// server sets `autoStaleSince` on the metadata but nothing ever
// clears it, so the UI's "regenerating…" badge would stick forever.
//
// Implementation notes
// ────────────────────
// • The renderer factory takes goods-editor's `renderDesignComposite`
// as `editorRenderer`. We wrap it here to close over the host's
// `canvas` module + the mockups/uploads/storage dirs, since
// marketingPreviewRenderer calls editorRenderer with only the
// design data (no native deps).
// • Interval is 2s for snappy admin feedback. Render time on a
// typical 800×800 composite is well under a second on a modern
// laptop, so even a quick reload after "Save Version" should
// find a fresh preview waiting.
// • We drain ONE item per tick so a burst of enqueues doesn't pin
// the event loop. Multiple items just get processed over multiple
// ticks.
// • .unref() the timer so it doesn't keep the process alive at
// shutdown.
const renderOne = marketingPreviewRenderer({
editorRenderer: (designArgs) => renderDesignComposite({
...designArgs,
canvas,
sharp,
// v0.6 — mockups now live under templatesStorageDir/products/<id>/
// sizes/<sid>/mockup.png. renderDesignComposite picks the right
// path from the resolved variant's `mockupPath` field. We pass
// templatesStorageDir as a parent for path resolution.
mockupsDir: templatesStorageDir,
uploadsDir,
stickersDir,
storageDir: templatesStorageDir,
}),
});
const PREVIEW_DRAIN_INTERVAL_MS = parseInt(
process.env.PREVIEW_DRAIN_INTERVAL_MS || '',
10,
) || 2000;
async function drainPreviewQueue() {
const id = templateStore.marketingPreviewQueue.dequeue();
if (!id) return;
try {
await renderOne({
templateId: id,
storagePath: templatesStorageDir,
// v0.6 — productsStore replaces variantCatalog for variant lookup.
productsStore: templateStore.productsStore,
auditLog: templateStore.auditLog,
});
logger.info({ templateId: id }, 'Marketing preview rendered');
} catch (err) {
logger.error({ templateId: id, err: err.message }, 'Marketing preview render failed');
}
}
setInterval(drainPreviewQueue, PREVIEW_DRAIN_INTERVAL_MS).unref();
// ── 404 + SPA catch-alls ───────────────────────────────────────────
app.all('/api/*', (_req, res) => {
res.status(404).json({ error: 'API route not found' });

View File

@@ -2,6 +2,15 @@
// (the original editor) to ~150 lines (mount the module + host chrome
// + cart state + toast surface).
//
// Routing status (v0.7+)
// ──────────────────────
// This file is NOT mounted by AppRouter as of v0.7. Both `/` and
// `/customize?template=<id>` now route to ShopHome and
// CustomizeRoute respectively. App.jsx is kept on disk one release
// for safety — nothing imports it, but if a deploy ever needs the
// legacy single-route fallback it's here. Slated for deletion once
// the deployment migration is confirmed.
//
// What stayed in the host
// ───────────────────────
// • Site header (logo, preview, download, share, cart icon + badge)
@@ -70,6 +79,24 @@ function App() {
const editorRef = useRef(null);
const [isExporting, setIsExporting] = useState(false);
// v0.8 — module-derived signals for header button gating.
//
// The editor module emits two coarse callbacks at history-commit
// boundaries (drag-end, add, delete, etc.):
//
// onElementCountChange(n) — current count of elements
// onCanClearChange(canClear) — whether the canvas has stuff
// to clear (count > 0 OR a
// template is loaded)
//
// We mirror them into local state so the Header can disable
// Preview/Download on empty canvases and grey out Clear when
// there's nothing destructive to do. Both fire once on mount with
// the initial state, so the header buttons start in the right
// shape without a separate effect.
const [elementCount, setElementCount] = useState(0);
const [canClear, setCanClear] = useState(false);
// ── Toast surface ───────────────────────────────────────────────
// See src/components/Toast.jsx for the renderer + architecture
// docblock. Shape: { message, kind, id } | null.
@@ -116,15 +143,11 @@ function App() {
// ── Editor action proxies ───────────────────────────────────────
// Preview and Download both fire the editor's export pipeline.
//
// No empty-canvas guard here, even though it would be friendlier
// than letting the pipeline render a blank PNG. The host doesn't
// own the elements array (it lives inside the module), so checking
// emptiness from out here would require the module to emit a
// count via callback. That's a module API extension worth doing
// alongside the Clear button reintroduction — both want the same
// "host can query editor state" pattern. Until then, the worst
// case is the user gets an empty PNG in Downloads.
// The Header already disables both buttons when isExporting is
// true OR elementCount === 0 (the latter sourced from
// onElementCountChange below), so the proxies themselves stay
// minimal — fire the imperative method on the editor ref and
// let the module's export pipeline take it from there.
const handleHeaderPreview = useCallback(() => {
editorRef.current?.preview();
}, []);
@@ -133,6 +156,16 @@ function App() {
editorRef.current?.testDownload();
}, []);
// The module's clear() owns the two-stage confirm flow (first call
// arms with a toast, second within 3s commits) so the host's job
// is just to fire the trigger. The Header has already guarded the
// call site with `canClear` + `!isExporting`, but ref.current may
// still be null during the brief window before the module mounts,
// hence the optional chain.
const handleHeaderClear = useCallback(() => {
editorRef.current?.clear();
}, []);
// ── Share handler ───────────────────────────────────────────────
// Three-level fallback: navigator.share (native OS share sheet,
// ideal on mobile + recent desktop), then navigator.clipboard
@@ -287,12 +320,17 @@ function App() {
onTestDownload={handleHeaderTestDownload}
isExporting={isExporting}
onPreview={handleHeaderPreview}
onClear={handleHeaderClear}
canClear={canClear}
elementCount={elementCount}
/>
<main style={{ flex: 1, minHeight: 0 }}>
<ApparelDesigner
ref={editorRef}
onExportingChange={setIsExporting}
onElementCountChange={setElementCount}
onCanClearChange={setCanClear}
shirtColors={SHIRT_COLORS}
shirtSizes={SHIRT_SIZES}
basePrice={SHIRT_BASE_PRICE_USD}

397
src/AppRouter.jsx Normal file
View File

@@ -0,0 +1,397 @@
// Top-level routed app. Wraps the existing single-page App in a
// router + TemplateStoreProvider so admin + future shop routes can
// live alongside the editor.
//
// v0.8 routes
// ───────────
// /auth/login → LoginRoute (auth-manager)
// /admin/login → AdminLoginPage (auth-manager)
// /account → AccountShell (auth-manager)
// /account/addresses → AccountAddressesPage (auth-manager)
// /admin/inventory → InventoryListPanel
// /admin/inventory/:id → InventoryDetailPanel
// (admin routes are now wrapped in <RequireAdmin> from auth-manager)
// (other routes unchanged from v0.7)
//
// v0.7 routes
// ───────────
// / → ShopHome (replaces legacy App.jsx)
// /shop → ShopHome (alias)
// /shop/:productSlug → ProductPage
// /cart → CartStub
// /customize → CustomizeRoute
// /admin/templates → TemplateListPanel + CreateTemplateModal
// /admin/templates/:id → TemplateEditorPanel
// /admin/products → ProductListPanel
// /admin/products/:id → ProductEditorPanel
// /admin/variants → VariantMockupPanel (retired stub)
// /admin/featured → FeaturedOrderPanel
// /admin/trash → TemplateTrashPanel
// * → ShopHome (SPA fallback)
//
// v0.8 changes
// ────────────
// • Inventory admin surfaces (list + detail) wired in for the new
// material-kind catalog. The list reads ?productId from window
// location for deep-linking from a material product's editor.
// • Per-option pricing rolls up into ProductBuyPanel's unit price;
// no routing changes — the buy panel sums priceCents internally.
// • Auth surface (auth-manager) mounts at /auth/login, /admin/login,
// and /account/*. Admin routes are now wrapped in <RequireAdmin>
// for the client guard; server-side, the API endpoints still need
// requireAdmin middleware once template-manager + commerce-module
// wire it in.
//
// v0.7 changes
// ────────────
// • Root `/` now serves the shop home, not the legacy App.jsx.
// App.jsx stays on disk one release for safety but is no longer
// routed. Existing /customize?template=<id> deep links continue
// to work via CustomizeRoute.
// • /shop/:productSlug mounts the customer-facing product detail.
// Both customizable and fixed kinds render here; the buy panel
// forks on kind internally.
// • /cart shows a localStorage-backed cart preview. "Proceed to
// checkout" surfaces "Checkout coming soon" — checkout is
// downstream.
//
// v0.6 changes (retained context)
// ───────────────────────────────
// • CreateTemplateModal pulls products from the API now.
// • AdminEditorCanvas reads template.variant directly.
// • TemplateStoreProvider no longer receives variantCatalog or
// phoneModels — template-manager owns the catalog.
import { useState, useCallback } from 'react';
import { BrowserRouter, Routes, Route, Link, useNavigate, useParams, useLocation } from 'react-router-dom';
import { DEFAULT_STICKER_CATEGORIES } from 'goods-editor';
import 'goods-editor/styles.css';
import {
TemplateStoreProvider,
TemplateListPanel,
TemplateEditorPanel,
TemplateTrashPanel,
VariantMockupPanel,
FeaturedOrderPanel,
CreateTemplateModal,
ProductListPanel,
ProductEditorPanel,
CreateProductModal,
// v0.8 — inventory admin panels.
InventoryListPanel,
InventoryDetailPanel,
useAdminTemplates,
useAdminProducts,
// Facet personalization (Project doc §R10).
FacetValueManager,
} from 'template-manager';
import 'template-manager/styles.css';
// v0.8 — auth surface.
import {
LoginForm,
AdminLoginPage,
RequireAdmin,
AccountShell,
AccountHomePage,
AccountAddressesPage,
// Facet personalization (Project doc §R10).
FacetPrompt,
AccountFacetPage,
useFacetConfig,
} from '@khalidadil/auth-manager';
import './styles/admin.css';
import './styles/shop.css';
import CustomizeRoute from './CustomizeRoute';
import { ShopHome } from './components/shop/ShopHome';
import { ProductPage } from './components/shop/ProductPage';
import { CartStub } from './components/shop/CartStub';
import { DEFAULT_PRODUCT_ID, DEFAULT_SIZE_ID } from './constants/seedProducts';
import { getEditorAdapter } from './editors/editorRegistry.js';
// URL builders — single source of truth for the URL shape so the
// rest of the app uses these instead of hand-building strings.
const customizeUrl = (templateId) => templateId ? `/customize?template=${encodeURIComponent(templateId)}` : '/customize';
const adminTemplateUrl = (templateId) => `/admin/templates/${encodeURIComponent(templateId)}`;
const adminTemplateListUrl = '/admin/templates';
const adminProductListUrl = '/admin/products';
const adminProductUrl = (productId) => `/admin/products/${encodeURIComponent(productId)}`;
// v0.8 — inventory admin URLs.
const adminInventoryListUrl = '/admin/inventory';
const adminInventoryUrl = (variantId) => `/admin/inventory/${encodeURIComponent(variantId)}`;
// v0.6 — shop URL now keyed by product slug (or id; both work since
// public template GET embeds the product). Existing shop pages keyed
// by productTypeId continue to work because legacy slugs match the
// new product slugs ("shirt", "magnet", …).
const shopTemplateUrl = (productSlug, templateId) =>
`/shop/${encodeURIComponent(productSlug)}/${encodeURIComponent(templateId)}`;
function AdminShell({ children }) {
// Facet personalization (Project doc §R10) — show the manager link
// only when a facet is configured; label comes from the config.
const { config: facetConfig } = useFacetConfig();
return (
<div className="admin-shell">
<nav className="admin-shell__nav">
<div className="admin-shell__brand">Template Admin</div>
<div className="admin-shell__links">
<Link to={adminProductListUrl} className="admin-shell__link">Products</Link>
<Link to={adminTemplateListUrl} className="admin-shell__link">Templates</Link>
{facetConfig.enabled && (
<Link to="/admin/facets" className="admin-shell__link">{facetConfig.label || 'Facet'}</Link>
)}
{/* v0.8 — inventory entry point. Sits next to Products since
it operates on the material-kind subset of the catalog. */}
<Link to={adminInventoryListUrl} className="admin-shell__link">Inventory</Link>
<Link to="/admin/featured" className="admin-shell__link">Featured</Link>
<Link to="/admin/trash" className="admin-shell__link">Trash</Link>
</div>
<Link to="/" className="admin-shell__back">← Back to site</Link>
</nav>
<main className="admin-shell__main">{children}</main>
</div>
);
}
// AdminEditorCanvas moved to src/editors/goods/GoodsAdminEditor.jsx
// (goods adapter; dispatched via editorRegistry — see renderCanvas above).
// ── /admin/templates — list + create flow ──────────────────────
function AdminTemplateListRoute() {
const navigate = useNavigate();
const { createTemplate } = useAdminTemplates();
const [createOpen, setCreateOpen] = useState(false);
const handleCreate = async (payload) => {
const created = await createTemplate(payload);
setCreateOpen(false);
navigate(adminTemplateUrl(created.id));
return created;
};
return (
<AdminShell>
<TemplateListPanel onCreateClick={() => setCreateOpen(true)} />
{createOpen && (
<CreateTemplateModal
defaultProductId={DEFAULT_PRODUCT_ID}
defaultSizeId={DEFAULT_SIZE_ID}
onConfirm={handleCreate}
onClose={() => setCreateOpen(false)}
/>
)}
</AdminShell>
);
}
// ── /admin/templates/:id — editor ──────────────────────────────
function AdminTemplateEditorRoute() {
const { id } = useParams();
return (
<AdminShell>
<TemplateEditorPanel
templateId={id}
renderCanvas={(canvasProps) => {
// Editor dispatch — pick the admin editing surface by the
// template's product editorKind (default 'goods'). Unknown kind
// ⇒ a clear placeholder, never a silent goods substitution.
const editorKind = canvasProps?.metadata?.editorKind || 'goods';
const AdminEditor = getEditorAdapter(editorKind)?.AdminEditor || null;
return AdminEditor
? <AdminEditor {...canvasProps} />
: <p className="tm-editor__placeholder">No editor registered for “{editorKind}”.</p>;
}}
onMarketingRegenerate={async (templateId) => {
try {
const res = await fetch(
`/api/templates/${encodeURIComponent(templateId)}/marketing-preview/regenerate`,
{ method: 'POST' },
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error('Regenerate failed:', body.error || res.status);
}
} catch (err) {
console.error('Regenerate request failed:', err);
}
}}
/>
</AdminShell>
);
}
// ── /admin/products — list + create flow ───────────────────────
function AdminProductListRoute() {
const navigate = useNavigate();
const { createProduct } = useAdminProducts();
const [createOpen, setCreateOpen] = useState(false);
// Two-step create: POST product, then optionally POST first size.
// We POST the size via a raw fetch rather than wiring useProduct
// here — the modal returns to the parent before the product page
// is mounted, so there's no useProduct instance yet. A direct fetch
// is simpler than coordinating the timing.
const handleCreate = async ({ product, firstSize }) => {
const created = await createProduct(product);
if (firstSize) {
try {
const res = await fetch(`/api/products/${encodeURIComponent(created.id)}/sizes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(firstSize),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
// Don't fail the whole flow — the product was created;
// just surface the size error in the console. Admin will
// see "no sizes" on the product page and can add one.
console.warn('First-size create failed:', body.error || res.status);
}
} catch (err) {
console.warn('First-size create network error:', err.message);
}
}
setCreateOpen(false);
navigate(adminProductUrl(created.id));
return created;
};
return (
<AdminShell>
<ProductListPanel onCreateClick={() => setCreateOpen(true)} />
{createOpen && (
<CreateProductModal
onConfirm={handleCreate}
onClose={() => setCreateOpen(false)}
/>
)}
</AdminShell>
);
}
// ── /admin/products/:id — editor ───────────────────────────────
function AdminProductEditorRoute() {
const { id } = useParams();
return (
<AdminShell>
<ProductEditorPanel productId={id} />
</AdminShell>
);
}
// v0.8 — /admin/inventory — list.
//
// InventoryListPanel reads ?productId from window.location.search on
// mount, so the "View inventory variants →" jump-link inside the
// material-product editor deep-links into a pre-filtered list. No
// extra plumbing needed here.
function AdminInventoryListRoute() {
return (
<AdminShell>
<InventoryListPanel />
</AdminShell>
);
}
// v0.8 — /admin/inventory/:id — single-variant detail.
function AdminInventoryDetailRoute() {
const { id } = useParams();
return (
<AdminShell>
<InventoryDetailPanel variantId={id} />
</AdminShell>
);
}
// Facet personalization (Project doc §R10) — /admin/facets — manage the
// facet vocabulary + the sticker-category→value assignment map. Sticker
// categories come from goods-editor's DEFAULT_STICKER_CATEGORIES.
function AdminFacetRoute() {
return (
<AdminShell>
<FacetValueManager availableStickerCategories={DEFAULT_STICKER_CATEGORIES} />
</AdminShell>
);
}
// v0.8 — small wrapper to pull `next` out of the URL search before
// handing it to LoginForm. LoginForm itself stays prop-driven so it
// can be reused in non-router contexts later.
function LoginRoute() {
const location = useLocation();
const params = new URLSearchParams(location.search);
const next = params.get('next') || undefined;
return (
<div className="shop-page">
<LoginForm next={next} appName="Pawfectly Yours" />
</div>
);
}
export default function AppRouter() {
return (
<BrowserRouter>
<TemplateStoreProvider
customizeUrl={customizeUrl}
adminTemplateUrl={adminTemplateUrl}
adminTemplateListUrl={adminTemplateListUrl}
adminProductListUrl={adminProductListUrl}
adminProductUrl={adminProductUrl}
adminInventoryListUrl={adminInventoryListUrl}
adminInventoryUrl={adminInventoryUrl}
shopTemplateUrl={shopTemplateUrl}
Link={Link}
useNavigate={useNavigate}
isAdmin
apiBase="/api"
>
{/* Facet personalization (Project doc §R10) — one-time dismissable
sign-in nudge for customers with no selection. Renders nothing
when no facet is configured / customer signed out / already
chosen. Inside AuthProvider (from main.jsx) so it sees auth. */}
<FacetPrompt apiBase="/api" />
<Routes>
<Route path="/" element={<ShopHome />} />
<Route path="/shop" element={<ShopHome />} />
<Route path="/shop/:productSlug" element={<ProductPage />} />
<Route path="/cart" element={<CartStub />} />
<Route path="/customize" element={<CustomizeRoute />} />
{/* v0.8 — auth + account */}
<Route path="/auth/login" element={<LoginRoute />} />
<Route path="/admin/login" element={<AdminLoginPage />} />
<Route path="/account" element={<AccountShell />}>
<Route index element={<AccountHomePage />} />
<Route path="addresses" element={<AccountAddressesPage />} />
{/* Facet personalization (Project doc §R10) — AccountFacetPage
hides itself when no facet is configured. */}
<Route path="facet" element={<AccountFacetPage />} />
{/* /account/designs lands once goods-editor-module exports a
MyDesignsPage component (Phase 2 / v08_GoodsEditor_v1).
/account/orders lands with commerce-module. */}
</Route>
{/* Admin — wrapped in RequireAdmin (client guard). Server-
side, the API endpoints still need requireAdmin
middleware once template-manager + commerce-module wire
it in. */}
<Route path={adminTemplateListUrl} element={<RequireAdmin><AdminTemplateListRoute /></RequireAdmin>} />
<Route path="/admin/templates/:id" element={<RequireAdmin><AdminTemplateEditorRoute /></RequireAdmin>} />
<Route path={adminProductListUrl} element={<RequireAdmin><AdminProductListRoute /></RequireAdmin>} />
<Route path="/admin/products/:id" element={<RequireAdmin><AdminProductEditorRoute /></RequireAdmin>} />
{/* v0.8 — inventory routes. */}
<Route path={adminInventoryListUrl} element={<RequireAdmin><AdminInventoryListRoute /></RequireAdmin>} />
<Route path="/admin/inventory/:id" element={<RequireAdmin><AdminInventoryDetailRoute /></RequireAdmin>} />
{/* Facet personalization (Project doc §R10). */}
<Route path="/admin/facets" element={<RequireAdmin><AdminFacetRoute /></RequireAdmin>} />
<Route path="/admin/variants" element={<RequireAdmin><AdminShell><VariantMockupPanel /></AdminShell></RequireAdmin>} />
<Route path="/admin/featured" element={<RequireAdmin><AdminShell><FeaturedOrderPanel /></AdminShell></RequireAdmin>} />
<Route path="/admin/trash" element={<RequireAdmin><AdminShell><TemplateTrashPanel /></AdminShell></RequireAdmin>} />
{/* SPA fallback — unknown routes land on the shop home. */}
<Route path="*" element={<ShopHome />} />
</Routes>
</TemplateStoreProvider>
</BrowserRouter>
);
}

321
src/CustomizeRoute.jsx Normal file
View File

@@ -0,0 +1,321 @@
// CustomizeRoute — the /customize?template=<id> entry point.
//
// Background
// ──────────
// Originally a parallel route alongside the legacy App.jsx flow.
// As of v0.7 the router no longer mounts App.jsx — `/` serves the
// shop home (ShopHome) and customize-only deep links land here.
// App.jsx stays on disk one release for safety but isn't routed;
// this file is the only customize entry point. The legacy TEMPLATES
// constant can be removed once the deployment migration is confirmed.
//
// v0.6 schema model
// ─────────────────
// The template's GET response embeds a `variant` object resolved
// server-side via productsStore (canvas, shape, physical, mockupPath,
// productLabel). We read it directly off `template.variant` instead of
// looking it up against a host-side catalog. Freeform mode (no
// templateId) passes variant=null and the editor falls back to its
// legacy single-product code path (getActiveProduct() inside
// DesignCanvas).
//
// Flow
// ────
// 1. Fetch the template by id via template-manager's useTemplate hook
// (which hits /api/public/templates/<id>).
// 2. Read the embedded variant from `template.variant` (already
// resolved by the server via productsStore).
// 3. Pass the pre-fetched template + variant to ApparelDesigner,
// which materializes fractional coords on mount and seeds the
// editor.
// 4. Freeform path: when templateId is null (user lands on
// /customize with no query), the editor renders an empty canvas
// against the host's default product.
//
// "Resume in-progress" UX is intentionally not implemented — the
// fetched template is authoritative. ApparelDesigner's pre-fetched-
// template effect explicitly ignores localStorage in this mode (see
// its docblock).
//
// Host concerns
// ─────────────
// Header chrome, toast, share, upload, cart, PWA install — owned
// here rather than in the editor. The editor stays focused on canvas
// + state; host owns env-specific I/O (upload endpoint, cart sink,
// share intent, toast surface).
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDesignPersistence } from 'goods-editor';
import { useTemplate, computeHiddenStickerCategories } from 'template-manager';
import { getEditorAdapter } from './editors/editorRegistry.js';
import { useAuthContext } from '@khalidadil/auth-manager';
import { useFacetCatalog } from './hooks/useFacetCatalog';
import { Header } from './components/Header';
import { PWAInstall } from './components/PWAInstall';
import { OfflineIndicator } from './components/OfflineIndicator';
import { Toast } from './components/Toast';
import { t } from './i18n/t';
const TOAST_DEFAULT_DURATION_MS = 3000;
export default function CustomizeRoute() {
const [params] = useSearchParams();
const templateId = params.get('template');
// Fetch the template. The hook handles loading/error states; when
// it returns null data with no error and no loading, the route is
// effectively in "no template selected" mode (user came to
// /customize directly) and we render a freeform editor.
const { loading: templateLoading, data: template, error: templateError } = useTemplate(templateId);
// v0.6 — variant is embedded in the public template response by
// the server (resolved via productsStore). We just read it off.
// Freeform mode (no templateId) leaves variant as null — the
// editor's DesignCanvas falls back to its legacy getActiveProduct()
// path in that case, so the user still gets a coherent blank
// canvas to design against.
const variant = template?.variant || null;
// ── Editor dispatch (host editorRegistry) ──────────────────────
// Which editor authors this product's designs. Resolved from the
// template's product (defaults 'goods'; the CMS surfaces editorKind on
// the public template/variant response once non-goods products exist).
// Unknown kind ⇒ no adapter ⇒ a clear fallback, never a silent goods
// substitution (mirrors the CMS's no-goods-fallback validator rule).
const editorKind = template?.editorKind || variant?.editorKind || 'goods';
const editorAdapter = getEditorAdapter(editorKind);
const CustomerCustomizer = editorAdapter?.CustomerCustomizer || null;
// ── Facet personalization (Project doc §R10) ───────────────
// Hide sticker categories that belong to facet values the customer
// didn't pick. null when no facet is configured or there's no
// selection ⇒ the editor shows every sticker. The customer's
// selection comes from AuthContext; the vocabulary + assignment map
// from the public catalog.
const { facetValues } = useAuthContext();
const { config: facetConfig, activeValueSlugs, assignments: stickerAssignments } = useFacetCatalog({ apiBase: '/api' });
const hiddenStickerCategories = facetConfig.enabled
? computeHiddenStickerCategories(facetValues, stickerAssignments, activeValueSlugs)
: null;
// ── Editor bridge ──────────────────────────────────────────────
const editorRef = useRef(null);
const [isExporting, setIsExporting] = useState(false);
// v0.8 — module-derived signals for header gating. The editor calls
// back on element count + clearability changes; the host uses them
// to disable the Clear/Preview/Download buttons when nothing is on
// canvas or the editor is mid-export.
const [elementCount, setElementCount] = useState(0);
const [canClear, setCanClear] = useState(false);
// ── Toast surface ──────────────────────────────────────────────
const [toast, setToast] = useState(null);
const toastTimerRef = useRef(null);
const showToast = useCallback((message, kind = 'info', duration = TOAST_DEFAULT_DURATION_MS) => {
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); toastTimerRef.current = null; }
setToast({ message, kind, id: Date.now() });
if (duration > 0) {
toastTimerRef.current = setTimeout(() => { setToast(null); toastTimerRef.current = null; }, duration);
}
}, []);
const dismissToast = useCallback(() => {
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); toastTimerRef.current = null; }
setToast(null);
}, []);
useEffect(() => () => {
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
}, []);
// ── Editor proxies ─────────────────────────────────────────────
const handleHeaderPreview = useCallback(() => editorRef.current?.preview(), []);
const handleHeaderTestDownload = useCallback(() => editorRef.current?.testDownload(), []);
const handleHeaderClear = useCallback(() => editorRef.current?.clear(), []);
// ── Share handler — three-level fallback ───────────────────────
// navigator.share (mobile) → clipboard write → toast with URL.
const handleShare = useCallback(async () => {
const url = window.location.href;
if (typeof navigator !== 'undefined' && navigator.share) {
try {
await navigator.share({ title: t('share.title'), text: t('share.text'), url });
return;
} catch { /* fallthrough */ }
}
if (typeof navigator !== 'undefined' && navigator.clipboard) {
try {
await navigator.clipboard.writeText(url);
showToast(t('toast.link-copied'), 'success');
return;
} catch { /* fallthrough */ }
}
showToast(t('toast.copy-failed', { url }), 'info', 8000);
}, [showToast]);
// ── Cart state ─────────────────────────────────────────────────
// Local to this route; the shop's persistent localStorage cart
// (see hooks/useCart) is keyed on real product/size ids and isn't
// the right sink for the editor's freeform snapshots yet.
const [cartItems, setCartItems] = useState([]);
// Design persistence (#23) — POSTs the editor snapshot to
// /api/designs so the design survives the browser session and the
// order pipeline can re-render it at print resolution. The server
// route only exists when server.js passes a Postgres `db` handle
// into mountEditorApi; without it the POST 404s and we fall back
// to the pre-#23 behavior (cart line without a design_id), so
// smoke testing without Postgres keeps working unchanged.
const { persistCurrentDesign } = useDesignPersistence();
const handleAddToCart = useCallback(async (snapshot) => {
// Try to persist FIRST so the line can carry the minted ids. The
// editor fires onAddToCart without awaiting it, so this await
// never blocks the editor UI — the line just lands in cart state
// when the round-trip settles (or immediately on failure).
let persisted = null;
try {
persisted = await persistCurrentDesign({
// Stored as-is: the server's extractDesignViews understands
// the editor snapshot shape ({ elements, views, activeViewId,
// shirtColorId, shirtSize, currentTemplate }) directly.
designJson: snapshot,
productId: template?.productId || variant?.productId || null,
sizeId: template?.sizeId || variant?.id || null,
// No source_images in v1 — element srcs already point at
// /uploads/… files the server can read, or data: URLs baked
// into design_json itself, so re-renders need no extra bytes.
variant: variant || undefined,
});
} catch (err) {
// Any failure (network, 404 because Postgres isn't wired,
// non-2xx) degrades to the pre-#23 cart add.
console.warn('[host] Design persistence failed; adding to cart without design_id:', err?.message || err);
}
setCartItems((prev) => [...prev, {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
addedAt: Date.now(),
...snapshot,
...(persisted ? {
designId: persisted.design_id,
// POST /designs doesn't return design_family_id today;
// carried defensively in case a later module version adds it.
...(persisted.design_family_id ? { designFamilyId: persisted.design_family_id } : {}),
thumbnailUrl: persisted.thumbnail_url || null,
} : {}),
}]);
}, [persistCurrentDesign, template, variant]);
const handleOpenCart = useCallback(() => {
// eslint-disable-next-line no-console
console.log('[host] Open cart', cartItems);
}, [cartItems]);
// ── Upload handler ─────────────────────────────────────────────
const handleUploadFile = useCallback(async (file) => {
const formData = new FormData();
formData.append('image', file);
try {
const response = await fetch('/api/upload', { method: 'POST', body: formData });
if (!response.ok) {
const errorText = await response.text().catch(() => '');
const message = `${response.status}${errorText ? `: ${errorText}` : ''}`;
showToast(t('toast.upload-failed', { error: message }), 'error', 5000);
throw new Error(`Upload failed (${message})`);
}
const data = await response.json();
return { url: data.preview.url, originalUrl: data.original.url };
} catch (err) {
if (!err.message?.startsWith('Upload failed')) {
showToast(t('toast.upload-failed', { error: err.message || 'network error' }), 'error', 5000);
}
throw err;
}
}, [showToast]);
// ── Template-fetch loading / error gates ───────────────────────
// Render the host chrome around them so the page header doesn't
// disappear during a refresh — users keep their sense of "where am I"
// even mid-load. The chrome is identical to the success render; only
// the <main> contents change.
const headerProps = {
cartCount: cartItems.length,
onShare: handleShare,
onOpenCart: handleOpenCart,
onTestDownload: handleHeaderTestDownload,
isExporting,
onPreview: handleHeaderPreview,
onClear: handleHeaderClear,
canClear,
elementCount,
};
if (templateId && templateLoading) {
return (
<div className="app-shell">
<OfflineIndicator />
<PWAInstall />
<Toast toast={toast} onDismiss={dismissToast} />
<Header {...headerProps} isExporting={false} />
<main style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 0 }}>
<p>Loading template…</p>
</main>
</div>
);
}
if (templateId && templateError) {
return (
<div className="app-shell">
<OfflineIndicator />
<PWAInstall />
<Toast toast={toast} onDismiss={dismissToast} />
<Header {...headerProps} isExporting={false} />
<main style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 0, padding: 24, textAlign: 'center' }}>
<p>
{templateError.code === 'not_found'
? 'That template isn’t available anymore. Try browsing the shop or starting from scratch.'
: 'Something went wrong loading this template. Refresh to retry.'}
</p>
</main>
</div>
);
}
return (
<div className="app-shell">
<OfflineIndicator />
<PWAInstall />
<Toast toast={toast} onDismiss={dismissToast} />
<Header {...headerProps} />
<main style={{ flex: 1, minHeight: 0 }}>
{CustomerCustomizer ? (
<CustomerCustomizer
editorRef={editorRef}
template={template}
variant={variant}
hiddenStickerCategories={hiddenStickerCategories}
onExportingChange={setIsExporting}
onElementCountChange={setElementCount}
onCanClearChange={setCanClear}
onUploadFile={handleUploadFile}
onSave={(elements, name, tpl, extra) => {
// Multi-view (2026-06-10): onSave gained a 4th arg with the
// full views map + active view. Single-view here, so it's
// { default: […] } — logged for contract visibility.
// eslint-disable-next-line no-console
console.log('[host] Save:', { elementCount: elements.length, name, templateId: tpl?.id, views: extra ? Object.keys(extra.views || {}) : undefined, activeViewId: extra?.activeViewId });
}}
onAddToCart={handleAddToCart}
/>
) : (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, textAlign: 'center' }}>
<p>This product uses an editor (“{editorKind}”) that isn’t available here yet.</p>
</div>
)}
</main>
</div>
);
}

View File

@@ -5,35 +5,35 @@ import '../styles/Header.css';
* Pawfectly Yours top bar.
*
* Layout:
* [logo + tagline] [page title] [preview] [download] [Share] [cart]
* [logo + tagline] [page title] [clear] [preview] [download] [Share] [cart]
*
* On mobile (<768px) collapses to:
* [logo] [cart]
*
* What's here vs what's gone
* ──────────────────────────
* Buttons removed during the module-split cleanup (May 2026):
* Save was removed during the module-split cleanup (May 2026) — it
* was redundant with Download (both fired the same export pipeline).
* The module's onSave callback prop on <ApparelDesigner> remains as
* the right integration point for real "commit this design"
* semantics when there's a backend to commit to.
*
* • Save — was redundant with Download (both fired the same export
* pipeline). The module's onSave callback prop on <ApparelDesigner>
* remains as the right integration point for real "commit this
* design" semantics when there's a backend to commit to.
* Menu was also removed — it opened the host's mobile bottom sheet,
* which now lives inside the module. The module owns its own mobile
* UI; the host had nothing left to put behind a menu.
*
* • Clear — clearing the canvas is editor state, not host chrome.
* Re-introducing it requires the module to expose a clear()
* method on its editor ref and a canClear callback so the host
* can disable the button when there's nothing to clear. Until
* that lands, removing the button is better than leaving a
* non-functional one in the chrome.
* Clear was removed alongside the other two but came back in v0.8
* once the module shipped a `clear()` method on its editor ref plus
* an `onCanClearChange` callback. The host now disables the button
* when there's nothing to clear (empty canvas, no template loaded)
* and lets the module own the actual confirm-and-clear flow — first
* click arms it with a toast, second click within 3s commits.
*
* • Menu — opened the host's mobile bottom sheet, which now lives
* inside the module. The module owns its own mobile UI; the
* host had nothing left to put behind a menu.
*
* Preview / Download are still here because they're host-surfaced
* controls that drive the editor via the editorRef.preview() and
* editorRef.testDownload() imperative API. They're disabled during
* an in-flight export (`isExporting`).
* Preview / Download / Clear are all host-surfaced controls that
* drive the editor via its imperative API (editorRef.preview(),
* editorRef.testDownload(), editorRef.clear()). All three disable
* during an in-flight export (`isExporting`). Preview and Download
* additionally disable when `elementCount === 0` — nothing to render.
*
* i18n
* ────
@@ -46,6 +46,15 @@ export function Header({
onShare, onOpenCart,
onTestDownload, isExporting = false,
onPreview,
onClear,
// canClear: true when the editor has at least one element OR a
// template loaded. Module-derived via onCanClearChange.
// elementCount: how many elements are on the canvas. Module-derived
// via onElementCountChange. Used to gate Preview/Download — with
// zero elements the export pipeline would render a blank PNG,
// which is never what the user wants.
canClear = false,
elementCount = 0,
}) {
// App name is reused for both the logo and the aria-label, so we
// resolve it once and interpolate into the aria-label below.
@@ -79,15 +88,42 @@ export function Header({
</h1>
<div className="ph-actions">
{/* Clear — wipe the canvas + drop the current template. The
* module's clear() method handles the two-stage confirm flow
* (first click arms with a toast, second click within 3s
* commits), so the host just fires the trigger. Disabled
* when canClear is false: the editor reports false when the
* elements array is empty AND no template is loaded, so this
* dims out on a truly blank canvas where there's nothing
* destructive to do. Also disabled mid-export to avoid
* clobbering an in-flight render's source data. */}
<button
type="button"
className="ph-iconbtn"
onClick={onClear}
disabled={!canClear || isExporting}
aria-label={t('header.clear')}
title={t('header.clear-tooltip')}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2" />
</svg>
</button>
{/* Print preview — server-renders the design at print resolution
* and shows it in a modal so the user can verify what will
* actually print before adding to cart. Disabled while another
* export is in flight. */}
* export is in flight OR when the canvas is empty (nothing
* meaningful to preview). */}
<button
type="button"
className="ph-iconbtn"
onClick={onPreview}
disabled={isExporting}
disabled={isExporting || elementCount === 0}
aria-label={t('header.preview')}
title={t('header.preview-tooltip')}
>
@@ -99,13 +135,14 @@ export function Header({
{/* Download — dedicated print-test button. Same export pipeline
* as Preview but auto-downloads instead of opening the modal.
* Disabled while an export is in flight so rapid clicks don't
* fire parallel POSTs. */}
* Disabled while an export is in flight (so rapid clicks don't
* fire parallel POSTs) and when the canvas is empty (no point
* downloading a blank PNG). */}
<button
type="button"
className="ph-iconbtn"
onClick={onTestDownload}
disabled={isExporting}
disabled={isExporting || elementCount === 0}
aria-label={t('header.download')}
title={t('header.download-tooltip')}
>

View File

@@ -0,0 +1,173 @@
// <CartStub> — minimal cart preview at /cart.
//
// Reads from useCart(). Renders a table of line items with thumbnail,
// label, size, option choices, quantity controls, line total, and a
// grand total. The "Proceed to checkout" button shows an inline
// banner with "Checkout coming soon" — checkout is downstream.
//
// Why a stub
// ──────────
// Per locked decision #14: checkout is intentionally out of scope
// for v0.7. The cart is the hand-off point. When a real checkout
// lands (Stripe Checkout session, custom flow, whatever), this
// component is the place to wire it — replace the banner with a
// POST to the order endpoint and a redirect.
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { ShopHeader } from './ShopHeader.jsx';
import { useCart } from '../../hooks/useCart.js';
function formatUSD(cents) {
return `$${((cents || 0) / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function describeOptions(optionChoices) {
const entries = Object.entries(optionChoices || {});
if (entries.length === 0) return null;
return entries
.map(([groupSlug, valueSlug]) => `${groupSlug}: ${valueSlug}`)
.join(' · ');
}
export function CartStub() {
const { items, count, totalCents, updateItem, removeItem, clear } = useCart();
const [checkoutBanner, setCheckoutBanner] = useState(false);
const handleCheckout = () => {
setCheckoutBanner(true);
// Auto-dismiss after a few seconds so the page doesn't get
// stuck in the toast state. Re-clicking the button re-arms it.
setTimeout(() => setCheckoutBanner(false), 4000);
};
return (
<div className="app-shell">
<ShopHeader />
<main className="shop-main">
<section className="shop-cart">
<header className="shop-cart__head">
<h1 className="shop-cart__title">Your cart</h1>
<p className="shop-cart__count">
{count === 0 ? 'Empty' : count === 1 ? '1 item' : `${count} items`}
</p>
</header>
{items.length === 0 ? (
<div className="shop-cart__empty">
<p>Your cart is empty.</p>
<Link to="/shop" className="shop-cart__continue">Continue shopping →</Link>
</div>
) : (
<>
<ul className="shop-cart__items">
{items.map((line) => (
<li key={line.id} className="shop-cart__line">
<div className="shop-cart__thumb">
{line.imageUrl ? (
<img src={line.imageUrl} alt={line.productLabel} loading="lazy" />
) : (
<div className="shop-cart__thumb-fallback" aria-hidden="true">🐾</div>
)}
</div>
<div className="shop-cart__details">
<h3 className="shop-cart__label">{line.productLabel}</h3>
<p className="shop-cart__meta">
{line.sizeLabel && <span>{line.sizeLabel}</span>}
{describeOptions(line.optionChoices) && (
<>
<span aria-hidden="true"> · </span>
<span>{describeOptions(line.optionChoices)}</span>
</>
)}
</p>
<p className="shop-cart__unit">{formatUSD(line.unitPriceCents)} each</p>
</div>
<div className="shop-cart__qty">
<button
type="button"
onClick={() => updateItem(line.id, { quantity: Math.max(1, line.quantity - 1) })}
aria-label="Decrease quantity"
className="shop-qty-btn"
disabled={line.quantity <= 1}
>−</button>
<input
type="number"
min="1"
max="99"
value={line.quantity}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (Number.isFinite(n) && n > 0) {
updateItem(line.id, { quantity: Math.min(99, n) });
}
}}
className="shop-qty-input"
aria-label="Quantity"
/>
<button
type="button"
onClick={() => updateItem(line.id, { quantity: Math.min(99, line.quantity + 1) })}
aria-label="Increase quantity"
className="shop-qty-btn"
>+</button>
</div>
<div className="shop-cart__line-total">
{formatUSD(line.unitPriceCents * line.quantity)}
</div>
<button
type="button"
onClick={() => removeItem(line.id)}
className="shop-cart__remove"
aria-label={`Remove ${line.productLabel} from cart`}
>
✕
</button>
</li>
))}
</ul>
<div className="shop-cart__summary">
<div className="shop-cart__total">
<span>Total</span>
<strong className="shop-price-large">{formatUSD(totalCents)}</strong>
</div>
<p className="shop-cart__shipping-note">
Shipping and taxes calculated at checkout.
</p>
{checkoutBanner && (
<p className="shop-cart__checkout-banner" role="status">
🚧 Checkout coming soon! We're still building this part — your cart will be here when it's ready.
</p>
)}
<div className="shop-cart__actions">
<Link to="/shop" className="shop-cart__continue">
← Continue shopping
</Link>
<div className="shop-cart__actions-right">
<button
type="button"
onClick={() => {
if (window.confirm('Clear all items from your cart?')) clear();
}}
className="shop-cart__clear"
>
Clear cart
</button>
<button
type="button"
onClick={handleCheckout}
className="shop-cart__checkout"
>
Proceed to checkout →
</button>
</div>
</div>
</div>
</>
)}
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,47 @@
// <Hero> — shop home hero block.
//
// Placeholder copy locked at v0.7 planning time. Will be replaced
// with brand-team copy once we have it; the structure (headline,
// subhead, two CTAs) is meant to survive that swap.
//
// CTAs route to:
// • "Start designing" → /shop?kind=customizable (filters the grid below)
// • "Browse ready-made" → /shop?kind=fixed
//
// Both CTAs scroll the page to the grid via ?kind= query param. The
// ShopHome route reads that param and seeds its kind filter from it,
// then the grid below the hero re-renders to match. We use hash-fragment
// (#grid) to also scroll the grid into view when the CTAs are tapped.
import { Link } from 'react-router-dom';
export function Hero() {
return (
<section className="shop-hero" aria-label="Pawfectly Yours — shop intro">
<div className="shop-hero__inner">
<h1 className="shop-hero__headline">
Custom Designs &amp; Ready-Made for Every Pet Parent
</h1>
<p className="shop-hero__subhead">
Personalize a shirt, mug, or magnet — or grab one of our ready-to-ship
favorites. Made with love for the furry, the feathered, and the fabulous.
</p>
<div className="shop-hero__ctas">
<Link to="/shop?kind=customizable#grid" className="shop-hero__cta shop-hero__cta--primary">
Start designing →
</Link>
<Link to="/shop?kind=fixed#grid" className="shop-hero__cta shop-hero__cta--ghost">
Browse ready-made
</Link>
</div>
</div>
<div className="shop-hero__art" aria-hidden="true">
{/* Decorative paw cluster. Kept inline so we don't need a
* separate image asset for the first-paint hero. */}
<span className="shop-hero__paw shop-hero__paw--lg">🐾</span>
<span className="shop-hero__paw shop-hero__paw--md">💕</span>
<span className="shop-hero__paw shop-hero__paw--sm">🐾</span>
</div>
</section>
);
}

View File

@@ -0,0 +1,282 @@
// <ProductBuyPanel> — purchase / customize action panel.
//
// Forks on product.kind:
// • customizable → size + options + "Customize this product" CTA.
// The CTA scrolls to the template gallery below. If the user
// wants to start from scratch, the gallery also offers a
// "Start blank" entry that mounts /customize with the product's
// default size.
// • fixed → size + options + quantity + "Add to cart". Pushes a
// line into useCart() and shows an inline confirmation banner.
//
// State shape
// ───────────
// selectedSizeId — defaults to the first size in product.sizes.
// optionChoices — { [groupSlug]: valueSlug }; defaults to the
// first value in each group.
// quantity — fixed-kind only; default 1.
//
// v0.8 — per-option pricing
// ─────────────────────────
// Each option value carries an optional `priceCents` field (default 0).
// The effective unit price is `size.basePriceCents + sum(selected
// values' priceCents)`. We surface this in three places:
// 1. The big total at the bottom (already multiplied by quantity).
// 2. A "+$X.XX" annotation on each option pill / swatch that has a
// non-zero price (so the customer sees the upcharge before they
// pick).
// 3. The `unitPriceCents` passed to addItem() — stays a single
// number so the cart doesn't need a separate option-cost field.
import { useState, useMemo, useRef } from 'react';
import { useCart } from '../../hooks/useCart.js';
// Currency symbol stored separately so neither template-literal
// dollar-substitution nor edit-tool back-reference handling can
// corrupt the literal sign mid-edit. (Felt cute, might revert.)
const CURRENCY_SYMBOL = String.fromCharCode(0x24);
function formatUSD(cents) {
const formatted = ((cents || 0) / 100).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return CURRENCY_SYMBOL + formatted;
}
// v0.8 — short form for the upcharge annotation on option pills.
// Drops trailing `.00` so a $2 upcharge reads "+$2" rather than
// "+$2.00" — less visual noise in the swatch grid.
function formatUpcharge(cents) {
if (!cents) return '';
const dollars = cents / 100;
const fixed = dollars.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const body = fixed.endsWith('.00') ? String(Math.round(dollars)) : fixed;
return '+' + CURRENCY_SYMBOL + body;
}
function buildDefaultChoices(options) {
const out = {};
for (const group of options || []) {
if (group.values && group.values.length > 0) {
out[group.slug] = group.values[0].slug;
}
}
return out;
}
export function ProductBuyPanel({ product, onScrollToTemplates }) {
const isFixed = product.kind === 'fixed';
const sizes = product.sizes || [];
const options = product.options || [];
const [selectedSizeId, setSelectedSizeId] = useState(sizes[0]?.id || null);
const [optionChoices, setOptionChoices] = useState(() => buildDefaultChoices(options));
const [quantity, setQuantity] = useState(1);
const [addedBanner, setAddedBanner] = useState(false);
const bannerTimerRef = useRef(null);
const { addItem } = useCart();
const selectedSize = useMemo(
() => sizes.find((s) => s.id === selectedSizeId) || sizes[0] || null,
[sizes, selectedSizeId],
);
// v0.8 — walk options[] and sum the priceCents of the values the
// customer has selected. Memoized so we only recompute when the
// option set or the picks change.
const optionsPriceCents = useMemo(() => {
let sum = 0;
for (const group of options) {
const chosenSlug = optionChoices[group.slug];
if (!chosenSlug) continue;
const chosenValue = (group.values || []).find((v) => v.slug === chosenSlug);
if (chosenValue?.priceCents) sum += chosenValue.priceCents;
}
return sum;
}, [options, optionChoices]);
const unitPriceCents = (selectedSize?.basePriceCents || 0) + optionsPriceCents;
const totalCents = unitPriceCents * (isFixed ? quantity : 1);
const handleOptionChange = (groupSlug, valueSlug) => {
setOptionChoices((prev) => ({ ...prev, [groupSlug]: valueSlug }));
};
const handleAddToCart = () => {
if (!selectedSize) return;
// Pull the first gallery image (or first-size mockup) for the
// cart row thumbnail. The product's images[] is already sorted
// by displayOrder server-side.
const firstImage = (product.images || [])[0];
const imageUrl = firstImage
? firstImage.url
: selectedSize.mockupUrl || null;
addItem({
productId: product.id,
productLabel: product.label,
sizeId: selectedSize.id,
sizeLabel: selectedSize.label,
optionChoices,
quantity,
unitPriceCents,
imageUrl,
});
// Inline "added!" banner. The cart icon badge in the header
// updates immediately via the useCart cross-tab sync.
if (bannerTimerRef.current) clearTimeout(bannerTimerRef.current);
setAddedBanner(true);
bannerTimerRef.current = setTimeout(() => setAddedBanner(false), 3000);
};
return (
<div className="shop-buy-panel">
{/* Size picker */}
{sizes.length > 0 && (
<div className="shop-buy-panel__group">
<label className="shop-buy-panel__label">Size</label>
<div className="shop-buy-panel__sizes" role="radiogroup" aria-label="Size">
{sizes.map((s) => (
<button
key={s.id}
type="button"
role="radio"
aria-checked={s.id === selectedSizeId}
className={`shop-size-btn ${s.id === selectedSizeId ? 'shop-size-btn--active' : ''}`}
onClick={() => setSelectedSizeId(s.id)}
>
<span className="shop-size-btn__label">{s.label}</span>
{s.basePriceCents !== unitPriceCents && (
<span className="shop-size-btn__price">{formatUSD(s.basePriceCents)}</span>
)}
</button>
))}
</div>
</div>
)}
{/* Option groups (color, etc.) */}
{options.map((group) => (
<div key={group.id} className="shop-buy-panel__group">
<label className="shop-buy-panel__label">{group.label}</label>
{group.kind === 'color' ? (
<div className="shop-buy-panel__color-swatches" role="radiogroup" aria-label={group.label}>
{group.values.map((v) => {
const upcharge = formatUpcharge(v.priceCents);
// Swatches are too small for visible text; surface
// the upcharge via the tooltip + aria-label only.
const tooltip = upcharge ? `${v.label} (${upcharge})` : v.label;
return (
<button
key={v.id}
type="button"
role="radio"
aria-checked={optionChoices[group.slug] === v.slug}
aria-label={tooltip}
title={tooltip}
className={`shop-color-swatch ${optionChoices[group.slug] === v.slug ? 'shop-color-swatch--active' : ''}`}
style={{ background: v.hex }}
onClick={() => handleOptionChange(group.slug, v.slug)}
/>
);
})}
</div>
) : (
<div className="shop-buy-panel__option-pills" role="radiogroup" aria-label={group.label}>
{group.values.map((v) => {
const upcharge = formatUpcharge(v.priceCents);
return (
<button
key={v.id}
type="button"
role="radio"
aria-checked={optionChoices[group.slug] === v.slug}
className={`shop-option-pill ${optionChoices[group.slug] === v.slug ? 'shop-option-pill--active' : ''}`}
onClick={() => handleOptionChange(group.slug, v.slug)}
>
{v.label}
{upcharge && (
<span className="shop-option-pill__price">{upcharge}</span>
)}
</button>
);
})}
</div>
)}
</div>
))}
{/* Quantity (fixed only) */}
{isFixed && (
<div className="shop-buy-panel__group">
<label className="shop-buy-panel__label" htmlFor="shop-quantity">Quantity</label>
<div className="shop-buy-panel__quantity">
<button
type="button"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
aria-label="Decrease quantity"
className="shop-qty-btn"
disabled={quantity <= 1}
>−</button>
<input
id="shop-quantity"
type="number"
min="1"
max="99"
value={quantity}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setQuantity(Number.isFinite(n) && n > 0 ? Math.min(99, n) : 1);
}}
className="shop-qty-input"
/>
<button
type="button"
onClick={() => setQuantity((q) => Math.min(99, q + 1))}
aria-label="Increase quantity"
className="shop-qty-btn"
>+</button>
</div>
</div>
)}
{/* Price line + primary CTA */}
<div className="shop-buy-panel__total">
<span className="shop-buy-panel__total-label">
{isFixed && quantity > 1 ? 'Total' : 'Price'}
</span>
<strong className="shop-price-large">{formatUSD(totalCents)}</strong>
</div>
{isFixed ? (
<>
<button
type="button"
onClick={handleAddToCart}
className="shop-buy-panel__cta shop-buy-panel__cta--primary"
disabled={!selectedSize}
>
Add to cart
</button>
{addedBanner && (
<p className="shop-buy-panel__added" role="status">
✨ Added to cart! <a href="/cart">View cart →</a>
</p>
)}
</>
) : (
<button
type="button"
onClick={onScrollToTemplates}
className="shop-buy-panel__cta shop-buy-panel__cta--primary"
>
Customize this product ↓
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,55 @@
// <ProductCard> — shop product card for the home grid.
//
// Wraps a single product summary returned by /api/public/products.
// Links to /shop/<slug>. Shows: first image (or first-size mockup as
// fallback), label, kind pill, "Starting at $X.XX".
//
// All visual surfaces use prefixed classes (.shop-card__*) and degrade
// gracefully when fields are missing (no image, no category).
//
// v0.7.4 — the public API now always populates imageUrl when at
// least one size exists (with the server's /products/* fallback
// serving the placeholder for unmocked sizes). The data-URL
// placeholder below is the safety net for the zero-sizes edge case
// so we never render the bare emoji fallback again.
import { Link } from 'react-router-dom';
import { PRODUCT_IMAGE_PLACEHOLDER_DATA_URL } from 'template-manager';
function formatUSD(cents) {
if (!cents) return '$0.00';
const formatted = (cents / 100).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return '$' + formatted;
}
export function ProductCard({ product }) {
const kind = product.kind || 'customizable';
const kindLabel = kind === 'fixed' ? 'Ready-made' : 'Customizable';
const imageSrc = product.imageUrl || PRODUCT_IMAGE_PLACEHOLDER_DATA_URL;
return (
<Link
to={`/shop/${encodeURIComponent(product.slug)}`}
className="shop-card"
aria-label={`${product.label} — ${kindLabel}, starting at ${formatUSD(product.startingPriceCents)}`}
>
<div className="shop-card__image">
<img
src={imageSrc}
alt={product.imageAlt || product.label}
loading="lazy"
/>
<span className={`shop-kind-pill shop-kind-pill--${kind}`}>{kindLabel}</span>
</div>
<div className="shop-card__body">
<h3 className="shop-card__label">{product.label}</h3>
<p className="shop-card__price">
<span className="shop-price-from">Starting at</span>{' '}
<strong>{formatUSD(product.startingPriceCents)}</strong>
</p>
</div>
</Link>
);
}

View File

@@ -0,0 +1,85 @@
// <ProductGallery> — image carousel for the product detail page.
//
// Shows the product's images[] sorted by displayOrder. Falls back to
// per-size mockups if images[] is empty (post-v0.7.4 every size has
// a mockupUrl that resolves to a real upload or the placeholder via
// the server's /products/* fallback chain). Main image + thumbnail
// strip. Click a thumbnail to swap the main image.
//
// Keyboard support: thumbnails are buttons, so Tab + Enter cycles
// through. Arrow keys aren't bound — we'd want a carousel role for
// that and v0.7 is intentionally minimal.
import { useState, useEffect, useMemo } from 'react';
import { PRODUCT_IMAGE_PLACEHOLDER_DATA_URL } from 'template-manager';
function buildGalleryItems(product) {
// Prefer product.images[] (already sorted by the server).
if (Array.isArray(product.images) && product.images.length > 0) {
return product.images.map((img) => ({
key: img.id,
url: img.url,
alt: img.alt || product.label,
}));
}
// Fall back to per-size mockups. v0.7.4: every size has a mockupUrl
// (the server's /products/* fallback serves the placeholder if no
// real upload). We map every size, not just those with hasMockup,
// so the gallery has thumbs for the full size range.
const sizes = (product.sizes || []).filter((s) => s.mockupUrl);
if (sizes.length > 0) {
return sizes.map((s) => ({
key: s.id,
url: s.mockupUrl,
alt: `${product.label} — ${s.label}`,
}));
}
// Zero-sizes edge case: synthesize a single placeholder item so the
// gallery never renders blank.
return [{
key: 'placeholder',
url: PRODUCT_IMAGE_PLACEHOLDER_DATA_URL,
alt: product.label,
}];
}
export function ProductGallery({ product }) {
const items = useMemo(() => buildGalleryItems(product), [product]);
const [activeIdx, setActiveIdx] = useState(0);
// Reset to first image when product changes.
useEffect(() => {
setActiveIdx(0);
}, [product.id]);
const active = items[Math.min(activeIdx, items.length - 1)];
return (
<div className="shop-gallery">
<div className="shop-gallery__main">
<img
src={active.url}
alt={active.alt}
className="shop-gallery__main-img"
/>
</div>
{items.length > 1 && (
<div className="shop-gallery__thumbs" role="tablist" aria-label="Product photos">
{items.map((item, i) => (
<button
key={item.key}
type="button"
role="tab"
aria-selected={i === activeIdx}
className={`shop-gallery__thumb ${i === activeIdx ? 'shop-gallery__thumb--active' : ''}`}
onClick={() => setActiveIdx(i)}
aria-label={`View photo ${i + 1}: ${item.alt}`}
>
<img src={item.url} alt="" loading="lazy" />
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,80 @@
// <ProductGrid> — paginated grid of product cards.
//
// Uses useShopProducts({ kind, q }) for data. Renders a responsive
// card grid with loading skeleton, empty state, error banner, and a
// "Load more" button when more pages exist.
//
// The `kind` and `q` props drive the underlying hook. Parents pass
// these from controlled state (filter buttons in ShopHome).
import { useShopProducts } from '../../hooks/useShopProducts.js';
import { ProductCard } from './ProductCard.jsx';
export function ProductGrid({ kind = 'all', q = '' }) {
const { items, loading, error, hasMore, loadMore } = useShopProducts({ kind, q });
// First-load skeleton — show a few card-shaped placeholders so the
// page doesn't look empty during the initial fetch. Subsequent
// page loads keep the existing items visible.
if (loading && items.length === 0) {
return (
<div className="shop-grid shop-grid--loading">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="shop-card shop-card--skeleton" aria-hidden="true">
<div className="shop-card__image shop-card__image--skeleton" />
<div className="shop-card__body">
<div className="shop-card__label-skeleton" />
<div className="shop-card__price-skeleton" />
</div>
</div>
))}
</div>
);
}
if (error && items.length === 0) {
return (
<div className="shop-grid shop-grid--error">
<p role="alert">
Something went wrong loading the catalog. Please try refreshing the page.
</p>
</div>
);
}
if (!loading && items.length === 0) {
return (
<div className="shop-grid shop-grid--empty">
<p>
{kind === 'fixed'
? "We don't have any ready-made products yet — check back soon!"
: kind === 'customizable'
? "No customizable designs available right now."
: "Our catalog is empty. Check back soon!"}
</p>
</div>
);
}
return (
<>
<div className="shop-grid">
{items.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
{hasMore && (
<div className="shop-grid__more">
<button
type="button"
onClick={loadMore}
disabled={loading}
className="shop-grid__more-btn"
>
{loading ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,43 @@
// <ProductInfo> — header block for the product detail page.
//
// Shows label, category, kind pill, description, and the "starting at"
// price. Lives in the right column of the two-column product page,
// above the buy panel.
function formatUSD(cents) {
if (!cents) return '$0.00';
return `$${(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function startingPriceCents(product) {
const sizes = product.sizes || [];
if (sizes.length === 0) return 0;
return sizes.reduce(
(min, s) => Math.min(min, s.basePriceCents || 0),
Number.POSITIVE_INFINITY,
);
}
export function ProductInfo({ product }) {
const kind = product.kind || 'customizable';
const kindLabel = kind === 'fixed' ? 'Ready-made' : 'Customizable';
const fromPrice = startingPriceCents(product);
return (
<div className="shop-info">
<div className="shop-info__head">
<span className={`shop-kind-pill shop-kind-pill--${kind}`}>{kindLabel}</span>
{product.category && (
<span className="shop-info__category">{product.category}</span>
)}
</div>
<h1 className="shop-info__label">{product.label}</h1>
<p className="shop-info__price">
<span className="shop-price-from">Starting at</span>{' '}
<strong className="shop-price-large">{formatUSD(fromPrice)}</strong>
</p>
{product.description && (
<p className="shop-info__description">{product.description}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,82 @@
// <ProductPage> — shop product detail at /shop/:productSlug.
//
// Composition:
// ShopHeader
// ├ ProductGallery (left column)
// └ ProductInfo + ProductBuyPanel (right column)
// TemplateGalleryForProduct (full width, customizable kind only)
//
// Loading / error / 404 states are handled inline (kept simple — no
// dedicated error boundary needed for a leaf route).
//
// The buy panel's "Customize this product" CTA scrolls the template
// gallery into view via a ref + scrollIntoView. The gallery section
// has id="templates" so the URL hash also works as a fallback.
import { useParams, Link } from 'react-router-dom';
import { useRef } from 'react';
import { ShopHeader } from './ShopHeader.jsx';
import { ProductGallery } from './ProductGallery.jsx';
import { ProductInfo } from './ProductInfo.jsx';
import { ProductBuyPanel } from './ProductBuyPanel.jsx';
import { TemplateGalleryForProduct } from './TemplateGalleryForProduct.jsx';
import { useShopProduct } from '../../hooks/useShopProduct.js';
export function ProductPage() {
const { productSlug } = useParams();
const { product, loading, error } = useShopProduct(productSlug);
const templatesRef = useRef(null);
const handleScrollToTemplates = () => {
if (templatesRef.current) {
templatesRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return (
<div className="app-shell">
<ShopHeader />
<main className="shop-main">
{loading && (
<div className="shop-product-page shop-product-page--loading">
<p>Loading…</p>
</div>
)}
{error && (
<div className="shop-product-page shop-product-page--error">
<h1>Product not found</h1>
<p>
We couldn't find that product. <Link to="/shop">Back to the shop →</Link>
</p>
</div>
)}
{product && (
<>
<nav className="shop-breadcrumb" aria-label="Breadcrumb">
<Link to="/shop">← Shop</Link>
</nav>
<article className="shop-product-page">
<div className="shop-product-page__gallery">
<ProductGallery product={product} />
</div>
<div className="shop-product-page__details">
<ProductInfo product={product} />
<ProductBuyPanel
product={product}
onScrollToTemplates={handleScrollToTemplates}
/>
</div>
</article>
{product.kind === 'customizable' && (
<div ref={templatesRef}>
<TemplateGalleryForProduct product={product} />
</div>
)}
</>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,76 @@
// <ShopHeader> — customer-facing top bar for shop routes.
//
// Mounted by ShopHome, ProductPage, and CartStub. Replaces the
// editor-focused Header from App.jsx for non-/customize routes.
//
// Layout
// ──────
// [brand mark + wordmark] [nav: Shop] [account chip] [cart icon + badge]
//
// The account chip (v0.8, from auth-manager) renders a "Sign in"
// link when logged out and an avatar dropdown when logged in.
//
// Reuses the .ph-* CSS classes from Header.css so the brand identity
// (paw mark, wordmark, tagline, cart-badge styling) stays consistent
// between the editor header and the shop header. The middle slot
// holds shop-specific nav links instead of the editor's Preview /
// Download / Share controls.
//
// Why a separate component instead of a prop-driven Header
// ────────────────────────────────────────────────────────
// The editor Header takes editorRef-based action callbacks (preview,
// download, share) that only make sense in the editor mount. The
// shop chrome needs none of those and shouldn't carry the dead
// callbacks around. A separate component keeps each surface focused.
//
// Cart count comes from useCart(), which is localStorage-backed and
// cross-tab synced. The badge re-renders the moment another tab adds
// to cart.
import { Link } from 'react-router-dom';
import { AccountMenu } from '@khalidadil/auth-manager';
import { useCart } from '../../hooks/useCart.js';
import { t } from '../../i18n/t.js';
export function ShopHeader() {
const { count } = useCart();
const appName = t('header.app-name');
const cartAria = count === 1
? t('header.cart-aria.singular', { count })
: t('header.cart-aria.plural', { count });
return (
<header className="ph-header shop-header" role="banner">
<Link
to="/"
className="ph-logo"
aria-label={t('header.logo-home-aria', { app: appName })}
>
<span className="ph-logo__mark" aria-hidden="true">🐾</span>
<span className="ph-logo__wordmark">
<span className="ph-logo__name">{appName}</span>
<span className="ph-logo__tagline">{t('header.app-tagline')}</span>
</span>
</Link>
<nav className="shop-header__nav" aria-label="Shop navigation">
<Link to="/shop" className="shop-header__nav-link">Shop</Link>
</nav>
<div className="ph-actions shop-header__actions">
{/* v0.8 — account chip lives between nav and cart. Renders a
"Sign in" link when logged out, an avatar+dropdown when
logged in. */}
<AccountMenu />
<Link to="/cart" className="ph-cart" aria-label={cartAria}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="9" cy="20" r="1.6" />
<circle cx="17" cy="20" r="1.6" />
<path d="M3 4h2l2.4 11.5a2 2 0 0 0 2 1.5h7.6a2 2 0 0 0 2-1.5L21 8H6" />
</svg>
{count > 0 && <span className="ph-cart__badge">{count}</span>}
</Link>
</div>
</header>
);
}

View File

@@ -0,0 +1,88 @@
// <ShopHome> — landing page at / and /shop.
//
// Layout: ShopHeader + Hero + kind filter tabs + ProductGrid.
//
// The kind filter is the only client-side state the page owns. We
// seed it from the URL's ?kind= query param so deep links (e.g. the
// hero's "Browse ready-made" CTA → /shop?kind=fixed) land on the
// right view. Tab clicks update both the local state AND the URL
// query so the page can be bookmarked / shared in any filter state.
//
// The hash fragment `#grid` is used by the hero CTAs to scroll the
// grid into view after a click; we add a target id on the grid
// section so the browser's native anchor scroll handles it.
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ShopHeader } from './ShopHeader.jsx';
import { Hero } from './Hero.jsx';
import { ProductGrid } from './ProductGrid.jsx';
const VALID_KINDS = new Set(['all', 'customizable', 'fixed']);
export function ShopHome() {
const [searchParams, setSearchParams] = useSearchParams();
const urlKind = searchParams.get('kind');
const initialKind = VALID_KINDS.has(urlKind) ? urlKind : 'all';
const [kind, setKind] = useState(initialKind);
// Keep local state and URL in sync on URL changes (back/forward navigation).
useEffect(() => {
const fromUrl = searchParams.get('kind');
const resolved = VALID_KINDS.has(fromUrl) ? fromUrl : 'all';
if (resolved !== kind) setKind(resolved);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
const setKindAndUrl = (next) => {
setKind(next);
const params = new URLSearchParams(searchParams);
if (next === 'all') params.delete('kind');
else params.set('kind', next);
setSearchParams(params, { replace: true });
};
return (
<div className="app-shell">
<ShopHeader />
<main className="shop-main">
<Hero />
<section className="shop-catalog" id="grid">
<div className="shop-catalog__head">
<h2 className="shop-catalog__title">Shop the catalog</h2>
<div className="shop-catalog__filters" role="tablist" aria-label="Filter by kind">
<button
type="button"
role="tab"
aria-selected={kind === 'all'}
className={`shop-filter ${kind === 'all' ? 'shop-filter--active' : ''}`}
onClick={() => setKindAndUrl('all')}
>
All
</button>
<button
type="button"
role="tab"
aria-selected={kind === 'customizable'}
className={`shop-filter ${kind === 'customizable' ? 'shop-filter--active' : ''}`}
onClick={() => setKindAndUrl('customizable')}
>
Designs
</button>
<button
type="button"
role="tab"
aria-selected={kind === 'fixed'}
className={`shop-filter ${kind === 'fixed' ? 'shop-filter--active' : ''}`}
onClick={() => setKindAndUrl('fixed')}
>
Ready-made
</button>
</div>
</div>
<ProductGrid kind={kind} />
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,55 @@
// <TemplateGalleryForProduct> — list templates pinned to a customizable product.
//
// Mounted on the product detail page below the buy panel, only when
// product.kind === 'customizable'. Wraps the template-manager's
// <TemplateGrid> with a productId filter. Each card already links
// out to /customize?template=<id> via the TemplateStoreProvider's
// customizeUrl factory (configured in AppRouter).
//
// We render this even if there are zero templates yet — the empty
// state from TemplateGrid is helpful messaging on its own ("No
// designs match your filters"). If/when the customer wants to design
// from scratch we surface a "Start blank" link that mounts /customize
// with the product's default size, skipping the template-required path.
import { TemplateGrid } from 'template-manager';
import { useAuthContext } from '@khalidadil/auth-manager';
import { useFacetCatalog } from '../../hooks/useFacetCatalog';
export function TemplateGalleryForProduct({ product }) {
const firstSize = (product.sizes || [])[0];
// Facet personalization (Project doc §R10) — narrow the design list
// to the customer's selected facet values (plus universal/General).
// Only when a facet is configured AND the customer has a selection;
// otherwise show all designs.
const { facetValues } = useAuthContext();
const { config: facetConfig } = useFacetCatalog();
const facetFilter = facetConfig.enabled && facetValues.length ? { facetValues } : {};
return (
<section className="shop-templates" id="templates">
<header className="shop-templates__head">
<h2 className="shop-templates__title">Pick a design to customize</h2>
<p className="shop-templates__sub">
Start with one of our designs — you can swap photos, change text, and
make it yours.
</p>
</header>
<TemplateGrid
filter={{ productId: product.id, ...facetFilter }}
emptyMessage="No designs for this product yet. Check back soon!"
className="shop-templates__grid"
/>
{firstSize && (
<div className="shop-templates__blank">
<p className="shop-templates__blank-sub">…or start from scratch:</p>
<a
href={`/customize?productId=${encodeURIComponent(product.id)}&sizeId=${encodeURIComponent(firstSize.id)}`}
className="shop-templates__blank-cta"
>
Start with a blank canvas →
</a>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,174 @@
/**
* Phone model catalog for the phone-case product type.
*
* Coverage: iPhone 13 through iPhone 17 (21 models).
*
* Schema per entry
* ─────────────────
* id: kebab-case stable identifier (used in template IDs,
* URLs, REST payloads)
* label: human-readable name shown in pickers
* series: '13' | '14' | '15' | '16' | '17' (for grouping)
* body: { heightMm, widthMm, thicknessMm } — external case
* dimensions, source: Apple specs + tech press as of
* May 2026.
* canvas: { width, height } — pixel dimensions of the editor
* canvas. Aspect ratio matches body height:width
* because the phone case wraps the back of the phone.
* Canvas size 600 (vs 300 for shirts) gives finer
* resolution for the typically-smaller phone case
* designs.
* cameraRegion: placeholder rectangle (or rectangles) in canvas
* coordinates marking the camera bump cutout — the
* non-printable area. Approximate for v1; production
* ranges should refine these against real die-line
* specs from the print shop.
* silhouette: SVG path of the case outline in canvas coords.
* v1: rounded-rect approximation; production should
* use the actual die-line.
*
* Why placeholder camera regions
* ──────────────────────────────
* The plan explicitly says variant catalog ships as placeholders. Camera
* regions on real production cases need exact die-line measurements from
* the print shop — those come during pre-launch QA, not now. The
* placeholder values are visually plausible (right corner, right size)
* so the editor's UX of "you can't put your design here" works end-to-end
* for testing. Replace with real die-line coordinates before launch.
*/
// Canvas height (longest dimension); width is derived from each model's
// body aspect ratio. Square-ish canvases tend to give the editor's
// transformer controls room without the controls cropping off-screen.
const CANVAS_LONG_EDGE = 600;
function buildCanvas(heightMm, widthMm) {
// Phone is taller than wide; canvas height matches the long edge.
const aspect = widthMm / heightMm;
return {
width: Math.round(CANVAS_LONG_EDGE * aspect),
height: CANVAS_LONG_EDGE,
};
}
// Camera-region pattern helpers. Each returns a rectangle (or array of
// them for compound bumps) sized as a fraction of the canvas, top-left
// origin. The actual phone case manufacturer crops circles inside these
// rects; we use rects because they're the conservative non-printable
// area that contains the cutouts.
function squareCorner(canvas) {
// iPhone 13-15 base: square bump in top-left corner.
const w = canvas.width * 0.32;
const h = canvas.width * 0.32; // square in canvas units
return [{ x: canvas.width * 0.05, y: canvas.width * 0.05, width: w, height: h }];
}
function verticalStrip(canvas) {
// iPhone 16 / 16e / 16-plus / 17 / 17-air: vertical pill, top-left.
const w = canvas.width * 0.14;
const h = canvas.width * 0.34;
return [{ x: canvas.width * 0.06, y: canvas.width * 0.05, width: w, height: h }];
}
function triangularBump(canvas) {
// iPhone 16-pro / 16-pro-max: triangular three-lens cluster top-left.
// Use the conservative bounding rect.
const w = canvas.width * 0.34;
const h = canvas.width * 0.34;
return [{ x: canvas.width * 0.04, y: canvas.width * 0.04, width: w, height: h }];
}
function plateauBar(canvas) {
// iPhone 17-pro / 17-pro-max: horizontal "plateau" bar spanning most
// of the back top edge.
const w = canvas.width * 0.88;
const h = canvas.width * 0.18;
return [{ x: canvas.width * 0.06, y: canvas.width * 0.04, width: w, height: h }];
}
// Helper: build a rounded-rect silhouette path in canvas coords.
// (M…L…A…Z form; corner radius ≈ 8% of width which matches Apple's
// hardware roundness reasonably for v1 visualization.)
function silhouettePath(canvas) {
const w = canvas.width;
const h = canvas.height;
const r = Math.round(w * 0.08);
return `M${r},0 L${w - r},0 A${r},${r} 0 0 1 ${w},${r} L${w},${h - r} A${r},${r} 0 0 1 ${w - r},${h} L${r},${h} A${r},${r} 0 0 1 0,${h - r} L0,${r} A${r},${r} 0 0 1 ${r},0 Z`;
}
function buildModel({ id, label, series, heightMm, widthMm, thicknessMm, cameraPattern }) {
const canvas = buildCanvas(heightMm, widthMm);
return {
id,
label,
series,
body: { heightMm, widthMm, thicknessMm },
canvas,
cameraRegion: cameraPattern(canvas),
silhouettePath: silhouettePath(canvas),
};
}
// Dimensions from Apple official specs + tech-press round-ups (Apr–May 2026):
// • iPhone 13 line: Apple spec sheets
// • iPhone 14 line: Apple spec sheets
// • iPhone 15 line: Apple spec sheets
// • iPhone 16 line + 16e: Apple spec sheets, ecoATM/SimplyMac aggregates
// • iPhone 17 line: TechCrunch lineup comparison + Apple specs
export const PHONE_MODELS = Object.fromEntries([
// iPhone 13 (4 models) — square camera top-left
buildModel({ id: 'iphone-13-mini', label: 'iPhone 13 mini', series: '13', heightMm: 131.5, widthMm: 64.2, thicknessMm: 7.65, cameraPattern: squareCorner }),
buildModel({ id: 'iphone-13', label: 'iPhone 13', series: '13', heightMm: 146.7, widthMm: 71.5, thicknessMm: 7.65, cameraPattern: squareCorner }),
buildModel({ id: 'iphone-13-pro', label: 'iPhone 13 Pro', series: '13', heightMm: 146.7, widthMm: 71.5, thicknessMm: 7.65, cameraPattern: squareCorner }),
buildModel({ id: 'iphone-13-pro-max', label: 'iPhone 13 Pro Max', series: '13', heightMm: 160.8, widthMm: 78.1, thicknessMm: 7.65, cameraPattern: squareCorner }),
// iPhone 14 (4 models) — square camera top-left, slightly larger on Pros
buildModel({ id: 'iphone-14', label: 'iPhone 14', series: '14', heightMm: 146.7, widthMm: 71.5, thicknessMm: 7.8, cameraPattern: squareCorner }),
buildModel({ id: 'iphone-14-plus', label: 'iPhone 14 Plus', series: '14', heightMm: 160.8, widthMm: 78.1, thicknessMm: 7.8, cameraPattern: squareCorner }),
buildModel({ id: 'iphone-14-pro', label: 'iPhone 14 Pro', series: '14', heightMm: 147.5, widthMm: 71.5, thicknessMm: 7.85, cameraPattern: squareCorner }),
buildModel({ id: 'iphone-14-pro-max', label: 'iPhone 14 Pro Max', series: '14', heightMm: 160.7, widthMm: 77.6, thicknessMm: 7.85, cameraPattern: squareCorner }),
// iPhone 15 (4 models) — square camera continues
buildModel({ id: 'iphone-15', label: 'iPhone 15', series: '15', heightMm: 147.6, widthMm: 71.6, thicknessMm: 7.8, cameraPattern: squareCorner }),
buildModel({ id: 'iphone-15-plus', label: 'iPhone 15 Plus', series: '15', heightMm: 160.9, widthMm: 77.8, thicknessMm: 7.8, cameraPattern: squareCorner }),
buildModel({ id: 'iphone-15-pro', label: 'iPhone 15 Pro', series: '15', heightMm: 146.6, widthMm: 70.6, thicknessMm: 8.25, cameraPattern: squareCorner }),
buildModel({ id: 'iphone-15-pro-max', label: 'iPhone 15 Pro Max', series: '15', heightMm: 159.9, widthMm: 76.7, thicknessMm: 8.25, cameraPattern: squareCorner }),
// iPhone 16 (5 models) — vertical strip on base/16e/Plus, triangular on Pros
buildModel({ id: 'iphone-16e', label: 'iPhone 16e', series: '16', heightMm: 138.4, widthMm: 67.0, thicknessMm: 7.8, cameraPattern: verticalStrip }),
buildModel({ id: 'iphone-16', label: 'iPhone 16', series: '16', heightMm: 147.6, widthMm: 71.6, thicknessMm: 7.8, cameraPattern: verticalStrip }),
buildModel({ id: 'iphone-16-plus', label: 'iPhone 16 Plus', series: '16', heightMm: 160.9, widthMm: 77.8, thicknessMm: 7.8, cameraPattern: verticalStrip }),
buildModel({ id: 'iphone-16-pro', label: 'iPhone 16 Pro', series: '16', heightMm: 149.6, widthMm: 71.5, thicknessMm: 8.25, cameraPattern: triangularBump }),
buildModel({ id: 'iphone-16-pro-max', label: 'iPhone 16 Pro Max', series: '16', heightMm: 163.0, widthMm: 77.6, thicknessMm: 8.25, cameraPattern: triangularBump }),
// iPhone 17 (4 models) — Pros get the new horizontal "plateau" bar
buildModel({ id: 'iphone-17', label: 'iPhone 17', series: '17', heightMm: 149.6, widthMm: 71.5, thicknessMm: 7.95, cameraPattern: verticalStrip }),
buildModel({ id: 'iphone-17-air', label: 'iPhone Air', series: '17', heightMm: 156.2, widthMm: 74.7, thicknessMm: 5.64, cameraPattern: verticalStrip }),
buildModel({ id: 'iphone-17-pro', label: 'iPhone 17 Pro', series: '17', heightMm: 150.0, widthMm: 71.9, thicknessMm: 8.75, cameraPattern: plateauBar }),
buildModel({ id: 'iphone-17-pro-max', label: 'iPhone 17 Pro Max', series: '17', heightMm: 163.4, widthMm: 78.0, thicknessMm: 8.75, cameraPattern: plateauBar }),
].map((m) => [m.id, m]));
/**
* Default phone model ID used when the user lands on the phone-case
* customize route without specifying a model. Picks the most common
* current-generation flagship as of catalog timestamp (May 2026).
*/
export const DEFAULT_PHONE_MODEL_ID = 'iphone-16';
/**
* Group phone models by series for menus. The picker UI typically
* shows series headers ("iPhone 17", "iPhone 16", ...) with the
* models under each.
*/
export function groupBySeries(models = PHONE_MODELS) {
const groups = {};
for (const model of Object.values(models)) {
if (!groups[model.series]) groups[model.series] = [];
groups[model.series].push(model);
}
// Sort each series by canvas height descending (biggest first within
// a generation feels right in pickers).
for (const series of Object.keys(groups)) {
groups[series].sort((a, b) => b.body.heightMm - a.body.heightMm);
}
// Return series in DESCENDING order (newest first).
return Object.entries(groups).sort(([a], [b]) => b.localeCompare(a));
}

View File

@@ -1,89 +1,69 @@
/**
* Product type configuration for the Pawfectly Yours editor.
* Legacy single-product stub.
*
* The frontend rendering is parameterized by these objects so that the same
* editor code can drive shirts, magnets, earrings, stickers, and so on. Only
* code reads this — there is intentionally no UI for picking the active
* product.
* v0.6 — the host no longer owns a runtime product catalog. The
* canonical catalog lives in template-manager (productsStore) and is
* seeded once from src/constants/seedProducts.js.
*
* Coordinate systems
* ──────────────────
* Every product has TWO coordinate systems:
* What's left in this file
* ────────────────────────
* The legacy single-product editor flow at `/` (apparel-designer's
* App.jsx) calls `getActiveProduct()` and reads a small set of
* shape-only fields off the result: `canvasSize`, `printZone`,
* `productSvg`, `background`. We keep a minimal stub here so that
* flow continues booting without re-architecting it for the v0.6
* data shape.
*
* 1. **Design coordinates** (`canvasSize`) — the integer space in which
* individual elements (images, text, stickers) live. Element x/y/width/
* height are in these units. The server export pipeline scales from
* `canvasSize` to the final 4500×4500 print resolution.
*
* 2. **Product SVG coordinates** (`productSvg.width / .height`) — the
* viewBox of the product mockup that's drawn behind the design canvas.
* The `printZone` field positions the design canvas inside the product
* mockup, in these units.
*
* Keeping `canvasSize` at 300 (the historical value) means existing templates
* and the server-side export math do not need to change. The visual fit
* (canvas overlapping the print zone of the product) is achieved by CSS
* scaling the rendered Konva Stage at runtime.
* Migration intent
* ────────────────
* The legacy `/` flow is slated for retirement; the customer-facing
* surface is `/customize?template=…` which reads the template's
* embedded variant. When the legacy flow is removed, this file goes
* with it.
*/
export const PRODUCT_TYPES = {
shirt: {
// Bare shirt shape with just enough fields for the legacy
// getActiveProduct() consumers in App.jsx + the editor's
// legacy single-product code paths.
const LEGACY_SHIRT_STUB = {
id: 'shirt',
label: 'T-Shirt',
canvasSize: 300,
// Where on the product mockup the design canvas should overlay, in
// product SVG coordinates. Sized roughly to a real DTG print zone for
// a youth-medium tee.
//
// Note: the product SVG's viewBox is offset (starts at 40, 50) to crop
// empty margin around the shirt outline. printZone coords are in that
// same viewBox space — i.e. (90, 130) is what the chest landed at
// after the crop, not the path's raw (130, 180).
printZone: { x: 90, y: 130, width: 220, height: 220 },
productSvg: {
// Component name (looked up by DesignCanvas) and the dimensions of its
// viewBox. Must match the actual SVG.
component: 'shirt',
width: 400,
height: 470,
},
background: {
// 'color' = user can pick from SHIRT_COLORS
// 'fixed' = use background.color as a constant fill
mode: 'color',
defaultColorId: 'white',
},
},
// Reference shapes for future products. None of these are wired into the
// UI yet, but the DesignCanvas/ProductSVG plumbing is ready to handle them.
//
// magnet: {
// id: 'magnet',
// label: 'Magnet',
// canvasSize: 300,
// printZone: { x: 30, y: 30, width: 240, height: 240 },
// productSvg: { component: 'magnet', width: 300, height: 300 },
// background: { mode: 'fixed', color: '#f4f1ee' },
// },
// earring: { ... },
// sticker: { ... },
productSvg: { component: 'shirt', width: 400, height: 470 },
background: { mode: 'color', defaultColorId: 'white' },
// No variants — v0.6 catalog lives in template-manager.
variants: [],
};
/**
* Which product the editor is currently configured for. Change this constant
* to point at a different `PRODUCT_TYPES` entry. There's intentionally no UI
* exposure — switching products is a code change.
* Return the active product for the legacy single-product editor.
* Always returns the shirt stub in v0.6.
*/
export function getActiveProduct() {
return LEGACY_SHIRT_STUB;
}
// ── Deprecated exports ────────────────────────────────────────
//
// These names are referenced in places that haven't been swept yet.
// They resolve to the legacy stub or to undefined so any straggling
// importer doesn't crash on module load. Will be removed in a
// follow-up cleanup pass.
/** @deprecated Use seedProducts.DEFAULT_PRODUCT_ID. */
export const DEFAULT_PRODUCT_TYPE_ID = 'shirt';
/** @deprecated Use seedProducts.DEFAULT_SIZE_ID. */
export const DEFAULT_VARIANT_ID = 'shirt-default';
/** @deprecated Use seedProducts. */
export const ACTIVE_PRODUCT_ID = 'shirt';
export function getActiveProduct() {
const product = PRODUCT_TYPES[ACTIVE_PRODUCT_ID];
if (!product) {
throw new Error(
`Unknown ACTIVE_PRODUCT_ID="${ACTIVE_PRODUCT_ID}". `
+ `Valid ids: ${Object.keys(PRODUCT_TYPES).join(', ')}`
);
}
return product;
/** @deprecated Use seedProducts; map productId/sizeId via the products API. */
export const PRODUCT_TYPES = { shirt: LEGACY_SHIRT_STUB };
/** @deprecated Use the resolved variant from a template GET response. */
export function getVariant(_productTypeId, _variantId) {
return null;
}

View File

@@ -0,0 +1,445 @@
/**
* Seed product catalog — used by template-manager's mountTemplateStore
* to populate `_products_index.json` on first boot only.
*
* v0.6 — replaces the pre-v0.6 PRODUCT_TYPES constant. The host no
* longer owns the catalog at runtime; this file is read once at boot
* and the data is then persisted in the storage dir.
*
* UUIDs are hardcoded so re-running the seed (after a wipe) produces
* the same identifiers, and so any test fixtures / docs that
* reference these IDs stay stable.
*
* Schema (per product)
* ────────────────────
* { id, slug, label, description, category, kind, options[], sizes[] }
* kind: 'customizable' | 'fixed' | 'material' (v0.7 + v0.8)
*
* Schema (per option value)
* ─────────────────────────
* { id, slug, label, hex?, priceCents }
* priceCents is v0.8 — non-negative integer, default 0; the unit
* price the customer pays is `size.basePriceCents + sum(selected
* values' priceCents)`.
*
* Schema (per size)
* ─────────────────
* { id, slug, label,
* physical: { width, height, unit: 'in' },
* canvas: { width, height } | null, // null for fixed + material
* printZone: { x, y, width, height } | null, // null for fixed + material
* silhouettePath, mockupFallbackColor, basePriceCents,
* defaultParcel: { length_in, width_in, height_in, weight_oz } } // v0.8
*
* Canvas convention
* ─────────────────
* 100 canvas units per physical inch (per locked decision §6). Exports
* at 300 DPI map cleanly via 3× resample on the export pipeline.
*
* Print zone convention
* ─────────────────────
* 10% inset from canvas edges by default. Each size carries an
* explicit rectangle so the admin can tune per-size if needed.
*/
// ── Pre-allocated UUIDs ────────────────────────────────────────
//
// Hardcoded so the seed is idempotent: wiping the storage dir and
// re-seeding produces identical IDs, which means existing template
// references (if any survived the wipe) still resolve.
//
// Naming: P_<slug> for products, S_<slug>_<size-slug> for sizes,
// O_<slug>_<option-slug> for option groups, V_<slug>_<option-slug>_<value-slug>
// for option values.
const P_SHIRT = 'c3ac22c7-ef49-4997-a525-d4f0eb32da6f';
const P_HOODIE = '6202b99a-a4a9-4f11-8b2d-32c17516ecfa';
const P_MAGNET = 'f530dc06-ef10-46b4-9903-3927f85bd9a7';
const P_MUG = '951cc0aa-6b28-460a-ad31-8ac8fdcc5ebd';
const P_POSTER = '95dca14e-28e4-46e8-93b1-b45e3b556530';
const P_TOTE = '1d69616b-96a8-4280-9beb-c8926222c589';
const P_PILLOW = 'c722b19b-d6c6-4639-b0eb-dec34731e50d';
const P_NOTEBOOK = '1ed028ad-1998-4ace-813b-79023bd08c1d';
const P_STICKER = '04507704-50a9-4de9-9b23-475d6b76bf0e';
const P_HAT = 'cb43b749-05c0-4d1c-8d0b-dadd9b28574d';
const P_PHONE_CASE = 'f5cbb673-8696-4dad-a63c-3a802ba53fd3';
// v0.7 — first fixed-kind (ready-made) product. No customization;
// customer picks size + color and adds to cart.
const P_RABBIT_TEE = '8b1f4c2d-3e9a-4b76-9c8e-2a1d5f6e7b89';
const S_SHIRT_S = '3df58f51-70ce-4898-8091-2bb186b04193';
const S_SHIRT_M = '86787c88-c4e0-4798-a34f-d1e173b2704b';
const S_SHIRT_L = '8cf5377f-8c6b-4afe-80c3-9337e6303176';
const S_SHIRT_XL = 'ecc7707a-1703-4c27-9da7-6b39709837ba';
const S_SHIRT_XXL = '3667ec33-6123-44bd-a12e-5fdfef3c2874';
const S_HOODIE_S = 'f002d98e-6601-46cb-9ed0-ad6608a794ac';
const S_HOODIE_M = '13849307-bed4-4654-9476-fb135561f05f';
const S_HOODIE_L = 'b327d6c5-8f0b-4639-b8ec-8d3f4ec74d1a';
const S_HOODIE_XL = 'af9ed947-e705-4205-87b0-6c529ce6b342';
const S_HOODIE_XXL = '83156320-5f30-4cf8-b28c-3f69fcf7887e';
const S_MAGNET_3X3 = '6f88ed75-7ab5-49ae-8dc5-18568da63899';
const S_MAGNET_4X4 = '3a9d60f5-821c-4f3f-8d41-62cfeaba208a';
const S_MAGNET_ROUND = '17e3cc66-f897-4717-8b08-c5b0308668df';
const S_MUG_11OZ = 'ccaf7033-3e43-4171-9cde-fe6be676ea05';
const S_MUG_15OZ = 'ae4ad837-6669-4491-af8e-445f90794779';
const S_POSTER_12X18 = '7d015072-f884-4e04-8a39-ce7c8e149059';
const S_POSTER_18X24 = '9a5529fd-e54e-4868-acbb-5a62c44d1297';
const S_TOTE_14X14 = '61e5519d-1230-4397-a795-7303a30e0881';
const S_PILLOW_18 = '22d04708-d964-4cdc-a682-fa9c02372a88';
const S_NOTEBOOK_A5 = 'add070b4-1fa3-47bb-84a5-df8d99c2b821';
const S_STICKER_3 = '6744bd80-e64b-4351-a0b8-9316dd138db5';
const S_HAT_CAP = '778c4b47-a46b-4753-8fa2-be8af1523f4a';
const S_PHONE_DEFAULT = '4be821c2-8ba5-4dd1-ad0b-1a188dd89796';
// Rabbit Tee (v0.7 fixed product) — same 5 apparel sizes as the
// shirt, with the same shape but distinct UUIDs so they're separable
// in the index + audit log.
const S_RABBIT_S = 'a1b2c3d4-e5f6-4a78-8b9c-1d2e3f4a5b6c';
const S_RABBIT_M = 'b2c3d4e5-f6a7-4b89-9c8d-2e3f4a5b6c7d';
const S_RABBIT_L = 'c3d4e5f6-a7b8-4c9d-8a9e-3f4a5b6c7d8e';
const S_RABBIT_XL = 'd4e5f6a7-b8c9-4d8e-9b8a-4a5b6c7d8e9f';
const S_RABBIT_XXL = 'e5f6a7b8-c9d8-4e8f-8c9b-5b6c7d8e9f8a';
// Option group + value UUIDs (shirt + hoodie colors).
const O_SHIRT_COLOR = '9d58a8b1-5976-4512-9095-883f16a4c1b8';
const V_SHIRT_WHITE = '29092f97-2654-4150-89e5-7a323eb891e4';
const V_SHIRT_BLACK = 'c25f746a-efc2-4213-9328-728ac6881bc6';
const V_SHIRT_PINK = '8e077db8-bc48-45c0-a3e1-df10b6f1367f';
const O_HOODIE_COLOR = '49a9bd93-84c9-4e9c-a4e6-c1e742c6777a';
const V_HOODIE_BLACK = '4e8d2854-8f0e-4c99-b854-8c0e07b59a49';
const V_HOODIE_NAVY = '618c49fc-9fc6-486b-9d83-a2fff373092f';
const V_HOODIE_GRAY = '0d1d8ec2-c7b9-40c9-9da8-9b5f1c6d1e3f';
// Rabbit tee (v0.7) color option.
const O_RABBIT_COLOR = 'f6a7b8c9-d8e9-4f8a-9d8e-6c7d8e9f8a7b';
const V_RABBIT_WHITE = 'a7b8c9d8-e9f8-4a9b-8e9f-7d8e9f8a7b6c';
const V_RABBIT_BLACK = 'b8c9d8e9-f8a7-4b8c-9f8a-8e9f8a7b6c5d';
const V_RABBIT_PINK = 'c9d8e9f8-a7b6-4c8d-8a7b-9f8a7b6c5d4e';
// ── Geometry helpers ──────────────────────────────────────────
//
// Same helpers the pre-v0.6 PRODUCT_TYPES used, lifted here so the
// seed file is self-contained (no import from the legacy products.js,
// which is being stripped down in this same PR).
function roundedRectPath(w, h, r) {
return `M${r},0 L${w - r},0 A${r},${r} 0 0 1 ${w},${r} L${w},${h - r} A${r},${r} 0 0 1 ${w - r},${h} L${r},${h} A${r},${r} 0 0 1 0,${h - r} L0,${r} A${r},${r} 0 0 1 ${r},0 Z`;
}
function circlePath(r) {
return `M${r},0 A${r},${r} 0 1 0 ${r},${2 * r} A${r},${r} 0 1 0 ${r},0 Z`;
}
/**
* Build a printZone rect inset by `pct` (0..1) from canvas edges.
*/
function insetPrintZone(canvasW, canvasH, pct = 0.1) {
const insetW = Math.round(canvasW * pct);
const insetH = Math.round(canvasH * pct);
return {
x: insetW,
y: insetH,
width: canvasW - 2 * insetW,
height: canvasH - 2 * insetH,
};
}
/**
* Build a size record for a fixed (ready-made) product. No canvas,
* no print zone, no silhouette — just physical dimensions + price.
* Used by v0.7 fixed-kind products like the rabbit tee.
*/
function makeFixedSize({ id, slug, label, widthIn, heightIn, fallbackColor, basePriceCents = 0 }) {
return {
id,
slug,
label,
physical: { width: widthIn, height: heightIn, unit: 'in' },
canvas: null,
printZone: null,
silhouettePath: null,
mockupFallbackColor: fallbackColor,
basePriceCents,
};
}
/**
* Build a size record for a rectangular product with physical inches
* x 100 canvas units. The helper centralizes the conversion so a
* future canvas-units change (e.g. moving to 144 units/inch) is a
* one-place edit.
*/
function makeRectSize({ id, slug, label, widthIn, heightIn, fallbackColor, basePriceCents = 0, printZonePct = 0.1, cornerPct = 0.04 }) {
const cW = widthIn * 100;
const cH = heightIn * 100;
const r = Math.round(Math.min(cW, cH) * cornerPct);
return {
id,
slug,
label,
physical: { width: widthIn, height: heightIn, unit: 'in' },
canvas: { width: cW, height: cH },
printZone: insetPrintZone(cW, cH, printZonePct),
silhouettePath: roundedRectPath(cW, cH, r),
mockupFallbackColor: fallbackColor,
basePriceCents,
};
}
function makeCircleSize({ id, slug, label, diameterIn, fallbackColor, basePriceCents = 0, printZonePct = 0.1 }) {
const c = diameterIn * 100;
const r = c / 2;
const inset = Math.round(c * printZonePct);
return {
id,
slug,
label,
physical: { width: diameterIn, height: diameterIn, unit: 'in' },
canvas: { width: c, height: c },
printZone: { x: inset, y: inset, width: c - 2 * inset, height: c - 2 * inset },
silhouettePath: circlePath(r),
mockupFallbackColor: fallbackColor,
basePriceCents,
};
}
// ── Seed catalog ──────────────────────────────────────────────
export const SEED_PRODUCTS = [
// ── T-Shirt ────────────────────────────────────────────────
{
id: P_SHIRT,
slug: 'shirt',
label: 'T-Shirt',
description: 'Soft cotton tee, printed on the front.',
category: 'Apparel',
options: [
{
id: O_SHIRT_COLOR,
slug: 'color',
label: 'Color',
kind: 'color',
values: [
{ id: V_SHIRT_WHITE, slug: 'white', label: 'White', hex: '#ffffff' },
{ id: V_SHIRT_BLACK, slug: 'black', label: 'Black', hex: '#1f2937' },
{ id: V_SHIRT_PINK, slug: 'pink', label: 'Dusty Pink', hex: '#fbcfe8' },
],
},
],
sizes: [
makeRectSize({ id: S_SHIRT_S, slug: 's', label: 'Small', widthIn: 12, heightIn: 16, fallbackColor: '#f4f4f5', basePriceCents: 2200 }),
makeRectSize({ id: S_SHIRT_M, slug: 'm', label: 'Medium', widthIn: 12, heightIn: 16, fallbackColor: '#f4f4f5', basePriceCents: 2200 }),
makeRectSize({ id: S_SHIRT_L, slug: 'l', label: 'Large', widthIn: 12, heightIn: 16, fallbackColor: '#f4f4f5', basePriceCents: 2200 }),
makeRectSize({ id: S_SHIRT_XL, slug: 'xl', label: 'X-Large', widthIn: 12, heightIn: 16, fallbackColor: '#f4f4f5', basePriceCents: 2400 }),
makeRectSize({ id: S_SHIRT_XXL, slug: 'xxl', label: 'XX-Large', widthIn: 12, heightIn: 16, fallbackColor: '#f4f4f5', basePriceCents: 2600 }),
],
},
// ── Hoodie ─────────────────────────────────────────────────
{
id: P_HOODIE,
slug: 'hoodie',
label: 'Hoodie',
description: 'Heavyweight pullover hoodie with front-and-back print options.',
category: 'Apparel',
options: [
{
id: O_HOODIE_COLOR,
slug: 'color',
label: 'Color',
kind: 'color',
values: [
{ id: V_HOODIE_BLACK, slug: 'black', label: 'Black', hex: '#111827' },
{ id: V_HOODIE_NAVY, slug: 'navy', label: 'Navy', hex: '#1e3a8a' },
{ id: V_HOODIE_GRAY, slug: 'gray', label: 'Heather Gray', hex: '#9ca3af' },
],
},
],
sizes: [
makeRectSize({ id: S_HOODIE_S, slug: 's', label: 'Small', widthIn: 12, heightIn: 16, fallbackColor: '#475569', basePriceCents: 4800 }),
makeRectSize({ id: S_HOODIE_M, slug: 'm', label: 'Medium', widthIn: 12, heightIn: 16, fallbackColor: '#475569', basePriceCents: 4800 }),
makeRectSize({ id: S_HOODIE_L, slug: 'l', label: 'Large', widthIn: 12, heightIn: 16, fallbackColor: '#475569', basePriceCents: 4800 }),
makeRectSize({ id: S_HOODIE_XL, slug: 'xl', label: 'X-Large', widthIn: 12, heightIn: 16, fallbackColor: '#475569', basePriceCents: 5200 }),
makeRectSize({ id: S_HOODIE_XXL, slug: 'xxl', label: 'XX-Large', widthIn: 12, heightIn: 16, fallbackColor: '#475569', basePriceCents: 5400 }),
],
},
// ── Magnet ─────────────────────────────────────────────────
{
id: P_MAGNET,
slug: 'magnet',
label: 'Magnet',
description: 'Refrigerator-safe matte magnet, square or die-cut round.',
category: 'Home',
options: [],
sizes: [
makeRectSize({ id: S_MAGNET_3X3, slug: '3x3-square', label: '3" × 3" Square', widthIn: 3, heightIn: 3, fallbackColor: '#fef9c3', basePriceCents: 700 }),
makeRectSize({ id: S_MAGNET_4X4, slug: '4x4-square', label: '4" × 4" Square', widthIn: 4, heightIn: 4, fallbackColor: '#fef9c3', basePriceCents: 900 }),
makeCircleSize({ id: S_MAGNET_ROUND, slug: '3in-round', label: '3" Round', diameterIn: 3, fallbackColor: '#fde68a', basePriceCents: 700 }),
],
},
// ── Mug ────────────────────────────────────────────────────
{
id: P_MUG,
slug: 'mug',
label: 'Ceramic Mug',
description: 'Dishwasher-safe ceramic mug. Print wraps the body.',
category: 'Drinkware',
options: [],
sizes: [
makeRectSize({ id: S_MUG_11OZ, slug: '11oz', label: '11oz', widthIn: 8, heightIn: 3.25, fallbackColor: '#f8fafc', basePriceCents: 1500 }),
makeRectSize({ id: S_MUG_15OZ, slug: '15oz', label: '15oz', widthIn: 8.5, heightIn: 4.25, fallbackColor: '#f1f5f9', basePriceCents: 1800 }),
],
},
// ── Poster ─────────────────────────────────────────────────
{
id: P_POSTER,
slug: 'poster',
label: 'Poster',
description: 'Premium matte poster, suitable for framing.',
category: 'Wall Art',
options: [],
sizes: [
makeRectSize({ id: S_POSTER_12X18, slug: '12x18', label: '12" × 18"', widthIn: 12, heightIn: 18, fallbackColor: '#f3f4f6', basePriceCents: 1800 }),
makeRectSize({ id: S_POSTER_18X24, slug: '18x24', label: '18" × 24"', widthIn: 18, heightIn: 24, fallbackColor: '#e5e7eb', basePriceCents: 2400 }),
],
},
// ── Tote Bag ───────────────────────────────────────────────
{
id: P_TOTE,
slug: 'tote',
label: 'Tote Bag',
description: 'Sturdy canvas tote with reinforced straps.',
category: 'Accessories',
options: [],
sizes: [
makeRectSize({ id: S_TOTE_14X14, slug: '14x14', label: '14" × 14"', widthIn: 14, heightIn: 14, fallbackColor: '#fef3c7', basePriceCents: 1800 }),
],
},
// ── Pillow ─────────────────────────────────────────────────
{
id: P_PILLOW,
slug: 'pillow',
label: 'Throw Pillow',
description: 'Plush throw pillow with hidden zipper and insert.',
category: 'Home',
options: [],
sizes: [
makeRectSize({ id: S_PILLOW_18, slug: '18x18', label: '18" × 18"', widthIn: 18, heightIn: 18, fallbackColor: '#dcfce7', basePriceCents: 2400, cornerPct: 0.09 }),
],
},
// ── Notebook ───────────────────────────────────────────────
{
id: P_NOTEBOOK,
slug: 'notebook',
label: 'Notebook',
description: 'A5 hardcover notebook, 120 lined pages.',
category: 'Stationery',
options: [],
sizes: [
makeRectSize({ id: S_NOTEBOOK_A5, slug: 'a5', label: 'A5', widthIn: 5.5, heightIn: 8.5, fallbackColor: '#fafaf9', basePriceCents: 1500 }),
],
},
// ── Sticker ────────────────────────────────────────────────
{
id: P_STICKER,
slug: 'sticker',
label: 'Sticker',
description: 'Weatherproof vinyl sticker, die-cut to your design.',
category: 'Stationery',
options: [],
sizes: [
makeRectSize({ id: S_STICKER_3, slug: '3in-die-cut', label: '3" Die-Cut', widthIn: 3, heightIn: 3, fallbackColor: '#fce7f3', basePriceCents: 400 }),
],
},
// ── Hat ────────────────────────────────────────────────────
{
id: P_HAT,
slug: 'hat',
label: 'Hat',
description: 'Adjustable baseball cap with embroidered front print.',
category: 'Apparel',
options: [],
sizes: [
makeRectSize({ id: S_HAT_CAP, slug: 'cap', label: 'Baseball Cap', widthIn: 5, heightIn: 2, fallbackColor: '#fce7f3', basePriceCents: 2200, cornerPct: 0.15 }),
],
},
// ── Phone Case ─────────────────────────────────────────────
//
// v0.6 — per-phone-model sizing is deferred. The single seed size
// here uses a generic 2.7×5.4 inch canvas (~iPhone 13/14/15 wrap
// area). When per-model sizing lands, this product will gain a
// size per phone model (each with its own canvas).
{
id: P_PHONE_CASE,
slug: 'phone-case',
label: 'Phone Case',
description: 'Slim flexible case with edge-to-edge print.',
category: 'Accessories',
options: [],
sizes: [
makeRectSize({ id: S_PHONE_DEFAULT, slug: 'generic', label: 'Generic Wrap', widthIn: 2.7, heightIn: 5.4, fallbackColor: '#e0e7ff', basePriceCents: 2200, cornerPct: 0.1 }),
],
},
// ── Rabbit T-Shirt (v0.7 fixed-kind example) ───────────────
//
// First ready-made product in the catalog. Pre-printed with a rabbit
// illustration; customer picks size + color and adds to cart. No
// editor mount, no templates can be pinned to it (server rejects).
// images[] is empty at seed time — admin uploads gallery photos via
// the product editor's new Photos section.
{
id: P_RABBIT_TEE,
slug: 'rabbit-tee',
label: 'Rabbit T-Shirt',
description: 'Ready-made tee featuring our resident bunny mascot. Pre-printed, soft cotton, ships fast.',
category: 'Apparel',
kind: 'fixed',
options: [
{
id: O_RABBIT_COLOR,
slug: 'color',
label: 'Color',
kind: 'color',
values: [
{ id: V_RABBIT_WHITE, slug: 'white', label: 'White', hex: '#ffffff' },
{ id: V_RABBIT_BLACK, slug: 'black', label: 'Black', hex: '#1f2937' },
{ id: V_RABBIT_PINK, slug: 'pink', label: 'Dusty Pink', hex: '#fbcfe8' },
],
},
],
sizes: [
makeFixedSize({ id: S_RABBIT_S, slug: 's', label: 'Small', widthIn: 12, heightIn: 16, fallbackColor: '#fce7f3', basePriceCents: 2400 }),
makeFixedSize({ id: S_RABBIT_M, slug: 'm', label: 'Medium', widthIn: 12, heightIn: 16, fallbackColor: '#fce7f3', basePriceCents: 2400 }),
makeFixedSize({ id: S_RABBIT_L, slug: 'l', label: 'Large', widthIn: 12, heightIn: 16, fallbackColor: '#fce7f3', basePriceCents: 2400 }),
makeFixedSize({ id: S_RABBIT_XL, slug: 'xl', label: 'X-Large', widthIn: 12, heightIn: 16, fallbackColor: '#fce7f3', basePriceCents: 2600 }),
makeFixedSize({ id: S_RABBIT_XXL, slug: 'xxl', label: 'XX-Large', widthIn: 12, heightIn: 16, fallbackColor: '#fce7f3', basePriceCents: 2800 }),
],
images: [],
},
];
// ── Convenience exports ────────────────────────────────────────
//
// Hosts using the legacy default-product flow at `/` still expect a
// pair of `defaultProductId` + `defaultSizeId` they can hand to the
// editor. Pre-v0.6 these were string slugs; v0.6 they're UUIDs. The
// legacy App.jsx in apparel-designer reads these via the host
// constants module (src/constants/products.js) which now re-exports
// them from here.
export const DEFAULT_PRODUCT_ID = P_SHIRT;
export const DEFAULT_SIZE_ID = S_SHIRT_M;

View File

@@ -0,0 +1,28 @@
/**
* Template category catalog. Lifted from the legacy
* goods-editor TEMPLATE_CATEGORIES constant — the 8 categories that
* the original 8 templates used. Hosts can add categories ad-hoc
* (templates store category as a free-text field), but having a
* curated list makes the picker UI cleaner.
*
* The plan defers the more sophisticated "categories per product
* type" structure to a future iteration; v1 is a flat list.
*/
export const TEMPLATE_CATEGORIES = [
'All',
'Sports',
'Music',
'Quotes',
'Animals',
'Abstract',
'Vintage',
'Nature',
'Tech',
];
/**
* Default category for newly-created templates when admin hasn't
* picked one explicitly.
*/
export const DEFAULT_CATEGORY = null;

View File

@@ -0,0 +1,60 @@
// Host editor registry — dispatch on product.editorKind.
//
// This mirrors template-manager's editor-neutrality seams on the HOST
// side: the CMS decides WHICH editor a product uses (the opaque
// product.editorKind field); the host owns mounting that editor's admin +
// customer surfaces, rendering its marketing preview, and pricing. Each
// entry is an adapter; routes look one up by editorKind and render the
// component for the surface they're on.
//
// The base adapter contract (keep it editor-agnostic — do NOT leak
// goods-only concepts like canvasRegion / schemaVersion / fractional
// coords into it, or a third editor re-scatters the hardcoding):
//
// {
// editorKind: string, // matches product.editorKind
// label: string, // admin-facing name
// persistence: 'in-cms' | 'external',
// // 'in-cms' → template-manager owns the design (goods)
// // 'external'→ the editor owns it; the host keeps a ref
// // in product.typeConfig (e.g. sign-studio's
// // builderProjectId)
// CustomerCustomizer: React component // the buy-time editor surface
// AdminEditor: React component // the template-manager renderCanvas surface
// // (server-side) renderMarketingPreview + priceFromChange are wired
// // where they run: renderMarketingPreview registers into
// // template-manager's marketingPreviewRenderer({ renderers }) in
// // server.js; goods uses the built-in editorRenderer path today.
// }
//
// v0.9 — only 'goods' is registered. Adding a 'sign' adapter
// (@khalidadil/sign-studio) is the next increment; see the project vault's
// Multi_Editor_Architecture.md.
import { GoodsCustomizer } from './goods/GoodsCustomizer.jsx';
import { GoodsAdminEditor } from './goods/GoodsAdminEditor.jsx';
export const DEFAULT_EDITOR_KIND = 'goods';
export const editorRegistry = {
goods: {
editorKind: 'goods',
label: 'Apparel (goods-editor)',
persistence: 'in-cms',
CustomerCustomizer: GoodsCustomizer,
AdminEditor: GoodsAdminEditor,
},
};
/**
* Resolve the adapter for a product's editorKind. Returns null for an
* unregistered kind — callers render a clear "no editor" fallback rather
* than silently substituting goods (mirrors the CMS's no-goods-fallback
* validator rule). A missing/undefined kind defaults to 'goods', which is
* what every pre-seam product resolves to.
*
* @param {string} [editorKind]
* @returns {object|null}
*/
export function getEditorAdapter(editorKind) {
return editorRegistry[editorKind || DEFAULT_EDITOR_KIND] || null;
}

View File

@@ -0,0 +1,142 @@
// GoodsAdminEditor — host adapter for template-manager's TemplateEditorPanel
// `renderCanvas` prop, for the goods-editor. Moved out of AppRouter so the
// admin editing surface is dispatched through the host editorRegistry
// (see ../editorRegistry.js) instead of hardcoded. Logic is unchanged from
// the former AppRouter `AdminEditorCanvas`.
//
// v0.6 changes
// ────────────
// The variant used to come from getVariant(productTypeId, variantId)
// against the host's PRODUCT_TYPES. Now it comes from the template
// itself — the templates GET response embeds a `variant` object
// resolved server-side via productsStore. We just read it.
import { useState, useMemo, useCallback } from 'react';
import { ApparelDesigner, dematerializeTemplate } from 'goods-editor';
import { useAdminProducts } from 'template-manager';
import { SHIRT_COLORS, SHIRT_SIZES, SHIRT_BASE_PRICE_USD } from '../../constants/shirt';
import { PHONE_MODELS } from '../../constants/phoneModels';
export function GoodsAdminEditor({ metadata, version, updateVersion }) {
// metadata.variant is set by useTemplateDraft from the GET
// response (the route embeds product/size/variant). When a brand-
// new template has no productId yet (edge case during create),
// variant is null; we still mount the editor with a sensible
// default canvas size.
const variant = metadata?.variant || null;
// Marketing Gallery Entry (Round 6) — persist Marketing-tab edits to the
// product-level entryConfig. `entryConfigOverride` holds the admin's
// optimistic edits for THIS session so the editor's canvas showcase + the
// Marketing-tab Play preview reflect changes live; until they edit, the
// effective config is the server value resolved onto the variant. The
// dedicated PATCH persists it (productId comes from the resolved variant).
const { patchEntryConfig } = useAdminProducts();
const productId = variant?.productId || null;
const [entryConfigOverride, setEntryConfigOverride] = useState(null);
const effectiveEntryConfig = entryConfigOverride !== null ? entryConfigOverride : (variant?.entryConfig || null);
const handleEntryConfigChange = useCallback((next) => {
setEntryConfigOverride(next); // optimistic — keeps the canvas + Play preview in sync with the tab
if (!productId) return;
patchEntryConfig(productId, next).catch((err) => {
console.error('[admin editor] entry-config save failed:', err.message);
});
}, [patchEntryConfig, productId]);
const template = useMemo(() => {
if (!metadata) return null;
// Multi-view (2026-06-10, schema v4): the version is a `views`
// map; the editor's materializer reads only that. Templates are
// currently authored single-view here (no `views` prop is passed
// to ApparelDesigner below), so the editor synthesizes its
// 'default' view and the map round-trips as { default: {…} }.
return {
...metadata,
version: version
? {
views: version.views || {},
schemaVersion: version.schemaVersion ?? 4,
}
: null,
};
}, [metadata, version]);
// Multi-view (2026-06-10) — convert absolute pixel coords back to
// fractional on the way out. onViewsChange replaces the old
// onElementsChange write-through: it fires at the same commit
// boundaries but carries the FULL views map, so the draft always
// captures every view (today that's just 'default'; when this
// canvas grows a `views` prop nothing here changes).
// dematerializeTemplate's flat→default wrap is NOT used here — the
// editor hands us a map already.
const canvasSize = variant?.canvas?.width || 300;
const handleViewsChange = useCallback((viewsAbsMap) => {
const fractional = dematerializeTemplate({
views: viewsAbsMap,
canvasSize,
});
updateVersion({
views: fractional.views,
schemaVersion: fractional.schemaVersion,
});
}, [updateVersion, canvasSize]);
// Upload handler (2026-06-09 — slot-preview persistence). Mirrors
// CustomizeRoute's handler (POST /api/upload → server-hosted URL),
// minus the toast surface (admin shell has none here; failures log
// and rethrow — the editor's upload paths degrade gracefully).
//
// Why the admin canvas needs this: goods-editor's admin slot-preview
// pipeline persists el.slot.previewSrc ONLY when onUploadFile is
// wired — otherwise previews stay an in-session blob-URL overlay
// that vanishes on reload and never reaches the stored template, so
// the customer-side faded preview placeholder silently never works.
// It also keeps the admin Upload tab on server URLs instead of the
// FileReader data: fallback, which would otherwise dematerialize
// multi-MB base64 strings into the template version JSON.
const handleUploadFile = useCallback(async (file) => {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/upload', { method: 'POST', body: formData });
if (!response.ok) {
const errorText = await response.text().catch(() => '');
const message = `Upload failed (${response.status}${errorText ? `: ${errorText}` : ''})`;
console.error('[admin editor]', message);
throw new Error(message);
}
const data = await response.json();
return { url: data.preview.url, originalUrl: data.original.url };
}, []);
if (!template) {
return (
<p className="tm-editor__placeholder">
Loading template metadata…
</p>
);
}
return (
<div className="admin-editor-canvas">
<ApparelDesigner
shirtColors={SHIRT_COLORS}
shirtSizes={SHIRT_SIZES}
basePrice={SHIRT_BASE_PRICE_USD}
template={template}
variant={variant}
phoneModels={PHONE_MODELS}
loadFonts={false}
isAdmin
onViewsChange={handleViewsChange}
onUploadFile={handleUploadFile}
// Marketing Gallery Entry (Round 6) — admin authoring. entryMode
// 'direct' lands the admin in the editor (so the Stage + Marketing
// tabs are reachable; the Marketing tab's Play button previews the
// gallery); onEntryConfigChange persists Marketing-tab edits.
entryConfig={effectiveEntryConfig}
entryMode="direct"
onEntryConfigChange={handleEntryConfigChange}
/>
</div>
);
}

View File

@@ -0,0 +1,63 @@
// GoodsCustomizer — the goods-editor customer surface, extracted from
// CustomizeRoute so the editor mount is dispatched through the host
// editorRegistry (see ../editorRegistry.js) instead of hardcoded in the
// route. Behaviour is identical to the pre-registry inline mount; the
// route still owns fetching (the goods template + variant), the page
// chrome (Header/Toast), cart, and the editorRef proxies.
//
// A second editor's customer surface (e.g. a SignCustomizer adapter)
// implements the same role but reads its own inputs — hence the editor-
// specific catalog/branding constants + view/entry derivation live HERE,
// in the goods adapter, not in the route.
import { ApparelDesigner } from 'goods-editor';
import 'goods-editor/styles.css';
import { SHIRT_COLORS, SHIRT_SIZES, SHIRT_BASE_PRICE_USD } from '../../constants/shirt';
import { PHONE_MODELS } from '../../constants/phoneModels';
import { t } from '../../i18n/t';
export function GoodsCustomizer({
editorRef,
template,
variant,
hiddenStickerCategories,
onExportingChange,
onElementCountChange,
onCanClearChange,
onUploadFile,
onSave,
onAddToCart,
}) {
return (
<ApparelDesigner
ref={editorRef}
onExportingChange={onExportingChange}
onElementCountChange={onElementCountChange}
onCanClearChange={onCanClearChange}
// Catalog + branding (goods-specific).
shirtColors={SHIRT_COLORS}
shirtSizes={SHIRT_SIZES}
basePrice={SHIRT_BASE_PRICE_USD}
designFilenamePrefix="pawfectly-yours-design"
shareTitle={t('share.title')}
loadFonts={false}
// Pre-fetched template + variant (null in freeform mode).
template={template}
variant={variant}
// Per-view geometry — the resolved variant carries variant.views[].
views={variant?.views || null}
// Marketing gallery entry — product-level entryConfig resolved onto
// the variant. Gallery mode only when it has images; else land
// straight in the editor.
entryConfig={variant?.entryConfig || null}
entryMode={variant?.entryConfig?.galleryImages?.length ? 'gallery' : 'direct'}
phoneModels={PHONE_MODELS}
// Facet personalization — sticker deny-list (null ⇒ show all).
hiddenStickerCategories={hiddenStickerCategories}
// Callbacks (owned by the route).
onUploadFile={onUploadFile}
onSave={onSave}
onAddToCart={onAddToCart}
/>
);
}

172
src/hooks/useCart.js Normal file
View File

@@ -0,0 +1,172 @@
// useCart() — localStorage-backed shopping cart.
//
// Persists across reload at localStorage.pawfectly_cart_v1. Broadcasts
// changes across tabs via the storage event so the cart badge in
// every open tab stays in sync.
//
// Cart line shape:
// {
// id, // line id (generated; not a product/size id)
// productId, productLabel,
// sizeId, sizeLabel,
// optionChoices, // { [groupSlug]: valueSlug }
// quantity,
// unitPriceCents, // base + summed option upcharges (v0.8);
// // ProductBuyPanel rolls option priceCents
// // into this number before calling addItem.
// imageUrl, // thumbnail for the cart row
// }
//
// Returned shape:
// {
// items, count,
// totalCents,
// addItem(spec), // adds a new line; returns the line id
// updateItem(id, patch), // merges patch into an existing line
// removeItem(id),
// clear(),
// }
//
// Why localStorage and not a server cart
// ──────────────────────────────────────
// Checkout is downstream (locked decision #14: toast "Checkout coming
// soon"). Until there's an order endpoint, server-side cart state has
// no consumer. localStorage is enough to power the cart icon badge,
// the /cart route preview, and to survive a page reload. When a real
// checkout lands, this hook can hand off its items to a server endpoint
// or stay as the source of truth depending on the architecture chosen.
//
// Why not React Context
// ─────────────────────
// Only the header (cart badge) and /cart (line items) need the cart
// state. Both can mount their own useCart() instance and the storage-
// event listener keeps them in sync. A Context would require a
// provider above both routes; the storage-event approach works
// without it and across tabs.
import { useCallback, useEffect, useState } from 'react';
const STORAGE_KEY = 'pawfectly_cart_v1';
// Custom event we dispatch on the window after a local write so the
// other hook instances IN THE SAME TAB also re-render. The `storage`
// event only fires across tabs, not within the originating tab.
const CHANGE_EVENT = 'pawfectly:cart-change';
function readFromStorage() {
if (typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
// Corrupt JSON or storage disabled (private mode in some browsers).
// Treat as empty rather than crashing the page.
return [];
}
}
function writeToStorage(items) {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
// Notify same-tab listeners. Cross-tab listeners get the native
// 'storage' event from the browser.
window.dispatchEvent(new CustomEvent(CHANGE_EVENT));
} catch {
// Quota exceeded or storage blocked. Cart will be ephemeral but
// we don't surface this to the user — the cart still works in
// memory for the rest of the session.
}
}
function genLineId() {
// Short pseudo-random; uniqueness only needs to be within the cart,
// not globally. Format is intentionally distinct from UUIDs so logs
// don't mistake cart line ids for product/size ids.
return `line_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
export function useCart() {
const [items, setItems] = useState(() => readFromStorage());
// Cross-tab + same-tab sync. The native 'storage' event fires when
// a DIFFERENT tab writes; our custom event fires when the SAME tab
// writes via another useCart() instance.
useEffect(() => {
if (typeof window === 'undefined') return;
const sync = () => setItems(readFromStorage());
const onStorage = (e) => {
if (e.key === STORAGE_KEY) sync();
};
window.addEventListener('storage', onStorage);
window.addEventListener(CHANGE_EVENT, sync);
return () => {
window.removeEventListener('storage', onStorage);
window.removeEventListener(CHANGE_EVENT, sync);
};
}, []);
const addItem = useCallback((spec) => {
const line = {
id: genLineId(),
productId: spec.productId,
productLabel: spec.productLabel || '',
sizeId: spec.sizeId,
sizeLabel: spec.sizeLabel || '',
optionChoices: spec.optionChoices || {},
quantity: Math.max(1, Math.floor(Number(spec.quantity) || 1)),
unitPriceCents: Math.max(0, Math.floor(Number(spec.unitPriceCents) || 0)),
imageUrl: spec.imageUrl || null,
};
setItems((prev) => {
const next = [...prev, line];
writeToStorage(next);
return next;
});
return line.id;
}, []);
const updateItem = useCallback((id, patch) => {
setItems((prev) => {
const next = prev.map((it) => {
if (it.id !== id) return it;
const merged = { ...it, ...patch };
if (patch.quantity !== undefined) {
merged.quantity = Math.max(1, Math.floor(Number(patch.quantity) || 1));
}
return merged;
});
writeToStorage(next);
return next;
});
}, []);
const removeItem = useCallback((id) => {
setItems((prev) => {
const next = prev.filter((it) => it.id !== id);
writeToStorage(next);
return next;
});
}, []);
const clear = useCallback(() => {
setItems([]);
writeToStorage([]);
}, []);
// Derived values. Cheap to recompute every render — the cart is
// small (almost always under 10 lines).
const count = items.reduce((sum, it) => sum + (it.quantity || 0), 0);
const totalCents = items.reduce((sum, it) => sum + (it.unitPriceCents || 0) * (it.quantity || 0), 0);
return {
items,
count,
totalCents,
addItem,
updateItem,
removeItem,
clear,
};
}

View File

@@ -0,0 +1,78 @@
// useFacetCatalog — host-side read of the personalization-facet
// catalog: config + the active value vocabulary + the sticker→value
// assignment map. Read-only; the three public endpoints are HTTP-
// cached (max-age=300), so multiple mounts are cheap.
//
// Used by:
// • CustomizeRoute — to compute the editor's sticker deny-list
// (computeHiddenStickerCategories needs the assignments + the
// active value slugs; the customer's SELECTION comes separately
// from AuthContext.facetValues).
// • TemplateGalleryForProduct — only reads config.enabled to decide
// whether to add ?facetValues= to the template query.
//
// When no facet is configured (FACET_KEY unset on the server),
// config.enabled is false and callers no-op (master switch — §R10).
import { useEffect, useState } from 'react';
const DISABLED = {
enabled: false,
key: null,
label: null,
selectionMode: 'multi',
requireSelection: false,
};
export function useFacetCatalog({ apiBase = '/api' } = {}) {
const [config, setConfig] = useState(DISABLED);
const [values, setValues] = useState([]);
const [assignments, setAssignments] = useState({});
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
(async () => {
setLoading(true);
try {
const [cfgRes, valsRes, mapRes] = await Promise.all([
fetch(`${apiBase}/public/facet-config`),
fetch(`${apiBase}/public/facet-values`),
fetch(`${apiBase}/public/sticker-facet-map`),
]);
if (!alive) return;
if (cfgRes.ok) {
const b = await cfgRes.json();
setConfig({
enabled: !!b.enabled,
key: b.key || null,
label: b.label || null,
selectionMode: b.selectionMode === 'single' ? 'single' : 'multi',
requireSelection: !!b.requireSelection,
});
} else {
setConfig(DISABLED);
}
if (valsRes.ok) {
const b = await valsRes.json();
setValues(Array.isArray(b.items) ? b.items : []);
}
if (mapRes.ok) {
const b = await mapRes.json();
setAssignments(b.assignments || {});
}
} catch {
if (alive) setConfig(DISABLED);
} finally {
if (alive) setLoading(false);
}
})();
return () => { alive = false; };
}, [apiBase]);
// /public/facet-values returns active values only, so these are the
// active slugs the deny-list's deletion-safety check needs.
const activeValueSlugs = values.map((v) => v.slug);
return { config, values, activeValueSlugs, assignments, loading };
}

View File

@@ -0,0 +1,61 @@
// useShopProduct(slugOrId) — single-product fetcher for the detail page.
//
// Wraps GET /api/public/products/:idOrSlug. The server resolves slug
// → id internally, so we can pass either. The response includes:
//
// { id, slug, label, description, category, kind,
// options[], sizes[], images[], templateCount }
//
// Notes
// ─────
// • templateCount is server-computed (only relevant for customizable
// products; fixed products always get 0). Material products are
// never served via this endpoint — the public route returns 404.
// • sizes carry `mockupUrl` when a per-size mockup exists, otherwise
// null. The product page falls back to the first image in images[]
// for the gallery if no mockups exist.
// • v0.8: each option value carries `priceCents` (default 0) and
// each size carries `defaultParcel`. ProductBuyPanel reads the
// former for upcharge annotations + unit-price rollup; commerce-
// module will read the latter for Shippo rate quotes.
import { useEffect, useRef, useState } from 'react';
export function useShopProduct(slugOrId) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const genRef = useRef(0);
useEffect(() => {
if (!slugOrId) {
setProduct(null);
setLoading(false);
return;
}
const gen = ++genRef.current;
setLoading(true);
setError(null);
(async () => {
try {
const res = await fetch(`/api/public/products/${encodeURIComponent(slugOrId)}`);
if (gen !== genRef.current) return;
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setError(body);
setProduct(null);
return;
}
const data = await res.json();
setProduct(data);
} catch (err) {
if (gen !== genRef.current) return;
setError({ message: err.message });
} finally {
if (gen === genRef.current) setLoading(false);
}
})();
}, [slugOrId]);
return { product, loading, error };
}

View File

@@ -0,0 +1,93 @@
// useShopProducts({ kind, q }) — paginated public-product fetcher.
//
// Wraps GET /api/public/products with cursor pagination. Used by the
// shop home grid. Cursor pagination means each "Load more" call
// appends rather than replacing.
//
// Returned shape:
// { items, loading, error, hasMore, loadMore, refetch }
//
// Re-fetches from scratch when kind or q change. The cursor is reset
// each time the filter changes — otherwise we'd be paginating into a
// list that no longer matches the active filter.
import { useCallback, useEffect, useRef, useState } from 'react';
const DEFAULT_LIMIT = 24;
export function useShopProducts({ kind = 'all', q = '', limit = DEFAULT_LIMIT } = {}) {
const [items, setItems] = useState([]);
const [nextCursor, setNextCursor] = useState(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Bump on filter change to reset the list; also bump on manual refetch.
const [refetchTrigger, setRefetchTrigger] = useState(0);
const genRef = useRef(0);
const refetch = useCallback(() => setRefetchTrigger((n) => n + 1), []);
// Reset + fetch first page whenever filters or refetch trigger change.
useEffect(() => {
const gen = ++genRef.current;
setLoading(true);
setError(null);
setItems([]);
setNextCursor(null);
setHasMore(false);
(async () => {
try {
const params = new URLSearchParams();
if (kind && kind !== 'all') params.set('kind', kind);
if (q) params.set('q', q);
params.set('limit', String(limit));
const res = await fetch(`/api/public/products?${params.toString()}`);
if (gen !== genRef.current) return;
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setError(body);
return;
}
const data = await res.json();
setItems(data.items || []);
setNextCursor(data.nextCursor || null);
setHasMore(!!data.nextCursor);
} catch (err) {
if (gen !== genRef.current) return;
setError({ message: err.message });
} finally {
if (gen === genRef.current) setLoading(false);
}
})();
}, [kind, q, limit, refetchTrigger]);
// Paginate forward. We don't bump the gen counter here — we WANT
// the in-flight result to land into the existing list.
const loadMore = useCallback(async () => {
if (!nextCursor || loading) return;
setLoading(true);
try {
const params = new URLSearchParams();
if (kind && kind !== 'all') params.set('kind', kind);
if (q) params.set('q', q);
params.set('limit', String(limit));
params.set('cursor', nextCursor);
const res = await fetch(`/api/public/products?${params.toString()}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setError(body);
return;
}
const data = await res.json();
setItems((prev) => [...prev, ...(data.items || [])]);
setNextCursor(data.nextCursor || null);
setHasMore(!!data.nextCursor);
} catch (err) {
setError({ message: err.message });
} finally {
setLoading(false);
}
}, [kind, q, limit, nextCursor, loading]);
return { items, loading, error, hasMore, loadMore, refetch };
}

View File

@@ -53,16 +53,22 @@
export const en = {
// ── Header ──────────────────────────────────────────────────
// Save / Clear / Menu were removed from the header chrome during the
// module split — Save was redundant with Download (same export
// pipeline), Clear needs a module-side `clear()` method on the editor
// ref before it can be reintroduced, and Menu opened the host's own
// bottom sheet which is now inside the module. Their i18n keys are
// gone with them; readd if you put any of those buttons back.
// Save and Menu were removed from the header chrome during the module
// split. Save was redundant with Download (same export pipeline); Menu
// opened the host's own bottom sheet which is now inside the module.
// Their i18n keys are gone with them; readd if you put either back.
//
// Clear was also removed but came back in v0.8 once the module
// exposed a `clear()` method on its editor ref plus an
// `onCanClearChange` callback so the host can gate the button.
// See Header.jsx for the button and App.jsx / CustomizeRoute.jsx
// for the wiring.
'header.app-name': 'Pawfectly Yours',
'header.app-tagline': 'Wear Your Pet. Share Your Story.',
'header.page-title': 'Customize Your Shirt',
'header.logo-home-aria': '{app} home',
'header.clear': 'Clear canvas',
'header.clear-tooltip': 'Remove all elements and start over',
'header.preview': 'Print preview',
'header.preview-tooltip': 'See your design at print resolution',
'header.download': 'Download print file',

View File

@@ -1,16 +1,24 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { AuthProvider } from '@khalidadil/auth-manager'
import '@khalidadil/auth-manager/styles.css'
import './index.css'
import App from './App'
import App from './AppRouter'
// Service worker registration is handled inside <PWAInstall /> via the
// `useRegisterSW` hook from `virtual:pwa-register/react`. We removed the
// hand-rolled `navigator.serviceWorker.ready` listener that used to live here
// because (a) it didn't actually register the SW — it just listened for updates
// — and (b) it duplicated state the plugin already exposes via the hook.
// v0.8 — <AuthProvider> wraps the whole tree so useCustomer/useAdmin hooks
// share a single /api/auth/me fetch instead of each call site re-fetching.
// AuthProvider degrades gracefully if /api/auth/me returns 404 (host
// running without DATABASE_URL set), so this wrap is safe even when
// auth-manager is disabled server-side.
//
// SW registration lives inside <PWAInstall /> via the `useRegisterSW`
// hook — we removed the hand-rolled `navigator.serviceWorker.ready`
// listener that used to live here.
createRoot(document.getElementById('root')).render(
<StrictMode>
<AuthProvider apiBase="/api">
<App />
</AuthProvider>
</StrictMode>,
)

2782
src/styles/admin.css Normal file

File diff suppressed because it is too large Load Diff

1090
src/styles/shop.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
{
"products": {
"c3ac22c7-ef49-4997-a525-d4f0eb32da6f": {
"id": "c3ac22c7-ef49-4997-a525-d4f0eb32da6f",
"slug": "shirt",
"label": "T-Shirt",
"category": "Apparel",
"sizeCount": 5,
"createdAt": "2026-05-25T12:31:21.547Z",
"updatedAt": "2026-05-25T12:31:21.547Z"
},
"6202b99a-a4a9-4f11-8b2d-32c17516ecfa": {
"id": "6202b99a-a4a9-4f11-8b2d-32c17516ecfa",
"slug": "hoodie",
"label": "Hoodie",
"category": "Apparel",
"sizeCount": 5,
"createdAt": "2026-05-25T12:31:21.552Z",
"updatedAt": "2026-05-25T12:31:21.552Z"
},
"f530dc06-ef10-46b4-9903-3927f85bd9a7": {
"id": "f530dc06-ef10-46b4-9903-3927f85bd9a7",
"slug": "magnet",
"label": "Magnet",
"category": "Home",
"sizeCount": 3,
"createdAt": "2026-05-25T12:31:21.554Z",
"updatedAt": "2026-05-25T12:31:21.554Z"
},
"951cc0aa-6b28-460a-ad31-8ac8fdcc5ebd": {
"id": "951cc0aa-6b28-460a-ad31-8ac8fdcc5ebd",
"slug": "mug",
"label": "Ceramic Mug",
"category": "Drinkware",
"sizeCount": 2,
"createdAt": "2026-05-25T12:31:21.555Z",
"updatedAt": "2026-05-25T12:31:21.555Z"
},
"95dca14e-28e4-46e8-93b1-b45e3b556530": {
"id": "95dca14e-28e4-46e8-93b1-b45e3b556530",
"slug": "poster",
"label": "Poster",
"category": "Wall Art",
"sizeCount": 2,
"createdAt": "2026-05-25T12:31:21.556Z",
"updatedAt": "2026-05-25T12:31:21.556Z"
},
"1d69616b-96a8-4280-9beb-c8926222c589": {
"id": "1d69616b-96a8-4280-9beb-c8926222c589",
"slug": "tote",
"label": "Tote Bag",
"category": "Accessories",
"sizeCount": 1,
"createdAt": "2026-05-25T12:31:21.557Z",
"updatedAt": "2026-05-25T12:31:21.557Z"
},
"c722b19b-d6c6-4639-b0eb-dec34731e50d": {
"id": "c722b19b-d6c6-4639-b0eb-dec34731e50d",
"slug": "pillow",
"label": "Throw Pillow",
"category": "Home",
"sizeCount": 1,
"createdAt": "2026-05-25T12:31:21.558Z",
"updatedAt": "2026-05-25T12:31:21.558Z"
},
"1ed028ad-1998-4ace-813b-79023bd08c1d": {
"id": "1ed028ad-1998-4ace-813b-79023bd08c1d",
"slug": "notebook",
"label": "Notebook",
"category": "Stationery",
"sizeCount": 1,
"createdAt": "2026-05-25T12:31:21.559Z",
"updatedAt": "2026-05-25T12:31:21.559Z"
},
"04507704-50a9-4de9-9b23-475d6b76bf0e": {
"id": "04507704-50a9-4de9-9b23-475d6b76bf0e",
"slug": "sticker",
"label": "Sticker",
"category": "Stationery",
"sizeCount": 1,
"createdAt": "2026-05-25T12:31:21.560Z",
"updatedAt": "2026-05-25T12:31:21.560Z"
},
"cb43b749-05c0-4d1c-8d0b-dadd9b28574d": {
"id": "cb43b749-05c0-4d1c-8d0b-dadd9b28574d",
"slug": "hat",
"label": "Hat",
"category": "Apparel",
"sizeCount": 1,
"createdAt": "2026-05-25T12:31:21.562Z",
"updatedAt": "2026-05-25T12:31:21.562Z"
},
"f5cbb673-8696-4dad-a63c-3a802ba53fd3": {
"id": "f5cbb673-8696-4dad-a63c-3a802ba53fd3",
"slug": "phone-case",
"label": "Phone Case",
"category": "Accessories",
"sizeCount": 1,
"createdAt": "2026-05-25T12:31:21.563Z",
"updatedAt": "2026-05-25T12:31:21.563Z"
}
},
"version": 1
}

View File

@@ -0,0 +1,35 @@
{
"id": "04507704-50a9-4de9-9b23-475d6b76bf0e",
"slug": "sticker",
"label": "Sticker",
"description": "Weatherproof vinyl sticker, die-cut to your design.",
"category": "Stationery",
"options": [],
"sizes": [
{
"id": "6744bd80-e64b-4351-a0b8-9316dd138db5",
"slug": "3in-die-cut",
"label": "3\" Die-Cut",
"physical": {
"width": 3,
"height": 3,
"unit": "in"
},
"canvas": {
"width": 300,
"height": 300
},
"printZone": {
"x": 30,
"y": 30,
"width": 240,
"height": 240
},
"silhouettePath": "M12,0 L288,0 A12,12 0 0 1 300,12 L300,288 A12,12 0 0 1 288,300 L12,300 A12,12 0 0 1 0,288 L0,12 A12,12 0 0 1 12,0 Z",
"mockupFallbackColor": "#fce7f3",
"basePriceCents": 400
}
],
"createdAt": "2026-05-25T12:31:21.560Z",
"updatedAt": "2026-05-25T12:31:21.560Z"
}

View File

@@ -0,0 +1,35 @@
{
"id": "1d69616b-96a8-4280-9beb-c8926222c589",
"slug": "tote",
"label": "Tote Bag",
"description": "Sturdy canvas tote with reinforced straps.",
"category": "Accessories",
"options": [],
"sizes": [
{
"id": "61e5519d-1230-4397-a795-7303a30e0881",
"slug": "14x14",
"label": "14\" × 14\"",
"physical": {
"width": 14,
"height": 14,
"unit": "in"
},
"canvas": {
"width": 1400,
"height": 1400
},
"printZone": {
"x": 140,
"y": 140,
"width": 1120,
"height": 1120
},
"silhouettePath": "M56,0 L1344,0 A56,56 0 0 1 1400,56 L1400,1344 A56,56 0 0 1 1344,1400 L56,1400 A56,56 0 0 1 0,1344 L0,56 A56,56 0 0 1 56,0 Z",
"mockupFallbackColor": "#fef3c7",
"basePriceCents": 1800
}
],
"createdAt": "2026-05-25T12:31:21.557Z",
"updatedAt": "2026-05-25T12:31:21.557Z"
}

View File

@@ -0,0 +1,35 @@
{
"id": "1ed028ad-1998-4ace-813b-79023bd08c1d",
"slug": "notebook",
"label": "Notebook",
"description": "A5 hardcover notebook, 120 lined pages.",
"category": "Stationery",
"options": [],
"sizes": [
{
"id": "add070b4-1fa3-47bb-84a5-df8d99c2b821",
"slug": "a5",
"label": "A5",
"physical": {
"width": 5.5,
"height": 8.5,
"unit": "in"
},
"canvas": {
"width": 550,
"height": 850
},
"printZone": {
"x": 55,
"y": 85,
"width": 440,
"height": 680
},
"silhouettePath": "M22,0 L528,0 A22,22 0 0 1 550,22 L550,828 A22,22 0 0 1 528,850 L22,850 A22,22 0 0 1 0,828 L0,22 A22,22 0 0 1 22,0 Z",
"mockupFallbackColor": "#fafaf9",
"basePriceCents": 1500
}
],
"createdAt": "2026-05-25T12:31:21.559Z",
"updatedAt": "2026-05-25T12:31:21.559Z"
}

View File

@@ -0,0 +1,154 @@
{
"id": "6202b99a-a4a9-4f11-8b2d-32c17516ecfa",
"slug": "hoodie",
"label": "Hoodie",
"description": "Heavyweight pullover hoodie with front-and-back print options.",
"category": "Apparel",
"options": [
{
"id": "49a9bd93-84c9-4e9c-a4e6-c1e742c6777a",
"slug": "color",
"label": "Color",
"kind": "color",
"values": [
{
"id": "4e8d2854-8f0e-4c99-b854-8c0e07b59a49",
"slug": "black",
"label": "Black",
"hex": "#111827"
},
{
"id": "618c49fc-9fc6-486b-9d83-a2fff373092f",
"slug": "navy",
"label": "Navy",
"hex": "#1e3a8a"
},
{
"id": "0d1d8ec2-c7b9-40c9-9da8-9b5f1c6d1e3f",
"slug": "gray",
"label": "Heather Gray",
"hex": "#9ca3af"
}
]
}
],
"sizes": [
{
"id": "f002d98e-6601-46cb-9ed0-ad6608a794ac",
"slug": "s",
"label": "Small",
"physical": {
"width": 12,
"height": 16,
"unit": "in"
},
"canvas": {
"width": 1200,
"height": 1600
},
"printZone": {
"x": 120,
"y": 160,
"width": 960,
"height": 1280
},
"silhouettePath": "M48,0 L1152,0 A48,48 0 0 1 1200,48 L1200,1552 A48,48 0 0 1 1152,1600 L48,1600 A48,48 0 0 1 0,1552 L0,48 A48,48 0 0 1 48,0 Z",
"mockupFallbackColor": "#475569",
"basePriceCents": 4800
},
{
"id": "13849307-bed4-4654-9476-fb135561f05f",
"slug": "m",
"label": "Medium",
"physical": {
"width": 12,
"height": 16,
"unit": "in"
},
"canvas": {
"width": 1200,
"height": 1600
},
"printZone": {
"x": 120,
"y": 160,
"width": 960,
"height": 1280
},
"silhouettePath": "M48,0 L1152,0 A48,48 0 0 1 1200,48 L1200,1552 A48,48 0 0 1 1152,1600 L48,1600 A48,48 0 0 1 0,1552 L0,48 A48,48 0 0 1 48,0 Z",
"mockupFallbackColor": "#475569",
"basePriceCents": 4800
},
{
"id": "b327d6c5-8f0b-4639-b8ec-8d3f4ec74d1a",
"slug": "l",
"label": "Large",
"physical": {
"width": 12,
"height": 16,
"unit": "in"
},
"canvas": {
"width": 1200,
"height": 1600
},
"printZone": {
"x": 120,
"y": 160,
"width": 960,
"height": 1280
},
"silhouettePath": "M48,0 L1152,0 A48,48 0 0 1 1200,48 L1200,1552 A48,48 0 0 1 1152,1600 L48,1600 A48,48 0 0 1 0,1552 L0,48 A48,48 0 0 1 48,0 Z",
"mockupFallbackColor": "#475569",
"basePriceCents": 4800
},
{
"id": "af9ed947-e705-4205-87b0-6c529ce6b342",
"slug": "xl",
"label": "X-Large",
"physical": {
"width": 12,
"height": 16,
"unit": "in"
},
"canvas": {
"width": 1200,
"height": 1600
},
"printZone": {
"x": 120,
"y": 160,
"width": 960,
"height": 1280
},
"silhouettePath": "M48,0 L1152,0 A48,48 0 0 1 1200,48 L1200,1552 A48,48 0 0 1 1152,1600 L48,1600 A48,48 0 0 1 0,1552 L0,48 A48,48 0 0 1 48,0 Z",
"mockupFallbackColor": "#475569",
"basePriceCents": 5200
},
{
"id": "83156320-5f30-4cf8-b28c-3f69fcf7887e",
"slug": "xxl",
"label": "XX-Large",
"physical": {
"width": 12,
"height": 16,
"unit": "in"
},
"canvas": {
"width": 1200,
"height": 1600
},
"printZone": {
"x": 120,
"y": 160,
"width": 960,
"height": 1280
},
"silhouettePath": "M48,0 L1152,0 A48,48 0 0 1 1200,48 L1200,1552 A48,48 0 0 1 1152,1600 L48,1600 A48,48 0 0 1 0,1552 L0,48 A48,48 0 0 1 48,0 Z",
"mockupFallbackColor": "#475569",
"basePriceCents": 5400
}
],
"createdAt": "2026-05-25T12:31:21.552Z",
"updatedAt": "2026-05-25T12:31:21.552Z"
}

View File

@@ -0,0 +1,58 @@
{
"id": "951cc0aa-6b28-460a-ad31-8ac8fdcc5ebd",
"slug": "mug",
"label": "Ceramic Mug",
"description": "Dishwasher-safe ceramic mug. Print wraps the body.",
"category": "Drinkware",
"options": [],
"sizes": [
{
"id": "ccaf7033-3e43-4171-9cde-fe6be676ea05",
"slug": "11oz",
"label": "11oz",
"physical": {
"width": 8,
"height": 3.25,
"unit": "in"
},
"canvas": {
"width": 800,
"height": 325
},
"printZone": {
"x": 80,
"y": 33,
"width": 640,
"height": 259
},
"silhouettePath": "M13,0 L787,0 A13,13 0 0 1 800,13 L800,312 A13,13 0 0 1 787,325 L13,325 A13,13 0 0 1 0,312 L0,13 A13,13 0 0 1 13,0 Z",
"mockupFallbackColor": "#f8fafc",
"basePriceCents": 1500
},
{
"id": "ae4ad837-6669-4491-af8e-445f90794779",
"slug": "15oz",
"label": "15oz",
"physical": {
"width": 8.5,
"height": 4.25,
"unit": "in"
},
"canvas": {
"width": 850,
"height": 425
},
"printZone": {
"x": 85,
"y": 43,
"width": 680,
"height": 339
},
"silhouettePath": "M17,0 L833,0 A17,17 0 0 1 850,17 L850,408 A17,17 0 0 1 833,425 L17,425 A17,17 0 0 1 0,408 L0,17 A17,17 0 0 1 17,0 Z",
"mockupFallbackColor": "#f1f5f9",
"basePriceCents": 1800
}
],
"createdAt": "2026-05-25T12:31:21.555Z",
"updatedAt": "2026-05-25T12:31:21.555Z"
}

View File

@@ -0,0 +1,58 @@
{
"id": "95dca14e-28e4-46e8-93b1-b45e3b556530",
"slug": "poster",
"label": "Poster",
"description": "Premium matte poster, suitable for framing.",
"category": "Wall Art",
"options": [],
"sizes": [
{
"id": "7d015072-f884-4e04-8a39-ce7c8e149059",
"slug": "12x18",
"label": "12\" × 18\"",
"physical": {
"width": 12,
"height": 18,
"unit": "in"
},
"canvas": {
"width": 1200,
"height": 1800
},
"printZone": {
"x": 120,
"y": 180,
"width": 960,
"height": 1440
},
"silhouettePath": "M48,0 L1152,0 A48,48 0 0 1 1200,48 L1200,1752 A48,48 0 0 1 1152,1800 L48,1800 A48,48 0 0 1 0,1752 L0,48 A48,48 0 0 1 48,0 Z",
"mockupFallbackColor": "#f3f4f6",
"basePriceCents": 1800
},
{
"id": "9a5529fd-e54e-4868-acbb-5a62c44d1297",
"slug": "18x24",
"label": "18\" × 24\"",
"physical": {
"width": 18,
"height": 24,
"unit": "in"
},
"canvas": {
"width": 1800,
"height": 2400
},
"printZone": {
"x": 180,
"y": 240,
"width": 1440,
"height": 1920
},
"silhouettePath": "M72,0 L1728,0 A72,72 0 0 1 1800,72 L1800,2328 A72,72 0 0 1 1728,2400 L72,2400 A72,72 0 0 1 0,2328 L0,72 A72,72 0 0 1 72,0 Z",
"mockupFallbackColor": "#e5e7eb",
"basePriceCents": 2400
}
],
"createdAt": "2026-05-25T12:31:21.556Z",
"updatedAt": "2026-05-25T12:31:21.556Z"
}

View File

@@ -0,0 +1,154 @@
{
"id": "c3ac22c7-ef49-4997-a525-d4f0eb32da6f",
"slug": "shirt",
"label": "T-Shirt",
"description": "Soft cotton tee, printed on the front.",
"category": "Apparel",
"options": [
{
"id": "9d58a8b1-5976-4512-9095-883f16a4c1b8",
"slug": "color",
"label": "Color",
"kind": "color",
"values": [
{
"id": "29092f97-2654-4150-89e5-7a323eb891e4",
"slug": "white",
"label": "White",
"hex": "#ffffff"
},
{
"id": "c25f746a-efc2-4213-9328-728ac6881bc6",
"slug": "black",
"label": "Black",
"hex": "#1f2937"
},
{
"id": "8e077db8-bc48-45c0-a3e1-df10b6f1367f",
"slug": "pink",
"label": "Dusty Pink",
"hex": "#fbcfe8"
}
]
}
],
"sizes": [
{
"id": "3df58f51-70ce-4898-8091-2bb186b04193",
"slug": "s",
"label": "Small",
"physical": {
"width": 12,
"height": 16,
"unit": "in"
},
"canvas": {
"width": 1200,
"height": 1600
},
"printZone": {
"x": 120,
"y": 160,
"width": 960,
"height": 1280
},
"silhouettePath": "M48,0 L1152,0 A48,48 0 0 1 1200,48 L1200,1552 A48,48 0 0 1 1152,1600 L48,1600 A48,48 0 0 1 0,1552 L0,48 A48,48 0 0 1 48,0 Z",
"mockupFallbackColor": "#f4f4f5",
"basePriceCents": 2200
},
{
"id": "86787c88-c4e0-4798-a34f-d1e173b2704b",
"slug": "m",
"label": "Medium",
"physical": {
"width": 12,
"height": 16,
"unit": "in"
},
"canvas": {
"width": 1200,
"height": 1600
},
"printZone": {
"x": 120,
"y": 160,
"width": 960,
"height": 1280
},
"silhouettePath": "M48,0 L1152,0 A48,48 0 0 1 1200,48 L1200,1552 A48,48 0 0 1 1152,1600 L48,1600 A48,48 0 0 1 0,1552 L0,48 A48,48 0 0 1 48,0 Z",
"mockupFallbackColor": "#f4f4f5",
"basePriceCents": 2200
},
{
"id": "8cf5377f-8c6b-4afe-80c3-9337e6303176",
"slug": "l",
"label": "Large",
"physical": {
"width": 12,
"height": 16,
"unit": "in"
},
"canvas": {
"width": 1200,
"height": 1600
},
"printZone": {
"x": 120,
"y": 160,
"width": 960,
"height": 1280
},
"silhouettePath": "M48,0 L1152,0 A48,48 0 0 1 1200,48 L1200,1552 A48,48 0 0 1 1152,1600 L48,1600 A48,48 0 0 1 0,1552 L0,48 A48,48 0 0 1 48,0 Z",
"mockupFallbackColor": "#f4f4f5",
"basePriceCents": 2200
},
{
"id": "ecc7707a-1703-4c27-9da7-6b39709837ba",
"slug": "xl",
"label": "X-Large",
"physical": {
"width": 12,
"height": 16,
"unit": "in"
},
"canvas": {
"width": 1200,
"height": 1600
},
"printZone": {
"x": 120,
"y": 160,
"width": 960,
"height": 1280
},
"silhouettePath": "M48,0 L1152,0 A48,48 0 0 1 1200,48 L1200,1552 A48,48 0 0 1 1152,1600 L48,1600 A48,48 0 0 1 0,1552 L0,48 A48,48 0 0 1 48,0 Z",
"mockupFallbackColor": "#f4f4f5",
"basePriceCents": 2400
},
{
"id": "3667ec33-6123-44bd-a12e-5fdfef3c2874",
"slug": "xxl",
"label": "XX-Large",
"physical": {
"width": 12,
"height": 16,
"unit": "in"
},
"canvas": {
"width": 1200,
"height": 1600
},
"printZone": {
"x": 120,
"y": 160,
"width": 960,
"height": 1280
},
"silhouettePath": "M48,0 L1152,0 A48,48 0 0 1 1200,48 L1200,1552 A48,48 0 0 1 1152,1600 L48,1600 A48,48 0 0 1 0,1552 L0,48 A48,48 0 0 1 48,0 Z",
"mockupFallbackColor": "#f4f4f5",
"basePriceCents": 2600
}
],
"createdAt": "2026-05-25T12:31:21.547Z",
"updatedAt": "2026-05-25T12:31:21.547Z"
}

View File

@@ -0,0 +1,35 @@
{
"id": "c722b19b-d6c6-4639-b0eb-dec34731e50d",
"slug": "pillow",
"label": "Throw Pillow",
"description": "Plush throw pillow with hidden zipper and insert.",
"category": "Home",
"options": [],
"sizes": [
{
"id": "22d04708-d964-4cdc-a682-fa9c02372a88",
"slug": "18x18",
"label": "18\" × 18\"",
"physical": {
"width": 18,
"height": 18,
"unit": "in"
},
"canvas": {
"width": 1800,
"height": 1800
},
"printZone": {
"x": 180,
"y": 180,
"width": 1440,
"height": 1440
},
"silhouettePath": "M162,0 L1638,0 A162,162 0 0 1 1800,162 L1800,1638 A162,162 0 0 1 1638,1800 L162,1800 A162,162 0 0 1 0,1638 L0,162 A162,162 0 0 1 162,0 Z",
"mockupFallbackColor": "#dcfce7",
"basePriceCents": 2400
}
],
"createdAt": "2026-05-25T12:31:21.558Z",
"updatedAt": "2026-05-25T12:31:21.558Z"
}

View File

@@ -0,0 +1,35 @@
{
"id": "cb43b749-05c0-4d1c-8d0b-dadd9b28574d",
"slug": "hat",
"label": "Hat",
"description": "Adjustable baseball cap with embroidered front print.",
"category": "Apparel",
"options": [],
"sizes": [
{
"id": "778c4b47-a46b-4753-8fa2-be8af1523f4a",
"slug": "cap",
"label": "Baseball Cap",
"physical": {
"width": 5,
"height": 2,
"unit": "in"
},
"canvas": {
"width": 500,
"height": 200
},
"printZone": {
"x": 50,
"y": 20,
"width": 400,
"height": 160
},
"silhouettePath": "M30,0 L470,0 A30,30 0 0 1 500,30 L500,170 A30,30 0 0 1 470,200 L30,200 A30,30 0 0 1 0,170 L0,30 A30,30 0 0 1 30,0 Z",
"mockupFallbackColor": "#fce7f3",
"basePriceCents": 2200
}
],
"createdAt": "2026-05-25T12:31:21.562Z",
"updatedAt": "2026-05-25T12:31:21.562Z"
}

View File

@@ -0,0 +1,81 @@
{
"id": "f530dc06-ef10-46b4-9903-3927f85bd9a7",
"slug": "magnet",
"label": "Magnet",
"description": "Refrigerator-safe matte magnet, square or die-cut round.",
"category": "Home",
"options": [],
"sizes": [
{
"id": "6f88ed75-7ab5-49ae-8dc5-18568da63899",
"slug": "3x3-square",
"label": "3\" × 3\" Square",
"physical": {
"width": 3,
"height": 3,
"unit": "in"
},
"canvas": {
"width": 300,
"height": 300
},
"printZone": {
"x": 30,
"y": 30,
"width": 240,
"height": 240
},
"silhouettePath": "M12,0 L288,0 A12,12 0 0 1 300,12 L300,288 A12,12 0 0 1 288,300 L12,300 A12,12 0 0 1 0,288 L0,12 A12,12 0 0 1 12,0 Z",
"mockupFallbackColor": "#fef9c3",
"basePriceCents": 700
},
{
"id": "3a9d60f5-821c-4f3f-8d41-62cfeaba208a",
"slug": "4x4-square",
"label": "4\" × 4\" Square",
"physical": {
"width": 4,
"height": 4,
"unit": "in"
},
"canvas": {
"width": 400,
"height": 400
},
"printZone": {
"x": 40,
"y": 40,
"width": 320,
"height": 320
},
"silhouettePath": "M16,0 L384,0 A16,16 0 0 1 400,16 L400,384 A16,16 0 0 1 384,400 L16,400 A16,16 0 0 1 0,384 L0,16 A16,16 0 0 1 16,0 Z",
"mockupFallbackColor": "#fef9c3",
"basePriceCents": 900
},
{
"id": "17e3cc66-f897-4717-8b08-c5b0308668df",
"slug": "3in-round",
"label": "3\" Round",
"physical": {
"width": 3,
"height": 3,
"unit": "in"
},
"canvas": {
"width": 300,
"height": 300
},
"printZone": {
"x": 30,
"y": 30,
"width": 240,
"height": 240
},
"silhouettePath": "M150,0 A150,150 0 1 0 150,300 A150,150 0 1 0 150,0 Z",
"mockupFallbackColor": "#fde68a",
"basePriceCents": 700
}
],
"createdAt": "2026-05-25T12:31:21.554Z",
"updatedAt": "2026-05-25T12:31:21.554Z"
}

View File

@@ -0,0 +1,35 @@
{
"id": "f5cbb673-8696-4dad-a63c-3a802ba53fd3",
"slug": "phone-case",
"label": "Phone Case",
"description": "Slim flexible case with edge-to-edge print.",
"category": "Accessories",
"options": [],
"sizes": [
{
"id": "4be821c2-8ba5-4dd1-ad0b-1a188dd89796",
"slug": "generic",
"label": "Generic Wrap",
"physical": {
"width": 2.7,
"height": 5.4,
"unit": "in"
},
"canvas": {
"width": 270,
"height": 540
},
"printZone": {
"x": 27,
"y": 54,
"width": 216,
"height": 432
},
"silhouettePath": "M27,0 L243,0 A27,27 0 0 1 270,27 L270,513 A27,27 0 0 1 243,540 L27,540 A27,27 0 0 1 0,513 L0,27 A27,27 0 0 1 27,0 Z",
"mockupFallbackColor": "#e0e7ff",
"basePriceCents": 2200
}
],
"createdAt": "2026-05-25T12:31:21.563Z",
"updatedAt": "2026-05-25T12:31:21.563Z"
}

View File

@@ -289,6 +289,13 @@ export default defineConfig({
'/api': { target: 'http://localhost:3001', changeOrigin: true },
'/uploads': { target: 'http://localhost:3001', changeOrigin: true },
'/exports': { target: 'http://localhost:3001', changeOrigin: true },
// v0.7.4 — product images (gallery photos + per-size mockups)
// are served by template-manager mounted on the Node server
// (port 3001). Without this proxy, browser <img src="/products/...">
// requests hit Vite's SPA fallback and return index.html as an
// image, breaking the shop product grid, product gallery, and
// admin size cards.
'/products': { target: 'http://localhost:3001', changeOrigin: true },
},
},
build: {
@@ -390,6 +397,7 @@ export default defineConfig({
['react-vendor', 'react', 'react-dom'],
['filerobot', 'react-filerobot-image-editor'],
['goods-editor', 'goods-editor'],
['template-manager', 'template-manager'],
];
for (const [chunk, ...packages] of groups) {
for (const pkg of packages) {