Skip to contentVibraUI
Part of the E-commerce dashboardinstalls at /ecommerce/pos

Point of sale

A till for the shop: the catalogue as drawn tiles with what the shelf holds, a ticket with a customer, named steppers, a discount code, 8% tax and cash, card or voucher, and a receipt that prints alone.

Open the live page

Installs at /ecommerce/pos. The page is a server component: shelf() reads every active db.products row at its list price with the stock it holds, departments() the chips, regulars() the twelve db.customers who have ordered most often (read off db.orders), and every product is drawn here with ProductArt and handed to the island as a node — a drawing imported into the browser would bring all of them. The island holds the sale: the ticket reserves what it holds against the shelf, so a tile says what is left beside it, wears the chosen fill and a count once it is on the ticket, and turns disabled and says Out of stock when nothing is. The search takes a scanned SKU — Enter rings up the product it names, or the one product a search leaves — and `/` reaches it from anywhere outside a field. till.ts holds the arithmetic both sides use: the subtotal, the code's discount off it, 8% tax on what is left and the total, each rounded once in cents. applyCode refuses a code the shop does not take (WELCOME10 is 10%, NORTHWIND5 5%) under the code field; completeSale re-reads every price and stock count from the store and refuses an empty ticket, a line the shelf cannot cover, an unknown code and cash short of the total — each said where the reader is looking — and otherwise takes the goods off the shelf, writes the sale into db.orders as channel "pos" (paid by cash, card or voucher, with its discount and tax) and hands back the receipt: store, till and cashier, the order number as the ticket number, the time in UTC, the lines, the totals, the tender and the change. Print prints the receipt alone, through a print sheet mounted with it; New sale puts the cashier back in the search. From 1024px the ticket stands beside the catalogue and stays in view; below it the ticket is a bottom sheet opened from a bar that says how many items and what they come to. Composes AppShell, PageHeader, ProductArt, Card, SearchInput, ToggleGroup, EmptyState, Select, Avatar, Button, AsyncButton, Input, CurrencyInput, SegmentedControl, Callout, Sheet and Dialog.

Preview

Install

npx shadcn@latest add @vibra/ecommerce-pos

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

Source

app/ecommerce/pos/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { ProductArt } from "@/components/ui/product-art"

import { signOut } from "./actions"
import { PosView } from "./components/pos-view"
import { currentUser, departments, regulars, shelf, shellNotifications, tillLine } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"

/**
 * The till. The page is a server component: it reads the shelf, the
 * departments and the regulars through `db`, draws every product here — a
 * drawing imported into the browser would carry all seventy of them — and
 * hands one client island the rows. The island rings the sale up and charges
 * it through `completeSale`, which takes the goods off the shelf and writes
 * the order.
 */
export default function PointOfSalePage() {
  const products = shelf().map((product) => ({
    ...product,
    art: <ProductArt name={product.name} category={product.category} className="aspect-[4/3]" />,
  }))

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.pos}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Point of sale"
        description="Ring up a sale at the counter, take cash, a card or a voucher, and print the receipt."
        meta={tillLine()}
      />

      <PosView products={products} departments={departments()} customers={regulars()} />
    </AppShell>
  )
}
app/ecommerce/pos/data.ts
/**
 * What the till reads. The shelf is `db.products` — every active product at
 * its list price with the stock it holds — and the regulars are the
 * `db.customers` who have bought most often, read off `db.orders`. The page
 * renders each product's drawing on the server and hands the island plain
 * rows; nothing here is written out as a literal. "Now" is REFERENCE_DATE.
 */
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Member, type Product } from "@/lib/sample-data"

import type { TillCustomer } from "./till"

/** The shop the till stands in, and which till this is. */
export const STORE = { name: "Northwind Store", till: "Till 1" } as const

/** A product as the till lists it, before the page draws it. */
export type ShelfProduct = Pick<Product, "id" | "name" | "sku" | "category" | "priceCents" | "stock" | "reorderPoint">

/** Every product the shop still sells, department by department, A to Z inside each. */
export function shelf(): ShelfProduct[] {
  return db.products
    .all()
    .filter((product) => product.status === "active")
    .sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name))
    .map(({ id, name, sku, category, priceCents, stock, reorderPoint }) => ({ id, name, sku, category, priceCents, stock, reorderPoint }))
}

/** The departments on the shelf, in the order the tiles run, each with how many products it holds. */
export function departments(): { value: string; label: string; count: number }[] {
  const counts = new Map<string, number>()
  for (const product of shelf()) counts.set(product.category, (counts.get(product.category) ?? 0) + 1)
  return [...counts].map(([value, count]) => ({ value, label: value, count }))
}

const REGULARS = 12

/**
 * The customers the till offers by name: the twelve who have ordered most
 * often, most orders first, a name breaking a tie. Anyone else is a walk-in.
 */
export function regulars(): TillCustomer[] {
  const orders = new Map<string, number>()
  for (const order of db.orders.all()) if (order.customerId) orders.set(order.customerId, (orders.get(order.customerId) ?? 0) + 1)
  return db.customers
    .all()
    .filter((customer) => orders.has(customer.id))
    .sort((a, b) => orders.get(b.id)! - orders.get(a.id)! || a.name.localeCompare(b.name))
    .slice(0, REGULARS)
    .map(({ id, name, company, avatarUrl }) => ({ id, name, company, avatarUrl }))
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person behind the counter: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

const DAY = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })

/** "Northwind Store · Till 1 · Sonia Keller · Fri, Sep 4, 2026": where, who and when, for the header. */
export function tillLine(): string {
  return `${STORE.name} · ${STORE.till} · ${ownerRow().name} · ${DAY.format(REFERENCE_DATE)}`
}
app/ecommerce/pos/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { REFERENCE_DATE, asString, db, fields, isRecord, isWhole, ownKey, type Order, type Result } from "@/lib/sample-data"

import { STORE, currentUser } from "./data"
import { TENDERS, money, totalsFor, type Receipt, type ReceiptLine, type Tender, type TicketLine } from "./till"

/**
 * What the till changes. Server actions, so the page stays a server component
 * and the island has something to call; `Result`, so a refusal comes back as
 * a sentence the ticket shows beside the control that asked. Every price,
 * name and stock count is read from the store here — the ticket's own copy is
 * a suggestion, and the charge is worked out again from the rows.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

/** The codes the shop takes, and what each takes off the goods, in percent. */
const CODES: Record<string, number> = { WELCOME10: 10, NORTHWIND5: 5 }

/** Where a till sale is recorded as having been made. */
const STORE_COUNTRY = "United States"

const refuse = (code: string, message: string, field?: string): Result<never> => ({
  ok: false,
  error: field ? { code, message, field } : { code, message },
})

/** Looks a discount code up; any case is fine, anything the shop does not take is refused. */
export async function applyCode(input: string): Promise<Result<{ code: string; percent: number }>> {
  const code = asString(input).trim().toUpperCase()
  if (!code) return refuse("empty", "Type a code first.", "code")
  const shown = code.slice(0, 40)
  if (!ownKey(CODES, code)) return refuse("unknown", `${shown} is not a code this store takes.`, "code")
  return { ok: true, data: { code, percent: CODES[code] } }
}

