diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..54c7971 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Next.js 14 work checkout tracking application that sends LINE notifications when users check out from work. The app integrates with NextAuth for authentication, LINE Messaging API for notifications, and Vercel AI SDK for generating personalized checkout messages. + +## Development Commands + +### Running the application +```bash +npm run dev # Start development server on localhost:3000 +npm run build # Build production bundle +npm start # Run production server +npm run lint # Run ESLint +``` + +### Testing +```bash +npm run test:e2e # Run all Playwright E2E tests +npm run test:e2e:smoke # Run smoke tests (login spec only, used in pre-push hook) +npm run test:e2e:ui # Run tests with Playwright UI +npm run test:e2e:debug # Run tests in debug mode +npm run test:e2e:headed # Run tests in headed mode +npm run test:e2e:report # Show test report +``` + +### Pre-push Hook +A pre-push git hook (`.githooks/pre-push`) automatically runs smoke E2E tests before pushing. Skip with: +```bash +SKIP_E2E=1 git push +``` + +## Architecture + +### Hybrid Routing +This project uses **both** App Router and Pages Router: +- **App Router** (`src/app/`): Main UI pages, new API routes (`/api/work_records`) +- **Pages Router** (`src/pages/`): Legacy API routes and authentication handlers + +### Key Components + +**Authentication:** +- Uses NextAuth.js with JWT strategy +- Simple password-based authentication (no database) +- Auth configuration: `src/pages/api/[...nextauth].ts` (Pages Router) +- App Router handler: `src/app/api/auth/[...nextauth]/route.ts` (delegates to Pages Router config) + +**API Routes:** +- `src/pages/api/checkout.ts` - Sends LINE push notifications for checkout +- `src/pages/api/vercelAI.ts` - Generates AI-powered checkout messages using Vercel AI SDK +- `src/app/api/work_records/route.ts` - New App Router API with Zod validation + +**Environment-Aware:** +- Uses `isDev()`, `isStg()` from `src/app/utility/checkEnvironment.ts` +- Different LINE tokens/targets for dev/stg/prod environments + +**Presentation Layer:** +- UI components in `src/app/presentation/components/` +- Client-side components use `"use client"` directive +- Main page: `src/app/page.tsx` + +### E2E Testing + +Tests are organized under `e2e/`: +- `e2e/tests/` - Test specs organized by feature (auth, checkout, api) +- `e2e/pages/` - Page Object Model implementations +- `e2e/global-setup.ts` - Authenticates and saves session state +- `e2e/.auth/user.json` - Persisted auth state (gitignored) + +**Test Environment:** +- Uses `.env.test` for test credentials +- `E2E_FAKE_LLM=1` flag disables real AI calls during tests (returns mock messages) +- Authentication persisted via `storageState` to avoid repeated logins + +### Validation + +Uses Zod for schema validation: +- Example: `src/app/api/work_records/validator.ts` defines work record schema +- Use `safeParse()` for validation with error handling + +## Important Patterns + +**AI Message Generation:** +- Vercel AI SDK replaces legacy Gemini integration (see commit history) +- Falls back to static messages if AI generation fails or times out +- Test mode returns fixed dummy messages + +**LINE Integration:** +- Environment-aware token/target selection +- Formats checkout messages with timestamp and optional user text +- Uses axios for LINE Messaging API calls + +**Path Aliases:** +- `@/*` maps to `src/*` (configured in tsconfig.json) + +## Style Guide + +From `.gemini/styleguide.md`: +- 日本語で返答してください (Respond in Japanese) + +## Git Workflow + +- Main branch: `main` +- PR workflow enabled with pr-agent (`.github/workflows/pr-agent.yml`) +- Pre-push hook runs smoke E2E tests automatically diff --git a/e2e/tests/route-search/route-search.spec.ts b/e2e/tests/route-search/route-search.spec.ts new file mode 100644 index 0000000..901ec9c --- /dev/null +++ b/e2e/tests/route-search/route-search.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '@playwright/test'; + +test.describe('経路検索機能テスト', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/', { waitUntil: 'domcontentloaded' }); + }); + + test.describe('API呼び出しの検証', () => { + test('トップページアクセス時に経路検索APIが呼び出される', async ({ page }) => { + const routeRequest = await page.waitForResponse( + (response) => response.url().includes('/api/route-search') && response.status() === 200, + ); + const data = await routeRequest.json(); + expect(data).toHaveProperty('route'); + expect(data).toHaveProperty('origin', '阪東橋駅'); + expect(data).toHaveProperty('destination', '綱島駅'); + expect(data).toHaveProperty('timestamp'); + }); + }); + + test.describe('ボタンの状態', () => { + test('読み込み完了後に「経路を表示」ボタンが表示される', async ({ page }) => { + // API応答を待つ + await page.waitForResponse( + (response) => response.url().includes('/api/route-search'), + ); + const button = page.getByRole('button', { name: '経路を表示' }); + await expect(button).toBeVisible(); + await expect(button).toBeEnabled(); + }); + }); + + test.describe('モーダル表示/非表示', () => { + test('「経路を表示」ボタンクリックでモーダルが表示される', async ({ page }) => { + await page.waitForResponse( + (response) => response.url().includes('/api/route-search'), + ); + await page.getByRole('button', { name: '経路を表示' }).click(); + + await expect(page.getByRole('heading', { name: '経路検索結果' })).toBeVisible(); + }); + + test('閉じるボタンでモーダルが非表示になる', async ({ page }) => { + await page.waitForResponse( + (response) => response.url().includes('/api/route-search'), + ); + await page.getByRole('button', { name: '経路を表示' }).click(); + await expect(page.getByRole('heading', { name: '経路検索結果' })).toBeVisible(); + + // 閉じるボタン(×)をクリック + await page.getByRole('button', { name: '\u00d7' }).click(); + await expect(page.getByRole('heading', { name: '経路検索結果' })).not.toBeVisible(); + }); + }); + + test.describe('経路情報の表示内容', () => { + test('E2Eテスト用ダミー経路情報が正しく表示される', async ({ page }) => { + await page.waitForResponse( + (response) => response.url().includes('/api/route-search'), + ); + await page.getByRole('button', { name: '経路を表示' }).click(); + + // E2E_FAKE_LLM=1の場合のダミーデータが表示されることを確認 + const modal = page.locator('[class*="modal"]'); + await expect(modal).toBeVisible(); + await expect(modal.getByText('阪東橋駅')).toBeVisible(); + await expect(modal.getByText('綱島駅')).toBeVisible(); + }); + }); + + test.describe('エラーケース', () => { + test('API失敗時にエラーメッセージが表示される', async ({ page }) => { + // APIをモックして失敗させる + await page.route('**/api/route-search', (route) => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Internal Server Error' }), + }); + }); + + await page.goto('/', { waitUntil: 'domcontentloaded' }); + + // エラー状態でもボタンが表示されることを待つ + const button = page.getByRole('button', { name: '経路を表示' }); + await expect(button).toBeVisible({ timeout: 10000 }); + + await button.click(); + + // モーダル内にエラーメッセージが表示される + await expect(page.getByText('経路情報の取得に失敗しました')).toBeVisible(); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index b848d15..d62e2cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,18 +8,16 @@ "name": "leaving-work", "version": "0.1.0", "dependencies": { - "@google/generative-ai": "^0.24.1", + "@ai-sdk/google": "^2.0.62", "@line/bot-sdk": "^9.2.2", "ai": "^5.0.44", "express": "^4.19.2", "google-home-player": "^2.0.1", "next": "14.1.3", "next-auth": "^4.24.11", - "pnpm": "^10.16.1", "react": "^18", "react-dom": "^18", - "react-markdown": "^10.1.0", - "tsx": "^4.20.5" + "react-markdown": "^10.1.0" }, "devDependencies": { "@playwright/test": "^1.54.2", @@ -33,10 +31,12 @@ "esbuild-css-modules-plugin": "^3.1.4", "eslint": "^8", "eslint-config-next": "14.1.3", + "pnpm": "^10.16.1", "react-router-dom": "^6.22.3", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "tsconfig-paths": "^4.2.0", + "tsx": "^4.20.5", "typescript": "^5" } }, @@ -55,6 +55,48 @@ "zod": "^3.25.76 || ^4" } }, + "node_modules/@ai-sdk/google": { + "version": "2.0.62", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-2.0.62.tgz", + "integrity": "sha512-RUpgkG5dWsmkYQsluTdutXakFpyQQ1NvELnQ0KD1VWTNLHWD70fO0FOpOs1cQKeTe7PspcJSii9Zekpaepv6qA==", + "dependencies": { + "@ai-sdk/provider": "2.0.1", + "@ai-sdk/provider-utils": "3.0.22" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.1.tgz", + "integrity": "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider-utils": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.22.tgz", + "integrity": "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw==", + "dependencies": { + "@ai-sdk/provider": "2.0.1", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/provider": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz", @@ -140,6 +182,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -155,6 +198,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -170,6 +214,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -185,6 +230,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -200,6 +246,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -215,6 +262,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -230,6 +278,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -245,6 +294,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -260,6 +310,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -275,6 +326,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -290,6 +342,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -305,6 +358,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -320,6 +374,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -335,6 +390,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -350,6 +406,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -365,6 +422,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -380,6 +438,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -395,6 +454,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -410,6 +470,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -425,6 +486,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -440,6 +502,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -455,6 +518,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -470,6 +534,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -485,6 +550,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -500,6 +566,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -567,14 +634,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@google/generative-ai": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", - "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1023,9 +1082,9 @@ "dev": true }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" }, "node_modules/@swc/helpers": { "version": "0.5.2", @@ -2801,6 +2860,7 @@ "version": "0.25.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -3641,6 +3701,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -3743,6 +3804,7 @@ "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, "dependencies": { "resolve-pkg-maps": "^1.0.0" }, @@ -6294,6 +6356,7 @@ "version": "10.16.1", "resolved": "https://registry.npmjs.org/pnpm/-/pnpm-10.16.1.tgz", "integrity": "sha512-DhVaomKduGcrSehHXaYiaqS96oX9zf3BU1CHSUbU88kfqvZMvcSl0auAAvRz1cP87c0ZeYnPA5D5ut08BGeHBg==", + "dev": true, "bin": { "pnpm": "bin/pnpm.cjs", "pnpx": "bin/pnpx.cjs" @@ -6708,6 +6771,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } @@ -7723,6 +7787,7 @@ "version": "4.20.5", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz", "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==", + "dev": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" diff --git a/package.json b/package.json index 072b1c9..624d2c9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:e2e:report": "playwright show-report" }, "dependencies": { + "@ai-sdk/google": "^2.0.62", "@line/bot-sdk": "^9.2.2", "ai": "^5.0.44", "express": "^4.19.2", @@ -26,7 +27,6 @@ "react-markdown": "^10.1.0" }, "devDependencies": { - "pnpm": "^10.16.1", "@playwright/test": "^1.54.2", "@types/axios": "^0.14.0", "@types/css-modules": "^1.0.5", @@ -38,12 +38,13 @@ "esbuild-css-modules-plugin": "^3.1.4", "eslint": "^8", "eslint-config-next": "14.1.3", + "pnpm": "^10.16.1", "react-router-dom": "^6.22.3", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "tsconfig-paths": "^4.2.0", - "typescript": "^5", - "tsx": "^4.20.5" + "tsx": "^4.20.5", + "typescript": "^5" }, "sideEffects": false } diff --git a/public/game/index.html b/public/game/index.html deleted file mode 100644 index 9ab23d2..0000000 --- a/public/game/index.html +++ /dev/null @@ -1,1009 +0,0 @@ - - - - - - 縦シューティングゲーム - - - -
- -
-
スコア: 0
-
ライフ: 3
-
-
-
BOSS
-
-
-
-
-
ゲームオーバー
-
最終スコア: 0
- -
-
-
ゲームクリア!
-
全てのボスを撃破しました!
-
最終スコア: 0
- -
-
- 矢印キー: 移動 | 自動射撃 - タップして移動 | 自動射撃 -
-
タップで移動
-
- - - - \ No newline at end of file diff --git a/src/app/api/route-search/route.ts b/src/app/api/route-search/route.ts new file mode 100644 index 0000000..e5efd77 --- /dev/null +++ b/src/app/api/route-search/route.ts @@ -0,0 +1,68 @@ +import { generateText } from 'ai' +import { createGoogleGenerativeAI } from '@ai-sdk/google' +import { routeSearchSchema } from './validator' + +const FALLBACK_ROUTE = `## 阪東橋駅 → 綱島駅 + +**推奨ルート(横浜市営地下鉄ブルーライン → 東急東横線)** + +1. 阪東橋駅(横浜市営地下鉄ブルーライン)乗車 +2. 横浜駅で下車 +3. 東急東横線に乗り換え +4. 綱島駅で下車 + +- 所要時間: 約30〜35分 +- 運賃: 約400〜500円` + +export async function GET() { + const origin = '阪東橋駅' + const destination = '綱島駅' + + const validation = routeSearchSchema.safeParse({ origin, destination }) + if (!validation.success) { + return Response.json({ error: validation.error.format() }, { status: 400 }) + } + + try { + const useFake = process.env.E2E_FAKE_LLM === '1' || process.env.NODE_ENV === 'test' + if (useFake) { + return Response.json({ + route: '**E2Eテスト用ダミー経路** 阪東橋駅 → 綱島駅: 約30分', + origin, + destination, + timestamp: new Date().toISOString(), + fallback: true, + }) + } + + const google = createGoogleGenerativeAI({ + apiKey: process.env.GEMINI_API_KEY, + }) + + const { text } = await generateText({ + model: google('gemini-2.0-flash'), + prompt: `${origin}から${destination}までの電車での経路を教えてください。乗り換え駅と路線名、所要時間の目安、運賃の目安を含めてマークダウン形式で簡潔に回答してください。`, + }) + + if (!text || typeof text !== 'string') { + throw new Error('Invalid response from Gemini API') + } + + return Response.json({ + route: text.trim(), + origin, + destination, + timestamp: new Date().toISOString(), + }) + } catch (error) { + console.error('Route search failed:', error) + + return Response.json({ + route: FALLBACK_ROUTE, + origin, + destination, + timestamp: new Date().toISOString(), + fallback: true, + }) + } +} diff --git a/src/app/api/route-search/validator.ts b/src/app/api/route-search/validator.ts new file mode 100644 index 0000000..5fdf9e7 --- /dev/null +++ b/src/app/api/route-search/validator.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' + +export const routeSearchSchema = z.object({ + origin: z + .string() + .nonempty('origin は必須です') + .max(100, 'origin は100文字以内である必要があります'), + destination: z + .string() + .nonempty('destination は必須です') + .max(100, 'destination は100文字以内である必要があります'), +}) + +export type RouteSearchInput = z.infer diff --git a/src/app/api/work_records/route.ts b/src/app/api/work_records/route.ts new file mode 100644 index 0000000..f19e264 --- /dev/null +++ b/src/app/api/work_records/route.ts @@ -0,0 +1,16 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { workRecordSchema } from './validator' + +export async function POST(request: Request) { + const body = await request.json() + // safeParseはzodが提供するスキーマに渡すbodyを引数にとってバリデーション結果とパース済みデータを返す + const result = workRecordSchema.safeParse(body) + + if (!result.success) { + console.log('Invalid User id') + return Response.json({ error: result.error.format() }, { status: 400 }) + } + + const { user_id, clock_out_time } = result.data +} + diff --git a/src/app/api/work_records/validator.ts b/src/app/api/work_records/validator.ts new file mode 100644 index 0000000..31c43c6 --- /dev/null +++ b/src/app/api/work_records/validator.ts @@ -0,0 +1,16 @@ +// zod はTypescript向けスキーマバリデーションライブラリ +import { z } from 'zod' + +export const workRecordSchema = z.object({ + user_id: z + .string() + .nonempty('user_id は必須です') + .regex(/^[a-zA-Z0-9]+$/, 'user_id は英数字である必要があります'), + clock_out_time: z + .string() + .nonempty('clock_out_time は必須です') + .refine( + (value) => !Number.isNaN(Date.parse(value)), + 'clock_out_time は ISO8601 形式である必要があります' + ), +}) \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index cb4d415..4893247 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,7 @@ import CheckoutWithText from "./presentation/components/checkoutWithText"; import AutoMessage from "./presentation/components/autoMessage"; import AnnouncementBanner from "./presentation/components/announcement/announcementBanner"; import NotificationIcon from "./presentation/components/announcement/notificationIcon"; +import RouteSearchModal from "./presentation/components/routeSearchModal"; import { useState } from "react"; import { useSession, signOut, signIn } from "next-auth/react"; @@ -57,12 +58,8 @@ export default function Home(): JSX.Element { onModalOpen={handleTextCheckoutOpen} onModalClose={handleTextCheckoutClose} /> - - - + diff --git a/src/app/presentation/components/routeSearchModal.tsx b/src/app/presentation/components/routeSearchModal.tsx new file mode 100644 index 0000000..7a4fb87 --- /dev/null +++ b/src/app/presentation/components/routeSearchModal.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Markdown from "react-markdown"; +import styles from "../styles/routeSearch.module.css"; + +interface RouteSearchResponse { + route: string; + origin: string; + destination: string; + timestamp: string; + fallback?: boolean; +} + +export default function RouteSearchModal() { + const [isOpen, setIsOpen] = useState(false); + const [routeData, setRouteData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRoute = async () => { + try { + const res = await fetch("/api/route-search"); + if (!res.ok) { + throw new Error(`API error: ${res.status}`); + } + const data: RouteSearchResponse = await res.json(); + setRouteData(data); + } catch (err) { + console.error("Route search failed:", err); + setError("経路情報の取得に失敗しました"); + } finally { + setIsLoading(false); + } + }; + + fetchRoute(); + }, []); + + return ( + <> +
+ +
+ + {isOpen && ( +
setIsOpen(false)}> +
e.stopPropagation()}> +
+

