← ブログ

カード拒否や価格変更にも耐える 1 ページチェックアウトを React と Next.js で作る方法

チェックアウトフォームは簡単です。カード拒否、3-D Secure、Pay の前に変わる価格、応答のない決済はそうではありません。React と Next.js で作る 1 ページチェックアウト。

Preet Suthar · · 6分で読めます

  • React
  • Next.js
  • E-commerce
  • Payments

チェックアウトのチュートリアルの多くは、フォームと Stripe を呼ぶ Pay ボタンで終わります。その部分は一発で動きます。注文を失う原因はその周りのすべてです。拒否されるカード、3-D Secure の確認を求める銀行、郵便番号で変わる送料、返ってこないリクエスト、そして Pay をもう一度押す買い物客です。

ここでは、私が React と Next.js で 1 ページのチェックアウトをどう組み立てているかを、重要な状態とそれぞれの短いコードとともに紹介します。shadcn/ui でも他のどんなコンポーネントセットでも使えます。難しいのはスタイリングではなくフローです。

1 ページに、折りたためる 3 ステップ

1 ページのチェックアウトならすべてが見える場所にあります。ステップ間でページの読み込みはなく、ブラウザの戻るボタンでフォームの半分が消えることもありません。ただ、1 枚の長いフォームは壁のようです。解決策は、同じページに連絡先・配送・支払いの 3 ステップを置き、一度に開くのは 1 つだけにすることです。

ステップが完了するとまとめの行に折りたたまれるので、完了したチェックアウトはレシートのように読めます。

Contact   [email protected]                         Change
Ship to   12 Mission St, San Francisco, CA 94110   Change
Method    Standard · Arrives Oct 26 – 28 · Free    Change

裏側の状態は小さなものです。完了したステップのリストを持ち、開いているステップはそこから導き出します。

const steps = ["contact", "delivery", "payment"] as const
type Step = (typeof steps)[number]

function openStep(done: Step[], editing: Step | null): Step {
  return editing ?? steps.find((step) => !done.includes(step)) ?? "payment"
}

しっくりくるかどうかは 2 つの細部で決まります。誰かが「変更」を押したら、開いたステップの見出しにフォーカスを移します。そうしないと、キーボードやスクリーンリーダーのユーザーはもう存在しないボタンの上に取り残されます。また、買い物客がサインインしている場合は連絡先を飛ばし、既定の住所で配送から始めます。

広い画面では注文サマリーをフォームの横に置きます。スマートフォンではフォームの上の「注文内容を表示」バーに折りたたみますが、合計は表示したままにします。合計こそ人が何度も確認する数字だからです。

blur 時に検証し、Pay 時にまとめる

フィールドのエラーは買い物客がそのフィールドを離れたときに表示し、その後は入力に合わせてリアルタイムに更新して、直った瞬間に消えるようにします。最初に入力している最中には決して表示しません。

フォームが有効になるまで Pay を無効にしないでください。無効なボタンは何が足りないのかを誰にも伝えられません。押せるようにしておき、上部に問題をすべて列挙したまとめを 1 つ置き、各項目をそのフィールドにリンクして、まとめにフォーカスを移します。このパターンは GOV.UK Design System のエラーサマリーが良い参考になります。

送料と税は住所に従う

送料と税は住所で決まるので、住所が揃ってからでないと読み込めません。入力が少し止まるのを待ち、住所がまた変わったら前のリクエストをキャンセルし、サマリーには古い数字ではなく「計算中…」と表示します。

useEffect(() => {
  if (!isComplete(address)) return
  const controller = new AbortController()
  const pause = setTimeout(async () => {
    setQuote({ status: "loading" })
    try {
      const [rates, tax] = await Promise.all([
        getRates(address, controller.signal),
        estimateTax(address, controller.signal),
      ])
      setQuote({ status: "ready", rates, tax })
    } catch {
      if (!controller.signal.aborted) setQuote({ status: "error" })
    }
  }, 300)
  return () => {
    clearTimeout(pause)
    controller.abort()
  }
}, [address])

新しい送料が届いたら、選ばれている配送方法がまだ提供されていればそのままにします。なければ一番安いものを選び、そのことを伝えます。「この住所では Express をご利用いただけません」。黙って配送方法を切り替えると、選んでいない送料を払わされることになります。

エクスプレス決済を最初に

Apple Pay、Google Pay、Shop Pay、PayPal はフォーム全体を置き換えるので、連絡先より上の一番上に置き、その下に「またはカードで支払う」の区切りを入れます。Pay ボタンの横に置くと、買い物客はより速い方法に気づく頃にはもう住所を入力し終えています。表示するのはブラウザで実際に使えるウォレットだけにします。Stripe の Express Checkout Element などの SDK はそれを判定してくれます。

Pay は boolean ではなくステートマシン

isSubmitting では Pay の後に起きることを表現できません。代わりにサーバーの応答に型を付けます。

type PlaceOrderResult =
  | { status: "succeeded"; orderId: string }
  | { status: "declined"; code: string }
  | { status: "requires_action"; clientSecret: string }
  | { status: "changed"; changes: PriceChange[] }

そして、すべてのケースを意図して処理します。

Pay は二度実行されない。 ダブルクリックは React が再レンダリングする前に 2 つのハンドラーを発火させるので、disabled={pending} のチェックだけでは足りません。ref でガードし、注文の確定中は「変更」ボタンもロックして、決済の最中にカートが変わらないようにします。

const placing = useRef(false)

