diff --git a/.env.example b/.env.example index 0c8d122..02710b4 100644 --- a/.env.example +++ b/.env.example @@ -1,50 +1,56 @@ -# ========== NomadCNA 环境变量示例(勿提交真实密钥) ========== +# ========== NomadCNA 环境变量示例 ========== +# 复制为 .env.local 后按环境填写。 +# +# Windows 本地开发连线上服务:见 .env.local 已配好的模板,并运行: +# python scripts/dev-tunnel.py # MinIO SSH 隧道 +# pnpm dev # Next.js +# backend/.venv/Scripts/uvicorn backend.main:app --host 127.0.0.1 --port 8000 --reload +# 或一键:powershell -ExecutionPolicy Bypass -File scripts/dev-windows.ps1 + APP_ENV=development NEXT_PUBLIC_APP_ENV=development +ROOT_DOMAIN=nomadro.cn +NEXT_PUBLIC_ROOT_DOMAIN=nomadro.cn -# PocketBase(服务端) -POCKETBASE_URL=http://127.0.0.1:8090 -POCKETBASE_EMAIL=admin@example.com -POCKETBASE_PASSWORD=changeme -NEXT_PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090 -POCKETBASE_JOIN_COLLECTION=member_applications - -# 支付 -PAYMENT_API_URL=http://127.0.0.1:8000 -PAYMENT_PROVIDER=zpay -PAYMENT_DEFAULT_AMOUNT=80 -PAYMENT_DEFAULT_TYPE=meetup -PAYMENT_JOIN_AMOUNT=80 -PAYMENT_JOIN_TYPE=meetup -ZPAY_PID=your_zpay_pid -ZPAY_KEY=your_zpay_key -ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php -XORPAY_AID=your_xorpay_aid -XORPAY_SECRET=your_xorpay_secret - -# MinIO 存储 -MINIO_ENDPOINT=127.0.0.1 -MINIO_PORT=9000 -MINIO_USE_SSL=false -MINIO_BUCKET=nomadcna -MINIO_ACCESS_KEY=minioadmin -MINIO_SECRET_KEY=minioadmin -MINIO_REGION=china -MINIO_UPLOAD_PREFIX=uploads -NEXT_PUBLIC_MINIO_PUBLIC_URL=http://127.0.0.1:9000/nomadcna - -# 站点 +# 本地前端 NEXT_PUBLIC_SITE_URL=http://localhost:3001 -NEXT_PUBLIC_SITE_ID=nomadcna +NEXT_PUBLIC_SITE_ID=meetup NEXT_PUBLIC_API_BASE_URL= - -# 视频会议与群聊 -NEXT_PUBLIC_MIROTALK_URL=https://mirotalk.nomadro.cn -NEXT_PUBLIC_LOUNGE_URL=https://lounge.nomadro.cn - -# FastAPI 代理(本地开发默认 127.0.0.1:8000) FASTAPI_PROXY_TARGET=http://127.0.0.1:8000 FRONTEND_ORIGINS=http://localhost:3001,http://127.0.0.1:3001 -# 生产环境跨站 SSO(本地不设置) -# AUTH_COOKIE_DOMAIN=.nomadro.cn +# SSH 隧道(Windows 访问线上 MinIO S3 API) +DEV_SSH_HOST=82.157.112.245 +DEV_SSH_USER=ubuntu +DEV_SSH_PASSWORD=your_ssh_password +DEV_TUNNEL_MINIO_PORT=9100 +DEV_TUNNEL_POCKETBASE=0 + +# PocketBase(Windows 用公网域名;Linux 线上同机可用 http://127.0.0.1:8090) +POCKETBASE_URL=https://pocketbase.nomadro.cn +NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.nomadro.cn +POCKETBASE_EMAIL=admin@example.com +POCKETBASE_PASSWORD=changeme + +# MinIO(Windows:先开隧道,再连 127.0.0.1:9100;公开读 URL 用线上域名) +MINIO_ENDPOINT=127.0.0.1 +MINIO_PORT=9100 +MINIO_USE_SSL=false +MINIO_BUCKET=hackrobot +MINIO_PUBLIC_URL=https://minio.nomadro.cn/hackrobot +NEXT_PUBLIC_MINIO_PUBLIC_URL=https://minio.nomadro.cn/hackrobot +MINIO_UPLOAD_PREFIX=nomadcna +MINIO_ACCESS_KEY=your_minio_access_key +MINIO_SECRET_KEY=your_minio_secret_key +MINIO_REGION=china + +# 支付 +PAYMENT_PROVIDER=zpay +PAYMENT_DEFAULT_AMOUNT=100 +PAYMENT_JOIN_AMOUNT=100 +PAYMENT_API_URL=http://127.0.0.1:8000 +DEV_AUTO_PAY=true + +# 视频会议 / 群聊 +NEXT_PUBLIC_MIROTALK_URL=https://mirotalk.nomadro.cn +NEXT_PUBLIC_LOUNGE_URL=https://lounge.nomadro.cn diff --git a/.env.local b/.env.local index 26a9f62..459e423 100644 --- a/.env.local +++ b/.env.local @@ -1,21 +1,30 @@ -# Linux 生产环境 - nomadro.cn -APP_ENV=production -NEXT_PUBLIC_APP_ENV=production +# ========== Windows 本地开发 · 连线上 nomadro.cn 服务 ========== +APP_ENV=development +NEXT_PUBLIC_APP_ENV=development ROOT_DOMAIN=nomadro.cn NEXT_PUBLIC_ROOT_DOMAIN=nomadro.cn -NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.nomadro.cn -POCKETBASE_URL=http://127.0.0.1:8090 -PAYMENT_API_URL=https://api.nomadro.cn -PAYMENT_PROVIDER=zpay -PAYMENT_DEFAULT_AMOUNT=100 -PAYMENT_JOIN_AMOUNT=100 -NEXT_PUBLIC_SITE_ID=meetup -NEXT_PUBLIC_SITE_URL=https://nomadro.cn -NEXT_PUBLIC_API_BASE_URL=/api -NEXT_PUBLIC_MIROTALK_URL=https://mirotalk.nomadro.cn -NEXT_PUBLIC_LOUNGE_URL=https://lounge.nomadro.cn -# MinIO +# 本地前端 +NEXT_PUBLIC_SITE_URL=http://localhost:3001 +NEXT_PUBLIC_SITE_ID=meetup +NEXT_PUBLIC_API_BASE_URL= +FASTAPI_PROXY_TARGET=http://127.0.0.1:8000 +FRONTEND_ORIGINS=http://localhost:3001,http://127.0.0.1:3001 + +# SSH 隧道(scripts/dev-tunnel.py 用) +DEV_SSH_HOST=82.157.112.245 +DEV_SSH_USER=ubuntu +DEV_SSH_PASSWORD=Xiao4669805@ +DEV_TUNNEL_MINIO_PORT=9100 +DEV_TUNNEL_POCKETBASE=0 + +# PocketBase:服务端走公网域名(Windows 无法直连服务器 127.0.0.1:8090) +POCKETBASE_URL=https://pocketbase.nomadro.cn +NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.nomadro.cn +POCKETBASE_EMAIL=xiaoshuang.eric@gmail.com +POCKETBASE_PASSWORD=Xiao4669805@ + +# MinIO:上传走 SSH 隧道映射的本地 9100;公开读 URL 走线上域名 MINIO_ENDPOINT=127.0.0.1 MINIO_PORT=9100 MINIO_USE_SSL=false @@ -27,6 +36,13 @@ MINIO_ACCESS_KEY=nomadro MINIO_SECRET_KEY=Xiao4669805@ MINIO_REGION=china -# PocketBase 管理员(check-user、ensure-user、vip 需要) -POCKETBASE_EMAIL=xiaoshuang.eric@gmail.com -POCKETBASE_PASSWORD=Xiao4669805@ +# 支付 / 视频会议 / 群聊(线上服务) +PAYMENT_PROVIDER=zpay +PAYMENT_DEFAULT_AMOUNT=100 +PAYMENT_JOIN_AMOUNT=100 +PAYMENT_API_URL=http://127.0.0.1:8000 +NEXT_PUBLIC_MIROTALK_URL=https://mirotalk.nomadro.cn +NEXT_PUBLIC_LOUNGE_URL=https://lounge.nomadro.cn + +# 本地调试可自动完成支付(无需真实回调) +DEV_AUTO_PAY=true diff --git a/README.md b/README.md index 7dff968..1961556 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,10 @@ NomadCNA 的目标用户是正在中国或亚洲范围内长期旅居、远程 | --- | --- | | 城市探索 | 展示中国数字游民城市数据,包括生活成本、网络、温度、社群密度、安全、文化等指标。 | | 下一站决策 | 根据预算、网络、气候、标签偏好生成城市推荐。 | -| 线上/线下活动 | 支持公开、登录成员、VIP、主持人等权限层级;线上和混合活动可进入 MiroTalk 视频房。 | -| Web 群聊 | 使用 The Lounge + Ergo IRC Server 提供数字游民 Web 群组聊天,活动参与人可进入群聊。 | +| 线上/线下活动 | 支持公开、登录成员、VIP、主持人等权限层级;线上/混合活动进入 **统一活动间**(MiroTalk 视频 + The Lounge 群聊分屏),账户昵称自动带入视频房。 | +| Web 群聊 | The Lounge + Ergo IRC;每活动可配置 `loungeChannel`(如 `#nomadcna-20260608`),默认频道 `#nomadcna,#online-events`。 | +| 访谈视频 | `/videos` 博主访谈列表,`/videos/[slug]` 单条详情;首页「访谈视频」链到列表页。 | +| 社区匹配 | `/dating` 滑动匹配:意图 Tab、城市筛选、互赞、每日额度、高级筛选、撤销滑动。 | | 会员加入与支付 | 支持加入表单、支付订单、支付状态轮询、VIP/会员写入。 | | Digital 子站 | `/digital` 是内容专题站,包含数字游民指南、课程、电子书、资源、工作机会、个人资料等。 | | 媒体与内容 | 支持图片/视频上传、远程媒体代理缓存、内容投稿、博客、电子书、视频等内容页面。 | @@ -58,6 +60,8 @@ NomadCNA 的目标用户是正在中国或亚洲范围内长期旅居、远程 │ └── lib/ # API、支付、媒体、活动、PocketBase 等工具 ├── backend/ # FastAPI 后端 ├── config/ # 主站和 Digital 子站配置 +│ └── remote-services.ts # 线上服务地址、Windows 远程开发模式 +├── scripts/ # dev-tunnel.py、dev-windows.ps1 ├── i18n/ # 路由和国际化导航封装 ├── messages/ # zh/en 文案 ├── public/ # 静态资源 @@ -73,9 +77,13 @@ NomadCNA 的目标用户是正在中国或亚洲范围内长期旅居、远程 - `/zh`、`/en`:首页与城市探索 - `/zh/dashboard`:下一站决策仪表盘 -- `/zh/meetups`:线上/线下活动,含 MiroTalk 视频房和 Web 群聊入口 +- `/zh/meetups`:线上/线下活动列表与详情弹窗 - `/zh/meetups/host`:活动发布页 -- `/zh/dating`:社区交友/匹配 +- `/zh/meetups/[id]/live`:**统一活动间**(内嵌 MiroTalk + The Lounge,需权限) +- `/zh/videos`:博主访谈视频列表 +- `/zh/videos/[slug]`:单条访谈详情 +- `/zh/dating`:社区滑动匹配(意图、城市、互赞、额度) +- `/zh/join`:成员加入(城市、寻找意图、资料) - `/zh/discuss`:讨论 - `/zh/gigs`:任务赏金 - `/zh/tools`:工具箱 @@ -154,13 +162,17 @@ FastAPI 入口为 `backend/main.py`,配置读取在 `backend/settings.py`。 | `/cnomadcna/*_notify` | 支付网关回调 | | `/api/cities`、`/api/cities/{slug}` | 城市数据 | | `/api/content/*` | 首页内容、内容投稿、内容详情 | +| `/api/videos` | 访谈视频列表(`type=video` 的 content_items) | | `/api/notifications/*` | 通知、回执、偏好 | | `/api/recommendations/next-stop` | 下一站推荐 | -| `/api/meetups` | 活动列表与活动创建 | +| `/api/meetups` | 活动列表与创建 | +| `/api/meetups/{id}` | 单个活动详情 | +| `/api/meetups/{id}/session` | 活动间会话:权限校验、视频/群聊 URL、社区昵称 | +| `/api/matches/*` | 匹配候选、滑动、互赞、额度、撤销、我的资料 | | `/api/discussions` | 讨论列表与创建 | | `/api/gigs` | 任务赏金列表、创建、申请 | -| `/api/profiles`、`/api/matches/swipes` | 资料与匹配 | -| `/api/feedback` | 用户反馈 | +| `/api/profiles` | 资料列表 | +| `/api/feedback` | 用户反馈(支持 `?type=report` 预填) | | `/api/stats/*` | 成员地图、概览、城市排行 | | `/api/services`、`/api/services/leads` | 增值服务与线索 | | `/api/ai/assistant` | AI 助手占位接口 | @@ -170,31 +182,68 @@ FastAPI 入口为 `backend/main.py`,配置读取在 `backend/settings.py`。 - `backend/pb_client.py`:PocketBase HTTP 客户端 - `backend/payment.py`:ZPay / XorPay 支付适配 - `backend/minio_storage.py`:MinIO 上传 +- `backend/meetup_live.py`:活动间 URL 生成、权限判断(MiroTalk / Lounge) +- `backend/avatar_assets.py`:与前端同步的头像池(seed 用) - `backend/init_db.py`、`seed_data.py`、`enhanced_seed.py`:初始化与种子数据 -- `backend/settings.py`:环境变量读取 +- `backend/settings.py`:环境变量读取(含 `MIROTALK_URL`、`LOUNGE_URL`) -### 活动、视频与群聊源码 +### 活动、视频与群聊(深度融合) -活动相关逻辑主要在: +相关文件: -- `app/[locale]/meetups/page.tsx` -- `app/[locale]/meetups/host/page.tsx` -- `app/lib/meetups.ts` +| 文件 | 作用 | +| --- | --- | +| `app/[locale]/meetups/page.tsx` | 活动列表、详情弹窗、「进入活动间」入口 | +| `app/[locale]/meetups/[id]/live/page.tsx` | 统一活动间页面 | +| `app/components/meetups/EventLiveRoom.tsx` | 分屏 UI:视频 iframe + 群聊 iframe | +| `app/[locale]/meetups/host/page.tsx` | 发起活动 | +| `app/lib/meetups.ts` | MiroTalk/Lounge URL、房间 slug、`loungeChannel`、权限工具 | +| `backend/meetup_live.py` | 后端同名逻辑,供 `/api/meetups/{id}/session` 使用 | `app/lib/meetups.ts` 负责: -- MiroTalk 默认地址:`https://mirotalk.nomadro.cn` -- MiroTalk 房间名规范化 -- MiroTalk 入会链接生成 -- Lounge 默认地址:`https://lounge.nomadro.cn` -- Web 群聊服务标签:`The Lounge + Ergo IRC` +- MiroTalk 默认地址:`NEXT_PUBLIC_MIROTALK_URL`(生产 `https://mirotalk.nomadro.cn`) +- MiroTalk 房间名规范化、`buildMiroTalkJoinUrl(meetup, displayName)` +- Lounge 默认地址:`NEXT_PUBLIC_LOUNGE_URL`(生产 `https://lounge.nomadro.cn`) +- 活动专属 IRC 频道:`loungeChannel` 字段,或从 `mirotalkRoom` 推导 +- 群聊 deep link:`buildMeetupChatUrl(meetup)` → `lounge.nomadro.cn/#/chan-#频道` +- 活动间路径:`buildMeetupLivePath(id)` → `/meetups/{id}/live` -线上/混合活动详情页会展示: +**账户打通**:`GET /api/meetups/{id}/session` 读取 PocketBase 登录态,校验 `public/members/vip/hosts`,将社区昵称写入 MiroTalk `name=` 参数。 -- MiroTalk P2P 视频入口 -- 页面内 iframe 打开视频房 -- The Lounge Web 群聊入口 -- 信令/中继服务器和群聊入口域名 +PocketBase `meetups` 集合字段(线上活动相关): + +- `mode`:`offline` / `online` / `hybrid` +- `accessLevel`:`public` / `members` / `vip` / `hosts` +- `meetingProvider`:如 `mirotalk` +- `mirotalkRoom`:视频房间 slug +- `loungeChannel`:IRC 频道,如 `#nomadcna-20260608` +- `features`:JSON,如 `group_video`、`screen_share` + +### 访谈视频 + +- 列表:`app/[locale]/videos/page.tsx`,数据来自 `GET /api/videos` +- 详情:`app/[locale]/videos/[slug]/page.tsx` +- 首页右侧「访谈视频」卡片链到 `/videos`(非单条 slug) + +### 社区匹配(Dating) + +- 页面:`app/[locale]/dating/page.tsx` +- 组件:`app/[locale]/dating/components/`(`IntentTabs`、`CityFilter`、`SwipeStack`、`LikesPanel`、`MatchModal`、`AdvancedFilters`、`SwipeQuota` 等) +- 工具:`app/[locale]/dating/utils.ts`、`constants.ts`、`types.ts` +- 头像池:`app/data/avatars.ts`(Unsplash 人像,与 `backend/avatar_assets.py` 同步) + +匹配 API(均需登录,部分需 VIP): + +- `GET /api/matches/candidates` — 候选列表(意图、城市、性别筛选) +- `POST /api/matches/swipes` — 左滑/右滑/超级赞 +- `POST /api/matches/swipes/undo` — 撤销上一次滑动 +- `GET /api/matches/likes`、`/mutual`、`/quota`、`/profile/me` + +### 媒体代理 + +- `app/lib/media.ts`:封面 URL 规范化(Unsplash 直连、MinIO 相对路径补全) +- `app/api/media/proxy/route.ts`:远程媒体抓取;MinIO 失败时直返图片 ### 配置与国际化 @@ -205,6 +254,7 @@ FastAPI 入口为 `backend/main.py`,配置读取在 `backend/settings.py`。 | `config/footer.config.ts` | 页脚 | | `config/services.config.ts` | 主站支付配置 | | `config/runtime-env.ts` | 运行环境判断 | +| `config/remote-services.ts` | 线上域名、MinIO/PocketBase 解析、远程开发模式 | | `config/digital/*` | Digital 子站主题、服务、课程、域名配置 | | `messages/zh.json`、`messages/en.json` | 中英文文案 | | `i18n/routing.ts` | 语言列表与默认语言 | @@ -226,6 +276,41 @@ FastAPI 入口为 `backend/main.py`,配置读取在 `backend/settings.py`。 pnpm install ``` +### Windows 本地连线上服务 + +典型配置见 `.env.example` / `.env.local`: + +1. PocketBase 用公网 `https://pocketbase.nomadro.cn` +2. MinIO S3 API 用 SSH 隧道映射到本机 `9100`: + +```powershell +python scripts/dev-tunnel.py +``` + +3. 本地跑 Next.js + FastAPI: + +```powershell +pnpm dev +# 另开终端 +backend\.venv\Scripts\uvicorn backend.main:app --host 127.0.0.1 --port 8000 --reload +``` + +或一键脚本: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts/dev-windows.ps1 +``` + +服务地址推导见 `config/remote-services.ts`。 + +### 初始化数据库 + +```bash +python -m backend.init_db +``` + +会创建/补全 PocketBase 集合字段,并 upsert 城市、活动、内容、资料等种子数据。 + ### 前端开发 ```bash @@ -275,6 +360,7 @@ APP_ENV=production NEXT_PUBLIC_APP_ENV=production NODE_ENV=production pnpm build | `NEXT_PUBLIC_SITE_URL` | 站点根地址 | | `NEXT_PUBLIC_SITE_ID` | 站点 ID,用于会员/VIP 隔离 | | `NEXT_PUBLIC_API_BASE_URL` | API 基础地址,生产通常为 `/api` | +| `FASTAPI_PROXY_TARGET` | Next.js rewrite 转发 FastAPI 的目标(本地 `http://127.0.0.1:8000`) | | `NEXT_PUBLIC_POCKETBASE_URL` | 浏览器可访问 PocketBase 地址 | | `NEXT_PUBLIC_MIROTALK_URL` | MiroTalk P2P 服务地址 | | `NEXT_PUBLIC_LOUNGE_URL` | The Lounge Web 群聊地址 | @@ -304,6 +390,8 @@ APP_ENV=production NEXT_PUBLIC_APP_ENV=production NODE_ENV=production pnpm build | `MINIO_UPLOAD_PREFIX` | 上传路径前缀 | | `FRONTEND_ORIGINS` | FastAPI CORS 白名单 | | `UPLOAD_DIR` | 后端本地上传目录 | +| `MIROTALK_URL` / `LOUNGE_URL` | 后端生成活动间 URL(默认同公网域名) | +| `DEV_SSH_HOST` / `DEV_SSH_PASSWORD` | Windows SSH 隧道(仅本地,勿提交) | ## 生产服务器支持 @@ -484,7 +572,7 @@ curl -I https://mirotalk.nomadro.cn/join?room=nomadcna-healthcheck - 只监听 `127.0.0.1:9000` - Nginx 通过 `https://lounge.nomadro.cn` 反代 - 默认网络锁定到本机 Ergo:`127.0.0.1:6667` -- 默认加入频道:`#nomadcna,#online-events` +- 默认加入频道:`#nomadcna,#online-events`(可按活动追加,如 `#nomadcna-20260608`) - 文件上传关闭 - The Lounge 自身消息持久化关闭,Ergo 保留内存历史 @@ -578,11 +666,17 @@ curl -I https://ergo.nomadro.cn/ ### 活动视频房如何生成? -`app/lib/meetups.ts` 会根据活动城市、日期、时间和 ID 生成 MiroTalk room slug。如果活动有自定义 `meetingUrl`,则优先使用自定义链接。 +`app/lib/meetups.ts` 会根据活动 `mirotalkRoom`、或城市/日期/时间/ID 生成 room slug。登录用户通过 `/api/meetups/{id}/session` 获取带 `name=` 的入会 URL。 + +### 如何进入统一活动间? + +活动详情弹窗点 **「进入活动间」** → `/meetups/{id}/live`。页面内分屏嵌入 MiroTalk 与 The Lounge;也可新窗口单独打开。 ### 群聊频道如何进入? -The Lounge public mode 的默认网络已锁定到 Ergo,并默认加入 `#nomadcna` 和 `#online-events`。活动详情页的“进入群聊”按钮指向 `https://lounge.nomadro.cn`。 +- 全局:`https://lounge.nomadro.cn`(默认 `#nomadcna`、`#online-events`) +- 单活动:`buildMeetupChatUrl(meetup)` → `lounge.nomadro.cn/#/chan-#频道` +- PocketBase 活动记录可设 `loungeChannel` 字段 ## 维护命令速查 diff --git a/app/[locale]/dating/components/AdvancedFilters.tsx b/app/[locale]/dating/components/AdvancedFilters.tsx new file mode 100644 index 0000000..277abb8 --- /dev/null +++ b/app/[locale]/dating/components/AdvancedFilters.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Link } from "@/i18n/navigation"; + +export interface AdvancedFilterState { + gender: string; + single: string; +} + +interface AdvancedFiltersProps { + value: AdvancedFilterState; + onChange: (next: AdvancedFilterState) => void; + isVip: boolean; + t: (key: string) => string; +} + +const GENDER_OPTIONS = ["all", "男", "女"]; +const SINGLE_OPTIONS = ["all", "单身", "恋爱中", "已婚", "不限"]; + +export default function AdvancedFilters({ value, onChange, isVip, t }: AdvancedFiltersProps) { + return ( +
+
+
+

