テーマ
Design: Home画面(候補者一覧)
要件は requirements.md を参照。本ドキュメントは 機能・ロジック層の設計 を定義する。見た目に関する仕様は ui-design.md を参照。 データ層の契約(コレクション・スキーマ・サービス境界)は ../data-model/design.md を唯一の正として参照する。
Overview
Purpose: Miss World Japan 2026 のトップページ(候補者一覧)を、data-model で定義された 3 系統(microCMS / Neon / Vercel ENV)から合成して描画する Server Component と、有料投票導線を制御する Client Component の責務を確定する。
Users: 一般来訪ユーザー(認証なし)。本サイトの 入口画面 であり、有料投票への起点となる。
Impact: 過去 design(自前 app/api/images/[id] 経由の画像配信、Prisma 単独からの候補者取得、DB 上の VotingPeriod シングルトン、_count.votes(where: status='ACTIVE') 集計)を撤回し、画像は microCMS CDN URL を直接使用、候補者は microCMS、得票は Neon JOIN 集計、投票期間は Vercel ENV から取得する構成に置き換える。
Goals
- Server Component で 3 系統(microCMS / Neon / Vercel ENV)を 合成して 描画し、Client へは確定済みの初期データのみを渡す
id昇順表示と Server 算出済みrankの保持を 唯一の真実 とし、Client では再計算しない- 投票成功時の楽観的更新は
voteCountのみでrankを変えない(要件 Feature 4「順位の最新化はページ再訪問時」と整合) - 投票期間取得失敗をエラー画面として表現し、ヘッダー・一覧描画を行わない
Non-Goals
- microCMS スキーマ・Prisma スキーマ・環境変数キー名(
data-model委譲) - 有料投票フロー本体(PaymentIntent 作成、Stripe Webhook ハンドラ、Vote 生成トランザクション)(
voting委譲) - 投票期間内/外の業務ルール定義(
steering/product.md委譲) - ランキング画面・候補者詳細画面のレンダリング(
candidate-ranking/candidate-detail委譲) - ビジュアル表現(色・余白・モーション)(
ui-design.md委譲)
Boundary Commitments
This Spec Owns
app/page.tsx(Server Component)の初期データ合成手順とエラー伝播app/_components/HomePageClient.tsx(Client Component)の責務範囲と props 契約- 表示順(候補者 ID 昇順)と Server 側 rank 算出の 呼び出し位置(
rankCandidates純関数をcandidate-rankingspec から借用) useCandidateListWithRankフックの契約(voteCountのみ楽観更新・rank保持)- Home 画面で使用するコンポーネント(
<HomeHero>/<CandidateGrid>/<CandidateCard>/<Confetti>)の Props 契約 - LCP 対策(先頭 4 枚の
priority属性付与)とレスポンシブsizes
Borrowed(他 spec 所有)
useVoteFlow/useVotingPeriodStatus: voting spec 単独所有。本 spec はfeatures/voting/hooks/配下のファイルを生成せず、import して使用するのみ。フックの実装変更があれば voting spec の Revalidation Triggers に従うrankCandidates: candidate-ranking spec 所有の純関数。本 spec はfeatures/candidates/utils/rankCandidatesを import して rank 付与に使う(再実装禁止)
Out of Boundary
- microCMS の API キー管理・コレクションスキーマ(
data-model) - Vote / Purchase / PurchaseItem の永続化・トランザクション(
data-model/voting) - PaymentIntent 作成・Stripe Payment Element・Webhook ハンドラ(
voting) - 候補者詳細画面・ランキング画面(各 spec)
- スタイル・モーション・カラートークン(
ui-design.md)
Allowed Dependencies
features/cms/services/candidateService(getCandidates)features/candidates/services/voteAggregationService(getVoteCountsByCandidateIds)features/cms/services/creditPackageService(getActiveCreditPackages)— 投票導線に必要features/candidates/utils/rankCandidates(candidate-ranking spec 所有の純関数)features/voting/hooks/useVoteFlow/useVotingPeriodStatus(voting spec 所有)lib/voting-period.ts(getVotingPeriod/InvalidVotingPeriodError)features/voting/*(PurchaseFlowContainer等)— UI 配置のみで内部実装は voting spec 所有- React 19(
useOptimistic)、motion、next/image、next/link
依存方向: data-model(契約) → home(本 spec) → voting(投票フロー)。逆方向の依存は持たない。
Revalidation Triggers
data-modelのCandidate/RankedCandidate型変更voteAggregationService.getVoteCountsByCandidateIdsのシグネチャ変更lib/voting-period.tsの戻り値型 / エラー cause の変更- 表示順ルール(id 昇順)の変更
- 楽観的更新ルール(
voteCountのみ・rank不変)の変更
Architecture
コンポーネント階層と責務分離
mermaid
graph TB
subgraph Server[Server Component]
Page[app/page.tsx]
end
subgraph Client[Client Component]
HomePageClient[app/_components/HomePageClient.tsx]
HomeHero[<HomeHero>]
CandidateGrid[<CandidateGrid>]
CandidateCard[<CandidateCard>]
Confetti[<Confetti>]
PurchaseFlow[<PurchaseFlowContainer>]
end
subgraph Hooks[features hooks]
UseListRank[useCandidateListWithRank]
UseVoteFlow[useVoteFlow]
UsePeriodStatus[useVotingPeriodStatus]
UseScrollTop[useScrollToTop]
end
subgraph DataModel[data-model spec]
CandidateSvc[features/cms/services/candidateService]
VoteAgg[features/candidates/services/voteAggregationService]
PackageSvc[features/cms/services/creditPackageService]
VotingPeriod[lib/voting-period.ts]
end
Page -->|getCandidates| CandidateSvc
Page -->|getVoteCountsByCandidateIds| VoteAgg
Page -->|getActiveCreditPackages| PackageSvc
Page -->|getVotingPeriod| VotingPeriod
Page -->|props| HomePageClient
HomePageClient --> UseListRank
HomePageClient --> UseVoteFlow
HomePageClient --> UsePeriodStatus
HomePageClient --> UseScrollTop
HomePageClient --> HomeHero
HomePageClient --> CandidateGrid
HomePageClient --> Confetti
HomePageClient --> PurchaseFlow
CandidateGrid --> CandidateCardArchitecture Pattern & Boundary Map
- Pattern: 「Server で確定データを合成 → Client は受け取った確定値を保持・楽観更新するだけ」
- Boundary Map:
- Server 側: 3 系統取得・rank 算出・エラー判定・メタデータ生成
- Client 側: フック束ね・楽観更新・モーダル制御・スクロール制御
- Hooks 層: 振る舞い(rank 保持 / モーダル状態 / 期間ラベル)の純粋ロジック
- Components 層: presentation のみ、ビジネスロジック持たず
- Steering 整合:
structure.mdの「責務分離」(features = logic、components = presentation)に従う
Technology Stack
- Next.js 15 App Router(Server Component / Client Component)
- React 19(
useOptimistic) next/image(microCMS CDN をremotePatterns経由で許可)motion(アニメ)microcms-js-sdk(間接 —lib/microcms.ts経由)@prisma/client@^7(間接 —lib/prisma.ts経由)
File Structure Plan
Directory Structure
app/
├── page.tsx # Server Component: 3 系統合成 + メタデータ
├── layout.tsx # 全画面共通 layout(steering 委譲、本 spec ではいじらない)
├── error.tsx # 取得失敗時のフォールバック画面
├── providers.tsx # クライアント側 providers(本 spec で必要に応じて使用)
└── _components/
└── HomePageClient.tsx # フック束ね + 子配布
features/
└── candidates/
├── hooks/
│ ├── useCandidateListWithRank.ts # Server 算出済み rank を保持しつつ voteCount を楽観更新
│ └── useScrollToTop.ts # スクロールトップボタン制御
└── types.ts # RankedCandidate 再 export(本体は candidate-ranking 所有の rankCandidates)
# 以下は本 spec で生成しない(他 spec 所有のファイルを import するのみ):
# - features/voting/hooks/useVoteFlow.ts (voting spec 所有)
# - features/voting/hooks/useVotingPeriodStatus.ts (voting spec 所有)
# - features/candidates/utils/rankCandidates.ts (candidate-ranking spec 所有)
components/
└── public/
├── HomeHero.tsx # サイトタイトル + 投票期間表示 + ランキング画面導線
├── CandidateGrid.tsx # スタガード描画用ラッパ
├── CandidateCard.tsx # 個別カード(priority / sizes / 詳細遷移 / 投票導線)
└── Confetti.tsx # 投票成功時の演出Modified Files
app/page.tsx— 3 系統合成版に書き直し、自前candidateService.getAllWithRank()は廃止app/error.tsx— 取得失敗時のフォールバック表示(投票期間取得失敗を含む)を整備next.config.ts—data-modelで追加される microCMS のremotePatternsを共有
Out-of-scope(再導入禁止)
app/api/images/[id]/**(microCMS CDN をnext/imageで直接読む)features/candidates/services/candidateService.ts(Prisma 直叩き版)features/voting/services/votingPeriodService.ts(DB 由来版)useState<'purchase' | null>のような個別モーダル state をHomePageClientに持たせる実装
System Flows
Flow 1: 初期描画(Server 合成)
mermaid
sequenceDiagram
participant Browser
participant Page as app/page.tsx (Server)
participant CmsCand as candidateService
participant CmsPkg as creditPackageService
participant VoteAgg as voteAggregationService
participant PeriodLib as lib/voting-period
Browser->>Page: GET /
par 並列取得
Page->>CmsCand: getCandidates()
Page->>CmsPkg: getActiveCreditPackages()
Page->>PeriodLib: getVotingPeriod()
end
CmsCand-->>Page: Candidate[](公開済みのみ)
CmsPkg-->>Page: CreditPackage[](isActive=true)
PeriodLib-->>Page: { startsAt, endsAt } or throw InvalidVotingPeriodError
Page->>VoteAgg: getVoteCountsByCandidateIds(ids)
VoteAgg-->>Page: Map<candidateId, count>
Page-->>Page: merge Candidate + voteCount, 算出 rank(voteCount 降順 + id 昇順)
Page-->>Browser: HTML(<HomePageClient candidates votingPeriod creditPackages />)Key Decision:
getVoteCountsByCandidateIdsの引数は 公開済み Candidate の ID 集合 で絞り込み、未公開候補者は集計に含めない(data-modelReq 4.8 を画面側で適用)rankは Server で確定し、Client へ渡してからは再計算しない
Flow 2: 投票成功時の楽観更新
mermaid
sequenceDiagram
participant User
participant Card as <CandidateCard>
participant Client as HomePageClient
participant Flow as useVoteFlow
participant Purchase as <PurchaseFlowContainer>
participant Optimistic as useOptimistic state
User->>Card: 投票ボタン押下
Card->>Client: onPaidVote(candidate)
Client->>Flow: openPurchaseModal(candidate)
Flow->>Purchase: open
Purchase-->>Flow: onComplete({ candidateId, totalCredits })
Flow->>Optimistic: addOptimisticVote({ candidateId, delta: totalCredits })
Optimistic-->>Client: voteCount のみ更新(rank 据え置き)
Flow->>Card: triggerConfettiKey Decision:
- 楽観更新の reducer は
voteCountのみ書き換え、rankは Server 値を必ず維持 - Confetti / モーダルクローズの順序は
useVoteFlow内で完結
Flow 3: 投票期間取得失敗
mermaid
stateDiagram-v2
[*] --> Fetching
Fetching --> ValidPeriod: getVotingPeriod() 成功
Fetching --> ErrorBoundary: InvalidVotingPeriodError
ValidPeriod --> Rendered: HomePageClient 描画
ErrorBoundary --> ErrorPage: app/error.tsx 表示
Rendered --> [*]
ErrorPage --> [*]Key Decision: InvalidVotingPeriodError は Server Component で 再 throw し、app/error.tsx の fallback で受ける。HomePageClient は 絶対に呼ばれない(ヘッダー・候補者一覧の描画を抑止)。
Requirements Traceability
| Req | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1 | Home 表示時にヘッダーと候補者一覧を描画 | app/page.tsx + <HomeHero> + <CandidateGrid> | Server Component レンダリング | Flow 1 |
| 1.2 | ページ上部のサイトタイトル・サブコピー表示 | <HomeHero> | 固定文言 props | Flow 1 |
| 1.2 | 投票期間表示(固定値禁止) | <HomeHero> | votingPeriod props(getVotingPeriod() 由来) | Flow 1 |
| 1.2 | ランキング画面への遷移 | <HomeHero> | next/link | Flow 1 |
| 1.3 | 期間値は data-model 由来 | lib/voting-period.ts(data-model) | getVotingPeriod() | Flow 1 |
| 1.4 | 有効期間取得失敗時はエラー画面 | app/page.tsx + app/error.tsx | throw → error.tsx | Flow 3 |
| 2.1 | 全候補者を一覧として表示 | <CandidateGrid> | candidates props | Flow 1 |
| 2.2 | 各候補者で画像・現在順位・得票数・名前・詳細導線を表示 | <CandidateCard> | RankedCandidate props | Flow 1 |
| 3.1 | 候補者 ID 昇順で表示 | app/page.tsx + useCandidateListWithRank | getCandidates() 取得結果を id 昇順にソート | Flow 1 |
| 3.2 | 投票数変動で並び順を変えない | useCandidateListWithRank | reducer は voteCount のみ更新 | Flow 2 |
| 4.1 | 現在順位は voteCount 降順から算出 | app/page.tsx(Server で確定) | rank 付与ロジック | Flow 1 |
| 4.2 | 順位最新化はページ再訪問時 | Server 算出 + Client 据え置き | useOptimistic は voteCount のみ更新 | Flow 2 |
| 4.3 | 同得票時は ID 昇順タイブレーク | app/page.tsx 内 sort | `(a,b) => b.votes - a.votes | |
| 5.1 | 候補者詳細へ単一操作で遷移 | <CandidateCard> | next/link to /candidate/[id] | — |
「全候補者」のスコープは
getCandidates()が返す公開済みコンテンツに限定される(data-modelReq 1.5 / 1.6)。これは要件 2.1 の運用解釈と整合する。
Components and Interfaces
Summary
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|---|---|---|---|---|---|
app/page.tsx | Server | 3 系統合成 + rank 算出 + メタデータ + エラー伝播 | 1.1-1.4, 3.1, 4.1-4.3 | data-model services(P0) | Service |
app/_components/HomePageClient.tsx | Client | フック束ね + props 配布 | 2.1, 3.2, 4.2 | hooks(P0) | UI |
app/error.tsx | Server | フォールバック画面 | 1.4 | Next.js error boundary(P0) | UI |
useCandidateListWithRank | Hook | Server 算出 rank を保持しつつ voteCount を楽観更新 | 3.2, 4.2 | useOptimistic(P0) | Service |
useVoteFlow | Hook | モーダル開閉 + Confetti 制御 | 2.2(投票導線) | (純関数) | Service |
useVotingPeriodStatus | Hook | 期間内判定 + ボタンラベル | (voting 経由) | clock(P1) | Service |
useScrollToTop | Hook | スクロールトップボタン制御 | — | — | Service |
<HomeHero> | Component | サイトタイトル + 期間表示 + ランキング遷移 | 1.2 | next/link(P0) | UI |
<CandidateGrid> | Component | カード一覧描画 | 2.1 | <CandidateCard>(P0) | UI |
<CandidateCard> | Component | 個別カード + 詳細遷移 + 投票導線 | 2.2, 5.1 | next/image, next/link(P0) | UI |
<Confetti> | Component | 投票成功演出 | 2.2 | motion(P1) | UI |
Server Component: app/page.tsx
| Field | Detail |
|---|---|
| Intent | 3 系統(microCMS / Neon / Vercel ENV)を合成し、確定済み初期データで HomePageClient を描画 |
| Requirements | 1.1, 1.2, 1.3, 1.4, 3.1, 4.1, 4.2, 4.3 |
Contracts: Service [x]
Algorithm
typescript
// app/page.tsx (Server Component, 簡略)
import { getCandidates } from '@/features/cms/services/candidateService';
import { getActiveCreditPackages } from '@/features/cms/services/creditPackageService';
import { getVoteCountsByCandidateIds } from '@/features/candidates/services/voteAggregationService';
import { rankCandidates, type RankedCandidate } from '@/features/candidates/utils/rankCandidates';
import { getVotingPeriod } from '@/lib/voting-period';
import { HomePageClient } from './_components/HomePageClient';
export const metadata = {
title: 'Miss World Japan 2026',
description: '多様な個性が響き合い、可能性を解き放つ',
};
export default async function HomePage() {
const votingPeriod = getVotingPeriod(); // throw on failure → error.tsx
const [candidates, creditPackages] = await Promise.all([
getCandidates(), // 公開済みのみ
getActiveCreditPackages(), // isActive=true のみ
]);
const voteCounts = await getVoteCountsByCandidateIds(
candidates.map((c) => c.id),
);
const withVotes = candidates.map((c) => ({
...c,
voteCount: voteCounts.get(c.id) ?? 0,
}));
// rank 算出は candidate-ranking spec 所有の純関数を借用
// rankCandidates は voteCount 降順 + id 昇順タイブレークで rank を付与する
const rankedDesc = rankCandidates(withVotes);
// 本画面の表示順は id 昇順(要件 Feature 1)。rank マップを抽出して id 昇順に並べ直す
const rankById = new Map(rankedDesc.map((c) => [c.id, c.rank] as const));
const ranked: RankedCandidate[] = [...withVotes]
.sort((a, b) => a.id.localeCompare(b.id))
.map((c) => ({ ...c, rank: rankById.get(c.id)! }));
return (
<HomePageClient
candidates={ranked}
votingPeriod={votingPeriod}
creditPackages={creditPackages}
/>
);
}- Preconditions:
getVotingPeriod()が成功し、getCandidates()が 0 件以上を返す - Postconditions:
<HomePageClient>に id 昇順のRankedCandidate[]を渡す。rankは確定値 - Errors:
InvalidVotingPeriodErrorは throw してerror.tsxで受ける。microCMS / Neon 取得失敗時も同様に throw
Implementation Notes
- Integration:
data-modelの services を直接呼ぶ。Prisma / microCMS SDK を直接触らない - Validation: 公開済み候補者の ID 集合だけを集計対象に渡す(未公開候補は集計から除外)
- Risks: candidates 件数が 0 の場合は空配列のままレンダリング(
<CandidateGrid>は空状態を許容)
Client Component: app/_components/HomePageClient.tsx
| Field | Detail |
|---|---|
| Intent | Server から渡された確定データをフックに渡し、子コンポーネントへ配布する |
| Requirements | 2.1, 3.2, 4.2 |
Contracts: UI [x]
Props
typescript
export type HomePageClientProps = Readonly<{
candidates: RankedCandidate[]; // id 昇順、rank 付き
votingPeriod: VotingPeriod; // { startsAt, endsAt }
creditPackages: CreditPackage[]; // isActive=true のみ
}>;Responsibilities (限定)
- フック束ね:
useCandidateListWithRank/useVoteFlow/useVotingPeriodStatus/useScrollToTop - 子コンポーネント(
<HomeHero>/<CandidateGrid>/<PurchaseFlowContainer>/<Confetti>)に props を配布
禁止事項:
useState<'purchase' | null>のような モーダル個別 state を直接保持しない(useVoteFlowが一元管理)- rank の再計算を行わない(Server 値を使う)
- サーバー通信を行わない(投票完了通信は
<PurchaseFlowContainer>の責務)
Implementation Notes
- Integration:
useVoteFlowの戻り値からモーダル開閉 / Confetti を制御する - Validation: Server からの props は信頼可能(型で表現済み)
- Risks: フック合成順序が変わると state が壊れる → 統合テストで
HomePageClientのフック合成を検証
Hook: useCandidateListWithRank
| Field | Detail |
|---|---|
| Intent | Server 確定の rank を維持しつつ、voteCount のみを useOptimistic で楽観更新する |
| Requirements | 3.2, 4.2 |
Contracts: Service [x]
Interface
typescript
// features/candidates/hooks/useCandidateListWithRank.ts
import { useOptimistic } from 'react';
import type { RankedCandidate } from '../types';
export type OptimisticVoteMutation = Readonly<{
candidateId: string;
delta: number;
}>;
export function useCandidateListWithRank(initial: RankedCandidate[]): {
candidates: RankedCandidate[];
addOptimisticVote: (mutation: OptimisticVoteMutation) => void;
};- Preconditions:
initialは Server で id 昇順かつ rank 確定済み - Postconditions: 戻り値
candidatesは id 昇順を維持し、voteCountのみ最新化される - Invariants:
rankフィールドは 絶対に再計算しない
Algorithm
typescript
const reducer = (
current: RankedCandidate[],
mutation: OptimisticVoteMutation,
): RankedCandidate[] =>
current.map((c) =>
c.id === mutation.candidateId
? { ...c, voteCount: c.voteCount + mutation.delta }
: c,
);
const [candidates, addOptimisticVote] = useOptimistic(initial, reducer);Implementation Notes
- Integration:
HomePageClientがuseVoteFlow.onCompleteからaddOptimisticVoteを呼ぶ - Validation:
deltaは正の整数(購入クレジット合計)を期待 - Risks: Server Action 失敗時は React が自動巻き戻し
Hook: useVoteFlow
| Field | Detail |
|---|---|
| Intent | 購入モーダル開閉と Confetti 制御をひとつのフックに集約 |
| Requirements | 2.2(投票導線) |
Contracts: Service [x]
Interface
typescript
// features/voting/hooks/useVoteFlow.ts
import type { Candidate } from '@/features/cms/types';
export type VoteFlowState = Readonly<{
activeModal: 'purchase' | null;
selectedCandidate: Candidate | null;
showConfetti: boolean;
}>;
export function useVoteFlow(): VoteFlowState & {
openPurchaseModal: (candidate: Candidate) => void;
closePurchaseModal: () => void;
handlePaidVoteConfirmed: (params: { totalCredits: number }) => void;
};Implementation Notes
- Integration:
HomePageClientから呼び、<PurchaseFlowContainer>と<Confetti>に状態を渡す - Validation: 同時に複数モーダルが開かないように内部 state で排他制御
- Risks: Confetti 終了タイマーのリーク →
useEffectクリーンアップでclearTimeout
Hook: useVotingPeriodStatus(参照のみ、実装は voting spec 所有)
| Field | Detail |
|---|---|
| Intent | 投票期間内/外の判定と、ボタンラベル(「投票する」/「投票期間外」)を提供 |
| Owner | voting spec(本 spec は contract のみ参照) |
| Requirements | (voting 経由で <CandidateCard> のラベル制御に使う) |
Contracts: Service [x](契約は voting spec の design.md を正とする)
Interface(voting spec が単独実装)
typescript
// features/voting/hooks/useVotingPeriodStatus.ts (voting spec が生成)
export type VotingPeriodStatus = Readonly<{
isOpen: boolean;
buttonLabel: '投票する' | '投票期間外';
}>;
export function useVotingPeriodStatus(period: {
startsAt: Date;
endsAt: Date;
}): VotingPeriodStatus;Hydration 安全性の要件(本 spec が voting spec に課す制約):
- 初期描画(SSR / hydration 直後)では
isOpen=false固定(または props から決定論的に算出)とし、useState+useEffectで mount 後にnew Date()ベースの判定へ更新する - これにより期間境界(
now ≈ startsAt/now ≈ endsAt)で hydration mismatch が発生しないことを保証 votingspec の design でこの初期値ポリシーを明示する責務を負う
Implementation Notes(本 spec での使用面)
- Integration:
<CandidateCard>のvoteLabel/voteDisabled算出に使う - Validation: 期間判定はクライアント時計に依存するが、サーバー側 Server Action でも再判定する(
votingspec) - Risks: クライアント時計の改竄は UI 表示のみへの影響、サーバーガードで実害なし
Component: <HomeHero>
| Field | Detail |
|---|---|
| Intent | サイトタイトル・サブコピー・投票期間・ランキング画面導線を表示 |
| Requirements | 1.2 |
Props
typescript
export type HomeHeroProps = Readonly<{
votingPeriod: VotingPeriod;
}>;表示要素
- サイトタイトル「Miss World Japan 2026」(固定文言)
- サブコピー「多様な個性が響き合い、可能性を解き放つ」(固定文言)
- 投票期間表示(
votingPeriod.startsAt/votingPeriod.endsAtの整形) - ランキング画面遷移ボタン(
<Link href="/ranking">)
Implementation Notes
- 期間の整形は
Intl.DateTimeFormat('ja-JP', { ... })を使用 - 固定値(開催年・期間)を文言にハードコードしない(要件 1.2)
Component: <CandidateGrid> / <CandidateCard>
| Field | Detail |
|---|---|
| Intent | 一覧描画とカード単位の表示・遷移 |
| Requirements | 2.1, 2.2, 5.1 |
Props
typescript
export type CandidateGridProps = Readonly<{
candidates: RankedCandidate[];
onPaidVote: (candidate: Candidate) => void;
}>;
export type CandidateCardProps = Readonly<{
candidate: RankedCandidate;
voteLabel: '投票する' | '投票期間外';
voteDisabled: boolean;
onPaidVote: (candidate: Candidate) => void;
isPriority: boolean;
}>;表示要素(カード)
- 候補者画像(
candidate.images[0]の microCMS CDN URL) - 現在順位(
candidate.rank) - 候補者名(
candidate.displayName) - 得票数(3 桁区切り、
candidate.voteCount.toLocaleString('ja-JP')) - 詳細遷移(
<Link href={\/candidate/${candidate.id}`}>`) - 投票ボタン(
onPaidVote(candidate)を発火)
Implementation Notes
next/imageのsrcは microCMS の CDN URL をそのまま使用(自前画像配信エンドポイントは存在しない)- 先頭 4 枚は
priority属性付与(<CandidateGrid>が index < 4 を判定) sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"固定React.memo化、onPaidVoteはuseCallbackで安定化
Data Models
Domain Types(画面層)
typescript
// features/cms/types.ts(data-model 所有、Home から参照のみ)
export type Candidate = Readonly<{
id: string; // microCMS contentId
displayName: string;
region: string;
age: number;
height: number;
occupation: string;
hobbies: ReadonlyArray<string>;
certifications: ReadonlyArray<string>;
motto: string;
dream: string;
message: string;
displayOrder: number;
images: ReadonlyArray<{ url: string; mime: 'JPEG' | 'PNG' | 'WEBP' }>;
}>;
// features/candidates/types.ts(本 spec で追加)
export type RankedCandidate = Candidate & Readonly<{
voteCount: number; // Purchase.status='SUCCEEDED' に絞った集計値
rank: number; // Server 確定(voteCount 降順 + id 昇順タイブレーク)
}>;Notes
Candidate.images[i].urlは microCMS CDN の絶対 URL。next/imageのsrcにそのまま渡せるvoteCountはdata-modelのvoteAggregationService.getVoteCountsByCandidateIds由来rankは Server でのみ算出し、Client では保持のみ
Error Handling
| シナリオ | 検出箇所 | 対応 |
|---|---|---|
| 候補者取得失敗(microCMS) | Server Component | throw → app/error.tsx で fallback UI |
| 得票集計失敗(Neon) | Server Component | throw → app/error.tsx で fallback UI |
InvalidVotingPeriodError | Server Component | throw → app/error.tsx「開催情報を取得できませんでした」 |
| 投票期間外 | useVotingPeriodStatus + Server Action(voting) | ボタンラベル「投票期間外」、Server Action は voting spec のガードで VotingPeriodClosedError |
| ニックネーム無効 | 購入モーダル(voting)+ Server Action | クライアント表示、サーバー InvalidNicknameError(data-model) |
| 二重送信 | クライアント送信ガード + paymentIntentId UNIQUE | UI 抑止 + サーバー側で冪等(voting) |
Testing Strategy
Unit
useCandidateListWithRank: 楽観更新でvoteCountのみ変化しrankが不変であることを検証(要件 3.2 / 4.2)useVoteFlow: モーダル開閉・Confetti 表示の状態機械(要件 2.2)useVotingPeriodStatus: 期間境界(now === startsAt/now === endsAt)の判定
Integration
HomePageClientのフック合成:useVoteFlow.onComplete→addOptimisticVote→ カード再描画 を 1 テストで検証- Server Component
app/page.tsxの合成: モックされた services から得たデータで rank が ID タイブレーク含めて正しく確定すること(要件 4.1 / 4.3) app/error.tsx経由のエラー伝播:getVotingPeriodがInvalidVotingPeriodErrorを投げた場合に<HomePageClient>が呼ばれないこと(要件 1.4)
E2E(Playwright)
- ホームを開く → 候補者カードが ID 昇順で並び、rank が voteCount 降順で付与されていることを DOM で検証(要件 3.1 / 4.1)
- カードの投票ボタン → 購入モーダル → 決済成功(Stripe テストキー)→ Confetti と voteCount 更新を確認(要件 2.2)
- 詳細遷移リンクが
/candidate/[id]に到達すること(要件 5.1)
Security Considerations
- Server Component が読み取り専用 microCMS API キーを使用。クライアントに漏れない(Server-only)
voteAggregationServiceは SELECT のみ。クライアントに公開されない- 投票期間判定は サーバー側でも再判定(クライアント表示のみに依存しない、
votingspec 側で実装)
Performance
- 初期表示:
getCandidates/getActiveCreditPackagesを Next.js Data Cache に乗せる(TTL はdata-model委譲)。getVoteCountsByCandidateIdsはキャッシュなし - 画像:
next/image+ microCMS CDN +priority(先頭 4 枚)+sizes固定 - アニメーション:
motionの GPU アクセラレーション - 再レンダリング:
<CandidateCard>をReact.memo化、onPaidVoteをuseCallbackで安定化
Migration Strategy
app/page.tsxからcandidateService.getAllWithRank()呼び出しを削除し、3 系統合成版に置き換えfeatures/candidates/services/candidateService.ts(旧版)が残っていれば削除(data-model所有のfeatures/cms/services/candidateService.tsと置き換わる)features/voting/services/votingPeriodService.ts(旧版、DB 由来)が残っていれば削除し、lib/voting-period.tsに統一app/api/images/[id]/**関連の参照を削除し、next/imageに microCMS CDN URL を直接渡すuseCandidateListWithRankの reducer がvoteCountのみ更新しrankを据え置く実装になっていることを確認- E2E でホーム描画と投票フローを通す
Supporting References
requirements.md— 機能要件ui-design.md— ビジュアル仕様../data-model/design.md— データ層契約(Candidate/RankedCandidate/ 各 service)../voting/— 投票フロー本体../candidate-detail/— 詳細遷移先../candidate-ranking/— ランキング遷移先steering/product.md「投票期間」 — 業務ルールsteering/structure.md— features / components 責務分離