diff --git a/src/App.jsx b/src/App.jsx index b69c2b1..de9e6ce 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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=` 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} />
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 +// 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= 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 ( +
+ +
{children}
+
+ ); +} + +// ── 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 ( +

+ Loading template metadata… +

+ ); + } + + return ( +
+ +
+ ); +} + +// ── /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 ( + + setCreateOpen(true)} /> + {createOpen && ( + setCreateOpen(false)} + /> + )} + + ); +} + +// ── /admin/templates/:id — editor ────────────────────────────── +function AdminTemplateEditorRoute() { + const { id } = useParams(); + return ( + + } + 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); + } + }} + /> + + ); +} + +// ── /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 ( + + setCreateOpen(true)} /> + {createOpen && ( + setCreateOpen(false)} + /> + )} + + ); +} + +// ── /admin/products/:id — editor ─────────────────────────────── +function AdminProductEditorRoute() { + const { id } = useParams(); + return ( + + + + ); +} + +// 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 ( + + + + ); +} + +// v0.8 — /admin/inventory/:id — single-variant detail. +function AdminInventoryDetailRoute() { + const { id } = useParams(); + return ( + + + + ); +} + +// 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 ( + + + + ); +} + +// 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 ( +
+ +
+ ); +} + +export default function AppRouter() { + return ( + + + {/* 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. */} + + + } /> + } /> + } /> + } /> + } /> + + {/* v0.8 — auth + account */} + } /> + } /> + }> + } /> + } /> + {/* Facet personalization (Project doc §R10) — AccountFacetPage + hides itself when no facet is configured. */} + } /> + {/* /account/designs lands once goods-editor-module exports a + MyDesignsPage component (Phase 2 / v08_GoodsEditor_v1). + /account/orders lands with commerce-module. */} + + + {/* Admin — wrapped in RequireAdmin (client guard). Server- + side, the API endpoints still need requireAdmin + middleware once template-manager + commerce-module wire + it in. */} +
} /> + } /> + } /> + } /> + {/* v0.8 — inventory routes. */} + } /> + } /> + {/* Facet personalization (Project doc §R10). */} + } /> + } /> + } /> + } /> + + {/* SPA fallback — unknown routes land on the shop home. */} + } /> + + + + ); +} diff --git a/src/CustomizeRoute.jsx b/src/CustomizeRoute.jsx new file mode 100644 index 0000000..5cd8d84 --- /dev/null +++ b/src/CustomizeRoute.jsx @@ -0,0 +1,340 @@ +// CustomizeRoute — the /customize?template= 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/). +// 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
contents change. + const headerProps = { + cartCount: cartItems.length, + onShare: handleShare, + onOpenCart: handleOpenCart, + onTestDownload: handleHeaderTestDownload, + isExporting, + onPreview: handleHeaderPreview, + onClear: handleHeaderClear, + canClear, + elementCount, + }; + + if (templateId && templateLoading) { + return ( +
+ + + +
+
+

Loading template…

+
+
+ ); + } + + if (templateId && templateError) { + return ( +
+ + + +
+
+

+ {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.'} +

+
+
+ ); + } + + return ( +
+ + + +
+ +
+ { + // 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} + /> +
+
+ ); +} diff --git a/src/components/Header.jsx b/src/components/Header.jsx index fb00765..62831bb 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -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] + * [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 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 - * 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({
+ {/* 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. */} + + {/* 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). */} + { + 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" + /> + +
+
+ {formatUSD(line.unitPriceCents * line.quantity)} +
+ + + ))} + + +
+
+ Total + {formatUSD(totalCents)} +
+

+ Shipping and taxes calculated at checkout. +

+ {checkoutBanner && ( +

+ 🚧 Checkout coming soon! We're still building this part — your cart will be here when it's ready. +

+ )} +
+ + ← Continue shopping + +
+ + +
+
+
+ + )} + +
+ + ); +} diff --git a/src/components/shop/Hero.jsx b/src/components/shop/Hero.jsx new file mode 100644 index 0000000..d0aeb8e --- /dev/null +++ b/src/components/shop/Hero.jsx @@ -0,0 +1,47 @@ +// — 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 ( +
+
+

+ Custom Designs & Ready-Made for Every Pet Parent +

+

+ 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. +

+
+ + Start designing → + + + Browse ready-made + +
+
+ +
+ ); +} diff --git a/src/components/shop/ProductBuyPanel.jsx b/src/components/shop/ProductBuyPanel.jsx new file mode 100644 index 0000000..42da064 --- /dev/null +++ b/src/components/shop/ProductBuyPanel.jsx @@ -0,0 +1,282 @@ +// — 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 ( +
+ {/* Size picker */} + {sizes.length > 0 && ( +
+ +
+ {sizes.map((s) => ( + + ))} +
+
+ )} + + {/* Option groups (color, etc.) */} + {options.map((group) => ( +
+ + {group.kind === 'color' ? ( +
+ {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 ( +
+ ) : ( +
+ {group.values.map((v) => { + const upcharge = formatUpcharge(v.priceCents); + return ( + + ); + })} +
+ )} +
+ ))} + + {/* Quantity (fixed only) */} + {isFixed && ( +
+ +
+ + { + const n = parseInt(e.target.value, 10); + setQuantity(Number.isFinite(n) && n > 0 ? Math.min(99, n) : 1); + }} + className="shop-qty-input" + /> + +
+
+ )} + + {/* Price line + primary CTA */} +
+ + {isFixed && quantity > 1 ? 'Total' : 'Price'} + + {formatUSD(totalCents)} +
+ + {isFixed ? ( + <> + + {addedBanner && ( +

+ ✨ Added to cart! View cart → +

+ )} + + ) : ( + + )} +
+ ); +} diff --git a/src/components/shop/ProductCard.jsx b/src/components/shop/ProductCard.jsx new file mode 100644 index 0000000..f0bd233 --- /dev/null +++ b/src/components/shop/ProductCard.jsx @@ -0,0 +1,55 @@ +// — shop product card for the home grid. +// +// Wraps a single product summary returned by /api/public/products. +// Links to /shop/. 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 ( + +
+ {product.imageAlt + {kindLabel} +
+
+

{product.label}

+

+ Starting at{' '} + {formatUSD(product.startingPriceCents)} +

+
+ + ); +} diff --git a/src/components/shop/ProductGallery.jsx b/src/components/shop/ProductGallery.jsx new file mode 100644 index 0000000..ebe7e8d --- /dev/null +++ b/src/components/shop/ProductGallery.jsx @@ -0,0 +1,85 @@ +// — 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 ( +
+
+ {active.alt} +
+ {items.length > 1 && ( +
+ {items.map((item, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/shop/ProductGrid.jsx b/src/components/shop/ProductGrid.jsx new file mode 100644 index 0000000..2c69aff --- /dev/null +++ b/src/components/shop/ProductGrid.jsx @@ -0,0 +1,80 @@ +// — 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 ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +