// 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 from auth-manager)
// (other routes unchanged from v0.7)
//
// v0.7 routes
// ───────────
// / → ShopHome (replaces legacy App.jsx)
// /shop → ShopHome (alias)
// /shop/:productSlug → ProductPage
// /cart → CartStub
// /customize → CustomizeRoute
// /admin/templates → TemplateListPanel + CreateTemplateModal
// /admin/templates/:id → TemplateEditorPanel
// /admin/products → ProductListPanel
// /admin/products/:id → ProductEditorPanel
// /admin/variants → VariantMockupPanel (retired stub)
// /admin/featured → FeaturedOrderPanel
// /admin/trash → TemplateTrashPanel
// * → ShopHome (SPA fallback)
//
// v0.8 changes
// ────────────
// • Inventory admin surfaces (list + detail) wired in for the new
// material-kind catalog. The list reads ?productId from window
// location for deep-linking from a material product's editor.
// • Per-option pricing rolls up into ProductBuyPanel's unit price;
// no routing changes — the buy panel sums priceCents internally.
// • Auth surface (auth-manager) mounts at /auth/login, /admin/login,
// and /account/*. Admin routes are now wrapped in
// for the client guard; server-side, the API endpoints still need
// requireAdmin middleware once template-manager + commerce-module
// wire it in.
//
// v0.7 changes
// ────────────
// • Root `/` now serves the shop home, not the legacy App.jsx.
// App.jsx stays on disk one release for safety but is no longer
// routed. Existing /customize?template= deep links continue
// to work via CustomizeRoute.
// • /shop/:productSlug mounts the customer-facing product detail.
// Both customizable and fixed kinds render here; the buy panel
// forks on kind internally.
// • /cart shows a localStorage-backed cart preview. "Proceed to
// checkout" surfaces "Checkout coming soon" — checkout is
// downstream.
//
// v0.6 changes (retained context)
// ───────────────────────────────
// • CreateTemplateModal pulls products from the API now.
// • AdminEditorCanvas reads template.variant directly.
// • TemplateStoreProvider no longer receives variantCatalog or
// phoneModels — template-manager owns the catalog.
import { useMemo, useState, useCallback } from 'react';
import { BrowserRouter, Routes, Route, Link, useNavigate, useParams, useLocation } from 'react-router-dom';
import { ApparelDesigner, dematerializeTemplate, DEFAULT_STICKER_CATEGORIES } from 'goods-editor';
import 'goods-editor/styles.css';
import {
TemplateStoreProvider,
TemplateListPanel,
TemplateEditorPanel,
TemplateTrashPanel,
VariantMockupPanel,
FeaturedOrderPanel,
CreateTemplateModal,
ProductListPanel,
ProductEditorPanel,
CreateProductModal,
// v0.8 — inventory admin panels.
InventoryListPanel,
InventoryDetailPanel,
useAdminTemplates,
useAdminProducts,
// Facet personalization (Project doc §R10).
FacetValueManager,
} from 'template-manager';
import 'template-manager/styles.css';
// v0.8 — auth surface.
import {
LoginForm,
AdminLoginPage,
RequireAdmin,
AccountShell,
AccountHomePage,
AccountAddressesPage,
// Facet personalization (Project doc §R10).
FacetPrompt,
AccountFacetPage,
useFacetConfig,
} from '@khalidadil/auth-manager';
import './styles/admin.css';
import './styles/shop.css';
import CustomizeRoute from './CustomizeRoute';
import { ShopHome } from './components/shop/ShopHome';
import { ProductPage } from './components/shop/ProductPage';
import { CartStub } from './components/shop/CartStub';
import { DEFAULT_PRODUCT_ID, DEFAULT_SIZE_ID } from './constants/seedProducts';
import { SHIRT_COLORS, SHIRT_SIZES, SHIRT_BASE_PRICE_USD } from './constants/shirt';
import { PHONE_MODELS } from './constants/phoneModels';
// URL builders — single source of truth for the URL shape so the
// rest of the app uses these instead of hand-building strings.
const customizeUrl = (templateId) => templateId ? `/customize?template=${encodeURIComponent(templateId)}` : '/customize';
const adminTemplateUrl = (templateId) => `/admin/templates/${encodeURIComponent(templateId)}`;
const adminTemplateListUrl = '/admin/templates';
const adminProductListUrl = '/admin/products';
const adminProductUrl = (productId) => `/admin/products/${encodeURIComponent(productId)}`;
// v0.8 — inventory admin URLs.
const adminInventoryListUrl = '/admin/inventory';
const adminInventoryUrl = (variantId) => `/admin/inventory/${encodeURIComponent(variantId)}`;
// v0.6 — shop URL now keyed by product slug (or id; both work since
// public template GET embeds the product). Existing shop pages keyed
// by productTypeId continue to work because legacy slugs match the
// new product slugs ("shirt", "magnet", …).
const shopTemplateUrl = (productSlug, templateId) =>
`/shop/${encodeURIComponent(productSlug)}/${encodeURIComponent(templateId)}`;
function AdminShell({ children }) {
// Facet personalization (Project doc §R10) — show the manager link
// only when a facet is configured; label comes from the config.
const { config: facetConfig } = useFacetConfig();
return (
{children}
);
}
// ── AdminEditorCanvas — host's adapter for TemplateEditorPanel's
// `renderCanvas` prop.
//
// v0.6 changes
// ────────────
// The variant used to come from getVariant(productTypeId, variantId)
// against the host's PRODUCT_TYPES. Now it comes from the template
// itself — the templates GET response embeds a `variant` object
// resolved server-side via productsStore. We just read it.
function AdminEditorCanvas({ metadata, version, updateVersion }) {
// metadata.variant is set by useTemplateDraft from the GET
// response (the route embeds product/size/variant). When a brand-
// new template has no productId yet (edge case during create),
// variant is null; we still mount the editor with a sensible
// default canvas size.
const variant = metadata?.variant || null;
// Marketing Gallery Entry (Round 6) — persist Marketing-tab edits to the
// product-level entryConfig. `entryConfigOverride` holds the admin's
// optimistic edits for THIS session so the editor's canvas showcase + the
// Marketing-tab Play preview reflect changes live; until they edit, the
// effective config is the server value resolved onto the variant. The
// dedicated PATCH persists it (productId comes from the resolved variant).
const { patchEntryConfig } = useAdminProducts();
const productId = variant?.productId || null;
const [entryConfigOverride, setEntryConfigOverride] = useState(null);
const effectiveEntryConfig = entryConfigOverride !== null ? entryConfigOverride : (variant?.entryConfig || null);
const handleEntryConfigChange = useCallback((next) => {
setEntryConfigOverride(next); // optimistic — keeps the canvas + Play preview in sync with the tab
if (!productId) return;
patchEntryConfig(productId, next).catch((err) => {
console.error('[admin editor] entry-config save failed:', err.message);
});
}, [patchEntryConfig, productId]);
const template = useMemo(() => {
if (!metadata) return null;
// Multi-view (2026-06-10, schema v4): the version is a `views`
// map; the editor's materializer reads only that. Templates are
// currently authored single-view here (no `views` prop is passed
// to ApparelDesigner below), so the editor synthesizes its
// 'default' view and the map round-trips as { default: {…} }.
return {
...metadata,
version: version
? {
views: version.views || {},
schemaVersion: version.schemaVersion ?? 4,
}
: null,
};
}, [metadata, version]);
// Multi-view (2026-06-10) — convert absolute pixel coords back to
// fractional on the way out. onViewsChange replaces the old
// onElementsChange write-through: it fires at the same commit
// boundaries but carries the FULL views map, so the draft always
// captures every view (today that's just 'default'; when this
// canvas grows a `views` prop nothing here changes).
// dematerializeTemplate's flat→default wrap is NOT used here — the
// editor hands us a map already.
const canvasSize = variant?.canvas?.width || 300;
const handleViewsChange = useCallback((viewsAbsMap) => {
const fractional = dematerializeTemplate({
views: viewsAbsMap,
canvasSize,
});
updateVersion({
views: fractional.views,
schemaVersion: fractional.schemaVersion,
});
}, [updateVersion, canvasSize]);
// Upload handler (2026-06-09 — slot-preview persistence). Mirrors
// CustomizeRoute's handler (POST /api/upload → server-hosted URL),
// minus the toast surface (admin shell has none here; failures log
// and rethrow — the editor's upload paths degrade gracefully).
//
// Why the admin canvas needs this: goods-editor's admin slot-preview
// pipeline persists el.slot.previewSrc ONLY when onUploadFile is
// wired — otherwise previews stay an in-session blob-URL overlay
// that vanishes on reload and never reaches the stored template, so
// the customer-side faded preview placeholder silently never works.
// It also keeps the admin Upload tab on server URLs instead of the
// FileReader data: fallback, which would otherwise dematerialize
// multi-MB base64 strings into the template version JSON.
const handleUploadFile = useCallback(async (file) => {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/upload', { method: 'POST', body: formData });
if (!response.ok) {
const errorText = await response.text().catch(() => '');
const message = `Upload failed (${response.status}${errorText ? `: ${errorText}` : ''})`;
console.error('[admin editor]', message);
throw new Error(message);
}
const data = await response.json();
return { url: data.preview.url, originalUrl: data.original.url };
}, []);
if (!template) {
return (
Loading template metadata…
);
}
return (
);
}
// ── /admin/templates — list + create flow ──────────────────────
function AdminTemplateListRoute() {
const navigate = useNavigate();
const { createTemplate } = useAdminTemplates();
const [createOpen, setCreateOpen] = useState(false);
const handleCreate = async (payload) => {
const created = await createTemplate(payload);
setCreateOpen(false);
navigate(adminTemplateUrl(created.id));
return created;
};
return (
setCreateOpen(true)} />
{createOpen && (
setCreateOpen(false)}
/>
)}
);
}
// ── /admin/templates/:id — editor ──────────────────────────────
function AdminTemplateEditorRoute() {
const { id } = useParams();
return (
}
onMarketingRegenerate={async (templateId) => {
try {
const res = await fetch(
`/api/templates/${encodeURIComponent(templateId)}/marketing-preview/regenerate`,
{ method: 'POST' },
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error('Regenerate failed:', body.error || res.status);
}
} catch (err) {
console.error('Regenerate request failed:', err);
}
}}
/>
);
}
// ── /admin/products — list + create flow ───────────────────────
function AdminProductListRoute() {
const navigate = useNavigate();
const { createProduct } = useAdminProducts();
const [createOpen, setCreateOpen] = useState(false);
// Two-step create: POST product, then optionally POST first size.
// We POST the size via a raw fetch rather than wiring useProduct
// here — the modal returns to the parent before the product page
// is mounted, so there's no useProduct instance yet. A direct fetch
// is simpler than coordinating the timing.
const handleCreate = async ({ product, firstSize }) => {
const created = await createProduct(product);
if (firstSize) {
try {
const res = await fetch(`/api/products/${encodeURIComponent(created.id)}/sizes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(firstSize),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
// Don't fail the whole flow — the product was created;
// just surface the size error in the console. Admin will
// see "no sizes" on the product page and can add one.
console.warn('First-size create failed:', body.error || res.status);
}
} catch (err) {
console.warn('First-size create network error:', err.message);
}
}
setCreateOpen(false);
navigate(adminProductUrl(created.id));
return created;
};
return (
setCreateOpen(true)} />
{createOpen && (
setCreateOpen(false)}
/>
)}
);
}
// ── /admin/products/:id — editor ───────────────────────────────
function AdminProductEditorRoute() {
const { id } = useParams();
return (
);
}
// v0.8 — /admin/inventory — list.
//
// InventoryListPanel reads ?productId from window.location.search on
// mount, so the "View inventory variants →" jump-link inside the
// material-product editor deep-links into a pre-filtered list. No
// extra plumbing needed here.
function AdminInventoryListRoute() {
return (
);
}
// v0.8 — /admin/inventory/:id — single-variant detail.
function AdminInventoryDetailRoute() {
const { id } = useParams();
return (
);
}
// Facet personalization (Project doc §R10) — /admin/facets — manage the
// facet vocabulary + the sticker-category→value assignment map. Sticker
// categories come from goods-editor's DEFAULT_STICKER_CATEGORIES.
function AdminFacetRoute() {
return (
);
}
// v0.8 — small wrapper to pull `next` out of the URL search before
// handing it to LoginForm. LoginForm itself stays prop-driven so it
// can be reused in non-router contexts later.
function LoginRoute() {
const location = useLocation();
const params = new URLSearchParams(location.search);
const next = params.get('next') || undefined;
return (
);
}
export default function AppRouter() {
return (
{/* Facet personalization (Project doc §R10) — one-time dismissable
sign-in nudge for customers with no selection. Renders nothing
when no facet is configured / customer signed out / already
chosen. Inside AuthProvider (from main.jsx) so it sees auth. */}
} />
} />
} />
} />
} />
{/* v0.8 — auth + account */}
} />
} />
}>
} />
} />
{/* Facet personalization (Project doc §R10) — AccountFacetPage
hides itself when no facet is configured. */}
} />
{/* /account/designs lands once goods-editor-module exports a
MyDesignsPage component (Phase 2 / v08_GoodsEditor_v1).
/account/orders lands with commerce-module. */}
{/* Admin — wrapped in RequireAdmin (client guard). Server-
side, the API endpoints still need requireAdmin
middleware once template-manager + commerce-module wire
it in. */}
} />
} />
} />
} />
{/* v0.8 — inventory routes. */}
} />
} />
{/* Facet personalization (Project doc §R10). */}
} />
} />
} />
} />
{/* SPA fallback — unknown routes land on the shop home. */}
} />
);
}