merge: dev → main (v2.0 周末契约 + 全站重构)

This commit is contained in:
2026-02-27 10:48:13 +08:00
106 changed files with 9721 additions and 2626 deletions
+55
View File
@@ -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.
+59
View File
@@ -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 }`
+125
View File
@@ -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 P2002user 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 statecatch 中捕获并渲染错误消息
---
## 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/catchfallback {}
- [x] **#20 ShareCardModal data 每次新引用触发 useEffect** ✅
- 依赖改为 imageSrc 字符串而非整个 data 对象
- [x] **#21 BlindboxRoomPage 多处 setTimeout 未清理** ✅
- timersRef 统一收集所有 setTimeoutunmount 时批量清理;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() 新建** ✅
- 缓存复用单个 AudioContextstate === "closed" 时才重建
- [x] **#29 ApiError.name 是 "Error" 而非 "ApiError"** ✅
- 已在 High 批次修复
- [x] **#30 blindbox lobby 加载房间失败静默无提示** ✅
- 新增 loadError state,失败时显示"加载失败 / 点击重试"
- [x] **#31 theme localStorage 读取不校验合法值** ✅
- 已在 Critical 批次修复(VALID_THEMES 白名单校验)
Vendored
+2
View File
@@ -4,6 +4,7 @@ pipeline {
environment {
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
"""
+69 -14
View File
@@ -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 类型定义
```
+111
View File
@@ -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 或类似)
+30 -1
View File
@@ -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": {
+2
View File
@@ -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",
@@ -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");
@@ -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");
+85 -2
View File
@@ -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])
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+37
View File
@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" rx="${radius}" fill="${BG_DARK}"/>
<text x="50%" y="54%" text-anchor="middle" dominant-baseline="central"
font-family="system-ui,-apple-system,sans-serif" font-weight="700"
font-size="${fontSize}" fill="${ACCENT}">NW</text>
</svg>`;
}
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.");
+274
View File
@@ -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<string, unknown>).image;
return typeof legacy === "string" ? legacy : "";
}
export default function AchievementsPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>("decisions");
const [stats, setStats] = useState<Stats>({
totalDecisions: 0,
totalContracts: 0,
completedContracts: 0,
completionRate: 0,
});
const [decisions, setDecisions] = useState<DecisionRecord[]>([]);
const [contracts, setContracts] = useState<ContractRecord[]>([]);
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 (
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
{/* Ambient glow */}
<div className="pointer-events-none fixed left-1/2 top-0 -translate-x-1/2 -translate-y-1/3 h-[320px] w-[320px] rounded-full bg-purple-500/8 blur-3xl" />
{/* Header */}
<div className="flex w-full max-w-sm items-center gap-3">
<button
onClick={() => router.back()}
aria-label="返回"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated"
>
<ArrowLeft size={16} className="text-muted" />
</button>
<div className="flex items-center gap-2">
<Trophy size={18} className="text-amber-400" />
<h1 className="text-base font-bold text-heading"></h1>
</div>
</div>
{/* Stats */}
<motion.div
className="mt-6 grid w-full max-w-sm grid-cols-3 gap-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
{statCards.map((s) => (
<div
key={s.label}
className="flex flex-col items-center gap-1.5 rounded-xl bg-surface p-3 ring-1 ring-border"
>
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${s.bg}`}>
<s.icon size={16} className={s.color} />
</div>
{loading ? (
<Skeleton className="h-5 w-10" />
) : (
<p className="text-lg font-black text-heading">{s.value}</p>
)}
<p className="text-[10px] text-muted">{s.label}</p>
</div>
))}
</motion.div>
{/* Tab switcher */}
<motion.div
className="mt-6 flex w-full max-w-sm rounded-xl bg-surface p-1 ring-1 ring-border"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-colors ${
tab === t.id ? "text-heading" : "text-muted"
}`}
>
{tab === t.id && (
<motion.div
layoutId="activeTab"
className="absolute inset-0 rounded-lg bg-elevated ring-1 ring-border"
transition={{ type: "spring", damping: 25, stiffness: 350 }}
/>
)}
<t.icon size={13} className="relative z-10" />
<span className="relative z-10">{t.label}</span>
</button>
))}
</motion.div>
{/* Content */}
<div className="mt-4 w-full max-w-sm">
<AnimatePresence mode="wait">
{tab === "decisions" && (
<motion.div
key="decisions"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.2 }}
className="flex flex-col gap-2"
>
{loading ? (
<>
<RecordItemSkeleton />
<RecordItemSkeleton />
<RecordItemSkeleton />
</>
) : decisions.length === 0 ? (
<EmptyState
icon={ClipboardList}
title="还没有决策记录"
subtitle="使用极速救场后会在这里记录"
color="amber"
/>
) : (
decisions.map((d) => (
<a
key={d.id}
href={buildNavUrl(d.restaurantData)}
target="_blank"
rel="noopener noreferrer"
className="flex gap-3 rounded-xl bg-elevated p-2.5 transition-colors active:bg-subtle"
>
{firstImage(d.restaurantData) && (
<RestaurantImage
src={firstImage(d.restaurantData)}
alt={d.restaurantName}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className="truncate text-sm font-semibold text-heading">
{d.restaurantName}
</p>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
<span>
{d.matchType === "unanimous" ? "全员一致" : "最佳匹配"}
</span>
<span>{d.participants} </span>
<span>
{new Date(d.createdAt).toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
})}
</span>
</div>
</div>
</a>
))
)}
</motion.div>
)}
{tab === "contracts" && (
<motion.div
key="contracts"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
className="flex flex-col gap-2"
>
{loading ? (
<>
<RecordItemSkeleton />
<RecordItemSkeleton />
<RecordItemSkeleton />
</>
) : contracts.length === 0 ? (
<EmptyState
icon={BarChart3}
title="还没有契约记录"
subtitle="完成或过期的契约会在这里显示"
color="purple"
/>
) : (
contracts.map((c) => (
<ContractHistoryItem key={c.id} record={c} />
))
)}
</motion.div>
)}
</AnimatePresence>
</div>
<div className="h-8 shrink-0" />
</div>
);
}
+7 -12
View File
@@ -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,
});
}
});
+16 -19
View File
@@ -1,30 +1,21 @@
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);
try {
const user = await prisma.user.create({
data: {
username: trimmedUsername,
@@ -38,4 +29,10 @@ export async function POST(req: NextRequest) {
username: user.username,
avatar: user.avatar,
});
}
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
throw new ApiError("用户名已被注册", 409);
}
throw e;
}
});
+55
View File
@@ -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,
});
});
+479
View File
@@ -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<string, string[]> = {
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<string, TaggedIdea[]>();
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<string>();
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<string>();
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<string, TaggedIdea>();
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);
});
+89
View File
@@ -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" });
});
+36
View File
@@ -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 },
);
});
+31
View File
@@ -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 },
);
});
+58
View File
@@ -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 });
});
+135
View File
@@ -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<null>((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<null>((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 });
});
+14 -24
View File
@@ -1,33 +1,27 @@
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();
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");
let data;
try {
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 });
@@ -47,8 +41,4 @@ export async function GET(req: Request) {
name: name || data.regeocode.formatted_address || null,
formatted: data.regeocode.formatted_address || null,
});
} catch (e) {
console.error("Regeo error:", e);
return NextResponse.json({ name: null });
}
}
});
+69
View File
@@ -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);
});
+20 -25
View File
@@ -1,34 +1,33 @@
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 = requireAmapApiKey();
const apiKey = process.env.AMAP_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: "AMAP_API_KEY not configured" },
{ status: 500 },
);
}
const location = req.nextUrl.searchParams.get("location");
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([]);
if (location) {
url.searchParams.set("location", location);
}
let data;
try {
const res = await fetch(url.toString());
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)
@@ -45,8 +44,4 @@ export async function GET(req: Request) {
});
return NextResponse.json(suggestions);
} catch (e) {
console.error("Location suggest error:", e);
return NextResponse.json([]);
}
}
});
+19
View File
@@ -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,6 +40,7 @@ export async function GET(
let alive = true;
(async () => {
try {
const status = await buildRoomStatus(id);
if (!status) {
send({ error: "room_not_found" });
@@ -33,6 +48,10 @@ export async function GET(
return;
}
if (alive) send(status);
} catch {
send({ error: "load_failed" });
controller.close();
}
})();
const unsubscribe = subscribe(id, async () => {
+8 -39
View File
@@ -1,25 +1,20 @@
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;
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 Error("KICKED");
throw new ApiError("你已被移出该房间", 403);
}
if (data.locked && !data.users.includes(userId)) {
throw new Error("LOCKED");
throw new ApiError("房间已锁定,无法加入", 403);
}
if (!data.users.includes(userId)) {
data.users.push(userId);
@@ -27,12 +22,7 @@ export async function POST(
return data;
});
if (!updated) {
return NextResponse.json(
{ error: "房间不存在或已过期" },
{ status: 404 },
);
}
if (!updated) throw new ApiError("房间不存在或已过期", 404);
notify(id);
@@ -40,25 +30,4 @@ export async function POST(
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 },
);
}
}
console.error("Failed to join room:", e);
return NextResponse.json(
{ error: "加入房间失败" },
{ status: 500 },
);
}
}
});
+10 -49
View File
@@ -1,25 +1,18 @@
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;
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 Error("FORBIDDEN");
throw new ApiError("只有房主可以执行此操作", 403);
}
switch (action) {
@@ -33,7 +26,7 @@ export async function POST(
case "kick":
if (!targetUserId || targetUserId === userId) {
throw new Error("INVALID_TARGET");
throw new ApiError("无效的操作对象");
}
data.users = data.users.filter((u) => u !== targetUserId);
if (!data.kickedUsers.includes(targetUserId)) {
@@ -60,47 +53,15 @@ export async function POST(
break;
default:
throw new Error("UNKNOWN_ACTION");
throw new ApiError("未知操作");
}
return data;
});
if (!updated) {
return NextResponse.json(
{ error: "房间不存在或已过期" },
{ status: 404 },
);
}
if (!updated) throw new ApiError("房间不存在或已过期", 404);
notify(id);
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 },
);
}
}
});
+12 -24
View File
@@ -1,25 +1,25 @@
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
const updated = await atomicUpdateRoom(id, (data) => {
if (!data.users.includes(userId) && data.creatorId !== userId) {
throw new ApiError("只有房间成员可以重置", 403);
}
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));
@@ -30,21 +30,9 @@ export async function POST(
return data;
});
if (!updated) {
return NextResponse.json(
{ error: "房间不存在或已过期" },
{ status: 404 },
);
}
if (!updated) throw new ApiError("房间不存在或已过期", 404);
notify(id);
return NextResponse.json({ ok: true });
} catch (e) {
console.error("Failed to reset room:", e);
return NextResponse.json(
{ error: "重置失败" },
{ status: 500 },
);
}
}
});
+4 -20
View File
@@ -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);
if (!status) {
return NextResponse.json(
{ error: "房间不存在或已过期" },
{ status: 404 },
);
}
if (!status) throw new ApiError("房间不存在或已过期", 404);
return NextResponse.json(status);
} catch (e) {
console.error("Failed to get room:", e);
return NextResponse.json(
{ error: "获取房间信息失败" },
{ status: 500 },
);
}
}
});
+14 -25
View File
@@ -1,26 +1,27 @@
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;
try {
const { userId, restaurantId, action } = await req.json();
if (!userId || restaurantId == null || !action) {
return NextResponse.json(
{ error: "userId, restaurantId, and action are required" },
{ status: 400 },
);
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'");
}
const rid = String(restaurantId);
const updated = await atomicUpdateRoom(id, (data) => {
if (!data.users.includes(userId)) {
throw new ApiError("你不是该房间的成员", 403);
}
const restaurantIndex = data.restaurants.findIndex((r) => r.id === rid);
const alreadySwiped =
restaurantIndex >= 0 &&
@@ -46,12 +47,7 @@ export async function POST(
return data;
});
if (!updated) {
return NextResponse.json(
{ error: "房间不存在或已过期" },
{ status: 404 },
);
}
if (!updated) throw new ApiError("房间不存在或已过期", 404);
notify(id);
@@ -59,11 +55,4 @@ export async function POST(
match: updated.match,
likeCount: updated.likes[rid]?.length ?? 0,
});
} catch (e) {
console.error("Failed to process swipe:", e);
return NextResponse.json(
{ error: "操作失败" },
{ status: 500 },
);
}
}
});
+10 -26
View File
@@ -1,26 +1,22 @@
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;
try {
const { userId, restaurantId } = await req.json();
if (!userId || restaurantId == null) {
return NextResponse.json(
{ error: "userId and restaurantId are required" },
{ status: 400 },
);
}
requireUserId(userId);
if (restaurantId == null) throw new ApiError("restaurantId is required");
const rid = String(restaurantId);
const updated = await atomicUpdateRoom(id, (data) => {
if (!data.users.includes(userId)) {
throw new ApiError("你不是该房间的成员", 403);
}
if (data.likes[rid]) {
data.likes[rid] = data.likes[rid].filter((u) => u !== userId);
if (data.likes[rid].length === 0) {
@@ -40,21 +36,9 @@ export async function POST(
return data;
});
if (!updated) {
return NextResponse.json(
{ error: "房间不存在或已过期" },
{ status: 404 },
);
}
if (!updated) throw new ApiError("房间不存在或已过期", 404);
notify(id);
return NextResponse.json({ ok: true });
} catch (e) {
console.error("Failed to undo swipe:", e);
return NextResponse.json(
{ error: "撤回失败" },
{ status: 500 },
);
}
}
});
+19 -32
View File
@@ -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,8 +103,7 @@ function filterByPrice(
});
}
export async function POST(req: Request) {
try {
export const POST = apiHandler(async (req) => {
const body = await req.json();
const {
lat,
@@ -114,27 +115,20 @@ export async function POST(req: Request) {
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 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 = process.env.AMAP_API_KEY;
if (!apiKey) {
console.error("AMAP_API_KEY not configured");
return NextResponse.json(
{ error: "服务配置异常,请稍后重试" },
{ status: 500 },
);
}
const apiKey = requireAmapApiKey();
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("location", `${numLng},${numLat}`);
url.searchParams.set("radius", String(radius));
url.searchParams.set("types", sceneConfig.poiTypes);
url.searchParams.set("show_fields", "business,photos");
@@ -147,8 +141,13 @@ export async function POST(req: Request) {
url.searchParams.set("keywords", cuisine);
}
let amapData;
try {
const amapRes = await fetch(url.toString());
const amapData = await amapRes.json();
amapData = await amapRes.json();
} catch {
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
}
let restaurants: Restaurant[] = [];
if (amapData.status === "1" && amapData.pois?.length > 0) {
@@ -159,20 +158,8 @@ export async function POST(req: Request) {
restaurants = results.slice(0, 15);
}
if (restaurants.length === 0) {
return NextResponse.json(
{ error: sceneConfig.emptyError },
{ status: 404 },
);
}
if (restaurants.length === 0) throw new ApiError(sceneConfig.emptyError, 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 },
);
}
}
});
+77
View File
@@ -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,
});
});
+33 -39
View File
@@ -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 });
}
const existing = await prisma.favorite.findFirst({
where: {
userId,
restaurantData: { contains: `"id":"${restaurant.id}"` },
},
});
if (existing) {
return NextResponse.json({ id: existing.id, alreadyExists: true });
}
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;
}
});
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 });
}
});
+11 -14
View File
@@ -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 });
}
});
+36 -40
View File
@@ -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<string, unknown> = {};
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);
}
try {
const user = await prisma.user.update({
where: { id: userId },
data: updateData,
});
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: JSON.parse(user.preferences),
preferences: prefs,
});
}
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
throw new ApiError("用户名已被占用", 409);
}
throw e;
}
});
File diff suppressed because it is too large Load Diff
+493
View File
@@ -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<UserProfile | null>(null);
const [showAuth, setShowAuth] = useState(false);
const [rooms, setRooms] = useState<RoomSummary[]>([]);
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 (
<div className="relative flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-6 overflow-y-auto scrollbar-none">
{/* Ambient */}
<div className="pointer-events-none fixed left-1/3 top-0 h-80 w-80 -translate-y-1/3 rounded-full bg-purple-600/8 blur-3xl" />
<div className="pointer-events-none fixed right-0 top-1/2 h-60 w-60 rounded-full bg-indigo-500/5 blur-3xl" />
{/* Back button */}
<button
onClick={() => router.push("/")}
className="absolute left-4 top-3 flex h-8 items-center gap-1 rounded-full bg-surface px-3 text-xs font-medium text-muted ring-1 ring-border transition-colors active:bg-elevated"
>
<ArrowLeft size={14} />
</button>
{/* Header */}
<motion.div
className="flex items-center gap-3"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5 }}
>
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-linear-to-br from-indigo-900 to-purple-700 shadow-lg shadow-purple-900/30 ring-1 ring-purple-500/20">
<Package size={22} className="text-purple-300" />
</div>
<div>
<h1 className="text-2xl font-black tracking-tight text-heading">
</h1>
<p className="text-xs font-medium tracking-widest text-muted">
ADVENTURE ROULETTE
</p>
</div>
</motion.div>
<motion.p
className="mt-2 max-w-xs text-center text-xs leading-relaxed text-muted"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.05 }}
>
"想做但一直没做"
</motion.p>
<motion.div
className="mt-4 flex items-center justify-center gap-4"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<div className="flex flex-col items-center gap-1 w-16">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-500/15">
<Plus size={14} className="text-purple-400" />
</div>
<span className="text-[10px] font-semibold text-muted"></span>
</div>
<ChevronRight size={12} className="shrink-0 text-dim" />
<div className="flex flex-col items-center gap-1 w-16">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-indigo-500/15">
<Package size={14} className="text-indigo-400" />
</div>
<span className="text-[10px] font-semibold text-muted"></span>
</div>
<ChevronRight size={12} className="shrink-0 text-dim" />
<div className="flex flex-col items-center gap-1 w-16">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-violet-500/15">
<Sparkles size={14} className="text-violet-400" />
</div>
<span className="text-[10px] font-semibold text-muted"></span>
</div>
</motion.div>
<AnimatePresence mode="wait">
{!hydrated || (loggedIn && loading) ? (
<BlindboxListSkeleton />
) : !loggedIn ? (
/* ============ Layer 1: Unauthenticated — Login CTA ============ */
<motion.div
key="intro"
className="mt-10 flex flex-col items-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<motion.button
onClick={() => 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 }}
>
<LogIn size={18} />
/
</motion.button>
<p className="mt-3 text-[11px] text-dim">
10
</p>
</motion.div>
) : loadError ? (
<motion.div
key="load-error"
className="mt-16 flex flex-col items-center gap-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Package size={36} className="text-purple-400/30" strokeWidth={1.5} />
<p className="text-sm text-muted"></p>
<button
onClick={fetchRooms}
className="mt-1 text-xs font-medium text-purple-400 active:text-purple-300"
>
</button>
</motion.div>
) : rooms.length === 0 ? (
/* ============ Layer 2: Logged in, no rooms — Create first ============ */
<motion.div
key="empty"
className="mt-10 flex flex-col items-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<motion.div
className="relative flex h-20 w-20 items-center justify-center"
animate={{ y: [0, -4, 0] }}
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
>
<div className="absolute inset-0 rounded-2xl bg-purple-600/15 blur-lg" />
<Package size={32} className="relative text-purple-400/60" strokeWidth={1.5} />
</motion.div>
<h2 className="mt-5 text-lg font-bold text-heading"></h2>
<p className="mt-1.5 text-sm text-tertiary">
TA
</p>
{/* Inline create form */}
<div className="mt-7 w-full max-w-xs">
<div className="flex gap-2">
<Input
type="text"
placeholder="我们的周末"
value={createName}
onChange={(e) => {
setCreateName(e.target.value.slice(0, 30));
setError("");
}}
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
maxLength={30}
size="xl"
variant="purple"
className="flex-1"
/>
<Button
onClick={handleCreate}
variant="purple"
size="lg"
loading={creating}
icon={<Plus size={16} />}
>
</Button>
</div>
{/* Join alternative */}
<div className="mt-5 flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-[10px] font-medium text-dim"></span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="mt-3 flex gap-2">
<Input
type="text"
placeholder="6 位房间号"
value={joinCode}
onChange={(e) => {
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]"
/>
<Button
onClick={handleJoin}
variant="secondary"
size="lg"
disabled={joinCode.trim().length < 6}
loading={joining}
icon={<LogIn size={16} />}
>
</Button>
</div>
{error && (
<motion.p
className="mt-3 text-center text-xs font-medium text-rose-400"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
</div>
</motion.div>
) : (
/* ============ Layer 3: Logged in, has rooms — Room list ============ */
<motion.div
key="rooms"
className="mt-6 flex w-full max-w-sm flex-col"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
{/* Create row */}
<div className="flex gap-2">
<Input
type="text"
placeholder="新房间名称"
value={createName}
onChange={(e) => {
setCreateName(e.target.value.slice(0, 30));
setError("");
}}
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
maxLength={30}
size="lg"
variant="purple"
className="flex-1"
/>
<Button
onClick={handleCreate}
variant="purple"
loading={creating}
icon={<Plus size={14} />}
>
</Button>
</div>
{/* Join row */}
<div className="mt-2 flex gap-2">
<Input
type="text"
placeholder="输入 6 位房间号加入"
value={joinCode}
onChange={(e) => {
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"
/>
<Button
onClick={handleJoin}
variant="secondary"
disabled={joinCode.trim().length < 6}
loading={joining}
icon={<LogIn size={14} />}
>
</Button>
</div>
{error && (
<motion.p
className="mt-2 text-center text-xs font-medium text-rose-400"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
{/* Room list */}
<div className="mt-5 flex flex-col gap-3">
{rooms.map((room, i) => (
<motion.button
key={room.id}
onClick={() => 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 */}
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-purple-600/15 ring-1 ring-purple-500/20">
<Package size={20} className="text-purple-400" strokeWidth={1.5} />
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-heading">{room.name}</p>
<div className="mt-1 flex items-center gap-3 text-[11px] text-muted">
<span className="flex items-center gap-1">
<Users size={11} />
{room.memberCount}
</span>
<span className="flex items-center gap-1">
<Package size={11} />
{room.poolCount}
</span>
</div>
{room.lastDrawn && (
<p className="mt-1 truncate text-[11px] text-purple-400/60">
{room.lastDrawn.content}
</p>
)}
</div>
{/* Members preview */}
<div className="flex shrink-0 -space-x-1.5">
{room.members.slice(0, 3).map((m) => (
<div
key={m.id}
className="flex h-7 w-7 items-center justify-center rounded-full bg-elevated text-xs ring-2 ring-surface"
title={m.username}
>
{m.avatar}
</div>
))}
{room.memberCount > 3 && (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-elevated text-[10px] font-bold text-muted ring-2 ring-surface">
+{room.memberCount - 3}
</div>
)}
</div>
<ChevronRight size={16} className="shrink-0 text-muted/50 transition-colors group-hover:text-purple-400" />
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Auth Modal */}
<AuthModal
open={showAuth}
onClose={() => setShowAuth(false)}
onAuth={handleAuth}
defaultTab="register"
/>
</div>
);
}
+51
View File
@@ -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 (
<div className="flex min-h-dvh flex-col items-center justify-center bg-background px-6">
<motion.div
className="flex flex-col items-center text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<motion.div
className="relative flex h-20 w-20 items-center justify-center"
animate={{ y: [0, -4, 0] }}
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
>
<div className="absolute inset-0 rounded-2xl bg-rose-500/15 blur-lg" />
<AlertTriangle size={36} className="relative text-rose-400/80" strokeWidth={1.5} />
</motion.div>
<h1 className="mt-6 text-xl font-bold text-heading"></h1>
<p className="mt-2 max-w-xs text-sm text-muted">
</p>
<div className="mt-8 flex gap-3">
<Button onClick={reset} variant="danger" icon={<RotateCcw size={15} />}>
</Button>
<Button onClick={() => window.location.href = "/"} variant="secondary" icon={<Home size={15} />}>
</Button>
</div>
</motion.div>
</div>
);
}
+45
View File
@@ -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 (
<html lang="zh-CN">
<body style={{ margin: 0, fontFamily: "system-ui, sans-serif", background: "#0a0a0a", color: "#e5e5e5" }}>
<div style={{ display: "flex", minHeight: "100dvh", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "1.5rem" }}>
<div style={{ fontSize: "3rem" }}></div>
<h1 style={{ marginTop: "1.5rem", fontSize: "1.25rem", fontWeight: 700 }}></h1>
<p style={{ marginTop: "0.5rem", fontSize: "0.875rem", color: "#a3a3a3", textAlign: "center" }}>
</p>
<button
onClick={reset}
style={{
marginTop: "2rem",
padding: "0.625rem 1.5rem",
fontSize: "0.875rem",
fontWeight: 600,
color: "#fff",
background: "#e11d48",
border: "none",
borderRadius: "0.75rem",
cursor: "pointer",
}}
>
</button>
</div>
</body>
</html>
);
}
+49 -2
View File
@@ -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%); }
}
+72 -58
View File
@@ -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<SceneType>("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 (
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-emerald-500" />
<div className="flex min-h-dvh flex-col items-center justify-center bg-background px-6">
<Skeleton className="h-16 w-16 rounded-2xl" />
<Skeleton className="mt-5 h-8 w-40" />
<Skeleton className="mt-2 h-4 w-24" />
<div className="mt-8 w-full max-w-xs rounded-2xl bg-surface px-6 py-5 ring-1 ring-border">
<div className="flex flex-col items-center gap-3">
<Skeleton className="h-5 w-44" />
<Skeleton className="h-7 w-24 rounded-full" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<div className="mt-6 flex gap-3">
<SkeletonCircle className="h-10 w-10" />
<SkeletonCircle className="h-10 w-10" />
<SkeletonCircle className="h-10 w-10" />
</div>
<Skeleton className="mt-8 h-12 w-full max-w-xs rounded-xl" />
</div>
);
}
@@ -70,62 +83,59 @@ export default function InvitePage() {
if (status === "not_found") {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-6">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-zinc-100">
<Utensils size={28} className="text-zinc-400" />
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-surface ring-1 ring-border">
<Utensils size={28} className="text-muted" />
</div>
<h1 className="text-xl font-bold text-zinc-900"></h1>
<p className="text-center text-sm text-zinc-500">
<h1 className="text-xl font-bold text-heading"></h1>
<p className="text-center text-sm text-muted">
</p>
<button
onClick={() => router.push("/")}
className="mt-2 rounded-xl bg-emerald-500 px-6 py-2.5 text-sm font-bold text-white shadow-md shadow-emerald-200 transition-colors hover:bg-emerald-600"
>
<Button onClick={() => router.push("/")} className="mt-2">
</button>
</Button>
</div>
);
}
return (
<div className="flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-12">
<div className="flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-12 overflow-y-auto scrollbar-none">
<motion.div
className="flex flex-col items-center"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5 }}
>
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500 shadow-lg shadow-emerald-200">
{scene === "drink" ? <Coffee size={28} className="text-white" /> : <Utensils size={28} className="text-white" />}
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-accent shadow-lg shadow-accent/20">
<span className="text-3xl leading-none">{sceneConfig.emoji}</span>
</div>
<h1 className="mt-5 text-3xl font-black tracking-tight text-zinc-900">
<h1 className="mt-5 text-3xl font-black tracking-tight text-heading">
NoWhatever
</h1>
<p className="mt-0.5 text-sm font-medium tracking-widest text-zinc-400">
<p className="mt-0.5 text-sm font-medium tracking-widest text-muted">
便
</p>
</motion.div>
<motion.div
className="mt-8 flex w-full max-w-xs flex-col items-center gap-2 rounded-2xl border border-emerald-100 bg-emerald-50/50 px-6 py-5"
className="mt-8 flex w-full max-w-xs flex-col items-center gap-2 rounded-2xl bg-surface px-6 py-5 ring-1 ring-accent/30"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<p className="text-center text-lg font-bold text-zinc-800">
<p className="text-center text-lg font-bold text-heading">
{sceneConfig.inviteText}
</p>
<div className="flex items-center gap-2 text-sm text-zinc-500">
<span className="rounded-full bg-white px-2.5 py-0.5 font-mono text-base font-bold tracking-widest text-emerald-600 shadow-sm">
<div className="flex items-center gap-2 text-sm text-muted">
<span className="rounded-full bg-elevated px-2.5 py-0.5 font-mono text-base font-bold tracking-widest text-accent ring-1 ring-border">
{roomId}
</span>
</div>
{userCount > 0 && (
<div className="flex items-center gap-1 text-xs text-zinc-400">
<div className="flex items-center gap-1 text-xs text-muted">
<Users size={13} />
<span>
<span className="font-semibold text-emerald-500">{userCount}</span>
<span className="font-semibold text-accent">{userCount}</span>
</span>
</div>
)}
@@ -138,35 +148,35 @@ export default function InvitePage() {
transition={{ duration: 0.5, delay: 0.2 }}
>
<div className="flex flex-col items-center gap-1.5 w-20">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-50">
<Users size={18} className="text-emerald-500" />
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/15">
<Users size={18} className="text-accent" />
</div>
<span className="text-xs font-semibold text-zinc-700"></span>
<span className="text-[10px] leading-tight text-zinc-400 text-center">
<span className="text-xs font-semibold text-secondary"></span>
<span className="text-[10px] leading-tight text-muted text-center">
</span>
</div>
<ChevronRight size={14} className="mt-3 shrink-0 text-zinc-300" />
<ChevronRight size={14} className="mt-3 shrink-0 text-subtle" />
<div className="flex flex-col items-center gap-1.5 w-20">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-50">
<Heart size={18} className="text-amber-500" />
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/15">
<Heart size={18} className="text-amber-400" />
</div>
<span className="text-xs font-semibold text-zinc-700"></span>
<span className="text-[10px] leading-tight text-zinc-400 text-center">
<span className="text-xs font-semibold text-secondary"></span>
<span className="text-[10px] leading-tight text-muted text-center">
</span>
</div>
<ChevronRight size={14} className="mt-3 shrink-0 text-zinc-300" />
<ChevronRight size={14} className="mt-3 shrink-0 text-subtle" />
<div className="flex flex-col items-center gap-1.5 w-20">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-rose-50">
<Sparkles size={18} className="text-rose-500" />
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-rose-500/15">
<Sparkles size={18} className="text-rose-400" />
</div>
<span className="text-xs font-semibold text-zinc-700"></span>
<span className="text-[10px] leading-tight text-zinc-400 text-center">
<span className="text-xs font-semibold text-secondary"></span>
<span className="text-[10px] leading-tight text-muted text-center">
</span>
</div>
@@ -178,20 +188,24 @@ export default function InvitePage() {
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<button
onClick={handleJoin}
disabled={joining}
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 text-sm font-bold text-white shadow-md shadow-emerald-200 transition-colors hover:bg-emerald-600 disabled:opacity-50"
{joinError && (
<motion.p
className="mb-3 text-center text-xs font-medium text-rose-400"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{joining ? (
<>
<Loader2 size={18} className="animate-spin" />
...
</>
) : (
"加入房间"
{joinError}
</motion.p>
)}
</button>
<Button
onClick={handleJoin}
size="lg"
fullWidth
loading={joining}
loadingText="加入中..."
>
</Button>
</motion.div>
</div>
);
+17 -2
View File
@@ -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 (
<html lang="zh-CN">
<html lang="zh-CN" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
</head>
<body className={`${geistSans.variable} font-sans antialiased`}>
{children}
<ToastProvider>
<PageTransition>{children}</PageTransition>
<GlobalUserBadge />
</ToastProvider>
</body>
</html>
);
+32
View File
@@ -0,0 +1,32 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "NoWhatever — 别说随便",
short_name: "NoWhatever",
description: "像 Tinder 一样滑卡片,和朋友一起决定去哪吃!",
start_url: "/",
display: "standalone",
background_color: "#030712",
theme_color: "#10b981",
orientation: "portrait",
icons: [
{
src: "/icon-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "/icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}
+136 -587
View File
@@ -1,629 +1,178 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { Plus, LogIn, Loader2, MapPin, Navigation, X, Users, Heart, Sparkles, ChevronRight, Flame, User } from "lucide-react";
import { getUserId, getCachedProfile, getCachedPreferences } from "@/lib/userId";
import { getAvatarBg } from "@/lib/avatars";
import AuthModal from "@/components/AuthModal";
import { motion } from "framer-motion";
import { Zap, Gift, Clock, ChevronRight, Trophy } from "lucide-react";
import BrandLogo from "@/components/BrandLogo";
import { SCENES, getSceneConfig } from "@/lib/sceneConfig";
import type { UserProfile, SceneType } from "@/types";
interface LocationSuggestion {
id: string;
name: string;
district: string;
address: string;
lat: number;
lng: number;
}
type GpsStatus = "idle" | "locating" | "success" | "failed" | "denied";
const DISTANCE_OPTIONS = [
{ label: "1km", value: 1000 },
{ label: "3km", value: 3000 },
{ label: "5km", value: 5000 },
] as const;
type GpsResult =
| { ok: true; lat: number; lng: number }
| { ok: false; reason: "unsupported" | "denied" | "timeout" | "unknown" };
function requestGps(): Promise<GpsResult> {
return new Promise((resolve) => {
if (!navigator.geolocation) {
resolve({ ok: false, reason: "unsupported" });
return;
}
navigator.geolocation.getCurrentPosition(
(pos) =>
resolve({ ok: true, lat: pos.coords.latitude, lng: pos.coords.longitude }),
(err) => {
const reason =
err.code === err.PERMISSION_DENIED
? "denied"
: err.code === err.TIMEOUT
? "timeout"
: "unknown";
resolve({ ok: false, reason });
},
{ timeout: 8000, enableHighAccuracy: false },
);
});
}
async function reverseGeocode(lat: number, lng: number): Promise<string | null> {
try {
const res = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
const data = await res.json();
return data.name || data.formatted || null;
} catch {
return null;
}
}
export default function LandingPage() {
const router = useRouter();
const [roomCode, setRoomCode] = useState("");
const [loading, setLoading] = useState(false);
const [loadingText, setLoadingText] = useState("");
const [error, setError] = useState("");
const [locationQuery, setLocationQuery] = useState("");
const [suggestions, setSuggestions] = useState<LocationSuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedLocation, setSelectedLocation] = useState<LocationSuggestion | null>(null);
const [fetchingSuggestions, setFetchingSuggestions] = useState(false);
const [radius, setRadius] = useState(3000);
const [priceRange, setPriceRange] = useState("any");
const [cuisine, setCuisine] = useState("");
const suggestRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const [gpsStatus, setGpsStatus] = useState<GpsStatus>("idle");
const [gpsCoords, setGpsCoords] = useState<{ lat: number; lng: number } | null>(null);
const [gpsLocationName, setGpsLocationName] = useState<string | null>(null);
const [scene, setScene] = useState<SceneType>("eat");
const sceneConfig = getSceneConfig(scene);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [authModalOpen, setAuthModalOpen] = useState(false);
useEffect(() => {
const cached = getCachedProfile();
if (cached) setProfile(cached);
const prefs = getCachedPreferences();
if (prefs.cuisine) setCuisine(prefs.cuisine);
if (prefs.priceRange) setPriceRange(prefs.priceRange);
if (prefs.radius) setRadius(prefs.radius);
}, []);
const handleSceneChange = useCallback((s: SceneType) => {
setScene(s);
setCuisine("");
setPriceRange("any");
}, []);
const doGpsLocate = useCallback(async () => {
setGpsStatus("locating");
const result = await requestGps();
if (result.ok) {
setGpsCoords({ lat: result.lat, lng: result.lng });
setGpsStatus("success");
const name = await reverseGeocode(result.lat, result.lng);
if (name) setGpsLocationName(name);
} else {
setGpsCoords(null);
setGpsLocationName(null);
setGpsStatus(result.reason === "denied" ? "denied" : "failed");
}
}, []);
useEffect(() => {
doGpsLocate();
}, [doGpsLocate]);
const fetchSuggestions = useCallback(async (query: string) => {
if (query.length < 1) {
setSuggestions([]);
setShowSuggestions(false);
return;
}
setFetchingSuggestions(true);
try {
const res = await fetch(`/api/location/suggest?keywords=${encodeURIComponent(query)}`);
const data: LocationSuggestion[] = await res.json();
setSuggestions(data);
setShowSuggestions(data.length > 0);
} catch {
setSuggestions([]);
} finally {
setFetchingSuggestions(false);
}
}, []);
const handleLocationInput = (val: string) => {
setLocationQuery(val);
setSelectedLocation(null);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => fetchSuggestions(val), 300);
};
const handleSelectLocation = (loc: LocationSuggestion) => {
setSelectedLocation(loc);
setLocationQuery(loc.name);
setShowSuggestions(false);
setSuggestions([]);
};
const clearLocation = () => {
setSelectedLocation(null);
setLocationQuery("");
setSuggestions([]);
setShowSuggestions(false);
};
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (suggestRef.current && !suggestRef.current.contains(e.target as Node)) {
setShowSuggestions(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const joinRoom = async (roomId: string) => {
const userId = getUserId();
const res = await fetch(`/api/room/${roomId}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
if (!res.ok) throw new Error("房间不存在");
return roomId;
};
const handleCreate = async () => {
setError("");
let coords: { lat: number; lng: number };
if (selectedLocation) {
coords = { lat: selectedLocation.lat, lng: selectedLocation.lng };
} else if (gpsCoords) {
coords = gpsCoords;
} else if (gpsStatus === "locating") {
setError("正在定位中,请稍候...");
return;
} else {
setError("无法获取位置,请在上方搜索并选择一个地点");
return;
}
setLoading(true);
try {
setLoadingText(sceneConfig.loadingText);
const res = await fetch("/api/room/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...coords, radius, priceRange, cuisine, userId: getUserId(), scene }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "创建房间失败");
}
if (!data.roomId) {
throw new Error("创建房间失败");
}
setLoadingText("正在进入房间...");
await joinRoom(data.roomId);
router.push(`/room/${data.roomId}`);
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败,请重试");
setLoading(false);
setLoadingText("");
}
};
const handleJoin = async (e: React.FormEvent) => {
e.preventDefault();
if (roomCode.length !== 4) {
setError("请输入 4 位房间号");
return;
}
setLoading(true);
setError("");
try {
await joinRoom(roomCode);
router.push(`/room/${roomCode}`);
} catch {
setError("房间不存在,请检查房间号");
setLoading(false);
}
};
return (
<div className="relative flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-6">
{/* Profile / Auth button */}
<div className="absolute right-4 top-3">
{profile ? (
<button
onClick={() => router.push("/profile")}
className={`flex h-8 items-center gap-1.5 rounded-full px-3 text-sm font-medium transition-colors active:opacity-80 ${getAvatarBg(profile.avatar)}`}
>
<span className="text-base leading-none">{profile.avatar}</span>
<span className="max-w-[5rem] truncate text-xs font-semibold text-zinc-700">{profile.username}</span>
</button>
) : (
<button
onClick={() => setAuthModalOpen(true)}
className="flex h-8 items-center gap-1.5 rounded-full bg-zinc-100 px-3 text-xs font-medium text-zinc-500 transition-colors active:bg-zinc-200"
>
<User size={14} />
</button>
)}
</div>
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-10 overflow-y-auto scrollbar-none">
{/* Ambient glow */}
<div className="pointer-events-none fixed left-1/2 top-0 -translate-x-1/2 -translate-y-1/3 h-[420px] w-[420px] rounded-full bg-orange-500/8 blur-3xl" />
<div className="pointer-events-none fixed left-1/4 top-1/2 h-[300px] w-[300px] rounded-full bg-purple-500/5 blur-3xl" />
{/* Header */}
<motion.div
className="flex items-center gap-3"
initial={{ y: -20, opacity: 0 }}
className="flex flex-col items-center gap-4"
initial={{ y: -30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5 }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<BrandLogo size={44} />
<div>
<h1 className="text-2xl font-black tracking-tight text-zinc-900">
<BrandLogo size={48} />
<div className="text-center">
<h1 className="text-2xl font-black tracking-tight text-heading">
NoWhatever
</h1>
<p className="text-xs font-medium tracking-widest text-zinc-400">
便
<p className="mt-1 text-[11px] font-medium tracking-[0.2em] text-muted">
便 ·
</p>
</div>
</motion.div>
<motion.p
className="mt-2 max-w-xs text-center text-xs leading-relaxed text-zinc-500"
className="mt-4 max-w-68 text-center text-sm leading-relaxed text-tertiary"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.05 }}
transition={{ delay: 0.2 }}
>
{sceneConfig.subtitle}
"随便"
<br />
</motion.p>
<motion.div
className="mt-4 flex items-center justify-center gap-4"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
{/* Dual Cards */}
<div className="mt-9 flex w-full max-w-sm flex-col gap-4">
{/* Card A: Panic Mode */}
<motion.button
onClick={() => router.push("/panic")}
className="group relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-yellow-400 to-orange-500 p-5 pb-4 text-left shadow-xl shadow-orange-500/25 ring-1 ring-white/15 transition-shadow hover:shadow-2xl hover:shadow-orange-500/35"
initial={{ x: -40, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
whileHover={{ scale: 1.02, rotate: -0.5 }}
whileTap={{ scale: 0.97 }}
>
<div className="flex flex-col items-center gap-1 w-16">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-50">
<Users size={14} className="text-emerald-500" />
</div>
<span className="text-[10px] font-semibold text-zinc-700"></span>
</div>
<div className="absolute -right-4 -top-4 h-28 w-28 rounded-full bg-white/15 blur-2xl" />
<div className="absolute -bottom-6 -left-6 h-20 w-20 rounded-full bg-yellow-300/20 blur-xl" />
<ChevronRight size={12} className="shrink-0 text-zinc-300" />
<div className="flex flex-col items-center gap-1 w-16">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-50">
<Heart size={14} className="text-amber-500" />
<div className="relative z-10">
<div className="flex items-center gap-2.5">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-black/15 ring-1 ring-white/10">
<Zap size={22} className="text-white" />
</div>
<span className="text-[10px] font-semibold text-zinc-700"></span>
<div className="flex-1">
<h2 className="text-lg font-black text-white"> </h2>
<p className="text-[10px] font-semibold tracking-wider text-white/60">
PANIC MODE
</p>
</div>
</div>
<p className="mt-3.5 text-sm font-medium leading-relaxed text-white/90">
10
</p>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs font-bold text-white/50">
<Clock size={12} />
<span> · </span>
</div>
<div className="flex items-center gap-0.5 text-xs font-semibold text-white/40 transition-colors group-hover:text-white/70">
<ChevronRight size={14} />
</div>
<ChevronRight size={12} className="shrink-0 text-zinc-300" />
<div className="flex flex-col items-center gap-1 w-16">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-rose-50">
<Sparkles size={14} className="text-rose-500" />
</div>
<span className="text-[10px] font-semibold text-zinc-700"></span>
</div>
</motion.div>
<motion.div
className="mt-4 flex items-center justify-center gap-2"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, delay: 0.12 }}
>
{SCENES.map((s) => {
const cfg = getSceneConfig(s);
const active = scene === s;
return (
<button
key={s}
onClick={() => handleSceneChange(s)}
disabled={loading}
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all disabled:opacity-50 ${
active
? "bg-emerald-500 text-white shadow-md shadow-emerald-200"
: "bg-zinc-100 text-zinc-500 hover:bg-zinc-200"
}`}
>
<span className="text-base leading-none">{cfg.emoji}</span>
{cfg.label}
</button>
);
})}
</motion.div>
<motion.div
className="mt-4 flex w-full max-w-xs flex-col gap-2.5"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.15 }}
>
<div ref={suggestRef} className="relative">
<div className="relative flex items-center">
<MapPin size={16} className="absolute left-3 text-zinc-400" />
<input
type="text"
placeholder="搜索位置(默认当前位置)"
value={locationQuery}
onChange={(e) => handleLocationInput(e.target.value)}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
disabled={loading}
className="h-10 w-full rounded-xl border border-zinc-200 bg-white pl-9 pr-9 text-sm text-zinc-700 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 disabled:opacity-50"
/>
{(selectedLocation || locationQuery) && !loading && (
<button
onClick={clearLocation}
className="absolute right-2.5 flex h-5 w-5 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-600"
>
<X size={14} />
</button>
)}
{fetchingSuggestions && (
<Loader2 size={14} className="absolute right-3 animate-spin text-zinc-300" />
)}
</div>
{selectedLocation && (
<div className="mt-1.5 flex items-center gap-1.5 px-1">
<Navigation size={12} className="shrink-0 text-emerald-500" />
<span className="truncate text-xs text-emerald-600">
{selectedLocation.district} {selectedLocation.address || selectedLocation.name}
</span>
</div>
)}
{!selectedLocation && !locationQuery && gpsStatus === "locating" && (
<div className="mt-1.5 flex items-center gap-1.5 px-1">
<Loader2 size={12} className="shrink-0 animate-spin text-emerald-400" />
<span className="text-xs text-zinc-400">...</span>
</div>
)}
{!selectedLocation && !locationQuery && gpsStatus === "success" && (
<div className="mt-1.5 flex items-center gap-1.5 px-1">
<Navigation size={12} className="shrink-0 text-emerald-500" />
<span className="truncate text-xs text-emerald-600">
{gpsLocationName || "已定位"}
</span>
</div>
)}
{!selectedLocation && !locationQuery && (gpsStatus === "failed" || gpsStatus === "denied") && (
<div className="mt-1.5 flex items-center justify-between px-1">
<div className="flex items-center gap-1.5">
<MapPin size={12} className="shrink-0 text-amber-500" />
<span className="text-xs text-amber-600">
{gpsStatus === "denied" ? "定位权限被拒绝" : "定位失败"}
</span>
</div>
<button
onClick={doGpsLocate}
className="shrink-0 text-xs font-medium text-emerald-500 active:text-emerald-700"
>
</button>
</div>
)}
{!selectedLocation && !locationQuery && gpsStatus === "idle" && (
<div className="mt-1.5 flex items-center gap-1.5 px-1">
<Navigation size={12} className="shrink-0 text-zinc-400" />
<span className="text-xs text-zinc-400">使</span>
</div>
)}
<AnimatePresence>
{showSuggestions && (
<motion.ul
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-xl border border-zinc-100 bg-white py-1 shadow-lg"
>
{suggestions.map((s) => (
<li key={s.id}>
<button
onClick={() => handleSelectLocation(s)}
className="flex w-full items-start gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-emerald-50"
>
<MapPin size={14} className="mt-0.5 shrink-0 text-zinc-400" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-zinc-800">{s.name}</p>
<p className="truncate text-xs text-zinc-400">{s.district} {s.address}</p>
</div>
</button>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
<div className="flex flex-col gap-2 rounded-xl border border-zinc-100 bg-zinc-50/50 px-3 py-2.5">
<div className="flex items-center gap-3">
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400">{sceneConfig.tagLabel}</span>
<div className="relative flex flex-1 items-center">
<input
type="text"
placeholder={sceneConfig.tagPlaceholder}
value={cuisine}
onChange={(e) => setCuisine(e.target.value)}
disabled={loading}
className="h-7 w-full rounded-full border-none bg-white pl-3 pr-7 text-xs text-zinc-700 outline-none ring-1 ring-zinc-200 transition-colors placeholder:text-zinc-300 focus:ring-2 focus:ring-emerald-300 disabled:opacity-50"
/>
{cuisine && !loading && (
<button
onClick={() => setCuisine("")}
className="absolute right-2 flex h-4 w-4 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-600"
>
<X size={12} />
</button>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400"></span>
<div className="flex flex-wrap items-center gap-1.5">
<Flame size={11} className="shrink-0 text-orange-400" />
{sceneConfig.hotTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setCuisine(tag)}
disabled={loading}
className={`h-6 rounded-full px-2.5 text-xs font-medium transition-colors disabled:opacity-50 ${
cuisine === tag
? "bg-emerald-500 text-white shadow-sm"
: "bg-white text-zinc-500 hover:bg-zinc-200"
}`}
>
{tag}
</button>
))}
</div>
</div>
<div className="flex items-center gap-3">
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400"></span>
<div className="flex gap-1.5">
{DISTANCE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setRadius(opt.value)}
disabled={loading}
className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
radius === opt.value
? "bg-emerald-500 text-white shadow-sm"
: "bg-white text-zinc-500 hover:bg-zinc-200"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-3">
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400"></span>
<div className="flex flex-wrap gap-1.5">
{sceneConfig.priceOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setPriceRange(opt.value)}
disabled={loading}
className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
priceRange === opt.value
? "bg-emerald-500 text-white shadow-sm"
: "bg-white text-zinc-500 hover:bg-zinc-200"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
<button
onClick={handleCreate}
disabled={loading}
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 text-sm font-bold text-white shadow-md shadow-emerald-200 transition-colors hover:bg-emerald-600 disabled:opacity-50"
>
{loading && loadingText ? (
<>
<Loader2 size={18} className="animate-spin" />
{loadingText}
</>
) : (
<>
<Plus size={18} strokeWidth={3} />
</>
)}
</button>
<div className="flex items-center gap-3 py-1">
<div className="h-px flex-1 bg-zinc-200" />
<span className="text-xs text-zinc-400"></span>
<div className="h-px flex-1 bg-zinc-200" />
</div>
<form onSubmit={handleJoin} className="flex gap-2">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
placeholder="输入 4 位房间号"
value={roomCode}
onChange={(e) => {
setRoomCode(e.target.value.replace(/\D/g, "").slice(0, 4));
setError("");
className="absolute inset-0 rounded-2xl"
whileHover={{
x: [0, -2, 2, -2, 2, 0],
transition: { duration: 0.4, repeat: Infinity },
}}
disabled={loading}
className="h-11 flex-1 rounded-xl border border-zinc-200 bg-white px-4 text-center text-lg font-semibold tracking-[0.3em] text-zinc-900 outline-none transition-colors placeholder:text-sm placeholder:tracking-normal placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 disabled:opacity-50"
/>
<button
type="submit"
disabled={loading || roomCode.length !== 4}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-zinc-900 text-white transition-colors hover:bg-zinc-700 disabled:opacity-30"
>
<LogIn size={18} />
</button>
</form>
</motion.button>
{error && (
<motion.p
className="text-center text-xs font-medium text-rose-500"
initial={{ opacity: 0, y: -4 }}
{/* Card B: Adventure Roulette */}
<motion.button
onClick={() => router.push("/blindbox")}
className="group relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-indigo-900 to-purple-800 p-5 pb-4 text-left shadow-xl shadow-purple-600/20 ring-1 ring-white/15 transition-shadow hover:shadow-2xl hover:shadow-purple-500/30"
initial={{ x: 40, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.4 }}
whileHover={{ scale: 1.02, rotate: 0.5 }}
whileTap={{ scale: 0.97 }}
>
<div className="absolute -right-6 -top-6 h-32 w-32 rounded-full bg-purple-400/15 blur-2xl" />
<div className="absolute -bottom-4 -left-4 h-24 w-24 rounded-full bg-indigo-300/15 blur-xl" />
<div className="relative z-10">
<div className="flex items-center gap-2.5">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-black/15 ring-1 ring-white/10">
<Gift size={22} className="text-white" />
</div>
<div className="flex-1">
<h2 className="text-lg font-black text-white">🎁 </h2>
<p className="text-[10px] font-semibold tracking-wider text-white/60">
ADVENTURE ROULETTE
</p>
</div>
</div>
<p className="mt-3.5 text-sm font-medium leading-relaxed text-white/90">
</p>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs font-bold text-white/50">
<Gift size={12} />
<span> · </span>
</div>
<div className="flex items-center gap-0.5 text-xs font-semibold text-white/40 transition-colors group-hover:text-white/70">
<ChevronRight size={14} />
</div>
</div>
</div>
<motion.div
className="absolute inset-0 rounded-2xl"
whileHover={{
x: [0, -2, 2, -2, 2, 0],
transition: { duration: 0.4, repeat: Infinity },
}}
/>
</motion.button>
</div>
{/* Achievements entry */}
<motion.button
onClick={() => router.push("/achievements")}
className="mt-6 flex w-full max-w-sm items-center gap-3 rounded-xl bg-surface px-4 py-3 ring-1 ring-border transition-colors active:bg-elevated"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
whileTap={{ scale: 0.98 }}
>
{error}
</motion.p>
)}
</motion.div>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-600/15">
<Trophy size={16} className="text-amber-400" />
</div>
<div className="flex-1 text-left">
<p className="text-sm font-semibold text-heading"></p>
<p className="text-[10px] text-muted"></p>
</div>
<ChevronRight size={14} className="text-muted" />
</motion.button>
{/* Footer */}
<motion.p
className="mt-auto pt-10 text-center text-[10px] font-medium tracking-widest text-muted"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
>
NOWHATEVER 便
</motion.p>
<AuthModal
open={authModalOpen}
onClose={() => setAuthModalOpen(false)}
onAuth={(p) => setProfile(p)}
/>
</div>
);
}
+589
View File
@@ -0,0 +1,589 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { Plus, LogIn, Loader2, MapPin, Navigation, X, Users, Heart, Sparkles, ChevronRight, Flame, ArrowLeft } from "lucide-react";
import { getUserId, getCachedPreferences } from "@/lib/userId";
import { SCENES, getSceneConfig } from "@/lib/sceneConfig";
import { useGeolocation } from "@/hooks/useGeolocation";
import { joinRoom } from "@/lib/room";
import type { SceneType } from "@/types";
interface LocationSuggestion {
id: string;
name: string;
district: string;
address: string;
lat: number;
lng: number;
}
const DISTANCE_OPTIONS = [
{ label: "1km", value: 1000 },
{ label: "3km", value: 3000 },
{ label: "5km", value: 5000 },
] as const;
export default function PanicPage() {
const router = useRouter();
const [roomCode, setRoomCode] = useState("");
const [loading, setLoading] = useState(false);
const [loadingText, setLoadingText] = useState("");
const [error, setError] = useState("");
const [locationQuery, setLocationQuery] = useState("");
const [suggestions, setSuggestions] = useState<LocationSuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedLocation, setSelectedLocation] = useState<LocationSuggestion | null>(null);
const [fetchingSuggestions, setFetchingSuggestions] = useState(false);
const [radius, setRadius] = useState(3000);
const [priceRange, setPriceRange] = useState("any");
const [cuisines, setCuisines] = useState<string[]>([]);
const [cuisineInput, setCuisineInput] = useState("");
const suggestRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const geo = useGeolocation();
const [scene, setScene] = useState<SceneType>("eat");
const sceneConfig = getSceneConfig(scene);
useEffect(() => {
const prefs = getCachedPreferences();
if (prefs.cuisines) setCuisines(prefs.cuisines);
else if (prefs.cuisine) setCuisines([prefs.cuisine]);
if (prefs.priceRange) setPriceRange(prefs.priceRange);
if (prefs.radius) setRadius(prefs.radius);
}, []);
const handleSceneChange = useCallback((s: SceneType) => {
setScene(s);
setCuisines([]);
setPriceRange("any");
}, []);
const fetchSuggestions = useCallback(async (query: string) => {
if (query.length < 1) {
setSuggestions([]);
setShowSuggestions(false);
return;
}
setFetchingSuggestions(true);
try {
const params = new URLSearchParams({ keywords: query });
if (geo.coords) {
params.set("location", `${geo.coords.lng},${geo.coords.lat}`);
}
const res = await fetch(`/api/location/suggest?${params.toString()}`);
if (!res.ok) { setSuggestions([]); setShowSuggestions(false); return; }
const data: LocationSuggestion[] = await res.json();
setSuggestions(Array.isArray(data) ? data : []);
setShowSuggestions(Array.isArray(data) && data.length > 0);
} catch {
setSuggestions([]);
} finally {
setFetchingSuggestions(false);
}
}, [geo.coords]);
const handleLocationInput = (val: string) => {
setLocationQuery(val);
setSelectedLocation(null);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => fetchSuggestions(val), 300);
};
const handleSelectLocation = (loc: LocationSuggestion) => {
setSelectedLocation(loc);
setLocationQuery(loc.name);
setShowSuggestions(false);
setSuggestions([]);
};
const clearLocation = () => {
setSelectedLocation(null);
setLocationQuery("");
setSuggestions([]);
setShowSuggestions(false);
};
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (suggestRef.current && !suggestRef.current.contains(e.target as Node)) {
setShowSuggestions(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleCreate = async () => {
setError("");
let coords: { lat: number; lng: number };
if (selectedLocation) {
coords = { lat: selectedLocation.lat, lng: selectedLocation.lng };
} else if (geo.coords) {
coords = geo.coords;
} else if (geo.status === "locating") {
setError("正在定位中,请稍候...");
return;
} else {
setError("无法获取位置,请在上方搜索并选择一个地点");
return;
}
setLoading(true);
try {
setLoadingText(sceneConfig.loadingText);
const res = await fetch("/api/room/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...coords, radius, priceRange, cuisine: cuisines.join("|"), userId: getUserId(), scene }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "创建房间失败");
}
if (!data.roomId) {
throw new Error("创建房间失败");
}
setLoadingText("正在进入房间...");
await joinRoom(data.roomId, getUserId());
router.push(`/room/${data.roomId}`);
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败,请重试");
setLoading(false);
setLoadingText("");
}
};
const handleJoin = async (e: React.FormEvent) => {
e.preventDefault();
if (roomCode.length !== 4) {
setError("请输入 4 位房间号");
return;
}
setLoading(true);
setError("");
try {
await joinRoom(roomCode, getUserId());
router.push(`/room/${roomCode}`);
} catch {
setError("房间不存在,请检查房间号");
setLoading(false);
}
};
return (
<div className="relative flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-6 overflow-y-auto scrollbar-none">
{/* Back button */}
<button
onClick={() => router.push("/")}
className="absolute left-4 top-3 flex h-8 items-center gap-1 rounded-full bg-surface px-3 text-xs font-medium text-muted ring-1 ring-border transition-colors active:bg-elevated"
>
<ArrowLeft size={14} />
</button>
<motion.div
className="flex items-center gap-3"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5 }}
>
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-linear-to-br from-yellow-400 to-orange-500 shadow-lg shadow-orange-500/20">
<Sparkles size={22} className="text-white" />
</div>
<div>
<h1 className="text-2xl font-black tracking-tight text-heading">
</h1>
<p className="text-xs font-medium tracking-widest text-muted">
10
</p>
</div>
</motion.div>
<motion.p
className="mt-2 max-w-xs text-center text-xs leading-relaxed text-muted"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.05 }}
>
{sceneConfig.subtitle}
</motion.p>
<motion.div
className="mt-4 flex items-center justify-center gap-4"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<div className="flex flex-col items-center gap-1 w-16">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500/15">
<Users size={14} className="text-orange-400" />
</div>
<span className="text-[10px] font-semibold text-muted"></span>
</div>
<ChevronRight size={12} className="shrink-0 text-dim" />
<div className="flex flex-col items-center gap-1 w-16">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-500/15">
<Heart size={14} className="text-amber-400" />
</div>
<span className="text-[10px] font-semibold text-muted"></span>
</div>
<ChevronRight size={12} className="shrink-0 text-dim" />
<div className="flex flex-col items-center gap-1 w-16">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-rose-500/15">
<Sparkles size={14} className="text-rose-400" />
</div>
<span className="text-[10px] font-semibold text-muted"></span>
</div>
</motion.div>
<motion.div
className="mt-4 w-full max-w-xs overflow-x-auto scrollbar-none py-1"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, delay: 0.12 }}
>
<div className="flex gap-2 px-1 pb-0.5">
{SCENES.map((s) => {
const cfg = getSceneConfig(s);
const active = scene === s;
return (
<button
key={s}
onClick={() => handleSceneChange(s)}
disabled={loading}
className={`flex shrink-0 items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all disabled:opacity-50 ${
active
? "bg-orange-500 text-white shadow-md shadow-orange-500/25"
: "bg-surface text-muted ring-1 ring-border hover:bg-elevated"
}`}
>
<span className="text-base leading-none">{cfg.emoji}</span>
{cfg.label}
</button>
);
})}
</div>
</motion.div>
<motion.div
className="mt-4 flex w-full max-w-xs flex-col gap-2.5"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.15 }}
>
<div ref={suggestRef} className="relative">
<div className="relative flex items-center">
<MapPin size={16} className="absolute left-3 text-muted" />
<input
type="text"
placeholder="搜索位置(默认当前位置)"
value={locationQuery}
onChange={(e) => handleLocationInput(e.target.value)}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
disabled={loading}
className="h-10 w-full rounded-xl border-none bg-surface pl-9 pr-9 text-sm text-foreground outline-none ring-1 ring-border transition-colors placeholder:text-dim focus:ring-2 focus:ring-orange-500/50 disabled:opacity-50"
/>
{(selectedLocation || locationQuery) && !loading && (
<button
onClick={clearLocation}
aria-label="清除位置"
className="absolute right-2.5 flex h-5 w-5 items-center justify-center rounded-full text-muted hover:text-secondary"
>
<X size={14} />
</button>
)}
{fetchingSuggestions && (
<Loader2 size={14} className="absolute right-3 animate-spin text-dim" />
)}
</div>
{selectedLocation && (
<div className="mt-1.5 flex items-center gap-1.5 px-1">
<Navigation size={12} className="shrink-0 text-orange-400" />
<span className="truncate text-xs text-orange-300/80">
{selectedLocation.district} {selectedLocation.address || selectedLocation.name}
</span>
</div>
)}
{!selectedLocation && !locationQuery && geo.status === "locating" && (
<div className="mt-1.5 flex items-center gap-1.5 px-1">
<Loader2 size={12} className="shrink-0 animate-spin text-orange-400" />
<span className="text-xs text-muted">...</span>
</div>
)}
{!selectedLocation && !locationQuery && geo.status === "success" && (
<div className="mt-1.5 flex items-center gap-1.5 px-1">
<Navigation size={12} className="shrink-0 text-orange-400" />
<span className="truncate text-xs text-orange-300/80">
{geo.locationName || "已定位"}
</span>
</div>
)}
{!selectedLocation && !locationQuery && (geo.status === "failed" || geo.status === "denied") && (
<div className="mt-1.5 flex items-center justify-between px-1">
<div className="flex items-center gap-1.5">
<MapPin size={12} className="shrink-0 text-amber-500" />
<span className="text-xs text-amber-400/80">
{geo.status === "denied" ? "定位权限被拒绝" : "定位失败"}
</span>
</div>
<button
onClick={geo.retry}
className="shrink-0 text-xs font-medium text-orange-400 active:text-orange-300"
>
</button>
</div>
)}
{!selectedLocation && !locationQuery && geo.status === "idle" && (
<div className="mt-1.5 flex items-center gap-1.5 px-1">
<Navigation size={12} className="shrink-0 text-dim" />
<span className="text-xs text-dim">使</span>
</div>
)}
<AnimatePresence>
{showSuggestions && (
<motion.ul
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-xl bg-surface py-1 shadow-xl ring-1 ring-subtle"
>
{suggestions.map((s) => (
<li key={s.id}>
<button
onClick={() => handleSelectLocation(s)}
className="flex w-full items-start gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-elevated"
>
<MapPin size={14} className="mt-0.5 shrink-0 text-dim" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{s.name}</p>
<p className="truncate text-xs text-muted">{s.district} {s.address}</p>
</div>
</button>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
<div className="flex flex-col gap-2 rounded-xl bg-surface/60 px-3 py-2.5 ring-1 ring-border">
<div className="flex items-center gap-3">
<span className="w-8 shrink-0 text-xs font-medium text-muted">{sceneConfig.tagLabel}</span>
<div className="relative flex flex-1 items-center">
<input
type="text"
placeholder={cuisines.length === 0 ? sceneConfig.tagPlaceholder : "继续添加..."}
value={cuisineInput}
onChange={(e) => setCuisineInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const val = cuisineInput.trim();
if (val && !cuisines.includes(val)) {
setCuisines((prev) => [...prev, val]);
}
setCuisineInput("");
}
}}
disabled={loading}
className="h-7 w-full rounded-full border-none bg-elevated pl-3 pr-7 text-xs text-foreground outline-none ring-1 ring-subtle transition-colors placeholder:text-dim focus:ring-2 focus:ring-orange-500/50 disabled:opacity-50"
/>
{cuisineInput && !loading && (
<button
type="button"
onClick={() => setCuisineInput("")}
aria-label="清除输入"
className="absolute right-2 flex h-4 w-4 items-center justify-center rounded-full text-muted hover:text-secondary"
>
<X size={12} />
</button>
)}
</div>
</div>
<div className="flex items-start gap-3">
<span className="mt-0.5 w-8 shrink-0 text-xs font-medium text-muted"></span>
<div className="flex flex-1 flex-wrap items-center gap-1.5">
<Flame size={11} className="shrink-0 text-orange-400" />
{sceneConfig.hotTags.map((tag) => {
const selected = cuisines.includes(tag);
return (
<button
key={tag}
type="button"
onClick={() =>
setCuisines((prev) =>
selected ? prev.filter((t) => t !== tag) : [...prev, tag],
)
}
disabled={loading}
className={`h-6 rounded-full px-2.5 text-xs font-medium transition-colors disabled:opacity-50 ${
selected
? "bg-orange-500 text-white shadow-sm shadow-orange-500/25"
: "bg-elevated text-muted hover:bg-subtle"
}`}
>
{tag}
</button>
);
})}
{cuisines.filter((t) => !sceneConfig.hotTags.includes(t)).map((tag) => (
<button
key={tag}
type="button"
onClick={() => setCuisines((prev) => prev.filter((t) => t !== tag))}
disabled={loading}
className="flex h-6 items-center gap-1 rounded-full bg-orange-500 pl-2.5 pr-1.5 text-xs font-medium text-white shadow-sm shadow-orange-500/25 disabled:opacity-50"
>
{tag}
<X size={10} />
</button>
))}
{cuisines.length > 0 && !loading && (
<button
type="button"
onClick={() => setCuisines([])}
aria-label="清除口味"
className="ml-1 flex h-5 items-center gap-0.5 rounded-full px-1.5 text-xs text-muted hover:text-secondary"
>
<X size={10} />
</button>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="w-8 shrink-0 text-xs font-medium text-muted"></span>
<div className="flex gap-1.5">
{DISTANCE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setRadius(opt.value)}
disabled={loading}
className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
radius === opt.value
? "bg-orange-500 text-white shadow-sm shadow-orange-500/25"
: "bg-elevated text-muted hover:bg-subtle"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-3">
<span className="w-8 shrink-0 text-xs font-medium text-muted"></span>
<div className="flex flex-wrap gap-1.5">
{sceneConfig.priceOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setPriceRange(opt.value)}
disabled={loading}
className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
priceRange === opt.value
? "bg-orange-500 text-white shadow-sm shadow-orange-500/25"
: "bg-elevated text-muted hover:bg-subtle"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
<button
onClick={handleCreate}
disabled={loading}
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-linear-to-r from-yellow-500 to-orange-500 text-sm font-bold text-white shadow-lg shadow-orange-500/25 transition-all hover:shadow-xl hover:shadow-orange-500/30 disabled:opacity-50"
>
{loading && loadingText ? (
<>
<Loader2 size={18} className="animate-spin" />
{loadingText}
</>
) : (
<>
<Plus size={18} strokeWidth={3} />
</>
)}
</button>
<div className="flex items-center gap-3 py-1">
<div className="h-px flex-1 bg-border" />
<span className="text-xs text-dim"></span>
<div className="h-px flex-1 bg-border" />
</div>
<form onSubmit={handleJoin} className="flex gap-2">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
placeholder="输入 4 位房间号"
value={roomCode}
onChange={(e) => {
setRoomCode(e.target.value.replace(/\D/g, "").slice(0, 4));
setError("");
}}
disabled={loading}
className="h-11 flex-1 rounded-xl border-none bg-surface px-4 text-center text-lg font-semibold tracking-[0.3em] text-heading outline-none ring-1 ring-border transition-colors placeholder:text-sm placeholder:tracking-normal placeholder:text-dim focus:ring-2 focus:ring-orange-500/50 disabled:opacity-50"
/>
<button
type="submit"
disabled={loading || roomCode.length !== 4}
aria-label="加入房间"
className="flex h-11 w-11 items-center justify-center rounded-xl bg-elevated text-secondary ring-1 ring-subtle transition-colors hover:bg-subtle disabled:opacity-30"
>
<LogIn size={18} />
</button>
</form>
{error && (
<motion.p
className="text-center text-xs font-medium text-rose-400"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
</motion.div>
</div>
);
}
+105 -277
View File
@@ -6,12 +6,7 @@ import { motion, AnimatePresence } from "framer-motion";
import {
ArrowLeft,
Mail,
Clock,
Star,
MapPin,
Trash2,
Loader2,
ChevronDown,
LogOut,
Lock,
Edit3,
@@ -19,27 +14,26 @@ import {
X,
Eye,
EyeOff,
Zap,
Trophy,
ChevronRight,
} from "lucide-react";
import Card from "@/components/Card";
import Input from "@/components/Input";
import ProfileFavoritesCard from "@/components/ProfileFavoritesCard";
import { useToast } from "@/hooks/useToast";
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
import { getAvatarBg, AVATARS } from "@/lib/avatars";
import type { UserProfile, UserPreferences, DecisionRecord, FavoriteRecord, Restaurant } from "@/types";
function firstImage(r: Restaurant): string {
if (r.images?.length > 0) return r.images[0];
// backward compat: old DB records may have `image` instead of `images`
const legacy = (r as unknown as Record<string, unknown>).image;
return typeof legacy === "string" ? legacy : "";
}
import type { UserProfile, UserPreferences, FavoriteRecord } from "@/types";
export default function ProfilePage() {
const router = useRouter();
const [userId, setUserId] = useState("");
const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences }) | null>(null);
const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences; decisionCount?: number }) | null>(null);
const [loading, setLoading] = useState(true);
const [history, setHistory] = useState<DecisionRecord[]>([]);
const [favorites, setFavorites] = useState<FavoriteRecord[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [favLoading, setFavLoading] = useState(false);
const [editingUsername, setEditingUsername] = useState(false);
@@ -61,14 +55,8 @@ export default function ProfilePage() {
const [emailSaving, setEmailSaving] = useState(false);
const [emailMsg, setEmailMsg] = useState("");
const [showHistory, setShowHistory] = useState(true);
const [showFavorites, setShowFavorites] = useState(true);
const [toast, setToast] = useState("");
const showToast = useCallback((msg: string) => {
setToast(msg);
setTimeout(() => setToast(""), 2200);
}, []);
const toast = useToast();
useEffect(() => {
const cached = getCachedProfile();
@@ -101,17 +89,10 @@ export default function ProfilePage() {
useEffect(() => {
if (!userId) return;
setHistoryLoading(true);
fetch(`/api/user/history?userId=${userId}`)
.then((r) => r.json())
.then(setHistory)
.catch(() => {})
.finally(() => setHistoryLoading(false));
setFavLoading(true);
fetch(`/api/user/favorite?userId=${userId}`)
.then((r) => r.json())
.then(setFavorites)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data) => setFavorites(Array.isArray(data) ? data : []))
.catch(() => {})
.finally(() => setFavLoading(false));
}, [userId]);
@@ -136,7 +117,7 @@ export default function ProfilePage() {
setProfile((prev) => prev ? { ...prev, username: trimmed } : prev);
setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar });
setEditingUsername(false);
showToast("用户名已更新");
toast.show("用户名已更新");
} else {
setUsernameMsg(data.error ?? "更新失败");
}
@@ -175,7 +156,7 @@ export default function ProfilePage() {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
showToast("密码已更新");
toast.show("密码已更新");
} else {
setPasswordMsg(data.error ?? "更新失败");
}
@@ -197,10 +178,10 @@ export default function ProfilePage() {
setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev);
setCachedProfile({ id: userId, username: profile!.username, avatar: emoji });
setEditingAvatar(false);
showToast("头像已更新");
toast.show("头像已更新");
}
} catch {
showToast("更新失败");
toast.show("更新失败");
}
};
@@ -239,9 +220,9 @@ export default function ProfilePage() {
body: JSON.stringify({ userId, favoriteId: favId }),
});
setFavorites((f) => f.filter((x) => x.id !== favId));
showToast("已取消收藏");
toast.show("已取消收藏");
} catch {
showToast("操作失败");
toast.show("操作失败");
}
};
@@ -252,52 +233,62 @@ export default function ProfilePage() {
if (loading) {
return (
<div className="flex min-h-dvh items-center justify-center bg-background">
<Loader2 size={24} className="animate-spin text-zinc-400" />
<div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
<div className="h-8 w-8" />
<h1 className="flex-1 text-base font-bold text-heading"></h1>
</nav>
<div className="mx-auto max-w-sm px-5">
<ProfileCardSkeleton />
<Card className="mt-4">
<div className="flex flex-col gap-2">
<RecordItemSkeleton />
<RecordItemSkeleton />
</div>
</Card>
<Card className="mt-4">
<div className="flex flex-col gap-2">
<RecordItemSkeleton />
<RecordItemSkeleton />
</div>
</Card>
</div>
</div>
);
}
if (!profile) return null;
const amapNavUrl = (r: Restaurant) =>
r.location
? `https://uri.amap.com/marker?position=${r.location}&name=${encodeURIComponent(r.name)}&callnative=1`
: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(r.name)}`;
return (
<div className="min-h-dvh bg-background pb-16">
<div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
<button
onClick={() => router.push("/")}
className="flex h-8 w-8 items-center justify-center rounded-full text-zinc-500 transition-colors active:bg-zinc-100"
className="flex h-8 w-8 items-center justify-center rounded-full text-muted transition-colors active:bg-elevated"
>
<ArrowLeft size={20} />
</button>
<h1 className="text-base font-bold text-zinc-900"></h1>
<h1 className="flex-1 text-base font-bold text-heading"></h1>
</nav>
<div className="mx-auto max-w-sm px-5">
{/* Profile card */}
<motion.div
className="rounded-2xl bg-white p-4 shadow-sm"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
>
<Card animated>
<div className="flex items-center gap-4">
<button
onClick={() => setEditingAvatar(!editingAvatar)}
className={`relative flex h-14 w-14 items-center justify-center rounded-2xl text-2xl transition-transform active:scale-95 ${getAvatarBg(profile.avatar)}`}
>
{profile.avatar}
<span className="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-white text-zinc-400 shadow-sm">
<span className="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-surface text-muted shadow-sm ring-1 ring-border">
<Edit3 size={10} />
</span>
</button>
<div className="flex-1">
{editingUsername ? (
<div className="flex items-center gap-2">
<input
<Input
type="text"
value={newUsername}
onChange={(e) => {
@@ -306,34 +297,41 @@ export default function ProfilePage() {
}}
maxLength={16}
autoFocus
className="h-8 flex-1 rounded-lg border border-zinc-200 px-2 text-sm text-zinc-800 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
size="sm"
className="flex-1"
/>
<button
onClick={handleSaveUsername}
disabled={usernameSaving}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500 text-white disabled:opacity-50"
className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-white disabled:opacity-50"
>
{usernameSaving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
</button>
<button
onClick={() => { setEditingUsername(false); setUsernameMsg(""); }}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-100 text-zinc-500"
className="flex h-8 w-8 items-center justify-center rounded-lg bg-elevated text-muted"
>
<X size={14} />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold text-zinc-900">{profile.username}</h2>
<h2 className="text-lg font-bold text-heading">{profile.username}</h2>
<button
onClick={() => { setEditingUsername(true); setNewUsername(profile.username); }}
className="text-zinc-400 transition-colors active:text-zinc-600"
className="text-muted transition-colors active:text-secondary"
>
<Edit3 size={13} />
</button>
</div>
)}
{usernameMsg && <p className="mt-1 text-xs text-rose-500">{usernameMsg}</p>}
{usernameMsg && <p className="mt-1 text-xs text-rose-400">{usernameMsg}</p>}
{(profile.decisionCount ?? 0) > 0 && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted">
<Zap size={11} className="text-amber-400" />
{profile.decisionCount}
</p>
)}
</div>
</div>
@@ -354,8 +352,8 @@ export default function ProfilePage() {
onClick={() => handleSaveAvatar(a.emoji)}
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
profile.avatar === a.emoji
? `${a.bg} scale-110 ring-2 ring-emerald-400 ring-offset-1`
: "bg-zinc-50 hover:bg-zinc-100"
? `${a.bg} scale-110 ring-2 ring-accent ring-offset-1 ring-offset-surface`
: "bg-elevated hover:bg-subtle"
}`}
>
{a.emoji}
@@ -365,21 +363,16 @@ export default function ProfilePage() {
</motion.div>
)}
</AnimatePresence>
</motion.div>
</Card>
{/* Change password */}
<motion.div
className="mt-4 rounded-2xl bg-white p-4 shadow-sm"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.05 }}
>
<Card animated className="mt-4" delay={0.05}>
<button
onClick={() => { setEditingPassword(!editingPassword); setPasswordMsg(""); }}
className="flex w-full items-center gap-2"
>
<Lock size={15} className="text-zinc-400" />
<h3 className="text-sm font-semibold text-zinc-700"></h3>
<Lock size={15} className="text-muted" />
<h3 className="text-sm font-semibold text-secondary"></h3>
</button>
<AnimatePresence>
@@ -393,46 +386,46 @@ export default function ProfilePage() {
>
<div className="mt-3 flex flex-col gap-3">
<div>
<p className="text-xs text-zinc-400"></p>
<p className="text-xs text-muted"></p>
<div className="relative mt-1">
<input
<Input
type={showPassword ? "text" : "password"}
value={currentPassword}
onChange={(e) => { setCurrentPassword(e.target.value); setPasswordMsg(""); }}
className="h-9 w-full rounded-lg border border-zinc-200 px-3 pr-9 text-sm text-zinc-800 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
className="pr-9"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted"
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
<div>
<p className="text-xs text-zinc-400"></p>
<input
<p className="text-xs text-muted"></p>
<Input
type={showPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setPasswordMsg(""); }}
placeholder="至少 6 个字符"
className="mt-1 h-9 w-full rounded-lg border border-zinc-200 px-3 text-sm text-zinc-800 outline-none placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
className="mt-1"
/>
</div>
<div>
<p className="text-xs text-zinc-400"></p>
<input
<p className="text-xs text-muted"></p>
<Input
type={showPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setPasswordMsg(""); }}
placeholder="再次输入新密码"
className="mt-1 h-9 w-full rounded-lg border border-zinc-200 px-3 text-sm text-zinc-800 outline-none placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
className="mt-1"
/>
</div>
{passwordMsg && (
<p className={`text-xs ${passwordMsg.includes("错误") || passwordMsg.includes("失败") || passwordMsg.includes("不一致") || passwordMsg.includes("至少") ? "text-rose-500" : "text-emerald-500"}`}>
<p className={`text-xs ${passwordMsg.includes("错误") || passwordMsg.includes("失败") || passwordMsg.includes("不一致") || passwordMsg.includes("至少") ? "text-rose-400" : "text-accent"}`}>
{passwordMsg}
</p>
)}
@@ -440,7 +433,7 @@ export default function ProfilePage() {
<button
onClick={handleSavePassword}
disabled={passwordSaving}
className="flex h-9 items-center justify-center gap-1.5 rounded-lg bg-emerald-500 text-xs font-semibold text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
className="flex h-9 items-center justify-center gap-1.5 rounded-lg bg-accent text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{passwordSaving ? <Loader2 size={14} className="animate-spin" /> : "保存新密码"}
</button>
@@ -448,22 +441,17 @@ export default function ProfilePage() {
</motion.div>
)}
</AnimatePresence>
</motion.div>
</Card>
{/* Email binding */}
<motion.div
className="mt-4 rounded-2xl bg-white p-4 shadow-sm"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.1 }}
>
<Card animated className="mt-4" delay={0.1}>
<div className="flex items-center gap-2">
<Mail size={15} className="text-zinc-400" />
<h3 className="text-sm font-semibold text-zinc-700"></h3>
<span className="text-[10px] text-zinc-400"></span>
<Mail size={15} className="text-muted" />
<h3 className="text-sm font-semibold text-secondary"></h3>
<span className="text-[10px] text-dim"></span>
</div>
<div className="mt-3 flex gap-2">
<input
<Input
type="email"
placeholder="your@email.com"
value={email}
@@ -471,192 +459,45 @@ export default function ProfilePage() {
setEmail(e.target.value);
setEmailMsg("");
}}
className="h-9 flex-1 rounded-lg border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
className="flex-1"
/>
<button
onClick={handleSaveEmail}
disabled={emailSaving}
className="flex h-9 items-center gap-1 rounded-lg bg-emerald-500 px-3 text-xs font-semibold text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
className="flex h-9 items-center gap-1 rounded-lg bg-accent px-3 text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{emailSaving ? <Loader2 size={13} className="animate-spin" /> : "保存"}
</button>
</div>
{emailMsg && (
<p className={`mt-2 text-xs ${emailMsg.includes("失败") || emailMsg.includes("不正确") ? "text-rose-500" : "text-emerald-500"}`}>
<p className={`mt-2 text-xs ${emailMsg.includes("失败") || emailMsg.includes("不正确") ? "text-rose-400" : "text-accent"}`}>
{emailMsg}
</p>
)}
</motion.div>
</Card>
{/* Decision History */}
<motion.div
className="mt-4 rounded-2xl bg-white p-4 shadow-sm"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.15 }}
>
{/* Achievements link */}
<Card animated className="mt-4" delay={0.15}>
<button
onClick={() => setShowHistory((v) => !v)}
className="flex w-full items-center justify-between"
onClick={() => router.push("/achievements")}
className="flex w-full items-center gap-2"
>
<div className="flex items-center gap-2">
<Clock size={15} className="text-zinc-400" />
<h3 className="text-sm font-semibold text-zinc-700">
{history.length > 0 && `(${history.length})`}
</h3>
</div>
<motion.span
animate={{ rotate: showHistory ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="text-zinc-400"
>
<ChevronDown size={16} />
</motion.span>
<Trophy size={15} className="text-amber-400" />
<h3 className="text-sm font-semibold text-secondary"></h3>
<span className="text-[10px] text-dim"> · </span>
<ChevronRight size={14} className="ml-auto text-muted" />
</button>
</Card>
<AnimatePresence>
{showHistory && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
{historyLoading ? (
<div className="flex justify-center py-6">
<Loader2 size={18} className="animate-spin text-zinc-300" />
</div>
) : history.length === 0 ? (
<p className="py-6 text-center text-xs text-zinc-400">
</p>
) : (
<div className="mt-3 flex flex-col gap-2">
{history.map((d) => (
<a
key={d.id}
href={amapNavUrl(d.restaurantData)}
target="_blank"
rel="noopener noreferrer"
className="flex gap-3 rounded-xl bg-zinc-50 p-2.5 transition-colors active:bg-zinc-100"
>
{firstImage(d.restaurantData) && (
<img
src={firstImage(d.restaurantData)}
alt={d.restaurantName}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
referrerPolicy="no-referrer"
<ProfileFavoritesCard
favorites={favorites}
loading={favLoading}
open={showFavorites}
onToggle={() => setShowFavorites((v) => !v)}
onRemove={handleRemoveFavorite}
onEmpty={() => router.push("/blindbox")}
delay={0.2}
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className="truncate text-sm font-semibold text-zinc-800">{d.restaurantName}</p>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-zinc-400">
<span>{d.matchType === "unanimous" ? "全员一致" : "最佳匹配"}</span>
<span>{d.participants} </span>
<span>{new Date(d.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" })}</span>
</div>
</div>
</a>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Favorites */}
<motion.div
className="mt-4 rounded-2xl bg-white p-4 shadow-sm"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<button
onClick={() => setShowFavorites((v) => !v)}
className="flex w-full items-center justify-between"
>
<div className="flex items-center gap-2">
<Star size={15} className="text-zinc-400" />
<h3 className="text-sm font-semibold text-zinc-700">
{favorites.length > 0 && `(${favorites.length})`}
</h3>
</div>
<motion.span
animate={{ rotate: showFavorites ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="text-zinc-400"
>
<ChevronDown size={16} />
</motion.span>
</button>
<AnimatePresence>
{showFavorites && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
{favLoading ? (
<div className="flex justify-center py-6">
<Loader2 size={18} className="animate-spin text-zinc-300" />
</div>
) : favorites.length === 0 ? (
<p className="py-6 text-center text-xs text-zinc-400">
</p>
) : (
<div className="mt-3 flex flex-col gap-2">
{favorites.map((f) => {
const r = f.restaurantData;
return (
<div
key={f.id}
className="flex gap-3 rounded-xl bg-zinc-50 p-2.5"
>
{firstImage(r) && (
<img
src={firstImage(r)}
alt={r.name}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
referrerPolicy="no-referrer"
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className="truncate text-sm font-semibold text-zinc-800">{r.name}</p>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-zinc-400">
<span className="flex items-center gap-0.5">
<Star size={10} className="fill-amber-400 text-amber-400" />
{r.rating}
</span>
<span>{r.price}</span>
{r.distance && (
<span className="flex items-center gap-0.5">
<MapPin size={10} />
{r.distance}
</span>
)}
</div>
</div>
<button
onClick={() => handleRemoveFavorite(f.id)}
className="flex h-8 w-8 shrink-0 items-center justify-center self-center rounded-full text-zinc-400 transition-colors active:bg-zinc-200 active:text-rose-500"
>
<Trash2 size={14} />
</button>
</div>
);
})}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Logout */}
<motion.div
@@ -667,27 +508,14 @@ export default function ProfilePage() {
>
<button
onClick={handleLogout}
className="flex items-center gap-1.5 text-xs font-medium text-zinc-400 transition-colors hover:text-rose-500"
className="flex items-center justify-center gap-2 rounded-xl bg-surface px-6 py-2.5 text-sm font-medium text-rose-400/80 ring-1 ring-border transition-colors hover:bg-elevated hover:text-rose-400"
>
<LogOut size={13} />
<LogOut size={14} />
退
</button>
</motion.div>
</div>
<AnimatePresence>
{toast && (
<motion.div
className="fixed left-1/2 top-10 z-60 -translate-x-1/2 rounded-xl bg-zinc-900 px-4 py-2.5 text-xs font-medium text-white shadow-lg"
initial={{ opacity: 0, y: -12, x: "-50%" }}
animate={{ opacity: 1, y: 0, x: "-50%" }}
exit={{ opacity: 0, y: -12, x: "-50%" }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
{toast}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+41 -29
View File
@@ -4,10 +4,14 @@ import { useEffect, useState, useCallback, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import TopNav from "@/components/TopNav";
import SwipeDeck from "@/components/SwipeDeck";
import { SwipeDeckSkeleton } from "@/components/Skeleton";
import LeaveConfirmModal from "@/components/LeaveConfirmModal";
import Button from "@/components/Button";
import { useRoomPolling } from "@/hooks/useRoomPolling";
import { getUserId } from "@/lib/userId";
import { joinRoom } from "@/lib/room";
import { getSceneConfig } from "@/lib/sceneConfig";
import { useToast } from "@/hooks/useToast";
export default function RoomPage() {
const params = useParams<{ id: string }>();
@@ -19,6 +23,7 @@ export default function RoomPage() {
const [joinFailed, setJoinFailed] = useState(false);
const [showLeaveConfirm, setShowLeaveConfirm] = useState(false);
const leavingRef = useRef(false);
const toast = useToast();
const {
userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts,
@@ -29,14 +34,9 @@ export default function RoomPage() {
const id = getUserId();
setUserId(id);
fetch(`/api/room/${roomId}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: id }),
}).then((res) => {
if (res.ok) setJoined(true);
else setJoinFailed(true);
}).catch(() => setJoinFailed(true));
joinRoom(roomId, id)
.then(() => setJoined(true))
.catch(() => setJoinFailed(true));
}, [roomId]);
useEffect(() => {
@@ -76,31 +76,48 @@ export default function RoomPage() {
}, []);
const handleReset = useCallback(async () => {
await fetch(`/api/room/${roomId}/reset`, { method: "POST" });
await mutate();
}, [roomId, mutate]);
const handleNarrow = useCallback(async (restaurantIds: string[]) => {
await fetch(`/api/room/${roomId}/reset`, {
try {
const res = await fetch(`/api/room/${roomId}/reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ restaurantIds }),
body: JSON.stringify({ userId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "重置失败");
}
await mutate();
}, [roomId, mutate]);
} catch (e) {
toast.show(e instanceof Error ? e.message : "重置失败");
}
}, [roomId, userId, mutate, toast]);
const handleNarrow = useCallback(async (restaurantIds: string[]) => {
try {
const res = await fetch(`/api/room/${roomId}/reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, restaurantIds }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "缩小范围失败");
}
await mutate();
} catch (e) {
toast.show(e instanceof Error ? e.message : "操作失败");
}
}, [roomId, userId, mutate, toast]);
if (notFound || joinFailed) {
return (
<div className="flex h-dvh flex-col items-center justify-center gap-4 bg-background px-6">
<p className="text-4xl">🍜</p>
<p className="text-base font-semibold text-zinc-700"></p>
<p className="text-sm text-zinc-400"> 24 </p>
<button
onClick={() => router.push("/")}
className="mt-2 h-10 rounded-xl bg-emerald-500 px-6 text-sm font-bold text-white shadow-sm transition-colors hover:bg-emerald-600"
>
<p className="text-base font-semibold text-secondary"></p>
<p className="text-sm text-muted"> 24 </p>
<Button onClick={() => router.push("/")}>
</button>
</Button>
</div>
);
}
@@ -111,12 +128,7 @@ export default function RoomPage() {
const sceneConfig = getSceneConfig(scene);
if (!ready) {
return (
<div className="flex h-dvh flex-col items-center justify-center gap-3 bg-background">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-emerald-500" />
<p className="text-sm text-zinc-400">...</p>
</div>
);
return <SwipeDeckSkeleton />;
}
return (
+3 -3
View File
@@ -14,9 +14,9 @@ export default function ActionButtons({
disabled,
}: ActionButtonsProps) {
return (
<div className="relative z-10 flex items-center justify-center gap-6 pb-5 pt-3">
<div className="relative z-10 -mt-2 flex items-center justify-center gap-4 pb-5">
<motion.button
className="flex h-13 w-13 items-center justify-center rounded-full bg-white shadow-lg shadow-rose-200/50 ring-1 ring-rose-100 disabled:opacity-40"
className="flex h-13 w-13 items-center justify-center rounded-full bg-surface shadow-lg shadow-rose-500/10 ring-1 ring-rose-500/20 disabled:opacity-40"
whileTap={{ scale: 0.85 }}
whileHover={{ scale: 1.08 }}
onClick={() => onAction("left")}
@@ -27,7 +27,7 @@ export default function ActionButtons({
</motion.button>
<motion.button
className="flex h-13 w-13 items-center justify-center rounded-full bg-white shadow-lg shadow-emerald-200/50 ring-1 ring-emerald-100 disabled:opacity-40"
className="flex h-13 w-13 items-center justify-center rounded-full bg-surface shadow-lg shadow-emerald-500/10 ring-1 ring-emerald-500/20 disabled:opacity-40"
whileTap={{ scale: 0.85 }}
whileHover={{ scale: 1.08 }}
onClick={() => onAction("right")}
+52 -71
View File
@@ -1,11 +1,14 @@
"use client";
import { useState, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, Loader2, Eye, EyeOff } from "lucide-react";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { X, Eye, EyeOff } from "lucide-react";
import { AVATARS } from "@/lib/avatars";
import { setCachedProfile } from "@/lib/userId";
import type { UserProfile } from "@/types";
import Modal from "@/components/Modal";
import Button from "@/components/Button";
import Input from "@/components/Input";
type Tab = "login" | "register";
@@ -13,11 +16,11 @@ interface AuthModalProps {
open: boolean;
onClose: () => void;
onAuth: (profile: UserProfile) => void;
defaultTab?: Tab;
}
export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
const backdropRef = useRef<HTMLDivElement>(null);
const [tab, setTab] = useState<Tab>("login");
export default function AuthModal({ open, onClose, onAuth, defaultTab = "login" }: AuthModalProps) {
const [tab, setTab] = useState<Tab>(defaultTab);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@@ -26,10 +29,6 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === backdropRef.current) onClose();
};
const resetForm = () => {
setUsername("");
setPassword("");
@@ -37,8 +36,17 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
setAvatar(AVATARS[0].emoji);
setShowPassword(false);
setError("");
setLoading(false);
};
useEffect(() => {
if (open) {
setTab(defaultTab);
resetForm();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const switchTab = (t: Tab) => {
setTab(t);
resetForm();
@@ -113,48 +121,30 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
};
return (
<AnimatePresence>
{open && (
<motion.div
ref={backdropRef}
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={handleBackdropClick}
>
<motion.div
className="relative w-full max-w-sm rounded-t-3xl bg-white px-5 pb-8 pt-5 shadow-2xl sm:rounded-3xl sm:pb-6"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", damping: 28, stiffness: 350 }}
>
<Modal open={open} onClose={onClose}>
<div className="mb-4 flex items-center justify-between">
<span className="text-lg font-bold text-zinc-900"></span>
<span className="text-lg font-bold text-heading"></span>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-100 text-zinc-400 transition-colors active:bg-zinc-200"
className="flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle"
>
<X size={16} />
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 rounded-xl bg-zinc-100 p-1">
<div className="flex gap-1 rounded-xl bg-elevated p-1">
{(["login", "register"] as const).map((t) => (
<button
key={t}
onClick={() => switchTab(t)}
className={`relative flex-1 rounded-lg py-2 text-sm font-semibold transition-colors ${
tab === t ? "text-zinc-900" : "text-zinc-400"
tab === t ? "text-heading" : "text-muted"
}`}
>
{tab === t && (
<motion.div
layoutId="auth-tab"
className="absolute inset-0 rounded-lg bg-white shadow-sm"
className="absolute inset-0 rounded-lg bg-subtle shadow-sm"
transition={{ type: "spring", stiffness: 400, damping: 30 }}
/>
)}
@@ -165,10 +155,9 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
))}
</div>
{/* Username */}
<div className="mt-5">
<p className="text-xs font-medium text-zinc-500"></p>
<input
<p className="text-xs font-medium text-muted"></p>
<Input
type="text"
value={username}
onChange={(e) => {
@@ -177,15 +166,15 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
}}
placeholder={tab === "register" ? "2-16 个字符" : "请输入用户名"}
maxLength={16}
className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
size="xl"
className="mt-2"
/>
</div>
{/* Password */}
<div className="mt-4">
<p className="text-xs font-medium text-zinc-500"></p>
<p className="text-xs font-medium text-muted"></p>
<div className="relative mt-2">
<input
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => {
@@ -193,23 +182,24 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
setError("");
}}
placeholder={tab === "register" ? "至少 6 个字符" : "请输入密码"}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 pr-10 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
size="xl"
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 transition-colors active:text-zinc-600"
aria-label={showPassword ? "隐藏密码" : "显示密码"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted transition-colors active:text-secondary"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{/* Confirm password (register only) */}
{tab === "register" && (
<div className="mt-4">
<p className="text-xs font-medium text-zinc-500"></p>
<input
<p className="text-xs font-medium text-muted"></p>
<Input
type={showPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => {
@@ -217,17 +207,17 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
setError("");
}}
placeholder="再次输入密码"
className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
size="xl"
className="mt-2"
/>
</div>
)}
{/* Avatar picker (register only) */}
{tab === "register" && (
<div className="mt-4">
<p className="text-xs font-medium text-zinc-500">
<p className="text-xs font-medium text-muted">
<span className="ml-1 text-zinc-300"></span>
<span className="ml-1 text-dim"></span>
</p>
<div className="mt-2 grid grid-cols-6 gap-2">
{AVATARS.map((a) => (
@@ -236,8 +226,8 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
onClick={() => setAvatar(a.emoji)}
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
avatar === a.emoji
? `${a.bg} scale-110 ring-2 ring-emerald-400 ring-offset-1`
: "bg-zinc-50 hover:bg-zinc-100"
? `${a.bg} scale-110 ring-2 ring-accent ring-offset-1 ring-offset-surface`
: "bg-elevated hover:bg-subtle"
}`}
>
{a.emoji}
@@ -249,7 +239,7 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
{error && (
<motion.p
className="mt-3 text-center text-xs font-medium text-rose-500"
className="mt-3 text-center text-xs font-medium text-rose-400"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
@@ -257,25 +247,16 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
</motion.p>
)}
<button
<Button
onClick={handleSubmit}
disabled={loading}
className="mt-5 flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 text-sm font-bold text-white shadow-md shadow-emerald-200 transition-colors hover:bg-emerald-600 disabled:opacity-50"
size="lg"
fullWidth
loading={loading}
loadingText={tab === "login" ? "登录中..." : "注册中..."}
className="mt-5"
>
{loading ? (
<>
<Loader2 size={16} className="animate-spin" />
{tab === "login" ? "登录中..." : "注册中..."}
</>
) : tab === "login" ? (
"登录"
) : (
"注册"
)}
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{tab === "login" ? "登录" : "注册"}
</Button>
</Modal>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { motion } from "framer-motion";
import { Trophy } from "lucide-react";
export interface DrawnIdea {
id: string;
content: string;
createdAt: string;
user?: { id: string; username: string; avatar: string };
drawnBy?: { id: string; username: string; avatar: string } | null;
}
export default function BlindboxDrawnHistory({ items }: { items: DrawnIdea[] }) {
if (items.length === 0) return null;
return (
<motion.div
className="mt-10 w-full max-w-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
<div className="mb-3 flex items-center gap-2">
<Trophy size={13} className="text-amber-400" />
<h3 className="text-xs font-bold tracking-wider text-muted">
</h3>
<div className="h-px flex-1 bg-border" />
</div>
<div className="flex flex-col gap-2">
{items.map((item, i) => (
<motion.div
key={item.id}
className="flex items-start gap-3 rounded-xl bg-surface/60 px-4 py-3 ring-1 ring-border/80"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.06 }}
>
<span className="mt-0.5 text-sm">🏆</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-secondary">
{item.content}
</p>
<div className="mt-1 flex items-center gap-2 text-[10px] text-dim">
{item.user && (
<span>{item.user.avatar} {item.user.username} </span>
)}
{item.drawnBy && (
<>
<span>·</span>
<span>{item.drawnBy.avatar} {item.drawnBy.username} </span>
</>
)}
<span>·</span>
<span>
{new Date(item.createdAt).toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
weekday: "short",
})}
</span>
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
);
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Package,
Loader2,
Pencil,
Trash2,
Check,
X,
UtensilsCrossed,
TreePine,
Film,
ShoppingBag,
Dumbbell,
Landmark,
Coffee,
} from "lucide-react";
import type { IdeaCategory } from "@/types";
export interface MyIdea {
id: string;
content: string;
createdAt: string;
category?: string | null;
timeSlot?: string | null;
estimatedMinutes?: number | null;
outdoor?: boolean | null;
searchQuery?: string | null;
searchType?: string | null;
}
const CATEGORY_CONFIG: Record<
IdeaCategory,
{ icon: typeof UtensilsCrossed; color: string; label: string }
> = {
dining: { icon: UtensilsCrossed, color: "text-orange-400", label: "美食" },
outdoor: { icon: TreePine, color: "text-emerald-400", label: "户外" },
entertainment: { icon: Film, color: "text-sky-400", label: "娱乐" },
shopping: { icon: ShoppingBag, color: "text-pink-400", label: "购物" },
sports: { icon: Dumbbell, color: "text-amber-400", label: "运动" },
culture: { icon: Landmark, color: "text-violet-400", label: "文化" },
relaxation: { icon: Coffee, color: "text-teal-400", label: "休闲" },
};
function CategoryBadge({ category }: { category?: string | null }) {
if (!category) return <span className="text-sm">💡</span>;
const cfg = CATEGORY_CONFIG[category as IdeaCategory];
if (!cfg) return <span className="text-sm">💡</span>;
const Icon = cfg.icon;
return <Icon size={14} className={`shrink-0 ${cfg.color}`} />;
}
function DurationLabel({ minutes }: { minutes?: number | null }) {
if (!minutes) return null;
const display = minutes >= 60 ? `${(minutes / 60).toFixed(minutes % 60 === 0 ? 0 : 1)}h` : `${minutes}min`;
return (
<span className="shrink-0 rounded-md bg-elevated px-1.5 py-0.5 text-[10px] font-medium text-dim">
~{display}
</span>
);
}
function MyIdeaItem({
idea,
onEdit,
onDelete,
}: {
idea: MyIdea;
onEdit: (id: string, content: string) => Promise<void>;
onDelete: (id: string) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(idea.content);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!draft.trim() || saving) return;
setSaving(true);
await onEdit(idea.id, draft);
setSaving(false);
setEditing(false);
};
return (
<motion.div
layout
className="flex items-center gap-2 rounded-xl bg-surface/60 px-3 py-2.5 ring-1 ring-border/80"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }}
>
{editing ? (
<>
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value.slice(0, 200))}
onKeyDown={(e) => { if (e.key === "Enter") handleSave(); if (e.key === "Escape") { setEditing(false); setDraft(idea.content); } }}
maxLength={200}
autoFocus
className="h-8 min-w-0 flex-1 rounded-lg bg-elevated px-2.5 text-sm text-foreground outline-none ring-1 ring-border focus:ring-2 focus:ring-purple-600/50"
/>
<button
onClick={handleSave}
disabled={saving || !draft.trim()}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-purple-600 text-white disabled:opacity-40"
>
{saving ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />}
</button>
<button
onClick={() => { setEditing(false); setDraft(idea.content); }}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-elevated text-muted"
>
<X size={12} />
</button>
</>
) : (
<>
<CategoryBadge category={idea.category} />
<p className="min-w-0 flex-1 truncate text-sm text-secondary">{idea.content}</p>
<DurationLabel minutes={idea.estimatedMinutes} />
<button
onClick={() => setEditing(true)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted transition-colors active:bg-elevated active:text-purple-400"
>
<Pencil size={12} />
</button>
<button
onClick={() => onDelete(idea.id)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted transition-colors active:bg-elevated active:text-rose-400"
>
<Trash2 size={12} />
</button>
</>
)}
</motion.div>
);
}
export default function BlindboxMyIdeas({
ideas,
onEdit,
onDelete,
}: {
ideas: MyIdea[];
onEdit: (id: string, content: string) => Promise<void>;
onDelete: (id: string) => Promise<void>;
}) {
return (
<motion.div
className="mt-6 w-full max-w-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<div className="mb-2 flex items-center gap-2">
<Package size={13} className="text-purple-400" />
<h3 className="text-xs font-bold tracking-wider text-muted">
{ideas.length}
</h3>
<div className="h-px flex-1 bg-border" />
</div>
<div className="flex flex-col gap-1.5">
<AnimatePresence>
{ideas.map((idea) => (
<MyIdeaItem key={idea.id} idea={idea} onEdit={onEdit} onDelete={onDelete} />
))}
</AnimatePresence>
</div>
</motion.div>
);
}
export { CATEGORY_CONFIG, CategoryBadge, DurationLabel };
+268
View File
@@ -0,0 +1,268 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
MapPin,
Clock,
Navigation,
Share2,
RefreshCw,
Sparkles,
ChevronRight,
ChevronLeft,
CornerDownLeft,
} from "lucide-react";
import { CategoryBadge } from "@/components/BlindboxMyIdeas";
import Button from "@/components/Button";
import type { WeekendPlanData } from "@/types";
interface BlindboxPlanProps {
days: WeekendPlanData[];
onAccept: () => void;
onRegenerate: () => void;
onShare: () => void;
onBack: () => void;
accepted?: boolean;
regenerating?: boolean;
}
function guessCategory(activity: string): string | null {
const lower = activity.toLowerCase();
if (/吃|餐|饭|火锅|烧烤|面|菜|厨|食/.test(lower)) return "dining";
if (/公园|山|湖|海|户外|骑|徒步|露营/.test(lower)) return "outdoor";
if (/电影|KTV|密室|游戏|桌游|剧/.test(lower)) return "entertainment";
if (/逛街|购物|商场|买/.test(lower)) return "shopping";
if (/运动|健身|球|跑|游泳|瑜伽/.test(lower)) return "sports";
if (/博物馆|展览|美术|书/.test(lower)) return "culture";
if (/咖啡|茶|SPA|按摩|下午茶/.test(lower)) return "relaxation";
return null;
}
function formatDuration(minutes: number): string {
if (minutes >= 60) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}h${m}min` : `${h}h`;
}
return `${minutes}min`;
}
export default function BlindboxPlan({
days,
onAccept,
onRegenerate,
onShare,
onBack,
accepted,
regenerating,
}: BlindboxPlanProps) {
const [dayIndex, setDayIndex] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
const currentDay = days[dayIndex];
const hasNext = dayIndex < days.length - 1;
const hasPrev = dayIndex > 0;
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [dayIndex]);
if (!currentDay) return null;
return (
<div className="flex min-h-0 flex-1 flex-col">
{/* Day header — sticky top */}
<div className="shrink-0 pb-3 text-center">
<motion.div
className="inline-flex items-center gap-1.5 rounded-full bg-purple-600/15 px-3 py-1 text-xs font-bold text-purple-400"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Sparkles size={12} />
{currentDay.date} ·
</motion.div>
{days.length > 1 && (
<div className="mt-2 flex items-center justify-center gap-1.5">
{days.map((day, i) => (
<button
key={day.date}
onClick={() => setDayIndex(i)}
className={`rounded-full transition-all ${
i === dayIndex
? "h-1.5 w-5 bg-purple-400"
: "h-1.5 w-1.5 bg-purple-400/25"
}`}
/>
))}
</div>
)}
{currentDay.summary && (
<motion.p
className="mt-2 text-xs text-muted"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
{currentDay.summary}
</motion.p>
)}
</div>
{/* Scrollable timeline */}
<div
ref={scrollRef}
className="min-h-0 flex-1 overflow-y-auto scrollbar-none"
>
<AnimatePresence mode="wait">
<motion.div
key={dayIndex}
className="relative mx-auto max-w-sm pl-6"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.25 }}
>
<div className="absolute left-[9px] top-2 bottom-2 w-px bg-purple-500/20" />
{currentDay.items.map((item, i) => (
<motion.div
key={i}
className="relative mb-4 last:mb-0"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + i * 0.08 }}
>
<div className="absolute -left-6 top-3 flex h-[18px] w-[18px] items-center justify-center rounded-full bg-purple-600/20 ring-2 ring-background">
<div className="h-2 w-2 rounded-full bg-purple-400" />
</div>
<div className="rounded-xl bg-surface/80 p-3.5 ring-1 ring-border/80">
<div className="flex items-start gap-2">
<span className="mt-0.5 text-sm font-black text-purple-400">{item.time}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<CategoryBadge category={guessCategory(item.activity)} />
<p className="truncate text-sm font-bold text-heading">{item.activity}</p>
</div>
<div className="mt-1.5 flex items-center gap-1 text-[11px] text-muted">
<MapPin size={10} className="shrink-0" />
<span className="truncate">{item.poi}</span>
</div>
{item.address && (
<p className="mt-0.5 truncate text-[10px] text-dim">{item.address}</p>
)}
<div className="mt-2 flex items-center gap-3">
<span className="flex items-center gap-1 text-[10px] text-dim">
<Clock size={9} />
{formatDuration(item.duration)}
</span>
{item.lat !== 0 && item.lng !== 0 && (
<a
href={`https://uri.amap.com/marker?position=${item.lng},${item.lat}&name=${encodeURIComponent(item.poi)}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[10px] font-medium text-purple-400/70 active:text-purple-400"
>
<Navigation size={9} />
</a>
)}
</div>
{item.reason && (
<p className="mt-1.5 text-[10px] leading-relaxed text-dim italic">
{item.reason}
</p>
)}
</div>
</div>
</div>
</motion.div>
))}
</motion.div>
</AnimatePresence>
{/* Back to pool — at end of scroll content */}
<div className="mt-6 flex justify-center pb-4">
<button
onClick={onBack}
className="flex items-center gap-1.5 text-xs font-medium text-muted active:text-foreground"
>
<CornerDownLeft size={12} />
</button>
</div>
</div>
{/* Fixed bottom bar — actions + day navigation */}
<div className="shrink-0 border-t border-border/40 bg-background/80 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] backdrop-blur-lg">
{/* Day navigation */}
{days.length > 1 && (
<div className="mx-auto mb-2.5 flex max-w-sm items-center justify-center gap-2 px-4">
{hasPrev && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
onClick={() => setDayIndex(dayIndex - 1)}
className="flex items-center gap-1 rounded-full bg-surface px-3 py-1.5 text-[11px] font-bold text-purple-400 ring-1 ring-border/60 active:bg-elevated"
>
<ChevronLeft size={12} />
{days[dayIndex - 1].date}
</motion.button>
)}
<span className="text-[10px] text-dim">
{dayIndex + 1} / {days.length}
</span>
{hasNext && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
onClick={() => setDayIndex(dayIndex + 1)}
className="flex items-center gap-1 rounded-full bg-purple-600/15 px-3 py-1.5 text-[11px] font-bold text-purple-400 active:bg-purple-600/25"
>
{days[dayIndex + 1].date}
<ChevronRight size={12} />
</motion.button>
)}
</div>
)}
{/* Action buttons */}
<div className="mx-auto flex max-w-sm items-center justify-center gap-3 px-4">
{accepted ? (
<Button
onClick={onShare}
variant="purple"
shape="pill"
icon={<Share2 size={14} />}
>
</Button>
) : (
<>
<Button
onClick={onAccept}
variant="purple"
shape="pill"
icon={<Sparkles size={14} />}
>
</Button>
<Button
onClick={onRegenerate}
variant="secondary"
shape="pill"
loading={regenerating}
icon={<RefreshCw size={14} />}
>
</Button>
</>
)}
</div>
</div>
</div>
);
}
+259
View File
@@ -0,0 +1,259 @@
import { QRCodeSVG } from "qrcode.react";
import type { WeekendPlanData } from "@/types";
export interface PlanShareData {
type: "plan";
days: WeekendPlanData[];
roomName: string;
}
export default function BlindboxPlanShareCard({
data,
cardRef,
}: {
data: PlanShareData;
cardRef: React.RefObject<HTMLDivElement | null>;
}) {
const { days, roomName } = data;
const shareUrl =
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
return (
<div
ref={cardRef}
style={{
width: 340,
padding: 1.5,
borderRadius: 20,
background: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
<div
style={{
borderRadius: 18.5,
background: "#0a0810",
position: "relative",
overflow: "hidden",
}}
>
{/* Decorative glows */}
<div
style={{
position: "absolute",
top: -30,
right: -20,
width: 140,
height: 140,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(124,58,237,0.2), transparent 70%)",
}}
/>
{/* Brand header */}
<div
style={{
padding: "14px 20px 12px",
display: "flex",
alignItems: "center",
gap: 8,
position: "relative",
}}
>
<span style={{ fontSize: 18 }}>📋</span>
<div>
<div
style={{
fontSize: 13,
fontWeight: 800,
color: "#ffffff",
letterSpacing: "0.02em",
}}
>
NoWhatever
</div>
<div
style={{
fontSize: 9,
fontWeight: 600,
color: "rgba(255,255,255,0.3)",
letterSpacing: "0.15em",
marginTop: 1,
}}
>
便 · WEEKEND PLAN
</div>
</div>
</div>
{/* Thin accent line */}
<div
style={{
height: 1,
margin: "0 20px",
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)",
}}
/>
{/* Each day */}
{days.map((day, dayIdx) => (
<div key={day.date}>
{/* Room + date badge */}
<div style={{ textAlign: "center", padding: "16px 20px 8px" }}>
<div
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.25em",
color: "rgba(167,139,250,0.5)",
}}
>
{roomName} · {day.date}
</div>
{day.summary && (
<div
style={{
fontSize: 11,
color: "rgba(255,255,255,0.5)",
marginTop: 6,
lineHeight: 1.5,
}}
>
{day.summary}
</div>
)}
</div>
{/* Timeline items */}
<div style={{ padding: "12px 20px 20px" }}>
{day.items.map((item, i) => (
<div
key={i}
style={{
display: "flex",
gap: 10,
marginBottom: i < day.items.length - 1 ? 12 : 0,
position: "relative",
}}
>
<div
style={{
width: 40,
flexShrink: 0,
fontSize: 12,
fontWeight: 900,
color: "#a78bfa",
textAlign: "right",
paddingTop: 2,
}}
>
{item.time}
</div>
<div style={{ width: 16, flexShrink: 0, display: "flex", flexDirection: "column", alignItems: "center" }}>
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "#7c3aed",
marginTop: 5,
flexShrink: 0,
}}
/>
{i < day.items.length - 1 && (
<div style={{ width: 1, flex: 1, background: "rgba(124,58,237,0.2)", marginTop: 4 }} />
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#ffffff" }}>
{item.activity}
</div>
<div style={{ fontSize: 10, color: "rgba(167,139,250,0.5)", marginTop: 3 }}>
📍 {item.poi}
</div>
{item.reason && (
<div
style={{
fontSize: 9,
color: "rgba(255,255,255,0.25)",
marginTop: 3,
fontStyle: "italic",
lineHeight: 1.4,
}}
>
{item.reason}
</div>
)}
</div>
</div>
))}
</div>
{/* Separator between days */}
{dayIdx < days.length - 1 && (
<div
style={{
height: 1,
margin: "0 20px",
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.15), transparent)",
}}
/>
)}
</div>
))}
{/* Contract stamp */}
<div
style={{
textAlign: "center",
padding: "0 20px 16px",
fontSize: 10,
fontWeight: 500,
color: "rgba(167,139,250,0.3)",
}}
>
</div>
{/* QR footer */}
<div
style={{
padding: "14px 20px 16px",
display: "flex",
alignItems: "center",
gap: 14,
borderTop: "1px solid rgba(255,255,255,0.04)",
}}
>
<div
style={{
padding: 5,
borderRadius: 8,
background: "#ffffff",
flexShrink: 0,
}}
>
<QRCodeSVG
value={shareUrl}
size={52}
level="M"
bgColor="#ffffff"
fgColor="#0a0810"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: "rgba(255,255,255,0.7)" }}>
便
</div>
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.2)", marginTop: 3 }}>
{shareUrl.replace(/^https?:\/\//, "")}
</div>
</div>
</div>
</div>
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
import { QRCodeSVG } from "qrcode.react";
export interface BlindboxShareData {
type: "blindbox";
idea: string;
submitter?: { avatar: string; username: string };
drawer?: { avatar: string; username: string };
roomName: string;
}
export default function BlindboxShareCard({
data,
cardRef,
}: {
data: BlindboxShareData;
cardRef: React.RefObject<HTMLDivElement | null>;
}) {
const { idea, submitter, drawer, roomName } = data;
const shareUrl =
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
return (
<div
ref={cardRef}
style={{
width: 340,
padding: 1.5,
borderRadius: 20,
background: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
<div
style={{
borderRadius: 18.5,
background: "#0a0810",
position: "relative",
overflow: "hidden",
}}
>
{/* Decorative glows */}
<div
style={{
position: "absolute",
top: -30,
right: -20,
width: 140,
height: 140,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(124,58,237,0.2), transparent 70%)",
}}
/>
<div
style={{
position: "absolute",
bottom: 80,
left: -30,
width: 120,
height: 120,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(99,102,241,0.12), transparent 70%)",
}}
/>
{/* Brand header */}
<div
style={{
padding: "14px 20px 12px",
display: "flex",
alignItems: "center",
gap: 8,
position: "relative",
}}
>
<span style={{ fontSize: 18 }}>🎁</span>
<div>
<div
style={{
fontSize: 13,
fontWeight: 800,
color: "#ffffff",
letterSpacing: "0.02em",
}}
>
NoWhatever
</div>
<div
style={{
fontSize: 9,
fontWeight: 600,
color: "rgba(255,255,255,0.3)",
letterSpacing: "0.15em",
marginTop: 1,
}}
>
便 · ADVENTURE ROULETTE
</div>
</div>
</div>
{/* Thin accent line */}
<div
style={{
height: 1,
margin: "0 20px",
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)",
}}
/>
{/* Room name badge */}
<div
style={{
textAlign: "center",
padding: "20px 20px 8px",
position: "relative",
}}
>
<div
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.25em",
color: "rgba(167,139,250,0.5)",
}}
>
{roomName}
</div>
</div>
{/* Idea card */}
<div style={{ padding: "0 16px 20px" }}>
<div
style={{
borderRadius: 14,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(167,139,250,0.1)",
padding: "28px 24px",
position: "relative",
}}
>
{/* Corner decorations */}
<div
style={{
position: "absolute",
top: 10,
left: 10,
width: 14,
height: 14,
borderLeft: "2px solid rgba(167,139,250,0.25)",
borderTop: "2px solid rgba(167,139,250,0.25)",
borderTopLeftRadius: 3,
}}
/>
<div
style={{
position: "absolute",
top: 10,
right: 10,
width: 14,
height: 14,
borderRight: "2px solid rgba(167,139,250,0.25)",
borderTop: "2px solid rgba(167,139,250,0.25)",
borderTopRightRadius: 3,
}}
/>
<div
style={{
position: "absolute",
bottom: 10,
left: 10,
width: 14,
height: 14,
borderLeft: "2px solid rgba(167,139,250,0.25)",
borderBottom: "2px solid rgba(167,139,250,0.25)",
borderBottomLeftRadius: 3,
}}
/>
<div
style={{
position: "absolute",
bottom: 10,
right: 10,
width: 14,
height: 14,
borderRight: "2px solid rgba(167,139,250,0.25)",
borderBottom: "2px solid rgba(167,139,250,0.25)",
borderBottomRightRadius: 3,
}}
/>
<div
style={{
fontSize: 22,
fontWeight: 900,
color: "#ffffff",
textAlign: "center",
lineHeight: 1.6,
letterSpacing: "0.02em",
}}
>
{idea}
</div>
<div
style={{
width: 48,
height: 1,
margin: "16px auto",
background:
"linear-gradient(to right, transparent, rgba(167,139,250,0.4), transparent)",
}}
/>
<div
style={{
textAlign: "center",
fontSize: 10,
fontWeight: 500,
color: "rgba(167,139,250,0.35)",
}}
>
</div>
</div>
</div>
{/* Attribution */}
{(submitter || drawer) && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "0 20px 16px",
}}
>
{submitter && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 11,
color: "rgba(196,181,253,0.4)",
}}
>
<span style={{ fontSize: 14 }}>{submitter.avatar}</span>
<span>{submitter.username} </span>
</div>
)}
{submitter && drawer && (
<span style={{ color: "rgba(196,181,253,0.2)" }}>·</span>
)}
{drawer && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 11,
color: "rgba(196,181,253,0.4)",
}}
>
<span style={{ fontSize: 14 }}>{drawer.avatar}</span>
<span>{drawer.username} </span>
</div>
)}
</div>
)}
{/* QR footer */}
<div
style={{
padding: "14px 20px 16px",
display: "flex",
alignItems: "center",
gap: 14,
borderTop: "1px solid rgba(255,255,255,0.04)",
}}
>
<div
style={{
padding: 5,
borderRadius: 8,
background: "#ffffff",
flexShrink: 0,
}}
>
<QRCodeSVG
value={shareUrl}
size={52}
level="M"
bgColor="#ffffff"
fgColor="#0a0810"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "rgba(255,255,255,0.7)",
}}
>
便
</div>
<div
style={{
fontSize: 10,
color: "rgba(255,255,255,0.2)",
marginTop: 3,
}}
>
{shareUrl.replace(/^https?:\/\//, "")}
</div>
</div>
</div>
</div>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { type ComponentProps, type ReactNode } from "react";
import { motion } from "framer-motion";
import { Loader2 } from "lucide-react";
const variantStyles = {
primary:
"bg-accent text-white shadow-lg shadow-accent/20 hover:bg-accent-hover disabled:opacity-50",
secondary:
"bg-surface text-secondary ring-1 ring-border hover:bg-elevated disabled:opacity-40",
danger:
"bg-rose-600 text-white hover:bg-rose-500 disabled:opacity-50",
ghost:
"text-muted hover:text-secondary hover:bg-elevated disabled:opacity-50",
purple:
"bg-purple-600 text-white hover:bg-purple-500 disabled:opacity-50",
} as const;
const sizeStyles = {
sm: "h-8 px-3 text-xs gap-1",
md: "h-10 px-4 text-sm gap-1.5",
lg: "h-11 px-6 text-sm font-bold gap-2",
} as const;
const spinnerSize = { sm: 13, md: 15, lg: 18 } as const;
interface ButtonProps
extends Omit<ComponentProps<typeof motion.button>, "ref" | "children"> {
variant?: keyof typeof variantStyles;
size?: "sm" | "md" | "lg";
shape?: "rounded" | "pill";
loading?: boolean;
loadingText?: string;
icon?: ReactNode;
fullWidth?: boolean;
children?: ReactNode;
}
export default function Button({
variant = "primary",
size = "md",
shape = "rounded",
loading = false,
loadingText,
icon,
fullWidth = false,
className = "",
disabled,
children,
...rest
}: ButtonProps) {
const base = "flex items-center justify-center font-semibold transition-colors";
const shapeClass = shape === "pill" ? "rounded-full" : "rounded-xl";
return (
<motion.button
className={`${base} ${variantStyles[variant]} ${sizeStyles[size]} ${shapeClass} ${fullWidth ? "w-full" : ""} ${className}`}
disabled={loading || disabled}
whileTap={{ scale: 0.95 }}
{...rest}
>
{loading ? (
<Loader2 size={spinnerSize[size]} className="animate-spin" />
) : icon ? (
icon
) : null}
{loading && loadingText ? loadingText : children}
</motion.button>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { motion } from "framer-motion";
import type { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
animated?: boolean;
delay?: number;
}
const fadeUp = {
initial: { y: 10, opacity: 0 },
animate: { y: 0, opacity: 1 },
} as const;
export default function Card({
children,
className = "",
animated = false,
delay,
}: CardProps) {
const cls = `rounded-2xl bg-surface p-4 ring-1 ring-border ${className}`;
if (animated) {
return (
<motion.div
className={cls}
{...fadeUp}
transition={delay ? { delay } : undefined}
>
{children}
</motion.div>
);
}
return <div className={cls}>{children}</div>;
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
interface PendingContract {
id: string;
roomName: string;
date: string;
activities: string[];
}
interface ContractCompletionModalProps {
contracts: PendingContract[];
userId: string;
onDone: () => void;
}
export default function ContractCompletionModal({
contracts,
userId,
onDone,
}: ContractCompletionModalProps) {
const [current, setCurrent] = useState(0);
const [loading, setLoading] = useState(false);
if (contracts.length === 0) return null;
const contract = contracts[current];
const handleAction = async (action: "complete" | "expire") => {
if (loading) return;
setLoading(true);
try {
await fetch("/api/blindbox/plan", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planId: contract.id, userId, action }),
});
} catch { /* best-effort */ }
setLoading(false);
if (current < contracts.length - 1) {
setCurrent((c) => c + 1);
} else {
onDone();
}
};
const summary =
contract.activities.length <= 3
? contract.activities.join(" → ")
: contract.activities.slice(0, 3).join(" → ") + "…";
return (
<AnimatePresence>
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-6 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
className="w-full max-w-sm overflow-hidden rounded-2xl bg-surface shadow-2xl ring-1 ring-border"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
>
<div className="bg-linear-to-br from-purple-600/15 to-indigo-600/15 px-5 py-4">
<p className="text-center text-xs font-bold tracking-wider text-purple-400/70">
</p>
<p className="mt-2 text-center text-base font-bold text-heading">
{contract.roomName}
</p>
<p className="mt-1 text-center text-[11px] text-muted">
{contract.date} · {summary}
</p>
</div>
<div className="px-5 py-4">
<p className="text-center text-sm text-secondary">
</p>
<div className="mt-4 flex gap-3">
<button
onClick={() => handleAction("complete")}
disabled={loading}
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl bg-emerald-600 py-3 text-sm font-bold text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
>
{loading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<CheckCircle2 size={16} />
)}
</button>
<button
onClick={() => handleAction("expire")}
disabled={loading}
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl bg-surface py-3 text-sm font-medium text-muted ring-1 ring-border transition-colors hover:bg-elevated disabled:opacity-50"
>
{loading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<XCircle size={16} />
)}
</button>
</div>
{contracts.length > 1 && (
<p className="mt-3 text-center text-[10px] text-dim">
{current + 1} / {contracts.length}
</p>
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
export type { PendingContract };
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { CheckCircle2, XCircle, ChevronDown } from "lucide-react";
import type { ContractRecord } from "@/types";
interface ContractHistoryItemProps {
record: ContractRecord;
}
export default function ContractHistoryItem({ record }: ContractHistoryItemProps) {
const [expanded, setExpanded] = useState(false);
const isCompleted = record.status === "completed";
const summary =
record.activities.length <= 3
? record.activities.join(" → ")
: record.activities.slice(0, 3).join(" → ") + "…";
return (
<button
onClick={() => setExpanded(!expanded)}
className="w-full rounded-xl bg-elevated p-3 text-left transition-colors active:bg-subtle"
>
<div className="flex items-start gap-3">
<div
className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${
isCompleted
? "bg-emerald-600/15 text-emerald-400"
: "bg-rose-600/15 text-rose-400/70"
}`}
>
{isCompleted ? <CheckCircle2 size={15} /> : <XCircle size={15} />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-semibold text-heading">
{record.roomName}
</p>
<span
className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold ${
isCompleted
? "bg-emerald-600/15 text-emerald-400"
: "bg-rose-600/15 text-rose-400/70"
}`}
>
{isCompleted ? "已完成" : "未完成"}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
<span>{record.date}</span>
<span>·</span>
<span>{record.activities.length} </span>
<span>·</span>
<span>
{new Date(record.createdAt).toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
})}
</span>
</div>
<p className="mt-1 truncate text-xs text-dim">{summary}</p>
</div>
<motion.span
animate={{ rotate: expanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="mt-1 shrink-0 text-muted"
>
<ChevronDown size={14} />
</motion.span>
</div>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-3 border-t border-border pt-3">
<div className="flex flex-col gap-1.5">
{record.activities.map((activity, i) => (
<div key={i} className="flex items-center gap-2">
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-600/15 text-[10px] font-bold text-purple-400">
{i + 1}
</div>
<span className="text-xs text-secondary">{activity}</span>
</div>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</button>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { motion } from "framer-motion";
import type { LucideIcon } from "lucide-react";
interface EmptyStateProps {
icon: LucideIcon;
title: string;
subtitle?: string;
ctaLabel?: string;
onCta?: () => void;
color?: string;
}
export default function EmptyState({
icon: Icon,
title,
subtitle,
ctaLabel,
onCta,
color = "purple",
}: EmptyStateProps) {
const colorMap: Record<string, { glow: string; icon: string; btn: string; btnHover: string }> = {
purple: { glow: "bg-purple-600/15", icon: "text-purple-400/60", btn: "bg-purple-600 hover:bg-purple-500", btnHover: "" },
amber: { glow: "bg-amber-500/15", icon: "text-amber-400/60", btn: "bg-amber-600 hover:bg-amber-500", btnHover: "" },
rose: { glow: "bg-rose-500/15", icon: "text-rose-400/60", btn: "bg-rose-600 hover:bg-rose-500", btnHover: "" },
sky: { glow: "bg-sky-500/15", icon: "text-sky-400/60", btn: "bg-sky-600 hover:bg-sky-500", btnHover: "" },
};
const c = colorMap[color] ?? colorMap.purple;
return (
<div className="flex flex-col items-center py-6">
<motion.div
className="relative flex h-14 w-14 items-center justify-center"
animate={{ y: [0, -3, 0] }}
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
>
<div className={`absolute inset-0 rounded-xl ${c.glow} blur-md`} />
<Icon size={24} className={`relative ${c.icon}`} strokeWidth={1.5} />
</motion.div>
<p className="mt-3 text-sm font-semibold text-secondary">{title}</p>
{subtitle && (
<p className="mt-1 text-xs text-muted">{subtitle}</p>
)}
{ctaLabel && onCta && (
<motion.button
onClick={onCta}
className={`mt-4 rounded-lg px-4 py-1.5 text-xs font-semibold text-white transition-colors ${c.btn}`}
whileTap={{ scale: 0.95 }}
>
{ctaLabel}
</motion.button>
)}
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
import { motion } from "framer-motion";
import { User, Sun, Moon, Monitor } from "lucide-react";
import { getCachedProfile } from "@/lib/userId";
import { type Theme, getStoredTheme, setStoredTheme } from "@/lib/theme";
import AuthModal from "@/components/AuthModal";
import type { UserProfile } from "@/types";
const HIDDEN_PREFIXES = ["/profile"];
export default function GlobalUserBadge() {
const router = useRouter();
const pathname = usePathname();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [showAuth, setShowAuth] = useState(false);
const [theme, setTheme] = useState<Theme>("system");
const hidden = HIDDEN_PREFIXES.some((p) => pathname.startsWith(p));
useEffect(() => {
setProfile(getCachedProfile());
}, [pathname]);
useEffect(() => {
setTheme(getStoredTheme());
}, []);
useEffect(() => {
const handler = () => setProfile(getCachedProfile());
window.addEventListener("nowhatever_auth", handler);
return () => window.removeEventListener("nowhatever_auth", handler);
}, []);
const handleAuth = useCallback((p: UserProfile) => {
setProfile(p);
setShowAuth(false);
}, []);
const cycleTheme = useCallback(() => {
const order: Theme[] = ["system", "light", "dark"];
const next = order[(order.indexOf(theme) + 1) % 3];
setTheme(next);
setStoredTheme(next);
}, [theme]);
const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
const themeLabel = theme === "light" ? "浅色" : theme === "dark" ? "深色" : "自动";
if (hidden) return null;
return (
<>
<motion.div
className="fixed right-4 top-3 z-50 flex items-center gap-1.5"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
>
<button
onClick={cycleTheme}
className="flex h-8 items-center gap-1 rounded-full bg-surface/80 px-2.5 text-muted ring-1 ring-border/50 backdrop-blur-md transition-colors hover:bg-elevated hover:text-heading"
>
<ThemeIcon size={13} />
<span className="text-[10px] font-medium">{themeLabel}</span>
</button>
{profile ? (
<button
onClick={() => router.push("/profile")}
className="flex h-8 items-center gap-1.5 rounded-full bg-surface/80 px-3 ring-1 ring-border/50 backdrop-blur-md transition-colors hover:bg-elevated active:opacity-80"
>
<span className="text-base leading-none">{profile.avatar}</span>
<span className="max-w-20 truncate text-xs font-semibold text-secondary">
{profile.username}
</span>
</button>
) : (
<button
onClick={() => setShowAuth(true)}
className="flex h-8 items-center gap-1.5 rounded-full bg-surface/80 px-3 text-xs font-medium text-muted ring-1 ring-border/50 backdrop-blur-md transition-colors hover:bg-elevated hover:text-secondary"
>
<User size={13} />
</button>
)}
</motion.div>
<AuthModal
open={showAuth}
onClose={() => setShowAuth(false)}
onAuth={handleAuth}
/>
</>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { type ComponentPropsWithoutRef, forwardRef } from "react";
const sizeStyles = {
sm: "h-8 rounded-lg px-2",
md: "h-9 rounded-lg px-3",
lg: "h-10 rounded-xl px-3",
xl: "h-11 rounded-xl px-4",
} as const;
const variantStyles = {
default: "bg-elevated text-heading focus:ring-accent/50",
purple: "bg-surface text-foreground focus:ring-purple-600",
} as const;
interface InputProps extends Omit<ComponentPropsWithoutRef<"input">, "size"> {
size?: keyof typeof sizeStyles;
variant?: keyof typeof variantStyles;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ size = "md", variant = "default", className = "", ...rest }, ref) => (
<input
ref={ref}
className={`w-full border-none text-sm outline-none ring-1 ring-border placeholder:text-dim focus:ring-2 ${sizeStyles[size]} ${variantStyles[variant]} ${className}`}
{...rest}
/>
),
);
Input.displayName = "Input";
export default Input;
+9 -36
View File
@@ -1,8 +1,7 @@
"use client";
import { useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { LogOut } from "lucide-react";
import Modal from "@/components/Modal";
interface LeaveConfirmModalProps {
open: boolean;
@@ -15,61 +14,35 @@ export default function LeaveConfirmModal({
onConfirm,
onCancel,
}: LeaveConfirmModalProps) {
const backdropRef = useRef<HTMLDivElement>(null);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === backdropRef.current) onCancel();
};
return (
<AnimatePresence>
{open && (
<motion.div
ref={backdropRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={handleBackdropClick}
>
<motion.div
className="mx-6 w-full max-w-xs rounded-2xl bg-white px-6 py-6 shadow-2xl"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 350 }}
>
<Modal open={open} onClose={onCancel} variant="dialog">
<div className="flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-50">
<LogOut size={22} className="text-rose-500" />
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-500/15">
<LogOut size={22} className="text-rose-400" />
</div>
<h2 className="mt-4 text-base font-bold text-zinc-900">
<h2 className="mt-4 text-base font-bold text-heading">
退
</h2>
<p className="mt-1.5 text-center text-xs leading-relaxed text-zinc-400">
<p className="mt-1.5 text-center text-xs leading-relaxed text-muted">
退
</p>
<div className="mt-5 flex w-full gap-2.5">
<button
onClick={onCancel}
className="flex h-11 flex-1 items-center justify-center rounded-xl border border-zinc-200 bg-white text-sm font-semibold text-zinc-700 transition-colors active:bg-zinc-50"
className="flex h-11 flex-1 items-center justify-center rounded-xl bg-elevated text-sm font-semibold text-secondary ring-1 ring-border transition-colors active:bg-subtle"
>
</button>
<button
onClick={onConfirm}
className="flex h-11 flex-1 items-center justify-center rounded-xl bg-rose-500 text-sm font-semibold text-white shadow-md shadow-rose-200 transition-colors active:bg-rose-600"
className="flex h-11 flex-1 items-center justify-center rounded-xl bg-rose-500 text-sm font-semibold text-white shadow-lg shadow-rose-500/20 transition-colors active:bg-rose-600"
>
退
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</Modal>
);
}
+201 -256
View File
@@ -12,17 +12,31 @@ import {
Clock,
Trophy,
RotateCcw,
SearchX,
Home,
ChevronDown,
Swords,
RefreshCw,
Share2,
Zap,
Heart,
UserPlus,
} from "lucide-react";
import { Restaurant, MatchType, RunnerUp, SceneType } from "@/types";
import {
Restaurant,
MatchType,
RunnerUp,
SceneType,
UserProfile,
} from "@/types";
import { fireCelebration, playChime } from "@/lib/celebrate";
import { isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal";
import RestaurantImage from "@/components/RestaurantImage";
import AuthModal from "@/components/AuthModal";
import Button from "@/components/Button";
import NoMatchResult from "@/components/NoMatchResult";
import RunnerUpCard from "@/components/RunnerUpCard";
import { buildNavUrl } from "@/lib/navigation";
import { useToast } from "@/hooks/useToast";
interface MatchResultProps {
restaurant: Restaurant;
@@ -39,134 +53,6 @@ interface MatchResultProps {
scene?: SceneType;
}
function buildNavUrl(restaurant: Restaurant): string {
if (restaurant.location) {
const [lng, lat] = restaurant.location.split(",");
return `https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(restaurant.name)}&callnative=1`;
}
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`;
}
function NoMatchResult({
onReset,
resetting,
}: {
onReset: () => Promise<void>;
resetting: boolean;
}) {
const router = useRouter();
return (
<motion.div
className="fixed inset-0 z-50 flex flex-col items-center justify-center overflow-y-auto bg-linear-to-b from-zinc-600 to-zinc-800 px-6 py-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
<motion.div
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 12, delay: 0.2 }}
>
<SearchX size={56} className="text-zinc-400" />
</motion.div>
<motion.h1
className="mt-4 text-3xl font-black text-white"
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.35 }}
>
</motion.h1>
<motion.p
className="mt-2 max-w-[16rem] text-center text-sm leading-relaxed text-zinc-400"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.45 }}
>
</motion.p>
<motion.div
className="mt-8 flex w-full max-w-xs flex-col gap-3"
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.55 }}
>
<motion.button
onClick={onReset}
disabled={resetting}
className="flex items-center justify-center gap-2 rounded-full bg-white px-8 py-3 text-sm font-bold text-zinc-800 shadow-lg transition-colors hover:bg-zinc-100 disabled:opacity-50"
whileTap={{ scale: 0.95 }}
>
<RotateCcw size={15} className={resetting ? "animate-spin" : ""} />
{resetting ? "重置中..." : "再来一轮"}
</motion.button>
<motion.button
onClick={() => router.push("/")}
className="flex items-center justify-center gap-2 rounded-full bg-white/15 px-8 py-3 text-sm font-bold text-white backdrop-blur-sm transition-colors hover:bg-white/25"
whileTap={{ scale: 0.95 }}
>
<Home size={15} />
</motion.button>
</motion.div>
</motion.div>
);
}
function RunnerUpCard({
restaurant,
likes,
userCount,
}: {
restaurant: Restaurant;
likes: number;
userCount: number;
}) {
return (
<a
href={buildNavUrl(restaurant)}
target="_blank"
rel="noopener noreferrer"
className="flex gap-3 rounded-xl bg-white/10 p-2.5 backdrop-blur-sm transition-colors hover:bg-white/20"
>
{restaurant.images?.[0] && (
<img
src={restaurant.images[0]}
alt={restaurant.name}
className="h-16 w-16 shrink-0 rounded-lg object-cover"
referrerPolicy="no-referrer"
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className="truncate text-sm font-bold text-white">
{restaurant.name}
</p>
<div className="mt-1 flex items-center gap-2 text-xs text-white/70">
<span className="flex items-center gap-0.5">
<Star size={11} className="fill-yellow-300 text-yellow-300" />
{restaurant.rating}
</span>
<span>{restaurant.price}</span>
{restaurant.distance && (
<span className="flex items-center gap-0.5">
<MapPin size={11} />
{restaurant.distance}
</span>
)}
</div>
<p className="mt-0.5 text-[11px] font-medium text-amber-200">
{likes}/{userCount}
</p>
</div>
</a>
);
}
export default function MatchResult({
restaurant,
matchType,
@@ -183,15 +69,16 @@ export default function MatchResult({
}: MatchResultProps) {
const router = useRouter();
const [showRunnerUps, setShowRunnerUps] = useState(false);
const [toast, setToast] = useState("");
const [showShareCard, setShowShareCard] = useState(false);
const toast = useToast();
const celebratedRef = useRef(false);
const historySavedRef = useRef(false);
const isSolo = userCount <= 1;
const isUnanimous = matchType === "unanimous";
const showToast = useCallback((msg: string) => {
setToast(msg);
setTimeout(() => setToast(""), 2200);
}, []);
const [favorited, setFavorited] = useState(false);
const [favLoading, setFavLoading] = useState(false);
const [registered, setRegistered] = useState(() => isRegistered());
const [showAuth, setShowAuth] = useState(false);
useEffect(() => {
if (isUnanimous && !celebratedRef.current) {
@@ -206,7 +93,7 @@ export default function MatchResult({
useEffect(() => {
if (historySavedRef.current) return;
if (!isRegistered()) return;
if (!registered) return;
if (matchType === "no_match") return;
historySavedRef.current = true;
@@ -221,48 +108,39 @@ export default function MatchResult({
participants: userCount,
}),
}).catch(() => {});
}, [userId, roomId, restaurant, matchType, userCount]);
}, [registered, userId, roomId, restaurant, matchType, userCount]);
const handleShare = useCallback(async () => {
const verb = scene === "drink" ? "喝" : "吃";
const lines = [
isUnanimous
? `🎉 默契度 100%${userCount} 人全员一致选了同一家!`
: `🎉 我们用 NoWhatever 选好了去哪${verb}`,
``,
`📍 ${restaurant.name}`,
restaurant.rating ? `${restaurant.rating}` : "",
restaurant.price && restaurant.price !== "未知" ? `💰 人均${restaurant.price}` : "",
restaurant.address ? `📮 ${restaurant.address}` : "",
``,
isUnanimous ? `✨ 这就是心有灵犀吧~` : "",
].filter(Boolean);
const handleOpenShareCard = useCallback(() => {
setShowShareCard(true);
}, []);
const text = lines.join("\n");
const navUrl = buildNavUrl(restaurant);
const shareData = {
title: `我们选了${restaurant.name}`,
text,
url: navUrl,
};
const handleAuth = useCallback(
(profile: UserProfile) => {
setRegistered(true);
setShowAuth(false);
toast.show(`欢迎,${profile.username}记录已保存`);
},
[toast],
);
const handleFavorite = useCallback(async () => {
if (!registered || favorited || favLoading) return;
setFavLoading(true);
try {
if (navigator.share && navigator.canShare?.(shareData)) {
await navigator.share(shareData);
return;
const res = await fetch("/api/user/favorite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, restaurant }),
});
if (res.ok) {
setFavorited(true);
toast.show("已收藏");
}
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return;
}
try {
await navigator.clipboard.writeText(`${text}\n\n${navUrl}`);
showToast("已复制,快去发给朋友吧!");
} catch {
showToast("复制失败,请手动复制");
/* ignore */
}
}, [restaurant, showToast, isUnanimous, userCount, scene]);
setFavLoading(false);
}, [registered, userId, restaurant, favorited, favLoading, toast]);
if (matchType === "no_match") {
return <NoMatchResult onReset={onReset} resetting={resetting} />;
@@ -282,51 +160,61 @@ export default function MatchResult({
return (
<motion.div
className={`fixed inset-0 z-50 flex flex-col items-center overflow-y-auto px-6 py-10 ${
isUnanimous
? "bg-linear-to-b from-emerald-500 to-teal-600"
: "bg-linear-to-b from-amber-500 to-orange-500"
}`}
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-background px-6 pb-24 pt-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
<div className="flex w-full max-w-sm flex-1 flex-col items-center justify-center">
{/* Accent glow behind icon */}
<div
className={`pointer-events-none fixed left-1/2 top-0 h-72 w-72 -translate-x-1/2 -translate-y-1/3 rounded-full blur-3xl ${
isUnanimous ? "bg-emerald-500/20" : "bg-amber-500/20"
}`}
/>
<div className="relative flex w-full max-w-sm flex-1 flex-col items-center justify-center">
<motion.div
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 12, delay: 0.2 }}
transition={{
type: "spring",
stiffness: 200,
damping: 12,
delay: 0.2,
}}
>
{isUnanimous ? (
<PartyPopper size={56} className="text-yellow-300" />
<PartyPopper size={56} className="text-emerald-400" />
) : (
<Trophy size={56} className="text-yellow-200" />
<Trophy size={56} className="text-amber-400" />
)}
</motion.div>
<motion.h1
className="mt-3 text-4xl font-black text-white"
className="mt-3 text-center text-4xl font-black text-heading"
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.35 }}
>
{isSolo ? "帮你选好了" : "就去这了"}
</motion.h1>
<motion.p
className={`mt-1 text-sm font-medium ${isUnanimous ? "text-emerald-100" : "text-amber-100"}`}
className={`mt-1 text-center text-sm font-medium ${isUnanimous ? "text-emerald-400" : "text-amber-400"}`}
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.45 }}
>
{isUnanimous
{isSolo
? "你的首选,别犹豫了"
: isUnanimous
? "大家一拍即合!"
: `${matchLikes}/${userCount} 人想去这家`}
</motion.p>
{isUnanimous && (
{isUnanimous && !isSolo && (
<motion.div
className="mt-3 flex items-center gap-2 rounded-full bg-white/20 px-4 py-1.5 backdrop-blur-sm"
className="mt-3 flex items-center gap-2 rounded-full bg-emerald-500/15 px-4 py-1.5 ring-1 ring-emerald-500/30"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
@@ -336,46 +224,74 @@ export default function MatchResult({
delay: 0.55,
}}
>
<Zap size={14} className="fill-yellow-300 text-yellow-300" />
<span className="text-xs font-bold text-white">
<Zap size={14} className="fill-emerald-400 text-emerald-400" />
<span className="text-xs font-bold text-emerald-300">
100% · {userCount}
</span>
<Zap size={14} className="fill-yellow-300 text-yellow-300" />
<Zap size={14} className="fill-emerald-400 text-emerald-400" />
</motion.div>
)}
{/* Result card */}
<motion.div
className="mt-6 w-full overflow-hidden rounded-2xl bg-white shadow-2xl"
className="relative mt-6 w-full overflow-hidden rounded-2xl bg-surface shadow-2xl ring-1 ring-border"
initial={{ y: 60, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 180, damping: 18, delay: 0.5 }}
transition={{
type: "spring",
stiffness: 180,
damping: 18,
delay: 0.5,
}}
>
{registered && (
<motion.button
onClick={handleFavorite}
disabled={favLoading}
className="absolute right-3 top-3 z-10 rounded-full bg-black/40 p-2 backdrop-blur-sm transition-colors hover:bg-black/60 disabled:opacity-50"
whileTap={{ scale: 0.85 }}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
delay: 0.7,
type: "spring",
stiffness: 300,
damping: 15,
}}
>
<Heart
size={18}
className={
favorited ? "fill-red-500 text-red-500" : "text-white"
}
/>
</motion.button>
)}
{restaurant.images?.[0] && (
<img
<RestaurantImage
src={restaurant.images[0]}
alt={restaurant.name}
className="h-44 w-full object-cover"
referrerPolicy="no-referrer"
/>
)}
<div className="p-4">
<div className="flex items-start justify-between gap-2">
<h2 className="text-lg font-bold leading-tight text-zinc-900">
<h2 className="text-lg font-bold leading-tight text-heading">
{restaurant.name}
</h2>
{restaurant.category && (
<span className="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold text-emerald-600">
<span className="shrink-0 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold text-emerald-400">
{restaurant.category}
</span>
)}
</div>
<div className="mt-2 flex items-center gap-3 text-sm text-zinc-500">
<div className="mt-2 flex items-center gap-3 text-sm text-muted">
<span className="flex items-center gap-1">
<Star size={13} className="fill-amber-400 text-amber-400" />
{restaurant.rating}
</span>
<span className="font-semibold text-emerald-600">
<span className="font-semibold text-emerald-400">
{restaurant.price}
</span>
{restaurant.distance && (
@@ -387,13 +303,13 @@ export default function MatchResult({
</div>
{restaurant.address && (
<p className="mt-2 text-xs leading-relaxed text-zinc-400">
<p className="mt-2 text-xs leading-relaxed text-muted">
{restaurant.address}
</p>
)}
{restaurant.openTime && (
<div className="mt-1.5 flex items-center gap-1 text-xs text-zinc-400">
<div className="mt-1.5 flex items-center gap-1 text-xs text-muted">
<Clock size={12} />
<span>{restaurant.openTime}</span>
</div>
@@ -407,7 +323,7 @@ export default function MatchResult({
.map((t) => (
<span
key={t}
className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700"
className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400"
>
{t.trim()}
</span>
@@ -417,6 +333,7 @@ export default function MatchResult({
</div>
</motion.div>
{/* Action buttons */}
<motion.div
className="mt-5 flex w-full flex-col gap-2.5"
initial={{ y: 30, opacity: 0 }}
@@ -427,9 +344,7 @@ export default function MatchResult({
href={buildNavUrl(restaurant)}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center gap-2 rounded-full bg-white px-8 py-3 text-sm font-bold shadow-lg transition-colors hover:bg-emerald-50 ${
isUnanimous ? "text-emerald-600" : "text-orange-600"
}`}
className="flex items-center justify-center gap-2 rounded-full bg-accent px-8 py-3 text-sm font-bold text-white shadow-lg shadow-accent/20 transition-colors hover:bg-accent-hover"
whileTap={{ scale: 0.95 }}
>
<Navigation size={16} />
@@ -439,7 +354,7 @@ export default function MatchResult({
{restaurant.tel && (
<motion.a
href={`tel:${restaurant.tel}`}
className="flex items-center justify-center gap-2 rounded-full bg-white/20 px-8 py-3 text-sm font-bold text-white backdrop-blur-sm transition-colors hover:bg-white/30"
className="flex items-center justify-center gap-2 rounded-full bg-surface px-8 py-3 text-sm font-bold text-secondary ring-1 ring-border transition-colors hover:bg-elevated"
whileTap={{ scale: 0.95 }}
>
<Phone size={15} />
@@ -447,16 +362,43 @@ export default function MatchResult({
</motion.a>
)}
<motion.button
onClick={handleShare}
className="flex items-center justify-center gap-2 rounded-full bg-white/20 px-8 py-3 text-sm font-bold text-white backdrop-blur-sm transition-colors hover:bg-white/30"
whileTap={{ scale: 0.95 }}
<Button
onClick={handleOpenShareCard}
variant="secondary"
shape="pill"
icon={<Share2 size={15} />}
className="px-8 py-3"
>
<Share2 size={15} />
</motion.button>
</Button>
</motion.div>
{/* Registration nudge */}
{!registered && (
<motion.div
className="mt-5 w-full rounded-2xl bg-surface/80 p-4 ring-1 ring-border/50 backdrop-blur-sm"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.75 }}
>
<p className="text-sm font-medium text-secondary">
</p>
<p className="mt-1 text-xs text-muted">
10
</p>
<Button
onClick={() => setShowAuth(true)}
fullWidth
icon={<UserPlus size={15} />}
className="mt-3"
>
</Button>
</motion.div>
)}
{/* Runner ups */}
{!isUnanimous && runnerUpRestaurants.length > 0 && (
<motion.div
className="mt-5 w-full"
@@ -466,7 +408,7 @@ export default function MatchResult({
>
<button
onClick={() => setShowRunnerUps((v) => !v)}
className="flex w-full items-center justify-center gap-1.5 py-2 text-xs font-semibold text-white/80 transition-colors hover:text-white"
className="flex w-full items-center justify-center gap-1.5 py-2 text-xs font-semibold text-muted transition-colors hover:text-foreground"
>
{runnerUpRestaurants.length}
<motion.span
@@ -497,72 +439,75 @@ export default function MatchResult({
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
<motion.div
className="mt-5 flex w-full flex-col items-center gap-2.5"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
>
{canNarrow ? (
<>
{canNarrow && (
<div className="mt-4 flex flex-col items-center gap-2">
<p className="text-xs text-muted">
{runnerUpRestaurants.length}
</p>
<motion.button
onClick={() => onNarrow(narrowIds)}
disabled={resetting}
className="flex w-full items-center justify-center gap-2 rounded-full bg-white/20 px-8 py-3 text-sm font-bold text-white backdrop-blur-sm transition-colors hover:bg-white/30 disabled:opacity-50"
className="flex w-full items-center justify-center gap-2 rounded-full bg-elevated px-8 py-3 text-sm font-bold text-secondary ring-1 ring-border transition-colors hover:bg-subtle disabled:opacity-50"
whileTap={{ scale: 0.95 }}
>
<Swords size={15} />
{resetting ? "加载中..." : `Top ${narrowIds.length} 决赛`}
</motion.button>
<motion.button
onClick={() => router.push("/")}
className="flex items-center gap-1.5 text-sm font-medium text-amber-200 underline underline-offset-2 hover:text-white"
</div>
)}
</motion.div>
)}
</div>
{/* Floating bottom bar */}
<motion.div
className="fixed inset-x-0 bottom-0 z-10 border-t border-border/50 bg-background/80 backdrop-blur-xl"
initial={{ y: 60, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.9, type: "spring", stiffness: 200, damping: 20 }}
>
<RefreshCw size={13} />
</motion.button>
</>
) : (
<>
<div className="mx-auto flex max-w-sm items-center gap-3 px-6 pb-6 pt-3">
<span className="shrink-0 text-xs text-muted"></span>
<motion.button
onClick={onReset}
disabled={resetting}
className="flex items-center justify-center gap-2 rounded-full bg-white/20 px-8 py-3 text-sm font-bold text-white backdrop-blur-sm transition-colors hover:bg-white/30 disabled:opacity-50"
className="flex flex-1 items-center justify-center gap-1.5 rounded-full bg-elevated py-2.5 text-xs font-bold text-secondary ring-1 ring-border transition-colors hover:bg-subtle disabled:opacity-50"
whileTap={{ scale: 0.95 }}
>
<RotateCcw size={14} className={resetting ? "animate-spin" : ""} />
<RotateCcw size={12} className={resetting ? "animate-spin" : ""} />
{resetting ? "重置中..." : "再来一轮"}
</motion.button>
<motion.button
onClick={() => router.push("/")}
className={`flex items-center gap-1.5 text-sm font-medium underline underline-offset-2 hover:text-white ${
isUnanimous ? "text-emerald-200" : "text-amber-200"
}`}
className="flex flex-1 items-center justify-center gap-1.5 rounded-full bg-elevated py-2.5 text-xs font-bold text-secondary ring-1 ring-border transition-colors hover:bg-subtle"
whileTap={{ scale: 0.95 }}
>
<RefreshCw size={13} />
<RefreshCw size={12} />
</motion.button>
</>
)}
</motion.div>
</div>
<AnimatePresence>
{toast && (
<motion.div
className="fixed left-1/2 top-10 z-60 -translate-x-1/2 rounded-xl bg-zinc-900 px-4 py-2.5 text-xs font-medium text-white shadow-lg"
initial={{ opacity: 0, y: -12, x: "-50%" }}
animate={{ opacity: 1, y: 0, x: "-50%" }}
exit={{ opacity: 0, y: -12, x: "-50%" }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
{toast}
</motion.div>
)}
</AnimatePresence>
<AuthModal
open={showAuth}
onClose={() => setShowAuth(false)}
onAuth={handleAuth}
defaultTab="register"
/>
<ShareCardModal
open={showShareCard}
onClose={() => setShowShareCard(false)}
data={{
type: "restaurant",
restaurant,
matchType,
matchLikes,
userCount,
scene,
}}
/>
</motion.div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
type ModalVariant = "sheet" | "dialog";
interface ModalProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
variant?: ModalVariant;
}
const sheet = {
backdrop:
"fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm sm:items-center",
content:
"relative w-full max-w-sm rounded-t-3xl bg-surface px-5 pb-8 pt-5 shadow-2xl ring-1 ring-border sm:rounded-3xl sm:pb-6",
initial: { y: "100%" },
animate: { y: 0 },
exit: { y: "100%" },
transition: { type: "spring" as const, damping: 28, stiffness: 350 },
};
const dialog = {
backdrop:
"fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",
content:
"mx-6 w-full max-w-xs rounded-2xl bg-surface px-6 py-6 shadow-2xl ring-1 ring-border",
initial: { scale: 0.9, opacity: 0 },
animate: { scale: 1, opacity: 1 },
exit: { scale: 0.9, opacity: 0 },
transition: { type: "spring" as const, damping: 25, stiffness: 350 },
};
const variants = { sheet, dialog };
export default function Modal({
open,
onClose,
children,
variant = "sheet",
}: ModalProps) {
const backdropRef = useRef<HTMLDivElement>(null);
const v = variants[variant];
return (
<AnimatePresence>
{open && (
<motion.div
ref={backdropRef}
className={v.backdrop}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={(e) => {
if (e.target === backdropRef.current) onClose();
}}
>
<motion.div
className={v.content}
initial={v.initial}
animate={v.animate}
exit={v.exit}
transition={v.transition}
>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { motion } from "framer-motion";
import { useRouter } from "next/navigation";
import { SearchX, RotateCcw, Home } from "lucide-react";
import Button from "@/components/Button";
interface NoMatchResultProps {
onReset: () => Promise<void>;
resetting: boolean;
}
export default function NoMatchResult({ onReset, resetting }: NoMatchResultProps) {
const router = useRouter();
return (
<motion.div
className="fixed inset-0 z-50 flex flex-col items-center justify-center overflow-y-auto bg-background px-6 py-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
<motion.div
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 12, delay: 0.2 }}
>
<SearchX size={56} className="text-muted" />
</motion.div>
<motion.h1
className="mt-4 text-3xl font-black text-heading"
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.35 }}
>
</motion.h1>
<motion.p
className="mt-2 max-w-[16rem] text-center text-sm leading-relaxed text-muted"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.45 }}
>
</motion.p>
<motion.div
className="mt-8 flex w-full max-w-xs flex-col gap-3"
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.55 }}
>
<Button
onClick={onReset}
shape="pill"
loading={resetting}
loadingText="重置中..."
icon={<RotateCcw size={15} />}
className="px-8 py-3"
>
</Button>
<Button
onClick={() => router.push("/")}
variant="secondary"
shape="pill"
icon={<Home size={15} />}
className="px-8 py-3"
>
</Button>
</motion.div>
</motion.div>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useContext, useRef, type PropsWithChildren } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
/**
* Preserves the previous route's React context during the exit animation
* so the old page doesn't break while fading out.
*/
function FrozenRoute({ children }: PropsWithChildren) {
const ctx = useContext(LayoutRouterContext);
const frozen = useRef(ctx).current;
return (
<LayoutRouterContext.Provider value={frozen}>
{children}
</LayoutRouterContext.Provider>
);
}
const variants = {
enter: { opacity: 0 },
center: { opacity: 1 },
exit: { opacity: 0 },
};
export default function PageTransition({ children }: PropsWithChildren) {
const pathname = usePathname();
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
>
<FrozenRoute>{children}</FrozenRoute>
</motion.div>
</AnimatePresence>
);
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { Star, MapPin, ChevronDown, Trash2, Heart } from "lucide-react";
import Card from "@/components/Card";
import EmptyState from "@/components/EmptyState";
import RestaurantImage from "@/components/RestaurantImage";
import { RecordItemSkeleton } from "@/components/Skeleton";
import type { FavoriteRecord, Restaurant } from "@/types";
function firstImage(r: Restaurant): string {
if (r.images?.length > 0) return r.images[0];
const legacy = (r as unknown as Record<string, unknown>).image;
return typeof legacy === "string" ? legacy : "";
}
interface ProfileFavoritesCardProps {
favorites: FavoriteRecord[];
loading: boolean;
open: boolean;
onToggle: () => void;
onRemove: (id: string) => Promise<void>;
onEmpty: () => void;
delay?: number;
}
export default function ProfileFavoritesCard({
favorites,
loading,
open,
onToggle,
onRemove,
onEmpty,
delay,
}: ProfileFavoritesCardProps) {
return (
<Card animated className="mt-4" delay={delay}>
<button
onClick={onToggle}
className="flex w-full items-center justify-between"
>
<div className="flex items-center gap-2">
<Star size={15} className="text-muted" />
<h3 className="text-sm font-semibold text-secondary">
{favorites.length > 0 && `(${favorites.length})`}
</h3>
</div>
<motion.span
animate={{ rotate: open ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="text-muted"
>
<ChevronDown size={16} />
</motion.span>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
{loading ? (
<div className="mt-3 flex flex-col gap-2">
<RecordItemSkeleton />
<RecordItemSkeleton />
</div>
) : favorites.length === 0 ? (
<EmptyState
icon={Heart}
title="还没有收藏的餐厅"
subtitle="在匹配结果中收藏喜欢的店"
ctaLabel="去创建第一个房间"
onCta={onEmpty}
color="amber"
/>
) : (
<div className="mt-3 flex flex-col gap-2">
{favorites.map((f) => {
const r = f.restaurantData;
return (
<div
key={f.id}
className="flex gap-3 rounded-xl bg-elevated p-2.5"
>
{firstImage(r) && (
<RestaurantImage
src={firstImage(r)}
alt={r.name}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className="truncate text-sm font-semibold text-heading">{r.name}</p>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
<span className="flex items-center gap-0.5">
<Star size={10} className="fill-amber-400 text-amber-400" />
{r.rating}
</span>
<span>{r.price}</span>
{r.distance && (
<span className="flex items-center gap-0.5">
<MapPin size={10} />
{r.distance}
</span>
)}
</div>
</div>
<button
onClick={() => onRemove(f.id)}
className="flex h-8 w-8 shrink-0 items-center justify-center self-center rounded-full text-muted transition-colors active:bg-subtle active:text-rose-400"
>
<Trash2 size={14} />
</button>
</div>
);
})}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</Card>
);
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { Clock, ChevronDown, ClipboardList } from "lucide-react";
import Card from "@/components/Card";
import EmptyState from "@/components/EmptyState";
import RestaurantImage from "@/components/RestaurantImage";
import { RecordItemSkeleton } from "@/components/Skeleton";
import { buildNavUrl } from "@/lib/navigation";
import type { DecisionRecord, Restaurant } from "@/types";
function firstImage(r: Restaurant): string {
if (r.images?.length > 0) return r.images[0];
const legacy = (r as unknown as Record<string, unknown>).image;
return typeof legacy === "string" ? legacy : "";
}
interface ProfileHistoryCardProps {
history: DecisionRecord[];
loading: boolean;
open: boolean;
onToggle: () => void;
onEmpty: () => void;
delay?: number;
}
export default function ProfileHistoryCard({
history,
loading,
open,
onToggle,
onEmpty,
delay,
}: ProfileHistoryCardProps) {
return (
<Card animated className="mt-4" delay={delay}>
<button
onClick={onToggle}
className="flex w-full items-center justify-between"
>
<div className="flex items-center gap-2">
<Clock size={15} className="text-muted" />
<h3 className="text-sm font-semibold text-secondary">
{history.length > 0 && `(${history.length})`}
</h3>
</div>
<motion.span
animate={{ rotate: open ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="text-muted"
>
<ChevronDown size={16} />
</motion.span>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
{loading ? (
<div className="mt-3 flex flex-col gap-2">
<RecordItemSkeleton />
<RecordItemSkeleton />
</div>
) : history.length === 0 ? (
<EmptyState
icon={ClipboardList}
title="还没有决策记录"
subtitle="创建房间开始一起选餐厅"
ctaLabel="去创建第一个房间"
onCta={onEmpty}
color="purple"
/>
) : (
<div className="mt-3 flex flex-col gap-2">
{history.map((d) => (
<a
key={d.id}
href={buildNavUrl(d.restaurantData)}
target="_blank"
rel="noopener noreferrer"
className="flex gap-3 rounded-xl bg-elevated p-2.5 transition-colors active:bg-subtle"
>
{firstImage(d.restaurantData) && (
<RestaurantImage
src={firstImage(d.restaurantData)}
alt={d.restaurantName}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className="truncate text-sm font-semibold text-heading">{d.restaurantName}</p>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
<span>{d.matchType === "unanimous" ? "全员一致" : "最佳匹配"}</span>
<span>{d.participants} </span>
<span>{new Date(d.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" })}</span>
</div>
</div>
</a>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</Card>
);
}
+34 -73
View File
@@ -1,17 +1,18 @@
"use client";
import { useCallback, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useCallback } from "react";
import { QRCodeSVG } from "qrcode.react";
import { X, Copy, Share2, QrCode } from "lucide-react";
import type { SceneType } from "@/types";
import { getSceneConfig } from "@/lib/sceneConfig";
import Modal from "@/components/Modal";
import Button from "@/components/Button";
import { useShare } from "@/hooks/useShare";
interface QrInviteModalProps {
open: boolean;
onClose: () => void;
roomId: string;
onToast: (msg: string) => void;
scene?: SceneType;
}
@@ -19,120 +20,80 @@ export default function QrInviteModal({
open,
onClose,
roomId,
onToast,
scene = "eat",
}: QrInviteModalProps) {
const { share, copyToClipboard } = useShare();
const sceneConfig = getSceneConfig(scene);
const inviteUrl =
typeof window !== "undefined"
? `${window.location.origin}/invite/${roomId}`
: "";
const backdropRef = useRef<HTMLDivElement>(null);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === backdropRef.current) onClose();
};
const handleCopy = useCallback(
() => copyToClipboard(inviteUrl, "邀请链接已复制,快去发给朋友吧!"),
[inviteUrl, copyToClipboard],
);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(inviteUrl);
onToast("邀请链接已复制,快去发给朋友吧!");
} catch {
onToast("复制失败,请手动复制链接");
}
}, [inviteUrl, onToast]);
const handleShare = useCallback(async () => {
const shareData = {
title: sceneConfig.shareTitle,
text: sceneConfig.shareText,
url: inviteUrl,
};
try {
if (navigator.share && navigator.canShare?.(shareData)) {
await navigator.share(shareData);
return;
}
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return;
}
handleCopy();
}, [inviteUrl, handleCopy, sceneConfig]);
const handleShare = useCallback(
() => share({ title: sceneConfig.shareTitle, text: sceneConfig.shareText, url: inviteUrl }, handleCopy),
[inviteUrl, sceneConfig, share, handleCopy],
);
return (
<AnimatePresence>
{open && (
<motion.div
ref={backdropRef}
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={handleBackdropClick}
>
<motion.div
className="relative w-full max-w-sm rounded-t-3xl bg-white px-6 pb-8 pt-5 shadow-2xl sm:rounded-3xl sm:pb-6"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", damping: 28, stiffness: 350 }}
>
<Modal open={open} onClose={onClose}>
<button
onClick={onClose}
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-zinc-100 text-zinc-400 transition-colors active:bg-zinc-200"
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle"
>
<X size={16} />
</button>
<div className="flex flex-col items-center">
<div className="flex items-center gap-2 text-zinc-800">
<QrCode size={18} className="text-emerald-500" />
<div className="flex items-center gap-2 text-heading">
<QrCode size={18} className="text-accent" />
<h2 className="text-lg font-bold"></h2>
</div>
<p className="mt-1 text-xs text-zinc-400">
<p className="mt-1 text-xs text-muted">
{sceneConfig.qrSubtitle}
</p>
<div className="mt-5 rounded-2xl border-2 border-dashed border-emerald-200 bg-emerald-50/30 p-4">
<div className="mt-5 rounded-2xl border-2 border-dashed border-subtle bg-elevated/50 p-4">
<QRCodeSVG
value={inviteUrl}
size={180}
level="M"
bgColor="transparent"
fgColor="#18181b"
fgColor="#e5e7eb"
/>
</div>
<div className="mt-4 flex items-center gap-2">
<span className="text-xs text-zinc-400"></span>
<span className="rounded-full bg-zinc-100 px-3 py-1 font-mono text-base font-bold tracking-[0.2em] text-emerald-600">
<span className="text-xs text-muted"></span>
<span className="rounded-full bg-elevated px-3 py-1 font-mono text-base font-bold tracking-[0.2em] text-accent ring-1 ring-border">
{roomId}
</span>
</div>
<div className="mt-5 flex w-full gap-2.5">
<button
<Button
onClick={handleCopy}
className="flex h-11 flex-1 items-center justify-center gap-1.5 rounded-xl border border-zinc-200 bg-white text-sm font-semibold text-zinc-700 transition-colors active:bg-zinc-50"
variant="secondary"
size="lg"
icon={<Copy size={15} />}
className="flex-1"
>
<Copy size={15} />
</button>
<button
</Button>
<Button
onClick={handleShare}
className="flex h-11 flex-1 items-center justify-center gap-1.5 rounded-xl bg-emerald-500 text-sm font-semibold text-white shadow-md shadow-emerald-200 transition-colors active:bg-emerald-600"
size="lg"
icon={<Share2 size={15} />}
className="flex-1"
>
<Share2 size={15} />
</button>
</Button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</Modal>
);
}
+49 -22
View File
@@ -1,9 +1,11 @@
"use client";
import { useCallback, useState, useEffect } from "react";
import { useCallback, useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Star, MapPin, Clock, ExternalLink, Flame, Bookmark, ChevronLeft, ChevronRight } from "lucide-react";
import { Restaurant } from "@/types";
import { getUserId, isRegistered } from "@/lib/userId";
import RestaurantImage from "@/components/RestaurantImage";
interface RestaurantCardProps {
restaurant: Restaurant;
@@ -57,16 +59,15 @@ function ImageGallery({ images, name }: { images: string[]; name: string }) {
return (
<div className="relative h-full w-full" onClick={handleTap} onPointerDown={stopAll}>
<img
<RestaurantImage
src={images[idx]}
alt={name}
className="absolute inset-0 h-full w-full object-cover"
draggable={false}
referrerPolicy="no-referrer"
/>
{fadingOut !== null && (
<img
<RestaurantImage
key={fadingOut}
src={images[fadingOut]}
alt=""
@@ -74,7 +75,6 @@ function ImageGallery({ images, name }: { images: string[]; name: string }) {
style={{ animation: "img-fade-out 280ms ease-out forwards" }}
onAnimationEnd={() => setFadingOut(null)}
draggable={false}
referrerPolicy="no-referrer"
/>
)}
@@ -119,6 +119,17 @@ function ImageGallery({ images, name }: { images: string[]; name: string }) {
export default function RestaurantCard({ restaurant, likeCount = 0 }: RestaurantCardProps) {
const [favorited, setFavorited] = useState(false);
const [likeBounce, setLikeBounce] = useState(false);
const prevLikeRef = useRef(likeCount);
useEffect(() => {
if (likeCount > prevLikeRef.current) {
setLikeBounce(true);
const t = setTimeout(() => setLikeBounce(false), 600);
return () => clearTimeout(t);
}
prevLikeRef.current = likeCount;
}, [likeCount]);
const images = restaurant.images?.filter(Boolean);
const hasImage = images && images.length > 0;
@@ -150,29 +161,45 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
const dianpingUrl = `https://m.dianping.com/search/keyword/0/0_${encodeURIComponent(restaurant.name)}`;
return (
<div className="flex h-full w-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
<div className="relative h-[58%] w-full shrink-0 overflow-hidden bg-zinc-100">
<div className="flex h-full w-full flex-col overflow-hidden rounded-2xl bg-surface shadow-xl ring-1 ring-border">
<div className="relative h-[58%] w-full shrink-0 overflow-hidden bg-elevated">
{hasImage && <ImageGallery images={images} name={restaurant.name} />}
<div className="pointer-events-none absolute inset-0 bg-linear-to-t from-black/40 via-transparent to-transparent" />
<div className="absolute bottom-3 left-4 flex items-center gap-1.5">
{restaurant.category && (
<span className="rounded-full bg-white/90 px-2.5 py-0.5 text-xs font-semibold text-zinc-700 shadow-sm backdrop-blur-sm">
<span className="rounded-full bg-white/80 px-2.5 py-0.5 text-xs font-semibold text-gray-800 shadow-sm backdrop-blur-sm">
{restaurant.category}
</span>
)}
<AnimatePresence>
{likeCount > 0 && (
<span className="flex items-center gap-0.5 rounded-full bg-rose-500/90 px-2 py-0.5 text-xs font-semibold text-white shadow-sm backdrop-blur-sm">
<Flame size={11} />
<motion.span
key="like-badge"
className="flex items-center gap-0.5 rounded-full bg-rose-500/90 px-2 py-0.5 text-xs font-semibold text-white shadow-sm backdrop-blur-sm"
initial={{ opacity: 0, scale: 0.5, x: -8 }}
animate={{
opacity: 1,
scale: likeBounce ? [1, 1.3, 1] : 1,
x: 0,
}}
exit={{ opacity: 0, scale: 0.5 }}
transition={likeBounce
? { scale: { duration: 0.4, ease: "easeInOut" }, default: { type: "spring", stiffness: 400, damping: 20 } }
: { type: "spring", stiffness: 400, damping: 20 }
}
>
<Flame size={11} className={likeBounce ? "animate-pulse" : ""} />
{likeCount}
</span>
</motion.span>
)}
</AnimatePresence>
</div>
</div>
<div className="flex flex-1 flex-col justify-center gap-2 px-5 py-3">
<div className="flex items-start justify-between gap-2">
<h2 className="text-lg font-bold leading-tight text-zinc-900">
<h2 className="text-lg font-bold leading-tight text-heading">
{restaurant.name}
</h2>
<div className="mt-0.5 flex shrink-0 gap-1.5">
@@ -183,8 +210,8 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
onTouchStart={stopAll}
className={`flex items-center justify-center rounded-full p-1 transition-colors ${
favorited
? "bg-amber-100 text-amber-500"
: "bg-zinc-50 text-zinc-400 active:bg-amber-50 active:text-amber-500"
? "bg-amber-500/20 text-amber-400"
: "bg-elevated text-muted active:bg-amber-500/15 active:text-amber-400"
}`}
>
<Bookmark size={13} className={favorited ? "fill-amber-400" : ""} />
@@ -194,7 +221,7 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
onClick={openLink(amapUrl)}
onPointerDown={stopAll}
onTouchStart={stopAll}
className="flex items-center gap-0.5 rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-500 transition-colors active:bg-blue-100"
className="flex items-center gap-0.5 rounded-full bg-blue-500/15 px-2 py-0.5 text-[11px] font-medium text-blue-400 transition-colors active:bg-blue-500/25"
>
<ExternalLink size={10} />
@@ -203,7 +230,7 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
onClick={openLink(dianpingUrl)}
onPointerDown={stopAll}
onTouchStart={stopAll}
className="flex items-center gap-0.5 rounded-full bg-orange-50 px-2 py-0.5 text-[11px] font-medium text-orange-500 transition-colors active:bg-orange-100"
className="flex items-center gap-0.5 rounded-full bg-orange-500/15 px-2 py-0.5 text-[11px] font-medium text-orange-400 transition-colors active:bg-orange-500/25"
>
<ExternalLink size={10} />
@@ -214,17 +241,17 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<div className="flex items-center gap-1">
<Star size={14} className="fill-amber-400 text-amber-400" />
<span className="text-sm font-semibold text-zinc-800">
<span className="text-sm font-semibold text-foreground">
{restaurant.rating}
</span>
</div>
<span className="text-sm font-semibold text-emerald-600">
<span className="text-sm font-semibold text-emerald-400">
{restaurant.price}
</span>
{restaurant.distance && (
<div className="flex items-center gap-1 text-zinc-400">
<div className="flex items-center gap-1 text-muted">
<MapPin size={13} />
<span className="text-xs">{restaurant.distance}</span>
</div>
@@ -232,13 +259,13 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
</div>
{restaurant.address && (
<p className="truncate text-xs leading-tight text-zinc-400">
<p className="truncate text-xs leading-tight text-muted">
{restaurant.address}
</p>
)}
{restaurant.openTime && (
<div className="flex items-center gap-1 text-xs text-zinc-400">
<div className="flex items-center gap-1 text-xs text-muted">
<Clock size={12} />
<span>{restaurant.openTime}</span>
</div>
@@ -252,7 +279,7 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
.map((t) => (
<span
key={t}
className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700"
className="shrink-0 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400"
>
{t.trim()}
</span>
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useState, useCallback } from "react";
import { UtensilsCrossed } from "lucide-react";
interface RestaurantImageProps {
src: string;
alt: string;
className?: string;
draggable?: boolean;
style?: React.CSSProperties;
onAnimationEnd?: () => void;
}
export default function RestaurantImage({
src,
alt,
className = "",
draggable,
style,
onAnimationEnd,
}: RestaurantImageProps) {
const [failed, setFailed] = useState(false);
const handleError = useCallback(() => setFailed(true), []);
if (failed) {
return (
<div
className={`flex items-center justify-center bg-elevated ${className}`}
style={style}
>
<UtensilsCrossed className="h-1/3 w-1/3 max-h-8 max-w-8 text-muted/40" strokeWidth={1.5} />
</div>
);
}
return (
<img
src={src}
alt={alt}
className={className}
draggable={draggable}
style={style}
referrerPolicy="no-referrer"
onError={handleError}
onAnimationEnd={onAnimationEnd}
/>
);
}
+388
View File
@@ -0,0 +1,388 @@
import { Star, MapPin, Zap } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import type { Restaurant, MatchType, SceneType } from "@/types";
import { getSceneConfig } from "@/lib/sceneConfig";
export interface RestaurantShareData {
type: "restaurant";
restaurant: Restaurant;
matchType: MatchType;
matchLikes: number;
userCount: number;
scene?: SceneType;
}
export default function RestaurantShareCard({
data,
cardRef,
imageDataUrl,
}: {
data: RestaurantShareData;
cardRef: React.RefObject<HTMLDivElement | null>;
imageDataUrl: string | null;
}) {
const { restaurant, matchType, matchLikes, userCount, scene } = data;
const isUnanimous = matchType === "unanimous";
const verb = getSceneConfig(scene ?? "eat").verb;
const shareUrl =
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
const accentFrom = isUnanimous ? "#059669" : "#b45309";
const accentTo = isUnanimous ? "#34d399" : "#fbbf24";
const accentText = isUnanimous ? "#6ee7b7" : "#fcd34d";
const accentBg = isUnanimous
? "rgba(16, 185, 129, 0.12)"
: "rgba(245, 158, 11, 0.12)";
return (
<div
ref={cardRef}
style={{
width: 340,
padding: 1.5,
borderRadius: 20,
background: `linear-gradient(160deg, ${accentFrom}, ${accentTo}40, ${accentFrom}30)`,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
<div
style={{
borderRadius: 18.5,
background: "#08080a",
position: "relative",
overflow: "hidden",
}}
>
{/* Decorative glows */}
<div
style={{
position: "absolute",
top: -40,
right: -30,
width: 160,
height: 160,
borderRadius: "50%",
background: `radial-gradient(circle, ${accentFrom}25, transparent 70%)`,
}}
/>
<div
style={{
position: "absolute",
bottom: 60,
left: -40,
width: 120,
height: 120,
borderRadius: "50%",
background: `radial-gradient(circle, ${accentTo}12, transparent 70%)`,
}}
/>
{/* Brand header */}
<div
style={{
padding: "14px 20px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
position: "relative",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 18 }}></span>
<div>
<div
style={{
fontSize: 13,
fontWeight: 800,
color: "#ffffff",
letterSpacing: "0.02em",
}}
>
NoWhatever
</div>
<div
style={{
fontSize: 9,
fontWeight: 600,
color: "rgba(255,255,255,0.35)",
letterSpacing: "0.15em",
marginTop: 1,
}}
>
便 · PANIC MODE
</div>
</div>
</div>
</div>
{/* Thin accent line */}
<div
style={{
height: 1,
margin: "0 20px",
background: `linear-gradient(to right, transparent, ${accentTo}30, transparent)`,
}}
/>
{/* Hero section */}
<div
style={{
padding: "24px 20px 20px",
textAlign: "center",
position: "relative",
}}
>
<div style={{ fontSize: 40, lineHeight: 1 }}>
{isUnanimous ? "🎉" : "🏆"}
</div>
<div
style={{
fontSize: 30,
fontWeight: 900,
color: "#ffffff",
marginTop: 12,
letterSpacing: "0.08em",
}}
>
{verb}
</div>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
marginTop: 12,
padding: "6px 16px",
borderRadius: 100,
background: accentBg,
border: `1px solid ${accentTo}20`,
}}
>
{isUnanimous && (
<Zap
size={12}
style={{ color: accentText, fill: accentText }}
/>
)}
<span
style={{
fontSize: 11,
fontWeight: 700,
color: accentText,
}}
>
{isUnanimous
? `默契度 100% · ${userCount}人全员一致`
: `${matchLikes}/${userCount} 人选了这家`}
</span>
{isUnanimous && (
<Zap
size={12}
style={{ color: accentText, fill: accentText }}
/>
)}
</div>
</div>
{/* Restaurant card */}
<div style={{ padding: "0 16px 16px" }}>
<div
style={{
borderRadius: 14,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.06)",
overflow: "hidden",
}}
>
{imageDataUrl && (
<img
src={imageDataUrl}
alt={restaurant.name}
style={{
width: "100%",
height: 150,
objectFit: "cover",
display: "block",
}}
/>
)}
<div style={{ padding: "14px 16px 16px" }}>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 8,
}}
>
<div
style={{
fontSize: 17,
fontWeight: 800,
color: "#ffffff",
lineHeight: 1.3,
}}
>
{restaurant.name}
</div>
{restaurant.category && (
<span
style={{
flexShrink: 0,
padding: "3px 8px",
borderRadius: 100,
background: accentBg,
fontSize: 10,
fontWeight: 600,
color: accentText,
whiteSpace: "nowrap",
}}
>
{restaurant.category}
</span>
)}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginTop: 10,
fontSize: 12,
}}
>
{restaurant.rating > 0 && (
<span
style={{
display: "flex",
alignItems: "center",
gap: 3,
color: "#e5e7eb",
fontWeight: 600,
}}
>
<Star
size={13}
style={{ color: "#fbbf24", fill: "#fbbf24" }}
/>
{restaurant.rating}
</span>
)}
{restaurant.price && restaurant.price !== "未知" && (
<span style={{ fontWeight: 700, color: accentText }}>
{restaurant.price}
</span>
)}
{restaurant.distance && (
<span
style={{
display: "flex",
alignItems: "center",
gap: 3,
color: "#9ca3af",
}}
>
<MapPin size={12} style={{ color: "#6b7280" }} />
{restaurant.distance}
</span>
)}
</div>
{restaurant.address && (
<div
style={{
marginTop: 8,
fontSize: 11,
color: "#6b7280",
lineHeight: 1.5,
}}
>
📍 {restaurant.address}
</div>
)}
{restaurant.tag && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
marginTop: 10,
}}
>
{restaurant.tag
.split(",")
.slice(0, 4)
.map((t) => (
<span
key={t}
style={{
padding: "2px 8px",
borderRadius: 4,
background: "rgba(255,255,255,0.05)",
fontSize: 10,
fontWeight: 500,
color: "#9ca3af",
}}
>
{t.trim()}
</span>
))}
</div>
)}
</div>
</div>
</div>
{/* QR footer */}
<div
style={{
padding: "14px 20px 16px",
display: "flex",
alignItems: "center",
gap: 14,
borderTop: "1px solid rgba(255,255,255,0.04)",
}}
>
<div
style={{
padding: 5,
borderRadius: 8,
background: "#ffffff",
flexShrink: 0,
}}
>
<QRCodeSVG
value={shareUrl}
size={52}
level="M"
bgColor="#ffffff"
fgColor="#08080a"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "rgba(255,255,255,0.7)",
}}
>
便
</div>
<div
style={{
fontSize: 10,
color: "rgba(255,255,255,0.25)",
marginTop: 3,
}}
>
{shareUrl.replace(/^https?:\/\//, "")}
</div>
</div>
</div>
</div>
</div>
);
}
+33 -68
View File
@@ -1,7 +1,6 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useCallback } from "react";
import {
X,
Lock,
@@ -12,7 +11,9 @@ import {
Loader2,
} from "lucide-react";
import { UserProfile } from "@/types";
import { getAvatar, getAvatarBg } from "@/lib/avatars";
import UserAvatar from "@/components/UserAvatar";
import Modal from "@/components/Modal";
import { useToast } from "@/hooks/useToast";
interface RoomManageModalProps {
open: boolean;
@@ -24,7 +25,6 @@ interface RoomManageModalProps {
swipeCounts: Record<string, number>;
totalCards: number;
userProfiles: Record<string, UserProfile>;
onToast: (msg: string) => void;
}
export default function RoomManageModal({
@@ -37,17 +37,12 @@ export default function RoomManageModal({
swipeCounts,
totalCards,
userProfiles,
onToast,
}: RoomManageModalProps) {
const backdropRef = useRef<HTMLDivElement>(null);
const toast = useToast();
const [loading, setLoading] = useState<string | null>(null);
const [confirmKick, setConfirmKick] = useState<string | null>(null);
const [confirmEnd, setConfirmEnd] = useState(false);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === backdropRef.current) onClose();
};
const manage = useCallback(
async (action: string, targetUserId?: string) => {
setLoading(action + (targetUserId ?? ""));
@@ -59,80 +54,62 @@ export default function RoomManageModal({
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
onToast(data.error ?? "操作失败");
toast.show(data.error ?? "操作失败");
return;
}
switch (action) {
case "lock":
onToast("房间已锁定,其他人无法加入");
toast.show("房间已锁定,其他人无法加入");
break;
case "unlock":
onToast("房间已解锁");
toast.show("房间已解锁");
break;
case "kick":
onToast("已将该用户移出房间");
toast.show("已将该用户移出房间");
setConfirmKick(null);
break;
case "end_voting":
onToast("已结束投票,正在结算结果");
toast.show("已结束投票,正在结算结果");
setConfirmEnd(false);
onClose();
break;
}
} catch {
onToast("操作失败,请重试");
toast.show("操作失败,请重试");
} finally {
setLoading(null);
}
},
[roomId, userId, onToast, onClose],
[roomId, userId, toast, onClose],
);
const otherUsers = users.filter((u) => u !== userId);
return (
<AnimatePresence>
{open && (
<motion.div
ref={backdropRef}
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={handleBackdropClick}
>
<motion.div
className="relative w-full max-w-sm rounded-t-3xl bg-white px-5 pb-8 pt-5 shadow-2xl sm:rounded-3xl sm:pb-6"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", damping: 28, stiffness: 350 }}
>
<Modal open={open} onClose={onClose}>
<button
onClick={onClose}
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-zinc-100 text-zinc-400 transition-colors active:bg-zinc-200"
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle"
>
<X size={16} />
</button>
<div className="flex items-center gap-2">
<Crown size={18} className="text-amber-500" />
<h2 className="text-lg font-bold text-zinc-900"></h2>
<Crown size={18} className="text-amber-400" />
<h2 className="text-lg font-bold text-heading"></h2>
</div>
<p className="mt-1 text-xs text-zinc-400">
<p className="mt-1 text-xs text-muted">
{roomId}
</p>
{/* Lock/Unlock */}
<div className="mt-5">
<button
onClick={() => manage(locked ? "unlock" : "lock")}
disabled={loading !== null}
className={`flex h-11 w-full items-center justify-center gap-2 rounded-xl text-sm font-semibold transition-colors disabled:opacity-50 ${
locked
? "border border-emerald-200 bg-emerald-50 text-emerald-700 active:bg-emerald-100"
: "border border-zinc-200 bg-white text-zinc-700 active:bg-zinc-50"
? "bg-accent/15 text-accent ring-1 ring-accent/30 active:bg-accent/25"
: "bg-elevated text-secondary ring-1 ring-border active:bg-subtle"
}`}
>
{loading === "lock" || loading === "unlock" ? (
@@ -146,17 +123,13 @@ export default function RoomManageModal({
</button>
</div>
{/* User list with kick */}
<div className="mt-5">
<h3 className="text-xs font-semibold text-zinc-500">
<h3 className="text-xs font-semibold text-muted">
{users.length}
</h3>
<div className="mt-2 flex flex-col gap-1.5">
{users.map((uid) => {
const profile = userProfiles[uid];
const emoji = profile?.avatar ?? getAvatar(uid).emoji;
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(uid).bg;
const displayName = profile?.username ?? uid.slice(0, 8);
const displayName = userProfiles[uid]?.username ?? uid.slice(0, 8);
const isCreator = uid === userId;
const swiped = swipeCounts[uid] ?? 0;
const finished = swiped >= totalCards;
@@ -164,27 +137,23 @@ export default function RoomManageModal({
return (
<div
key={uid}
className="flex items-center gap-2.5 rounded-xl bg-zinc-50 px-3 py-2.5"
className="flex items-center gap-2.5 rounded-xl bg-elevated px-3 py-2.5"
>
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-base ${bg}`}
>
{emoji}
</span>
<UserAvatar userId={uid} profile={userProfiles[uid]} />
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-1.5">
{isCreator && (
<span className="flex items-center gap-0.5 text-[10px] font-bold text-amber-500">
<span className="flex items-center gap-0.5 text-[10px] font-bold text-amber-400">
<Crown size={10} />
</span>
)}
<span className="truncate text-xs font-medium text-zinc-500">
<span className="truncate text-xs font-medium text-tertiary">
{displayName}
</span>
</div>
<span
className={`text-[11px] ${finished ? "text-emerald-500" : "text-zinc-400"}`}
className={`text-[11px] ${finished ? "text-accent" : "text-muted"}`}
>
{swiped}/{totalCards}
{finished ? " 已完成" : " 进行中"}
@@ -211,7 +180,7 @@ export default function RoomManageModal({
</button>
<button
onClick={() => setConfirmKick(null)}
className="rounded-lg bg-zinc-200 px-2.5 py-1 text-[11px] font-semibold text-zinc-600 transition-colors active:bg-zinc-300"
className="rounded-lg bg-subtle px-2.5 py-1 text-[11px] font-semibold text-tertiary transition-colors active:bg-elevated"
>
</button>
@@ -219,7 +188,7 @@ export default function RoomManageModal({
) : (
<button
onClick={() => setConfirmKick(uid)}
className="flex items-center gap-0.5 rounded-lg px-2 py-1 text-[11px] font-medium text-zinc-400 transition-colors active:bg-zinc-100 active:text-rose-500"
className="flex items-center gap-0.5 rounded-lg px-2 py-1 text-[11px] font-medium text-muted transition-colors active:bg-subtle active:text-rose-400"
>
<UserX size={13} />
@@ -233,11 +202,10 @@ export default function RoomManageModal({
</div>
</div>
{/* End voting */}
<div className="mt-5">
{confirmEnd ? (
<div className="flex flex-col gap-2 rounded-xl border border-amber-200 bg-amber-50 p-3">
<p className="text-xs font-medium text-amber-800">
<div className="flex flex-col gap-2 rounded-xl bg-amber-500/10 p-3 ring-1 ring-amber-500/30">
<p className="text-xs font-medium text-amber-300">
</p>
<div className="flex gap-2">
@@ -255,7 +223,7 @@ export default function RoomManageModal({
</button>
<button
onClick={() => setConfirmEnd(false)}
className="flex h-9 flex-1 items-center justify-center rounded-lg bg-white text-xs font-semibold text-zinc-600 transition-colors active:bg-zinc-50"
className="flex h-9 flex-1 items-center justify-center rounded-lg bg-elevated text-xs font-semibold text-tertiary transition-colors active:bg-subtle"
>
</button>
@@ -265,16 +233,13 @@ export default function RoomManageModal({
<button
onClick={() => setConfirmEnd(true)}
disabled={loading !== null}
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl border border-amber-200 bg-amber-50 text-sm font-semibold text-amber-700 transition-colors active:bg-amber-100 disabled:opacity-50"
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-amber-500/10 text-sm font-semibold text-amber-400 ring-1 ring-amber-500/30 transition-colors active:bg-amber-500/20 disabled:opacity-50"
>
<Flag size={15} />
</button>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</Modal>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { Star, MapPin } from "lucide-react";
import type { Restaurant } from "@/types";
import RestaurantImage from "@/components/RestaurantImage";
import { buildNavUrl } from "@/lib/navigation";
interface RunnerUpCardProps {
restaurant: Restaurant;
likes: number;
userCount: number;
}
export default function RunnerUpCard({ restaurant, likes, userCount }: RunnerUpCardProps) {
return (
<a
href={buildNavUrl(restaurant)}
target="_blank"
rel="noopener noreferrer"
className="flex gap-3 rounded-xl bg-surface/80 p-2.5 ring-1 ring-border/50 backdrop-blur-sm transition-colors hover:bg-elevated/80"
>
{restaurant.images?.[0] && (
<RestaurantImage
src={restaurant.images[0]}
alt={restaurant.name}
className="h-16 w-16 shrink-0 rounded-lg object-cover"
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className="truncate text-sm font-bold text-heading">
{restaurant.name}
</p>
<div className="mt-1 flex items-center gap-2 text-xs text-tertiary">
<span className="flex items-center gap-0.5">
<Star size={11} className="fill-yellow-300 text-yellow-300" />
{restaurant.rating}
</span>
<span>{restaurant.price}</span>
{restaurant.distance && (
<span className="flex items-center gap-0.5">
<MapPin size={11} />
{restaurant.distance}
</span>
)}
</div>
<p className="mt-0.5 text-[11px] font-medium text-amber-400">
{likes}/{userCount}
</p>
</div>
</a>
);
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, Download, Share2, Loader2 } from "lucide-react";
import { useToast } from "@/hooks/useToast";
import { useShare } from "@/hooks/useShare";
import {
loadImageAsDataUrl,
generateImage,
downloadDataUrl,
dataUrlToFile,
} from "@/lib/shareImage";
import RestaurantShareCard, {
type RestaurantShareData,
} from "@/components/RestaurantShareCard";
import BlindboxShareCard, {
type BlindboxShareData,
} from "@/components/BlindboxShareCard";
import BlindboxPlanShareCard, {
type PlanShareData,
} from "@/components/BlindboxPlanShareCard";
export type ShareCardData = RestaurantShareData | BlindboxShareData | PlanShareData;
interface ShareCardModalProps {
open: boolean;
onClose: () => void;
data: ShareCardData;
}
export default function ShareCardModal({
open,
onClose,
data,
}: ShareCardModalProps) {
const toast = useToast();
const cardRef = useRef<HTMLDivElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const [generating, setGenerating] = useState(false);
const [imageDataUrl, setImageDataUrl] = useState<string | null>(null);
const [imageLoading, setImageLoading] = useState(false);
const imageSrc = data.type === "restaurant" ? data.restaurant.images?.[0] : undefined;
useEffect(() => {
if (!open) {
setImageDataUrl(null);
return;
}
if (!imageSrc) return;
setImageLoading(true);
loadImageAsDataUrl(imageSrc)
.then(setImageDataUrl)
.finally(() => setImageLoading(false));
}, [open, imageSrc]);
const handleGenerate = useCallback(async (): Promise<string | null> => {
if (!cardRef.current) return null;
setGenerating(true);
try {
return await generateImage(cardRef.current);
} catch {
try {
setImageDataUrl(null);
await new Promise((r) => setTimeout(r, 100));
if (!cardRef.current) return null;
return await generateImage(cardRef.current);
} catch {
return null;
}
} finally {
setGenerating(false);
}
}, []);
const handleSave = useCallback(async () => {
const png = await handleGenerate();
if (!png) {
toast.show("生成图片失败,请重试");
return;
}
const name =
data.type === "restaurant"
? `NoWhatever_${data.restaurant.name}.png`
: `NoWhatever_周末契约.png`;
downloadDataUrl(png, name);
toast.show("图片已保存");
}, [handleGenerate, toast, data]);
const { share: nativeShare } = useShare();
const handleShare = useCallback(async () => {
const png = await handleGenerate();
if (!png) {
toast.show("生成图片失败,请重试");
return;
}
const file = dataUrlToFile(png, "NoWhatever.png");
const shared = await nativeShare({ files: [file] });
if (!shared) {
downloadDataUrl(png, "NoWhatever.png");
toast.show("图片已保存,快去分享吧!");
}
}, [handleGenerate, toast, nativeShare]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === backdropRef.current) onClose();
};
return (
<AnimatePresence>
{open && (
<motion.div
ref={backdropRef}
className="fixed inset-0 z-70 overflow-y-auto overflow-x-hidden bg-black/80 backdrop-blur-sm scrollbar-none"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={handleBackdropClick}
>
<motion.div
className="flex min-h-dvh flex-col items-center justify-center px-5 py-6"
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ duration: 0.25, ease: "easeOut" }}
>
{/* Card + close button wrapper */}
<div className="relative flex shrink-0 justify-center">
<button
onClick={onClose}
aria-label="关闭"
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/70 transition-colors active:bg-black/60"
>
<X size={16} />
</button>
{imageLoading ? (
<div
style={{ width: 340, height: 400 }}
className="flex items-center justify-center rounded-2xl bg-[#111113]"
>
<Loader2
size={24}
className="animate-spin text-gray-500"
/>
</div>
) : data.type === "restaurant" ? (
<RestaurantShareCard
data={data}
cardRef={cardRef}
imageDataUrl={imageDataUrl}
/>
) : data.type === "plan" ? (
<BlindboxPlanShareCard data={data} cardRef={cardRef} />
) : (
<BlindboxShareCard data={data} cardRef={cardRef} />
)}
</div>
{/* Action buttons */}
<div className="mt-5 flex w-full max-w-[340px] shrink-0 gap-3">
<button
onClick={handleSave}
disabled={generating}
className="flex h-12 flex-1 items-center justify-center gap-2 rounded-2xl bg-white/10 text-sm font-semibold text-gray-200 transition-colors active:bg-white/15 disabled:opacity-50"
>
{generating ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
</button>
<button
onClick={handleShare}
disabled={generating}
className={`flex h-12 flex-1 items-center justify-center gap-2 rounded-2xl text-sm font-bold text-white shadow-lg transition-colors disabled:opacity-50 ${
data.type === "blindbox" || data.type === "plan"
? "bg-purple-600 shadow-purple-900/30 active:bg-purple-500"
: "bg-emerald-600 shadow-emerald-900/30 active:bg-emerald-500"
}`}
>
{generating ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Share2 size={16} />
)}
</button>
</div>
<p className="mt-3 shrink-0 text-center text-[10px] text-gray-500">
</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className = "" }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-lg bg-elevated ${className}`}
/>
);
}
export function SkeletonCircle({ className = "" }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-full bg-elevated ${className}`}
/>
);
}
export function RoomCardSkeleton() {
return (
<div className="flex w-full items-center gap-3 rounded-2xl bg-surface p-4 ring-1 ring-border">
<Skeleton className="h-11 w-11 shrink-0 rounded-xl" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-36" />
</div>
<Skeleton className="h-3 w-10" />
</div>
);
}
export function ProfileCardSkeleton() {
return (
<div className="rounded-2xl bg-surface p-4 ring-1 ring-border">
<div className="flex items-center gap-4">
<Skeleton className="h-14 w-14 rounded-2xl" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-3 w-40" />
</div>
</div>
</div>
);
}
export function RecordItemSkeleton() {
return (
<div className="flex gap-3 rounded-xl bg-elevated p-2.5">
<Skeleton className="h-12 w-12 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col justify-center gap-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-36" />
</div>
</div>
);
}
export function SwipeDeckSkeleton() {
return (
<div className="flex h-dvh flex-col bg-background">
<nav className="flex h-14 items-center px-4">
<div className="flex items-center gap-1.5">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-7 w-28 rounded-full" />
<Skeleton className="h-7 w-16 rounded-full" />
</div>
</nav>
<div className="flex flex-1 items-center justify-center px-4">
<div className="h-[60vh] w-full max-w-sm">
<div className="h-full overflow-hidden rounded-2xl bg-surface ring-1 ring-border">
<Skeleton className="h-[58%] w-full rounded-none" />
<div className="flex flex-col gap-3 px-5 py-4">
<Skeleton className="h-5 w-40" />
<div className="flex gap-3">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-10" />
</div>
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-32" />
</div>
</div>
</div>
</div>
<div className="flex items-center justify-center gap-4 pb-5">
<SkeletonCircle className="h-13 w-13" />
<SkeletonCircle className="h-13 w-13" />
</div>
</div>
);
}
export function BlindboxRoomSkeleton() {
return (
<div className="flex min-h-dvh flex-col items-center bg-background px-5 py-6">
<div className="flex w-full max-w-sm items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex flex-1 flex-col gap-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-2.5 w-16" />
</div>
<div className="flex -space-x-1.5">
<SkeletonCircle className="h-7 w-7 ring-2 ring-background" />
<SkeletonCircle className="h-7 w-7 ring-2 ring-background" />
</div>
<Skeleton className="h-8 w-8 rounded-full" />
</div>
<Skeleton className="mt-10 h-36 w-36 rounded-2xl" />
<Skeleton className="mt-6 h-5 w-32" />
<Skeleton className="mt-2 h-3 w-48" />
<div className="mt-8 w-full max-w-sm space-y-3">
<Skeleton className="h-12 w-full rounded-xl" />
<Skeleton className="h-12 w-full rounded-xl" />
</div>
</div>
);
}
export function BlindboxListSkeleton() {
return (
<div className="mt-6 flex w-full max-w-sm flex-col gap-3">
<div className="flex gap-2">
<Skeleton className="h-10 flex-1 rounded-xl" />
<Skeleton className="h-10 w-20 rounded-xl" />
</div>
<div className="flex gap-2">
<Skeleton className="h-10 flex-1 rounded-xl" />
<Skeleton className="h-10 w-20 rounded-xl" />
</div>
<div className="mt-3 flex flex-col gap-3">
<RoomCardSkeleton />
<RoomCardSkeleton />
<RoomCardSkeleton />
</div>
</div>
);
}
+53 -70
View File
@@ -8,7 +8,7 @@ import MatchResult from "./MatchResult";
import SwipeGuide from "./SwipeGuide";
import { Restaurant, SwipeDirection, MatchType, RunnerUp, UserProfile, SceneType } from "@/types";
import { Heart, Undo2, Check } from "lucide-react";
import { getAvatar, getAvatarBg } from "@/lib/avatars";
import UserAvatar from "@/components/UserAvatar";
function UserProgressBar({
userId,
@@ -16,49 +16,54 @@ function UserProgressBar({
localIndex,
total,
userProfiles,
onUndo,
}: {
userId: string;
swipeCounts: Record<string, number>;
localIndex: number;
total: number;
userProfiles: Record<string, UserProfile>;
onUndo: () => void;
}) {
const others = Object.entries(swipeCounts).filter(([id]) => id !== userId);
if (others.length === 0) return null;
const myProfile = userProfiles[userId];
const myAvatar = myProfile?.avatar ?? getAvatar(userId).emoji;
const myAvatarBg = myProfile ? getAvatarBg(myProfile.avatar) : "bg-emerald-100";
return (
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="flex items-center gap-1 text-[11px] tabular-nums text-emerald-500">
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${myAvatarBg} text-[10px] leading-none`}>
{myAvatar}
<div className="flex items-center gap-x-3">
<div className="flex flex-1 flex-wrap items-center gap-x-3 gap-y-1">
<span className="flex items-center gap-1.5 text-[11px] text-accent">
<UserAvatar userId={userId} profile={userProfiles[userId]} size="xs" />
<span className="rounded bg-accent/10 px-1 py-px tabular-nums font-medium">
{localIndex}/{total}
</span>
{localIndex}/{total}
</span>
{others.map(([id, count]) => {
const finished = count >= total;
const profile = userProfiles[id];
const emoji = profile?.avatar ?? getAvatar(id).emoji;
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
const label = profile?.username ?? "";
const label = userProfiles[id]?.username ?? "";
return (
<span
key={id}
className={`flex items-center gap-1 text-[11px] tabular-nums ${finished ? "text-emerald-400" : "text-zinc-400"}`}
className={`flex items-center gap-1.5 text-[11px] ${finished ? "text-emerald-400" : "text-muted"}`}
>
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${bg} text-[10px] leading-none`}>
{emoji}
</span>
{label && <span className="max-w-[3rem] truncate">{label}</span>}
<UserAvatar userId={id} profile={userProfiles[id]} size="xs" />
{label && <span className="max-w-12 truncate">{label}</span>}
<span className={`rounded px-1 py-px tabular-nums font-medium ${finished ? "bg-emerald-500/10" : "bg-elevated"}`}>
{count}/{total}
</span>
{finished && <Check size={10} className="text-emerald-400" />}
</span>
);
})}
</div>
<button
onClick={onUndo}
disabled={localIndex === 0}
className="flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[11px] font-medium text-amber-400 transition-colors active:bg-amber-500/15 disabled:opacity-0"
>
<Undo2 size={12} />
</button>
</div>
);
}
@@ -79,17 +84,11 @@ function WaitingProgress({
const others = entries.filter(([id]) => id !== userId);
const finishedCount = others.filter(([, c]) => c >= total).length;
const myProfile = userProfiles[userId];
const myEmoji = myProfile?.avatar ?? getAvatar(userId).emoji;
const myBg = myProfile ? getAvatarBg(myProfile.avatar) : "bg-emerald-100";
return (
<div className="flex w-60 flex-col gap-2.5 rounded-2xl bg-zinc-50 px-4 py-3">
<div className="flex w-60 flex-col gap-2.5 rounded-2xl bg-surface px-4 py-3 ring-1 ring-border">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs font-medium text-emerald-600">
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${myBg} text-sm leading-none`}>
{myEmoji}
</span>
<span className="flex items-center gap-1.5 text-xs font-medium text-accent">
<UserAvatar userId={userId} profile={userProfiles[userId]} size="sm" />
{total}/{total}
</span>
<Check size={14} className="text-emerald-400" />
@@ -98,24 +97,24 @@ function WaitingProgress({
{others.map(([id, count]) => {
const finished = count >= total;
const pct = Math.min((count / total) * 100, 100);
const profile = userProfiles[id];
const emoji = profile?.avatar ?? getAvatar(id).emoji;
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
const label = profile?.username ?? "";
const label = userProfiles[id]?.username ?? "";
return (
<div key={id} className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className={`flex items-center gap-1.5 text-xs font-medium ${finished ? "text-emerald-600" : "text-zinc-500"}`}>
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full text-sm leading-none ${finished ? "bg-emerald-100" : bg}`}>
{emoji}
</span>
{label && <span className="max-w-[3rem] truncate">{label}</span>}
<span className={`flex items-center gap-1.5 text-xs font-medium ${finished ? "text-accent" : "text-muted"}`}>
<UserAvatar
userId={id}
profile={userProfiles[id]}
size="sm"
bg={finished ? "bg-emerald-500/20" : undefined}
/>
{label && <span className="max-w-12 truncate">{label}</span>}
{count}/{total}
</span>
{finished && <Check size={14} className="text-emerald-400" />}
</div>
{!finished && (
<div className="ml-7 h-1 overflow-hidden rounded-full bg-zinc-200">
<div className="ml-7 h-1 overflow-hidden rounded-full bg-elevated">
<motion.div
className="h-full rounded-full bg-amber-400"
animate={{ width: `${pct}%` }}
@@ -127,7 +126,7 @@ function WaitingProgress({
);
})}
<p className="text-center text-[10px] text-zinc-400">
<p className="text-center text-[10px] text-muted">
{finishedCount}/{others.length}
</p>
</div>
@@ -292,6 +291,13 @@ export default function SwipeDeck({
prevLikeCounts.current = {};
}, []);
useEffect(() => {
const serverIndex = swipeCounts[userId] ?? 0;
if (serverIndex === 0 && currentIndex > 0 && !resetting) {
clearLocalState();
}
}, [swipeCounts, userId, currentIndex, resetting, clearLocalState]);
const handleReset = useCallback(async () => {
setResetting(true);
try {
@@ -319,47 +325,24 @@ export default function SwipeDeck({
? restaurants.find((r) => r.id === resolvedMatchId) ?? null
: null;
const showWaiting = allSwiped && !resolvedMatchId;
const showWaiting = allSwiped && !resolvedMatchId && userCount > 1;
return (
<>
<div className="relative flex flex-1 items-center justify-center px-4">
<div className="relative h-[60vh] w-full max-w-sm">
{!allSwiped && !resolvedMatchId && (
<div className="mx-auto w-full max-w-sm px-4 pb-1">
<div className="flex items-center gap-2">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-zinc-100">
<motion.div
className="h-full rounded-full bg-emerald-400"
initial={{ width: 0 }}
animate={{
width: `${((currentIndex) / restaurants.length) * 100}%`,
}}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
</div>
<span className="shrink-0 text-[11px] tabular-nums text-zinc-400">
{currentIndex}/{restaurants.length}
</span>
<button
onClick={handleUndo}
disabled={currentIndex === 0}
className="flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[11px] font-medium text-amber-500 transition-colors active:bg-amber-50 disabled:opacity-0"
>
<Undo2 size={12} />
</button>
</div>
<div className="absolute inset-x-0 bottom-full mb-2">
<UserProgressBar
userId={userId}
swipeCounts={swipeCounts}
localIndex={currentIndex}
total={restaurants.length}
userProfiles={userProfiles}
onUndo={handleUndo}
/>
</div>
)}
<div className="relative flex flex-1 items-center justify-center px-4">
<div className="relative h-[60vh] w-full max-w-sm">
{currentIndex === 0 && !resolvedMatchId && guideVisible && (
<SwipeGuide onDismiss={() => setGuideVisible(false)} />
)}
@@ -385,8 +368,8 @@ export default function SwipeDeck({
{showWaiting && (
<div className="flex h-full flex-col items-center justify-center gap-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-300 border-t-emerald-500" />
<p className="text-sm font-medium text-zinc-500"></p>
<div className="h-5 w-5 animate-spin rounded-full border-2 border-subtle border-t-accent" />
<p className="text-sm font-medium text-muted"></p>
<WaitingProgress
userId={userId}
swipeCounts={swipeCounts}
+4 -2
View File
@@ -13,7 +13,8 @@ import RestaurantCard from "./RestaurantCard";
import { Restaurant, SwipeDirection } from "@/types";
const SWIPE_THRESHOLD = 120;
const EXIT_X = 600;
const getExitX = () =>
typeof window !== "undefined" ? window.innerWidth * 1.5 : 600;
const ROTATION_RANGE = 18;
interface SwipeableCardProps {
@@ -66,7 +67,8 @@ export default function SwipeableCard({
const flyOut = (direction: SwipeDirection) => {
if (isSwiping.current) return;
isSwiping.current = true;
const exitX = direction === "right" ? EXIT_X : -EXIT_X;
const exit = getExitX();
const exitX = direction === "right" ? exit : -exit;
animate(x, exitX, {
type: "spring",
stiffness: 600,
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
interface ToastProps {
message: string;
position?: "top" | "bottom";
}
const positionClass = {
top: "top-10",
bottom: "bottom-8",
};
export default function Toast({ message, position = "top" }: ToastProps) {
const isTop = position === "top";
const y = isTop ? -12 : 12;
return (
<AnimatePresence>
{message && (
<motion.div
className={`fixed left-1/2 z-60 -translate-x-1/2 rounded-xl bg-elevated px-4 py-2.5 text-xs font-medium text-heading shadow-lg ring-1 ring-subtle ${positionClass[position]}`}
initial={{ opacity: 0, y, x: "-50%" }}
animate={{ opacity: 1, y: 0, x: "-50%" }}
exit={{ opacity: 0, y, x: "-50%" }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
{message}
</motion.div>
)}
</AnimatePresence>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { ToastContext, type ToastPosition } from "@/hooks/useToast";
import Toast from "./Toast";
export default function ToastProvider({
children,
}: {
children: React.ReactNode;
}) {
const [message, setMessage] = useState("");
const [position, setPosition] = useState<ToastPosition>("top");
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const show = useCallback((msg: string, pos: ToastPosition = "top") => {
clearTimeout(timerRef.current);
setMessage(msg);
setPosition(pos);
timerRef.current = setTimeout(() => setMessage(""), 2200);
}, []);
return (
<ToastContext.Provider value={{ show }}>
{children}
<Toast message={message} position={position} />
</ToastContext.Provider>
);
}
+29 -64
View File
@@ -1,8 +1,7 @@
"use client";
import { useState, useCallback } from "react";
import { Users, QrCode, LogOut, Crown, Lock } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
import { QrCode, LogOut, Crown, Lock } from "lucide-react";
import QrInviteModal from "./QrInviteModal";
import RoomManageModal from "./RoomManageModal";
import type { UserProfile, SceneType } from "@/types";
@@ -10,7 +9,7 @@ import { getSceneConfig } from "@/lib/sceneConfig";
interface TopNavProps {
roomId: string;
userCount: number;
userCount?: number;
onExit?: () => void;
isCreator?: boolean;
userId?: string;
@@ -36,84 +35,51 @@ export default function TopNav({
scene = "eat",
}: TopNavProps) {
const sceneConfig = getSceneConfig(scene);
const [toast, setToast] = useState("");
const [showQr, setShowQr] = useState(false);
const [showManage, setShowManage] = useState(false);
const showToast = useCallback((msg: string) => {
setToast(msg);
setTimeout(() => setToast(""), 2200);
}, []);
return (
<>
<nav className="relative z-10 flex h-14 items-center justify-between px-4">
<nav className="relative z-10 flex h-14 items-center px-4">
<div className="flex items-center gap-1.5">
<button
onClick={() => setShowQr(true)}
className="flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-600 transition-colors active:bg-emerald-100"
>
<QrCode size={13} />
</button>
{isCreator && (
<button
onClick={() => setShowManage(true)}
className="flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-600 transition-colors active:bg-amber-100"
>
<Crown size={13} />
</button>
)}
</div>
<h1 className="text-center text-base font-bold tracking-tight text-zinc-900">
<span className="block leading-tight">NoWhatever</span>
<span className="block text-[10px] font-medium tracking-widest text-zinc-400">
便
</span>
</h1>
<div className="flex items-center justify-end gap-1.5 text-xs text-zinc-500">
{locked && (
<Lock size={12} className="text-amber-500" />
)}
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-medium">
{roomId}
</span>
<div className="flex items-center gap-0.5">
<Users size={13} />
<span className="font-semibold text-emerald-500">{userCount}</span>
</div>
<button
onClick={onExit}
className="ml-1 flex items-center justify-center rounded-full p-1 text-zinc-400 transition-colors active:bg-zinc-100 active:text-zinc-600"
className="flex items-center justify-center rounded-full p-1.5 text-muted transition-colors active:bg-elevated active:text-secondary"
aria-label="退出房间"
>
<LogOut size={15} />
</button>
</div>
</nav>
<AnimatePresence>
{toast && (
<motion.div
className="fixed left-1/2 top-16 z-50 -translate-x-1/2 rounded-xl bg-zinc-900 px-4 py-2.5 text-xs font-medium text-white shadow-lg"
initial={{ opacity: 0, y: -12, x: "-50%" }}
animate={{ opacity: 1, y: 0, x: "-50%" }}
exit={{ opacity: 0, y: -12, x: "-50%" }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
<button
onClick={() => setShowQr(true)}
className="flex items-center gap-1 rounded-full bg-accent/15 px-2.5 py-1 text-xs font-semibold text-accent transition-colors active:bg-accent/25"
>
{toast}
</motion.div>
<QrCode size={13} />
{roomId}
</button>
{isCreator && (
<button
onClick={() => setShowManage(true)}
className="flex items-center gap-1 rounded-full bg-amber-500/15 px-2.5 py-1 text-xs font-semibold text-amber-400 transition-colors active:bg-amber-500/25"
>
<Crown size={13} />
{locked && <Lock size={11} />}
</button>
)}
</AnimatePresence>
</div>
<h1 className="pointer-events-none absolute inset-x-0 text-center text-base font-bold tracking-tight text-heading">
<span className="block leading-tight">NoWhatever</span>
<span className="block text-[10px] font-medium tracking-widest text-muted">
便
</span>
</h1>
</nav>
<QrInviteModal
open={showQr}
onClose={() => setShowQr(false)}
roomId={roomId}
onToast={showToast}
scene={scene}
/>
@@ -128,7 +94,6 @@ export default function TopNav({
swipeCounts={swipeCounts}
totalCards={totalCards}
userProfiles={userProfiles}
onToast={showToast}
/>
)}
</>
+34
View File
@@ -0,0 +1,34 @@
import { resolveAvatar } from "@/lib/avatars";
const sizeStyles = {
xs: "h-4 w-4 text-[10px]",
sm: "h-5 w-5 text-sm",
md: "h-8 w-8 text-base",
lg: "h-11 w-11 text-xl",
xl: "h-14 w-14 text-2xl",
} as const;
interface UserAvatarProps {
userId: string;
profile?: { avatar: string } | null;
size?: keyof typeof sizeStyles;
bg?: string;
className?: string;
}
export default function UserAvatar({
userId,
profile,
size = "md",
bg,
className = "",
}: UserAvatarProps) {
const avatar = resolveAvatar(userId, profile);
return (
<span
className={`inline-flex items-center justify-center rounded-full leading-none ${sizeStyles[size]} ${bg ?? avatar.bg} ${className}`}
>
{avatar.emoji}
</span>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
import { Calendar, Clock, Sparkles, X } from "lucide-react";
import Button from "@/components/Button";
interface TimeConfig {
date: string;
startHour: number;
endHour: number;
}
const PRESETS: { label: string; value: TimeConfig }[] = [
{ label: "周六全天", value: { date: "周六", startHour: 10, endHour: 21 } },
{ label: "周日全天", value: { date: "周日", startHour: 10, endHour: 21 } },
{ label: "整个周末", value: { date: "整个周末", startHour: 10, endHour: 21 } },
];
const HOURS = Array.from({ length: 15 }, (_, i) => i + 7);
export default function WeekendTimeSelector({
onConfirm,
onClose,
loading,
}: {
onConfirm: (config: TimeConfig) => void;
onClose: () => void;
loading?: boolean;
}) {
const [config, setConfig] = useState<TimeConfig>(PRESETS[0].value);
return (
<motion.div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
className="w-full max-w-md rounded-t-3xl bg-surface px-6 pb-8 pt-4"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
>
<div className="mx-auto mb-4 h-1 w-10 rounded-full bg-border" />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar size={16} className="text-purple-400" />
<h3 className="text-sm font-bold text-heading"></h3>
</div>
<button onClick={onClose} aria-label="关闭" className="text-muted active:text-foreground">
<X size={18} />
</button>
</div>
<div className="mt-4 flex gap-2">
{PRESETS.map((preset) => (
<button
key={preset.value.date}
onClick={() => setConfig({ ...preset.value })}
className={`flex-1 rounded-xl py-2.5 text-xs font-bold transition-all ${
config.date === preset.value.date
? "bg-purple-600 text-white shadow-lg shadow-purple-900/30"
: "bg-elevated text-secondary ring-1 ring-border"
}`}
>
{preset.label}
</button>
))}
</div>
<div className="mt-5 flex items-center gap-3">
<Clock size={14} className="shrink-0 text-muted" />
<div className="flex flex-1 items-center gap-2">
<select
value={config.startHour}
onChange={(e) => setConfig((c) => ({ ...c, startHour: Number(e.target.value) }))}
className="h-9 flex-1 rounded-lg bg-elevated px-2 text-center text-sm font-semibold text-foreground ring-1 ring-border"
>
{HOURS.filter((h) => h < config.endHour).map((h) => (
<option key={h} value={h}>{String(h).padStart(2, "0")}:00</option>
))}
</select>
<span className="text-xs text-muted"></span>
<select
value={config.endHour}
onChange={(e) => setConfig((c) => ({ ...c, endHour: Number(e.target.value) }))}
className="h-9 flex-1 rounded-lg bg-elevated px-2 text-center text-sm font-semibold text-foreground ring-1 ring-border"
>
{HOURS.filter((h) => h > config.startHour).map((h) => (
<option key={h} value={h}>{String(h).padStart(2, "0")}:00</option>
))}
</select>
</div>
</div>
<Button
onClick={() => onConfirm(config)}
variant="purple"
size="lg"
loading={loading}
icon={<Sparkles size={16} />}
className="mt-6 w-full"
>
</Button>
</motion.div>
</motion.div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useState, useEffect, useCallback } from "react";
export type GpsStatus = "idle" | "locating" | "success" | "failed" | "denied";
type GpsResult =
| { ok: true; lat: number; lng: number }
| { ok: false; reason: "unsupported" | "denied" | "timeout" | "unknown" };
function requestGps(): Promise<GpsResult> {
return new Promise((resolve) => {
if (!navigator.geolocation) {
resolve({ ok: false, reason: "unsupported" });
return;
}
navigator.geolocation.getCurrentPosition(
(pos) =>
resolve({ ok: true, lat: pos.coords.latitude, lng: pos.coords.longitude }),
(err) => {
const reason =
err.code === err.PERMISSION_DENIED
? "denied"
: err.code === err.TIMEOUT
? "timeout"
: "unknown";
resolve({ ok: false, reason });
},
{ timeout: 8000, enableHighAccuracy: false },
);
});
}
async function reverseGeocode(lat: number, lng: number): Promise<string | null> {
try {
const res = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
const data = await res.json();
return data.name || data.formatted || null;
} catch {
return null;
}
}
export function useGeolocation() {
const [status, setStatus] = useState<GpsStatus>("idle");
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
const [locationName, setLocationName] = useState<string | null>(null);
const locate = useCallback(async () => {
setStatus("locating");
const result = await requestGps();
if (result.ok) {
setCoords({ lat: result.lat, lng: result.lng });
setStatus("success");
const name = await reverseGeocode(result.lat, result.lng);
if (name) setLocationName(name);
} else {
setCoords(null);
setLocationName(null);
setStatus(result.reason === "denied" ? "denied" : "failed");
}
}, []);
useEffect(() => {
locate();
}, [locate]);
return { status, coords, locationName, retry: locate };
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useCallback } from "react";
import { useToast } from "@/hooks/useToast";
export function useShare() {
const toast = useToast();
const copyToClipboard = useCallback(
async (text: string, successMsg = "已复制") => {
try {
await navigator.clipboard.writeText(text);
toast.show(successMsg);
} catch {
toast.show("复制失败,请手动复制");
}
},
[toast],
);
/** Try the native Web Share API; calls `fallback` if unavailable. Returns true if native share was triggered. */
const share = useCallback(
async (data: ShareData, fallback?: () => void): Promise<boolean> => {
try {
if (navigator.share && navigator.canShare?.(data)) {
await navigator.share(data);
return true;
}
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return true;
}
fallback?.();
return false;
},
[],
);
return { share, copyToClipboard };
}
+15
View File
@@ -0,0 +1,15 @@
import { createContext, useContext } from "react";
export type ToastPosition = "top" | "bottom";
export interface ToastContextValue {
show: (msg: string, position?: ToastPosition) => void;
}
export const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}
+172
View File
@@ -0,0 +1,172 @@
import OpenAI from "openai";
import type { IdeaTags, PlanItem } from "@/types";
function getClient() {
const apiKey = process.env.DEEPSEEK_API_KEY;
if (!apiKey) throw new Error("DEEPSEEK_API_KEY is not configured");
return new OpenAI({ baseURL: "https://api.deepseek.com", apiKey });
}
const TAG_SYSTEM_PROMPT = `你是一个周末活动分析助手。用户会输入一条周末活动想法,你需要分析并返回结构化 JSON。
返回字段:
- category: 活动品类,必须是以下之一:
"dining"(餐饮美食)| "outdoor"(户外活动)| "entertainment"(娱乐休闲,如电影、KTV、密室)| "shopping"(购物逛街)| "sports"(运动健身)| "culture"(文化艺术,如博物馆、展览)| "relaxation"(放松休息,如SPA、咖啡、下午茶)
- timeSlot: 最适合的时间段,必须是以下之一:
"morning"(上午)| "afternoon"(下午)| "evening"(晚上)| "flexible"(任意时间都可以)| "all_day"(需要一整天)
- estimatedMinutes: 预估活动时长(整数,单位分钟)
- outdoor: 是否户外活动(布尔值)
- searchQuery: 在地图服务上搜索的关键词(品牌名、地名或品类名)
- searchType: 搜索策略,必须是以下之一:
"brand"(连锁品牌,有多个分店)| "place"(唯一地点,如某个公园)| "category"(模糊品类,搜附近匹配的)
只返回 JSON,不要任何额外文字。`;
const SCHEDULE_SYSTEM_PROMPT = `你是一个周末行程规划师。根据用户选定的活动和候选地点坐标,生成最优行程安排。
规划原则:
1. 选择地理位置相近的 POI,最小化总移动距离
2. 尊重活动的时间偏好(公园上午、正餐在饭点、电影灵活)
3. 活动之间留出合理的交通时间(15-30分钟)
4. 如果有"category"类型的活动,选择离其他已确定地点最近的候选
返回 JSON 格式:
{
"items": [
{
"time": "10:00",
"activity": "原始活动描述",
"poi": "选定的具体 POI 名称",
"address": "详细地址",
"lat": 31.2,
"lng": 121.5,
"duration": 120,
"reason": "选择这个时间和地点的简短理由"
}
],
"summary": "一句话总结这个行程的亮点"
}
按时间顺序排列。只返回 JSON。`;
export interface ScheduleContext {
ideas: {
content: string;
category: string;
timeSlot: string;
estimatedMinutes: number;
searchQuery: string;
searchType: string;
}[];
candidates: Record<
string,
{ name: string; address: string; lat: number; lng: number; rating?: number }[]
>;
userLocation: { lat: number; lng: number };
availableTime: { date: string; startHour: number; endHour: number };
}
export async function tagIdea(content: string): Promise<IdeaTags | null> {
try {
const client = getClient();
const response = await client.chat.completions.create({
model: "deepseek-chat",
messages: [
{ role: "system", content: TAG_SYSTEM_PROMPT },
{ role: "user", content },
],
response_format: { type: "json_object" },
max_tokens: 200,
temperature: 0.3,
});
const text = response.choices[0]?.message?.content;
if (!text) return null;
const parsed = JSON.parse(text);
const validCategories = ["dining", "outdoor", "entertainment", "shopping", "sports", "culture", "relaxation"];
const validTimeSlots = ["morning", "afternoon", "evening", "flexible", "all_day"];
const validSearchTypes = ["brand", "place", "category"];
if (
!validCategories.includes(parsed.category) ||
!validTimeSlots.includes(parsed.timeSlot) ||
!validSearchTypes.includes(parsed.searchType) ||
typeof parsed.estimatedMinutes !== "number" ||
typeof parsed.outdoor !== "boolean" ||
typeof parsed.searchQuery !== "string"
) {
return null;
}
return {
category: parsed.category,
timeSlot: parsed.timeSlot,
estimatedMinutes: parsed.estimatedMinutes,
outdoor: parsed.outdoor,
searchQuery: parsed.searchQuery,
searchType: parsed.searchType,
};
} catch {
return null;
}
}
export async function generateSchedule(
ctx: ScheduleContext,
): Promise<{ items: PlanItem[]; summary: string } | null> {
try {
const client = getClient();
const userPrompt = `
可用时间:${ctx.availableTime.date}${ctx.availableTime.startHour}:00 - ${ctx.availableTime.endHour}:00
用户出发位置:纬度 ${ctx.userLocation.lat},经度 ${ctx.userLocation.lng}
活动列表:
${ctx.ideas.map((idea, i) => `${i + 1}. "${idea.content}"(品类:${idea.category},偏好时间:${idea.timeSlot},预估${idea.estimatedMinutes}分钟)`).join("\n")}
各活动的候选地点:
${Object.entries(ctx.candidates)
.map(
([query, pois]) =>
`"${query}" 的候选:\n${pois.map((p) => ` - ${p.name} | ${p.address} | 坐标(${p.lat},${p.lng})${p.rating ? ` | 评分${p.rating}` : ""}`).join("\n")}`,
)
.join("\n\n")}
请为以上活动生成最优行程安排。`;
const response = await client.chat.completions.create({
model: "deepseek-chat",
messages: [
{ role: "system", content: SCHEDULE_SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
response_format: { type: "json_object" },
max_tokens: 1500,
temperature: 0.5,
});
const text = response.choices[0]?.message?.content;
if (!text) return null;
const parsed = JSON.parse(text);
if (!Array.isArray(parsed.items) || parsed.items.length === 0) return null;
return {
items: parsed.items.map((item: Record<string, unknown>) => ({
time: String(item.time ?? ""),
activity: String(item.activity ?? ""),
poi: String(item.poi ?? ""),
address: String(item.address ?? ""),
lat: Number(item.lat) || 0,
lng: Number(item.lng) || 0,
duration: Number(item.duration) || 60,
reason: String(item.reason ?? ""),
})),
summary: String(parsed.summary ?? ""),
};
} catch {
return null;
}
}
+7
View File
@@ -0,0 +1,7 @@
import { ApiError } from "@/lib/api";
export function requireAmapApiKey(): string {
const key = process.env.AMAP_API_KEY;
if (!key) throw new ApiError("服务配置异常,请稍后重试", 500);
return key;
}
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export class ApiError extends Error {
constructor(
message: string,
public status: number = 400,
) {
super(message);
this.name = "ApiError";
}
}
/** Validates that value is a non-empty string; throws 401 otherwise. */
export function requireUserId(value: unknown): string {
if (!value || typeof value !== "string") {
throw new ApiError("请先登录", 401);
}
return value;
}
/** Finds user by ID; throws 404 if not found. */
export async function requireUser(userId: string) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new ApiError("用户不存在", 404);
return user;
}
type RouteContext = { params: Promise<Record<string, string>> };
type RouteHandler = (
req: NextRequest,
ctx: RouteContext,
) => Promise<NextResponse>;
/**
* Wraps a Next.js route handler with unified error handling.
* - ApiError instances are converted to JSON responses with matching status codes
* - Unknown errors are logged and returned as 500
*/
export function apiHandler(handler: RouteHandler): RouteHandler {
return async (req, ctx) => {
try {
return await handler(req, ctx);
} catch (e) {
if (e instanceof ApiError) {
return NextResponse.json({ error: e.message }, { status: e.status });
}
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === "P2002"
) {
return NextResponse.json(
{ error: "该记录已存在或值已被占用" },
{ status: 409 },
);
}
console.error(`[API ${req.method} ${req.nextUrl.pathname}]`, e);
return NextResponse.json({ error: "操作失败" }, { status: 500 });
}
};
}
+11
View File
@@ -25,3 +25,14 @@ export function getAvatarBg(emoji: string): string {
const found = AVATARS.find((a) => a.emoji === emoji);
return found?.bg ?? "bg-zinc-100";
}
export function resolveAvatar(
userId: string,
profile?: { avatar: string } | null,
): { emoji: string; bg: string } {
if (profile) {
return { emoji: profile.avatar, bg: getAvatarBg(profile.avatar) };
}
const fallback = getAvatar(userId);
return { emoji: fallback.emoji, bg: fallback.bg };
}
+41
View File
@@ -0,0 +1,41 @@
import { prisma } from "@/lib/prisma";
import { ApiError } from "@/lib/api";
export function generateRoomCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}
export async function generateUniqueRoomCode(): Promise<string> {
for (let attempt = 0; attempt < 10; attempt++) {
const code = generateRoomCode();
const existing = await prisma.blindBoxRoom.findUnique({ where: { code } });
if (!existing) return code;
}
throw new Error("无法生成唯一房间号");
}
/** Throws 403 if user is not a member of the room. */
export async function requireMembership(roomId: string, userId: string) {
const member = await prisma.blindBoxMember.findUnique({
where: { roomId_userId: { roomId, userId } },
});
if (!member) throw new ApiError("你不是这个房间的成员", 403);
return member;
}
export async function getRoomByCode(code: string) {
return prisma.blindBoxRoom.findUnique({
where: { code },
include: {
members: {
include: { user: { select: { id: true, username: true, avatar: true } } },
},
_count: { select: { ideas: { where: { status: "in_pool" } } } },
},
});
}
+12 -3
View File
@@ -62,9 +62,20 @@ export function fireCelebration() {
setTimeout(frame, 300);
}
let _audioCtx: AudioContext | null = null;
function getAudioContext(): AudioContext {
if (!_audioCtx || _audioCtx.state === "closed") {
_audioCtx = new AudioContext();
}
return _audioCtx;
}
export function playChime() {
try {
const ctx = new AudioContext();
const ctx = getAudioContext();
if (ctx.state === "suspended") ctx.resume();
const gain = ctx.createGain();
gain.connect(ctx.destination);
gain.gain.setValueAtTime(0.15, ctx.currentTime);
@@ -88,8 +99,6 @@ export function playChime() {
osc.start(start);
osc.stop(start + 0.6);
});
setTimeout(() => ctx.close(), 2000);
} catch {
// Audio not available — silent fallback
}
+11
View File
@@ -0,0 +1,11 @@
import type { Restaurant } from "@/types";
export function buildNavUrl(restaurant: Restaurant): string {
if (restaurant.location) {
const parts = restaurant.location.split(",");
if (parts.length === 2 && parts[0] && parts[1]) {
return `https://uri.amap.com/marker?position=${parts[0]},${parts[1]}&name=${encodeURIComponent(restaurant.name)}&callnative=1`;
}
}
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`;
}
+11
View File
@@ -0,0 +1,11 @@
export async function joinRoom(roomId: string, userId: string): Promise<void> {
const res = await fetch(`/api/room/${roomId}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "加入房间失败");
}
}
+43 -15
View File
@@ -4,6 +4,7 @@ export interface SceneConfig {
key: SceneType;
label: string;
emoji: string;
verb: string;
poiTypes: string;
defaultImage: string;
hotTags: readonly string[];
@@ -22,56 +23,83 @@ export interface SceneConfig {
const SCENE_CONFIGS: Record<SceneType, SceneConfig> = {
eat: {
key: "eat",
label: "吃什么",
label: "餐厅",
emoji: "🍜",
verb: "吃",
poiTypes: "050000",
defaultImage:
"https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800&q=80",
hotTags: ["火锅", "日料", "烧烤", "西餐", "川菜", "咖啡甜品", "小吃快餐"],
hotTags: ["火锅", "日料", "烧烤", "西餐", "川菜", "粤菜", "小吃快餐"],
priceOptions: [
{ label: "不限", value: "any" },
{ label: "¥50以下", value: "under50" },
{ label: "¥50-100", value: "50to100" },
{ label: "¥100+", value: "over100" },
],
tagLabel: "美食",
tagLabel: "口味",
tagPlaceholder: "想吃什么?(留空则不限)",
loadingText: "正在搜索周边美食...",
emptyError: "附近没有找到餐厅,试试扩大搜索范围或换个菜系",
loadingText: "正在搜索周边餐厅...",
emptyError: "附近没有找到餐厅,试试扩大搜索范围或换个口味",
subtitle: "和朋友一起滑卡片,再也不用纠结吃什么",
inviteText: "有人邀请你一起选餐厅",
shareTitle: "别说随便啦,来滑卡片决定吃什么!",
shareText: "我建好房间了,快点开链接一起选餐厅,滑中同一家就去吃!",
qrSubtitle: "让朋友扫码加入房间,一起滑卡片选餐厅",
},
drink: {
key: "drink",
label: "喝什么",
drinks: {
key: "drinks",
label: "咖啡/奶茶",
emoji: "🧋",
verb: "喝",
poiTypes: "050301|050302|050303|050400",
defaultImage:
"https://images.unsplash.com/photo-1556679343-c7306c1976bc?w=800&q=80",
hotTags: ["奶茶", "咖啡", "酒吧", "果茶", "甜品", "茶馆", "鸡尾酒"],
hotTags: ["咖啡", "奶茶", "拿铁", "美式", "水果茶", "冷萃", "芋泥"],
priceOptions: [
{ label: "不限", value: "any" },
{ label: "¥20以下", value: "under20" },
{ label: "¥20-50", value: "20to50" },
{ label: "¥50+", value: "over50" },
],
tagLabel: "饮品",
tagLabel: "类型",
tagPlaceholder: "想喝什么?(留空则不限)",
loadingText: "正在搜索周边饮品店...",
emptyError: "附近没有找到饮品店,试试扩大搜索范围或换个类型",
emptyError: "附近没有找到饮品店,试试扩大搜索范围",
subtitle: "和朋友一起滑卡片,再也不用纠结喝什么",
inviteText: "有人邀请你一起选饮品店",
shareTitle: "别说随便啦,来滑卡片决定喝什么!",
shareText: "我建好房间了,快点开链接一起选店,滑中同一家就去喝!",
qrSubtitle: "让朋友扫码加入房间,一起滑卡片选店",
qrSubtitle: "让朋友扫码加入房间,一起滑卡片选饮品店",
},
dessert: {
key: "dessert",
label: "甜品",
emoji: "🍰",
verb: "吃",
poiTypes: "050403|050404|050400",
defaultImage:
"https://images.unsplash.com/photo-1488477181946-6428a0291777?w=800&q=80",
hotTags: ["蛋糕", "冰淇淋", "芋圆", "麻薯", "草莓", "班戟"],
priceOptions: [
{ label: "不限", value: "any" },
{ label: "¥20以下", value: "under20" },
{ label: "¥20-50", value: "20to50" },
{ label: "¥50+", value: "over50" },
],
tagLabel: "类型",
tagPlaceholder: "想吃什么甜品?(留空则不限)",
loadingText: "正在搜索周边甜品店...",
emptyError: "附近没有找到甜品店,试试扩大搜索范围",
subtitle: "和朋友一起滑卡片,选一家甜蜜蜜的甜品店",
inviteText: "有人邀请你一起选甜品店",
shareTitle: "别说随便啦,来滑卡片决定吃什么甜品!",
shareText: "我建好房间了,快点开链接一起选甜品店,滑中同一家就去吃!",
qrSubtitle: "让朋友扫码加入房间,一起滑卡片选甜品店",
},
};
export const SCENES: SceneType[] = ["eat", "drink"];
export const SCENES: SceneType[] = ["eat", "drinks", "dessert"];
export function getSceneConfig(scene: SceneType): SceneConfig {
return SCENE_CONFIGS[scene];
export function getSceneConfig(scene: string): SceneConfig {
return SCENE_CONFIGS[scene as SceneType] ?? SCENE_CONFIGS.eat;
}

Some files were not shown because too many files have changed in this diff Show More