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
+2
@@ -4,6 +4,7 @@ pipeline {
|
|||||||
environment {
|
environment {
|
||||||
APP_NAME = 'no-whatever'
|
APP_NAME = 'no-whatever'
|
||||||
AMAP_KEY = '7f6be40a6de3f7fbb7bc3f825b67573b'
|
AMAP_KEY = '7f6be40a6de3f7fbb7bc3f825b67573b'
|
||||||
|
DEEPSEEK_KEY = credentials('deepseek-api-key')
|
||||||
}
|
}
|
||||||
|
|
||||||
triggers {
|
triggers {
|
||||||
@@ -37,6 +38,7 @@ pipeline {
|
|||||||
-v /data/${APP_NAME}:/app/data \
|
-v /data/${APP_NAME}:/app/data \
|
||||||
-e DATABASE_URL=file:/app/data/prod.db \
|
-e DATABASE_URL=file:/app/data/prod.db \
|
||||||
-e AMAP_API_KEY=${AMAP_KEY} \
|
-e AMAP_API_KEY=${AMAP_KEY} \
|
||||||
|
-e DEEPSEEK_API_KEY=${DEEPSEEK_KEY} \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
${APP_NAME}:latest
|
${APP_NAME}:latest
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,18 +1,52 @@
|
|||||||
# NoWhatever — 别说随便
|
# NoWhatever — 别说随便
|
||||||
|
|
||||||
像 Tinder 一样滑卡片,和朋友一起决定去哪吃!解决聚餐时"随便都行"的纠结痛点,无需下载 App,用完即走。
|
> 亲密关系决策引擎。别再说"随便"了,两个模式覆盖你们所有的选择困难症。
|
||||||
|
|
||||||
|
## 两大模式
|
||||||
|
|
||||||
|
### ⚡️ 极速救场 · Panic Mode
|
||||||
|
|
||||||
|
10 秒内出结果,立刻闭嘴,听天由命。
|
||||||
|
|
||||||
|
- 基于 GPS 或手动选点,搜索附近餐厅 / 酒吧
|
||||||
|
- Tinder 式滑卡投票 — 右滑想去、左滑跳过
|
||||||
|
- 多人实时匹配 — 分享房间链接,所有人同时滑,自动算出最优解
|
||||||
|
- 全员一致时触发庆祝特效,非全员一致可发起 Top N 决赛
|
||||||
|
- 匹配结果支持一键导航、电话订位、收藏、生成分享卡片
|
||||||
|
|
||||||
|
### 🎁 周末契约 · Adventure Roulette
|
||||||
|
|
||||||
|
丢入疯狂想法,周末盲盒开奖,绝不反悔。
|
||||||
|
|
||||||
|
- 创建专属房间,邀请 TA 用 6 位房间号加入
|
||||||
|
- 平日随时向盲盒池投放想法(美食 / 旅行 / 运动 / 奇葩挑战)
|
||||||
|
- 周末一起盲抽,开奖结果不可反悔
|
||||||
|
- 支持多个房间并行,房间成员共同管理想法池
|
||||||
|
|
||||||
|
## 通用能力
|
||||||
|
|
||||||
|
- **用户系统** — 用户名 + 密码注册,10 秒完成,头像自选
|
||||||
|
- **分享卡片** — 匹配 / 开奖结果一键生成品牌分享图,支持保存 & Web Share API
|
||||||
|
- **个人中心** — 决策历史回顾、餐厅收藏管理
|
||||||
|
- **多场景** — 吃饭 / 喝酒场景切换,复用同一套滑卡机制
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Next.js** (App Router) + **React** + **TypeScript**
|
- **Next.js 16** (App Router) + **React 19** + **TypeScript**
|
||||||
- **Tailwind CSS** — Utility-first styling
|
- **Prisma** + **SQLite** — 数据持久化
|
||||||
|
- **Tailwind CSS v4** — Utility-first styling
|
||||||
- **Framer Motion** — Physics-based swipe & drag animations
|
- **Framer Motion** — Physics-based swipe & drag animations
|
||||||
|
- **SWR** — 实时轮询 & 数据缓存
|
||||||
- **Lucide React** — Icon library
|
- **Lucide React** — Icon library
|
||||||
|
- **canvas-confetti** — 匹配庆祝特效
|
||||||
|
- **html-to-image** + **qrcode.react** — 分享卡片 & 邀请二维码
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma db push
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -23,16 +57,37 @@ Open [http://localhost:3000](http://localhost:3000) in your browser (best viewed
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── globals.css # Global styles (mobile-first, no scroll)
|
│ ├── page.tsx # 首页 — 双模式入口
|
||||||
│ ├── layout.tsx # Root layout with viewport meta
|
│ ├── panic/page.tsx # 极速救场 — 定位 / 选点 / 创建房间
|
||||||
│ └── page.tsx # Main entry page
|
│ ├── room/[id]/page.tsx # 滑卡房间 — 多人实时投票
|
||||||
├── components/
|
│ ├── invite/[id]/page.tsx # 邀请页 — 扫码 / 链接加入房间
|
||||||
│ ├── TopNav.tsx # Navigation bar with room info
|
│ ├── blindbox/page.tsx # 周末契约大厅 — 房间列表
|
||||||
│ ├── RestaurantCard.tsx # Restaurant display card
|
│ ├── blindbox/[code]/page.tsx # 盲盒房间 — 想法投放 & 开奖
|
||||||
│ ├── SwipeableCard.tsx # Framer Motion drag/swipe logic
|
│ ├── profile/page.tsx # 个人中心 — 历史 / 收藏 / 资料
|
||||||
│ ├── SwipeDeck.tsx # Card stack orchestrator
|
│ └── api/ # API Routes
|
||||||
│ ├── ActionButtons.tsx # Nope / Like action buttons
|
│ ├── auth/ # 登录 / 注册
|
||||||
│ └── MatchResult.tsx # Match celebration screen
|
│ ├── 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/
|
└── 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",
|
"bcryptjs": "^3.0.3",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.34.3",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"openai": "^6.25.0",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -4260,6 +4262,12 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -5606,6 +5614,27 @@
|
|||||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -7094,7 +7123,7 @@
|
|||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|||||||
@@ -13,8 +13,10 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.34.3",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"openai": "^6.25.0",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.3",
|
"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
|
data String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
|
|
||||||
|
@@index([expiresAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -24,6 +26,12 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
decisions Decision[]
|
decisions Decision[]
|
||||||
favorites Favorite[]
|
favorites Favorite[]
|
||||||
|
|
||||||
|
createdBlindBoxRooms BlindBoxRoom[] @relation("RoomCreator")
|
||||||
|
blindBoxMemberships BlindBoxMember[]
|
||||||
|
submittedIdeas BlindBoxIdea[] @relation("IdeaSubmitter")
|
||||||
|
drawnIdeas BlindBoxIdea[] @relation("IdeaDrawer")
|
||||||
|
weekendPlans WeekendPlan[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Decision {
|
model Decision {
|
||||||
@@ -35,13 +43,88 @@ model Decision {
|
|||||||
matchType String
|
matchType String
|
||||||
participants Int
|
participants Int
|
||||||
createdAt DateTime @default(now())
|
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 {
|
model Favorite {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
restaurantId String @default("")
|
||||||
restaurantData String
|
restaurantData String
|
||||||
createdAt DateTime @default(now())
|
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 { prisma } from "@/lib/prisma";
|
||||||
import bcrypt from "bcryptjs";
|
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();
|
const { username, password } = await req.json();
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) throw new ApiError("请输入用户名和密码");
|
||||||
return NextResponse.json({ error: "请输入用户名和密码" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { username: username.trim() } });
|
const user = await prisma.user.findUnique({ where: { username: username.trim() } });
|
||||||
if (!user) {
|
if (!user) throw new ApiError("用户名或密码错误", 401);
|
||||||
return NextResponse.json({ error: "用户名或密码错误" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||||
if (!valid) {
|
if (!valid) throw new ApiError("用户名或密码错误", 401);
|
||||||
return NextResponse.json({ error: "用户名或密码错误" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,30 +1,21 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import bcrypt from "bcryptjs";
|
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();
|
const { username, password, avatar } = await req.json();
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) throw new ApiError("用户名和密码为必填项");
|
||||||
return NextResponse.json({ error: "用户名和密码为必填项" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedUsername = username.trim();
|
const trimmedUsername = validateUsername(username);
|
||||||
if (trimmedUsername.length < 2 || trimmedUsername.length > 16) {
|
validatePassword(password);
|
||||||
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 passwordHash = await bcrypt.hash(password, 10);
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
try {
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
username: trimmedUsername,
|
username: trimmedUsername,
|
||||||
@@ -38,4 +29,10 @@ export async function POST(req: NextRequest) {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
avatar: user.avatar,
|
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,33 +1,27 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { apiHandler, ApiError } from "@/lib/api";
|
||||||
|
import { requireAmapApiKey } from "@/lib/amap";
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export const GET = apiHandler(async (req) => {
|
||||||
const { searchParams } = new URL(req.url);
|
const lat = req.nextUrl.searchParams.get("lat");
|
||||||
const lat = searchParams.get("lat");
|
const lng = req.nextUrl.searchParams.get("lng");
|
||||||
const lng = searchParams.get("lng");
|
|
||||||
|
|
||||||
if (!lat || !lng) {
|
if (!lat || !lng) throw new ApiError("lat and lng are required");
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "lat and lng are required" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = process.env.AMAP_API_KEY;
|
const apiKey = requireAmapApiKey();
|
||||||
if (!apiKey) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "AMAP_API_KEY not configured" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = new URL("https://restapi.amap.com/v3/geocode/regeo");
|
const url = new URL("https://restapi.amap.com/v3/geocode/regeo");
|
||||||
url.searchParams.set("key", apiKey);
|
url.searchParams.set("key", apiKey);
|
||||||
url.searchParams.set("location", `${lng},${lat}`);
|
url.searchParams.set("location", `${lng},${lat}`);
|
||||||
url.searchParams.set("extensions", "base");
|
url.searchParams.set("extensions", "base");
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
const res = await fetch(url.toString());
|
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) {
|
if (data.status !== "1" || !data.regeocode) {
|
||||||
return NextResponse.json({ name: null });
|
return NextResponse.json({ name: null });
|
||||||
@@ -47,8 +41,4 @@ export async function GET(req: Request) {
|
|||||||
name: name || data.regeocode.formatted_address || null,
|
name: name || data.regeocode.formatted_address || null,
|
||||||
formatted: data.regeocode.formatted_address || null,
|
formatted: data.regeocode.formatted_address || null,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
});
|
||||||
console.error("Regeo error:", e);
|
|
||||||
return NextResponse.json({ name: 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,34 +1,33 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { apiHandler, ApiError } from "@/lib/api";
|
||||||
|
import { requireAmapApiKey } from "@/lib/amap";
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export const GET = apiHandler(async (req) => {
|
||||||
const { searchParams } = new URL(req.url);
|
const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
|
||||||
const keywords = searchParams.get("keywords")?.trim();
|
if (!keywords) return NextResponse.json([]);
|
||||||
|
|
||||||
if (!keywords) {
|
const apiKey = requireAmapApiKey();
|
||||||
return NextResponse.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = process.env.AMAP_API_KEY;
|
const location = req.nextUrl.searchParams.get("location");
|
||||||
if (!apiKey) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "AMAP_API_KEY not configured" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
|
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
|
||||||
url.searchParams.set("key", apiKey);
|
url.searchParams.set("key", apiKey);
|
||||||
url.searchParams.set("keywords", keywords);
|
url.searchParams.set("keywords", keywords);
|
||||||
url.searchParams.set("datatype", "poi");
|
url.searchParams.set("datatype", "poi");
|
||||||
|
if (location) {
|
||||||
const res = await fetch(url.toString());
|
url.searchParams.set("location", location);
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.status !== "1" || !data.tips) {
|
|
||||||
return NextResponse.json([]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
data = await res.json();
|
||||||
|
} catch {
|
||||||
|
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status !== "1" || !data.tips) return NextResponse.json([]);
|
||||||
|
|
||||||
const suggestions = data.tips
|
const suggestions = data.tips
|
||||||
.filter((t: { location?: string }) => t.location && t.location !== "")
|
.filter((t: { location?: string }) => t.location && t.location !== "")
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
@@ -45,8 +44,4 @@ export async function GET(req: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(suggestions);
|
return NextResponse.json(suggestions);
|
||||||
} catch (e) {
|
});
|
||||||
console.error("Location suggest error:", e);
|
|
||||||
return NextResponse.json([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
||||||
|
import { getRoomData } from "@/lib/store";
|
||||||
import { subscribe } from "@/lib/roomEvents";
|
import { subscribe } from "@/lib/roomEvents";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -9,6 +10,19 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { id } = await params;
|
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 encoder = new TextEncoder();
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
@@ -26,6 +40,7 @@ export async function GET(
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
try {
|
||||||
const status = await buildRoomStatus(id);
|
const status = await buildRoomStatus(id);
|
||||||
if (!status) {
|
if (!status) {
|
||||||
send({ error: "room_not_found" });
|
send({ error: "room_not_found" });
|
||||||
@@ -33,6 +48,10 @@ export async function GET(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (alive) send(status);
|
if (alive) send(status);
|
||||||
|
} catch {
|
||||||
|
send({ error: "load_failed" });
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const unsubscribe = subscribe(id, async () => {
|
const unsubscribe = subscribe(id, async () => {
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/store";
|
||||||
import { notify } from "@/lib/roomEvents";
|
import { notify } from "@/lib/roomEvents";
|
||||||
|
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||||
|
|
||||||
export async function POST(
|
export const POST = apiHandler(async (req, { params }) => {
|
||||||
req: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
|
||||||
const { userId } = await req.json();
|
const { userId } = await req.json();
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: "userId required" }, { status: 400 });
|
requireUserId(userId);
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await atomicUpdateRoom(id, (data) => {
|
const updated = await atomicUpdateRoom(id, (data) => {
|
||||||
if (data.kickedUsers.includes(userId)) {
|
if (data.kickedUsers.includes(userId)) {
|
||||||
throw new Error("KICKED");
|
throw new ApiError("你已被移出该房间", 403);
|
||||||
}
|
}
|
||||||
if (data.locked && !data.users.includes(userId)) {
|
if (data.locked && !data.users.includes(userId)) {
|
||||||
throw new Error("LOCKED");
|
throw new ApiError("房间已锁定,无法加入", 403);
|
||||||
}
|
}
|
||||||
if (!data.users.includes(userId)) {
|
if (!data.users.includes(userId)) {
|
||||||
data.users.push(userId);
|
data.users.push(userId);
|
||||||
@@ -27,12 +22,7 @@ export async function POST(
|
|||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) throw new ApiError("房间不存在或已过期", 404);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "房间不存在或已过期" },
|
|
||||||
{ status: 404 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(id);
|
notify(id);
|
||||||
|
|
||||||
@@ -40,25 +30,4 @@ export async function POST(
|
|||||||
roomId: id,
|
roomId: id,
|
||||||
userCount: updated.users.length,
|
userCount: updated.users.length,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
});
|
||||||
if (e instanceof Error) {
|
|
||||||
if (e.message === "LOCKED") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "房间已锁定,无法加入" },
|
|
||||||
{ status: 403 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (e.message === "KICKED") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "你已被移出该房间" },
|
|
||||||
{ status: 403 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.error("Failed to join room:", e);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "加入房间失败" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/store";
|
||||||
import { notify } from "@/lib/roomEvents";
|
import { notify } from "@/lib/roomEvents";
|
||||||
|
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||||
|
|
||||||
export async function POST(
|
export const POST = apiHandler(async (req, { params }) => {
|
||||||
req: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
|
||||||
const { userId, action, targetUserId } = await req.json();
|
const { userId, action, targetUserId } = await req.json();
|
||||||
if (!userId || !action) {
|
|
||||||
return NextResponse.json(
|
requireUserId(userId);
|
||||||
{ error: "userId and action required" },
|
if (!action) throw new ApiError("action required");
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await atomicUpdateRoom(id, (data) => {
|
const updated = await atomicUpdateRoom(id, (data) => {
|
||||||
if (data.creatorId !== userId) {
|
if (data.creatorId !== userId) {
|
||||||
throw new Error("FORBIDDEN");
|
throw new ApiError("只有房主可以执行此操作", 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -33,7 +26,7 @@ export async function POST(
|
|||||||
|
|
||||||
case "kick":
|
case "kick":
|
||||||
if (!targetUserId || targetUserId === userId) {
|
if (!targetUserId || targetUserId === userId) {
|
||||||
throw new Error("INVALID_TARGET");
|
throw new ApiError("无效的操作对象");
|
||||||
}
|
}
|
||||||
data.users = data.users.filter((u) => u !== targetUserId);
|
data.users = data.users.filter((u) => u !== targetUserId);
|
||||||
if (!data.kickedUsers.includes(targetUserId)) {
|
if (!data.kickedUsers.includes(targetUserId)) {
|
||||||
@@ -60,47 +53,15 @@ export async function POST(
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error("UNKNOWN_ACTION");
|
throw new ApiError("未知操作");
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) throw new ApiError("房间不存在或已过期", 404);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "房间不存在或已过期" },
|
|
||||||
{ status: 404 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(id);
|
notify(id);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/store";
|
||||||
import { notify } from "@/lib/roomEvents";
|
import { notify } from "@/lib/roomEvents";
|
||||||
|
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||||
|
|
||||||
export async function POST(
|
export const POST = apiHandler(async (req, { params }) => {
|
||||||
req: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const userId = body?.userId;
|
||||||
|
requireUserId(userId);
|
||||||
|
|
||||||
let restaurantIds: string[] | undefined;
|
let restaurantIds: string[] | undefined;
|
||||||
try {
|
|
||||||
const body = await req.json().catch(() => null);
|
|
||||||
if (body?.restaurantIds && Array.isArray(body.restaurantIds)) {
|
if (body?.restaurantIds && Array.isArray(body.restaurantIds)) {
|
||||||
restaurantIds = body.restaurantIds;
|
restaurantIds = body.restaurantIds;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// No body or invalid JSON — plain reset
|
const updated = await atomicUpdateRoom(id, (data) => {
|
||||||
|
if (!data.users.includes(userId) && data.creatorId !== userId) {
|
||||||
|
throw new ApiError("只有房间成员可以重置", 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await atomicUpdateRoom(id, (data) => {
|
|
||||||
if (restaurantIds && restaurantIds.length > 0) {
|
if (restaurantIds && restaurantIds.length > 0) {
|
||||||
const idSet = new Set(restaurantIds);
|
const idSet = new Set(restaurantIds);
|
||||||
data.restaurants = data.restaurants.filter((r) => idSet.has(r.id));
|
data.restaurants = data.restaurants.filter((r) => idSet.has(r.id));
|
||||||
@@ -30,21 +30,9 @@ export async function POST(
|
|||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) throw new ApiError("房间不存在或已过期", 404);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "房间不存在或已过期" },
|
|
||||||
{ status: 404 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(id);
|
notify(id);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (e) {
|
});
|
||||||
console.error("Failed to reset room:", e);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "重置失败" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,28 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
||||||
|
import { apiHandler, ApiError } from "@/lib/api";
|
||||||
|
|
||||||
export async function GET(
|
export const GET = apiHandler(async (_req, { params }) => {
|
||||||
_req: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const { id } = await 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);
|
return NextResponse.json(status);
|
||||||
} catch (e) {
|
});
|
||||||
console.error("Failed to get room:", e);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "获取房间信息失败" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/store";
|
||||||
import { notify } from "@/lib/roomEvents";
|
import { notify } from "@/lib/roomEvents";
|
||||||
|
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||||
|
|
||||||
export async function POST(
|
export const POST = apiHandler(async (req, { params }) => {
|
||||||
req: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
|
||||||
const { userId, restaurantId, action } = await req.json();
|
const { userId, restaurantId, action } = await req.json();
|
||||||
|
|
||||||
if (!userId || restaurantId == null || !action) {
|
requireUserId(userId);
|
||||||
return NextResponse.json(
|
if (restaurantId == null || !action) {
|
||||||
{ error: "userId, restaurantId, and action are required" },
|
throw new ApiError("restaurantId and action are required");
|
||||||
{ status: 400 },
|
}
|
||||||
);
|
if (action !== "like" && action !== "pass") {
|
||||||
|
throw new ApiError("action must be 'like' or 'pass'");
|
||||||
}
|
}
|
||||||
|
|
||||||
const rid = String(restaurantId);
|
const rid = String(restaurantId);
|
||||||
|
|
||||||
const updated = await atomicUpdateRoom(id, (data) => {
|
const updated = await atomicUpdateRoom(id, (data) => {
|
||||||
|
if (!data.users.includes(userId)) {
|
||||||
|
throw new ApiError("你不是该房间的成员", 403);
|
||||||
|
}
|
||||||
|
|
||||||
const restaurantIndex = data.restaurants.findIndex((r) => r.id === rid);
|
const restaurantIndex = data.restaurants.findIndex((r) => r.id === rid);
|
||||||
const alreadySwiped =
|
const alreadySwiped =
|
||||||
restaurantIndex >= 0 &&
|
restaurantIndex >= 0 &&
|
||||||
@@ -46,12 +47,7 @@ export async function POST(
|
|||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) throw new ApiError("房间不存在或已过期", 404);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "房间不存在或已过期" },
|
|
||||||
{ status: 404 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(id);
|
notify(id);
|
||||||
|
|
||||||
@@ -59,11 +55,4 @@ export async function POST(
|
|||||||
match: updated.match,
|
match: updated.match,
|
||||||
likeCount: updated.likes[rid]?.length ?? 0,
|
likeCount: updated.likes[rid]?.length ?? 0,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
});
|
||||||
console.error("Failed to process swipe:", e);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "操作失败" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/store";
|
||||||
import { notify } from "@/lib/roomEvents";
|
import { notify } from "@/lib/roomEvents";
|
||||||
|
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||||
|
|
||||||
export async function POST(
|
export const POST = apiHandler(async (req, { params }) => {
|
||||||
req: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
|
||||||
const { userId, restaurantId } = await req.json();
|
const { userId, restaurantId } = await req.json();
|
||||||
|
|
||||||
if (!userId || restaurantId == null) {
|
requireUserId(userId);
|
||||||
return NextResponse.json(
|
if (restaurantId == null) throw new ApiError("restaurantId is required");
|
||||||
{ error: "userId and restaurantId are required" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rid = String(restaurantId);
|
const rid = String(restaurantId);
|
||||||
|
|
||||||
const updated = await atomicUpdateRoom(id, (data) => {
|
const updated = await atomicUpdateRoom(id, (data) => {
|
||||||
|
if (!data.users.includes(userId)) {
|
||||||
|
throw new ApiError("你不是该房间的成员", 403);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.likes[rid]) {
|
if (data.likes[rid]) {
|
||||||
data.likes[rid] = data.likes[rid].filter((u) => u !== userId);
|
data.likes[rid] = data.likes[rid].filter((u) => u !== userId);
|
||||||
if (data.likes[rid].length === 0) {
|
if (data.likes[rid].length === 0) {
|
||||||
@@ -40,21 +36,9 @@ export async function POST(
|
|||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) throw new ApiError("房间不存在或已过期", 404);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "房间不存在或已过期" },
|
|
||||||
{ status: 404 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(id);
|
notify(id);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (e) {
|
});
|
||||||
console.error("Failed to undo swipe:", e);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "撤回失败" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
|
|||||||
import { createRoom } from "@/lib/store";
|
import { createRoom } from "@/lib/store";
|
||||||
import { Restaurant, SceneType } from "@/types";
|
import { Restaurant, SceneType } from "@/types";
|
||||||
import { getSceneConfig } from "@/lib/sceneConfig";
|
import { getSceneConfig } from "@/lib/sceneConfig";
|
||||||
|
import { apiHandler, ApiError } from "@/lib/api";
|
||||||
|
import { requireAmapApiKey } from "@/lib/amap";
|
||||||
|
|
||||||
interface AmapPoiV5 {
|
interface AmapPoiV5 {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -101,8 +103,7 @@ function filterByPrice(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export const POST = apiHandler(async (req) => {
|
||||||
try {
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const {
|
const {
|
||||||
lat,
|
lat,
|
||||||
@@ -114,27 +115,20 @@ export async function POST(req: Request) {
|
|||||||
scene = "eat" as SceneType,
|
scene = "eat" as SceneType,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
const sceneConfig = getSceneConfig(scene === "drink" ? "drink" : "eat");
|
const sceneConfig = getSceneConfig(scene);
|
||||||
|
|
||||||
if (!lat || !lng) {
|
const numLat = Number(lat);
|
||||||
return NextResponse.json(
|
const numLng = Number(lng);
|
||||||
{ error: "无法获取位置信息,请允许定位权限后重试" },
|
if (!Number.isFinite(numLat) || !Number.isFinite(numLng) ||
|
||||||
{ status: 400 },
|
numLat < -90 || numLat > 90 || numLng < -180 || numLng > 180) {
|
||||||
);
|
throw new ApiError("无法获取位置信息,请允许定位权限后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = process.env.AMAP_API_KEY;
|
const apiKey = requireAmapApiKey();
|
||||||
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");
|
const url = new URL("https://restapi.amap.com/v5/place/around");
|
||||||
url.searchParams.set("key", apiKey);
|
url.searchParams.set("key", apiKey);
|
||||||
url.searchParams.set("location", `${lng},${lat}`);
|
url.searchParams.set("location", `${numLng},${numLat}`);
|
||||||
url.searchParams.set("radius", String(radius));
|
url.searchParams.set("radius", String(radius));
|
||||||
url.searchParams.set("types", sceneConfig.poiTypes);
|
url.searchParams.set("types", sceneConfig.poiTypes);
|
||||||
url.searchParams.set("show_fields", "business,photos");
|
url.searchParams.set("show_fields", "business,photos");
|
||||||
@@ -147,8 +141,13 @@ export async function POST(req: Request) {
|
|||||||
url.searchParams.set("keywords", cuisine);
|
url.searchParams.set("keywords", cuisine);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let amapData;
|
||||||
|
try {
|
||||||
const amapRes = await fetch(url.toString());
|
const amapRes = await fetch(url.toString());
|
||||||
const amapData = await amapRes.json();
|
amapData = await amapRes.json();
|
||||||
|
} catch {
|
||||||
|
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
|
||||||
|
}
|
||||||
|
|
||||||
let restaurants: Restaurant[] = [];
|
let restaurants: Restaurant[] = [];
|
||||||
if (amapData.status === "1" && amapData.pois?.length > 0) {
|
if (amapData.status === "1" && amapData.pois?.length > 0) {
|
||||||
@@ -159,20 +158,8 @@ export async function POST(req: Request) {
|
|||||||
restaurants = results.slice(0, 15);
|
restaurants = results.slice(0, 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (restaurants.length === 0) {
|
if (restaurants.length === 0) throw new ApiError(sceneConfig.emptyError, 404);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: sceneConfig.emptyError },
|
|
||||||
{ status: 404 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomId = await createRoom(restaurants, userId, sceneConfig.key);
|
const roomId = await createRoom(restaurants, userId, sceneConfig.key);
|
||||||
return NextResponse.json({ roomId, restaurants });
|
return NextResponse.json({ roomId, restaurants });
|
||||||
} catch (e) {
|
});
|
||||||
console.error("Failed to create room:", e);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "搜索失败,请检查网络后重试" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 "@/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");
|
const userId = req.nextUrl.searchParams.get("userId");
|
||||||
if (!userId) {
|
if (!userId) return NextResponse.json([]);
|
||||||
return NextResponse.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const favorites = await prisma.favorite.findMany({
|
const favorites = await prisma.favorite.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -14,59 +14,53 @@ export async function GET(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
favorites.map((f) => ({
|
favorites.map((f) => {
|
||||||
id: f.id,
|
let restaurantData = {};
|
||||||
restaurantData: JSON.parse(f.restaurantData),
|
try { restaurantData = JSON.parse(f.restaurantData); } catch { /* ignore */ }
|
||||||
createdAt: f.createdAt.toISOString(),
|
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();
|
const { userId, restaurant } = await req.json();
|
||||||
|
|
||||||
if (!userId || !restaurant) {
|
requireUserId(userId);
|
||||||
return NextResponse.json({ error: "缺少必要字段" }, { status: 400 });
|
if (!restaurant?.id || typeof restaurant.id !== "string") {
|
||||||
|
throw new ApiError("缺少必要字段");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
await requireUser(userId);
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "请先设置个人资料" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.favorite.findFirst({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
restaurantData: { contains: `"id":"${restaurant.id}"` },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
return NextResponse.json({ id: existing.id, alreadyExists: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try {
|
||||||
const fav = await prisma.favorite.create({
|
const fav = await prisma.favorite.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
|
restaurantId: restaurant.id,
|
||||||
restaurantData: JSON.stringify(restaurant),
|
restaurantData: JSON.stringify(restaurant),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ id: fav.id });
|
return NextResponse.json({ id: fav.id });
|
||||||
}
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||||
|
const existing = await prisma.favorite.findFirst({
|
||||||
|
where: { userId, restaurantId: restaurant.id },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ id: existing?.id ?? "", alreadyExists: true });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export const DELETE = apiHandler(async (req) => {
|
||||||
const { userId, favoriteId } = await req.json();
|
const { userId, favoriteId } = await req.json();
|
||||||
|
|
||||||
if (!userId || !favoriteId) {
|
requireUserId(userId);
|
||||||
return NextResponse.json({ error: "缺少必要字段" }, { status: 400 });
|
if (!favoriteId) throw new ApiError("缺少必要字段");
|
||||||
}
|
|
||||||
|
|
||||||
const fav = await prisma.favorite.findUnique({ where: { id: favoriteId } });
|
const fav = await prisma.favorite.findUnique({ where: { id: favoriteId } });
|
||||||
if (!fav || fav.userId !== userId) {
|
if (!fav || fav.userId !== userId) throw new ApiError("收藏不存在", 404);
|
||||||
return NextResponse.json({ error: "收藏不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.favorite.delete({ where: { id: favoriteId } });
|
await prisma.favorite.delete({ where: { id: favoriteId } });
|
||||||
return NextResponse.json({ ok: true });
|
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 { prisma } from "@/lib/prisma";
|
||||||
|
import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api";
|
||||||
|
|
||||||
const MAX_HISTORY = 50;
|
const MAX_HISTORY = 50;
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export const GET = apiHandler(async (req) => {
|
||||||
const userId = req.nextUrl.searchParams.get("userId");
|
const userId = req.nextUrl.searchParams.get("userId");
|
||||||
if (!userId) {
|
if (!userId) return NextResponse.json([]);
|
||||||
return NextResponse.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const decisions = await prisma.decision.findMany({
|
const decisions = await prisma.decision.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -26,20 +25,18 @@ export async function GET(req: NextRequest) {
|
|||||||
createdAt: d.createdAt.toISOString(),
|
createdAt: d.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export const POST = apiHandler(async (req) => {
|
||||||
const { userId, roomId, restaurant, matchType, participants } =
|
const { userId, roomId, restaurant, matchType, participants } =
|
||||||
await req.json();
|
await req.json();
|
||||||
|
|
||||||
if (!userId || !roomId || !restaurant || !matchType) {
|
requireUserId(userId);
|
||||||
return NextResponse.json({ error: "缺少必要字段" }, { status: 400 });
|
if (!roomId || !restaurant || !matchType) {
|
||||||
|
throw new ApiError("缺少必要字段");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
await requireUser(userId);
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "用户未注册" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.decision.findFirst({
|
const existing = await prisma.decision.findFirst({
|
||||||
where: { userId, roomId },
|
where: { userId, roomId },
|
||||||
@@ -73,4 +70,4 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ id: decision.id });
|
return NextResponse.json({ id: decision.id });
|
||||||
}
|
});
|
||||||
|
|||||||
+36
-40
@@ -1,68 +1,56 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import bcrypt from "bcryptjs";
|
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");
|
const userId = req.nextUrl.searchParams.get("id");
|
||||||
if (!userId) {
|
if (!userId) return NextResponse.json(null);
|
||||||
return NextResponse.json(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) {
|
if (!user) return NextResponse.json(null);
|
||||||
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({
|
return NextResponse.json({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
preferences: JSON.parse(user.preferences),
|
preferences,
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
decisionCount,
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
export async function PUT(req: NextRequest) {
|
export const PUT = apiHandler(async (req) => {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { userId } = body;
|
const { userId } = body;
|
||||||
|
|
||||||
if (!userId) {
|
requireUserId(userId);
|
||||||
return NextResponse.json({ error: "缺少用户 ID" }, { status: 400 });
|
const existing = await requireUser(userId);
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { id: userId } });
|
|
||||||
if (!existing) {
|
|
||||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (body.username !== undefined) {
|
if (body.username !== undefined) {
|
||||||
const trimmed = body.username.trim();
|
const trimmed = validateUsername(body.username);
|
||||||
if (trimmed.length < 2 || trimmed.length > 16) {
|
|
||||||
return NextResponse.json({ error: "用户名需要 2-16 个字符" }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (trimmed !== existing.username) {
|
if (trimmed !== existing.username) {
|
||||||
const taken = await prisma.user.findUnique({ where: { username: trimmed } });
|
const taken = await prisma.user.findUnique({ where: { username: trimmed } });
|
||||||
if (taken) {
|
if (taken) throw new ApiError("用户名已被占用", 409);
|
||||||
return NextResponse.json({ error: "用户名已被占用" }, { status: 409 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
updateData.username = trimmed;
|
updateData.username = trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.newPassword !== undefined) {
|
if (body.newPassword !== undefined) {
|
||||||
if (!body.currentPassword) {
|
if (!body.currentPassword) throw new ApiError("请输入当前密码");
|
||||||
return NextResponse.json({ error: "请输入当前密码" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const valid = await bcrypt.compare(body.currentPassword, existing.passwordHash);
|
const valid = await bcrypt.compare(body.currentPassword, existing.passwordHash);
|
||||||
if (!valid) {
|
if (!valid) throw new ApiError("当前密码错误", 403);
|
||||||
return NextResponse.json({ error: "当前密码错误" }, { status: 403 });
|
validatePassword(body.newPassword, "新密码");
|
||||||
}
|
|
||||||
if (body.newPassword.length < 6) {
|
|
||||||
return NextResponse.json({ error: "新密码至少 6 个字符" }, { status: 400 });
|
|
||||||
}
|
|
||||||
updateData.passwordHash = await bcrypt.hash(body.newPassword, 10);
|
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 !== undefined) {
|
||||||
if (body.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
|
if (body.email) validateEmail(body.email);
|
||||||
return NextResponse.json({ error: "邮箱格式不正确" }, { status: 400 });
|
|
||||||
}
|
|
||||||
updateData.email = body.email || null;
|
updateData.email = body.email || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,16 +67,26 @@ export async function PUT(req: NextRequest) {
|
|||||||
updateData.preferences = JSON.stringify(body.preferences);
|
updateData.preferences = JSON.stringify(body.preferences);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const user = await prisma.user.update({
|
const user = await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let prefs = {};
|
||||||
|
try { prefs = JSON.parse(user.preferences); } catch { /* fallback */ }
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
preferences: JSON.parse(user.preferences),
|
preferences: prefs,
|
||||||
});
|
});
|
||||||
}
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||||
|
throw new ApiError("用户名已被占用", 409);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #f8f9fa;
|
--background: #030712;
|
||||||
--foreground: #171717;
|
--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 {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--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-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
@@ -39,3 +81,8 @@ body {
|
|||||||
from { opacity: 1; }
|
from { opacity: 1; }
|
||||||
to { opacity: 0; }
|
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 { useParams, useRouter } from "next/navigation";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Utensils,
|
|
||||||
Users,
|
Users,
|
||||||
Heart,
|
Heart,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Loader2,
|
|
||||||
Coffee,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getUserId } from "@/lib/userId";
|
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 { getSceneConfig } from "@/lib/sceneConfig";
|
||||||
import type { SceneType } from "@/types";
|
import type { SceneType } from "@/types";
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ export default function InvitePage() {
|
|||||||
const [userCount, setUserCount] = useState(0);
|
const [userCount, setUserCount] = useState(0);
|
||||||
const [scene, setScene] = useState<SceneType>("eat");
|
const [scene, setScene] = useState<SceneType>("eat");
|
||||||
const [joining, setJoining] = useState(false);
|
const [joining, setJoining] = useState(false);
|
||||||
|
const [joinError, setJoinError] = useState("");
|
||||||
|
|
||||||
const sceneConfig = getSceneConfig(scene);
|
const sceneConfig = getSceneConfig(scene);
|
||||||
|
|
||||||
@@ -46,23 +47,35 @@ export default function InvitePage() {
|
|||||||
|
|
||||||
const handleJoin = async () => {
|
const handleJoin = async () => {
|
||||||
setJoining(true);
|
setJoining(true);
|
||||||
|
setJoinError("");
|
||||||
try {
|
try {
|
||||||
const userId = getUserId();
|
await joinRoom(roomId, getUserId());
|
||||||
await fetch(`/api/room/${roomId}/join`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ userId }),
|
|
||||||
});
|
|
||||||
router.push(`/room/${roomId}`);
|
router.push(`/room/${roomId}`);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
setJoinError(e instanceof Error ? e.message : "加入失败,请重试");
|
||||||
setJoining(false);
|
setJoining(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
<div className="flex min-h-dvh flex-col items-center justify-center bg-background px-6">
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-emerald-500" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,62 +83,59 @@ export default function InvitePage() {
|
|||||||
if (status === "not_found") {
|
if (status === "not_found") {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-6">
|
<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">
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-surface ring-1 ring-border">
|
||||||
<Utensils size={28} className="text-zinc-400" />
|
<Utensils size={28} className="text-muted" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-zinc-900">房间不存在</h1>
|
<h1 className="text-xl font-bold text-heading">房间不存在</h1>
|
||||||
<p className="text-center text-sm text-zinc-500">
|
<p className="text-center text-sm text-muted">
|
||||||
这个房间已过期或不存在,请让朋友重新分享链接
|
这个房间已过期或不存在,请让朋友重新分享链接
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button onClick={() => router.push("/")} className="mt-2">
|
||||||
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<motion.div
|
||||||
className="flex flex-col items-center"
|
className="flex flex-col items-center"
|
||||||
initial={{ y: -20, opacity: 0 }}
|
initial={{ y: -20, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ duration: 0.5 }}
|
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">
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-accent shadow-lg shadow-accent/20">
|
||||||
{scene === "drink" ? <Coffee size={28} className="text-white" /> : <Utensils size={28} className="text-white" />}
|
<span className="text-3xl leading-none">{sceneConfig.emoji}</span>
|
||||||
</div>
|
</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
|
NoWhatever
|
||||||
</h1>
|
</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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<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 }}
|
initial={{ y: 10, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ duration: 0.5, delay: 0.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}
|
{sceneConfig.inviteText}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
<div className="flex items-center gap-2 text-sm text-muted">
|
||||||
<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">
|
<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}
|
{roomId}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{userCount > 0 && (
|
{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} />
|
<Users size={13} />
|
||||||
<span>
|
<span>
|
||||||
已有 <span className="font-semibold text-emerald-500">{userCount}</span> 人在房间
|
已有 <span className="font-semibold text-accent">{userCount}</span> 人在房间
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -138,35 +148,35 @@ export default function InvitePage() {
|
|||||||
transition={{ duration: 0.5, delay: 0.2 }}
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-1.5 w-20">
|
<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">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/15">
|
||||||
<Users size={18} className="text-emerald-500" />
|
<Users size={18} className="text-accent" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-semibold text-zinc-700">加入房间</span>
|
<span className="text-xs font-semibold text-secondary">加入房间</span>
|
||||||
<span className="text-[10px] leading-tight text-zinc-400 text-center">
|
<span className="text-[10px] leading-tight text-muted text-center">
|
||||||
和朋友一起
|
和朋友一起
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 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">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/15">
|
||||||
<Heart size={18} className="text-amber-500" />
|
<Heart size={18} className="text-amber-400" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-semibold text-zinc-700">各自滑卡</span>
|
<span className="text-xs font-semibold text-secondary">各自滑卡</span>
|
||||||
<span className="text-[10px] leading-tight text-zinc-400 text-center">
|
<span className="text-[10px] leading-tight text-muted text-center">
|
||||||
右滑喜欢的店
|
右滑喜欢的店
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 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">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-rose-500/15">
|
||||||
<Sparkles size={18} className="text-rose-500" />
|
<Sparkles size={18} className="text-rose-400" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-semibold text-zinc-700">匹配结果</span>
|
<span className="text-xs font-semibold text-secondary">匹配结果</span>
|
||||||
<span className="text-[10px] leading-tight text-zinc-400 text-center">
|
<span className="text-[10px] leading-tight text-muted text-center">
|
||||||
滑中同一家就去
|
滑中同一家就去
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,20 +188,24 @@ export default function InvitePage() {
|
|||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ duration: 0.5, delay: 0.3 }}
|
transition={{ duration: 0.5, delay: 0.3 }}
|
||||||
>
|
>
|
||||||
<button
|
{joinError && (
|
||||||
onClick={handleJoin}
|
<motion.p
|
||||||
disabled={joining}
|
className="mb-3 text-center text-xs font-medium text-rose-400"
|
||||||
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"
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
>
|
>
|
||||||
{joining ? (
|
{joinError}
|
||||||
<>
|
</motion.p>
|
||||||
<Loader2 size={18} className="animate-spin" />
|
|
||||||
加入中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"加入房间"
|
|
||||||
)}
|
)}
|
||||||
</button>
|
<Button
|
||||||
|
onClick={handleJoin}
|
||||||
|
size="lg"
|
||||||
|
fullWidth
|
||||||
|
loading={joining}
|
||||||
|
loadingText="加入中..."
|
||||||
|
>
|
||||||
|
加入房间
|
||||||
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+17
-2
@@ -1,6 +1,10 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import GlobalUserBadge from "@/components/GlobalUserBadge";
|
||||||
|
|
||||||
|
import PageTransition from "@/components/PageTransition";
|
||||||
|
import ToastProvider from "@/components/ToastProvider";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -18,17 +22,28 @@ export const viewport: Viewport = {
|
|||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
maximumScale: 1,
|
maximumScale: 1,
|
||||||
userScalable: false,
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
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`}>
|
<body className={`${geistSans.variable} font-sans antialiased`}>
|
||||||
{children}
|
<ToastProvider>
|
||||||
|
<PageTransition>{children}</PageTransition>
|
||||||
|
<GlobalUserBadge />
|
||||||
|
</ToastProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
+135
-586
@@ -1,629 +1,178 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Plus, LogIn, Loader2, MapPin, Navigation, X, Users, Heart, Sparkles, ChevronRight, Flame, User } from "lucide-react";
|
import { Zap, Gift, Clock, ChevronRight, Trophy } from "lucide-react";
|
||||||
import { getUserId, getCachedProfile, getCachedPreferences } from "@/lib/userId";
|
|
||||||
import { getAvatarBg } from "@/lib/avatars";
|
|
||||||
import AuthModal from "@/components/AuthModal";
|
|
||||||
import BrandLogo from "@/components/BrandLogo";
|
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() {
|
export default function LandingPage() {
|
||||||
const router = useRouter();
|
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 (
|
return (
|
||||||
<div className="relative flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-6">
|
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-10 overflow-y-auto scrollbar-none">
|
||||||
{/* Profile / Auth button */}
|
{/* Ambient glow */}
|
||||||
<div className="absolute right-4 top-3">
|
<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" />
|
||||||
{profile ? (
|
<div className="pointer-events-none fixed left-1/4 top-1/2 h-[300px] w-[300px] rounded-full bg-purple-500/5 blur-3xl" />
|
||||||
<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>
|
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex items-center gap-3"
|
className="flex flex-col items-center gap-4"
|
||||||
initial={{ y: -20, opacity: 0 }}
|
initial={{ y: -30, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
>
|
>
|
||||||
<BrandLogo size={44} />
|
<BrandLogo size={48} />
|
||||||
<div>
|
<div className="text-center">
|
||||||
<h1 className="text-2xl font-black tracking-tight text-zinc-900">
|
<h1 className="text-2xl font-black tracking-tight text-heading">
|
||||||
NoWhatever
|
NoWhatever
|
||||||
</h1>
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.p
|
<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 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.5, delay: 0.05 }}
|
transition={{ delay: 0.2 }}
|
||||||
>
|
>
|
||||||
{sceneConfig.subtitle}
|
别再说"随便"了。
|
||||||
|
<br />
|
||||||
|
两个模式,覆盖你们所有的选择困难症。
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
<motion.div
|
{/* Dual Cards */}
|
||||||
className="mt-4 flex items-center justify-center gap-4"
|
<div className="mt-9 flex w-full max-w-sm flex-col gap-4">
|
||||||
initial={{ y: 10, opacity: 0 }}
|
{/* Card A: Panic Mode */}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
<motion.button
|
||||||
transition={{ duration: 0.5, delay: 0.1 }}
|
onClick={() => router.push("/panic")}
|
||||||
|
className="group relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-yellow-400 to-orange-500 p-5 pb-4 text-left shadow-xl shadow-orange-500/25 ring-1 ring-white/15 transition-shadow hover:shadow-2xl hover:shadow-orange-500/35"
|
||||||
|
initial={{ x: -40, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.3 }}
|
||||||
|
whileHover={{ scale: 1.02, rotate: -0.5 }}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-1 w-16">
|
<div className="absolute -right-4 -top-4 h-28 w-28 rounded-full bg-white/15 blur-2xl" />
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-50">
|
<div className="absolute -bottom-6 -left-6 h-20 w-20 rounded-full bg-yellow-300/20 blur-xl" />
|
||||||
<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="relative z-10">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex flex-col items-center gap-1 w-16">
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-black/15 ring-1 ring-white/10">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-50">
|
<Zap size={22} className="text-white" />
|
||||||
<Heart size={14} className="text-amber-500" />
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-semibold text-zinc-700">各自滑卡</span>
|
<div className="flex-1">
|
||||||
|
<h2 className="text-lg font-black text-white">⚡️ 极速救场</h2>
|
||||||
|
<p className="text-[10px] font-semibold tracking-wider text-white/60">
|
||||||
|
PANIC MODE
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3.5 text-sm font-medium leading-relaxed text-white/90">
|
||||||
|
10秒内出结果,立刻闭嘴,听天由命
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-bold text-white/50">
|
||||||
|
<Clock size={12} />
|
||||||
|
<span>即时决策 · 转盘匹配</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5 text-xs font-semibold text-white/40 transition-colors group-hover:text-white/70">
|
||||||
|
进入
|
||||||
|
<ChevronRight size={14} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<span className="text-[10px] font-semibold text-zinc-700">匹配结果</span>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mt-4 flex items-center justify-center gap-2"
|
className="absolute inset-0 rounded-2xl"
|
||||||
initial={{ y: 10, opacity: 0 }}
|
whileHover={{
|
||||||
animate={{ y: 0, opacity: 1 }}
|
x: [0, -2, 2, -2, 2, 0],
|
||||||
transition={{ duration: 0.4, delay: 0.12 }}
|
transition: { duration: 0.4, repeat: Infinity },
|
||||||
>
|
|
||||||
{SCENES.map((s) => {
|
|
||||||
const cfg = getSceneConfig(s);
|
|
||||||
const active = scene === s;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
onClick={() => handleSceneChange(s)}
|
|
||||||
disabled={loading}
|
|
||||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all disabled:opacity-50 ${
|
|
||||||
active
|
|
||||||
? "bg-emerald-500 text-white shadow-md shadow-emerald-200"
|
|
||||||
: "bg-zinc-100 text-zinc-500 hover:bg-zinc-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-base leading-none">{cfg.emoji}</span>
|
|
||||||
{cfg.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="mt-4 flex w-full max-w-xs flex-col gap-2.5"
|
|
||||||
initial={{ y: 20, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.15 }}
|
|
||||||
>
|
|
||||||
<div ref={suggestRef} className="relative">
|
|
||||||
<div className="relative flex items-center">
|
|
||||||
<MapPin size={16} className="absolute left-3 text-zinc-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索位置(默认当前位置)"
|
|
||||||
value={locationQuery}
|
|
||||||
onChange={(e) => handleLocationInput(e.target.value)}
|
|
||||||
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
|
||||||
disabled={loading}
|
|
||||||
className="h-10 w-full rounded-xl border border-zinc-200 bg-white pl-9 pr-9 text-sm text-zinc-700 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
{(selectedLocation || locationQuery) && !loading && (
|
|
||||||
<button
|
|
||||||
onClick={clearLocation}
|
|
||||||
className="absolute right-2.5 flex h-5 w-5 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-600"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{fetchingSuggestions && (
|
|
||||||
<Loader2 size={14} className="absolute right-3 animate-spin text-zinc-300" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedLocation && (
|
|
||||||
<div className="mt-1.5 flex items-center gap-1.5 px-1">
|
|
||||||
<Navigation size={12} className="shrink-0 text-emerald-500" />
|
|
||||||
<span className="truncate text-xs text-emerald-600">
|
|
||||||
{selectedLocation.district} {selectedLocation.address || selectedLocation.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!selectedLocation && !locationQuery && gpsStatus === "locating" && (
|
|
||||||
<div className="mt-1.5 flex items-center gap-1.5 px-1">
|
|
||||||
<Loader2 size={12} className="shrink-0 animate-spin text-emerald-400" />
|
|
||||||
<span className="text-xs text-zinc-400">正在获取当前位置...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!selectedLocation && !locationQuery && gpsStatus === "success" && (
|
|
||||||
<div className="mt-1.5 flex items-center gap-1.5 px-1">
|
|
||||||
<Navigation size={12} className="shrink-0 text-emerald-500" />
|
|
||||||
<span className="truncate text-xs text-emerald-600">
|
|
||||||
当前位置:{gpsLocationName || "已定位"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!selectedLocation && !locationQuery && (gpsStatus === "failed" || gpsStatus === "denied") && (
|
|
||||||
<div className="mt-1.5 flex items-center justify-between px-1">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<MapPin size={12} className="shrink-0 text-amber-500" />
|
|
||||||
<span className="text-xs text-amber-600">
|
|
||||||
{gpsStatus === "denied" ? "定位权限被拒绝" : "定位失败"},请搜索选择位置
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={doGpsLocate}
|
|
||||||
className="shrink-0 text-xs font-medium text-emerald-500 active:text-emerald-700"
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!selectedLocation && !locationQuery && gpsStatus === "idle" && (
|
|
||||||
<div className="mt-1.5 flex items-center gap-1.5 px-1">
|
|
||||||
<Navigation size={12} className="shrink-0 text-zinc-400" />
|
|
||||||
<span className="text-xs text-zinc-400">将使用当前定位</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{showSuggestions && (
|
|
||||||
<motion.ul
|
|
||||||
initial={{ opacity: 0, y: -4 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -4 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-xl border border-zinc-100 bg-white py-1 shadow-lg"
|
|
||||||
>
|
|
||||||
{suggestions.map((s) => (
|
|
||||||
<li key={s.id}>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSelectLocation(s)}
|
|
||||||
className="flex w-full items-start gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-emerald-50"
|
|
||||||
>
|
|
||||||
<MapPin size={14} className="mt-0.5 shrink-0 text-zinc-400" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium text-zinc-800">{s.name}</p>
|
|
||||||
<p className="truncate text-xs text-zinc-400">{s.district} {s.address}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</motion.ul>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 rounded-xl border border-zinc-100 bg-zinc-50/50 px-3 py-2.5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400">{sceneConfig.tagLabel}</span>
|
|
||||||
<div className="relative flex flex-1 items-center">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={sceneConfig.tagPlaceholder}
|
|
||||||
value={cuisine}
|
|
||||||
onChange={(e) => setCuisine(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
className="h-7 w-full rounded-full border-none bg-white pl-3 pr-7 text-xs text-zinc-700 outline-none ring-1 ring-zinc-200 transition-colors placeholder:text-zinc-300 focus:ring-2 focus:ring-emerald-300 disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
{cuisine && !loading && (
|
|
||||||
<button
|
|
||||||
onClick={() => setCuisine("")}
|
|
||||||
className="absolute right-2 flex h-4 w-4 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-600"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400"></span>
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<Flame size={11} className="shrink-0 text-orange-400" />
|
|
||||||
{sceneConfig.hotTags.map((tag) => (
|
|
||||||
<button
|
|
||||||
key={tag}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCuisine(tag)}
|
|
||||||
disabled={loading}
|
|
||||||
className={`h-6 rounded-full px-2.5 text-xs font-medium transition-colors disabled:opacity-50 ${
|
|
||||||
cuisine === tag
|
|
||||||
? "bg-emerald-500 text-white shadow-sm"
|
|
||||||
: "bg-white text-zinc-500 hover:bg-zinc-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400">距离</span>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
{DISTANCE_OPTIONS.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRadius(opt.value)}
|
|
||||||
disabled={loading}
|
|
||||||
className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
|
|
||||||
radius === opt.value
|
|
||||||
? "bg-emerald-500 text-white shadow-sm"
|
|
||||||
: "bg-white text-zinc-500 hover:bg-zinc-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400">人均</span>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{sceneConfig.priceOptions.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPriceRange(opt.value)}
|
|
||||||
disabled={loading}
|
|
||||||
className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
|
|
||||||
priceRange === opt.value
|
|
||||||
? "bg-emerald-500 text-white shadow-sm"
|
|
||||||
: "bg-white text-zinc-500 hover:bg-zinc-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 text-sm font-bold text-white shadow-md shadow-emerald-200 transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading && loadingText ? (
|
|
||||||
<>
|
|
||||||
<Loader2 size={18} className="animate-spin" />
|
|
||||||
{loadingText}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Plus size={18} strokeWidth={3} />
|
|
||||||
创建新房间
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 py-1">
|
|
||||||
<div className="h-px flex-1 bg-zinc-200" />
|
|
||||||
<span className="text-xs text-zinc-400">或加入已有房间</span>
|
|
||||||
<div className="h-px flex-1 bg-zinc-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleJoin} className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
maxLength={4}
|
|
||||||
placeholder="输入 4 位房间号"
|
|
||||||
value={roomCode}
|
|
||||||
onChange={(e) => {
|
|
||||||
setRoomCode(e.target.value.replace(/\D/g, "").slice(0, 4));
|
|
||||||
setError("");
|
|
||||||
}}
|
}}
|
||||||
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
|
</motion.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>
|
|
||||||
|
|
||||||
{error && (
|
{/* Card B: Adventure Roulette */}
|
||||||
<motion.p
|
<motion.button
|
||||||
className="text-center text-xs font-medium text-rose-500"
|
onClick={() => router.push("/blindbox")}
|
||||||
initial={{ opacity: 0, y: -4 }}
|
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 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
>
|
>
|
||||||
{error}
|
<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>
|
</motion.p>
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<AuthModal
|
|
||||||
open={authModalOpen}
|
|
||||||
onClose={() => setAuthModalOpen(false)}
|
|
||||||
onAuth={(p) => setProfile(p)}
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+105
-277
@@ -6,12 +6,7 @@ import { motion, AnimatePresence } from "framer-motion";
|
|||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Mail,
|
Mail,
|
||||||
Clock,
|
|
||||||
Star,
|
|
||||||
MapPin,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronDown,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
Lock,
|
Lock,
|
||||||
Edit3,
|
Edit3,
|
||||||
@@ -19,27 +14,26 @@ import {
|
|||||||
X,
|
X,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
Zap,
|
||||||
|
Trophy,
|
||||||
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} 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 { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
|
||||||
import { getAvatarBg, AVATARS } from "@/lib/avatars";
|
import { getAvatarBg, AVATARS } from "@/lib/avatars";
|
||||||
import type { UserProfile, UserPreferences, DecisionRecord, FavoriteRecord, Restaurant } from "@/types";
|
import type { UserProfile, UserPreferences, FavoriteRecord } 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 : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [userId, setUserId] = useState("");
|
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 [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const [history, setHistory] = useState<DecisionRecord[]>([]);
|
|
||||||
const [favorites, setFavorites] = useState<FavoriteRecord[]>([]);
|
const [favorites, setFavorites] = useState<FavoriteRecord[]>([]);
|
||||||
const [historyLoading, setHistoryLoading] = useState(false);
|
|
||||||
const [favLoading, setFavLoading] = useState(false);
|
const [favLoading, setFavLoading] = useState(false);
|
||||||
|
|
||||||
const [editingUsername, setEditingUsername] = useState(false);
|
const [editingUsername, setEditingUsername] = useState(false);
|
||||||
@@ -61,14 +55,8 @@ export default function ProfilePage() {
|
|||||||
const [emailSaving, setEmailSaving] = useState(false);
|
const [emailSaving, setEmailSaving] = useState(false);
|
||||||
const [emailMsg, setEmailMsg] = useState("");
|
const [emailMsg, setEmailMsg] = useState("");
|
||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(true);
|
|
||||||
const [showFavorites, setShowFavorites] = useState(true);
|
const [showFavorites, setShowFavorites] = useState(true);
|
||||||
const [toast, setToast] = useState("");
|
const toast = useToast();
|
||||||
|
|
||||||
const showToast = useCallback((msg: string) => {
|
|
||||||
setToast(msg);
|
|
||||||
setTimeout(() => setToast(""), 2200);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cached = getCachedProfile();
|
const cached = getCachedProfile();
|
||||||
@@ -101,17 +89,10 @@ export default function ProfilePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
setHistoryLoading(true);
|
|
||||||
fetch(`/api/user/history?userId=${userId}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then(setHistory)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setHistoryLoading(false));
|
|
||||||
|
|
||||||
setFavLoading(true);
|
setFavLoading(true);
|
||||||
fetch(`/api/user/favorite?userId=${userId}`)
|
fetch(`/api/user/favorite?userId=${userId}`)
|
||||||
.then((r) => r.json())
|
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
||||||
.then(setFavorites)
|
.then((data) => setFavorites(Array.isArray(data) ? data : []))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setFavLoading(false));
|
.finally(() => setFavLoading(false));
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
@@ -136,7 +117,7 @@ export default function ProfilePage() {
|
|||||||
setProfile((prev) => prev ? { ...prev, username: trimmed } : prev);
|
setProfile((prev) => prev ? { ...prev, username: trimmed } : prev);
|
||||||
setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar });
|
setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar });
|
||||||
setEditingUsername(false);
|
setEditingUsername(false);
|
||||||
showToast("用户名已更新");
|
toast.show("用户名已更新");
|
||||||
} else {
|
} else {
|
||||||
setUsernameMsg(data.error ?? "更新失败");
|
setUsernameMsg(data.error ?? "更新失败");
|
||||||
}
|
}
|
||||||
@@ -175,7 +156,7 @@ export default function ProfilePage() {
|
|||||||
setCurrentPassword("");
|
setCurrentPassword("");
|
||||||
setNewPassword("");
|
setNewPassword("");
|
||||||
setConfirmPassword("");
|
setConfirmPassword("");
|
||||||
showToast("密码已更新");
|
toast.show("密码已更新");
|
||||||
} else {
|
} else {
|
||||||
setPasswordMsg(data.error ?? "更新失败");
|
setPasswordMsg(data.error ?? "更新失败");
|
||||||
}
|
}
|
||||||
@@ -197,10 +178,10 @@ export default function ProfilePage() {
|
|||||||
setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev);
|
setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev);
|
||||||
setCachedProfile({ id: userId, username: profile!.username, avatar: emoji });
|
setCachedProfile({ id: userId, username: profile!.username, avatar: emoji });
|
||||||
setEditingAvatar(false);
|
setEditingAvatar(false);
|
||||||
showToast("头像已更新");
|
toast.show("头像已更新");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
showToast("更新失败");
|
toast.show("更新失败");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -239,9 +220,9 @@ export default function ProfilePage() {
|
|||||||
body: JSON.stringify({ userId, favoriteId: favId }),
|
body: JSON.stringify({ userId, favoriteId: favId }),
|
||||||
});
|
});
|
||||||
setFavorites((f) => f.filter((x) => x.id !== favId));
|
setFavorites((f) => f.filter((x) => x.id !== favId));
|
||||||
showToast("已取消收藏");
|
toast.show("已取消收藏");
|
||||||
} catch {
|
} catch {
|
||||||
showToast("操作失败");
|
toast.show("操作失败");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -252,52 +233,62 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh items-center justify-center bg-background">
|
<div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
|
||||||
<Loader2 size={24} className="animate-spin text-zinc-400" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!profile) return null;
|
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 (
|
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">
|
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/")}
|
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} />
|
<ArrowLeft size={20} />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-base font-bold text-zinc-900">个人中心</h1>
|
<h1 className="flex-1 text-base font-bold text-heading">个人中心</h1>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mx-auto max-w-sm px-5">
|
<div className="mx-auto max-w-sm px-5">
|
||||||
{/* Profile card */}
|
{/* Profile card */}
|
||||||
<motion.div
|
<Card animated>
|
||||||
className="rounded-2xl bg-white p-4 shadow-sm"
|
|
||||||
initial={{ y: 10, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingAvatar(!editingAvatar)}
|
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)}`}
|
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}
|
{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} />
|
<Edit3 size={10} />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{editingUsername ? (
|
{editingUsername ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={newUsername}
|
value={newUsername}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -306,34 +297,41 @@ export default function ProfilePage() {
|
|||||||
}}
|
}}
|
||||||
maxLength={16}
|
maxLength={16}
|
||||||
autoFocus
|
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
|
<button
|
||||||
onClick={handleSaveUsername}
|
onClick={handleSaveUsername}
|
||||||
disabled={usernameSaving}
|
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} />}
|
{usernameSaving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingUsername(false); setUsernameMsg(""); }}
|
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} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<button
|
||||||
onClick={() => { setEditingUsername(true); setNewUsername(profile.username); }}
|
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} />
|
<Edit3 size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -354,8 +352,8 @@ export default function ProfilePage() {
|
|||||||
onClick={() => handleSaveAvatar(a.emoji)}
|
onClick={() => handleSaveAvatar(a.emoji)}
|
||||||
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
|
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
|
||||||
profile.avatar === a.emoji
|
profile.avatar === a.emoji
|
||||||
? `${a.bg} scale-110 ring-2 ring-emerald-400 ring-offset-1`
|
? `${a.bg} scale-110 ring-2 ring-accent ring-offset-1 ring-offset-surface`
|
||||||
: "bg-zinc-50 hover:bg-zinc-100"
|
: "bg-elevated hover:bg-subtle"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{a.emoji}
|
{a.emoji}
|
||||||
@@ -365,21 +363,16 @@ export default function ProfilePage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</Card>
|
||||||
|
|
||||||
{/* Change password */}
|
{/* Change password */}
|
||||||
<motion.div
|
<Card animated className="mt-4" delay={0.05}>
|
||||||
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 }}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingPassword(!editingPassword); setPasswordMsg(""); }}
|
onClick={() => { setEditingPassword(!editingPassword); setPasswordMsg(""); }}
|
||||||
className="flex w-full items-center gap-2"
|
className="flex w-full items-center gap-2"
|
||||||
>
|
>
|
||||||
<Lock size={15} className="text-zinc-400" />
|
<Lock size={15} className="text-muted" />
|
||||||
<h3 className="text-sm font-semibold text-zinc-700">修改密码</h3>
|
<h3 className="text-sm font-semibold text-secondary">修改密码</h3>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -393,46 +386,46 @@ export default function ProfilePage() {
|
|||||||
>
|
>
|
||||||
<div className="mt-3 flex flex-col gap-3">
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-zinc-400">当前密码</p>
|
<p className="text-xs text-muted">当前密码</p>
|
||||||
<div className="relative mt-1">
|
<div className="relative mt-1">
|
||||||
<input
|
<Input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={(e) => { setCurrentPassword(e.target.value); setPasswordMsg(""); }}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
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} />}
|
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-zinc-400">新密码</p>
|
<p className="text-xs text-muted">新密码</p>
|
||||||
<input
|
<Input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => { setNewPassword(e.target.value); setPasswordMsg(""); }}
|
onChange={(e) => { setNewPassword(e.target.value); setPasswordMsg(""); }}
|
||||||
placeholder="至少 6 个字符"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-zinc-400">确认新密码</p>
|
<p className="text-xs text-muted">确认新密码</p>
|
||||||
<input
|
<Input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => { setConfirmPassword(e.target.value); setPasswordMsg(""); }}
|
onChange={(e) => { setConfirmPassword(e.target.value); setPasswordMsg(""); }}
|
||||||
placeholder="再次输入新密码"
|
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>
|
</div>
|
||||||
|
|
||||||
{passwordMsg && (
|
{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}
|
{passwordMsg}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -440,7 +433,7 @@ export default function ProfilePage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSavePassword}
|
onClick={handleSavePassword}
|
||||||
disabled={passwordSaving}
|
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" /> : "保存新密码"}
|
{passwordSaving ? <Loader2 size={14} className="animate-spin" /> : "保存新密码"}
|
||||||
</button>
|
</button>
|
||||||
@@ -448,22 +441,17 @@ export default function ProfilePage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</Card>
|
||||||
|
|
||||||
{/* Email binding */}
|
{/* Email binding */}
|
||||||
<motion.div
|
<Card animated className="mt-4" delay={0.1}>
|
||||||
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 }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Mail size={15} className="text-zinc-400" />
|
<Mail size={15} className="text-muted" />
|
||||||
<h3 className="text-sm font-semibold text-zinc-700">绑定邮箱</h3>
|
<h3 className="text-sm font-semibold text-secondary">绑定邮箱</h3>
|
||||||
<span className="text-[10px] text-zinc-400">(可选)</span>
|
<span className="text-[10px] text-dim">(可选)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
value={email}
|
value={email}
|
||||||
@@ -471,192 +459,45 @@ export default function ProfilePage() {
|
|||||||
setEmail(e.target.value);
|
setEmail(e.target.value);
|
||||||
setEmailMsg("");
|
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
|
<button
|
||||||
onClick={handleSaveEmail}
|
onClick={handleSaveEmail}
|
||||||
disabled={emailSaving}
|
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" /> : "保存"}
|
{emailSaving ? <Loader2 size={13} className="animate-spin" /> : "保存"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{emailMsg && (
|
{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}
|
{emailMsg}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</Card>
|
||||||
|
|
||||||
{/* Decision History */}
|
{/* Achievements link */}
|
||||||
<motion.div
|
<Card animated className="mt-4" delay={0.15}>
|
||||||
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 }}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowHistory((v) => !v)}
|
onClick={() => router.push("/achievements")}
|
||||||
className="flex w-full items-center justify-between"
|
className="flex w-full items-center gap-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<Trophy size={15} className="text-amber-400" />
|
||||||
<Clock size={15} className="text-zinc-400" />
|
<h3 className="text-sm font-semibold text-secondary">成就墙</h3>
|
||||||
<h3 className="text-sm font-semibold text-zinc-700">
|
<span className="text-[10px] text-dim">决策记录 · 契约成就</span>
|
||||||
决策记录 {history.length > 0 && `(${history.length})`}
|
<ChevronRight size={14} className="ml-auto text-muted" />
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<motion.span
|
|
||||||
animate={{ rotate: showHistory ? 180 : 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="text-zinc-400"
|
|
||||||
>
|
|
||||||
<ChevronDown size={16} />
|
|
||||||
</motion.span>
|
|
||||||
</button>
|
</button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<AnimatePresence>
|
<ProfileFavoritesCard
|
||||||
{showHistory && (
|
favorites={favorites}
|
||||||
<motion.div
|
loading={favLoading}
|
||||||
initial={{ height: 0, opacity: 0 }}
|
open={showFavorites}
|
||||||
animate={{ height: "auto", opacity: 1 }}
|
onToggle={() => setShowFavorites((v) => !v)}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
onRemove={handleRemoveFavorite}
|
||||||
transition={{ duration: 0.2 }}
|
onEmpty={() => router.push("/blindbox")}
|
||||||
className="overflow-hidden"
|
delay={0.2}
|
||||||
>
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -667,27 +508,14 @@ export default function ProfilePage() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-29
@@ -4,10 +4,14 @@ import { useEffect, useState, useCallback, useRef } from "react";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import TopNav from "@/components/TopNav";
|
import TopNav from "@/components/TopNav";
|
||||||
import SwipeDeck from "@/components/SwipeDeck";
|
import SwipeDeck from "@/components/SwipeDeck";
|
||||||
|
import { SwipeDeckSkeleton } from "@/components/Skeleton";
|
||||||
import LeaveConfirmModal from "@/components/LeaveConfirmModal";
|
import LeaveConfirmModal from "@/components/LeaveConfirmModal";
|
||||||
|
import Button from "@/components/Button";
|
||||||
import { useRoomPolling } from "@/hooks/useRoomPolling";
|
import { useRoomPolling } from "@/hooks/useRoomPolling";
|
||||||
import { getUserId } from "@/lib/userId";
|
import { getUserId } from "@/lib/userId";
|
||||||
|
import { joinRoom } from "@/lib/room";
|
||||||
import { getSceneConfig } from "@/lib/sceneConfig";
|
import { getSceneConfig } from "@/lib/sceneConfig";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
|
||||||
export default function RoomPage() {
|
export default function RoomPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
@@ -19,6 +23,7 @@ export default function RoomPage() {
|
|||||||
const [joinFailed, setJoinFailed] = useState(false);
|
const [joinFailed, setJoinFailed] = useState(false);
|
||||||
const [showLeaveConfirm, setShowLeaveConfirm] = useState(false);
|
const [showLeaveConfirm, setShowLeaveConfirm] = useState(false);
|
||||||
const leavingRef = useRef(false);
|
const leavingRef = useRef(false);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts,
|
userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts,
|
||||||
@@ -29,14 +34,9 @@ export default function RoomPage() {
|
|||||||
const id = getUserId();
|
const id = getUserId();
|
||||||
setUserId(id);
|
setUserId(id);
|
||||||
|
|
||||||
fetch(`/api/room/${roomId}/join`, {
|
joinRoom(roomId, id)
|
||||||
method: "POST",
|
.then(() => setJoined(true))
|
||||||
headers: { "Content-Type": "application/json" },
|
.catch(() => setJoinFailed(true));
|
||||||
body: JSON.stringify({ userId: id }),
|
|
||||||
}).then((res) => {
|
|
||||||
if (res.ok) setJoined(true);
|
|
||||||
else setJoinFailed(true);
|
|
||||||
}).catch(() => setJoinFailed(true));
|
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,31 +76,48 @@ export default function RoomPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleReset = useCallback(async () => {
|
const handleReset = useCallback(async () => {
|
||||||
await fetch(`/api/room/${roomId}/reset`, { method: "POST" });
|
try {
|
||||||
await mutate();
|
const res = await fetch(`/api/room/${roomId}/reset`, {
|
||||||
}, [roomId, mutate]);
|
|
||||||
|
|
||||||
const handleNarrow = useCallback(async (restaurantIds: string[]) => {
|
|
||||||
await fetch(`/api/room/${roomId}/reset`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ restaurantIds }),
|
body: JSON.stringify({ userId }),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "重置失败");
|
||||||
|
}
|
||||||
await mutate();
|
await mutate();
|
||||||
}, [roomId, mutate]);
|
} catch (e) {
|
||||||
|
toast.show(e instanceof Error ? e.message : "重置失败");
|
||||||
|
}
|
||||||
|
}, [roomId, userId, mutate, toast]);
|
||||||
|
|
||||||
|
const handleNarrow = useCallback(async (restaurantIds: string[]) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/room/${roomId}/reset`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId, restaurantIds }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "缩小范围失败");
|
||||||
|
}
|
||||||
|
await mutate();
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof Error ? e.message : "操作失败");
|
||||||
|
}
|
||||||
|
}, [roomId, userId, mutate, toast]);
|
||||||
|
|
||||||
if (notFound || joinFailed) {
|
if (notFound || joinFailed) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh flex-col items-center justify-center gap-4 bg-background px-6">
|
<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-4xl">🍜</p>
|
||||||
<p className="text-base font-semibold text-zinc-700">房间不存在或已过期</p>
|
<p className="text-base font-semibold text-secondary">房间不存在或已过期</p>
|
||||||
<p className="text-sm text-zinc-400">房间号可能有误,或房间已超过 24 小时</p>
|
<p className="text-sm text-muted">房间号可能有误,或房间已超过 24 小时</p>
|
||||||
<button
|
<Button onClick={() => router.push("/")}>
|
||||||
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"
|
|
||||||
>
|
|
||||||
返回首页
|
返回首页
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -111,12 +128,7 @@ export default function RoomPage() {
|
|||||||
const sceneConfig = getSceneConfig(scene);
|
const sceneConfig = getSceneConfig(scene);
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return (
|
return <SwipeDeckSkeleton />;
|
||||||
<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 (
|
return (
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ export default function ActionButtons({
|
|||||||
disabled,
|
disabled,
|
||||||
}: ActionButtonsProps) {
|
}: ActionButtonsProps) {
|
||||||
return (
|
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
|
<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 }}
|
whileTap={{ scale: 0.85 }}
|
||||||
whileHover={{ scale: 1.08 }}
|
whileHover={{ scale: 1.08 }}
|
||||||
onClick={() => onAction("left")}
|
onClick={() => onAction("left")}
|
||||||
@@ -27,7 +27,7 @@ export default function ActionButtons({
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<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 }}
|
whileTap={{ scale: 0.85 }}
|
||||||
whileHover={{ scale: 1.08 }}
|
whileHover={{ scale: 1.08 }}
|
||||||
onClick={() => onAction("right")}
|
onClick={() => onAction("right")}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { X, Loader2, Eye, EyeOff } from "lucide-react";
|
import { X, Eye, EyeOff } from "lucide-react";
|
||||||
import { AVATARS } from "@/lib/avatars";
|
import { AVATARS } from "@/lib/avatars";
|
||||||
import { setCachedProfile } from "@/lib/userId";
|
import { setCachedProfile } from "@/lib/userId";
|
||||||
import type { UserProfile } from "@/types";
|
import type { UserProfile } from "@/types";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import Button from "@/components/Button";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
|
||||||
type Tab = "login" | "register";
|
type Tab = "login" | "register";
|
||||||
|
|
||||||
@@ -13,11 +16,11 @@ interface AuthModalProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAuth: (profile: UserProfile) => void;
|
onAuth: (profile: UserProfile) => void;
|
||||||
|
defaultTab?: Tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
|
export default function AuthModal({ open, onClose, onAuth, defaultTab = "login" }: AuthModalProps) {
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
const [tab, setTab] = useState<Tab>(defaultTab);
|
||||||
const [tab, setTab] = useState<Tab>("login");
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
@@ -26,10 +29,6 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
||||||
if (e.target === backdropRef.current) onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setUsername("");
|
setUsername("");
|
||||||
setPassword("");
|
setPassword("");
|
||||||
@@ -37,8 +36,17 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
|
|||||||
setAvatar(AVATARS[0].emoji);
|
setAvatar(AVATARS[0].emoji);
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
setError("");
|
setError("");
|
||||||
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTab(defaultTab);
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const switchTab = (t: Tab) => {
|
const switchTab = (t: Tab) => {
|
||||||
setTab(t);
|
setTab(t);
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -113,48 +121,30 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal open={open} onClose={onClose}>
|
||||||
{open && (
|
|
||||||
<motion.div
|
|
||||||
ref={backdropRef}
|
|
||||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
onClick={handleBackdropClick}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="relative w-full max-w-sm rounded-t-3xl bg-white px-5 pb-8 pt-5 shadow-2xl sm:rounded-3xl sm:pb-6"
|
|
||||||
initial={{ y: "100%" }}
|
|
||||||
animate={{ y: 0 }}
|
|
||||||
exit={{ y: "100%" }}
|
|
||||||
transition={{ type: "spring", damping: 28, stiffness: 350 }}
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<span className="text-lg font-bold text-zinc-900">欢迎</span>
|
<span className="text-lg font-bold text-heading">欢迎</span>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-100 text-zinc-400 transition-colors active:bg-zinc-200"
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
<div className="flex gap-1 rounded-xl bg-elevated p-1">
|
||||||
<div className="flex gap-1 rounded-xl bg-zinc-100 p-1">
|
|
||||||
{(["login", "register"] as const).map((t) => (
|
{(["login", "register"] as const).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => switchTab(t)}
|
onClick={() => switchTab(t)}
|
||||||
className={`relative flex-1 rounded-lg py-2 text-sm font-semibold transition-colors ${
|
className={`relative flex-1 rounded-lg py-2 text-sm font-semibold transition-colors ${
|
||||||
tab === t ? "text-zinc-900" : "text-zinc-400"
|
tab === t ? "text-heading" : "text-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab === t && (
|
{tab === t && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId="auth-tab"
|
layoutId="auth-tab"
|
||||||
className="absolute inset-0 rounded-lg bg-white shadow-sm"
|
className="absolute inset-0 rounded-lg bg-subtle shadow-sm"
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -165,10 +155,9 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Username */}
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<p className="text-xs font-medium text-zinc-500">用户名</p>
|
<p className="text-xs font-medium text-muted">用户名</p>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -177,15 +166,15 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
|
|||||||
}}
|
}}
|
||||||
placeholder={tab === "register" ? "2-16 个字符" : "请输入用户名"}
|
placeholder={tab === "register" ? "2-16 个字符" : "请输入用户名"}
|
||||||
maxLength={16}
|
maxLength={16}
|
||||||
className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
|
size="xl"
|
||||||
|
className="mt-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password */}
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="text-xs font-medium text-zinc-500">密码</p>
|
<p className="text-xs font-medium text-muted">密码</p>
|
||||||
<div className="relative mt-2">
|
<div className="relative mt-2">
|
||||||
<input
|
<Input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -193,23 +182,24 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
|
|||||||
setError("");
|
setError("");
|
||||||
}}
|
}}
|
||||||
placeholder={tab === "register" ? "至少 6 个字符" : "请输入密码"}
|
placeholder={tab === "register" ? "至少 6 个字符" : "请输入密码"}
|
||||||
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 pr-10 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
|
size="xl"
|
||||||
|
className="pr-10"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 transition-colors active:text-zinc-600"
|
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted transition-colors active:text-secondary"
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Confirm password (register only) */}
|
|
||||||
{tab === "register" && (
|
{tab === "register" && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="text-xs font-medium text-zinc-500">确认密码</p>
|
<p className="text-xs font-medium text-muted">确认密码</p>
|
||||||
<input
|
<Input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -217,17 +207,17 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
|
|||||||
setError("");
|
setError("");
|
||||||
}}
|
}}
|
||||||
placeholder="再次输入密码"
|
placeholder="再次输入密码"
|
||||||
className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
|
size="xl"
|
||||||
|
className="mt-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Avatar picker (register only) */}
|
|
||||||
{tab === "register" && (
|
{tab === "register" && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="text-xs font-medium text-zinc-500">
|
<p className="text-xs font-medium text-muted">
|
||||||
选择头像
|
选择头像
|
||||||
<span className="ml-1 text-zinc-300">(可选)</span>
|
<span className="ml-1 text-dim">(可选)</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 grid grid-cols-6 gap-2">
|
<div className="mt-2 grid grid-cols-6 gap-2">
|
||||||
{AVATARS.map((a) => (
|
{AVATARS.map((a) => (
|
||||||
@@ -236,8 +226,8 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
|
|||||||
onClick={() => setAvatar(a.emoji)}
|
onClick={() => setAvatar(a.emoji)}
|
||||||
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
|
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
|
||||||
avatar === a.emoji
|
avatar === a.emoji
|
||||||
? `${a.bg} scale-110 ring-2 ring-emerald-400 ring-offset-1`
|
? `${a.bg} scale-110 ring-2 ring-accent ring-offset-1 ring-offset-surface`
|
||||||
: "bg-zinc-50 hover:bg-zinc-100"
|
: "bg-elevated hover:bg-subtle"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{a.emoji}
|
{a.emoji}
|
||||||
@@ -249,7 +239,7 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<motion.p
|
<motion.p
|
||||||
className="mt-3 text-center text-xs font-medium text-rose-500"
|
className="mt-3 text-center text-xs font-medium text-rose-400"
|
||||||
initial={{ opacity: 0, y: -4 }}
|
initial={{ opacity: 0, y: -4 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
>
|
>
|
||||||
@@ -257,25 +247,16 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
|
|||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={loading}
|
size="lg"
|
||||||
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"
|
fullWidth
|
||||||
|
loading={loading}
|
||||||
|
loadingText={tab === "login" ? "登录中..." : "注册中..."}
|
||||||
|
className="mt-5"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{tab === "login" ? "登录" : "注册"}
|
||||||
<>
|
</Button>
|
||||||
<Loader2 size={16} className="animate-spin" />
|
</Modal>
|
||||||
{tab === "login" ? "登录中..." : "注册中..."}
|
|
||||||
</>
|
|
||||||
) : tab === "login" ? (
|
|
||||||
"登录"
|
|
||||||
) : (
|
|
||||||
"注册"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useRef } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
|
||||||
interface LeaveConfirmModalProps {
|
interface LeaveConfirmModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -15,61 +14,35 @@ export default function LeaveConfirmModal({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: LeaveConfirmModalProps) {
|
}: LeaveConfirmModalProps) {
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
||||||
if (e.target === backdropRef.current) onCancel();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal open={open} onClose={onCancel} variant="dialog">
|
||||||
{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 }}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-50">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-500/15">
|
||||||
<LogOut size={22} className="text-rose-500" />
|
<LogOut size={22} className="text-rose-400" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="mt-4 text-base font-bold text-zinc-900">
|
<h2 className="mt-4 text-base font-bold text-heading">
|
||||||
确定要退出房间吗?
|
确定要退出房间吗?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1.5 text-center text-xs leading-relaxed text-zinc-400">
|
<p className="mt-1.5 text-center text-xs leading-relaxed text-muted">
|
||||||
退出后你的滑卡进度不会丢失,可以用房间号重新加入
|
退出后你的滑卡进度不会丢失,可以用房间号重新加入
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-5 flex w-full gap-2.5">
|
<div className="mt-5 flex w-full gap-2.5">
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex h-11 flex-1 items-center justify-center rounded-xl border border-zinc-200 bg-white text-sm font-semibold text-zinc-700 transition-colors active:bg-zinc-50"
|
className="flex h-11 flex-1 items-center justify-center rounded-xl bg-elevated text-sm font-semibold text-secondary ring-1 ring-border transition-colors active:bg-subtle"
|
||||||
>
|
>
|
||||||
继续滑卡
|
继续滑卡
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className="flex h-11 flex-1 items-center justify-center rounded-xl bg-rose-500 text-sm font-semibold text-white shadow-md shadow-rose-200 transition-colors active:bg-rose-600"
|
className="flex h-11 flex-1 items-center justify-center rounded-xl bg-rose-500 text-sm font-semibold text-white shadow-lg shadow-rose-500/20 transition-colors active:bg-rose-600"
|
||||||
>
|
>
|
||||||
退出房间
|
退出房间
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</Modal>
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+201
-256
@@ -12,17 +12,31 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Trophy,
|
Trophy,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
SearchX,
|
|
||||||
Home,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Swords,
|
Swords,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Share2,
|
Share2,
|
||||||
Zap,
|
Zap,
|
||||||
|
Heart,
|
||||||
|
UserPlus,
|
||||||
} from "lucide-react";
|
} 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 { fireCelebration, playChime } from "@/lib/celebrate";
|
||||||
import { isRegistered } from "@/lib/userId";
|
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 {
|
interface MatchResultProps {
|
||||||
restaurant: Restaurant;
|
restaurant: Restaurant;
|
||||||
@@ -39,134 +53,6 @@ interface MatchResultProps {
|
|||||||
scene?: SceneType;
|
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({
|
export default function MatchResult({
|
||||||
restaurant,
|
restaurant,
|
||||||
matchType,
|
matchType,
|
||||||
@@ -183,15 +69,16 @@ export default function MatchResult({
|
|||||||
}: MatchResultProps) {
|
}: MatchResultProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [showRunnerUps, setShowRunnerUps] = useState(false);
|
const [showRunnerUps, setShowRunnerUps] = useState(false);
|
||||||
const [toast, setToast] = useState("");
|
const [showShareCard, setShowShareCard] = useState(false);
|
||||||
|
const toast = useToast();
|
||||||
const celebratedRef = useRef(false);
|
const celebratedRef = useRef(false);
|
||||||
const historySavedRef = useRef(false);
|
const historySavedRef = useRef(false);
|
||||||
|
const isSolo = userCount <= 1;
|
||||||
const isUnanimous = matchType === "unanimous";
|
const isUnanimous = matchType === "unanimous";
|
||||||
|
const [favorited, setFavorited] = useState(false);
|
||||||
const showToast = useCallback((msg: string) => {
|
const [favLoading, setFavLoading] = useState(false);
|
||||||
setToast(msg);
|
const [registered, setRegistered] = useState(() => isRegistered());
|
||||||
setTimeout(() => setToast(""), 2200);
|
const [showAuth, setShowAuth] = useState(false);
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isUnanimous && !celebratedRef.current) {
|
if (isUnanimous && !celebratedRef.current) {
|
||||||
@@ -206,7 +93,7 @@ export default function MatchResult({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (historySavedRef.current) return;
|
if (historySavedRef.current) return;
|
||||||
if (!isRegistered()) return;
|
if (!registered) return;
|
||||||
if (matchType === "no_match") return;
|
if (matchType === "no_match") return;
|
||||||
|
|
||||||
historySavedRef.current = true;
|
historySavedRef.current = true;
|
||||||
@@ -221,48 +108,39 @@ export default function MatchResult({
|
|||||||
participants: userCount,
|
participants: userCount,
|
||||||
}),
|
}),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, [userId, roomId, restaurant, matchType, userCount]);
|
}, [registered, userId, roomId, restaurant, matchType, userCount]);
|
||||||
|
|
||||||
const handleShare = useCallback(async () => {
|
const handleOpenShareCard = useCallback(() => {
|
||||||
const verb = scene === "drink" ? "喝" : "吃";
|
setShowShareCard(true);
|
||||||
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 text = lines.join("\n");
|
const handleAuth = useCallback(
|
||||||
const navUrl = buildNavUrl(restaurant);
|
(profile: UserProfile) => {
|
||||||
|
setRegistered(true);
|
||||||
const shareData = {
|
setShowAuth(false);
|
||||||
title: `我们选了${restaurant.name}!`,
|
toast.show(`欢迎,${profile.username}!记录已保存`);
|
||||||
text,
|
},
|
||||||
url: navUrl,
|
[toast],
|
||||||
};
|
);
|
||||||
|
|
||||||
|
const handleFavorite = useCallback(async () => {
|
||||||
|
if (!registered || favorited || favLoading) return;
|
||||||
|
setFavLoading(true);
|
||||||
try {
|
try {
|
||||||
if (navigator.share && navigator.canShare?.(shareData)) {
|
const res = await fetch("/api/user/favorite", {
|
||||||
await navigator.share(shareData);
|
method: "POST",
|
||||||
return;
|
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 {
|
} catch {
|
||||||
showToast("复制失败,请手动复制");
|
/* ignore */
|
||||||
}
|
}
|
||||||
}, [restaurant, showToast, isUnanimous, userCount, scene]);
|
setFavLoading(false);
|
||||||
|
}, [registered, userId, restaurant, favorited, favLoading, toast]);
|
||||||
|
|
||||||
if (matchType === "no_match") {
|
if (matchType === "no_match") {
|
||||||
return <NoMatchResult onReset={onReset} resetting={resetting} />;
|
return <NoMatchResult onReset={onReset} resetting={resetting} />;
|
||||||
@@ -282,51 +160,61 @@ export default function MatchResult({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={`fixed inset-0 z-50 flex flex-col items-center overflow-y-auto px-6 py-10 ${
|
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-background px-6 pb-24 pt-10"
|
||||||
isUnanimous
|
|
||||||
? "bg-linear-to-b from-emerald-500 to-teal-600"
|
|
||||||
: "bg-linear-to-b from-amber-500 to-orange-500"
|
|
||||||
}`}
|
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.4 }}
|
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
|
<motion.div
|
||||||
initial={{ scale: 0, rotate: -20 }}
|
initial={{ scale: 0, rotate: -20 }}
|
||||||
animate={{ scale: 1, rotate: 0 }}
|
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 ? (
|
{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.div>
|
||||||
|
|
||||||
<motion.h1
|
<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 }}
|
initial={{ y: 30, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ delay: 0.35 }}
|
transition={{ delay: 0.35 }}
|
||||||
>
|
>
|
||||||
就去这了!
|
{isSolo ? "帮你选好了" : "就去这了"}
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
<motion.p
|
<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 }}
|
initial={{ y: 20, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ delay: 0.45 }}
|
transition={{ delay: 0.45 }}
|
||||||
>
|
>
|
||||||
{isUnanimous
|
{isSolo
|
||||||
|
? "你的首选,别犹豫了"
|
||||||
|
: isUnanimous
|
||||||
? "大家一拍即合!"
|
? "大家一拍即合!"
|
||||||
: `${matchLikes}/${userCount} 人想去这家`}
|
: `${matchLikes}/${userCount} 人想去这家`}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
{isUnanimous && (
|
{isUnanimous && !isSolo && (
|
||||||
<motion.div
|
<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 }}
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{
|
transition={{
|
||||||
@@ -336,46 +224,74 @@ export default function MatchResult({
|
|||||||
delay: 0.55,
|
delay: 0.55,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Zap size={14} className="fill-yellow-300 text-yellow-300" />
|
<Zap size={14} className="fill-emerald-400 text-emerald-400" />
|
||||||
<span className="text-xs font-bold text-white">
|
<span className="text-xs font-bold text-emerald-300">
|
||||||
默契度 100% · {userCount} 人全员一致
|
默契度 100% · {userCount} 人全员一致
|
||||||
</span>
|
</span>
|
||||||
<Zap size={14} className="fill-yellow-300 text-yellow-300" />
|
<Zap size={14} className="fill-emerald-400 text-emerald-400" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Result card */}
|
||||||
<motion.div
|
<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 }}
|
initial={{ y: 60, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
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] && (
|
{restaurant.images?.[0] && (
|
||||||
<img
|
<RestaurantImage
|
||||||
src={restaurant.images[0]}
|
src={restaurant.images[0]}
|
||||||
alt={restaurant.name}
|
alt={restaurant.name}
|
||||||
className="h-44 w-full object-cover"
|
className="h-44 w-full object-cover"
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<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}
|
{restaurant.name}
|
||||||
</h2>
|
</h2>
|
||||||
{restaurant.category && (
|
{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}
|
{restaurant.category}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<span className="flex items-center gap-1">
|
||||||
<Star size={13} className="fill-amber-400 text-amber-400" />
|
<Star size={13} className="fill-amber-400 text-amber-400" />
|
||||||
{restaurant.rating}
|
{restaurant.rating}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-emerald-600">
|
<span className="font-semibold text-emerald-400">
|
||||||
{restaurant.price}
|
{restaurant.price}
|
||||||
</span>
|
</span>
|
||||||
{restaurant.distance && (
|
{restaurant.distance && (
|
||||||
@@ -387,13 +303,13 @@ export default function MatchResult({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{restaurant.address && (
|
{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}
|
{restaurant.address}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{restaurant.openTime && (
|
{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} />
|
<Clock size={12} />
|
||||||
<span>{restaurant.openTime}</span>
|
<span>{restaurant.openTime}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -407,7 +323,7 @@ export default function MatchResult({
|
|||||||
.map((t) => (
|
.map((t) => (
|
||||||
<span
|
<span
|
||||||
key={t}
|
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()}
|
{t.trim()}
|
||||||
</span>
|
</span>
|
||||||
@@ -417,6 +333,7 @@ export default function MatchResult({
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mt-5 flex w-full flex-col gap-2.5"
|
className="mt-5 flex w-full flex-col gap-2.5"
|
||||||
initial={{ y: 30, opacity: 0 }}
|
initial={{ y: 30, opacity: 0 }}
|
||||||
@@ -427,9 +344,7 @@ export default function MatchResult({
|
|||||||
href={buildNavUrl(restaurant)}
|
href={buildNavUrl(restaurant)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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 ${
|
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"
|
||||||
isUnanimous ? "text-emerald-600" : "text-orange-600"
|
|
||||||
}`}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<Navigation size={16} />
|
<Navigation size={16} />
|
||||||
@@ -439,7 +354,7 @@ export default function MatchResult({
|
|||||||
{restaurant.tel && (
|
{restaurant.tel && (
|
||||||
<motion.a
|
<motion.a
|
||||||
href={`tel:${restaurant.tel}`}
|
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 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<Phone size={15} />
|
<Phone size={15} />
|
||||||
@@ -447,16 +362,43 @@ export default function MatchResult({
|
|||||||
</motion.a>
|
</motion.a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<motion.button
|
<Button
|
||||||
onClick={handleShare}
|
onClick={handleOpenShareCard}
|
||||||
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"
|
variant="secondary"
|
||||||
whileTap={{ scale: 0.95 }}
|
shape="pill"
|
||||||
|
icon={<Share2 size={15} />}
|
||||||
|
className="px-8 py-3"
|
||||||
>
|
>
|
||||||
<Share2 size={15} />
|
生成分享卡片
|
||||||
分享结果到群里
|
</Button>
|
||||||
</motion.button>
|
|
||||||
</motion.div>
|
</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 && (
|
{!isUnanimous && runnerUpRestaurants.length > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mt-5 w-full"
|
className="mt-5 w-full"
|
||||||
@@ -466,7 +408,7 @@ export default function MatchResult({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRunnerUps((v) => !v)}
|
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})
|
其他候选({runnerUpRestaurants.length})
|
||||||
<motion.span
|
<motion.span
|
||||||
@@ -497,72 +439,75 @@ export default function MatchResult({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<motion.div
|
{canNarrow && (
|
||||||
className="mt-5 flex w-full flex-col items-center gap-2.5"
|
<div className="mt-4 flex flex-col items-center gap-2">
|
||||||
initial={{ opacity: 0 }}
|
<p className="text-xs text-muted">
|
||||||
animate={{ opacity: 1 }}
|
还有 {runnerUpRestaurants.length} 家不相上下,再选一轮?
|
||||||
transition={{ delay: 0.8 }}
|
</p>
|
||||||
>
|
|
||||||
{canNarrow ? (
|
|
||||||
<>
|
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={() => onNarrow(narrowIds)}
|
onClick={() => onNarrow(narrowIds)}
|
||||||
disabled={resetting}
|
disabled={resetting}
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-full bg-white/20 px-8 py-3 text-sm font-bold text-white backdrop-blur-sm transition-colors hover:bg-white/30 disabled:opacity-50"
|
className="flex w-full items-center justify-center gap-2 rounded-full bg-elevated px-8 py-3 text-sm font-bold text-secondary ring-1 ring-border transition-colors hover:bg-subtle disabled:opacity-50"
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<Swords size={15} />
|
<Swords size={15} />
|
||||||
{resetting ? "加载中..." : `Top ${narrowIds.length} 决赛`}
|
{resetting ? "加载中..." : `Top ${narrowIds.length} 决赛`}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
<motion.button
|
</div>
|
||||||
onClick={() => router.push("/")}
|
)}
|
||||||
className="flex items-center gap-1.5 text-sm font-medium text-amber-200 underline underline-offset-2 hover:text-white"
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating bottom bar */}
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-x-0 bottom-0 z-10 border-t border-border/50 bg-background/80 backdrop-blur-xl"
|
||||||
|
initial={{ y: 60, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.9, type: "spring", stiffness: 200, damping: 20 }}
|
||||||
>
|
>
|
||||||
<RefreshCw size={13} />
|
<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>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
disabled={resetting}
|
disabled={resetting}
|
||||||
className="flex items-center justify-center gap-2 rounded-full bg-white/20 px-8 py-3 text-sm font-bold text-white backdrop-blur-sm transition-colors hover:bg-white/30 disabled:opacity-50"
|
className="flex flex-1 items-center justify-center gap-1.5 rounded-full bg-elevated py-2.5 text-xs font-bold text-secondary ring-1 ring-border transition-colors hover:bg-subtle disabled:opacity-50"
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<RotateCcw size={14} className={resetting ? "animate-spin" : ""} />
|
<RotateCcw size={12} className={resetting ? "animate-spin" : ""} />
|
||||||
{resetting ? "重置中..." : "再来一轮"}
|
{resetting ? "重置中..." : "再来一轮"}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
className={`flex items-center gap-1.5 text-sm font-medium underline underline-offset-2 hover:text-white ${
|
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"
|
||||||
isUnanimous ? "text-emerald-200" : "text-amber-200"
|
whileTap={{ scale: 0.95 }}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<RefreshCw size={13} />
|
<RefreshCw size={12} />
|
||||||
换一批店
|
换一批店
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</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>
|
</motion.div>
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
<AuthModal
|
||||||
|
open={showAuth}
|
||||||
|
onClose={() => setShowAuth(false)}
|
||||||
|
onAuth={handleAuth}
|
||||||
|
defaultTab="register"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShareCardModal
|
||||||
|
open={showShareCard}
|
||||||
|
onClose={() => setShowShareCard(false)}
|
||||||
|
data={{
|
||||||
|
type: "restaurant",
|
||||||
|
restaurant,
|
||||||
|
matchType,
|
||||||
|
matchLikes,
|
||||||
|
userCount,
|
||||||
|
scene,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</motion.div>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
import { X, Copy, Share2, QrCode } from "lucide-react";
|
import { X, Copy, Share2, QrCode } from "lucide-react";
|
||||||
import type { SceneType } from "@/types";
|
import type { SceneType } from "@/types";
|
||||||
import { getSceneConfig } from "@/lib/sceneConfig";
|
import { getSceneConfig } from "@/lib/sceneConfig";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import Button from "@/components/Button";
|
||||||
|
import { useShare } from "@/hooks/useShare";
|
||||||
|
|
||||||
interface QrInviteModalProps {
|
interface QrInviteModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
onToast: (msg: string) => void;
|
|
||||||
scene?: SceneType;
|
scene?: SceneType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,120 +20,80 @@ export default function QrInviteModal({
|
|||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
roomId,
|
roomId,
|
||||||
onToast,
|
|
||||||
scene = "eat",
|
scene = "eat",
|
||||||
}: QrInviteModalProps) {
|
}: QrInviteModalProps) {
|
||||||
|
const { share, copyToClipboard } = useShare();
|
||||||
const sceneConfig = getSceneConfig(scene);
|
const sceneConfig = getSceneConfig(scene);
|
||||||
const inviteUrl =
|
const inviteUrl =
|
||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
? `${window.location.origin}/invite/${roomId}`
|
? `${window.location.origin}/invite/${roomId}`
|
||||||
: "";
|
: "";
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
const handleCopy = useCallback(
|
||||||
if (e.target === backdropRef.current) onClose();
|
() => copyToClipboard(inviteUrl, "邀请链接已复制,快去发给朋友吧!"),
|
||||||
};
|
[inviteUrl, copyToClipboard],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
const handleShare = useCallback(
|
||||||
try {
|
() => share({ title: sceneConfig.shareTitle, text: sceneConfig.shareText, url: inviteUrl }, handleCopy),
|
||||||
await navigator.clipboard.writeText(inviteUrl);
|
[inviteUrl, sceneConfig, share, handleCopy],
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal open={open} onClose={onClose}>
|
||||||
{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 }}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-zinc-100 text-zinc-400 transition-colors active:bg-zinc-200"
|
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="flex items-center gap-2 text-zinc-800">
|
<div className="flex items-center gap-2 text-heading">
|
||||||
<QrCode size={18} className="text-emerald-500" />
|
<QrCode size={18} className="text-accent" />
|
||||||
<h2 className="text-lg font-bold">邀请饭搭子</h2>
|
<h2 className="text-lg font-bold">邀请饭搭子</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-zinc-400">
|
<p className="mt-1 text-xs text-muted">
|
||||||
{sceneConfig.qrSubtitle}
|
{sceneConfig.qrSubtitle}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-5 rounded-2xl border-2 border-dashed border-emerald-200 bg-emerald-50/30 p-4">
|
<div className="mt-5 rounded-2xl border-2 border-dashed border-subtle bg-elevated/50 p-4">
|
||||||
<QRCodeSVG
|
<QRCodeSVG
|
||||||
value={inviteUrl}
|
value={inviteUrl}
|
||||||
size={180}
|
size={180}
|
||||||
level="M"
|
level="M"
|
||||||
bgColor="transparent"
|
bgColor="transparent"
|
||||||
fgColor="#18181b"
|
fgColor="#e5e7eb"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
<div className="mt-4 flex items-center gap-2">
|
||||||
<span className="text-xs text-zinc-400">房间号</span>
|
<span className="text-xs text-muted">房间号</span>
|
||||||
<span className="rounded-full bg-zinc-100 px-3 py-1 font-mono text-base font-bold tracking-[0.2em] text-emerald-600">
|
<span className="rounded-full bg-elevated px-3 py-1 font-mono text-base font-bold tracking-[0.2em] text-accent ring-1 ring-border">
|
||||||
{roomId}
|
{roomId}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex w-full gap-2.5">
|
<div className="mt-5 flex w-full gap-2.5">
|
||||||
<button
|
<Button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="flex h-11 flex-1 items-center justify-center gap-1.5 rounded-xl border border-zinc-200 bg-white text-sm font-semibold text-zinc-700 transition-colors active:bg-zinc-50"
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
icon={<Copy size={15} />}
|
||||||
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Copy size={15} />
|
|
||||||
复制链接
|
复制链接
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
className="flex h-11 flex-1 items-center justify-center gap-1.5 rounded-xl bg-emerald-500 text-sm font-semibold text-white shadow-md shadow-emerald-200 transition-colors active:bg-emerald-600"
|
size="lg"
|
||||||
|
icon={<Share2 size={15} />}
|
||||||
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Share2 size={15} />
|
|
||||||
发送邀请
|
发送邀请
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</Modal>
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"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 { Star, MapPin, Clock, ExternalLink, Flame, Bookmark, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Restaurant } from "@/types";
|
import { Restaurant } from "@/types";
|
||||||
import { getUserId, isRegistered } from "@/lib/userId";
|
import { getUserId, isRegistered } from "@/lib/userId";
|
||||||
|
import RestaurantImage from "@/components/RestaurantImage";
|
||||||
|
|
||||||
interface RestaurantCardProps {
|
interface RestaurantCardProps {
|
||||||
restaurant: Restaurant;
|
restaurant: Restaurant;
|
||||||
@@ -57,16 +59,15 @@ function ImageGallery({ images, name }: { images: string[]; name: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full" onClick={handleTap} onPointerDown={stopAll}>
|
<div className="relative h-full w-full" onClick={handleTap} onPointerDown={stopAll}>
|
||||||
<img
|
<RestaurantImage
|
||||||
src={images[idx]}
|
src={images[idx]}
|
||||||
alt={name}
|
alt={name}
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{fadingOut !== null && (
|
{fadingOut !== null && (
|
||||||
<img
|
<RestaurantImage
|
||||||
key={fadingOut}
|
key={fadingOut}
|
||||||
src={images[fadingOut]}
|
src={images[fadingOut]}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -74,7 +75,6 @@ function ImageGallery({ images, name }: { images: string[]; name: string }) {
|
|||||||
style={{ animation: "img-fade-out 280ms ease-out forwards" }}
|
style={{ animation: "img-fade-out 280ms ease-out forwards" }}
|
||||||
onAnimationEnd={() => setFadingOut(null)}
|
onAnimationEnd={() => setFadingOut(null)}
|
||||||
draggable={false}
|
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) {
|
export default function RestaurantCard({ restaurant, likeCount = 0 }: RestaurantCardProps) {
|
||||||
const [favorited, setFavorited] = useState(false);
|
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 images = restaurant.images?.filter(Boolean);
|
||||||
const hasImage = images && images.length > 0;
|
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)}`;
|
const dianpingUrl = `https://m.dianping.com/search/keyword/0/0_${encodeURIComponent(restaurant.name)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
<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-zinc-100">
|
<div className="relative h-[58%] w-full shrink-0 overflow-hidden bg-elevated">
|
||||||
{hasImage && <ImageGallery images={images} name={restaurant.name} />}
|
{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="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">
|
<div className="absolute bottom-3 left-4 flex items-center gap-1.5">
|
||||||
{restaurant.category && (
|
{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}
|
{restaurant.category}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<AnimatePresence>
|
||||||
{likeCount > 0 && (
|
{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">
|
<motion.span
|
||||||
<Flame size={11} />
|
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} 人想去
|
{likeCount} 人想去
|
||||||
</span>
|
</motion.span>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col justify-center gap-2 px-5 py-3">
|
<div className="flex flex-1 flex-col justify-center gap-2 px-5 py-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<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}
|
{restaurant.name}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-0.5 flex shrink-0 gap-1.5">
|
<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}
|
onTouchStart={stopAll}
|
||||||
className={`flex items-center justify-center rounded-full p-1 transition-colors ${
|
className={`flex items-center justify-center rounded-full p-1 transition-colors ${
|
||||||
favorited
|
favorited
|
||||||
? "bg-amber-100 text-amber-500"
|
? "bg-amber-500/20 text-amber-400"
|
||||||
: "bg-zinc-50 text-zinc-400 active:bg-amber-50 active:text-amber-500"
|
: "bg-elevated text-muted active:bg-amber-500/15 active:text-amber-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Bookmark size={13} className={favorited ? "fill-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)}
|
onClick={openLink(amapUrl)}
|
||||||
onPointerDown={stopAll}
|
onPointerDown={stopAll}
|
||||||
onTouchStart={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} />
|
<ExternalLink size={10} />
|
||||||
@@ -203,7 +230,7 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
|
|||||||
onClick={openLink(dianpingUrl)}
|
onClick={openLink(dianpingUrl)}
|
||||||
onPointerDown={stopAll}
|
onPointerDown={stopAll}
|
||||||
onTouchStart={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} />
|
<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 flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Star size={14} className="fill-amber-400 text-amber-400" />
|
<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}
|
{restaurant.rating}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-sm font-semibold text-emerald-600">
|
<span className="text-sm font-semibold text-emerald-400">
|
||||||
{restaurant.price}
|
{restaurant.price}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{restaurant.distance && (
|
{restaurant.distance && (
|
||||||
<div className="flex items-center gap-1 text-zinc-400">
|
<div className="flex items-center gap-1 text-muted">
|
||||||
<MapPin size={13} />
|
<MapPin size={13} />
|
||||||
<span className="text-xs">{restaurant.distance}</span>
|
<span className="text-xs">{restaurant.distance}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,13 +259,13 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{restaurant.address && (
|
{restaurant.address && (
|
||||||
<p className="truncate text-xs leading-tight text-zinc-400">
|
<p className="truncate text-xs leading-tight text-muted">
|
||||||
{restaurant.address}
|
{restaurant.address}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{restaurant.openTime && (
|
{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} />
|
<Clock size={12} />
|
||||||
<span>{restaurant.openTime}</span>
|
<span>{restaurant.openTime}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,7 +279,7 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
|
|||||||
.map((t) => (
|
.map((t) => (
|
||||||
<span
|
<span
|
||||||
key={t}
|
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()}
|
{t.trim()}
|
||||||
</span>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Lock,
|
Lock,
|
||||||
@@ -12,7 +11,9 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { UserProfile } from "@/types";
|
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 {
|
interface RoomManageModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -24,7 +25,6 @@ interface RoomManageModalProps {
|
|||||||
swipeCounts: Record<string, number>;
|
swipeCounts: Record<string, number>;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
userProfiles: Record<string, UserProfile>;
|
userProfiles: Record<string, UserProfile>;
|
||||||
onToast: (msg: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RoomManageModal({
|
export default function RoomManageModal({
|
||||||
@@ -37,17 +37,12 @@ export default function RoomManageModal({
|
|||||||
swipeCounts,
|
swipeCounts,
|
||||||
totalCards,
|
totalCards,
|
||||||
userProfiles,
|
userProfiles,
|
||||||
onToast,
|
|
||||||
}: RoomManageModalProps) {
|
}: RoomManageModalProps) {
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
const toast = useToast();
|
||||||
const [loading, setLoading] = useState<string | null>(null);
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
const [confirmKick, setConfirmKick] = useState<string | null>(null);
|
const [confirmKick, setConfirmKick] = useState<string | null>(null);
|
||||||
const [confirmEnd, setConfirmEnd] = useState(false);
|
const [confirmEnd, setConfirmEnd] = useState(false);
|
||||||
|
|
||||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
||||||
if (e.target === backdropRef.current) onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const manage = useCallback(
|
const manage = useCallback(
|
||||||
async (action: string, targetUserId?: string) => {
|
async (action: string, targetUserId?: string) => {
|
||||||
setLoading(action + (targetUserId ?? ""));
|
setLoading(action + (targetUserId ?? ""));
|
||||||
@@ -59,80 +54,62 @@ export default function RoomManageModal({
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
onToast(data.error ?? "操作失败");
|
toast.show(data.error ?? "操作失败");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "lock":
|
case "lock":
|
||||||
onToast("房间已锁定,其他人无法加入");
|
toast.show("房间已锁定,其他人无法加入");
|
||||||
break;
|
break;
|
||||||
case "unlock":
|
case "unlock":
|
||||||
onToast("房间已解锁");
|
toast.show("房间已解锁");
|
||||||
break;
|
break;
|
||||||
case "kick":
|
case "kick":
|
||||||
onToast("已将该用户移出房间");
|
toast.show("已将该用户移出房间");
|
||||||
setConfirmKick(null);
|
setConfirmKick(null);
|
||||||
break;
|
break;
|
||||||
case "end_voting":
|
case "end_voting":
|
||||||
onToast("已结束投票,正在结算结果");
|
toast.show("已结束投票,正在结算结果");
|
||||||
setConfirmEnd(false);
|
setConfirmEnd(false);
|
||||||
onClose();
|
onClose();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
onToast("操作失败,请重试");
|
toast.show("操作失败,请重试");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(null);
|
setLoading(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[roomId, userId, onToast, onClose],
|
[roomId, userId, toast, onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
const otherUsers = users.filter((u) => u !== userId);
|
const otherUsers = users.filter((u) => u !== userId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal open={open} onClose={onClose}>
|
||||||
{open && (
|
|
||||||
<motion.div
|
|
||||||
ref={backdropRef}
|
|
||||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
onClick={handleBackdropClick}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="relative w-full max-w-sm rounded-t-3xl bg-white px-5 pb-8 pt-5 shadow-2xl sm:rounded-3xl sm:pb-6"
|
|
||||||
initial={{ y: "100%" }}
|
|
||||||
animate={{ y: 0 }}
|
|
||||||
exit={{ y: "100%" }}
|
|
||||||
transition={{ type: "spring", damping: 28, stiffness: 350 }}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-zinc-100 text-zinc-400 transition-colors active:bg-zinc-200"
|
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Crown size={18} className="text-amber-500" />
|
<Crown size={18} className="text-amber-400" />
|
||||||
<h2 className="text-lg font-bold text-zinc-900">房间管理</h2>
|
<h2 className="text-lg font-bold text-heading">房间管理</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-zinc-400">
|
<p className="mt-1 text-xs text-muted">
|
||||||
房间号 {roomId}
|
房间号 {roomId}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Lock/Unlock */}
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<button
|
<button
|
||||||
onClick={() => manage(locked ? "unlock" : "lock")}
|
onClick={() => manage(locked ? "unlock" : "lock")}
|
||||||
disabled={loading !== null}
|
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 ${
|
className={`flex h-11 w-full items-center justify-center gap-2 rounded-xl text-sm font-semibold transition-colors disabled:opacity-50 ${
|
||||||
locked
|
locked
|
||||||
? "border border-emerald-200 bg-emerald-50 text-emerald-700 active:bg-emerald-100"
|
? "bg-accent/15 text-accent ring-1 ring-accent/30 active:bg-accent/25"
|
||||||
: "border border-zinc-200 bg-white text-zinc-700 active:bg-zinc-50"
|
: "bg-elevated text-secondary ring-1 ring-border active:bg-subtle"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{loading === "lock" || loading === "unlock" ? (
|
{loading === "lock" || loading === "unlock" ? (
|
||||||
@@ -146,17 +123,13 @@ export default function RoomManageModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User list with kick */}
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<h3 className="text-xs font-semibold text-zinc-500">
|
<h3 className="text-xs font-semibold text-muted">
|
||||||
房间成员({users.length})
|
房间成员({users.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-2 flex flex-col gap-1.5">
|
<div className="mt-2 flex flex-col gap-1.5">
|
||||||
{users.map((uid) => {
|
{users.map((uid) => {
|
||||||
const profile = userProfiles[uid];
|
const displayName = userProfiles[uid]?.username ?? uid.slice(0, 8);
|
||||||
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 isCreator = uid === userId;
|
||||||
const swiped = swipeCounts[uid] ?? 0;
|
const swiped = swipeCounts[uid] ?? 0;
|
||||||
const finished = swiped >= totalCards;
|
const finished = swiped >= totalCards;
|
||||||
@@ -164,27 +137,23 @@ export default function RoomManageModal({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={uid}
|
key={uid}
|
||||||
className="flex items-center gap-2.5 rounded-xl bg-zinc-50 px-3 py-2.5"
|
className="flex items-center gap-2.5 rounded-xl bg-elevated px-3 py-2.5"
|
||||||
>
|
>
|
||||||
<span
|
<UserAvatar userId={uid} profile={userProfiles[uid]} />
|
||||||
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 min-w-0 flex-1 flex-col">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{isCreator && (
|
{isCreator && (
|
||||||
<span className="flex items-center gap-0.5 text-[10px] font-bold text-amber-500">
|
<span className="flex items-center gap-0.5 text-[10px] font-bold text-amber-400">
|
||||||
<Crown size={10} />
|
<Crown size={10} />
|
||||||
房主
|
房主
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="truncate text-xs font-medium text-zinc-500">
|
<span className="truncate text-xs font-medium text-tertiary">
|
||||||
{displayName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`text-[11px] ${finished ? "text-emerald-500" : "text-zinc-400"}`}
|
className={`text-[11px] ${finished ? "text-accent" : "text-muted"}`}
|
||||||
>
|
>
|
||||||
{swiped}/{totalCards}
|
{swiped}/{totalCards}
|
||||||
{finished ? " 已完成" : " 进行中"}
|
{finished ? " 已完成" : " 进行中"}
|
||||||
@@ -211,7 +180,7 @@ export default function RoomManageModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmKick(null)}
|
onClick={() => setConfirmKick(null)}
|
||||||
className="rounded-lg bg-zinc-200 px-2.5 py-1 text-[11px] font-semibold text-zinc-600 transition-colors active:bg-zinc-300"
|
className="rounded-lg bg-subtle px-2.5 py-1 text-[11px] font-semibold text-tertiary transition-colors active:bg-elevated"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
@@ -219,7 +188,7 @@ export default function RoomManageModal({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmKick(uid)}
|
onClick={() => setConfirmKick(uid)}
|
||||||
className="flex items-center gap-0.5 rounded-lg px-2 py-1 text-[11px] font-medium text-zinc-400 transition-colors active:bg-zinc-100 active:text-rose-500"
|
className="flex items-center gap-0.5 rounded-lg px-2 py-1 text-[11px] font-medium text-muted transition-colors active:bg-subtle active:text-rose-400"
|
||||||
>
|
>
|
||||||
<UserX size={13} />
|
<UserX size={13} />
|
||||||
移出
|
移出
|
||||||
@@ -233,11 +202,10 @@ export default function RoomManageModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* End voting */}
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
{confirmEnd ? (
|
{confirmEnd ? (
|
||||||
<div className="flex flex-col gap-2 rounded-xl border border-amber-200 bg-amber-50 p-3">
|
<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-800">
|
<p className="text-xs font-medium text-amber-300">
|
||||||
确定要结束投票吗?将根据当前已有的投票结果直接结算。
|
确定要结束投票吗?将根据当前已有的投票结果直接结算。
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -255,7 +223,7 @@ export default function RoomManageModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmEnd(false)}
|
onClick={() => setConfirmEnd(false)}
|
||||||
className="flex h-9 flex-1 items-center justify-center rounded-lg bg-white text-xs font-semibold text-zinc-600 transition-colors active:bg-zinc-50"
|
className="flex h-9 flex-1 items-center justify-center rounded-lg bg-elevated text-xs font-semibold text-tertiary transition-colors active:bg-subtle"
|
||||||
>
|
>
|
||||||
再等等
|
再等等
|
||||||
</button>
|
</button>
|
||||||
@@ -265,16 +233,13 @@ export default function RoomManageModal({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setConfirmEnd(true)}
|
onClick={() => setConfirmEnd(true)}
|
||||||
disabled={loading !== null}
|
disabled={loading !== null}
|
||||||
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl border border-amber-200 bg-amber-50 text-sm font-semibold text-amber-700 transition-colors active:bg-amber-100 disabled:opacity-50"
|
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-amber-500/10 text-sm font-semibold text-amber-400 ring-1 ring-amber-500/30 transition-colors active:bg-amber-500/20 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Flag size={15} />
|
<Flag size={15} />
|
||||||
结束投票(立即出结果)
|
结束投票(立即出结果)
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</Modal>
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 SwipeGuide from "./SwipeGuide";
|
||||||
import { Restaurant, SwipeDirection, MatchType, RunnerUp, UserProfile, SceneType } from "@/types";
|
import { Restaurant, SwipeDirection, MatchType, RunnerUp, UserProfile, SceneType } from "@/types";
|
||||||
import { Heart, Undo2, Check } from "lucide-react";
|
import { Heart, Undo2, Check } from "lucide-react";
|
||||||
import { getAvatar, getAvatarBg } from "@/lib/avatars";
|
import UserAvatar from "@/components/UserAvatar";
|
||||||
|
|
||||||
function UserProgressBar({
|
function UserProgressBar({
|
||||||
userId,
|
userId,
|
||||||
@@ -16,49 +16,54 @@ function UserProgressBar({
|
|||||||
localIndex,
|
localIndex,
|
||||||
total,
|
total,
|
||||||
userProfiles,
|
userProfiles,
|
||||||
|
onUndo,
|
||||||
}: {
|
}: {
|
||||||
userId: string;
|
userId: string;
|
||||||
swipeCounts: Record<string, number>;
|
swipeCounts: Record<string, number>;
|
||||||
localIndex: number;
|
localIndex: number;
|
||||||
total: number;
|
total: number;
|
||||||
userProfiles: Record<string, UserProfile>;
|
userProfiles: Record<string, UserProfile>;
|
||||||
|
onUndo: () => void;
|
||||||
}) {
|
}) {
|
||||||
const others = Object.entries(swipeCounts).filter(([id]) => id !== userId);
|
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 (
|
return (
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
|
<div className="flex items-center gap-x-3">
|
||||||
<span className="flex items-center gap-1 text-[11px] tabular-nums text-emerald-500">
|
<div className="flex flex-1 flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${myAvatarBg} text-[10px] leading-none`}>
|
<span className="flex items-center gap-1.5 text-[11px] text-accent">
|
||||||
{myAvatar}
|
<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>
|
||||||
你 {localIndex}/{total}
|
|
||||||
</span>
|
</span>
|
||||||
{others.map(([id, count]) => {
|
{others.map(([id, count]) => {
|
||||||
const finished = count >= total;
|
const finished = count >= total;
|
||||||
const profile = userProfiles[id];
|
const label = userProfiles[id]?.username ?? "";
|
||||||
const emoji = profile?.avatar ?? getAvatar(id).emoji;
|
|
||||||
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
|
|
||||||
const label = profile?.username ?? "";
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={id}
|
key={id}
|
||||||
className={`flex items-center gap-1 text-[11px] tabular-nums ${finished ? "text-emerald-400" : "text-zinc-400"}`}
|
className={`flex items-center gap-1.5 text-[11px] ${finished ? "text-emerald-400" : "text-muted"}`}
|
||||||
>
|
>
|
||||||
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${bg} text-[10px] leading-none`}>
|
<UserAvatar userId={id} profile={userProfiles[id]} size="xs" />
|
||||||
{emoji}
|
{label && <span className="max-w-12 truncate">{label}</span>}
|
||||||
</span>
|
<span className={`rounded px-1 py-px tabular-nums font-medium ${finished ? "bg-emerald-500/10" : "bg-elevated"}`}>
|
||||||
{label && <span className="max-w-[3rem] truncate">{label}</span>}
|
|
||||||
{count}/{total}
|
{count}/{total}
|
||||||
|
</span>
|
||||||
{finished && <Check size={10} className="text-emerald-400" />}
|
{finished && <Check size={10} className="text-emerald-400" />}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 others = entries.filter(([id]) => id !== userId);
|
||||||
const finishedCount = others.filter(([, c]) => c >= total).length;
|
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 (
|
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">
|
<div className="flex items-center justify-between">
|
||||||
<span className="flex items-center gap-1.5 text-xs font-medium text-emerald-600">
|
<span className="flex items-center gap-1.5 text-xs font-medium text-accent">
|
||||||
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${myBg} text-sm leading-none`}>
|
<UserAvatar userId={userId} profile={userProfiles[userId]} size="sm" />
|
||||||
{myEmoji}
|
|
||||||
</span>
|
|
||||||
你 {total}/{total}
|
你 {total}/{total}
|
||||||
</span>
|
</span>
|
||||||
<Check size={14} className="text-emerald-400" />
|
<Check size={14} className="text-emerald-400" />
|
||||||
@@ -98,24 +97,24 @@ function WaitingProgress({
|
|||||||
{others.map(([id, count]) => {
|
{others.map(([id, count]) => {
|
||||||
const finished = count >= total;
|
const finished = count >= total;
|
||||||
const pct = Math.min((count / total) * 100, 100);
|
const pct = Math.min((count / total) * 100, 100);
|
||||||
const profile = userProfiles[id];
|
const label = userProfiles[id]?.username ?? "";
|
||||||
const emoji = profile?.avatar ?? getAvatar(id).emoji;
|
|
||||||
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
|
|
||||||
const label = profile?.username ?? "";
|
|
||||||
return (
|
return (
|
||||||
<div key={id} className="flex flex-col gap-1">
|
<div key={id} className="flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<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={`flex items-center gap-1.5 text-xs font-medium ${finished ? "text-accent" : "text-muted"}`}>
|
||||||
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full text-sm leading-none ${finished ? "bg-emerald-100" : bg}`}>
|
<UserAvatar
|
||||||
{emoji}
|
userId={id}
|
||||||
</span>
|
profile={userProfiles[id]}
|
||||||
{label && <span className="max-w-[3rem] truncate">{label}</span>}
|
size="sm"
|
||||||
|
bg={finished ? "bg-emerald-500/20" : undefined}
|
||||||
|
/>
|
||||||
|
{label && <span className="max-w-12 truncate">{label}</span>}
|
||||||
{count}/{total}
|
{count}/{total}
|
||||||
</span>
|
</span>
|
||||||
{finished && <Check size={14} className="text-emerald-400" />}
|
{finished && <Check size={14} className="text-emerald-400" />}
|
||||||
</div>
|
</div>
|
||||||
{!finished && (
|
{!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
|
<motion.div
|
||||||
className="h-full rounded-full bg-amber-400"
|
className="h-full rounded-full bg-amber-400"
|
||||||
animate={{ width: `${pct}%` }}
|
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} 人已完成
|
{finishedCount}/{others.length} 人已完成
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,6 +291,13 @@ export default function SwipeDeck({
|
|||||||
prevLikeCounts.current = {};
|
prevLikeCounts.current = {};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const serverIndex = swipeCounts[userId] ?? 0;
|
||||||
|
if (serverIndex === 0 && currentIndex > 0 && !resetting) {
|
||||||
|
clearLocalState();
|
||||||
|
}
|
||||||
|
}, [swipeCounts, userId, currentIndex, resetting, clearLocalState]);
|
||||||
|
|
||||||
const handleReset = useCallback(async () => {
|
const handleReset = useCallback(async () => {
|
||||||
setResetting(true);
|
setResetting(true);
|
||||||
try {
|
try {
|
||||||
@@ -319,47 +325,24 @@ export default function SwipeDeck({
|
|||||||
? restaurants.find((r) => r.id === resolvedMatchId) ?? null
|
? restaurants.find((r) => r.id === resolvedMatchId) ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const showWaiting = allSwiped && !resolvedMatchId;
|
const showWaiting = allSwiped && !resolvedMatchId && userCount > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="relative flex flex-1 items-center justify-center px-4">
|
||||||
|
<div className="relative h-[60vh] w-full max-w-sm">
|
||||||
{!allSwiped && !resolvedMatchId && (
|
{!allSwiped && !resolvedMatchId && (
|
||||||
<div className="mx-auto w-full max-w-sm px-4 pb-1">
|
<div className="absolute inset-x-0 bottom-full mb-2">
|
||||||
<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
|
<UserProgressBar
|
||||||
userId={userId}
|
userId={userId}
|
||||||
swipeCounts={swipeCounts}
|
swipeCounts={swipeCounts}
|
||||||
localIndex={currentIndex}
|
localIndex={currentIndex}
|
||||||
total={restaurants.length}
|
total={restaurants.length}
|
||||||
userProfiles={userProfiles}
|
userProfiles={userProfiles}
|
||||||
|
onUndo={handleUndo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative flex flex-1 items-center justify-center px-4">
|
|
||||||
<div className="relative h-[60vh] w-full max-w-sm">
|
|
||||||
{currentIndex === 0 && !resolvedMatchId && guideVisible && (
|
{currentIndex === 0 && !resolvedMatchId && guideVisible && (
|
||||||
<SwipeGuide onDismiss={() => setGuideVisible(false)} />
|
<SwipeGuide onDismiss={() => setGuideVisible(false)} />
|
||||||
)}
|
)}
|
||||||
@@ -385,8 +368,8 @@ export default function SwipeDeck({
|
|||||||
|
|
||||||
{showWaiting && (
|
{showWaiting && (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-4">
|
<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" />
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-subtle border-t-accent" />
|
||||||
<p className="text-sm font-medium text-zinc-500">等待其他人完成选择</p>
|
<p className="text-sm font-medium text-muted">等待其他人完成选择</p>
|
||||||
<WaitingProgress
|
<WaitingProgress
|
||||||
userId={userId}
|
userId={userId}
|
||||||
swipeCounts={swipeCounts}
|
swipeCounts={swipeCounts}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import RestaurantCard from "./RestaurantCard";
|
|||||||
import { Restaurant, SwipeDirection } from "@/types";
|
import { Restaurant, SwipeDirection } from "@/types";
|
||||||
|
|
||||||
const SWIPE_THRESHOLD = 120;
|
const SWIPE_THRESHOLD = 120;
|
||||||
const EXIT_X = 600;
|
const getExitX = () =>
|
||||||
|
typeof window !== "undefined" ? window.innerWidth * 1.5 : 600;
|
||||||
const ROTATION_RANGE = 18;
|
const ROTATION_RANGE = 18;
|
||||||
|
|
||||||
interface SwipeableCardProps {
|
interface SwipeableCardProps {
|
||||||
@@ -66,7 +67,8 @@ export default function SwipeableCard({
|
|||||||
const flyOut = (direction: SwipeDirection) => {
|
const flyOut = (direction: SwipeDirection) => {
|
||||||
if (isSwiping.current) return;
|
if (isSwiping.current) return;
|
||||||
isSwiping.current = true;
|
isSwiping.current = true;
|
||||||
const exitX = direction === "right" ? EXIT_X : -EXIT_X;
|
const exit = getExitX();
|
||||||
|
const exitX = direction === "right" ? exit : -exit;
|
||||||
animate(x, exitX, {
|
animate(x, exitX, {
|
||||||
type: "spring",
|
type: "spring",
|
||||||
stiffness: 600,
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+29
-64
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState } from "react";
|
||||||
import { Users, QrCode, LogOut, Crown, Lock } from "lucide-react";
|
import { QrCode, LogOut, Crown, Lock } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import QrInviteModal from "./QrInviteModal";
|
import QrInviteModal from "./QrInviteModal";
|
||||||
import RoomManageModal from "./RoomManageModal";
|
import RoomManageModal from "./RoomManageModal";
|
||||||
import type { UserProfile, SceneType } from "@/types";
|
import type { UserProfile, SceneType } from "@/types";
|
||||||
@@ -10,7 +9,7 @@ import { getSceneConfig } from "@/lib/sceneConfig";
|
|||||||
|
|
||||||
interface TopNavProps {
|
interface TopNavProps {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
userCount: number;
|
userCount?: number;
|
||||||
onExit?: () => void;
|
onExit?: () => void;
|
||||||
isCreator?: boolean;
|
isCreator?: boolean;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@@ -36,84 +35,51 @@ export default function TopNav({
|
|||||||
scene = "eat",
|
scene = "eat",
|
||||||
}: TopNavProps) {
|
}: TopNavProps) {
|
||||||
const sceneConfig = getSceneConfig(scene);
|
const sceneConfig = getSceneConfig(scene);
|
||||||
const [toast, setToast] = useState("");
|
|
||||||
const [showQr, setShowQr] = useState(false);
|
const [showQr, setShowQr] = useState(false);
|
||||||
const [showManage, setShowManage] = useState(false);
|
const [showManage, setShowManage] = useState(false);
|
||||||
|
|
||||||
const showToast = useCallback((msg: string) => {
|
|
||||||
setToast(msg);
|
|
||||||
setTimeout(() => setToast(""), 2200);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
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">
|
<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
|
<button
|
||||||
onClick={onExit}
|
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="退出房间"
|
aria-label="退出房间"
|
||||||
>
|
>
|
||||||
<LogOut size={15} />
|
<LogOut size={15} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
</nav>
|
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"
|
||||||
<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 }}
|
|
||||||
>
|
>
|
||||||
{toast}
|
<QrCode size={13} />
|
||||||
</motion.div>
|
邀请({roomId})
|
||||||
|
</button>
|
||||||
|
{isCreator && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowManage(true)}
|
||||||
|
className="flex items-center gap-1 rounded-full bg-amber-500/15 px-2.5 py-1 text-xs font-semibold text-amber-400 transition-colors active:bg-amber-500/25"
|
||||||
|
>
|
||||||
|
<Crown size={13} />
|
||||||
|
管理
|
||||||
|
{locked && <Lock size={11} />}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</div>
|
||||||
|
|
||||||
|
<h1 className="pointer-events-none absolute inset-x-0 text-center text-base font-bold tracking-tight text-heading">
|
||||||
|
<span className="block leading-tight">NoWhatever</span>
|
||||||
|
<span className="block text-[10px] font-medium tracking-widest text-muted">
|
||||||
|
别说随便
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<QrInviteModal
|
<QrInviteModal
|
||||||
open={showQr}
|
open={showQr}
|
||||||
onClose={() => setShowQr(false)}
|
onClose={() => setShowQr(false)}
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
onToast={showToast}
|
|
||||||
scene={scene}
|
scene={scene}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -128,7 +94,6 @@ export default function TopNav({
|
|||||||
swipeCounts={swipeCounts}
|
swipeCounts={swipeCounts}
|
||||||
totalCards={totalCards}
|
totalCards={totalCards}
|
||||||
userProfiles={userProfiles}
|
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);
|
const found = AVATARS.find((a) => a.emoji === emoji);
|
||||||
return found?.bg ?? "bg-zinc-100";
|
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);
|
setTimeout(frame, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _audioCtx: AudioContext | null = null;
|
||||||
|
|
||||||
|
function getAudioContext(): AudioContext {
|
||||||
|
if (!_audioCtx || _audioCtx.state === "closed") {
|
||||||
|
_audioCtx = new AudioContext();
|
||||||
|
}
|
||||||
|
return _audioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
export function playChime() {
|
export function playChime() {
|
||||||
try {
|
try {
|
||||||
const ctx = new AudioContext();
|
const ctx = getAudioContext();
|
||||||
|
if (ctx.state === "suspended") ctx.resume();
|
||||||
|
|
||||||
const gain = ctx.createGain();
|
const gain = ctx.createGain();
|
||||||
gain.connect(ctx.destination);
|
gain.connect(ctx.destination);
|
||||||
gain.gain.setValueAtTime(0.15, ctx.currentTime);
|
gain.gain.setValueAtTime(0.15, ctx.currentTime);
|
||||||
@@ -88,8 +99,6 @@ export function playChime() {
|
|||||||
osc.start(start);
|
osc.start(start);
|
||||||
osc.stop(start + 0.6);
|
osc.stop(start + 0.6);
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => ctx.close(), 2000);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Audio not available — silent fallback
|
// 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;
|
key: SceneType;
|
||||||
label: string;
|
label: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
|
verb: string;
|
||||||
poiTypes: string;
|
poiTypes: string;
|
||||||
defaultImage: string;
|
defaultImage: string;
|
||||||
hotTags: readonly string[];
|
hotTags: readonly string[];
|
||||||
@@ -22,56 +23,83 @@ export interface SceneConfig {
|
|||||||
const SCENE_CONFIGS: Record<SceneType, SceneConfig> = {
|
const SCENE_CONFIGS: Record<SceneType, SceneConfig> = {
|
||||||
eat: {
|
eat: {
|
||||||
key: "eat",
|
key: "eat",
|
||||||
label: "吃什么",
|
label: "餐厅",
|
||||||
emoji: "🍜",
|
emoji: "🍜",
|
||||||
|
verb: "吃",
|
||||||
poiTypes: "050000",
|
poiTypes: "050000",
|
||||||
defaultImage:
|
defaultImage:
|
||||||
"https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800&q=80",
|
"https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800&q=80",
|
||||||
hotTags: ["火锅", "日料", "烧烤", "西餐", "川菜", "咖啡甜品", "小吃快餐"],
|
hotTags: ["火锅", "日料", "烧烤", "西餐", "川菜", "粤菜", "小吃快餐"],
|
||||||
priceOptions: [
|
priceOptions: [
|
||||||
{ label: "不限", value: "any" },
|
{ label: "不限", value: "any" },
|
||||||
{ label: "¥50以下", value: "under50" },
|
{ label: "¥50以下", value: "under50" },
|
||||||
{ label: "¥50-100", value: "50to100" },
|
{ label: "¥50-100", value: "50to100" },
|
||||||
{ label: "¥100+", value: "over100" },
|
{ label: "¥100+", value: "over100" },
|
||||||
],
|
],
|
||||||
tagLabel: "美食",
|
tagLabel: "口味",
|
||||||
tagPlaceholder: "想吃什么?(留空则不限)",
|
tagPlaceholder: "想吃什么?(留空则不限)",
|
||||||
loadingText: "正在搜索周边美食...",
|
loadingText: "正在搜索周边餐厅...",
|
||||||
emptyError: "附近没有找到餐厅,试试扩大搜索范围或换个菜系",
|
emptyError: "附近没有找到餐厅,试试扩大搜索范围或换个口味",
|
||||||
subtitle: "和朋友一起滑卡片,再也不用纠结吃什么",
|
subtitle: "和朋友一起滑卡片,再也不用纠结吃什么",
|
||||||
inviteText: "有人邀请你一起选餐厅",
|
inviteText: "有人邀请你一起选餐厅",
|
||||||
shareTitle: "别说随便啦,来滑卡片决定吃什么!",
|
shareTitle: "别说随便啦,来滑卡片决定吃什么!",
|
||||||
shareText: "我建好房间了,快点开链接一起选餐厅,滑中同一家就去吃!",
|
shareText: "我建好房间了,快点开链接一起选餐厅,滑中同一家就去吃!",
|
||||||
qrSubtitle: "让朋友扫码加入房间,一起滑卡片选餐厅",
|
qrSubtitle: "让朋友扫码加入房间,一起滑卡片选餐厅",
|
||||||
},
|
},
|
||||||
drink: {
|
drinks: {
|
||||||
key: "drink",
|
key: "drinks",
|
||||||
label: "喝什么",
|
label: "咖啡/奶茶",
|
||||||
emoji: "🧋",
|
emoji: "🧋",
|
||||||
|
verb: "喝",
|
||||||
poiTypes: "050301|050302|050303|050400",
|
poiTypes: "050301|050302|050303|050400",
|
||||||
defaultImage:
|
defaultImage:
|
||||||
"https://images.unsplash.com/photo-1556679343-c7306c1976bc?w=800&q=80",
|
"https://images.unsplash.com/photo-1556679343-c7306c1976bc?w=800&q=80",
|
||||||
hotTags: ["奶茶", "咖啡", "酒吧", "果茶", "甜品", "茶馆", "鸡尾酒"],
|
hotTags: ["咖啡", "奶茶", "拿铁", "美式", "水果茶", "冷萃", "芋泥"],
|
||||||
priceOptions: [
|
priceOptions: [
|
||||||
{ label: "不限", value: "any" },
|
{ label: "不限", value: "any" },
|
||||||
{ label: "¥20以下", value: "under20" },
|
{ label: "¥20以下", value: "under20" },
|
||||||
{ label: "¥20-50", value: "20to50" },
|
{ label: "¥20-50", value: "20to50" },
|
||||||
{ label: "¥50+", value: "over50" },
|
{ label: "¥50+", value: "over50" },
|
||||||
],
|
],
|
||||||
tagLabel: "饮品",
|
tagLabel: "类型",
|
||||||
tagPlaceholder: "想喝什么?(留空则不限)",
|
tagPlaceholder: "想喝什么?(留空则不限)",
|
||||||
loadingText: "正在搜索周边饮品店...",
|
loadingText: "正在搜索周边饮品店...",
|
||||||
emptyError: "附近没有找到饮品店,试试扩大搜索范围或换个类型",
|
emptyError: "附近没有找到饮品店,试试扩大搜索范围",
|
||||||
subtitle: "和朋友一起滑卡片,再也不用纠结喝什么",
|
subtitle: "和朋友一起滑卡片,再也不用纠结喝什么",
|
||||||
inviteText: "有人邀请你一起选饮品店",
|
inviteText: "有人邀请你一起选饮品店",
|
||||||
shareTitle: "别说随便啦,来滑卡片决定喝什么!",
|
shareTitle: "别说随便啦,来滑卡片决定喝什么!",
|
||||||
shareText: "我建好房间了,快点开链接一起选店,滑中同一家就去喝!",
|
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 {
|
export function getSceneConfig(scene: string): SceneConfig {
|
||||||
return SCENE_CONFIGS[scene];
|
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