diff --git a/.cursor/rules/design-system.mdc b/.cursor/rules/design-system.mdc new file mode 100644 index 0000000..c966c2b --- /dev/null +++ b/.cursor/rules/design-system.mdc @@ -0,0 +1,55 @@ +--- +description: Dark theme design token system — enforces consistent color usage across all UI +globs: "**/*.tsx,**/*.css" +alwaysApply: false +--- + +# Design System: Dark Theme Tokens + +This project uses a unified dark theme. All colors come from semantic tokens defined in `globals.css` via Tailwind v4 `@theme`. + +## Token Map + +| Token | Tailwind Class | Hex | Usage | +|-------|---------------|-----|-------| +| background | `bg-background` | #030712 | Page-level backgrounds | +| surface | `bg-surface` | #111827 | Cards, modals, inputs, panels | +| elevated | `bg-elevated` | #1f2937 | Hover states, secondary buttons, nested containers | +| border | `ring-border` / `bg-border` | #1f2937 | Borders, dividers | +| subtle | `ring-subtle` / `bg-subtle` | #374151 | Secondary borders, hover-next-level | +| foreground | `text-foreground` | #f3f4f6 | Primary text, input text | +| muted | `text-muted` | #6b7280 | Secondary text, labels, placeholders emphasis | +| dim | `text-dim` | #4b5563 | Tertiary text, timestamps, divider labels | +| accent | `bg-accent` | #10b981 | Primary CTA, active indicators | +| accent-hover | `bg-accent-hover` | #059669 | Accent hover state | + +## Mandatory Rules + +1. **NEVER use `bg-white`** for cards, modals, or page backgrounds. Use `bg-surface` or `bg-elevated`. +2. **NEVER use `text-zinc-*` or `text-gray-*` for standard text.** Use `text-foreground`, `text-muted`, or `text-dim`. +3. **NEVER use `bg-gray-950/900/800`** — use `bg-background`, `bg-surface`, `bg-elevated` respectively. +4. **NEVER use `ring-gray-*` / `border-gray-*`** — use `ring-border` or `ring-subtle`. +5. **Light-on-dark badges**: use `bg-{color}-500/10..15` + `text-{color}-400` (e.g. `bg-amber-500/10 text-amber-400`). +6. **Inputs**: `bg-surface ring-1 ring-border text-foreground placeholder:text-dim focus:ring-2 focus:ring-{accent}/50`. + +## Button Color Hierarchy + +Buttons follow a strict 4-tier color system: + +| Tier | Color | When to use | Example | +|------|-------|-------------|---------| +| **System CTA** | `bg-accent` | Login, join room, navigate, share, save — any universal primary action | "导航过去", "登录", "加入房间" | +| **Mode Accent** | Orange gradient (Panic) / `bg-purple-600` (BlindBox) | The ONE primary CTA of each mode only | "创建新房间", "提交想法" | +| **Dramatic** | `from-red-600 to-rose-500` | Irreversible, high-ceremony actions only | "开启周末盲盒(绝不反悔)" | +| **Destructive** | `bg-rose-500` or `bg-amber-500` | Leave, kick, end session | "退出房间", "踢出" | +| **Secondary** | `bg-surface` or `bg-elevated` + `ring-1 ring-border` | Cancel, close, alternative actions | "继续滑卡", "换一批店" | + +Rules: +- System-level actions ALWAYS use `bg-accent`, never mode-specific colors. +- Mode accent colors are ONLY for the core action of that mode, not for navigation or system actions within the mode. +- Never invent new CTA colors outside this hierarchy. + +## Exceptions + +- Image overlays and swipe overlays may use `bg-black/*` or `bg-white/*` for translucency on photos. +- Filter pills/tags in Panic mode use `bg-orange-500` for active state — these are toggles, not CTA buttons. diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc new file mode 100644 index 0000000..c9fde6f --- /dev/null +++ b/.cursor/rules/project-conventions.mdc @@ -0,0 +1,59 @@ +--- +description: Project-wide coding conventions for NoWhatever +alwaysApply: true +--- + +# NoWhatever Project Conventions + +## Philosophy + +- This is a **new project** — never worry about backward compatibility or legacy code migration +- Prioritize **code elegance and reuse** over quick hacks +- Extract shared logic into reusable utilities (`src/lib/`) and components (`src/components/`) +- DRY: if the same pattern appears twice, extract it + +## Tech Stack + +- **Framework**: Next.js App Router (all pages are `"use client"`) +- **Database**: Prisma + SQLite +- **Styling**: Tailwind CSS v4 (use `bg-linear-to-*` NOT `bg-gradient-to-*`) +- **Animation**: framer-motion +- **Icons**: lucide-react +- **Effects**: canvas-confetti + +## Tailwind v4 Syntax + +- Gradients: `bg-linear-to-br` (not `bg-gradient-to-br`) +- Arbitrary values: prefer Tailwind built-in classes over `[]` when possible (e.g. `max-w-20` not `max-w-[5rem]`) + +## Component Patterns + +- Page components export `default function PageName()` +- Use `useCallback` for event handlers passed as props +- Use `motion.*` from framer-motion for animated elements +- Mobile-first, `min-h-dvh` for full-height pages +- `overflow-y-auto scrollbar-none` for scrollable pages +- Extract reusable UI patterns into shared components (modals, cards, empty states) + +## Theme-Safe Colors + +- **Never use `text-white` for content text on page backgrounds** — it becomes invisible in light mode +- Use semantic colors: `text-heading`, `text-foreground`, `text-secondary`, `text-muted` for content text +- `text-white` is only correct on **colored backgrounds** (buttons, badges, gradient cards, overlays) +- Same applies to `bg-black` — prefer `bg-background`, `bg-surface`, `bg-elevated` + +## Loading States + +- **Page-level and list-level loading**: always use skeleton screens (`src/components/Skeleton.tsx`), never bare spinners +- Skeleton shape should mimic the actual content layout (cards, list items, avatars, text lines) +- Compose page skeletons from reusable skeleton primitives: `Skeleton`, `SkeletonCircle`, `RecordItemSkeleton`, `RoomCardSkeleton`, etc. +- **Button-level loading** (submit, save, join): keep using inline `Loader2` spinner — skeleton screens don't apply to buttons +- When adding a new page or async data section, always include a skeleton loading state + +## API Routes + +- Located in `src/app/api/` +- Return `NextResponse.json()` with appropriate status codes +- Always handle errors with try/catch +- Extract common validation (userId, membership) into utility functions in `src/lib/` +- Use consistent error response shape: `{ error: string }` diff --git a/BUGFIX.md b/BUGFIX.md new file mode 100644 index 0000000..e837f8f --- /dev/null +++ b/BUGFIX.md @@ -0,0 +1,125 @@ +# 待修复问题清单 + +> 全面代码审查后整理,按严重程度排列。 +> 修复后在对应条目前打 `[x]`。 + +--- + +## Critical — 必须修复 + +- [x] **#1 非成员可操作任意房间** + - 文件:`api/room/[id]/swipe/route.ts`、`undo/route.ts`、`reset/route.ts` + - swipe / undo 没有校验 userId 是否在 `data.users` 中,非成员的 like 会被计入匹配 + - reset 无需任何 userId,任何人知道 4 位房间号即可清空所有投票 + - 修复:在 `atomicUpdateRoom` 回调开头加成员校验;reset 要求 userId 并验证 creatorId + +- [x] **#2 盲盒抽取竞态条件** + - 文件:`api/blindbox/draw/route.ts` + - `findMany({ status: "in_pool" })` 和 `update` 不在事务中,两个并发请求可抽到同一个想法 + - 修复:用 `prisma.$transaction` + `updateMany({ where: { id, status: "in_pool" } })` 保证原子性,count=0 时返回 409 + +- [x] **#3 SwipeDeck 不响应他人重置** + - 文件:`src/components/SwipeDeck.tsx` + - 其他用户重置房间后,当前用户的 `currentIndex` / `localMatchId` 不清零,卡片位置错乱 + - 修复:加 useEffect 检测 server 端 swipeCount 归零时调用 `clearLocalState()` + +- [x] **#4 API 错误响应被当数组用导致崩溃** + - 文件:`src/app/panic/page.tsx`(suggestions)、`src/app/profile/page.tsx`(history/favorites) + - fetch 后没检查 `res.ok`,非 200 返回的 `{ error: "..." }` 被当作数组,`.map()` 崩溃 + - 修复:所有 fetch 后先检查 `res.ok`,失败时设为空数组 + +- [x] **#5 theme.ts SSR 崩溃** + - 文件:`src/lib/theme.ts` + - `setStoredTheme` / `applyTheme` 直接访问 `localStorage` / `document`,无 `typeof window` 守卫 + - 修复:函数开头加 `if (typeof window === "undefined") return;` + +--- + +## High — 应该修复 + +- [x] **#6 用户名唯一性 TOCTOU 竞态** ✅ + - register 去掉 findUnique 直接 create + catch P2002;user PUT 同理 + - apiHandler 全局兜底 P2002 → 409 + +- [x] **#7 SSE events 接口无认证** ✅ + - 校验 userId query param + 房间成员身份;start() 加 try/catch + +- [x] **#8 GET /api/user 暴露任意用户邮箱** ⚠️ + - 需要真实服务端 auth 才能彻底修复,暂保留;已加 JSON.parse 安全防护 + +- [x] **#9 收藏去重用 JSON contains 匹配** ✅ + - Favorite 模型新增 `restaurantId` 字段 + `@@unique([userId, restaurantId])` + +- [x] **#10 缺少数据库索引** ✅ + - 补齐所有缺失索引并 db push + +- [x] **#11 无 onDelete 级联** ✅ + - Decision/Favorite/BlindBoxMember 加 `onDelete: Cascade`,BlindBoxIdea.drawnBy 加 `onDelete: SetNull` + +- [x] **#12 密码无最大长度限制** ✅ + - `validatePassword` 加 128 字符上限 + +- [x] **#13 AuthModal 关闭后重开不重置表单** ✅ + - useEffect 监听 open 变 true 时重置全部表单状态 + +- [x] **#14 邀请页加入失败无错误提示** ✅ + - 新增 joinError state,catch 中捕获并渲染错误消息 + +--- + +## Medium — 建议修复 + +- [x] **#15 房间 ID 空间仅 9000 个** ✅ + - 扩展为 6 位字母数字 (30^6 ≈ 7.3 亿),createRoom 用 P2002 重试 + +- [x] **#16 盲盒想法编辑/删除有 TOCTOU** ✅ + - PUT/DELETE 改用 updateMany/deleteMany 原子操作 + +- [x] **#17 lat/lng 未校验为合法坐标** ✅ + - Number.isFinite + 范围校验 (-90~90, -180~180) + +- [x] **#18 swipe action 未校验** ✅ + - 校验 action 必须为 'like' 或 'pass' + +- [x] **#19 JSON.parse(preferences) 可能崩溃** ✅ + - GET 和 PUT 响应均加 try/catch,fallback {} + +- [x] **#20 ShareCardModal data 每次新引用触发 useEffect** ✅ + - 依赖改为 imageSrc 字符串而非整个 data 对象 + +- [x] **#21 BlindboxRoomPage 多处 setTimeout 未清理** ✅ + - timersRef 统一收集所有 setTimeout,unmount 时批量清理;confetti rAF 用 alive ref 控制 + +- [x] **#22 外部 API (高德) 失败返回泛化 500** ✅ + - 三个高德 API 路由 fetch 加 try/catch → 503 + +- [x] **#23 navigation.ts location.split(",") 不校验格式** ✅ + - 校验 split 结果长度为 2 且两部分非空 + +- [x] **#24 handleReset / handleNarrow 吞掉 fetch 错误** ✅ + - 检查 res.ok,失败时 toast 提示 + +- [x] **#25 confettiCanvasRef 未使用(死代码)** ✅ + - 删除 ref 和 canvas 元素 + +- [x] **#26 requireString 接受纯空格字符串** ✅ + - 加 .trim() 校验 + +--- + +## Low — 可以改进 + +- [x] **#27 icon-only 按钮缺少 aria-label** ✅ + - panic/blindbox/ShareCardModal/AuthModal 中所有 icon-only 按钮补 aria-label + +- [x] **#28 AudioContext 每次 playChime() 新建** ✅ + - 缓存复用单个 AudioContext,state === "closed" 时才重建 + +- [x] **#29 ApiError.name 是 "Error" 而非 "ApiError"** ✅ + - 已在 High 批次修复 + +- [x] **#30 blindbox lobby 加载房间失败静默无提示** ✅ + - 新增 loadError state,失败时显示"加载失败 / 点击重试" + +- [x] **#31 theme localStorage 读取不校验合法值** ✅ + - 已在 Critical 批次修复(VALID_THEMES 白名单校验) diff --git a/Jenkinsfile b/Jenkinsfile index 16cb8d6..2756cf9 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -2,8 +2,9 @@ pipeline { agent any environment { - APP_NAME = 'no-whatever' - AMAP_KEY = '7f6be40a6de3f7fbb7bc3f825b67573b' + APP_NAME = 'no-whatever' + AMAP_KEY = '7f6be40a6de3f7fbb7bc3f825b67573b' + DEEPSEEK_KEY = credentials('deepseek-api-key') } triggers { @@ -37,6 +38,7 @@ pipeline { -v /data/${APP_NAME}:/app/data \ -e DATABASE_URL=file:/app/data/prod.db \ -e AMAP_API_KEY=${AMAP_KEY} \ + -e DEEPSEEK_API_KEY=${DEEPSEEK_KEY} \ --restart unless-stopped \ ${APP_NAME}:latest """ diff --git a/README.md b/README.md index 14bc3a6..3da2142 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,52 @@ # NoWhatever — 别说随便 -像 Tinder 一样滑卡片,和朋友一起决定去哪吃!解决聚餐时"随便都行"的纠结痛点,无需下载 App,用完即走。 +> 亲密关系决策引擎。别再说"随便"了,两个模式覆盖你们所有的选择困难症。 + +## 两大模式 + +### ⚡️ 极速救场 · Panic Mode + +10 秒内出结果,立刻闭嘴,听天由命。 + +- 基于 GPS 或手动选点,搜索附近餐厅 / 酒吧 +- Tinder 式滑卡投票 — 右滑想去、左滑跳过 +- 多人实时匹配 — 分享房间链接,所有人同时滑,自动算出最优解 +- 全员一致时触发庆祝特效,非全员一致可发起 Top N 决赛 +- 匹配结果支持一键导航、电话订位、收藏、生成分享卡片 + +### 🎁 周末契约 · Adventure Roulette + +丢入疯狂想法,周末盲盒开奖,绝不反悔。 + +- 创建专属房间,邀请 TA 用 6 位房间号加入 +- 平日随时向盲盒池投放想法(美食 / 旅行 / 运动 / 奇葩挑战) +- 周末一起盲抽,开奖结果不可反悔 +- 支持多个房间并行,房间成员共同管理想法池 + +## 通用能力 + +- **用户系统** — 用户名 + 密码注册,10 秒完成,头像自选 +- **分享卡片** — 匹配 / 开奖结果一键生成品牌分享图,支持保存 & Web Share API +- **个人中心** — 决策历史回顾、餐厅收藏管理 +- **多场景** — 吃饭 / 喝酒场景切换,复用同一套滑卡机制 ## Tech Stack -- **Next.js** (App Router) + **React** + **TypeScript** -- **Tailwind CSS** — Utility-first styling +- **Next.js 16** (App Router) + **React 19** + **TypeScript** +- **Prisma** + **SQLite** — 数据持久化 +- **Tailwind CSS v4** — Utility-first styling - **Framer Motion** — Physics-based swipe & drag animations +- **SWR** — 实时轮询 & 数据缓存 - **Lucide React** — Icon library +- **canvas-confetti** — 匹配庆祝特效 +- **html-to-image** + **qrcode.react** — 分享卡片 & 邀请二维码 ## Getting Started ```bash npm install +npx prisma generate +npx prisma db push npm run dev ``` @@ -23,16 +57,37 @@ Open [http://localhost:3000](http://localhost:3000) in your browser (best viewed ``` src/ ├── app/ -│ ├── globals.css # Global styles (mobile-first, no scroll) -│ ├── layout.tsx # Root layout with viewport meta -│ └── page.tsx # Main entry page -├── components/ -│ ├── TopNav.tsx # Navigation bar with room info -│ ├── RestaurantCard.tsx # Restaurant display card -│ ├── SwipeableCard.tsx # Framer Motion drag/swipe logic -│ ├── SwipeDeck.tsx # Card stack orchestrator -│ ├── ActionButtons.tsx # Nope / Like action buttons -│ └── MatchResult.tsx # Match celebration screen +│ ├── page.tsx # 首页 — 双模式入口 +│ ├── panic/page.tsx # 极速救场 — 定位 / 选点 / 创建房间 +│ ├── room/[id]/page.tsx # 滑卡房间 — 多人实时投票 +│ ├── invite/[id]/page.tsx # 邀请页 — 扫码 / 链接加入房间 +│ ├── blindbox/page.tsx # 周末契约大厅 — 房间列表 +│ ├── blindbox/[code]/page.tsx # 盲盒房间 — 想法投放 & 开奖 +│ ├── profile/page.tsx # 个人中心 — 历史 / 收藏 / 资料 +│ └── api/ # API Routes +│ ├── auth/ # 登录 / 注册 +│ ├── room/ # 房间 CRUD / 滑动 / 匹配 +│ ├── blindbox/ # 盲盒房间 / 想法 / 抽奖 +│ ├── user/ # 用户资料 / 历史 / 收藏 +│ └── location/ # 地理编码 / 地点建议 +├── components/ # UI 组件 +│ ├── SwipeDeck.tsx # 卡片堆栈编排器 +│ ├── SwipeableCard.tsx # 拖拽 / 滑动逻辑 +│ ├── RestaurantCard.tsx # 餐厅信息展示卡 +│ ├── MatchResult.tsx # 匹配成功庆祝页 +│ ├── ShareCardModal.tsx # 分享卡片生成弹窗 +│ ├── AuthModal.tsx # 登录 / 注册弹窗 +│ ├── TopNav.tsx # 顶部导航栏 +│ └── ... # 其他 UI 组件 +├── hooks/ +│ └── useRoomPolling.ts # 房间状态实时轮询 +├── lib/ # 工具函数 & 服务 +│ ├── prisma.ts # Prisma 客户端 +│ ├── buildRoomStatus.ts # 房间状态构建 & 匹配算法 +│ ├── sceneConfig.ts # 场景配置(吃饭 / 喝酒) +│ ├── celebrate.ts # 庆祝特效 & 音效 +│ ├── userId.ts # 用户 ID & 注册状态管理 +│ └── ... # 其他工具 └── types/ - └── index.ts # TypeScript type definitions + └── index.ts # TypeScript 类型定义 ``` diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..1f08ee8 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,111 @@ +# NoWhatever 产品优化路线图 + +> 基于当前产品形态的全面审视,按优先级排列。 +> 已完成项标记 ~~删除线~~。 + +--- + +## P0 — 核心闭环 + +### ~~分享结果卡片~~(已完成) +- ~~匹配成功 / 盲盒开奖后,一键生成品牌分享图~~ +- ~~支持保存图片、Web Share API 分享~~ +- ~~卡片包含餐厅/想法详情 + 二维码,形成增长飞轮~~ + +### 匹配成功页补全后续动作 +- ~~一键导航提升为首要 CTA~~(已完成,导航按钮已作为 accent 主按钮置顶) +- ~~加入一键打电话订位~~(已完成,`tel` 字段存在时展示拨号按钮) +- "不满意?再来一轮" 提升可见度:从底部灰色文字改为固定在页面底部的半透明浮动条,带 `RotateCcw` 图标 + 引导文案 +- 加入"收藏这家"快捷操作(复用已有收藏 API `/api/user/favorite`,用心形图标放在结果卡片右上角) +- 非全员一致时,在"Top N 决赛"按钮上方增加一句引导语("还有 X 家不相上下,再选一轮?"),降低用户理解成本 + +--- + +## P1 — 体验提升 + +### 单人等待体验修复 +- `userCount === 1` 时跳过"等待其他人完成选择"spinner,直接出结果 +- 匹配文案适配单人场景("就去这了"→"帮你选好了","X/X 人想去"→"你的首选") + +### 盲盒想法互动 +- 对想法点赞 / 加权(增加被抽中概率) +- 想法分类标签(美食 / 旅行 / 运动 / 奇葩挑战) +- 抽中后打卡确认(拍照上传,形成回忆) +- "本周契约执行率" 统计 + +### ~~PWA 支持~~(已完成) +- ~~添加 Web App Manifest,支持"添加到主屏幕"~~ +- ~~Service Worker 离线缓存基础页面~~ +- ~~`viewport-fit=cover` 适配刘海屏~~ + +### 盲盒房间删除 / 退出 +- 房间创建者可删除房间(级联清理成员 & 想法) +- 非创建者可退出房间(从成员列表移除自己) + +### 首次体验引导优化 +- ~~极速救场完成一轮后引导注册("注册保存记录")~~(已完成,匹配成功页展示注册卡片,注册后自动保存记录) +- 盲盒模式先展示 demo / 动画,让用户看到价值再引导注册 +- 统一两个模式的登录体验(目前极速救场不需登录,盲盒必须登录) + +--- + +## P2 — 场景拓展 & 数据 + +### 更多极速救场场景 +- 当前只有"吃饭"和"喝酒"两个场景 +- 可扩展:看电影、去公园、玩什么游戏、周末去哪 +- 复用同一套滑卡机制,接入不同 POI 数据源 + +### 个人数据洞察 +- 你最常吃的菜系 Top 3 +- 你和 TA 的口味重合度 +- 月度决策次数趋势图 +- 在个人中心以简单可视化展示 + +### 首页社交证明 +- "已帮助 X 对情侣做出 Y 次决定"(全局计数器) +- 最近一次匹配的匿名动态("3分钟前,一对情侣在北京选中了 XXX") +- 提升首页说服力,推动新用户转化 + +### 盲盒开奖提醒 +- 周五下午推送"本周盲盒待抽 X 个想法" +- 浏览器 Notification API 提醒 +- 房间内"设定开奖日"功能,到时间提醒所有成员 + +--- + +## P3 — 长期留存 + +### 成就 & 激励系统 +- 决策次数徽章("已拯救 10 次选择困难症") +- 连续使用天数 +- 盲盒投放数量成就 +- 在个人中心展示,增加用户粘性 + +### 浅色模式 +- 当前暗色主题是唯一选项 +- 白天户外使用体验差 +- 跟随系统 / 手动切换 + +### 空状态插图优化 +- 个人中心"还没有决策记录""还没有收藏"用纯文字展示 +- 替换为插图 + CTA 按钮("去创建第一个房间") + +--- + +## 技术债务 + +### 安全 & 稳定性 +- [ ] API 接口加入 Rate Limiting +- [ ] 添加全局 Error Boundary +- [ ] 历史记录 / 收藏列表加分页 +- [ ] 餐厅图片加载失败时的 fallback 占位 + +### 性能优化 +- [ ] 餐厅图片使用 Next.js Image 组件优化 +- [ ] 加入 Loading Skeleton 替代纯 spinner +- [ ] 盲盒房间过期策略(避免僵尸房间堆积) + +### 监控 +- [ ] 接入基础数据埋点(页面 PV、功能使用率) +- [ ] 错误上报(Sentry 或类似) diff --git a/package-lock.json b/package-lock.json index ec8835e..91c7b03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,10 @@ "bcryptjs": "^3.0.3", "canvas-confetti": "^1.9.4", "framer-motion": "^12.34.3", + "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", "next": "16.1.6", + "openai": "^6.25.0", "prisma": "^6.19.2", "qrcode.react": "^4.2.0", "react": "19.2.3", @@ -4260,6 +4262,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5606,6 +5614,27 @@ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "license": "MIT" }, + "node_modules/openai": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.25.0.tgz", + "integrity": "sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7094,7 +7123,7 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "funding": { diff --git a/package.json b/package.json index 8db0243..c4d0916 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,10 @@ "bcryptjs": "^3.0.3", "canvas-confetti": "^1.9.4", "framer-motion": "^12.34.3", + "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", "next": "16.1.6", + "openai": "^6.25.0", "prisma": "^6.19.2", "qrcode.react": "^4.2.0", "react": "19.2.3", diff --git a/prisma/migrations/20260226020724_add_blindbox_idea/migration.sql b/prisma/migrations/20260226020724_add_blindbox_idea/migration.sql new file mode 100644 index 0000000..b898939 --- /dev/null +++ b/prisma/migrations/20260226020724_add_blindbox_idea/migration.sql @@ -0,0 +1,47 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "avatar" TEXT NOT NULL DEFAULT '🐱', + "email" TEXT, + "preferences" TEXT NOT NULL DEFAULT '{}', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "Decision" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "roomId" TEXT NOT NULL, + "restaurantName" TEXT NOT NULL, + "restaurantData" TEXT NOT NULL, + "matchType" TEXT NOT NULL, + "participants" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Decision_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Favorite" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "restaurantData" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Favorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "BlindBoxIdea" ( + "id" TEXT NOT NULL PRIMARY KEY, + "roomId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'in_pool', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/prisma/migrations/20260226040305_add_blindbox_room_system/migration.sql b/prisma/migrations/20260226040305_add_blindbox_room_system/migration.sql new file mode 100644 index 0000000..27bec12 --- /dev/null +++ b/prisma/migrations/20260226040305_add_blindbox_room_system/migration.sql @@ -0,0 +1,52 @@ +/* + Warnings: + + - Added the required column `userId` to the `BlindBoxIdea` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateTable +CREATE TABLE "BlindBoxRoom" ( + "id" TEXT NOT NULL PRIMARY KEY, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "creatorId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "BlindBoxRoom_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "BlindBoxMember" ( + "id" TEXT NOT NULL PRIMARY KEY, + "roomId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "joinedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "BlindBoxMember_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "BlindBoxRoom" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "BlindBoxMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_BlindBoxIdea" ( + "id" TEXT NOT NULL PRIMARY KEY, + "roomId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'in_pool', + "drawnById" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "BlindBoxIdea_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "BlindBoxRoom" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "BlindBoxIdea_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "BlindBoxIdea_drawnById_fkey" FOREIGN KEY ("drawnById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_BlindBoxIdea" ("content", "createdAt", "id", "roomId", "status") SELECT "content", "createdAt", "id", "roomId", "status" FROM "BlindBoxIdea"; +DROP TABLE "BlindBoxIdea"; +ALTER TABLE "new_BlindBoxIdea" RENAME TO "BlindBoxIdea"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "BlindBoxRoom_code_key" ON "BlindBoxRoom"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "BlindBoxMember_roomId_userId_key" ON "BlindBoxMember"("roomId", "userId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f3a86b3..3d294e0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,6 +12,8 @@ model Room { data String createdAt DateTime @default(now()) expiresAt DateTime + + @@index([expiresAt]) } model User { @@ -24,6 +26,12 @@ model User { createdAt DateTime @default(now()) decisions Decision[] favorites Favorite[] + + createdBlindBoxRooms BlindBoxRoom[] @relation("RoomCreator") + blindBoxMemberships BlindBoxMember[] + submittedIdeas BlindBoxIdea[] @relation("IdeaSubmitter") + drawnIdeas BlindBoxIdea[] @relation("IdeaDrawer") + weekendPlans WeekendPlan[] } model Decision { @@ -35,13 +43,88 @@ model Decision { matchType String participants Int createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([roomId]) } model Favorite { id String @id @default(cuid()) userId String + restaurantId String @default("") restaurantData String createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, restaurantId]) + @@index([userId]) +} + +model BlindBoxRoom { + id String @id @default(cuid()) + code String @unique + name String + creatorId String + city String? + lat Float? + lng Float? + createdAt DateTime @default(now()) + + creator User @relation("RoomCreator", fields: [creatorId], references: [id]) + members BlindBoxMember[] + ideas BlindBoxIdea[] + plans WeekendPlan[] +} + +model BlindBoxMember { + id String @id @default(cuid()) + roomId String + userId String + joinedAt DateTime @default(now()) + + room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([roomId, userId]) +} + +model BlindBoxIdea { + id String @id @default(uuid()) + roomId String + userId String + content String + status String @default("in_pool") + drawnById String? + createdAt DateTime @default(now()) + + category String? + timeSlot String? + estimatedMinutes Int? + outdoor Boolean? + searchQuery String? + searchType String? + + room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) + user User @relation("IdeaSubmitter", fields: [userId], references: [id], onDelete: Cascade) + drawnBy User? @relation("IdeaDrawer", fields: [drawnById], references: [id], onDelete: SetNull) + + @@index([roomId, status]) + @@index([userId]) +} + +model WeekendPlan { + id String @id @default(cuid()) + roomId String + userId String + planData String + status String @default("active") + endTime DateTime? + createdAt DateTime @default(now()) + + room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([roomId]) + @@index([userId]) } diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..0c1d427 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/icon-192x192.png b/public/icon-192x192.png new file mode 100644 index 0000000..3fcc54e Binary files /dev/null and b/public/icon-192x192.png differ diff --git a/public/icon-512x512.png b/public/icon-512x512.png new file mode 100644 index 0000000..281918f Binary files /dev/null and b/public/icon-512x512.png differ diff --git a/scripts/generate-pwa-icons.mjs b/scripts/generate-pwa-icons.mjs new file mode 100644 index 0000000..07a1676 --- /dev/null +++ b/scripts/generate-pwa-icons.mjs @@ -0,0 +1,37 @@ +import sharp from "sharp"; +import { mkdirSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const publicDir = join(__dirname, "..", "public"); + +const ACCENT = "#10b981"; +const BG_DARK = "#030712"; + +function buildSvg(size) { + const fontSize = Math.round(size * 0.32); + const radius = Math.round(size * 0.18); + return ` + + NW + `; +} + +const sizes = [192, 512]; + +for (const size of sizes) { + const svg = Buffer.from(buildSvg(size)); + const out = join(publicDir, `icon-${size}x${size}.png`); + await sharp(svg).resize(size, size).png().toFile(out); + console.log(`✓ ${out}`); +} + +const appleSvg = Buffer.from(buildSvg(180)); +const appleOut = join(publicDir, "apple-touch-icon.png"); +await sharp(appleSvg).resize(180, 180).png().toFile(appleOut); +console.log(`✓ ${appleOut}`); + +console.log("\nPWA icons generated."); diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx new file mode 100644 index 0000000..644c174 --- /dev/null +++ b/src/app/achievements/page.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; +import { + ArrowLeft, + Trophy, + Zap, + Gift, + ClipboardList, + Target, + TrendingUp, + BarChart3, +} from "lucide-react"; +import { isRegistered, getCachedProfile } from "@/lib/userId"; +import RestaurantImage from "@/components/RestaurantImage"; +import ContractHistoryItem from "@/components/ContractHistoryItem"; +import EmptyState from "@/components/EmptyState"; +import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton"; +import { buildNavUrl } from "@/lib/navigation"; +import type { DecisionRecord, ContractRecord, Restaurant } from "@/types"; + +type Tab = "decisions" | "contracts"; + +interface Stats { + totalDecisions: number; + totalContracts: number; + completedContracts: number; + completionRate: number; +} + +function firstImage(r: Restaurant): string { + if (r.images?.length > 0) return r.images[0]; + const legacy = (r as unknown as Record).image; + return typeof legacy === "string" ? legacy : ""; +} + +export default function AchievementsPage() { + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState("decisions"); + const [stats, setStats] = useState({ + totalDecisions: 0, + totalContracts: 0, + completedContracts: 0, + completionRate: 0, + }); + const [decisions, setDecisions] = useState([]); + const [contracts, setContracts] = useState([]); + + useEffect(() => { + if (!isRegistered()) { + router.replace("/"); + return; + } + const p = getCachedProfile(); + if (!p) return; + + (async () => { + try { + const res = await fetch(`/api/user/achievements?userId=${p.id}`); + if (!res.ok) return; + const data = await res.json(); + setStats(data.stats); + setDecisions(data.decisions); + setContracts(data.contracts); + } catch { /* ignore */ } + finally { setLoading(false); } + })(); + }, [router]); + + const statCards = [ + { + label: "决策记录", + value: stats.totalDecisions, + icon: Target, + color: "text-amber-400", + bg: "bg-amber-600/15", + }, + { + label: "契约完成", + value: stats.completedContracts, + icon: Trophy, + color: "text-emerald-400", + bg: "bg-emerald-600/15", + }, + { + label: "完成率", + value: stats.totalContracts > 0 ? `${stats.completionRate}%` : "—", + icon: TrendingUp, + color: "text-purple-400", + bg: "bg-purple-600/15", + }, + ]; + + const tabs: { id: Tab; label: string; icon: typeof Zap }[] = [ + { id: "decisions", label: "极速救场", icon: Zap }, + { id: "contracts", label: "周末契约", icon: Gift }, + ]; + + return ( +
+ {/* Ambient glow */} +
+ + {/* Header */} +
+ +
+ +

