diff --git a/src/AppRouter.jsx b/src/AppRouter.jsx index 236c43d..780ee67 100644 --- a/src/AppRouter.jsx +++ b/src/AppRouter.jsx @@ -62,9 +62,9 @@ // • TemplateStoreProvider no longer receives variantCatalog or // phoneModels — template-manager owns the catalog. -import { useMemo, useState, useCallback } from 'react'; +import { 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 { DEFAULT_STICKER_CATEGORIES } from 'goods-editor'; import 'goods-editor/styles.css'; import { TemplateStoreProvider, @@ -107,8 +107,7 @@ 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'; +import { getEditorAdapter } from './editors/editorRegistry.js'; // URL builders — single source of truth for the URL shape so the // rest of the app uses these instead of hand-building strings. @@ -154,138 +153,8 @@ function AdminShell({ 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 ( -
- -
- ); -} +// AdminEditorCanvas moved to src/editors/goods/GoodsAdminEditor.jsx +// (goods adapter; dispatched via editorRegistry — see renderCanvas above). // ── /admin/templates — list + create flow ────────────────────── function AdminTemplateListRoute() { @@ -322,7 +191,16 @@ function AdminTemplateEditorRoute() { } + renderCanvas={(canvasProps) => { + // Editor dispatch — pick the admin editing surface by the + // template's product editorKind (default 'goods'). Unknown kind + // ⇒ a clear placeholder, never a silent goods substitution. + const editorKind = canvasProps?.metadata?.editorKind || 'goods'; + const AdminEditor = getEditorAdapter(editorKind)?.AdminEditor || null; + return AdminEditor + ? + :

No editor registered for “{editorKind}”.