export type SaleInput = {
  /** A customer's id, or nothing for a walk-in. */
  customerId?: string
  lines: TicketLine[]
  code?: string
  tender: Tender
  /** The cash handed over, in cents; a cash sale needs it. */
  tenderedCents?: number
}

/** The most a ticket holds: lines, units of one product, and cash handed over ($100,000). */
const MAX_LINES = 100
const MAX_QTY = 10_000
const MAX_TENDERED_CENTS = 10_000_000

/** What the shelf says when it cannot cover a line. */
const short = (name: string, stock: number, qty: number) =>
  stock <= 0 ? `${name} is out of stock.` : `Only ${stock} ${name} left — the ticket asks for ${qty}.`

/**
 * Sales are rung up one at a time. The store here is an in-memory array, so
 * a chain of promises is the lock: a sale waits for the one before it to be
 * written before it reads the shelf, and two tills can never both sell the
 * last unit. A real store takes this lock in the database — a transaction,
 * or a conditional decrement (`stock = stock - qty WHERE stock >= qty`) — and
 * allocates the order number there too.
 */
let queue: Promise<unknown> = Promise.resolve()

function oneAtATime<T>(work: () => Promise<T>): Promise<T> {
  const turn = queue.then(work, work)
  queue = turn.catch(() => undefined)
  return turn
}

/** The next free order number after every one the book holds. */
function nextNumber(): string {
  const highest = db.orders.all().reduce((max, order) => Math.max(max, Number(order.number.replace(/\D/g, "")) || 0), 100_000)
  return `ORD-${highest + 1}`
}

/**
 * Rings a ticket up. Refuses an empty ticket, a product that is not on the
 * shelf, a line the shelf cannot cover, a code the shop does not take and a
 * cash tender short of the total; otherwise takes the goods off the shelf,
 * writes the sale into the order book as a "pos" order and hands back the
 * receipt, with what each sold product has left.
 */
export async function completeSale(input: SaleInput): Promise<Result<Receipt>> {
  return oneAtATime(() => ringUp(input))
}

async function ringUp(sale: SaleInput): Promise<Result<Receipt>> {
  const input = fields(sale)
  const sent = input.lines ?? []
  if (!Array.isArray(sent) || sent.length > MAX_LINES) return refuse("invalid", `A ticket holds up to ${MAX_LINES} lines.`)
  // One line per product, whatever the ticket sent: two lines of the same
  // thing are one line of both — and never more of it than a shelf could hold.
  const wanted = new Map<string, number>()
  for (const line of sent) {
    if (!isRecord(line) || typeof line.productId !== "string") return refuse("invalid", "Every line names a product.")
    const qty = (wanted.get(line.productId) ?? 0) + (isWhole(line.qty, 1, MAX_QTY) ? line.qty : NaN)
    if (!isWhole(qty, 1, MAX_QTY)) return refuse("invalid", `A quantity is a whole number from 1 to ${MAX_QTY.toLocaleString("en-US")}.`)
    wanted.set(line.productId, qty)
  }
  if (wanted.size === 0) return refuse("empty", "The ticket is empty — add something to it first.")

  const lines: ReceiptLine[] = []
  for (const [productId, qty] of wanted) {
    const product = await db.products.get(productId)
    if (!product) return refuse("not_found", `No product with id "${productId}".`)
    if (product.status !== "active") return refuse("unavailable", `${product.name} is no longer sold.`, productId)
    if (qty > product.stock) return refuse("stock", short(product.name, product.stock, qty), productId)
    lines.push({ productId, name: product.name, qty, unitCents: product.priceCents, totalCents: qty * product.priceCents })
  }

  let code: Receipt["code"] = null
  if (input.code !== undefined && input.code !== null && input.code !== "") {
    const known = await applyCode(input.code)
    if (!known.ok) return known
    code = known.data
  }

  let customer: Receipt["customer"] = null
  if (input.customerId !== undefined && input.customerId !== null && input.customerId !== "") {
    const row = typeof input.customerId === "string" ? await db.customers.get(input.customerId) : undefined
    if (!row) return refuse("not_found", "No such customer.")
    customer = { id: row.id, name: row.name }
  }

  // Only the tenders the till offers, by value: the form's word is a suggestion.
  const tender = TENDERS.find((option) => option.value === input.tender)?.value
  if (!tender) return refuse("invalid", "Take cash, a card or a voucher.")
  const totals = totalsFor(lines, code?.percent ?? 0)
  let tenderedCents: number | null = null
  let changeCents: number | null = null
  if (tender === "cash") {
    const handed = input.tenderedCents ?? 0
    if (typeof handed !== "number" || !Number.isFinite(handed) || handed <= 0) return refuse("tendered", "Enter the cash handed over.", "tendered")
    if (handed > MAX_TENDERED_CENTS) return refuse("tendered", "A till takes up to $100,000 in cash.", "tendered")
    if (handed < totals.totalCents) {
      return refuse("short", `${money(totals.totalCents - handed)} short — the total is ${money(totals.totalCents)}.`, "tendered")
    }
    tenderedCents = Math.round(handed)
    changeCents = tenderedCents - totals.totalCents
  }

  // The shelf, read again at the moment of writing, every line before any is
  // taken: a line it can no longer cover refuses the whole sale with nothing
  // taken off.
  const counts: { productId: string; had: number; left: number }[] = []
  for (const line of lines) {
    const product = await db.products.get(line.productId)
    if (!product) return refuse("not_found", `No product with id "${line.productId}".`)
    const left = product.stock - line.qty
    if (left < 0) return refuse("stock", short(product.name, product.stock, line.qty), product.id)
    counts.push({ productId: product.id, had: product.stock, left })
  }

  const order: Omit<Order, "id"> = {
    number: nextNumber(),
    customerId: customer?.id ?? "",
    // Handed over at the counter: paid and fulfilled in the same moment.
    status: "fulfilled",
    items: lines.map(({ productId, qty, unitCents }) => ({ productId, qty, unitCents })),
    totalCents: totals.totalCents,
    discountCents: totals.discountCents,
    taxCents: totals.taxCents,
    currency: "USD",
    placedAt: REFERENCE_DATE,
    fulfilledAt: REFERENCE_DATE,
    paymentMethod: tender,
    country: STORE_COUNTRY,
    channel: "pos",
  }
  // The order first, so a book that refuses it leaves the shelf as it was;
  // then the goods come off, and a shelf that refuses puts back what it took.
  const created = await db.orders.create(order)
  if (!created.ok) return created
  const stock: Receipt["stock"] = []
  for (const count of counts) {
    const taken = await db.products.update(count.productId, { stock: count.left })
    if (!taken.ok) {
      for (const back of counts.slice(0, stock.length)) await db.products.update(back.productId, { stock: back.had })
      await db.orders.remove(created.data.id)
      return taken
    }
    stock.push({ productId: count.productId, stock: count.left })
  }

  return {
    ok: true,
    data: {
      orderId: created.data.id,
      number: created.data.number,
      store: STORE.name,
      till: STORE.till,
      cashier: currentUser().name,
      placedAt: REFERENCE_DATE,
      customer,
      lines,
      code,
      totals,
      tender,
      tenderedCents,
      changeCents,
      stock,
    },
  }
}
app/ecommerce/pos/till.ts
/**
 * The till's arithmetic and its words, one copy for both sides of the
 * counter: the ticket in the browser totals what the cashier is ringing up,
 * and completeSale on the server totals it again from the store's own prices
 * before it charges. Plain functions and types — no store, no React at
 * runtime — so the island and the action both import it.
 */
