Skip to contentVibraUI
Dashboards

Storefront

The shop's shelf, read one way for every storefront section: the products a shopper can still buy, what each sold over a window, what its stock reads as, the aisle of its department it sits in, what buyers rated it, the stock of each size, the shop's delivery terms, codes and tax, and where its page lives.

Server-only, since it reads db: a section imports it from its own data file and hands the rows to its island as props, so the store never reaches the browser. A sale is the E-commerce dashboard's rule (vocabulary-ecommerce's salesBetween: paid and fulfilled orders, each line at the price it charged), counted over the days before REFERENCE_DATE, so the shelf's bestsellers are the catalogue page's bestsellers too. Archived products are off the shelf. Stock reads out at none, low at or under the product's reorder point and in above it. A product's page in the shop is /products/<id>; point productHref at your own route and every card follows. The catalogue records a department and no finer, so an aisle is a rule over the product's name (SUBCATEGORIES, first match wins, the last aisle of a department taking the rest); a department's listing is /products?department=<name>, an aisle's adds &subcategory=<name>. A score is what buyers wrote: storefront-ratings.ts reads the stars off db.reviews, one review per order line that reached a buyer — delivered, or delivered and sent back — so a kept product scores mostly fours and fives and a returned one three at most, and a score on any band counts the reviews a reviews band lists. Swap db.reviews for your reviews table and every band that prints a score follows. Sizes are a rule too: sizeRunOf reads the run off what a product is (clothes XS to XXL, shoes US 7 to 12, socks S to L; a beanie, a belt or a bag comes in one size) and sizesOf shares the product's one stock count across the run's usual curve in whole units that add up to it exactly. POLICY holds the shop's terms — standard and express delivery, the order total from which standard is free, the days a buyer has to send a product back — which every band that quotes them reads. They are set in checkout.ts, the one file a browser may import: plain constants and pure functions, no store, so a cart island totals as the shopper changes it by the same arithmetic a server file uses — the subtotal, a code's discount (WELCOME10 and NORTHWIND5 take a share off; SAVE15 takes $15 off $120 or more, and says how much more it needs), standard delivery free from $100 of goods after the discount, 8% tax on those goods, each rounded once in that order — and the rules a card and an address are checked by (a Luhn check, an expiry measured against the month the band calls now, a 3 or 4 digit code); nothing is ever charged.

Install

npx shadcn@latest add @vibra/storefront-ecommerce

Needs the @vibra registry in your components.json — set it up once.

Props

PropTypeDefaultDescription
storeShelf(days?: number) => ShelfProduct[]90Every active product with what it sold over the window, bestselling first; a tie goes to the name.
ShelfProduct{ id, sku, name, category, subcategory, supplier, priceCents, stock, stockState, sold, rating, ratings, href }—A product as a storefront shows it: the catalogue row's own fields, its aisle, its stock read as a state, its units sold, the mean and count of its ratings, and its page.
unitsSold(days?: number) => Map<string, number>90Units each product sold over the window; a product that sold none is absent.
stockState(product: { stock: number; reorderPoint: number }) => "in" | "low" | "out"—Out at none, low at or under the reorder point, in above it.
productHref(id: string) => string—A product's page in the shop: /products/<id>.
SHELF_DAYSnumber90The window a shelf counts its sales over.
storeDepartments(days?: number) => ShelfDepartment[]90Every department with a product on the shelf, A to Z, with its products and its aisles, each bestselling first; an empty aisle is left out.
subcategoryOf(product: { name: string; category: string }) => string—The aisle a product sits in: the first of its department's SUBCATEGORIES whose words its name uses, else the department's last aisle.
departmentHref(department: string, subcategory?: string) => string—A department's listing, /products?department=<name>, or one aisle of it with &subcategory=<name>.
storeRatings() => StoreRating[]—storefront-ratings.ts: every rating buyers gave, one per review in db.reviews: { productId, orderId, customerId, stars, receivedAt, reviewId }.
ratingsOf() => Map<string, number[]>—storefront-ratings.ts: every product's stars, rating by rating; a product nobody rated is absent.
summarize(stars: number[]) => { average, count, byStars }—storefront-ratings.ts: the mean, the count, and how many gave five stars down to one.
sizeRunOf(product: { name: string; category: string }) => SizeRun | undefined—The run of sizes a product comes in, read off what it is; undefined for a product sold in one size.
sizesOf(product: { stock: number }, run: SizeRun) => { size: string; units: number }[]—The product's stock shared across the run's sizes in whole units that add up to it exactly; a size left at none is sold out.
POLICY{ standardCents, expressCents, freeFromCents, returnDays }695, 1495, 10000, 30The shop's delivery and returns terms, quoted by every band that states them; set in checkout.ts.
cartTotals(lines: { unitCents; qty }[], { code?, method? }) => CartTotalsmethod: "standard"checkout.ts: units, subtotal, discount, goods, delivery, tax and total in cents, whether standard delivery is free and how far there is to go. Client-safe.
checkCode(input: string, subtotalCents: number) => { ok: true; code } | { ok: false; message }—checkout.ts: a typed code looked up in DISCOUNT_CODES, any case, and an amount off checked against its minimum, with the refusal in words.
DELIVERY, FREE_DELIVERY_FROM_CENTS, TAX_PERCENT, RETURN_DAYSconstantsstandard 695 (3–5 days), express 1495 (1–2), pickup 0; 10000; 8; 30checkout.ts: the terms every band totals and quotes by; arrivalWindow(method, placed) turns a way into its first and last working day.
cardNumberError, expiryError, cvcError, postcodeError, EMAIL(value: string, …) => string | null—checkout.ts: why a typed card number, expiry, security code or postcode is refused, in words, or null; an expiry is measured against the { year, month } the band passes as now.

