Compare commits

..

1 Commits

Author SHA1 Message Date
36cbbc0aa4 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>
2026-09-05 04:29:08 -05:00
5 changed files with 316 additions and 192 deletions

View File

@@ -62,9 +62,9 @@
// • TemplateStoreProvider no longer receives variantCatalog or // • TemplateStoreProvider no longer receives variantCatalog or
// phoneModels — template-manager owns the catalog. // 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 { 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 'goods-editor/styles.css';
import { import {
TemplateStoreProvider, TemplateStoreProvider,
@@ -107,8 +107,7 @@ import { ShopHome } from './components/shop/ShopHome';
import { ProductPage } from './components/shop/ProductPage'; import { ProductPage } from './components/shop/ProductPage';
import { CartStub } from './components/shop/CartStub'; import { CartStub } from './components/shop/CartStub';
import { DEFAULT_PRODUCT_ID, DEFAULT_SIZE_ID } from './constants/seedProducts'; import { DEFAULT_PRODUCT_ID, DEFAULT_SIZE_ID } from './constants/seedProducts';
import { SHIRT_COLORS, SHIRT_SIZES, SHIRT_BASE_PRICE_USD } from './constants/shirt'; import { getEditorAdapter } from './editors/editorRegistry.js';
import { PHONE_MODELS } from './constants/phoneModels';
// URL builders — single source of truth for the URL shape so the // URL builders — single source of truth for the URL shape so the
// rest of the app uses these instead of hand-building strings. // 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 // AdminEditorCanvas moved to src/editors/goods/GoodsAdminEditor.jsx
// `renderCanvas` prop. // (goods adapter; dispatched via editorRegistry — see renderCanvas above).
//
// 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 ────────────────────── // ── /admin/templates — list + create flow ──────────────────────
function AdminTemplateListRoute() { function AdminTemplateListRoute() {
@@ -322,7 +191,16 @@ function AdminTemplateEditorRoute() {
<AdminShell> <AdminShell>
<TemplateEditorPanel <TemplateEditorPanel
templateId={id} 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) => { onMarketingRegenerate={async (templateId) => {
try { try {
const res = await fetch( const res = await fetch(

View File

@@ -46,9 +46,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { ApparelDesigner, useDesignPersistence } from 'goods-editor'; import { useDesignPersistence } from 'goods-editor';
import 'goods-editor/styles.css';
import { useTemplate, computeHiddenStickerCategories } from 'template-manager'; import { useTemplate, computeHiddenStickerCategories } from 'template-manager';
import { getEditorAdapter } from './editors/editorRegistry.js';
import { useAuthContext } from '@khalidadil/auth-manager'; import { useAuthContext } from '@khalidadil/auth-manager';
import { useFacetCatalog } from './hooks/useFacetCatalog'; import { useFacetCatalog } from './hooks/useFacetCatalog';
@@ -57,8 +57,6 @@ import { PWAInstall } from './components/PWAInstall';
import { OfflineIndicator } from './components/OfflineIndicator'; import { OfflineIndicator } from './components/OfflineIndicator';
import { Toast } from './components/Toast'; 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'; import { t } from './i18n/t';
const TOAST_DEFAULT_DURATION_MS = 3000; const TOAST_DEFAULT_DURATION_MS = 3000;
@@ -81,6 +79,16 @@ export default function CustomizeRoute() {
// canvas to design against. // canvas to design against.
const variant = template?.variant || null; 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) ─────────────── // ── Facet personalization (Project doc §R10) ───────────────
// Hide sticker categories that belong to facet values the customer // Hide sticker categories that belong to facet values the customer
// didn't pick. null when no facet is configured or there's no // didn't pick. null when no facet is configured or there's no
@@ -283,57 +291,30 @@ export default function CustomizeRoute() {
<Header {...headerProps} /> <Header {...headerProps} />
<main style={{ flex: 1, minHeight: 0 }}> <main style={{ flex: 1, minHeight: 0 }}>
<ApparelDesigner {CustomerCustomizer ? (
ref={editorRef} <CustomerCustomizer
onExportingChange={setIsExporting} editorRef={editorRef}
onElementCountChange={setElementCount} template={template}
onCanClearChange={setCanClear} variant={variant}
// Catalog + branding. hiddenStickerCategories={hiddenStickerCategories}
shirtColors={SHIRT_COLORS} onExportingChange={setIsExporting}
shirtSizes={SHIRT_SIZES} onElementCountChange={setElementCount}
basePrice={SHIRT_BASE_PRICE_USD} onCanClearChange={setCanClear}
designFilenamePrefix="pawfectly-yours-design" onUploadFile={handleUploadFile}
shareTitle={t('share.title')} onSave={(elements, name, tpl, extra) => {
loadFonts={false} // Multi-view (2026-06-10): onSave gained a 4th arg with the
// v0.3.0 prop surface — pre-fetched template + variant. // full views map + active view. Single-view here, so it's
// When template is null (freeform), the editor's pre-fetched // { default: […] } — logged for contract visibility.
// effect is a no-op and the legacy effect runs — but with // eslint-disable-next-line no-console
// empty templates array and null initialTemplate, the user console.log('[host] Save:', { elementCount: elements.length, name, templateId: tpl?.id, views: extra ? Object.keys(extra.views || {}) : undefined, activeViewId: extra?.activeViewId });
// just lands on a blank canvas. }}
template={template} onAddToCart={handleAddToCart}
variant={variant} />
// Stage & Canvas (Round 5f) — per-view geometry. The resolved ) : (
// variant carries variant.views[] = each view's { id, label, <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, textAlign: 'center' }}>
// variant } (its own canvas size + shape + product photo). <p>This product uses an editor (“{editorKind}”) that isn’t available here yet.</p>
// Passing it makes the editor render EVERY view with its own </div>
// 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}
/>
</main> </main>
</div> </div>
); );

View 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;
}

View 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>
);
}

View 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}
/>
);
}