async function pay() {
  if (placing.current) return
  placing.current = true
  try {
    handleResult(await placeOrder(draft, attemptKey))
  } finally {
    placing.current = false
  }
}

すべての試行に冪等キーを付ける。 Pay が押されたときに crypto.randomUUID() で生成し、決済リクエストと一緒に送ります。Stripe をはじめほとんどの決済 API は冪等キーを受け付けるので、同じキーで再試行されたリクエストが二重に課金されることはありません。

決済途中で離れるときは確認する。 決済の実行中は beforeunload リスナーを追加し、結果が確定したら外します。

カード拒否には「問題が発生しました」ではなく言葉が要る

カード拒否はチェックアウトで最もよく起きる失敗で、しかも文言が最もひどい部分です。返ってきたコードを、何をすればいいかがわかる文に対応付け、メッセージにフォーカスを移し、カード情報は入力されたままにしておきます。

const declineCopy: Record<string, string> = {
  insufficient_funds: "Your card has insufficient funds. Try another card.",
  expired_card: "This card has expired. Check the date or use another card.",
  incorrect_cvc: "The security code doesn't match. Check it and try again.",
}

const fallbackDecline =
  "Your bank declined this payment. Try another card or contact your bank."

requires_action はエラーではありません。銀行が 3-D Secure を求めているという意味で、ヨーロッパでは強力な顧客認証(SCA)のもとで日常的なことです。処理中の状態を保ったまま、「銀行で確認」というラベルのダイアログで銀行の認証画面を開き、それが解決したら続行します。

カートの後で価格が変わったとき

価格、在庫、割引はカートから Pay までの間に変わることがあります。買い物客が見た合計を注文と一緒に送り、サーバーで価格を再計算し、食い違えば課金を拒否します。

"use server"

export async function placeOrder(draft: Draft, key: string) {
  const cart = await repriceCart(draft.lines)
  if (cart.total !== draft.expectedTotal) {
    return { status: "changed", changes: diffLines(draft, cart) } as const
  }
  return chargeAndCreateOrder(cart, draft, key)
}

クライアントでは、サマリーを更新し、古い合計に取り消し線を引き、変更点を「Ring vase は $74 になりました(以前は $68)」のように平易な言葉で列挙します。買い物客は新しい金額でもう一度 Pay を押します。チェックアウト中に売り切れた商品も同じ扱いです。合計から外し、目印を付け、削除するかそれなしで続けるかを選べるようにします。

決済の応答が返ってこないとき

リクエストが止まってしまうこともあります。タイムアウト後に表示しうる最悪のものは「もう一度お試しください」です。最初の課金が通っているかもしれないからです。妥当な時間待ったら「お支払い状況を確認しています…」と表示し、注文が存在するかどうかわかるまで、同じ冪等キーでステータスのエンドポイントをポーリングします。新しい試行を提案するのは、前の試行が失敗したとサーバーが言ったときだけです。

チェックリスト

  • ステップはまとめの行に折りたたまれ、「変更」は開き直したステップにフォーカスを移す。
  • エラーは blur 時に、Pay 時にはリンク付きのまとめを 1 つ、Pay ボタンは無効にしない。
  • 送料と税は住所が揃うまで待ち、古いリクエストはキャンセルし、古い合計は決して表示しない。
  • エクスプレスウォレットは一番上に、ブラウザが対応しているものだけ。
  • Pay の周りに ref ガード、冪等キー、beforeunload の確認を置く。
  • 拒否コードごとに具体的な文言を出し、フォームは入力されたまま。
  • 3-D Secure はエラーではなくステップとして扱う。
  • 価格の変更や売り切れは課金を止め、理由を説明する。
  • タイムアウト時はもう一度支払わせるのではなくステータスを確認する。

shadcn/ui で作る

shadcn/ui はこのすべてに使える入力欄、ラジオグループ、折りたたみを提供してくれます。上のフローは自分で書く部分で、それが作業の大半です。

私はこれを一度、HextaUI Pro の Checkout ブロックとして、無料の HextaUI コンポーネントから作りました。カートと 3 つのコールバック onGetRates、onEstimateTax、onPlaceOrder を受け取り、上の 4 つの結果を処理します。onPlaceOrder が 20 秒以内に応答しなければ、もう一度支払わせるのではなく、同じ冪等キーで onCheckStatus をポーリングします。server actions を使った Stripe PaymentIntents、Shopify チェックアウトへの引き渡し、Medusa の例が付いています。ウォレット、カード、カード拒否、3-D Secure には Payment ブロックを、配送には Address Form を使っています。どのブロックにもライブプレビューがあり、何かを決める前に実際に操作できます。

ストアが Shopify で動いているなら、作り直さずに Shopify のホスト型チェックアウトを使ってください。独自のチェックアウトが意味を持つのは、自分で決済を受ける場合です。

よくある質問

1 ページのチェックアウトは複数ステップのものより良いですか? ほとんどのストアでは、ステップが折りたたまれる 1 ページ構成が両方の良いところを取れます。すべてが見える場所にありつつ、一度に向き合うのは 1 セクションだけです。

フォームが有効になるまで Pay ボタンを無効にすべきですか? いいえ。押せるようにしておき、足りない項目のまとめを各フィールドへのリンク付きで表示します。

二重課金はどう防げばいいですか? ハンドラーを ref でガードし、すべての試行に冪等キーを付けて送り、タイムアウトした決済はもう一度支払わせるのではなくステータスを確認します。

Apple Pay と Google Pay はどこに置くべきですか? チェックアウトの一番上、連絡先の前です。フォームの残りすべてを置き換えるものだからです。