| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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"; |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const CATALOG_CODE_FIELD = "code"; |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export interface CatalogItem { |
| code: string; |
| name?: string; |
| pack?: string; |
| color?: string; |
| price?: string; |
| |
| |
| |
| |
| |
| |
| image: string; |
| |
| known: boolean; |
| } |
|
|
| |
| |
| |
| 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); |
| |
| |
| 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), |
| }; |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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}`; |
| |
| |
| |
| |
| 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}`; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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() : ""; |
| } |
|
|
| |
| |
| 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 { |
| |
| |
| |
| |
| return null; |
| } |
| } |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| export function slugFromFilename(name: string): string { |
| return name |
| .replace(/\.[A-Za-z0-9]+$/, "") |
| .toLowerCase() |
| .replace(/[^a-z0-9]+/g, "-") |
| .replace(/^-+|-+$/g, "") |
| .slice(0, 64); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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"', |
| }; |
|
|
| |
| |
| 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 }; |
| } |
|
|
| |
| |
| export function cssLength(value: number, unit: "in" | "mm"): string { |
| return `${Number.isInteger(value) ? value : value.toFixed(2).replace(/0+$/, "")}${unit}`; |
| } |
|
|
| |
|
|
| |
| |
| 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", |
| }; |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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 }; |
| } |
|
|
| |
| |
| |
| 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, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function folio(pages: CatalogPage[]): { number: number; total: number }[] { |
| return pages.map((_, i) => ({ number: i + 1, total: pages.length })); |
| } |
|
|