loopable / web /src /customer-grid /CatalogView.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
ea7b176 verified
Raw
History Blame Contribute Delete
45.7 kB
// ---------------------------------------------------------------------------
// 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 (
<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden>
<path d="M8 3.4v9.2M3.4 8h9.2" {...STROKE} />
</svg>
);
}
function IconTrash() {
return (
<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden>
<path d="M3.4 4.6h9.2M6.4 4.6V3.2h3.2v1.4M4.8 4.6l.6 8h5.2l.6-8" {...STROKE} />
</svg>
);
}
function IconUp() {
return (
<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden>
<path d="M4.4 9.6L8 6l3.6 3.6" {...STROKE} />
</svg>
);
}
function IconDown() {
return (
<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden>
<path d="M4.4 6.4L8 10l3.6-3.6" {...STROKE} />
</svg>
);
}
function IconPrint() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden>
<path d="M4.6 6.4V2.8h6.8v3.6" {...STROKE} />
<path d="M2.8 6.4h10.4v4.4H11v2.4H5v-2.4H2.8z" {...STROKE} />
<path d="M5 10.8h6" {...STROKE} />
</svg>
);
}
/**
* 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 (
<svg className={className} viewBox="0 0 44 26" aria-hidden>
<g fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round">
<path d="M11 4.5l6.5 6.5L11 17.5 4.5 11z" />
<path d="M22 2.5l7.5 7.5L22 17.5 14.5 10z" />
<path d="M33 4.5L39.5 11 33 17.5 26.5 11z" />
</g>
<rect x="4" y="20" width="36" height="2.6" fill="currentColor" />
</svg>
);
}
// ------------------------------------------------------------ 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 (
<div className="cg-cat-shot cg-cat-shot-empty">
<span>No image</span>
</div>
);
if (missing)
return (
<div className="cg-cat-shot cg-cat-shot-empty">
<span>
{editorialSlug(code) !== null
? `No editorial image named ${editorialSlug(code)}`
: `No master for ${code}`}
</span>
</div>
);
return (
<div className={cover ? "cg-cat-shot cg-cat-shot-cover" : "cg-cat-shot"}>
<img src={assetUrl(code, quality)} alt="" loading="eager" />
</div>
);
}
function ProductCard({
item,
quality,
layout,
hasAsset,
}: {
item: CatalogItem;
quality: CatalogQuality;
layout: ReturnType<typeof sectionLayout>;
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 (
<div className="cg-cat-card">
<Shot code={item.image} quality={quality} missing={known === false} />
{/* CAPS in CSS (`text-transform`), never by upper-casing the string: the
value stays the value, so a copy of it is not shouting. */}
<div className="cg-cat-desc">{item.name ?? (item.known ? "" : "Not in this table")}</div>
{layout.showPack && item.pack ? <div className="cg-cat-pill">{item.pack}</div> : null}
<div className="cg-cat-meta">
<span className="cg-cat-sku">{item.code}</span>
{layout.showColor && item.color ? (
<span className="cg-cat-color">{item.color}</span>
) : null}
</div>
{layout.showPrice && item.price ? <div className="cg-cat-price">{item.price}</div> : null}
</div>
);
}
/** 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 (
<div className={light ? "cg-cat-foot cg-cat-foot-light" : "cg-cat-foot"}>
{mark ? <CrownMark className="cg-cat-foot-mark" /> : null}
<span className="cg-cat-folio">{folio}</span>
</div>
);
}
/** 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 = (
<div className="cg-cat-mark">
{brand.logo && logoKnown !== false ? (
<img src={assetUrl(brand.logo, quality)} alt="" />
) : (
<CrownMark className="cg-cat-mark-crown" />
)}
<span className="cg-cat-mark-co">{company}</span>
</div>
);
if (page.kind === "cover") {
const known = page.imageCode ? hasAsset(page.imageCode) : null;
return (
<div className="cg-cat-page cg-cat-p-cover" style={style} data-kind="cover">
<Shot code={page.imageCode} quality={quality} missing={known === false} cover />
<div className="cg-cat-cover-scrim" />
{Wordmark}
<div className="cg-cat-cover-ttl">
<span className="cg-cat-cover-a">{page.title}</span>
{page.body ? <span className="cg-cat-cover-b">{page.body}</span> : null}
</div>
</div>
);
}
if (page.kind === "intro") {
return (
<div className="cg-cat-page cg-cat-p-intro" style={style} data-kind="intro">
<div className="cg-cat-intro-field">
{/* The terracotta lattice bleeding off the left edge — the reference's
own device for the letter spread, drawn from the crown's diamond. */}
<svg className="cg-cat-lattice" viewBox="0 0 200 260" aria-hidden>
<g fill="none" stroke="currentColor" strokeWidth="7">
<path d="M-40 130l60-60 60 60-60 60z" />
<path d="M40 190l60-60 60 60-60 60z" />
<path d="M100 130l40 40 40-90" />
</g>
</svg>
</div>
<div className="cg-cat-intro-body">
<h2 className="cg-cat-intro-ttl">{page.title}</h2>
{/* 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() ? (
<p key={i} className="cg-cat-intro-p">
{para.trim()}
</p>
) : null
)}
<div className="cg-cat-intro-sign">The {company} Team</div>
</div>
<PageFooter folio={folio} light mark={false} />
</div>
);
}
if (page.kind === "gallery") {
const known = page.imageCode ? hasAsset(page.imageCode) : null;
return (
<div className="cg-cat-page cg-cat-p-gallery" style={style} data-kind="gallery">
<Shot code={page.imageCode} quality={quality} missing={known === false} cover />
<div className="cg-cat-gal-wash" />
{page.title ? <div className="cg-cat-gal-ttl">{page.title}</div> : null}
<PageFooter folio={folio} light />
</div>
);
}
const layout = sectionLayout(page);
const stripKnown = page.imageCode ? hasAsset(page.imageCode) : null;
return (
<div
className={`cg-cat-page cg-cat-p-section${page.imageCode ? " has-strip" : ""}`}
style={style}
data-kind="section"
>
<h2 className="cg-cat-sec-ttl">{page.title}</h2>
<div className="cg-cat-grid" style={{ "--cat-cols": layout.cols } as React.CSSProperties}>
{items.map((item) => (
<ProductCard
key={item.code}
item={item}
quality={quality}
layout={layout}
hasAsset={hasAsset}
/>
))}
</div>
{page.imageCode ? (
<div className="cg-cat-strip">
<Shot code={page.imageCode} quality={quality} missing={stripKnown === false} cover />
</div>
) : null}
<PageFooter folio={folio} />
</div>
);
});
// ------------------------------------------------------------------- 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 = (
<CatalogPageView
cat={cat}
page={page}
index={i}
items={itemsFor(page)}
quality={quality}
hasAsset={hasAsset}
/>
);
if (scale === undefined) return <div key={page.id}>{body}</div>;
return (
<div
key={page.id}
className={`cg-cat-slot${selectedId === page.id ? " is-sel" : ""}`}
style={{
width: box.w * px * scale,
height: box.h * px * scale,
"--cat-scale": scale,
} as React.CSSProperties}
onMouseDown={() => onSelect?.(page.id)}
>
{body}
</div>
);
})}
</>
);
}
// ------------------------------------------------------------------- view
export const CatalogView = memo(function CatalogView({
catalogs,
onCatalogs,
fields,
fieldByKey,
poolRows,
filteredCodes,
}: {
catalogs: CatalogSpec[];
onCatalogs: (next: CatalogSpec[]) => void;
fields: Field[];
fieldByKey: Map<string, Field>;
/** 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<string | null>(catalogs[0]?.id ?? null);
const [pageId, setPageId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [picked, setPicked] = useState<Set<string>>(() => new Set());
const [assetCodes, setAssetCodes] = useState<Set<string> | null>(null);
const [editorialSlugs, setEditorialSlugs] = useState<Set<string> | null>(null);
const edFileRef = useRef<HTMLInputElement | null>(null);
const [printing, setPrinting] = useState<{ cat: CatalogSpec; quality: CatalogQuality } | null>(
null
);
const [askQuality, setAskQuality] = useState<CatalogSpec | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const printRef = useRef<HTMLDivElement | null>(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<CatalogSpec>) => {
write(catalogs.map((c) => (c.id === id ? { ...c, ...patch } : c)));
},
[catalogs, write]
);
const patchPage = useCallback(
(catId: string, pgId: string, patch: Partial<CatalogPage>) => {
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:<slug>` 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<void>((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 (
<div className="cg-cat cg-cat-blank">
<div className="cg-cat-blank-in">
<CrownMark className="cg-cat-blank-mark" />
<h3>No catalogs yet</h3>
<p>
A catalog is a printable document built from this table&rsquo;s products — cover,
introduction, product sections and full-page images. Print it to PDF from the browser
when it is ready.
</p>
<button type="button" className="cg-btn cg-btn--primary" onClick={addCatalog}>
<IconPlus /> New catalog
</button>
</div>
</div>
);
const used = cat ? codesUsed(cat) : 0;
return (
<div className="cg-cat">
{/* ------------------------------------------------------------ rail */}
<aside className="cg-cat-rail">
<div className="cg-cat-rail-head">
<span>Catalogs</span>
<button
type="button"
className="cg-cat-icon-btn"
title={
catalogs.length >= MAX_CATALOGS
? `A view holds ${MAX_CATALOGS} catalogs.`
: "New catalog"
}
aria-label="New catalog"
disabled={catalogs.length >= MAX_CATALOGS}
onClick={addCatalog}
>
<IconPlus />
</button>
</div>
<ul className="cg-cat-list">
{catalogs.map((c) => (
<li key={c.id}>
<button
type="button"
className={`cg-cat-item${c.id === openId ? " is-open" : ""}`}
onClick={() => {
setOpenId(c.id);
setPageId(c.pages[0]?.id ?? null);
}}
>
<span className="cg-cat-item-name">{c.name || "Untitled catalog"}</span>
<span className="cg-cat-item-meta">
{c.pages.length} {c.pages.length === 1 ? "page" : "pages"}
</span>
</button>
</li>
))}
</ul>
{cat ? (
<>
<div className="cg-cat-rail-head cg-cat-rail-sub">
<span>Pages</span>
<span className="cg-cat-count">
{cat.pages.length}/{MAX_CATALOG_PAGES}
</span>
</div>
<ul className="cg-cat-pagelist">
{cat.pages.map((p, i) => (
<li key={p.id}>
<button
type="button"
className={`cg-cat-pageitem${p.id === pageId ? " is-sel" : ""}`}
onClick={() => setPageId(p.id)}
>
<span className="cg-cat-pageno">{i + 1}</span>
<span className="cg-cat-pagettl">
{p.title || PAGE_KIND_LABELS[p.kind]}
</span>
<span className="cg-cat-pagekind">{PAGE_KIND_LABELS[p.kind]}</span>
</button>
</li>
))}
</ul>
<div className="cg-cat-addrow">
{CATALOG_PAGE_KINDS.map((k) => (
<button
key={k}
type="button"
className="cg-btn"
disabled={cat.pages.length >= MAX_CATALOG_PAGES}
onClick={() => addPage(k)}
>
<IconPlus /> {PAGE_KIND_LABELS[k]}
</button>
))}
</div>
</>
) : null}
</aside>
{/* ----------------------------------------------------------- stage */}
<div className="cg-cat-stage" ref={stageRef}>
{cat ? (
<>
<div className="cg-cat-bar">
<input
className="cg-input cg-cat-name"
value={cat.name}
maxLength={CATALOG_NAME_MAX}
aria-label="Catalog name"
onChange={(e) => patchCat(cat.id, { name: e.target.value })}
/>
<select
className="cg-select"
aria-label="Paper size"
value={cat.paper}
onChange={(e) => patchCat(cat.id, { paper: e.target.value as CatalogPaper })}
>
{CATALOG_PAPERS.map((p) => (
<option key={p} value={p}>
{PAPER_LABELS[p]}
</option>
))}
</select>
<select
className="cg-select"
aria-label="Orientation"
value={cat.orientation}
onChange={(e) =>
patchCat(cat.id, { orientation: e.target.value as CatalogOrientation })
}
>
{CATALOG_ORIENTATIONS.map((o) => (
<option key={o} value={o}>
{o === "portrait" ? "Portrait" : "Landscape"}
</option>
))}
</select>
<span className="cg-cat-budget" title="Products placed across this catalog's pages">
{used}/{MAX_CATALOG_CODES} products
</span>
<button
type="button"
className="cg-btn cg-btn--primary cg-cat-print"
onClick={() => setAskQuality(cat)}
>
<IconPrint /> Print
</button>
</div>
<div className="cg-cat-scroll">
<CatalogPages
cat={cat}
itemsFor={itemsFor}
quality="web"
hasAsset={hasAsset}
scale={scale}
selectedId={pageId ?? undefined}
onSelect={setPageId}
/>
</div>
</>
) : (
<div className="cg-mode-empty">Pick a catalog to open it.</div>
)}
</div>
{/* ------------------------------------------------------- inspector */}
<aside className="cg-cat-insp">
{cat && page ? (
<>
<div className="cg-cat-insp-head">
<span>{PAGE_KIND_LABELS[page.kind]}</span>
<span className="cg-cat-insp-tools">
<button
type="button"
className="cg-cat-icon-btn"
aria-label="Move page up"
onClick={() => movePage(-1)}
>
<IconUp />
</button>
<button
type="button"
className="cg-cat-icon-btn"
aria-label="Move page down"
onClick={() => movePage(1)}
>
<IconDown />
</button>
<button
type="button"
className="cg-cat-icon-btn cg-cat-del"
aria-label="Delete page"
onClick={() =>
patchCat(cat.id, { pages: cat.pages.filter((p) => p.id !== page.id) })
}
>
<IconTrash />
</button>
</span>
</div>
<label className="cg-cat-fld">
<span>Title</span>
<input
className="cg-input"
value={page.title ?? ""}
onChange={(e) => patchPage(cat.id, page.id, { title: e.target.value })}
/>
</label>
{page.kind === "cover" || page.kind === "intro" ? (
<label className="cg-cat-fld">
<span>{page.kind === "cover" ? "Second line" : "Body"}</span>
<textarea
className="cg-input cg-cat-area"
rows={page.kind === "cover" ? 2 : 9}
value={page.body ?? ""}
onChange={(e) => patchPage(cat.id, page.id, { body: e.target.value })}
/>
</label>
) : null}
{page.kind !== "intro" ? (
<>
<label className="cg-cat-fld">
<span>{page.kind === "section" ? "Footer image (code)" : "Image (code)"}</span>
<input
className="cg-input"
value={page.imageCode ?? ""}
placeholder="e.g. CRY-2003 or ed:showroom"
onChange={(e) => patchPage(cat.id, page.id, { imageCode: e.target.value })}
/>
{page.imageCode && hasAsset(page.imageCode) === false ? (
<em className="cg-cat-warn">
{editorialSlug(page.imageCode) !== null
? "No editorial image is stored under this name — the page will print an empty frame."
: "No image is archived under this code — the page will print an empty frame."}
</em>
) : (
<em className="cg-cat-hint">
Fills the width edge to edge, so a wide photograph reads best here. A cut-out
product render will be cropped and enlarged to fill it.
</em>
)}
</label>
<label className="cg-cat-fld">
<span>Editorial library</span>
<select
className="cg-select"
value={editorialSlug(page.imageCode ?? "") ?? ""}
onChange={(e) =>
patchPage(cat.id, page.id, {
imageCode: e.target.value ? `${EDITORIAL_PREFIX}${e.target.value}` : "",
})
}
>
<option value="">(use a product code above)</option>
{[...(editorialSlugs ?? [])].sort().map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<button
type="button"
className="cg-btn"
onClick={() => edFileRef.current?.click()}
>
Upload a photograph…
</button>
<input
ref={edFileRef}
type="file"
accept="image/png,image/jpeg"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void uploadEditorialFile(f);
e.target.value = "";
}}
/>
<em className="cg-cat-hint">
Lifestyle photography without a SKU lives here. Storing a new one needs an
admin login; everyone can pick from the library.
</em>
</label>
</>
) : null}
{page.kind === "section" ? (
<>
<div className="cg-cat-insp-sub">Layout</div>
<div className="cg-cat-row">
<label className="cg-cat-fld cg-cat-fld-inline">
<span>Columns</span>
<select
className="cg-select"
value={sectionLayout(page).cols}
onChange={(e) =>
patchPage(cat.id, page.id, {
layout: { ...page.layout, cols: Number(e.target.value) },
})
}
>
{CATALOG_COLS.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
</div>
{(
[
["showPack", "Pack line", false],
["showColor", "Colour", false],
["showPrice", "Price", true],
] as const
).map(([key, label, optIn]) => {
const l = sectionLayout(page);
const on = key === "showPrice" ? l.showPrice : key === "showPack" ? l.showPack : l.showColor;
return (
<label key={key} className="cg-check-row">
<input
type="checkbox"
checked={on}
onChange={() => {
// Literal-only storage: the toggle writes the opt-out (or the
// opt-in) and DELETES the key to go back to the default, so the
// default has exactly one spelling on the wire.
const next = { ...page.layout };
if (optIn) {
if (on) delete next.showPrice;
else next.showPrice = true;
} else if (key === "showPack") {
if (on) next.showPack = false;
else delete next.showPack;
} else {
if (on) next.showColor = false;
else delete next.showColor;
}
patchPage(cat.id, page.id, {
layout: Object.keys(next).length ? next : undefined,
});
}}
/>
<span>{label}</span>
</label>
);
})}
<div className="cg-cat-insp-sub">
Products
<span className="cg-cat-count">{page.products?.length ?? 0} on this page</span>
</div>
<div className="cg-cat-picked">
{(page.products ?? []).map((code) => (
<span key={code} className="cg-cat-chip">
{code}
<button
type="button"
aria-label={`Remove ${code}`}
onClick={() =>
patchPage(cat.id, page.id, {
products: (page.products ?? []).filter((c) => c !== code),
})
}
>
&times;
</button>
</span>
))}
</div>
<button
type="button"
className="cg-btn cg-cat-addfiltered"
disabled={!filteredCodes.length}
onClick={() => commitCodes(filteredCodes)}
>
<IconPlus /> Add the {filteredCodes.length} products this view shows
</button>
<input
className="cg-input"
placeholder="Search products by SKU or name"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="cg-cat-results">
{matches.map((m) => (
<label key={m.code} className="cg-check-row">
<input
type="checkbox"
checked={picked.has(m.code)}
onChange={() =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(m.code)) next.delete(m.code);
else next.add(m.code);
return next;
})
}
/>
<span className="cg-cat-res-code">{m.code}</span>
<span className="cg-cat-res-name">{m.label}</span>
</label>
))}
{/* The 200 is STATED, never silent: a search that stops early has
to say so or the user reads absence as "we do not stock it". */}
{matches.length >= 200 ? (
<div className="cg-cat-more">
Showing the first 200 matches — narrow the search to see the rest.
</div>
) : null}
</div>
<button
type="button"
className="cg-btn cg-btn--primary"
disabled={!picked.size}
onClick={() => commitCodes([...picked])}
>
Add {picked.size} selected
</button>
{note ? <div className="cg-cat-note">{note}</div> : null}
</>
) : null}
<div className="cg-cat-insp-sub">Brand</div>
<div className="cg-cat-row">
<label className="cg-cat-fld cg-cat-fld-inline">
<span>Primary</span>
<input
type="color"
className="cg-cat-swatch"
value={cat.brand?.primary ?? DEFAULT_BRAND.primary}
onChange={(e) =>
patchCat(cat.id, { brand: { ...cat.brand, primary: e.target.value } })
}
/>
</label>
<label className="cg-cat-fld cg-cat-fld-inline">
<span>Accent</span>
<input
type="color"
className="cg-cat-swatch"
value={cat.brand?.accent ?? DEFAULT_BRAND.accent}
onChange={(e) =>
patchCat(cat.id, { brand: { ...cat.brand, accent: e.target.value } })
}
/>
</label>
</div>
<label className="cg-cat-fld">
<span>Company</span>
<input
className="cg-input"
value={cat.brand?.company ?? ""}
placeholder={DEFAULT_BRAND.company}
onChange={(e) =>
patchCat(cat.id, { brand: { ...cat.brand, company: e.target.value } })
}
/>
</label>
<div className="cg-cat-insp-sub">Listing columns</div>
{(
[
["name", "Description"],
["pack", "Pack line"],
["color", "Colour"],
["price", "Price"],
] as const
).map(([slot, label]) => (
<label key={slot} className="cg-cat-fld cg-cat-fld-inline">
<span>{label}</span>
<select
className="cg-select"
value={cat.fields?.[slot] ?? ""}
onChange={(e) => {
const next = { ...cat.fields };
if (e.target.value) next[slot] = e.target.value;
else delete next[slot];
patchCat(cat.id, { fields: Object.keys(next).length ? next : undefined });
}}
>
{/* An explicit empty option — the wave-7 trap: a <select> whose value
is not among its options renders the FIRST one, so "unbound" needs a
row of its own rather than being the absence of one. */}
<option value="">Not shown</option>
{fields.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
</option>
))}
</select>
</label>
))}
<button
type="button"
className="cg-btn"
onClick={() => {
const guess = autoBind(fields);
patchCat(cat.id, { fields: Object.keys(guess).length ? guess : undefined });
}}
>
Guess from this table
</button>
<div className="cg-cat-insp-foot">
<button
type="button"
className="cg-btn cg-btn--danger"
onClick={() => {
const next = catalogs.filter((c) => c.id !== cat.id);
write(next);
setOpenId(next[0]?.id ?? null);
setPageId(null);
}}
>
<IconTrash /> Delete this catalog
</button>
</div>
</>
) : (
<div className="cg-cat-insp-blank">Select a page to edit it.</div>
)}
</aside>
{/* --------------------------------------------------- quality prompt */}
{askQuality ? (
<div className="cg-cat-modal-wrap" role="dialog" aria-modal="true" aria-label="Print quality">
<div className="cg-cat-modal">
<h3>Print &ldquo;{askQuality.name || "Untitled catalog"}&rdquo;</h3>
<p>
Your browser&rsquo;s print dialog opens next — choose &ldquo;Save as PDF&rdquo; there.
Pick the image quality first.
</p>
<button
type="button"
className="cg-cat-choice"
onClick={() => {
setPrinting({ cat: askQuality, quality: "print" });
setAskQuality(null);
}}
>
<strong>High quality</strong>
<em>Original master images. Slower, and a much larger PDF — use this for print.</em>
</button>
<button
type="button"
className="cg-cat-choice"
onClick={() => {
setPrinting({ cat: askQuality, quality: "web" });
setAskQuality(null);
}}
>
<strong>Standard</strong>
<em>The same images you see here. Fast, small file — fine for a proof or email.</em>
</button>
<button type="button" className="cg-btn" onClick={() => setAskQuality(null)}>
Cancel
</button>
</div>
</div>
) : null}
{/* ------------------------------------------------------ print tree */}
{printing
? createPortal(
<div className="cg-cat-print-portal" ref={printRef}>
<CatalogPages
cat={printing.cat}
itemsFor={itemsFor}
quality={printing.quality}
hasAsset={hasAsset}
/>
</div>,
document.body
)
: null}
</div>
);
});
export default CatalogView;