テーマ
プロジェクト構造とコーディング規約
設計の基本方針: デザインと機能の分離
このプロジェクトは Headless UI パターン + Feature-based 構造 を採用し、デザイン(見た目)と機能(ロジック)を明確に分離する。
3層モデル
┌─────────────────────────────────────────────────────────┐
│ app/ (Pages) 画面の組み立て・データ取得・レイアウト │
├─────────────────────────────────────────────────────────┤
│ features/ (Logic) 機能ロジック(フック・サービス・型) │
├─────────────────────────────────────────────────────────┤
│ components/ (UI) 表示専用コンポーネント(presentation)│
└─────────────────────────────────────────────────────────┘| 層 | 責務 | 例 |
|---|---|---|
| app/ | ページ単位の組み立て、データ取得、メタデータ生成 | page.tsx 内で useVoteAction と <VoteButton> を組み合わせる |
| features/ | ビジネスロジック・状態管理・API通信 | useVoteAction.ts, voteService.ts, VotingContext.tsx |
| components/ | propsを受け取って表示するだけ | <VoteButton canVote label onClick> |
分離の原則
- components/ 配下のコンポーネントは features/ をインポートしてはならない(依存方向は app → features / app → components のみ)
- components/ は React Hooks の useState / useReducer などローカル状態のみ許可。Context消費・API呼び出し・ビジネスロジックは禁止
- features/ のフックはUIを返さない。
{ data, action }形式の値・関数のみを返す - app/ が両者を組み合わせる場になる
ディレクトリ構成
miss-world-japan-voting/
├── .kiro/
│ ├── steering/ # プロジェクト全体の方針
│ └── specs/ # 機能・画面ごとの仕様
│ └── <feature>/
│ ├── requirements.md # 機能要件(What)
│ ├── design.md # 技術設計(How: ロジック・データフロー)
│ └── ui-design.md # ビジュアルデザイン仕様(How: 見た目)
│
├── app/ # Pages層: 画面組み立て
│ ├── layout.tsx
│ ├── globals.css # CSS変数 (design tokens)
│ ├── providers.tsx # Provider集約 ("use client")
│ ├── page.tsx # Home
│ ├── candidate/[id]/page.tsx # 候補者詳細
│ ├── ranking/page.tsx # ランキング
│ └── api/ # 将来のRoute Handlers
│
├── features/ # Logic層: ビジネスロジック
│ ├── auth/
│ │ ├── contexts/AuthContext.tsx
│ │ ├── hooks/
│ │ │ ├── useAuth.ts
│ │ │ └── useLoginFlow.ts
│ │ ├── services/authService.ts
│ │ └── types.ts
│ ├── voting/
│ │ ├── contexts/VotingContext.tsx
│ │ ├── hooks/
│ │ │ ├── useVoteAction.ts # 投票実行
│ │ │ ├── useFreeVoteStatus.ts # 無料投票可否
│ │ │ └── useVoteCountdown.ts # 残り時間
│ │ ├── services/voteService.ts
│ │ └── types.ts
│ ├── candidates/
│ │ ├── hooks/
│ │ │ ├── useCandidates.ts
│ │ │ ├── useCandidateListWithRank.ts # ID昇順 + 順位付与(home画面用)
│ │ │ └── useCandidateRanking.ts # 得票降順 + 順位付与(ranking画面用)
│ │ ├── services/candidateService.ts
│ │ ├── data/candidates.ts # デモ用静的データ
│ │ └── types.ts
│ ├── voters/
│ │ ├── hooks/useVoterRanking.ts
│ │ ├── data/voters.ts
│ │ └── types.ts
│ └── payment/
│ ├── hooks/usePurchaseFlow.ts
│ ├── services/stripeService.ts
│ ├── data/creditPackages.ts
│ └── types.ts
│
├── components/ # UI層: 表示専用
│ ├── ui/ # shadcn/ui プリミティブ
│ ├── layout/
│ │ ├── Footer.tsx
│ │ ├── PageBackground.tsx # 装飾背景
│ │ ├── ScrollToTop.tsx
│ │ └── ScrollToTopButton.tsx
│ ├── candidate/
│ │ ├── CandidateCard.tsx
│ │ ├── CandidateImageSlider.tsx
│ │ ├── CandidateInfo.tsx
│ │ ├── CandidateBadge.tsx
│ │ └── CandidateTag.tsx
│ ├── ranking/
│ │ ├── RankingList.tsx
│ │ ├── RankingItem.tsx
│ │ └── RankIcon.tsx
│ ├── voter/
│ │ ├── VoterRanking.tsx
│ │ └── VoterRankItem.tsx
│ ├── voting/
│ │ ├── VoteButton.tsx
│ │ ├── FreeVoteButton.tsx
│ │ ├── PaidVoteButton.tsx
│ │ └── VoteCountdown.tsx
│ ├── modals/
│ │ ├── SocialLoginModal.tsx
│ │ ├── PurchaseCreditsModal.tsx
│ │ ├── CreditPackageCard.tsx
│ │ └── StripePaymentModal.tsx
│ └── effects/
│ └── Confetti.tsx
│
├── containers/ # (任意) UI と feature を繋ぐ薄い層
│ ├── CandidateCardContainer.tsx
│ └── VoteButtonContainer.tsx
│
├── design-tokens/ # デザイントークン
│ ├── colors.ts # TS でも参照可能にする
│ ├── typography.ts
│ └── tokens.css # CSS 変数定義(globals.css から import)
│
├── lib/ # 汎用ユーティリティ
│ ├── cn.ts
│ └── date.ts
│
├── public/
│ └── images/
│
├── .storybook/ # Storybook 設定
└── stories/ # コンポーネントカタログ(components/ と対応)レイヤーごとの実装ルール
features/ レイヤー (機能)
- 公開するもの: カスタムフック、サービス関数、型
- 公開しないもの: JSX、HTML、CSSクラス、UIプリミティブ
- 依存先: 他 features の hooks(限定的)、lib、外部API
- テスト: フックは
@testing-library/reactのrenderHook、サービスは Vitest 単体テスト
ts
// Good: features/voting/hooks/useVoteAction.ts
export function useVoteAction(candidateId: number) {
const { isAuthenticated } = useAuth();
const { canVoteFree, executeFreeVote } = useVoting();
return {
canVote: canVoteFree(),
requiresLogin: !isAuthenticated,
vote: () => executeFreeVote(candidateId),
};
}
// Bad: フック内で JSX を返す
export function useVoteButton() {
return <button>投票</button>; // NG
}components/ レイヤー (UI)
- 公開するもの: React コンポーネント
- 必須: すべての props を型定義、Storybookストーリー、デフォルト値
- 禁止:
useAuth(),useVoting()などの Context 消費、fetch()、ビジネスロジック - 許可:
useState(UI内部状態のみ)、useRef、アニメーション制御
tsx
// Good: 表示専用、propsのみで全制御可能
type VoteButtonProps = {
variant: 'free' | 'paid';
canVote: boolean;
label: string;
onClick: () => void;
};
export function VoteButton({ variant, canVote, label, onClick }: VoteButtonProps) {
return (
<button
disabled={!canVote}
onClick={onClick}
className={cn(
'px-6 py-3 rounded-full',
variant === 'free' ? 'bg-[var(--gold)] text-white' : 'border-2 border-[var(--gold)]'
)}
>
{label}
</button>
);
}
// Bad: components から features を呼ぶ
export function VoteButton() {
const { canVoteFree } = useVoting(); // NG
return <button disabled={!canVoteFree()}>投票</button>;
}app/ レイヤー (ページ・組み立て)
- 責務: features のフックと components を組み合わせ、画面を構築
- データ取得: Server Component で初期データを取得し、Client Component に props で渡す
- 副作用: Server Actions / Route Handlers から呼ぶ
tsx
// app/page.tsx (Server Component)
import { getCandidates } from '@/features/candidates/services/candidateService';
import { CandidateGrid } from './_components/CandidateGrid';
export default async function HomePage() {
const candidates = await getCandidates();
return <CandidateGrid candidates={candidates} />;
}
// app/_components/CandidateGrid.tsx (Client Component)
'use client';
import { useVoteAction } from '@/features/voting/hooks/useVoteAction';
import { CandidateCard } from '@/components/candidate/CandidateCard';
export function CandidateGrid({ candidates }: Props) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{candidates.map((c) => (
<CandidateCardContainer key={c.id} candidate={c} />
))}
</div>
);
}containers/ レイヤー (任意・接着剤)
ロジックフックと UI が複雑に絡む場合のみ使う。シンプルなページなら不要。
tsx
// containers/CandidateCardContainer.tsx
'use client';
import { useVoteAction } from '@/features/voting/hooks/useVoteAction';
import { CandidateCard } from '@/components/candidate/CandidateCard';
export function CandidateCardContainer({ candidate }: Props) {
const vote = useVoteAction(candidate.id);
return <CandidateCard candidate={candidate} {...vote} />;
}デザイントークン管理
単一ソースの原則
すべての色・タイポ・スペーシングは design-tokens/ で定義し、CSS と TS の両方からアクセスできるようにする。
css
/* design-tokens/tokens.css → app/globals.css で @import */
:root {
--gold: #D4AF37;
--gold-light: #E8D7A2;
--gold-dark: #B8941F;
--rose-gold: #E8B4A8;
--ivory: #FFFFF0;
--charcoal: #2C2C2C;
--font-display: 'Cormorant Garamond', serif;
--font-body: 'Jost', sans-serif;
}ts
// design-tokens/colors.ts (TS 参照用)
export const colors = {
gold: 'var(--gold)',
goldLight: 'var(--gold-light)',
goldDark: 'var(--gold-dark)',
roseGold: 'var(--rose-gold)',
ivory: 'var(--ivory)',
charcoal: 'var(--charcoal)',
} as const;
export type ColorToken = keyof typeof colors;Figma との同期
- Figma 側のデザイントークンを single source of truth とする
- 変更が発生したら
design-tokens/tokens.cssを手動 or CLI(Style Dictionary など)で同期 - 将来的には Figma Tokens Plugin → JSON → コード変換のパイプラインを構築
命名規則
- 色: 役割名(
--primary,--success)と装飾名(--gold,--rose-gold)を併存させる - フォント:
--font-display,--font-body - 角丸:
--radius-sm,--radius-md,--radius-lg,--radius-full - シャドウ:
--shadow-card,--shadow-modal
Storybook によるコンポーネントカタログ
すべての components/ 配下のコンポーネントは Storybook ストーリーを必須とする。
tsx
// stories/voting/VoteButton.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { VoteButton } from '@/components/voting/VoteButton';
const meta: Meta<typeof VoteButton> = {
title: 'Voting/VoteButton',
component: VoteButton,
};
export default meta;
export const FreeVoteAvailable: StoryObj<typeof VoteButton> = {
args: { variant: 'free', canVote: true, label: '無料で投票する(一日一回)' },
};
export const FreeVoteDisabled: StoryObj<typeof VoteButton> = {
args: { variant: 'free', canVote: false, label: '投票済み(あと5時間20分で再投票可能)' },
};
export const PaidVote: StoryObj<typeof VoteButton> = {
args: { variant: 'paid', canVote: true, label: '投票券を購入して投票する' },
};これにより:
- デザイナーが Storybook 上で全状態を確認できる
- ロジックを通さずに UI 単体のレビューが可能
- ビジュアルリグレッションテスト(Chromatic 等)に乗せやすい
デザイナーとのハンドオフ
役割分担
| 担当 | アウトプット |
|---|---|
| デザイナー | Figma デザイン、ui-design.md(仕様)、デザイントークン |
| エンジニア | features/ ロジック、components/ 実装、Storybook ストーリー |
| 協働 | コンポーネント命名、props 設計、状態バリエーションの洗い出し |
レビュー手順
- Figma レビュー: ui-design.md と Figma の整合性を確認
- Storybook レビュー: 実装された components/ をデザイナーが Storybook で確認
- 統合レビュー: app/ 上でロジック含む完成形を最終確認
仕様変更時のフロー
- 見た目だけの変更 → ui-design.md と components/ のみ更新(features は触らない)
- 機能だけの変更 → requirements.md / design.md と features/ のみ更新(components は触らない)
- 両方の変更 → 3つのドキュメントすべてと、各レイヤーのコードを更新
ファイル命名規則
- コンポーネント: PascalCase(
CandidateCard.tsx) - フック: camelCase + use prefix(
useVoteAction.ts) - サービス: camelCase + Service(
voteService.ts) - 型: PascalCase(
Candidate,Vote,User) - ストーリー:
<ComponentName>.stories.tsx - テスト:
<ComponentName>.test.tsx
インポートエイリアス
json
{
"compilerOptions": {
"paths": {
"@/*": ["./*"],
"@/features/*": ["./features/*"],
"@/components/*": ["./components/*"],
"@/design-tokens/*": ["./design-tokens/*"]
}
}
}Git運用
- ブランチ命名:
feature/<spec-name>、design/<area>、refactor/<scope> - コミット: Conventional Commits(
feat:,fix:,style:,refactor:,docs:) - PR レビュー観点: 「features と components の依存方向が正しいか」を必ずチェック
現行実装メモ (Implementation Notes)
上記「ディレクトリ構成」は当初設計時の理想形を示しているが、現行コードベースでは以下の実装パターンが採用されている。新規コードを書く際はこちらの実際のパターンに従うこと。
components/ の分割: 用途別 (public / admin / ui)
feature単位(candidate/, voting/, modals/ など)ではなく、利用面で分割する。
components/
├── ui/ # shadcn/ui プリミティブ (button, card, dialog, ...)
├── public/ # 投票サイト側の表示専用コンポーネント
│ # CandidateCard, CandidateGrid, FreeVoteButton, PaidVoteButton,
│ # PurchaseCreditsModal, SocialLoginModal, Confetti, HomeHero,
│ # PageBackground, Footer, ScrollToTopButton
└── admin/ # 管理画面側のコンポーネント
# AdminNavbar, CandidateForm, ImageUploader,
# CandidateDeleteButton, LoginForm理由: 投票サイトと管理画面は UI トーン・ターゲット利用者・依存ライブラリが大きく異なるため、最初の分岐を「public / admin」にすることで再利用範囲が明確になる。
ページローカル _components/ パターン
そのページでしか使わないクライアントコンポーネントは、ページと同階層の _components/ に置く(Next.js App Router の慣習でアンダースコア始まりはルート対象外)。
app/
├── _components/HomePageClient.tsx
├── candidate/[id]/_components/
│ ├── CandidateDetailClient.tsx
│ ├── CandidateImageSlider.tsx
│ ├── InfoBadgeRow.tsx
│ ├── PRMessage.tsx
│ └── TagList.tsx
└── ranking/_components/
├── RankingItem.tsx
└── RankingPageClient.tsx判断基準:
- 複数画面で再利用される表示部品 →
components/public/またはcomponents/admin/ - 単一ページ専用の組み立て・分割 →
app/<route>/_components/
管理画面 (admin) の構造
管理画面は app/admin/ 配下に集約し、Server Actions を _actions/ に分離する。
app/admin/
├── layout.tsx # 管理画面レイアウト + 認証ガード
├── login/page.tsx
├── candidates/
│ ├── page.tsx # 一覧
│ └── new/page.tsx # 新規作成
└── _actions/
├── candidates.ts # 候補者CRUD Server Actions
└── images.ts # 画像 Server Actions_actions/ 配下のファイルは先頭に "use server" を記述し、app/admin/ 内のフォームから直接呼び出す。
features/ の現状
当初設計の voters/, payment/ は未実装。実装済みは auth/, voting/, candidates/ の3つで、薄い構成(contexts + 必要最小限の hooks/types)に留まっている。サービス層は現状 Server Actions / Route Handler 側にあり、features/*/services/ は将来の拡張ポイント。
features/
├── auth/contexts/AuthContext.tsx # クライアント状態(モック含む)
├── voting/
│ ├── contexts/VotingContext.tsx # 無料投票クールダウン管理(JST 0:00 リセット)
│ └── hooks/
│ ├── useVoteFlow.ts # ログイン/購入モーダル + Confetti のフロー制御
│ └── useFreeVoteStatus.ts # 残り時間カウントダウン
└── candidates/
└── hooks/useCandidateRanking.ts # 得票降順 + 順位付与lib/ の現状
当初設計の lib/contexts/, lib/data/, lib/types/ は採用していない。
- Context →
features/<domain>/contexts/ - 型 →
features/<domain>/types.ts - データ → Prisma + DB
lib/は 真に汎用的なユーティリティ のみ(現状はprisma.ts(Prisma Client 共有インスタンス) とutils.ts(cn()等))
ルートレベルのファイル
auth.ts— NextAuth.js v5 の設定とハンドラを集約してエクスポートproxy.ts— 開発用プロキシ設定prisma.config.ts— Prisma 7 の設定(seed 等)
未実装の予定パターン
以下は当初設計に含まれているが現時点では未実装。新規実装時に検討する。
containers/レイヤー(features と components の接着剤).storybook/およびstories/(コンポーネントカタログ)- Vitest / Playwright のテストファイル
import エイリアス(実態)
tsconfig.json の paths に @/*, @/features/*, @/components/*, @/design-tokens/* を定義済み。新規 import は基本的にこれらのエイリアスを使う。