diff --git a/.github/workflows/feature-push-ci.yml b/.github/workflows/feature-push-ci.yml
index b2eb630..abaf983 100644
--- a/.github/workflows/feature-push-ci.yml
+++ b/.github/workflows/feature-push-ci.yml
@@ -23,28 +23,16 @@ jobs:
with:
fetch-depth: 0
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '20'
-
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- - name: Get pnpm store directory
- shell: bash
- run: |
- echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
-
- - name: Setup pnpm cache
- uses: actions/cache@v4
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
with:
- path: ${{ env.STORE_PATH }}
- key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pnpm-store-
+ node-version: '20'
+ cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
diff --git a/.gitignore b/.gitignore
index df7da5f..f861afe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,9 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
+# pnpm project - ignore npm lock file
+package-lock.json
+
# local env files
.env*.local
.env.local
diff --git a/CLAUDE.md b/CLAUDE.md
index dcbe03e..7fe50a9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,6 +26,7 @@ WIN×Ⅱ is a membership-based affiliate blog platform that tracks conversions f
- Future: AFB, もしもアフィリエイト, バリューコマース
**Analytics & Tracking:**
+- Vercel Analytics (@vercel/analytics): Page views and Web Vitals monitoring
- Google Tag Manager (GTM): Tag management and event tracking
- Google Analytics 4 (GA4): User behavior analytics
- Google Search Console (GSC): Search performance and sitemap management
@@ -38,6 +39,7 @@ WIN×Ⅱ is a membership-based affiliate blog platform that tracks conversions f
- Radix UI primitives (@radix-ui/react-slot, @radix-ui/react-label)
- @tailwindcss/typography (Markdown prose styling)
- react-markdown v10.1.0 + remark-gfm + rehype-raw (Markdown rendering)
+- @vercel/analytics (page views, Web Vitals monitoring)
**Deployment:**
- Vercel
@@ -82,6 +84,7 @@ WIN×Ⅱ is a membership-based affiliate blog platform that tracks conversions f
- `components/providers/session-provider.tsx`: Next-Auth SessionProvider wrapper
**Analytics & Tracking:**
+- `app/layout.tsx`: Vercel Analytics `` component (page views, Web Vitals)
- `components/analytics/google-tag-manager.tsx`: GTM script injection components
- `lib/gtm.ts`: Type-safe GTM helper functions (trackSignUp, trackDealClick)
- `types/gtm.d.ts`: GTM event type definitions and window.dataLayer extension
diff --git a/app/api/blogs/route.ts b/app/api/blogs/route.ts
index b78733d..1501469 100644
--- a/app/api/blogs/route.ts
+++ b/app/api/blogs/route.ts
@@ -1,25 +1,90 @@
+/**
+ * ブログ一覧API
+ *
+ * microCMSからブログ記事を取得します。
+ * includeRestrictedパラメータがtrueの場合は年齢検証を実行します。
+ *
+ * Query Parameters:
+ * - limit: number (default: 10) - 取得件数
+ * - offset: number (default: 0) - スキップ件数
+ * - orders: string (default: '-publishedAt') - ソート順
+ * - categoryId: string (optional) - カテゴリID
+ * - includeRestricted: 'true' | 'false' (default: 'false') - 年齢制限コンテンツを含むか
+ */
+
import { NextRequest, NextResponse } from "next/server";
import { getBlogs } from "@/lib/microcms";
+import { verifyAge } from "@/lib/age-verification";
-// ISR: 60秒ごとに再検証
-export const revalidate = 60;
+export const dynamic = "force-dynamic";
+/**
+ * GET /api/blogs
+ *
+ * ブログ記事一覧を取得します。
+ * 年齢制限コンテンツを含む場合は、サーバーサイドで年齢検証を実行します。
+ */
export async function GET(request: NextRequest) {
+ const searchParams = request.nextUrl.searchParams;
+
+ const limit = Math.max(1, Number(searchParams.get("limit")) || 10);
+ const offset = Math.max(0, Number(searchParams.get("offset")) || 0);
+ const orders = searchParams.get("orders") || "-publishedAt";
+ const categoryId = searchParams.get("categoryId") || undefined;
+ const includeRestricted = searchParams.get("includeRestricted") === "true";
+
try {
- const { searchParams } = new URL(request.url);
- const limit = Math.max(1, Number(searchParams.get("limit")) || 10);
- const offset = Math.max(0, Number(searchParams.get("offset")) || 0);
- const orders = searchParams.get("orders") || "-publishedAt";
+ // 年齢制限コンテンツを含む場合は年齢検証
+ let filters = "";
+
+ if (includeRestricted) {
+ const ageVerification = await verifyAge();
- const data = await getBlogs({
+ // 成人でない場合はエラー
+ if (ageVerification.status !== "adult") {
+ return NextResponse.json(
+ { error: "Age verification required" },
+ { status: 403 }
+ );
+ }
+
+ // 成人の場合はカテゴリフィルタのみ適用
+ if (categoryId) {
+ filters = `category[equals]${categoryId}`;
+ }
+ } else {
+ // 年齢制限コンテンツを除外
+ const restrictedFilter = "restricted[not_equals]true";
+ filters = categoryId
+ ? `${restrictedFilter}[and]category[equals]${categoryId}`
+ : restrictedFilter;
+ }
+
+ const { contents, totalCount } = await getBlogs({
limit,
offset,
orders,
+ filters,
+ fields: [
+ "id",
+ "title",
+ "content",
+ "thumbnail",
+ "category.id",
+ "category.name",
+ "restricted",
+ "createdAt",
+ "publishedAt",
+ ].join(","),
});
- return NextResponse.json(data, { status: 200 });
+ return NextResponse.json({
+ contents,
+ totalCount,
+ hasMore: offset + limit < totalCount,
+ });
} catch (error) {
- console.error("[api/blogs] Failed to fetch blogs:", error);
+ console.error("[GET /api/blogs] Error:", error);
return NextResponse.json(
{ error: "Failed to fetch blogs" },
{ status: 500 }
diff --git a/app/api/history/route.ts b/app/api/history/route.ts
index 32901c9..ed9dc12 100644
--- a/app/api/history/route.ts
+++ b/app/api/history/route.ts
@@ -24,7 +24,7 @@ interface HistoryItem {
dealName: string; // 案件名
dealId: string; // 案件ID
eventId: string; // イベントID
- status: string | null; // ステータス(未確定/成果確定/否認、G列が空の場合はnull)
+ status: string | null; // ステータス(未確定/確定/否認、G列が空の場合はnull)
statusLabel: string | null; // ステータス表示ラベル(G列が空の場合はnull)
originalReward?: number; // 原始報酬額(参考、成果がある場合)
}
@@ -87,9 +87,9 @@ export async function GET(_request: NextRequest) {
status = "pending";
statusLabel = "未確定";
originalReward = result?.originalReward;
- } else if (normalizedStatus.includes("成果確定") || normalizedStatus.includes("確定") || normalizedStatus.includes("approved")) {
+ } else if (normalizedStatus.includes("確定") || normalizedStatus.includes("確定") || normalizedStatus.includes("approved")) {
status = "approved";
- statusLabel = "成果確定";
+ statusLabel = "確定";
originalReward = result?.originalReward;
} else if (normalizedStatus.includes("否認") || normalizedStatus.includes("cancelled")) {
status = "cancelled";
@@ -106,9 +106,9 @@ export async function GET(_request: NextRequest) {
status = "pending";
statusLabel = "未確定";
originalReward = result.originalReward;
- } else if (resultStatus.includes("成果確定") || resultStatus.includes("確定") || resultStatus.includes("approved")) {
+ } else if (resultStatus.includes("確定") || resultStatus.includes("確定") || resultStatus.includes("approved")) {
status = "approved";
- statusLabel = "成果確定";
+ statusLabel = "確定";
originalReward = result.originalReward;
} else if (resultStatus.includes("否認") || resultStatus.includes("cancelled")) {
status = "cancelled";
diff --git a/app/api/members/me/route.ts b/app/api/members/me/route.ts
index 46e53b3..2dd8d22 100644
--- a/app/api/members/me/route.ts
+++ b/app/api/members/me/route.ts
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth";
-import { getMemberById } from "@/lib/sheets";
+import { getMemberById, updateMember } from "@/lib/sheets";
+import { updateProfileSchema } from "@/lib/validations/profile";
/**
* 会員情報取得API
@@ -46,3 +47,63 @@ export async function GET() {
);
}
}
+
+/**
+ * 会員情報更新API
+ *
+ * PUT /api/members/me
+ *
+ * セッションからmemberIdを取得し、Google Sheetsの会員情報を更新
+ * 現在は生年月日のみ更新可能(将来的に他フィールドも追加可能)
+ */
+export async function PUT(req: Request) {
+ try {
+ // 1. セッション認証
+ const session = await getServerSession(authOptions);
+
+ if (!session || !session.user?.memberId) {
+ return NextResponse.json(
+ { error: "認証が必要です" },
+ { status: 401 }
+ );
+ }
+
+ // 2. リクエストボディ検証
+ const body = await req.json();
+ const validatedData = updateProfileSchema.parse(body);
+
+ // 3. Google Sheets更新
+ const updatedMember = await updateMember(
+ session.user.memberId,
+ validatedData
+ );
+
+ if (!updatedMember) {
+ return NextResponse.json(
+ { error: "会員情報が見つかりません" },
+ { status: 404 }
+ );
+ }
+
+ // 4. 成功レスポンス(パスワードハッシュ除外)
+ const { passwordHash: _passwordHash, ...memberData } = updatedMember;
+ void _passwordHash;
+
+ return NextResponse.json(memberData, { status: 200 });
+ } catch (error) {
+ console.error("Update member info error:", error);
+
+ // Zodバリデーションエラー
+ if (error instanceof Error && error.name === "ZodError") {
+ return NextResponse.json(
+ { error: "入力データが不正です" },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: "会員情報更新中にエラーが発生しました" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/blog/[id]/page.tsx b/app/blog/[id]/page.tsx
index 1ece1a5..5561cb9 100644
--- a/app/blog/[id]/page.tsx
+++ b/app/blog/[id]/page.tsx
@@ -7,6 +7,8 @@ import { formatDate } from "@/lib/utils";
import { extractExcerpt } from "@/lib/blog-utils";
import { BlogContent } from "@/components/blog/blog-content";
import { BackToBlogLink } from "@/components/blog/back-to-blog-link";
+import { verifyAge, getAccessDeniedError } from "@/lib/age-verification";
+import AccessDeniedMessage from "@/components/age-verification/access-denied-message";
// import { DealCTAButton } from "@/components/deal/deal-cta-button"; // 将来的にGoogle Sheets APIから案件取得時に使用
interface BlogDetailPageProps {
@@ -90,6 +92,20 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
notFound();
}
+ // 年齢制限コンテンツの場合は年齢検証
+ if (blog.restricted) {
+ const ageVerification = await verifyAge();
+ const accessError = getAccessDeniedError(ageVerification);
+
+ if (accessError) {
+ return (
+
+ );
+ }
+ }
+
const excerpt = extractExcerpt(blog.content, 120);
const articleSchema = {
diff --git a/app/blog/page.tsx b/app/blog/page.tsx
index 08dcf7f..a37b35f 100644
--- a/app/blog/page.tsx
+++ b/app/blog/page.tsx
@@ -58,6 +58,7 @@ export default async function BlogListPage() {
getBlogs({
limit: BLOGS_PER_PAGE,
offset: 0,
+ filters: "restricted[not_equals]true", // 年齢制限コンテンツを除外
}),
getCategories({ limit: 100 }),
]);
diff --git a/app/layout.tsx b/app/layout.tsx
index d5b30f2..cc9e32b 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -7,6 +7,7 @@ import { Header } from "@/components/layout/header";
import { Footer } from "@/components/layout/footer";
import { PageTransition } from "@/components/layout/page-transition";
import { GoogleTagManager, GoogleTagManagerNoScript } from "@/components/analytics/google-tag-manager";
+import { Analytics } from "@vercel/analytics/next";
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const gtmId = process.env.NEXT_PUBLIC_GTM_ID || "";
@@ -87,6 +88,7 @@ export default function RootLayout({
+