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:
56
src/App.jsx
56
src/App.jsx
@@ -2,6 +2,15 @@
|
|||||||
// (the original editor) to ~150 lines (mount the module + host chrome
|
// (the original editor) to ~150 lines (mount the module + host chrome
|
||||||
// + cart state + toast surface).
|
// + cart state + toast surface).
|
||||||
//
|
//
|
||||||
|
// Routing status (v0.7+)
|
||||||
|
// ──────────────────────
|
||||||
|
// This file is NOT mounted by AppRouter as of v0.7. Both `/` and
|
||||||
|
// `/customize?template=<id>` now route to ShopHome and
|
||||||
|
// CustomizeRoute respectively. App.jsx is kept on disk one release
|
||||||
|
// for safety — nothing imports it, but if a deploy ever needs the
|
||||||
|
// legacy single-route fallback it's here. Slated for deletion once
|
||||||
|
// the deployment migration is confirmed.
|
||||||
|
//
|
||||||
// What stayed in the host
|
// What stayed in the host
|
||||||
// ───────────────────────
|
// ───────────────────────
|
||||||
// • Site header (logo, preview, download, share, cart icon + badge)
|
// • Site header (logo, preview, download, share, cart icon + badge)
|
||||||
@@ -70,6 +79,24 @@ function App() {
|
|||||||
const editorRef = useRef(null);
|
const editorRef = useRef(null);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
// v0.8 — module-derived signals for header button gating.
|
||||||
|
//
|
||||||
|
// The editor module emits two coarse callbacks at history-commit
|
||||||
|
// boundaries (drag-end, add, delete, etc.):
|
||||||
|
//
|
||||||
|
// onElementCountChange(n) — current count of elements
|
||||||
|
// onCanClearChange(canClear) — whether the canvas has stuff
|
||||||
|
// to clear (count > 0 OR a
|
||||||
|
// template is loaded)
|
||||||
|
//
|
||||||
|
// We mirror them into local state so the Header can disable
|
||||||
|
// Preview/Download on empty canvases and grey out Clear when
|
||||||
|
// there's nothing destructive to do. Both fire once on mount with
|
||||||
|
// the initial state, so the header buttons start in the right
|
||||||
|
// shape without a separate effect.
|
||||||
|
const [elementCount, setElementCount] = useState(0);
|
||||||
|
const [canClear, setCanClear] = useState(false);
|
||||||
|
|
||||||
// ── Toast surface ───────────────────────────────────────────────
|
// ── Toast surface ───────────────────────────────────────────────
|
||||||
// See src/components/Toast.jsx for the renderer + architecture
|
// See src/components/Toast.jsx for the renderer + architecture
|
||||||
// docblock. Shape: { message, kind, id } | null.
|
// docblock. Shape: { message, kind, id } | null.
|
||||||
@@ -116,15 +143,11 @@ function App() {
|
|||||||
|
|
||||||
// ── Editor action proxies ───────────────────────────────────────
|
// ── Editor action proxies ───────────────────────────────────────
|
||||||
// Preview and Download both fire the editor's export pipeline.
|
// Preview and Download both fire the editor's export pipeline.
|
||||||
//
|
// The Header already disables both buttons when isExporting is
|
||||||
// No empty-canvas guard here, even though it would be friendlier
|
// true OR elementCount === 0 (the latter sourced from
|
||||||
// than letting the pipeline render a blank PNG. The host doesn't
|
// onElementCountChange below), so the proxies themselves stay
|
||||||
// own the elements array (it lives inside the module), so checking
|
// minimal — fire the imperative method on the editor ref and
|
||||||
// emptiness from out here would require the module to emit a
|
// let the module's export pipeline take it from there.
|
||||||
// count via callback. That's a module API extension worth doing
|
|
||||||
// alongside the Clear button reintroduction — both want the same
|
|
||||||
// "host can query editor state" pattern. Until then, the worst
|
|
||||||
// case is the user gets an empty PNG in Downloads.
|
|
||||||
const handleHeaderPreview = useCallback(() => {
|
const handleHeaderPreview = useCallback(() => {
|
||||||
editorRef.current?.preview();
|
editorRef.current?.preview();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -133,6 +156,16 @@ function App() {
|
|||||||
editorRef.current?.testDownload();
|
editorRef.current?.testDownload();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The module's clear() owns the two-stage confirm flow (first call
|
||||||
|
// arms with a toast, second within 3s commits) so the host's job
|
||||||
|
// is just to fire the trigger. The Header has already guarded the
|
||||||
|
// call site with `canClear` + `!isExporting`, but ref.current may
|
||||||
|
// still be null during the brief window before the module mounts,
|
||||||
|
// hence the optional chain.
|
||||||
|
const handleHeaderClear = useCallback(() => {
|
||||||
|
editorRef.current?.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ── Share handler ───────────────────────────────────────────────
|
// ── Share handler ───────────────────────────────────────────────
|
||||||
// Three-level fallback: navigator.share (native OS share sheet,
|
// Three-level fallback: navigator.share (native OS share sheet,
|
||||||
// ideal on mobile + recent desktop), then navigator.clipboard
|
// ideal on mobile + recent desktop), then navigator.clipboard
|
||||||
@@ -287,12 +320,17 @@ function App() {
|
|||||||
onTestDownload={handleHeaderTestDownload}
|
onTestDownload={handleHeaderTestDownload}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onPreview={handleHeaderPreview}
|
onPreview={handleHeaderPreview}
|
||||||
|
onClear={handleHeaderClear}
|
||||||
|
canClear={canClear}
|
||||||
|
elementCount={elementCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main style={{ flex: 1, minHeight: 0 }}>
|
<main style={{ flex: 1, minHeight: 0 }}>
|
||||||
<ApparelDesigner
|
<ApparelDesigner
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
onExportingChange={setIsExporting}
|
onExportingChange={setIsExporting}
|
||||||
|
onElementCountChange={setElementCount}
|
||||||
|
onCanClearChange={setCanClear}
|
||||||
shirtColors={SHIRT_COLORS}
|
shirtColors={SHIRT_COLORS}
|
||||||
shirtSizes={SHIRT_SIZES}
|
shirtSizes={SHIRT_SIZES}
|
||||||
basePrice={SHIRT_BASE_PRICE_USD}
|
basePrice={SHIRT_BASE_PRICE_USD}
|
||||||
|
|||||||
519
src/AppRouter.jsx
Normal file
519
src/AppRouter.jsx
Normal 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
340
src/CustomizeRoute.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,35 +5,35 @@ import '../styles/Header.css';
|
|||||||
* Pawfectly Yours top bar.
|
* Pawfectly Yours top bar.
|
||||||
*
|
*
|
||||||
* Layout:
|
* Layout:
|
||||||
* [logo + tagline] [page title] [preview] [download] [Share] [cart]
|
* [logo + tagline] [page title] [clear] [preview] [download] [Share] [cart]
|
||||||
*
|
*
|
||||||
* On mobile (<768px) collapses to:
|
* On mobile (<768px) collapses to:
|
||||||
* [logo] [cart]
|
* [logo] [cart]
|
||||||
*
|
*
|
||||||
* What's here vs what's gone
|
* What's here vs what's gone
|
||||||
* ──────────────────────────
|
* ──────────────────────────
|
||||||
* Buttons removed during the module-split cleanup (May 2026):
|
* Save was removed during the module-split cleanup (May 2026) — it
|
||||||
|
* was redundant with Download (both fired the same export pipeline).
|
||||||
|
* The module's onSave callback prop on <ApparelDesigner> remains as
|
||||||
|
* the right integration point for real "commit this design"
|
||||||
|
* semantics when there's a backend to commit to.
|
||||||
*
|
*
|
||||||
* • Save — was redundant with Download (both fired the same export
|
* Menu was also removed — it opened the host's mobile bottom sheet,
|
||||||
* pipeline). The module's onSave callback prop on <ApparelDesigner>
|
* which now lives inside the module. The module owns its own mobile
|
||||||
* remains as the right integration point for real "commit this
|
* UI; the host had nothing left to put behind a menu.
|
||||||
* design" semantics when there's a backend to commit to.
|
|
||||||
*
|
*
|
||||||
* • Clear — clearing the canvas is editor state, not host chrome.
|
* Clear was removed alongside the other two but came back in v0.8
|
||||||
* Re-introducing it requires the module to expose a clear()
|
* once the module shipped a `clear()` method on its editor ref plus
|
||||||
* method on its editor ref and a canClear callback so the host
|
* an `onCanClearChange` callback. The host now disables the button
|
||||||
* can disable the button when there's nothing to clear. Until
|
* when there's nothing to clear (empty canvas, no template loaded)
|
||||||
* that lands, removing the button is better than leaving a
|
* and lets the module own the actual confirm-and-clear flow — first
|
||||||
* non-functional one in the chrome.
|
* click arms it with a toast, second click within 3s commits.
|
||||||
*
|
*
|
||||||
* • Menu — opened the host's mobile bottom sheet, which now lives
|
* Preview / Download / Clear are all host-surfaced controls that
|
||||||
* inside the module. The module owns its own mobile UI; the
|
* drive the editor via its imperative API (editorRef.preview(),
|
||||||
* host had nothing left to put behind a menu.
|
* editorRef.testDownload(), editorRef.clear()). All three disable
|
||||||
*
|
* during an in-flight export (`isExporting`). Preview and Download
|
||||||
* Preview / Download are still here because they're host-surfaced
|
* additionally disable when `elementCount === 0` — nothing to render.
|
||||||
* controls that drive the editor via the editorRef.preview() and
|
|
||||||
* editorRef.testDownload() imperative API. They're disabled during
|
|
||||||
* an in-flight export (`isExporting`).
|
|
||||||
*
|
*
|
||||||
* i18n
|
* i18n
|
||||||
* ────
|
* ────
|
||||||
@@ -46,6 +46,15 @@ export function Header({
|
|||||||
onShare, onOpenCart,
|
onShare, onOpenCart,
|
||||||
onTestDownload, isExporting = false,
|
onTestDownload, isExporting = false,
|
||||||
onPreview,
|
onPreview,
|
||||||
|
onClear,
|
||||||
|
// canClear: true when the editor has at least one element OR a
|
||||||
|
// template loaded. Module-derived via onCanClearChange.
|
||||||
|
// elementCount: how many elements are on the canvas. Module-derived
|
||||||
|
// via onElementCountChange. Used to gate Preview/Download — with
|
||||||
|
// zero elements the export pipeline would render a blank PNG,
|
||||||
|
// which is never what the user wants.
|
||||||
|
canClear = false,
|
||||||
|
elementCount = 0,
|
||||||
}) {
|
}) {
|
||||||
// App name is reused for both the logo and the aria-label, so we
|
// App name is reused for both the logo and the aria-label, so we
|
||||||
// resolve it once and interpolate into the aria-label below.
|
// resolve it once and interpolate into the aria-label below.
|
||||||
@@ -79,15 +88,42 @@ export function Header({
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="ph-actions">
|
<div className="ph-actions">
|
||||||
|
{/* Clear — wipe the canvas + drop the current template. The
|
||||||
|
* module's clear() method handles the two-stage confirm flow
|
||||||
|
* (first click arms with a toast, second click within 3s
|
||||||
|
* commits), so the host just fires the trigger. Disabled
|
||||||
|
* when canClear is false: the editor reports false when the
|
||||||
|
* elements array is empty AND no template is loaded, so this
|
||||||
|
* dims out on a truly blank canvas where there's nothing
|
||||||
|
* destructive to do. Also disabled mid-export to avoid
|
||||||
|
* clobbering an in-flight render's source data. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ph-iconbtn"
|
||||||
|
onClick={onClear}
|
||||||
|
disabled={!canClear || isExporting}
|
||||||
|
aria-label={t('header.clear')}
|
||||||
|
title={t('header.clear-tooltip')}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6" />
|
||||||
|
<path d="M14 11v6" />
|
||||||
|
<path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Print preview — server-renders the design at print resolution
|
{/* Print preview — server-renders the design at print resolution
|
||||||
* and shows it in a modal so the user can verify what will
|
* and shows it in a modal so the user can verify what will
|
||||||
* actually print before adding to cart. Disabled while another
|
* actually print before adding to cart. Disabled while another
|
||||||
* export is in flight. */}
|
* export is in flight OR when the canvas is empty (nothing
|
||||||
|
* meaningful to preview). */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ph-iconbtn"
|
className="ph-iconbtn"
|
||||||
onClick={onPreview}
|
onClick={onPreview}
|
||||||
disabled={isExporting}
|
disabled={isExporting || elementCount === 0}
|
||||||
aria-label={t('header.preview')}
|
aria-label={t('header.preview')}
|
||||||
title={t('header.preview-tooltip')}
|
title={t('header.preview-tooltip')}
|
||||||
>
|
>
|
||||||
@@ -99,13 +135,14 @@ export function Header({
|
|||||||
|
|
||||||
{/* Download — dedicated print-test button. Same export pipeline
|
{/* Download — dedicated print-test button. Same export pipeline
|
||||||
* as Preview but auto-downloads instead of opening the modal.
|
* as Preview but auto-downloads instead of opening the modal.
|
||||||
* Disabled while an export is in flight so rapid clicks don't
|
* Disabled while an export is in flight (so rapid clicks don't
|
||||||
* fire parallel POSTs. */}
|
* fire parallel POSTs) and when the canvas is empty (no point
|
||||||
|
* downloading a blank PNG). */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ph-iconbtn"
|
className="ph-iconbtn"
|
||||||
onClick={onTestDownload}
|
onClick={onTestDownload}
|
||||||
disabled={isExporting}
|
disabled={isExporting || elementCount === 0}
|
||||||
aria-label={t('header.download')}
|
aria-label={t('header.download')}
|
||||||
title={t('header.download-tooltip')}
|
title={t('header.download-tooltip')}
|
||||||
>
|
>
|
||||||
|
|||||||
173
src/components/shop/CartStub.jsx
Normal file
173
src/components/shop/CartStub.jsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// <CartStub> — minimal cart preview at /cart.
|
||||||
|
//
|
||||||
|
// Reads from useCart(). Renders a table of line items with thumbnail,
|
||||||
|
// label, size, option choices, quantity controls, line total, and a
|
||||||
|
// grand total. The "Proceed to checkout" button shows an inline
|
||||||
|
// banner with "Checkout coming soon" — checkout is downstream.
|
||||||
|
//
|
||||||
|
// Why a stub
|
||||||
|
// ──────────
|
||||||
|
// Per locked decision #14: checkout is intentionally out of scope
|
||||||
|
// for v0.7. The cart is the hand-off point. When a real checkout
|
||||||
|
// lands (Stripe Checkout session, custom flow, whatever), this
|
||||||
|
// component is the place to wire it — replace the banner with a
|
||||||
|
// POST to the order endpoint and a redirect.
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ShopHeader } from './ShopHeader.jsx';
|
||||||
|
import { useCart } from '../../hooks/useCart.js';
|
||||||
|
|
||||||
|
function formatUSD(cents) {
|
||||||
|
return `$${((cents || 0) / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeOptions(optionChoices) {
|
||||||
|
const entries = Object.entries(optionChoices || {});
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
return entries
|
||||||
|
.map(([groupSlug, valueSlug]) => `${groupSlug}: ${valueSlug}`)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CartStub() {
|
||||||
|
const { items, count, totalCents, updateItem, removeItem, clear } = useCart();
|
||||||
|
const [checkoutBanner, setCheckoutBanner] = useState(false);
|
||||||
|
|
||||||
|
const handleCheckout = () => {
|
||||||
|
setCheckoutBanner(true);
|
||||||
|
// Auto-dismiss after a few seconds so the page doesn't get
|
||||||
|
// stuck in the toast state. Re-clicking the button re-arms it.
|
||||||
|
setTimeout(() => setCheckoutBanner(false), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<ShopHeader />
|
||||||
|
<main className="shop-main">
|
||||||
|
<section className="shop-cart">
|
||||||
|
<header className="shop-cart__head">
|
||||||
|
<h1 className="shop-cart__title">Your cart</h1>
|
||||||
|
<p className="shop-cart__count">
|
||||||
|
{count === 0 ? 'Empty' : count === 1 ? '1 item' : `${count} items`}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="shop-cart__empty">
|
||||||
|
<p>Your cart is empty.</p>
|
||||||
|
<Link to="/shop" className="shop-cart__continue">Continue shopping →</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="shop-cart__items">
|
||||||
|
{items.map((line) => (
|
||||||
|
<li key={line.id} className="shop-cart__line">
|
||||||
|
<div className="shop-cart__thumb">
|
||||||
|
{line.imageUrl ? (
|
||||||
|
<img src={line.imageUrl} alt={line.productLabel} loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<div className="shop-cart__thumb-fallback" aria-hidden="true">🐾</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shop-cart__details">
|
||||||
|
<h3 className="shop-cart__label">{line.productLabel}</h3>
|
||||||
|
<p className="shop-cart__meta">
|
||||||
|
{line.sizeLabel && <span>{line.sizeLabel}</span>}
|
||||||
|
{describeOptions(line.optionChoices) && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true"> · </span>
|
||||||
|
<span>{describeOptions(line.optionChoices)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="shop-cart__unit">{formatUSD(line.unitPriceCents)} each</p>
|
||||||
|
</div>
|
||||||
|
<div className="shop-cart__qty">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateItem(line.id, { quantity: Math.max(1, line.quantity - 1) })}
|
||||||
|
aria-label="Decrease quantity"
|
||||||
|
className="shop-qty-btn"
|
||||||
|
disabled={line.quantity <= 1}
|
||||||
|
>−</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="99"
|
||||||
|
value={line.quantity}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
if (Number.isFinite(n) && n > 0) {
|
||||||
|
updateItem(line.id, { quantity: Math.min(99, n) });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="shop-qty-input"
|
||||||
|
aria-label="Quantity"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateItem(line.id, { quantity: Math.min(99, line.quantity + 1) })}
|
||||||
|
aria-label="Increase quantity"
|
||||||
|
className="shop-qty-btn"
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
|
<div className="shop-cart__line-total">
|
||||||
|
{formatUSD(line.unitPriceCents * line.quantity)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(line.id)}
|
||||||
|
className="shop-cart__remove"
|
||||||
|
aria-label={`Remove ${line.productLabel} from cart`}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="shop-cart__summary">
|
||||||
|
<div className="shop-cart__total">
|
||||||
|
<span>Total</span>
|
||||||
|
<strong className="shop-price-large">{formatUSD(totalCents)}</strong>
|
||||||
|
</div>
|
||||||
|
<p className="shop-cart__shipping-note">
|
||||||
|
Shipping and taxes calculated at checkout.
|
||||||
|
</p>
|
||||||
|
{checkoutBanner && (
|
||||||
|
<p className="shop-cart__checkout-banner" role="status">
|
||||||
|
🚧 Checkout coming soon! We're still building this part — your cart will be here when it's ready.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="shop-cart__actions">
|
||||||
|
<Link to="/shop" className="shop-cart__continue">
|
||||||
|
← Continue shopping
|
||||||
|
</Link>
|
||||||
|
<div className="shop-cart__actions-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm('Clear all items from your cart?')) clear();
|
||||||
|
}}
|
||||||
|
className="shop-cart__clear"
|
||||||
|
>
|
||||||
|
Clear cart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCheckout}
|
||||||
|
className="shop-cart__checkout"
|
||||||
|
>
|
||||||
|
Proceed to checkout →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/shop/Hero.jsx
Normal file
47
src/components/shop/Hero.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// <Hero> — shop home hero block.
|
||||||
|
//
|
||||||
|
// Placeholder copy locked at v0.7 planning time. Will be replaced
|
||||||
|
// with brand-team copy once we have it; the structure (headline,
|
||||||
|
// subhead, two CTAs) is meant to survive that swap.
|
||||||
|
//
|
||||||
|
// CTAs route to:
|
||||||
|
// • "Start designing" → /shop?kind=customizable (filters the grid below)
|
||||||
|
// • "Browse ready-made" → /shop?kind=fixed
|
||||||
|
//
|
||||||
|
// Both CTAs scroll the page to the grid via ?kind= query param. The
|
||||||
|
// ShopHome route reads that param and seeds its kind filter from it,
|
||||||
|
// then the grid below the hero re-renders to match. We use hash-fragment
|
||||||
|
// (#grid) to also scroll the grid into view when the CTAs are tapped.
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function Hero() {
|
||||||
|
return (
|
||||||
|
<section className="shop-hero" aria-label="Pawfectly Yours — shop intro">
|
||||||
|
<div className="shop-hero__inner">
|
||||||
|
<h1 className="shop-hero__headline">
|
||||||
|
Custom Designs & Ready-Made for Every Pet Parent
|
||||||
|
</h1>
|
||||||
|
<p className="shop-hero__subhead">
|
||||||
|
Personalize a shirt, mug, or magnet — or grab one of our ready-to-ship
|
||||||
|
favorites. Made with love for the furry, the feathered, and the fabulous.
|
||||||
|
</p>
|
||||||
|
<div className="shop-hero__ctas">
|
||||||
|
<Link to="/shop?kind=customizable#grid" className="shop-hero__cta shop-hero__cta--primary">
|
||||||
|
Start designing →
|
||||||
|
</Link>
|
||||||
|
<Link to="/shop?kind=fixed#grid" className="shop-hero__cta shop-hero__cta--ghost">
|
||||||
|
Browse ready-made
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shop-hero__art" aria-hidden="true">
|
||||||
|
{/* Decorative paw cluster. Kept inline so we don't need a
|
||||||
|
* separate image asset for the first-paint hero. */}
|
||||||
|
<span className="shop-hero__paw shop-hero__paw--lg">🐾</span>
|
||||||
|
<span className="shop-hero__paw shop-hero__paw--md">💕</span>
|
||||||
|
<span className="shop-hero__paw shop-hero__paw--sm">🐾</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
src/components/shop/ProductBuyPanel.jsx
Normal file
282
src/components/shop/ProductBuyPanel.jsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
// <ProductBuyPanel> — purchase / customize action panel.
|
||||||
|
//
|
||||||
|
// Forks on product.kind:
|
||||||
|
// • customizable → size + options + "Customize this product" CTA.
|
||||||
|
// The CTA scrolls to the template gallery below. If the user
|
||||||
|
// wants to start from scratch, the gallery also offers a
|
||||||
|
// "Start blank" entry that mounts /customize with the product's
|
||||||
|
// default size.
|
||||||
|
// • fixed → size + options + quantity + "Add to cart". Pushes a
|
||||||
|
// line into useCart() and shows an inline confirmation banner.
|
||||||
|
//
|
||||||
|
// State shape
|
||||||
|
// ───────────
|
||||||
|
// selectedSizeId — defaults to the first size in product.sizes.
|
||||||
|
// optionChoices — { [groupSlug]: valueSlug }; defaults to the
|
||||||
|
// first value in each group.
|
||||||
|
// quantity — fixed-kind only; default 1.
|
||||||
|
//
|
||||||
|
// v0.8 — per-option pricing
|
||||||
|
// ─────────────────────────
|
||||||
|
// Each option value carries an optional `priceCents` field (default 0).
|
||||||
|
// The effective unit price is `size.basePriceCents + sum(selected
|
||||||
|
// values' priceCents)`. We surface this in three places:
|
||||||
|
// 1. The big total at the bottom (already multiplied by quantity).
|
||||||
|
// 2. A "+$X.XX" annotation on each option pill / swatch that has a
|
||||||
|
// non-zero price (so the customer sees the upcharge before they
|
||||||
|
// pick).
|
||||||
|
// 3. The `unitPriceCents` passed to addItem() — stays a single
|
||||||
|
// number so the cart doesn't need a separate option-cost field.
|
||||||
|
|
||||||
|
import { useState, useMemo, useRef } from 'react';
|
||||||
|
import { useCart } from '../../hooks/useCart.js';
|
||||||
|
|
||||||
|
// Currency symbol stored separately so neither template-literal
|
||||||
|
// dollar-substitution nor edit-tool back-reference handling can
|
||||||
|
// corrupt the literal sign mid-edit. (Felt cute, might revert.)
|
||||||
|
const CURRENCY_SYMBOL = String.fromCharCode(0x24);
|
||||||
|
|
||||||
|
function formatUSD(cents) {
|
||||||
|
const formatted = ((cents || 0) / 100).toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
return CURRENCY_SYMBOL + formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.8 — short form for the upcharge annotation on option pills.
|
||||||
|
// Drops trailing `.00` so a $2 upcharge reads "+$2" rather than
|
||||||
|
// "+$2.00" — less visual noise in the swatch grid.
|
||||||
|
function formatUpcharge(cents) {
|
||||||
|
if (!cents) return '';
|
||||||
|
const dollars = cents / 100;
|
||||||
|
const fixed = dollars.toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
const body = fixed.endsWith('.00') ? String(Math.round(dollars)) : fixed;
|
||||||
|
return '+' + CURRENCY_SYMBOL + body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultChoices(options) {
|
||||||
|
const out = {};
|
||||||
|
for (const group of options || []) {
|
||||||
|
if (group.values && group.values.length > 0) {
|
||||||
|
out[group.slug] = group.values[0].slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductBuyPanel({ product, onScrollToTemplates }) {
|
||||||
|
const isFixed = product.kind === 'fixed';
|
||||||
|
const sizes = product.sizes || [];
|
||||||
|
const options = product.options || [];
|
||||||
|
|
||||||
|
const [selectedSizeId, setSelectedSizeId] = useState(sizes[0]?.id || null);
|
||||||
|
const [optionChoices, setOptionChoices] = useState(() => buildDefaultChoices(options));
|
||||||
|
const [quantity, setQuantity] = useState(1);
|
||||||
|
const [addedBanner, setAddedBanner] = useState(false);
|
||||||
|
const bannerTimerRef = useRef(null);
|
||||||
|
|
||||||
|
const { addItem } = useCart();
|
||||||
|
|
||||||
|
const selectedSize = useMemo(
|
||||||
|
() => sizes.find((s) => s.id === selectedSizeId) || sizes[0] || null,
|
||||||
|
[sizes, selectedSizeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// v0.8 — walk options[] and sum the priceCents of the values the
|
||||||
|
// customer has selected. Memoized so we only recompute when the
|
||||||
|
// option set or the picks change.
|
||||||
|
const optionsPriceCents = useMemo(() => {
|
||||||
|
let sum = 0;
|
||||||
|
for (const group of options) {
|
||||||
|
const chosenSlug = optionChoices[group.slug];
|
||||||
|
if (!chosenSlug) continue;
|
||||||
|
const chosenValue = (group.values || []).find((v) => v.slug === chosenSlug);
|
||||||
|
if (chosenValue?.priceCents) sum += chosenValue.priceCents;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, [options, optionChoices]);
|
||||||
|
|
||||||
|
const unitPriceCents = (selectedSize?.basePriceCents || 0) + optionsPriceCents;
|
||||||
|
const totalCents = unitPriceCents * (isFixed ? quantity : 1);
|
||||||
|
|
||||||
|
const handleOptionChange = (groupSlug, valueSlug) => {
|
||||||
|
setOptionChoices((prev) => ({ ...prev, [groupSlug]: valueSlug }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToCart = () => {
|
||||||
|
if (!selectedSize) return;
|
||||||
|
// Pull the first gallery image (or first-size mockup) for the
|
||||||
|
// cart row thumbnail. The product's images[] is already sorted
|
||||||
|
// by displayOrder server-side.
|
||||||
|
const firstImage = (product.images || [])[0];
|
||||||
|
const imageUrl = firstImage
|
||||||
|
? firstImage.url
|
||||||
|
: selectedSize.mockupUrl || null;
|
||||||
|
addItem({
|
||||||
|
productId: product.id,
|
||||||
|
productLabel: product.label,
|
||||||
|
sizeId: selectedSize.id,
|
||||||
|
sizeLabel: selectedSize.label,
|
||||||
|
optionChoices,
|
||||||
|
quantity,
|
||||||
|
unitPriceCents,
|
||||||
|
imageUrl,
|
||||||
|
});
|
||||||
|
// Inline "added!" banner. The cart icon badge in the header
|
||||||
|
// updates immediately via the useCart cross-tab sync.
|
||||||
|
if (bannerTimerRef.current) clearTimeout(bannerTimerRef.current);
|
||||||
|
setAddedBanner(true);
|
||||||
|
bannerTimerRef.current = setTimeout(() => setAddedBanner(false), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shop-buy-panel">
|
||||||
|
{/* Size picker */}
|
||||||
|
{sizes.length > 0 && (
|
||||||
|
<div className="shop-buy-panel__group">
|
||||||
|
<label className="shop-buy-panel__label">Size</label>
|
||||||
|
<div className="shop-buy-panel__sizes" role="radiogroup" aria-label="Size">
|
||||||
|
{sizes.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={s.id === selectedSizeId}
|
||||||
|
className={`shop-size-btn ${s.id === selectedSizeId ? 'shop-size-btn--active' : ''}`}
|
||||||
|
onClick={() => setSelectedSizeId(s.id)}
|
||||||
|
>
|
||||||
|
<span className="shop-size-btn__label">{s.label}</span>
|
||||||
|
{s.basePriceCents !== unitPriceCents && (
|
||||||
|
<span className="shop-size-btn__price">{formatUSD(s.basePriceCents)}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Option groups (color, etc.) */}
|
||||||
|
{options.map((group) => (
|
||||||
|
<div key={group.id} className="shop-buy-panel__group">
|
||||||
|
<label className="shop-buy-panel__label">{group.label}</label>
|
||||||
|
{group.kind === 'color' ? (
|
||||||
|
<div className="shop-buy-panel__color-swatches" role="radiogroup" aria-label={group.label}>
|
||||||
|
{group.values.map((v) => {
|
||||||
|
const upcharge = formatUpcharge(v.priceCents);
|
||||||
|
// Swatches are too small for visible text; surface
|
||||||
|
// the upcharge via the tooltip + aria-label only.
|
||||||
|
const tooltip = upcharge ? `${v.label} (${upcharge})` : v.label;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={optionChoices[group.slug] === v.slug}
|
||||||
|
aria-label={tooltip}
|
||||||
|
title={tooltip}
|
||||||
|
className={`shop-color-swatch ${optionChoices[group.slug] === v.slug ? 'shop-color-swatch--active' : ''}`}
|
||||||
|
style={{ background: v.hex }}
|
||||||
|
onClick={() => handleOptionChange(group.slug, v.slug)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="shop-buy-panel__option-pills" role="radiogroup" aria-label={group.label}>
|
||||||
|
{group.values.map((v) => {
|
||||||
|
const upcharge = formatUpcharge(v.priceCents);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={optionChoices[group.slug] === v.slug}
|
||||||
|
className={`shop-option-pill ${optionChoices[group.slug] === v.slug ? 'shop-option-pill--active' : ''}`}
|
||||||
|
onClick={() => handleOptionChange(group.slug, v.slug)}
|
||||||
|
>
|
||||||
|
{v.label}
|
||||||
|
{upcharge && (
|
||||||
|
<span className="shop-option-pill__price">{upcharge}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Quantity (fixed only) */}
|
||||||
|
{isFixed && (
|
||||||
|
<div className="shop-buy-panel__group">
|
||||||
|
<label className="shop-buy-panel__label" htmlFor="shop-quantity">Quantity</label>
|
||||||
|
<div className="shop-buy-panel__quantity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||||
|
aria-label="Decrease quantity"
|
||||||
|
className="shop-qty-btn"
|
||||||
|
disabled={quantity <= 1}
|
||||||
|
>−</button>
|
||||||
|
<input
|
||||||
|
id="shop-quantity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="99"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
setQuantity(Number.isFinite(n) && n > 0 ? Math.min(99, n) : 1);
|
||||||
|
}}
|
||||||
|
className="shop-qty-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuantity((q) => Math.min(99, q + 1))}
|
||||||
|
aria-label="Increase quantity"
|
||||||
|
className="shop-qty-btn"
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Price line + primary CTA */}
|
||||||
|
<div className="shop-buy-panel__total">
|
||||||
|
<span className="shop-buy-panel__total-label">
|
||||||
|
{isFixed && quantity > 1 ? 'Total' : 'Price'}
|
||||||
|
</span>
|
||||||
|
<strong className="shop-price-large">{formatUSD(totalCents)}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFixed ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddToCart}
|
||||||
|
className="shop-buy-panel__cta shop-buy-panel__cta--primary"
|
||||||
|
disabled={!selectedSize}
|
||||||
|
>
|
||||||
|
Add to cart
|
||||||
|
</button>
|
||||||
|
{addedBanner && (
|
||||||
|
<p className="shop-buy-panel__added" role="status">
|
||||||
|
✨ Added to cart! <a href="/cart">View cart →</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onScrollToTemplates}
|
||||||
|
className="shop-buy-panel__cta shop-buy-panel__cta--primary"
|
||||||
|
>
|
||||||
|
Customize this product ↓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/shop/ProductCard.jsx
Normal file
55
src/components/shop/ProductCard.jsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// <ProductCard> — shop product card for the home grid.
|
||||||
|
//
|
||||||
|
// Wraps a single product summary returned by /api/public/products.
|
||||||
|
// Links to /shop/<slug>. Shows: first image (or first-size mockup as
|
||||||
|
// fallback), label, kind pill, "Starting at $X.XX".
|
||||||
|
//
|
||||||
|
// All visual surfaces use prefixed classes (.shop-card__*) and degrade
|
||||||
|
// gracefully when fields are missing (no image, no category).
|
||||||
|
//
|
||||||
|
// v0.7.4 — the public API now always populates imageUrl when at
|
||||||
|
// least one size exists (with the server's /products/* fallback
|
||||||
|
// serving the placeholder for unmocked sizes). The data-URL
|
||||||
|
// placeholder below is the safety net for the zero-sizes edge case
|
||||||
|
// so we never render the bare emoji fallback again.
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { PRODUCT_IMAGE_PLACEHOLDER_DATA_URL } from 'template-manager';
|
||||||
|
|
||||||
|
function formatUSD(cents) {
|
||||||
|
if (!cents) return '$0.00';
|
||||||
|
const formatted = (cents / 100).toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
return '$' + formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductCard({ product }) {
|
||||||
|
const kind = product.kind || 'customizable';
|
||||||
|
const kindLabel = kind === 'fixed' ? 'Ready-made' : 'Customizable';
|
||||||
|
const imageSrc = product.imageUrl || PRODUCT_IMAGE_PLACEHOLDER_DATA_URL;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/shop/${encodeURIComponent(product.slug)}`}
|
||||||
|
className="shop-card"
|
||||||
|
aria-label={`${product.label} — ${kindLabel}, starting at ${formatUSD(product.startingPriceCents)}`}
|
||||||
|
>
|
||||||
|
<div className="shop-card__image">
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={product.imageAlt || product.label}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span className={`shop-kind-pill shop-kind-pill--${kind}`}>{kindLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="shop-card__body">
|
||||||
|
<h3 className="shop-card__label">{product.label}</h3>
|
||||||
|
<p className="shop-card__price">
|
||||||
|
<span className="shop-price-from">Starting at</span>{' '}
|
||||||
|
<strong>{formatUSD(product.startingPriceCents)}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/components/shop/ProductGallery.jsx
Normal file
85
src/components/shop/ProductGallery.jsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// <ProductGallery> — image carousel for the product detail page.
|
||||||
|
//
|
||||||
|
// Shows the product's images[] sorted by displayOrder. Falls back to
|
||||||
|
// per-size mockups if images[] is empty (post-v0.7.4 every size has
|
||||||
|
// a mockupUrl that resolves to a real upload or the placeholder via
|
||||||
|
// the server's /products/* fallback chain). Main image + thumbnail
|
||||||
|
// strip. Click a thumbnail to swap the main image.
|
||||||
|
//
|
||||||
|
// Keyboard support: thumbnails are buttons, so Tab + Enter cycles
|
||||||
|
// through. Arrow keys aren't bound — we'd want a carousel role for
|
||||||
|
// that and v0.7 is intentionally minimal.
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { PRODUCT_IMAGE_PLACEHOLDER_DATA_URL } from 'template-manager';
|
||||||
|
|
||||||
|
function buildGalleryItems(product) {
|
||||||
|
// Prefer product.images[] (already sorted by the server).
|
||||||
|
if (Array.isArray(product.images) && product.images.length > 0) {
|
||||||
|
return product.images.map((img) => ({
|
||||||
|
key: img.id,
|
||||||
|
url: img.url,
|
||||||
|
alt: img.alt || product.label,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Fall back to per-size mockups. v0.7.4: every size has a mockupUrl
|
||||||
|
// (the server's /products/* fallback serves the placeholder if no
|
||||||
|
// real upload). We map every size, not just those with hasMockup,
|
||||||
|
// so the gallery has thumbs for the full size range.
|
||||||
|
const sizes = (product.sizes || []).filter((s) => s.mockupUrl);
|
||||||
|
if (sizes.length > 0) {
|
||||||
|
return sizes.map((s) => ({
|
||||||
|
key: s.id,
|
||||||
|
url: s.mockupUrl,
|
||||||
|
alt: `${product.label} — ${s.label}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Zero-sizes edge case: synthesize a single placeholder item so the
|
||||||
|
// gallery never renders blank.
|
||||||
|
return [{
|
||||||
|
key: 'placeholder',
|
||||||
|
url: PRODUCT_IMAGE_PLACEHOLDER_DATA_URL,
|
||||||
|
alt: product.label,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductGallery({ product }) {
|
||||||
|
const items = useMemo(() => buildGalleryItems(product), [product]);
|
||||||
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
|
|
||||||
|
// Reset to first image when product changes.
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveIdx(0);
|
||||||
|
}, [product.id]);
|
||||||
|
|
||||||
|
const active = items[Math.min(activeIdx, items.length - 1)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shop-gallery">
|
||||||
|
<div className="shop-gallery__main">
|
||||||
|
<img
|
||||||
|
src={active.url}
|
||||||
|
alt={active.alt}
|
||||||
|
className="shop-gallery__main-img"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{items.length > 1 && (
|
||||||
|
<div className="shop-gallery__thumbs" role="tablist" aria-label="Product photos">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={i === activeIdx}
|
||||||
|
className={`shop-gallery__thumb ${i === activeIdx ? 'shop-gallery__thumb--active' : ''}`}
|
||||||
|
onClick={() => setActiveIdx(i)}
|
||||||
|
aria-label={`View photo ${i + 1}: ${item.alt}`}
|
||||||
|
>
|
||||||
|
<img src={item.url} alt="" loading="lazy" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/components/shop/ProductGrid.jsx
Normal file
80
src/components/shop/ProductGrid.jsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// <ProductGrid> — paginated grid of product cards.
|
||||||
|
//
|
||||||
|
// Uses useShopProducts({ kind, q }) for data. Renders a responsive
|
||||||
|
// card grid with loading skeleton, empty state, error banner, and a
|
||||||
|
// "Load more" button when more pages exist.
|
||||||
|
//
|
||||||
|
// The `kind` and `q` props drive the underlying hook. Parents pass
|
||||||
|
// these from controlled state (filter buttons in ShopHome).
|
||||||
|
|
||||||
|
import { useShopProducts } from '../../hooks/useShopProducts.js';
|
||||||
|
import { ProductCard } from './ProductCard.jsx';
|
||||||
|
|
||||||
|
export function ProductGrid({ kind = 'all', q = '' }) {
|
||||||
|
const { items, loading, error, hasMore, loadMore } = useShopProducts({ kind, q });
|
||||||
|
|
||||||
|
// First-load skeleton — show a few card-shaped placeholders so the
|
||||||
|
// page doesn't look empty during the initial fetch. Subsequent
|
||||||
|
// page loads keep the existing items visible.
|
||||||
|
if (loading && items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="shop-grid shop-grid--loading">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="shop-card shop-card--skeleton" aria-hidden="true">
|
||||||
|
<div className="shop-card__image shop-card__image--skeleton" />
|
||||||
|
<div className="shop-card__body">
|
||||||
|
<div className="shop-card__label-skeleton" />
|
||||||
|
<div className="shop-card__price-skeleton" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="shop-grid shop-grid--error">
|
||||||
|
<p role="alert">
|
||||||
|
Something went wrong loading the catalog. Please try refreshing the page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loading && items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="shop-grid shop-grid--empty">
|
||||||
|
<p>
|
||||||
|
{kind === 'fixed'
|
||||||
|
? "We don't have any ready-made products yet — check back soon!"
|
||||||
|
: kind === 'customizable'
|
||||||
|
? "No customizable designs available right now."
|
||||||
|
: "Our catalog is empty. Check back soon!"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="shop-grid">
|
||||||
|
{items.map((p) => (
|
||||||
|
<ProductCard key={p.id} product={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasMore && (
|
||||||
|
<div className="shop-grid__more">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loading}
|
||||||
|
className="shop-grid__more-btn"
|
||||||
|
>
|
||||||
|
{loading ? 'Loading…' : 'Load more'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/shop/ProductInfo.jsx
Normal file
43
src/components/shop/ProductInfo.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// <ProductInfo> — header block for the product detail page.
|
||||||
|
//
|
||||||
|
// Shows label, category, kind pill, description, and the "starting at"
|
||||||
|
// price. Lives in the right column of the two-column product page,
|
||||||
|
// above the buy panel.
|
||||||
|
|
||||||
|
function formatUSD(cents) {
|
||||||
|
if (!cents) return '$0.00';
|
||||||
|
return `$${(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startingPriceCents(product) {
|
||||||
|
const sizes = product.sizes || [];
|
||||||
|
if (sizes.length === 0) return 0;
|
||||||
|
return sizes.reduce(
|
||||||
|
(min, s) => Math.min(min, s.basePriceCents || 0),
|
||||||
|
Number.POSITIVE_INFINITY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductInfo({ product }) {
|
||||||
|
const kind = product.kind || 'customizable';
|
||||||
|
const kindLabel = kind === 'fixed' ? 'Ready-made' : 'Customizable';
|
||||||
|
const fromPrice = startingPriceCents(product);
|
||||||
|
return (
|
||||||
|
<div className="shop-info">
|
||||||
|
<div className="shop-info__head">
|
||||||
|
<span className={`shop-kind-pill shop-kind-pill--${kind}`}>{kindLabel}</span>
|
||||||
|
{product.category && (
|
||||||
|
<span className="shop-info__category">{product.category}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="shop-info__label">{product.label}</h1>
|
||||||
|
<p className="shop-info__price">
|
||||||
|
<span className="shop-price-from">Starting at</span>{' '}
|
||||||
|
<strong className="shop-price-large">{formatUSD(fromPrice)}</strong>
|
||||||
|
</p>
|
||||||
|
{product.description && (
|
||||||
|
<p className="shop-info__description">{product.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/shop/ProductPage.jsx
Normal file
82
src/components/shop/ProductPage.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// <ProductPage> — shop product detail at /shop/:productSlug.
|
||||||
|
//
|
||||||
|
// Composition:
|
||||||
|
// ShopHeader
|
||||||
|
// ├ ProductGallery (left column)
|
||||||
|
// └ ProductInfo + ProductBuyPanel (right column)
|
||||||
|
// TemplateGalleryForProduct (full width, customizable kind only)
|
||||||
|
//
|
||||||
|
// Loading / error / 404 states are handled inline (kept simple — no
|
||||||
|
// dedicated error boundary needed for a leaf route).
|
||||||
|
//
|
||||||
|
// The buy panel's "Customize this product" CTA scrolls the template
|
||||||
|
// gallery into view via a ref + scrollIntoView. The gallery section
|
||||||
|
// has id="templates" so the URL hash also works as a fallback.
|
||||||
|
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { ShopHeader } from './ShopHeader.jsx';
|
||||||
|
import { ProductGallery } from './ProductGallery.jsx';
|
||||||
|
import { ProductInfo } from './ProductInfo.jsx';
|
||||||
|
import { ProductBuyPanel } from './ProductBuyPanel.jsx';
|
||||||
|
import { TemplateGalleryForProduct } from './TemplateGalleryForProduct.jsx';
|
||||||
|
import { useShopProduct } from '../../hooks/useShopProduct.js';
|
||||||
|
|
||||||
|
export function ProductPage() {
|
||||||
|
const { productSlug } = useParams();
|
||||||
|
const { product, loading, error } = useShopProduct(productSlug);
|
||||||
|
const templatesRef = useRef(null);
|
||||||
|
|
||||||
|
const handleScrollToTemplates = () => {
|
||||||
|
if (templatesRef.current) {
|
||||||
|
templatesRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<ShopHeader />
|
||||||
|
<main className="shop-main">
|
||||||
|
{loading && (
|
||||||
|
<div className="shop-product-page shop-product-page--loading">
|
||||||
|
<p>Loading…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="shop-product-page shop-product-page--error">
|
||||||
|
<h1>Product not found</h1>
|
||||||
|
<p>
|
||||||
|
We couldn't find that product. <Link to="/shop">Back to the shop →</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{product && (
|
||||||
|
<>
|
||||||
|
<nav className="shop-breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<Link to="/shop">← Shop</Link>
|
||||||
|
</nav>
|
||||||
|
<article className="shop-product-page">
|
||||||
|
<div className="shop-product-page__gallery">
|
||||||
|
<ProductGallery product={product} />
|
||||||
|
</div>
|
||||||
|
<div className="shop-product-page__details">
|
||||||
|
<ProductInfo product={product} />
|
||||||
|
<ProductBuyPanel
|
||||||
|
product={product}
|
||||||
|
onScrollToTemplates={handleScrollToTemplates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{product.kind === 'customizable' && (
|
||||||
|
<div ref={templatesRef}>
|
||||||
|
<TemplateGalleryForProduct product={product} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/shop/ShopHeader.jsx
Normal file
76
src/components/shop/ShopHeader.jsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// <ShopHeader> — customer-facing top bar for shop routes.
|
||||||
|
//
|
||||||
|
// Mounted by ShopHome, ProductPage, and CartStub. Replaces the
|
||||||
|
// editor-focused Header from App.jsx for non-/customize routes.
|
||||||
|
//
|
||||||
|
// Layout
|
||||||
|
// ──────
|
||||||
|
// [brand mark + wordmark] [nav: Shop] [account chip] [cart icon + badge]
|
||||||
|
//
|
||||||
|
// The account chip (v0.8, from auth-manager) renders a "Sign in"
|
||||||
|
// link when logged out and an avatar dropdown when logged in.
|
||||||
|
//
|
||||||
|
// Reuses the .ph-* CSS classes from Header.css so the brand identity
|
||||||
|
// (paw mark, wordmark, tagline, cart-badge styling) stays consistent
|
||||||
|
// between the editor header and the shop header. The middle slot
|
||||||
|
// holds shop-specific nav links instead of the editor's Preview /
|
||||||
|
// Download / Share controls.
|
||||||
|
//
|
||||||
|
// Why a separate component instead of a prop-driven Header
|
||||||
|
// ────────────────────────────────────────────────────────
|
||||||
|
// The editor Header takes editorRef-based action callbacks (preview,
|
||||||
|
// download, share) that only make sense in the editor mount. The
|
||||||
|
// shop chrome needs none of those and shouldn't carry the dead
|
||||||
|
// callbacks around. A separate component keeps each surface focused.
|
||||||
|
//
|
||||||
|
// Cart count comes from useCart(), which is localStorage-backed and
|
||||||
|
// cross-tab synced. The badge re-renders the moment another tab adds
|
||||||
|
// to cart.
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { AccountMenu } from '@khalidadil/auth-manager';
|
||||||
|
import { useCart } from '../../hooks/useCart.js';
|
||||||
|
import { t } from '../../i18n/t.js';
|
||||||
|
|
||||||
|
export function ShopHeader() {
|
||||||
|
const { count } = useCart();
|
||||||
|
const appName = t('header.app-name');
|
||||||
|
const cartAria = count === 1
|
||||||
|
? t('header.cart-aria.singular', { count })
|
||||||
|
: t('header.cart-aria.plural', { count });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="ph-header shop-header" role="banner">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="ph-logo"
|
||||||
|
aria-label={t('header.logo-home-aria', { app: appName })}
|
||||||
|
>
|
||||||
|
<span className="ph-logo__mark" aria-hidden="true">🐾</span>
|
||||||
|
<span className="ph-logo__wordmark">
|
||||||
|
<span className="ph-logo__name">{appName}</span>
|
||||||
|
<span className="ph-logo__tagline">{t('header.app-tagline')}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="shop-header__nav" aria-label="Shop navigation">
|
||||||
|
<Link to="/shop" className="shop-header__nav-link">Shop</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="ph-actions shop-header__actions">
|
||||||
|
{/* v0.8 — account chip lives between nav and cart. Renders a
|
||||||
|
"Sign in" link when logged out, an avatar+dropdown when
|
||||||
|
logged in. */}
|
||||||
|
<AccountMenu />
|
||||||
|
<Link to="/cart" className="ph-cart" aria-label={cartAria}>
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="9" cy="20" r="1.6" />
|
||||||
|
<circle cx="17" cy="20" r="1.6" />
|
||||||
|
<path d="M3 4h2l2.4 11.5a2 2 0 0 0 2 1.5h7.6a2 2 0 0 0 2-1.5L21 8H6" />
|
||||||
|
</svg>
|
||||||
|
{count > 0 && <span className="ph-cart__badge">{count}</span>}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/components/shop/ShopHome.jsx
Normal file
88
src/components/shop/ShopHome.jsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// <ShopHome> — landing page at / and /shop.
|
||||||
|
//
|
||||||
|
// Layout: ShopHeader + Hero + kind filter tabs + ProductGrid.
|
||||||
|
//
|
||||||
|
// The kind filter is the only client-side state the page owns. We
|
||||||
|
// seed it from the URL's ?kind= query param so deep links (e.g. the
|
||||||
|
// hero's "Browse ready-made" CTA → /shop?kind=fixed) land on the
|
||||||
|
// right view. Tab clicks update both the local state AND the URL
|
||||||
|
// query so the page can be bookmarked / shared in any filter state.
|
||||||
|
//
|
||||||
|
// The hash fragment `#grid` is used by the hero CTAs to scroll the
|
||||||
|
// grid into view after a click; we add a target id on the grid
|
||||||
|
// section so the browser's native anchor scroll handles it.
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { ShopHeader } from './ShopHeader.jsx';
|
||||||
|
import { Hero } from './Hero.jsx';
|
||||||
|
import { ProductGrid } from './ProductGrid.jsx';
|
||||||
|
|
||||||
|
const VALID_KINDS = new Set(['all', 'customizable', 'fixed']);
|
||||||
|
|
||||||
|
export function ShopHome() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const urlKind = searchParams.get('kind');
|
||||||
|
const initialKind = VALID_KINDS.has(urlKind) ? urlKind : 'all';
|
||||||
|
const [kind, setKind] = useState(initialKind);
|
||||||
|
|
||||||
|
// Keep local state and URL in sync on URL changes (back/forward navigation).
|
||||||
|
useEffect(() => {
|
||||||
|
const fromUrl = searchParams.get('kind');
|
||||||
|
const resolved = VALID_KINDS.has(fromUrl) ? fromUrl : 'all';
|
||||||
|
if (resolved !== kind) setKind(resolved);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const setKindAndUrl = (next) => {
|
||||||
|
setKind(next);
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
if (next === 'all') params.delete('kind');
|
||||||
|
else params.set('kind', next);
|
||||||
|
setSearchParams(params, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<ShopHeader />
|
||||||
|
<main className="shop-main">
|
||||||
|
<Hero />
|
||||||
|
<section className="shop-catalog" id="grid">
|
||||||
|
<div className="shop-catalog__head">
|
||||||
|
<h2 className="shop-catalog__title">Shop the catalog</h2>
|
||||||
|
<div className="shop-catalog__filters" role="tablist" aria-label="Filter by kind">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={kind === 'all'}
|
||||||
|
className={`shop-filter ${kind === 'all' ? 'shop-filter--active' : ''}`}
|
||||||
|
onClick={() => setKindAndUrl('all')}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={kind === 'customizable'}
|
||||||
|
className={`shop-filter ${kind === 'customizable' ? 'shop-filter--active' : ''}`}
|
||||||
|
onClick={() => setKindAndUrl('customizable')}
|
||||||
|
>
|
||||||
|
Designs
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={kind === 'fixed'}
|
||||||
|
className={`shop-filter ${kind === 'fixed' ? 'shop-filter--active' : ''}`}
|
||||||
|
onClick={() => setKindAndUrl('fixed')}
|
||||||
|
>
|
||||||
|
Ready-made
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProductGrid kind={kind} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/shop/TemplateGalleryForProduct.jsx
Normal file
55
src/components/shop/TemplateGalleryForProduct.jsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// <TemplateGalleryForProduct> — list templates pinned to a customizable product.
|
||||||
|
//
|
||||||
|
// Mounted on the product detail page below the buy panel, only when
|
||||||
|
// product.kind === 'customizable'. Wraps the template-manager's
|
||||||
|
// <TemplateGrid> with a productId filter. Each card already links
|
||||||
|
// out to /customize?template=<id> via the TemplateStoreProvider's
|
||||||
|
// customizeUrl factory (configured in AppRouter).
|
||||||
|
//
|
||||||
|
// We render this even if there are zero templates yet — the empty
|
||||||
|
// state from TemplateGrid is helpful messaging on its own ("No
|
||||||
|
// designs match your filters"). If/when the customer wants to design
|
||||||
|
// from scratch we surface a "Start blank" link that mounts /customize
|
||||||
|
// with the product's default size, skipping the template-required path.
|
||||||
|
|
||||||
|
import { TemplateGrid } from 'template-manager';
|
||||||
|
import { useAuthContext } from '@khalidadil/auth-manager';
|
||||||
|
import { useFacetCatalog } from '../../hooks/useFacetCatalog';
|
||||||
|
|
||||||
|
export function TemplateGalleryForProduct({ product }) {
|
||||||
|
const firstSize = (product.sizes || [])[0];
|
||||||
|
// Facet personalization (Project doc §R10) — narrow the design list
|
||||||
|
// to the customer's selected facet values (plus universal/General).
|
||||||
|
// Only when a facet is configured AND the customer has a selection;
|
||||||
|
// otherwise show all designs.
|
||||||
|
const { facetValues } = useAuthContext();
|
||||||
|
const { config: facetConfig } = useFacetCatalog();
|
||||||
|
const facetFilter = facetConfig.enabled && facetValues.length ? { facetValues } : {};
|
||||||
|
return (
|
||||||
|
<section className="shop-templates" id="templates">
|
||||||
|
<header className="shop-templates__head">
|
||||||
|
<h2 className="shop-templates__title">Pick a design to customize</h2>
|
||||||
|
<p className="shop-templates__sub">
|
||||||
|
Start with one of our designs — you can swap photos, change text, and
|
||||||
|
make it yours.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<TemplateGrid
|
||||||
|
filter={{ productId: product.id, ...facetFilter }}
|
||||||
|
emptyMessage="No designs for this product yet. Check back soon!"
|
||||||
|
className="shop-templates__grid"
|
||||||
|
/>
|
||||||
|
{firstSize && (
|
||||||
|
<div className="shop-templates__blank">
|
||||||
|
<p className="shop-templates__blank-sub">…or start from scratch:</p>
|
||||||
|
<a
|
||||||
|
href={`/customize?productId=${encodeURIComponent(product.id)}&sizeId=${encodeURIComponent(firstSize.id)}`}
|
||||||
|
className="shop-templates__blank-cta"
|
||||||
|
>
|
||||||
|
Start with a blank canvas →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/hooks/useCart.js
Normal file
172
src/hooks/useCart.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// useCart() — localStorage-backed shopping cart.
|
||||||
|
//
|
||||||
|
// Persists across reload at localStorage.pawfectly_cart_v1. Broadcasts
|
||||||
|
// changes across tabs via the storage event so the cart badge in
|
||||||
|
// every open tab stays in sync.
|
||||||
|
//
|
||||||
|
// Cart line shape:
|
||||||
|
// {
|
||||||
|
// id, // line id (generated; not a product/size id)
|
||||||
|
// productId, productLabel,
|
||||||
|
// sizeId, sizeLabel,
|
||||||
|
// optionChoices, // { [groupSlug]: valueSlug }
|
||||||
|
// quantity,
|
||||||
|
// unitPriceCents, // base + summed option upcharges (v0.8);
|
||||||
|
// // ProductBuyPanel rolls option priceCents
|
||||||
|
// // into this number before calling addItem.
|
||||||
|
// imageUrl, // thumbnail for the cart row
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Returned shape:
|
||||||
|
// {
|
||||||
|
// items, count,
|
||||||
|
// totalCents,
|
||||||
|
// addItem(spec), // adds a new line; returns the line id
|
||||||
|
// updateItem(id, patch), // merges patch into an existing line
|
||||||
|
// removeItem(id),
|
||||||
|
// clear(),
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Why localStorage and not a server cart
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// Checkout is downstream (locked decision #14: toast "Checkout coming
|
||||||
|
// soon"). Until there's an order endpoint, server-side cart state has
|
||||||
|
// no consumer. localStorage is enough to power the cart icon badge,
|
||||||
|
// the /cart route preview, and to survive a page reload. When a real
|
||||||
|
// checkout lands, this hook can hand off its items to a server endpoint
|
||||||
|
// or stay as the source of truth depending on the architecture chosen.
|
||||||
|
//
|
||||||
|
// Why not React Context
|
||||||
|
// ─────────────────────
|
||||||
|
// Only the header (cart badge) and /cart (line items) need the cart
|
||||||
|
// state. Both can mount their own useCart() instance and the storage-
|
||||||
|
// event listener keeps them in sync. A Context would require a
|
||||||
|
// provider above both routes; the storage-event approach works
|
||||||
|
// without it and across tabs.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'pawfectly_cart_v1';
|
||||||
|
// Custom event we dispatch on the window after a local write so the
|
||||||
|
// other hook instances IN THE SAME TAB also re-render. The `storage`
|
||||||
|
// event only fires across tabs, not within the originating tab.
|
||||||
|
const CHANGE_EVENT = 'pawfectly:cart-change';
|
||||||
|
|
||||||
|
function readFromStorage() {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
// Corrupt JSON or storage disabled (private mode in some browsers).
|
||||||
|
// Treat as empty rather than crashing the page.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeToStorage(items) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||||
|
// Notify same-tab listeners. Cross-tab listeners get the native
|
||||||
|
// 'storage' event from the browser.
|
||||||
|
window.dispatchEvent(new CustomEvent(CHANGE_EVENT));
|
||||||
|
} catch {
|
||||||
|
// Quota exceeded or storage blocked. Cart will be ephemeral but
|
||||||
|
// we don't surface this to the user — the cart still works in
|
||||||
|
// memory for the rest of the session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function genLineId() {
|
||||||
|
// Short pseudo-random; uniqueness only needs to be within the cart,
|
||||||
|
// not globally. Format is intentionally distinct from UUIDs so logs
|
||||||
|
// don't mistake cart line ids for product/size ids.
|
||||||
|
return `line_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCart() {
|
||||||
|
const [items, setItems] = useState(() => readFromStorage());
|
||||||
|
|
||||||
|
// Cross-tab + same-tab sync. The native 'storage' event fires when
|
||||||
|
// a DIFFERENT tab writes; our custom event fires when the SAME tab
|
||||||
|
// writes via another useCart() instance.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const sync = () => setItems(readFromStorage());
|
||||||
|
const onStorage = (e) => {
|
||||||
|
if (e.key === STORAGE_KEY) sync();
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
window.addEventListener(CHANGE_EVENT, sync);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', onStorage);
|
||||||
|
window.removeEventListener(CHANGE_EVENT, sync);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addItem = useCallback((spec) => {
|
||||||
|
const line = {
|
||||||
|
id: genLineId(),
|
||||||
|
productId: spec.productId,
|
||||||
|
productLabel: spec.productLabel || '',
|
||||||
|
sizeId: spec.sizeId,
|
||||||
|
sizeLabel: spec.sizeLabel || '',
|
||||||
|
optionChoices: spec.optionChoices || {},
|
||||||
|
quantity: Math.max(1, Math.floor(Number(spec.quantity) || 1)),
|
||||||
|
unitPriceCents: Math.max(0, Math.floor(Number(spec.unitPriceCents) || 0)),
|
||||||
|
imageUrl: spec.imageUrl || null,
|
||||||
|
};
|
||||||
|
setItems((prev) => {
|
||||||
|
const next = [...prev, line];
|
||||||
|
writeToStorage(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return line.id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateItem = useCallback((id, patch) => {
|
||||||
|
setItems((prev) => {
|
||||||
|
const next = prev.map((it) => {
|
||||||
|
if (it.id !== id) return it;
|
||||||
|
const merged = { ...it, ...patch };
|
||||||
|
if (patch.quantity !== undefined) {
|
||||||
|
merged.quantity = Math.max(1, Math.floor(Number(patch.quantity) || 1));
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
writeToStorage(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeItem = useCallback((id) => {
|
||||||
|
setItems((prev) => {
|
||||||
|
const next = prev.filter((it) => it.id !== id);
|
||||||
|
writeToStorage(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setItems([]);
|
||||||
|
writeToStorage([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Derived values. Cheap to recompute every render — the cart is
|
||||||
|
// small (almost always under 10 lines).
|
||||||
|
const count = items.reduce((sum, it) => sum + (it.quantity || 0), 0);
|
||||||
|
const totalCents = items.reduce((sum, it) => sum + (it.unitPriceCents || 0) * (it.quantity || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
count,
|
||||||
|
totalCents,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
removeItem,
|
||||||
|
clear,
|
||||||
|
};
|
||||||
|
}
|
||||||
78
src/hooks/useFacetCatalog.js
Normal file
78
src/hooks/useFacetCatalog.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// useFacetCatalog — host-side read of the personalization-facet
|
||||||
|
// catalog: config + the active value vocabulary + the sticker→value
|
||||||
|
// assignment map. Read-only; the three public endpoints are HTTP-
|
||||||
|
// cached (max-age=300), so multiple mounts are cheap.
|
||||||
|
//
|
||||||
|
// Used by:
|
||||||
|
// • CustomizeRoute — to compute the editor's sticker deny-list
|
||||||
|
// (computeHiddenStickerCategories needs the assignments + the
|
||||||
|
// active value slugs; the customer's SELECTION comes separately
|
||||||
|
// from AuthContext.facetValues).
|
||||||
|
// • TemplateGalleryForProduct — only reads config.enabled to decide
|
||||||
|
// whether to add ?facetValues= to the template query.
|
||||||
|
//
|
||||||
|
// When no facet is configured (FACET_KEY unset on the server),
|
||||||
|
// config.enabled is false and callers no-op (master switch — §R10).
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const DISABLED = {
|
||||||
|
enabled: false,
|
||||||
|
key: null,
|
||||||
|
label: null,
|
||||||
|
selectionMode: 'multi',
|
||||||
|
requireSelection: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useFacetCatalog({ apiBase = '/api' } = {}) {
|
||||||
|
const [config, setConfig] = useState(DISABLED);
|
||||||
|
const [values, setValues] = useState([]);
|
||||||
|
const [assignments, setAssignments] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [cfgRes, valsRes, mapRes] = await Promise.all([
|
||||||
|
fetch(`${apiBase}/public/facet-config`),
|
||||||
|
fetch(`${apiBase}/public/facet-values`),
|
||||||
|
fetch(`${apiBase}/public/sticker-facet-map`),
|
||||||
|
]);
|
||||||
|
if (!alive) return;
|
||||||
|
if (cfgRes.ok) {
|
||||||
|
const b = await cfgRes.json();
|
||||||
|
setConfig({
|
||||||
|
enabled: !!b.enabled,
|
||||||
|
key: b.key || null,
|
||||||
|
label: b.label || null,
|
||||||
|
selectionMode: b.selectionMode === 'single' ? 'single' : 'multi',
|
||||||
|
requireSelection: !!b.requireSelection,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setConfig(DISABLED);
|
||||||
|
}
|
||||||
|
if (valsRes.ok) {
|
||||||
|
const b = await valsRes.json();
|
||||||
|
setValues(Array.isArray(b.items) ? b.items : []);
|
||||||
|
}
|
||||||
|
if (mapRes.ok) {
|
||||||
|
const b = await mapRes.json();
|
||||||
|
setAssignments(b.assignments || {});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (alive) setConfig(DISABLED);
|
||||||
|
} finally {
|
||||||
|
if (alive) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [apiBase]);
|
||||||
|
|
||||||
|
// /public/facet-values returns active values only, so these are the
|
||||||
|
// active slugs the deny-list's deletion-safety check needs.
|
||||||
|
const activeValueSlugs = values.map((v) => v.slug);
|
||||||
|
|
||||||
|
return { config, values, activeValueSlugs, assignments, loading };
|
||||||
|
}
|
||||||
61
src/hooks/useShopProduct.js
Normal file
61
src/hooks/useShopProduct.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// useShopProduct(slugOrId) — single-product fetcher for the detail page.
|
||||||
|
//
|
||||||
|
// Wraps GET /api/public/products/:idOrSlug. The server resolves slug
|
||||||
|
// → id internally, so we can pass either. The response includes:
|
||||||
|
//
|
||||||
|
// { id, slug, label, description, category, kind,
|
||||||
|
// options[], sizes[], images[], templateCount }
|
||||||
|
//
|
||||||
|
// Notes
|
||||||
|
// ─────
|
||||||
|
// • templateCount is server-computed (only relevant for customizable
|
||||||
|
// products; fixed products always get 0). Material products are
|
||||||
|
// never served via this endpoint — the public route returns 404.
|
||||||
|
// • sizes carry `mockupUrl` when a per-size mockup exists, otherwise
|
||||||
|
// null. The product page falls back to the first image in images[]
|
||||||
|
// for the gallery if no mockups exist.
|
||||||
|
// • v0.8: each option value carries `priceCents` (default 0) and
|
||||||
|
// each size carries `defaultParcel`. ProductBuyPanel reads the
|
||||||
|
// former for upcharge annotations + unit-price rollup; commerce-
|
||||||
|
// module will read the latter for Shippo rate quotes.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export function useShopProduct(slugOrId) {
|
||||||
|
const [product, setProduct] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const genRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slugOrId) {
|
||||||
|
setProduct(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gen = ++genRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/public/products/${encodeURIComponent(slugOrId)}`);
|
||||||
|
if (gen !== genRef.current) return;
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
setError(body);
|
||||||
|
setProduct(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setProduct(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (gen !== genRef.current) return;
|
||||||
|
setError({ message: err.message });
|
||||||
|
} finally {
|
||||||
|
if (gen === genRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [slugOrId]);
|
||||||
|
|
||||||
|
return { product, loading, error };
|
||||||
|
}
|
||||||
93
src/hooks/useShopProducts.js
Normal file
93
src/hooks/useShopProducts.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// useShopProducts({ kind, q }) — paginated public-product fetcher.
|
||||||
|
//
|
||||||
|
// Wraps GET /api/public/products with cursor pagination. Used by the
|
||||||
|
// shop home grid. Cursor pagination means each "Load more" call
|
||||||
|
// appends rather than replacing.
|
||||||
|
//
|
||||||
|
// Returned shape:
|
||||||
|
// { items, loading, error, hasMore, loadMore, refetch }
|
||||||
|
//
|
||||||
|
// Re-fetches from scratch when kind or q change. The cursor is reset
|
||||||
|
// each time the filter changes — otherwise we'd be paginating into a
|
||||||
|
// list that no longer matches the active filter.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 24;
|
||||||
|
|
||||||
|
export function useShopProducts({ kind = 'all', q = '', limit = DEFAULT_LIMIT } = {}) {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [nextCursor, setNextCursor] = useState(null);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
// Bump on filter change to reset the list; also bump on manual refetch.
|
||||||
|
const [refetchTrigger, setRefetchTrigger] = useState(0);
|
||||||
|
const genRef = useRef(0);
|
||||||
|
|
||||||
|
const refetch = useCallback(() => setRefetchTrigger((n) => n + 1), []);
|
||||||
|
|
||||||
|
// Reset + fetch first page whenever filters or refetch trigger change.
|
||||||
|
useEffect(() => {
|
||||||
|
const gen = ++genRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setItems([]);
|
||||||
|
setNextCursor(null);
|
||||||
|
setHasMore(false);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (kind && kind !== 'all') params.set('kind', kind);
|
||||||
|
if (q) params.set('q', q);
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
const res = await fetch(`/api/public/products?${params.toString()}`);
|
||||||
|
if (gen !== genRef.current) return;
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
setError(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setItems(data.items || []);
|
||||||
|
setNextCursor(data.nextCursor || null);
|
||||||
|
setHasMore(!!data.nextCursor);
|
||||||
|
} catch (err) {
|
||||||
|
if (gen !== genRef.current) return;
|
||||||
|
setError({ message: err.message });
|
||||||
|
} finally {
|
||||||
|
if (gen === genRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [kind, q, limit, refetchTrigger]);
|
||||||
|
|
||||||
|
// Paginate forward. We don't bump the gen counter here — we WANT
|
||||||
|
// the in-flight result to land into the existing list.
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (!nextCursor || loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (kind && kind !== 'all') params.set('kind', kind);
|
||||||
|
if (q) params.set('q', q);
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
params.set('cursor', nextCursor);
|
||||||
|
const res = await fetch(`/api/public/products?${params.toString()}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
setError(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setItems((prev) => [...prev, ...(data.items || [])]);
|
||||||
|
setNextCursor(data.nextCursor || null);
|
||||||
|
setHasMore(!!data.nextCursor);
|
||||||
|
} catch (err) {
|
||||||
|
setError({ message: err.message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [kind, q, limit, nextCursor, loading]);
|
||||||
|
|
||||||
|
return { items, loading, error, hasMore, loadMore, refetch };
|
||||||
|
}
|
||||||
@@ -53,16 +53,22 @@
|
|||||||
|
|
||||||
export const en = {
|
export const en = {
|
||||||
// ── Header ──────────────────────────────────────────────────
|
// ── Header ──────────────────────────────────────────────────
|
||||||
// Save / Clear / Menu were removed from the header chrome during the
|
// Save and Menu were removed from the header chrome during the module
|
||||||
// module split — Save was redundant with Download (same export
|
// split. Save was redundant with Download (same export pipeline); Menu
|
||||||
// pipeline), Clear needs a module-side `clear()` method on the editor
|
// opened the host's own bottom sheet which is now inside the module.
|
||||||
// ref before it can be reintroduced, and Menu opened the host's own
|
// Their i18n keys are gone with them; readd if you put either back.
|
||||||
// bottom sheet which is now inside the module. Their i18n keys are
|
//
|
||||||
// gone with them; readd if you put any of those buttons back.
|
// Clear was also removed but came back in v0.8 once the module
|
||||||
|
// exposed a `clear()` method on its editor ref plus an
|
||||||
|
// `onCanClearChange` callback so the host can gate the button.
|
||||||
|
// See Header.jsx for the button and App.jsx / CustomizeRoute.jsx
|
||||||
|
// for the wiring.
|
||||||
'header.app-name': 'Pawfectly Yours',
|
'header.app-name': 'Pawfectly Yours',
|
||||||
'header.app-tagline': 'Wear Your Pet. Share Your Story.',
|
'header.app-tagline': 'Wear Your Pet. Share Your Story.',
|
||||||
'header.page-title': 'Customize Your Shirt',
|
'header.page-title': 'Customize Your Shirt',
|
||||||
'header.logo-home-aria': '{app} home',
|
'header.logo-home-aria': '{app} home',
|
||||||
|
'header.clear': 'Clear canvas',
|
||||||
|
'header.clear-tooltip': 'Remove all elements and start over',
|
||||||
'header.preview': 'Print preview',
|
'header.preview': 'Print preview',
|
||||||
'header.preview-tooltip': 'See your design at print resolution',
|
'header.preview-tooltip': 'See your design at print resolution',
|
||||||
'header.download': 'Download print file',
|
'header.download': 'Download print file',
|
||||||
|
|||||||
2379
src/styles/admin.css
Normal 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
1090
src/styles/shop.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -289,6 +289,13 @@ export default defineConfig({
|
|||||||
'/api': { target: 'http://localhost:3001', changeOrigin: true },
|
'/api': { target: 'http://localhost:3001', changeOrigin: true },
|
||||||
'/uploads': { target: 'http://localhost:3001', changeOrigin: true },
|
'/uploads': { target: 'http://localhost:3001', changeOrigin: true },
|
||||||
'/exports': { target: 'http://localhost:3001', changeOrigin: true },
|
'/exports': { target: 'http://localhost:3001', changeOrigin: true },
|
||||||
|
// v0.7.4 — product images (gallery photos + per-size mockups)
|
||||||
|
// are served by template-manager mounted on the Node server
|
||||||
|
// (port 3001). Without this proxy, browser <img src="/products/...">
|
||||||
|
// requests hit Vite's SPA fallback and return index.html as an
|
||||||
|
// image, breaking the shop product grid, product gallery, and
|
||||||
|
// admin size cards.
|
||||||
|
'/products': { target: 'http://localhost:3001', changeOrigin: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
@@ -390,6 +397,7 @@ export default defineConfig({
|
|||||||
['react-vendor', 'react', 'react-dom'],
|
['react-vendor', 'react', 'react-dom'],
|
||||||
['filerobot', 'react-filerobot-image-editor'],
|
['filerobot', 'react-filerobot-image-editor'],
|
||||||
['goods-editor', 'goods-editor'],
|
['goods-editor', 'goods-editor'],
|
||||||
|
['template-manager', 'template-manager'],
|
||||||
];
|
];
|
||||||
for (const [chunk, ...packages] of groups) {
|
for (const [chunk, ...packages] of groups) {
|
||||||
for (const pkg of packages) {
|
for (const pkg of packages) {
|
||||||
|
|||||||
Reference in New Issue
Block a user