// ---------------------------------------------------------------------------
// 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")}
);
}
/** 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 = (
);
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 (
);
})}
>
);
}
// ------------------------------------------------------------------- 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.