Skip to content

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整形 / バリデーション
PurchaseCreditsModalStorybook で各ステップ表示確認
決済E2E (Stripe テストモード)Playwright + テストカード番号

関連

  • requirements.md / ui-design.md
  • ../voting-flow/design.md
  • ../social-login/design.md