Skip to content

Design: 投票フロー(横断仕様)

要件は requirements.md 参照。本ドキュメントは features/voting/ レイヤーの設計を定義する。横断仕様のため ui-design.md は持たず、画面別UIは home / candidate-detail / candidate-ranking の ui-design.md を参照。


モジュール構成

features/voting/
├── contexts/
│   └── VotingContext.tsx
├── hooks/
│   ├── useVoteAction.ts          # 投票実行の統合フック
│   ├── useFreeVoteStatus.ts      # 無料投票可否 + 残り時間
│   ├── useVoteCountdown.ts       # 残り時間(リアルタイム更新)
│   └── useVoteFlow.ts            # モーダル・Confettiの状態管理
├── services/
│   └── voteService.ts            # API/Context呼び出しのラッパー
└── types.ts

型定義

ts
// features/voting/types.ts

export type VoteType = 'free' | 'paid';

export interface VoteState {
  canVote: boolean;
  label: string;          // ボタンに表示するテキスト
  countdown: { hours: number; minutes: number } | null;
  requiresLogin: boolean;
}

export interface VoteAction {
  state: VoteState;
  executeFree: () => Promise<void>;
  executePaid: (count: number) => Promise<void>;
}

export interface PendingVote {
  type: VoteType;
  candidateId: number;
  paidPackage?: CreditPackage; // 有料の場合
}

export interface VoteFlowState {
  selectedCandidate: Candidate | null;
  activeModal: 'login' | 'purchase' | null;
  pendingVote: PendingVote | null;
  showConfetti: boolean;
}

関連する Context 型(参考)

AuthContextUser / AuthContextType 型は social-login/design.md で定義する。本ファイルでは useAuth() 経由でこれらを参照する。

ts
// features/auth/types.ts (social-login/design.md より)
export type AuthProvider = 'facebook' | 'instagram';
export interface User {
  id: string;
  name: string;
  email: string;
  provider: AuthProvider;
}
export interface AuthContextType {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  login: (provider: AuthProvider) => Promise<void>;
  logout: () => void;
}

VotingContext

features/voting/contexts/VotingContext.tsx で投票状態を保持する。要件 (requirements.md) の振る舞いを以下のインターフェースで実装する。

インターフェース

ts
interface VotingContextType {
  lastFreeVoteDate: string | null;        // ISO形式、最後に無料投票した日時
  paidVoteCredits: number;                 // 残り投票券数
  canVoteFree: () => boolean;              // 当日無料投票可否を返す
  getTimeUntilNextVote: () => { hours: number; minutes: number } | null;
  useFreeVote: () => void;                 // 無料投票を1票消費
  usePaidVote: () => boolean;              // 有料投票を1票消費(残数なしならfalse)
  purchaseCredits: (amount: number) => void; // 投票券を加算
}

主要メソッドの実装方針

canVoteFree()

ts
function canVoteFree(): boolean {
  if (!lastFreeVoteDate) return true;
  const last = new Date(lastFreeVoteDate);
  const now = new Date();
  // 0:00:00基準で同じ日付かを比較
  return (
    last.getFullYear() !== now.getFullYear() ||
    last.getMonth() !== now.getMonth() ||
    last.getDate() !== now.getDate()
  );
}

getTimeUntilNextVote()

ts
function getTimeUntilNextVote(): { hours: number; minutes: number } | null {
  if (canVoteFree()) return null;
  const now = new Date();
  const tomorrow = new Date(now);
  tomorrow.setDate(now.getDate() + 1);
  tomorrow.setHours(0, 0, 0, 0);
  const diff = tomorrow.getTime() - now.getTime();
  return {
    hours: Math.floor(diff / (1000 * 60 * 60)),
    minutes: Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)),
  };
}

Provider実装

ts
'use client';

import { createContext, useContext, useState, useEffect, ReactNode } from 'react';

const STORAGE_KEY = 'mwj-voting-state';