{t("advancedFilters")}

+ {!isVip ? ( + + {t("upgradeVip")} + + ) : null} +
+
+ + +
+ {!isVip ? ( +

{t("vipFilterHint")}

+ ) : null} +
+
+ ); +} diff --git a/app/[locale]/dating/components/CityFilter.tsx b/app/[locale]/dating/components/CityFilter.tsx new file mode 100644 index 0000000..a55a814 --- /dev/null +++ b/app/[locale]/dating/components/CityFilter.tsx @@ -0,0 +1,32 @@ +"use client"; + +interface CityFilterProps { + value: string; + onChange: (city: string) => void; + cities: Array<{ slug: string; name: string; nameEn?: string }>; + locale: string; + t: (key: string) => string; +} + +export default function CityFilter({ value, onChange, cities, locale, t }: CityFilterProps) { + return ( +
+ + +
+ ); +} diff --git a/app/[locale]/dating/components/IntentTabs.tsx b/app/[locale]/dating/components/IntentTabs.tsx new file mode 100644 index 0000000..d7e1503 --- /dev/null +++ b/app/[locale]/dating/components/IntentTabs.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { TAB_ORDER } from "../constants"; +import type { MatchIntent } from "../types"; + +interface IntentTabsProps { + activeTab: MatchIntent; + onChange: (tab: MatchIntent) => void; + t: (key: string) => string; +} + +export default function IntentTabs({ activeTab, onChange, t }: IntentTabsProps) { + return ( +
+
+ {TAB_ORDER.map((tab) => ( + + ))} +
+
+ ); +} diff --git a/app/[locale]/dating/components/LikesPanel.tsx b/app/[locale]/dating/components/LikesPanel.tsx new file mode 100644 index 0000000..87580ce --- /dev/null +++ b/app/[locale]/dating/components/LikesPanel.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Link } from "@/i18n/navigation"; +import { mediaUrl } from "@/app/lib/media"; +import type { DatingProfile } from "../types"; + +interface LikesPanelProps { + likes: DatingProfile[]; + mutual: DatingProfile[]; + formatTime: (value?: string) => string; + t: (key: string) => string; + onSelectProfile?: (profile: DatingProfile) => void; +} + +function ProfileRow({ + profile, + timeLabel, + badge, + onSelect, +}: { + profile: DatingProfile; + timeLabel: string; + badge?: string; + onSelect?: () => void; +}) { + return ( + + ); +} + +export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfile }: LikesPanelProps) { + return ( +
+
+
+
+

{t("title")} 💕

+

{t("likedYou")}

+
+ {likes.length > 0 ? ( + {likes.length} + ) : null} +
+
+ {likes.length ? ( + likes.map((profile) => ( + onSelectProfile?.(profile)} + /> + )) + ) : ( +
+ {t("noLikesYet")} +
+ )} +
+
+ +
+
+
+

{t("mutualTitle")}

+

{t("mutualSubtitle")}

+
+ {mutual.length > 0 ? ( + {mutual.length} + ) : null} +
+
+ {mutual.length ? ( + mutual.map((profile) => ( + onSelectProfile?.(profile)} + /> + )) + ) : ( +
+ {t("noMutualYet")} +
+ )} +
+ {mutual.length > 0 ? ( + + {t("goMessages")} + + ) : null} +
+
+ ); +} diff --git a/app/[locale]/dating/components/MatchModal.tsx b/app/[locale]/dating/components/MatchModal.tsx new file mode 100644 index 0000000..868c3c2 --- /dev/null +++ b/app/[locale]/dating/components/MatchModal.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Link } from "@/i18n/navigation"; +import { mediaUrl } from "@/app/lib/media"; +import type { DatingProfile } from "../types"; + +interface MatchModalProps { + profile: DatingProfile | null; + onClose: () => void; + t: (key: string) => string; +} + +export default function MatchModal({ profile, onClose, t }: MatchModalProps) { + if (!profile) return null; + + return ( +
+
+
+

{t("matchCelebration")}

+

{t("itsAMatch")}

+

{t("matchHint")}

+
+
+ {profile.name} +
+
+

{profile.name}

+

📍 {profile.location}

+
+ + {t("sendMessage")} + + +
+
+
+
+ ); +} diff --git a/app/[locale]/dating/components/SwipeCard.tsx b/app/[locale]/dating/components/SwipeCard.tsx new file mode 100644 index 0000000..66c7fea --- /dev/null +++ b/app/[locale]/dating/components/SwipeCard.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { Link } from "@/i18n/navigation"; +import { mediaUrl } from "@/app/lib/media"; +import type { DatingProfile } from "../types"; + +interface SwipeCardProps { + profile: DatingProfile; + badgeText: string; + dragX: number; + isDragging: boolean; + isAnimatingOut: "left" | "right" | null; + onLike: () => void; + onDislike: () => void; + onBeginDrag: (clientX: number) => void; + onMoveDrag: (clientX: number) => void; + onEndDrag: () => void; + t: (key: string) => string; +} + +export default function SwipeCard({ + profile, + badgeText, + dragX, + isDragging, + isAnimatingOut, + onLike, + onDislike, + onBeginDrag, + onMoveDrag, + onEndDrag, + t, +}: SwipeCardProps) { + return ( +
{ + if (e.key === "ArrowRight") onLike(); + if (e.key === "ArrowLeft") onDislike(); + }} + onPointerDown={(e) => { + e.currentTarget.setPointerCapture(e.pointerId); + onBeginDrag(e.clientX); + }} + onPointerMove={(e) => onMoveDrag(e.clientX)} + onPointerUp={onEndDrag} + onPointerCancel={onEndDrag} + > +
45 ? "opacity-100" : "opacity-0" + }`} + > + {t("likeOverlay")} +
+
+ {t("passOverlay")} +
+ +
+ + {badgeText} + + + {t("report")} + +
+ +
+ {profile.name} +
+ +
+

+ {profile.name},{profile.age} +

+

📍 {profile.location}

+ {(profile.gender || profile.single) && ( +

+ {[profile.gender, profile.single].filter(Boolean).join(" · ")} +

+ )} +
+ +
+

{t("matchTags")}:

+
+ {profile.tags.map((tag) => ( + + {tag} + + ))} +
+
+ +

{profile.bio}

+ +
+
+ + {t("dislike")} +
+
+ + {t("like")} +
+
+
+ ); +} diff --git a/app/[locale]/dating/components/SwipeQuota.tsx b/app/[locale]/dating/components/SwipeQuota.tsx new file mode 100644 index 0000000..61fae21 --- /dev/null +++ b/app/[locale]/dating/components/SwipeQuota.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Link } from "@/i18n/navigation"; + +interface SwipeQuotaProps { + vip: boolean; + used: number; + limit: number; + remaining: number; + t: (key: string) => string; +} + +export default function SwipeQuota({ vip, used, limit, remaining, t }: SwipeQuotaProps) { + if (vip) { + return ( +
+
+ {t("vipUnlimited")} +
+
+ ); + } + + const pct = Math.min(100, Math.round((used / Math.max(limit, 1)) * 100)); + + return ( +
+
+
+ {t("dailyQuota")} + + {remaining} / {limit} + +
+
+
+
+ {remaining <= 3 ? ( +

+ {t("quotaLow")}{" "} + + {t("upgradeVip")} + +

+ ) : null} +
+
+ ); +} diff --git a/app/[locale]/dating/components/SwipeStack.tsx b/app/[locale]/dating/components/SwipeStack.tsx new file mode 100644 index 0000000..402bc41 --- /dev/null +++ b/app/[locale]/dating/components/SwipeStack.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { mediaUrl } from "@/app/lib/media"; +import type { DatingProfile } from "../types"; +import SwipeCard from "./SwipeCard"; + +interface SwipeStackProps { + current: DatingProfile; + next?: DatingProfile; + badgeText: string; + dragX: number; + isDragging: boolean; + isAnimatingOut: "left" | "right" | null; + onLike: () => void; + onDislike: () => void; + onBeginDrag: (clientX: number) => void; + onMoveDrag: (clientX: number) => void; + onEndDrag: () => void; + t: (key: string) => string; +} + +export default function SwipeStack({ + current, + next, + badgeText, + dragX, + isDragging, + isAnimatingOut, + onLike, + onDislike, + onBeginDrag, + onMoveDrag, + onEndDrag, + t, +}: SwipeStackProps) { + return ( +
+ {next ? ( +
+
+ +
+
{next.name}
+
+ ) : null} +
+ +
+
+ ); +} diff --git a/app/[locale]/dating/constants.ts b/app/[locale]/dating/constants.ts new file mode 100644 index 0000000..8442d4c --- /dev/null +++ b/app/[locale]/dating/constants.ts @@ -0,0 +1,128 @@ +import { avatarForIndex } from "@/app/data/avatars"; +import type { DatingProfile, MatchIntent } from "./types"; + +export const TAB_ORDER: MatchIntent[] = [ + "friends", + "dating", + "partner", + "roommate", + "cofounder", + "explore", +]; + +export const PROFILE_GRADIENTS = [ + "from-rose-400 via-pink-400 to-fuchsia-400", + "from-amber-400 via-orange-400 to-red-400", + "from-emerald-400 via-teal-400 to-cyan-400", + "from-violet-400 via-purple-400 to-indigo-400", + "from-blue-400 via-indigo-400 to-violet-500", + "from-sky-400 via-cyan-400 to-teal-400", +]; + +export const FALLBACK_PROFILES: DatingProfile[] = [ + { + id: "fb-1", + name: "林晓雨", + age: 28, + location: "大理", + citySlug: "dali", + gender: "女", + single: "单身", + lookingFor: ["friends", "dating", "explore"], + tags: ["本科学历", "远程工作者", "数字游民", "咖啡爱好者"], + bio: "数字游民三年,目前在云南大理远程做产品设计。喜欢爬山、摄影,周末常去洱海边发呆。", + gradient: PROFILE_GRADIENTS[0], + initial: "林", + avatarColor: "bg-rose-500", + photo: avatarForIndex(0), + }, + { + id: "fb-2", + name: "陈浩然", + age: 32, + location: "成都", + citySlug: "chengdu", + gender: "男", + single: "单身", + lookingFor: ["friends", "partner", "cofounder"], + tags: ["硕士学历", "独立开发者", "自由职业者", "素食主义"], + bio: "在成都住了两年,做独立开发。喜欢冲浪和冥想,寻找志同道合的旅伴一起探索世界。", + gradient: PROFILE_GRADIENTS[1], + initial: "陈", + avatarColor: "bg-amber-500", + photo: avatarForIndex(4), + }, + { + id: "fb-3", + name: "王思琪", + age: 26, + location: "深圳", + citySlug: "shenzhen", + gender: "女", + single: "单身", + lookingFor: ["dating", "roommate", "explore"], + tags: ["本科学历", "内容创作者", "数字游民", "环保主义者"], + bio: "自由撰稿人,在深圳写写画画。热爱瑜伽和冥想,相信慢生活才是真正的奢侈。", + gradient: PROFILE_GRADIENTS[2], + initial: "王", + avatarColor: "bg-emerald-500", + photo: avatarForIndex(2), + }, + { + id: "fb-4", + name: "张明远", + age: 30, + location: "杭州", + citySlug: "hangzhou", + gender: "男", + single: "单身", + lookingFor: ["cofounder", "friends"], + tags: ["MBA学历", "创业中", "自由职业者", "徒步爱好者"], + bio: "正在杭州创业,做远程团队协作工具。周末喜欢去西湖徒步,偶尔品鉴龙井茶。", + gradient: PROFILE_GRADIENTS[3], + initial: "张", + avatarColor: "bg-violet-500", + photo: avatarForIndex(28), + }, + { + id: "fb-5", + name: "李雅婷", + age: 27, + location: "厦门", + citySlug: "xiamen", + gender: "女", + single: "单身", + lookingFor: ["explore", "friends"], + tags: ["本科学历", "UI设计师", "数字游民", "摄影爱好者"], + bio: "在厦门远程做UI设计,业余时间探索闽南美食。喜欢用相机记录生活,寻找一起探店的朋友。", + gradient: PROFILE_GRADIENTS[4], + initial: "李", + avatarColor: "bg-rose-500", + photo: avatarForIndex(12), + }, + { + id: "fb-6", + name: "刘子轩", + age: 33, + location: "昆明", + citySlug: "kunming", + gender: "男", + single: "单身", + lookingFor: ["roommate", "friends"], + tags: ["博士学历", "数据科学家", "远程工作者", "户外爱好者"], + bio: "在昆明远程工作,热爱户外运动。正在探索云南各地,希望认识更多志同道合的朋友。", + gradient: PROFILE_GRADIENTS[5], + initial: "刘", + avatarColor: "bg-blue-500", + photo: avatarForIndex(10), + }, +]; + +export const BADGE_KEYS: Record = { + friends: "badgeFriends", + dating: "badgeDating", + partner: "badgePartner", + roommate: "badgeRoommate", + cofounder: "badgeCofounder", + explore: "badgeExplore", +}; diff --git a/app/[locale]/dating/page.tsx b/app/[locale]/dating/page.tsx index aea1b1f..13712f0 100644 --- a/app/[locale]/dating/page.tsx +++ b/app/[locale]/dating/page.tsx @@ -1,161 +1,238 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { Link, useTranslation } from "@/i18n/navigation"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link, useLocale, useTranslation } from "@/i18n/navigation"; +import { useSearchParams } from "next/navigation"; import Footer from "@/app/components/Footer"; -import { apiFetch } from "@/app/lib/api-client"; -import { mediaUrl } from "@/app/lib/media"; -import { avatarForIndex, avatarForName } from "@/app/data/avatars"; +import { cities } from "@/app/data/cities"; +import { fetchAuthMe } from "@/app/lib/api-client"; +import IntentTabs from "./components/IntentTabs"; +import CityFilter from "./components/CityFilter"; +import LikesPanel from "./components/LikesPanel"; +import SwipeStack from "./components/SwipeStack"; +import MatchModal from "./components/MatchModal"; +import AdvancedFilters from "./components/AdvancedFilters"; +import SwipeQuota from "./components/SwipeQuota"; +import { BADGE_KEYS } from "./constants"; +import type { DatingProfile, MatchIntent } from "./types"; +import type { MatchQuota } from "./utils"; +import { + fetchCandidates, + fetchLikes, + fetchMutualMatches, + fetchQuota, + filterFallbackProfiles, + formatTimeAgo, + saveSwipe, + undoSwipe, +} from "./utils"; -type TabType = "friends" | "dating" | "partner" | "roommate" | "cofounder" | "explore"; - -interface Profile { - id: string; - name: string; - age: number; - location: string; - tags: string[]; - bio: string; - gradient: string; - initial: string; - avatarColor: string; - photo: string; +function DatingSkeleton() { + return ( +
+
+
+
+
+ ); } -interface MatchCard { - id: string; - name: string; - location: string; - timeAgo: string; - initial: string; - color: string; - photo: string; -} - -interface ProfileRecord { - id: string; - name?: string; - age?: number; - location?: string; - tags?: string[]; - bio?: string; - photo?: string; -} - -const FALLBACK_PROFILES: Profile[] = [ - { id: "1", name: "林晓雨", age: 28, location: "大理", tags: ["本科学历", "远程工作者", "数字游民", "咖啡爱好者"], bio: "数字游民三年,目前在云南大理远程做产品设计。喜欢爬山、摄影,周末常去洱海边发呆。", gradient: "from-rose-400 via-pink-400 to-fuchsia-400", initial: "林", avatarColor: "bg-rose-500", photo: avatarForIndex(0) }, - { id: "2", name: "陈浩然", age: 32, location: "成都", tags: ["硕士学历", "独立开发者", "自由职业者", "素食主义"], bio: "在成都住了两年,做独立开发。喜欢冲浪和冥想,寻找志同道合的旅伴一起探索世界。", gradient: "from-amber-400 via-orange-400 to-red-400", initial: "陈", avatarColor: "bg-amber-500", photo: avatarForIndex(4) }, - { id: "3", name: "王思琪", age: 26, location: "深圳", tags: ["本科学历", "内容创作者", "数字游民", "环保主义者"], bio: "自由撰稿人,在深圳写写画画。热爱瑜伽和冥想,相信慢生活才是真正的奢侈。", gradient: "from-emerald-400 via-teal-400 to-cyan-400", initial: "王", avatarColor: "bg-emerald-500", photo: avatarForIndex(2) }, - { id: "4", name: "张明远", age: 30, location: "杭州", tags: ["MBA学历", "创业中", "自由职业者", "徒步爱好者"], bio: "正在杭州创业,做远程团队协作工具。周末喜欢去西湖徒步,偶尔品鉴龙井茶。", gradient: "from-violet-400 via-purple-400 to-indigo-400", initial: "张", avatarColor: "bg-violet-500", photo: avatarForIndex(28) }, - { id: "5", name: "李雅婷", age: 27, location: "厦门", tags: ["本科学历", "UI设计师", "数字游民", "摄影爱好者"], bio: "在厦门远程做UI设计,业余时间探索闽南美食。喜欢用相机记录生活,寻找一起探店的朋友。", gradient: "from-rose-500 via-red-400 to-orange-500", initial: "李", avatarColor: "bg-rose-500", photo: avatarForIndex(12) }, - { id: "6", name: "刘子轩", age: 33, location: "昆明", tags: ["博士学历", "数据科学家", "远程工作者", "户外爱好者"], bio: "在昆明远程工作,热爱户外运动。正在探索云南各地,希望认识更多志同道合的朋友。", gradient: "from-blue-400 via-indigo-400 to-violet-500", initial: "刘", avatarColor: "bg-blue-500", photo: avatarForIndex(10) }, -]; - -const FALLBACK_MATCHES: MatchCard[] = [ - { id: "m1", name: "周雨桐", location: "杭州", timeAgo: "2天前", initial: "周", color: "bg-pink-400", photo: avatarForIndex(6) }, - { id: "m2", name: "吴俊杰", location: "成都", timeAgo: "1周前", initial: "吴", color: "bg-amber-400", photo: avatarForIndex(30) }, - { id: "m3", name: "郑诗涵", location: "上海", timeAgo: "3周前", initial: "郑", color: "bg-emerald-400", photo: avatarForIndex(15) }, - { id: "m4", name: "孙宇航", location: "深圳", timeAgo: "1月前", initial: "孙", color: "bg-violet-400", photo: avatarForIndex(18) }, - { id: "m5", name: "赵梦琪", location: "厦门", timeAgo: "1年前", initial: "赵", color: "bg-rose-400", photo: avatarForIndex(24) }, -]; - -const TAB_ORDER: TabType[] = ["friends", "dating", "partner", "roommate", "cofounder", "explore"]; - -const PROFILE_GRADIENTS = [ - "from-rose-400 via-pink-400 to-fuchsia-400", - "from-amber-400 via-orange-400 to-red-400", - "from-emerald-400 via-teal-400 to-cyan-400", - "from-violet-400 via-purple-400 to-indigo-400", - "from-blue-400 via-indigo-400 to-violet-500", -]; - -function mapProfile(record: ProfileRecord, index: number): Profile { - const name = record.name || "数字游民"; - const initial = name.slice(0, 1); - return { - id: record.id, - name, - age: Number(record.age || 28 + index), - location: record.location || "远程", - tags: Array.isArray(record.tags) ? record.tags : ["数字游民", "远程工作者"], - bio: record.bio || "正在完善个人资料,期待结识同城和同路的远程工作伙伴。", - gradient: PROFILE_GRADIENTS[index % PROFILE_GRADIENTS.length], - initial, - avatarColor: "bg-[#ff4d4f]", - photo: record.photo || avatarForName(name), - }; -} - -function toMatch(profile: Profile, index: number): MatchCard { - return { - id: `m-${profile.id}`, - name: profile.name, - location: profile.location, - timeAgo: index < 2 ? "刚刚活跃" : "本周活跃", - initial: profile.initial, - color: profile.avatarColor, - photo: profile.photo, - }; -} - -export default function DatingPage() { +function DatingPageInner() { const { t } = useTranslation("match"); - const [activeTab, setActiveTab] = useState("friends"); + const locale = useLocale(); + const searchParams = useSearchParams(); + const initialIntent = (searchParams.get("intent") as MatchIntent) || "friends"; + const initialCity = searchParams.get("city") || "all"; + + const [activeTab, setActiveTab] = useState( + ["friends", "dating", "partner", "roommate", "cofounder", "explore"].includes(initialIntent) + ? initialIntent + : "friends" + ); + const [cityFilter, setCityFilter] = useState(initialCity); + const [profiles, setProfiles] = useState([]); + const [likes, setLikes] = useState([]); + const [mutual, setMutual] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); - const [profiles, setProfiles] = useState(FALLBACK_PROFILES); + const [loading, setLoading] = useState(true); + const [usingFallback, setUsingFallback] = useState(false); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [isVip, setIsVip] = useState(false); + const [quota, setQuota] = useState({ vip: false, limit: 25, used: 0, remaining: 25 }); + const [advancedFilters, setAdvancedFilters] = useState({ gender: "all", single: "all" }); + const [showLoginPrompt, setShowLoginPrompt] = useState(false); + const [showQuotaPrompt, setShowQuotaPrompt] = useState(false); + const [canUndo, setCanUndo] = useState(false); + const [matchCelebration, setMatchCelebration] = useState(null); + const [mobilePanelOpen, setMobilePanelOpen] = useState(false); const [dragX, setDragX] = useState(0); const [isDragging, setIsDragging] = useState(false); const [isAnimatingOut, setIsAnimatingOut] = useState<"left" | "right" | null>(null); const startXRef = useRef(0); + const swipeLockRef = useRef(false); + + const cityOptions = useMemo( + () => + cities.map((city) => ({ + slug: city.slug || city.name, + name: city.name, + nameEn: city.nameEn, + })), + [] + ); + + const refreshSidePanels = useCallback(async () => { + if (!isLoggedIn) { + setLikes([]); + setMutual([]); + return; + } + try { + const [likesData, mutualData] = await Promise.all([fetchLikes(), fetchMutualMatches()]); + setLikes(likesData); + setMutual(mutualData); + } catch { + setLikes([]); + setMutual([]); + } + }, [isLoggedIn]); + + const refreshQuota = useCallback(async () => { + if (!isLoggedIn) return; + try { + const data = await fetchQuota(); + setQuota(data); + setIsVip(data.vip); + } catch { + // ignore + } + }, [isLoggedIn]); + + const loadCandidates = useCallback( + async (intent: MatchIntent, city: string, advanced = advancedFilters) => { + setLoading(true); + setCurrentIndex(0); + setCanUndo(false); + try { + const items = await fetchCandidates(intent, city, advanced); + if (items.length) { + setProfiles(items); + setUsingFallback(false); + } else { + const fallback = filterFallbackProfiles(intent, city, advanced); + setProfiles(fallback); + setUsingFallback(true); + } + } catch { + const fallback = filterFallbackProfiles(intent, city, advanced); + setProfiles(fallback); + setUsingFallback(true); + } finally { + setLoading(false); + } + }, + [advancedFilters] + ); useEffect(() => { - apiFetch<{ items: ProfileRecord[] }>("/api/profiles") - .then((data) => { - if (data.items?.length) { - setProfiles(data.items.map(mapProfile)); - setCurrentIndex(0); - } - }) - .catch(() => setProfiles(FALLBACK_PROFILES)); + fetchAuthMe().then((data) => setIsLoggedIn(Boolean(data?.user?.id))); }, []); - const matches = useMemo( - () => (profiles.length ? profiles.slice(0, 5).map(toMatch) : FALLBACK_MATCHES), - [profiles] - ); - const currentProfile = profiles[currentIndex] || profiles[0] || FALLBACK_PROFILES[0]; + useEffect(() => { + if (isLoggedIn) refreshQuota(); + }, [isLoggedIn, refreshQuota]); + + useEffect(() => { + loadCandidates(activeTab, cityFilter, advancedFilters); + }, [activeTab, cityFilter, advancedFilters, loadCandidates]); + + useEffect(() => { + refreshSidePanels(); + }, [refreshSidePanels]); + + useEffect(() => { + const params = new URLSearchParams(); + params.set("intent", activeTab); + if (cityFilter && cityFilter !== "all") params.set("city", cityFilter); + const next = `/dating?${params.toString()}`; + window.history.replaceState(null, "", next); + }, [activeTab, cityFilter]); + + const currentProfile = profiles[currentIndex]; + const nextProfile = profiles[currentIndex + 1]; const hasMoreProfiles = currentIndex < profiles.length - 1; + const badgeText = t(BADGE_KEYS[activeTab]); + const formatTime = (value?: string) => formatTimeAgo(value, locale); - const saveSwipe = (action: "like" | "dislike") => { - if (!currentProfile) return; - apiFetch("/api/matches/swipes", { - method: "POST", - body: JSON.stringify({ profileId: currentProfile.id, action }), - }).catch(() => {}); - }; - - const nextProfile = () => { + const advanceProfile = () => { window.setTimeout(() => { - setCurrentIndex((i) => (hasMoreProfiles ? i + 1 : 0)); + setCurrentIndex((index) => (hasMoreProfiles ? index + 1 : index)); setDragX(0); setIsAnimatingOut(null); - }, 160); + swipeLockRef.current = false; + }, 180); }; - const handleLike = () => { - saveSwipe("like"); - setIsAnimatingOut("right"); - setDragX(260); - nextProfile(); + const performSwipe = async (action: "like" | "dislike") => { + if (!currentProfile || swipeLockRef.current) return; + if (!isLoggedIn) { + setShowLoginPrompt(true); + setDragX(0); + return; + } + if (!quota.vip && quota.remaining <= 0) { + setShowQuotaPrompt(true); + setDragX(0); + return; + } + swipeLockRef.current = true; + setIsAnimatingOut(action === "like" ? "right" : "left"); + setDragX(action === "like" ? 260 : -260); + try { + if (!usingFallback) { + const result = await saveSwipe(currentProfile.id, action, activeTab); + setCanUndo(true); + if (result.matched) { + setMatchCelebration(currentProfile); + refreshSidePanels(); + } else if (action === "like") { + refreshSidePanels(); + } + refreshQuota(); + } else { + setCanUndo(true); + } + } catch (err) { + const message = err instanceof Error ? err.message : ""; + if (message.includes("滑动次数") || message.includes("升级会员")) { + setShowQuotaPrompt(true); + } else { + setShowLoginPrompt(true); + } + swipeLockRef.current = false; + setIsAnimatingOut(null); + setDragX(0); + return; + } finally { + advanceProfile(); + } }; - const handleDislike = () => { - saveSwipe("dislike"); - setIsAnimatingOut("left"); - setDragX(-260); - nextProfile(); + const handleUndo = async () => { + if (!canUndo || currentIndex <= 0) return; + try { + if (!usingFallback) await undoSwipe(); + setCurrentIndex((index) => Math.max(0, index - 1)); + setCanUndo(false); + refreshQuota(); + } catch { + // ignore + } }; + const handleLike = () => performSwipe("like"); + const handleDislike = () => performSwipe("dislike"); + const beginDrag = (clientX: number) => { startXRef.current = clientX; setIsDragging(true); @@ -180,130 +257,226 @@ export default function DatingPage() { setDragX(0); }; - const badgeMap: Record = { - friends: t("badgeFriends"), - dating: t("badgeDating"), - partner: t("badgePartner"), - roommate: t("badgeRoommate"), - cofounder: t("badgeCofounder"), - explore: t("badgeExplore"), + const jumpToProfile = (profile: DatingProfile) => { + const index = profiles.findIndex((item) => item.id === profile.id); + if (index >= 0) setCurrentIndex(index); + setMobilePanelOpen(false); }; - const badgeText = badgeMap[activeTab]; return (
-
-
- {TAB_ORDER.map((tab) => ( +
+
+
+

{t("heroTitle")}

+

{t("heroSubtitle")}

+
+
- ))} + + {t("editProfile")} + +
-
-
); } + +export default function DatingPage() { + return ( + }> + + + ); +} diff --git a/app/[locale]/dating/types.ts b/app/[locale]/dating/types.ts new file mode 100644 index 0000000..f5cd40d --- /dev/null +++ b/app/[locale]/dating/types.ts @@ -0,0 +1,45 @@ +export type MatchIntent = "friends" | "dating" | "partner" | "roommate" | "cofounder" | "explore"; + +export interface ProfileRecord { + id: string; + name?: string; + age?: number; + location?: string; + citySlug?: string; + gender?: string; + single?: string; + tags?: string[]; + bio?: string; + photo?: string; + lookingFor?: MatchIntent[]; + likedAt?: string; + matchedAt?: string; + intent?: MatchIntent; + connectionId?: string; +} + +export interface DatingProfile { + id: string; + name: string; + age: number; + location: string; + citySlug: string; + gender: string; + single: string; + tags: string[]; + bio: string; + lookingFor: MatchIntent[]; + gradient: string; + initial: string; + avatarColor: string; + photo: string; + likedAt?: string; + matchedAt?: string; + intent?: MatchIntent; +} + +export interface SwipeResult { + ok: boolean; + matched: boolean; + match?: Record | null; +} diff --git a/app/[locale]/dating/utils.ts b/app/[locale]/dating/utils.ts new file mode 100644 index 0000000..63e67c6 --- /dev/null +++ b/app/[locale]/dating/utils.ts @@ -0,0 +1,131 @@ +import { apiFetch } from "@/app/lib/api-client"; +import { avatarForName } from "@/app/data/avatars"; +import { FALLBACK_PROFILES, PROFILE_GRADIENTS } from "./constants"; +import type { DatingProfile, MatchIntent, ProfileRecord, SwipeResult } from "./types"; + +export function mapProfile(record: ProfileRecord, index: number): DatingProfile { + const name = record.name || "数字游民"; + const initial = name.slice(0, 1); + return { + id: record.id, + name, + age: Number(record.age || 26 + (index % 8)), + location: record.location || "远程", + citySlug: record.citySlug || "", + gender: record.gender || "", + single: record.single || "", + tags: Array.isArray(record.tags) ? record.tags : ["数字游民", "远程工作者"], + bio: record.bio || "正在完善个人资料,期待结识同城和同路的远程工作伙伴。", + lookingFor: Array.isArray(record.lookingFor) ? record.lookingFor : ["friends", "explore"], + gradient: PROFILE_GRADIENTS[index % PROFILE_GRADIENTS.length], + initial, + avatarColor: "bg-[#ff4d4f]", + photo: record.photo || avatarForName(name), + likedAt: record.likedAt, + matchedAt: record.matchedAt, + intent: record.intent, + }; +} + +export function filterFallbackProfiles( + intent: MatchIntent, + city: string, + advanced?: { gender: string; single: string } +): DatingProfile[] { + const cityQuery = city.trim().toLowerCase(); + return FALLBACK_PROFILES.filter((profile) => { + if (!profile.lookingFor.includes(intent)) return false; + if (!cityQuery || cityQuery === "all") { + // continue + } else if ( + profile.citySlug.toLowerCase() !== cityQuery && + !profile.location.toLowerCase().includes(cityQuery) + ) { + return false; + } + if (advanced?.gender && advanced.gender !== "all" && profile.gender !== advanced.gender) { + return false; + } + if (advanced?.single && advanced.single !== "all" && profile.single !== advanced.single) { + return false; + } + return true; + }); +} + +export function formatTimeAgo(value?: string, locale = "zh"): string { + if (!value) return locale === "zh" ? "刚刚活跃" : "Active now"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return locale === "zh" ? "刚刚活跃" : "Active now"; + const diff = Date.now() - date.getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return locale === "zh" ? "刚刚" : "Just now"; + if (minutes < 60) return locale === "zh" ? `${minutes} 分钟前` : `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return locale === "zh" ? `${hours} 小时前` : `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return locale === "zh" ? `${days} 天前` : `${days}d ago`; + const weeks = Math.floor(days / 7); + if (weeks < 5) return locale === "zh" ? `${weeks} 周前` : `${weeks}w ago`; + const months = Math.floor(days / 30); + return locale === "zh" ? `${Math.max(months, 1)} 月前` : `${Math.max(months, 1)}mo ago`; +} + +export interface MatchQuota { + vip: boolean; + limit: number; + used: number; + remaining: number; +} + +export async function fetchCandidates( + intent: MatchIntent, + city: string, + advanced?: { gender: string; single: string } +): Promise { + const params = new URLSearchParams({ intent }); + if (city && city !== "all") params.set("city", city); + if (advanced?.gender && advanced.gender !== "all") params.set("gender", advanced.gender); + if (advanced?.single && advanced.single !== "all") params.set("single", advanced.single); + params.set("exclude_swiped", "true"); + const data = await apiFetch<{ items: ProfileRecord[] }>(`/api/matches/candidates?${params}`); + return (data.items || []).map((item, index) => mapProfile(item, index)); +} + +export async function fetchQuota(): Promise { + const data = await apiFetch("/api/matches/quota"); + return { + vip: Boolean(data.vip), + limit: Number(data.limit || 25), + used: Number(data.used || 0), + remaining: Number(data.remaining || 0), + }; +} + +export async function undoSwipe(): Promise<{ profileId?: string }> { + return apiFetch<{ ok: boolean; profileId?: string }>("/api/matches/swipes/undo", { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function fetchLikes(): Promise { + const data = await apiFetch<{ items: ProfileRecord[] }>("/api/matches/likes"); + return (data.items || []).map((item, index) => mapProfile(item, index)); +} + +export async function fetchMutualMatches(): Promise { + const data = await apiFetch<{ items: ProfileRecord[] }>("/api/matches/mutual"); + return (data.items || []).map((item, index) => mapProfile(item, index)); +} + +export async function saveSwipe( + profileId: string, + action: "like" | "dislike", + intent: MatchIntent +): Promise { + return apiFetch("/api/matches/swipes", { + method: "POST", + body: JSON.stringify({ profileId, action, intent }), + }); +} diff --git a/app/[locale]/feedback/page.tsx b/app/[locale]/feedback/page.tsx index 8c81c24..eed9dee 100644 --- a/app/[locale]/feedback/page.tsx +++ b/app/[locale]/feedback/page.tsx @@ -1,20 +1,40 @@ "use client"; -import { useState } from "react"; +import { Suspense, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; import { useTranslation } from "@/i18n/navigation"; import Footer from "@/app/components/Footer"; import { apiFetch } from "@/app/lib/api-client"; -export default function FeedbackPage() { +type FeedbackType = "suggestion" | "bug" | "report"; + +function FeedbackForm() { const { t } = useTranslation("feedback"); + const searchParams = useSearchParams(); + const initialType = searchParams.get("type") === "report" ? "report" : "suggestion"; + const reportTarget = searchParams.get("target") || ""; + const [submitted, setSubmitted] = useState(false); const [form, setForm] = useState({ - type: "suggestion" as "suggestion" | "bug", + type: initialType as FeedbackType, email: "", title: "", content: "", + targetId: reportTarget, }); + useEffect(() => { + if (reportTarget) { + setForm((prev) => ({ + ...prev, + type: "report", + targetId: reportTarget, + title: prev.title || t("reportTitle").replace("{target}", reportTarget), + content: prev.content || t("reportContent").replace("{target}", reportTarget), + })); + } + }, [reportTarget, t]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await apiFetch("/api/feedback", { @@ -26,32 +46,34 @@ export default function FeedbackPage() { return (
-
+
-

- - {t("title")} +

+ + {form.type === "report" ? t("reportPageTitle") : t("title")}

-

- {t("subtitle")} +

+ {form.type === "report" ? t("reportPageSubtitle") : t("subtitle")}

{submitted ? ( -
- 🙏 -

- {t("successTitle")} -

-

- {t("successDesc")} -

+
+ 🙏 +

{t("successTitle")}

+

{t("successDesc")}

@@ -59,93 +81,76 @@ export default function FeedbackPage() { ) : (
- -
- - + +
+ {(["suggestion", "bug", "report"] as FeedbackType[]).map((type) => ( + + ))}
+ {form.type === "report" && form.targetId ? ( +
+ {t("reportTargetLabel")}: {form.targetId} +
+ ) : null} +
- + setForm((f) => ({ ...f, email: e.target.value }))} placeholder={t("emailPlaceholder")} - className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f]" + className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100" />
- + setForm((f) => ({ ...f, title: e.target.value }))} placeholder={t("titlePlaceholder")} - className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f]" + className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100" />
- +