; + }} onMarketingRegenerate={async (templateId) => { try { const res = await fetch( diff --git a/src/CustomizeRoute.jsx b/src/CustomizeRoute.jsx index 5cd8d84..66cb600 100644 --- a/src/CustomizeRoute.jsx +++ b/src/CustomizeRoute.jsx @@ -46,9 +46,9 @@ 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 { useDesignPersistence } from 'goods-editor'; import { useTemplate, computeHiddenStickerCategories } from 'template-manager'; +import { getEditorAdapter } from './editors/editorRegistry.js'; import { useAuthContext } from '@khalidadil/auth-manager'; import { useFacetCatalog } from './hooks/useFacetCatalog'; @@ -57,8 +57,6 @@ 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; @@ -81,6 +79,16 @@ export default function CustomizeRoute() { // canvas to design against. const variant = template?.variant || null; + // ── Editor dispatch (host editorRegistry) ────────────────────── + // Which editor authors this product's designs. Resolved from the + // template's product (defaults 'goods'; the CMS surfaces editorKind on + // the public template/variant response once non-goods products exist). + // Unknown kind ⇒ no adapter ⇒ a clear fallback, never a silent goods + // substitution (mirrors the CMS's no-goods-fallback validator rule). + const editorKind = template?.editorKind || variant?.editorKind || 'goods'; + const editorAdapter = getEditorAdapter(editorKind); + const CustomerCustomizer = editorAdapter?.CustomerCustomizer || 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 @@ -283,57 +291,30 @@ export default function CustomizeRoute() {
- { - // 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} - /> + {CustomerCustomizer ? ( + { + // 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} + /> + ) : ( +
+

This product uses an editor (“{editorKind}”) that isn’t available here yet.

+
+ )}
); diff --git a/src/editors/editorRegistry.js b/src/editors/editorRegistry.js new file mode 100644 index 0000000..980b7b7 --- /dev/null +++ b/src/editors/editorRegistry.js @@ -0,0 +1,60 @@ +// Host editor registry — dispatch on product.editorKind. +// +// This mirrors template-manager's editor-neutrality seams on the HOST +// side: the CMS decides WHICH editor a product uses (the opaque +// product.editorKind field); the host owns mounting that editor's admin + +// customer surfaces, rendering its marketing preview, and pricing. Each +// entry is an adapter; routes look one up by editorKind and render the +// component for the surface they're on. +// +// The base adapter contract (keep it editor-agnostic — do NOT leak +// goods-only concepts like canvasRegion / schemaVersion / fractional +// coords into it, or a third editor re-scatters the hardcoding): +// +// { +// editorKind: string, // matches product.editorKind +// label: string, // admin-facing name +// persistence: 'in-cms' | 'external', +// // 'in-cms' → template-manager owns the design (goods) +// // 'external'→ the editor owns it; the host keeps a ref +// // in product.typeConfig (e.g. sign-studio's +// // builderProjectId) +// CustomerCustomizer: React component // the buy-time editor surface +// AdminEditor: React component // the template-manager renderCanvas surface +// // (server-side) renderMarketingPreview + priceFromChange are wired +// // where they run: renderMarketingPreview registers into +// // template-manager's marketingPreviewRenderer({ renderers }) in +// // server.js; goods uses the built-in editorRenderer path today. +// } +// +// v0.9 — only 'goods' is registered. Adding a 'sign' adapter +// (@khalidadil/sign-studio) is the next increment; see the project vault's +// Multi_Editor_Architecture.md. +import { GoodsCustomizer } from './goods/GoodsCustomizer.jsx'; +import { GoodsAdminEditor } from './goods/GoodsAdminEditor.jsx'; + +export const DEFAULT_EDITOR_KIND = 'goods'; + +export const editorRegistry = { + goods: { + editorKind: 'goods', + label: 'Apparel (goods-editor)', + persistence: 'in-cms', + CustomerCustomizer: GoodsCustomizer, + AdminEditor: GoodsAdminEditor, + }, +}; + +/** + * Resolve the adapter for a product's editorKind. Returns null for an + * unregistered kind — callers render a clear "no editor" fallback rather + * than silently substituting goods (mirrors the CMS's no-goods-fallback + * validator rule). A missing/undefined kind defaults to 'goods', which is + * what every pre-seam product resolves to. + * + * @param {string} [editorKind] + * @returns {object|null} + */ +export function getEditorAdapter(editorKind) { + return editorRegistry[editorKind || DEFAULT_EDITOR_KIND] || null; +} diff --git a/src/editors/goods/GoodsAdminEditor.jsx b/src/editors/goods/GoodsAdminEditor.jsx new file mode 100644 index 0000000..28ee86d --- /dev/null +++ b/src/editors/goods/GoodsAdminEditor.jsx @@ -0,0 +1,142 @@ +// GoodsAdminEditor — host adapter for template-manager's TemplateEditorPanel +// `renderCanvas` prop, for the goods-editor. Moved out of AppRouter so the +// admin editing surface is dispatched through the host editorRegistry +// (see ../editorRegistry.js) instead of hardcoded. Logic is unchanged from +// the former AppRouter `AdminEditorCanvas`. +// +// 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. +import { useState, useMemo, useCallback } from 'react'; +import { ApparelDesigner, dematerializeTemplate } from 'goods-editor'; +import { useAdminProducts } from 'template-manager'; + +import { SHIRT_COLORS, SHIRT_SIZES, SHIRT_BASE_PRICE_USD } from '../../constants/shirt'; +import { PHONE_MODELS } from '../../constants/phoneModels'; + +export function GoodsAdminEditor({ 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 ( +
+ +
+ ); +} diff --git a/src/editors/goods/GoodsCustomizer.jsx b/src/editors/goods/GoodsCustomizer.jsx new file mode 100644 index 0000000..a3c10a5 --- /dev/null +++ b/src/editors/goods/GoodsCustomizer.jsx @@ -0,0 +1,63 @@ +// GoodsCustomizer — the goods-editor customer surface, extracted from +// CustomizeRoute so the editor mount is dispatched through the host +// editorRegistry (see ../editorRegistry.js) instead of hardcoded in the +// route. Behaviour is identical to the pre-registry inline mount; the +// route still owns fetching (the goods template + variant), the page +// chrome (Header/Toast), cart, and the editorRef proxies. +// +// A second editor's customer surface (e.g. a SignCustomizer adapter) +// implements the same role but reads its own inputs — hence the editor- +// specific catalog/branding constants + view/entry derivation live HERE, +// in the goods adapter, not in the route. +import { ApparelDesigner } from 'goods-editor'; +import 'goods-editor/styles.css'; + +import { SHIRT_COLORS, SHIRT_SIZES, SHIRT_BASE_PRICE_USD } from '../../constants/shirt'; +import { PHONE_MODELS } from '../../constants/phoneModels'; +import { t } from '../../i18n/t'; + +export function GoodsCustomizer({ + editorRef, + template, + variant, + hiddenStickerCategories, + onExportingChange, + onElementCountChange, + onCanClearChange, + onUploadFile, + onSave, + onAddToCart, +}) { + return ( + + ); +}