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 (
+
+ );
+}