examples/storefront Public
// The product catalog: what the shop sells, at what price, and how many are left.import { HttpError } from "./http.ts";import type { ImageUrls } from "./images.ts";export type Product = { id: string; name: string; description: string; price_cents: number; stock: number; image_url: string | null; active: boolean;};type Row = { id: string; name: string; description: string; price_cents: number; stock: number; image_key: string | null; active: number;};const COLUMNS = "id, name, description, price_cents, stock, image_key, active";const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;export function toProduct(r: Row, images: ImageUrls): Product { return { id: r.id, name: r.name, description: r.description, price_cents: r.price_cents, stock: r.stock, image_url: r.image_key ? images(r.image_key) : null, active: r.active === 1, };}export const DEFAULT_PAGE = 24;export const MAX_PAGE = 100;export type Page = { products: Product[]; next: string | null };// Active products by name, a page at a time. The cursor is the (name, id) of the last product on// the previous page, so pages stay correct while products are added or retired, and the query// walks the products_by_name index instead of skipping rows with OFFSET.export async function listProducts(db: D1Database, images: ImageUrls, limit = DEFAULT_PAGE, after: string | null = null): Promise<Page> { const from = after === null ? null : decodeCursor(after); const { results } = await db .prepare( `SELECT ${COLUMNS} FROM products WHERE active = 1 AND (?1 IS NULL OR (name, id) > (?1, ?2)) ORDER BY name, id LIMIT ?3`, ) .bind(from?.[0] ?? null, from?.[1] ?? null, limit + 1) .all<Row>(); const page = results.slice(0, limit); const last = page[page.length - 1]; return { products: page.map((r) => toProduct(r, images)), next: results.length > limit && last ? encodeCursor(last.name, last.id) : null };}export function parseLimit(value: string | null): number { if (value === null) return DEFAULT_PAGE; const n = Number(value); if (!Number.isInteger(n) || n < 1 || n > MAX_PAGE) throw new HttpError(400, "bad_limit", `limit is a whole number from 1 to ${MAX_PAGE}.`); return n;}function encodeCursor(name: string, id: string): string { const bytes = new TextEncoder().encode(JSON.stringify([name, id])); return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");}function decodeCursor(cursor: string): [string, string] { try { const bin = atob(cursor.replace(/-/g, "+").replace(/_/g, "/")); const v: unknown = JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)))); if (Array.isArray(v) && v.length === 2 && typeof v[0] === "string" && typeof v[1] === "string") return [v[0], v[1]]; } catch { // fall through to the error below } throw new HttpError(400, "bad_cursor", "after is a cursor from a previous page's next.");}// Any product, active or not. Shopper routes check `active` themselves.export async function getProduct(db: D1Database, id: string, images: ImageUrls): Promise<Product | null> { const row = await db.prepare(`SELECT ${COLUMNS} FROM products WHERE id = ?`).bind(id).first<Row>(); return row ? toProduct(row, images) : null;}// The active products among `ids`, in the order given. Ids that are unknown or inactive are left// out. Search uses it to turn index hits into products with their current price and stock.export async function activeProducts(db: D1Database, ids: string[], images: ImageUrls): Promise<Product[]> { if (!ids.length) return []; const { results } = await db .prepare(`SELECT ${COLUMNS} FROM products WHERE active = 1 AND id IN (SELECT value FROM json_each(?))`) .bind(JSON.stringify(ids)) .all<Row>(); const byId = new Map(results.map((r) => [r.id, r])); return ids.flatMap((id) => { const row = byId.get(id); return row ? [toProduct(row, images)] : []; });}export type ProductInput = { id: string; name: string; description: string; price_cents: number; stock: number; active: boolean;};export function parseProductInput(id: string, body: Record<string, unknown>): ProductInput { if (!SLUG.test(id) || id.length > 64) { throw new HttpError(400, "bad_id", "A product id is a lowercase slug of at most 64 characters."); } const { name, description = "", price_cents, stock = 0, active = true } = body; if (typeof name !== "string" || !name.trim() || name.length > 120) { throw new HttpError(400, "bad_name", "name is a string of 1 to 120 characters."); } if (typeof description !== "string" || description.length > 2000) { throw new HttpError(400, "bad_description", "description is a string of at most 2,000 characters."); } if (!Number.isInteger(price_cents) || (price_cents as number) <= 0) { throw new HttpError(400, "bad_price", "price_cents is a whole number of cents above zero."); } if (!Number.isInteger(stock) || (stock as number) < 0) { throw new HttpError(400, "bad_stock", "stock is a whole number, zero or more."); } if (typeof active !== "boolean") throw new HttpError(400, "bad_active", "active is true or false."); return { id, name: name.trim(), description, price_cents: price_cents as number, stock: stock as number, active };}export async function upsertProduct(db: D1Database, p: ProductInput, now: number, images: ImageUrls): Promise<Product> { await db .prepare( `INSERT INTO products (id, name, description, price_cents, stock, active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET name = excluded.name, description = excluded.description, price_cents = excluded.price_cents, stock = excluded.stock, active = excluded.active, updated_at = excluded.updated_at`, ) .bind(p.id, p.name, p.description, p.price_cents, p.stock, p.active ? 1 : 0, now, now) .run(); return (await getProduct(db, p.id, images))!;}