loopable / web /src /customer-grid /catalogData.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
ea7b176 verified
Raw
History Blame Contribute Delete
19.9 kB
// ---------------------------------------------------------------------------
// 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<string, Row> {
const out = new Map<string, Row>();
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, Field>): string | undefined {
for (const [key, field] of fieldByKey) if (field.type === "image") return key;
return undefined;
}
export function resolveItems(
codes: string[],
byCode: Map<string, Row>,
binds: CatalogSpec["fields"] | undefined,
format: (field: Field, value: Row[string]) => string,
fieldByKey: Map<string, Field>
): 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<CatalogSpec["fields"]> {
const out: NonNullable<CatalogSpec["fields"]> = {};
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:<slug>` 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<Set<string> | 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<Set<string> | 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<string> {
const data = await new Promise<string>((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<string> {
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<string>((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<CatalogPaper, { w: number; h: number; unit: "in" | "mm" }> = {
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<CatalogPaper, string> = {
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<CatalogPageKind, string> = {
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 }));
}