Compare commits
8 Commits
87f5753804
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 36cbbc0aa4 | |||
|
|
5c21188df1 | ||
|
|
26f30ce388 | ||
|
|
840a5dbcb3 | ||
|
|
dfa1f90a16 | ||
|
|
6599a5e68f | ||
|
|
aba2dbedfb | ||
|
|
b5b8808251 |
134
.env.example
134
.env.example
@@ -34,6 +34,140 @@ NODE_ENV=development
|
|||||||
# FILE_TTL_MS=86400000 # 24 hours
|
# FILE_TTL_MS=86400000 # 24 hours
|
||||||
# CLEANUP_INTERVAL_MS=3600000 # 1 hour
|
# 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
|
# Build-time — values the `docker compose build` step uses
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
This document describes the template structure for creating custom t-shirt design templates.
|
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
|
## Template Structure
|
||||||
|
|
||||||
```json
|
```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") |
|
| `fontFamily`| string| No | Google Font name (default: "DM Sans") |
|
||||||
| `fill` | string | No | Hex color (default: "#0f172a") |
|
| `fill` | string | No | Hex color (default: "#0f172a") |
|
||||||
| `rotation` | number | No | Rotation in degrees (default: 0) |
|
| `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
|
### Image Element
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export default defineConfig([
|
|||||||
files: ['server.js'],
|
files: ['server.js'],
|
||||||
extends: [js.configs.recommended],
|
extends: [js.configs.recommended],
|
||||||
languageOptions: {
|
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,
|
globals: globals.node,
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
|
|||||||
22
index.html
22
index.html
@@ -6,9 +6,25 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#fdf2f4" />
|
<meta name="theme-color" content="#fdf2f4" />
|
||||||
<title>Pawfectly Yours — Customize Your Shirt</title>
|
<title>Pawfectly Yours — Customize Your Shirt</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<!--
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
Fonts are served by the goods-editor module from the host's fonts/
|
||||||
<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">
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
1751
package-lock.json
generated
1751
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,9 @@
|
|||||||
"test": "echo 'Editor tests live in the goods-editor module. Run them from there.' && exit 0"
|
"test": "echo 'Editor tests live in the goods-editor module. Run them from there.' && exit 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@huggingface/transformers": "^3.4.0",
|
||||||
"canvas": "^3.0.0",
|
"canvas": "^3.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -22,10 +24,12 @@
|
|||||||
"express-rate-limit": "^7.4.0",
|
"express-rate-limit": "^7.4.0",
|
||||||
"konva": "^10.0.0",
|
"konva": "^10.0.0",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
|
"pg": "^8.13.0",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.5.0",
|
||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-router-dom": "^7.1.0",
|
||||||
"react-filerobot-image-editor": "^4.8.1",
|
"react-filerobot-image-editor": "^4.8.1",
|
||||||
"react-konva": "^19.2.3",
|
"react-konva": "^19.2.3",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
|
|||||||
@@ -1,145 +1,28 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Downloads TTF font files for the Google Fonts used in the editor and saves
|
* DEPRECATED — fonts now live with goods-editor-module.
|
||||||
* them under ./fonts/. server.js scans that directory at startup and calls
|
|
||||||
* registerFont() for each file.
|
|
||||||
*
|
*
|
||||||
* Why we do this:
|
* The host used to fetch TTFs into its own fonts/ directory and
|
||||||
* node-canvas does not pick up Google Fonts automatically. It only knows
|
* register them at server boot. After the v0.8 module reorg, font
|
||||||
* about fonts that are either (a) registered explicitly via registerFont()
|
* ownership moved into goods-editor-module:
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Source:
|
* • The TTF files live under goods-editor-module/fonts/.
|
||||||
* We pull TTFs from the Fontsource jsDelivr CDN. URLs look like
|
* • Fetch them with `npm run fetch-fonts` inside goods-editor-module.
|
||||||
* https://cdn.jsdelivr.net/fontsource/fonts/<slug>@latest/latin-400-normal.ttf
|
* • mountEditorApi (called from the host's server.js) registers them
|
||||||
* Slugs are the lowercase, hyphenated family name. The script asks for two
|
* with node-canvas at boot and serves them to the browser via
|
||||||
* weights per family (regular + bold) and falls through gracefully if a
|
* /api/fonts/fonts.css.
|
||||||
* weight isn't published.
|
* • The host's index.html loads that stylesheet directly — no more
|
||||||
|
* Google Fonts CDN, no more host-side TTFs.
|
||||||
*
|
*
|
||||||
* Usage:
|
* This file is kept as a tombstone so anyone who looks for it
|
||||||
* npm run fetch-fonts
|
* understands what happened. Safe to `rm scripts/fetch-fonts.mjs`
|
||||||
*
|
* in the next housekeeping commit; nothing references it anymore
|
||||||
* Re-running is cheap: existing files are skipped unless --force is passed.
|
* (package.json's scripts block was already trimmed during the
|
||||||
|
* module-split work in May 2026).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mkdir, writeFile, access } from 'node:fs/promises';
|
console.error(
|
||||||
import { constants as fsConstants } from 'node:fs';
|
'[fetch-fonts] This script has moved to goods-editor-module.\n' +
|
||||||
import { join, dirname } from 'node:path';
|
' Run: (cd ../goods-editor-module && npm run fetch-fonts)'
|
||||||
import { fileURLToPath } from 'node:url';
|
);
|
||||||
|
process.exit(1);
|
||||||
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);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|||||||
113
scripts/sweep-worker.mjs
Normal file
113
scripts/sweep-worker.mjs
Normal 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);
|
||||||
|
});
|
||||||
406
server.js
406
server.js
@@ -16,7 +16,16 @@
|
|||||||
// • POST /api/export — the canvas+sharp render pipeline
|
// • POST /api/export — the canvas+sharp render pipeline
|
||||||
// • GET /api/download/:filename — serve exported PNGs
|
// • GET /api/download/:filename — serve exported PNGs
|
||||||
// • GET /api/health — health probe
|
// • 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 express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
@@ -28,10 +37,17 @@ import rateLimit from 'express-rate-limit';
|
|||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import pinoHttp from 'pino-http';
|
import pinoHttp from 'pino-http';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname, join, resolve, sep } from 'path';
|
import { basename, dirname, join, resolve } from 'path';
|
||||||
import { mkdirSync, existsSync, readdirSync, statSync, rmSync } from 'fs';
|
import { mkdirSync, existsSync, readdirSync, readFileSync, statSync, rmSync } from 'fs';
|
||||||
|
|
||||||
import { mountEditorApi } from 'goods-editor/server';
|
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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@@ -53,13 +69,19 @@ app.use(pinoHttp({
|
|||||||
// ── Filesystem layout ──────────────────────────────────────────────
|
// ── Filesystem layout ──────────────────────────────────────────────
|
||||||
const uploadsDir = join(__dirname, 'uploads');
|
const uploadsDir = join(__dirname, 'uploads');
|
||||||
const exportsDir = join(__dirname, 'exports');
|
const exportsDir = join(__dirname, 'exports');
|
||||||
|
const templatesStorageDir = join(__dirname, 'templates');
|
||||||
const fontsDir = join(__dirname, 'node_modules', 'goods-editor', 'fonts');
|
const fontsDir = join(__dirname, 'node_modules', 'goods-editor', 'fonts');
|
||||||
const stickersDir = IS_PRODUCTION
|
const stickersDir = IS_PRODUCTION
|
||||||
? join(__dirname, 'dist', 'stickers')
|
? join(__dirname, 'dist', 'stickers')
|
||||||
: join(__dirname, 'public', '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 });
|
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 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;
|
const CLEANUP_INTERVAL_MS = parseInt(process.env.CLEANUP_INTERVAL_MS || '', 10) || 60 * 60 * 1000;
|
||||||
|
|
||||||
function cleanupOldFiles(dir, ttlMs) {
|
// Referenced-file exemption (#24) — template JSONs (element srcs,
|
||||||
if (!existsSync(dir)) return 0;
|
// 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();
|
const now = Date.now();
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
|
let protectedCount = 0;
|
||||||
let entries = [];
|
let entries = [];
|
||||||
try { entries = readdirSync(dir); } catch { return 0; }
|
try { entries = readdirSync(dir); } catch { return { removed, protectedCount }; }
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const full = join(dir, entry);
|
const full = join(dir, entry);
|
||||||
try {
|
try {
|
||||||
const stat = statSync(full);
|
const stat = statSync(full);
|
||||||
if (stat.isFile() && now - stat.mtimeMs > ttlMs) {
|
if (stat.isFile() && now - stat.mtimeMs > ttlMs) {
|
||||||
rmSync(full);
|
if (protectedNames && protectedNames.has(entry)) {
|
||||||
removed++;
|
protectedCount++;
|
||||||
|
} else {
|
||||||
|
rmSync(full);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
} else if (stat.isDirectory()) {
|
} 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 */ }
|
} catch { /* skip */ }
|
||||||
}
|
}
|
||||||
return removed;
|
return { removed, protectedCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCleanup() {
|
// exports/designs is goods-editor's designsDir (mountEditorApi default:
|
||||||
const u = cleanupOldFiles(uploadsDir, FILE_TTL_MS);
|
// <exportsDir>/designs). It holds persisted design artifacts — source
|
||||||
const e = cleanupOldFiles(exportsDir, FILE_TTL_MS);
|
// images, thumbnails, print renders — whose lifecycle is db-driven
|
||||||
if (u > 0 || e > 0) {
|
// (designs.deleted_at soft-delete, 30-day guest TTL), not this sweep's
|
||||||
logger.info({ removedUploads: u, removedExports: e, ttlMs: FILE_TTL_MS }, 'Cleanup pass complete');
|
// 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();
|
// NOTE: the initial run + interval start AFTER the auth-manager
|
||||||
setInterval(runCleanup, CLEANUP_INTERVAL_MS).unref();
|
// bootstrap below — collectReferencedUploads reads `pgPool`, which is
|
||||||
|
// declared there (TDZ until then).
|
||||||
|
|
||||||
// ── App-level middleware (CORS, JSON, rate limits) ─────────────────
|
// ── App-level middleware (CORS, JSON, rate limits) ─────────────────
|
||||||
const corsOptions = IS_PRODUCTION
|
const corsOptions = IS_PRODUCTION
|
||||||
@@ -106,6 +213,116 @@ app.use(cors(corsOptions));
|
|||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, 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({
|
const uploadLimiter = rateLimit({
|
||||||
windowMs: 60 * 60 * 1000,
|
windowMs: 60 * 60 * 1000,
|
||||||
max: 30,
|
max: 30,
|
||||||
@@ -149,6 +366,26 @@ const upload = multer({
|
|||||||
limits: { fileSize: 20 * 1024 * 1024 },
|
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) => {
|
app.post('/api/upload', uploadLimiter, upload.single('image'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
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) ────────────
|
// ── 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, {
|
mountEditorApi(app, {
|
||||||
canvas,
|
canvas,
|
||||||
sharp,
|
sharp,
|
||||||
@@ -191,10 +445,124 @@ mountEditorApi(app, {
|
|||||||
fontsDir,
|
fontsDir,
|
||||||
stickersDir,
|
stickersDir,
|
||||||
uploadsDir,
|
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',
|
basePath: '/api',
|
||||||
logger,
|
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 ───────────────────────────────────────────
|
// ── 404 + SPA catch-alls ───────────────────────────────────────────
|
||||||
app.all('/api/*', (_req, res) => {
|
app.all('/api/*', (_req, res) => {
|
||||||
res.status(404).json({ error: 'API route not found' });
|
res.status(404).json({ error: 'API route not found' });
|
||||||
|
|||||||
56
src/App.jsx
56
src/App.jsx
@@ -2,6 +2,15 @@
|
|||||||
// (the original editor) to ~150 lines (mount the module + host chrome
|
// (the original editor) to ~150 lines (mount the module + host chrome
|
||||||
// + cart state + toast surface).
|
// + 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
|
// What stayed in the host
|
||||||
// ───────────────────────
|
// ───────────────────────
|
||||||
// • Site header (logo, preview, download, share, cart icon + badge)
|
// • Site header (logo, preview, download, share, cart icon + badge)
|
||||||
@@ -70,6 +79,24 @@ function App() {
|
|||||||
const editorRef = useRef(null);
|
const editorRef = useRef(null);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
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 ───────────────────────────────────────────────
|
// ── Toast surface ───────────────────────────────────────────────
|
||||||
// See src/components/Toast.jsx for the renderer + architecture
|
// See src/components/Toast.jsx for the renderer + architecture
|
||||||
// docblock. Shape: { message, kind, id } | null.
|
// docblock. Shape: { message, kind, id } | null.
|
||||||
@@ -116,15 +143,11 @@ function App() {
|
|||||||
|
|
||||||
// ── Editor action proxies ───────────────────────────────────────
|
// ── Editor action proxies ───────────────────────────────────────
|
||||||
// Preview and Download both fire the editor's export pipeline.
|
// Preview and Download both fire the editor's export pipeline.
|
||||||
//
|
// The Header already disables both buttons when isExporting is
|
||||||
// No empty-canvas guard here, even though it would be friendlier
|
// true OR elementCount === 0 (the latter sourced from
|
||||||
// than letting the pipeline render a blank PNG. The host doesn't
|
// onElementCountChange below), so the proxies themselves stay
|
||||||
// own the elements array (it lives inside the module), so checking
|
// minimal — fire the imperative method on the editor ref and
|
||||||
// emptiness from out here would require the module to emit a
|
// let the module's export pipeline take it from there.
|
||||||
// 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.
|
|
||||||
const handleHeaderPreview = useCallback(() => {
|
const handleHeaderPreview = useCallback(() => {
|
||||||
editorRef.current?.preview();
|
editorRef.current?.preview();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -133,6 +156,16 @@ function App() {
|
|||||||
editorRef.current?.testDownload();
|
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 ───────────────────────────────────────────────
|
// ── Share handler ───────────────────────────────────────────────
|
||||||
// Three-level fallback: navigator.share (native OS share sheet,
|
// Three-level fallback: navigator.share (native OS share sheet,
|
||||||
// ideal on mobile + recent desktop), then navigator.clipboard
|
// ideal on mobile + recent desktop), then navigator.clipboard
|
||||||
@@ -287,12 +320,17 @@ function App() {
|
|||||||
onTestDownload={handleHeaderTestDownload}
|
onTestDownload={handleHeaderTestDownload}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onPreview={handleHeaderPreview}
|
onPreview={handleHeaderPreview}
|
||||||
|
onClear={handleHeaderClear}
|
||||||
|
canClear={canClear}
|
||||||
|
elementCount={elementCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main style={{ flex: 1, minHeight: 0 }}>
|
<main style={{ flex: 1, minHeight: 0 }}>
|
||||||
<ApparelDesigner
|
<ApparelDesigner
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
onExportingChange={setIsExporting}
|
onExportingChange={setIsExporting}
|
||||||
|
onElementCountChange={setElementCount}
|
||||||
|
onCanClearChange={setCanClear}
|
||||||
shirtColors={SHIRT_COLORS}
|
shirtColors={SHIRT_COLORS}
|
||||||
shirtSizes={SHIRT_SIZES}
|
shirtSizes={SHIRT_SIZES}
|
||||||
basePrice={SHIRT_BASE_PRICE_USD}
|
basePrice={SHIRT_BASE_PRICE_USD}
|
||||||
|
|||||||
397
src/AppRouter.jsx
Normal file
397
src/AppRouter.jsx
Normal 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
321
src/CustomizeRoute.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,35 +5,35 @@ import '../styles/Header.css';
|
|||||||
* Pawfectly Yours top bar.
|
* Pawfectly Yours top bar.
|
||||||
*
|
*
|
||||||
* Layout:
|
* Layout:
|
||||||
* [logo + tagline] [page title] [preview] [download] [Share] [cart]
|
* [logo + tagline] [page title] [clear] [preview] [download] [Share] [cart]
|
||||||
*
|
*
|
||||||
* On mobile (<768px) collapses to:
|
* On mobile (<768px) collapses to:
|
||||||
* [logo] [cart]
|
* [logo] [cart]
|
||||||
*
|
*
|
||||||
* What's here vs what's gone
|
* 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
|
* Menu was also removed — it opened the host's mobile bottom sheet,
|
||||||
* pipeline). The module's onSave callback prop on <ApparelDesigner>
|
* which now lives inside the module. The module owns its own mobile
|
||||||
* remains as the right integration point for real "commit this
|
* UI; the host had nothing left to put behind a menu.
|
||||||
* design" semantics when there's a backend to commit to.
|
|
||||||
*
|
*
|
||||||
* • Clear — clearing the canvas is editor state, not host chrome.
|
* Clear was removed alongside the other two but came back in v0.8
|
||||||
* Re-introducing it requires the module to expose a clear()
|
* once the module shipped a `clear()` method on its editor ref plus
|
||||||
* method on its editor ref and a canClear callback so the host
|
* an `onCanClearChange` callback. The host now disables the button
|
||||||
* can disable the button when there's nothing to clear. Until
|
* when there's nothing to clear (empty canvas, no template loaded)
|
||||||
* that lands, removing the button is better than leaving a
|
* and lets the module own the actual confirm-and-clear flow — first
|
||||||
* non-functional one in the chrome.
|
* click arms it with a toast, second click within 3s commits.
|
||||||
*
|
*
|
||||||
* • Menu — opened the host's mobile bottom sheet, which now lives
|
* Preview / Download / Clear are all host-surfaced controls that
|
||||||
* inside the module. The module owns its own mobile UI; the
|
* drive the editor via its imperative API (editorRef.preview(),
|
||||||
* host had nothing left to put behind a menu.
|
* editorRef.testDownload(), editorRef.clear()). All three disable
|
||||||
*
|
* during an in-flight export (`isExporting`). Preview and Download
|
||||||
* Preview / Download are still here because they're host-surfaced
|
* additionally disable when `elementCount === 0` — nothing to render.
|
||||||
* controls that drive the editor via the editorRef.preview() and
|
|
||||||
* editorRef.testDownload() imperative API. They're disabled during
|
|
||||||
* an in-flight export (`isExporting`).
|
|
||||||
*
|
*
|
||||||
* i18n
|
* i18n
|
||||||
* ────
|
* ────
|
||||||
@@ -46,6 +46,15 @@ export function Header({
|
|||||||
onShare, onOpenCart,
|
onShare, onOpenCart,
|
||||||
onTestDownload, isExporting = false,
|
onTestDownload, isExporting = false,
|
||||||
onPreview,
|
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
|
// App name is reused for both the logo and the aria-label, so we
|
||||||
// resolve it once and interpolate into the aria-label below.
|
// resolve it once and interpolate into the aria-label below.
|
||||||
@@ -79,15 +88,42 @@ export function Header({
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="ph-actions">
|
<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
|
{/* Print preview — server-renders the design at print resolution
|
||||||
* and shows it in a modal so the user can verify what will
|
* and shows it in a modal so the user can verify what will
|
||||||
* actually print before adding to cart. Disabled while another
|
* 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ph-iconbtn"
|
className="ph-iconbtn"
|
||||||
onClick={onPreview}
|
onClick={onPreview}
|
||||||
disabled={isExporting}
|
disabled={isExporting || elementCount === 0}
|
||||||
aria-label={t('header.preview')}
|
aria-label={t('header.preview')}
|
||||||
title={t('header.preview-tooltip')}
|
title={t('header.preview-tooltip')}
|
||||||
>
|
>
|
||||||
@@ -99,13 +135,14 @@ export function Header({
|
|||||||
|
|
||||||
{/* Download — dedicated print-test button. Same export pipeline
|
{/* Download — dedicated print-test button. Same export pipeline
|
||||||
* as Preview but auto-downloads instead of opening the modal.
|
* as Preview but auto-downloads instead of opening the modal.
|
||||||
* Disabled while an export is in flight so rapid clicks don't
|
* Disabled while an export is in flight (so rapid clicks don't
|
||||||
* fire parallel POSTs. */}
|
* fire parallel POSTs) and when the canvas is empty (no point
|
||||||
|
* downloading a blank PNG). */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ph-iconbtn"
|
className="ph-iconbtn"
|
||||||
onClick={onTestDownload}
|
onClick={onTestDownload}
|
||||||
disabled={isExporting}
|
disabled={isExporting || elementCount === 0}
|
||||||
aria-label={t('header.download')}
|
aria-label={t('header.download')}
|
||||||
title={t('header.download-tooltip')}
|
title={t('header.download-tooltip')}
|
||||||
>
|
>
|
||||||
|
|||||||
173
src/components/shop/CartStub.jsx
Normal file
173
src/components/shop/CartStub.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/shop/Hero.jsx
Normal file
47
src/components/shop/Hero.jsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
src/components/shop/ProductBuyPanel.jsx
Normal file
282
src/components/shop/ProductBuyPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/shop/ProductCard.jsx
Normal file
55
src/components/shop/ProductCard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/components/shop/ProductGallery.jsx
Normal file
85
src/components/shop/ProductGallery.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/components/shop/ProductGrid.jsx
Normal file
80
src/components/shop/ProductGrid.jsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/shop/ProductInfo.jsx
Normal file
43
src/components/shop/ProductInfo.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/shop/ProductPage.jsx
Normal file
82
src/components/shop/ProductPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/shop/ShopHeader.jsx
Normal file
76
src/components/shop/ShopHeader.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/components/shop/ShopHome.jsx
Normal file
88
src/components/shop/ShopHome.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/shop/TemplateGalleryForProduct.jsx
Normal file
55
src/components/shop/TemplateGalleryForProduct.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
src/constants/phoneModels.js
Normal file
174
src/constants/phoneModels.js
Normal 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));
|
||||||
|
}
|
||||||
@@ -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
|
* v0.6 — the host no longer owns a runtime product catalog. The
|
||||||
* editor code can drive shirts, magnets, earrings, stickers, and so on. Only
|
* canonical catalog lives in template-manager (productsStore) and is
|
||||||
* code reads this — there is intentionally no UI for picking the active
|
* seeded once from src/constants/seedProducts.js.
|
||||||
* product.
|
|
||||||
*
|
*
|
||||||
* Coordinate systems
|
* What's left in this file
|
||||||
* ──────────────────
|
* ────────────────────────
|
||||||
* Every product has TWO coordinate systems:
|
* 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
|
* Migration intent
|
||||||
* individual elements (images, text, stickers) live. Element x/y/width/
|
* ────────────────
|
||||||
* height are in these units. The server export pipeline scales from
|
* The legacy `/` flow is slated for retirement; the customer-facing
|
||||||
* `canvasSize` to the final 4500×4500 print resolution.
|
* surface is `/customize?template=…` which reads the template's
|
||||||
*
|
* embedded variant. When the legacy flow is removed, this file goes
|
||||||
* 2. **Product SVG coordinates** (`productSvg.width / .height`) — the
|
* with it.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const PRODUCT_TYPES = {
|
// Bare shirt shape with just enough fields for the legacy
|
||||||
shirt: {
|
// getActiveProduct() consumers in App.jsx + the editor's
|
||||||
id: 'shirt',
|
// legacy single-product code paths.
|
||||||
label: 'T-Shirt',
|
const LEGACY_SHIRT_STUB = {
|
||||||
canvasSize: 300,
|
id: 'shirt',
|
||||||
// Where on the product mockup the design canvas should overlay, in
|
label: 'T-Shirt',
|
||||||
// product SVG coordinates. Sized roughly to a real DTG print zone for
|
canvasSize: 300,
|
||||||
// a youth-medium tee.
|
printZone: { x: 90, y: 130, width: 220, height: 220 },
|
||||||
//
|
productSvg: { component: 'shirt', width: 400, height: 470 },
|
||||||
// Note: the product SVG's viewBox is offset (starts at 40, 50) to crop
|
background: { mode: 'color', defaultColorId: 'white' },
|
||||||
// empty margin around the shirt outline. printZone coords are in that
|
// No variants — v0.6 catalog lives in template-manager.
|
||||||
// same viewBox space — i.e. (90, 130) is what the chest landed at
|
variants: [],
|
||||||
// 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: { ... },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which product the editor is currently configured for. Change this constant
|
* Return the active product for the legacy single-product editor.
|
||||||
* to point at a different `PRODUCT_TYPES` entry. There's intentionally no UI
|
* Always returns the shirt stub in v0.6.
|
||||||
* exposure — switching products is a code change.
|
|
||||||
*/
|
*/
|
||||||
|
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 const ACTIVE_PRODUCT_ID = 'shirt';
|
||||||
|
|
||||||
export function getActiveProduct() {
|
/** @deprecated Use seedProducts; map productId/sizeId via the products API. */
|
||||||
const product = PRODUCT_TYPES[ACTIVE_PRODUCT_ID];
|
export const PRODUCT_TYPES = { shirt: LEGACY_SHIRT_STUB };
|
||||||
if (!product) {
|
|
||||||
throw new Error(
|
/** @deprecated Use the resolved variant from a template GET response. */
|
||||||
`Unknown ACTIVE_PRODUCT_ID="${ACTIVE_PRODUCT_ID}". `
|
export function getVariant(_productTypeId, _variantId) {
|
||||||
+ `Valid ids: ${Object.keys(PRODUCT_TYPES).join(', ')}`
|
return null;
|
||||||
);
|
|
||||||
}
|
|
||||||
return product;
|
|
||||||
}
|
}
|
||||||
|
|||||||
445
src/constants/seedProducts.js
Normal file
445
src/constants/seedProducts.js
Normal 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;
|
||||||
28
src/constants/templateCategories.js
Normal file
28
src/constants/templateCategories.js
Normal 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;
|
||||||
60
src/editors/editorRegistry.js
Normal file
60
src/editors/editorRegistry.js
Normal 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;
|
||||||
|
}
|
||||||
142
src/editors/goods/GoodsAdminEditor.jsx
Normal file
142
src/editors/goods/GoodsAdminEditor.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/editors/goods/GoodsCustomizer.jsx
Normal file
63
src/editors/goods/GoodsCustomizer.jsx
Normal 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
172
src/hooks/useCart.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
78
src/hooks/useFacetCatalog.js
Normal file
78
src/hooks/useFacetCatalog.js
Normal 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 };
|
||||||
|
}
|
||||||
61
src/hooks/useShopProduct.js
Normal file
61
src/hooks/useShopProduct.js
Normal 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 };
|
||||||
|
}
|
||||||
93
src/hooks/useShopProducts.js
Normal file
93
src/hooks/useShopProducts.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -53,16 +53,22 @@
|
|||||||
|
|
||||||
export const en = {
|
export const en = {
|
||||||
// ── Header ──────────────────────────────────────────────────
|
// ── Header ──────────────────────────────────────────────────
|
||||||
// Save / Clear / Menu were removed from the header chrome during the
|
// Save and Menu were removed from the header chrome during the module
|
||||||
// module split — Save was redundant with Download (same export
|
// split. Save was redundant with Download (same export pipeline); Menu
|
||||||
// pipeline), Clear needs a module-side `clear()` method on the editor
|
// opened the host's own bottom sheet which is now inside the module.
|
||||||
// ref before it can be reintroduced, and Menu opened the host's own
|
// Their i18n keys are gone with them; readd if you put either back.
|
||||||
// bottom sheet which is now inside the module. Their i18n keys are
|
//
|
||||||
// gone with them; readd if you put any of those buttons 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-name': 'Pawfectly Yours',
|
||||||
'header.app-tagline': 'Wear Your Pet. Share Your Story.',
|
'header.app-tagline': 'Wear Your Pet. Share Your Story.',
|
||||||
'header.page-title': 'Customize Your Shirt',
|
'header.page-title': 'Customize Your Shirt',
|
||||||
'header.logo-home-aria': '{app} home',
|
'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': 'Print preview',
|
||||||
'header.preview-tooltip': 'See your design at print resolution',
|
'header.preview-tooltip': 'See your design at print resolution',
|
||||||
'header.download': 'Download print file',
|
'header.download': 'Download print file',
|
||||||
|
|||||||
22
src/main.jsx
22
src/main.jsx
@@ -1,16 +1,24 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { AuthProvider } from '@khalidadil/auth-manager'
|
||||||
|
import '@khalidadil/auth-manager/styles.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './AppRouter'
|
||||||
|
|
||||||
// Service worker registration is handled inside <PWAInstall /> via the
|
// v0.8 — <AuthProvider> wraps the whole tree so useCustomer/useAdmin hooks
|
||||||
// `useRegisterSW` hook from `virtual:pwa-register/react`. We removed the
|
// share a single /api/auth/me fetch instead of each call site re-fetching.
|
||||||
// hand-rolled `navigator.serviceWorker.ready` listener that used to live here
|
// AuthProvider degrades gracefully if /api/auth/me returns 404 (host
|
||||||
// because (a) it didn't actually register the SW — it just listened for updates
|
// running without DATABASE_URL set), so this wrap is safe even when
|
||||||
// — and (b) it duplicated state the plugin already exposes via the hook.
|
// 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(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<AuthProvider apiBase="/api">
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
2782
src/styles/admin.css
Normal file
2782
src/styles/admin.css
Normal file
File diff suppressed because it is too large
Load Diff
1090
src/styles/shop.css
Normal file
1090
src/styles/shop.css
Normal file
File diff suppressed because it is too large
Load Diff
104
templates/_products_index.json
Normal file
104
templates/_products_index.json
Normal 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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -289,6 +289,13 @@ export default defineConfig({
|
|||||||
'/api': { target: 'http://localhost:3001', changeOrigin: true },
|
'/api': { target: 'http://localhost:3001', changeOrigin: true },
|
||||||
'/uploads': { target: 'http://localhost:3001', changeOrigin: true },
|
'/uploads': { target: 'http://localhost:3001', changeOrigin: true },
|
||||||
'/exports': { 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: {
|
build: {
|
||||||
@@ -390,6 +397,7 @@ export default defineConfig({
|
|||||||
['react-vendor', 'react', 'react-dom'],
|
['react-vendor', 'react', 'react-dom'],
|
||||||
['filerobot', 'react-filerobot-image-editor'],
|
['filerobot', 'react-filerobot-image-editor'],
|
||||||
['goods-editor', 'goods-editor'],
|
['goods-editor', 'goods-editor'],
|
||||||
|
['template-manager', 'template-manager'],
|
||||||
];
|
];
|
||||||
for (const [chunk, ...packages] of groups) {
|
for (const [chunk, ...packages] of groups) {
|
||||||
for (const pkg of packages) {
|
for (const pkg of packages) {
|
||||||
|
|||||||
Reference in New Issue
Block a user