テーマ
Design: 候補者詳細画面
要件は requirements.md、ビジュアルは ui-design.md 参照。
アーキテクチャ
app/candidate/[id]/page.tsx (Server)
├─ generateStaticParams() で候補者IDを事前生成
├─ generateMetadata() で OGP・タイトル動的生成
├─ candidateService.getById(id) → notFound() if missing
└─ <CandidateDetailClient> へ props 渡し
│
├─ useVoteAction(candidateId)
├─ useVoteFlow()
└─ render:
├─ <DetailHeader candidate rank />
├─ <CandidateImageSlider images />
├─ <CandidateInfo candidate />
├─ <VoterRanking voters />
├─ <SocialLoginModal />
├─ <PurchaseCreditsModal />
└─ <Confetti />レイヤー別の責務
Server Component: app/candidate/[id]/page.tsx
generateStaticParams(): 全候補者IDを返却(SSG)generateMetadata({ params }): 候補者名・OGP画像を含む動的メタデータcandidateService.getById(id): Server側で取得、null ならnotFound()- 投票者データ:
voterService.getByCandidateId(id)
Client Component: app/candidate/[id]/_components/CandidateDetailClient.tsx
- 投票フロー(useVoteFlow)と投票アクション(useVoteAction)の組み合わせ
- 画像スライダーの状態管理(currentSlide)
使用フック
| フック | 役割 |
|---|---|
useVoteAction(candidateId) | 投票実行(home画面と共通) |
useVoteFlow() | モーダル・Confetti制御(home画面と共通) |
useCandidateRank(candidateId, allCandidates) | 詳細ページに表示する順位 |
useImageSlider(images) | currentSlide状態 + 切替メソッド |
使用コンポーネント
| コンポーネント | Props |
|---|---|
<NavLinks> | links: { href, label, icon }[] |
<DetailHeader> | name, rank, votes |
<VoteButtonGroup> | freeVoteState, onFreeVote, onPaidVote |
<CandidateImageSlider> | images, name, currentSlide, onSlideChange |
<InfoBadgeRow> | items: { label, value }[] |
<TagList> | title, tags: string[] |
<PRMessage> | message |
<VoterRanking> | voters: Voter[], topN: 5 |
データフロー
詳細ページの順位表示は クライアント側でリアルタイム計算しない。Server側で全候補者の現時点の votes を取得し、ソート → 該当候補者の順位を計算してから渡す。
ts
// Server
const allCandidates = await candidateService.getAll();
const ranked = [...allCandidates].sort((a, b) => b.votes - a.votes);
const rank = ranked.findIndex(c => c.id === id) + 1;投票後はクライアント側で楽観的に votes を更新するが、ページ全体の順位は再計算しない(Home画面・Ranking画面に戻ったタイミングで反映)。
エッジケース
| シナリオ | 対応 |
|---|---|
| 存在しないID | notFound() → not-found.tsx |
| 画像URL切れ | <ImageWithFallback> でデフォルト画像表示 |
| voter データなし | 「まだ応援者がいません」表示(または空) |
候補者の images[] が1件のみ | サムネイル非表示、矢印無効化 |
SSG/ISR戦略
- ビルド時: 全9名の候補者ページを静的生成
- 投票数が変動するため、
revalidate: 60(秒) で1分毎再生成 - 投票完了時はクライアント側で楽観的に表示し、ISR反映を待たない
関連
requirements.md/ui-design.md../home/design.md— 共通フック・モーダルの詳細../voting-flow/design.md— 投票ロジック詳細