Dependencies

Source

lib/dashboards/ecommerce/storefront.ts
/**
 * The shop's shelf, read one way for every storefront section: which of the
 * catalogue a shopper can still buy, what each product sold over a window,
 * what its stock reads as, which aisle of its department it sits in, what
 * buyers rated it (`storefront-ratings`), and where its page lives in the
 * shop. A sale is the E-commerce dashboard's own rule (`salesBetween`: paid
 * and fulfilled orders only), so a product that is a bestseller on a product
 * card is one on the dashboard's catalogue too, and "Low stock" means the
 * same thing on every band that says it.
 *
 * Server-only, because it reads `db`: a section imports it from its own
 * `<name>.data.ts` and hands the rows to its island as props, so the store
 * never reaches the browser. "Now" is `REFERENCE_DATE`.
 */
import { DELIVERY, FREE_DELIVERY_FROM_CENTS, RETURN_DAYS } from "@/lib/dashboards/ecommerce/checkout"
import { ratingsOf, summarize } from "@/lib/dashboards/ecommerce/storefront-ratings"
import { salesBetween } from "@/lib/dashboards/ecommerce/vocabulary"
import { REFERENCE_DATE, daysAgo, db, type Product } from "@/lib/sample-data"

/** How far back a shelf counts its sales, unless a section asks for another window. */
export const SHELF_DAYS = 90

/** What a product's stock reads as on a shelf. */
export type StockState = "in" | "low" | "out"

/**
 * "out" at none, "low" at or under the reorder point — the level the store
 * restocks at — and "in" above it.
 */
export function stockState(product: Pick<Product, "stock" | "reorderPoint">): StockState {
  if (product.stock <= 0) return "out"
  return product.stock <= product.reorderPoint ? "low" : "in"
}

/** A product's page in the shop. */
export function productHref(id: string): string {
  return `/products/${id}`
}

/** One aisle of a department, and the words the names of the products in it use; the last aisle takes the rest. */
export type Subcategory = { name: string; words?: RegExp }

/**
 * The aisles of each department, in the order the shop files them. The
 * catalogue records a department and no finer, so an aisle is a rule over
 * the product's name — a jacket is outerwear, a skillet cookware — and the
 * last aisle of each department takes whatever the others do not name. Put
 * your own taxonomy here, or read it off your catalogue, and every band that
 * counts an aisle follows.
 */
