v0.7: shop storefront + routed app

AppRouter replaces the single-page App as the mounted root: shop routes
(/ and /shop → ShopHome, /shop/:productSlug → ProductPage, /cart →
CartStub, /customize → CustomizeRoute) plus the template-manager admin
surfaces (/admin/templates|products|featured|trash, v0.8 adds
/admin/inventory + RequireAdmin wrapping). App.jsx stays on disk one
release as the documented legacy fallback — nothing imports it.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
khalid@traclabs.com
2026-09-01 23:28:58 -05:00
parent aba2dbedfb
commit 6599a5e68f
23 changed files with 5928 additions and 41 deletions

View File

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

519
src/AppRouter.jsx Normal file
View File

@@ -0,0 +1,519 @@
// 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 { useMemo, useState, useCallback } from 'react';
import { BrowserRouter, Routes, Route, Link, useNavigate, useParams, useLocation } from 'react-router-dom';
import { ApparelDesigner, dematerializeTemplate, 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 { SHIRT_COLORS, SHIRT_SIZES, SHIRT_BASE_PRICE_USD } from './constants/shirt';
import { PHONE_MODELS } from './constants/phoneModels';
// 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 — host's adapter for TemplateEditorPanel's
// `renderCanvas` prop.
//
// 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.
function AdminEditorCanvas({ 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>
);
}
// ── /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={(props) => <AdminEditorCanvas {...props} />}
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>
);
}

340
src/CustomizeRoute.jsx Normal file
View File

@@ -0,0 +1,340 @@
// 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 { ApparelDesigner, useDesignPersistence } from 'goods-editor';
import 'goods-editor/styles.css';
import { useTemplate, computeHiddenStickerCategories } from 'template-manager';
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 { SHIRT_COLORS, SHIRT_SIZES, SHIRT_BASE_PRICE_USD } from './constants/shirt';
import { PHONE_MODELS } from './constants/phoneModels';
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;
// ── 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 }}>
<ApparelDesigner
ref={editorRef}
onExportingChange={setIsExporting}
onElementCountChange={setElementCount}
onCanClearChange={setCanClear}
// Catalog + branding.
shirtColors={SHIRT_COLORS}
shirtSizes={SHIRT_SIZES}
basePrice={SHIRT_BASE_PRICE_USD}
designFilenamePrefix="pawfectly-yours-design"
shareTitle={t('share.title')}
loadFonts={false}
// v0.3.0 prop surface — pre-fetched template + variant.
// When template is null (freeform), the editor's pre-fetched
// effect is a no-op and the legacy effect runs — but with
// empty templates array and null initialTemplate, the user
// just lands on a blank canvas.
template={template}
variant={variant}
// Stage & Canvas (Round 5f) — per-view geometry. The resolved
// variant carries variant.views[] = each view's { id, label,
// variant } (its own canvas size + shape + product photo).
// Passing it makes the editor render EVERY view with its own
// geometry instead of synthesizing a single default view from the
// transitional top-level (front-view) variant shim. Freeform mode
// (no template ⇒ variant null) leaves this null, so the editor
// falls back to one default view — unchanged behaviour.
views={variant?.views || null}
// Marketing Gallery Entry (Round 6) — the customer entry flow shows
// the product's marketing gallery in front of the editor (a camera
// transform then zooms into it). entryConfig is product-level
// (resolved onto the variant). Show the gallery only when it has
// images; otherwise (freeform / unauthored) mount straight into the
// editor via 'direct'.
entryConfig={variant?.entryConfig || null}
entryMode={variant?.entryConfig?.galleryImages?.length ? 'gallery' : 'direct'}
phoneModels={PHONE_MODELS}
// Facet personalization (Project doc §R10) — sticker deny-list
// (null until goods-editor consumes it; harmless meanwhile).
hiddenStickerCategories={hiddenStickerCategories}
// Callbacks.
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}
/>
</main>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

2379
src/styles/admin.css Normal file

File diff suppressed because it is too large Load Diff

1090
src/styles/shop.css Normal file

File diff suppressed because it is too large Load Diff

View File

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