Skip to content

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-ranking spec から借用)
  • 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)、motionnext/imagenext/link

依存方向: data-model(契約) → home(本 spec) → voting(投票フロー)。逆方向の依存は持たない。

Revalidation Triggers

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

Architecture 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.tsdata-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-model Req 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: triggerConfetti

Key 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

ReqSummaryComponentsInterfacesFlows
1.1Home 表示時にヘッダーと候補者一覧を描画app/page.tsx + <HomeHero> + <CandidateGrid>Server Component レンダリングFlow 1
1.2ページ上部のサイトタイトル・サブコピー表示<HomeHero>固定文言 propsFlow 1
1.2投票期間表示(固定値禁止)<HomeHero>votingPeriod props(getVotingPeriod() 由来)Flow 1
1.2ランキング画面への遷移<HomeHero>next/linkFlow 1
1.3期間値は data-model 由来lib/voting-period.ts(data-model)getVotingPeriod()Flow 1
1.4有効期間取得失敗時はエラー画面app/page.tsx + app/error.tsxthrow → error.tsxFlow 3
2.1全候補者を一覧として表示<CandidateGrid>candidates propsFlow 1
2.2各候補者で画像・現在順位・得票数・名前・詳細導線を表示<CandidateCard>RankedCandidate propsFlow 1
3.1候補者 ID 昇順で表示app/page.tsx + useCandidateListWithRankgetCandidates() 取得結果を id 昇順にソートFlow 1
3.2投票数変動で並び順を変えないuseCandidateListWithRankreducer は 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-model Req 1.5 / 1.6)。これは要件 2.1 の運用解釈と整合する。


Components and Interfaces

Summary

ComponentDomain/LayerIntentReq CoverageKey DependenciesContracts
app/page.tsxServer3 系統合成 + rank 算出 + メタデータ + エラー伝播1.1-1.4, 3.1, 4.1-4.3data-model services(P0)Service
app/_components/HomePageClient.tsxClientフック束ね + props 配布2.1, 3.2, 4.2hooks(P0)UI
app/error.tsxServerフォールバック画面1.4Next.js error boundary(P0)UI
useCandidateListWithRankHookServer 算出 rank を保持しつつ voteCount を楽観更新3.2, 4.2useOptimistic(P0)Service
useVoteFlowHookモーダル開閉 + Confetti 制御2.2(投票導線)(純関数)Service
useVotingPeriodStatusHook期間内判定 + ボタンラベル(voting 経由)clock(P1)Service
useScrollToTopHookスクロールトップボタン制御Service
<HomeHero>Componentサイトタイトル + 期間表示 + ランキング遷移1.2next/link(P0)UI
<CandidateGrid>Componentカード一覧描画2.1<CandidateCard>(P0)UI
<CandidateCard>Component個別カード + 詳細遷移 + 投票導線2.2, 5.1next/image, next/link(P0)UI
<Confetti>Component投票成功演出2.2motion(P1)UI

Server Component: app/page.tsx

FieldDetail
Intent3 系統(microCMS / Neon / Vercel ENV)を合成し、確定済み初期データで HomePageClient を描画
Requirements1.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

FieldDetail
IntentServer から渡された確定データをフックに渡し、子コンポーネントへ配布する
Requirements2.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

FieldDetail
IntentServer 確定の rank を維持しつつ、voteCount のみを useOptimistic で楽観更新する
Requirements3.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: HomePageClientuseVoteFlow.onComplete から addOptimisticVote を呼ぶ
  • Validation: delta は正の整数(購入クレジット合計)を期待
  • Risks: Server Action 失敗時は React が自動巻き戻し

Hook: useVoteFlow

FieldDetail
Intent購入モーダル開閉と Confetti 制御をひとつのフックに集約
Requirements2.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 所有)

FieldDetail
Intent投票期間内/外の判定と、ボタンラベル(「投票する」/「投票期間外」)を提供
Ownervoting 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 が発生しないことを保証
  • voting spec の design でこの初期値ポリシーを明示する責務を負う