export const SUBCATEGORIES: Record<string, readonly Subcategory[]> = {
  Apparel: [
    { name: "Outerwear", words: /\b(jackets?|coats?|vests?|parkas?)\b/i },
    { name: "Tops", words: /\b(sweaters?|t-shirts?|shirts?|hoodies?)\b/i },
    { name: "Shoes and socks", words: /\b(shoes?|socks?|boots?)\b/i },
    { name: "Accessories" },
  ],
  Electronics: [
    { name: "Keyboards and mice", words: /\b(keyboards?|mouse|mice)\b/i },
    { name: "Screens and cameras", words: /\b(monitors?|webcams?|cameras?)\b/i },
    { name: "Audio", words: /\b(headphones?|speakers?|earbuds?)\b/i },
    { name: "Accessories" },
  ],
  Furniture: [
    { name: "Tables and desks", words: /\b(tables?|desks?)\b/i },
    { name: "Seating", words: /\b(chairs?|armchairs?|stools?|ottomans?|sofas?|benches?)\b/i },
    { name: "Shelving and storage", words: /\b(shelf|shelves|bookshelf|stands?|cabinets?)\b/i },
    { name: "Dividers and decor" },
  ],
  Home: [
    { name: "Cookware", words: /\b(skillets?|pans?|pots?|baking|kettles?)\b/i },
    { name: "Prep and storage", words: /\b(cutting|knife|knives|containers?)\b/i },
    { name: "Tableware", words: /\b(mugs?|plates?|bowls?|cups?)\b/i },
    { name: "Bath and living" },
  ],
  Office: [
    // A name ends on the thing it names: a desk organizer is an organizer.
    { name: "Desks and chairs", words: /\b(desks?|chairs?)$/i },
    { name: "Storage and organizers", words: /\b(cabinets?|organizers?|trays?)\b/i },
    { name: "Lighting and equipment", words: /\b(lamps?|shredders?|whiteboards?)\b/i },
    { name: "Stationery" },
  ],
  Outdoor: [
    { name: "Tents and sleep", words: /\b(tents?|sleeping|hammocks?)\b/i },
    { name: "Hiking", words: /\b(backpacks?|trekking|water bottles?)\b/i },
    { name: "Camp kitchen", words: /\b(grills?|coolers?|stoves?)\b/i },
    { name: "Around camp" },
  ],
}

/** The aisle a product sits in: the first of its department's that names it. A department with no aisles is its own. */
export function subcategoryOf(product: Pick<Product, "name" | "category">): string {
  const aisles = SUBCATEGORIES[product.category]
  return aisles?.find((aisle) => !aisle.words || aisle.words.test(product.name))?.name ?? product.category
}

/** A department's listing in the shop, or one aisle of it: the listing's own route with the filter in its query. */
export function departmentHref(department: string, subcategory?: string): string {
  const query = new URLSearchParams({ department })
  if (subcategory) query.set("subcategory", subcategory)
  return `/products?${query}`
}

/**
 * The shop's delivery and returns terms: what standard and express cost,
 * the order total from which standard is free, and how long a buyer has to
 * send a product back. Terms, not data. They are set in `checkout.ts`, where
 * a cart and a checkout total by them in the browser too; this is the same
 * terms as one record for the bands that quote them.
 */
export const POLICY = {
  standardCents: DELIVERY.standard.cents,
  expressCents: DELIVERY.express.cents,
  freeFromCents: FREE_DELIVERY_FROM_CENTS,
  returnDays: RETURN_DAYS,
} as const

/** A run of sizes, what the picker calls them, and the share of stock each usually takes. */
export type SizeRun = { label: string; sizes: readonly string[]; shares: readonly number[] }

/** Clothes run XS to XXL, weighted to the middle; shoes in whole US sizes; socks small to large. */
export const SIZE_RUNS = {
  clothing: { label: "Size", sizes: ["XS", "S", "M", "L", "XL", "XXL"], shares: [0.05, 0.2, 0.3, 0.27, 0.15, 0.03] },
  shoes: { label: "US size", sizes: ["7", "8", "9", "10", "11", "12"], shares: [0.08, 0.16, 0.24, 0.24, 0.18, 0.1] },
  socks: { label: "Size", sizes: ["S", "M", "L"], shares: [0.25, 0.45, 0.3] },
} as const satisfies Record<string, SizeRun>

