結帳流程

在一個畫面中分階段進行:收件資訊 → 付款 → 確認 → 完成。前面階段填寫的內容,會在最後一起顯示。

結帳

重點

  1. 1

    用一個數字記住目前在哪個階段

    1 = 收件資訊,2 = 付款,3 = 確認,4 = 完成。用這個數字切換要顯示的畫面。「下一步」就 +1,「返回」就 −1。

  2. 2

    前進之前,只驗證目前階段的輸入

    所有階段的驗證規則集中在一個 schema 裡,按下「下一步」時只驗證目前階段的輸入欄位(trigger)。有錯誤就不能前進。

  3. 3

    在上方顯示進度

    完成的階段打 ✓,目前的階段是黑色,還沒到的階段是灰色。知道還剩幾個階段,使用者就比較不會中途放棄。

使用的工具

點擊查看第 2 部分的比較

修改程式碼

修改程式碼,右邊的預覽會跟著變化。

import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "./components/ui/button"
import { Input } from "./components/ui/input"
import { Label } from "./components/ui/label"

// 輸入驗證規則(Zod):所有步驟的欄位都寫在同一個 schema 裡
const schema = z.object({
  name: z.string().min(1, "請輸入收件人姓名"),
  zip: z.string().regex(/^\d{6}$/, "請輸入 6 位數字(例:110001)"), // 臺灣的郵遞區號(3+3 碼)是 6 位數字
  address: z.string().min(1, "請輸入地址"),
  cardNumber: z.string().regex(/^\d{4} ?\d{4} ?\d{4} ?\d{4}$/, "請輸入 16 位數的卡號"),
  expiry: z.string().regex(/^\d{2} ?\/ ?\d{2}$/, "請以 月 / 年 的格式輸入(例:12 / 28)"),
  cvc: z.string().regex(/^\d{3,4}$/, "請輸入 3~4 位數字"),
})

// 把全形數字和符號轉成半形(這樣開著中文輸入法輸入也能通過)
function toHalfWidth(value) {
  return value
    .replace(/[\uff10-\uff19]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0))
    .replace(/[\uff0d\u30fc\u2010]/g, "-")
    .replace(/\uff0f/g, "/")
    .replace(/\u3000/g, " ")
}

// 卡號:只保留數字,每 4 位加一個空格(4242424242424242 → 4242 4242 4242 4242)
function formatCardNumber(value) {
  const digits = toHalfWidth(value).replace(/\D/g, "").slice(0, 16)
  return digits.match(/.{1,4}/g)?.join(" ") ?? ""
}

// 有效期限:只保留數字,在前 2 位後面加上 / (1228 → 12 / 28)
function formatExpiry(value) {
  const digits = toHalfWidth(value).replace(/\D/g, "").slice(0, 4)
  return digits.length > 2 ? digits.slice(0, 2) + " / " + digits.slice(2) : digits
}

// 幫輸入框加上「邊輸入邊整理格式」的功能(包在 register 的結果外面使用)
// 輸入法還在選字時就改寫內容會讓文字錯亂,所以等選字結束後再整理格式
function withFormat(field, format) {
  function update(event) {
    event.target.value = format(event.target.value)
    field.onChange(event)
  }
  return {
    ...field,
    onChange: (event) => (event.nativeEvent.isComposing ? field.onChange(event) : update(event)),
    onCompositionEnd: update,
  }
}

// 每個步驟要驗證的欄位
const stepFields = {
  1: ["name", "zip", "address"],
  2: ["cardNumber", "expiry", "cvc"],
}

const stepLabels = ["收件資訊", "付款", "確認"]

// 把標籤、輸入框和錯誤訊息組合在一起
function Field({ id, label, error, children }) {
  return (
    <div className="flex flex-col gap-2">
      <Label htmlFor={id}>{label}</Label>
      {children}
      {error && <p className="text-sm text-destructive">{error}</p>}
    </div>
  )
}

// 頂端的步驟列(① 收件資訊 — ② 付款 — ③ 確認)
function StepBar({ step }) {
  return (
    <div className="mb-6 flex items-center gap-2 text-xs">
      {stepLabels.map((label, index) => {
        const number = index + 1
        return (
          <div key={label} className="flex flex-1 items-center gap-2 last:flex-none">
            <span
              // 已完成和目前的步驟(number <= step)為深色,還沒到的步驟為灰色
              className={number <= step ? "bg-primary text-primary-foreground flex size-6 shrink-0 items-center justify-center rounded-full" : "flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"}
            >
              {/* 已完成的步驟(number < step)顯示 ✓,其他顯示編號 */}
              {number < step ? "✓" : number}
            </span>
            <span className="whitespace-nowrap">{label}</span>
            {number < stepLabels.length && <span className="h-px flex-1 bg-border" />}
          </div>
        )
      })}
    </div>
  )
}