経路検索結果

+ +
+ +
+ {isLoading && ( +

経路を検索中...

+ )} + {error &&

{error}

} + {routeData && ( + {routeData.route} + )} +
+
+
+ )} + + ); +} diff --git a/src/app/presentation/styles/checkout.module.css b/src/app/presentation/styles/checkout.module.css index da030ba..98e3dcb 100644 --- a/src/app/presentation/styles/checkout.module.css +++ b/src/app/presentation/styles/checkout.module.css @@ -87,24 +87,3 @@ background-color: #c82333; } -/* ゲームボタンのスタイル */ -.gameButton { - position: absolute; - top: 20px; - left: 20px; - text-decoration: none; -} - -.playGame { - padding: 0.5rem 1rem; - background-color: #28a745; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 0.9rem; -} - -.playGame:hover { - background-color: #218838; -} \ No newline at end of file diff --git a/src/app/presentation/styles/routeSearch.module.css b/src/app/presentation/styles/routeSearch.module.css new file mode 100644 index 0000000..ee42d96 --- /dev/null +++ b/src/app/presentation/styles/routeSearch.module.css @@ -0,0 +1,230 @@ +.routeButton { + position: absolute; + top: 20px; + left: 20px; +} + +.openButton { + padding: 0.5rem 1rem; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; +} + +.openButton:hover { + background-color: #0056b3; +} + +.openButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; +} + +.modal { + background: white; + border-radius: 12px; + padding: 24px; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + position: relative; + color: #333; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid #eee; +} + +.title { + font-size: 1.1rem; + font-weight: bold; + margin: 0; +} + +.closeButton { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #666; + padding: 0; + line-height: 1; +} + +.closeButton:hover { + color: #333; +} + +.content { + font-size: 0.9rem; + line-height: 1.6; +} + +/* Markdown階層構造のスタイリング */ +.content h1 { + font-size: 1.5rem; + font-weight: bold; + margin: 1.5rem 0 1rem 0; + padding-bottom: 0.5rem; + border-bottom: 2px solid #007bff; + color: #007bff; +} + +.content h2 { + font-size: 1.3rem; + font-weight: bold; + margin: 1.2rem 0 0.8rem 0; + color: #0056b3; +} + +.content h3 { + font-size: 1.1rem; + font-weight: bold; + margin: 1rem 0 0.6rem 0; + color: #333; +} + +.content p { + margin: 0.8rem 0; + color: #333; +} + +.content ul, +.content ol { + margin: 1rem 0; + padding-left: 0; + list-style: none; +} + +.content li { + margin: 0.6rem 0; + padding: 0.8rem 1rem 0.8rem 3rem; + line-height: 1.8; + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border-radius: 8px; + position: relative; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.content li:hover { + transform: translateX(4px); + box-shadow: 0 4px 8px rgba(0, 123, 255, 0.15); + background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); +} + +.content ol { + counter-reset: list-counter; +} + +.content ol li { + counter-increment: list-counter; +} + +.content ol li::before { + content: counter(list-counter); + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + width: 1.8rem; + height: 1.8rem; + background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 0.85rem; + box-shadow: 0 2px 6px rgba(0, 123, 255, 0.3); +} + +.content ul li::before { + content: "→"; + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: #007bff; + font-size: 1.2rem; + font-weight: bold; +} + +.content li strong { + color: #007bff; + font-weight: 600; +} + +.content strong { + font-weight: bold; + color: #000; +} + +.content em { + font-style: italic; + color: #555; +} + +.content code { + background-color: #f5f5f5; + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: monospace; + font-size: 0.85rem; +} + +.content pre { + background-color: #f5f5f5; + padding: 1rem; + border-radius: 4px; + overflow-x: auto; + margin: 1rem 0; +} + +.content pre code { + background-color: transparent; + padding: 0; +} + +.content blockquote { + border-left: 4px solid #007bff; + padding-left: 1rem; + margin: 1rem 0; + color: #555; + font-style: italic; +} + +.loading { + text-align: center; + padding: 20px; + color: #666; +} + +.error { + text-align: center; + padding: 20px; + color: #dc3545; +}