// --------------------------------------------------------------------------- // customer-grid / catalogData.ts // Wave-18 owner item 4 (contract C6-CATALOG) — the CATALOG view's pure layer: // paper geometry, page templates, product resolution and the C2-ASSET image // URLs. No React, no DOM: everything here is drivable by the gate. // // What a catalog IS, stated once so the rest of the code can be short: a view // holds up to MAX_CATALOGS print artifacts; each is an ordered list of PAGES; // a `section` page names PRODUCT CODES and the renderer joins them to the live // pool at paint time. Codes, never pids — a catalogue outlives any one fetch, // and the code is the thing actually printed on the page. // // Fidelity target is `reference/Royal Imports/Royal Collection 2027.pdf` // (rendered and read before this file was written): letterspaced caps section // headers, product renders on white, caps description, a navy pill carrying the // pack line, SKU in accent bold beside the colour name, a full-bleed lifestyle // strip under the grid, page number + crown mark in the footer. // --------------------------------------------------------------------------- import { API_V1 } from "../apiContract"; import { CATALOG_CODE_MAX, MAX_CATALOG_CODES, MAX_CATALOG_PAGES, RECORD_IMAGE_PREFIX, } from "./types"; import type { CatalogOrientation, CatalogPage, CatalogPageKind, CatalogPaper, CatalogQuality, CatalogSpec, Field, Row, } from "./types"; // ----------------------------------------------------------------- the pool /** * The column a catalog joins on. `product_data`'s business key * (`aios_grid_fields.json: business_key = "code"`) and ALSO the C2-ASSET key — * the 1,142 masters are named for `default_code`, so one string is both the * SKU printed on the page and the image that goes above it. That coincidence is * why the catalog binds to a named column instead of to the table's primary * field: on `customer_data` the primary field is a customer NAME, and joining * assets on it would ask the asset route for an image of a person. */ export const CATALOG_CODE_FIELD = "code"; /** Code → row, over the WHOLE pool (not the filtered rows) so a catalogue keeps * painting the products it names after the user narrows the view. */ export function indexByCode(rows: Row[]): Map { const out = new Map(); for (const r of rows) { const code = r[CATALOG_CODE_FIELD]; if (typeof code === "string" && code && !out.has(code)) out.set(code, r); } return out; } /** * The four listing lines, resolved for ONE product. * * `code` is always present (it is the identity); everything else is absent when * the catalog has not bound a column for it, or when the row carries no value. * ⚠ `pack` is the bound field's value VERBATIM — never composed from a number. * The reference prints "6-Piece per Pack" on p7 and "6-Sets Per Case" on p121, * so any composed rule is wrong on one of those pages. */ export interface CatalogItem { code: string; name?: string; pack?: string; color?: string; price?: string; /** * ⭐ Wave-19 R7 — WHICH PICTURE this item prints, resolved from the table's `image` column * with the code as the fallback. Always a non-empty string when `known`; equal to `code` on * every row nobody has overridden, which is why the ruling could keep the fallback and still * call the field authoritative. */ image: string; /** False when the row is not in the pool at all — drawn as a stated gap. */ known: boolean; } /** The table's picture column, if it has one. FIRST `image`-typed field: a table with two is * ambiguous and picking the first is at least stable, where picking "none" would silently * disable the feature for the user who added a second one. */ export function imageFieldKey(fieldByKey: Map): string | undefined { for (const [key, field] of fieldByKey) if (field.type === "image") return key; return undefined; } export function resolveItems( codes: string[], byCode: Map, binds: CatalogSpec["fields"] | undefined, format: (field: Field, value: Row[string]) => string, fieldByKey: Map ): CatalogItem[] { const line = (row: Row, key: string | undefined): string | undefined => { if (!key) return undefined; const field = fieldByKey.get(key); if (!field) return undefined; const text = format(field, row[key]); return text ? text : undefined; }; const imageKey = imageFieldKey(fieldByKey); return codes.map((code) => { const row = byCode.get(code); // An unknown code still prints its own frame under its own name — the gap is STATED, and // stating it needs the code as the reference it would have used. if (!row) return { code, image: code, known: false }; return { code, known: true, image: rowImageRef(row, imageKey) || code, name: line(row, binds?.name), pack: line(row, binds?.pack), color: line(row, binds?.color), price: line(row, binds?.price), }; }); } /** * The default column bindings for a NEW catalog, guessed from the table's own * fields. A guess, not a contract: every one is overridable in the designer, and * a wrong guess renders a wrong LINE rather than a wrong number. * * `name` deliberately skips the code column — printing the SKU twice is the one * outcome that always looks broken. */ export function autoBind(fields: Field[]): NonNullable { const out: NonNullable = {}; const find = (test: RegExp, types?: string[]): string | undefined => fields.find( (f) => f.key !== CATALOG_CODE_FIELD && (test.test(f.key) || test.test(f.label)) && (!types || types.includes(f.type)) )?.key; out.name = find(/^(product|name|title|description)$/i) ?? fields.find((f) => f.key !== CATALOG_CODE_FIELD && f.type === "text")?.key; out.pack = find(/pack|case|carton|inner/i); out.color = find(/colou?r/i); out.price = find(/price|msrp|list/i, ["currency", "int", "pct"]); for (const k of ["name", "pack", "color", "price"] as const) if (!out[k]) delete out[k]; return out; } // -------------------------------------------------------------- the images /** * C2-ASSET, consumed as a SHAPE (the contract's client rule: tokens, not * values). `API_V1` is the app's own base — this file never names a host. * * R9: `web` is an ~800px alpha-preserving PNG (transparency matters — the 2027 * listing sits product renders directly on white), `print` serves the ORIGINAL * master bytes. The response is `immutable`-cached, so swapping quality for an * export costs one fetch per image and nothing after that. */ /** * DEBT-5 (2026-08-04): EDITORIAL imagery — non-SKU, full-bleed lifestyle photos * for gallery pages. Addressed as `ed:` wherever a page stores an image * code: the colon is outside the product-code charset, so the two namespaces * cannot collide, and every existing catalog keeps meaning what it meant. */ export const EDITORIAL_PREFIX = "ed:"; export function editorialSlug(code: string): string | null { return code.startsWith(EDITORIAL_PREFIX) ? code.slice(EDITORIAL_PREFIX.length).trim().toLowerCase() : null; } export function assetUrl(code: string, quality: CatalogQuality): string { const slug = editorialSlug(code); if (slug !== null) return `${API_V1}/assets/editorial/${encodeURIComponent(slug)}?q=${quality}`; // ⭐ Wave-19 R7 / C5 — the THIRD namespace: a picture uploaded through an `image` field. The // tenant is NOT in the URL and never will be: the route takes it from the session, so a // reference copied out of one tenant's cell resolves to nothing in another's (a 404, not // somebody else's photograph). if (code.startsWith(RECORD_IMAGE_PREFIX)) { const id = code.slice(RECORD_IMAGE_PREFIX.length).trim(); return `${API_V1}/assets/records/${encodeURIComponent(id)}?q=${quality}`; } return `${API_V1}/assets/products/${encodeURIComponent(code)}?q=${quality}`; } /** * ⭐ Wave-19 R7 — WHICH REFERENCE A ROW'S PICTURE IS, given the catalog's bound image column. * * The ruling is "catalog reads the field (code fallback kept)", and the fallback is the half * that matters: Royal's 1,142 masters are named for `default_code`, so a product nobody has * touched already names its own picture. Reading the field FIRST is what lets one SKU be * overridden — a lifestyle shot for the cover product, say — without uploading 1,141 others. * * `imageKey` absent (a catalog bound before this field existed, or a table with no image * column) collapses to exactly the old behaviour: the code IS the reference. */ export function rowImageRef(row: Row | undefined, imageKey?: string): string { if (!row) return ""; if (imageKey) { const v = row[imageKey]; if (typeof v === "string" && v.trim()) return v.trim(); } const code = row[CATALOG_CODE_FIELD]; return typeof code === "string" ? code.trim() : ""; } /** C2-ASSET's manifest — which codes HAVE an image, so the designer can mark * the ones that will print an empty frame instead of discovering it at print. */ export const ASSET_MANIFEST_URL = `${API_V1}/assets/products`; export const EDITORIAL_MANIFEST_URL = `${API_V1}/assets/editorial`; export async function fetchAssetCodes(): Promise | null> { try { const res = await fetch(ASSET_MANIFEST_URL, { credentials: "same-origin" }); if (!res.ok) return null; const body = (await res.json()) as { codes?: unknown }; if (!Array.isArray(body.codes)) return null; return new Set(body.codes.filter((c): c is string => typeof c === "string")); } catch { // A manifest we cannot reach means "we do not know", NOT "nothing exists". // Returning null keeps the designer silent instead of marking every product // missing — a false gap report would send the user hunting for images that // are sitting right there. return null; } } /** The editorial library, same null-means-unknown semantics as fetchAssetCodes. */ export async function fetchEditorialSlugs(): Promise | null> { try { const res = await fetch(EDITORIAL_MANIFEST_URL, { credentials: "same-origin" }); if (!res.ok) return null; const body = (await res.json()) as { slugs?: unknown }; if (!Array.isArray(body.slugs)) return null; return new Set( body.slugs.filter((s): s is string => typeof s === "string").map((s) => s.toLowerCase()) ); } catch { return null; } } /** Upload one editorial master (admin route). Resolves to the stored slug, or * throws with the server's honest error message. */ export async function uploadEditorial(slug: string, file: Blob): Promise { const data = await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result ?? "")); r.onerror = () => reject(new Error("could not read the file")); r.readAsDataURL(file); }); const res = await fetch(EDITORIAL_MANIFEST_URL, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, data }), }); const body = (await res.json().catch(() => ({}))) as { slug?: string; error?: { message?: string }; }; if (!res.ok) throw new Error(body.error?.message ?? `upload failed (${res.status})`); return body.slug ?? slug; } /** * ⭐ Wave-19 R7 / C5 — upload one RECORD image and get back the reference to store in the cell. * * Session-gated, not admin-gated: attaching a picture to your own row is the same class of act * as typing a note. The tenant is never sent — the server takes it from the session, which is * what makes a `rec:` reference meaningless outside the tenant that minted it. * * ⚠ The client checks the size FIRST purely to spare a doomed 2 MB round trip. The server * re-checks the DECODED bytes and re-parses the image; this is a courtesy, not the wall. */ export const RECORD_IMAGE_MAX_BYTES = 2 * 1024 * 1024; export const RECORD_ASSET_URL = `${API_V1}/assets/records`; export async function uploadRecordImage(file: Blob): Promise { if (file.size > RECORD_IMAGE_MAX_BYTES) throw new Error( `Images cap at ${RECORD_IMAGE_MAX_BYTES / (1024 * 1024)} MB — that one is ` + `${(file.size / (1024 * 1024)).toFixed(1)} MB.` ); const data = await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result ?? "")); r.onerror = () => reject(new Error("could not read the file")); r.readAsDataURL(file); }); const res = await fetch(RECORD_ASSET_URL, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data }), }); const body = (await res.json().catch(() => ({}))) as { ref?: string; error?: { message?: string }; }; if (!res.ok) throw new Error(body.error?.message ?? `upload failed (${res.status})`); if (!body.ref) throw new Error("the server accepted the image but named no reference"); return body.ref; } /** A slug guessed from a filename: lowercased, non-alphanumerics folded to * hyphens, trimmed — mirrors the server's charset so the guess is accepted. */ export function slugFromFilename(name: string): string { return name .replace(/\.[A-Za-z0-9]+$/, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 64); } // -------------------------------------------------------------- the paper /** * Physical page sizes. Emitted into `@page { size: … }` as EXPLICIT dimensions * rather than the CSS keywords (`letter`, `A4`, `ledger`): the keyword set has * no unambiguous name for 11x17 — `ledger` and `tabloid` differ by rotation * depending on who you read — and an explicit pair is what the print gate can * actually assert against the PDF it gets back. */ export const PAPER_SIZES: Record = { letter: { w: 8.5, h: 11, unit: "in" }, a4: { w: 210, h: 297, unit: "mm" }, tabloid: { w: 11, h: 17, unit: "in" }, }; export const PAPER_LABELS: Record = { letter: 'Letter · 8.5 × 11"', a4: "A4 · 210 × 297 mm", tabloid: 'Tabloid · 11 × 17"', }; /** The page box, with orientation applied. Landscape SWAPS the pair — it does * not rotate the content, which is what `@page`'s own keyword would do. */ export function pageBox( paper: CatalogPaper, orientation: CatalogOrientation ): { w: number; h: number; unit: "in" | "mm" } { const size = PAPER_SIZES[paper]; return orientation === "landscape" ? { w: size.h, h: size.w, unit: size.unit } : { ...size }; } /** `8.5in` / `297mm` — one place that decides how a dimension is spelled, so the * screen box and the `@page` rule can never drift apart by a unit. */ export function cssLength(value: number, unit: "in" | "mm"): string { return `${Number.isInteger(value) ? value : value.toFixed(2).replace(/0+$/, "")}${unit}`; } // ------------------------------------------------------- catalogs & pages /** Royal Imports' own palette, measured off the 2027 cover: deep navy field, * terracotta accent. The defaults for a new catalog; every catalog can differ. */ export const DEFAULT_BRAND = { primary: "#16233A", accent: "#C47B5A", company: "Royal Imports", } as const; function mintId(prefix: string): string { const rand = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().slice(0, 8) : Math.random().toString(36).slice(2, 10); return `${prefix}_${rand}`; } export const newCatalogId = (): string => mintId("cat"); export const newPageId = (): string => mintId("pg"); export const PAGE_KIND_LABELS: Record = { cover: "Cover", intro: "Introduction", section: "Product section", gallery: "Full-page image", }; /** A fresh page of a kind, carrying the defaults its template reads best at. */ export function newPage(kind: CatalogPageKind): CatalogPage { const page: CatalogPage = { id: newPageId(), kind }; if (kind === "cover") page.title = "Collection"; if (kind === "intro") page.title = "About us"; if (kind === "section") page.title = "New section"; return page; } /** * A new catalog, opening on the reference's own running order: cover, letter, * one product section. Three pages rather than an empty list because an empty * designer teaches nothing — the user should see the artifact they are editing. */ export function newCatalog(name: string, fields: Field[]): CatalogSpec { const binds = autoBind(fields); const cat: CatalogSpec = { id: newCatalogId(), name, paper: "letter", orientation: "portrait", brand: { ...DEFAULT_BRAND }, pages: [newPage("cover"), newPage("intro"), newPage("section")], }; if (Object.keys(binds).length) cat.fields = binds; return cat; } /** How much of the 500-code allowance this catalog has spent. Shown in the * designer BEFORE the cap bites, so a full catalogue is a fact the user can see * rather than a silent truncation on the next save ([[no-unverifiable-aggregates]]). */ export function codesUsed(cat: CatalogSpec): number { let n = 0; for (const p of cat.pages) n += p.products?.length ?? 0; return n; } export function codesRemaining(cat: CatalogSpec): number { return Math.max(0, MAX_CATALOG_CODES - codesUsed(cat)); } export function pagesRemaining(cat: CatalogSpec): number { return Math.max(0, MAX_CATALOG_PAGES - cat.pages.length); } /** * Add codes to a page, honouring BOTH caps and reporting what it refused. * * The report is the point: "added 40 of 60, the catalogue is full" is a fact the * user can act on. Silently keeping 40 is the defect this house calls a silent * `[:N]` cap. */ export function addCodes( cat: CatalogSpec, pageId: string, codes: string[] ): { pages: CatalogPage[]; added: number; skippedDuplicate: number; skippedCap: number } { const budget = codesRemaining(cat); let added = 0; let skippedDuplicate = 0; let skippedCap = 0; const pages = cat.pages.map((p) => { if (p.id !== pageId) return p; const have = new Set(p.products ?? []); const next = [...(p.products ?? [])]; for (const raw of codes) { const code = raw.slice(0, CATALOG_CODE_MAX); if (!code) continue; if (have.has(code)) { skippedDuplicate += 1; continue; } if (added >= budget) { skippedCap += 1; continue; } have.add(code); next.push(code); added += 1; } return next.length ? { ...p, products: next } : p; }); return { pages, added, skippedDuplicate, skippedCap }; } /** The layout a section page renders at, defaults applied. The three toggles are * stored literal-only (see `CatalogPage.layout`), so "absent" is where every * default lives and this is the ONE place that knows what they are. */ export function sectionLayout(page: CatalogPage): { cols: number; showPack: boolean; showColor: boolean; showPrice: boolean; } { const l = page.layout; return { cols: l?.cols ?? 3, showPack: l?.showPack !== false, showColor: l?.showColor !== false, showPrice: l?.showPrice === true, }; } /** * The printed page NUMBER for each page, and the total. * * The cover is page 1 and carries no folio — exactly the reference, where the * first numeral to appear on paper is on the intro spread. So the number is the * INDEX, and whether it is drawn is the template's business. */ export function folio(pages: CatalogPage[]): { number: number; total: number }[] { return pages.map((_, i) => ({ number: i + 1, total: pages.length })); }