/** Worn without a size: a beanie, a belt, a bag. */
const ONE_SIZE = /\b(beanie|belt|bag)\b/i

/**
 * The run a product comes in, read off what it is; undefined for anything
 * sold in one size. The catalogue keeps one stock count per product, so a
 * size is a rule over the product, like an aisle.
 */
export function sizeRunOf(product: Pick<Product, "name" | "category">): SizeRun | undefined {
  if (product.category !== "Apparel" || ONE_SIZE.test(product.name)) return undefined
  if (/\bshoes?\b/i.test(product.name)) return SIZE_RUNS.shoes
  if (/\bsocks?\b/i.test(product.name)) return SIZE_RUNS.socks
  return SIZE_RUNS.clothing
}

/**
 * `total` shared across `shares` in whole numbers that add up to it: each
 * share's floor first, then one more to the largest remainders, the earlier
 * size winning a tie.
 */
export function apportion(total: number, shares: readonly number[]): number[] {
  const raw = shares.map((share) => total * share)
  const whole = raw.map((value) => Math.floor(value))
  let left = total - whole.reduce((sum, value) => sum + value, 0)
  const order = raw
    .map((value, index) => ({ index, rest: value - whole[index] }))
    .sort((a, b) => b.rest - a.rest || a.index - b.index)
  for (const { index } of order) {
    if (left <= 0) break
    whole[index] += 1
    left -= 1
  }
  return whole
}

/** One size of a product and the units of it on hand. */
export type SizeStock = { size: string; units: number }

/**
 * A product's stock by size, in the run's order: the run's usual curve
 * shared out in whole units that add up to the product's count exactly, so a
 * size the curve leaves at none is sold out. Replace it with your variants'
 * own stock and every band that sells a size follows.
 */
export function sizesOf(product: Pick<Product, "stock">, run: SizeRun): SizeStock[] {
  const units = apportion(Math.max(0, product.stock), run.shares)
  return run.sizes.map((size, index) => ({ size, units: units[index] }))
}

/**
 * Units each product sold over the `days` before `REFERENCE_DATE`, by the
 * dashboard's rule for a sale. A product that sold none is absent.
 */
export function unitsSold(days: number = SHELF_DAYS): Map<string, number> {
  const sales = salesBetween(db.orders.all(), daysAgo(days).getTime(), REFERENCE_DATE.getTime())
  return new Map([...sales].map(([id, sale]) => [id, sale.units]))
}

/** A product as a storefront shows it. */
export type ShelfProduct = {
  id: string
  sku: string
  name: string
  category: string
  /** Its aisle within the department: `subcategoryOf`. */
  subcategory: string
  supplier: string
  /** The catalogue's list price, in cents. */
  priceCents: number
  /** Units on hand. */
  stock: number
  stockState: StockState
  /** Units sold over the shelf's window. */
  sold: number
  /** The mean of the stars buyers gave it, unrounded; 0 when nobody has rated it. */
  rating: number
  /** How many ratings the mean is of. */
  ratings: number
  href: string
}

/**
 * Every product still sold — an archived one is off the shelf — with what it
 * sold over the `days` before `REFERENCE_DATE`, bestselling first. A tie goes
 * to the name that sorts first, so the order is the same on every render.
 */
export function storeShelf(days: number = SHELF_DAYS): ShelfProduct[] {
  const sold = unitsSold(days)
  const stars = ratingsOf()
  return db.products
    .all()
    .filter((product) => product.status === "active")
    .map((product) => {
      const score = summarize(stars.get(product.id) ?? [])
      return {
        id: product.id,
        sku: product.sku,
        name: product.name,
        category: product.category,
        subcategory: subcategoryOf(product),
        supplier: product.supplier,
        priceCents: product.priceCents,
        stock: product.stock,
        stockState: stockState(product),
        sold: sold.get(product.id) ?? 0,
        rating: score.average,
        ratings: score.count,
        href: productHref(product.id),
      }
    })
    .sort((a, b) => b.sold - a.sold || a.name.localeCompare(b.name))
}

