File size: 19,919 Bytes
4748aae ea7b176 4748aae ea7b176 4748aae ea7b176 4748aae ea7b176 4748aae ea7b176 4748aae ea7b176 4748aae 9371818 4748aae 9371818 ea7b176 4748aae 9371818 4748aae 9371818 ea7b176 9371818 4748aae | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 | // ---------------------------------------------------------------------------
// 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 }));
}
|