Compare commits
3 Commits
840a5dbcb3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 36cbbc0aa4 | |||
|
|
5c21188df1 | ||
|
|
26f30ce388 |
68
server.js
68
server.js
@@ -247,11 +247,14 @@ if (facetConfig.key) {
|
|||||||
// can still iterate on goods-editor / template-manager pieces without
|
// can still iterate on goods-editor / template-manager pieces without
|
||||||
// provisioning Postgres locally.
|
// provisioning Postgres locally.
|
||||||
let pgPool = null;
|
let pgPool = null;
|
||||||
|
// mountAuth's returned guards (requireAdmin etc.) — captured so they can
|
||||||
|
// be threaded into other module mounts below. Null when auth is disabled.
|
||||||
|
let authGuards = null;
|
||||||
if (process.env.DATABASE_URL) {
|
if (process.env.DATABASE_URL) {
|
||||||
pgPool = new Pool({ connectionString: process.env.DATABASE_URL });
|
pgPool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
try {
|
try {
|
||||||
await runMigrations(pgPool, { logger });
|
await runMigrations(pgPool, { logger });
|
||||||
mountAuth(app, {
|
authGuards = mountAuth(app, {
|
||||||
db: pgPool,
|
db: pgPool,
|
||||||
apiBase: '/api',
|
apiBase: '/api',
|
||||||
callbackBaseUrl: process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`,
|
callbackBaseUrl: process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`,
|
||||||
@@ -279,32 +282,39 @@ if (process.env.DATABASE_URL) {
|
|||||||
logger.error({ err: err.message }, 'auth-manager bootstrap failed; auth disabled');
|
logger.error({ err: err.message }, 'auth-manager bootstrap failed; auth disabled');
|
||||||
try { await pgPool.end(); } catch { /* ignore */ }
|
try { await pgPool.end(); } catch { /* ignore */ }
|
||||||
pgPool = null;
|
pgPool = null;
|
||||||
|
authGuards = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn('DATABASE_URL not set — auth-manager disabled. Set DATABASE_URL in .env to enable customer accounts and admin SSO.');
|
logger.warn('DATABASE_URL not set — auth-manager disabled. Set DATABASE_URL in .env to enable customer accounts and admin SSO.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── goods-editor designs migration (#23) ──────────────────────────
|
// ── goods-editor designs migrations (#23) ──────────────────────────
|
||||||
// The module documents that the HOST applies its designs schema before
|
// The module documents that the HOST applies its designs schema before
|
||||||
// passing `db` to mountEditorApi (001_designs.sql is idempotent —
|
// passing `db` to mountEditorApi. Every file in its migrations/ dir is
|
||||||
// CREATE TABLE IF NOT EXISTS + CREATE OR REPLACE). With the file: dep
|
// idempotent (CREATE TABLE/ADD COLUMN IF NOT EXISTS), so we apply ALL
|
||||||
// the SQL is reachable through node_modules; a packed install ships
|
// of them in filename order — hardcoding 001 silently skipped
|
||||||
// dist/ only, so a missing file logs a warning instead of crashing —
|
// 002_design_family.sql when the module grew it (the store SELECTs
|
||||||
// the /designs routes still mount and fail loudly until the table is
|
// design_family_id unconditionally → every GET /api/designs 500'd).
|
||||||
// created by hand.
|
// With the file: dep the SQL is reachable through node_modules; a
|
||||||
|
// packed install ships dist/ only, so a missing dir logs a warning
|
||||||
|
// instead of crashing — the /designs routes still mount and fail
|
||||||
|
// loudly until the schema is applied by hand.
|
||||||
if (pgPool) {
|
if (pgPool) {
|
||||||
const designsMigrationPath = join(
|
const designsMigrationsDir = join(
|
||||||
__dirname, 'node_modules', 'goods-editor', 'src', 'server', 'migrations', '001_designs.sql',
|
__dirname, 'node_modules', 'goods-editor', 'src', 'server', 'migrations',
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
if (existsSync(designsMigrationPath)) {
|
if (existsSync(designsMigrationsDir)) {
|
||||||
await pgPool.query(readFileSync(designsMigrationPath, 'utf8'));
|
const files = readdirSync(designsMigrationsDir).filter((f) => f.endsWith('.sql')).sort();
|
||||||
logger.info('goods-editor designs migration applied');
|
for (const file of files) {
|
||||||
|
await pgPool.query(readFileSync(join(designsMigrationsDir, file), 'utf8'));
|
||||||
|
}
|
||||||
|
logger.info({ files }, 'goods-editor designs migrations applied');
|
||||||
} else {
|
} else {
|
||||||
logger.warn({ path: designsMigrationPath }, 'goods-editor designs migration not found — apply 001_designs.sql manually before using design persistence');
|
logger.warn({ path: designsMigrationsDir }, 'goods-editor migrations dir not found — apply its *.sql manually before using design persistence');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err: err.message }, 'goods-editor designs migration failed — POST /api/designs will error until the designs table exists');
|
logger.error({ err: err.message }, 'goods-editor designs migrations failed — POST /api/designs will error until the schema is applied');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,6 +426,18 @@ app.post('/api/upload', uploadLimiter, upload.single('image'), async (req, res)
|
|||||||
// the auth bootstrap failed), in which case the routes simply don't
|
// the auth bootstrap failed), in which case the routes simply don't
|
||||||
// mount and the client's persistCurrentDesign 404s into its graceful
|
// mount and the client's persistCurrentDesign 404s into its graceful
|
||||||
// cart-without-design_id fallback.
|
// cart-without-design_id fallback.
|
||||||
|
//
|
||||||
|
// Font-registration roster: ops-diagnosis data, admin-only when auth is
|
||||||
|
// live. Registered BEFORE mountEditorApi so this guard runs ahead of the
|
||||||
|
// module's own GET /api/fonts/registered handler (non-admins 403, admins
|
||||||
|
// fall through). We deliberately do NOT pass auth.middleware into
|
||||||
|
// mountEditorApi for this — the module applies that same middleware to
|
||||||
|
// the whole /designs group, and a requireAdmin there would lock guests
|
||||||
|
// out of design persistence (guest rows are a designed-in feature).
|
||||||
|
if (authGuards) {
|
||||||
|
app.use('/api/fonts/registered', authGuards.requireAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
mountEditorApi(app, {
|
mountEditorApi(app, {
|
||||||
canvas,
|
canvas,
|
||||||
sharp,
|
sharp,
|
||||||
@@ -423,9 +445,25 @@ mountEditorApi(app, {
|
|||||||
fontsDir,
|
fontsDir,
|
||||||
stickersDir,
|
stickersDir,
|
||||||
uploadsDir,
|
uploadsDir,
|
||||||
|
// Coverage-gap audit items: the design routes' thumbnail generator
|
||||||
|
// wants the mockup/template roots (previously only passed to
|
||||||
|
// renderDesignComposite for marketing previews, not here).
|
||||||
|
mockupsDir: templatesStorageDir,
|
||||||
|
storageDir: templatesStorageDir,
|
||||||
basePath: '/api',
|
basePath: '/api',
|
||||||
logger,
|
logger,
|
||||||
db: pgPool,
|
db: pgPool,
|
||||||
|
// Design-route ownership (coverage-gap audit, sharpest item): owned
|
||||||
|
// rows 404 for non-owners, admins bypass. These match the module's
|
||||||
|
// documented defaults but are passed explicitly so the enforcement is
|
||||||
|
// visible here and survives any future change to those defaults.
|
||||||
|
// req.customer / req.admin are populated by auth-manager's
|
||||||
|
// authMiddleware, which mountAuth app.use()s ABOVE this mount; when
|
||||||
|
// auth is disabled both resolve null/false = the open dev harness.
|
||||||
|
auth: {
|
||||||
|
customerId: (req) => req.customer?.id ?? null,
|
||||||
|
isAdmin: (req) => !!req.admin,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Template store (admin + public). adminAuth pass-through for v1
|
// Template store (admin + public). adminAuth pass-through for v1
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,15 @@
|
|||||||
* status/actions/upload-btn/remove
|
* status/actions/upload-btn/remove
|
||||||
* tm-featured, tm-featured__list/row/handle/name/type
|
* tm-featured, tm-featured__list/row/handle/name/type
|
||||||
* tm-trash, tm-trash__header/desc/list/date
|
* tm-trash, tm-trash__header/desc/list/date
|
||||||
|
* tm-admin-inventory, tm-admin-inventory__header/title/header-actions/
|
||||||
|
* new/bulk/filters/search/product-filter/low-filter/error/empty/
|
||||||
|
* table/row/cell-link/option-pill/sku/stock-badge[--ok|--low|--out]/
|
||||||
|
* stock-label
|
||||||
|
* tm-inventory-detail, tm-inventory-detail__back/header/options/
|
||||||
|
* option-pill/stock/stock-count[--ok|--low|--out]/stock-meta/
|
||||||
|
* stock-state/stock-actions/meta/error/save/history/tx-table/
|
||||||
|
* delta-pos/delta-neg/danger/delete
|
||||||
|
* tm-bulk-receive__rows/row/variant/qty/remove/add, tm-modal__card--wide
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ── Design tokens ────────────────────────────────────────────────── */
|
/* ── Design tokens ────────────────────────────────────────────────── */
|
||||||
@@ -2377,3 +2386,397 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--as-text-subtle);
|
color: var(--as-text-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Inventory admin (`tm-admin-inventory`, v0.8 material kinds) ──
|
||||||
|
List page at /admin/inventory. Mirrors the tm-admin-list chrome so
|
||||||
|
the two catalog surfaces read as siblings. Stock states come from
|
||||||
|
classifyStock(): out (0) / low (≤ threshold) / ok. */
|
||||||
|
.tm-admin-inventory {
|
||||||
|
background: var(--as-surface);
|
||||||
|
border: 1px solid var(--as-border);
|
||||||
|
border-radius: var(--as-radius-lg);
|
||||||
|
box-shadow: var(--as-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--as-space-4);
|
||||||
|
padding: var(--as-space-5) var(--as-space-6);
|
||||||
|
border-bottom: 1px solid var(--as-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--as-space-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__new {
|
||||||
|
background: var(--as-accent) !important;
|
||||||
|
color: white !important;
|
||||||
|
border-color: var(--as-accent) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px var(--as-space-4) !important;
|
||||||
|
}
|
||||||
|
.tm-admin-inventory__new:hover:not(:disabled) {
|
||||||
|
background: var(--as-accent-hover) !important;
|
||||||
|
border-color: var(--as-accent-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__bulk {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__filters {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--as-space-3);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: var(--as-space-4) var(--as-space-6);
|
||||||
|
background: var(--as-bg-subtle);
|
||||||
|
border-bottom: 1px solid var(--as-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__search {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__product-filter {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__low-filter {
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--as-space-2);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--as-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tm-admin-inventory__low-filter input[type="checkbox"] {
|
||||||
|
accent-color: var(--as-accent);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__error {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--as-space-3) var(--as-space-6);
|
||||||
|
background: var(--as-danger-soft);
|
||||||
|
color: var(--as-danger);
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--as-danger-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__empty {
|
||||||
|
padding: var(--as-space-7) var(--as-space-6);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--as-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.tm-admin-inventory__table thead th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--as-text-muted);
|
||||||
|
padding: var(--as-space-3) var(--as-space-6);
|
||||||
|
background: var(--as-bg-subtle);
|
||||||
|
border-bottom: 1px solid var(--as-border);
|
||||||
|
}
|
||||||
|
.tm-admin-inventory__table tbody td {
|
||||||
|
padding: var(--as-space-4) var(--as-space-6);
|
||||||
|
border-bottom: 1px solid var(--as-border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.tm-admin-inventory__table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.tm-admin-inventory__row:hover {
|
||||||
|
background: var(--as-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__cell-link {
|
||||||
|
color: var(--as-text);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.tm-admin-inventory__cell-link:hover {
|
||||||
|
color: var(--as-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__option-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin-right: var(--as-space-1);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--as-bg-subtle);
|
||||||
|
border: 1px solid var(--as-border);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--as-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__sku {
|
||||||
|
font-family: var(--as-font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--as-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stock badge — the at-a-glance state chip. */
|
||||||
|
.tm-admin-inventory__stock-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.tm-admin-inventory__stock-badge--ok {
|
||||||
|
background: var(--as-success-soft);
|
||||||
|
color: var(--as-success);
|
||||||
|
}
|
||||||
|
.tm-admin-inventory__stock-badge--low {
|
||||||
|
background: var(--as-warning-soft);
|
||||||
|
color: var(--as-warning-text);
|
||||||
|
}
|
||||||
|
.tm-admin-inventory__stock-badge--out {
|
||||||
|
background: var(--as-danger-soft);
|
||||||
|
color: var(--as-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-inventory__stock-label {
|
||||||
|
margin-left: var(--as-space-2);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--as-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inventory detail (`tm-inventory-detail`) ────────────────────── */
|
||||||
|
.tm-inventory-detail {
|
||||||
|
background: var(--as-surface);
|
||||||
|
border: 1px solid var(--as-border);
|
||||||
|
border-radius: var(--as-radius-lg);
|
||||||
|
box-shadow: var(--as-shadow);
|
||||||
|
padding: var(--as-space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--as-space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-inventory-detail__back {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--as-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.tm-inventory-detail__back:hover {
|
||||||
|
color: var(--as-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-inventory-detail__header h1 {
|
||||||
|
margin: var(--as-space-2) 0 var(--as-space-1);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-inventory-detail__options {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--as-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-inventory-detail__option-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--as-bg-subtle);
|
||||||
|
border: 1px solid var(--as-border);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--as-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stock hero — big count + state + receive/adjust actions. */
|
||||||
|
.tm-inventory-detail__stock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--as-space-5);
|
||||||
|
padding: var(--as-space-5);
|
||||||
|
background: var(--as-bg-subtle);
|
||||||
|
border: 1px solid var(--as-border);
|
||||||
|
border-radius: var(--as-radius-lg);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-inventory-detail__stock-count {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.tm-inventory-detail__stock-count--ok { color: var(--as-success); }
|
||||||
|
.tm-inventory-detail__stock-count--low { color: var(--as-warning); }
|
||||||
|
.tm-inventory-detail__stock-count--out { color: var(--as-danger); }
|
||||||
|
|
||||||
|
.tm-inventory-detail__stock-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
.tm-inventory-detail__stock-state {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tm-inventory-detail__stock-meta small {
|
||||||
|
color: var(--as-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-inventory-detail__stock-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--as-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-inventory-detail__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--as-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-inventory-detail__error {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--as-space-3) var(--as-space-4);
|
||||||
|
background: var(--as-danger-soft);
|
||||||
|
color: var(--as-danger);
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: var(--as-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-inventory-detail__save {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--as-accent) !important;
|
||||||
|
color: white !important;
|
||||||
|
border-color: var(--as-accent) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tm-inventory-detail__save:hover:not(:disabled) {
|
||||||
|
background: var(--as-accent-hover) !important;
|
||||||
|
border-color: var(--as-accent-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transaction history table. */
|
||||||
|
.tm-inventory-detail__history h2 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 var(--as-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-inventory-detail__tx-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tm-inventory-detail__tx-table thead th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--as-text-muted);
|
||||||
|
padding: var(--as-space-2) var(--as-space-3);
|
||||||
|
background: var(--as-bg-subtle);
|
||||||
|
border-bottom: 1px solid var(--as-border);
|
||||||
|
}
|
||||||
|
.tm-inventory-detail__tx-table tbody td {
|
||||||
|
padding: var(--as-space-2) var(--as-space-3);
|
||||||
|
border-bottom: 1px solid var(--as-border);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.tm-inventory-detail__tx-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-inventory-detail__delta-pos { color: var(--as-success); font-weight: 700; }
|
||||||
|
.tm-inventory-detail__delta-neg { color: var(--as-danger); font-weight: 700; }
|
||||||
|
|
||||||
|
/* Danger zone. */
|
||||||
|
.tm-inventory-detail__danger {
|
||||||
|
border-top: 1px solid var(--as-border);
|
||||||
|
padding-top: var(--as-space-4);
|
||||||
|
}
|
||||||
|
.tm-inventory-detail__delete {
|
||||||
|
color: var(--as-danger) !important;
|
||||||
|
border-color: var(--as-danger-soft) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tm-inventory-detail__delete:hover:not(:disabled) {
|
||||||
|
background: var(--as-danger-soft) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bulk receive modal (`tm-bulk-receive`) ──────────────────────── */
|
||||||
|
.tm-modal__card--wide {
|
||||||
|
max-width: 560px;
|
||||||
|
width: min(560px, calc(100vw - 2 * var(--as-space-5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-bulk-receive__rows {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--as-space-2);
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-bulk-receive__row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--as-space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-bulk-receive__variant {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-bulk-receive__qty {
|
||||||
|
width: 88px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-bulk-receive__remove {
|
||||||
|
color: var(--as-danger) !important;
|
||||||
|
border: none !important;
|
||||||
|
background: none !important;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: var(--as-space-1) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-bulk-receive__add {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: var(--as-space-2);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user