テーマ
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-dark、mb-1 - サブテキスト: 「{候補者名} への投票」(opacity 0.8)
<CreditPackageRow>(個別パッケージ行)
flex items-center justify-between gap-4 py-3 border-b border-gold/15- 左側: パッケージ名(
1.05rem、weight 500)+ 価格(0.95rem、gold-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.5rem、gold-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), opacity | spring damping: 20 |
| パッケージ行 hover | 背景フェード | 150ms |
| 数量増減ボタン押下 | scale(0.95) → 戻り | 100ms |
| 合計金額の数値変更 | フェード or tween 0.95→1.05→1 | 150ms |
| Loading スピナー | 360deg 回転 | 1s linear infinite |
| Success Check | scale (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で関連付け - カード関連入力のラベル付け・
inputmode・autocomplete(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— 投票アクションラベル(「投票する」「投票期間外」)