import type * as React from "react"

import { formatCurrency } from "@/lib/format"

/** Sales tax, in percent, on the goods after any discount. */
export const TAX_PERCENT = 8

export type Tender = "cash" | "card" | "voucher"

export const TENDERS: { value: Tender; label: string }[] = [
  { value: "cash", label: "Cash" },
  { value: "card", label: "Card" },
  { value: "voucher", label: "Voucher" },
]

export const TENDER_LABELS: Record<Tender, string> = { cash: "Cash", card: "Card", voucher: "Voucher" }

/** A product on the shelf, as the catalogue hands it over — its drawing rendered on the server. */
export type TillProduct = {
  id: string
  name: string
  sku: string
  category: string
  priceCents: number
  stock: number
  reorderPoint: number
  art: React.ReactNode
}

/** Someone the till can ring a sale up to; everyone else is a walk-in. */
export type TillCustomer = { id: string; name: string; company: string; avatarUrl?: string }

/** One line of the ticket as the island holds it: which product, how many. */
export type TicketLine = { productId: string; qty: number }

export type Totals = {
  units: number
  subtotalCents: number
  discountCents: number
  taxCents: number
  totalCents: number
}

/**
 * Subtotal, the discount taken off it, the tax on what is left, and what the
 * customer pays — in cents, each rounded once, in that order.
 */
export function totalsFor(lines: readonly { qty: number; unitCents: number }[], percentOff = 0): Totals {
  const units = lines.reduce((sum, line) => sum + line.qty, 0)
  const subtotalCents = lines.reduce((sum, line) => sum + line.qty * line.unitCents, 0)
  const discountCents = Math.round((subtotalCents * percentOff) / 100)
  const taxCents = Math.round(((subtotalCents - discountCents) * TAX_PERCENT) / 100)
  return { units, subtotalCents, discountCents, taxCents, totalCents: subtotalCents - discountCents + taxCents }
}

