テーマ
Design: 投票券購入フロー
要件は requirements.md、ビジュアルは ui-design.md 参照。
モジュール構成
features/payment/
├── hooks/
│ ├── usePurchaseFlow.ts # パッケージ選択 → 決済 → 投票完了の状態管理
│ └── useStripePayment.ts # 決済処理ラッパー
├── services/
│ ├── stripeService.ts # Stripe API 呼び出し
│ └── creditPackages.ts # パッケージ定義
└── types.ts
components/modals/
├── PurchaseCreditsModal.tsx # 表示専用
├── CreditPackageCard.tsx # 個別パッケージカード
└── StripePaymentModal.tsx # 表示専用型定義
ts
// features/payment/types.ts
export interface CreditPackage {
id: string;
credits: number;
price: number; // JPY
bonus?: number;
popular?: boolean;
}
export type PurchaseStep = 'select' | 'payment' | 'processing' | 'success' | 'error';
export interface PurchaseState {
step: PurchaseStep;
selectedPackage: CreditPackage | null;
errorMessage?: string;
}
export interface CardInput {
cardNumber: string;
expiry: string;
cvc: string;
cardName: string;
}パッケージ定義
ts
// features/payment/services/creditPackages.ts
export const CREDIT_PACKAGES: CreditPackage[] = [
{ id: 'small', credits: 10, price: 1000 },
{ id: 'medium', credits: 35, price: 3000, popular: true },
{ id: 'large', credits: 60, price: 5000 },
{ id: 'xl', credits: 150, price: 10000 },
];カスタムフック
usePurchaseFlow(candidateId, onComplete)
パッケージ選択 → 決済 → 投票実行 までを一気通貫で管理する。
ts
export function usePurchaseFlow(
candidate: Candidate | null,
onComplete: (voteCount: number) => void,
) {
const { isAuthenticated } = useAuth();
const { purchaseCredits } = useVoting();
const { open: openLogin } = useLoginFlow();
const [step, setStep] = useState<PurchaseStep>('select');
const [selectedPackage, setSelectedPackage] = useState<CreditPackage | null>(null);
const [pendingPackage, setPendingPackage] = useState<CreditPackage | null>(null);
const selectPackage = (pkg: CreditPackage) => {
if (!isAuthenticated) {
setPendingPackage(pkg);
openLogin(() => proceedToPayment(pkg));
return;
}
proceedToPayment(pkg);
};
const proceedToPayment = (pkg: CreditPackage) => {
setSelectedPackage(pkg);
setStep('payment');
};
const submitPayment = async (card: CardInput) => {
if (!selectedPackage || !candidate) return;
setStep('processing');
try {
await stripeService.processPayment({
amount: selectedPackage.price,
card,
candidateId: candidate.id,
credits: selectedPackage.credits,
});
const totalCredits = selectedPackage.credits + (selectedPackage.bonus ?? 0);
purchaseCredits(totalCredits);
onComplete(totalCredits);
setStep('success');
} catch (e: any) {
setStep('error');
}
};
const reset = () => {
setStep('select');
setSelectedPackage(null);
setPendingPackage(null);
};
return { step, selectedPackage, selectPackage, submitPayment, reset };
}useStripePayment()
カード入力フォームのバリデーション・整形を担当。
ts
export function useStripePayment() {
const [card, setCard] = useState<CardInput>({ cardNumber: '', expiry: '', cvc: '', cardName: '' });
const setCardNumber = (raw: string) => {
const cleaned = raw.replace(/\s/g, '');
if (cleaned.length > 16 || !/^\d*$/.test(cleaned)) return;
const formatted = cleaned.match(/.{1,4}/g)?.join(' ') ?? cleaned;
setCard(c => ({ ...c, cardNumber: formatted }));
};
const setExpiry = (raw: string) => {
const cleaned = raw.replace(/\//g, '');
if (cleaned.length > 4 || !/^\d*$/.test(cleaned)) return;
const formatted = cleaned.length >= 2
? `${cleaned.slice(0, 2)}/${cleaned.slice(2, 4)}`
: cleaned;
setCard(c => ({ ...c, expiry: formatted }));
};
const setCvc = (raw: string) => {
if (raw.length > 3 || !/^\d*$/.test(raw)) return;
setCard(c => ({ ...c, cvc: raw }));
};
const setCardName = (raw: string) => {
setCard(c => ({ ...c, cardName: raw }));
};
const isValid =
card.cardNumber.replace(/\s/g, '').length === 16 &&
card.expiry.length === 5 &&
card.cvc.length === 3 &&
card.cardName.trim().length > 0;
const reset = () => setCard({ cardNumber: '', expiry: '', cvc: '', cardName: '' });
return { card, setCardNumber, setExpiry, setCvc, setCardName, isValid, reset };
}サービス層
ts
export async function processPayment(input: ProcessPaymentInput): Promise<void> {
await new Promise(r => setTimeout(r, 2000));
// 常に成功
}コンポーネント (UI層)
<PurchaseCreditsModal>
tsx
interface PurchaseCreditsModalProps {
candidate: Candidate | null;
isOpen: boolean;
step: PurchaseStep;
packages: CreditPackage[];
onClose: () => void;
onSelectPackage: (pkg: CreditPackage) => void;
}<CreditPackageCard>
tsx
interface CreditPackageCardProps {
package: CreditPackage;
onSelect: () => void;
}<StripePaymentModal>
tsx
interface StripePaymentModalProps {
isOpen: boolean;
amount: number;
itemName: string;
step: 'payment' | 'processing' | 'success' | 'error';
card: CardInput;
isValid: boolean;
onCardNumberChange: (v: string) => void;
onExpiryChange: (v: string) => void;
onCvcChange: (v: string) => void;
onCardNameChange: (v: string) => void;
onSubmit: () => void;
onClose: () => void;
}すべて表示専用。useStripePayment() の戻り値を props として受け取る。
統合例
tsx
// containers/PurchaseFlowContainer.tsx
'use client';
export function PurchaseFlowContainer({ candidate, isOpen, onClose, onComplete }: Props) {
const flow = usePurchaseFlow(candidate, onComplete);
const payment = useStripePayment();
return (
<>
<PurchaseCreditsModal
candidate={candidate}
isOpen={isOpen && flow.step === 'select'}
packages={CREDIT_PACKAGES}
step={flow.step}
onClose={() => { flow.reset(); onClose(); }}
onSelectPackage={flow.selectPackage}
/>
<StripePaymentModal
isOpen={flow.step === 'payment' || flow.step === 'processing' || flow.step === 'success'}
amount={flow.selectedPackage?.price ?? 0}
itemName={`${flow.selectedPackage?.credits ?? 0}票パッケージ`}
step={flow.step === 'select' ? 'payment' : flow.step}
card={payment.card}
isValid={payment.isValid}
onCardNumberChange={payment.setCardNumber}
onExpiryChange={payment.setExpiry}
onCvcChange={payment.setCvc}
onCardNameChange={payment.setCardName}
onSubmit={() => flow.submitPayment(payment.card)}
onClose={() => { flow.reset(); payment.reset(); onClose(); }}
/>
</>
);
}エラーハンドリング
| シナリオ | 対応 |
|---|---|
| ネットワーク失敗 | リトライボタン表示 |
テスト戦略
| 対象 | テスト |
|---|---|
usePurchaseFlow | 各ステップ遷移、未認証時の login 連携 |
useStripePayment | 整形 / バリデーション |
PurchaseCreditsModal | Storybook で各ステップ表示確認 |
| 決済E2E (Stripe テストモード) | Playwright + テストカード番号 |
関連
requirements.md/ui-design.md../voting-flow/design.md../social-login/design.md