テーマ
Design: Home画面(候補者一覧)
要件は requirements.md 参照。本ドキュメントは 機能・ロジック層の設計を定義する。見た目に関する仕様は ui-design.md を参照。
アーキテクチャ概要
┌──────────────────────────────────────────────────────────┐
│ app/page.tsx (Server Component) │
│ - getCandidates() でデータ取得 │
│ - メタデータ生成 │
│ - <HomePageClient> へ初期データを props で渡す │
└────────────────────┬─────────────────────────────────────┘
│
┌────────────────────▼─────────────────────────────────────┐
│ app/_components/HomePageClient.tsx ('use client') │
│ - useCandidateRanking() で順位計算 │
│ - useVoteFlow() でモーダル状態を一元管理 │
│ - <CandidateGrid> へ render │
└────────────────────┬─────────────────────────────────────┘
│
┌────────────┴───────────────┐
▼ ▼
┌──────────────────┐ ┌─────────────────────┐
│ features/ │ │ components/ │
│ (logic only) │ │ (presentation only) │
└──────────────────┘ └─────────────────────┘レイヤー別の責務
Server Component: app/page.tsx
- 責務: 静的データ取得、メタデータ生成、HTML 初期描画
- 使用:
candidateService.getAll()を呼び出し - 出力:
<HomePageClient candidates={...} />
Client Component: app/_components/HomePageClient.tsx
- 責務: 状態管理、フックの組み合わせ、子コンポーネントへの props 配布
- 使用フック:
useCandidateRanking(candidates)— 順位付きリストを返すuseVoteFlow()— 投票フロー全体(ログイン要否、モーダル開閉、Confetti制御)useScrollToTop()— スクロールトップボタン制御
features/ レイヤー
| フック | 入力 | 出力 |
|---|---|---|
useCandidateRanking(candidates) | 候補者配列 | RankedCandidate[] (順位フィールド付き) |
useVoteAction(candidateId) | 候補者ID | { canVote, label, requiresLogin, execute } |
useVoteFlow() | – | { openLogin, openPurchase, modals, confetti } |
useFreeVoteStatus() | – | { canVote, countdown } |
components/ レイヤー
| コンポーネント | Props | 内部状態 |
|---|---|---|
<CandidateGrid> | candidates, onFreeVote, onPaidVote, onSelectDetail | スタガードアニメ用 index |
<CandidateCard> | candidate, rank, freeVoteState, onFreeVote, onPaidVote, onSelectDetail | hover状態 |
<HomeHero> | votingPeriod | – |
<Confetti> | active, particleCount | – |
<SocialLoginModal> | isOpen, onClose, onLoginSuccess | – |
<PurchaseCreditsModal> | candidate, isOpen, onClose, onVoteComplete, onOpenLogin | – |
データフロー
初期描画
Server: candidateService.getAll()
→ app/page.tsx
→ HomePageClient (props: candidates)
→ useCandidateRanking
→ CandidateGrid
→ CandidateCard × N無料投票フロー
User clicks FreeVoteButton
→ CandidateCard.onFreeVote(candidate)
→ HomePageClient.handleFreeVote
→ useVoteAction.execute()
├─ if !canVote: return (UIで disabled なので通常到達しない)
├─ if !isAuthenticated:
│ → setPendingVote({ type: 'free', candidateId })
│ → openLoginModal()
│ → (after login) → execute()
└─ if isAuthenticated:
→ voteService.castFreeVote(candidateId)
→ setCandidates (votes++)
→ triggerConfetti(3000ms)有料投票フロー
User clicks PaidVoteButton
→ CandidateCard.onPaidVote(candidate)
→ HomePageClient.openPurchaseModal(candidate)
→ User selects package
→ if !isAuthenticated:
→ openLoginModal({ pendingPackage })
→ (after login) → openStripeModal(package)
→ User completes Stripe payment
→ handlePaymentSuccess(voteCount)
→ voteService.castPaidVote(candidateId, voteCount)
→ setCandidates (votes += voteCount)
→ triggerConfetti(3000ms)
→ close all modals状態管理
グローバル (Context)
- AuthContext:
user, isAuthenticated, login, logout - VotingContext:
lastFreeVoteDate, paidVoteCredits, canVoteFree, useFreeVote, ...
ページローカル (HomePageClient内)
ts
const [candidates, setCandidates] = useState<Candidate[]>(initialCandidates);
const [selectedCandidate, setSelectedCandidate] = useState<Candidate | null>(null);
const [activeModal, setActiveModal] = useState<'login' | 'purchase' | null>(null);
const [pendingVote, setPendingVote] = useState<{ type: 'free' | 'paid'; candidateId: number } | null>(null);
const [showConfetti, setShowConfetti] = useState(false);楽観的更新を採用: 投票実行時に即座に votes を加算し、API失敗時にロールバックする想定。
型定義
ts
// features/candidates/types.ts
export interface Candidate {
id: number;
name: string;
region: string;
image: string;
images: string[];
age: number;
height: number;
occupation: string;
hobbies: string[];
specialties: string[];
motto: string;
dream: string;
message: string;
votes: number;
}
export interface RankedCandidate extends Candidate {
rank: number;
}
// features/voting/types.ts
export type PendingVote = {
type: 'free' | 'paid';
candidateId: number;
} | null;
export interface VoteActionResult {
canVote: boolean;
label: string;
requiresLogin: boolean;
execute: () => void | Promise<void>;
}サービス層
voteService.ts
ts
export async function castFreeVote(candidateId: number): Promise<void> {
// VotingContext の useFreeVote() を呼び、候補者の votes++ を返す
}
export async function castPaidVote(candidateId: number, count: number): Promise<void> {
// VotingContext の usePaidVote() を count 回呼び、votes を加算
}candidateService.ts
ts
export async function getAll(): Promise<Candidate[]> {
return candidatesData; // import from data/candidates.ts
}エラーハンドリング
| シナリオ | 対応 |
|---|---|
| 候補者データ取得失敗(Server側) | error.tsx で fallback UI を表示 |
| 投票API失敗(Client側) | sonner の Toast で通知、UI状態をロールバック |
| 連打による二重投票 | ボタンの pending 状態で disabled 化 |
| 認証セッション切れ | 自動ログアウト → SocialLoginModal を再表示 |
パフォーマンス
- 初期表示: Server Component で候補者データを SSG/ISR
- 画像最適化:
next/image+priority(above-the-fold のみ) - アニメーション:
motionで GPU 加速、will-change: transformを要素に付与 - レンダリング最適化:
<CandidateCard>をReact.memo化、onFreeVote等はuseCallback
テスト戦略
| 対象 | テスト種別 | ツール |
|---|---|---|
useVoteAction | 単体 | Vitest + renderHook |
useCandidateRanking | 単体 | Vitest |
CandidateCard | UI スナップショット | Storybook + Chromatic |
| 投票フロー全体 | E2E | Playwright |
| Server Component | 統合 | Vitest |
関連ドキュメント
requirements.md— 機能要件ui-design.md— ビジュアルデザイン仕様../voting-flow/— 投票ロジック横断仕様../social-login/— ログインモーダル仕様../credit-purchase/— 投票券購入仕様