/** A department as a storefront shows it: its products, and its aisles with theirs. */
export type ShelfDepartment = {
  name: string
  /** Its products still sold, bestselling first. */
  products: ShelfProduct[]
  /** Its aisles in the order the shop files them, each with its products, bestselling first; an aisle with none on the shelf is left out. */
  subcategories: { name: string; products: ShelfProduct[] }[]
}

/**
 * Every department with a product on the shelf, A to Z, each with its
 * products and its aisles. The counts a band prints are the lengths of these
 * lists, so a chip, a tile and a column that count one aisle agree.
 */
export function storeDepartments(days: number = SHELF_DAYS): ShelfDepartment[] {
  const shelf = storeShelf(days)
  const names = [...new Set(shelf.map((product) => product.category))].sort((a, b) => a.localeCompare(b))
  return names.map((name) => {
    const products = shelf.filter((product) => product.category === name)
    const order = SUBCATEGORIES[name]?.map((aisle) => aisle.name) ?? [name]
    const subcategories = order
      .map((aisle) => ({ name: aisle, products: products.filter((product) => product.subcategory === aisle) }))
      .filter((aisle) => aisle.products.length > 0)
    return { name, products, subcategories }
  })
}
lib/dashboards/ecommerce/storefront-ratings.ts
/**
 * What buyers gave the products they received, read off the reviews they
 * wrote (`db.reviews`). Every review is one buyer's stars for one order line
 * that reached them — delivered, or delivered and sent back — so each rating
 * belongs to one real purchase, a product people send back scores lower, and
 * a score on a product card counts exactly the reviews a reviews band lists.
 *
 * Swap `db.reviews` for your reviews table and every band that shows a score
 * follows. Server-only, because it reads `db`.
 */
import { db } from "@/lib/sample-data"

/** One buyer's rating of one product they received. */
export type StoreRating = {
  productId: string
  orderId: string
  /** Who placed the order: a customer id. */
  customerId: string
  /** One to five. */
  stars: number
  /** When the order reached them. */
  receivedAt: Date
  /** The review the stars were given in. */
  reviewId: string
}

/** Every rating buyers gave, one per review, in the order the book delivered them. */
export function storeRatings(): StoreRating[] {
  const delivered = new Map(db.orders.all().map((order) => [order.id, order.fulfilledAt ?? order.placedAt]))
  return db.reviews.all().map((review) => ({
    productId: review.productId,
    orderId: review.orderId,
    customerId: review.customerId,
    stars: review.rating,
    receivedAt: delivered.get(review.orderId) ?? review.at,
    reviewId: review.id,
  }))
}

/** Every product's stars, rating by rating: a product nobody rated is absent. */
export function ratingsOf(): Map<string, number[]> {
  const ratings = new Map<string, number[]>()
  for (const rating of storeRatings()) ratings.set(rating.productId, [...(ratings.get(rating.productId) ?? []), rating.stars])
  return ratings
}

/** A score as a band prints it: the mean, how many ratings it is of, and how many gave each star. */
export type RatingSummary = {
  /** The mean of the stars, unrounded; 0 with no ratings. */
  average: number
  count: number
  /** How many gave five stars, four, three, two and one, in that order. */
  byStars: [number, number, number, number, number]
}

/** The summary of a run of stars. */
export function summarize(stars: readonly number[]): RatingSummary {
  const byStars: RatingSummary["byStars"] = [0, 0, 0, 0, 0]
  for (const star of stars) byStars[5 - star] += 1
  const total = stars.reduce((sum, star) => sum + star, 0)
  return { average: stars.length ? total / stars.length : 0, count: stars.length, byStars }
}
lib/dashboards/ecommerce/checkout.ts
/**
 * The shop's checkout, one copy for every band that sells: what delivery
 * costs and when it is free, the codes the shop takes, the sales tax, the
 * arithmetic that turns a cart into a total, and the rules a delivery address
 * and a card are checked by. Plain constants and pure functions — no store,
 * no React — so a cart island totals as the shopper changes it, and a server
 * file totals the same lines the same way.
 *
 * Nothing here charges anyone. The card rules only say whether what was typed
 * could be a card; wire your payment provider where a band confirms.
 */
import { formatCurrency } from "@/lib/format"

