Skip to content

UI Design: 有料投票フロー(複数パッケージ組み合わせ購入)

機能は requirements.md、技術は design.md 参照。デザイントークンは home/ui-design.md 参照。


モーダル1: PurchaseCreditsModal(複数パッケージの組み合わせ購入)

レイアウト

┌───────────────────────────────────────────────┐
│                                          [✕] │
│         🛒 有料で投票                          │
│       {候補者名} への投票                       │
├───────────────────────────────────────────────┤
│  パッケージを選んで数量を入力してください          │
│  ┌─────────────────────────────────────────┐ │
│  │ 10票  ¥1,000          [−] [ 2 ] [+]     │ │
│  │                                          │ │
│  │ 35票  ¥3,000          [−] [ 1 ] [+]     │ │
│  │                                          │ │
│  │ 60票  ¥5,000          [−] [ 0 ] [+]     │ │
│  │                                          │ │
│  │ 150票 ¥10,000         [−] [ 0 ] [+]     │ │
│  └─────────────────────────────────────────┘ │
├───────────────────────────────────────────────┤
│  ニックネーム                                  │
│  [はなこ                            ]          │
│  ※ 同じニックネームは応援者ランキング上で       │
│    同じ支援者として集計されます                  │
├───────────────────────────────────────────────┤
│  合計票数: 55 票        合計金額: ¥5,000       │
│                                               │
│  [           決済へ進む           ]           │
└───────────────────────────────────────────────┘

コンテナ

  • Overlay: fixed inset-0 bg-black/50 backdrop-blur-sm
  • Body: bg-white rounded-3xl p-8 max-w-xl w-full shadow-2xl
  • z-index: 90 (stripe modal より下)

ヘッダー

  • ShoppingCart アイコン (gold) + 「有料で投票」
  • 候補者名: display フォント、gold-darkmb-1
  • サブテキスト: 「{候補者名} への投票」(opacity 0.8)

<CreditPackageRow>(個別パッケージ行)

  • flex items-center justify-between gap-4 py-3 border-b border-gold/15
  • 左側: パッケージ名(1.05rem、weight 500)+ 価格(0.95remgold-dark、weight 600)
  • 右側: 数量入力グループ
    • flex items-center gap-2
    • − ボタン / + ボタン: 円形 32x32px、bg-gold/10、ホバー時 bg-gold/20、disabled 時(0 で / 上限で +) opacity-0.3
    • 数量フィールド: 横幅 56px、中央寄せ、rounded-md border border-gold/30、フォーカス時にゴールド枠
  • 数量を 0 にした行はグレーアウトしないが、数量入力欄の値は 0 表示

<NicknameInput>(ニックネーム入力)

  • <label> 「ニックネーム」(weight 500、mb-1)
  • 入力フィールド:
    • w-full h-11 rounded-xl border border-gold/30 px-4
    • フォーカス時: border-gold + outline-2 outline-gold/30
    • エラー時: border-red-400 + 下部に text-red-500 text-sm でメッセージ
  • 補助テキスト: text-xs opacity-0.6 mt-1
    • 「同じニックネームは応援者ランキング上で同じ支援者として集計されます」

合計表示エリア

  • 背景: rgba(212, 175, 55, 0.05)
  • 枠: 1px solid rgba(212, 175, 55, 0.2)
  • rounded-xl p-4 flex items-center justify-between
  • 左: 「合計票数: {totalCredits} 票」(gold-dark、weight 600)
  • 右: 「¥{totalAmountJpy.toLocaleString('ja-JP')}」(display フォント、1.5remgold-dark)

「決済へ進む」ボタン

状態スタイル
通常(合計票数 > 0 かつニックネーム有効)bg-gold text-white w-full py-4 rounded-full font-semibold
disabled (totalCredits === 0 または nicknameError)opacity 0.4, cursor-not-allowed, クリック不可

モーダル2: StripePaymentModal

レイアウト

┌────────────────────────────────────────────┐
│ [← 戻る]                              [✕] │
│                                            │
│      🔒 セキュア決済 (Stripe)               │
│                                            │
│  ┌────────────────────────────────────┐    │
│  │ 投票内容: {候補者名} へ 55 票        │    │
│  │ 内訳: 10票×2 / 35票×1              │    │
│  │ 合計金額: ¥5,000                    │    │
│  └────────────────────────────────────┘    │
├────────────────────────────────────────────┤
│ ┌────────────────────────────────────────┐ │
│ │  Stripe <PaymentElement>               │ │
│ │  Link 優先 / カード / Apple Pay /       │ │
│ │  Google Pay などを Stripe が単一        │ │
│ │  コンポーネントで描画                    │ │
│ └────────────────────────────────────────┘ │
│                                            │
│ ┌────────────────────────────────────────┐ │
│ │ ¥5,000 を支払う                        │ │
│ └────────────────────────────────────────┘ │
│                                            │
│ 🔒 Stripeによるセキュア決済                  │
└────────────────────────────────────────────┘

コンテナ

  • Body: bg-white rounded-3xl p-8 max-w-md w-full shadow-2xl
  • z-index: 110 (purchase modal より上)