export function VotingProvider({ children }: { children: ReactNode }) {
  const [lastFreeVoteDate, setLastFreeVoteDate] = useState<string | null>(null);
  const [paidVoteCredits, setPaidVoteCredits] = useState(0);

  // 起動時に localStorage から復元
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const saved = localStorage.getItem(STORAGE_KEY);
    if (saved) {
      try {
        const parsed = JSON.parse(saved);
        setLastFreeVoteDate(parsed.lastFreeVoteDate ?? null);
        setPaidVoteCredits(parsed.paidVoteCredits ?? 0);
      } catch {}
    }
  }, []);

  // 状態変更時に永続化
  useEffect(() => {
    if (typeof window === 'undefined') return;
    localStorage.setItem(STORAGE_KEY, JSON.stringify({ lastFreeVoteDate, paidVoteCredits }));
  }, [lastFreeVoteDate, paidVoteCredits]);

  // ... 以下、上記メソッドを実装
}

カスタムフック

useFreeVoteStatus()

無料投票可否と残り時間を返す。詳細はリアルタイム更新する。

ts
export function useFreeVoteStatus() {
  const { canVoteFree, getTimeUntilNextVote } = useVoting();
  const [countdown, setCountdown] = useState(getTimeUntilNextVote());

  useEffect(() => {
    if (canVoteFree()) {
      setCountdown(null);
      return;
    }
    const id = setInterval(() => {
      setCountdown(getTimeUntilNextVote());
    }, 60_000); // 1分ごと
    return () => clearInterval(id);
  }, [canVoteFree, getTimeUntilNextVote]);

  return {
    canVote: canVoteFree(),
    countdown,
    label: canVoteFree()
      ? '無料で投票する(一日一回)'
      : countdown
        ? `投票済み(あと${countdown.hours}時間${countdown.minutes}分で再投票可能)`
        : '投票済み',
  };
}

useVoteAction(candidateId)

特定候補者への投票アクションを返す。UIから呼ばれる主要フック。

ts
export function useVoteAction(candidateId: number) {
  const { isAuthenticated } = useAuth();
  const { useFreeVote, usePaidVote } = useVoting();
  const status = useFreeVoteStatus();

  const executeFree = async () => {
    if (!status.canVote) return;
    if (!isAuthenticated) throw new RequiresLoginError('free');
    await voteService.castFreeVote(candidateId);
    useFreeVote();
  };

  const executePaid = async (count: number) => {
    if (!isAuthenticated) throw new RequiresLoginError('paid');
    await voteService.castPaidVote(candidateId, count);
  };

  return {
    state: {
      canVote: status.canVote,
      label: status.label,
      countdown: status.countdown,
      requiresLogin: !isAuthenticated,
    },
    executeFree,
    executePaid,
  };
}

useVoteFlow()

モーダル・Confetti・保留中投票の状態を集中管理する。各ページで1度だけ呼ぶ。

ts
export function useVoteFlow() {
  const [selectedCandidate, setSelectedCandidate] = useState<Candidate | null>(null);
  const [activeModal, setActiveModal] = useState<'login' | 'purchase' | null>(null);
  const [pendingVote, setPendingVote] = useState<PendingVote | null>(null);
  const [showConfetti, setShowConfetti] = useState(false);

  const triggerConfetti = () => {
    setShowConfetti(true);
    setTimeout(() => {
      setShowConfetti(false);
      setSelectedCandidate(null);
    }, 3000);
  };

  const handleFreeVoteClick = (candidate: Candidate, isAuthenticated: boolean, canVote: boolean) => {
    if (!canVote) return;
    setSelectedCandidate(candidate);
    if (!isAuthenticated) {
      setPendingVote({ type: 'free', candidateId: candidate.id });
      setActiveModal('login');
    } else {
      // 即実行
      executeFreeVote(candidate);
    }
  };

  const handlePaidVoteClick = (candidate: Candidate) => {
    setSelectedCandidate(candidate);
    setActiveModal('purchase');
  };

  const handleLoginSuccess = () => {
    setActiveModal(null);
    if (pendingVote?.type === 'free' && selectedCandidate) {
      executeFreeVote(selectedCandidate);
    }
    // 'paid' の場合は PurchaseCreditsModal が自動で次のステップに進む
    setPendingVote(null);
  };

  // ... 以下省略
  
  return {
    selectedCandidate,
    activeModal,
    pendingVote,
    showConfetti,
    handleFreeVoteClick,
    handlePaidVoteClick,
    handleLoginSuccess,
    triggerConfetti,
    closeModals: () => setActiveModal(null),
  };
}

