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>
520 lines
22 KiB
JavaScript
520 lines
22 KiB
JavaScript
// 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>
|
|
);
|
|
}
|