feat(host): editorRegistry — dispatch editor surfaces by product.editorKind
Increment 1 (goods, zero behavior change): mirror template-manager's editor-neutrality seams on the host so a second editor (e.g. sign-studio) can be added as an adapter instead of by re-touching mount sites. - src/editors/editorRegistry.js — getEditorAdapter(editorKind); NO goods fallback for an unregistered kind (mirrors the CMS's validator rule). - src/editors/goods/GoodsCustomizer.jsx — the customer ApparelDesigner mount, extracted from CustomizeRoute (goods branding + view/entry derivation moved into the adapter). - src/editors/goods/GoodsAdminEditor.jsx — the former AppRouter AdminEditorCanvas, moved verbatim. - CustomizeRoute + AppRouter renderCanvas now dispatch on product.editorKind (default 'goods'). App.jsx confirmed dead (main.jsx renders AppRouter), so only 2 live mounts change. Verified: vite build clean; the /customize customer surface renders and is interactive on an alternate-port smoke, no React errors. The admin surface is a verbatim move (build/import-verified; its interactive render sits behind admin SSO). editorKind resolves to 'goods' until the CMS surfaces it on the public template/variant response (Increment 2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
// 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() {
|
||||
<AdminShell>
|
||||
<TemplateEditorPanel
|
||||
templateId={id}
|
||||
renderCanvas={(props) => <AdminEditorCanvas {...props} />}
|
||||
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
|
||||
? <AdminEditor {...canvasProps} />
|
||||
: <p className="tm-editor__placeholder">No editor registered for “{editorKind}”.</p>;
|
||||
}}
|
||||
onMarketingRegenerate={async (templateId) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
|
||||
@@ -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() {
|
||||
<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}
|
||||
/>
|
||||
{CustomerCustomizer ? (
|
||||
<CustomerCustomizer
|
||||
editorRef={editorRef}
|
||||
template={template}
|
||||
variant={variant}
|
||||
hiddenStickerCategories={hiddenStickerCategories}
|
||||
onExportingChange={setIsExporting}
|
||||
onElementCountChange={setElementCount}
|
||||
onCanClearChange={setCanClear}
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, textAlign: 'center' }}>
|
||||
<p>This product uses an editor (“{editorKind}”) that isn’t available here yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
60
src/editors/editorRegistry.js
Normal file
60
src/editors/editorRegistry.js
Normal file
@@ -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;
|
||||
}
|
||||
142
src/editors/goods/GoodsAdminEditor.jsx
Normal file
142
src/editors/goods/GoodsAdminEditor.jsx
Normal file
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
63
src/editors/goods/GoodsCustomizer.jsx
Normal file
63
src/editors/goods/GoodsCustomizer.jsx
Normal file
@@ -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 (
|
||||
<ApparelDesigner
|
||||
ref={editorRef}
|
||||
onExportingChange={onExportingChange}
|
||||
onElementCountChange={onElementCountChange}
|
||||
onCanClearChange={onCanClearChange}
|
||||
// Catalog + branding (goods-specific).
|
||||
shirtColors={SHIRT_COLORS}
|
||||
shirtSizes={SHIRT_SIZES}
|
||||
basePrice={SHIRT_BASE_PRICE_USD}
|
||||
designFilenamePrefix="pawfectly-yours-design"
|
||||
shareTitle={t('share.title')}
|
||||
loadFonts={false}
|
||||
// Pre-fetched template + variant (null in freeform mode).
|
||||
template={template}
|
||||
variant={variant}
|
||||
// Per-view geometry — the resolved variant carries variant.views[].
|
||||
views={variant?.views || null}
|
||||
// Marketing gallery entry — product-level entryConfig resolved onto
|
||||
// the variant. Gallery mode only when it has images; else land
|
||||
// straight in the editor.
|
||||
entryConfig={variant?.entryConfig || null}
|
||||
entryMode={variant?.entryConfig?.galleryImages?.length ? 'gallery' : 'direct'}
|
||||
phoneModels={PHONE_MODELS}
|
||||
// Facet personalization — sticker deny-list (null ⇒ show all).
|
||||
hiddenStickerCategories={hiddenStickerCategories}
|
||||
// Callbacks (owned by the route).
|
||||
onUploadFile={onUploadFile}
|
||||
onSave={onSave}
|
||||
onAddToCart={onAddToCart}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user