Skip to content

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 強化
  • 投票フロー本体は voting spec の <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 契約により generateMetadatapage.tsx の二重取得は dedupe される、data-model spec の契約に依存)
  • features/cms/services/creditPackageService.getActiveCreditPackages
  • features/candidates/services/voteAggregationService.getVoteCountsByCandidateIds / getTopVotersByCandidateId(戻り値の nicknamenormalizeNickname 適用済み、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.getVotingPeriod
  • next/image(microCMS CDN)、next/link

Revalidation Triggers

  • data-modelCandidate / 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 --> Confetti

Architecture 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 />)
    end

Key Decision:

  • 順位算出は全候補者を取得して rankCandidates 純関数で実行(candidate-ranking 所有のユーティリティを共有)
  • 404 は next/navigationnotFound() を使う

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' }
    end

Key Decision:

  • generateMetadata は別途 getCandidate(id) を call するが、Next.js が page.tsx の取得と dedupe するため実質 1 回(fetch cache 経由)

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: triggerConfetti

Key Decision:

  • 楽観更新は voteCount のみ。rank はページ再訪問時に最新化(要件 Feature 3)

Requirements Traceability

ReqSummaryComponentsInterfacesFlows
1.1ID をキーに候補者取得page.tsxgetCandidate(id)Flow 1
1.2該当なしで 404page.tsxnotFound()Flow 1
1.3候補者名を含むメタデータ生成generateMetadataNext.js metadata APIFlow 2
2.1候補者一覧/ランキングへの戻り導線<DetailHeader> または共通 layout<Link href="/"> / <Link href="/ranking">
3.1現在順位・名前・得票数を表示<DetailHeader>propsFlow 1
3.2現在順位はページ表示時点の voteCount 降順から算出page.tsx + rankCandidates全候補者ソート → 該当 ID の rankFlow 1
3.3投票更新時は voteCount のみ更新、順位は不変useOptimisticreducer で voteCount のみFlow 3
3.4順位最新化はページ再訪問時Server 算出Flow 1
4.1投票アクション 1 つ、ラベルは「投票する」<PaidVoteButton> + useVotingPeriodStatuslabel props
5.1切り替え可能なギャラリーとして表示<CandidateImageSlider>images props
5.2画像切り替え操作で対応画像を表示useImageSlidercurrentIndex 状態
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.1PR メッセージの段落構造を保持<CandidateInfo>whitespace-pre-line で改行保持
7.2PR メッセージを他プロフィールと区別<CandidateInfo>セクション分け
8.1応援者ランキング上位 5 件<VoterRanking> + voteAggregationServicegetTopVotersByCandidateId(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 を動的生成generateMetadataimages: [{ url: candidate.images[0].url }]Flow 2

Components and Interfaces

Summary

ComponentDomain/LayerIntentReq CoverageKey DependenciesContracts
app/candidate/[id]/page.tsxServer候補者取得 + 404 + 順位算出 + 応援者取得 + メタデータ1.1-1.3, 3.2, 8.1, Flow 1/2data-model services(P0)Service
generateMetadataServer動的 OGP / タイトル1.3, 非機能candidateService(P0)Service
<CandidateDetailClient>Clientフック束ね + 子配布 + 楽観更新3.3, 9.1hooks(P0)UI
useImageSliderHook画像スライダーの index 管理5.2(純フック)Service
useVoteFlow(voting 所有)Hookモーダル開閉 + Confetti4.1, 9.1Service
useVotingPeriodStatus(voting 所有)Hook期間内/外ラベル制御4.1clockService
<DetailHeader>Component名前 + 順位 + 得票数3.1UI
<CandidateImageSlider>Component画像ギャラリー + サムネイル + 切替5.1-5.5next/imageUI
<CandidateInfo>Componentプロフィール表 + PR メッセージ6.x, 7.xUI
<VoterRanking>Component応援者ランキング上位 58.xUI
<PaidVoteButton>Component投票導線(ラベル切替)4.1useVotingPeriodStatus 結果UI
<PurchaseFlowContainer>(voting 所有)Component投票フロー(voting)UI
<Confetti>Component成功演出9.1UI

Server Component: app/candidate/[id]/page.tsx

FieldDetail
Intent候補者単一取得 + 404 判定 + 全候補者から順位算出 + 応援者集計 + 投票期間取得を行い、Client へ確定済みデータを渡す
Requirements1.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>

FieldDetail
IntentServer から渡された確定データをフックに渡し、子コンポーネントへ配布 + 楽観更新
Requirements3.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) で画像切替
  • useOptimisticcandidate.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/imagesrc は 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 でも voting spec が再判定)

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 getCandidatenotFound() → 404
microCMS 一覧取得失敗page.tsxthrow → app/error.tsx
応援者集計失敗page.tsxthrow → app/error.tsx
InvalidVotingPeriodErrorpage.tsxthrow → app/error.tsx
target が ranked にいない(整合性異常)page.tsxnotFound()(保険)
投票期間外useVotingPeriodStatus + voting specボタンラベル「投票期間外」+ Server Action ガード
投票成功useVoteFlow.handlePaidVoteConfirmedConfetti + 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

  • 詳細ページは閲覧+投票導線のみ。投票本体のセキュリティは voting spec(CSRF / Webhook 署名 / 金額改竄防止)
  • microCMS API は読み取り専用キー
  • 候補者非公開状態の漏洩を避けるため、getCandidate(id)公開済みのみ を返す(data-model 所有)

Performance

  • 初期表示: 全候補者取得 + 集計の合計で 200-400ms 想定
  • 画像: メイン画像 1 枚に prioritynext/image + microCMS CDN
  • メタデータ取得: page.tsx の取得と generateMetadata の取得は data-model spec が保証する Request Memoization 契約(fetch 直接 or React cache() ラップ)により dedupe される。dedupe されない場合は data-model spec の Revalidation Trigger に該当し本 spec も再検証対象
  • 再レンダリング: <CandidateImageSlider> のみ index 変更で再描画、他は React.memo 化を検討

Migration Strategy

  1. app/candidate/[id]/page.tsx から candidateService.getAllWithRank() 呼び出しを削除し、getCandidates + voteAggregationService + rankCandidates の合成に置き換え
  2. features/voting/services/voterService.ts の参照を削除し、voteAggregationService.getTopVotersByCandidateId に切替
  3. features/voting/services/votingPeriodService.ts の参照を削除し、lib/voting-period.getVotingPeriod に切替
  4. app/api/images/[id]/** 参照を削除し、next/image に microCMS CDN URL を直渡し
  5. <CandidateDetailClient>useOptimistic reducer が voteCount のみ更新する実装になっていることを確認
  6. E2E で詳細画面と投票フローを通す

Supporting References

  • requirements.md — 機能要件
  • ui-design.md — ビジュアル仕様
  • ../data-model/design.mdCandidate / voteAggregationService / creditPackageService / lib/voting-period
  • ../voting/design.md<PurchaseFlowContainer> / useVoteFlow / useVotingPeriodStatus
  • ../home/design.md — 同等の Server 合成パターン
  • ../candidate-ranking/design.mdrankCandidates 純関数の所有
  • steering/product.md「投票期間」 — 業務ルール
  • steering/structure.md — features / components 責務分離