Przejdź do treści
Wszystkie przykłady

Zamówienie

Pola kontaktu, dostawy i płatności, opcje dostawy jako karty wyboru i podsumowanie zamówienia zawsze w zasięgu wzroku.

Dodaj ten przykład do projektu z zainstalowanym prfct. Polecenie zapisuje poniższe pliki i instaluje komponenty, których używają.

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/checkout.json
components/checkout/checkout.tsx
"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&apos;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>
  )
}
app/checkout/page.tsx
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>
  )
}