diff --git "a/web/src/customer-grid/CatalogView.tsx" "b/web/src/customer-grid/CatalogView.tsx" --- "a/web/src/customer-grid/CatalogView.tsx" +++ "b/web/src/customer-grid/CatalogView.tsx" @@ -1,1244 +1,1728 @@ -// --------------------------------------------------------------------------- -// customer-grid / CatalogView.tsx -// Wave-18 owner item 4 (contract C6-CATALOG) — the CATALOG view. -// -// A view holds several catalogs; a catalog is an ordered list of pages; a -// section page names product CODES and joins them to the live pool at paint -// time. Three columns: the rail (catalogs, then that catalog's pages), the -// stage (the pages themselves, at true paper size, scaled to fit), and the -// inspector (everything about the selected page, plus the catalog's brand, -// paper and column bindings). -// -// FIDELITY. The default templates reproduce `Royal Collection 2027.pdf`, which -// was rendered and read before this file was written: -// · section header — letterspaced caps, navy, light weight, top-left -// · product renders on WHITE (the C2-ASSET web derivative is alpha-preserving -// PNG precisely so this works) -// · description in caps, centred, two lines -// · a full-width navy PILL carrying the pack line -// · SKU in terracotta bold on the left, colour name on the right -// · a full-bleed lifestyle strip under the grid -// · crown mark bottom-left, folio bottom-right -// · gallery pages full-bleed with a letterspaced caps title -// -// TYPE. Catalog pages use a display SERIF for titles (`--cat-serif`, a system -// Didone/old-style stack — Didot, Bodoni, Playfair, Cormorant, Georgia). That is -// deliberate and it is the ONLY place it appears: app chrome stays Inter, per -// the owner constant. Listing text is Inter with wide tracking, which is what -// the reference's geometric sans reads as at listing size. NO EMOJIS anywhere — -// every mark here is a drawn path. -// --------------------------------------------------------------------------- - -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { - CATALOG_COLS, - CATALOG_NAME_MAX, - CATALOG_ORIENTATIONS, - CATALOG_PAGE_KINDS, - CATALOG_PAPERS, - MAX_CATALOGS, - MAX_CATALOG_PAGES, - MAX_CATALOG_CODES, -} from "./types"; -import type { - CatalogOrientation, - CatalogPage, - CatalogPageKind, - CatalogPaper, - CatalogQuality, - CatalogSpec, - Field, - Row, -} from "./types"; -import { - CATALOG_CODE_FIELD, - DEFAULT_BRAND, - EDITORIAL_PREFIX, - PAGE_KIND_LABELS, - PAPER_LABELS, - addCodes, - assetUrl, - autoBind, - codesUsed, - editorialSlug, - fetchAssetCodes, - fetchEditorialSlugs, - indexByCode, - newCatalog, - newPage, - pageBox, - resolveItems, - sectionLayout, - slugFromFilename, - uploadEditorial, -} from "./catalogData"; -import type { CatalogItem } from "./catalogData"; -// Wave-19 R7 — the record-image namespace, so the gap report can say "I do not know" about an -// upload rather than marking every one of them missing. -import { RECORD_IMAGE_PREFIX } from "./types"; -import { applyPrintCss, awaitImages, printOnce } from "./catalogPrint"; -import { formatDisplay } from "./display"; - -// ------------------------------------------------------------------- marks - -const STROKE = { - stroke: "currentColor", - strokeWidth: 1.5, - strokeLinecap: "round" as const, - strokeLinejoin: "round" as const, - fill: "none", -}; - -function IconPlus() { - return ( - - - - ); -} -function IconTrash() { - return ( - - - - ); -} -function IconUp() { - return ( - - - - ); -} -function IconDown() { - return ( - - - - ); -} -function IconPrint() { - return ( - - - - - - ); -} - -/** - * The Royal crown-diamond mark, drawn as paths. - * - * A row of three diamond outlines over a solid bar — the silhouette the 2027 - * catalogue carries on its cover, its footers and its back page. Drawn rather - * than uploaded because a catalog has to print the mark at whatever size the - * page gives it, and because an image dependency for the ONE element on every - * page would make an unreachable asset route into a blank catalogue. - */ -function CrownMark({ className }: { className?: string }) { - return ( - - - - - - - - - ); -} - -// ------------------------------------------------------------ page templates - -/** The image slot every template shares: the asset, or a STATED gap. - * - * A missing master is drawn as a labelled frame naming the code, never as - * silence — the user has to be able to see, in the designer, that this page - * will print an empty box, and which SKU caused it. */ -function Shot({ - code, - quality, - missing, - cover, -}: { - code?: string; - quality: CatalogQuality; - missing?: boolean; - cover?: boolean; -}) { - if (!code) - return ( -
- No image -
- ); - if (missing) - return ( -
- - {editorialSlug(code) !== null - ? `No editorial image named ${editorialSlug(code)}` - : `No master for ${code}`} - -
- ); - return ( -
- -
- ); -} - -function ProductCard({ - item, - quality, - layout, - hasAsset, -}: { - item: CatalogItem; - quality: CatalogQuality; - layout: ReturnType; - hasAsset: (code: string) => boolean | null; -}) { - // ⭐ Wave-19 R7 — the picture is the ITEM'S image reference, which equals the code on every - // row nobody has overridden. Both the tile and the gap report read the SAME string, or the - // designer could mark a product "no master" while printing a picture it does have. - const known = hasAsset(item.image); - return ( -
- - {/* CAPS in CSS (`text-transform`), never by upper-casing the string: the - value stays the value, so a copy of it is not shouting. */} -
{item.name ?? (item.known ? "" : "Not in this table")}
- {layout.showPack && item.pack ?
{item.pack}
: null} -
- {item.code} - {layout.showColor && item.color ? ( - {item.color} - ) : null} -
- {layout.showPrice && item.price ?
{item.price}
: null} -
- ); -} - -/** Crown mark left, folio right — exactly the reference's footer, which carries no - * company line (the wordmark's place is the cover). `light` inverts it for the - * pages whose field is dark. - * - * `mark={false}` drops the crown: the intro page's terracotta lattice bleeds through the - * bottom-left corner, and a second terracotta mark sitting inside it reads as a smudge on the - * lattice rather than as the brand. The reference's own letter spread carries no crown there - * either — judged from the rendered page, not assumed. */ -function PageFooter({ - folio, - light, - mark = true, -}: { - folio: number; - light?: boolean; - mark?: boolean; -}) { - return ( -
- {mark ? : null} - {folio} -
- ); -} - -/** ONE page, at true paper size. The same component paints the stage and the - * print tree — there is no second renderer that could drift from what the - * screenshot showed. */ -const CatalogPageView = memo(function CatalogPageView({ - cat, - page, - index, - items, - quality, - hasAsset, -}: { - cat: CatalogSpec; - page: CatalogPage; - index: number; - items: CatalogItem[]; - quality: CatalogQuality; - hasAsset: (code: string) => boolean | null; -}) { - const box = pageBox(cat.paper, cat.orientation); - const px = box.unit === "in" ? 96 : 96 / 25.4; - const brand = cat.brand ?? {}; - const primary = brand.primary ?? DEFAULT_BRAND.primary; - const accent = brand.accent ?? DEFAULT_BRAND.accent; - const company = brand.company ?? DEFAULT_BRAND.company; - const style = { - "--cat-w": `${box.w * px}px`, - "--cat-h": `${box.h * px}px`, - "--cat-primary": primary, - "--cat-accent": accent, - } as React.CSSProperties; - const folio = index + 1; - const logoKnown = brand.logo ? hasAsset(brand.logo) : null; - - const Wordmark = ( -
- {brand.logo && logoKnown !== false ? ( - - ) : ( - - )} - {company} -
- ); - - if (page.kind === "cover") { - const known = page.imageCode ? hasAsset(page.imageCode) : null; - return ( -
- -
- {Wordmark} -
- {page.title} - {page.body ? {page.body} : null} -
-
- ); - } - - if (page.kind === "intro") { - return ( -
-
- {/* The terracotta lattice bleeding off the left edge — the reference's - own device for the letter spread, drawn from the crown's diamond. */} - - - - - - - -
-
-

{page.title}

- {/* Paragraphs, not a wall: the reference's letter is set in short - blocks and reads as one because of the spacing between them. */} - {(page.body ?? "").split(/\n{2,}/).map((para, i) => - para.trim() ? ( -

- {para.trim()} -

- ) : null - )} -
The {company} Team
-
- -
- ); - } - - if (page.kind === "gallery") { - const known = page.imageCode ? hasAsset(page.imageCode) : null; - return ( -
- -
- {page.title ?
{page.title}
: null} - -
- ); - } - - const layout = sectionLayout(page); - const stripKnown = page.imageCode ? hasAsset(page.imageCode) : null; - return ( -
-

{page.title}

-
- {items.map((item) => ( - - ))} -
- {page.imageCode ? ( -
- -
- ) : null} - -
- ); -}); - -// ------------------------------------------------------------------- stage - -function CatalogPages({ - cat, - itemsFor, - quality, - hasAsset, - scale, - selectedId, - onSelect, -}: { - cat: CatalogSpec; - itemsFor: (page: CatalogPage) => CatalogItem[]; - quality: CatalogQuality; - hasAsset: (code: string) => boolean | null; - /** Absent = paint at true size (the PRINT tree). */ - scale?: number; - selectedId?: string; - onSelect?: (id: string) => void; -}) { - const box = pageBox(cat.paper, cat.orientation); - const px = box.unit === "in" ? 96 : 96 / 25.4; - return ( - <> - {cat.pages.map((page, i) => { - const body = ( - - ); - if (scale === undefined) return
{body}
; - return ( -
onSelect?.(page.id)} - > - {body} -
- ); - })} - - ); -} - -// ------------------------------------------------------------------- view - -export const CatalogView = memo(function CatalogView({ - catalogs, - onCatalogs, - fields, - fieldByKey, - poolRows, - filteredCodes, -}: { - catalogs: CatalogSpec[]; - onCatalogs: (next: CatalogSpec[]) => void; - fields: Field[]; - fieldByKey: Map; - /** The WHOLE pool — a catalogue keeps painting what it names after the view - * is filtered, so the join must not run over the filtered rows. */ - poolRows: Row[]; - /** The codes the CURRENT filter kept — the "add what the view shows" door. */ - filteredCodes: string[]; -}) { - const [openId, setOpenId] = useState(catalogs[0]?.id ?? null); - const [pageId, setPageId] = useState(null); - const [search, setSearch] = useState(""); - const [picked, setPicked] = useState>(() => new Set()); - const [assetCodes, setAssetCodes] = useState | null>(null); - const [editorialSlugs, setEditorialSlugs] = useState | null>(null); - const edFileRef = useRef(null); - const [printing, setPrinting] = useState<{ cat: CatalogSpec; quality: CatalogQuality } | null>( - null - ); - const [askQuality, setAskQuality] = useState(null); - const stageRef = useRef(null); - const printRef = useRef(null); - const [stageW, setStageW] = useState(0); - - const cat = catalogs.find((c) => c.id === openId) ?? null; - const page = cat?.pages.find((p) => p.id === pageId) ?? null; - - // The manifest, once. `null` means "we do not know" and the designer stays - // quiet — a false gap report sends the user hunting for images that are there. - useEffect(() => { - let live = true; - void fetchAssetCodes().then((codes) => { - if (live) setAssetCodes(codes); - }); - void fetchEditorialSlugs().then((slugs) => { - if (live) setEditorialSlugs(slugs); - }); - return () => { - live = false; - }; - }, []); - - const hasAsset = useCallback( - (code: string): boolean | null => { - // `ed:` codes resolve against the EDITORIAL library (DEBT-5); everything - // else stays a product code. Unknown-library (null) keeps the designer - // quiet, same as the product manifest's own rule. - const slug = editorialSlug(code); - if (slug !== null) return editorialSlugs ? editorialSlugs.has(slug) : null; - // ⭐ Wave-19 R7 — a `rec:` upload has NO manifest to check against, and inventing one - // would be a per-tenant listing endpoint built for a designer badge. `null` is the - // vocabulary this function already has for "I do not know", and it is the truthful - // answer: an uploaded picture was validated at upload, so the far likelier state is that - // it is there. Marking it missing would send the user hunting for a file that exists. - if (code.startsWith(RECORD_IMAGE_PREFIX)) return null; - return assetCodes ? assetCodes.has(code) : null; - }, - [assetCodes, editorialSlugs] - ); - - useLayoutEffect(() => { - const el = stageRef.current; - if (!el || typeof ResizeObserver === "undefined") return; - const ro = new ResizeObserver(() => setStageW(el.clientWidth)); - ro.observe(el); - setStageW(el.clientWidth); - return () => ro.disconnect(); - }, []); - - const byCode = useMemo(() => indexByCode(poolRows), [poolRows]); - const itemsFor = useCallback( - (p: CatalogPage) => - resolveItems(p.products ?? [], byCode, cat?.fields, formatDisplay, fieldByKey), - [byCode, cat?.fields, fieldByKey] - ); - - const scale = useMemo(() => { - if (!cat || !stageW) return 0.5; - const box = pageBox(cat.paper, cat.orientation); - const px = box.unit === "in" ? 96 : 96 / 25.4; - return Math.min(1, Math.max(0.2, (stageW - 56) / (box.w * px))); - }, [cat, stageW]); - - // ------------------------------------------------------------- mutation - const write = useCallback( - (next: CatalogSpec[]) => { - onCatalogs(next); - }, - [onCatalogs] - ); - const patchCat = useCallback( - (id: string, patch: Partial) => { - write(catalogs.map((c) => (c.id === id ? { ...c, ...patch } : c))); - }, - [catalogs, write] - ); - const patchPage = useCallback( - (catId: string, pgId: string, patch: Partial) => { - write( - catalogs.map((c) => - c.id !== catId - ? c - : { ...c, pages: c.pages.map((p) => (p.id === pgId ? { ...p, ...patch } : p)) } - ) - ); - }, - [catalogs, write] - ); - - const addCatalog = () => { - if (catalogs.length >= MAX_CATALOGS) return; - const made = newCatalog(`Catalog ${catalogs.length + 1}`, fields); - write([...catalogs, made]); - setOpenId(made.id); - setPageId(made.pages[0]?.id ?? null); - }; - - const addPage = (kind: CatalogPageKind) => { - if (!cat || cat.pages.length >= MAX_CATALOG_PAGES) return; - const made = newPage(kind); - patchCat(cat.id, { pages: [...cat.pages, made] }); - setPageId(made.id); - }; - - const movePage = (delta: number) => { - if (!cat || !page) return; - const i = cat.pages.findIndex((p) => p.id === page.id); - const j = i + delta; - if (i < 0 || j < 0 || j >= cat.pages.length) return; - const pages = [...cat.pages]; - [pages[i], pages[j]] = [pages[j], pages[i]]; - patchCat(cat.id, { pages }); - }; - - // ------------------------------------------------------------ the picker - const matches = useMemo(() => { - if (!cat) return []; - const q = search.trim().toLowerCase(); - const nameKey = cat.fields?.name; - const out: { code: string; label: string }[] = []; - for (const r of poolRows) { - const code = r[CATALOG_CODE_FIELD]; - if (typeof code !== "string" || !code) continue; - const nameField = nameKey ? fieldByKey.get(nameKey) : undefined; - const label = nameField ? formatDisplay(nameField, r[nameKey as string]) : ""; - if (q && !code.toLowerCase().includes(q) && !label.toLowerCase().includes(q)) continue; - out.push({ code, label }); - if (out.length >= 200) break; - } - return out; - }, [cat, poolRows, search, fieldByKey]); - - const commitCodes = (codes: string[]) => { - if (!cat || !page) return; - const res = addCodes(cat, page.id, codes); - patchCat(cat.id, { pages: res.pages }); - setPicked(new Set()); - if (res.skippedCap || res.skippedDuplicate) { - const parts: string[] = []; - if (res.skippedDuplicate) parts.push(`${res.skippedDuplicate} already on the page`); - if (res.skippedCap) - parts.push(`${res.skippedCap} over the ${MAX_CATALOG_CODES}-product catalog limit`); - setNote(`Added ${res.added}. Skipped ${parts.join("; ")}.`); - } else { - setNote(`Added ${res.added}.`); - } - }; - const [note, setNote] = useState(""); - - // DEBT-5: one editorial master in, `ed:` bound to the open page. The server - // owns validation (admin wall, charset, opens-as-an-image); this just reports honestly. - const uploadEditorialFile = async (f: File) => { - const slug = slugFromFilename(f.name); - if (!slug) { - setNote("That filename does not reduce to a usable name — rename the file first."); - return; - } - setNote(`Uploading ${slug}…`); - try { - const stored = await uploadEditorial(slug, f); - setEditorialSlugs(await fetchEditorialSlugs()); - if (cat && page) patchPage(cat.id, page.id, { imageCode: `${EDITORIAL_PREFIX}${stored}` }); - setNote(`Editorial image "${stored}" stored and bound to this page.`); - } catch (e) { - setNote(e instanceof Error ? e.message : "The upload failed."); - } - }; - - // ------------------------------------------------------------- printing - useEffect(() => { - if (!printing) return; - let cancelled = false; - void (async () => { - // One frame so the print tree is in the DOM before its images are awaited. - await new Promise((res) => requestAnimationFrame(() => res())); - await awaitImages(printRef.current); - if (cancelled) return; - const drop = applyPrintCss(printing.cat); - await printOnce(); - drop(); - if (!cancelled) setPrinting(null); - })(); - return () => { - cancelled = true; - }; - }, [printing]); - - // ----------------------------------------------------------------- paint - if (!catalogs.length) - return ( -
-
- -

No catalogs yet

-

- A catalog is a printable document built from this table’s products — cover, - introduction, product sections and full-page images. Print it to PDF from the browser - when it is ready. -

- -
-
- ); - - const used = cat ? codesUsed(cat) : 0; - - return ( -
- {/* ------------------------------------------------------------ rail */} - - - {/* ----------------------------------------------------------- stage */} -
- {cat ? ( - <> -
- patchCat(cat.id, { name: e.target.value })} - /> - - - - {used}/{MAX_CATALOG_CODES} products - - -
-
- -
- - ) : ( -
Pick a catalog to open it.
- )} -
- - {/* ------------------------------------------------------- inspector */} -