テーマ
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 型(参考)
AuthContext の User / 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