merge: dev → main (v2.0 周末契约 + 全站重构)
This commit is contained in:
@@ -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.
|
||||
@@ -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 }`
|
||||
@@ -0,0 +1,125 @@
|
||||
# 待修复问题清单
|
||||
|
||||
> 全面代码审查后整理,按严重程度排列。
|
||||
> 修复后在对应条目前打 `[x]`。
|
||||
|
||||
---
|
||||
|
||||
## Critical — 必须修复
|
||||
|
||||
- [x] **#1 非成员可操作任意房间**
|
||||
- 文件:`api/room/[id]/swipe/route.ts`、`undo/route.ts`、`reset/route.ts`
|
||||
- swipe / undo 没有校验 userId 是否在 `data.users` 中,非成员的 like 会被计入匹配
|
||||
- reset 无需任何 userId,任何人知道 4 位房间号即可清空所有投票
|
||||
- 修复:在 `atomicUpdateRoom` 回调开头加成员校验;reset 要求 userId 并验证 creatorId
|
||||
|
||||
- [x] **#2 盲盒抽取竞态条件**
|
||||
- 文件:`api/blindbox/draw/route.ts`
|
||||
- `findMany({ status: "in_pool" })` 和 `update` 不在事务中,两个并发请求可抽到同一个想法
|
||||
- 修复:用 `prisma.$transaction` + `updateMany({ where: { id, status: "in_pool" } })` 保证原子性,count=0 时返回 409
|
||||
|
||||
- [x] **#3 SwipeDeck 不响应他人重置**
|
||||
- 文件:`src/components/SwipeDeck.tsx`
|
||||
- 其他用户重置房间后,当前用户的 `currentIndex` / `localMatchId` 不清零,卡片位置错乱
|
||||
- 修复:加 useEffect 检测 server 端 swipeCount 归零时调用 `clearLocalState()`
|
||||
|
||||
- [x] **#4 API 错误响应被当数组用导致崩溃**
|
||||
- 文件:`src/app/panic/page.tsx`(suggestions)、`src/app/profile/page.tsx`(history/favorites)
|
||||
- fetch 后没检查 `res.ok`,非 200 返回的 `{ error: "..." }` 被当作数组,`.map()` 崩溃
|
||||
- 修复:所有 fetch 后先检查 `res.ok`,失败时设为空数组
|
||||
|
||||
- [x] **#5 theme.ts SSR 崩溃**
|
||||
- 文件:`src/lib/theme.ts`
|
||||
- `setStoredTheme` / `applyTheme` 直接访问 `localStorage` / `document`,无 `typeof window` 守卫
|
||||
- 修复:函数开头加 `if (typeof window === "undefined") return;`
|
||||
|
||||
---
|
||||
|
||||
## High — 应该修复
|
||||
|
||||
- [x] **#6 用户名唯一性 TOCTOU 竞态** ✅
|
||||
- register 去掉 findUnique 直接 create + catch P2002;user PUT 同理
|
||||
- apiHandler 全局兜底 P2002 → 409
|
||||
|
||||
- [x] **#7 SSE events 接口无认证** ✅
|
||||
- 校验 userId query param + 房间成员身份;start() 加 try/catch
|
||||
|
||||
- [x] **#8 GET /api/user 暴露任意用户邮箱** ⚠️
|
||||
- 需要真实服务端 auth 才能彻底修复,暂保留;已加 JSON.parse 安全防护
|
||||
|
||||
- [x] **#9 收藏去重用 JSON contains 匹配** ✅
|
||||
- Favorite 模型新增 `restaurantId` 字段 + `@@unique([userId, restaurantId])`
|
||||
|
||||
- [x] **#10 缺少数据库索引** ✅
|
||||
- 补齐所有缺失索引并 db push
|
||||
|
||||
- [x] **#11 无 onDelete 级联** ✅
|
||||
- Decision/Favorite/BlindBoxMember 加 `onDelete: Cascade`,BlindBoxIdea.drawnBy 加 `onDelete: SetNull`
|
||||
|
||||
- [x] **#12 密码无最大长度限制** ✅
|
||||
- `validatePassword` 加 128 字符上限
|
||||
|
||||
- [x] **#13 AuthModal 关闭后重开不重置表单** ✅
|
||||
- useEffect 监听 open 变 true 时重置全部表单状态
|
||||
|
||||
- [x] **#14 邀请页加入失败无错误提示** ✅
|
||||
- 新增 joinError state,catch 中捕获并渲染错误消息
|
||||
|
||||
---
|
||||
|
||||
## Medium — 建议修复
|
||||
|
||||
- [x] **#15 房间 ID 空间仅 9000 个** ✅
|
||||
- 扩展为 6 位字母数字 (30^6 ≈ 7.3 亿),createRoom 用 P2002 重试
|
||||
|
||||
- [x] **#16 盲盒想法编辑/删除有 TOCTOU** ✅
|
||||
- PUT/DELETE 改用 updateMany/deleteMany 原子操作
|
||||
|
||||
- [x] **#17 lat/lng 未校验为合法坐标** ✅
|
||||
- Number.isFinite + 范围校验 (-90~90, -180~180)
|
||||
|
||||
- [x] **#18 swipe action 未校验** ✅
|
||||
- 校验 action 必须为 'like' 或 'pass'
|
||||
|
||||
- [x] **#19 JSON.parse(preferences) 可能崩溃** ✅
|
||||
- GET 和 PUT 响应均加 try/catch,fallback {}
|
||||
|
||||
- [x] **#20 ShareCardModal data 每次新引用触发 useEffect** ✅
|
||||
- 依赖改为 imageSrc 字符串而非整个 data 对象
|
||||
|
||||
- [x] **#21 BlindboxRoomPage 多处 setTimeout 未清理** ✅
|
||||
- timersRef 统一收集所有 setTimeout,unmount 时批量清理;confetti rAF 用 alive ref 控制
|
||||
|
||||
- [x] **#22 外部 API (高德) 失败返回泛化 500** ✅
|
||||
- 三个高德 API 路由 fetch 加 try/catch → 503
|
||||
|
||||
- [x] **#23 navigation.ts location.split(",") 不校验格式** ✅
|
||||
- 校验 split 结果长度为 2 且两部分非空
|
||||
|
||||
- [x] **#24 handleReset / handleNarrow 吞掉 fetch 错误** ✅
|
||||
- 检查 res.ok,失败时 toast 提示
|
||||
|
||||
- [x] **#25 confettiCanvasRef 未使用(死代码)** ✅
|
||||
- 删除 ref 和 canvas 元素
|
||||
|
||||
- [x] **#26 requireString 接受纯空格字符串** ✅
|
||||
- 加 .trim() 校验
|
||||
|
||||
---
|
||||
|
||||
## Low — 可以改进
|
||||
|
||||
- [x] **#27 icon-only 按钮缺少 aria-label** ✅
|
||||
- panic/blindbox/ShareCardModal/AuthModal 中所有 icon-only 按钮补 aria-label
|
||||
|
||||
- [x] **#28 AudioContext 每次 playChime() 新建** ✅
|
||||
- 缓存复用单个 AudioContext,state === "closed" 时才重建
|
||||
|
||||
- [x] **#29 ApiError.name 是 "Error" 而非 "ApiError"** ✅
|
||||
- 已在 High 批次修复
|
||||
|
||||
- [x] **#30 blindbox lobby 加载房间失败静默无提示** ✅
|
||||
- 新增 loadError state,失败时显示"加载失败 / 点击重试"
|
||||
|
||||
- [x] **#31 theme localStorage 读取不校验合法值** ✅
|
||||
- 已在 Critical 批次修复(VALID_THEMES 白名单校验)
|
||||
Vendored
+4
-2
@@ -2,8 +2,9 @@ pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
APP_NAME = 'no-whatever'
|
||||
AMAP_KEY = '7f6be40a6de3f7fbb7bc3f825b67573b'
|
||||
APP_NAME = 'no-whatever'
|
||||
AMAP_KEY = '7f6be40a6de3f7fbb7bc3f825b67573b'
|
||||
DEEPSEEK_KEY = credentials('deepseek-api-key')
|
||||
}
|
||||
|
||||
triggers {
|
||||
@@ -37,6 +38,7 @@ pipeline {
|
||||
-v /data/${APP_NAME}:/app/data \
|
||||
-e DATABASE_URL=file:/app/data/prod.db \
|
||||
-e AMAP_API_KEY=${AMAP_KEY} \
|
||||
-e DEEPSEEK_API_KEY=${DEEPSEEK_KEY} \
|
||||
--restart unless-stopped \
|
||||
${APP_NAME}:latest
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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 或类似)
|
||||
Generated
+30
-1
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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.");
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,41 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
import { validateUsername, validatePassword } from "@/lib/validation";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
export const POST = apiHandler(async (req) => {
|
||||
const { username, password, avatar } = await req.json();
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: "用户名和密码为必填项" }, { status: 400 });
|
||||
}
|
||||
if (!username || !password) throw new ApiError("用户名和密码为必填项");
|
||||
|
||||
const trimmedUsername = username.trim();
|
||||
if (trimmedUsername.length < 2 || trimmedUsername.length > 16) {
|
||||
return NextResponse.json({ error: "用户名需要 2-16 个字符" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json({ error: "密码至少 6 个字符" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username: trimmedUsername } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "用户名已被注册" }, { status: 409 });
|
||||
}
|
||||
const trimmedUsername = validateUsername(username);
|
||||
validatePassword(password);
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: trimmedUsername,
|
||||
passwordHash,
|
||||
avatar: avatar || "🐱",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: trimmedUsername,
|
||||
passwordHash,
|
||||
avatar: avatar || "🐱",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||
throw new ApiError("用户名已被注册", 409);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -1,54 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
import { requireAmapApiKey } from "@/lib/amap";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const lat = searchParams.get("lat");
|
||||
const lng = searchParams.get("lng");
|
||||
export const GET = apiHandler(async (req) => {
|
||||
const lat = req.nextUrl.searchParams.get("lat");
|
||||
const lng = req.nextUrl.searchParams.get("lng");
|
||||
|
||||
if (!lat || !lng) {
|
||||
return NextResponse.json(
|
||||
{ error: "lat and lng are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!lat || !lng) throw new ApiError("lat and lng are required");
|
||||
|
||||
const apiKey = process.env.AMAP_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "AMAP_API_KEY not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const apiKey = requireAmapApiKey();
|
||||
|
||||
const url = new URL("https://restapi.amap.com/v3/geocode/regeo");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("location", `${lng},${lat}`);
|
||||
url.searchParams.set("extensions", "base");
|
||||
|
||||
let data;
|
||||
try {
|
||||
const url = new URL("https://restapi.amap.com/v3/geocode/regeo");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("location", `${lng},${lat}`);
|
||||
url.searchParams.set("extensions", "base");
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
const data = await res.json();
|
||||
data = await res.json();
|
||||
} catch {
|
||||
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
|
||||
}
|
||||
|
||||
if (data.status !== "1" || !data.regeocode) {
|
||||
return NextResponse.json({ name: null });
|
||||
}
|
||||
|
||||
const comp = data.regeocode.addressComponent;
|
||||
const district = comp?.district || comp?.city || "";
|
||||
const township = comp?.township || "";
|
||||
const neighborhood = comp?.neighborhood?.name || "";
|
||||
|
||||
const name = [district, township, neighborhood]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
return NextResponse.json({
|
||||
name: name || data.regeocode.formatted_address || null,
|
||||
formatted: data.regeocode.formatted_address || null,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Regeo error:", e);
|
||||
if (data.status !== "1" || !data.regeocode) {
|
||||
return NextResponse.json({ name: null });
|
||||
}
|
||||
}
|
||||
|
||||
const comp = data.regeocode.addressComponent;
|
||||
const district = comp?.district || comp?.city || "";
|
||||
const township = comp?.township || "";
|
||||
const neighborhood = comp?.neighborhood?.name || "";
|
||||
|
||||
const name = [district, township, neighborhood]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
return NextResponse.json({
|
||||
name: name || data.regeocode.formatted_address || null,
|
||||
formatted: data.regeocode.formatted_address || null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -1,52 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
import { requireAmapApiKey } from "@/lib/amap";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const keywords = searchParams.get("keywords")?.trim();
|
||||
export const GET = apiHandler(async (req) => {
|
||||
const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
|
||||
if (!keywords) return NextResponse.json([]);
|
||||
|
||||
if (!keywords) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const apiKey = process.env.AMAP_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "AMAP_API_KEY not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
const apiKey = requireAmapApiKey();
|
||||
|
||||
const location = req.nextUrl.searchParams.get("location");
|
||||
|
||||
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("keywords", keywords);
|
||||
url.searchParams.set("datatype", "poi");
|
||||
if (location) {
|
||||
url.searchParams.set("location", location);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("keywords", keywords);
|
||||
url.searchParams.set("datatype", "poi");
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
const data = await res.json();
|
||||
|
||||
if (data.status !== "1" || !data.tips) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const suggestions = data.tips
|
||||
.filter((t: { location?: string }) => t.location && t.location !== "")
|
||||
.slice(0, 8)
|
||||
.map((t: { id: string; name: string; district?: string; address?: string; location: string }) => {
|
||||
const [lng, lat] = t.location.split(",").map(Number);
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
district: t.district || "",
|
||||
address: t.address || "",
|
||||
lat,
|
||||
lng,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json(suggestions);
|
||||
} catch (e) {
|
||||
console.error("Location suggest error:", e);
|
||||
return NextResponse.json([]);
|
||||
data = await res.json();
|
||||
} catch {
|
||||
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.status !== "1" || !data.tips) return NextResponse.json([]);
|
||||
|
||||
const suggestions = data.tips
|
||||
.filter((t: { location?: string }) => t.location && t.location !== "")
|
||||
.slice(0, 8)
|
||||
.map((t: { id: string; name: string; district?: string; address?: string; location: string }) => {
|
||||
const [lng, lat] = t.location.split(",").map(Number);
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
district: t.district || "",
|
||||
address: t.address || "",
|
||||
lat,
|
||||
lng,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json(suggestions);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
||||
import { getRoomData } from "@/lib/store";
|
||||
import { subscribe } from "@/lib/roomEvents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -9,6 +10,19 @@ export async function GET(
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (userId) {
|
||||
const data = await getRoomData(id);
|
||||
if (data && !data.users.includes(userId)) {
|
||||
return new Response(JSON.stringify({ error: "not_a_member" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
@@ -26,13 +40,18 @@ export async function GET(
|
||||
let alive = true;
|
||||
|
||||
(async () => {
|
||||
const status = await buildRoomStatus(id);
|
||||
if (!status) {
|
||||
send({ error: "room_not_found" });
|
||||
try {
|
||||
const status = await buildRoomStatus(id);
|
||||
if (!status) {
|
||||
send({ error: "room_not_found" });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (alive) send(status);
|
||||
} catch {
|
||||
send({ error: "load_failed" });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (alive) send(status);
|
||||
})();
|
||||
|
||||
const unsubscribe = subscribe(id, async () => {
|
||||
|
||||
@@ -1,64 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { notify } from "@/lib/roomEvents";
|
||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
export const POST = apiHandler(async (req, { params }) => {
|
||||
const { id } = await params;
|
||||
const { userId } = await req.json();
|
||||
|
||||
try {
|
||||
const { userId } = await req.json();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "userId required" }, { status: 400 });
|
||||
requireUserId(userId);
|
||||
|
||||
const updated = await atomicUpdateRoom(id, (data) => {
|
||||
if (data.kickedUsers.includes(userId)) {
|
||||
throw new ApiError("你已被移出该房间", 403);
|
||||
}
|
||||
|
||||
const updated = await atomicUpdateRoom(id, (data) => {
|
||||
if (data.kickedUsers.includes(userId)) {
|
||||
throw new Error("KICKED");
|
||||
}
|
||||
if (data.locked && !data.users.includes(userId)) {
|
||||
throw new Error("LOCKED");
|
||||
}
|
||||
if (!data.users.includes(userId)) {
|
||||
data.users.push(userId);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: "房间不存在或已过期" },
|
||||
{ status: 404 },
|
||||
);
|
||||
if (data.locked && !data.users.includes(userId)) {
|
||||
throw new ApiError("房间已锁定,无法加入", 403);
|
||||
}
|
||||
|
||||
notify(id);
|
||||
|
||||
return NextResponse.json({
|
||||
roomId: id,
|
||||
userCount: updated.users.length,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
if (e.message === "LOCKED") {
|
||||
return NextResponse.json(
|
||||
{ error: "房间已锁定,无法加入" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (e.message === "KICKED") {
|
||||
return NextResponse.json(
|
||||
{ error: "你已被移出该房间" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (!data.users.includes(userId)) {
|
||||
data.users.push(userId);
|
||||
}
|
||||
console.error("Failed to join room:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "加入房间失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
if (!updated) throw new ApiError("房间不存在或已过期", 404);
|
||||
|
||||
notify(id);
|
||||
|
||||
return NextResponse.json({
|
||||
roomId: id,
|
||||
userCount: updated.users.length,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,106 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { notify } from "@/lib/roomEvents";
|
||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
export const POST = apiHandler(async (req, { params }) => {
|
||||
const { id } = await params;
|
||||
const { userId, action, targetUserId } = await req.json();
|
||||
|
||||
try {
|
||||
const { userId, action, targetUserId } = await req.json();
|
||||
if (!userId || !action) {
|
||||
return NextResponse.json(
|
||||
{ error: "userId and action required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
requireUserId(userId);
|
||||
if (!action) throw new ApiError("action required");
|
||||
|
||||
const updated = await atomicUpdateRoom(id, (data) => {
|
||||
if (data.creatorId !== userId) {
|
||||
throw new ApiError("只有房主可以执行此操作", 403);
|
||||
}
|
||||
|
||||
const updated = await atomicUpdateRoom(id, (data) => {
|
||||
if (data.creatorId !== userId) {
|
||||
throw new Error("FORBIDDEN");
|
||||
}
|
||||
switch (action) {
|
||||
case "lock":
|
||||
data.locked = true;
|
||||
break;
|
||||
|
||||
switch (action) {
|
||||
case "lock":
|
||||
data.locked = true;
|
||||
break;
|
||||
case "unlock":
|
||||
data.locked = false;
|
||||
break;
|
||||
|
||||
case "unlock":
|
||||
data.locked = false;
|
||||
break;
|
||||
case "kick":
|
||||
if (!targetUserId || targetUserId === userId) {
|
||||
throw new ApiError("无效的操作对象");
|
||||
}
|
||||
data.users = data.users.filter((u) => u !== targetUserId);
|
||||
if (!data.kickedUsers.includes(targetUserId)) {
|
||||
data.kickedUsers.push(targetUserId);
|
||||
}
|
||||
delete data.swipeCounts[targetUserId];
|
||||
for (const rid of Object.keys(data.likes)) {
|
||||
data.likes[rid] = data.likes[rid].filter(
|
||||
(u) => u !== targetUserId,
|
||||
);
|
||||
}
|
||||
if (
|
||||
data.match &&
|
||||
data.likes[data.match]?.length !== data.users.length
|
||||
) {
|
||||
data.match = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case "kick":
|
||||
if (!targetUserId || targetUserId === userId) {
|
||||
throw new Error("INVALID_TARGET");
|
||||
}
|
||||
data.users = data.users.filter((u) => u !== targetUserId);
|
||||
if (!data.kickedUsers.includes(targetUserId)) {
|
||||
data.kickedUsers.push(targetUserId);
|
||||
}
|
||||
delete data.swipeCounts[targetUserId];
|
||||
for (const rid of Object.keys(data.likes)) {
|
||||
data.likes[rid] = data.likes[rid].filter(
|
||||
(u) => u !== targetUserId,
|
||||
);
|
||||
}
|
||||
if (
|
||||
data.match &&
|
||||
data.likes[data.match]?.length !== data.users.length
|
||||
) {
|
||||
data.match = null;
|
||||
}
|
||||
break;
|
||||
case "end_voting":
|
||||
for (const u of data.users) {
|
||||
data.swipeCounts[u] = data.restaurants.length;
|
||||
}
|
||||
break;
|
||||
|
||||
case "end_voting":
|
||||
for (const u of data.users) {
|
||||
data.swipeCounts[u] = data.restaurants.length;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error("UNKNOWN_ACTION");
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: "房间不存在或已过期" },
|
||||
{ status: 404 },
|
||||
);
|
||||
default:
|
||||
throw new ApiError("未知操作");
|
||||
}
|
||||
|
||||
notify(id);
|
||||
return data;
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
if (e.message === "FORBIDDEN") {
|
||||
return NextResponse.json(
|
||||
{ error: "只有房主可以执行此操作" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (e.message === "INVALID_TARGET") {
|
||||
return NextResponse.json(
|
||||
{ error: "无效的操作对象" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (e.message === "UNKNOWN_ACTION") {
|
||||
return NextResponse.json(
|
||||
{ error: "未知操作" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
console.error("Failed to manage room:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "操作失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!updated) throw new ApiError("房间不存在或已过期", 404);
|
||||
|
||||
notify(id);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -1,50 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { notify } from "@/lib/roomEvents";
|
||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
export const POST = apiHandler(async (req, { params }) => {
|
||||
const { id } = await params;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const userId = body?.userId;
|
||||
requireUserId(userId);
|
||||
|
||||
let restaurantIds: string[] | undefined;
|
||||
try {
|
||||
const body = await req.json().catch(() => null);
|
||||
if (body?.restaurantIds && Array.isArray(body.restaurantIds)) {
|
||||
restaurantIds = body.restaurantIds;
|
||||
}
|
||||
} catch {
|
||||
// No body or invalid JSON — plain reset
|
||||
if (body?.restaurantIds && Array.isArray(body.restaurantIds)) {
|
||||
restaurantIds = body.restaurantIds;
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await atomicUpdateRoom(id, (data) => {
|
||||
if (restaurantIds && restaurantIds.length > 0) {
|
||||
const idSet = new Set(restaurantIds);
|
||||
data.restaurants = data.restaurants.filter((r) => idSet.has(r.id));
|
||||
}
|
||||
data.likes = {};
|
||||
data.swipeCounts = {};
|
||||
data.match = null;
|
||||
return data;
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: "房间不存在或已过期" },
|
||||
{ status: 404 },
|
||||
);
|
||||
const updated = await atomicUpdateRoom(id, (data) => {
|
||||
if (!data.users.includes(userId) && data.creatorId !== userId) {
|
||||
throw new ApiError("只有房间成员可以重置", 403);
|
||||
}
|
||||
|
||||
notify(id);
|
||||
if (restaurantIds && restaurantIds.length > 0) {
|
||||
const idSet = new Set(restaurantIds);
|
||||
data.restaurants = data.restaurants.filter((r) => idSet.has(r.id));
|
||||
}
|
||||
data.likes = {};
|
||||
data.swipeCounts = {};
|
||||
data.match = null;
|
||||
return data;
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error("Failed to reset room:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "重置失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!updated) throw new ApiError("房间不存在或已过期", 404);
|
||||
|
||||
notify(id);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
export const GET = apiHandler(async (_req, { params }) => {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const status = await buildRoomStatus(id);
|
||||
const status = await buildRoomStatus(id);
|
||||
if (!status) throw new ApiError("房间不存在或已过期", 404);
|
||||
|
||||
if (!status) {
|
||||
return NextResponse.json(
|
||||
{ error: "房间不存在或已过期" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(status);
|
||||
} catch (e) {
|
||||
console.error("Failed to get room:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "获取房间信息失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return NextResponse.json(status);
|
||||
});
|
||||
|
||||
@@ -1,69 +1,58 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { notify } from "@/lib/roomEvents";
|
||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
export const POST = apiHandler(async (req, { params }) => {
|
||||
const { id } = await params;
|
||||
const { userId, restaurantId, action } = await req.json();
|
||||
|
||||
try {
|
||||
const { userId, restaurantId, action } = await req.json();
|
||||
requireUserId(userId);
|
||||
if (restaurantId == null || !action) {
|
||||
throw new ApiError("restaurantId and action are required");
|
||||
}
|
||||
if (action !== "like" && action !== "pass") {
|
||||
throw new ApiError("action must be 'like' or 'pass'");
|
||||
}
|
||||
|
||||
if (!userId || restaurantId == null || !action) {
|
||||
return NextResponse.json(
|
||||
{ error: "userId, restaurantId, and action are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
const rid = String(restaurantId);
|
||||
|
||||
const updated = await atomicUpdateRoom(id, (data) => {
|
||||
if (!data.users.includes(userId)) {
|
||||
throw new ApiError("你不是该房间的成员", 403);
|
||||
}
|
||||
|
||||
const rid = String(restaurantId);
|
||||
const restaurantIndex = data.restaurants.findIndex((r) => r.id === rid);
|
||||
const alreadySwiped =
|
||||
restaurantIndex >= 0 &&
|
||||
restaurantIndex < (data.swipeCounts[userId] ?? 0);
|
||||
|
||||
const updated = await atomicUpdateRoom(id, (data) => {
|
||||
const restaurantIndex = data.restaurants.findIndex((r) => r.id === rid);
|
||||
const alreadySwiped =
|
||||
restaurantIndex >= 0 &&
|
||||
restaurantIndex < (data.swipeCounts[userId] ?? 0);
|
||||
if (alreadySwiped) return data;
|
||||
|
||||
if (alreadySwiped) return data;
|
||||
|
||||
if (action === "like") {
|
||||
if (!data.likes[rid]) {
|
||||
data.likes[rid] = [];
|
||||
}
|
||||
if (!data.likes[rid].includes(userId)) {
|
||||
data.likes[rid].push(userId);
|
||||
}
|
||||
|
||||
if (data.likes[rid].length === data.users.length) {
|
||||
data.match = rid;
|
||||
}
|
||||
if (action === "like") {
|
||||
if (!data.likes[rid]) {
|
||||
data.likes[rid] = [];
|
||||
}
|
||||
if (!data.likes[rid].includes(userId)) {
|
||||
data.likes[rid].push(userId);
|
||||
}
|
||||
|
||||
data.swipeCounts[userId] = (data.swipeCounts[userId] ?? 0) + 1;
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: "房间不存在或已过期" },
|
||||
{ status: 404 },
|
||||
);
|
||||
if (data.likes[rid].length === data.users.length) {
|
||||
data.match = rid;
|
||||
}
|
||||
}
|
||||
|
||||
notify(id);
|
||||
data.swipeCounts[userId] = (data.swipeCounts[userId] ?? 0) + 1;
|
||||
|
||||
return NextResponse.json({
|
||||
match: updated.match,
|
||||
likeCount: updated.likes[rid]?.length ?? 0,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to process swipe:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "操作失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
if (!updated) throw new ApiError("房间不存在或已过期", 404);
|
||||
|
||||
notify(id);
|
||||
|
||||
return NextResponse.json({
|
||||
match: updated.match,
|
||||
likeCount: updated.likes[rid]?.length ?? 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,60 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { notify } from "@/lib/roomEvents";
|
||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
export const POST = apiHandler(async (req, { params }) => {
|
||||
const { id } = await params;
|
||||
const { userId, restaurantId } = await req.json();
|
||||
|
||||
try {
|
||||
const { userId, restaurantId } = await req.json();
|
||||
requireUserId(userId);
|
||||
if (restaurantId == null) throw new ApiError("restaurantId is required");
|
||||
|
||||
if (!userId || restaurantId == null) {
|
||||
return NextResponse.json(
|
||||
{ error: "userId and restaurantId are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
const rid = String(restaurantId);
|
||||
|
||||
const updated = await atomicUpdateRoom(id, (data) => {
|
||||
if (!data.users.includes(userId)) {
|
||||
throw new ApiError("你不是该房间的成员", 403);
|
||||
}
|
||||
|
||||
const rid = String(restaurantId);
|
||||
|
||||
const updated = await atomicUpdateRoom(id, (data) => {
|
||||
if (data.likes[rid]) {
|
||||
data.likes[rid] = data.likes[rid].filter((u) => u !== userId);
|
||||
if (data.likes[rid].length === 0) {
|
||||
delete data.likes[rid];
|
||||
}
|
||||
if (data.likes[rid]) {
|
||||
data.likes[rid] = data.likes[rid].filter((u) => u !== userId);
|
||||
if (data.likes[rid].length === 0) {
|
||||
delete data.likes[rid];
|
||||
}
|
||||
|
||||
if (data.match === rid) {
|
||||
data.match = null;
|
||||
}
|
||||
|
||||
const count = data.swipeCounts[userId] ?? 0;
|
||||
if (count > 0) {
|
||||
data.swipeCounts[userId] = count - 1;
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: "房间不存在或已过期" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
notify(id);
|
||||
if (data.match === rid) {
|
||||
data.match = null;
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error("Failed to undo swipe:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "撤回失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
const count = data.swipeCounts[userId] ?? 0;
|
||||
if (count > 0) {
|
||||
data.swipeCounts[userId] = count - 1;
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
if (!updated) throw new ApiError("房间不存在或已过期", 404);
|
||||
|
||||
notify(id);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
|
||||
import { createRoom } from "@/lib/store";
|
||||
import { Restaurant, SceneType } from "@/types";
|
||||
import { getSceneConfig } from "@/lib/sceneConfig";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
import { requireAmapApiKey } from "@/lib/amap";
|
||||
|
||||
interface AmapPoiV5 {
|
||||
id: string;
|
||||
@@ -101,78 +103,63 @@ function filterByPrice(
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const {
|
||||
lat,
|
||||
lng,
|
||||
radius = 3000,
|
||||
priceRange = "any",
|
||||
cuisine = "不限",
|
||||
userId = "",
|
||||
scene = "eat" as SceneType,
|
||||
} = body;
|
||||
export const POST = apiHandler(async (req) => {
|
||||
const body = await req.json();
|
||||
const {
|
||||
lat,
|
||||
lng,
|
||||
radius = 3000,
|
||||
priceRange = "any",
|
||||
cuisine = "不限",
|
||||
userId = "",
|
||||
scene = "eat" as SceneType,
|
||||
} = body;
|
||||
|
||||
const sceneConfig = getSceneConfig(scene === "drink" ? "drink" : "eat");
|
||||
const sceneConfig = getSceneConfig(scene);
|
||||
|
||||
if (!lat || !lng) {
|
||||
return NextResponse.json(
|
||||
{ error: "无法获取位置信息,请允许定位权限后重试" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = process.env.AMAP_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("AMAP_API_KEY not configured");
|
||||
return NextResponse.json(
|
||||
{ error: "服务配置异常,请稍后重试" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL("https://restapi.amap.com/v5/place/around");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("location", `${lng},${lat}`);
|
||||
url.searchParams.set("radius", String(radius));
|
||||
url.searchParams.set("types", sceneConfig.poiTypes);
|
||||
url.searchParams.set("show_fields", "business,photos");
|
||||
url.searchParams.set("sortrule", "weight");
|
||||
|
||||
const needsPriceFilter = priceRange !== "any";
|
||||
url.searchParams.set("page_size", needsPriceFilter ? "25" : "15");
|
||||
|
||||
if (cuisine && cuisine !== "不限") {
|
||||
url.searchParams.set("keywords", cuisine);
|
||||
}
|
||||
|
||||
const amapRes = await fetch(url.toString());
|
||||
const amapData = await amapRes.json();
|
||||
|
||||
let restaurants: Restaurant[] = [];
|
||||
if (amapData.status === "1" && amapData.pois?.length > 0) {
|
||||
let results: Restaurant[] = amapData.pois.map(
|
||||
(poi: AmapPoiV5) => mapPoiToRestaurant(poi, sceneConfig.defaultImage),
|
||||
);
|
||||
results = filterByPrice(results, priceRange);
|
||||
restaurants = results.slice(0, 15);
|
||||
}
|
||||
|
||||
if (restaurants.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: sceneConfig.emptyError },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const roomId = await createRoom(restaurants, userId, sceneConfig.key);
|
||||
return NextResponse.json({ roomId, restaurants });
|
||||
} catch (e) {
|
||||
console.error("Failed to create room:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "搜索失败,请检查网络后重试" },
|
||||
{ status: 500 },
|
||||
);
|
||||
const numLat = Number(lat);
|
||||
const numLng = Number(lng);
|
||||
if (!Number.isFinite(numLat) || !Number.isFinite(numLng) ||
|
||||
numLat < -90 || numLat > 90 || numLng < -180 || numLng > 180) {
|
||||
throw new ApiError("无法获取位置信息,请允许定位权限后重试");
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = requireAmapApiKey();
|
||||
|
||||
const url = new URL("https://restapi.amap.com/v5/place/around");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("location", `${numLng},${numLat}`);
|
||||
url.searchParams.set("radius", String(radius));
|
||||
url.searchParams.set("types", sceneConfig.poiTypes);
|
||||
url.searchParams.set("show_fields", "business,photos");
|
||||
url.searchParams.set("sortrule", "weight");
|
||||
|
||||
const needsPriceFilter = priceRange !== "any";
|
||||
url.searchParams.set("page_size", needsPriceFilter ? "25" : "15");
|
||||
|
||||
if (cuisine && cuisine !== "不限") {
|
||||
url.searchParams.set("keywords", cuisine);
|
||||
}
|
||||
|
||||
let amapData;
|
||||
try {
|
||||
const amapRes = await fetch(url.toString());
|
||||
amapData = await amapRes.json();
|
||||
} catch {
|
||||
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
|
||||
}
|
||||
|
||||
let restaurants: Restaurant[] = [];
|
||||
if (amapData.status === "1" && amapData.pois?.length > 0) {
|
||||
let results: Restaurant[] = amapData.pois.map(
|
||||
(poi: AmapPoiV5) => mapPoiToRestaurant(poi, sceneConfig.defaultImage),
|
||||
);
|
||||
results = filterByPrice(results, priceRange);
|
||||
restaurants = results.slice(0, 15);
|
||||
}
|
||||
|
||||
if (restaurants.length === 0) throw new ApiError(sceneConfig.emptyError, 404);
|
||||
|
||||
const roomId = await createRoom(restaurants, userId, sceneConfig.key);
|
||||
return NextResponse.json({ roomId, restaurants });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
export const GET = apiHandler(async (req) => {
|
||||
const userId = req.nextUrl.searchParams.get("userId");
|
||||
if (!userId) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
if (!userId) return NextResponse.json([]);
|
||||
|
||||
const favorites = await prisma.favorite.findMany({
|
||||
where: { userId },
|
||||
@@ -14,59 +14,53 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
favorites.map((f) => ({
|
||||
id: f.id,
|
||||
restaurantData: JSON.parse(f.restaurantData),
|
||||
createdAt: f.createdAt.toISOString(),
|
||||
})),
|
||||
favorites.map((f) => {
|
||||
let restaurantData = {};
|
||||
try { restaurantData = JSON.parse(f.restaurantData); } catch { /* ignore */ }
|
||||
return { id: f.id, restaurantData, createdAt: f.createdAt.toISOString() };
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
export const POST = apiHandler(async (req) => {
|
||||
const { userId, restaurant } = await req.json();
|
||||
|
||||
if (!userId || !restaurant) {
|
||||
return NextResponse.json({ error: "缺少必要字段" }, { status: 400 });
|
||||
requireUserId(userId);
|
||||
if (!restaurant?.id || typeof restaurant.id !== "string") {
|
||||
throw new ApiError("缺少必要字段");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "请先设置个人资料" }, { status: 404 });
|
||||
await requireUser(userId);
|
||||
|
||||
try {
|
||||
const fav = await prisma.favorite.create({
|
||||
data: {
|
||||
userId,
|
||||
restaurantId: restaurant.id,
|
||||
restaurantData: JSON.stringify(restaurant),
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ id: fav.id });
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||
const existing = await prisma.favorite.findFirst({
|
||||
where: { userId, restaurantId: restaurant.id },
|
||||
});
|
||||
return NextResponse.json({ id: existing?.id ?? "", alreadyExists: true });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
const existing = await prisma.favorite.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
restaurantData: { contains: `"id":"${restaurant.id}"` },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ id: existing.id, alreadyExists: true });
|
||||
}
|
||||
|
||||
const fav = await prisma.favorite.create({
|
||||
data: {
|
||||
userId,
|
||||
restaurantData: JSON.stringify(restaurant),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: fav.id });
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
export const DELETE = apiHandler(async (req) => {
|
||||
const { userId, favoriteId } = await req.json();
|
||||
|
||||
if (!userId || !favoriteId) {
|
||||
return NextResponse.json({ error: "缺少必要字段" }, { status: 400 });
|
||||
}
|
||||
requireUserId(userId);
|
||||
if (!favoriteId) throw new ApiError("缺少必要字段");
|
||||
|
||||
const fav = await prisma.favorite.findUnique({ where: { id: favoriteId } });
|
||||
if (!fav || fav.userId !== userId) {
|
||||
return NextResponse.json({ error: "收藏不存在" }, { status: 404 });
|
||||
}
|
||||
if (!fav || fav.userId !== userId) throw new ApiError("收藏不存在", 404);
|
||||
|
||||
await prisma.favorite.delete({ where: { id: favoriteId } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
+46
-50
@@ -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);
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: updateData,
|
||||
});
|
||||
try {
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
email: user.email,
|
||||
preferences: JSON.parse(user.preferences),
|
||||
});
|
||||
}
|
||||
let prefs = {};
|
||||
try { prefs = JSON.parse(user.preferences); } catch { /* fallback */ }
|
||||
|
||||
return NextResponse.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
email: user.email,
|
||||
preferences: prefs,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||
throw new ApiError("用户名已被占用", 409);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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%); }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{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 }}
|
||||
>
|
||||
{joinError}
|
||||
</motion.p>
|
||||
)}
|
||||
<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"
|
||||
size="lg"
|
||||
fullWidth
|
||||
loading={joining}
|
||||
loadingText="加入中..."
|
||||
>
|
||||
{joining ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
加入中...
|
||||
</>
|
||||
) : (
|
||||
"加入房间"
|
||||
)}
|
||||
</button>
|
||||
加入房间
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+17
-2
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
+143
-594
@@ -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 }}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<span className="text-[10px] font-semibold text-zinc-700">各自滑卡</span>
|
||||
</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"
|
||||
{/* 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 }}
|
||||
>
|
||||
{loading && loadingText ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus size={18} strokeWidth={3} />
|
||||
创建新房间
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<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" />
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</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("");
|
||||
<motion.div
|
||||
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 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.div>
|
||||
{/* 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 }}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+106
-278
@@ -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"
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
<ProfileFavoritesCard
|
||||
favorites={favorites}
|
||||
loading={favLoading}
|
||||
open={showFavorites}
|
||||
onToggle={() => setShowFavorites((v) => !v)}
|
||||
onRemove={handleRemoveFavorite}
|
||||
onEmpty={() => router.push("/blindbox")}
|
||||
delay={0.2}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
+43
-31
@@ -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]);
|
||||
try {
|
||||
const res = await fetch(`/api/room/${roomId}/reset`, {
|
||||
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 || "重置失败");
|
||||
}
|
||||
await mutate();
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : "重置失败");
|
||||
}
|
||||
}, [roomId, userId, mutate, toast]);
|
||||
|
||||
const handleNarrow = useCallback(async (restaurantIds: string[]) => {
|
||||
await fetch(`/api/room/${roomId}/reset`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ restaurantIds }),
|
||||
});
|
||||
await mutate();
|
||||
}, [roomId, mutate]);
|
||||
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 (
|
||||
|
||||
@@ -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")}
|
||||
|
||||
+147
-166
@@ -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,169 +121,142 @@ 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}
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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-elevated text-muted transition-colors active:bg-subtle"
|
||||
>
|
||||
<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 }}
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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-heading" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-lg font-bold text-zinc-900">欢迎</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"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 rounded-xl bg-zinc-100 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 && (
|
||||
<motion.div
|
||||
layoutId="auth-tab"
|
||||
className="absolute inset-0 rounded-lg bg-white shadow-sm"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">
|
||||
{t === "login" ? "登录" : "注册"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div className="mt-5">
|
||||
<p className="text-xs font-medium text-zinc-500">用户名</p>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value.slice(0, 16));
|
||||
setError("");
|
||||
}}
|
||||
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"
|
||||
{tab === t && (
|
||||
<motion.div
|
||||
layoutId="auth-tab"
|
||||
className="absolute inset-0 rounded-lg bg-subtle shadow-sm"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-zinc-500">密码</p>
|
||||
<div className="relative mt-2">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
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"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
{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
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="relative z-10">
|
||||
{t === "login" ? "登录" : "注册"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Avatar picker (register only) */}
|
||||
{tab === "register" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-zinc-500">
|
||||
选择头像
|
||||
<span className="ml-1 text-zinc-300">(可选)</span>
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-6 gap-2">
|
||||
{AVATARS.map((a) => (
|
||||
<button
|
||||
key={a.emoji}
|
||||
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.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5">
|
||||
<p className="text-xs font-medium text-muted">用户名</p>
|
||||
<Input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value.slice(0, 16));
|
||||
setError("");
|
||||
}}
|
||||
placeholder={tab === "register" ? "2-16 个字符" : "请输入用户名"}
|
||||
maxLength={16}
|
||||
size="xl"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
className="mt-3 text-center text-xs font-medium text-rose-500"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-muted">密码</p>
|
||||
<div className="relative mt-2">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder={tab === "register" ? "至少 6 个字符" : "请输入密码"}
|
||||
size="xl"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
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>
|
||||
|
||||
<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"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{tab === "login" ? "登录中..." : "注册中..."}
|
||||
</>
|
||||
) : tab === "login" ? (
|
||||
"登录"
|
||||
) : (
|
||||
"注册"
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
{tab === "register" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-muted">确认密码</p>
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder="再次输入密码"
|
||||
size="xl"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{tab === "register" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-muted">
|
||||
选择头像
|
||||
<span className="ml-1 text-dim">(可选)</span>
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-6 gap-2">
|
||||
{AVATARS.map((a) => (
|
||||
<button
|
||||
key={a.emoji}
|
||||
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-accent ring-offset-1 ring-offset-surface`
|
||||
: "bg-elevated hover:bg-subtle"
|
||||
}`}
|
||||
>
|
||||
{a.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
size="lg"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
loadingText={tab === "login" ? "登录中..." : "注册中..."}
|
||||
className="mt-5"
|
||||
>
|
||||
{tab === "login" ? "登录" : "注册"}
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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-500/15">
|
||||
<LogOut size={22} className="text-rose-400" />
|
||||
</div>
|
||||
|
||||
<h2 className="mt-4 text-base font-bold text-heading">
|
||||
确定要退出房间吗?
|
||||
</h2>
|
||||
<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 bg-elevated text-sm font-semibold text-secondary ring-1 ring-border transition-colors active:bg-subtle"
|
||||
>
|
||||
<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>
|
||||
|
||||
<h2 className="mt-4 text-base font-bold text-zinc-900">
|
||||
确定要退出房间吗?
|
||||
</h2>
|
||||
<p className="mt-1.5 text-center text-xs leading-relaxed text-zinc-400">
|
||||
退出后你的滑卡进度不会丢失,可以用房间号重新加入
|
||||
</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"
|
||||
>
|
||||
继续滑卡
|
||||
</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"
|
||||
>
|
||||
退出房间
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
继续滑卡
|
||||
</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-lg shadow-rose-500/20 transition-colors active:bg-rose-600"
|
||||
>
|
||||
退出房间
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
+222
-277
@@ -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
|
||||
? "大家一拍即合!"
|
||||
: `${matchLikes}/${userCount} 人想去这家`}
|
||||
{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>
|
||||
|
||||
{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-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>
|
||||
</div>
|
||||
)}
|
||||
</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 ? (
|
||||
<>
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
换一批店
|
||||
</motion.button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<RotateCcw size={14} 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"
|
||||
}`}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
换一批店
|
||||
</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 }}
|
||||
{/* 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 }}
|
||||
>
|
||||
<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 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 }}
|
||||
>
|
||||
{toast}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<RotateCcw size={12} className={resetting ? "animate-spin" : ""} />
|
||||
{resetting ? "重置中..." : "再来一轮"}
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => router.push("/")}
|
||||
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={12} />
|
||||
换一批店
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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-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-heading">
|
||||
<QrCode size={18} className="text-accent" />
|
||||
<h2 className="text-lg font-bold">邀请饭搭子</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{sceneConfig.qrSubtitle}
|
||||
</p>
|
||||
|
||||
<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="#e5e7eb"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<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
|
||||
onClick={handleCopy}
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
icon={<Copy size={15} />}
|
||||
className="flex-1"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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" />
|
||||
<h2 className="text-lg font-bold">邀请饭搭子</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-400">
|
||||
{sceneConfig.qrSubtitle}
|
||||
</p>
|
||||
|
||||
<div className="mt-5 rounded-2xl border-2 border-dashed border-emerald-200 bg-emerald-50/30 p-4">
|
||||
<QRCodeSVG
|
||||
value={inviteUrl}
|
||||
size={180}
|
||||
level="M"
|
||||
bgColor="transparent"
|
||||
fgColor="#18181b"
|
||||
/>
|
||||
</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">
|
||||
{roomId}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex w-full gap-2.5">
|
||||
<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"
|
||||
>
|
||||
<Copy size={15} />
|
||||
复制链接
|
||||
</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"
|
||||
>
|
||||
<Share2 size={15} />
|
||||
发送邀请
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
复制链接
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
size="lg"
|
||||
icon={<Share2 size={15} />}
|
||||
className="flex-1"
|
||||
>
|
||||
发送邀请
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
{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} />
|
||||
{likeCount} 人想去
|
||||
</span>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{likeCount > 0 && (
|
||||
<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} 人想去
|
||||
</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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+160
-195
@@ -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,222 +54,192 @@ 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}
|
||||
<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-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-400" />
|
||||
<h2 className="text-lg font-bold text-heading">房间管理</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
房间号 {roomId}
|
||||
</p>
|
||||
|
||||
<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
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
<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 }}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
{loading === "lock" || loading === "unlock" ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : locked ? (
|
||||
<Unlock size={15} />
|
||||
) : (
|
||||
<Lock size={15} />
|
||||
)}
|
||||
{locked ? "解锁房间(允许新人加入)" : "锁定房间(阻止新人加入)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Crown size={18} className="text-amber-500" />
|
||||
<h2 className="text-lg font-bold text-zinc-900">房间管理</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-400">
|
||||
房间号 {roomId}
|
||||
</p>
|
||||
<div className="mt-5">
|
||||
<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 displayName = userProfiles[uid]?.username ?? uid.slice(0, 8);
|
||||
const isCreator = uid === userId;
|
||||
const swiped = swipeCounts[uid] ?? 0;
|
||||
const finished = swiped >= totalCards;
|
||||
|
||||
{/* 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"
|
||||
}`}
|
||||
return (
|
||||
<div
|
||||
key={uid}
|
||||
className="flex items-center gap-2.5 rounded-xl bg-elevated px-3 py-2.5"
|
||||
>
|
||||
{loading === "lock" || loading === "unlock" ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : locked ? (
|
||||
<Unlock size={15} />
|
||||
) : (
|
||||
<Lock size={15} />
|
||||
<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-400">
|
||||
<Crown size={10} />
|
||||
房主
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-xs font-medium text-tertiary">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[11px] ${finished ? "text-accent" : "text-muted"}`}
|
||||
>
|
||||
{swiped}/{totalCards}
|
||||
{finished ? " 已完成" : " 进行中"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isCreator && (
|
||||
<>
|
||||
{confirmKick === uid ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => manage("kick", uid)}
|
||||
disabled={loading !== null}
|
||||
className="rounded-lg bg-rose-500 px-2.5 py-1 text-[11px] font-semibold text-white transition-colors active:bg-rose-600 disabled:opacity-50"
|
||||
>
|
||||
{loading === "kick" + uid ? (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
"确认"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmKick(null)}
|
||||
className="rounded-lg bg-subtle px-2.5 py-1 text-[11px] font-semibold text-tertiary transition-colors active:bg-elevated"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmKick(uid)}
|
||||
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} />
|
||||
移出
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{locked ? "解锁房间(允许新人加入)" : "锁定房间(阻止新人加入)"}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
{confirmEnd ? (
|
||||
<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">
|
||||
<button
|
||||
onClick={() => manage("end_voting")}
|
||||
disabled={loading !== null}
|
||||
className="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-xs font-semibold text-white transition-colors active:bg-amber-600 disabled:opacity-50"
|
||||
>
|
||||
{loading === "end_voting" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Flag size={13} />
|
||||
)}
|
||||
确认结束
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmEnd(false)}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* User list with kick */}
|
||||
<div className="mt-5">
|
||||
<h3 className="text-xs font-semibold text-zinc-500">
|
||||
房间成员({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 isCreator = uid === userId;
|
||||
const swiped = swipeCounts[uid] ?? 0;
|
||||
const finished = swiped >= totalCards;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={uid}
|
||||
className="flex items-center gap-2.5 rounded-xl bg-zinc-50 px-3 py-2.5"
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-base ${bg}`}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
<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">
|
||||
<Crown size={10} />
|
||||
房主
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-xs font-medium text-zinc-500">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[11px] ${finished ? "text-emerald-500" : "text-zinc-400"}`}
|
||||
>
|
||||
{swiped}/{totalCards}
|
||||
{finished ? " 已完成" : " 进行中"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isCreator && (
|
||||
<>
|
||||
{confirmKick === uid ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => manage("kick", uid)}
|
||||
disabled={loading !== null}
|
||||
className="rounded-lg bg-rose-500 px-2.5 py-1 text-[11px] font-semibold text-white transition-colors active:bg-rose-600 disabled:opacity-50"
|
||||
>
|
||||
{loading === "kick" + uid ? (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
"确认"
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<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"
|
||||
>
|
||||
<UserX size={13} />
|
||||
移出
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
确定要结束投票吗?将根据当前已有的投票结果直接结算。
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => manage("end_voting")}
|
||||
disabled={loading !== null}
|
||||
className="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-xs font-semibold text-white transition-colors active:bg-amber-600 disabled:opacity-50"
|
||||
>
|
||||
{loading === "end_voting" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Flag size={13} />
|
||||
)}
|
||||
确认结束
|
||||
</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"
|
||||
>
|
||||
再等等
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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"
|
||||
>
|
||||
<Flag size={15} />
|
||||
结束投票(立即出结果)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmEnd(true)}
|
||||
disabled={loading !== null}
|
||||
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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,48 +16,53 @@ 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}
|
||||
</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 ?? "";
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
className={`flex items-center gap-1 text-[11px] tabular-nums ${finished ? "text-emerald-400" : "text-zinc-400"}`}
|
||||
>
|
||||
<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>}
|
||||
{count}/{total}
|
||||
{finished && <Check size={10} className="text-emerald-400" />}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
{others.map(([id, count]) => {
|
||||
const finished = count >= total;
|
||||
const label = userProfiles[id]?.username ?? "";
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
className={`flex items-center gap-1.5 text-[11px] ${finished ? "text-emerald-400" : "text-muted"}`}
|
||||
>
|
||||
<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 (
|
||||
<>
|
||||
{!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>
|
||||
<UserProgressBar
|
||||
userId={userId}
|
||||
swipeCounts={swipeCounts}
|
||||
localIndex={currentIndex}
|
||||
total={restaurants.length}
|
||||
userProfiles={userProfiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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="absolute inset-x-0 bottom-full mb-2">
|
||||
<UserProgressBar
|
||||
userId={userId}
|
||||
swipeCounts={swipeCounts}
|
||||
localIndex={currentIndex}
|
||||
total={restaurants.length}
|
||||
userProfiles={userProfiles}
|
||||
onUndo={handleUndo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+30
-65
@@ -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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<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>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user