# 订单结算

> 地址、配送、发票与支付方式组成的订单结算区块。

A shadcn/ui block from the [@yiui](https://yiui.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/yiui/checkout-01).

- Page, with a live preview: https://shadcn.exchange/r/yiui/checkout-01
- Screenshot: https://shadcn.exchange/shot/yiui/checkout-01/light.png
- Registry item: https://yiui.dev/r/checkout-01.json
- Listed: 2026-11-01, source last changed 2026-11-01

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/yiui/checkout-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `button`, `radio-group`, `dialog`, `separator`, `https://yiui.dev/r/region-picker.json`, `https://yiui.dev/r/phone-input.json`, `https://yiui.dev/r/id-number-input.json`, `https://yiui.dev/r/segmented-control.json`, `https://yiui.dev/r/timeline.json`, `https://yiui.dev/r/chinese-number.json`, `https://yiui.dev/r/bar-field.json`

## Files

### `src/registry/blocks/checkout-01/page.tsx`

```tsx
"use client"
import { Checkout01 } from "@/components/checkout-01/checkout-01"

/**
 * 结算页入口：把下面的示例数据换成你的地址簿、购物车和支付方式，
 * onSubmit 里调用你的下单接口，返回订单号（和可选的进度时间线）。
 */
const regions = [
  {
    code: "330000",
    name: "浙江省",
    children: [
      {
        code: "330100",
        name: "杭州市",
        children: [{ code: "330106", name: "西湖区" }],
      },
    ],
  },
]
const addresses = [
  {
    id: "home",
    name: "林小满",
    phone: "13800138000",
    regionCode: "330106",
    region: "浙江省 杭州市 西湖区",
    detail: "示例路 18 号 302 室",
  },
]
const items = [
  {
    id: "shirt",
    title: "亚麻衬衫",
    variant: "原色 / M",
    quantity: 1,
    price: 269,
  },
  { id: "bag", title: "帆布托特包", variant: "米色", quantity: 1, price: 89 },
]
// 支付方式图标：微信支付 / 支付宝的品牌图形（路径取自 Simple Icons，CC0），颜色跟随色调变量
const paymentMethods = [
  {
    id: "wechat",
    label: "微信支付",
    icon: (
      <span className="grid size-6 place-items-center rounded-md bg-(--flow-green)/12 text-(--flow-green)">
        <svg
          viewBox="0 0 24 24"
          aria-hidden="true"
          className="size-4 fill-current"
        >
          <path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z" />
        </svg>
      </span>
    ),
  },
  {
    id: "alipay",
    label: "支付宝",
    icon: (
      <span className="grid size-6 place-items-center rounded-md bg-(--flow-blue)/12 text-(--flow-blue)">
        <svg
          viewBox="0 0 24 24"
          aria-hidden="true"
          className="size-4 fill-current"
        >
          <path d="M19.695 15.07c3.426 1.158 4.203 1.22 4.203 1.22V3.846c0-2.124-1.705-3.845-3.81-3.845H3.914C1.808.001.102 1.722.102 3.846v16.31c0 2.123 1.706 3.845 3.813 3.845h16.173c2.105 0 3.81-1.722 3.81-3.845v-.157s-6.19-2.602-9.315-4.119c-2.096 2.602-4.8 4.181-7.607 4.181-4.75 0-6.361-4.19-4.112-6.949.49-.602 1.324-1.175 2.617-1.497 2.025-.502 5.247.313 8.266 1.317a16.796 16.796 0 0 0 1.341-3.302H5.781v-.952h4.799V6.975H4.77v-.953h5.81V3.591s0-.409.411-.409h2.347v2.84h5.744v.951h-5.744v1.704h4.69a19.453 19.453 0 0 1-1.986 5.06c1.424.52 2.702 1.011 3.654 1.333m-13.81-2.032c-.596.06-1.71.325-2.321.869-1.83 1.608-.735 4.55 2.968 4.55 2.151 0 4.301-1.388 5.99-3.61-2.403-1.182-4.438-2.028-6.637-1.809" />
        </svg>
      </span>
    ),
  },
]
export default function CheckoutPage() {
  return (
    <Checkout01
      addresses={addresses}
      regions={regions}
      items={items}
      paymentMethods={paymentMethods}
      discount={20}
      shippingOptions={[
        { id: "express", label: "快递配送", fee: 8, eta: "预计 2–3 天送达" },
        {
          id: "pickup",
          label: "到店自提",
          fee: 0,
          pickup: true,
          description: "示例生活馆 · 示例路 18 号",
        },
      ]}
      onSubmit={async (payload) => {
        // TODO: 调用你的下单接口，失败时 throw new Error("中文原因")
        console.info("submit order", payload)
        await new Promise((resolve) => setTimeout(resolve, 600))
        return { orderId: "EX20261030001" }
      }}
    />
  )
}
export { regions, addresses, items, paymentMethods }
```

### `src/registry/blocks/checkout-01/checkout-01.tsx`

```tsx
"use client"
import * as React from "react"
import { Package } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogTitle,
  DialogDescription,
} from "@/components/ui/dialog"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Separator } from "@/components/ui/separator"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import {
  IdNumberInput,
  isValidUscc,
} from "@/components/ui/id-number-input/id-number-input"
import { toRmbUppercase } from "@/lib/chinese-number"
import { CheckoutCompletion, CheckoutProgress } from "./completion"
import { cn } from "@/lib/utils"
import { DEFAULT_LABELS, money } from "./config"
import {
  CheckoutAddressForm,
  CheckoutAddressCard,
  CheckoutPaymentOptions,
  CheckoutTextField,
} from "./form-parts"
import type { Checkout01Props, CheckoutResult, Invoice } from "./types"

function Checkout01({
  addresses,
  defaultAddressId,
  regions,
  shippingOptions,
  items,
  discount = 0,
  paymentMethods,
  invoice: initialInvoice = { type: "none" },
  onAddressCreate,
  onSubmit,
  onViewOrder,
  onContinue,
  labels,
  className,
  ...props
}: Checkout01Props) {
  const t = { ...DEFAULT_LABELS, ...labels }
  const root = React.useRef<HTMLDivElement>(null)
  const [local, setLocal] = React.useState(addresses)
  const all = [
    ...addresses,
    ...local.filter((a) => !addresses.some((b) => b.id === a.id)),
  ]
  const [addressId, setAddressId] = React.useState(
    defaultAddressId ?? addresses[0]?.id ?? "",
  )
  const [shippingId, setShippingId] = React.useState(
    shippingOptions[0]?.id ?? "",
  )
  const [paymentId, setPaymentId] = React.useState(
    paymentMethods.find((m) => !m.disabled)?.id ?? "",
  )
  const [invoice, setInvoice] = React.useState<Invoice>(initialInvoice)
  const [narrow, setNarrow] = React.useState(false)
  React.useEffect(() => {
    const element = root.current
    if (!element) return
    const observer = new ResizeObserver(([entry]) =>
      setNarrow(entry.contentRect.width < 768),
    )
    observer.observe(element)
    return () => observer.disconnect()
  }, [])
  const [open, setOpen] = React.useState(false)
  const [adding, setAdding] = React.useState(false)
  const [attempt, setAttempt] = React.useState(false)
  const [pending, setPending] = React.useState(false)
  const [error, setError] = React.useState("")
  const [resultTime, setResultTime] = React.useState("")
  const [result, setResult] = React.useState<CheckoutResult | null>(null)
  const address = all.find((a) => a.id === addressId)
  const shipping = shippingOptions.find((s) => s.id === shippingId)
  const subtotal =
    items.reduce(
      (sum, item) => sum + Math.round(item.price * 100) * item.quantity,
      0,
    ) / 100
  const total = Math.max(
    0,
    Math.round((subtotal + (shipping?.fee ?? 0) - discount) * 100) / 100,
  )
  const invoiceInvalid =
    invoice.type === "company" &&
    (!invoice.title?.trim() || !isValidUscc(invoice.taxId ?? ""))
  async function submit() {
    if (pending) return
    setAttempt(true)
    setError("")
    const invalid =
      !shipping?.pickup && !address
        ? "address"
        : !shipping
          ? "shipping"
          : invoiceInvalid
            ? "invoice"
            : !paymentMethods.some((m) => m.id === paymentId && !m.disabled)
              ? "payment"
              : null
    if (invalid) {
      const node = root.current?.querySelector<HTMLElement>(
        '[data-module="' + invalid + '"]',
      )
      node?.scrollIntoView({ block: "center" })
      if (invalid === "invoice") {
        const field = node?.querySelector<HTMLElement>(
          invoice.title?.trim()
            ? "[data-invoice-tax] input"
            : "[data-invoice-title] input",
        )
        field?.focus()
      } else node?.querySelector<HTMLElement>("button, input")?.focus()
      return
    }
    setPending(true)
    try {
      setResultTime(
        new Intl.DateTimeFormat("zh-CN", {
          hour: "2-digit",
          minute: "2-digit",
        }).format(new Date()),
      )
      setResult(
        await onSubmit({
          address: shipping?.pickup ? undefined : address,
          shippingId,
          paymentId,
          invoice,
          items,
          total,
        }),
      )
    } catch (e) {
      setError(e instanceof Error ? e.message : t.failed)
    } finally {
      setPending(false)
    }
  }
  const submitButton = (size: "default" | "lg", className?: string) => (
    <Button
      size={size}
      className={className}
      disabled={pending}
      onClick={submit}
    >
      {pending ? t.pending : t.submit}
    </Button>
  )
  if (result)
    return (
      <div
        data-slot="checkout-01"
        className={cn(
          "grid min-h-[42rem] place-items-center bg-muted/30 px-4 py-10",
          className,
        )}
        {...props}
      >
        <CheckoutCompletion
          orderId={result.orderId}
          total={total}
          payment={paymentMethods.find((m) => m.id === paymentId)?.label}
          labels={labels}
          items={
            result.timeline ?? [
              { title: t.created, time: resultTime, status: "done" },
              { title: t.processing, status: "upcoming" },
            ]
          }
          onViewOrder={() => onViewOrder?.(result.orderId)}
          onContinue={onContinue}
        />
      </div>
    )
  const section =
    "grid scroll-m-8 gap-4 border-b border-edge p-6 outline-none focus-visible:ring-2 focus-visible:ring-ring"
  return (
    <div
      ref={root}
      data-slot="checkout-01"
      className={cn(
        "@container/checkout bg-background text-foreground",
        className,
      )}
      {...props}
    >
      <div className="grid pb-[calc(6rem+env(safe-area-inset-bottom))] @3xl/checkout:grid-cols-[1fr_22rem] @3xl/checkout:pb-0">
        <div className="min-w-0">
          <section data-module="address" tabIndex={-1} className={section}>
            <div className="flex items-center justify-between">
              <h2 className="font-semibold">
                {shipping?.pickup ? t.pickup : t.address}
              </h2>
              {!shipping?.pickup && (
                <Button
                  size="default"
                  variant="outline"
                  onClick={() => setOpen(true)}
                >
                  {t.change}
                </Button>
              )}
            </div>
            {shipping?.pickup ? (
              <p>{shipping.description}</p>
            ) : address ? (
              <CheckoutAddressCard address={address} />
            ) : (
              <p>{t.empty}</p>
            )}
            {attempt && !shipping?.pickup && !address && (
              <p role="alert" className="text-destructive">
                {t.invalid}
              </p>
            )}
          </section>
          <section data-module="shipping" tabIndex={-1} className={section}>
            <h2 className="font-semibold">{t.shipping}</h2>
            <RadioGroup
              aria-label={t.shipping}
              value={shippingId}
              onValueChange={(v) => setShippingId(String(v))}
            >
              {shippingOptions.map((s) => (
                <label
                  key={s.id}
                  className="flex cursor-pointer items-center gap-3 rounded-md border border-edge p-4 has-checked:border-primary"
                >
                  <RadioGroupItem value={s.id} />
                  <span className="flex-1">
                    {s.label}
                    <small className="block text-muted-foreground">
                      {s.eta}
                    </small>
                  </span>
                  <span className="font-mono text-sm">
                    {s.fee ? money(s.fee) : t.free}
                  </span>
                </label>
              ))}
            </RadioGroup>
          </section>
          <section data-module="invoice" tabIndex={-1} className={section}>
            <h2 className="flex justify-between font-semibold">
              {t.invoice}
              <small className="font-normal text-muted-foreground">
                {t.electronic}
              </small>
            </h2>
            <SegmentedControl
              aria-label={t.invoice}
              value={invoice.type}
              onValueChange={(type) =>
                setInvoice({
                  type: type as Invoice["type"],
                  title: type === "personal" ? t.personal : "",
                  taxId: "",
                })
              }
              options={[
                { value: "none", label: t.none },
                { value: "personal", label: t.personal },
                { value: "company", label: t.company },
              ]}
            />
            {invoice.type !== "none" && (
              <div data-invoice-title>
                <CheckoutTextField
                  label={t.title}
                  value={invoice.title ?? ""}
                  error={
                    attempt && !invoice.title?.trim()
                      ? t.titleRequired
                      : undefined
                  }
                  onChange={(e) =>
                    setInvoice({ ...invoice, title: e.target.value })
                  }
                />
              </div>
            )}
            {invoice.type === "company" && (
              <>
                <div data-invoice-tax>
                  <IdNumberInput
                    error={
                      attempt && !isValidUscc(invoice.taxId ?? "")
                        ? t.taxIdInvalid
                        : undefined
                    }
                    kind="uscc"
                    label={t.taxId}
                    value={invoice.taxId ?? ""}
                    onValueChange={(taxId) => setInvoice({ ...invoice, taxId })}
                  />
                </div>
                <p className="text-xs text-muted-foreground">{t.taxHint}</p>
              </>
            )}
          </section>
          <section data-module="payment" tabIndex={-1} className={section}>
            <h2 className="font-semibold">{t.payment}</h2>
            <CheckoutPaymentOptions
              label={t.payment}
              methods={paymentMethods}
              value={paymentId}
              onValueChange={setPaymentId}
            />
          </section>
        </div>
        <aside className="min-w-0 border-edge @3xl/checkout:border-l">
          <div className="grid gap-5 p-6 @3xl/checkout:sticky @3xl/checkout:top-6">
            <h2 className="font-semibold">{t.summary}</h2>
            {items.map((item) => (
              <div key={item.id} className="flex items-center gap-3">
                <div className="flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-edge bg-muted">
                  {item.image ? (
                    // eslint-disable-next-line @next/next/no-img-element -- 图片地址由使用方提供，可能是任意外部域名，不走 next/image
                    <img
                      src={item.image}
                      alt=""
                      className="size-full object-cover"
                    />
                  ) : (
                    <Package className="size-5 text-muted-foreground" />
                  )}
                </div>
                <div className="min-w-0 flex-1 text-sm">
                  <p>{item.title}</p>
                  <p className="text-xs text-muted-foreground">
                    {item.variant} × {item.quantity}
                  </p>
                </div>
                <span className="font-mono text-sm">
                  {money(item.price * item.quantity)}
                </span>
              </div>
            ))}
            <Separator />
            {[
              [t.subtotal, subtotal],
              [t.freight, shipping?.fee ?? 0],
              [t.discount, -discount],
            ].map(([label, value]) => (
              <div key={String(label)} className="flex justify-between text-sm">
                <span className="text-muted-foreground">{label}</span>
                <span className="font-mono">{money(Number(value))}</span>
              </div>
            ))}
            <Separator />
            <div className="flex items-baseline justify-between">
              <span>{t.total}</span>
              <strong className="font-mono text-3xl">{money(total)}</strong>
            </div>
            <p className="text-xs text-muted-foreground">
              {toRmbUppercase(total)}
            </p>
            {error && (
              <p role="alert" className="text-sm text-destructive">
                {error}
              </p>
            )}
            <div className="hidden @3xl/checkout:grid">
              {submitButton("lg")}
            </div>
          </div>
        </aside>
      </div>
      <div className="fixed inset-x-0 bottom-0 z-30 flex items-center justify-between border-t border-edge bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))] @3xl/checkout:hidden">
        <span>
          {t.total} <strong className="font-mono">{money(total)}</strong>
        </span>
        {submitButton("lg", "w-1/2")}
      </div>
      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent
          data-narrow={narrow || undefined}
          className="flex max-h-[85dvh] min-w-0 flex-col overflow-hidden data-[narrow]:top-auto data-[narrow]:bottom-0 data-[narrow]:translate-y-0 data-[narrow]:rounded-b-none max-sm:top-auto max-sm:bottom-0 max-sm:translate-y-0 max-sm:rounded-b-none pointer-coarse:top-auto pointer-coarse:bottom-0 pointer-coarse:translate-y-0"
        >
          <DialogTitle className="shrink-0">{t.address}</DialogTitle>
          {all.length === 0 ? (
            <DialogDescription>{t.empty}</DialogDescription>
          ) : (
            <DialogDescription className="sr-only">
              {t.address}
            </DialogDescription>
          )}
          <div className="min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain pb-[max(1rem,env(safe-area-inset-bottom))]">
            {all.length > 0 && (
              <RadioGroup
                aria-label={t.address}
                value={addressId}
                onValueChange={(id) => {
                  setAddressId(String(id))
                  setOpen(false)
                }}
              >
                {all.map((address) => (
                  <label
                    key={address.id}
                    className="flex cursor-pointer items-start gap-3 rounded-md border border-edge p-4 has-checked:border-primary has-checked:bg-primary/5"
                  >
                    <RadioGroupItem
                      value={address.id}
                      className="mt-0.5"
                      onClick={() => {
                        setAddressId(address.id)
                        setOpen(false)
                      }}
                    />
                    <CheckoutAddressCard address={address} />
                  </label>
                ))}
              </RadioGroup>
            )}
            <Button
              size="default"
              variant="outline"
              className="my-4 w-full"
              aria-expanded={adding}
              onClick={() => setAdding(!adding)}
            >
              {t.add}
            </Button>
            {adding && (
              <CheckoutAddressForm
                regions={regions}
                labels={labels}
                onSave={async (addr) => {
                  const saved = (await onAddressCreate?.(addr)) ?? addr
                  setLocal([...all, saved])
                  setAddressId(saved.id)
                  setOpen(false)
                  setAdding(false)
                }}
              />
            )}
          </div>
        </DialogContent>
      </Dialog>
    </div>
  )
}
export { Checkout01, CheckoutProgress }
export type { Checkout01Props } from "./types"
```

### `src/registry/blocks/checkout-01/types.ts`

```ts
import type { ComponentProps, ReactNode } from "react"
import type { RegionOption } from "@/components/ui/region-picker/region-picker"
type Address = {
  id: string
  name: string
  phone: string
  regionCode: string
  region: string
  detail: string
}
type ShippingOption = {
  id: string
  label: string
  description?: string
  fee: number
  eta?: string
  pickup?: boolean
}
type CheckoutItem = {
  id: string
  title: string
  variant?: string
  quantity: number
  price: number
  image?: string
}
type PaymentMethod = {
  id: string
  label: string
  description?: string
  icon?: ReactNode
  disabled?: boolean
}
type Invoice = {
  type: "none" | "personal" | "company"
  title?: string
  taxId?: string
}
type CheckoutTimelineItem = {
  title: string
  description?: string
  time?: string
  status?: "done" | "current" | "upcoming"
}
type CheckoutPayload = {
  address?: Address
  shippingId: string
  paymentId: string
  invoice: Invoice
  items: CheckoutItem[]
  total: number
}
type CheckoutResult = { orderId: string; timeline?: CheckoutTimelineItem[] }
type Checkout01Props = Omit<ComponentProps<"div">, "onSubmit"> & {
  addresses: Address[]
  defaultAddressId?: string
  regions: RegionOption[]
  shippingOptions: ShippingOption[]
  items: CheckoutItem[]
  discount?: number
  paymentMethods: PaymentMethod[]
  invoice?: Invoice
  onAddressCreate?: (address: Address) => Promise<Address> | Address
  onSubmit: (payload: CheckoutPayload) => Promise<CheckoutResult>
  onViewOrder?: (orderId: string) => void
  onContinue?: () => void
  labels?: Partial<typeof import("./config").DEFAULT_LABELS>
}
export type {
  Address,
  ShippingOption,
  CheckoutItem,
  PaymentMethod,
  Invoice,
  CheckoutPayload,
  CheckoutResult,
  CheckoutTimelineItem,
  Checkout01Props,
}
```

### `src/registry/blocks/checkout-01/config.ts`

```ts
const DEFAULT_LABELS = {
  address: "收货地址",
  change: "更换",
  add: "新增地址",
  save: "保存地址",
  name: "收货人",
  phone: "手机号",
  region: "省市区",
  detail: "详细地址",
  shipping: "配送方式",
  pickup: "自提门店",
  free: "免运费",
  invoice: "发票",
  electronic: "电子普通发票",
  none: "不开发票",
  personal: "个人",
  company: "企业",
  title: "发票抬头",
  taxId: "统一社会信用代码",
  taxHint: "企业发票请填写与登记信息一致的统一社会信用代码",
  payment: "支付方式",
  summary: "订单摘要",
  subtotal: "商品金额",
  freight: "运费",
  discount: "优惠",
  total: "实付",
  submit: "提交订单",
  pending: "提交中…",
  invalid: "请完善此模块的信息",
  nameRequired: "请填写收货人",
  phoneInvalid: "请输入有效的 11 位手机号",
  regionRequired: "请选择完整的省市区",
  detailRequired: "请填写详细地址",
  titleRequired: "请填写发票抬头",
  taxIdInvalid: "请输入有效的统一社会信用代码",
  copy: "复制订单号",
  copied: "已复制",
  paid: "已支付",
  copyFailed: "复制失败，请手动复制",
  addressInvalid: "请填写完整地址和有效手机号",
  taxInvalid: "请填写抬头和有效的统一社会信用代码",
  failed: "操作失败，请重试",
  success: "订单已提交",
  orderId: "订单号",
  view: "查看订单",
  continue: "继续购物",
  empty: "暂无地址，请新增收货地址",
  created: "已下单",
  processing: "等待处理",
  saving: "保存中…",
}
const money = (value: number) =>
  new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(
    value,
  )
export { DEFAULT_LABELS, money }
```

### `src/registry/blocks/checkout-01/form-parts.tsx`

```tsx
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { PhoneInput } from "@/components/ui/phone-input/phone-input"
import {
  RegionPicker,
  type RegionOption,
} from "@/components/ui/region-picker/region-picker"
import { BarField } from "@/components/ui/bar-field/bar-field"
import { DEFAULT_LABELS } from "./config"
import type { Address, PaymentMethod } from "./types"

function CheckoutTextField({
  label,
  error,
  className,
  value,
  defaultValue,
  ...props
}: Omit<React.ComponentProps<"input">, "size"> & {
  label: string
  error?: string
}) {
  return (
    <BarField
      {...props}
      value={value === undefined ? undefined : String(value)}
      defaultValue={
        defaultValue === undefined ? undefined : String(defaultValue)
      }
      label={label}
      error={error}
      className={className}
    />
  )
}

function CheckoutAddressForm({
  regions,
  formId,
  hideSubmit = false,
  onSave,
  labels: overrides,
}: {
  regions: RegionOption[]
  formId?: string
  hideSubmit?: boolean
  onSave: (address: Address) => Promise<void>
  labels?: Partial<typeof DEFAULT_LABELS>
}) {
  const t = { ...DEFAULT_LABELS, ...overrides }
  const [value, setValue] = React.useState<Address>({
    id: "",
    name: "",
    phone: "",
    regionCode: "",
    region: "",
    detail: "",
  })
  const [attempt, setAttempt] = React.useState(false)
  const [pending, setPending] = React.useState(false)
  const [error, setError] = React.useState("")
  const errors = {
    name: !value.name.trim() ? t.nameRequired : undefined,
    phone: !/^1[3-9]\d{9}$/.test(value.phone) ? t.phoneInvalid : undefined,
    region: !value.regionCode ? t.regionRequired : undefined,
    detail: !value.detail.trim() ? t.detailRequired : undefined,
  }
  const invalid = Object.values(errors).some(Boolean)
  return (
    <form
      id={formId}
      data-slot="checkout-address-form"
      className="grid gap-3"
      noValidate
      onSubmit={async (e) => {
        e.preventDefault()
        setAttempt(true)
        if (invalid) {
          const fields =
            e.currentTarget.querySelectorAll<HTMLElement>("[data-field]")
          const first = Array.from(fields).find(
            (field) => errors[field.dataset.field as keyof typeof errors],
          )
          first?.scrollIntoView({ block: "center" })
          first
            ?.querySelector<HTMLElement>('input:not([type="hidden"]), button')
            ?.focus()
          return
        }
        setPending(true)
        setError("")
        try {
          await onSave({ ...value, id: crypto.randomUUID() })
        } catch (e) {
          setError(e instanceof Error ? e.message : t.failed)
        } finally {
          setPending(false)
        }
      }}
    >
      <div data-field="name">
        <CheckoutTextField
          error={attempt ? errors.name : undefined}
          label={t.name}
          autoComplete="shipping name"
          value={value.name}
          onChange={(e) => setValue({ ...value, name: e.target.value })}
        />
      </div>
      <div data-field="phone">
        <PhoneInput
          error={attempt ? errors.phone : undefined}
          label={t.phone}
          value={value.phone}
          onValueChange={(phone) => setValue({ ...value, phone })}
        />
      </div>
      <div data-field="region">
        <RegionPicker
          error={attempt ? errors.region : undefined}
          label={t.region}
          regions={regions}
          value={value.regionCode || null}
          onValueChange={(code, path) =>
            setValue({
              ...value,
              regionCode: code ?? "",
              region: path.map((x) => x.name).join(" "),
            })
          }
        />
      </div>
      <div data-field="detail">
        <CheckoutTextField
          error={attempt ? errors.detail : undefined}
          label={t.detail}
          autoComplete="shipping street-address"
          value={value.detail}
          onChange={(e) => setValue({ ...value, detail: e.target.value })}
        />
      </div>
      {error && <p role="alert">{error}</p>}
      <Button
        type="submit"
        size="default"
        className={hideSubmit ? "hidden" : undefined}
        disabled={pending}
      >
        {pending ? t.saving : t.save}
      </Button>
    </form>
  )
}
function CheckoutPaymentOptions({
  methods,
  value,
  onValueChange,
  label,
}: {
  methods: PaymentMethod[]
  value: string
  onValueChange: (value: string) => void
  label: string
}) {
  return (
    <RadioGroup
      data-slot="checkout-payment-options"
      aria-label={label}
      value={value}
      onValueChange={(v) => onValueChange(String(v))}
    >
      {methods.map((m) => (
        <label
          key={m.id}
          className="flex cursor-pointer items-center gap-3 rounded-md border border-edge p-4 has-checked:border-primary has-disabled:opacity-50"
        >
          <RadioGroupItem value={m.id} disabled={m.disabled} />
          {m.icon}
          <span className="flex-1">
            {m.label}
            <small className="block text-muted-foreground">
              {m.description}
            </small>
          </span>
        </label>
      ))}
    </RadioGroup>
  )
}
function CheckoutAddressCard({ address }: { address: Address }) {
  return (
    <div
      data-slot="checkout-address-card"
      className="min-w-0 flex-1 space-y-2 text-sm"
    >
      <p className="font-medium">
        {address.name}{" "}
        <span className="ml-3 font-mono">
          {address.phone.replace(/(\d{3})(\d{4})(\d{4})/, "$1 $2 $3")}
        </span>
      </p>
      <p className="text-muted-foreground">
        {address.region} {address.detail}
      </p>
    </div>
  )
}
export {
  CheckoutTextField,
  CheckoutAddressForm,
  CheckoutPaymentOptions,
  CheckoutAddressCard,
}
```

### `src/registry/blocks/checkout-01/completion.tsx`

```tsx
"use client"
import * as React from "react"
import { Check, Copy } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
  Timeline,
  TimelineItem,
  TimelineMarker,
  TimelineConnector,
  TimelineContent,
  TimelineTitle,
  TimelineDescription,
  TimelineTime,
} from "@/components/ui/timeline/timeline"
import { DEFAULT_LABELS, money } from "./config"
import type { CheckoutTimelineItem } from "./types"

function CheckoutProgress({ items }: { items: CheckoutTimelineItem[] }) {
  return (
    <Timeline data-slot="checkout-progress">
      {items.map((item, i) => {
        const status = item.status ?? "done"
        return (
          <TimelineItem key={i} status={status}>
            <TimelineMarker
              status={status}
              tone={status === "done" ? "primary" : "muted"}
            >
              {status === "done" ? <Check /> : i + 1}
            </TimelineMarker>
            {i < items.length - 1 && <TimelineConnector />}
            <TimelineContent
              className={
                "pb-4 " + (status === "upcoming" ? "text-muted-foreground" : "")
              }
            >
              <div className="flex items-baseline justify-between gap-2">
                <TimelineTitle>{item.title}</TimelineTitle>
                {item.time && (
                  <TimelineTime className="font-mono text-xs">
                    {item.time}
                  </TimelineTime>
                )}
              </div>
              {item.description && (
                <TimelineDescription>{item.description}</TimelineDescription>
              )}
            </TimelineContent>
          </TimelineItem>
        )
      })}
    </Timeline>
  )
}
function CheckoutCompletion({
  orderId,
  total,
  payment,
  items,
  labels,
  onViewOrder,
  onContinue,
}: {
  orderId: string
  total: number
  payment?: string
  items: CheckoutTimelineItem[]
  labels?: Partial<typeof DEFAULT_LABELS>
  onViewOrder?: () => void
  onContinue?: () => void
}) {
  const t = { ...DEFAULT_LABELS, ...labels }
  const [copyState, setCopyState] = React.useState<"idle" | "done" | "failed">(
    "idle",
  )
  return (
    <div
      data-slot="checkout-completion"
      className="mx-auto grid w-full max-w-md gap-6 rounded-xl border border-edge bg-card p-6 text-card-foreground sm:p-8"
    >
      <div className="grid justify-items-center gap-3 text-center">
        <span
          data-slot="checkout-success-badge"
          className="flex size-16 items-center justify-center rounded-full bg-primary/10 text-primary"
        >
          <Check className="size-8" />
        </span>
        <h2 className="text-2xl font-semibold">{t.success}</h2>
        <div className="flex flex-wrap items-center justify-center gap-2 text-xs text-muted-foreground">
          <span>{t.orderId}</span>
          <span className="font-mono text-foreground">{orderId}</span>
          <Button
            variant="ghost"
            size="icon"
            aria-label={copyState === "done" ? t.copied : t.copy}
            onClick={async () => {
              try {
                await navigator.clipboard.writeText(orderId)
                setCopyState("done")
              } catch {
                setCopyState("failed")
              }
            }}
          >
            {copyState === "done" ? <Check /> : <Copy />}
          </Button>
        </div>
        <span role="status" className="sr-only">
          {copyState === "done"
            ? t.copied
            : copyState === "failed"
              ? t.copyFailed
              : ""}
        </span>
        <p className="text-xs text-muted-foreground">{t.total}</p>
        <p className="font-mono text-4xl font-semibold tracking-tight">
          {money(total)}
        </p>
        <p className="text-sm text-muted-foreground">{payment}</p>
      </div>
      <div className="border-t border-edge pt-6">
        <CheckoutProgress items={items} />
      </div>
      <div className="grid grid-cols-2 gap-3">
        <Button size="default" onClick={onViewOrder}>
          {t.view}
        </Button>
        <Button size="default" variant="outline" onClick={onContinue}>
          {t.continue}
        </Button>
      </div>
    </div>
  )
}
export { CheckoutProgress, CheckoutCompletion }
```