/** "$83.71". */
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`
}

/**
 * The amounts a cashier is most likely handed for a total: the exact sum,
 * then the next round figures above it — no more than three.
 */
export function quickCash(totalCents: number): number[] {
  if (totalCents <= 0) return []
  const out = [totalCents]
  for (const step of [500, 1_000, 2_000, 5_000, 10_000]) {
    const up = Math.ceil(totalCents / step) * step
    if (!out.includes(up)) out.push(up)
    if (out.length === 3) break
  }
  return out
}

/** One printed line of a receipt. */
export type ReceiptLine = { productId: string; name: string; qty: number; unitCents: number; totalCents: number }

/** What completeSale hands back: the receipt, and what the shelf holds after it. */
export type Receipt = {
  orderId: string
  /** The order number, which is the ticket number. */
  number: string
  store: string
  till: string
  cashier: string
  placedAt: Date
  customer: { id: string; name: string } | null
  lines: ReceiptLine[]
  code: { code: string; percent: number } | null
  totals: Totals
  tender: Tender
  tenderedCents: number | null
  changeCents: number | null
  /** Each sold product's stock after the sale, so the tiles can say so. */
  stock: { productId: string; stock: number }[]
}
app/ecommerce/pos/components/catalogue.tsx
"use client"

import * as React from "react"
import { SearchXIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { SearchInput } from "@/components/ui/search-input"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

import { ProductTile } from "./product-tile"
import { useTill } from "./till-context"

/** A department chip: the category, and how many products it holds. */
export type Department = { value: string; label: string; count: number }

const ALL = "all"

/**
 * The shelf as tiles: a search that also takes a scanned SKU — Enter rings up
 * the product it names, or the one product a search leaves — and a chip per
 * department, the chosen one filled. `/` reaches the search from anywhere on
 * the page that is not already a field.
 */
export function Catalogue({ departments }: { departments: Department[] }) {
  const till = useTill()
  const titleId = React.useId()
  const [query, setQuery] = React.useState("")
  const [department, setDepartment] = React.useState(ALL)

  const needle = query.trim().toLowerCase()
  const visible = till.products.filter(
    (product) =>
      (department === ALL || product.category === department) &&
      (!needle || product.name.toLowerCase().includes(needle) || product.sku.toLowerCase().includes(needle))
  )
  const total = till.products.length

  function scan(event: React.KeyboardEvent<HTMLInputElement>) {
    if (event.key !== "Enter" || !needle) return
    const pick = till.products.find((product) => product.sku.toLowerCase() === needle) ?? (visible.length === 1 ? visible[0] : undefined)
    if (!pick) return
    event.preventDefault()
    if (till.shelf(pick.id) - till.inTicket(pick.id) <= 0) return
    till.add(pick.id)
    setQuery("")
  }

  return (
    <Card role="region" aria-labelledby={titleId}>
      <CardHeader>
        <CardTitle>
          <h2 id={titleId}>Catalogue</h2>
        </CardTitle>
        <CardDescription className="tabular-nums">
          {visible.length === total ? `${total} products` : `${visible.length} of ${total} products`}
        </CardDescription>
      </CardHeader>
      <CardContent className="@container flex flex-col gap-4">
        <div className="flex flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:justify-between">
          <ToggleGroup
            aria-label="Department"
            value={[department]}
            onValueChange={(next) => {
              // Pressing the chosen chip again hands back nothing; the shelf
              // always shows some department, so that press is ignored.
              const [chosen] = next
              if (chosen) setDepartment(chosen)
            }}
            spacing={1}
            className="flex-wrap"
          >
            {[{ value: ALL, label: "All", count: total }, ...departments].map((chip) => (
              <ToggleGroupItem
                key={chip.value}
                value={chip.value}
                size="sm"
                className="rounded-full border border-border px-3 font-normal text-muted-foreground aria-pressed:border-transparent aria-pressed:bg-brand-muted aria-pressed:font-medium aria-pressed:text-foreground"
              >
                {chip.label}
                <span className="tabular-nums text-muted-foreground">{chip.count}</span>
              </ToggleGroupItem>
            ))}
          </ToggleGroup>
          <SearchInput
            id={till.searchId}
            aria-label="Search the catalogue"
            placeholder="Search or scan a SKU"
            shortcut="/"
            value={query}
            onValueChange={setQuery}
            onKeyDown={scan}
            className="@2xl:w-64 @2xl:shrink-0"
          />
        </div>

        {visible.length > 0 ? (
          <ul aria-label="Products" className="grid grid-cols-2 gap-3 @sm:grid-cols-3 @2xl:grid-cols-4 @5xl:grid-cols-5">
            {visible.map((product) => (
              <ProductTile key={product.id} product={product} />
            ))}
          </ul>
        ) : (
          <EmptyState
            size="sm"
            icon={<SearchXIcon />}
            title={`Nothing on the shelf matches “${query.trim()}”`}
            description="Try part of the name, or scan the SKU again."
            action={
              <Button variant="outline" size="sm" onClick={() => setQuery("")}>
                Clear search
              </Button>
            }
          />
        )}
      </CardContent>
    </Card>
  )
}
app/ecommerce/pos/components/pos-view.tsx
"use client"

import * as React from "react"

import { useMediaQuery } from "@/hooks/use-media-query"

import type { TillCustomer, TillProduct } from "../till"
import { Catalogue, type Department } from "./catalogue"
import { ReceiptDialog } from "./receipt-dialog"
import { TicketBar, TicketCard } from "./ticket"
import { TillProvider, useTill } from "./till-context"

const noop = () => () => {}

/**
 * Which counter the page is laid out as. Unknown until the browser has said —
 * the server cannot know the width — and in that first frame both layouts are
 * in the markup, each shown by its own breakpoint, so neither a laptop nor a
 * phone sees the other's layout flash past. From then on only one is mounted.
 */
function useLayout(): "unknown" | "wide" | "narrow" {
  const mounted = React.useSyncExternalStore(noop, () => true, () => false)
  const wide = useMediaQuery("(min-width: 64rem)")
  return !mounted ? "unknown" : wide ? "wide" : "narrow"
}

/**
 * The till's status line on a phone. The ticket's own line lives in its
 * sheet, and the sheet closes when a sale goes through, so the page keeps
 * one that is always there to say the sale was recorded.
 */
function TillStatus() {
  const till = useTill()
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {till.notice}
    </p>
  )
}

export type PosViewProps = {
  products: TillProduct[]
  departments: Department[]
  customers: TillCustomer[]
}

/**
 * The counter: the catalogue on the left and the ticket beside it from
 * 1024px, where the ticket stays in view while the catalogue scrolls. Below
 * that the ticket is a sheet, opened from a bar that follows the reader down
 * the catalogue and says what is on it. The receipt opens over either.
 */
export function PosView({ products, departments, customers }: PosViewProps) {
  const layout = useLayout()

  return (
    <TillProvider products={products} customers={customers}>
      <div
        data-slot="pos"
        className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_24rem]"
      >
        <Catalogue departments={departments} />
        {layout !== "narrow" ? <TicketCard className="hidden lg:sticky lg:top-20 lg:flex" /> : null}
        {layout !== "wide" ? <TicketBar className="lg:hidden" /> : null}
      </div>
      {layout === "narrow" ? <TillStatus /> : null}
      <ReceiptDialog />
    </TillProvider>
  )
}
app/ecommerce/pos/components/product-tile.tsx
"use client"

import { cn } from "@/lib/utils"

import { money, type TillProduct } from "../till"
import { useTill } from "./till-context"

/**
 * One product on the counter, as a button that rings one more of it up. It
 * says what the shelf holds beside the ticket — the ticket reserves what it
 * holds, so a tile never offers the last one twice — and a product the ticket
 * already holds wears the chosen fill and a count. A product with nothing
 * left says so, and a press on it does nothing.
 */
export function ProductTile({ product }: { product: TillProduct }) {
  const till = useTill()
  const shelf = till.shelf(product.id)
  const inTicket = till.inTicket(product.id)
  const left = shelf - inTicket
  const soldOut = shelf <= 0
  const stockText = soldOut ? "Out of stock" : left <= 0 ? "All in the ticket" : `${left} left`
  const low = !soldOut && left > 0 && left <= product.reorderPoint
  const label = [product.name, money(product.priceCents), stockText, inTicket > 0 ? `${inTicket} in the ticket` : null]
    .filter(Boolean)
    .join(", ")

  return (
    <li className="flex">
      <button
        type="button"
        data-slot="pos-tile"
        data-in-ticket={inTicket > 0 || undefined}
        aria-label={label}
        // Not `disabled`: a tile whose last unit goes onto the ticket has
        // focus at that moment, and a disabled button would drop it to the
        // page. It stays focusable and says why a press does nothing.
        aria-disabled={left <= 0 || undefined}
        onClick={() => {
          if (left > 0) till.add(product.id)
        }}
        className={cn(
          "group/tile flex w-full flex-col gap-2 rounded-lg bg-card p-2 text-left ring-1 ring-border transition-colors duration-(--duration-fast) ease-(--ease-standard) focus-ring",
          "hover:bg-accent/60 data-in-ticket:bg-brand-muted data-in-ticket:hover:bg-brand-muted",
          "aria-disabled:cursor-not-allowed aria-disabled:hover:bg-card data-in-ticket:aria-disabled:hover:bg-brand-muted [&[aria-disabled=true]_[data-slot=product-art]]:opacity-40"
        )}
      >
        <span className="relative block">
          {product.art}
          {inTicket > 0 ? (
            <span
              aria-hidden="true"
              className="absolute top-1.5 right-1.5 grid h-5 min-w-5 place-items-center rounded-full bg-foreground px-1.5 text-2xs font-medium text-background tabular-nums"
            >
              {inTicket}
            </span>
          ) : null}
        </span>
        <span className="line-clamp-2 min-h-10 text-sm font-medium text-foreground">{product.name}</span>
        <span className="mt-auto flex flex-wrap items-baseline justify-between gap-x-2">
          <span className="text-sm font-semibold tabular-nums">{money(product.priceCents)}</span>
          <span
            className={cn(
              "text-xs tabular-nums",
              // The warning tone reads on the plain tile only: on the chosen
              // fill it falls under the 4.5:1 floor, so a tile on the ticket
              // says its count in the muted ink.
              soldOut ? "font-medium text-danger" : low && inTicket === 0 ? "text-warning" : "text-muted-foreground"
            )}
          >
            {stockText}
          </span>
        </span>
      </button>
    </li>
  )
}
app/ecommerce/pos/components/receipt-dialog.tsx
"use client"

import * as React from "react"
import { PrinterIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"

import { TAX_PERCENT, TENDER_LABELS, money, type Receipt } from "../till"
import { useTill } from "./till-context"

// Pinned to UTC, so the paper says what the order book says wherever the till is.
const PRINTED_AT = new Intl.DateTimeFormat("en-US", {
  dateStyle: "medium",
  timeStyle: "short",
  hourCycle: "h23",
  timeZone: "UTC",
})

/**
 * The sheet a printer sees while the receipt is open: the receipt and nothing
 * else — the page behind it, the backdrop, the dialog's own title and buttons
 * all go — at a till roll's width.
 */
const PRINT_SHEET = `@media print {
  body:has([data-slot="pos-receipt"]) > *:not(:has([data-slot="pos-receipt"])),
  [data-slot="dialog-overlay"],
  [data-slot="dialog-header"],
  [data-slot="dialog-close"],
  [data-slot="dialog-content"]:has([data-slot="pos-receipt"]) [data-slot="dialog-footer"] { display: none !important; }
  [data-slot="dialog-content"]:has([data-slot="pos-receipt"]) {
    position: static !important; inset: auto !important; transform: none !important; translate: none !important;
    max-width: 80mm !important; margin: 0 auto !important; padding: 0 !important; box-shadow: none !important;
  }
}`

/** One label and its amount, a row of the receipt. */
function Row({ label, value, strong = false }: { label: React.ReactNode; value: string; strong?: boolean }) {
  return (
    <div className={strong ? "flex items-baseline justify-between gap-3 font-semibold" : "flex items-baseline justify-between gap-3"}>
      <dt className={strong ? undefined : "text-muted-foreground"}>{label}</dt>
      <dd className="tabular-nums">{value}</dd>
    </div>
  )
}

/** The receipt itself: who sold what to whom, when, for how much, and how it was paid. */
function ReceiptSlip({ receipt }: { receipt: Receipt }) {
  const { totals } = receipt
  return (
    <div data-slot="pos-receipt" className="flex flex-col gap-3 rounded-lg bg-card p-4 text-sm ring-1 ring-border">
      <div className="flex flex-col items-center gap-0.5 text-center">
        <span className="font-semibold">{receipt.store}</span>
        <span className="text-xs text-muted-foreground">{`${receipt.till} · ${receipt.cashier}`}</span>
      </div>
      <div className="flex items-baseline justify-between gap-3 border-y border-dashed border-rule py-2 text-xs">
        <span className="font-mono">{receipt.number}</span>
        <time dateTime={receipt.placedAt.toISOString()} className="text-muted-foreground tabular-nums">
          {`${PRINTED_AT.format(receipt.placedAt)} UTC`}
        </time>
      </div>
      <div className="flex items-baseline justify-between gap-3 text-xs">
        <span className="text-muted-foreground">Customer</span>
        <span className="font-medium">{receipt.customer ? receipt.customer.name : "Walk-in"}</span>
      </div>
      <ul aria-label="Items" className="flex flex-col gap-2">
        {receipt.lines.map((line) => (
          <li key={line.productId} className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3">
            <span className="flex min-w-0 gap-1.5">
              <span className="text-muted-foreground tabular-nums">{`${line.qty} ×`}</span>
              <span className="truncate">{line.name}</span>
            </span>
            <span className="text-right tabular-nums">{money(line.totalCents)}</span>
            {line.qty > 1 ? (
              <span className="col-span-2 pl-6 text-xs text-muted-foreground tabular-nums">{`${money(line.unitCents)} each`}</span>
            ) : null}
          </li>
        ))}
      </ul>
      <dl className="flex flex-col gap-1 border-t border-dashed border-rule pt-2">
        <Row label="Subtotal" value={money(totals.subtotalCents)} />
        {receipt.code ? (
          <Row
            label={
              <>
                Discount · <span className="font-mono">{receipt.code.code}</span>
              </>
            }
            value={`−${money(totals.discountCents)}`}
          />
        ) : null}
        <Row label={`Tax · ${TAX_PERCENT}%`} value={money(totals.taxCents)} />
        <Row label="Total" value={money(totals.totalCents)} strong />
      </dl>
      <dl className="flex flex-col gap-1 border-t border-dashed border-rule pt-2">
        {receipt.tender === "cash" && receipt.tenderedCents !== null ? (
          <>
            <Row label="Cash tendered" value={money(receipt.tenderedCents)} />
            <Row label="Change" value={money(receipt.changeCents ?? 0)} strong />
          </>
        ) : (
          <Row label={TENDER_LABELS[receipt.tender]} value={money(totals.totalCents)} />
        )}
      </dl>
    </div>
  )
}

/**
 * The receipt, over the counter, once a sale goes through. Print prints the
 * receipt alone; New sale — or closing it any other way — puts the cashier
 * back in the search, ready for the next customer.
 */
export function ReceiptDialog() {
  const till = useTill()
  // The dialog outlives its receipt by one animation, so the slip stays drawn while it fades.
  const [last, setLast] = React.useState(till.receipt)
  if (till.receipt && till.receipt !== last) setLast(till.receipt)
  const receipt = till.receipt ?? last

  return (
    <Dialog
      open={till.receipt !== null}
      onOpenChange={(open) => {
        if (!open) till.newSale()
      }}
    >
      <DialogContent className="sm:max-w-sm" finalFocus={() => document.getElementById(till.searchId)}>
        {receipt ? (
          <>
            <style dangerouslySetInnerHTML={{ __html: PRINT_SHEET }} />
            <DialogHeader>
              <DialogTitle>Receipt</DialogTitle>
              <DialogDescription>{`Sale complete — ${money(receipt.totals.totalCents)} taken.`}</DialogDescription>
            </DialogHeader>
            <ReceiptSlip receipt={receipt} />
            <DialogFooter>
              <Button variant="outline" onClick={() => window.print()}>
                <PrinterIcon data-icon="inline-start" />
                Print receipt
              </Button>
              <Button onClick={till.newSale}>New sale</Button>
            </DialogFooter>
          </>
        ) : null}
      </DialogContent>
    </Dialog>
  )
}
app/ecommerce/pos/components/ticket-lines.tsx
"use client"

import * as React from "react"
import { MinusIcon, PlusIcon, UserRoundIcon, XIcon } from "lucide-react"

import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

import { money, type TillCustomer } from "../till"
import { WALK_IN, useTill } from "./till-context"

/** A customer's face beside their name; a walk-in wears a plain disc. */
function Face({ customer }: { customer: TillCustomer | undefined }) {
  if (!customer) {
    return (
      <span aria-hidden="true" className="grid size-6 shrink-0 place-items-center rounded-full bg-secondary text-muted-foreground">
        <UserRoundIcon className="size-3.5" />
      </span>
    )
  }
  return (
    <span aria-hidden="true" className="shrink-0">
      <Avatar size="sm">
        {customer.avatarUrl ? <AvatarImage src={customer.avatarUrl} alt="" /> : null}
        <AvatarFallback className="text-2xs">{getInitials(customer.name)}</AvatarFallback>
      </Avatar>
    </span>
  )
}

/** Who the sale is for: a walk-in, or one of the regulars by name. */
export function CustomerField() {
  const till = useTill()
  const labelId = React.useId()
  const byId = new Map(till.customers.map((customer) => [customer.id, customer]))
  const items = [{ value: WALK_IN, label: "Walk-in" }, ...till.customers.map((customer) => ({ value: customer.id, label: customer.name }))]

  return (
    <div className="flex flex-col gap-1.5">
      <Label id={labelId} className="text-xs text-muted-foreground">
        Customer
      </Label>
      <Select value={till.customerId} onValueChange={(next) => till.setCustomerId((next as string | null) ?? WALK_IN)} items={items}>
        <SelectTrigger aria-labelledby={labelId} className="h-9 w-full">
          <SelectValue>
            {(value: string) => {
              const customer = byId.get(value)
              return (
                <span className="flex min-w-0 items-center gap-2">
                  <Face customer={customer} />
                  <span className="truncate">{customer ? customer.name : "Walk-in"}</span>
                  {customer ? <span className="truncate text-muted-foreground">{customer.company}</span> : null}
                </span>
              )
            }}
          </SelectValue>
        </SelectTrigger>
        <SelectContent>
          <SelectItem value={WALK_IN}>
            <Face customer={undefined} />
            Walk-in
          </SelectItem>
          <SelectGroup>
            <SelectLabel>Regulars</SelectLabel>
            {till.customers.map((customer) => (
              <SelectItem key={customer.id} value={customer.id}>
                <Face customer={customer} />
                <span className="truncate">{customer.name}</span>
                <span className="truncate text-muted-foreground">{customer.company}</span>
              </SelectItem>
            ))}
          </SelectGroup>
        </SelectContent>
      </Select>
    </div>
  )
}

/** Which of a line's buttons had focus when the line went. */
type Control = "less" | "off"

/**
 * What is on the ticket, a line a product: its drawing, the price each, a
 * stepper whose buttons say what they do to which product, the line's total
 * and a way to take the line off. The stepper stops at what the shelf holds.
 *
 * When a line goes, the button that took it goes with it, so focus moves on:
 * to the same button on the line that takes its place, or the one above it,
 * and once the ticket is empty to the search beside it — or, in the phone's
 * sheet, where the search is behind the sheet, to the note saying it is empty.
 */
export function TicketLines({ where = "card" }: { where?: "card" | "sheet" }) {
  const till = useTill()
  const byId = new Map(till.products.map((product) => [product.id, product]))
  const list = React.useRef<HTMLUListElement>(null)
  const empty = React.useRef<HTMLParagraphElement>(null)
  const gone = React.useRef<{ index: number; control: Control } | null>(null)

  React.useEffect(() => {
    const was = gone.current
    if (!was) return
    gone.current = null
    const rows = list.current?.children
    if (rows && rows.length > 0) {
      const row = rows[Math.min(was.index, rows.length - 1)]
      row.querySelector<HTMLElement>(`[data-line-control="${was.control}"]`)?.focus()
      return
    }
    if (where === "card") document.getElementById(till.searchId)?.focus()
    else empty.current?.focus()
  }, [till.lines, till.searchId, where])

  function leave(productId: string, control: Control) {
    gone.current = { index: till.lines.findIndex((line) => line.productId === productId), control }
  }

  if (till.lines.length === 0) {
    return (
      <p
        ref={empty}
        tabIndex={-1}
        data-slot="pos-ticket-empty"
        className="rounded-lg bg-muted/50 px-3 py-6 text-center text-sm text-muted-foreground outline-none"
      >
        Nothing rung up yet. Pick a product, or scan its SKU into the search.
      </p>
    )
  }

  return (
    <ul ref={list} aria-label="Lines" className="-mx-1 flex min-h-0 flex-1 flex-col overflow-y-auto px-1">
      {till.lines.map((line) => {
        const product = byId.get(line.productId)
        if (!product) return null
        const atShelf = line.qty >= till.shelf(product.id)
        return (
          <li
            key={line.productId}
            aria-label={product.name}
            className="grid grid-cols-[2.5rem_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 border-b border-border py-2.5 last:border-b-0"
          >
            <span className="row-span-2 w-10">{product.art}</span>
            <span className="truncate text-sm font-medium">{product.name}</span>
            <span className="text-right text-sm font-semibold tabular-nums">{money(line.qty * product.priceCents)}</span>
            <span className="text-xs text-muted-foreground tabular-nums">{`${money(product.priceCents)} each`}</span>
            <span className="flex items-center justify-end gap-1">
              <Button
                variant="outline"
                size="icon-xs"
                aria-label={`Remove one ${product.name}`}
                data-line-control="less"
                onClick={() => {
                  if (line.qty === 1) leave(product.id, "less")
                  till.step(product.id, -1)
                }}
              >
                <MinusIcon />
              </Button>
              <span className="min-w-6 text-center text-sm tabular-nums">{line.qty}</span>
              {/* Focusable when it stops: the press that reaches the shelf's
                  last unit leaves focus here rather than on the page. */}
              <Button
                variant="outline"
                size="icon-xs"
                aria-label={`Add one ${product.name}`}
                disabled={atShelf}
                focusableWhenDisabled
                title={atShelf ? "No more on the shelf" : undefined}
                onClick={() => till.step(product.id, 1)}
              >
                <PlusIcon />
              </Button>
              <Button
                variant="ghost"
                size="icon-xs"
                aria-label={`Take ${product.name} off the ticket`}
                data-line-control="off"
                className="ml-1 text-muted-foreground"
                onClick={() => {
                  leave(product.id, "off")
                  till.remove(product.id)
                }}
              >
                <XIcon />
              </Button>
            </span>
          </li>
        )
      })}
    </ul>
  )
}
app/ecommerce/pos/components/ticket-payment.tsx
"use client"

import * as React from "react"
import { XIcon } from "lucide-react"

import { formatCurrency } from "@/lib/format"
import { AsyncButton } from "@/components/ui/async-button"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { CurrencyInput } from "@/components/ui/currency-input"
import { Input } from "@/components/ui/input"
import { SegmentedControl } from "@/components/ui/segmented-control"

import { TAX_PERCENT, TENDERS, money, quickCash, type Tender } from "../till"
import { useTill } from "./till-context"

/** "$90" for a round amount, "$83.71" for any other. */
const note = (cents: number) =>
  cents % 100 === 0 ? formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 }) : money(cents)

/** The code field, or the code in force with a way to take it off; a refusal lands under it. */
function DiscountField() {
  const till = useTill()
  const alertId = React.useId()
  const [draft, setDraft] = React.useState("")
  const [pending, setPending] = React.useState(false)
  // The form and the applied code take turns in one place, so the control
  // that had focus goes away; focus follows to the one that replaces it.
  const field = React.useRef<HTMLInputElement>(null)
  const remove = React.useRef<HTMLButtonElement>(null)
  const follow = React.useRef(false)
  React.useEffect(() => {
    if (!follow.current) return
    follow.current = false
    ;(till.code ? remove.current : field.current)?.focus()
  }, [till.code])

  async function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setPending(true)
    const applied = await till.apply(draft)
    setPending(false)
    if (!applied) return
    setDraft("")
    follow.current = true
  }

  return (
    <div role="group" aria-label="Discount" className="flex flex-col gap-2">
      {till.code ? (
        <div className="flex items-center justify-between gap-2 rounded-lg bg-muted/50 py-1.5 pr-1.5 pl-3 text-sm">
          <span>
            <span className="font-mono font-medium">{till.code.code}</span>
            <span className="text-muted-foreground">{` · ${till.code.percent}% off the goods`}</span>
          </span>
          <Button
            ref={remove}
            variant="ghost"
            size="icon-xs"
            aria-label={`Remove the code ${till.code.code}`}
            onClick={() => {
              follow.current = true
              till.dropCode()
            }}
          >
            <XIcon />
          </Button>
        </div>
      ) : (
        <form className="flex gap-2" onSubmit={submit}>
          <Input
            ref={field}
            aria-label="Discount code"
            placeholder="Discount code"
            autoComplete="off"
            spellCheck={false}
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            aria-invalid={till.refusals.code ? true : undefined}
            aria-describedby={till.refusals.code ? alertId : undefined}
            className="font-mono uppercase placeholder:font-sans placeholder:normal-case"
          />
          <Button type="submit" variant="outline" disabled={pending}>
            Apply
          </Button>
        </form>
      )}
      {till.refusals.code ? (
        <Callout id={alertId} variant="danger" role="alert">
          {till.refusals.code}
        </Callout>
      ) : null}
    </div>
  )
}

/** What was handed over, the likely notes as one-tap amounts, and the change it leaves. */
function CashField() {
  const till = useTill()
  const alertId = React.useId()
  const total = till.totals.totalCents
  const handed = till.tenderedCents
  const change = handed !== null && total > 0 && handed >= total ? handed - total : null

  return (
    <div role="group" aria-label="Cash" className="flex flex-col gap-2">
      <CurrencyInput
        aria-label="Cash tendered"
        placeholder="Cash tendered"
        hideControls
        value={handed === null ? null : handed / 100}
        onValueChange={(value) => till.setTenderedCents(value === null ? null : Math.round(value * 100))}
        aria-invalid={till.refusals.tendered ? true : undefined}
        aria-describedby={till.refusals.tendered ? alertId : undefined}
      />
      {total > 0 ? (
        <div className="flex flex-wrap gap-1.5">
          {quickCash(total).map((amount) => (
            <Button key={amount} variant="outline" size="xs" className="tabular-nums" onClick={() => till.setTenderedCents(amount)}>
              {amount === total ? `Exact ${money(amount)}` : note(amount)}
            </Button>
          ))}
        </div>
      ) : null}
      <div className="flex items-baseline justify-between text-sm">
        <span className="text-muted-foreground">Change due</span>
        <span className="font-semibold tabular-nums">{change === null ? "—" : money(change)}</span>
      </div>
      {till.refusals.tendered ? (
        <Callout id={alertId} variant="danger" role="alert">
          {till.refusals.tendered}
        </Callout>
      ) : null}
    </div>
  )
}

/**
 * The money half of the ticket: the code, the totals as the till works them
 * out, how it is paid and the Charge button. A refusal is said under the
 * field that caused it — the code, the cash — or above Charge; a success in
 * the status line under it.
 */
export function TicketPayment() {
  const till = useTill()
  const { subtotalCents, discountCents, taxCents, totalCents } = till.totals

  return (
    <div data-slot="pos-payment" className="flex flex-col gap-4 border-t border-rule pt-4">
      <DiscountField />

      <dl data-slot="pos-totals" className="flex flex-col gap-1.5 text-sm">
        <div className="flex items-baseline justify-between gap-3">
          <dt className="text-muted-foreground">Subtotal</dt>
          <dd className="tabular-nums">{money(subtotalCents)}</dd>
        </div>
        {till.code ? (
          <div className="flex items-baseline justify-between gap-3">
            <dt className="text-muted-foreground">
              Discount · <span className="font-mono">{till.code.code}</span>
            </dt>
            <dd className="tabular-nums">{`−${money(discountCents)}`}</dd>
          </div>
        ) : null}
        <div className="flex items-baseline justify-between gap-3">
          <dt className="text-muted-foreground">{`Tax · ${TAX_PERCENT}%`}</dt>
          <dd className="tabular-nums">{money(taxCents)}</dd>
        </div>
        <div className="mt-1 flex items-baseline justify-between gap-3 border-t border-border pt-2.5 font-semibold">
          <dt>Total</dt>
          <dd className="text-xl tabular-nums">{money(totalCents)}</dd>
        </div>
      </dl>

      <div className="flex flex-col gap-3">
        <SegmentedControl
          aria-label="Tender"
          fullWidth
          options={TENDERS}
          value={till.tender}
          onValueChange={(value) => till.setTender(value as Tender)}
        />
        {till.tender === "cash" ? <CashField /> : null}
      </div>

      <div className="flex flex-col gap-2">
        {till.refusals.sale ? (
          <Callout variant="danger" role="alert">
            {till.refusals.sale}
          </Callout>
        ) : null}
        <AsyncButton size="lg" className="w-full" onClick={till.charge}>
          {`Charge ${money(totalCents)}`}
        </AsyncButton>
        <p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
          {till.notice}
        </p>
      </div>
    </div>
  )
}
app/ecommerce/pos/components/ticket.tsx
"use client"

import * as React from "react"
import { ReceiptTextIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"

import { itemCount, money } from "../till"
import { CustomerField, TicketLines } from "./ticket-lines"
import { TicketPayment } from "./ticket-payment"
import { useTill } from "./till-context"

/** The ticket's contents, the same in the card beside the catalogue and in the phone's sheet. */
function TicketBody({ where }: { where: "card" | "sheet" }) {
  return (
    <>
      <CustomerField />
      <TicketLines where={where} />
      <TicketPayment />
    </>
  )
}

/**
 * The ticket beside the catalogue, from 1024px: a framed card that stays in
 * view while the catalogue scrolls, its lines scrolling inside it when a sale
 * runs long so the totals and Charge never leave the screen.
 */
export function TicketCard({ className }: { className?: string }) {
  const till = useTill()
  const titleId = React.useId()

  return (
    <Card
      role="region"
      aria-labelledby={titleId}
      className={cn("max-h-[calc(100svh-6rem)]", className)}
    >
      <CardHeader>
        <CardTitle>
          <h2 id={titleId}>Ticket</h2>
        </CardTitle>
        <CardDescription className="tabular-nums">{itemCount(till.totals.units)}</CardDescription>
        {till.lines.length > 0 ? (
          <CardAction>
            {/* Clear goes with the lines it clears, so focus goes on to the search. */}
            <Button
              variant="ghost"
              size="sm"
              onClick={() => {
                till.clear()
                document.getElementById(till.searchId)?.focus()
              }}
            >
              Clear
            </Button>
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent className="flex min-h-0 flex-col gap-4">
        <TicketBody where="card" />
      </CardContent>
    </Card>
  )
}

/**
 * The ticket on a phone: a bar that follows the reader down the catalogue
 * and says what is on it, opening the ticket as a sheet from the bottom.
 * Clear empties the ticket and closes the sheet, since an empty one has
 * nothing left to show, and focus goes back to the bar.
 */
export function TicketBar({ className }: { className?: string }) {
  const till = useTill()
  const bar = React.useRef<HTMLButtonElement>(null)
  const summary = `${itemCount(till.totals.units)} · ${money(till.totals.totalCents)}`

  return (
    <div data-slot="pos-ticket-bar" className={cn("sticky bottom-4 z-20", className)}>
      <Button
        ref={bar}
        size="lg"
        aria-label={`Ticket · ${summary}`}
        aria-haspopup="dialog"
        className="h-11 w-full justify-between shadow-(--shadow-float)"
        onClick={() => till.setSheetOpen(true)}
      >
        <span className="flex items-center gap-2">
          <ReceiptTextIcon data-icon="inline-start" />
          Ticket
        </span>
        <span className="tabular-nums">{summary}</span>
      </Button>
      <Sheet open={till.sheetOpen} onOpenChange={till.setSheetOpen}>
        {/* Closed, it hands focus back to the bar — even where a press never
            focused it — unless the sale's receipt has opened over the page. */}
        <SheetContent
          side="bottom"
          className="max-h-[88svh] gap-0"
          finalFocus={() => (till.receipt ? false : (bar.current ?? true))}
        >
          <SheetHeader className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 pr-14 pb-2">
            <SheetTitle className="text-lg">Ticket</SheetTitle>
            {till.lines.length > 0 ? (
              <Button
                variant="ghost"
                size="sm"
                className="row-span-2"
                onClick={() => {
                  till.clear()
                  till.setSheetOpen(false)
                }}
              >
                Clear
              </Button>
            ) : null}
            <SheetDescription className="col-start-1 tabular-nums">{itemCount(till.totals.units)}</SheetDescription>
          </SheetHeader>
          <div className="flex min-h-0 flex-col gap-4 overflow-y-auto px-4 pb-4">
            <TicketBody where="sheet" />
          </div>
        </SheetContent>
      </Sheet>
    </div>
  )
}
app/ecommerce/pos/components/till-context.tsx
"use client"

import * as React from "react"

import { applyCode, completeSale } from "../actions"
import {
  TENDER_LABELS,
  money,
  totalsFor,
  type Receipt,
  type Tender,
  type TicketLine,
  type TillCustomer,
  type TillProduct,
  type Totals,
} from "../till"

/** The customer select's value for a sale to nobody in particular. */
export const WALK_IN = "walk-in"

/** Where a refused charge is said: under the field that caused it, or above the Charge button. */
export type Refusals = { code?: string; tendered?: string; sale?: string }

export type TillValue = {
  products: TillProduct[]
  customers: TillCustomer[]
  /** The id the catalogue's search field carries, so `/`, a scan and New sale can all reach it. */
  searchId: string
  lines: TicketLine[]
  totals: Totals
  /** How many of a product the ticket holds, and how many are left on the shelf beside it. */
  inTicket: (productId: string) => number
  shelf: (productId: string) => number
  customerId: string
  setCustomerId: (id: string) => void
  code: { code: string; percent: number } | null
  tender: Tender
  setTender: (tender: Tender) => void
  tenderedCents: number | null
  setTenderedCents: (cents: number | null) => void
  refusals: Refusals
  notice: string
  receipt: Receipt | null
  sheetOpen: boolean
  setSheetOpen: (open: boolean) => void
  add: (productId: string) => void
  step: (productId: string, delta: 1 | -1) => void
  remove: (productId: string) => void
  clear: () => void
  apply: (code: string) => Promise<boolean>
  dropCode: () => void
  charge: () => Promise<void>
  newSale: () => void
}

const TillContext = React.createContext<TillValue | null>(null)

/** The till the ticket, the tiles and the receipt all read and ring up through. */
export function useTill(): TillValue {
  const value = React.useContext(TillContext)
  if (!value) throw new Error("useTill needs a <TillProvider> above it.")
  return value
}

/**
 * Holds the sale in progress: the lines, who it is for, the code, how it is
 * paid and what the shelf holds. The shelf starts at the store's counts and
 * only moves when the server says a sale went through; the ticket reserves
 * against it, so a tile never offers more than is there.
 */
export function TillProvider({
  products,
  customers,
  children,
}: {
  products: TillProduct[]
  customers: TillCustomer[]
  children: React.ReactNode
}) {
  const searchId = `${React.useId()}-search`
  const byId = React.useMemo(() => new Map(products.map((product) => [product.id, product])), [products])
  const [stock, setStock] = React.useState(() => new Map(products.map((product) => [product.id, product.stock])))
  const [lines, setLines] = React.useState<TicketLine[]>([])
  const [customerId, setCustomerId] = React.useState(WALK_IN)
  const [code, setCode] = React.useState<TillValue["code"]>(null)
  const [tender, setTender] = React.useState<Tender>("cash")
  const [tenderedCents, setTenderedCents] = React.useState<number | null>(null)
  const [refusals, setRefusals] = React.useState<Refusals>({})
  const [notice, setNotice] = React.useState("")
  const [receipt, setReceipt] = React.useState<Receipt | null>(null)
  const [sheetOpen, setSheetOpen] = React.useState(false)

  const inTicket = React.useCallback(
    (productId: string) => lines.find((line) => line.productId === productId)?.qty ?? 0,
    [lines]
  )
  const shelf = React.useCallback((productId: string) => stock.get(productId) ?? 0, [stock])

  const totals = React.useMemo(
    () =>
      totalsFor(
        lines.map((line) => ({ qty: line.qty, unitCents: byId.get(line.productId)?.priceCents ?? 0 })),
        code?.percent ?? 0
      ),
    [lines, byId, code]
  )

  const setQty = React.useCallback(
    (productId: string, qty: number) => {
      setRefusals({})
      setLines((current) => {
        const capped = Math.min(qty, stock.get(productId) ?? 0)
        if (capped <= 0) return current.filter((line) => line.productId !== productId)
        if (!current.some((line) => line.productId === productId)) return [...current, { productId, qty: capped }]
        return current.map((line) => (line.productId === productId ? { ...line, qty: capped } : line))
      })
    },
    [stock]
  )

  function reset() {
    setLines([])
    setCode(null)
    setCustomerId(WALK_IN)
    setTender("cash")
    setTenderedCents(null)
    setRefusals({})
  }

  const value: TillValue = {
    products,
    customers,
    searchId,
    lines,
    totals,
    inTicket,
    shelf,
    customerId,
    setCustomerId,
    code,
    tender,
    setTender: (next) => {
      setTender(next)
      setRefusals((current) => ({ ...current, tendered: undefined }))
    },
    tenderedCents,
    setTenderedCents: (cents) => {
      setTenderedCents(cents)
      setRefusals((current) => ({ ...current, tendered: undefined }))
    },
    refusals,
    notice,
    receipt,
    sheetOpen,
    setSheetOpen,
    add: (productId) => setQty(productId, inTicket(productId) + 1),
    step: (productId, delta) => setQty(productId, inTicket(productId) + delta),
    remove: (productId) => setQty(productId, 0),
    clear: () => {
      reset()
      setNotice("")
    },
    apply: async (entered) => {
      setRefusals((current) => ({ ...current, code: undefined }))
      const result = await applyCode(entered)
      if (!result.ok) {
        setRefusals((current) => ({ ...current, code: result.error.message }))
        return false
      }
      setCode(result.data)
      setNotice(`${result.data.code} applied — ${result.data.percent}% off the goods.`)
      return true
    },
    dropCode: () => {
      setCode(null)
      setNotice("")
    },
    charge: async () => {
      setRefusals({})
      setNotice("")
      const result = await completeSale({
        customerId: customerId === WALK_IN ? undefined : customerId,
        lines,
        code: code?.code,
        tender,
        tenderedCents: tender === "cash" ? (tenderedCents ?? undefined) : undefined,
      })
      if (!result.ok) {
        const { message, field } = result.error
        setRefusals(field === "tendered" ? { tendered: message } : field === "code" ? { code: message } : { sale: message })
        return
      }
      // The sale is in the book and the receipt holds it: the ticket is
      // spent, so it empties now rather than reserving the goods a second
      // time against the shelf the server has already taken them off.
      const sold = result.data
      reset()
      setStock((current) => {
        const next = new Map(current)
        for (const { productId, stock: left } of sold.stock) next.set(productId, left)
        return next
      })
      setSheetOpen(false)
      setReceipt(sold)
      setNotice(`Sale ${sold.number} recorded — ${money(sold.totals.totalCents)} by ${TENDER_LABELS[sold.tender].toLowerCase()}.`)
    },
    newSale: () => setReceipt(null),
  }

  return <TillContext.Provider value={value}>{children}</TillContext.Provider>
}