Checkout
Delivery, payment and the order summary, in the steps it takes to pay.
One-page checkout
checkout-01Contact, the delivery address, how it travels and the card on one page, with the order beside them; Pay checks every field and confirms the order with its number and its days.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { OnePageCheckout } from "./checkout-01.client"
import { checkoutStart } from "./checkout-01.data"
/**
* The checkout on one page: contact, the delivery address, how the parcel
* travels and the card, with the order beside them — its lines, its totals as
* the way chosen changes them, and the Pay button. On a phone the order
* follows the form, the Pay button last.
*
* A server component: the cart, the countries, now and the order's number
* come from `./checkout-01.data`; every picture is drawn here.
*/
export function Checkout01() {
const titleId = React.useId()
const { lines, countries, placedAt, month, orderNumber } = checkoutStart()
const drawn = lines.map((line) => ({ ...line, art: <ProductArt name={line.name} category={line.category} className="rounded-md" /> }))
return (
<section
data-section="checkout-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex max-w-xl flex-col gap-2">
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Checkout
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Contact, delivery and payment on one page, with your order beside them.
</p>
</div>
<OnePageCheckout lines={drawn} countries={countries} placedAt={placedAt} month={month} orderNumber={orderNumber} />
</div>
</section>
)
}
export default Checkout01"use client"
import * as React from "react"
import { CircleCheckIcon, CreditCardIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
DELIVERY,
TAX_PERCENT,
arrivalWindow,
cardNumberError,
cartTotals,
cvcError,
expiryError,
formatCardNumber,
itemCount,
money,
postcodeError,
type YearMonth,
} from "@/lib/dashboards/ecommerce/checkout"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { FieldError, FieldLegend, FieldSet } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import type { CartLine } from "./checkout-01.data"
export type CheckoutLine = CartLine & { art: React.ReactNode }
type Method = "standard" | "express"
type Name = "email" | "firstName" | "lastName" | "address" | "address2" | "city" | "postcode" | "country" | "cardNumber" | "cardName" | "expiry" | "cvc"
type Spec = { name: Name; label: string; autoComplete: string; type?: string; inputMode?: "numeric" | "email"; placeholder?: string; wide?: boolean }
const DAY = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
const LONG_DAY = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
const ADDRESS: Spec[] = [
{ name: "firstName", label: "First name", autoComplete: "given-name" },
{ name: "lastName", label: "Last name", autoComplete: "family-name" },
{ name: "address", label: "Street address", autoComplete: "address-line1", wide: true },
{ name: "address2", label: "Apartment or suite (optional)", autoComplete: "address-line2", wide: true },
{ name: "city", label: "Town or city", autoComplete: "address-level2" },
{ name: "postcode", label: "Postcode", autoComplete: "postal-code" },
]
const CARD: Spec[] = [
{ name: "cardName", label: "Name on card", autoComplete: "cc-name", wide: true },
{ name: "expiry", label: "Expiry (MM/YY)", autoComplete: "cc-exp", placeholder: "MM/YY", inputMode: "numeric" },
{ name: "cvc", label: "Security code", autoComplete: "cc-csc", inputMode: "numeric" },
]
/** The order focus looks for the first refusal in: the page's own order. */
const ORDER: Name[] = ["email", "firstName", "lastName", "address", "city", "postcode", "country", "cardNumber", "cardName", "expiry", "cvc"]
/** Every refusal the form can make, from what was typed. */
function refusals(value: (name: Name) => string, month: YearMonth): Partial<Record<Name, string>> {
const out: Partial<Record<Name, string>> = {}
const email = value("email")
if (!email) out.email = "Enter your email, so we can send the receipt."
else if (!isEmail(email)) out.email = "That email is missing something. Check the @ and what follows it."
if (!value("firstName")) out.firstName = "Enter your first name."
if (!value("lastName")) out.lastName = "Enter your last name."
if (!value("address")) out.address = "Enter the street address."
if (!value("city")) out.city = "Enter the town or city."
const postcode = postcodeError(value("postcode"))
if (postcode) out.postcode = postcode
if (!value("country")) out.country = "Choose the country it goes to."
const card = cardNumberError(value("cardNumber"))
if (card) out.cardNumber = card
if (!value("cardName")) out.cardName = "Enter the name as it is on the card."
const expiry = expiryError(value("expiry"), month)
if (expiry) out.expiry = expiry
const cvc = cvcError(value("cvc"))
if (cvc) out.cvc = cvc
return out
}
/**
* The whole checkout on one page: contact, delivery address, how it travels
* (each way with its price and the days it arrives) and the card, beside the
* order it pays for. The summary follows the way chosen. Pay checks every
* field — the card's number by its check digit, its expiry against the month
* the band calls now — refuses each in words tied to it and puts focus on the
* first; a complete order is confirmed in the status line with its number,
* its receipt and its days. It sends nothing: call your payment provider
* where the order is confirmed.
*/
export function OnePageCheckout({ lines, countries, placedAt, month, orderNumber }: { lines: CheckoutLine[]; countries: string[]; placedAt: string; month: YearMonth; orderNumber: string }) {
const base = React.useId()
const formId = `${base}form`
const idOf = (name: string) => `${base}${name}`
const [method, setMethod] = React.useState<Method>("standard")
const [card, setCard] = React.useState("")
const [errors, setErrors] = React.useState<Partial<Record<Name, string>>>({})
const [placed, setPlaced] = React.useState("")
const totals = cartTotals(lines, { method })
const placedDate = new Date(placedAt)
const arrives = (way: Method) => arrivalWindow(way, placedDate).map((day) => DAY.format(day)).join(" to ")
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const data = new FormData(event.currentTarget)
const value = (name: Name) => String(data.get(name) ?? "").trim()
const next = refusals(value, month)
setErrors(next)
const first = ORDER.find((name) => next[name])
if (first) {
setPlaced("")
document.getElementById(idOf(first))?.focus()
return
}
const [soonest, latest] = arrivalWindow(method, placedDate)
setPlaced(`Order ${orderNumber} is placed. The receipt is on its way to ${value("email")}, and the parcel arrives between ${LONG_DAY.format(soonest)} and ${LONG_DAY.format(latest)}.`)
}
/** A field's control props: its id, its name, and what it is refused for. */
const checked = (name: Name) => ({
id: idOf(name),
name,
"aria-invalid": errors[name] ? true : undefined,
"aria-describedby": errors[name] ? idOf(`${name}-error`) : undefined,
})
const refusal = (name: Name) => (errors[name] ? <FieldError id={idOf(`${name}-error`)}>{errors[name]}</FieldError> : null)
const field = (spec: Spec) => (
<div key={spec.name} className={cn("flex flex-col gap-2", spec.wide && "sm:col-span-2")}>
<Label htmlFor={idOf(spec.name)}>{spec.label}</Label>
<Input {...checked(spec.name)} type={spec.type ?? "text"} autoComplete={spec.autoComplete} inputMode={spec.inputMode} placeholder={spec.placeholder} className="h-9" />
{refusal(spec.name)}
</div>
)
return (
<div className="mt-8 grid gap-8 md:mt-10 lg:grid-cols-[minmax(0,1fr)_24rem] lg:gap-12">
<form id={formId} noValidate onSubmit={submit} aria-label="Checkout" className="flex flex-col gap-8">
<FieldSet>
<FieldLegend className="mb-3 font-semibold">Contact</FieldLegend>
<div className="flex flex-col gap-2">
<Label htmlFor={idOf("email")}>Email</Label>
<Input {...checked("email")} type="email" autoComplete="email" inputMode="email" className="h-9" />
{refusal("email")}
</div>
</FieldSet>
<div className="border-t border-rule pt-6">
<FieldSet>
<FieldLegend className="mb-3 font-semibold">Delivery address</FieldLegend>
<div className="grid gap-4 sm:grid-cols-2">
{ADDRESS.map(field)}
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor={idOf("country")}>Country</Label>
<NativeSelect {...checked("country")} autoComplete="country-name" defaultValue="" className="w-full [&_select]:h-9 [&_select]:bg-card dark:[&_select]:bg-card">
<NativeSelectOption value="" disabled>
Choose a country
</NativeSelectOption>
{countries.map((country) => (
<NativeSelectOption key={country} value={country}>
{country}
</NativeSelectOption>
))}
</NativeSelect>
{refusal("country")}
</div>
</div>
</FieldSet>
</div>
<div className="border-t border-rule pt-6">
<FieldSet>
<FieldLegend id={idOf("delivery")} className="mb-3 font-semibold">Delivery</FieldLegend>
<RadioGroup value={method} onValueChange={(next) => setMethod(next as Method)} aria-labelledby={idOf("delivery")} className="grid gap-2 sm:grid-cols-2">
{(["standard", "express"] as const).map((way) => {
const cost = way === "standard" && totals.freeDelivery ? "Free" : money(DELIVERY[way].cents)
return (
<Label key={way} htmlFor={idOf(way)} className="flex cursor-pointer items-start gap-3 rounded-lg bg-card p-3 leading-snug ring-1 ring-border has-[[data-checked]]:bg-brand-muted">
<RadioGroupItem id={idOf(way)} value={way} className="mt-0.5" />
<span className="flex flex-1 flex-col gap-1">
<span className="flex justify-between gap-2">
{DELIVERY[way].label} <span className="tabular-nums">{cost}</span>
</span>
<span className="text-xs font-normal text-muted-foreground">Arrives {arrives(way)}</span>
</span>
</Label>
)
})}
</RadioGroup>
</FieldSet>
</div>
<div className="border-t border-rule pt-6">
<FieldSet>
<FieldLegend className="mb-3 font-semibold">Payment</FieldLegend>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor={idOf("cardNumber")}>Card number</Label>
<div className="relative">
<Input
{...checked("cardNumber")}
autoComplete="cc-number"
inputMode="numeric"
value={card}
onChange={(event) => setCard(formatCardNumber(event.target.value))}
className="h-9 pr-10 tabular-nums"
/>
<CreditCardIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground" />
</div>
{refusal("cardNumber")}
</div>
{CARD.map(field)}
</div>
</FieldSet>
</div>
</form>
<Card className="h-fit lg:sticky lg:top-6">
<CardHeader>
<CardTitle>
<h3>Your order</h3>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ul aria-label="Items in your order" className="flex flex-col gap-3">
{lines.map((line) => (
<li key={line.productId} className="flex items-center gap-3 text-sm">
<div className="w-12 shrink-0">{line.art}</div>
<span className="flex min-w-0 flex-1 flex-col">
<span className="font-medium text-foreground">{line.name}</span>
<span className="text-xs text-muted-foreground tabular-nums">Qty {line.qty}</span>
</span>
<span className="tabular-nums">{money(line.qty * line.unitCents)}</span>
</li>
))}
</ul>
<dl className="flex flex-col gap-2 border-t border-rule pt-4 text-sm *:flex *:justify-between *:gap-4 [&_dd]:tabular-nums">
<div>
<dt className="text-muted-foreground">Subtotal, {itemCount(totals.units)}</dt>
<dd>{money(totals.subtotalCents)}</dd>
</div>
<div>
<dt className="text-muted-foreground">{DELIVERY[method].label}</dt>
<dd>{totals.deliveryCents === 0 ? "Free" : money(totals.deliveryCents)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Tax ({TAX_PERCENT}%)</dt>
<dd>{money(totals.taxCents)}</dd>
</div>
<div className="border-t border-rule pt-3 font-semibold">
<dt>Total</dt>
<dd className="text-base">{money(totals.totalCents)}</dd>
</div>
</dl>
<Button type="submit" form={formId} size="lg" className="h-10">
Pay {money(totals.totalCents)}
</Button>
<p role="status" aria-live="polite" className="text-sm text-foreground">
{placed ? (
<span className="inline-flex items-start gap-2">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{placed}
</span>
) : null}
</p>
</CardContent>
</Card>
</div>
)
}/**
* What `checkout-01` reads, all from cart-ecommerce: the shopper's cart
* (`shopperCart`), the countries the shop delivers to (`shipsTo`), the moment
* it calls now (`checkoutNow`: `REFERENCE_DATE`, for the delivery dates and
* a card's expiry) and the number the order would be given
* (`nextOrderNumber`). What delivery costs, the tax and the rules a card is
* checked by are the checkout terms the island totals with.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { checkoutNow, nextOrderNumber, shipsTo, shopperCart, type CartLine } from "@/lib/dashboards/ecommerce/cart"
export type { CartLine }
/** What the checkout needs from the store: the lines, where it delivers, now, and the next order's number. */
export function checkoutStart() {
return { lines: shopperCart(), countries: shipsTo(), ...checkoutNow(), orderNumber: nextOrderNumber() }
}npx shadcn@latest add @vibra/checkout-01Checkout in steps
checkout-02Contact, delivery, payment and a review under the kit's Stepper, each step checked before the next, a finished step one press away, and the order beside every step.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { SteppedCheckout } from "./checkout-02.client"
import { checkoutStart } from "./checkout-02.data"
/**
* The checkout in steps: contact, delivery, payment and a review, with a
* progress rail over them that goes back to any finished step. The order sits
* beside every step, its total following the way chosen; on a phone it comes
* after the form.
*
* A server component: the cart, the countries, now and the order's number
* come from `./checkout-02.data`; every picture is drawn here.
*/
export function Checkout02() {
const titleId = React.useId()
const { lines, countries, placedAt, month, orderNumber } = checkoutStart()
const drawn = lines.map((line) => ({ ...line, art: <ProductArt name={line.name} category={line.category} className="rounded-md" /> }))
return (
<section
data-section="checkout-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex max-w-xl flex-col gap-2">
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Checkout
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Four short steps. Each is checked before the next, and you can go back to any you have finished.
</p>
</div>
<SteppedCheckout lines={drawn} countries={countries} placedAt={placedAt} month={month} orderNumber={orderNumber} />
</div>
</section>
)
}
export default Checkout02"use client"
import * as React from "react"
import { ArrowLeftIcon, CircleCheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
DELIVERY,
TAX_PERCENT,
arrivalWindow,
cardDigits,
cardNumberError,
cartTotals,
cvcError,
expiryError,
formatCardNumber,
itemCount,
money,
postcodeError,
type YearMonth,
} from "@/lib/dashboards/ecommerce/checkout"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Stepper } from "@/components/ui/stepper"
import type { CartLine } from "./checkout-02.data"
export type SteppedLine = CartLine & { art: React.ReactNode }
type Method = "standard" | "express"
type Name = "email" | "name" | "address" | "city" | "postcode" | "country" | "cardNumber" | "expiry" | "cvc"
type Spec = { name: Name; label: string; autoComplete: string; inputMode?: "numeric" | "email"; placeholder?: string; wide?: boolean }
const STEPS = [
{ id: "contact", title: "Contact" },
{ id: "delivery", title: "Delivery" },
{ id: "payment", title: "Payment" },
{ id: "review", title: "Review" },
]
const FIELDS: Spec[][] = [
[{ name: "email", label: "Email", autoComplete: "email", inputMode: "email", wide: true }],
[
{ name: "name", label: "Full name", autoComplete: "name", wide: true },
{ name: "address", label: "Street address", autoComplete: "street-address", wide: true },
{ name: "city", label: "Town or city", autoComplete: "address-level2" },
{ name: "postcode", label: "Postcode", autoComplete: "postal-code" },
],
[
{ name: "cardNumber", label: "Card number", autoComplete: "cc-number", inputMode: "numeric", wide: true },
{ name: "expiry", label: "Expiry (MM/YY)", autoComplete: "cc-exp", inputMode: "numeric", placeholder: "MM/YY" },
{ name: "cvc", label: "Security code", autoComplete: "cc-csc", inputMode: "numeric" },
],
[],
]
/** What each step checks, in the order its fields are drawn. */
const CHECKS: Name[][] = [["email"], ["name", "address", "city", "postcode", "country"], ["cardNumber", "expiry", "cvc"], []]
const LONG_DAY = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
/** What an empty field is refused with, for the fields that only need something in them. */
const EMPTY: Partial<Record<Name, string>> = {
name: "Enter the name the parcel goes to.",
address: "Enter the street address.",
city: "Enter the town or city.",
country: "Choose the country it goes to.",
}
function refusal(name: Name, value: string, month: YearMonth): string | null {
if (name === "email") return !value ? "Enter your email, so we can send the receipt." : isEmail(value) ? null : "That email is missing something. Check the @ and what follows it."
if (name === "postcode") return postcodeError(value)
if (name === "cardNumber") return cardNumberError(value)
if (name === "expiry") return expiryError(value, month)
if (name === "cvc") return cvcError(value)
return value ? null : (EMPTY[name] ?? null)
}
/**
* The checkout in four steps under the kit's Stepper — contact, delivery,
* payment, review — each checked before the next opens: a refusal is said
* under its field and tied to it, with focus on the first. Back and a
* finished step's own marker go back without losing a thing typed. The
* review reads everything back, each part with a way to change it; placing the
* order confirms it in the status line. On every step the order sits beside
* the form, its total following the way chosen. It sends nothing.
*/
export function SteppedCheckout({ lines, countries, placedAt, month, orderNumber }: { lines: SteppedLine[]; countries: string[]; placedAt: string; month: YearMonth; orderNumber: string }) {
const base = React.useId()
const idOf = (name: string) => `${base}${name}`
const [step, setStep] = React.useState(0)
const [done, setDone] = React.useState<number[]>([])
const [values, setValues] = React.useState<Record<Name, string>>({ email: "", name: "", address: "", city: "", postcode: "", country: "", cardNumber: "", expiry: "", cvc: "" })
const [method, setMethod] = React.useState<Method>("standard")
const [errors, setErrors] = React.useState<Partial<Record<Name, string>>>({})
const [placed, setPlaced] = React.useState("")
const headingRef = React.useRef<HTMLHeadingElement>(null)
const moved = React.useRef(false)
const totals = cartTotals(lines, { method })
const [soonest, latest] = arrivalWindow(method, new Date(placedAt))
// A step that opens takes focus on its heading, so the next thing read is where the reader now is.
React.useEffect(() => {
if (moved.current) headingRef.current?.focus()
}, [step])
const set = (name: Name, value: string) => setValues((current) => ({ ...current, [name]: name === "cardNumber" ? formatCardNumber(value) : value }))
const go = (to: number) => {
moved.current = true
setErrors({})
setStep(to)
}
function next(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (step === 3) {
setPlaced(`Order ${orderNumber} is placed. The receipt is on its way to ${values.email}, and it arrives between ${LONG_DAY.format(soonest)} and ${LONG_DAY.format(latest)}.`)
setDone([0, 1, 2, 3])
return
}
const found: Partial<Record<Name, string>> = {}
for (const name of CHECKS[step]) {
const problem = refusal(name, values[name].trim(), month)
if (problem) found[name] = problem
}
setErrors(found)
const first = CHECKS[step].find((name) => found[name])
if (first) {
document.getElementById(idOf(first))?.focus()
return
}
setDone((current) => [...new Set([...current, step])])
go(step + 1)
}
const control = (name: Name) => ({
id: idOf(name),
name,
value: values[name],
"aria-invalid": errors[name] ? true : undefined,
"aria-describedby": errors[name] ? idOf(`${name}-error`) : undefined,
})
const said = (name: Name) => (errors[name] ? <FieldError id={idOf(`${name}-error`)}>{errors[name]}</FieldError> : null)
const ending = cardDigits(values.cardNumber).slice(-4)
return (
<div className="mt-8 grid gap-8 md:mt-10 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-12">
<div className="flex min-w-0 flex-col gap-8">
<nav aria-label="Checkout steps">
{/* Placed, the flow is done: every step reads complete, none is current, and none leads back. */}
<Stepper steps={STEPS} activeStep={placed ? STEPS.length : step} completedSteps={done} onStepClick={go} clickable={placed ? "none" : "completed"} size="sm" />
</nav>
<form noValidate onSubmit={next} aria-labelledby={idOf("heading")} className="flex flex-col gap-6">
<h3 id={idOf("heading")} ref={headingRef} tabIndex={-1} className="text-lg font-semibold outline-none">
{["Where should we send the receipt?", "Where is it going?", "How are you paying?", "Check it over"][step]}
</h3>
{step < 3 ? (
<div className="grid gap-4 sm:grid-cols-2">
{FIELDS[step].map((spec) => (
<div key={spec.name} className={cn("flex flex-col gap-2", spec.wide && "sm:col-span-2")}>
<Label htmlFor={idOf(spec.name)}>{spec.label}</Label>
<Input {...control(spec.name)} onChange={(event) => set(spec.name, event.target.value)} autoComplete={spec.autoComplete} inputMode={spec.inputMode} placeholder={spec.placeholder} className="h-9" />
{said(spec.name)}
</div>
))}
{step === 1 ? (
<>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor={idOf("country")}>Country</Label>
<NativeSelect {...control("country")} onChange={(event) => set("country", event.target.value)} autoComplete="country-name" className="w-full [&_select]:h-9 [&_select]:bg-card dark:[&_select]:bg-card">
<NativeSelectOption value="" disabled>
Choose a country
</NativeSelectOption>
{countries.map((country) => (
<NativeSelectOption key={country} value={country}>
{country}
</NativeSelectOption>
))}
</NativeSelect>
{said("country")}
</div>
<RadioGroup value={method} onValueChange={(way) => setMethod(way as Method)} aria-label="How it travels" className="grid gap-2 sm:col-span-2 sm:grid-cols-2">
{(["standard", "express"] as const).map((way) => (
<Label key={way} htmlFor={idOf(way)} className="flex cursor-pointer items-start gap-3 rounded-lg bg-card p-3 ring-1 ring-border has-[[data-checked]]:bg-brand-muted">
<RadioGroupItem id={idOf(way)} value={way} className="mt-0.5" />
<span className="flex flex-1 justify-between gap-2">
{DELIVERY[way].label}
<span className="tabular-nums">{way === "standard" && totals.freeDelivery ? "Free" : money(DELIVERY[way].cents)}</span>
</span>
</Label>
))}
</RadioGroup>
</>
) : null}
</div>
) : (
<dl className="flex flex-col divide-y divide-border panel text-sm">
{[
{ step: 0, term: "Contact", detail: values.email },
{ step: 1, term: "Deliver to", detail: `${values.name}, ${values.address}, ${values.city} ${values.postcode}, ${values.country}` },
{ step: 1, term: DELIVERY[method].label, detail: `Arrives ${LONG_DAY.format(soonest)} to ${LONG_DAY.format(latest)}` },
{ step: 2, term: "Payment", detail: `Card ending ${ending}, expires ${values.expiry}` },
].map((row) => (
<div key={row.term} className="flex items-start gap-4 p-4">
<dt className="w-24 shrink-0 text-muted-foreground sm:w-32">{row.term}</dt>
<dd className="min-w-0 flex-1 text-foreground">{row.detail}</dd>
{placed ? null : (
<Button type="button" variant="link" size="xs" className="h-auto px-0" onClick={() => go(row.step)}>
Change <span className="sr-only">{row.term.toLowerCase()}</span>
</Button>
)}
</div>
))}
</dl>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
{step > 0 && !placed ? (
<Button type="button" variant="ghost" onClick={() => go(step - 1)}>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Back
</Button>
) : (
<span />
)}
<Button type="submit" size="lg" className="h-10 px-5" disabled={Boolean(placed)}>
{step === 3 ? `Place order, ${money(totals.totalCents)}` : `Continue to ${STEPS[step + 1].title.toLowerCase()}`}
</Button>
</div>
<p role="status" aria-live="polite" className="text-sm text-foreground">
{placed ? (
<span className="inline-flex items-start gap-2">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{placed}
</span>
) : null}
</p>
</form>
</div>
<Card className="h-fit lg:sticky lg:top-6">
<CardHeader>
<CardTitle>
<h3>Order, {itemCount(totals.units)}</h3>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ul aria-label="Items in your order" className="flex flex-col gap-3">
{lines.map((line) => (
<li key={line.productId} className="flex items-center gap-3 text-sm">
<div className="w-10 shrink-0">{line.art}</div>
<span className="min-w-0 flex-1 truncate">{line.name}</span>
<span className="tabular-nums">{money(line.qty * line.unitCents)}</span>
</li>
))}
</ul>
<dl className="flex flex-col gap-2 border-t border-rule pt-4 text-sm *:flex *:justify-between *:gap-4 [&_dd]:tabular-nums">
<div>
<dt className="text-muted-foreground">Subtotal</dt>
<dd>{money(totals.subtotalCents)}</dd>
</div>
<div>
<dt className="text-muted-foreground">{DELIVERY[method].label}</dt>
<dd>{totals.deliveryCents === 0 ? "Free" : money(totals.deliveryCents)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Tax ({TAX_PERCENT}%)</dt>
<dd>{money(totals.taxCents)}</dd>
</div>
<div className="border-t border-rule pt-3 font-semibold">
<dt>Total</dt>
<dd className="text-base">{money(totals.totalCents)}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
)
}/**
* What `checkout-02` reads, all from cart-ecommerce: the shopper's cart
* (`shopperCart`), the countries the shop delivers to (`shipsTo`), the moment
* it calls now (`checkoutNow`) and the number the order would be given
* (`nextOrderNumber`). The terms it totals by are the checkout lib's.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { checkoutNow, nextOrderNumber, shipsTo, shopperCart, type CartLine } from "@/lib/dashboards/ecommerce/cart"
export type { CartLine }
/** What the stepped checkout starts from: the lines, where the shop delivers, now, and the next order's number. */
export function checkoutStart() {
return { lines: shopperCart(), countries: shipsTo(), ...checkoutNow(), orderNumber: nextOrderNumber() }
}npx shadcn@latest add @vibra/checkout-02Delivery or collection
checkout-03Choose delivery or collection first: a row of the next five working days, each with its price, and an address; or a counter with its hours, who collects and a phone to text.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { DeliverOrCollect } from "./checkout-03.client"
import { collectStart } from "./checkout-03.data"
/**
* Delivery or collection: the shopper picks first, then the band asks only
* what that way needs — a day and an address, or a counter, a name and a
* phone — beside the order, whose total follows the pick.
*
* A server component: the cart, the countries, now and the counters come from
* `./checkout-03.data`; every picture is drawn here.
*/
export function Checkout03() {
const titleId = React.useId()
const { lines, countries, placedAt, shops, readyFromHour } = collectStart()
const drawn = lines.map((line) => ({ ...line, art: <ProductArt name={line.name} category={line.category} className="rounded-sm" /> }))
return (
<section
data-section="checkout-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex max-w-xl flex-col gap-2">
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
How would you like it?
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Pick a delivery day, or collect it free from one of our counters.
</p>
</div>
<DeliverOrCollect lines={drawn} countries={countries} placedAt={placedAt} shops={shops} readyFromHour={readyFromHour} />
</div>
</section>
)
}
export default Checkout03"use client"
import * as React from "react"
import { CircleCheckIcon, StoreIcon, TruckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { DELIVERY, TAX_PERCENT, addWorkingDays, cartTotals, itemCount, money, postcodeError } from "@/lib/dashboards/ecommerce/checkout"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import type { CartLine, Shop } from "./checkout-03.data"
export type CollectLine = CartLine & { art: React.ReactNode }
type Mode = "deliver" | "collect"
type Name = "email" | "name" | "address" | "city" | "postcode" | "country" | "collector" | "phone"
const WEEKDAY = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" })
const DATE = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const LONG = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
/** A phone a counter can text: digits, spaces, a plus and dashes, with 7 to 15 digits in it. */
const PHONE = /^\+?[\d\s-]+$/
/**
* Delivery or collection, chosen first. Delivery offers the next five working
* days as a row of days, each with its price — the first two at the express
* rate, the rest at the standard one, free when the goods reach the line —
* and takes the address; collection offers the shop's counters, each with its
* hours, and takes who collects and a phone to text when it is ready, from ten
* on the next working day. Continue checks the fields the choice needs,
* refuses in words tied to each and focuses the first, and books the choice
* in the status line. The summary follows the choice. It sends nothing.
*/
export function DeliverOrCollect({ lines, countries, placedAt, shops, readyFromHour }: { lines: CollectLine[]; countries: string[]; placedAt: string; shops: readonly Shop[]; readyFromHour: number }) {
const base = React.useId()
const idOf = (name: string) => `${base}${name}`
const placed = new Date(placedAt)
const days = [1, 2, 3, 4, 5].map((ahead) => addWorkingDays(placed, ahead))
const [mode, setMode] = React.useState<Mode>("deliver")
const [day, setDay] = React.useState(2)
const [shop, setShop] = React.useState(shops[0].id)
const [errors, setErrors] = React.useState<Partial<Record<Name, string>>>({})
const [booked, setBooked] = React.useState("")
const method = mode === "collect" ? "pickup" : day < DELIVERY.express.days[1] ? "express" : "standard"
const totals = cartTotals(lines, { method })
const priceOf = (index: number) => (index < DELIVERY.express.days[1] ? money(DELIVERY.express.cents) : totals.freeDelivery ? "Free" : money(DELIVERY.standard.cents))
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const data = new FormData(event.currentTarget)
const value = (name: Name) => String(data.get(name) ?? "").trim()
const found: Partial<Record<Name, string>> = {}
const email = value("email")
if (!email) found.email = "Enter your email, so we can send the receipt."
else if (!isEmail(email)) found.email = "That email is missing something. Check the @ and what follows it."
if (mode === "deliver") {
if (!value("name")) found.name = "Enter the name the parcel goes to."
if (!value("address")) found.address = "Enter the street address."
if (!value("city")) found.city = "Enter the town or city."
const postcode = postcodeError(value("postcode"))
if (postcode) found.postcode = postcode
if (!value("country")) found.country = "Choose the country it goes to."
} else {
if (!value("collector")) found.collector = "Enter the name of whoever collects it; the counter asks for it."
const phone = value("phone")
const digits = phone.replace(/\D/g, "").length
if (!phone) found.phone = "Enter a phone number, so we can text you when it is ready."
else if (!PHONE.test(phone) || digits < 7 || digits > 15) found.phone = "That does not look like a phone number."
}
setErrors(found)
const first = (Object.keys(found) as Name[])[0]
if (first) {
setBooked("")
document.getElementById(idOf(first))?.focus()
return
}
if (mode === "deliver") setBooked(`Delivery booked for ${LONG.format(days[day])}, to ${value("city")}. The receipt is on its way to ${email}.`)
else {
const counter = shops.find((entry) => entry.id === shop)!
setBooked(`Collection booked at ${counter.name} from ${LONG.format(days[0])} at ${readyFromHour}:00. We will text ${value("phone")} when it is ready.`)
}
}
const control = (name: Name) => ({
id: idOf(name),
name,
"aria-invalid": errors[name] ? true : undefined,
"aria-describedby": errors[name] ? idOf(`${name}-error`) : undefined,
})
const text = (name: Name, label: string, autoComplete: string, wide = false, type = "text") => (
<div className={cn("flex flex-col gap-2", wide && "sm:col-span-2")}>
<Label htmlFor={idOf(name)}>{label}</Label>
<Input {...control(name)} type={type} autoComplete={autoComplete} className="h-9" />
{errors[name] ? <FieldError id={idOf(`${name}-error`)}>{errors[name]}</FieldError> : null}
</div>
)
const choice = "flex cursor-pointer items-start gap-3 rounded-lg bg-card p-3 leading-snug ring-1 ring-border has-[[data-checked]]:bg-brand-muted"
return (
<div className="mt-8 grid gap-8 md:mt-10 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-12">
<form noValidate onSubmit={submit} aria-label="Delivery or collection" className="flex min-w-0 flex-col gap-6">
<RadioGroup
value={mode}
onValueChange={(next) => {
setMode(next as Mode)
setErrors({})
setBooked("")
}}
aria-label="How you get it"
className="grid grid-cols-2 gap-2"
>
{([["deliver", "Deliver it", TruckIcon, "To your door"], ["collect", "Collect it", StoreIcon, "Free, from a Northwind counter"]] as const).map(([value, label, Icon, note]) => (
<Label key={value} htmlFor={idOf(value)} className={choice}>
<RadioGroupItem id={idOf(value)} value={value} className="mt-0.5" />
<span className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<Icon aria-hidden="true" className="size-4 text-muted-foreground" />
{label}
</span>
<span className="text-xs font-normal text-muted-foreground">{note}</span>
</span>
</Label>
))}
</RadioGroup>
{text("email", "Email", "email", true, "email")}
{mode === "deliver" ? (
<>
<div className="flex flex-col gap-2">
<p id={idOf("days")} className="text-sm font-medium">
Delivery day
</p>
<RadioGroup value={String(day)} onValueChange={(next) => setDay(Number(next))} aria-labelledby={idOf("days")} className="grid grid-cols-3 gap-2 sm:grid-cols-5">
{days.map((date, index) => (
<Label
key={index}
htmlFor={idOf(`day-${index}`)}
className="flex cursor-pointer flex-col items-center gap-1 rounded-lg bg-card p-2.5 text-center ring-1 ring-border has-[[data-checked]]:bg-brand-muted has-[:focus-visible]:focus-outline"
>
<RadioGroupItem id={idOf(`day-${index}`)} value={String(index)} className="sr-only" />
<span className="text-xs font-normal text-muted-foreground">{WEEKDAY.format(date)}</span>
<span className="tabular-nums">{DATE.format(date)}</span>
<span className="text-xs font-normal text-muted-foreground tabular-nums">{priceOf(index)}</span>
</Label>
))}
</RadioGroup>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{text("name", "Full name", "name", true)}
{text("address", "Street address", "street-address", true)}
{text("city", "Town or city", "address-level2")}
{text("postcode", "Postcode", "postal-code")}
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor={idOf("country")}>Country</Label>
<NativeSelect {...control("country")} autoComplete="country-name" defaultValue="" className="w-full [&_select]:h-9 [&_select]:bg-card dark:[&_select]:bg-card">
<NativeSelectOption value="" disabled>
Choose a country
</NativeSelectOption>
{countries.map((country) => (
<NativeSelectOption key={country} value={country}>
{country}
</NativeSelectOption>
))}
</NativeSelect>
{errors.country ? <FieldError id={idOf("country-error")}>{errors.country}</FieldError> : null}
</div>
</div>
</>
) : (
<>
<div className="flex flex-col gap-2">
<p id={idOf("shops")} className="text-sm font-medium">
Collect from
</p>
<RadioGroup value={shop} onValueChange={(next) => setShop(String(next))} aria-labelledby={idOf("shops")} className="grid gap-2">
{shops.map((counter) => (
<Label key={counter.id} htmlFor={idOf(counter.id)} className={choice}>
<RadioGroupItem id={idOf(counter.id)} value={counter.id} className="mt-0.5" />
<span className="flex flex-col gap-1">
{counter.name}
<span className="text-xs font-normal text-muted-foreground">
{counter.address} · {counter.hours}
</span>
</span>
</Label>
))}
</RadioGroup>
<p className="text-xs text-muted-foreground">Ready from {LONG.format(days[0])} at {readyFromHour}:00.</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{text("collector", "Who collects it", "name")}
{text("phone", "Mobile number", "tel", false, "tel")}
</div>
</>
)}
<Button type="submit" size="lg" className="h-10 self-start px-5">
Continue to payment
</Button>
<p role="status" aria-live="polite" className="text-sm text-foreground">
{booked ? (
<span className="inline-flex items-start gap-2">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{booked}
</span>
) : null}
</p>
</form>
<Card className="h-fit lg:sticky lg:top-6">
<CardHeader>
<CardTitle>
<h3>Order, {itemCount(totals.units)}</h3>
</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-2 text-sm *:flex *:justify-between *:gap-4 [&_dd]:tabular-nums">
{lines.map((line) => (
<div key={line.productId}>
<dt className="flex min-w-0 items-center gap-3 text-muted-foreground">
<span className="w-8 shrink-0">{line.art}</span>
<span className="truncate">{line.name}</span>
</dt>
<dd className="self-center">{money(line.qty * line.unitCents)}</dd>
</div>
))}
<div className="mt-2 border-t border-rule pt-3">
<dt className="text-muted-foreground">{DELIVERY[method].label}</dt>
<dd>{totals.deliveryCents === 0 ? "Free" : money(totals.deliveryCents)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Tax ({TAX_PERCENT}%)</dt>
<dd>{money(totals.taxCents)}</dd>
</div>
<div className="border-t border-rule pt-3 font-semibold">
<dt>Total</dt>
<dd className="text-base">{money(totals.totalCents)}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
)
}/**
* What `checkout-03` reads: the shopper's cart, the countries the shop
* delivers to and the moment it calls now (all from cart-ecommerce), and the
* shop's own counters — the places an order can be collected from, and when a
* counter has an order ready: from ten on the next working day. The shops are
* copy, kept here: put your own counters in `SHOPS`.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { checkoutNow, shipsTo, shopperCart, type CartLine } from "@/lib/dashboards/ecommerce/cart"
export type { CartLine }
/** A counter an order can be collected from. */
export type Shop = { id: string; name: string; address: string; hours: string }
export const SHOPS: readonly Shop[] = [
{ id: "harbour", name: "Northwind Harbour Street", address: "18 Harbour Street", hours: "Monday to Saturday, 9:00 to 18:00" },
{ id: "market", name: "Northwind Market Hall", address: "Unit 4, Market Hall, Castle Road", hours: "Every day, 8:00 to 20:00" },
{ id: "riverside", name: "Northwind Riverside", address: "2 Wharf Lane", hours: "Monday to Friday, 10:00 to 19:00" },
]
/** The hour, in the shop's own time, from which a counter hands over an order placed the working day before. */
export const READY_FROM_HOUR = 10
/** What the band starts from: the lines, where it delivers, now, and the counters. */
export function collectStart() {
return { lines: shopperCart(), countries: shipsTo(), ...checkoutNow(), shops: SHOPS, readyFromHour: READY_FROM_HOUR }
}npx shadcn@latest add @vibra/checkout-03Buy now in a dialog
checkout-04The bestseller beside its picture, score and price, and a Buy now that opens an express checkout over the page: how many, where it goes and the card, paid in one step.
import * as React from "react"
import Link from "next/link"
import { formatNumber } from "@/lib/format"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { Rating } from "@/components/ui/rating"
import { BuyNowDialog } from "./checkout-04.client"
import { SHELF_DAYS, buyNow } from "./checkout-04.data"
export type Checkout04Props = {
/** Opens the express checkout on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/** A mean as a score: one decimal, always. */
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/**
* Buy it now: the shop's bestseller beside its picture — its department, its
* score from the buyers who received it, its price and what it sold — and a
* Buy now button that opens an express checkout over the page: how many,
* where it goes and the card, paid in one step.
*
* A server component: the product, the countries, now and the order's number
* come from `./checkout-04.data`; both pictures are drawn here.
*/
export function Checkout04({ defaultOpen = false }: Checkout04Props) {
const titleId = React.useId()
const { product, countries, placedAt, month, orderNumber } = buyNow()
return (
<section
data-section="checkout-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl items-center gap-8 md:grid-cols-2 md:gap-12">
<ProductArt name={product.name} category={product.category} className="rounded-xl" />
<div className="flex flex-col items-start gap-4">
<p className="type-eyebrow">Bestseller in {product.category}</p>
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
<Link href={product.href} className="rounded-sm focus-ring hover:underline">
{product.name}
</Link>
</h2>
{product.ratings ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Rating value={Math.round(product.rating)} size="sm" aria-hidden="true" />
<span aria-hidden="true" className="tabular-nums">
{score(product.rating)} from {product.ratings} reviews
</span>
<span className="sr-only">
Rated {score(product.rating)} out of 5 from {product.ratings} reviews
</span>
</div>
) : null}
<Price amount={product.priceCents / 100} size="lg" />
<p className="max-w-md text-sm text-pretty text-muted-foreground md:text-base">
{product.sold} sold in the last {SHELF_DAYS} days, and {product.stock} ready to ship. Buy it in one step: where it goes, the
card, done.
</p>
<BuyNowDialog
product={product}
art={<ProductArt name={product.name} category={product.category} className="rounded-md" />}
countries={countries}
placedAt={placedAt}
month={month}
orderNumber={orderNumber}
defaultOpen={defaultOpen}
/>
</div>
</div>
</section>
)
}
export default Checkout04"use client"
import * as React from "react"
import { CircleCheckIcon, MinusIcon, PlusIcon, ZapIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
TAX_PERCENT,
arrivalWindow,
cardNumberError,
cartTotals,
cvcError,
expiryError,
formatCardNumber,
money,
postcodeError,
type YearMonth,
} from "@/lib/dashboards/ecommerce/checkout"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import type { BuyNowProduct } from "./checkout-04.data"
type Name = "email" | "name" | "address" | "postcode" | "country" | "cardNumber" | "expiry" | "cvc"
const LONG = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
/** The form's own order, which focus looks for the first refusal in. */
const ORDER: Name[] = ["email", "name", "postcode", "address", "country", "cardNumber", "expiry", "cvc"]
function refusals(value: (name: Name) => string, month: YearMonth): Partial<Record<Name, string>> {
const out: Partial<Record<Name, string>> = {}
const email = value("email")
if (!email) out.email = "Enter your email, so we can send the receipt."
else if (!isEmail(email)) out.email = "That email is missing something. Check the @ and what follows it."
if (!value("name")) out.name = "Enter the name the parcel goes to."
if (!value("address")) out.address = "Enter the street address and town."
const postcode = postcodeError(value("postcode"))
if (postcode) out.postcode = postcode
if (!value("country")) out.country = "Choose the country it goes to."
const checks: [Name, string | null][] = [["cardNumber", cardNumberError(value("cardNumber"))], ["expiry", expiryError(value("expiry"), month)], ["cvc", cvcError(value("cvc"))]]
for (const [name, problem] of checks) if (problem) out[name] = problem
return out
}
/**
* Buy now: the button opens the kit's Dialog on the product, focus inside —
* how many (a stepper capped at one and at the stock), the email, the
* address and the card in one short form, and the total as the quantity
* changes it. Pay checks every field, refuses each in words tied to it with
* focus on the first, and confirms the order in the dialog's status line.
* Escape and Close shut it, focus back on the button. `defaultOpen` opens it
* for a preview with focus on the dialog itself. It sends nothing.
*/
export function BuyNowDialog({ product, art, countries, placedAt, month, orderNumber, defaultOpen = false }: { product: BuyNowProduct; art: React.ReactNode; countries: string[]; placedAt: string; month: YearMonth; orderNumber: string; defaultOpen?: boolean }) {
const base = React.useId()
const idOf = (name: string) => `${base}${name}`
const [open, setOpen] = React.useState(defaultOpen)
const [qty, setQty] = React.useState(1)
const [card, setCard] = React.useState("")
const [errors, setErrors] = React.useState<Partial<Record<Name, string>>>({})
const [placed, setPlaced] = React.useState("")
const openedByDefault = React.useRef(defaultOpen)
const popupRef = React.useRef<HTMLDivElement>(null)
const totals = cartTotals([{ unitCents: product.priceCents, qty }])
function pay(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const data = new FormData(event.currentTarget)
const value = (name: Name) => String(data.get(name) ?? "").trim()
const found = refusals(value, month)
setErrors(found)
const first = ORDER.find((name) => found[name])
if (first) {
setPlaced("")
document.getElementById(idOf(first))?.focus()
return
}
const [soonest, latest] = arrivalWindow("standard", new Date(placedAt)).map((day) => LONG.format(day))
setPlaced(`Order ${orderNumber} is placed: ${qty} × ${product.name}, ${money(totals.totalCents)}. It arrives between ${soonest} and ${latest}; the receipt is on its way to ${value("email")}.`)
}
const control = (name: Name) => ({
id: idOf(name),
name,
"aria-invalid": errors[name] ? true : undefined,
"aria-describedby": errors[name] ? idOf(`${name}-error`) : undefined,
})
const said = (name: Name) => (errors[name] ? <FieldError id={idOf(`${name}-error`)}>{errors[name]}</FieldError> : null)
const field = (name: Name, label: string, autoComplete: string, extra: React.ComponentProps<"input"> = {}, className?: string) => (
<div className={cn("flex flex-col gap-1.5", className)}>
<Label htmlFor={idOf(name)}>{label}</Label>
<Input {...control(name)} autoComplete={autoComplete} className="h-9" {...extra} />
{said(name)}
</div>
)
return (
<Dialog
open={open}
onOpenChange={(next) => {
openedByDefault.current = false
setOpen(next)
}}
>
<DialogTrigger
render={
<Button size="lg" className="h-10 px-5">
<ZapIcon data-icon="inline-start" aria-hidden="true" />
Buy now
</Button>
}
/>
<DialogContent
ref={popupRef}
initialFocus={() => (openedByDefault.current ? popupRef.current : true)}
className="max-h-[calc(100svh-2rem)] gap-5 overflow-y-auto p-5 sm:max-w-lg"
>
<DialogHeader className="pr-8">
<DialogTitle>Buy the {product.name}</DialogTitle>
<DialogDescription>One step: where it goes and how you pay.</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-4 rounded-lg bg-surface p-3 ring-1 ring-border">
<div className="w-14 shrink-0">{art}</div>
<div className="flex min-w-0 flex-1 flex-col">
<span className="font-medium">{product.name}</span>
<span className="text-xs text-muted-foreground tabular-nums">{money(product.priceCents)} each</span>
</div>
<div role="group" aria-label={`Quantity of ${product.name}`} className="flex h-8 items-center rounded-lg bg-card ring-1 ring-border">
<Button variant="ghost" size="icon-sm" aria-label={`Remove one ${product.name}`} disabled={qty <= 1} focusableWhenDisabled onClick={() => setQty(qty - 1)}>
<MinusIcon />
</Button>
<span className="min-w-7 text-center text-sm font-medium tabular-nums">{qty}</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={qty >= product.stock ? `Add one ${product.name}, all ${product.stock} in stock are chosen` : `Add one ${product.name}`}
disabled={qty >= product.stock}
focusableWhenDisabled
onClick={() => setQty(qty + 1)}
>
<PlusIcon />
</Button>
</div>
</div>
<form noValidate onSubmit={pay} aria-label={`Buy the ${product.name}`} className="grid gap-4 sm:grid-cols-6">
{field("email", "Email", "email", { type: "email", inputMode: "email" }, "sm:col-span-6")}
{field("name", "Full name", "name", {}, "sm:col-span-3")}
{field("postcode", "Postcode", "postal-code", {}, "sm:col-span-3")}
{field("address", "Street and town", "street-address", {}, "sm:col-span-6")}
<div className="flex flex-col gap-1.5 sm:col-span-6">
<Label htmlFor={idOf("country")}>Country</Label>
<NativeSelect {...control("country")} autoComplete="country-name" defaultValue="" className="w-full [&_select]:h-9 [&_select]:bg-card dark:[&_select]:bg-card">
<NativeSelectOption value="" disabled>
Choose a country
</NativeSelectOption>
{countries.map((country) => (
<NativeSelectOption key={country} value={country}>
{country}
</NativeSelectOption>
))}
</NativeSelect>
{said("country")}
</div>
{field("cardNumber", "Card number", "cc-number", { inputMode: "numeric", value: card, onChange: (event) => setCard(formatCardNumber(event.target.value)) }, "sm:col-span-6")}
{field("expiry", "Expiry (MM/YY)", "cc-exp", { inputMode: "numeric", placeholder: "MM/YY" }, "sm:col-span-3")}
{field("cvc", "Security code", "cc-csc", { inputMode: "numeric" }, "sm:col-span-3")}
<dl className="flex flex-col gap-1.5 border-t border-rule pt-4 text-sm *:flex *:justify-between *:gap-4 [&_dd]:tabular-nums sm:col-span-6">
<div>
<dt className="text-muted-foreground">
{qty} × {money(product.priceCents)}
</dt>
<dd>{money(totals.subtotalCents)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Standard delivery</dt>
<dd>{totals.deliveryCents === 0 ? "Free" : money(totals.deliveryCents)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Tax ({TAX_PERCENT}%)</dt>
<dd>{money(totals.taxCents)}</dd>
</div>
<div className="border-t border-rule pt-2 font-semibold">
<dt>Total</dt>
<dd>{money(totals.totalCents)}</dd>
</div>
</dl>
<Button type="submit" size="lg" className="h-10 sm:col-span-6" disabled={Boolean(placed)}>
Pay {money(totals.totalCents)}
</Button>
<p role="status" aria-live="polite" className="text-sm text-foreground sm:col-span-6">
{placed ? (
<span className="inline-flex items-start gap-2">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{placed}
</span>
) : null}
</p>
</form>
</DialogContent>
</Dialog>
)
}/**
* What `checkout-04` reads: the product a shopper can buy in one step — the
* shop's bestseller that is in stock, off the storefront's shelf, with the
* score its buyers gave it — and, from cart-ecommerce, the countries the
* shop delivers to, the moment it calls now and the number the order would
* be given.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { checkoutNow, nextOrderNumber, shipsTo } from "@/lib/dashboards/ecommerce/cart"
import { SHELF_DAYS, storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
export { SHELF_DAYS }
/** The product the band sells in one step, as it shows it. */
export type BuyNowProduct = Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "stock" | "sold" | "rating" | "ratings" | "href">
/** The shop's bestseller in stock, and what an express checkout needs besides. */
export function buyNow() {
const product = storeShelf().find((row) => row.stock > 0)
if (!product) throw new Error("checkout-04 sells the bestseller in stock, and nothing is in stock")
const { id, name, category, priceCents, stock, sold, rating, ratings, href } = product
return { product: { id, name, category, priceCents, stock, sold, rating, ratings, href }, countries: shipsTo(), ...checkoutNow(), orderNumber: nextOrderNumber() }
}npx shadcn@latest add @vibra/checkout-04Review and pay
checkout-05The last step: who it goes to and when, read back with a way to change each; a card or a bank transfer; the billing address when it differs; the terms; and the order with the button that places it.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { ReviewAndPay } from "./checkout-05.client"
import { reviewStart } from "./checkout-05.data"
/**
* Review and pay, the last step: who it goes to and when, read back with a
* way to change each; how to pay — a card, or a bank transfer the order waits
* for — with the billing address and the terms; and the order with its total
* beside it, where the button that places it sits.
*
* A server component: the shopper, the cart, the countries, now and the
* order's number come from `./checkout-05.data`; every picture is drawn here.
*/
export function Checkout05() {
const titleId = React.useId()
const { shopper, lines, countries, placedAt, month, orderNumber } = reviewStart()
const drawn = lines.map((line) => ({ ...line, art: <ProductArt name={line.name} category={line.category} className="rounded-md" /> }))
return (
<section
data-section="checkout-05"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex max-w-xl flex-col gap-2">
<p className="type-eyebrow">Last step</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Review and pay
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Check where it is going, then choose how to pay. Nothing is charged until you press the button.
</p>
</div>
<ReviewAndPay shopper={shopper} lines={drawn} countries={countries} placedAt={placedAt} month={month} orderNumber={orderNumber} />
</div>
</section>
)
}
export default Checkout05"use client"
import * as React from "react"
import Link from "next/link"
import { CircleCheckIcon, CreditCardIcon, LandmarkIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
RETURN_DAYS,
TAX_PERCENT,
arrivalWindow,
cardNumberError,
cartTotals,
cvcError,
expiryError,
formatCardNumber,
itemCount,
money,
postcodeError,
type YearMonth,
} from "@/lib/dashboards/ecommerce/checkout"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import type { CartLine, Shopper } from "./checkout-05.data"
export type ReviewLine = CartLine & { art: React.ReactNode }
type Way = "card" | "bank"
type Name = "cardNumber" | "cardName" | "expiry" | "cvc" | "billAddress" | "billPostcode" | "billCountry" | "terms"
const DAY = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
const ORDER: Name[] = ["cardNumber", "cardName", "expiry", "cvc", "billAddress", "billPostcode", "billCountry", "terms"]
/** How many working days a bank-transfer order is held for its money. */
const HOLD_DAYS = 3
/**
* The last step: who it goes to and when, read back with a way to change
* each; the lines and the total; then how to pay — a card, or a bank
* transfer the order is held for — the billing address when it is not the
* delivery one, and the terms. The button says what it will do. Everything the
* way needs is checked and refused in words tied to it, focus on the first;
* the order is confirmed in the status line. It sends nothing.
*/
export function ReviewAndPay({ shopper, lines, countries, placedAt, month, orderNumber }: { shopper: Shopper; lines: ReviewLine[]; countries: string[]; placedAt: string; month: YearMonth; orderNumber: string }) {
const base = React.useId()
const idOf = (name: string) => `${base}${name}`
const [way, setWay] = React.useState<Way>("card")
const [sameBilling, setSameBilling] = React.useState(true)
const [agreed, setAgreed] = React.useState(false)
const [card, setCard] = React.useState("")
const [errors, setErrors] = React.useState<Partial<Record<Name, string>>>({})
const [placed, setPlaced] = React.useState("")
const totals = cartTotals(lines)
const [soonest, latest] = arrivalWindow("standard", new Date(placedAt))
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const data = new FormData(event.currentTarget)
const value = (name: Name) => String(data.get(name) ?? "").trim()
const found: Partial<Record<Name, string>> = {}
if (way === "card") {
const checks: [Name, string | null][] = [
["cardNumber", cardNumberError(value("cardNumber"))],
["cardName", value("cardName") ? null : "Enter the name as it is on the card."],
["expiry", expiryError(value("expiry"), month)],
["cvc", cvcError(value("cvc"))],
]
for (const [name, problem] of checks) if (problem) found[name] = problem
}
if (!sameBilling) {
if (!value("billAddress")) found.billAddress = "Enter the billing street address and town."
const postcode = postcodeError(value("billPostcode"))
if (postcode) found.billPostcode = postcode
if (!value("billCountry")) found.billCountry = "Choose the billing country."
}
if (!agreed) found.terms = "Agree to the terms of sale to place the order."
setErrors(found)
const first = ORDER.find((name) => found[name])
if (first) {
setPlaced("")
document.getElementById(idOf(first))?.focus()
return
}
setPlaced(
way === "card"
? `Paid. Order ${orderNumber} arrives between ${DAY.format(soonest)} and ${DAY.format(latest)}, and the receipt is on its way to ${shopper.email}.`
: `Order ${orderNumber} is placed and held for ${HOLD_DAYS} working days. The account details and the reference ${orderNumber} are on their way to ${shopper.email}.`
)
}
const control = (name: Name) => ({
id: idOf(name),
name,
"aria-invalid": errors[name] ? true : undefined,
"aria-describedby": errors[name] ? idOf(`${name}-error`) : undefined,
})
const said = (name: Name) => (errors[name] ? <FieldError id={idOf(`${name}-error`)}>{errors[name]}</FieldError> : null)
const field = (name: Name, label: string, autoComplete: string, extra: React.ComponentProps<"input"> = {}, className?: string) => (
<div className={cn("flex flex-col gap-2", className)}>
<Label htmlFor={idOf(name)}>{label}</Label>
<Input {...control(name)} autoComplete={autoComplete} className="h-9" {...extra} />
{said(name)}
</div>
)
const choice = "flex cursor-pointer items-start gap-3 rounded-lg bg-card p-3 leading-snug ring-1 ring-border has-[[data-checked]]:bg-brand-muted"
return (
<form noValidate onSubmit={submit} aria-label="Review and pay" className="mt-8 grid gap-8 md:mt-10 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-12">
<div className="flex min-w-0 flex-col gap-8">
<dl className="divide-y divide-border panel text-sm">
{[
{ term: "Contact", detail: shopper.email, href: "/checkout", what: "the email" },
{ term: "Deliver to", detail: `${shopper.name}, ${shopper.country}`, href: "/checkout/delivery", what: "the address" },
{ term: "Arrives", detail: `${DAY.format(soonest)} to ${DAY.format(latest)}, by standard delivery`, href: "/checkout/delivery", what: "the delivery" },
].map((row) => (
<div key={row.term} className="flex items-start gap-4 p-4">
<dt className="w-20 shrink-0 text-muted-foreground sm:w-28">{row.term}</dt>
{/* The link rides in the detail it changes: a definition list's
group holds its term and its details and nothing else. */}
<dd className="flex min-w-0 flex-1 items-start justify-between gap-4">
<span className="min-w-0 break-words">{row.detail}</span>
<Link href={row.href} className="shrink-0 rounded-sm text-brand focus-ring hover:underline">
Change <span className="sr-only">{row.what}</span>
</Link>
</dd>
</div>
))}
</dl>
<div className="flex flex-col gap-3">
<p id={idOf("way")} className="text-sm font-medium">
How you pay
</p>
<RadioGroup
value={way}
onValueChange={(next) => {
setWay(next as Way)
setErrors({})
}}
aria-labelledby={idOf("way")}
className="grid gap-2 sm:grid-cols-2"
>
<Label htmlFor={idOf("card")} className={choice}>
<RadioGroupItem id={idOf("card")} value="card" className="mt-0.5" />
<span className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<CreditCardIcon aria-hidden="true" className="size-4 text-muted-foreground" /> Card
</span>
<span className="text-xs font-normal text-muted-foreground">Charged now, shipped straight away</span>
</span>
</Label>
<Label htmlFor={idOf("bank")} className={choice}>
<RadioGroupItem id={idOf("bank")} value="bank" className="mt-0.5" />
<span className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<LandmarkIcon aria-hidden="true" className="size-4 text-muted-foreground" /> Bank transfer
</span>
<span className="text-xs font-normal text-muted-foreground">Held {HOLD_DAYS} working days for the money</span>
</span>
</Label>
</RadioGroup>
{way === "card" ? (
<div className="mt-2 grid gap-4 sm:grid-cols-2">
{field("cardNumber", "Card number", "cc-number", { inputMode: "numeric", value: card, onChange: (event) => setCard(formatCardNumber(event.target.value)) }, "sm:col-span-2")}
{field("cardName", "Name on card", "cc-name", {}, "sm:col-span-2")}
{field("expiry", "Expiry (MM/YY)", "cc-exp", { inputMode: "numeric", placeholder: "MM/YY" })}
{field("cvc", "Security code", "cc-csc", { inputMode: "numeric" })}
</div>
) : (
<p className="rounded-lg bg-surface p-3 text-sm text-pretty text-muted-foreground ring-1 ring-border">
We email the account details with the reference {orderNumber}. The order ships once the transfer arrives, usually within two working days.
</p>
)}
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Checkbox id={idOf("same")} checked={sameBilling} onCheckedChange={setSameBilling} />
<Label htmlFor={idOf("same")} className="font-normal">
The billing address is the delivery address
</Label>
</div>
{sameBilling ? null : (
<div className="grid gap-4 sm:grid-cols-2">
{field("billAddress", "Billing street and town", "billing street-address", {}, "sm:col-span-2")}
{field("billPostcode", "Billing postcode", "billing postal-code")}
<div className="flex flex-col gap-2">
<Label htmlFor={idOf("billCountry")}>Billing country</Label>
<NativeSelect {...control("billCountry")} autoComplete="billing country-name" defaultValue="" className="w-full [&_select]:h-9 [&_select]:bg-card dark:[&_select]:bg-card">
<NativeSelectOption value="" disabled>
Choose a country
</NativeSelectOption>
{countries.map((country) => (
<NativeSelectOption key={country} value={country}>
{country}
</NativeSelectOption>
))}
</NativeSelect>
{said("billCountry")}
</div>
</div>
)}
</div>
</div>
<div className="flex h-fit flex-col gap-4 frame p-4 lg:sticky lg:top-6">
<ul aria-label="Items in your order" className="flex flex-col gap-3">
{lines.map((line) => (
<li key={line.productId} className="flex items-center gap-3 text-sm">
<div className="w-10 shrink-0">{line.art}</div>
<span className="min-w-0 flex-1 truncate">{line.name}</span>
<span className="tabular-nums">{money(line.qty * line.unitCents)}</span>
</li>
))}
</ul>
<dl className="flex flex-col gap-2 border-t border-rule pt-4 text-sm *:flex *:justify-between *:gap-4 [&_dd]:tabular-nums">
<div>
<dt className="text-muted-foreground">Subtotal, {itemCount(totals.units)}</dt>
<dd>{money(totals.subtotalCents)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Standard delivery</dt>
<dd>{totals.deliveryCents === 0 ? "Free" : money(totals.deliveryCents)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Tax ({TAX_PERCENT}%)</dt>
<dd>{money(totals.taxCents)}</dd>
</div>
<div className="border-t border-rule pt-3 font-semibold">
<dt>Total</dt>
<dd className="text-base">{money(totals.totalCents)}</dd>
</div>
</dl>
<div className="flex flex-col gap-1.5">
<div className="flex items-start gap-2">
<Checkbox {...control("terms")} checked={agreed} onCheckedChange={setAgreed} className="mt-0.5" />
<Label htmlFor={idOf("terms")} className="text-sm leading-snug font-normal">
I agree to the terms of sale, and to returns within {RETURN_DAYS} days
</Label>
</div>
{said("terms")}
</div>
<Button type="submit" size="lg" className="h-10" disabled={Boolean(placed)}>
{way === "card" ? `Pay ${money(totals.totalCents)}` : "Place order"}
</Button>
<p role="status" aria-live="polite" className="text-sm text-foreground">
{placed ? (
<span className="inline-flex items-start gap-2">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{placed}
</span>
) : null}
</p>
</div>
</form>
)
}/**
* What `checkout-05` reads: who is paying — the signed-in shopper, here the
* customer behind the newest order in the book, with the name, email and
* country their account keeps — and, from cart-ecommerce, the shopper's
* cart, the countries the shop delivers to, the moment it calls now and the
* number the order would be given.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { checkoutNow, nextOrderNumber, shipsTo, shopperCart, type CartLine } from "@/lib/dashboards/ecommerce/cart"
import { db } from "@/lib/sample-data"
export type { CartLine }
/** The shopper as the review reads them back. */
export type Shopper = { name: string; email: string; country: string }
/** The customer behind the newest order: the account the checkout is signed in to. */
export function shopper(): Shopper {
const [newest] = [...db.orders.all()].sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
const customer = db.customers.all().find((row) => row.id === newest?.customerId)
if (!customer) throw new Error("checkout-05 reads back the newest order's customer, and there is none")
return { name: customer.name, email: customer.email, country: customer.country }
}
/** What the last step starts from: who pays, the lines, where the shop delivers, now and the order's number. */
export function reviewStart() {
return { shopper: shopper(), lines: shopperCart(), countries: shipsTo(), ...checkoutNow(), orderNumber: nextOrderNumber() }
}npx shadcn@latest add @vibra/checkout-05