export default function App() {
  // ① 目前的步驟(1 = 收件資訊,2 = 付款,3 = 確認,4 = 完成)
  const [step, setStep] = useState(1)

  const { register, trigger, getValues, setValue, reset, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
    mode: "onChange",
  })

  // 「下一步」:先驗證目前步驟的輸入,再往下走
  async function goNext() {
    const ok = await trigger(stepFields[step]) // 只驗證目前步驟的欄位(有錯誤時顯示紅色訊息)
    if (!ok) return
    setStep(step + 1)
  }

  // 「上一步」
  function goBack() {
    // 回到上一個步驟
    setStep(step - 1)
  }

  // 「確認下單」
  function placeOrder() {
    // 進入完成步驟(4)
    setStep(4)
  }

  // 「重新開始」
  function restart() {
    reset() // 清除所有輸入
    // 回到第 1 個步驟
    setStep(1)
  }

  // 臺灣的郵遞區號沒有免費的地址查詢服務,所以這裡不自動填入地址(由使用者自行輸入)

  // 在確認步驟顯示的輸入內容
  const values = getValues()
  //console.log(step)

  return (
    <div className="mx-auto my-6 w-full max-w-md rounded-xl border bg-card p-6 text-card-foreground">
      {/* step 為 4 時不顯示 StepBar */}
      {step === 4 ? null : <StepBar step={step} />}

      {/* ① 收件資訊:只在 step 為 1 時顯示 */}
      {step === 1 && (
        <div className="flex flex-col gap-4">
          <p className="text-lg font-semibold">收件資訊</p>
          <Field id="name" label="收件人" error={errors.name?.message}>
            <Input id="name" {...register("name")} />
          </Field>
          <Field id="zip" label="郵遞區號" error={errors.zip?.message}>
            <Input
              id="zip"
              placeholder="110001"
              inputMode="numeric"
              {...register("zip", { setValueAs: toHalfWidth })}
            />
          </Field>
          <Field id="address" label="地址" error={errors.address?.message}>
            <Input id="address" {...register("address")} />
          </Field>
          <div className="flex justify-end">
            <Button type="button" onClick={goNext}>下一步</Button>
          </div>
        </div>
      )}

      {/* ② 付款:只在 step 為 2 時顯示 */}
      {step === 2 && (
        <div className="flex flex-col gap-4">
          <p className="text-lg font-semibold">付款</p>
          <Field id="cardNumber" label="信用卡卡號" error={errors.cardNumber?.message}>
            <Input
              id="cardNumber"
              placeholder="4242 4242 4242 4242"
              inputMode="numeric"
              autoComplete="cc-number"
              {...withFormat(register("cardNumber"), formatCardNumber)}
            />
          </Field>
          <div className="grid grid-cols-2 gap-3">
            <Field id="expiry" label="有效期限" error={errors.expiry?.message}>
              <Input
                id="expiry"
                placeholder="12 / 28"
                inputMode="numeric"
                autoComplete="cc-exp"
                {...withFormat(register("expiry"), formatExpiry)}
              />
            </Field>
            <Field id="cvc" label="安全碼" error={errors.cvc?.message}>
              <Input id="cvc" placeholder="123" inputMode="numeric" autoComplete="cc-csc" {...withFormat(register("cvc"), (value) => toHalfWidth(value).replace(/\D/g, "").slice(0, 4))} />
            </Field>
          </div>
          <p className="text-xs text-muted-foreground">這是示範,請勿輸入真實的信用卡卡號</p>
          <div className="flex justify-between">
            <Button type="button" variant="outline" onClick={goBack}>上一步</Button>
            <Button type="button" onClick={goNext}>下一步</Button>
          </div>
        </div>
      )}

      {/* ③ 確認:只在 step 為 3 時顯示 */}
      {step === 3 && (
        <div className="flex flex-col gap-4">
          <p className="text-lg font-semibold">確認訂單內容</p>
          <dl className="space-y-3 text-sm">
            <div className="flex justify-between gap-4">
              <dt className="text-muted-foreground">收件資訊</dt>
              <dd className="text-right">
                {values.name}
                <br />{values.zip} {values.address}
              </dd>
            </div>
            <div className="flex justify-between gap-4">
              <dt className="text-muted-foreground">付款</dt>
              {/* 卡號只顯示末 4 碼 */}
              <dd>信用卡 ****{values.cardNumber?.slice(-4)}</dd>
            </div>
            <div className="flex justify-between border-t pt-3 font-semibold">
              <dt>總計</dt>
              <dd>NT$7,200</dd>
            </div>
          </dl>
          <div className="flex justify-between">
            <Button type="button" variant="outline" onClick={goBack}>上一步</Button>
            <Button type="button" onClick={placeOrder}>確認下單</Button>
          </div>
        </div>
      )}

      {/* 完成:只在 step 為 4 時顯示 */}
      {step === 4 && (
        <div className="py-6 text-center">
          <div className="mx-auto flex size-12 items-center justify-center rounded-full bg-emerald-100 text-xl text-emerald-600">✓</div>
          <p className="mt-4 text-lg font-semibold">感謝您的訂購</p>
          <p className="mt-1 text-sm text-muted-foreground">訂單編號:A-20261003-0001</p>
          <Button type="button" variant="outline" className="mt-6" onClick={restart}>
            重新開始(示範用)
          </Button>
        </div>
      )}
    </div>
  )
}