購入手続き

お届け先 → お支払い → 確認 → 完了 と、1つの画面を段階に分けて進めます。前の段階で入れた内容は、最後にまとめて見せます。

購入手続き

ここがポイント

  1. 1

    今どの段階かを、1つの数字で覚える

    1=お届け先、2=お支払い、3=確認、4=完了。この数字で、出す画面を切り替える。「次へ」は+1、「戻る」は−1

  2. 2

    進む前に、その段階の入力だけ確かめる

    全部の段階の入力チェックを1つにまとめておき、「次へ」では今の段階の入力欄だけを確かめる(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)。全部の段階の入力を、1つにまとめて書く
const schema = z.object({
  name: z.string().min(1, "お名前を入力してください"),
  zip: z.string().regex(/^\d{3}-?\d{4}$/, "7けたの数字で入力してください(例:1500002)"), // ハイフン(-)はあってもなくてもよい
  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(/[0-9]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0))
    .replace(/[-ー‐]/g, "-")
    .replace(///g, "/")
    .replace(/ /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 = ["お届け先", "お支払い", "確認"]

// ラベル・入力欄・エラーの文を、1つのまとまりにする
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=完了)。今は決まった値。ここを state にする
  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)
  }

  // 郵便番号が7けたそろったら、住所を調べて、住所の欄に入れる(zipcloud という無料のサービスを使う)
  async function fillAddress(zip) {
    const digits = toHalfWidth(zip).replace("-", "") // ハイフンを取って、数字だけにする
    if (!/^\d{7}$/.test(digits)) return // 7けたの数字でなければ、何もしない
    const response = await fetch("https://zipcloud.ibsnet.co.jp/api/search?zipcode=" + digits)
    const data = await response.json()
    if (!data.results) return // 見つからなかったら、何もしない(自分で入れてもらう)
    const found = data.results[0]
    setValue("address", found.address1 + found.address2 + found.address3, { shouldValidate: true })
  }

  // 確認の画面で見せる、入力した内容
  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="1500002"
              inputMode="numeric"
              {...register("zip", { setValueAs: toHalfWidth, onChange: (event) => fillAddress(event.target.value) })}
            />
          </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>¥36,060</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>
  )
}