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
|
||||
// + cart state + toast surface).
|
||||
//
|
||||
// Routing status (v0.7+)
|
||||
// ──────────────────────
|
||||
// This file is NOT mounted by AppRouter as of v0.7. Both `/` and
|
||||
// `/customize?template=<id>` now route to ShopHome and
|
||||
// CustomizeRoute respectively. App.jsx is kept on disk one release
|
||||
// for safety — nothing imports it, but if a deploy ever needs the
|
||||
// legacy single-route fallback it's here. Slated for deletion once
|
||||
// the deployment migration is confirmed.
|
||||
//
|
||||
// What stayed in the host
|
||||
// ───────────────────────
|
||||
// • Site header (logo, preview, download, share, cart icon + badge)
|
||||
@@ -70,6 +79,24 @@ function App() {
|
||||
const editorRef = useRef(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// v0.8 — module-derived signals for header button gating.
|
||||
//
|
||||
// The editor module emits two coarse callbacks at history-commit
|
||||
// boundaries (drag-end, add, delete, etc.):
|
||||
//
|
||||
// onElementCountChange(n) — current count of elements
|
||||
// onCanClearChange(canClear) — whether the canvas has stuff
|
||||
// to clear (count > 0 OR a
|
||||
// template is loaded)
|
||||
//
|
||||
// We mirror them into local state so the Header can disable
|
||||
// Preview/Download on empty canvases and grey out Clear when
|
||||
// there's nothing destructive to do. Both fire once on mount with
|
||||
// the initial state, so the header buttons start in the right
|
||||
// shape without a separate effect.
|
||||
const [elementCount, setElementCount] = useState(0);
|
||||
const [canClear, setCanClear] = useState(false);
|
||||
|
||||
// ── Toast surface ───────────────────────────────────────────────
|
||||
// See src/components/Toast.jsx for the renderer + architecture
|
||||
// docblock. Shape: { message, kind, id } | null.
|
||||
@@ -116,15 +143,11 @@ function App() {
|
||||
|
||||
// ── Editor action proxies ───────────────────────────────────────
|
||||
// Preview and Download both fire the editor's export pipeline.
|
||||
//
|
||||
// No empty-canvas guard here, even though it would be friendlier
|
||||
// than letting the pipeline render a blank PNG. The host doesn't
|
||||
// own the elements array (it lives inside the module), so checking
|
||||
// emptiness from out here would require the module to emit a
|
||||
// count via callback. That's a module API extension worth doing
|
||||
// alongside the Clear button reintroduction — both want the same
|
||||
// "host can query editor state" pattern. Until then, the worst
|
||||
// case is the user gets an empty PNG in Downloads.
|
||||
// The Header already disables both buttons when isExporting is
|
||||
// true OR elementCount === 0 (the latter sourced from
|
||||
// onElementCountChange below), so the proxies themselves stay
|
||||
// minimal — fire the imperative method on the editor ref and
|
||||
// let the module's export pipeline take it from there.
|
||||
const handleHeaderPreview = useCallback(() => {
|
||||
editorRef.current?.preview();
|
||||
}, []);
|
||||
@@ -133,6 +156,16 @@ function App() {
|
||||
editorRef.current?.testDownload();
|
||||
}, []);
|
||||
|
||||
// The module's clear() owns the two-stage confirm flow (first call
|
||||
// arms with a toast, second within 3s commits) so the host's job
|
||||
// is just to fire the trigger. The Header has already guarded the
|
||||
// call site with `canClear` + `!isExporting`, but ref.current may
|
||||
// still be null during the brief window before the module mounts,
|
||||
// hence the optional chain.
|
||||
const handleHeaderClear = useCallback(() => {
|
||||
editorRef.current?.clear();
|
||||
}, []);
|
||||
|
||||
// ── Share handler ───────────────────────────────────────────────
|
||||
// Three-level fallback: navigator.share (native OS share sheet,
|
||||
// ideal on mobile + recent desktop), then navigator.clipboard
|
||||
@@ -287,12 +320,17 @@ function App() {
|
||||
onTestDownload={handleHeaderTestDownload}
|
||||
isExporting={isExporting}
|
||||
onPreview={handleHeaderPreview}
|
||||
onClear={handleHeaderClear}
|
||||
canClear={canClear}
|
||||
elementCount={elementCount}
|
||||
/>
|
||||
|
||||
<main style={{ flex: 1, minHeight: 0 }}>
|
||||
<ApparelDesigner
|
||||
ref={editorRef}
|
||||
onExportingChange={setIsExporting}
|
||||
onElementCountChange={setElementCount}
|
||||
onCanClearChange={setCanClear}
|
||||
shirtColors={SHIRT_COLORS}
|
||||
shirtSizes={SHIRT_SIZES}
|
||||
basePrice={SHIRT_BASE_PRICE_USD}
|
||||
|
||||
519
src/AppRouter.jsx
Normal file
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.
|
||||
*
|
||||
* Layout:
|
||||
* [logo + tagline] [page title] [preview] [download] [Share] [cart]
|
||||
* [logo + tagline] [page title] [clear] [preview] [download] [Share] [cart]
|
||||
*
|
||||
* On mobile (<768px) collapses to:
|
||||
* [logo] [cart]
|
||||
* [logo] [cart]
|
||||
*
|
||||
* What's here vs what's gone
|
||||
* ──────────────────────────
|
||||
* Buttons removed during the module-split cleanup (May 2026):
|
||||
* Save was removed during the module-split cleanup (May 2026) — it
|
||||
* was redundant with Download (both fired the same export pipeline).
|
||||
* The module's onSave callback prop on <ApparelDesigner> remains as
|
||||
* the right integration point for real "commit this design"
|
||||
* semantics when there's a backend to commit to.
|
||||
*
|
||||
* • Save — was redundant with Download (both fired the same export
|
||||
* pipeline). The module's onSave callback prop on <ApparelDesigner>
|
||||
* remains as the right integration point for real "commit this
|
||||
* design" semantics when there's a backend to commit to.
|
||||
* Menu was also removed — it opened the host's mobile bottom sheet,
|
||||
* which now lives inside the module. The module owns its own mobile
|
||||
* UI; the host had nothing left to put behind a menu.
|
||||
*
|
||||
* • Clear — clearing the canvas is editor state, not host chrome.
|
||||
* Re-introducing it requires the module to expose a clear()
|
||||
* method on its editor ref and a canClear callback so the host
|
||||
* can disable the button when there's nothing to clear. Until
|
||||
* that lands, removing the button is better than leaving a
|
||||
* non-functional one in the chrome.
|
||||
* Clear was removed alongside the other two but came back in v0.8
|
||||
* once the module shipped a `clear()` method on its editor ref plus
|
||||
* an `onCanClearChange` callback. The host now disables the button
|
||||
* when there's nothing to clear (empty canvas, no template loaded)
|
||||
* and lets the module own the actual confirm-and-clear flow — first
|
||||
* click arms it with a toast, second click within 3s commits.
|
||||
*
|
||||
* • Menu — opened the host's mobile bottom sheet, which now lives
|
||||
* inside the module. The module owns its own mobile UI; the
|
||||
* host had nothing left to put behind a menu.
|
||||
*
|
||||
* Preview / Download are still here because they're host-surfaced
|
||||
* controls that drive the editor via the editorRef.preview() and
|
||||
* editorRef.testDownload() imperative API. They're disabled during
|
||||
* an in-flight export (`isExporting`).
|
||||
* Preview / Download / Clear are all host-surfaced controls that
|
||||
* drive the editor via its imperative API (editorRef.preview(),
|
||||
* editorRef.testDownload(), editorRef.clear()). All three disable
|
||||
* during an in-flight export (`isExporting`). Preview and Download
|
||||
* additionally disable when `elementCount === 0` — nothing to render.
|
||||
*
|
||||
* i18n
|
||||
* ────
|
||||
@@ -46,6 +46,15 @@ export function Header({
|
||||
onShare, onOpenCart,
|
||||
onTestDownload, isExporting = false,
|
||||
onPreview,
|
||||
onClear,
|
||||
// canClear: true when the editor has at least one element OR a
|
||||
// template loaded. Module-derived via onCanClearChange.
|
||||
// elementCount: how many elements are on the canvas. Module-derived
|
||||
// via onElementCountChange. Used to gate Preview/Download — with
|
||||
// zero elements the export pipeline would render a blank PNG,
|
||||
// which is never what the user wants.
|
||||
canClear = false,
|
||||
elementCount = 0,
|
||||
}) {
|
||||
// App name is reused for both the logo and the aria-label, so we
|
||||
// resolve it once and interpolate into the aria-label below.
|
||||
@@ -79,15 +88,42 @@ export function Header({
|
||||
</h1>
|
||||
|
||||
<div className="ph-actions">
|
||||
{/* Clear — wipe the canvas + drop the current template. The
|
||||
* module's clear() method handles the two-stage confirm flow
|
||||
* (first click arms with a toast, second click within 3s
|
||||
* commits), so the host just fires the trigger. Disabled
|
||||
* when canClear is false: the editor reports false when the
|
||||
* elements array is empty AND no template is loaded, so this
|
||||
* dims out on a truly blank canvas where there's nothing
|
||||
* destructive to do. Also disabled mid-export to avoid
|
||||
* clobbering an in-flight render's source data. */}
|
||||
<button
|
||||
type="button"
|
||||
className="ph-iconbtn"
|
||||
onClick={onClear}
|
||||
disabled={!canClear || isExporting}
|
||||
aria-label={t('header.clear')}
|
||||
title={t('header.clear-tooltip')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Print preview — server-renders the design at print resolution
|
||||
* and shows it in a modal so the user can verify what will
|
||||
* actually print before adding to cart. Disabled while another
|
||||
* export is in flight. */}
|
||||
* export is in flight OR when the canvas is empty (nothing
|
||||
* meaningful to preview). */}
|
||||
<button
|
||||
type="button"
|
||||
className="ph-iconbtn"
|
||||
onClick={onPreview}
|
||||
disabled={isExporting}
|
||||
disabled={isExporting || elementCount === 0}
|
||||
aria-label={t('header.preview')}
|
||||
title={t('header.preview-tooltip')}
|
||||
>
|
||||
@@ -99,13 +135,14 @@ export function Header({
|
||||
|
||||
{/* Download — dedicated print-test button. Same export pipeline
|
||||
* as Preview but auto-downloads instead of opening the modal.
|
||||
* Disabled while an export is in flight so rapid clicks don't
|
||||
* fire parallel POSTs. */}
|
||||
* Disabled while an export is in flight (so rapid clicks don't
|
||||
* fire parallel POSTs) and when the canvas is empty (no point
|
||||
* downloading a blank PNG). */}
|
||||
<button
|
||||
type="button"
|
||||
className="ph-iconbtn"
|
||||
onClick={onTestDownload}
|
||||
disabled={isExporting}
|
||||
disabled={isExporting || elementCount === 0}
|
||||
aria-label={t('header.download')}
|
||||
title={t('header.download-tooltip')}
|
||||
>
|
||||
|
||||
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 = {
|
||||
// ── Header ──────────────────────────────────────────────────
|
||||
// Save / Clear / Menu were removed from the header chrome during the
|
||||
// module split — Save was redundant with Download (same export
|
||||
// pipeline), Clear needs a module-side `clear()` method on the editor
|
||||
// ref before it can be reintroduced, and Menu opened the host's own
|
||||
// bottom sheet which is now inside the module. Their i18n keys are
|
||||
// gone with them; readd if you put any of those buttons back.
|
||||
// Save and Menu were removed from the header chrome during the module
|
||||
// split. Save was redundant with Download (same export pipeline); Menu
|
||||
// opened the host's own bottom sheet which is now inside the module.
|
||||
// Their i18n keys are gone with them; readd if you put either back.
|
||||
//
|
||||
// Clear was also removed but came back in v0.8 once the module
|
||||
// exposed a `clear()` method on its editor ref plus an
|
||||
// `onCanClearChange` callback so the host can gate the button.
|
||||
// See Header.jsx for the button and App.jsx / CustomizeRoute.jsx
|
||||
// for the wiring.
|
||||
'header.app-name': 'Pawfectly Yours',
|
||||
'header.app-tagline': 'Wear Your Pet. Share Your Story.',
|
||||
'header.page-title': 'Customize Your Shirt',
|
||||
'header.logo-home-aria': '{app} home',
|
||||
'header.clear': 'Clear canvas',
|
||||
'header.clear-tooltip': 'Remove all elements and start over',
|
||||
'header.preview': 'Print preview',
|
||||
'header.preview-tooltip': 'See your design at print resolution',
|
||||
'header.download': 'Download print file',
|
||||
|
||||
2379
src/styles/admin.css
Normal file
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
Reference in New Issue
Block a user