Skip to content

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, onSelectDetailhover状態
<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
CandidateCardUI スナップショットStorybook + Chromatic
投票フロー全体E2EPlaywright
Server Component統合Vitest

関連ドキュメント

  • requirements.md — 機能要件
  • ui-design.md — ビジュアルデザイン仕様
  • ../voting-flow/ — 投票ロジック横断仕様
  • ../social-login/ — ログインモーダル仕様
  • ../credit-purchase/ — 投票券購入仕様