/** How an order reaches the shopper. */
export type DeliveryMethod = "standard" | "express" | "pickup"

/** What each way costs, in cents, and how many working days it takes, at the soonest and the latest. */
export const DELIVERY: Record<DeliveryMethod, { label: string; cents: number; days: readonly [number, number] }> = {
  standard: { label: "Standard delivery", cents: 695, days: [3, 5] },
  express: { label: "Express delivery", cents: 1_495, days: [1, 2] },
  pickup: { label: "Collect in store", cents: 0, days: [1, 1] },
}

/** From this much in goods, after any discount, standard delivery is free. */
export const FREE_DELIVERY_FROM_CENTS = 10_000

/** How many days a buyer has to send a product back. */
export const RETURN_DAYS = 30

/** Sales tax, in percent, on the goods after any discount; delivery is not taxed. */
export const TAX_PERCENT = 8

/** A code the shop takes: a share off the goods, or an amount off an order of at least `minimumCents`. */
export type DiscountCode =
  | { code: string; kind: "percent"; percent: number; label: string }
  | { code: string; kind: "amount"; cents: number; minimumCents: number; label: string }

export const DISCOUNT_CODES: readonly DiscountCode[] = [
  { code: "WELCOME10", kind: "percent", percent: 10, label: "10% off your order" },
  { code: "NORTHWIND5", kind: "percent", percent: 5, label: "5% off your order" },
  { code: "SAVE15", kind: "amount", cents: 1_500, minimumCents: 12_000, label: "$15 off an order of $120 or more" },
]

/** "$74.99". */
export function money(cents: number): string {
  return formatCurrency(cents / 100, "USD")
}

/** "1 item", "3 items". */
export function itemCount(units: number): string {
  return units === 1 ? "1 item" : `${units} items`
}

/** What a code takes off goods worth `subtotalCents`: nothing when an amount's minimum is not met. */
export function discountOf(code: DiscountCode | null | undefined, subtotalCents: number): number {
  if (!code) return 0
  if (code.kind === "percent") return Math.round((subtotalCents * code.percent) / 100)
  return subtotalCents >= code.minimumCents ? Math.min(code.cents, subtotalCents) : 0
}

/** What a code is worth to this cart, or why it is not. */
export type CodeCheck = { ok: true; code: DiscountCode } | { ok: false; message: string }

/**
 * Looks a typed code up — any case, any spaces around it — and checks an
 * amount off against the goods in the cart, saying how much more it needs.
 */
export function checkCode(input: string, subtotalCents: number): CodeCheck {
  const typed = input.trim().toUpperCase()
  if (!typed) return { ok: false, message: "Type a code first." }
  const code = DISCOUNT_CODES.find((entry) => entry.code === typed)
  if (!code) return { ok: false, message: `${typed} is not a code this shop takes.` }
  if (code.kind === "amount" && subtotalCents < code.minimumCents)
    return {
      ok: false,
      message: `${code.code} is for orders of ${money(code.minimumCents)} or more. Add ${money(code.minimumCents - subtotalCents)} to use it.`,
    }
  return { ok: true, code }
}

/** A line of a cart as the arithmetic needs it. */
export type PricedLine = { unitCents: number; qty: number }

export type CartTotals = {
  units: number
  subtotalCents: number
  discountCents: number
  /** The goods after the discount: what free delivery and the tax are measured on. */
  goodsCents: number
  deliveryCents: number
  taxCents: number
  totalCents: number
  /** Standard delivery is free for these goods. */
  freeDelivery: boolean
  /** How much more in goods makes standard delivery free; 0 once it is. */
  toFreeDeliveryCents: number
}

/**
 * A cart's totals, in cents, each rounded once and in this order: the
 * subtotal, the discount off it, delivery — free by standard from
 * `FREE_DELIVERY_FROM_CENTS` of goods, never charged on an empty cart — the
 * tax on the goods, and what the shopper pays.
 */