エラー型

ts
export class RequiresLoginError extends Error {
  constructor(public voteType: VoteType) {
    super('Authentication required');
    this.name = 'RequiresLoginError';
  }
}

export class CooldownError extends Error {
  constructor() {
    super('Free vote is on cooldown');
    this.name = 'CooldownError';
  }
}

export class InsufficientCreditsError extends Error {
  constructor(public required: number, public available: number) {
    super(`Insufficient credits: ${required} required, ${available} available`);
    this.name = 'InsufficientCreditsError';
  }
}

サービス層 (voteService.ts)

ts
// features/voting/services/voteService.ts

// 直接 Context を更新
export async function castFreeVote(candidateId: number): Promise<void> {
  // 上位の useVoteAction で Context メソッドを呼ぶため、サービスは薄い
  await new Promise(r => setTimeout(r, 200));
}

投票フロー全体図

[Component] FreeVoteButton.onClick(candidateId)

[useVoteAction] executeFree()

[useFreeVoteStatus] canVote? -- false → return
    ↓ true
[useAuth] isAuthenticated? -- false → throw RequiresLoginError
    ↓ true
[voteService] castFreeVote(candidateId)

[VotingContext] useFreeVote() → setLastFreeVoteDate(now)

[Page] setCandidates(c => c.map(... votes++))

[Page] triggerConfetti()
    ↓ 3000ms
[Component] FreeVoteButton (re-render with disabled state + countdown)

二重送信の防止

要件「進行中の投票が完了するまで追加のアクションを受け付けない」を実装するため、以下を組み合わせる。

  • useVoteAction 内に isExecuting の boolean state を保持する
  • executeFree / executePaid の呼び出し冒頭で isExecuting === true ならば即座に return
  • 投票実行直前に setIsExecuting(true)、完了/失敗時に setIsExecuting(false)
  • VoteState.canVote!isExecuting && status.canVote && ... で合成し、UI側にも反映
ts
const [isExecuting, setIsExecuting] = useState(false);

const executeFree = async () => {
  if (isExecuting || !status.canVote) return;
  if (!isAuthenticated) throw new RequiresLoginError('free');
  setIsExecuting(true);
  try {
    await voteService.castFreeVote(candidateId);
    useFreeVote();
  } finally {
    setIsExecuting(false);
  }
};

テスト戦略

単体テスト (Vitest + renderHook)

テスト対象観点
useFreeVoteStatus日付変更で canVote が true に戻る、countdown が正しい
useVoteAction.executeFree未認証時に RequiresLoginError、認証済み時に成功
useVoteAction.executePaidクレジット不足時のエラー、消費の正しさ
useVoteAction (二重送信)連続呼び出し時に isExecuting で抑制される
useVoteFlowモーダル開閉、pendingVote の遷移
VotingContext.canVoteFree日付境界(00:00をまたいだ場合)
VotingContext.getTimeUntilNextVote残り時間の計算精度(境界値)

E2E テスト (Playwright)

シナリオ
未認証 → 投票クリック → ログイン → 自動投票完了
認証済み → 無料投票 → Confetti → 24時まで disabled 表示
認証済み → 有料投票 → パッケージ選択 → Stripe → 投票成立
翌日にアクセス → 無料投票が再び有効になる

関連

  • requirements.md
  • ../home/design.md / ../candidate-detail/design.md / ../candidate-ranking/design.md
  • ../social-login/design.md
  • ../credit-purchase/design.md