成就墙

+
+
+ + {/* Stats */} + + {statCards.map((s) => ( +
+
+ +
+ {loading ? ( + + ) : ( +

{s.value}

+ )} +

{s.label}

+
+ ))} +
+ + {/* Tab switcher */} + + {tabs.map((t) => ( + + ))} + + + {/* Content */} +
+ + {tab === "decisions" && ( + + {loading ? ( + <> + + + + + ) : decisions.length === 0 ? ( + + ) : ( + decisions.map((d) => ( + + {firstImage(d.restaurantData) && ( + + )} +
+

+ {d.restaurantName} +

+
+ + {d.matchType === "unanimous" ? "全员一致" : "最佳匹配"} + + {d.participants} 人参与 + + {new Date(d.createdAt).toLocaleDateString("zh-CN", { + month: "short", + day: "numeric", + })} + +
+
+
+ )) + )} +
+ )} + + {tab === "contracts" && ( + + {loading ? ( + <> + + + + + ) : contracts.length === 0 ? ( + + ) : ( + contracts.map((c) => ( + + )) + )} + + )} +
+
+ +
+
+ ); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 8d72ef7..c802679 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,27 +1,22 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import bcrypt from "bcryptjs"; +import { apiHandler, ApiError } from "@/lib/api"; -export async function POST(req: NextRequest) { +export const POST = apiHandler(async (req) => { const { username, password } = await req.json(); - if (!username || !password) { - return NextResponse.json({ error: "请输入用户名和密码" }, { status: 400 }); - } + if (!username || !password) throw new ApiError("请输入用户名和密码"); const user = await prisma.user.findUnique({ where: { username: username.trim() } }); - if (!user) { - return NextResponse.json({ error: "用户名或密码错误" }, { status: 401 }); - } + if (!user) throw new ApiError("用户名或密码错误", 401); const valid = await bcrypt.compare(password, user.passwordHash); - if (!valid) { - return NextResponse.json({ error: "用户名或密码错误" }, { status: 401 }); - } + if (!valid) throw new ApiError("用户名或密码错误", 401); return NextResponse.json({ id: user.id, username: user.username, avatar: user.avatar, }); -} +}); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 3f5cc28..ea8c7bd 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -1,41 +1,38 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { Prisma } from "@prisma/client"; import bcrypt from "bcryptjs"; +import { apiHandler, ApiError } from "@/lib/api"; +import { validateUsername, validatePassword } from "@/lib/validation"; -export async function POST(req: NextRequest) { +export const POST = apiHandler(async (req) => { const { username, password, avatar } = await req.json(); - if (!username || !password) { - return NextResponse.json({ error: "用户名和密码为必填项" }, { status: 400 }); - } + if (!username || !password) throw new ApiError("用户名和密码为必填项"); - const trimmedUsername = username.trim(); - if (trimmedUsername.length < 2 || trimmedUsername.length > 16) { - return NextResponse.json({ error: "用户名需要 2-16 个字符" }, { status: 400 }); - } - - if (password.length < 6) { - return NextResponse.json({ error: "密码至少 6 个字符" }, { status: 400 }); - } - - const existing = await prisma.user.findUnique({ where: { username: trimmedUsername } }); - if (existing) { - return NextResponse.json({ error: "用户名已被注册" }, { status: 409 }); - } + const trimmedUsername = validateUsername(username); + validatePassword(password); const passwordHash = await bcrypt.hash(password, 10); - const user = await prisma.user.create({ - data: { - username: trimmedUsername, - passwordHash, - avatar: avatar || "🐱", - }, - }); + try { + const user = await prisma.user.create({ + data: { + username: trimmedUsername, + passwordHash, + avatar: avatar || "🐱", + }, + }); - return NextResponse.json({ - id: user.id, - username: user.username, - avatar: user.avatar, - }); -} + return NextResponse.json({ + id: user.id, + username: user.username, + avatar: user.avatar, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { + throw new ApiError("用户名已被注册", 409); + } + throw e; + } +}); diff --git a/src/app/api/blindbox/draw/route.ts b/src/app/api/blindbox/draw/route.ts new file mode 100644 index 0000000..7adc1d7 --- /dev/null +++ b/src/app/api/blindbox/draw/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireMembership } from "@/lib/blindbox"; +import { apiHandler, ApiError, requireUserId } from "@/lib/api"; + +export const POST = apiHandler(async (req) => { + const { roomId, userId } = await req.json(); + + requireUserId(userId); + if (!roomId || typeof roomId !== "string") throw new ApiError("roomId 不能为空"); + + await requireMembership(roomId, userId); + + const userSelect = { id: true, username: true, avatar: true } as const; + + const idea = await prisma.$transaction(async (tx) => { + const pool = await tx.blindBoxIdea.findMany({ + where: { roomId, status: "in_pool" }, + select: { id: true }, + }); + + if (pool.length === 0) { + throw new ApiError("盒子是空的,先往里面塞点想法吧!", 404); + } + + const picked = pool[Math.floor(Math.random() * pool.length)]; + + const { count } = await tx.blindBoxIdea.updateMany({ + where: { id: picked.id, status: "in_pool" }, + data: { status: "drawn", drawnById: userId }, + }); + + if (count === 0) { + throw new ApiError("手慢了,再试一次", 409); + } + + return tx.blindBoxIdea.findUnique({ + where: { id: picked.id }, + include: { + user: { select: userSelect }, + drawnBy: { select: userSelect }, + }, + }); + }); + + if (!idea) throw new ApiError("抽取失败", 500); + + return NextResponse.json({ + id: idea.id, + content: idea.content, + createdAt: idea.createdAt, + submitter: idea.user, + drawnBy: idea.drawnBy, + }); +}); diff --git a/src/app/api/blindbox/plan/route.ts b/src/app/api/blindbox/plan/route.ts new file mode 100644 index 0000000..924b784 --- /dev/null +++ b/src/app/api/blindbox/plan/route.ts @@ -0,0 +1,479 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireMembership } from "@/lib/blindbox"; +import { apiHandler, ApiError, requireUserId } from "@/lib/api"; +import { requireAmapApiKey } from "@/lib/amap"; +import { generateSchedule, type ScheduleContext } from "@/lib/ai"; + +interface AvailableTime { + date: string; + startHour: number; + endHour: number; +} + +interface TaggedIdea { + id: string; + content: string; + category: string; + timeSlot: string; + estimatedMinutes: number; + searchQuery: string; + searchType: string; +} + +const SLOT_CATEGORY_MAP: Record = { + morning: ["outdoor", "sports", "culture"], + lunch: ["dining"], + afternoon: ["entertainment", "shopping", "relaxation", "outdoor", "culture"], + dinner: ["dining"], + evening: ["entertainment", "relaxation"], +}; + +function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): TaggedIdea[] { + const byCategory = new Map(); + for (const idea of ideas) { + const list = byCategory.get(idea.category) || []; + list.push(idea); + byCategory.set(idea.category, list); + } + + const slots: string[] = []; + if (availableHours >= 10) { + slots.push("morning", "lunch", "afternoon", "dinner", "evening"); + } else if (availableHours >= 7) { + slots.push("morning", "lunch", "afternoon", "evening"); + } else if (availableHours >= 5) { + slots.push("lunch", "afternoon", "evening"); + } else { + slots.push("afternoon", "evening"); + } + + const selected: TaggedIdea[] = []; + const usedIds = new Set(); + + for (const slot of slots) { + const preferredCategories = SLOT_CATEGORY_MAP[slot] || []; + + let picked: TaggedIdea | null = null; + for (const cat of preferredCategories) { + const pool = (byCategory.get(cat) || []).filter((i) => !usedIds.has(i.id)); + if (pool.length > 0) { + picked = pool[Math.floor(Math.random() * pool.length)]; + break; + } + } + + if (!picked) { + const remaining = ideas.filter((i) => !usedIds.has(i.id)); + if (remaining.length > 0) { + picked = remaining[Math.floor(Math.random() * remaining.length)]; + } + } + + if (picked) { + selected.push(picked); + usedIds.add(picked.id); + } + } + + return selected; +} + +async function searchPois( + query: string, + searchType: string, + anchorLat: number, + anchorLng: number, +): Promise<{ name: string; address: string; lat: number; lng: number; rating?: number }[]> { + const apiKey = requireAmapApiKey(); + + if (searchType === "category") { + const url = new URL("https://restapi.amap.com/v5/place/around"); + url.searchParams.set("key", apiKey); + url.searchParams.set("location", `${anchorLng},${anchorLat}`); + url.searchParams.set("keywords", query); + url.searchParams.set("radius", "5000"); + url.searchParams.set("show_fields", "business"); + url.searchParams.set("page_size", "8"); + + const res = await fetch(url.toString()); + const data = await res.json(); + if (data.status !== "1" || !data.pois?.length) return []; + return mapPois(data.pois); + } + + // Text/brand search — bias results to the room's location + const url = new URL("https://restapi.amap.com/v5/place/text"); + url.searchParams.set("key", apiKey); + url.searchParams.set("keywords", query); + url.searchParams.set("location", `${anchorLng},${anchorLat}`); + url.searchParams.set("show_fields", "business"); + url.searchParams.set("page_size", "8"); + + const res = await fetch(url.toString()); + const data = await res.json(); + if (data.status !== "1" || !data.pois?.length) return []; + return mapPois(data.pois); +} + +function mapPois( + pois: { name: string; address?: string; location?: string; business?: { rating?: string } }[], +) { + return pois + .filter((p) => p.location) + .map((p) => { + const [lng, lat] = (p.location ?? "0,0").split(",").map(Number); + const ratingStr = p.business?.rating; + return { + name: p.name, + address: p.address || "", + lat, + lng, + rating: ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || undefined : undefined, + }; + }); +} + +export const POST = apiHandler(async (req) => { + const { roomId, userId, availableTime } = await req.json(); + + requireUserId(userId); + if (!roomId) throw new ApiError("roomId 不能为空"); + + await requireMembership(roomId, userId); + + const at = availableTime as AvailableTime; + if ( + !at?.date || + typeof at.startHour !== "number" || + typeof at.endHour !== "number" || + at.endHour <= at.startHour + ) { + throw new ApiError("请选择有效的可用时间"); + } + + const room = await prisma.blindBoxRoom.findUnique({ where: { id: roomId } }); + if (!room) throw new ApiError("房间不存在", 404); + if (!room.lat || !room.lng) { + throw new ApiError("请先设置房间位置", 400); + } + + const allIdeas = await prisma.blindBoxIdea.findMany({ + where: { roomId, status: "in_pool", category: { not: null } }, + select: { + id: true, + content: true, + category: true, + timeSlot: true, + estimatedMinutes: true, + searchQuery: true, + searchType: true, + }, + }); + + const taggedIdeas: TaggedIdea[] = allIdeas.filter( + (i): i is TaggedIdea => + !!i.category && !!i.timeSlot && !!i.searchQuery && !!i.searchType && + typeof i.estimatedMinutes === "number", + ); + + if (taggedIdeas.length < 2) { + throw new ApiError("盒子里至少需要 2 个已标记的想法才能生成计划", 400); + } + + // Split into day configs — "整个周末" generates two separate days + const dayConfigs: AvailableTime[] = + at.date === "整个周末" + ? [ + { date: "周六", startHour: at.startHour, endHour: at.endHour }, + { date: "周日", startHour: at.startHour, endHour: at.endHour }, + ] + : [at]; + + // Select ideas per day — skip extra days when ideas run out + const dayIdeas: TaggedIdea[][] = []; + const usedIds = new Set(); + for (const dayConfig of dayConfigs) { + const remaining = taggedIdeas.filter((i) => !usedIds.has(i.id)); + if (remaining.length < 2) break; + const selected = selectIdeasForSlots(remaining, dayConfig.endHour - dayConfig.startHour); + for (const idea of selected) usedIds.add(idea.id); + dayIdeas.push(selected); + } + // Trim to actual days generated (may be fewer than requested for "整个周末") + const actualDayConfigs = dayConfigs.slice(0, dayIdeas.length); + + const allSelected = dayIdeas.flat(); + if (allSelected.length === 0) { + throw new ApiError("无法从想法池中选出合适的活动", 400); + } + + // Deduplicate search queries across all days + const uniqueByQuery = new Map(); + for (const idea of allSelected) { + if (!uniqueByQuery.has(idea.searchQuery)) uniqueByQuery.set(idea.searchQuery, idea); + } + + // Phase 1: search brand/place type queries in parallel + const brandPlaceQueries = [...uniqueByQuery.values()].filter((i) => i.searchType !== "category"); + + const searchResults = await Promise.all( + brandPlaceQueries.map(async (idea) => { + try { + const pois = await searchPois(idea.searchQuery, idea.searchType, room.lat!, room.lng!); + return { query: idea.searchQuery, pois }; + } catch { + return { query: idea.searchQuery, pois: [] }; + } + }), + ); + + const candidates: ScheduleContext["candidates"] = {}; + for (const result of searchResults) { + candidates[result.query] = result.pois; + } + + // Phase 2: category-type queries anchored to centroid of found POIs + const catQueries = [...uniqueByQuery.values()].filter((i) => i.searchType === "category"); + if (catQueries.length > 0) { + const allPois = Object.values(candidates).flat(); + let anchorLat = room.lat; + let anchorLng = room.lng; + if (allPois.length > 0) { + anchorLat = allPois.reduce((s, p) => s + p.lat, 0) / allPois.length; + anchorLng = allPois.reduce((s, p) => s + p.lng, 0) / allPois.length; + } + + const catResults = await Promise.all( + catQueries.map(async (idea) => { + try { + const pois = await searchPois(idea.searchQuery, idea.searchType, anchorLat, anchorLng); + return { query: idea.searchQuery, pois }; + } catch { + return { query: idea.searchQuery, pois: [] }; + } + }), + ); + + for (const result of catResults) { + candidates[result.query] = result.pois; + } + } + + // Generate schedule for each day (parallel AI calls) + const schedules = await Promise.all( + actualDayConfigs.map((dayConfig, idx) => { + const ideas = dayIdeas[idx]; + const ctx: ScheduleContext = { + ideas: ideas.map((i) => ({ + content: i.content, + category: i.category, + timeSlot: i.timeSlot, + estimatedMinutes: i.estimatedMinutes, + searchQuery: i.searchQuery, + searchType: i.searchType, + })), + candidates, + userLocation: { lat: room.lat!, lng: room.lng! }, + availableTime: dayConfig, + }; + return generateSchedule(ctx); + }), + ); + + const days = schedules + .map((schedule, idx) => + schedule + ? { date: actualDayConfigs[idx].date, items: schedule.items, summary: schedule.summary } + : null, + ) + .filter((d) => d !== null); + + if (days.length === 0) { + throw new ApiError("AI 规划失败,请稍后重试", 500); + } + + const plan = await prisma.weekendPlan.create({ + data: { + roomId, + userId, + planData: JSON.stringify({ + days, + selectedIdeaIds: allSelected.map((i) => i.id), + }), + }, + }); + + return NextResponse.json({ + id: plan.id, + days, + createdAt: plan.createdAt, + }); +}); + +/** + * Map "周六"/"周日" to the next occurrence of that weekday from a reference date. + * Returns a Date at 00:00 of that day. + */ +function nextWeekday(dayLabel: string, from: Date): Date { + const targetDow = dayLabel === "周日" ? 0 : 6; // Sunday=0, Saturday=6 + const d = new Date(from); + d.setHours(0, 0, 0, 0); + const diff = (targetDow - d.getDay() + 7) % 7; + d.setDate(d.getDate() + (diff === 0 ? 0 : diff)); + return d; +} + +function computeEndTime(planData: string, now: Date): Date | null { + try { + const parsed = JSON.parse(planData); + const days = parsed.days as { date: string; items: { time: string; duration: number }[] }[]; + if (!days?.length) return null; + + const lastDay = days[days.length - 1]; + const lastItem = lastDay.items[lastDay.items.length - 1]; + if (!lastItem) return null; + + const base = nextWeekday(lastDay.date, now); + const [h, m] = lastItem.time.split(":").map(Number); + base.setHours(h, m, 0, 0); + base.setMinutes(base.getMinutes() + (lastItem.duration || 60)); + + // If computed end time is in the past, it's for next week + if (base.getTime() < now.getTime()) { + base.setDate(base.getDate() + 7); + } + + return base; + } catch { + return null; + } +} + +export const PATCH = apiHandler(async (req) => { + const { planId, userId, action } = await req.json(); + requireUserId(userId); + if (!planId) throw new ApiError("planId 不能为空"); + + const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } }); + if (!plan) throw new ApiError("计划不存在", 404); + if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403); + + const act = action || "accept"; + + if (act === "accept") { + if (plan.status !== "active") throw new ApiError("该计划无法接受", 400); + const endTime = computeEndTime(plan.planData, new Date()); + await prisma.weekendPlan.update({ + where: { id: planId }, + data: { status: "accepted", endTime }, + }); + return NextResponse.json({ ok: true, endTime }); + } + + if (act === "complete" || act === "expire") { + if (plan.status !== "accepted") throw new ApiError("只能更新已接受的计划", 400); + await prisma.weekendPlan.update({ + where: { id: planId }, + data: { status: act === "complete" ? "completed" : "expired" }, + }); + return NextResponse.json({ ok: true }); + } + + throw new ApiError("无效的操作", 400); +}); + +export const GET = apiHandler(async (req) => { + const { searchParams } = new URL(req.url); + const mode = searchParams.get("mode") || "latest"; + const userId = searchParams.get("userId"); + requireUserId(userId); + + if (mode === "latest") { + const roomId = searchParams.get("roomId"); + if (!roomId) throw new ApiError("roomId 不能为空"); + + const plan = await prisma.weekendPlan.findFirst({ + where: { roomId, userId: userId!, status: "accepted" }, + orderBy: { createdAt: "desc" }, + select: { id: true, planData: true, endTime: true, createdAt: true }, + }); + + if (!plan) return NextResponse.json({ plan: null }); + const parsed = JSON.parse(plan.planData); + return NextResponse.json({ + plan: { id: plan.id, days: parsed.days, endTime: plan.endTime, createdAt: plan.createdAt }, + }); + } + + if (mode === "pending") { + const plans = await prisma.weekendPlan.findMany({ + where: { + userId: userId!, + status: "accepted", + endTime: { not: null, lt: new Date() }, + }, + orderBy: { createdAt: "desc" }, + select: { id: true, planData: true, roomId: true, createdAt: true }, + take: 5, + }); + + const result = await Promise.all( + plans.map(async (p) => { + const room = await prisma.blindBoxRoom.findUnique({ + where: { id: p.roomId }, + select: { name: true, code: true }, + }); + const parsed = JSON.parse(p.planData); + const days = parsed.days as { date: string; items: { activity: string }[] }[]; + return { + id: p.id, + roomName: room?.name ?? "未知房间", + roomCode: room?.code ?? "", + date: days.map((d) => d.date).join(" + "), + activities: days.flatMap((d) => d.items.map((i) => i.activity)), + createdAt: p.createdAt, + }; + }), + ); + + return NextResponse.json({ pending: result }); + } + + if (mode === "history") { + const plans = await prisma.weekendPlan.findMany({ + where: { + userId: userId!, + status: { in: ["completed", "expired"] }, + }, + orderBy: { createdAt: "desc" }, + select: { id: true, planData: true, status: true, roomId: true, createdAt: true }, + take: 50, + }); + + const result = await Promise.all( + plans.map(async (p) => { + const room = await prisma.blindBoxRoom.findUnique({ + where: { id: p.roomId }, + select: { name: true, code: true }, + }); + const parsed = JSON.parse(p.planData); + const days = parsed.days as { date: string; items: { activity: string }[] }[]; + return { + id: p.id, + status: p.status, + roomName: room?.name ?? "未知房间", + roomCode: room?.code ?? "", + date: days.map((d) => d.date).join(" + "), + dayCount: days.length, + activities: days.flatMap((d) => d.items.map((i) => i.activity)), + createdAt: p.createdAt, + }; + }), + ); + + return NextResponse.json({ history: result }); + } + + throw new ApiError("无效的 mode 参数", 400); +}); diff --git a/src/app/api/blindbox/room/[code]/route.ts b/src/app/api/blindbox/room/[code]/route.ts new file mode 100644 index 0000000..62d1f30 --- /dev/null +++ b/src/app/api/blindbox/room/[code]/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getRoomByCode, requireMembership } from "@/lib/blindbox"; +import { apiHandler, ApiError, requireUserId } from "@/lib/api"; + +export const GET = apiHandler(async (_req, { params }) => { + const { code } = await params; + const room = await getRoomByCode(code.toUpperCase()); + + if (!room) throw new ApiError("房间不存在", 404); + + return NextResponse.json({ + id: room.id, + code: room.code, + name: room.name, + creatorId: room.creatorId, + city: room.city, + lat: room.lat, + lng: room.lng, + poolCount: room._count.ideas, + members: room.members.map((m) => ({ + ...m.user, + joinedAt: m.joinedAt, + })), + }); +}); + +export const PATCH = apiHandler(async (req, { params }) => { + const { code } = await params; + const { userId, city, lat, lng } = await req.json(); + + requireUserId(userId); + + const room = await prisma.blindBoxRoom.findUnique({ + where: { code: code.toUpperCase() }, + }); + if (!room) throw new ApiError("房间不存在", 404); + + await requireMembership(room.id, userId); + + const numLat = Number(lat); + const numLng = Number(lng); + if ( + !Number.isFinite(numLat) || !Number.isFinite(numLng) || + numLat < -90 || numLat > 90 || numLng < -180 || numLng > 180 + ) { + throw new ApiError("位置坐标无效"); + } + + const updated = await prisma.blindBoxRoom.update({ + where: { id: room.id }, + data: { + city: typeof city === "string" ? city.trim() : null, + lat: numLat, + lng: numLng, + }, + }); + + return NextResponse.json({ + city: updated.city, + lat: updated.lat, + lng: updated.lng, + }); +}); + +export const DELETE = apiHandler(async (req, { params }) => { + const { code } = await params; + const { userId } = await req.json(); + + requireUserId(userId); + + const room = await prisma.blindBoxRoom.findUnique({ + where: { code: code.toUpperCase() }, + }); + if (!room) throw new ApiError("房间不存在", 404); + + if (room.creatorId === userId) { + await prisma.blindBoxRoom.delete({ where: { id: room.id } }); + return NextResponse.json({ action: "deleted" }); + } + + const membership = await prisma.blindBoxMember.findUnique({ + where: { roomId_userId: { roomId: room.id, userId } }, + }); + if (!membership) throw new ApiError("你不是该房间成员", 403); + + await prisma.blindBoxMember.delete({ where: { id: membership.id } }); + return NextResponse.json({ action: "left" }); +}); diff --git a/src/app/api/blindbox/room/join/route.ts b/src/app/api/blindbox/room/join/route.ts new file mode 100644 index 0000000..98b3208 --- /dev/null +++ b/src/app/api/blindbox/room/join/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiHandler, ApiError, requireUserId } from "@/lib/api"; + +export const POST = apiHandler(async (req) => { + const { userId, code } = await req.json(); + + requireUserId(userId); + if (!code || typeof code !== "string") throw new ApiError("请输入房间号"); + + const room = await prisma.blindBoxRoom.findUnique({ + where: { code: code.trim().toUpperCase() }, + }); + if (!room) throw new ApiError("房间不存在,请检查房间号", 404); + + const existing = await prisma.blindBoxMember.findUnique({ + where: { roomId_userId: { roomId: room.id, userId } }, + }); + if (existing) { + return NextResponse.json({ + id: room.id, + code: room.code, + name: room.name, + alreadyMember: true, + }); + } + + await prisma.blindBoxMember.create({ + data: { roomId: room.id, userId }, + }); + + return NextResponse.json( + { id: room.id, code: room.code, name: room.name }, + { status: 201 }, + ); +}); diff --git a/src/app/api/blindbox/room/route.ts b/src/app/api/blindbox/room/route.ts new file mode 100644 index 0000000..f7db278 --- /dev/null +++ b/src/app/api/blindbox/room/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { generateUniqueRoomCode } from "@/lib/blindbox"; +import { apiHandler, requireUserId, requireUser } from "@/lib/api"; +import { validateRoomName } from "@/lib/validation"; + +export const POST = apiHandler(async (req) => { + const { userId, name } = await req.json(); + + requireUserId(userId); + + const roomName = validateRoomName(name); + + await requireUser(userId); + + const code = await generateUniqueRoomCode(); + + const room = await prisma.blindBoxRoom.create({ + data: { + code, + name: roomName, + creatorId: userId, + members: { create: { userId } }, + }, + }); + + return NextResponse.json( + { id: room.id, code: room.code, name: room.name }, + { status: 201 }, + ); +}); diff --git a/src/app/api/blindbox/rooms/route.ts b/src/app/api/blindbox/rooms/route.ts new file mode 100644 index 0000000..84400c9 --- /dev/null +++ b/src/app/api/blindbox/rooms/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiHandler, requireUserId } from "@/lib/api"; + +export const GET = apiHandler(async (req) => { + const userId = requireUserId(req.nextUrl.searchParams.get("userId")); + + const memberships = await prisma.blindBoxMember.findMany({ + where: { userId }, + include: { + room: { + include: { + members: { + include: { user: { select: { id: true, username: true, avatar: true } } }, + orderBy: { joinedAt: "asc" }, + take: 5, + }, + _count: { + select: { ideas: true, members: true }, + }, + ideas: { + where: { status: "drawn" }, + orderBy: { createdAt: "desc" }, + take: 1, + select: { content: true, createdAt: true }, + }, + }, + }, + }, + orderBy: { joinedAt: "desc" }, + }); + + const rooms = memberships.map((m) => ({ + id: m.room.id, + code: m.room.code, + name: m.room.name, + memberCount: m.room._count.members, + ideaCount: m.room._count.ideas, + poolCount: 0, + members: m.room.members.map((mb) => mb.user), + lastDrawn: m.room.ideas[0] ?? null, + joinedAt: m.joinedAt, + })); + + const roomIds = rooms.map((r) => r.id); + const poolCounts = await prisma.blindBoxIdea.groupBy({ + by: ["roomId"], + where: { roomId: { in: roomIds }, status: "in_pool" }, + _count: true, + }); + const poolMap = new Map(poolCounts.map((p) => [p.roomId, p._count])); + + for (const room of rooms) { + room.poolCount = poolMap.get(room.id) ?? 0; + } + + return NextResponse.json({ rooms }); +}); diff --git a/src/app/api/blindbox/route.ts b/src/app/api/blindbox/route.ts new file mode 100644 index 0000000..91d4a42 --- /dev/null +++ b/src/app/api/blindbox/route.ts @@ -0,0 +1,135 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireMembership } from "@/lib/blindbox"; +import { apiHandler, ApiError, requireUserId } from "@/lib/api"; +import { validateIdeaContent, requireString } from "@/lib/validation"; +import { tagIdea } from "@/lib/ai"; + +const TAG_TIMEOUT_MS = 3000; + +export const POST = apiHandler(async (req) => { + const { roomId, userId, content } = await req.json(); + + requireUserId(userId); + requireString(roomId, "roomId"); + const trimmedContent = validateIdeaContent(content); + + await requireMembership(roomId, userId); + + const idea = await prisma.blindBoxIdea.create({ + data: { roomId, userId, content: trimmedContent }, + }); + + const tags = await Promise.race([ + tagIdea(trimmedContent), + new Promise((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS)), + ]); + + if (tags) { + await prisma.blindBoxIdea.update({ + where: { id: idea.id }, + data: { + category: tags.category, + timeSlot: tags.timeSlot, + estimatedMinutes: tags.estimatedMinutes, + outdoor: tags.outdoor, + searchQuery: tags.searchQuery, + searchType: tags.searchType, + }, + }); + } + + return NextResponse.json( + { id: idea.id, ...tags && { tags } }, + { status: 201 }, + ); +}); + +export const GET = apiHandler(async (req) => { + const userId = requireUserId(req.nextUrl.searchParams.get("userId")); + const roomId = requireString(req.nextUrl.searchParams.get("roomId"), "roomId"); + + await requireMembership(roomId, userId); + + const [poolCount, myIdeas, drawn] = await Promise.all([ + prisma.blindBoxIdea.count({ + where: { roomId, status: "in_pool" }, + }), + prisma.blindBoxIdea.findMany({ + where: { roomId, userId, status: "in_pool" }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + content: true, + createdAt: true, + category: true, + timeSlot: true, + estimatedMinutes: true, + outdoor: true, + searchQuery: true, + searchType: true, + }, + }), + prisma.blindBoxIdea.findMany({ + where: { roomId, status: "drawn" }, + orderBy: { createdAt: "desc" }, + include: { + user: { select: { id: true, username: true, avatar: true } }, + drawnBy: { select: { id: true, username: true, avatar: true } }, + }, + }), + ]); + + return NextResponse.json({ poolCount, myIdeas, drawn }); +}); + +export const PUT = apiHandler(async (req) => { + const { ideaId, userId, content } = await req.json(); + + requireUserId(userId); + requireString(ideaId, "ideaId"); + const trimmedContent = validateIdeaContent(content); + + const { count } = await prisma.blindBoxIdea.updateMany({ + where: { id: ideaId, userId, status: "in_pool" }, + data: { content: trimmedContent }, + }); + + if (count === 0) throw new ApiError("想法不存在、已被抽中或无权编辑", 404); + + const tags = await Promise.race([ + tagIdea(trimmedContent), + new Promise((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS)), + ]); + + if (tags) { + await prisma.blindBoxIdea.updateMany({ + where: { id: ideaId, userId, status: "in_pool" }, + data: { + category: tags.category, + timeSlot: tags.timeSlot, + estimatedMinutes: tags.estimatedMinutes, + outdoor: tags.outdoor, + searchQuery: tags.searchQuery, + searchType: tags.searchType, + }, + }); + } + + return NextResponse.json({ id: ideaId, content: trimmedContent, ...tags && { tags } }); +}); + +export const DELETE = apiHandler(async (req) => { + const { ideaId, userId } = await req.json(); + + requireUserId(userId); + requireString(ideaId, "ideaId"); + + const { count } = await prisma.blindBoxIdea.deleteMany({ + where: { id: ideaId, userId, status: "in_pool" }, + }); + + if (count === 0) throw new ApiError("想法不存在、已被抽中或无权删除", 404); + + return NextResponse.json({ deleted: true }); +}); diff --git a/src/app/api/location/regeo/route.ts b/src/app/api/location/regeo/route.ts index 8225c5a..debc5b5 100644 --- a/src/app/api/location/regeo/route.ts +++ b/src/app/api/location/regeo/route.ts @@ -1,54 +1,44 @@ import { NextResponse } from "next/server"; +import { apiHandler, ApiError } from "@/lib/api"; +import { requireAmapApiKey } from "@/lib/amap"; -export async function GET(req: Request) { - const { searchParams } = new URL(req.url); - const lat = searchParams.get("lat"); - const lng = searchParams.get("lng"); +export const GET = apiHandler(async (req) => { + const lat = req.nextUrl.searchParams.get("lat"); + const lng = req.nextUrl.searchParams.get("lng"); - if (!lat || !lng) { - return NextResponse.json( - { error: "lat and lng are required" }, - { status: 400 }, - ); - } + if (!lat || !lng) throw new ApiError("lat and lng are required"); - const apiKey = process.env.AMAP_API_KEY; - if (!apiKey) { - return NextResponse.json( - { error: "AMAP_API_KEY not configured" }, - { status: 500 }, - ); - } + const apiKey = requireAmapApiKey(); + const url = new URL("https://restapi.amap.com/v3/geocode/regeo"); + url.searchParams.set("key", apiKey); + url.searchParams.set("location", `${lng},${lat}`); + url.searchParams.set("extensions", "base"); + + let data; try { - const url = new URL("https://restapi.amap.com/v3/geocode/regeo"); - url.searchParams.set("key", apiKey); - url.searchParams.set("location", `${lng},${lat}`); - url.searchParams.set("extensions", "base"); - const res = await fetch(url.toString()); - const data = await res.json(); + data = await res.json(); + } catch { + throw new ApiError("位置服务暂时不可用,请稍后重试", 503); + } - if (data.status !== "1" || !data.regeocode) { - return NextResponse.json({ name: null }); - } - - const comp = data.regeocode.addressComponent; - const district = comp?.district || comp?.city || ""; - const township = comp?.township || ""; - const neighborhood = comp?.neighborhood?.name || ""; - - const name = [district, township, neighborhood] - .filter(Boolean) - .join(" ") - .trim(); - - return NextResponse.json({ - name: name || data.regeocode.formatted_address || null, - formatted: data.regeocode.formatted_address || null, - }); - } catch (e) { - console.error("Regeo error:", e); + if (data.status !== "1" || !data.regeocode) { return NextResponse.json({ name: null }); } -} + + const comp = data.regeocode.addressComponent; + const district = comp?.district || comp?.city || ""; + const township = comp?.township || ""; + const neighborhood = comp?.neighborhood?.name || ""; + + const name = [district, township, neighborhood] + .filter(Boolean) + .join(" ") + .trim(); + + return NextResponse.json({ + name: name || data.regeocode.formatted_address || null, + formatted: data.regeocode.formatted_address || null, + }); +}); diff --git a/src/app/api/location/search/route.ts b/src/app/api/location/search/route.ts new file mode 100644 index 0000000..6462ab3 --- /dev/null +++ b/src/app/api/location/search/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { apiHandler, ApiError } from "@/lib/api"; +import { requireAmapApiKey } from "@/lib/amap"; + +interface AmapPoiV5 { + id: string; + name: string; + address?: string; + location?: string; + type?: string; + business?: { + rating?: string; + cost?: string; + tel?: string; + }; +} + +export const GET = apiHandler(async (req) => { + const keywords = req.nextUrl.searchParams.get("keywords")?.trim(); + if (!keywords) throw new ApiError("keywords 不能为空"); + + const city = req.nextUrl.searchParams.get("city")?.trim(); + const types = req.nextUrl.searchParams.get("types")?.trim(); + + const apiKey = requireAmapApiKey(); + + const url = new URL("https://restapi.amap.com/v5/place/text"); + url.searchParams.set("key", apiKey); + url.searchParams.set("keywords", keywords); + url.searchParams.set("show_fields", "business"); + url.searchParams.set("page_size", "10"); + + if (city) url.searchParams.set("region", city); + if (types) url.searchParams.set("types", types); + + let data; + try { + const res = await fetch(url.toString()); + data = await res.json(); + } catch { + throw new ApiError("位置服务暂时不可用,请稍后重试", 503); + } + + if (data.status !== "1" || !data.pois?.length) { + return NextResponse.json([]); + } + + const results = data.pois + .filter((poi: AmapPoiV5) => poi.location) + .map((poi: AmapPoiV5) => { + const [lng, lat] = (poi.location ?? "0,0").split(",").map(Number); + const ratingStr = poi.business?.rating; + const rating = ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || null : null; + const costStr = poi.business?.cost; + const cost = costStr && costStr !== "[]" && costStr !== "0" ? Number(costStr) : null; + + return { + id: poi.id, + name: poi.name, + address: poi.address || "", + lat, + lng, + rating, + cost, + }; + }); + + return NextResponse.json(results); +}); diff --git a/src/app/api/location/suggest/route.ts b/src/app/api/location/suggest/route.ts index 2771b72..35242f3 100644 --- a/src/app/api/location/suggest/route.ts +++ b/src/app/api/location/suggest/route.ts @@ -1,52 +1,47 @@ import { NextResponse } from "next/server"; +import { apiHandler, ApiError } from "@/lib/api"; +import { requireAmapApiKey } from "@/lib/amap"; -export async function GET(req: Request) { - const { searchParams } = new URL(req.url); - const keywords = searchParams.get("keywords")?.trim(); +export const GET = apiHandler(async (req) => { + const keywords = req.nextUrl.searchParams.get("keywords")?.trim(); + if (!keywords) return NextResponse.json([]); - if (!keywords) { - return NextResponse.json([]); - } - - const apiKey = process.env.AMAP_API_KEY; - if (!apiKey) { - return NextResponse.json( - { error: "AMAP_API_KEY not configured" }, - { status: 500 }, - ); + const apiKey = requireAmapApiKey(); + + const location = req.nextUrl.searchParams.get("location"); + + const url = new URL("https://restapi.amap.com/v3/assistant/inputtips"); + url.searchParams.set("key", apiKey); + url.searchParams.set("keywords", keywords); + url.searchParams.set("datatype", "poi"); + if (location) { + url.searchParams.set("location", location); } + let data; try { - const url = new URL("https://restapi.amap.com/v3/assistant/inputtips"); - url.searchParams.set("key", apiKey); - url.searchParams.set("keywords", keywords); - url.searchParams.set("datatype", "poi"); - const res = await fetch(url.toString()); - const data = await res.json(); - - if (data.status !== "1" || !data.tips) { - return NextResponse.json([]); - } - - const suggestions = data.tips - .filter((t: { location?: string }) => t.location && t.location !== "") - .slice(0, 8) - .map((t: { id: string; name: string; district?: string; address?: string; location: string }) => { - const [lng, lat] = t.location.split(",").map(Number); - return { - id: t.id, - name: t.name, - district: t.district || "", - address: t.address || "", - lat, - lng, - }; - }); - - return NextResponse.json(suggestions); - } catch (e) { - console.error("Location suggest error:", e); - return NextResponse.json([]); + data = await res.json(); + } catch { + throw new ApiError("位置服务暂时不可用,请稍后重试", 503); } -} + + if (data.status !== "1" || !data.tips) return NextResponse.json([]); + + const suggestions = data.tips + .filter((t: { location?: string }) => t.location && t.location !== "") + .slice(0, 8) + .map((t: { id: string; name: string; district?: string; address?: string; location: string }) => { + const [lng, lat] = t.location.split(",").map(Number); + return { + id: t.id, + name: t.name, + district: t.district || "", + address: t.address || "", + lat, + lng, + }; + }); + + return NextResponse.json(suggestions); +}); diff --git a/src/app/api/room/[id]/events/route.ts b/src/app/api/room/[id]/events/route.ts index 60a4348..a5fe318 100644 --- a/src/app/api/room/[id]/events/route.ts +++ b/src/app/api/room/[id]/events/route.ts @@ -1,4 +1,5 @@ import { buildRoomStatus } from "@/lib/buildRoomStatus"; +import { getRoomData } from "@/lib/store"; import { subscribe } from "@/lib/roomEvents"; export const dynamic = "force-dynamic"; @@ -9,6 +10,19 @@ export async function GET( ) { const { id } = await params; + const url = new URL(req.url); + const userId = url.searchParams.get("userId"); + + if (userId) { + const data = await getRoomData(id); + if (data && !data.users.includes(userId)) { + return new Response(JSON.stringify({ error: "not_a_member" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + } + const encoder = new TextEncoder(); const stream = new ReadableStream({ @@ -26,13 +40,18 @@ export async function GET( let alive = true; (async () => { - const status = await buildRoomStatus(id); - if (!status) { - send({ error: "room_not_found" }); + try { + const status = await buildRoomStatus(id); + if (!status) { + send({ error: "room_not_found" }); + controller.close(); + return; + } + if (alive) send(status); + } catch { + send({ error: "load_failed" }); controller.close(); - return; } - if (alive) send(status); })(); const unsubscribe = subscribe(id, async () => { diff --git a/src/app/api/room/[id]/join/route.ts b/src/app/api/room/[id]/join/route.ts index ea49960..7f7c96b 100644 --- a/src/app/api/room/[id]/join/route.ts +++ b/src/app/api/room/[id]/join/route.ts @@ -1,64 +1,33 @@ import { NextResponse } from "next/server"; import { atomicUpdateRoom } from "@/lib/store"; import { notify } from "@/lib/roomEvents"; +import { apiHandler, ApiError, requireUserId } from "@/lib/api"; -export async function POST( - req: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export const POST = apiHandler(async (req, { params }) => { const { id } = await params; + const { userId } = await req.json(); - try { - const { userId } = await req.json(); - if (!userId) { - return NextResponse.json({ error: "userId required" }, { status: 400 }); + requireUserId(userId); + + const updated = await atomicUpdateRoom(id, (data) => { + if (data.kickedUsers.includes(userId)) { + throw new ApiError("你已被移出该房间", 403); } - - const updated = await atomicUpdateRoom(id, (data) => { - if (data.kickedUsers.includes(userId)) { - throw new Error("KICKED"); - } - if (data.locked && !data.users.includes(userId)) { - throw new Error("LOCKED"); - } - if (!data.users.includes(userId)) { - data.users.push(userId); - } - return data; - }); - - if (!updated) { - return NextResponse.json( - { error: "房间不存在或已过期" }, - { status: 404 }, - ); + if (data.locked && !data.users.includes(userId)) { + throw new ApiError("房间已锁定,无法加入", 403); } - - notify(id); - - return NextResponse.json({ - roomId: id, - userCount: updated.users.length, - }); - } catch (e) { - if (e instanceof Error) { - if (e.message === "LOCKED") { - return NextResponse.json( - { error: "房间已锁定,无法加入" }, - { status: 403 }, - ); - } - if (e.message === "KICKED") { - return NextResponse.json( - { error: "你已被移出该房间" }, - { status: 403 }, - ); - } + if (!data.users.includes(userId)) { + data.users.push(userId); } - console.error("Failed to join room:", e); - return NextResponse.json( - { error: "加入房间失败" }, - { status: 500 }, - ); - } -} + return data; + }); + + if (!updated) throw new ApiError("房间不存在或已过期", 404); + + notify(id); + + return NextResponse.json({ + roomId: id, + userCount: updated.users.length, + }); +}); diff --git a/src/app/api/room/[id]/manage/route.ts b/src/app/api/room/[id]/manage/route.ts index 1219256..a806676 100644 --- a/src/app/api/room/[id]/manage/route.ts +++ b/src/app/api/room/[id]/manage/route.ts @@ -1,106 +1,67 @@ import { NextResponse } from "next/server"; import { atomicUpdateRoom } from "@/lib/store"; import { notify } from "@/lib/roomEvents"; +import { apiHandler, ApiError, requireUserId } from "@/lib/api"; -export async function POST( - req: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export const POST = apiHandler(async (req, { params }) => { const { id } = await params; + const { userId, action, targetUserId } = await req.json(); - try { - const { userId, action, targetUserId } = await req.json(); - if (!userId || !action) { - return NextResponse.json( - { error: "userId and action required" }, - { status: 400 }, - ); + requireUserId(userId); + if (!action) throw new ApiError("action required"); + + const updated = await atomicUpdateRoom(id, (data) => { + if (data.creatorId !== userId) { + throw new ApiError("只有房主可以执行此操作", 403); } - const updated = await atomicUpdateRoom(id, (data) => { - if (data.creatorId !== userId) { - throw new Error("FORBIDDEN"); - } + switch (action) { + case "lock": + data.locked = true; + break; - switch (action) { - case "lock": - data.locked = true; - break; + case "unlock": + data.locked = false; + break; - case "unlock": - data.locked = false; - break; + case "kick": + if (!targetUserId || targetUserId === userId) { + throw new ApiError("无效的操作对象"); + } + data.users = data.users.filter((u) => u !== targetUserId); + if (!data.kickedUsers.includes(targetUserId)) { + data.kickedUsers.push(targetUserId); + } + delete data.swipeCounts[targetUserId]; + for (const rid of Object.keys(data.likes)) { + data.likes[rid] = data.likes[rid].filter( + (u) => u !== targetUserId, + ); + } + if ( + data.match && + data.likes[data.match]?.length !== data.users.length + ) { + data.match = null; + } + break; - case "kick": - if (!targetUserId || targetUserId === userId) { - throw new Error("INVALID_TARGET"); - } - data.users = data.users.filter((u) => u !== targetUserId); - if (!data.kickedUsers.includes(targetUserId)) { - data.kickedUsers.push(targetUserId); - } - delete data.swipeCounts[targetUserId]; - for (const rid of Object.keys(data.likes)) { - data.likes[rid] = data.likes[rid].filter( - (u) => u !== targetUserId, - ); - } - if ( - data.match && - data.likes[data.match]?.length !== data.users.length - ) { - data.match = null; - } - break; + case "end_voting": + for (const u of data.users) { + data.swipeCounts[u] = data.restaurants.length; + } + break; - case "end_voting": - for (const u of data.users) { - data.swipeCounts[u] = data.restaurants.length; - } - break; - - default: - throw new Error("UNKNOWN_ACTION"); - } - - return data; - }); - - if (!updated) { - return NextResponse.json( - { error: "房间不存在或已过期" }, - { status: 404 }, - ); + default: + throw new ApiError("未知操作"); } - notify(id); + return data; + }); - return NextResponse.json({ ok: true }); - } catch (e) { - if (e instanceof Error) { - if (e.message === "FORBIDDEN") { - return NextResponse.json( - { error: "只有房主可以执行此操作" }, - { status: 403 }, - ); - } - if (e.message === "INVALID_TARGET") { - return NextResponse.json( - { error: "无效的操作对象" }, - { status: 400 }, - ); - } - if (e.message === "UNKNOWN_ACTION") { - return NextResponse.json( - { error: "未知操作" }, - { status: 400 }, - ); - } - } - console.error("Failed to manage room:", e); - return NextResponse.json( - { error: "操作失败" }, - { status: 500 }, - ); - } -} + if (!updated) throw new ApiError("房间不存在或已过期", 404); + + notify(id); + + return NextResponse.json({ ok: true }); +}); diff --git a/src/app/api/room/[id]/reset/route.ts b/src/app/api/room/[id]/reset/route.ts index a5272fd..031ace1 100644 --- a/src/app/api/room/[id]/reset/route.ts +++ b/src/app/api/room/[id]/reset/route.ts @@ -1,50 +1,38 @@ import { NextResponse } from "next/server"; import { atomicUpdateRoom } from "@/lib/store"; import { notify } from "@/lib/roomEvents"; +import { apiHandler, ApiError, requireUserId } from "@/lib/api"; -export async function POST( - req: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export const POST = apiHandler(async (req, { params }) => { const { id } = await params; + const body = await req.json().catch(() => ({})); + const userId = body?.userId; + requireUserId(userId); + let restaurantIds: string[] | undefined; - try { - const body = await req.json().catch(() => null); - if (body?.restaurantIds && Array.isArray(body.restaurantIds)) { - restaurantIds = body.restaurantIds; - } - } catch { - // No body or invalid JSON — plain reset + if (body?.restaurantIds && Array.isArray(body.restaurantIds)) { + restaurantIds = body.restaurantIds; } - try { - const updated = await atomicUpdateRoom(id, (data) => { - if (restaurantIds && restaurantIds.length > 0) { - const idSet = new Set(restaurantIds); - data.restaurants = data.restaurants.filter((r) => idSet.has(r.id)); - } - data.likes = {}; - data.swipeCounts = {}; - data.match = null; - return data; - }); - - if (!updated) { - return NextResponse.json( - { error: "房间不存在或已过期" }, - { status: 404 }, - ); + const updated = await atomicUpdateRoom(id, (data) => { + if (!data.users.includes(userId) && data.creatorId !== userId) { + throw new ApiError("只有房间成员可以重置", 403); } - notify(id); + if (restaurantIds && restaurantIds.length > 0) { + const idSet = new Set(restaurantIds); + data.restaurants = data.restaurants.filter((r) => idSet.has(r.id)); + } + data.likes = {}; + data.swipeCounts = {}; + data.match = null; + return data; + }); - return NextResponse.json({ ok: true }); - } catch (e) { - console.error("Failed to reset room:", e); - return NextResponse.json( - { error: "重置失败" }, - { status: 500 }, - ); - } -} + if (!updated) throw new ApiError("房间不存在或已过期", 404); + + notify(id); + + return NextResponse.json({ ok: true }); +}); diff --git a/src/app/api/room/[id]/route.ts b/src/app/api/room/[id]/route.ts index 7c2a1f5..cda1d39 100644 --- a/src/app/api/room/[id]/route.ts +++ b/src/app/api/room/[id]/route.ts @@ -1,28 +1,12 @@ import { NextResponse } from "next/server"; import { buildRoomStatus } from "@/lib/buildRoomStatus"; +import { apiHandler, ApiError } from "@/lib/api"; -export async function GET( - _req: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export const GET = apiHandler(async (_req, { params }) => { const { id } = await params; - try { - const status = await buildRoomStatus(id); + const status = await buildRoomStatus(id); + if (!status) throw new ApiError("房间不存在或已过期", 404); - if (!status) { - return NextResponse.json( - { error: "房间不存在或已过期" }, - { status: 404 }, - ); - } - - return NextResponse.json(status); - } catch (e) { - console.error("Failed to get room:", e); - return NextResponse.json( - { error: "获取房间信息失败" }, - { status: 500 }, - ); - } -} + return NextResponse.json(status); +}); diff --git a/src/app/api/room/[id]/swipe/route.ts b/src/app/api/room/[id]/swipe/route.ts index ffdedbd..88663af 100644 --- a/src/app/api/room/[id]/swipe/route.ts +++ b/src/app/api/room/[id]/swipe/route.ts @@ -1,69 +1,58 @@ import { NextResponse } from "next/server"; import { atomicUpdateRoom } from "@/lib/store"; import { notify } from "@/lib/roomEvents"; +import { apiHandler, ApiError, requireUserId } from "@/lib/api"; -export async function POST( - req: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export const POST = apiHandler(async (req, { params }) => { const { id } = await params; + const { userId, restaurantId, action } = await req.json(); - try { - const { userId, restaurantId, action } = await req.json(); + requireUserId(userId); + if (restaurantId == null || !action) { + throw new ApiError("restaurantId and action are required"); + } + if (action !== "like" && action !== "pass") { + throw new ApiError("action must be 'like' or 'pass'"); + } - if (!userId || restaurantId == null || !action) { - return NextResponse.json( - { error: "userId, restaurantId, and action are required" }, - { status: 400 }, - ); + const rid = String(restaurantId); + + const updated = await atomicUpdateRoom(id, (data) => { + if (!data.users.includes(userId)) { + throw new ApiError("你不是该房间的成员", 403); } - const rid = String(restaurantId); + const restaurantIndex = data.restaurants.findIndex((r) => r.id === rid); + const alreadySwiped = + restaurantIndex >= 0 && + restaurantIndex < (data.swipeCounts[userId] ?? 0); - const updated = await atomicUpdateRoom(id, (data) => { - const restaurantIndex = data.restaurants.findIndex((r) => r.id === rid); - const alreadySwiped = - restaurantIndex >= 0 && - restaurantIndex < (data.swipeCounts[userId] ?? 0); + if (alreadySwiped) return data; - if (alreadySwiped) return data; - - if (action === "like") { - if (!data.likes[rid]) { - data.likes[rid] = []; - } - if (!data.likes[rid].includes(userId)) { - data.likes[rid].push(userId); - } - - if (data.likes[rid].length === data.users.length) { - data.match = rid; - } + if (action === "like") { + if (!data.likes[rid]) { + data.likes[rid] = []; + } + if (!data.likes[rid].includes(userId)) { + data.likes[rid].push(userId); } - data.swipeCounts[userId] = (data.swipeCounts[userId] ?? 0) + 1; - - return data; - }); - - if (!updated) { - return NextResponse.json( - { error: "房间不存在或已过期" }, - { status: 404 }, - ); + if (data.likes[rid].length === data.users.length) { + data.match = rid; + } } - notify(id); + data.swipeCounts[userId] = (data.swipeCounts[userId] ?? 0) + 1; - return NextResponse.json({ - match: updated.match, - likeCount: updated.likes[rid]?.length ?? 0, - }); - } catch (e) { - console.error("Failed to process swipe:", e); - return NextResponse.json( - { error: "操作失败" }, - { status: 500 }, - ); - } -} + return data; + }); + + if (!updated) throw new ApiError("房间不存在或已过期", 404); + + notify(id); + + return NextResponse.json({ + match: updated.match, + likeCount: updated.likes[rid]?.length ?? 0, + }); +}); diff --git a/src/app/api/room/[id]/undo/route.ts b/src/app/api/room/[id]/undo/route.ts index 16ca7c3..8b37b18 100644 --- a/src/app/api/room/[id]/undo/route.ts +++ b/src/app/api/room/[id]/undo/route.ts @@ -1,60 +1,44 @@ import { NextResponse } from "next/server"; import { atomicUpdateRoom } from "@/lib/store"; import { notify } from "@/lib/roomEvents"; +import { apiHandler, ApiError, requireUserId } from "@/lib/api"; -export async function POST( - req: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export const POST = apiHandler(async (req, { params }) => { const { id } = await params; + const { userId, restaurantId } = await req.json(); - try { - const { userId, restaurantId } = await req.json(); + requireUserId(userId); + if (restaurantId == null) throw new ApiError("restaurantId is required"); - if (!userId || restaurantId == null) { - return NextResponse.json( - { error: "userId and restaurantId are required" }, - { status: 400 }, - ); + const rid = String(restaurantId); + + const updated = await atomicUpdateRoom(id, (data) => { + if (!data.users.includes(userId)) { + throw new ApiError("你不是该房间的成员", 403); } - const rid = String(restaurantId); - - const updated = await atomicUpdateRoom(id, (data) => { - if (data.likes[rid]) { - data.likes[rid] = data.likes[rid].filter((u) => u !== userId); - if (data.likes[rid].length === 0) { - delete data.likes[rid]; - } + if (data.likes[rid]) { + data.likes[rid] = data.likes[rid].filter((u) => u !== userId); + if (data.likes[rid].length === 0) { + delete data.likes[rid]; } - - if (data.match === rid) { - data.match = null; - } - - const count = data.swipeCounts[userId] ?? 0; - if (count > 0) { - data.swipeCounts[userId] = count - 1; - } - - return data; - }); - - if (!updated) { - return NextResponse.json( - { error: "房间不存在或已过期" }, - { status: 404 }, - ); } - notify(id); + if (data.match === rid) { + data.match = null; + } - return NextResponse.json({ ok: true }); - } catch (e) { - console.error("Failed to undo swipe:", e); - return NextResponse.json( - { error: "撤回失败" }, - { status: 500 }, - ); - } -} + const count = data.swipeCounts[userId] ?? 0; + if (count > 0) { + data.swipeCounts[userId] = count - 1; + } + + return data; + }); + + if (!updated) throw new ApiError("房间不存在或已过期", 404); + + notify(id); + + return NextResponse.json({ ok: true }); +}); diff --git a/src/app/api/room/create/route.ts b/src/app/api/room/create/route.ts index d4c0921..78ead33 100644 --- a/src/app/api/room/create/route.ts +++ b/src/app/api/room/create/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from "next/server"; import { createRoom } from "@/lib/store"; import { Restaurant, SceneType } from "@/types"; import { getSceneConfig } from "@/lib/sceneConfig"; +import { apiHandler, ApiError } from "@/lib/api"; +import { requireAmapApiKey } from "@/lib/amap"; interface AmapPoiV5 { id: string; @@ -101,78 +103,63 @@ function filterByPrice( }); } -export async function POST(req: Request) { - try { - const body = await req.json(); - const { - lat, - lng, - radius = 3000, - priceRange = "any", - cuisine = "不限", - userId = "", - scene = "eat" as SceneType, - } = body; +export const POST = apiHandler(async (req) => { + const body = await req.json(); + const { + lat, + lng, + radius = 3000, + priceRange = "any", + cuisine = "不限", + userId = "", + scene = "eat" as SceneType, + } = body; - const sceneConfig = getSceneConfig(scene === "drink" ? "drink" : "eat"); + const sceneConfig = getSceneConfig(scene); - if (!lat || !lng) { - return NextResponse.json( - { error: "无法获取位置信息,请允许定位权限后重试" }, - { status: 400 }, - ); - } - - const apiKey = process.env.AMAP_API_KEY; - if (!apiKey) { - console.error("AMAP_API_KEY not configured"); - return NextResponse.json( - { error: "服务配置异常,请稍后重试" }, - { status: 500 }, - ); - } - - const url = new URL("https://restapi.amap.com/v5/place/around"); - url.searchParams.set("key", apiKey); - url.searchParams.set("location", `${lng},${lat}`); - url.searchParams.set("radius", String(radius)); - url.searchParams.set("types", sceneConfig.poiTypes); - url.searchParams.set("show_fields", "business,photos"); - url.searchParams.set("sortrule", "weight"); - - const needsPriceFilter = priceRange !== "any"; - url.searchParams.set("page_size", needsPriceFilter ? "25" : "15"); - - if (cuisine && cuisine !== "不限") { - url.searchParams.set("keywords", cuisine); - } - - const amapRes = await fetch(url.toString()); - const amapData = await amapRes.json(); - - let restaurants: Restaurant[] = []; - if (amapData.status === "1" && amapData.pois?.length > 0) { - let results: Restaurant[] = amapData.pois.map( - (poi: AmapPoiV5) => mapPoiToRestaurant(poi, sceneConfig.defaultImage), - ); - results = filterByPrice(results, priceRange); - restaurants = results.slice(0, 15); - } - - if (restaurants.length === 0) { - return NextResponse.json( - { error: sceneConfig.emptyError }, - { status: 404 }, - ); - } - - const roomId = await createRoom(restaurants, userId, sceneConfig.key); - return NextResponse.json({ roomId, restaurants }); - } catch (e) { - console.error("Failed to create room:", e); - return NextResponse.json( - { error: "搜索失败,请检查网络后重试" }, - { status: 500 }, - ); + const numLat = Number(lat); + const numLng = Number(lng); + if (!Number.isFinite(numLat) || !Number.isFinite(numLng) || + numLat < -90 || numLat > 90 || numLng < -180 || numLng > 180) { + throw new ApiError("无法获取位置信息,请允许定位权限后重试"); } -} + + const apiKey = requireAmapApiKey(); + + const url = new URL("https://restapi.amap.com/v5/place/around"); + url.searchParams.set("key", apiKey); + url.searchParams.set("location", `${numLng},${numLat}`); + url.searchParams.set("radius", String(radius)); + url.searchParams.set("types", sceneConfig.poiTypes); + url.searchParams.set("show_fields", "business,photos"); + url.searchParams.set("sortrule", "weight"); + + const needsPriceFilter = priceRange !== "any"; + url.searchParams.set("page_size", needsPriceFilter ? "25" : "15"); + + if (cuisine && cuisine !== "不限") { + url.searchParams.set("keywords", cuisine); + } + + let amapData; + try { + const amapRes = await fetch(url.toString()); + amapData = await amapRes.json(); + } catch { + throw new ApiError("位置服务暂时不可用,请稍后重试", 503); + } + + let restaurants: Restaurant[] = []; + if (amapData.status === "1" && amapData.pois?.length > 0) { + let results: Restaurant[] = amapData.pois.map( + (poi: AmapPoiV5) => mapPoiToRestaurant(poi, sceneConfig.defaultImage), + ); + results = filterByPrice(results, priceRange); + restaurants = results.slice(0, 15); + } + + if (restaurants.length === 0) throw new ApiError(sceneConfig.emptyError, 404); + + const roomId = await createRoom(restaurants, userId, sceneConfig.key); + return NextResponse.json({ roomId, restaurants }); +}); diff --git a/src/app/api/user/achievements/route.ts b/src/app/api/user/achievements/route.ts new file mode 100644 index 0000000..4b4a5e3 --- /dev/null +++ b/src/app/api/user/achievements/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiHandler, requireUserId } from "@/lib/api"; + +export const GET = apiHandler(async (req) => { + const userId = req.nextUrl.searchParams.get("userId"); + requireUserId(userId); + + const [decisions, contracts] = await Promise.all([ + prisma.decision.findMany({ + where: { userId: userId! }, + orderBy: { createdAt: "desc" }, + take: 50, + }), + prisma.weekendPlan.findMany({ + where: { + userId: userId!, + status: { in: ["completed", "expired"] }, + }, + orderBy: { createdAt: "desc" }, + take: 50, + select: { + id: true, + planData: true, + status: true, + roomId: true, + createdAt: true, + }, + }), + ]); + + const roomIds = [...new Set(contracts.map((c) => c.roomId))]; + const rooms = await prisma.blindBoxRoom.findMany({ + where: { id: { in: roomIds } }, + select: { id: true, name: true, code: true }, + }); + const roomMap = new Map(rooms.map((r) => [r.id, r])); + + const completedCount = contracts.filter((c) => c.status === "completed").length; + + const contractRecords = contracts.map((c) => { + const parsed = JSON.parse(c.planData); + const days = parsed.days as { date: string; items: { activity: string }[] }[]; + const room = roomMap.get(c.roomId); + return { + id: c.id, + status: c.status, + roomName: room?.name ?? "未知房间", + roomCode: room?.code ?? "", + date: days.map((d) => d.date).join(" + "), + dayCount: days.length, + activities: days.flatMap((d) => d.items.map((i) => i.activity)), + createdAt: c.createdAt.toISOString(), + }; + }); + + const decisionRecords = decisions.map((d) => ({ + id: d.id, + roomId: d.roomId, + restaurantName: d.restaurantName, + restaurantData: JSON.parse(d.restaurantData), + matchType: d.matchType, + participants: d.participants, + createdAt: d.createdAt.toISOString(), + })); + + return NextResponse.json({ + stats: { + totalDecisions: decisions.length, + totalContracts: contracts.length, + completedContracts: completedCount, + completionRate: contracts.length > 0 ? Math.round((completedCount / contracts.length) * 100) : 0, + }, + decisions: decisionRecords, + contracts: contractRecords, + }); +}); diff --git a/src/app/api/user/favorite/route.ts b/src/app/api/user/favorite/route.ts index e17a8de..0095884 100644 --- a/src/app/api/user/favorite/route.ts +++ b/src/app/api/user/favorite/route.ts @@ -1,11 +1,11 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { Prisma } from "@prisma/client"; +import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api"; -export async function GET(req: NextRequest) { +export const GET = apiHandler(async (req) => { const userId = req.nextUrl.searchParams.get("userId"); - if (!userId) { - return NextResponse.json([]); - } + if (!userId) return NextResponse.json([]); const favorites = await prisma.favorite.findMany({ where: { userId }, @@ -14,59 +14,53 @@ export async function GET(req: NextRequest) { }); return NextResponse.json( - favorites.map((f) => ({ - id: f.id, - restaurantData: JSON.parse(f.restaurantData), - createdAt: f.createdAt.toISOString(), - })), + favorites.map((f) => { + let restaurantData = {}; + try { restaurantData = JSON.parse(f.restaurantData); } catch { /* ignore */ } + return { id: f.id, restaurantData, createdAt: f.createdAt.toISOString() }; + }), ); -} +}); -export async function POST(req: NextRequest) { +export const POST = apiHandler(async (req) => { const { userId, restaurant } = await req.json(); - if (!userId || !restaurant) { - return NextResponse.json({ error: "缺少必要字段" }, { status: 400 }); + requireUserId(userId); + if (!restaurant?.id || typeof restaurant.id !== "string") { + throw new ApiError("缺少必要字段"); } - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) { - return NextResponse.json({ error: "请先设置个人资料" }, { status: 404 }); + await requireUser(userId); + + try { + const fav = await prisma.favorite.create({ + data: { + userId, + restaurantId: restaurant.id, + restaurantData: JSON.stringify(restaurant), + }, + }); + return NextResponse.json({ id: fav.id }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { + const existing = await prisma.favorite.findFirst({ + where: { userId, restaurantId: restaurant.id }, + }); + return NextResponse.json({ id: existing?.id ?? "", alreadyExists: true }); + } + throw e; } +}); - const existing = await prisma.favorite.findFirst({ - where: { - userId, - restaurantData: { contains: `"id":"${restaurant.id}"` }, - }, - }); - - if (existing) { - return NextResponse.json({ id: existing.id, alreadyExists: true }); - } - - const fav = await prisma.favorite.create({ - data: { - userId, - restaurantData: JSON.stringify(restaurant), - }, - }); - - return NextResponse.json({ id: fav.id }); -} - -export async function DELETE(req: NextRequest) { +export const DELETE = apiHandler(async (req) => { const { userId, favoriteId } = await req.json(); - if (!userId || !favoriteId) { - return NextResponse.json({ error: "缺少必要字段" }, { status: 400 }); - } + requireUserId(userId); + if (!favoriteId) throw new ApiError("缺少必要字段"); const fav = await prisma.favorite.findUnique({ where: { id: favoriteId } }); - if (!fav || fav.userId !== userId) { - return NextResponse.json({ error: "收藏不存在" }, { status: 404 }); - } + if (!fav || fav.userId !== userId) throw new ApiError("收藏不存在", 404); await prisma.favorite.delete({ where: { id: favoriteId } }); return NextResponse.json({ ok: true }); -} +}); diff --git a/src/app/api/user/history/route.ts b/src/app/api/user/history/route.ts index b5503df..b68e669 100644 --- a/src/app/api/user/history/route.ts +++ b/src/app/api/user/history/route.ts @@ -1,13 +1,12 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api"; const MAX_HISTORY = 50; -export async function GET(req: NextRequest) { +export const GET = apiHandler(async (req) => { const userId = req.nextUrl.searchParams.get("userId"); - if (!userId) { - return NextResponse.json([]); - } + if (!userId) return NextResponse.json([]); const decisions = await prisma.decision.findMany({ where: { userId }, @@ -26,20 +25,18 @@ export async function GET(req: NextRequest) { createdAt: d.createdAt.toISOString(), })), ); -} +}); -export async function POST(req: NextRequest) { +export const POST = apiHandler(async (req) => { const { userId, roomId, restaurant, matchType, participants } = await req.json(); - if (!userId || !roomId || !restaurant || !matchType) { - return NextResponse.json({ error: "缺少必要字段" }, { status: 400 }); + requireUserId(userId); + if (!roomId || !restaurant || !matchType) { + throw new ApiError("缺少必要字段"); } - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) { - return NextResponse.json({ error: "用户未注册" }, { status: 404 }); - } + await requireUser(userId); const existing = await prisma.decision.findFirst({ where: { userId, roomId }, @@ -73,4 +70,4 @@ export async function POST(req: NextRequest) { } return NextResponse.json({ id: decision.id }); -} +}); diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 60a8725..9db9ed2 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -1,68 +1,56 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { Prisma } from "@prisma/client"; import bcrypt from "bcryptjs"; +import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api"; +import { validateUsername, validatePassword, validateEmail } from "@/lib/validation"; -export async function GET(req: NextRequest) { +export const GET = apiHandler(async (req) => { const userId = req.nextUrl.searchParams.get("id"); - if (!userId) { - return NextResponse.json(null); - } + if (!userId) return NextResponse.json(null); const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) { - return NextResponse.json(null); - } + if (!user) return NextResponse.json(null); + + const decisionCount = await prisma.decision.count({ where: { userId } }); + + let preferences = {}; + try { preferences = JSON.parse(user.preferences); } catch { /* fallback */ } return NextResponse.json({ id: user.id, username: user.username, avatar: user.avatar, email: user.email, - preferences: JSON.parse(user.preferences), + preferences, createdAt: user.createdAt.toISOString(), + decisionCount, }); -} +}); -export async function PUT(req: NextRequest) { +export const PUT = apiHandler(async (req) => { const body = await req.json(); const { userId } = body; - if (!userId) { - return NextResponse.json({ error: "缺少用户 ID" }, { status: 400 }); - } - - const existing = await prisma.user.findUnique({ where: { id: userId } }); - if (!existing) { - return NextResponse.json({ error: "用户不存在" }, { status: 404 }); - } + requireUserId(userId); + const existing = await requireUser(userId); const updateData: Record = {}; if (body.username !== undefined) { - const trimmed = body.username.trim(); - if (trimmed.length < 2 || trimmed.length > 16) { - return NextResponse.json({ error: "用户名需要 2-16 个字符" }, { status: 400 }); - } + const trimmed = validateUsername(body.username); if (trimmed !== existing.username) { const taken = await prisma.user.findUnique({ where: { username: trimmed } }); - if (taken) { - return NextResponse.json({ error: "用户名已被占用" }, { status: 409 }); - } + if (taken) throw new ApiError("用户名已被占用", 409); } updateData.username = trimmed; } if (body.newPassword !== undefined) { - if (!body.currentPassword) { - return NextResponse.json({ error: "请输入当前密码" }, { status: 400 }); - } + if (!body.currentPassword) throw new ApiError("请输入当前密码"); const valid = await bcrypt.compare(body.currentPassword, existing.passwordHash); - if (!valid) { - return NextResponse.json({ error: "当前密码错误" }, { status: 403 }); - } - if (body.newPassword.length < 6) { - return NextResponse.json({ error: "新密码至少 6 个字符" }, { status: 400 }); - } + if (!valid) throw new ApiError("当前密码错误", 403); + validatePassword(body.newPassword, "新密码"); updateData.passwordHash = await bcrypt.hash(body.newPassword, 10); } @@ -71,9 +59,7 @@ export async function PUT(req: NextRequest) { } if (body.email !== undefined) { - if (body.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) { - return NextResponse.json({ error: "邮箱格式不正确" }, { status: 400 }); - } + if (body.email) validateEmail(body.email); updateData.email = body.email || null; } @@ -81,16 +67,26 @@ export async function PUT(req: NextRequest) { updateData.preferences = JSON.stringify(body.preferences); } - const user = await prisma.user.update({ - where: { id: userId }, - data: updateData, - }); + try { + const user = await prisma.user.update({ + where: { id: userId }, + data: updateData, + }); - return NextResponse.json({ - id: user.id, - username: user.username, - avatar: user.avatar, - email: user.email, - preferences: JSON.parse(user.preferences), - }); -} + let prefs = {}; + try { prefs = JSON.parse(user.preferences); } catch { /* fallback */ } + + return NextResponse.json({ + id: user.id, + username: user.username, + avatar: user.avatar, + email: user.email, + preferences: prefs, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { + throw new ApiError("用户名已被占用", 409); + } + throw e; + } +}); diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx new file mode 100644 index 0000000..938f4bf --- /dev/null +++ b/src/app/blindbox/[code]/page.tsx @@ -0,0 +1,1023 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { motion, AnimatePresence, useAnimation } from "framer-motion"; +import { + ArrowLeft, + Send, + Loader2, + Package, + Flame, + Users, + Share2, + LogIn, + Copy, + Trash2, + LogOut, + MapPin, + Calendar, + Sparkles, + ClipboardCheck, + ChevronRight, +} from "lucide-react"; +import confetti from "canvas-confetti"; +import { getCachedProfile, isRegistered } from "@/lib/userId"; +import ShareCardModal from "@/components/ShareCardModal"; +import Button from "@/components/Button"; +import BlindboxMyIdeas, { type MyIdea } from "@/components/BlindboxMyIdeas"; +import BlindboxDrawnHistory, { type DrawnIdea } from "@/components/BlindboxDrawnHistory"; +import WeekendTimeSelector from "@/components/WeekendTimeSelector"; +import BlindboxPlan from "@/components/BlindboxPlan"; +import ContractCompletionModal, { type PendingContract } from "@/components/ContractCompletionModal"; +import { useToast } from "@/hooks/useToast"; +import { useShare } from "@/hooks/useShare"; +import { BlindboxRoomSkeleton } from "@/components/Skeleton"; +import type { UserProfile, WeekendPlanData } from "@/types"; + +interface RoomInfo { + id: string; + code: string; + name: string; + creatorId: string; + city: string | null; + lat: number | null; + lng: number | null; + poolCount: number; + members: { id: string; username: string; avatar: string }[]; +} + +type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal"; + +export default function BlindboxRoomPage() { + const { code } = useParams<{ code: string }>(); + const router = useRouter(); + + const [profile, setProfile] = useState(null); + const [room, setRoom] = useState(null); + const [isMember, setIsMember] = useState(false); + const [joiningRoom, setJoiningRoom] = useState(false); + const [pageLoading, setPageLoading] = useState(true); + + const [input, setInput] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [poolCount, setPoolCount] = useState(0); + const [myIdeas, setMyIdeas] = useState([]); + const [drawnHistory, setDrawnHistory] = useState([]); + const [phase, setPhase] = useState("pool"); + const [revealedIdea, setRevealedIdea] = useState(null); + const [submitFlash, setSubmitFlash] = useState(false); + const [error, setError] = useState(""); + const [showInvite, setShowInvite] = useState(false); + const [showShareCard, setShowShareCard] = useState(false); + const toast = useToast(); + const [confirmLeave, setConfirmLeave] = useState(false); + const [leaving, setLeaving] = useState(false); + const [locating, setLocating] = useState(false); + const [planId, setPlanId] = useState(null); + const [planDays, setPlanDays] = useState([]); + const [planAccepted, setPlanAccepted] = useState(false); + const [generating, setGenerating] = useState(false); + const [showPlanShareCard, setShowPlanShareCard] = useState(false); + const [activeContract, setActiveContract] = useState<{ + id: string; + days: WeekendPlanData[]; + endTime: string | null; + } | null>(null); + const [pendingContracts, setPendingContracts] = useState([]); + + const boxControls = useAnimation(); + const inputRef = useRef(null); + const timersRef = useRef[]>([]); + const confettiAliveRef = useRef(false); + + useEffect(() => { + return () => { + timersRef.current.forEach(clearTimeout); + confettiAliveRef.current = false; + }; + }, []); + + useEffect(() => { + if (!isRegistered()) { + router.replace("/blindbox"); + return; + } + setProfile(getCachedProfile()); + }, [router]); + + const fetchRoom = useCallback(async () => { + if (!code) return; + try { + const res = await fetch(`/api/blindbox/room/${code}`); + if (!res.ok) { + router.replace("/blindbox"); + return; + } + const data: RoomInfo = await res.json(); + setRoom(data); + + const p = getCachedProfile(); + const memberCheck = data.members.some((m) => m.id === p?.id); + setIsMember(memberCheck); + setPoolCount(data.poolCount); + } catch { + router.replace("/blindbox"); + } finally { + setPageLoading(false); + } + }, [code, router]); + + useEffect(() => { + fetchRoom(); + }, [fetchRoom]); + + const fetchIdeas = useCallback(async () => { + const p = getCachedProfile(); + if (!room || !p) return; + try { + const res = await fetch(`/api/blindbox?roomId=${room.id}&userId=${p.id}`); + if (res.ok) { + const data = await res.json(); + setPoolCount(data.poolCount ?? 0); + setMyIdeas(data.myIdeas ?? []); + setDrawnHistory(data.drawn ?? []); + } + } catch { /* ignore */ } + }, [room]); + + const fetchAcceptedPlan = useCallback(async () => { + const p = getCachedProfile(); + if (!room || !p) return; + try { + const res = await fetch(`/api/blindbox/plan?mode=latest&roomId=${room.id}&userId=${p.id}`); + if (!res.ok) return; + const data = await res.json(); + if (data.plan) { + setActiveContract({ + id: data.plan.id, + days: data.plan.days, + endTime: data.plan.endTime ?? null, + }); + } + } catch { /* ignore */ } + }, [room]); + + useEffect(() => { + if (isMember && room) { + fetchIdeas(); + fetchAcceptedPlan(); + } + }, [isMember, room, fetchIdeas, fetchAcceptedPlan]); + + // Check for expired contracts on load + useEffect(() => { + const p = getCachedProfile(); + if (!isMember || !p) return; + (async () => { + try { + const res = await fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`); + if (!res.ok) return; + const data = await res.json(); + if (data.pending?.length) setPendingContracts(data.pending); + } catch { /* ignore */ } + })(); + }, [isMember]); + + // Browser notification timer for active contract + useEffect(() => { + if (!activeContract?.endTime) return; + const end = new Date(activeContract.endTime).getTime(); + const now = Date.now(); + const ms = end - now; + if (ms <= 0) return; + + if (typeof Notification !== "undefined" && Notification.permission === "default") { + Notification.requestPermission(); + } + + const timer = setTimeout(() => { + if (typeof Notification !== "undefined" && Notification.permission === "granted") { + const n = new Notification("周末契约到期", { + body: "你的周末契约已结束,完成了吗?", + icon: "/icon-192x192.png", + }); + n.onclick = () => { window.focus(); n.close(); }; + } + // Refresh pending contracts + const p = getCachedProfile(); + if (p) { + fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`) + .then((r) => r.json()) + .then((d) => { if (d.pending?.length) setPendingContracts(d.pending); }) + .catch(() => {}); + } + }, ms); + + return () => clearTimeout(timer); + }, [activeContract?.endTime]); + + useEffect(() => { + if (isMember && inputRef.current) { + const t = setTimeout(() => inputRef.current?.focus(), 300); + timersRef.current.push(t); + } + }, [isMember]); + + const handleJoinRoom = async () => { + if (joiningRoom || !profile || !room) return; + setJoiningRoom(true); + try { + const res = await fetch("/api/blindbox/room/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: profile.id, code }), + }); + if (res.ok) { + setIsMember(true); + fetchRoom(); + } + } catch { /* ignore */ } + finally { setJoiningRoom(false); } + }; + + const handleSetLocation = useCallback(async () => { + if (locating || !profile || !room) return; + setLocating(true); + try { + const pos = await new Promise((resolve, reject) => + navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }), + ); + const { latitude: lat, longitude: lng } = pos.coords; + const regeoRes = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`); + const regeo = regeoRes.ok ? await regeoRes.json() : {}; + const cityName = regeo.name || "未知位置"; + + const patchRes = await fetch(`/api/blindbox/room/${room.code}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: profile.id, city: cityName, lat, lng }), + }); + if (!patchRes.ok) throw new Error("保存位置失败"); + + setRoom((prev) => prev ? { ...prev, city: cityName, lat, lng } : prev); + toast.show("位置已设置"); + } catch { + toast.show("获取位置失败,请允许定位权限"); + } finally { + setLocating(false); + } + }, [locating, profile, room, toast]); + + const handleGeneratePlan = useCallback(async (timeConfig: { date: string; startHour: number; endHour: number }) => { + if (generating || !profile || !room) return; + setGenerating(true); + setPhase("planning"); + setError(""); + try { + const res = await fetch("/api/blindbox/plan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + roomId: room.id, + userId: profile.id, + availableTime: timeConfig, + }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "生成失败"); + } + const data = await res.json(); + setPlanId(data.id); + setPlanDays(data.days); + setPlanAccepted(false); + setPhase("plan_reveal"); + fireConfetti(); + } catch (e) { + setError(e instanceof Error ? e.message : "生成计划失败"); + setPhase("pool"); + } finally { + setGenerating(false); + } + }, [generating, profile, room]); + + const handleSubmit = async () => { + const text = input.trim(); + if (!text || submitting || !profile || !room) return; + setSubmitting(true); + setError(""); + try { + const res = await fetch("/api/blindbox", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ roomId: room.id, userId: profile.id, content: text }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "提交失败"); + } + const data = await res.json(); + setInput(""); + setPoolCount((c) => c + 1); + setMyIdeas((prev) => [{ + id: data.id, + content: text, + createdAt: new Date().toISOString(), + ...data.tags && { + category: data.tags.category, + timeSlot: data.tags.timeSlot, + estimatedMinutes: data.tags.estimatedMinutes, + outdoor: data.tags.outdoor, + searchQuery: data.tags.searchQuery, + searchType: data.tags.searchType, + }, + }, ...prev]); + setSubmitFlash(true); + timersRef.current.push(setTimeout(() => setSubmitFlash(false), 600)); + boxControls.start({ + scale: [1, 1.08, 1], + rotate: [0, -3, 3, 0], + transition: { duration: 0.5 }, + }); + } catch (e) { + setError(e instanceof Error ? e.message : "提交失败"); + } finally { + setSubmitting(false); + } + }; + + const handleEditIdea = useCallback(async (ideaId: string, newContent: string) => { + if (!profile) return; + const trimmed = newContent.trim(); + if (!trimmed || trimmed.length > 200) return; + try { + const res = await fetch("/api/blindbox", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ideaId, userId: profile.id, content: trimmed }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "编辑失败"); + } + const data = await res.json(); + setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? { + ...i, + content: trimmed, + ...data.tags && { + category: data.tags.category, + timeSlot: data.tags.timeSlot, + estimatedMinutes: data.tags.estimatedMinutes, + outdoor: data.tags.outdoor, + searchQuery: data.tags.searchQuery, + searchType: data.tags.searchType, + }, + } : i))); + } catch (e) { + toast.show(e instanceof Error ? e.message : "编辑失败"); + } + }, [profile, toast]); + + const handleDeleteIdea = useCallback(async (ideaId: string) => { + if (!profile) return; + try { + const res = await fetch("/api/blindbox", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ideaId, userId: profile.id }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "删除失败"); + } + setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId)); + setPoolCount((c) => Math.max(0, c - 1)); + } catch (e) { + toast.show(e instanceof Error ? e.message : "删除失败"); + } + }, [profile]); + + const handleDraw = async () => { + if (poolCount === 0 || !profile || !room) { + setError("盒子是空的,先往里面塞点想法吧!"); + return; + } + + setPhase("shaking"); + setError(""); + + await boxControls.start({ + rotate: [0, -8, 8, -10, 10, -12, 12, -8, 8, -4, 4, 0], + scale: [1, 1.05, 0.95, 1.08, 0.92, 1.1, 0.9, 1.05, 0.95, 1], + transition: { duration: 2.5, ease: "easeInOut" }, + }); + + try { + const res = await fetch("/api/blindbox/draw", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ roomId: room.id, userId: profile.id }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "抽取失败"); + } + + const idea = await res.json(); + setRevealedIdea(idea); + setPhase("reveal"); + setPoolCount((c) => Math.max(0, c - 1)); + setDrawnHistory((prev) => [idea, ...prev]); + fireConfetti(); + } catch (e) { + setError(e instanceof Error ? e.message : "抽取失败"); + setPhase("pool"); + } + }; + + const fireConfetti = () => { + const colors = ["#a855f7", "#6366f1", "#ec4899", "#f59e0b", "#10b981"]; + confetti({ particleCount: 100, spread: 120, origin: { y: 0.4 }, colors, startVelocity: 45, ticks: 250 }); + confettiAliveRef.current = true; + const end = Date.now() + 3000; + const frame = () => { + if (Date.now() > end || !confettiAliveRef.current) return; + confetti({ particleCount: 3, angle: 60, spread: 55, origin: { x: 0, y: 0.6 }, colors, startVelocity: 35, ticks: 150 }); + confetti({ particleCount: 3, angle: 120, spread: 55, origin: { x: 1, y: 0.6 }, colors, startVelocity: 35, ticks: 150 }); + requestAnimationFrame(frame); + }; + timersRef.current.push(setTimeout(frame, 200)); + }; + + const { share, copyToClipboard } = useShare(); + + const handleCopyCode = useCallback( + () => room ? copyToClipboard(room.code, "房间号已复制") : undefined, + [room, copyToClipboard], + ); + + const handleShare = useCallback(() => { + if (!room) return; + const url = typeof window !== "undefined" ? `${window.location.origin}/blindbox/${room.code}` : ""; + share( + { title: `周末契约 · ${room.name}`, text: `来和我一起玩周末盲盒吧!房间号:${room.code}`, url }, + handleCopyCode, + ); + }, [room, share, handleCopyCode]); + + const isCreator = profile?.id === room?.creatorId; + + const handleLeaveOrDelete = async () => { + if (!confirmLeave) { + setConfirmLeave(true); + timersRef.current.push(setTimeout(() => setConfirmLeave(false), 3000)); + return; + } + if (leaving || !profile || !room) return; + setLeaving(true); + try { + const res = await fetch(`/api/blindbox/room/${room.code}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: profile.id }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "操作失败"); + } + router.replace("/blindbox"); + } catch (e) { + toast.show(e instanceof Error ? e.message : "操作失败"); + setConfirmLeave(false); + } finally { + setLeaving(false); + } + }; + + if (pageLoading) { + return ; + } + + if (!room) return null; + + return ( +
+ + {/* Header */} +
+ +
+

{room.name}

+
+

房间 {room.code}

+ +
+
+ + {/* Members */} +
+ {room.members.slice(0, 4).map((m) => ( +
+ {m.avatar} +
+ ))} + {room.members.length > 4 && ( +
+ +{room.members.length - 4} +
+ )} +
+ + +
+ + {/* Active contract indicator */} + {activeContract && phase !== "plan_reveal" && ( + { + setPlanId(activeContract.id); + setPlanDays(activeContract.days); + setPlanAccepted(true); + setPhase("plan_reveal"); + }} + > + + 契约进行中 + + {activeContract.days.map((d) => d.date).join(" + ")} + + + + )} + + {/* Invite panel */} + + {showInvite && ( + +
+ 房间号 + + {room.code} + +
+ + +
+ + )} + + + {/* Non-member state */} + {!isMember ? ( + + +

你还不是这个房间的成员

+ +
+ ) : ( + <> + {/* Blind Box Visual — hidden during plan phases */} + {phase !== "planning" && phase !== "plan_reveal" && ( +
+ +
+
+
+
+
+ + + + + ✨ + +
+ + + + 盒子里已有{" "} + {poolCount}{" "} + 个想法 + +
+ )} + + {/* Pool / Shaking / Reveal phases */} + + {phase === "pool" && ( + +
+ { setInput(e.target.value); setError(""); }} + onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }} + maxLength={200} + disabled={submitting} + className="h-12 flex-1 rounded-xl border-none bg-surface px-4 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600 disabled:opacity-50" + /> + +
+ +
+ +
+ + 抽一个 + + { + if (!room?.city) { + toast.show("请先点击房间名下方设置位置"); + return; + } + setPhase("time_select"); + }} + disabled={poolCount < 2} + className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-purple-600 to-indigo-600 text-sm font-black text-white shadow-lg shadow-purple-900/40 transition-shadow hover:shadow-xl hover:shadow-purple-900/50 disabled:opacity-40 disabled:shadow-none" + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.97 }} + > + + 周末计划 + +
+ + {error && ( + + {error} + + )} + + )} + + {phase === "shaking" && ( + +

+ 命运正在决定... +

+
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+ )} + + {phase === "reveal" && revealedIdea && ( + +
+
+
+
+
+ +
+

+ ✦ 周末契约 ✦ +

+ + {revealedIdea.content} + +
+ + {/* Attribution */} +
+ {revealedIdea.user && ( + + {revealedIdea.user.avatar} {revealedIdea.user.username} 投入 + + )} + {revealedIdea.drawnBy && ( + <> + · + + {revealedIdea.drawnBy.avatar} {revealedIdea.drawnBy.username} 抽中 + + + )} +
+ +

+ 此契约一旦开启,绝不反悔 +

+
+
+ +
+ + +
+ + )} + + {phase === "planning" && ( + + +
+ + +

+ AI 正在规划你的周末... +

+

搜索地点 · 优化路线 · 安排时间

+ + )} + + {phase === "plan_reveal" && planDays.length > 0 && ( + + { + setPlanAccepted(true); + fireConfetti(); + if (planId && profile) { + try { + const res = await fetch("/api/blindbox/plan", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ planId, userId: profile.id, action: "accept" }), + }); + const data = await res.json(); + setActiveContract({ + id: planId, + days: planDays, + endTime: data.endTime ?? null, + }); + } catch { /* best-effort */ } + } + toast.show("契约已接受!"); + timersRef.current.push(setTimeout(() => { + setPhase("pool"); + setPlanId(null); + setPlanDays([]); + setPlanAccepted(false); + }, 1500)); + }} + onRegenerate={() => { + setPhase("time_select"); + }} + onShare={() => setShowPlanShareCard(true)} + onBack={() => { + setPhase("pool"); + setPlanId(null); + setPlanDays([]); + setPlanAccepted(false); + }} + /> + + )} + + + {/* Time selector modal */} + + {phase === "time_select" && ( + setPhase("pool")} + loading={generating} + /> + )} + + + {myIdeas.length > 0 && phase === "pool" && ( + + )} + + {phase !== "shaking" && phase !== "planning" && ( + + )} + + )} + + {revealedIdea && room && ( + setShowShareCard(false)} + data={{ + type: "blindbox", + idea: revealedIdea.content, + submitter: revealedIdea.user ?? undefined, + drawer: revealedIdea.drawnBy ?? undefined, + roomName: room.name, + }} + /> + )} + + {planDays.length > 0 && room && ( + setShowPlanShareCard(false)} + data={{ + type: "plan", + days: planDays, + roomName: room.name, + }} + /> + )} + + {/* Leave / Delete — hidden during plan view */} + {isMember && room && phase !== "plan_reveal" && phase !== "planning" && ( + + + + )} + +
+ + {/* Contract expiration check modal */} + {pendingContracts.length > 0 && profile && ( + { + setPendingContracts([]); + setActiveContract(null); + }} + /> + )} +
+ ); +} diff --git a/src/app/blindbox/page.tsx b/src/app/blindbox/page.tsx new file mode 100644 index 0000000..02e0575 --- /dev/null +++ b/src/app/blindbox/page.tsx @@ -0,0 +1,493 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; +import { + ArrowLeft, + Package, + Plus, + LogIn, + Users, + Sparkles, + ChevronRight, +} from "lucide-react"; +import { getCachedProfile, isRegistered } from "@/lib/userId"; +import AuthModal from "@/components/AuthModal"; +import Button from "@/components/Button"; +import Input from "@/components/Input"; +import { BlindboxListSkeleton } from "@/components/Skeleton"; +import type { UserProfile } from "@/types"; + +interface RoomSummary { + id: string; + code: string; + name: string; + memberCount: number; + poolCount: number; + members: { id: string; username: string; avatar: string }[]; + lastDrawn: { content: string; createdAt: string } | null; +} + +export default function BlindboxLobbyPage() { + const router = useRouter(); + const [hydrated, setHydrated] = useState(false); + const [loggedIn, setLoggedIn] = useState(false); + const [profile, setProfile] = useState(null); + const [showAuth, setShowAuth] = useState(false); + const [rooms, setRooms] = useState([]); + const [loading, setLoading] = useState(true); + + const [createName, setCreateName] = useState(""); + const [creating, setCreating] = useState(false); + const [joinCode, setJoinCode] = useState(""); + const [joining, setJoining] = useState(false); + const [error, setError] = useState(""); + const [loadError, setLoadError] = useState(false); + + useEffect(() => { + const registered = isRegistered(); + setLoggedIn(registered); + if (registered) { + setProfile(getCachedProfile()); + } + setHydrated(true); + }, []); + + useEffect(() => { + const handler = () => { + const registered = isRegistered(); + setLoggedIn(registered); + setProfile(registered ? getCachedProfile() : null); + }; + window.addEventListener("nowhatever_auth", handler); + return () => window.removeEventListener("nowhatever_auth", handler); + }, []); + + const fetchRooms = useCallback(async () => { + const p = getCachedProfile(); + if (!p) return; + setLoading(true); + setLoadError(false); + try { + const res = await fetch(`/api/blindbox/rooms?userId=${p.id}`); + if (!res.ok) throw new Error(); + const data = await res.json(); + setRooms(Array.isArray(data.rooms) ? data.rooms : []); + } catch { + setLoadError(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (loggedIn) fetchRooms(); + else setLoading(false); + }, [loggedIn, fetchRooms]); + + const handleAuth = (p: UserProfile) => { + setProfile(p); + setLoggedIn(true); + setShowAuth(false); + }; + + const handleCreate = async () => { + if (creating || !profile) return; + setCreating(true); + setError(""); + try { + const res = await fetch("/api/blindbox/room", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: profile.id, name: createName.trim() || undefined }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + router.push(`/blindbox/${data.code}`); + } catch (e) { + setError(e instanceof Error ? e.message : "创建失败"); + } finally { + setCreating(false); + } + }; + + const handleJoin = async () => { + if (joining || !profile || !joinCode.trim()) return; + setJoining(true); + setError(""); + try { + const res = await fetch("/api/blindbox/room/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: profile.id, code: joinCode.trim() }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + router.push(`/blindbox/${data.code}`); + } catch (e) { + setError(e instanceof Error ? e.message : "加入失败"); + } finally { + setJoining(false); + } + }; + + return ( +
+ {/* Ambient */} +
+
+ + {/* Back button */} + + + {/* Header */} + +
+ +
+
+

+ 周末契约 +

+

+ ADVENTURE ROULETTE +

+
+
+ + + 平日蓄水,周末开奖。把所有"想做但一直没做"的事,交给命运来决定。 + + + +
+
+ +
+ 塞入想法 +
+ + + +
+
+ +
+ 周末开奖 +
+ + + +
+
+ +
+ 执行契约 +
+
+ + + {!hydrated || (loggedIn && loading) ? ( + + ) : !loggedIn ? ( + /* ============ Layer 1: Unauthenticated — Login CTA ============ */ + + setShowAuth(true)} + className="flex h-12 w-full max-w-xs items-center justify-center gap-2 rounded-2xl bg-linear-to-r from-purple-600 to-indigo-600 text-sm font-bold text-white shadow-lg shadow-purple-900/40 transition-shadow hover:shadow-xl hover:shadow-purple-900/50" + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.97 }} + > + + 登录 / 注册 + + +

+ 10 秒注册,无需手机号 +

+
+ ) : loadError ? ( + + +

加载房间失败

+ +
+ ) : rooms.length === 0 ? ( + /* ============ Layer 2: Logged in, no rooms — Create first ============ */ + + +
+ + + +

还没有盲盒房间

+

+ 创建第一个房间,邀请 TA 一起玩 +

+ + {/* Inline create form */} +
+
+ { + setCreateName(e.target.value.slice(0, 30)); + setError(""); + }} + onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} + maxLength={30} + size="xl" + variant="purple" + className="flex-1" + /> + +
+ + {/* Join alternative */} +
+
+ 或输入房间号加入 +
+
+ +
+ { + setJoinCode(e.target.value.toUpperCase().slice(0, 6)); + setError(""); + }} + onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }} + maxLength={6} + size="xl" + variant="purple" + className="flex-1 text-center font-mono tracking-[0.15em]" + /> + +
+ + {error && ( + + {error} + + )} +
+ + ) : ( + /* ============ Layer 3: Logged in, has rooms — Room list ============ */ + + {/* Create row */} +
+ { + setCreateName(e.target.value.slice(0, 30)); + setError(""); + }} + onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} + maxLength={30} + size="lg" + variant="purple" + className="flex-1" + /> + +
+ + {/* Join row */} +
+ { + setJoinCode(e.target.value.toUpperCase().slice(0, 6)); + setError(""); + }} + onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }} + maxLength={6} + size="lg" + variant="purple" + className="flex-1 text-center font-mono tracking-[0.15em] placeholder:font-sans placeholder:tracking-normal" + /> + +
+ + {error && ( + + {error} + + )} + + {/* Room list */} +
+ {rooms.map((room, i) => ( + router.push(`/blindbox/${room.code}`)} + className="group flex w-full items-center gap-3 rounded-2xl bg-surface p-4 text-left ring-1 ring-border transition-all hover:bg-elevated hover:ring-purple-500/30" + initial={{ opacity: 0, x: -20 }} + animate={{ opacity: 1, x: 0 }} + transition={{ delay: i * 0.06 }} + whileTap={{ scale: 0.98 }} + > + {/* Icon */} +
+ +
+ + {/* Info */} +
+

{room.name}

+
+ + + {room.memberCount} + + + + {room.poolCount} 待抽 + +
+ {room.lastDrawn && ( +

+ 最近抽中:{room.lastDrawn.content} +

+ )} +
+ + {/* Members preview */} +
+ {room.members.slice(0, 3).map((m) => ( +
+ {m.avatar} +
+ ))} + {room.memberCount > 3 && ( +
+ +{room.memberCount - 3} +
+ )} +
+ + +
+ ))} +
+
+ )} + + + {/* Auth Modal */} + setShowAuth(false)} + onAuth={handleAuth} + defaultTab="register" + /> +
+ ); +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..0c34e22 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect } from "react"; +import { motion } from "framer-motion"; +import { AlertTriangle, RotateCcw, Home } from "lucide-react"; +import Button from "@/components/Button"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("[ErrorBoundary]", error); + }, [error]); + + return ( +
+ + +
+ + + +

出了点问题

+

+ 页面遇到了意外错误,请重试或返回首页 +

+ +
+ + +
+ +
+ ); +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..6cfba47 --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("[GlobalError]", error); + }, [error]); + + return ( + + +
+
⚠️
+

应用崩溃了

+

+ 发生了严重错误,请尝试刷新页面 +

+ +
+ + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 22f9ba3..3bd166d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,13 +1,55 @@ @import "tailwindcss"; :root { - --background: #f8f9fa; - --foreground: #171717; + --background: #030712; + --foreground: #f3f4f6; + --surface: #111827; + --elevated: #1f2937; + --inset: #0a0f1a; + --border: #1f2937; + --subtle: #374151; + --muted: #6b7280; + --dim: #4b5563; + --accent: #10b981; + --accent-hover: #059669; + --heading: #ffffff; + --secondary: #d1d5db; + --tertiary: #9ca3af; +} + +[data-theme="light"] { + --background: #f8fafc; + --foreground: #1e293b; + --surface: #ffffff; + --elevated: #f1f5f9; + --inset: #e2e8f0; + --border: #e2e8f0; + --subtle: #cbd5e1; + --muted: #64748b; + --dim: #94a3b8; + --accent: #059669; + --accent-hover: #047857; + --heading: #0f172a; + --secondary: #334155; + --tertiary: #64748b; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --color-surface: var(--surface); + --color-elevated: var(--elevated); + --color-inset: var(--inset); + --color-border: var(--border); + --color-subtle: var(--subtle); + --color-muted: var(--muted); + --color-dim: var(--dim); + --color-accent: var(--accent); + --color-accent-hover: var(--accent-hover); + --color-heading: var(--heading); + --color-secondary: var(--secondary); + --color-tertiary: var(--tertiary); + --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } @@ -39,3 +81,8 @@ body { from { opacity: 1; } to { opacity: 0; } } + +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} diff --git a/src/app/invite/[id]/page.tsx b/src/app/invite/[id]/page.tsx index 84ef7e8..a7ae3e2 100644 --- a/src/app/invite/[id]/page.tsx +++ b/src/app/invite/[id]/page.tsx @@ -4,15 +4,15 @@ import { useEffect, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { motion } from "framer-motion"; import { - Utensils, Users, Heart, Sparkles, ChevronRight, - Loader2, - Coffee, } from "lucide-react"; import { getUserId } from "@/lib/userId"; +import { joinRoom } from "@/lib/room"; +import { Skeleton, SkeletonCircle } from "@/components/Skeleton"; +import Button from "@/components/Button"; import { getSceneConfig } from "@/lib/sceneConfig"; import type { SceneType } from "@/types"; @@ -27,6 +27,7 @@ export default function InvitePage() { const [userCount, setUserCount] = useState(0); const [scene, setScene] = useState("eat"); const [joining, setJoining] = useState(false); + const [joinError, setJoinError] = useState(""); const sceneConfig = getSceneConfig(scene); @@ -46,23 +47,35 @@ export default function InvitePage() { const handleJoin = async () => { setJoining(true); + setJoinError(""); try { - const userId = getUserId(); - await fetch(`/api/room/${roomId}/join`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId }), - }); + await joinRoom(roomId, getUserId()); router.push(`/room/${roomId}`); - } catch { + } catch (e) { + setJoinError(e instanceof Error ? e.message : "加入失败,请重试"); setJoining(false); } }; if (status === "loading") { return ( -
-
+
+ + + +
+
+ + + +
+
+
+ + + +
+
); } @@ -70,62 +83,59 @@ export default function InvitePage() { if (status === "not_found") { return (
-
- +
+
-

房间不存在

-

+

房间不存在

+

这个房间已过期或不存在,请让朋友重新分享链接

- +
); } return ( -
+
-
- {scene === "drink" ? : } +
+ {sceneConfig.emoji}
-

+

NoWhatever

-

+

别说随便

-

+

{sceneConfig.inviteText}

-
- +
+ {roomId}
{userCount > 0 && ( -
+
- 已有 {userCount} 人在房间 + 已有 {userCount} 人在房间
)} @@ -138,35 +148,35 @@ export default function InvitePage() { transition={{ duration: 0.5, delay: 0.2 }} >
-
- +
+
- 加入房间 - + 加入房间 + 和朋友一起
- +
-
- +
+
- 各自滑卡 - + 各自滑卡 + 右滑喜欢的店
- +
-
- +
+
- 匹配结果 - + 匹配结果 + 滑中同一家就去
@@ -178,20 +188,24 @@ export default function InvitePage() { animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.5, delay: 0.3 }} > - + 加入房间 +
); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 216ae40..e622b2d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,10 @@ import type { Metadata, Viewport } from "next"; import { Geist } from "next/font/google"; import "./globals.css"; +import GlobalUserBadge from "@/components/GlobalUserBadge"; + +import PageTransition from "@/components/PageTransition"; +import ToastProvider from "@/components/ToastProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -18,17 +22,28 @@ export const viewport: Viewport = { initialScale: 1, maximumScale: 1, userScalable: false, + viewportFit: "cover", + themeColor: "#10b981", }; +const themeScript = `(function(){try{var t=localStorage.getItem("nowhatever-theme")||"system";var r=t;if(t==="system")r=window.matchMedia("(prefers-color-scheme:light)").matches?"light":"dark";document.documentElement.setAttribute("data-theme",r)}catch(e){}})()`; + export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( - + + +