ヘッダー

  • 戻るボタン: 左上、ArrowLeft + 「戻る」
  • 鍵アイコン (Lock) + 「セキュア決済」
  • 投票内容ボックス:
    • 背景: rgba(212, 175, 55, 0.05)
    • 枠: 1px solid rgba(212, 175, 55, 0.2)
    • rounded-xl p-4
    • 内訳行: text-sm opacity-0.7(「10票×2 / 35票×1」のように , 区切りで列挙)
    • 合計金額: 別行で text-lg font-semibold gold-dark

Payment Element 領域

決済情報入力エリアには Stripe <PaymentElement> を 1 つだけ配置する。Link / カード / Apple Pay / Google Pay などの個別 input は当アプリの DOM 上に存在させない。

  • 個別 input・整形ロジック・autocomplete 属性 (cc-number / cc-exp / cc-csc / cc-name 等) はアプリ側で定義しない。すべて Stripe Elements が内部で提供する
  • Stripe ダッシュボードで Link を有効化 することで Link がデフォルトの第一候補として表示される。未登録ユーザーはそのままカード入力にフォールバック可能
  • カード番号の桁数制限・ブランド別バリデーション・有効期限フォーマット整形・3DS / Apple Pay / Google Pay の出し分けは <PaymentElement> に完全委譲する
  • アプリ側のカスタムフック (useStripePayment のような自前バリデーション) は導入しない
  • PCI DSS スコープを SAQ-A に最小化するため、カード番号などの機微情報は当アプリの DOM に一切流入させない(design.md「コンポーネント (UI層)」と整合)
  • スタイリング:
    • <PaymentElement> を内包するコンテナ: rounded-xl border-2 border-gold/20 px-4 py-3
    • <Elements>appearance オプション(theme, variables.colorPrimary 等)で配色をブランドに合わせる
    • <PaymentElement options={{ layout: 'tabs' }} /> で Link / カード等をタブ表示

「支払う」ボタン

状態スタイル
通常gold背景、白文字、rounded-full py-4、weight 600。テキストは「¥{amount.toLocaleString('ja-JP')} を支払う」
disabled (!isValid)opacity 0.4、cursor not-allowed
processingスピナー + 「処理中...」

状態別の表示

step: 'processing'

  • フォーム全体を半透明の overlay で覆う
  • 中央に Loader2 (回転)
  • 下に「決済処理中...」テキスト

step: 'pending_settlement'

  • 同上の overlay 表示に加え、メインメッセージを「決済処理は完了しました。投票反映まで少々お待ちください」に置き換える
  • 閉じる・Esc・外側クリックを抑止

step: 'success'

  • フォームを Check アイコン + 「決済完了」表示に置き換え
  • 1.5秒後に自動でモーダルを閉じる
  • Confetti 用に親に通知

step: 'error'

  • フォーム下に赤背景のエラーボックス
  • 「もう一度試す」ボタンで step: 'payment' に戻す
  • タイムアウト時は「サポートに連絡」ボタンも併設

フッター

  • 鍵アイコン + 「Stripeによるセキュア決済」
  • 0.75rem、opacity 0.6、中央揃え

アニメーション

要素プロパティtiming
Modal 開閉scale (0.9→1), opacityspring damping: 20
パッケージ行 hover背景フェード150ms
数量増減ボタン押下scale(0.95) → 戻り100ms
合計金額の数値変更フェード or tween 0.95→1.05→1150ms
Loading スピナー360deg 回転1s linear infinite
Success Checkscale (0→1)spring

レスポンシブ

モバイル

  • <PurchaseCreditsModal>: 横幅 max-w-sm
  • パッケージ行は縦積み(左に名前+価格、右下に数量入力グループ)もしくは横並びコンパクトレイアウト
  • 合計表示・「決済へ進む」ボタンは sticky bottom にして常時表示する
  • StripePaymentModal: 横幅 max-w-sm

タブレット以上

  • <PurchaseCreditsModal>: max-w-xl、行内は横並び
  • StripePaymentModal: max-w-md

アクセシビリティ

  • 各パッケージ行の数量入力に <label> を関連付け(「{パッケージ名} の数量」)
  • 数量入力の inputmode="numeric"pattern="[0-9]*"min="0"step="1"
  • − / + ボタンに aria-label="数量を1つ減らす" / aria-label="数量を1つ増やす"
  • 合計票数・合計金額の領域は aria-live="polite" で値変更を読み上げ
  • ニックネーム入力フィールドに aria-required="true"aria-invalid を必要に応じて付与、エラーメッセージは aria-describedby で関連付け
  • カード関連入力のラベル付け・inputmodeautocomplete (cc-number / cc-exp / cc-csc / cc-name)・桁数バリデーション・エラー表示は Stripe <PaymentElement> に完全委譲する
  • アプリ側で実装するアクセシビリティ:
    • 「支払う」ボタンに対するキーボード操作 (Enter / Space)
    • 処理中はモーダル本体に aria-busy="true" を付与
    • サーバー由来のエラーメッセージ表示領域に role="alert" を付与

関連

  • requirements.md / design.md
  • ../home/ui-design.md — デザイントークン
  • ../candidate-detail/requirements.md — 投票アクションラベル(「投票する」「投票期間外」)