export function cartTotals(
  lines: readonly PricedLine[],
  { code, method = "standard" }: { code?: DiscountCode | null; method?: DeliveryMethod } = {}
): CartTotals {
  const units = lines.reduce((sum, line) => sum + line.qty, 0)
  const subtotalCents = lines.reduce((sum, line) => sum + line.qty * line.unitCents, 0)
  const discountCents = discountOf(code, subtotalCents)
  const goodsCents = subtotalCents - discountCents
  const freeDelivery = goodsCents >= FREE_DELIVERY_FROM_CENTS
  const deliveryCents = units === 0 || method === "pickup" || (method === "standard" && freeDelivery) ? 0 : DELIVERY[method].cents
  const taxCents = Math.round((goodsCents * TAX_PERCENT) / 100)
  return {
    units,
    subtotalCents,
    discountCents,
    goodsCents,
    deliveryCents,
    taxCents,
    totalCents: goodsCents + deliveryCents + taxCents,
    freeDelivery,
    toFreeDeliveryCents: Math.max(0, FREE_DELIVERY_FROM_CENTS - goodsCents),
  }
}

const DAY_MS = 86_400_000

/** `days` working days after `from`, skipping Saturdays and Sundays, at midnight UTC. */
export function addWorkingDays(from: Date, days: number): Date {
  let at = Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate())
  for (let left = days; left > 0; ) {
    at += DAY_MS
    const weekday = new Date(at).getUTCDay()
    if (weekday !== 0 && weekday !== 6) left -= 1
  }
  return new Date(at)
}

/** The first and last day an order placed on `placed` can arrive by `method`. */
export function arrivalWindow(method: DeliveryMethod, placed: Date): [Date, Date] {
  const [soonest, latest] = DELIVERY[method].days
  return [addWorkingDays(placed, soonest), addWorkingDays(placed, latest)]
}

/** The month a band calls "now", so a card that ran out before it is refused. */
export type YearMonth = { year: number; month: number }

/** The digits a card number is typed with, spaces and dashes dropped. */
export const cardDigits = (value: string) => value.replace(/[\s-]/g, "")

/** Groups of four as the shopper types: "4242 4242 4242 4242". */
export function formatCardNumber(value: string): string {
  return cardDigits(value).replace(/\D/g, "").slice(0, 19).replace(/(\d{4})(?=\d)/g, "$1 ")
}

/** The Luhn check every card number carries in its last digit. */
export function passesLuhn(digits: string): boolean {
  let sum = 0
  for (let index = 0; index < digits.length; index++) {
    let digit = Number(digits[digits.length - 1 - index])
    if (index % 2 === 1) digit = digit * 2 > 9 ? digit * 2 - 9 : digit * 2
    sum += digit
  }
  return sum % 10 === 0
}

/** Why a typed card number cannot be a card, or null when it could be. */
export function cardNumberError(value: string): string | null {
  const digits = cardDigits(value)
  if (!digits) return "Enter the card number."
  if (!/^\d{13,19}$/.test(digits)) return "A card number is 13 to 19 digits."
  if (!passesLuhn(digits)) return "That card number does not add up. Check it against the card."
  return null
}

/** Why a typed expiry is refused — unreadable, or a month already gone — or null. */
export function expiryError(value: string, now: YearMonth): string | null {
  const typed = value.trim()
  if (!typed) return "Enter the expiry date."
  const match = typed.match(/^(\d{1,2})\s*\/\s*(\d{2})$/)
  const month = match ? Number(match[1]) : 0
  if (!match || month < 1 || month > 12) return "Write the expiry as MM/YY, as it is on the card."
  const year = 2000 + Number(match[2])
  if (year < now.year || (year === now.year && month < now.month)) return "That card has expired. Use another one."
  return null
}

/** Why a security code is refused, or null. */
export function cvcError(value: string): string | null {
  const typed = value.trim()
  if (!typed) return "Enter the security code."
  return /^\d{3,4}$/.test(typed) ? null : "The security code is the 3 or 4 digits on the back of the card."
}

/** Why a postcode is refused: letters, digits, spaces and a dash, 3 to 10 of them. */
export function postcodeError(value: string): string | null {
  const typed = value.trim()
  if (!typed) return "Enter the postcode."
  return /^[A-Za-z0-9][A-Za-z0-9 -]{1,8}[A-Za-z0-9]$/.test(typed) ? null : "That does not look like a postcode."
}