// --------------------------------------------------------------------------- // 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 */}