Checkout
Contact, delivery and payment fields, delivery options as radio cards, and an order summary that stays in view.
Add this example to a project that has prfct installed. The command writes the files below and installs the components they use.
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/checkout.json"use client"
import * as React from "react"
import {
CheckIcon,
ChevronDownIcon,
CoffeeIcon,
CreditCardIcon,
LampDeskIcon,
LockIcon,
NotebookIcon,
TagIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
const items = [
{
id: "lamp",
name: "Arc desk lamp",
detail: "Graphite",
quantity: 1,
price: 128,
icon: LampDeskIcon,
},
{
id: "notebooks",
name: "Field notebook set",
detail: "Dot grid, set of 3",
quantity: 1,
price: 24,
icon: NotebookIcon,
},
{
id: "mug",
name: "Stoneware mug",
detail: "Chalk, 350 ml",
quantity: 2,
price: 19,
icon: CoffeeIcon,
},
]
const methods = [
{
id: "standard",
title: "Standard",
description: "3–5 business days",
price: 0,
note: "We'll email a tracking link. Delivery takes 3–5 business days.",
},
{
id: "express",
title: "Express",
description: "Next business day",
price: 12,
note: "We'll email a tracking link. It arrives the next business day.",
},
{
id: "pickup",
title: "Pickup",
description: "From our studio in Rotterdam",
price: 0,
note: "We'll email you when it's ready to collect, usually the next day.",
},
] as const
type Method = (typeof methods)[number]
// Sorted at runtime, so the list stays alphabetical in every language. The
// collator names its locale: with the runtime's own, the server and the
// browser could put the names in a different order.
const countries = [
"Austria",
"Belgium",
"France",
"Germany",
"Ireland",
"Netherlands",
"Poland",
"Spain",
]
.sort(new Intl.Collator("en-IE").compare)
.map((label) => ({ label, value: label }))
const promo = { code: "PRFCT10", rate: 0.1 }
// Prices include Dutch VAT; the studio ships from Rotterdam.
const VAT_RATE = 0.21
const money = new Intl.NumberFormat("en-IE", {
style: "currency",
currency: "EUR",
})
const price = (amount: number) => (amount ? money.format(amount) : "Free")
type Errors = Partial<Record<string, string>>
const addressErrors = {
address: "Enter a street name and house number.",
city: "Enter a city.",
postalCode: "Enter a postal code.",
country: "Select a country.",
}
function validate(data: FormData) {
const value = (name: string) => String(data.get(name) ?? "").trim()
const errors: Errors = {}
const check = (name: string, valid: boolean, message: string) => {
if (!valid) errors[name] = message
}
const address = (prefix: string) =>
Object.entries(addressErrors).forEach(([field, message]) =>
check(`${prefix}-${field}`, !!value(`${prefix}-${field}`), message)
)
// Lenient: spaces and slashes are optional, "0429" reads as April 2029.
const [, month = 0, year = 0] =
/^(\d\d)\s*\/?\s*(\d\d)$/.exec(value("expiry"))?.map(Number) ?? []
// Checked in the order the fields appear: the first error gets focus.
check(
"email",
/^\S+@\S+\.\S+$/.test(value("email")),
"Enter an email address like name@example.com."
)
check("name", !!value("name"), "Enter your full name.")
address("shipping")
check(
"card",
/^\d{15,16}$/.test(value("card").replace(/[\s-]/g, "")),
"Enter the 16-digit number on the front of your card."
)
check(
"expiry",
month >= 1 && month <= 12,
"Enter the expiry date as MM / YY."
)
if (!errors.expiry && new Date(2000 + year, month) <= new Date()) {
errors.expiry = "This card has expired. Use a different card."
}
check(
"cvc",
/^\d{3,4}$/.test(value("cvc")),
"Enter the 3 or 4 digits on the back of your card."
)
check(
"cardName",
!!value("cardName"),
"Enter the name as it appears on the card."
)
if (!data.get("billingSame")) address("billing")
return errors
}
/** Ties a control to the label and error FormField renders for its name. */
const wiring = (name: string, error?: string) => ({
id: `checkout-${name}`,
"aria-invalid": error ? true : undefined,
"aria-describedby": error ? `checkout-${name}-error` : undefined,
})
/** A labelled text input, or the control passed as children, with its error. */
function FormField({
name,
label,
error,
children,
...props
}: Omit<React.ComponentProps<typeof Input>, "name"> & {
name: string
label: React.ReactNode
error?: string
}) {
return (
<Field data-invalid={error ? true : undefined}>
<FieldLabel htmlFor={`checkout-${name}`}>{label}</FieldLabel>
{children ?? <Input name={name} {...wiring(name, error)} {...props} />}
{error && <FieldError id={`checkout-${name}-error`}>{error}</FieldError>}
</Field>
)
}
function AddressFields({
prefix,
errors,
onCountryChange,
}: {
prefix: "shipping" | "billing"
errors: Errors
onCountryChange: (name: string) => void
}) {
const field = (name: string) => ({
name: `${prefix}-${name}`,
error: errors[`${prefix}-${name}`],
})
const country = field("country")
return (
<>
<FormField
{...field("address")}
label="Address"
autoComplete={`${prefix} address-line1`}
/>
<FormField
{...field("apartment")}
label={
// One inline run, so "(optional)" wraps with the words.
<span>
Apartment, suite or floor{" "}
<span className="font-normal text-muted-foreground">
(optional)
</span>
</span>
}
autoComplete={`${prefix} address-line2`}
/>
{/* Side by side once the field group has room. */}
<div className="grid gap-5 @sm:grid-cols-[2fr_1fr] @sm:gap-3">
<FormField
{...field("city")}
label="City"
autoComplete={`${prefix} address-level2`}
/>
<FormField
{...field("postalCode")}
label="Postal code"
autoComplete={`${prefix} postal-code`}
/>
</div>
<FormField {...country} label="Country">
<Select
items={countries}
name={country.name}
onValueChange={() => onCountryChange(country.name)}
>
<SelectTrigger
className="w-full"
{...wiring(country.name, country.error)}
>
<SelectValue placeholder="Select a country" />
</SelectTrigger>
<SelectContent>
{countries.map(({ label, value }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</>
)
}
function Section({
id,
title,
children,
}: {
id: string
title: string
children: React.ReactNode
}) {
return (
<section
aria-labelledby={id}
className="flex flex-col gap-5 border-t py-8 first:border-t-0 first:pt-0"
>
<h2 id={id} className="text-heading-sm">
{title}
</h2>
{children}
</section>
)
}
function DeliveryMethods({
value,
onChange,
}: {
value: Method
onChange: (method: Method) => void
}) {
return (
<RadioGroup
aria-labelledby="checkout-method"
value={value.id}
onValueChange={(id) =>
onChange(methods.find((m) => m.id === id) ?? value)
}
>
{methods.map((method) => (
<FieldLabel key={method.id} htmlFor={`checkout-${method.id}`}>
<Field orientation="horizontal">
<RadioGroupItem value={method.id} id={`checkout-${method.id}`} />
{/* The price shares the title's row, so the description gets
the card's full width. */}
<FieldContent>
<span className="flex justify-between gap-3">
<FieldTitle>{method.title}</FieldTitle>
<span className="text-sm font-medium numeric">
{price(method.price)}
</span>
</span>
<FieldDescription>{method.description}</FieldDescription>
</FieldContent>
</Field>
</FieldLabel>
))}
</RadioGroup>
)
}
function PaymentFields({
errors,
onCountryChange,
}: {
errors: Errors
onCountryChange: (name: string) => void
}) {
const [billingSame, setBillingSame] = React.useState(true)
return (
<FieldGroup>
<FormField name="card" label="Card number" error={errors.card}>
<InputGroup>
<InputGroupAddon>
<CreditCardIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
name="card"
{...wiring("card", errors.card)}
inputMode="numeric"
autoComplete="cc-number"
placeholder="1234 1234 1234 1234"
className="numeric"
/>
</InputGroup>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField
name="expiry"
label="Expiry date"
placeholder="MM / YY"
inputMode="numeric"
autoComplete="cc-exp"
className="numeric"
error={errors.expiry}
/>
<FormField
name="cvc"
label="CVC"
inputMode="numeric"
autoComplete="cc-csc"
maxLength={4}
className="numeric"
error={errors.cvc}
/>
</div>
<FormField
name="cardName"
label="Name on card"
autoComplete="cc-name"
error={errors.cardName}
/>
<Field orientation="horizontal">
<Checkbox
id="checkout-billing-same"
name="billingSame"
checked={billingSame}
onCheckedChange={setBillingSame}
/>
<FieldLabel htmlFor="checkout-billing-same" className="font-normal">
Billing address is the same as delivery
</FieldLabel>
</Field>
{!billingSame && (
<FieldSet>
<FieldLegend className="mt-2 mb-4 text-heading-xs">
Billing address
</FieldLegend>
<FieldGroup>
<AddressFields
prefix="billing"
errors={errors}
onCountryChange={onCountryChange}
/>
</FieldGroup>
</FieldSet>
)}
</FieldGroup>
)
}
type Order = { number: string; firstName: string; email: string }
function CheckoutForm({
method,
onMethodChange,
total,
onComplete,
}: {
method: Method
onMethodChange: (method: Method) => void
total: number
onComplete: (order: Order) => void
}) {
const [errors, setErrors] = React.useState<Errors>({})
const [pending, setPending] = React.useState(false)
const clear = (name: string) =>
setErrors((current) => ({ ...current, [name]: undefined }))
function onChange(event: React.ChangeEvent<HTMLFormElement>) {
// A field showing an error is checked again as people type, so the error
// clears the moment it's fixed; other fields wait for submit. Not on blur:
// an error vanishing on mousedown would move Pay out from under the
// pointer and swallow the click.
const name = event.target.getAttribute("name")
if (!name || !errors[name]) return
const message = validate(new FormData(event.currentTarget))[name]
setErrors({ ...errors, [name]: message })
}
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const data = new FormData(form)
const next = validate(data)
setErrors(next)
const [first] = Object.keys(next)
if (first) {
// Centered rather than scrolled to an edge, so its label and error
// stay clear of the sticky site header.
const control = form.querySelector<HTMLElement>(`#checkout-${first}`)
control?.focus({ preventScroll: true })
control?.scrollIntoView({ block: "center" })
return
}
setPending(true)
await new Promise((resolve) => setTimeout(resolve, 1200))
onComplete({
number: `HS-${Math.floor(10000 + Math.random() * 90000)}`,
firstName: String(data.get("name")).trim().split(" ")[0],
email: String(data.get("email")).trim(),
})
}
return (
<form noValidate onSubmit={onSubmit} onChange={onChange}>
<Section id="checkout-contact" title="Contact">
<FieldGroup>
<FormField
name="email"
label="Email"
type="email"
autoComplete="email"
error={errors.email}
/>
<Field orientation="horizontal">
<Checkbox id="checkout-updates" name="updates" defaultChecked />
<FieldLabel htmlFor="checkout-updates" className="font-normal">
Email me order updates
</FieldLabel>
</Field>
</FieldGroup>
</Section>
<Section id="checkout-address" title="Delivery address">
<FieldGroup>
<FormField
name="name"
label="Full name"
autoComplete="shipping name"
error={errors.name}
/>
<AddressFields
prefix="shipping"
errors={errors}
onCountryChange={clear}
/>
</FieldGroup>
</Section>
<Section id="checkout-method" title="Delivery method">
<DeliveryMethods value={method} onChange={onMethodChange} />
</Section>
<Section id="checkout-payment" title="Payment">
<PaymentFields errors={errors} onCountryChange={clear} />
</Section>
<div className="flex flex-col gap-3">
<Button type="submit" size="xl" loading={pending} className="numeric">
Pay {money.format(total)}
</Button>
<p className="text-center text-xs text-balance text-muted-foreground">
Card details are encrypted and go straight to our payment provider. We
never store them.
</p>
</div>
</form>
)
}
function Confirmation({
order,
method,
onReset,
}: {
order: Order
method: Method
onReset: () => void
}) {
const heading = React.useRef<HTMLHeadingElement>(null)
// The form that had focus is gone; the heading takes it, so screen readers
// announce the result and keyboard users continue from here.
React.useEffect(() => heading.current?.focus(), [])
const steps = [
"We pack your order by hand, usually within one business day.",
method.note,
"Changed your mind? Returns are free within 30 days.",
]
return (
<div className="flex flex-col items-start gap-8">
<div className="flex flex-col gap-3">
<CheckIcon
aria-hidden="true"
className="mb-1 size-10 rounded-full border p-2.5"
/>
<p className="text-sm text-muted-foreground numeric">
Order {order.number}
</p>
<h2
ref={heading}
tabIndex={-1}
className="text-heading-xl outline-none"
>
Thank you, {order.firstName}
</h2>
<p className="text-sm text-muted-foreground">
We've sent the receipt to{" "}
<span className="font-medium text-foreground">{order.email}</span>.
</p>
</div>
<div className="flex w-full flex-col gap-2">
<h3 className="text-heading-xs">What happens next</h3>
<ol className="divide-y border-y text-sm text-muted-foreground">
{steps.map((step) => (
<li key={step} className="py-3.5">
{step}
</li>
))}
</ol>
</div>
<Button variant="outline" onClick={onReset}>
Back to the shop
</Button>
</div>
)
}
function DiscountForm({
applied,
onAppliedChange,
}: {
applied: boolean
onAppliedChange: (applied: boolean) => void
}) {
const id = React.useId()
const [code, setCode] = React.useState("")
const [error, setError] = React.useState<string>()
function onSubmit(event: React.FormEvent) {
event.preventDefault()
// One button applies and removes, so focus stays put either way.
const valid = code.trim().toUpperCase() === promo.code
if (applied || valid) {
onAppliedChange(!applied)
setCode("")
setError(undefined)
} else {
setError(
code.trim()
? "This code isn't valid. Check the spelling and try again."
: "Enter a discount code."
)
}
}
return (
<form noValidate onSubmit={onSubmit}>
<Field data-invalid={error ? true : undefined}>
<FieldLabel htmlFor={id} className="sr-only">
Discount code
</FieldLabel>
<InputGroup>
{applied && (
<InputGroupAddon>
<TagIcon aria-hidden="true" />
</InputGroupAddon>
)}
<InputGroupInput
id={id}
value={applied ? promo.code : code}
readOnly={applied}
onChange={(event) => setCode(event.target.value)}
placeholder="Discount code"
autoComplete="off"
aria-invalid={error ? true : undefined}
aria-describedby={`${id}-message`}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton type="submit" variant="secondary" size="sm">
{applied ? "Remove" : "Apply"}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<div id={`${id}-message`} role="status" className="empty:hidden">
{error && <FieldError>{error}</FieldError>}
{applied && (
<FieldDescription>10% off applied to your order.</FieldDescription>
)}
</div>
</Field>
</form>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{label}</dt>
<dd className="numeric">{value}</dd>
</div>
)
}
type Totals = { subtotal: number; discount: number; total: number }
function OrderSummary({
totals: { subtotal, discount, total },
method,
children,
}: {
totals: Totals
method: Method
children?: React.ReactNode
}) {
const vat = total - total / (1 + VAT_RATE)
return (
<div className="flex flex-col gap-6">
<ul className="flex flex-col gap-4">
{items.map(({ icon: Icon, ...item }) => (
<li key={item.id} className="flex items-start gap-4 text-sm">
<span className="flex size-16 shrink-0 items-center justify-center rounded-lg border bg-muted text-muted-foreground">
<Icon className="size-6" strokeWidth={1.5} aria-hidden="true" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground">{item.detail}</span>
<span className="text-muted-foreground numeric">
Qty {item.quantity}
{item.quantity > 1 && <> · {money.format(item.price)} each</>}
</span>
</span>
<span className="font-medium numeric">
{money.format(item.price * item.quantity)}
</span>
</li>
))}
</ul>
{children}
<dl className="flex flex-col gap-2.5 border-t pt-6 text-sm">
<Row label="Subtotal" value={money.format(subtotal)} />
{discount > 0 && (
<Row
label={`Discount (${promo.code})`}
value={`−${money.format(discount)}`}
/>
)}
<Row label={`Delivery · ${method.title}`} value={price(method.price)} />
<div className="mt-3 flex items-end justify-between gap-4 border-t pt-5">
<dt className="flex flex-col">
<span className="font-medium">Total</span>
<span className="text-xs text-muted-foreground numeric">
Including {money.format(vat)} VAT
</span>
</dt>
<dd className="text-heading-lg numeric">{money.format(total)}</dd>
</div>
</dl>
</div>
)
}
// One column for the header, the form and the narrow summary: centered on
// its own, pushed against the summary once the two sit side by side.
const column = "mx-auto w-full max-w-xl @4xl:mr-0"
const gutter = "px-4 @lg:px-8 @5xl:px-12"
export default function CheckoutExample() {
const [method, setMethod] = React.useState<Method>(methods[0])
const [discounted, setDiscounted] = React.useState(false)
const [order, setOrder] = React.useState<Order | null>(null)
const [summaryOpen, setSummaryOpen] = React.useState(false)
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0)
const discount = discounted ? subtotal * promo.rate : 0
const total = subtotal - discount + method.price
// Rendered twice, as a disclosure on narrow containers and a sticky column
// on wide ones; container queries show one of them.
const summary = (
<OrderSummary totals={{ subtotal, discount, total }} method={method}>
{/* A placed order can't take a code any more. */}
{!order && (
<DiscountForm applied={discounted} onAppliedChange={setDiscounted} />
)}
</OrderSummary>
)
return (
<div className="@container">
{/* Wide containers get an app-sized screen: the form scrolls in its own
pane while the summary stays in view beside it. */}
<div className="grid overflow-hidden rounded-xl border bg-background @4xl:h-[46rem] @4xl:grid-cols-[7fr_5fr] @4xl:grid-rows-1">
<div className="flex min-h-0 min-w-0 flex-col">
{/* Both header rows are h-16 including their border, so the
hairline runs straight across the two columns. */}
<header className={cn("flex h-16 shrink-0 border-b", gutter)}>
<div className={cn(column, "flex items-center justify-between")}>
<span className="text-heading-sm">Halden Studio</span>
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<LockIcon className="size-3.5" aria-hidden="true" />
{/* On the narrowest containers the lock says it alone. */}
<span className="sr-only @xs:not-sr-only">Secure checkout</span>
</span>
</div>
</header>
<Collapsible
open={summaryOpen}
onOpenChange={setSummaryOpen}
className="border-b bg-surface @4xl:hidden"
>
<CollapsibleTrigger
className={cn(
"flex w-full outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
gutter
)}
>
<span
className={cn(column, "flex h-14 items-center gap-2 text-sm")}
>
{summaryOpen ? "Hide order summary" : "Show order summary"}
<ChevronDownIcon
aria-hidden="true"
className="size-4 text-muted-foreground transition-transform duration-base ease-standard in-data-panel-open:rotate-180"
/>
<span className="ml-auto text-heading-sm numeric">
{money.format(total)}
</span>
</span>
</CollapsibleTrigger>
<CollapsibleContent className={gutter}>
<div className={cn(column, "pt-2 pb-8")}>{summary}</div>
</CollapsibleContent>
</Collapsible>
<div
className={cn(
"relative scrollbar-thin py-8 @4xl:flex-1 @4xl:overflow-y-auto @5xl:py-10",
gutter
)}
>
<div className={column}>
{order ? (
<Confirmation
order={order}
method={method}
onReset={() => setOrder(null)}
/>
) : (
<CheckoutForm
method={method}
onMethodChange={setMethod}
total={total}
onComplete={setOrder}
/>
)}
</div>
</div>
</div>
<aside
aria-labelledby="checkout-summary"
className="hidden min-h-0 flex-col border-l bg-surface @4xl:flex"
>
<h2
id="checkout-summary"
className="flex h-16 shrink-0 items-center border-b px-8 text-heading-sm @5xl:px-12"
>
Order summary
</h2>
<div className="relative flex-1 scrollbar-thin overflow-y-auto px-8 py-8 @5xl:px-12 @5xl:py-10">
<div className="max-w-md">{summary}</div>
</div>
</aside>
</div>
</div>
)
}import type { Metadata } from "next"
import Checkout from "@/components/checkout/checkout"
export const metadata: Metadata = {
title: "Checkout",
description: "A one-page checkout: contact, delivery and payment, with an order summary that stays in view.",
}
export default function CheckoutPage() {
return (
<main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
<Checkout />
</main>
)
}