Implementation Notes(本 spec での使用面)

  • Integration: <CandidateCard>voteLabel / voteDisabled 算出に使う
  • Validation: 期間判定はクライアント時計に依存するが、サーバー側 Server Action でも再判定する(voting spec)
  • Risks: クライアント時計の改竄は UI 表示のみへの影響、サーバーガードで実害なし

Component: <HomeHero>

FieldDetail
Intentサイトタイトル・サブコピー・投票期間・ランキング画面導線を表示
Requirements1.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>

FieldDetail
Intent一覧描画とカード単位の表示・遷移
Requirements2.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/imagesrc は microCMS の CDN URL をそのまま使用(自前画像配信エンドポイントは存在しない)
  • 先頭 4 枚は priority 属性付与(<CandidateGrid> が index < 4 を判定)
  • sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" 固定
  • React.memo 化、onPaidVoteuseCallback で安定化

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/imagesrc にそのまま渡せる
  • voteCountdata-modelvoteAggregationService.getVoteCountsByCandidateIds 由来
  • rank は Server でのみ算出し、Client では保持のみ

Error Handling

シナリオ検出箇所対応
候補者取得失敗(microCMS)Server Componentthrow → app/error.tsx で fallback UI
得票集計失敗(Neon)Server Componentthrow → app/error.tsx で fallback UI
InvalidVotingPeriodErrorServer Componentthrow → app/error.tsx「開催情報を取得できませんでした」
投票期間外useVotingPeriodStatus + Server Action(voting)ボタンラベル「投票期間外」、Server Action は voting spec のガードで VotingPeriodClosedError
ニックネーム無効購入モーダル(voting)+ Server Actionクライアント表示、サーバー InvalidNicknameError(data-model)
二重送信クライアント送信ガード + paymentIntentId UNIQUEUI 抑止 + サーバー側で冪等(voting)

Testing Strategy

Unit

  • useCandidateListWithRank: 楽観更新で voteCount のみ変化し rank が不変であることを検証(要件 3.2 / 4.2)
  • useVoteFlow: モーダル開閉・Confetti 表示の状態機械(要件 2.2)
  • useVotingPeriodStatus: 期間境界(now === startsAt / now === endsAt)の判定

Integration

  • HomePageClient のフック合成: useVoteFlow.onCompleteaddOptimisticVote → カード再描画 を 1 テストで検証
  • Server Component app/page.tsx の合成: モックされた services から得たデータで rank が ID タイブレーク含めて正しく確定すること(要件 4.1 / 4.3)
  • app/error.tsx 経由のエラー伝播: getVotingPeriodInvalidVotingPeriodError を投げた場合に <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 のみ。クライアントに公開されない
  • 投票期間判定は サーバー側でも再判定(クライアント表示のみに依存しない、voting spec 側で実装)

Performance

  • 初期表示: getCandidates / getActiveCreditPackages を Next.js Data Cache に乗せる(TTL は data-model 委譲)。getVoteCountsByCandidateIds はキャッシュなし
  • 画像: next/image + microCMS CDN + priority(先頭 4 枚)+ sizes 固定
  • アニメーション: motion の GPU アクセラレーション
  • 再レンダリング: <CandidateCard>React.memo 化、onPaidVoteuseCallback で安定化

Migration Strategy

  1. app/page.tsx から candidateService.getAllWithRank() 呼び出しを削除し、3 系統合成版に置き換え
  2. features/candidates/services/candidateService.ts(旧版)が残っていれば削除(data-model 所有の features/cms/services/candidateService.ts と置き換わる)
  3. features/voting/services/votingPeriodService.ts(旧版、DB 由来)が残っていれば削除し、lib/voting-period.ts に統一
  4. app/api/images/[id]/** 関連の参照を削除し、next/image に microCMS CDN URL を直接渡す
  5. useCandidateListWithRank の reducer が voteCount のみ更新し rank を据え置く実装になっていることを確認
  6. 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 責務分離