テーマ
Design: 候補者詳細画面
要件は requirements.md、ビジュアルは ui-design.md 参照。データ層の契約は ../data-model/design.md、投票フローは ../voting/design.md を唯一の正として参照する。
Overview
Purpose: 個別候補者のプロフィール・画像ギャラリー・PR メッセージ・応援者ランキング(ニックネーム単位)を表示し、有料投票への導線を提供する画面を、data-model の 3 系統(microCMS / Neon / Vercel ENV)から合成して描画する。
Users: 一般来訪ユーザー(認証なし)。Home / Ranking / 直接 URL から遷移。
Impact: 過去 design(Prisma 単独の candidateService.getAllWithRank() + voterService.getTopByCandidateId() + DB 由来 votingPeriodService + /api/images/[id])を撤回し、候補者は microCMS、得票・応援者集計は Neon JOIN、投票期間は Vercel ENV から取得する構成に置き換える。
Goals
- Server Component で 3 系統 + 応援者集計を合成し、Client は presentation と投票導線にのみ責務を限定
- 順位はページ表示時点で固定し、投票後も
useOptimisticで得票数のみ更新(rank不変) - 画像は microCMS CDN を
next/imageに直渡し - 動的メタデータ(OGP / タイトル)で候補者名を含めた SEO 強化
- 投票フロー本体は
votingspec の<PurchaseFlowContainer>をそのまま使う
Non-Goals
- 投票フロー本体(
voting委譲) - microCMS / Neon / Vercel ENV のスキーマ(
data-model) - 候補者一覧 / ランキング(各 spec)
- 順位ハイライトの具体ビジュアル(
ui-design.md)
Boundary Commitments
This Spec Owns
app/candidate/[id]/page.tsx(Server Component)の合成 + ID 取得 + 404 判定 + メタデータ<CandidateDetailClient>(Client)の責務範囲と props 契約<DetailHeader>/<CandidateImageSlider>/<CandidateInfo>/<VoterRanking>/<PaidVoteButton>の Props 契約- 順位算出ロジックの呼び出し(全候補者集計 → 該当 ID 抽出)
- 画像ギャラリーの状態管理(
useImageSlider)と現在表示画像の識別 - 応援者ランキング(ニックネーム単位上位 5)の取得呼び出し
Out of Boundary
- データ層スキーマ(
data-model) - 投票フロー本体(
voting) - 投票期間取得実装(
lib/voting-period.ts) - 候補者一覧・ランキング画面(各 spec)
- ビジュアル表現(
ui-design.md)
Allowed Dependencies
features/cms/services/candidateService.getCandidates/getCandidate(Request Memoization 契約によりgenerateMetadataとpage.tsxの二重取得は dedupe される、data-model spec の契約に依存)features/cms/services/creditPackageService.getActiveCreditPackagesfeatures/candidates/services/voteAggregationService.getVoteCountsByCandidateIds/getTopVotersByCandidateId(戻り値のnicknameはnormalizeNickname適用済み、data-model spec の契約に依存)features/candidates/utils/rankCandidates(candidate-ranking所有、純関数。型RankedCandidateも同 spec が export)features/voting/components/PurchaseFlowContainer(voting所有)features/voting/hooks/{useVoteFlow,useVotingPeriodStatus}(voting所有、本 spec は import のみ)lib/voting-period.getVotingPeriodnext/image(microCMS CDN)、next/link
Revalidation Triggers
data-modelのCandidate/voteAggregationService.getTopVotersByCandidateIdシグネチャ変更- 順位算出ルール(voteCount 降順 + ID 昇順タイブレーク)の変更
- 投票期間取得 API の変更
- 応援者ランキングの集計対象(
Purchase.status='SUCCEEDED')の変更 Vote.nicknameの正規化前後ポリシー変更(現在は voting spec の Webhook ハンドラでnormalizeNickname適用済みの値を保存している前提。これが変わると応援者ランキングの集計キーが破綻するため再検証必須)getCandidate/getCandidatesの Request Memoization 契約の変更(dedupe されなくなると Performance 想定が崩れる)
Architecture
コンポーネント階層
mermaid
graph TB
subgraph Server[Server Component]
Page[app/candidate/[id]/page.tsx]
end
subgraph Client[Client Layer]
Detail[<CandidateDetailClient>]
Header[<DetailHeader>]
Slider[<CandidateImageSlider>]
Info[<CandidateInfo>]
Voters[<VoterRanking>]
Button[<PaidVoteButton>]
Container[<PurchaseFlowContainer>]
Confetti[<Confetti>]
end
subgraph Hooks[Hooks]
UseVote[useVoteFlow]
UsePeriod[useVotingPeriodStatus]
UseSlider[useImageSlider]
UseOpt[useOptimistic]
end
subgraph DataModel[data-model + utils]
CandSvc[candidateService]
VoteAgg[voteAggregationService]
PkgSvc[creditPackageService]
PeriodLib[lib/voting-period]
RankUtil[rankCandidates]
end
Page -->|getCandidate / getCandidates| CandSvc
Page -->|getVoteCountsByCandidateIds| VoteAgg
Page -->|getTopVotersByCandidateId| VoteAgg
Page -->|getActiveCreditPackages| PkgSvc
Page -->|getVotingPeriod| PeriodLib
Page --> RankUtil
Page -->|props| Detail
Detail --> UseVote
Detail --> UsePeriod
Detail --> UseSlider
Detail --> UseOpt
Detail --> Header
Detail --> Slider
Detail --> Info
Detail --> Voters
Detail --> Button
Detail --> Container
Detail --> ConfettiArchitecture Pattern & Boundary Map
- Pattern: 「Server で全データ確定 + Client は presentation と投票導線」
- Boundary Map:
- Server: 候補者取得 + 404 判定 + メタデータ生成 + 順位算出 + 応援者集計 + 投票期間取得
- Client: 画像スライダー状態 + 投票モーダル開閉 + 楽観更新
- voting spec: 投票フロー本体(購入モーダル / 決済モーダル / Webhook)
- Steering 整合:
structure.mdの features/components 分離、product.mdの投票期間業務ルール
File Structure Plan
Directory Structure
app/
└── candidate/
└── [id]/
├── page.tsx # Server Component: 合成 + メタデータ + 404
└── _components/
└── CandidateDetailClient.tsx # Client: フック束ね + 子配布
features/candidates/
└── hooks/
└── useImageSlider.ts # 画像スライダー状態管理
components/
└── public/
├── DetailHeader.tsx # 候補者名 + 現在順位 + 得票数
├── CandidateImageSlider.tsx # 画像ギャラリー + サムネイル
├── CandidateInfo.tsx # プロフィール表 + PR メッセージ
├── VoterRanking.tsx # 応援者ランキング上位 5
└── PaidVoteButton.tsx # 「投票する」/「投票期間外」Modified Files
- なし(新規ファイルのみ)
Out-of-scope(再導入禁止)
features/voting/services/voterService.ts(data-model のvoteAggregationService.getTopVotersByCandidateIdに統合)features/voting/services/votingPeriodService.ts(lib/voting-period.tsに統合)features/candidates/services/candidateService.getAllWithRank()(microCMS 移行で意味を持たない)app/api/images/[id]/**参照
System Flows
Flow 1: 詳細ページ初期描画
mermaid
sequenceDiagram
participant Browser
participant Page as page.tsx (Server)
participant CandSvc as candidateService
participant VoteAgg as voteAggregationService
participant PkgSvc as creditPackageService
participant PeriodLib as lib/voting-period
participant RankUtil as rankCandidates
Browser->>Page: GET /candidate/[id]
Page->>CandSvc: getCandidate(id)
CandSvc-->>Page: Candidate | null
alt Candidate not found
Page->>Page: notFound() → 404
else found
par 並列取得
Page->>CandSvc: getCandidates() (順位算出用)
Page->>VoteAgg: getTopVotersByCandidateId(id, limit=5)
Page->>PkgSvc: getActiveCreditPackages()
Page->>PeriodLib: getVotingPeriod()
end
Page->>VoteAgg: getVoteCountsByCandidateIds(allIds)
VoteAgg-->>Page: Map<id, count>
Page->>RankUtil: rankCandidates(allCandidates + voteCounts)
RankUtil-->>Page: RankedCandidate[]
Page-->>Page: target = ranked.find(c => c.id === id)
Page-->>Browser: HTML(<CandidateDetailClient />)
endKey Decision:
- 順位算出は全候補者を取得して
rankCandidates純関数で実行(candidate-ranking所有のユーティリティを共有) - 404 は
next/navigationのnotFound()を使う
Flow 2: 動的メタデータ生成
mermaid
sequenceDiagram
participant Bot as Crawler / SNS
participant GenMeta as generateMetadata
participant CandSvc as candidateService
Bot->>GenMeta: /candidate/[id] OGP 取得
GenMeta->>CandSvc: getCandidate(id)
CandSvc-->>GenMeta: Candidate | null
alt Candidate exists
GenMeta-->>Bot: { title: `${name} | Miss World Japan 2026`, openGraph: { images: [...] } }
else not found
GenMeta-->>Bot: { title: 'Not Found' }
endKey Decision:
generateMetadataは別途getCandidate(id)を call するが、Next.js がpage.tsxの取得と dedupe するため実質 1 回(fetchcache 経由)
Flow 3: 投票成功時の楽観更新
mermaid
sequenceDiagram
participant User
participant Button as <PaidVoteButton>
participant Detail as CandidateDetailClient
participant Flow as useVoteFlow
participant Purchase as <PurchaseFlowContainer>
participant Optimistic as useOptimistic
User->>Button: 投票ボタン押下
Button->>Detail: onPaidVote(candidate)
Detail->>Flow: openPurchaseModal(candidate)
Flow->>Purchase: open
Purchase-->>Flow: onComplete({ totalCredits })
Flow->>Optimistic: addOptimisticVote({ delta: totalCredits })
Optimistic-->>Detail: voteCount のみ更新(rank 据え置き)
Flow->>Detail: triggerConfettiKey Decision:
- 楽観更新は
voteCountのみ。rankはページ再訪問時に最新化(要件 Feature 3)
Requirements Traceability
| Req | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1 | ID をキーに候補者取得 | page.tsx | getCandidate(id) | Flow 1 |
| 1.2 | 該当なしで 404 | page.tsx | notFound() | Flow 1 |
| 1.3 | 候補者名を含むメタデータ生成 | generateMetadata | Next.js metadata API | Flow 2 |
| 2.1 | 候補者一覧/ランキングへの戻り導線 | <DetailHeader> または共通 layout | <Link href="/"> / <Link href="/ranking"> | — |
| 3.1 | 現在順位・名前・得票数を表示 | <DetailHeader> | props | Flow 1 |
| 3.2 | 現在順位はページ表示時点の voteCount 降順から算出 | page.tsx + rankCandidates | 全候補者ソート → 該当 ID の rank | Flow 1 |
| 3.3 | 投票更新時は voteCount のみ更新、順位は不変 | useOptimistic | reducer で voteCount のみ | Flow 3 |
| 3.4 | 順位最新化はページ再訪問時 | Server 算出 | — | Flow 1 |
| 4.1 | 投票アクション 1 つ、ラベルは「投票する」 | <PaidVoteButton> + useVotingPeriodStatus | label props | — |
| 5.1 | 切り替え可能なギャラリーとして表示 | <CandidateImageSlider> | images props | — |
| 5.2 | 画像切り替え操作で対応画像を表示 | useImageSlider | currentIndex 状態 | — |
| 5.3 | サムネイル等の直接遷移手段を提供 | <CandidateImageSlider> | thumbnails | — |
| 5.4 | 現在表示中画像を識別可能にする | <CandidateImageSlider> | aria-current / visual highlight | — |
| 5.5 | 画像が 1 件のみ → 切替 UI を非表示/無効化 | <CandidateImageSlider> | images.length 判定 | — |
| 6.1 | プロフィール項目(出身地・職業・身長・趣味・特技・資格)を表示 | <CandidateInfo> | candidate props | — |
| 6.2 | 趣味/特技および資格を複数項目リストとして列挙 | <CandidateInfo> | hobbies / certifications 配列 | — |
| 7.1 | PR メッセージの段落構造を保持 | <CandidateInfo> | whitespace-pre-line で改行保持 | — |
| 7.2 | PR メッセージを他プロフィールと区別 | <CandidateInfo> | セクション分け | — |
| 8.1 | 応援者ランキング上位 5 件 | <VoterRanking> + voteAggregationService | getTopVotersByCandidateId(id, 5) | Flow 1 |
| 8.2 | 各応援者の順位・ニックネーム・投票数 | <VoterRanking> | props | — |
| 8.3 | 同一文字列ニックネームを同一支援者として集計 | voteAggregationService(data-model) | voting spec の Webhook ハンドラが normalizeNickname 適用済み値で Vote を生成 → voteAggregationService.getTopVotersByCandidateId は GROUP BY nickname で同一支援者にまとまる | — |
| 8.4 | 応援者 0 件で空状態許容 | <VoterRanking> | 空配列ハンドリング | — |
| 9.1 | 投票成功時に視覚的成功フィードバック | <Confetti> + useVoteFlow | 一定時間表示 | Flow 3 |
| 非機能-メタデータ | OGP 画像・タイトル・description を動的生成 | generateMetadata | images: [{ url: candidate.images[0].url }] | Flow 2 |
Components and Interfaces
Summary
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|---|---|---|---|---|---|
app/candidate/[id]/page.tsx | Server | 候補者取得 + 404 + 順位算出 + 応援者取得 + メタデータ | 1.1-1.3, 3.2, 8.1, Flow 1/2 | data-model services(P0) | Service |
generateMetadata | Server | 動的 OGP / タイトル | 1.3, 非機能 | candidateService(P0) | Service |
<CandidateDetailClient> | Client | フック束ね + 子配布 + 楽観更新 | 3.3, 9.1 | hooks(P0) | UI |
useImageSlider | Hook | 画像スライダーの index 管理 | 5.2 | (純フック) | Service |
useVoteFlow(voting 所有) | Hook | モーダル開閉 + Confetti | 4.1, 9.1 | — | Service |
useVotingPeriodStatus(voting 所有) | Hook | 期間内/外ラベル制御 | 4.1 | clock | Service |
<DetailHeader> | Component | 名前 + 順位 + 得票数 | 3.1 | — | UI |
<CandidateImageSlider> | Component | 画像ギャラリー + サムネイル + 切替 | 5.1-5.5 | next/image | UI |
<CandidateInfo> | Component | プロフィール表 + PR メッセージ | 6.x, 7.x | — | UI |
<VoterRanking> | Component | 応援者ランキング上位 5 | 8.x | — | UI |
<PaidVoteButton> | Component | 投票導線(ラベル切替) | 4.1 | useVotingPeriodStatus 結果 | UI |
<PurchaseFlowContainer>(voting 所有) | Component | 投票フロー | (voting) | — | UI |
<Confetti> | Component | 成功演出 | 9.1 | — | UI |
Server Component: app/candidate/[id]/page.tsx
| Field | Detail |
|---|---|
| Intent | 候補者単一取得 + 404 判定 + 全候補者から順位算出 + 応援者集計 + 投票期間取得を行い、Client へ確定済みデータを渡す |
| Requirements | 1.1, 1.2, 3.2, 8.1, Flow 1 |
Algorithm
typescript
// app/candidate/[id]/page.tsx
import { notFound } from 'next/navigation';
import { getCandidate, getCandidates } from '@/features/cms/services/candidateService';
import { getActiveCreditPackages } from '@/features/cms/services/creditPackageService';
import {
getVoteCountsByCandidateIds,
getTopVotersByCandidateId,
} from '@/features/candidates/services/voteAggregationService';
import { rankCandidates } from '@/features/candidates/utils/rankCandidates';
import { getVotingPeriod } from '@/lib/voting-period';
import { CandidateDetailClient } from './_components/CandidateDetailClient';
type Params = { id: string };
export async function generateMetadata({ params }: { params: Promise<Params> }) {
const { id } = await params;
const candidate = await getCandidate(id);
if (!candidate) return { title: 'Not Found | Miss World Japan 2026' };
return {
title: `${candidate.displayName} | Miss World Japan 2026`,
description: candidate.message.slice(0, 120),
openGraph: {
title: `${candidate.displayName} | Miss World Japan 2026`,
images: candidate.images[0]?.url ? [candidate.images[0].url] : [],
},
};
}
export default async function CandidateDetailPage({ params }: { params: Promise<Params> }) {
const { id } = await params;
const candidate = await getCandidate(id);
if (!candidate) notFound();
const votingPeriod = getVotingPeriod();
const [allCandidates, topVoters, creditPackages] = await Promise.all([
getCandidates(),
getTopVotersByCandidateId(id, 5),
getActiveCreditPackages(),
]);
const voteCounts = await getVoteCountsByCandidateIds(allCandidates.map((c) => c.id));
const ranked = rankCandidates(
allCandidates.map((c) => ({ ...c, voteCount: voteCounts.get(c.id) ?? 0 })),
);
const target = ranked.find((c) => c.id === id);
if (!target) notFound(); // microCMS と Neon の不整合への保険
return (
<CandidateDetailClient
candidate={target}
topVoters={topVoters}
creditPackages={creditPackages}
votingPeriod={votingPeriod}
/>
);
}Implementation Notes
- Integration:
data-modelの services を call。直接 microCMS / Prisma に触らない - Validation: 404 判定は candidate 単体取得時 と 全候補者から target を抽出する時 の二段で行う(microCMS と Neon の整合性保険)
- Risks: 全候補者取得 + 集計のオーバーヘッドがあるが、件数が数十名程度なら 100-300ms 程度
Client Component: <CandidateDetailClient>
| Field | Detail |
|---|---|
| Intent | Server から渡された確定データをフックに渡し、子コンポーネントへ配布 + 楽観更新 |
| Requirements | 3.3, 9.1 |
Props
typescript
export type CandidateDetailClientProps = Readonly<{
candidate: RankedCandidate; // rank + voteCount 付き
topVoters: ReadonlyArray<{ nickname: string; voteCount: number }>;
creditPackages: CreditPackage[];
votingPeriod: VotingPeriod;
}>;Responsibilities
useVoteFlow()でモーダル開閉と Confetti を制御useVotingPeriodStatus(votingPeriod)でボタンラベルと disabled を算出useImageSlider(candidate.images)で画像切替useOptimisticでcandidate.voteCountのみを楽観更新(rankは不変)<PurchaseFlowContainer>を投票フロー本体として配置
typescript
const [optimistic, addOptimisticVote] = useOptimistic(
candidate,
(current, mutation: { delta: number }) => ({
...current,
voteCount: current.voteCount + mutation.delta,
}),
);禁止事項:
rankの再計算useState<'purchase' | null>でのモーダル個別 state 保持(useVoteFlow経由)- 直接 Server 通信(投票関連は
<PurchaseFlowContainer>内 voting spec の責務)
Hook: useImageSlider
typescript
// features/candidates/hooks/useImageSlider.ts
export function useImageSlider(images: ReadonlyArray<{ url: string }>): {
currentIndex: number;
hasMultiple: boolean; // images.length > 1
goTo: (index: number) => void;
next: () => void;
prev: () => void;
};Implementation Notes
hasMultipleが false の場合、UI 側で切替コントロールを非表示/無効化(要件 5.5)- 範囲外 index は無視
Component: <DetailHeader>
typescript
export type DetailHeaderProps = Readonly<{
candidate: RankedCandidate; // displayName + rank + voteCount
}>;表示要素: candidate.displayName / 現在 ${candidate.rank} 位 / ${candidate.voteCount.toLocaleString('ja-JP')} 票
Component: <CandidateImageSlider>
typescript
export type CandidateImageSliderProps = Readonly<{
images: ReadonlyArray<{ url: string }>;
altPrefix: string; // 候補者名 + 「の写真」
}>;責務:
useImageSliderで current 管理- メイン画像 + サムネイル一覧
- 現在表示中画像を視覚的に識別(aria-current="true" + 強調表現)
- 1 枚のみの場合は切替 UI を非表示
Implementation Notes
next/imageのsrcは microCMS CDN URL を直渡し- 最初の画像に
priority属性を付与(LCP 対策)
Component: <CandidateInfo>
typescript
export type CandidateInfoProps = Readonly<{
candidate: Candidate;
}>;表示要素:
- プロフィール表(出身地・職業・身長 cm・趣味/特技・資格/大会実績)
- PR メッセージセクション(
whitespace-pre-lineで改行保持)
Implementation Notes
- 複数項目(hobbies / certifications)はリスト要素として列挙(要件 6.2)
- PR メッセージは独立セクションとして他項目と視覚的に分離(要件 7.2)
Component: <VoterRanking>
typescript
export type VoterRankingProps = Readonly<{
voters: ReadonlyArray<{ nickname: string; voteCount: number }>;
}>;表示要素:
- 順位(1-5)+ ニックネーム + 投票数(3 桁区切り + 「票」)
- voters が空配列の場合は空状態メッセージ(任意、要件 8.4)
Implementation Notes
- 並び順は service が voteCount 降順で返す前提
Component: <PaidVoteButton>
typescript
export type PaidVoteButtonProps = Readonly<{
label: '投票する' | '投票期間外';
disabled: boolean;
onClick: () => void;
}>;Implementation Notes
- ラベル決定は
useVotingPeriodStatusの結果に基づき<CandidateDetailClient>から props で渡す - 投票期間外は
disabledで抑止(サーバー側 Server Action でもvotingspec が再判定)
Data Models
本 spec はデータ層に新規モデルを追加しない。
typescript
// 本 spec で扱う型(他 spec 所有)
import type { Candidate, CreditPackage } from '@/features/cms/types'; // data-model 所有
import type { RankedCandidate } from '@/features/candidates/utils/rankCandidates'; // candidate-ranking 所有(共有純関数)
import type { VotingPeriod } from '@/lib/voting-period'; // data-model 所有Error Handling
| シナリオ | 検出箇所 | 対応 |
|---|---|---|
| ID が microCMS に存在しない | page.tsx getCandidate | notFound() → 404 |
| microCMS 一覧取得失敗 | page.tsx | throw → app/error.tsx |
| 応援者集計失敗 | page.tsx | throw → app/error.tsx |
InvalidVotingPeriodError | page.tsx | throw → app/error.tsx |
| target が ranked にいない(整合性異常) | page.tsx | notFound()(保険) |
| 投票期間外 | useVotingPeriodStatus + voting spec | ボタンラベル「投票期間外」+ Server Action ガード |
| 投票成功 | useVoteFlow.handlePaidVoteConfirmed | Confetti + voteCount 楽観更新 |
Testing Strategy
Unit
useImageSlider: index 移動・範囲外無視・hasMultiple 判定(要件 5.2, 5.5)<CandidateInfo>: PR メッセージの改行保持(要件 7.1)<VoterRanking>: 空配列で空状態、5 件で順位/ニックネーム/投票数表示(要件 8.x)
Integration
page.tsx: candidate が存在しない場合にnotFound()、存在する場合に rank が正しく算出されることをモックされたサービスで検証(要件 1.1, 1.2, 3.2)<CandidateDetailClient>: 投票完了 →voteCountのみ更新、rank据え置きを検証(要件 3.3)generateMetadata: candidate.displayName がタイトルに含まれ、OGP に画像 URL が入ること(要件 1.3, 非機能)
E2E(Playwright)
/candidate/[id]を直接開く → DOM に名前・順位・得票数・プロフィール・PR メッセージ・応援者ランキングが表示される(要件全体)- 画像サムネイルクリック → メイン画像が切り替わる(要件 5.2)
- 「投票する」 → 購入モーダル → 決済成功 → Confetti + voteCount 更新 + rank 不変(要件 3.3, 9.1)
- 投票期間外 → ボタンラベル「投票期間外」、disabled(要件 4.1)
- 存在しない ID → 404 表示(要件 1.2)
Security Considerations
- 詳細ページは閲覧+投票導線のみ。投票本体のセキュリティは
votingspec(CSRF / Webhook 署名 / 金額改竄防止) - microCMS API は読み取り専用キー
- 候補者非公開状態の漏洩を避けるため、
getCandidate(id)は 公開済みのみ を返す(data-model所有)
Performance
- 初期表示: 全候補者取得 + 集計の合計で 200-400ms 想定
- 画像: メイン画像 1 枚に
priority、next/image+ microCMS CDN - メタデータ取得:
page.tsxの取得とgenerateMetadataの取得は data-model spec が保証する Request Memoization 契約(fetch直接 or Reactcache()ラップ)により dedupe される。dedupe されない場合は data-model spec の Revalidation Trigger に該当し本 spec も再検証対象 - 再レンダリング:
<CandidateImageSlider>のみ index 変更で再描画、他はReact.memo化を検討
Migration Strategy
app/candidate/[id]/page.tsxからcandidateService.getAllWithRank()呼び出しを削除し、getCandidates + voteAggregationService + rankCandidatesの合成に置き換えfeatures/voting/services/voterService.tsの参照を削除し、voteAggregationService.getTopVotersByCandidateIdに切替features/voting/services/votingPeriodService.tsの参照を削除し、lib/voting-period.getVotingPeriodに切替app/api/images/[id]/**参照を削除し、next/imageに microCMS CDN URL を直渡し<CandidateDetailClient>のuseOptimisticreducer がvoteCountのみ更新する実装になっていることを確認- E2E で詳細画面と投票フローを通す
Supporting References
requirements.md— 機能要件ui-design.md— ビジュアル仕様../data-model/design.md—Candidate/voteAggregationService/creditPackageService/lib/voting-period../voting/design.md—<PurchaseFlowContainer>/useVoteFlow/useVotingPeriodStatus../home/design.md— 同等の Server 合成パターン../candidate-ranking/design.md—rankCandidates純関数の所有steering/product.md「投票期間」 — 業務ルールsteering/structure.md— features / components 責務分離