examples/storefront Public
// The product catalog: what the shop sells, at what price, and how many are left.import { HttpError } from "./http.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): 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, };}// Active products by name. The first 100 for now; a catalog bigger than that needs pages.export async function listProducts(db: D1Database): Promise<Product[]> { const { results } = await db .prepare(`SELECT ${COLUMNS} FROM products WHERE active = 1 ORDER BY name, id LIMIT 100`) .all<Row>(); return results.map(toProduct);}// Any product, active or not. Shopper routes check `active` themselves.export async function getProduct(db: D1Database, id: string): Promise<Product | null> { const row = await db.prepare(`SELECT ${COLUMNS} FROM products WHERE id = ?`).bind(id).first<Row>(); return row ? toProduct(row) : null;}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): 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))!;}