Compare commits

..

No commits in common. "dev2" and "dev1" have entirely different histories.
dev2 ... dev1

234 changed files with 1396 additions and 60363 deletions

11
.gitignore vendored
View File

@ -29,13 +29,6 @@ pocketbase/pb_data/*
frontend/.next/ frontend/.next/
frontend/out/ frontend/out/
# Mini programs
wemini/node_modules/
wemini/dist/
dymini/node_modules/
dymini/dist/
redmini/dist/
# Python # Python
*.pyc *.pyc
.venv/ .venv/
@ -60,7 +53,3 @@ scripts/check_ports.py
scripts/find_caddy.py scripts/find_caddy.py
scripts/read_caddy.py scripts/read_caddy.py
scripts/verify_site.py scripts/verify_site.py
wemini/.swc/
wemini/project.private.config.json

View File

@ -46,42 +46,11 @@ nomadweb/
│ ├── app/routers/ # REST │ ├── app/routers/ # REST
│ ├── app/services/ # PocketBase、S3、城市深度等 │ ├── app/services/ # PocketBase、S3、城市深度等
│ └── app/data/ # Mock 降级(开发) │ └── app/data/ # Mock 降级(开发)
├── wemini/ # 微信小程序(Taro → weapp,可联网)
├── dymini/ # 抖音小程序(Taro → tt,可联网)
├── redmini/ # 小红书 miniTool(纯前端离线,禁止联网)
├── deploy/ # Caddy、systemd、env 模板 ├── deploy/ # Caddy、systemd、env 模板
├── scripts/ # 本地开发与生产更新 ├── scripts/ # 本地开发与生产更新
└── pocketbase/ # PB 数据目录(本地可选) └── pocketbase/ # PB 数据目录(本地可选)
``` ```
### 小程序
| 目录 | 平台 | 联网 | 说明 |
|------|------|------|------|
| `wemini/` | 微信 | ✅ → `nomadweb.nomadro.com/api/v1` | Taro 原生 weapp(参考 Desktop/wemini:`ScrollView` + 手写 SCSS,**不用 web-view**);视觉对齐 H5 |
| `dymini/` | 抖音 | ✅ → 同上 | `npm run dev:tt` |
| `redmini/` | 小红书 | ❌ 纯离线 | H5 class DOM + `globals.css` 镜像;`python scripts/assemble.py && python scripts/pack.py` |
**微信 `wemini/`**
```bash
cd wemini
npm install --legacy-peer-deps
npm run dev:weapp
# 微信开发者工具打开本目录(miniprogramRoot=dist/),清缓存编译
```
| Tab | 内容 |
|-----|------|
| 首页 | Hero / 三条环 / 目的地 / 生活方式 / 签证 / 心声 / FAQ |
| 目的地 | 筛选 + 城市卡 → 详情页 |
| 活动 | Meetup 列表 + RSVP(需登录) |
| 我的 | 微信登录、收藏、等级 |
样式:`wemini/src/app.scss`、`wemini/src/styles/h5-theme.scss`(WXSS 安全,不灌整份 H5 CSS)。
Git 远程:`origin`(gitea)与 `nomadro`([git.nomadro.cn/hackrobot/nomadweb](https://git.nomadro.cn/hackrobot/nomadweb.git))。
## 快速开始 ## 快速开始
### 1. PocketBase(可选) ### 1. PocketBase(可选)
@ -180,18 +149,13 @@ NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
## Git 与部署 ## Git 与部署
仓库: 仓库:[gitea.dsx2020.com/eric/nomadweb](https://gitea.dsx2020.com/eric/nomadweb) · 分支 **`dev1`**
- [gitea.dsx2020.com/eric/nomadweb](https://gitea.dsx2020.com/eric/nomadweb)(`origin`)
- [git.nomadro.cn/hackrobot/nomadweb](https://git.nomadro.cn/hackrobot/nomadweb.git)(`nomadro`)
当前开发分支 **`dev2`**(亦保留 `dev1` / `main`)。
```bash ```bash
git checkout dev2 git checkout dev1
git pull origin dev2 git pull origin dev1
# … 改代码 … # … 改代码 …
git push origin dev2 git push origin dev1
git push nomadro dev2
python scripts/deploy_update.py # 服务器 pull + 增量构建重启 python scripts/deploy_update.py # 服务器 pull + 增量构建重启
``` ```
@ -207,18 +171,6 @@ python scripts/deploy_update.py # 服务器 pull + 增量构建重启
密钥仅放服务器 `deploy/nomadro-api.env`(模板见 `*.env.example`),勿提交。 密钥仅放服务器 `deploy/nomadro-api.env`(模板见 `*.env.example`),勿提交。
### 微信小程序(明文)
| 项 | 值 |
|----|-----|
| AppID | `wx11f6afc44ea1532f` |
| AppSecret | `d1814f60f900cfdee1813ccc6e00bc2d` |
- 目录:`wemini/`(`project.config.json` 已写入 AppID)
- 服务端 env:`WECHAT_APPID` / `WECHAT_SECRET` / `WECHAT_ALLOW_DEVTOOLS`(见 `deploy/nomadro-api.env.example`)
- 登录:`wx.login` → `POST /api/v1/auth/wechat` `{ "code": "..." }`
- 公众平台 request 合法域名:`nomadweb.nomadro.com`
## 快捷键 ## 快捷键
| 按键 | 功能 | | 按键 | 功能 |

View File

@ -187,71 +187,6 @@ EXTRA_ROUTES: list[dict[str, Any]] = [
"status": "published", "status": "published",
"emoji": "🚄", "emoji": "🚄",
}, },
{
"slug": "tax-base-loop",
"title": "税务友好试住线",
"titleEn": "Tax Base Loop",
"citySlugs": ["tbilisi", "dubai"],
"durationDays": 75,
"budget": 22000,
"description": "第比利斯试住 + 迪拜枢纽月,适合评估税务与中转策略。",
"descriptionEn": "Tbilisi base trial plus Dubai hub month.",
"stops": ["第比利斯 45 天", "迪拜 30 天"],
"status": "published",
"emoji": "🧾",
},
{
"slug": "latam-spring-trail",
"title": "拉美春城双城",
"titleEn": "LATAM Spring Trail",
"citySlugs": ["medellin", "mexico"],
"durationDays": 60,
"budget": 15000,
"description": "麦德林 → 墨西哥城,北美时区远程友好。",
"descriptionEn": "Medellín to Mexico City for NA timezones.",
"stops": ["麦德林 30 天", "墨西哥城 30 天"],
"status": "published",
"emoji": "🌺",
},
{
"slug": "create-slow-china",
"title": "国内慢创作线",
"titleEn": "China Slow Create",
"citySlugs": ["dali"],
"durationDays": 45,
"budget": 9000,
"description": "大理深度创作,适合内容创作者与独立开发者。",
"descriptionEn": "Dali deep create stay.",
"stops": ["大理 45 天"],
"status": "published",
"emoji": "📷",
},
{
"slug": "euro-oss-sprint",
"title": "欧洲开源冲刺线",
"titleEn": "Euro OSS Sprint",
"citySlugs": ["berlin", "lisbon"],
"durationDays": 50,
"budget": 32000,
"description": "柏林技术社群 + 里斯本阳光回调。",
"descriptionEn": "Berlin tech then Lisbon sun.",
"stops": ["柏林 25 天", "里斯本 25 天"],
"status": "published",
"emoji": "🖥️",
},
{
"slug": "east-asia-net",
"title": "东亚网速双城",
"titleEn": "East Asia Net Duo",
"citySlugs": ["seoul", "tokyo"],
"durationDays": 35,
"budget": 30000,
"description": "首尔与东京高强度协作月,适合产品迭代。",
"descriptionEn": "Seoul and Tokyo sprint months.",
"stops": ["首尔 18 天", "东京 17 天"],
"status": "published",
"emoji": "📶",
},
] ]
@ -299,45 +234,4 @@ CONTENT_ITEMS: list[dict[str, Any]] = [
"status": "published", "status": "published",
"authorName": "nomadro", "authorName": "nomadro",
}, },
{
"slug": "medellin-trial",
"type": "guide",
"title": "麦德林试住手册",
"titleEn": "Medellín Trial Guide",
"subtitle": "安全区、西语与北美时区",
"description": "30 天试住清单与预算模板。",
"targetUrl": "/destinations/medellin",
"ctaLabel": "看城市",
"citySlugs": ["medellin"],
"sortOrder": 4,
"status": "published",
"authorName": "Sofia",
},
{
"slug": "tbilisi-tax-note",
"type": "guide",
"title": "第比利斯税务笔记",
"titleEn": "Tbilisi Tax Notes",
"subtitle": "远程签证与开户提醒",
"description": "非正式法律意见,帮助你先做试住决策。",
"targetUrl": "/destinations/tbilisi",
"ctaLabel": "打开笔记",
"citySlugs": ["tbilisi"],
"sortOrder": 5,
"status": "published",
"authorName": "Devon",
},
{
"slug": "first-month-video",
"type": "video",
"title": "旅居第一月稳定输出",
"subtitle": "SIM、短租、社群五步法",
"description": "新手最容易踩坑的第一周怎么过。",
"targetUrl": "/videos/first-month",
"ctaLabel": "看视频",
"citySlugs": ["chiangmai", "lisbon", "bali"],
"sortOrder": 6,
"status": "published",
"authorName": "nomadro",
},
] ]

View File

@ -26,7 +26,7 @@ MEETUPS = [
"id": "chiangmai-cowork", "id": "chiangmai-cowork",
"title": "清迈联合办公下午茶", "title": "清迈联合办公下午茶",
"city": "清迈", "city": "清迈",
"destination_slug": "chiangmai", "destination_slug": "chiang-mai",
"emoji": "☕", "emoji": "☕",
"date": "2026-09-18", "date": "2026-09-18",
"time": "15:00", "time": "15:00",
@ -96,133 +96,6 @@ MEETUPS = [
"tags": ["税务", "日本", "干货"], "tags": ["税务", "日本", "干货"],
"is_upcoming": True, "is_upcoming": True,
}, },
{
"id": "berlin-opensource",
"title": "柏林开源与 DevOps 夜谈",
"city": "柏林",
"destination_slug": "berlin",
"emoji": "🖥️",
"date": "2026-09-28",
"time": "19:00",
"venue": "Kreuzberg Co-working",
"description": "分享自托管、CI 与远程团队协作实践,会后可继续社交。",
"mode": "hybrid",
"access_level": "public",
"mirotalkRoom": "nomadro-berlin-oss",
"rsvp_count": 22,
"max_attendees": 40,
"organizer": "Berlin Remote",
"tags": ["开源", "技术", "欧洲"],
"is_upcoming": True,
},
{
"id": "dali-create-walk",
"title": "大理洱海创作散步",
"city": "大理",
"destination_slug": "dali",
"emoji": "📷",
"date": "2026-10-02",
"time": "16:00",
"venue": "才村码头集合",
"description": "边走边拍,分享内容选题与变现路径,适合创作者与慢旅居者。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 8,
"max_attendees": 18,
"organizer": "大理创作者局",
"tags": ["内容", "户外", "国内"],
"is_upcoming": True,
},
{
"id": "medellin-spanish",
"title": "麦德林西语咖啡局",
"city": "麦德林",
"destination_slug": "medellin",
"emoji": "☕",
"date": "2026-10-08",
"time": "10:30",
"venue": "Laureles 咖啡馆",
"description": "初级西语练习 + 安全区与租房情报交换,新手友好。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 16,
"max_attendees": 20,
"organizer": "Medellín Nomads",
"tags": ["语言", "拉美", "社交"],
"is_upcoming": True,
},
{
"id": "seoul-night-office",
"title": "首尔深夜联合办公",
"city": "首尔",
"destination_slug": "seoul",
"emoji": "🌃",
"date": "2026-10-15",
"time": "21:00",
"venue": "江南共享办公",
"description": "适合东亚时区冲刺交付的游民,自带电脑即可。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 12,
"max_attendees": 25,
"organizer": "Seoul Workation",
"tags": ["办公", "韩国", "效率"],
"is_upcoming": True,
},
{
"id": "dubai-hub-brunch",
"title": "迪拜枢纽早午餐",
"city": "迪拜",
"destination_slug": "dubai",
"emoji": "🥂",
"date": "2026-10-18",
"time": "11:00",
"venue": "Downtown 咖啡厅",
"description": "中转游民见面:签证、航班与下一站城市选择。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 10,
"max_attendees": 16,
"organizer": "Dubai Hub Crew",
"tags": ["中东", "社交", "跳板"],
"is_upcoming": True,
},
{
"id": "tbilisi-tax-cafe",
"title": "第比利斯税务咖啡聊",
"city": "第比利斯",
"destination_slug": "tbilisi",
"emoji": "🍷",
"date": "2026-10-22",
"time": "15:00",
"venue": "Vake 区咖啡馆",
"description": "交流远程签证与本地公司实务(非正式法律意见),会后可徒步旧城。",
"mode": "offline",
"access_level": "members",
"rsvp_count": 19,
"max_attendees": 22,
"organizer": "Tbilisi Base",
"tags": ["税务", "欧洲", "干货"],
"is_upcoming": True,
},
{
"id": "mexico-food-walk",
"title": "墨西哥城街头美食漫步",
"city": "墨西哥城",
"destination_slug": "mexico",
"emoji": "🌮",
"date": "2026-10-26",
"time": "17:30",
"venue": "Roma Norte 集合",
"description": "边吃边聊北美时区接单与安全出行经验。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 13,
"max_attendees": 18,
"organizer": "CDMX Nomads",
"tags": ["美食", "拉美", "社交"],
"is_upcoming": True,
},
] ]
DISCUSSIONS = [ DISCUSSIONS = [
@ -291,90 +164,6 @@ DISCUSSIONS = [
"created_at": "2026-08-28", "created_at": "2026-08-28",
"tags": ["活动", "投票"], "tags": ["活动", "投票"],
}, },
{
"id": "medellin-safety",
"title": "麦德林哪些街区适合第一次长住?",
"excerpt": "Laureles / Poblado / Envigado 的网速、噪音和夜间出行体验求分享。",
"author": "Sofia",
"author_emoji": "🌺",
"category": "住宿",
"reply_count": 17,
"like_count": 41,
"created_at": "2026-08-30",
"tags": ["麦德林", "安全"],
},
{
"id": "tbilisi-bank",
"title": "第比利斯开户与收美元的实操经验",
"excerpt": "需要本地号码吗?Wise 能不能直接用?求最新流程。",
"author": "Devon",
"author_emoji": "🍷",
"category": "财务",
"reply_count": 14,
"like_count": 36,
"created_at": "2026-09-01",
"tags": ["格鲁吉亚", "银行"],
},
{
"id": "berlin-winter",
"title": "柏林冬天怎么保持产出和社交?",
"excerpt": "日照短、天气冷,有没有室内运动 + meetup 推荐。",
"author": "Ken",
"author_emoji": "🎨",
"category": "生活方式",
"reply_count": 9,
"like_count": 22,
"created_at": "2026-09-02",
"tags": ["柏林", "健康"],
},
{
"id": "dali-wifi",
"title": "大理合租找房:网速坑位合集",
"excerpt": "哪些区域光纤稳定?有没有房东靠谱名单。",
"author": "Lina",
"author_emoji": "📷",
"category": "住宿",
"reply_count": 21,
"like_count": 48,
"created_at": "2026-09-03",
"tags": ["大理", "网速"],
},
{
"id": "dubai-summer",
"title": "迪拜夏天还能正常远程办公吗?",
"excerpt": "空调联合办公、作息调整和预算增加幅度求真实数据。",
"author": "Omar",
"author_emoji": "🏙️",
"category": "生活方式",
"reply_count": 8,
"like_count": 19,
"created_at": "2026-09-04",
"tags": ["迪拜", "气候"],
},
{
"id": "seoul-visa",
"title": "韩国 Workation 签证材料清单核对",
"excerpt": "收入证明、保险、行程……有人最近过签吗?",
"author": "Yuki",
"author_emoji": "🇰🇷",
"category": "签证",
"reply_count": 12,
"like_count": 33,
"created_at": "2026-09-05",
"tags": ["韩国", "签证"],
},
{
"id": "cowork-month-pass",
"title": "各城市联合办公月票性价比对比",
"excerpt": "清迈 / 里斯本 / 柏林 / 麦德林,欢迎贴价目与座位体验。",
"author": "Alex",
"author_emoji": "🚀",
"category": "远程工作",
"reply_count": 26,
"like_count": 61,
"created_at": "2026-09-06",
"tags": ["办公", "成本"],
},
] ]
GIGS = [ GIGS = [
@ -386,10 +175,7 @@ GIGS = [
"deadline": "2026-09-15", "deadline": "2026-09-15",
"tags": ["设计", "远程"], "tags": ["设计", "远程"],
"poster": "nomadro", "poster": "nomadro",
"poster_email": "demo@nomadro.com",
"status": "open", "status": "open",
"category": "设计",
"location": "远程",
}, },
{ {
"id": "meetup-host", "id": "meetup-host",
@ -399,10 +185,7 @@ GIGS = [
"deadline": "2026-09-20", "deadline": "2026-09-20",
"tags": ["活动", "深圳"], "tags": ["活动", "深圳"],
"poster": "深圳湾区主理人", "poster": "深圳湾区主理人",
"poster_email": "demo@nomadro.com",
"status": "open", "status": "open",
"category": "活动",
"location": "深圳",
}, },
{ {
"id": "content-translate", "id": "content-translate",
@ -412,127 +195,7 @@ GIGS = [
"deadline": "2026-10-01", "deadline": "2026-10-01",
"tags": ["翻译", "内容"], "tags": ["翻译", "内容"],
"poster": "nomadro", "poster": "nomadro",
"poster_email": "demo@nomadro.com",
"status": "open", "status": "open",
"category": "内容",
"location": "远程",
},
{
"id": "landing-page",
"title": "城市落地页前端实现",
"description": "Next.js + Tailwind,按 Figma 还原清迈/里斯本两套落地页。",
"budget": "¥3500",
"deadline": "2026-10-10",
"tags": ["前端", "Next.js"],
"poster": "Marco",
"poster_email": "marco@nomadro.com",
"status": "open",
"category": "开发",
"location": "远程",
},
{
"id": "video-edit",
"title": "游民访谈短视频剪辑",
"description": "4 条 60–90 秒竖版视频,含字幕与封面。",
"budget": "¥1500",
"deadline": "2026-10-12",
"tags": ["视频", "剪辑"],
"poster": "Lina",
"poster_email": "lina@nomadro.com",
"status": "open",
"category": "内容",
"location": "远程",
},
{
"id": "seo-audit",
"title": "目的地内容 SEO 审计",
"description": "审计 10 篇城市指南关键词与内链结构,输出改进清单。",
"budget": "¥900",
"deadline": "2026-10-08",
"tags": ["SEO", "内容"],
"poster": "nomadro",
"poster_email": "demo@nomadro.com",
"status": "open",
"category": "运营",
"location": "远程",
},
{
"id": "photo-pack",
"title": "大理旅居照片素材包",
"description": "需 30 张可商用风格照片(咖啡馆、洱海、合租场景)。",
"budget": "¥600",
"deadline": "2026-10-05",
"tags": ["摄影", "大理"],
"poster": "Lina",
"poster_email": "lina@nomadro.com",
"status": "open",
"category": "设计",
"location": "大理",
},
{
"id": "tax-checklist",
"title": "格鲁吉亚税务清单校对",
"description": "把英文政策摘要整理成中文 checklist(非法律意见)。",
"budget": "¥700",
"deadline": "2026-10-18",
"tags": ["税务", "文档"],
"poster": "Devon",
"poster_email": "devon@nomadro.com",
"status": "open",
"category": "内容",
"location": "远程",
},
{
"id": "meetup-photo",
"title": "麦德林活动跟拍",
"description": "一场线下咖啡局跟拍 + 当天出 15 张精修。",
"budget": "$80",
"deadline": "2026-10-08",
"tags": ["摄影", "活动"],
"poster": "Sofia",
"poster_email": "sofia@nomadro.com",
"status": "open",
"category": "活动",
"location": "麦德林",
},
{
"id": "chatbot-faq",
"title": "签证 FAQ 聊天机器人语料",
"description": "整理 50 条问答并标注意图,供客服机器人训练。",
"budget": "¥1100",
"deadline": "2026-10-20",
"tags": ["AI", "内容"],
"poster": "Alex",
"poster_email": "alex@nomadro.com",
"status": "open",
"category": "开发",
"location": "远程",
},
{
"id": "ux-test",
"title": "下一站推荐工具可用性测试",
"description": "招募 5 名游民完成任务并录制反馈(远程)。",
"budget": "¥80/人",
"deadline": "2026-10-15",
"tags": ["UX", "研究"],
"poster": "Sara",
"poster_email": "sara@nomadro.com",
"status": "open",
"category": "产品",
"location": "远程",
},
{
"id": "community-mod",
"title": "周末社区值班版主",
"description": "处理讨论区举报与欢迎新人,需中英双语。",
"budget": "¥300/周末",
"deadline": "2026-10-30",
"tags": ["社区", "兼职"],
"poster": "nomadro",
"poster_email": "demo@nomadro.com",
"status": "open",
"category": "运营",
"location": "远程",
}, },
] ]
@ -554,14 +217,6 @@ DISCUSSION_REPLIES = {
"created_at": "2026-08-21", "created_at": "2026-08-21",
"like_count": 8, "like_count": 8,
}, },
{
"id": "r3",
"author": "Yuki",
"author_emoji": "🇮🇩",
"content": "巴厘岛短期用 B211A 足够,长期再看第二家园成本是否划算。",
"created_at": "2026-08-22",
"like_count": 6,
},
], ],
"async-remote": [ "async-remote": [
{ {
@ -572,130 +227,6 @@ DISCUSSION_REPLIES = {
"created_at": "2026-08-23", "created_at": "2026-08-23",
"like_count": 9, "like_count": 9,
}, },
{
"id": "r2",
"author": "Alex",
"author_emoji": "🚀",
"content": "模板三要素:昨天完成 / 今天计划 / 卡点,别写散文。",
"created_at": "2026-08-23",
"like_count": 11,
},
],
"coliving-tips": [
{
"id": "r1",
"author": "小林",
"author_emoji": "🧳",
"content": "一定要问清押金退还条件和网速实测截图,别只看宣传。",
"created_at": "2026-08-26",
"like_count": 14,
},
],
"medellin-safety": [
{
"id": "r1",
"author": "Alex",
"author_emoji": "🌮",
"content": "第一次建议 Laureles,生活成本与便利平衡;夜间少拿手机逛街。",
"created_at": "2026-08-31",
"like_count": 10,
},
],
"tbilisi-bank": [
{
"id": "r1",
"author": "Omar",
"author_emoji": "🏙️",
"content": "先办本地 SIM 再去银行,材料准备护照+地址证明更顺利。",
"created_at": "2026-09-01",
"like_count": 7,
},
],
"dali-wifi": [
{
"id": "r1",
"author": "阿静",
"author_emoji": "🏡",
"content": "才村/喜洲合租差别大,入住前用手机热点对照测速最靠谱。",
"created_at": "2026-09-03",
"like_count": 9,
},
],
"cowork-month-pass": [
{
"id": "r1",
"author": "Marco",
"author_emoji": "💻",
"content": "里斯本月票大约 €150–250;清迈很多空间 ¥600–1000 就够用。",
"created_at": "2026-09-06",
"like_count": 15,
},
],
"health-insurance": [
{
"id": "r1",
"author": "Sofia",
"author_emoji": "🏥",
"content": "30 岁以下 SafetyWing 够用;有慢性病建议加本地门诊险。",
"created_at": "2026-08-28",
"like_count": 10,
},
{
"id": "r2",
"author": "Ken",
"author_emoji": "🇯🇵",
"content": "在日本长期停留我叠了本地国民健康保险,报销更稳。",
"created_at": "2026-08-28",
"like_count": 7,
},
],
"meetup-feedback": [
{
"id": "r1",
"author": "Lina",
"author_emoji": "📷",
"content": "大理 + 清迈希望能固定每月一场创作局。",
"created_at": "2026-08-29",
"like_count": 18,
},
{
"id": "r2",
"author": "Omar",
"author_emoji": "🏙️",
"content": "迪拜适合枢纽月见面,建议放周末早午餐。",
"created_at": "2026-08-29",
"like_count": 9,
},
],
"berlin-winter": [
{
"id": "r1",
"author": "Devon",
"author_emoji": "🖥️",
"content": "冬天把联合办公当社交主场,再加一周一次室内攀岩/跑步局。",
"created_at": "2026-09-02",
"like_count": 11,
},
],
"dubai-summer": [
{
"id": "r1",
"author": "Nina",
"author_emoji": "🍷",
"content": "夏天基本只在空调空间办公,预算里把交通和水电预留高一点。",
"created_at": "2026-09-04",
"like_count": 6,
},
],
"seoul-visa": [
{
"id": "r1",
"author": "Jin",
"author_emoji": "🇰🇷",
"content": "最近材料重点是收入证明和保险;审批大约 3–6 周,建议提前办。",
"created_at": "2026-09-05",
"like_count": 8,
},
], ],
} }
@ -740,44 +271,4 @@ NOMAD_ROUTES = [
"stops": ["tokyo", "chiangmai"], "stops": ["tokyo", "chiangmai"],
"emoji": "🚄", "emoji": "🚄",
}, },
{
"id": "tax-base-loop",
"slug": "tax-base-loop",
"title": "税务友好试住线",
"duration_days": 75,
"budget": 22000,
"description": "第比利斯试住 + 迪拜枢纽月,适合评估税务与中转策略。",
"stops": ["tbilisi", "dubai"],
"emoji": "🧾",
},
{
"id": "latam-spring",
"slug": "latam-spring-trail",
"title": "拉美春城双城",
"duration_days": 60,
"budget": 15000,
"description": "麦德林 → 墨西哥城,北美时区远程友好。",
"stops": ["medellin", "mexico"],
"emoji": "🌺",
},
{
"id": "create-slow",
"slug": "create-slow-china",
"title": "国内慢创作线",
"duration_days": 45,
"budget": 9000,
"description": "大理深度创作 + 短途补给,适合内容创作者。",
"stops": ["dali"],
"emoji": "📷",
},
{
"id": "euro-oss",
"slug": "euro-oss-sprint",
"title": "欧洲开源冲刺线",
"duration_days": 50,
"budget": 32000,
"description": "柏林技术社群 + 里斯本阳光回调。",
"stops": ["berlin", "lisbon"],
"emoji": "🖥️",
},
] ]

View File

@ -95,7 +95,7 @@ JOBS = [
"type": "全职", "type": "全职",
"salary": "$80k–120k", "salary": "$80k–120k",
"tags": ["React", "TypeScript", "远程"], "tags": ["React", "TypeScript", "远程"],
"url": "/gigs", "url": "https://nomadro.com",
}, },
{ {
"id": "j2", "id": "j2",
@ -105,7 +105,7 @@ JOBS = [
"type": "合同", "type": "合同",
"salary": "€60–90/h", "salary": "€60–90/h",
"tags": ["K8s", "AWS", "异步"], "tags": ["K8s", "AWS", "异步"],
"url": "/gigs", "url": "https://nomadro.com",
}, },
{ {
"id": "j3", "id": "j3",
@ -115,96 +115,6 @@ JOBS = [
"type": "兼职", "type": "兼职",
"salary": "面议", "salary": "面议",
"tags": ["社区", "写作", "游民"], "tags": ["社区", "写作", "游民"],
"url": "/gigs", "url": "https://nomadro.com",
},
{
"id": "j4",
"title": "Product Designer",
"company": "Atlantic Remote",
"location": "欧盟时区 ±3h",
"type": "全职",
"salary": "€55k–75k",
"tags": ["Figma", "B2B", "远程"],
"url": "/gigs",
},
{
"id": "j5",
"title": "Growth Marketer",
"company": "Hub Ventures",
"location": "中东/欧洲友好",
"type": "合同",
"salary": "$4k–6k/月",
"tags": ["SEO", "内容", "增长"],
"url": "/gigs",
},
{
"id": "j6",
"title": "Backend Engineer (Python)",
"company": "Latam Cloud",
"location": "北美时区",
"type": "全职",
"salary": "$90k–130k",
"tags": ["Python", "FastAPI", "Postgres"],
"url": "/gigs",
},
{
"id": "j7",
"title": "Community Manager",
"company": "nomadro",
"location": "全球远程",
"type": "兼职",
"salary": "¥8k–12k/月",
"tags": ["社区", "活动", "中英"],
"url": "/gigs",
},
{
"id": "j8",
"title": "Mobile Engineer (iOS/Android)",
"company": "Seoul Soft",
"location": "东亚时区",
"type": "全职",
"salary": "$70k–100k",
"tags": ["Flutter", "移动端"],
"url": "/gigs",
},
{
"id": "j9",
"title": "Technical Writer",
"company": "Docs Anywhere",
"location": "异步优先",
"type": "合同",
"salary": "$40–70/h",
"tags": ["文档", "开发者关系"],
"url": "/gigs",
},
{
"id": "j10",
"title": "Data Analyst",
"company": "Wander Metrics",
"location": "全球远程",
"type": "全职",
"salary": "$65k–95k",
"tags": ["SQL", "看板", "增长"],
"url": "/gigs",
},
{
"id": "j11",
"title": "Customer Success(签证顾问支持)",
"company": "nomadro",
"location": "中国时区友好",
"type": "兼职",
"salary": "¥6k–10k/月",
"tags": ["客服", "签证", "中文"],
"url": "/gigs",
},
{
"id": "j12",
"title": "Full-stack Engineer",
"company": "Indie Nomad Tools",
"location": "全球远程",
"type": "全职",
"salary": "$85k–115k",
"tags": ["Next.js", "Python", "独立产品"],
"url": "/gigs",
}, },
] ]

View File

@ -43,48 +43,6 @@ DESTINATIONS = [
"nomads_count": "4,100+", "highlights": ["🚄 极致公共交通", "🛡️ 全球最安全城市", "📶 网速亚洲第一", "🍣 美食文化巅峰"], "nomads_count": "4,100+", "highlights": ["🚄 极致公共交通", "🛡️ 全球最安全城市", "📶 网速亚洲第一", "🍣 美食文化巅峰"],
"map_x": 820, "map_y": 210, "map_x": 820, "map_y": 210,
}, },
{
"id": "7", "slug": "berlin", "name": "柏林", "country": "德国", "emoji": "🎨",
"tag": "欧洲 · 创意之都", "description": "开放的创业与开源氛围,租金相对西欧友好,夜生活与艺术场景极具吸引力。",
"region": "europe", "cost": 9800, "speed": 140, "temperature": 12, "rating": 8.9, "hue": 250,
"nomads_count": "7,200+", "highlights": ["🎨 艺术与街头文化", "💻 创业生态活跃", "🚲 骑行友好", "🍺 社交场景丰富"],
"map_x": 500, "map_y": 160,
},
{
"id": "8", "slug": "dali", "name": "大理", "country": "中国", "emoji": "🏔️",
"tag": "国内 · 慢生活", "description": "苍山洱海之间的内容创作者聚集地,生活节奏慢、社交松弛,适合深度创作与试住。",
"region": "asia", "cost": 4200, "speed": 80, "temperature": 18, "rating": 8.7, "hue": 190,
"nomads_count": "3,600+", "highlights": ["📷 内容创作友好", "🏞️ 自然风光", "🍵 咖啡馆密集", "🏡 合租成本低"],
"map_x": 730, "map_y": 220,
},
{
"id": "9", "slug": "seoul", "name": "首尔", "country": "韩国", "emoji": "🏙️",
"tag": "亚洲 · 潮流都市", "description": "高速网络与深夜城市活力并存,适合对接东亚市场与产品迭代。",
"region": "asia", "cost": 11000, "speed": 220, "temperature": 14, "rating": 8.5, "hue": 330,
"nomads_count": "3,200+", "highlights": ["📶 全球顶尖网速", "🍜 美食与便利店文化", "🛍️ 潮流消费", "🚇 地铁覆盖极强"],
"map_x": 800, "map_y": 200,
},
{
"id": "10", "slug": "medellin", "name": "麦德林", "country": "哥伦比亚", "emoji": "🌺",
"tag": "拉美 · 春城", "description": "四季如春、生活成本友好,英语游民社区成熟,适合北美时区远程工作。",
"region": "latam", "cost": 5200, "speed": 90, "temperature": 23, "rating": 9.0, "hue": 140,
"nomads_count": "6,400+", "highlights": ["🌤️ 四季如春", "💰 高性价比", "🕐 北美时区", "🤝 社区活动密集"],
"map_x": 260, "map_y": 300,
},
{
"id": "11", "slug": "dubai", "name": "迪拜", "country": "阿联酋", "emoji": "🏙️",
"tag": "中东 · 枢纽都市", "description": "免税与签证便利吸引远程高收入人群,国际航班枢纽,适合中东/欧亚跳板。",
"region": "asia", "cost": 14000, "speed": 180, "temperature": 32, "rating": 8.4, "hue": 40,
"nomads_count": "4,800+", "highlights": ["🧾 税务友好", "✈️ 航线枢纽", "🏗️ 现代基础设施", "🌐 多元国际社区"],
"map_x": 600, "map_y": 240,
},
{
"id": "12", "slug": "tbilisi", "name": "第比利斯", "country": "格鲁吉亚", "emoji": "🍷",
"tag": "欧亚 · 税务友好", "description": "1% 小企业税与远程友好签证,旧城氛围浓厚,东欧游民新基地。",
"region": "europe", "cost": 4800, "speed": 70, "temperature": 16, "rating": 8.8, "hue": 15,
"nomads_count": "5,100+", "highlights": ["🧾 1% 小企业税", "🏰 旧城与温泉", "🍷 葡萄酒文化", "💰 低生活成本"],
"map_x": 560, "map_y": 190,
},
] ]
VISAS = [ VISAS = [
@ -100,18 +58,6 @@ VISAS = [
"duration": "1-4年", "income_req": "$2,500/月", "approval_time": "2-4 周", "extra": "🌮 北美时区友好", "difficulty": 30, "difficulty_label": "简单"}, "duration": "1-4年", "income_req": "$2,500/月", "approval_time": "2-4 周", "extra": "🌮 北美时区友好", "difficulty": 30, "difficulty_label": "简单"},
{"id": "6", "country": "爱沙尼亚", "flag": "🇪🇪", "name": "爱沙尼亚 DNV", "badge": "🚀 先锋", "badge_type": "pioneer", {"id": "6", "country": "爱沙尼亚", "flag": "🇪🇪", "name": "爱沙尼亚 DNV", "badge": "🚀 先锋", "badge_type": "pioneer",
"duration": "1年", "income_req": "€3,504/月", "approval_time": "2-4 周", "extra": "💻 全球首个数字游民签证", "difficulty": 40, "difficulty_label": "中等"}, "duration": "1年", "income_req": "€3,504/月", "approval_time": "2-4 周", "extra": "💻 全球首个数字游民签证", "difficulty": 40, "difficulty_label": "中等"},
{"id": "7", "country": "格鲁吉亚", "flag": "🇬🇪", "name": "格鲁吉亚 Remotely from Georgia", "badge": "⭐ 推荐", "badge_type": "easy",
"duration": "1年", "income_req": "$2,000/月", "approval_time": "约 10 天", "extra": "🧾 可搭配小企业 1% 税", "difficulty": 28, "difficulty_label": "简单"},
{"id": "8", "country": "阿联酋", "flag": "🇦🇪", "name": "迪拜 Virtual Working Programme", "badge": "🔥 热门", "badge_type": "hot",
"duration": "1年", "income_req": "$3,500/月", "approval_time": "2-4 周", "extra": "✈️ 中东航线枢纽", "difficulty": 42, "difficulty_label": "中等"},
{"id": "9", "country": "哥伦比亚", "flag": "🇨🇴", "name": "哥伦比亚 V Visa (Digital Nomad)", "badge": "💰 低成本", "badge_type": "budget",
"duration": "最长 2 年", "income_req": "约 3× 最低工资", "approval_time": "2-6 周", "extra": "🌺 适合麦德林春城", "difficulty": 32, "difficulty_label": "简单"},
{"id": "10", "country": "韩国", "flag": "🇰🇷", "name": "韩国 Workation Visa (C-3-8)", "badge": "🆕 新政策", "badge_type": "new",
"duration": "最长 2 年", "income_req": "约 $65,000/年", "approval_time": "1-2 个月", "extra": "🏙️ 对接首尔与济州", "difficulty": 55, "difficulty_label": "中等"},
{"id": "11", "country": "德国", "flag": "🇩🇪", "name": "德国 Freelance / Self-employment", "badge": "🚀 先锋", "badge_type": "pioneer",
"duration": "1-3年", "income_req": "需商业计划与客户证明", "approval_time": "2-4 个月", "extra": "🎨 适合柏林自由职业", "difficulty": 65, "difficulty_label": "较难"},
{"id": "12", "country": "泰国", "flag": "🇹🇭", "name": "泰国 DTV 签证", "badge": "⭐ 推荐", "badge_type": "easy",
"duration": "最长 5 年(多次入境)", "income_req": "约 50 万泰铢存款证明", "approval_time": "2-6 周", "extra": "🤝 远程工作友好", "difficulty": 38, "difficulty_label": "中等"},
] ]
FAQS = [ FAQS = [
@ -133,11 +79,6 @@ TESTIMONIALS = [
{"id": "1", "avatar": "👩‍💻", "content": "在清迈住了 8 个月,月花费不到 4000 元,但生活质量比国内一线城市高太多了。每天早上骑摩托去咖啡馆,这种感觉无法形容。", "author": "小林", "role": "前端开发 · 清迈 🇹🇭", "rating": 5}, {"id": "1", "avatar": "👩‍💻", "content": "在清迈住了 8 个月,月花费不到 4000 元,但生活质量比国内一线城市高太多了。每天早上骑摩托去咖啡馆,这种感觉无法形容。", "author": "小林", "role": "前端开发 · 清迈 🇹🇭", "rating": 5},
{"id": "2", "avatar": "👨‍🎨", "content": "里斯本的 D7 签证让我在欧洲有了基地。白天在 Alfama 区的共享办公空间工作,周末去 Sintra 徒步,完美平衡。", "author": "Marco", "role": "UI 设计师 · 里斯本 🇵🇹", "rating": 5}, {"id": "2", "avatar": "👨‍🎨", "content": "里斯本的 D7 签证让我在欧洲有了基地。白天在 Alfama 区的共享办公空间工作,周末去 Sintra 徒步,完美平衡。", "author": "Marco", "role": "UI 设计师 · 里斯本 🇵🇹", "rating": 5},
{"id": "3", "avatar": "🧑‍💼", "content": "带着家人做数字游民听起来疯狂,但在巴厘岛乌布,孩子们上国际学校,我和妻子远程工作,这是我们做过最正确的决定。", "author": "张家", "role": "产品经理 · 巴厘岛 🇮🇩", "rating": 5}, {"id": "3", "avatar": "🧑‍💼", "content": "带着家人做数字游民听起来疯狂,但在巴厘岛乌布,孩子们上国际学校,我和妻子远程工作,这是我们做过最正确的决定。", "author": "张家", "role": "产品经理 · 巴厘岛 🇮🇩", "rating": 5},
{"id": "4", "avatar": "👩‍🎨", "content": "麦德林四季如春,英语社区很活跃。我用北美时区接单,下午还能去公园运动,效率反而更高。", "author": "Sofia", "role": "自由职业设计 · 麦德林 🇨🇴", "rating": 5},
{"id": "5", "avatar": "👨‍💻", "content": "第比利斯生活成本低,还能开本地公司走 1% 税。旧城咖啡馆写代码,周末去卡兹别吉徒步,节奏很舒服。", "author": "Devon", "role": "DevOps · 第比利斯 🇬🇪", "rating": 5},
{"id": "6", "avatar": "📷", "content": "大理适合慢创作。合租便宜、风景治愈,但要主动找同频伙伴,别只待在民宿里。", "author": "Lina", "role": "内容创作者 · 大理 🇨🇳", "rating": 4},
{"id": "7", "avatar": "🧑‍🚀", "content": "柏林创业氛围强,开源 meetup 特别多。冬天冷、德语门槛真实存在,但找到圈子后很值得。", "author": "Ken", "role": "数据分析 · 柏林 🇩🇪", "rating": 4},
{"id": "8", "avatar": "🏙️", "content": "迪拜当跳板很香:航班方便、签证路径清晰。夏天太热,建议把深度工作时间放在室内联合办公。", "author": "Omar", "role": "增长顾问 · 迪拜 🇦🇪", "rating": 4},
] ]
TOOLS = [ TOOLS = [
@ -153,13 +94,6 @@ BLOG_POSTS = [
{"id": "1", "slug": "chiangmai-guide-2026", "title": "清迈数字游民完全指南 2026", "excerpt": "从签证到住宿,从咖啡馆到 Co-working,一篇搞定清迈旅居。", "emoji": "🏔️", "author": "nomadro", "published_at": "2026-01-15", "read_time": 8, "tags": ["清迈", "指南", "东南亚"]}, {"id": "1", "slug": "chiangmai-guide-2026", "title": "清迈数字游民完全指南 2026", "excerpt": "从签证到住宿,从咖啡馆到 Co-working,一篇搞定清迈旅居。", "emoji": "🏔️", "author": "nomadro", "published_at": "2026-01-15", "read_time": 8, "tags": ["清迈", "指南", "东南亚"]},
{"id": "2", "slug": "portugal-d7-visa", "title": "葡萄牙 D7 签证申请全攻略", "excerpt": "手把手教你申请欧洲最受欢迎的远程工作签证。", "emoji": "🇵🇹", "author": "nomadro", "published_at": "2026-02-03", "read_time": 12, "tags": ["签证", "葡萄牙", "欧洲"]}, {"id": "2", "slug": "portugal-d7-visa", "title": "葡萄牙 D7 签证申请全攻略", "excerpt": "手把手教你申请欧洲最受欢迎的远程工作签证。", "emoji": "🇵🇹", "author": "nomadro", "published_at": "2026-02-03", "read_time": 12, "tags": ["签证", "葡萄牙", "欧洲"]},
{"id": "3", "slug": "nomad-tax-basics", "title": "数字游民税务入门:你需要知道的 5 件事", "excerpt": "183 天规则、双重征税、税务居民身份——一文理清。", "emoji": "🧾", "author": "nomadro", "published_at": "2026-02-20", "read_time": 10, "tags": ["税务", "法律", "指南"]}, {"id": "3", "slug": "nomad-tax-basics", "title": "数字游民税务入门:你需要知道的 5 件事", "excerpt": "183 天规则、双重征税、税务居民身份——一文理清。", "emoji": "🧾", "author": "nomadro", "published_at": "2026-02-20", "read_time": 10, "tags": ["税务", "法律", "指南"]},
{"id": "4", "slug": "medellin-spring-city", "title": "麦德林春城试住 30 天笔记", "excerpt": "时区、安全区、联合办公与西语社区初体验。", "emoji": "🌺", "author": "Sofia", "published_at": "2026-03-05", "read_time": 9, "tags": ["麦德林", "拉美", "试住"]},
{"id": "5", "slug": "tbilisi-tax-base", "title": "第比利斯当税务基地靠谱吗?", "excerpt": "远程签证、小企业税与旧城生活成本实测。", "emoji": "🍷", "author": "Devon", "published_at": "2026-03-18", "read_time": 11, "tags": ["格鲁吉亚", "税务", "欧洲"]},
{"id": "6", "slug": "dali-slow-create", "title": "大理慢创作:合租、网速与社群", "excerpt": "国内游民城市怎么住得久又不散漫。", "emoji": "🏔️", "author": "Lina", "published_at": "2026-04-02", "read_time": 7, "tags": ["大理", "内容", "国内"]},
{"id": "7", "slug": "berlin-startup-nomad", "title": "柏林自由职业与开源社群指南", "excerpt": "签证路径、共享办公与每周活动清单。", "emoji": "🎨", "author": "Ken", "published_at": "2026-04-20", "read_time": 10, "tags": ["柏林", "创业", "开源"]},
{"id": "8", "slug": "dubai-hub-month", "title": "迪拜一个月跳板旅居怎么排期", "excerpt": "签证、炎热季节、航班中转与办公点选择。", "emoji": "🏙️", "author": "Omar", "published_at": "2026-05-01", "read_time": 8, "tags": ["迪拜", "中东", "枢纽"]},
{"id": "9", "slug": "seoul-workation", "title": "首尔 Workation:网速、便利与成本", "excerpt": "C-3-8 签证与江南区办公踩点。", "emoji": "🏙️", "author": "Yuki", "published_at": "2026-05-16", "read_time": 9, "tags": ["首尔", "韩国", "网速"]},
{"id": "10", "slug": "first-month-checklist", "title": "旅居第一月清单:从落地到稳定输出", "excerpt": "SIM、短租、联合办公、社群与预算五步走。", "emoji": "✅", "author": "nomadro", "published_at": "2026-06-01", "read_time": 6, "tags": ["清单", "新手", "落地"]},
] ]
BLOG_CONTENT: dict[str, str] = { BLOG_CONTENT: dict[str, str] = {
@ -283,102 +217,6 @@ A: 满足居住要求后可以申请永居或入籍。""",
3. 🏦 使用 Wise 等工具便于跨境汇款记录 3. 🏦 使用 Wise 等工具便于跨境汇款记录
4. 👨‍💼 收入超过一定金额建议聘请税务顾问 4. 👨‍💼 收入超过一定金额建议聘请税务顾问
5. 📱 推荐工具:Xero(记账)、TaxScouts(报税)""", 5. 📱 推荐工具:Xero(记账)、TaxScouts(报税)""",
"medellin-spring-city": """## 为什么麦德林?
四季如春、生活成本可控,且贴近北美时区,适合接美加客户。Laureles / El Poblado 是游民常见落脚区。
## 30 天试住建议
1. 前 7 天住短租,实测网速与噪音
2. 办本地 SIM,备份热点
3. 参加 2 场线下 meetup,验证社区匹配度
4. 确定联合办公月票再签长租
## 预算参考(月)
- 住宿:¥2,000–3,500
- 餐饮:¥1,200–1,800
- 交通:¥300–500
- 办公:¥400–800""",
"tbilisi-tax-base": """## 远程签证 + 小企业税
格鲁吉亚对远程工作者友好,许多人会评估本地公司与 1% 税路径(需自行核实最新政策)。
## 生活感受
旧城与 Vake 区咖啡馆多,适合深度工作;冬季偏冷,夏天舒服。
## 注意
- 银行开户与本地手机号流程可能变动
- 税务方案务必咨询持证顾问
- 把「试住 30 天」和「税务落地」分开决策""",
"dali-slow-create": """## 适合谁
内容创作者、独立开发者、想放慢节奏但仍保持产出的人。
## 实操建议
- 合租优先选稳定 WiFi 与独立工作桌
- 用固定作息对抗「度假感」
- 主动加入本地创作者局,避免社交孤岛
## 成本
月生活费常可控制在 ¥3,500–5,000,视合租与出行频率而定。""",
"berlin-startup-nomad": """## 柏林为什么吸引游民
开源、设计、创业活动密度高,共享办公选择多。
## 落地要点
- 提前规划居留/自由职业路径
- 冬天日照短,准备补光与室内运动
- 德语不是必须,但能明显提升生活便利
## 每周节奏
2 天联合办公 + 1 场 meetup + 周末短途,比纯咖啡馆更稳。""",
"dubai-hub-month": """## 适合当跳板
国际航班密集,签证路径相对清晰,适合中东/欧亚中转月。
## 炎热季节
夏季高温,深度工作尽量安排在空调联合办公;户外活动放早晚。
## 预算
整体偏高,建议按「枢纽月」而非「低成本旅居」来规划。""",
"seoul-workation": """## 网速与便利
首尔网速与城市便利度极强,适合需要高频协作的产品/工程角色。
## 成本与节奏
住宿与餐饮成本接近一线城市;用便利店与共享办公控制变量。
## 签证
关注 Workation / 相关短期远程路径的最新材料要求。""",
"first-month-checklist": """## 落地五步
1. **连接**:落地办 SIM,测公寓与办公点网速
2. **住处**:先短租 7–14 天,再决定长租
3. **办公**:固定 1 个主点 + 2 个备份咖啡馆
4. **社群**:报名 1 场 meetup,加 1 个本地群
5. **预算**:用第一周真实账单校准月预算
## 输出稳定
每天保护 4 小时深度工作,旅行探索放在下午后段。""",
} }
TICKER_MESSAGES = [ TICKER_MESSAGES = [
@ -388,10 +226,4 @@ TICKER_MESSAGES = [
"🏝️ 巴厘岛 Co-working 今日 89% 满座", "🏝️ 巴厘岛 Co-working 今日 89% 满座",
"📶 清迈平均网速 95Mbps", "📶 清迈平均网速 95Mbps",
"🌅 东京远程工作者满意度 9.1", "🌅 东京远程工作者满意度 9.1",
"🌺 Sofia 在麦德林办完本地 SIM",
"🍷 Devon 把下一站设为第比利斯",
"🎨 Ken 报名柏林开源 Meetup",
"🏙️ Omar 从迪拜中转飞往里斯本",
"📷 Lina 在大理发布本周创作日志",
"🇰🇷 Yuki 实测首尔联合办公 220Mbps",
] ]

View File

@ -37,60 +37,6 @@ SERVICES = [
"category": "职业", "category": "职业",
"provider": "HR 志愿者", "provider": "HR 志愿者",
}, },
{
"id": "coliving-match",
"title": "合租匹配咨询",
"description": "按城市、预算与作息匹配合租对象,附沟通话术模板。",
"emoji": "🏡",
"price": "¥149 起",
"category": "住宿",
"provider": "社区主理人",
},
{
"id": "insurance-pick",
"title": "国际保险选型",
"description": "按年龄、目的地与既往史对比 SafetyWing / Genki / 本地险。",
"emoji": "🏥",
"price": "¥99 起",
"category": "安全",
"provider": "保险顾问",
},
{
"id": "content-pack",
"title": "城市内容素材包",
"description": "可商用照片 + 短视频 B-roll,覆盖咖啡馆与联合办公场景。",
"emoji": "📷",
"price": "¥259 起",
"category": "内容",
"provider": "创作者网络",
},
{
"id": "plan-coach",
"title": "90 天旅居计划辅导",
"description": "一起排城市顺序、预算与签证节点,输出可执行计划表。",
"emoji": "🗓️",
"price": "¥499 起",
"category": "规划",
"provider": "nomadro 教练",
},
{
"id": "bank-setup",
"title": "跨境账户开户辅导",
"description": "Wise / Revolut / 本地银行材料清单与常见拒因规避。",
"emoji": "💳",
"price": "¥179 起",
"category": "财务",
"provider": "财务志愿者",
},
{
"id": "meetup-host-coach",
"title": "线下活动主办陪跑",
"description": "从选题、场地到 RSVP 转化,帮你办成第一场同城局。",
"emoji": "🎉",
"price": "¥399 起",
"category": "社区",
"provider": "活动主理人",
},
] ]
VIDEOS = [ VIDEOS = [
@ -105,7 +51,7 @@ VIDEOS = [
"city": "清迈", "city": "清迈",
"published_at": "2026-03-12", "published_at": "2026-03-12",
"tags": ["泰国", "成本", "社区"], "tags": ["泰国", "成本", "社区"],
"video_url": "", "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
}, },
{ {
"id": "lisbon-d7", "id": "lisbon-d7",
@ -118,7 +64,7 @@ VIDEOS = [
"city": "里斯本", "city": "里斯本",
"published_at": "2026-02-08", "published_at": "2026-02-08",
"tags": ["葡萄牙", "签证"], "tags": ["葡萄牙", "签证"],
"video_url": "", "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
}, },
{ {
"id": "bali-cowork", "id": "bali-cowork",
@ -131,124 +77,7 @@ VIDEOS = [
"city": "巴厘岛", "city": "巴厘岛",
"published_at": "2026-01-20", "published_at": "2026-01-20",
"tags": ["印尼", "办公"], "tags": ["印尼", "办公"],
"video_url": "", "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
},
{
"id": "medellin-30d",
"slug": "medellin-30d",
"title": "麦德林 30 天试住 vlog",
"excerpt": "安全区、西语学习与北美时区接单节奏。",
"emoji": "🌺",
"duration": "16:20",
"guest": "Sofia",
"city": "麦德林",
"published_at": "2026-03-28",
"tags": ["哥伦比亚", "试住"],
"video_url": "",
},
{
"id": "tbilisi-tax",
"slug": "tbilisi-tax",
"title": "第比利斯税务基地访谈",
"excerpt": "远程签证、开户与旧城生活成本。",
"emoji": "🍷",
"duration": "21:05",
"guest": "Devon",
"city": "第比利斯",
"published_at": "2026-04-05",
"tags": ["格鲁吉亚", "税务"],
"video_url": "",
},
{
"id": "berlin-oss",
"slug": "berlin-oss",
"title": "柏林开源 Meetup 一日",
"excerpt": "创业办公空间与技术社交怎么玩。",
"emoji": "🎨",
"duration": "14:48",
"guest": "Ken",
"city": "柏林",
"published_at": "2026-04-22",
"tags": ["德国", "开源"],
"video_url": "",
},
{
"id": "dali-create",
"slug": "dali-create",
"title": "大理慢创作一周",
"excerpt": "合租、网速与内容产出作息。",
"emoji": "📷",
"duration": "12:55",
"guest": "Lina",
"city": "大理",
"published_at": "2026-05-03",
"tags": ["大理", "内容"],
"video_url": "",
},
{
"id": "dubai-hub",
"slug": "dubai-hub",
"title": "迪拜枢纽月怎么排",
"excerpt": "签证、炎热季节与航班中转策略。",
"emoji": "🏙️",
"duration": "13:40",
"guest": "Omar",
"city": "迪拜",
"published_at": "2026-05-18",
"tags": ["迪拜", "中东"],
"video_url": "",
},
{
"id": "seoul-net",
"slug": "seoul-net",
"title": "首尔网速与深夜办公",
"excerpt": "江南联合办公实测与 Workation 签证提示。",
"emoji": "🇰🇷",
"duration": "11:22",
"guest": "Yuki",
"city": "首尔",
"published_at": "2026-06-02",
"tags": ["韩国", "网速"],
"video_url": "",
},
{
"id": "mexico-food",
"slug": "mexico-food",
"title": "墨西哥城美食与办公平衡",
"excerpt": "Roma Norte 咖啡馆踩点与时区协作。",
"emoji": "🌮",
"duration": "15:10",
"guest": "Alex",
"city": "墨西哥城",
"published_at": "2026-06-15",
"tags": ["墨西哥", "美食"],
"video_url": "",
},
{
"id": "first-month",
"slug": "first-month",
"title": "旅居第一月:从慌乱到稳定",
"excerpt": "SIM、短租、社群与预算五步法。",
"emoji": "✅",
"duration": "10:05",
"guest": "nomadro",
"city": "线上",
"published_at": "2026-06-28",
"tags": ["新手", "清单"],
"video_url": "",
},
{
"id": "barcelona-visa",
"slug": "barcelona-visa",
"title": "巴塞罗那 Nomad Visa 体验",
"excerpt": "材料、审批周期与地中海生活成本。",
"emoji": "🏖️",
"duration": "19:30",
"guest": "Sara",
"city": "巴塞罗那",
"published_at": "2026-07-08",
"tags": ["西班牙", "签证"],
"video_url": "",
}, },
] ]
@ -261,14 +90,6 @@ MEMBER_MAP = [
{"name": "Ken", "city": "东京", "lat": 35.68, "lng": 139.69, "emoji": "🗼"}, {"name": "Ken", "city": "东京", "lat": 35.68, "lng": 139.69, "emoji": "🗼"},
{"name": "Lina", "city": "大理", "lat": 25.60, "lng": 100.27, "emoji": "🏔️"}, {"name": "Lina", "city": "大理", "lat": 25.60, "lng": 100.27, "emoji": "🏔️"},
{"name": "Omar", "city": "迪拜", "lat": 25.20, "lng": 55.27, "emoji": "🏙️"}, {"name": "Omar", "city": "迪拜", "lat": 25.20, "lng": 55.27, "emoji": "🏙️"},
{"name": "Devon", "city": "柏林", "lat": 52.52, "lng": 13.40, "emoji": "🖥️"},
{"name": "Sara", "city": "麦德林", "lat": 6.25, "lng": -75.56, "emoji": "🌺"},
{"name": "Nina", "city": "第比利斯", "lat": 41.72, "lng": 44.79, "emoji": "🍷"},
{"name": "Jin", "city": "首尔", "lat": 37.57, "lng": 126.98, "emoji": "🇰🇷"},
{"name": "Priya", "city": "里斯本", "lat": 38.74, "lng": -9.15, "emoji": "💻"},
{"name": "Tom", "city": "清迈", "lat": 18.80, "lng": 98.97, "emoji": "☕"},
{"name": "Mia", "city": "巴厘岛", "lat": -8.65, "lng": 115.14, "emoji": "🧘"},
{"name": "Leo", "city": "墨西哥城", "lat": 19.42, "lng": -99.16, "emoji": "🚀"},
] ]
CITY_RANKING = [ CITY_RANKING = [
@ -277,23 +98,12 @@ CITY_RANKING = [
{"slug": "bali", "name": "巴厘岛", "score": 87, "nomads": "15k+"}, {"slug": "bali", "name": "巴厘岛", "score": 87, "nomads": "15k+"},
{"slug": "mexico", "name": "墨西哥城", "score": 85, "nomads": "6k+"}, {"slug": "mexico", "name": "墨西哥城", "score": 85, "nomads": "6k+"},
{"slug": "barcelona", "name": "巴塞罗那", "score": 84, "nomads": "7k+"}, {"slug": "barcelona", "name": "巴塞罗那", "score": 84, "nomads": "7k+"},
{"slug": "medellin", "name": "麦德林", "score": 88, "nomads": "6k+"},
{"slug": "tbilisi", "name": "第比利斯", "score": 86, "nomads": "5k+"},
{"slug": "berlin", "name": "柏林", "score": 83, "nomads": "7k+"},
{"slug": "dali", "name": "大理", "score": 82, "nomads": "4k+"},
{"slug": "seoul", "name": "首尔", "score": 81, "nomads": "3k+"},
{"slug": "tokyo", "name": "东京", "score": 80, "nomads": "4k+"},
{"slug": "dubai", "name": "迪拜", "score": 79, "nomads": "5k+"},
] ]
DAILY_TIPS = { DAILY_TIPS = {
"1": {"title": "第一天:确定税务居民身份", "body": "出发前了解母国与目标国的 183 天规则,避免双重征税意外。"}, "1": {"title": "第一天:确定税务居民身份", "body": "出发前了解母国与目标国的 183 天规则,避免双重征税意外。"},
"2": {"title": "第二天:准备多币种账户", "body": "Wise / Revolut 等跨境账户可大幅降低取现与换汇成本。"}, "2": {"title": "第二天:准备多币种账户", "body": "Wise / Revolut 等跨境账户可大幅降低取现与换汇成本。"},
"3": {"title": "第三天:测试远程办公网速", "body": "落地先住 3 天短租,实测联合办公与公寓 WiFi 再签长租。"}, "3": {"title": "第三天:测试远程办公网速", "body": "落地先住 3 天短租,实测联合办公与公寓 WiFi 再签长租。"},
"4": {"title": "第四天:加入同城社群", "body": "报名一场 meetup,比独自逛咖啡馆更快建立支持网络。"},
"5": {"title": "第五天:备份通讯方案", "body": "本地 SIM + eSIM 热点双备份,开会前先压测上行带宽。"},
"6": {"title": "第六天:校准月预算", "body": "用第一周真实账单更新计划表,别只信网上平均值。"},
"7": {"title": "第七天:固定作息", "body": "保护每日 4 小时深度工作,探索放在下午后段。"},
} }
WEATHER_CITIES = { WEATHER_CITIES = {
@ -303,10 +113,4 @@ WEATHER_CITIES = {
"mexico": {"lat": 19.43, "lng": -99.13, "name": "墨西哥城"}, "mexico": {"lat": 19.43, "lng": -99.13, "name": "墨西哥城"},
"barcelona": {"lat": 41.39, "lng": 2.17, "name": "巴塞罗那"}, "barcelona": {"lat": 41.39, "lng": 2.17, "name": "巴塞罗那"},
"tokyo": {"lat": 35.68, "lng": 139.69, "name": "东京"}, "tokyo": {"lat": 35.68, "lng": 139.69, "name": "东京"},
"berlin": {"lat": 52.52, "lng": 13.40, "name": "柏林"},
"dali": {"lat": 25.60, "lng": 100.27, "name": "大理"},
"seoul": {"lat": 37.57, "lng": 126.98, "name": "首尔"},
"medellin": {"lat": 6.25, "lng": -75.56, "name": "麦德林"},
"dubai": {"lat": 25.20, "lng": 55.27, "name": "迪拜"},
"tbilisi": {"lat": 41.72, "lng": 44.79, "name": "第比利斯"},
} }

View File

@ -4,7 +4,7 @@ MATCH_INTENTS = ("friends", "dating", "partner", "roommate", "cofounder", "explo
CANDIDATE_PROFILES = [ CANDIDATE_PROFILES = [
{ {
"id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiangmai", "id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiang-mai",
"gender": "女", "single": "是", "bio": "远程前端,喜欢咖啡和徒步。", "gender": "女", "single": "是", "bio": "远程前端,喜欢咖啡和徒步。",
"photo": "🧳", "tags": ["远程", "咖啡", "徒步"], "photo": "🧳", "tags": ["远程", "咖啡", "徒步"],
"lookingFor": ["friends", "dating", "explore"], "lookingFor": ["friends", "dating", "explore"],
@ -22,7 +22,7 @@ CANDIDATE_PROFILES = [
"lookingFor": ["roommate", "friends"], "lookingFor": ["roommate", "friends"],
}, },
{ {
"id": "p4", "userId": "u4", "name": "Alex", "location": "墨西哥城", "citySlug": "mexico", "id": "p4", "userId": "u4", "name": "Alex", "location": "墨西哥城", "citySlug": "mexico-city",
"gender": "男", "single": "是", "bio": "独立开发者,拉美时区常驻。", "gender": "男", "single": "是", "bio": "独立开发者,拉美时区常驻。",
"photo": "🚀", "tags": ["独立开发", "拉美"], "photo": "🚀", "tags": ["独立开发", "拉美"],
"lookingFor": ["cofounder", "friends", "dating"], "lookingFor": ["cofounder", "friends", "dating"],
@ -51,52 +51,4 @@ CANDIDATE_PROFILES = [
"photo": "🖥️", "tags": ["DevOps", "开源"], "photo": "🖥️", "tags": ["DevOps", "开源"],
"lookingFor": ["cofounder", "friends"], "lookingFor": ["cofounder", "friends"],
}, },
{
"id": "p9", "userId": "u9", "name": "Sofia", "location": "麦德林", "citySlug": "medellin",
"gender": "女", "single": "是", "bio": "自由设计师,北美时区接单,爱西语咖啡局。",
"photo": "🌺", "tags": ["设计", "西语", "拉美"],
"lookingFor": ["friends", "dating", "explore"],
},
{
"id": "p10", "userId": "u10", "name": "Omar", "location": "迪拜", "citySlug": "dubai",
"gender": "男", "single": "是", "bio": "增长顾问,常把迪拜当欧亚跳板。",
"photo": "🏙️", "tags": ["增长", "中东", "航班"],
"lookingFor": ["friends", "partner", "explore"],
},
{
"id": "p11", "userId": "u11", "name": "Nina", "location": "第比利斯", "citySlug": "tbilisi",
"gender": "女", "single": "是", "bio": "独立顾问,研究税务友好基地与旧城生活。",
"photo": "🍷", "tags": ["税务", "顾问", "欧洲"],
"lookingFor": ["friends", "cofounder"],
},
{
"id": "p12", "userId": "u12", "name": "Jin", "location": "首尔", "citySlug": "seoul",
"gender": "男", "single": "是", "bio": "移动端工程师,沉迷深夜联合办公。",
"photo": "🇰🇷", "tags": ["移动端", "效率", "网速"],
"lookingFor": ["friends", "roommate", "explore"],
},
{
"id": "p13", "userId": "u13", "name": "Priya", "location": "里斯本", "citySlug": "lisbon",
"gender": "女", "single": "是", "bio": "产品设计师,喜欢海边徒步与博物馆。",
"photo": "🌊", "tags": ["产品设计", "徒步"],
"lookingFor": ["dating", "friends"],
},
{
"id": "p14", "userId": "u14", "name": "Tom", "location": "清迈", "citySlug": "chiangmai",
"gender": "男", "single": "是", "bio": "后端工程师,咖啡店常客,周末骑摩托。",
"photo": "☕", "tags": ["后端", "咖啡", "摩托"],
"lookingFor": ["friends", "cofounder", "explore"],
},
{
"id": "p15", "userId": "u15", "name": "Mia", "location": "巴厘岛", "citySlug": "bali",
"gender": "女", "single": "是", "bio": "瑜伽老师兼内容创作者,寻找同频合租。",
"photo": "🧘", "tags": ["瑜伽", "内容", "合租"],
"lookingFor": ["roommate", "friends", "partner"],
},
{
"id": "p16", "userId": "u16", "name": "Leo", "location": "墨西哥城", "citySlug": "mexico",
"gender": "男", "single": "是", "bio": "创业者,做拉美远程协作工具。",
"photo": "🌮", "tags": ["创业", "拉美", "SaaS"],
"lookingFor": ["cofounder", "friends", "dating"],
},
] ]

View File

@ -13,8 +13,7 @@ from app.data import mock_data
from app.data import community_data from app.data import community_data
from app.services.recommendations import recommend_destinations from app.services.recommendations import recommend_destinations
from app.services.meetup_live import ( from app.services.meetup_live import (
build_lounge_chat_url, build_mirotalk_join_url, can_access_meetup, build_lounge_chat_url, build_mirotalk_join_url, can_access_meetup, create_mirotalk_room_slug,
create_lounge_channel, create_mirotalk_room_slug,
) )
from app.services.auth import get_user_by_token from app.services.auth import get_user_by_token
from app.services import community_store, social_store from app.services import community_store, social_store
@ -276,35 +275,15 @@ async def rsvp_meetup(body: MeetupRsvpRequest, authorization: str | None = Heade
raise HTTPException(400, "活动已满员") raise HTTPException(400, "活动已满员")
meetup = community_store.get_meetup(body.meetup_id) or {} meetup = community_store.get_meetup(body.meetup_id) or {}
title = meetup.get("title") or "活动"
mode = meetup.get("mode") or ""
organizer_id = meetup.get("organizer_id") or "" organizer_id = meetup.get("organizer_id") or ""
if organizer_id and organizer_id != user_id:
from app.services.notify import notify_user from app.services.notify import notify_user
if organizer_id and organizer_id != user_id:
notify_user( notify_user(
organizer_id, organizer_id,
"新的活动报名", "新的活动报名",
f"{user.get('name', '游民')} 报名了「{title}」", f"{user.get('name', '游民')} 报名了「{meetup.get('title', '活动')}」",
"/meetups", f"/meetups",
"meetup",
)
# Confirm to attendee with next-step deep link (live room or profile calendar)
if mode in ("online", "hybrid"):
notify_user(
user_id,
"报名成功",
f"你已报名「{title}」,活动开始时可进入直播间",
f"/meetups/{body.meetup_id}/live",
"meetup",
)
else:
notify_user(
user_id,
"报名成功",
f"你已报名「{title}」,可在个人中心导出日历",
"/profile",
"meetup", "meetup",
) )
return {"success": True, "message": res["message"], "rsvp_count": res["rsvp_count"]} return {"success": True, "message": res["message"], "rsvp_count": res["rsvp_count"]}
@ -369,37 +348,17 @@ async def meetup_session(meetup_id: str, authorization: str | None = Header(None
mode = meetup.get("mode", "offline") mode = meetup.get("mode", "offline")
display = (user or {}).get("name", "访客") display = (user or {}).get("name", "访客")
if mode not in ("online", "hybrid"):
can_join = False
lock = lock or "offline"
elif can_join:
# Live rooms need login + RSVP (organizer exempt)
if not user:
can_join = False
lock = "login_required"
else:
organizer_id = meetup.get("organizer_id") or ""
is_host = bool(organizer_id) and organizer_id == user["id"]
if not is_host:
rsvps = community_store.user_rsvp_ids(user["id"])
mid = meetup.get("id") or meetup_id
if mid not in rsvps and meetup_id not in rsvps:
can_join = False
lock = "rsvp_required"
session = MeetupSession( session = MeetupSession(
canJoin=can_join, canJoin=can_join and mode in ("online", "hybrid"),
lockReason=lock if not can_join else "", lockReason=lock if not can_join else "",
displayName=display, displayName=display,
mode=mode, mode=mode,
mirotalkRoom=meetup.get("mirotalkRoom") or create_mirotalk_room_slug(meetup), mirotalkRoom=meetup.get("mirotalkRoom") or create_mirotalk_room_slug(meetup),
loungeChannel=meetup.get("loungeChannel") or "", loungeChannel=meetup.get("loungeChannel", ""),
) )
if session.canJoin: if session.canJoin:
session.videoUrl = build_mirotalk_join_url(meetup, display) session.videoUrl = build_mirotalk_join_url(meetup, display)
session.chatUrl = build_lounge_chat_url(meetup) session.chatUrl = build_lounge_chat_url(meetup)
if not session.loungeChannel:
session.loungeChannel = create_lounge_channel(meetup)
return session return session

View File

@ -5,40 +5,25 @@ import httpx
from app.schemas import ( from app.schemas import (
AuthLogin, AuthRegister, AuthResponse, Destination, GoogleLoginRequest, AuthLogin, AuthRegister, AuthResponse, Destination, GoogleLoginRequest,
WeChatLoginRequest,
FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile, FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile,
UserPlanPayload, UserPlanPayload,
) )
from app.services.auth import ( from app.services.auth import (
get_favorites, get_plan, get_user_by_token, get_favorites, get_plan, get_user_by_token,
login_demo, login_google, login_wechat, login_user, register_user, save_plan, toggle_favorite, login_demo, login_google, login_user, register_user, save_plan, toggle_favorite,
update_profile, update_profile,
) )
from app.services.wechat import code2session
from app.services.pocketbase import pb from app.services.pocketbase import pb
from app.services import social_store
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
def _with_social_seed(result: dict | None) -> dict | None:
if not result:
return result
try:
uid = (result.get("user") or {}).get("id")
if uid:
social_store.seed_demo_social_activity(uid)
except Exception:
pass
return result
@router.post("/register", response_model=AuthResponse) @router.post("/register", response_model=AuthResponse)
async def register(body: AuthRegister): async def register(body: AuthRegister):
result = register_user(body.email, body.password, body.name) result = register_user(body.email, body.password, body.name)
if not result: if not result:
raise HTTPException(400, "该邮箱已注册") raise HTTPException(400, "该邮箱已注册")
return AuthResponse(**_with_social_seed(result)) return AuthResponse(**result)
@router.post("/login", response_model=AuthResponse) @router.post("/login", response_model=AuthResponse)
@ -46,7 +31,7 @@ async def login(body: AuthLogin):
result = login_user(body.email, body.password) result = login_user(body.email, body.password)
if not result: if not result:
raise HTTPException(401, "邮箱或密码错误") raise HTTPException(401, "邮箱或密码错误")
return AuthResponse(**_with_social_seed(result)) return AuthResponse(**result)
@router.get("/me", response_model=UserProfile) @router.get("/me", response_model=UserProfile)
@ -84,7 +69,7 @@ async def demo_login():
result = login_demo() result = login_demo()
if not result: if not result:
raise HTTPException(500, "演示账号不可用") raise HTTPException(500, "演示账号不可用")
return AuthResponse(**_with_social_seed(result)) return AuthResponse(**result)
@router.post("/google", response_model=AuthResponse) @router.post("/google", response_model=AuthResponse)
@ -107,22 +92,7 @@ async def google_login(body: GoogleLoginRequest):
result = login_google(email, name) result = login_google(email, name)
if not result: if not result:
raise HTTPException(500, "登录失败") raise HTTPException(500, "登录失败")
return AuthResponse(**_with_social_seed(result)) return AuthResponse(**result)
@router.post("/wechat", response_model=AuthResponse)
async def wechat_login(body: WeChatLoginRequest):
"""微信小程序 wx.login code → jscode2session → 签发 nomadro token。"""
try:
session = await code2session(body.code)
except RuntimeError as e:
raise HTTPException(401, str(e)) from e
openid = session.get("openid") or ""
unionid = session.get("unionid") or None
result = login_wechat(openid, unionid)
if not result:
raise HTTPException(500, "微信登录失败")
return AuthResponse(**_with_social_seed(result))
@router.get("/favorites", response_model=FavoriteResponse) @router.get("/favorites", response_model=FavoriteResponse)

View File

@ -100,14 +100,6 @@ async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | Non
if not res.get("ok"): if not res.get("ok"):
raise HTTPException(400, res.get("error", "申请失败")) raise HTTPException(400, res.get("error", "申请失败"))
poster_id = res.get("poster_id") or "" poster_id = res.get("poster_id") or ""
if not poster_id:
email = (res.get("poster_email") or "").strip().lower()
if email:
from app.services.auth import find_user_public_by_email
peer = find_user_public_by_email(email)
if peer:
poster_id = peer["id"]
if poster_id and poster_id != user["id"]: if poster_id and poster_id != user["id"]:
from app.services.notify import notify_user from app.services.notify import notify_user

View File

@ -23,21 +23,8 @@ def _user(authorization: str | None) -> dict:
async def ebook_status(authorization: str | None = Header(None)): async def ebook_status(authorization: str | None = Header(None)):
user = _user(authorization) user = _user(authorization)
owned = social_store.has_ebook(user["id"]) owned = social_store.has_ebook(user["id"])
has_files = bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)
return { return {
"owned": owned, "owned": owned,
"has_files": has_files,
"amount_fen": PAYMENT_EBOOK_AMOUNT,
"amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}",
}
@router.get("/product")
async def ebook_product():
"""Public product info — no auth (buy CTA honesty)."""
has_files = bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)
return {
"has_files": has_files,
"amount_fen": PAYMENT_EBOOK_AMOUNT, "amount_fen": PAYMENT_EBOOK_AMOUNT,
"amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}", "amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}",
} }
@ -54,5 +41,5 @@ async def ebook_downloads(authorization: str | None = Header(None)):
if EBOOK_EPUB_URL: if EBOOK_EPUB_URL:
files.append({"format": "epub", "label": "EPUB", "url": EBOOK_EPUB_URL}) files.append({"format": "epub", "label": "EPUB", "url": EBOOK_EPUB_URL})
if not files: if not files:
files.append({"format": "online", "label": "Online reading", "url": "/book/read"}) files.append({"format": "online", "label": "在线阅读", "url": "/book/read"})
return {"owned": True, "files": files, "has_files": bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)} return {"owned": True, "files": files}

View File

@ -25,36 +25,14 @@ def _token_user(authorization: str | None) -> dict:
async def join_member(body: JoinMemberRequest, authorization: str | None = Header(None)): async def join_member(body: JoinMemberRequest, authorization: str | None = Header(None)):
user = _token_user(authorization) user = _token_user(authorization)
profile = social_store.join_member(user["id"], user["name"], body.model_dump()) profile = social_store.join_member(user["id"], user["name"], body.model_dump())
return {"success": True, "profile": _profile_payload(profile, user["id"])} return {"success": True, "profile": profile}
@router.get("/join/status") @router.get("/join/status")
async def join_status(authorization: str | None = Header(None)): async def join_status(authorization: str | None = Header(None)):
user = _token_user(authorization) user = _token_user(authorization)
profile = social_store.get_public_profile(user["id"]) profile = social_store._profiles.get(user["id"])
return {"complete": bool(profile), "profile": _profile_payload(profile, user["id"]) if profile else None} return {"complete": bool(profile), "profile": profile}
def _profile_payload(profile: dict | None, user_id: str) -> dict:
if not profile:
return {}
return {
"id": profile.get("id") or f"user-{user_id}",
"userId": profile.get("userId") or user_id,
"name": profile.get("name") or "游民",
"location": profile.get("location") or "",
"citySlug": profile.get("citySlug") or "",
"gender": profile.get("gender") or "",
"single": profile.get("single") or "",
"bio": profile.get("bio") or "",
"photo": profile.get("photo") or "🧑‍💻",
"tags": profile.get("tags") or [],
"lookingFor": profile.get("lookingFor") or profile.get("looking_for") or [],
}
def _has_match_profile(user_id: str) -> bool:
return social_store.get_public_profile(user_id) is not None
@router.get("/matches/candidates", response_model=list[MatchProfile]) @router.get("/matches/candidates", response_model=list[MatchProfile])
@ -67,13 +45,8 @@ async def match_candidates(
exclude_swiped: bool = Query(True), exclude_swiped: bool = Query(True),
): ):
user = _token_user(authorization) user = _token_user(authorization)
if not _has_match_profile(user["id"]): if user["id"] not in social_store._profiles:
# Soft-join so dating is usable after login without a stuck gate raise HTTPException(400, "请先完成加入资料")
social_store.join_member(
user["id"],
user.get("name") or "游民",
{"city": "全球", "lookingFor": [intent, "explore"], "bio": "刚加入 nomadro 匹配"},
)
return [MatchProfile(**p) for p in social_store.list_candidates( return [MatchProfile(**p) for p in social_store.list_candidates(
user["id"], intent=intent, city=city, gender=gender, single=single, exclude_swiped=exclude_swiped user["id"], intent=intent, city=city, gender=gender, single=single, exclude_swiped=exclude_swiped
)] )]
@ -163,5 +136,5 @@ async def post_message(conv_id: str, body: SendMessageRequest, authorization: st
async def vip_check(authorization: str | None = Header(None)): async def vip_check(authorization: str | None = Header(None)):
user = _token_user(authorization) user = _token_user(authorization)
vip = social_store.is_vip(user["id"]) vip = social_store.is_vip(user["id"])
exp = social_store.membership_expires_at(user["id"]) exp = (social_store._memberships.get(user["id"]) or {}).get("expires_at", 0)
return VipStatus(vip=vip, expires_at=exp) return VipStatus(vip=vip, expires_at=exp)

View File

@ -539,10 +539,6 @@ class GoogleLoginRequest(BaseModel):
id_token: str id_token: str
class WeChatLoginRequest(BaseModel):
code: str
class FeedbackRequest(BaseModel): class FeedbackRequest(BaseModel):
content: str = Field(min_length=4, max_length=4000) content: str = Field(min_length=4, max_length=4000)
category: str = "general" category: str = "general"

View File

@ -225,46 +225,6 @@ def login_google(email: str, name: str) -> dict[str, Any] | None:
return _issue_login(_users[email]) return _issue_login(_users[email])
def login_wechat(openid: str, unionid: str | None = None) -> dict[str, Any] | None:
"""Map WeChat openid to a stable nomadro account."""
oid = (openid or "").strip()
if not oid:
return None
# Prefer unionid for cross-app identity when present
if unionid:
email = f"wxu_{unionid}@wechat.nomadro.local"
name = f"微信用户{unionid[-4:]}"
else:
email = f"wx_{oid}@wechat.nomadro.local"
name = f"微信用户{oid[-4:]}"
if use_pb():
user = _get_account_by_email(email)
if not user:
uid = secrets.token_hex(8)
row = safe_create(
"nomad_accounts",
{
"email": email,
"passwordHash": _hash_password(secrets.token_hex(16)),
"name": name,
"avatar": "🟢",
"legacyUserId": uid,
"favorites": [],
"plan": _empty_plan(),
},
)
if not row:
return None
user = _account_from_pb(row)
return _issue_login(user)
if email not in _users:
register_user(email, secrets.token_hex(16), name)
_users[email]["avatar"] = "🟢"
return _issue_login(_users[email])
def login_demo() -> dict[str, Any] | None: def login_demo() -> dict[str, Any] | None:
email = "demo@nomadro.com" email = "demo@nomadro.com"
if use_pb(): if use_pb():
@ -440,111 +400,13 @@ def find_user_public_by_email(email: str) -> dict | None:
return _user_profile(user) if user else None return _user_profile(user) if user else None
# Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase
_DEMO_SEED_USERS = [
("demo@nomadro.com", "demo123", "演示用户", "🧑‍💻"),
("xiaolin@nomadro.com", "demo123", "小林", "🧳"),
("marco@nomadro.com", "demo123", "Marco", "💻"),
("yuki@nomadro.com", "demo123", "Yuki", "🎨"),
("alex@nomadro.com", "demo123", "Alex", "🚀"),
("sara@nomadro.com", "demo123", "Sara", "☕"),
("ken@nomadro.com", "demo123", "Ken", "📊"),
("lina@nomadro.com", "demo123", "Lina", "📷"),
("devon@nomadro.com", "demo123", "Devon", "🖥️"),
("sofia@nomadro.com", "demo123", "Sofia", "🌺"),
("omar@nomadro.com", "demo123", "Omar", "🏙️"),
("nina@nomadro.com", "demo123", "Nina", "🍷"),
]
_DEMO_FAVORITES = {
"演示用户": ["chiangmai", "lisbon", "bali"],
"小林": ["chiangmai", "dali", "bali"],
"Marco": ["lisbon", "barcelona", "berlin"],
"Yuki": ["bali", "seoul", "tokyo"],
"Alex": ["mexico", "medellin", "lisbon"],
"Sara": ["barcelona", "lisbon", "medellin"],
"Ken": ["tokyo", "seoul", "berlin"],
"Lina": ["dali", "chiangmai", "bali"],
"Devon": ["berlin", "tbilisi", "lisbon"],
"Sofia": ["medellin", "mexico", "barcelona"],
"Omar": ["dubai", "lisbon", "tbilisi"],
"Nina": ["tbilisi", "berlin", "dubai"],
}
_DEMO_PLANS = {
"演示用户": {
"items": [
{"slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️", "cost": 3800, "months": 2, "note": "先稳住节奏"},
{"slug": "bali", "name": "巴厘岛", "country": "印尼", "emoji": "🏝️", "cost": 4500, "months": 1, "note": ""},
{"slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊", "cost": 9000, "months": 2, "note": "评估 D7"},
],
"meta": {"title": "演示旅居计划", "startMonth": "2026-10", "monthlyBudget": 8000, "checklist": {"visa": True, "sim": True}},
"updated_at": 1,
},
"小林": {
"items": [
{"slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️", "cost": 3800, "months": 3, "note": ""},
{"slug": "dali", "name": "大理", "country": "中国", "emoji": "🏔️", "cost": 4200, "months": 1, "note": "慢创作"},
],
"meta": {"title": "东南亚慢旅", "startMonth": "2026-09", "monthlyBudget": 4500, "checklist": {}},
"updated_at": 1,
},
"Marco": {
"items": [
{"slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊", "cost": 9000, "months": 4, "note": ""},
{"slug": "barcelona", "name": "巴塞罗那", "country": "西班牙", "emoji": "🏖️", "cost": 10500, "months": 1, "note": ""},
],
"meta": {"title": "欧洲基地", "startMonth": "2026-08", "monthlyBudget": 12000, "checklist": {"visa": True}},
"updated_at": 1,
},
"Sofia": {
"items": [
{"slug": "medellin", "name": "麦德林", "country": "哥伦比亚", "emoji": "🌺", "cost": 5200, "months": 2, "note": ""},
{"slug": "mexico", "name": "墨西哥城", "country": "墨西哥", "emoji": "🌃", "cost": 6500, "months": 1, "note": ""},
],
"meta": {"title": "拉美春城", "startMonth": "2026-10", "monthlyBudget": 5500, "checklist": {}},
"updated_at": 1,
},
"Devon": {
"items": [
{"slug": "tbilisi", "name": "第比利斯", "country": "格鲁吉亚", "emoji": "🍷", "cost": 4800, "months": 3, "note": ""},
{"slug": "berlin", "name": "柏林", "country": "德国", "emoji": "🎨", "cost": 9800, "months": 1, "note": ""},
],
"meta": {"title": "税务友好试住", "startMonth": "2026-11", "monthlyBudget": 6000, "checklist": {}},
"updated_at": 1,
},
}
def _seed_demo_users() -> None:
for email, password, name, avatar in _DEMO_SEED_USERS:
if email not in _users:
created = register_user(email, password, name)
if not created:
continue
uid = _users[email]["id"]
_users[email]["avatar"] = avatar
favs = _DEMO_FAVORITES.get(name)
if favs and not (_favorites.get(uid) or []):
_favorites[uid] = list(favs)
plan = _DEMO_PLANS.get(name)
if plan and not ((_plans.get(uid) or {}).get("items") or []):
_plans[uid] = plan
_persist_json()
# Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase # Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase
if not use_pb(): if not use_pb():
if not _restore_json(): if not _restore_json():
login_demo() login_demo()
elif "demo@nomadro.com" not in _users: elif "demo@nomadro.com" not in _users:
login_demo() login_demo()
_seed_demo_users()
else: else:
_migrate_json_to_pb() _migrate_json_to_pb()
if not _get_account_by_email("demo@nomadro.com"): if not _get_account_by_email("demo@nomadro.com"):
register_user("demo@nomadro.com", "demo123", "演示用户") register_user("demo@nomadro.com", "demo123", "演示用户")
# Best-effort: ensure a few named demo accounts exist in PocketBase too
for email, password, name, _avatar in _DEMO_SEED_USERS:
if not _get_account_by_email(email):
register_user(email, password, name)

View File

@ -263,7 +263,7 @@ def create_discussion(user_id: str, user_name: str, payload: dict) -> dict:
{ {
"legacyId": did, "legacyId": did,
"title": payload["title"], "title": payload["title"],
"excerpt": (payload.get("excerpt") or payload.get("content") or "")[:1000], "excerpt": payload.get("excerpt") or payload.get("content", "")[:200],
"author": user_name, "author": user_name,
"authorUserId": user_id, "authorUserId": user_id,
"authorEmoji": payload.get("author_emoji", "🧑‍💻"), "authorEmoji": payload.get("author_emoji", "🧑‍💻"),
@ -361,7 +361,6 @@ def _gig_from_pb(r: dict) -> dict:
"tags": r.get("tags") or [], "tags": r.get("tags") or [],
"poster": r.get("posterName", ""), "poster": r.get("posterName", ""),
"poster_id": r.get("posterId", ""), "poster_id": r.get("posterId", ""),
"poster_email": r.get("posterEmail", ""),
"status": r.get("status", "open"), "status": r.get("status", "open"),
"category": r.get("category", ""), "category": r.get("category", ""),
"location": r.get("location", ""), "location": r.get("location", ""),
@ -417,7 +416,6 @@ def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
"ok": True, "ok": True,
"message": "申请已提交", "message": "申请已提交",
"poster_id": g.get("posterId", ""), "poster_id": g.get("posterId", ""),
"poster_email": g.get("posterEmail", ""),
"title": g.get("title", ""), "title": g.get("title", ""),
} }
@ -570,9 +568,6 @@ _JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "community_store.jso
_j: dict[str, Any] = {} _j: dict[str, Any] = {}
_SEED_VERSION = 4
def _jload() -> None: def _jload() -> None:
global _j global _j
if _j: if _j:
@ -582,30 +577,19 @@ def _jload() -> None:
_j.update(json.loads(_JSON_PATH.read_text(encoding="utf-8"))) _j.update(json.loads(_JSON_PATH.read_text(encoding="utf-8")))
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
pass pass
needs_seed = not _j.get("seeded") or not ( if not _j.get("seeded"):
(_j.get("meetups") or []) and (_j.get("discussions") or []) and (_j.get("gigs") or [])
)
version_bump = int(_j.get("seed_version") or 0) < _SEED_VERSION
if needs_seed or version_bump:
keep_notif = _j.get("notifications") or {}
keep_rsvp = _j.get("rsvps") or {}
keep_apps = _j.get("gig_apps") or []
keep_feedback = _j.get("feedback") or []
keep_views = _j.get("views") or {}
keep_likes = _j.get("discussion_likes") or {}
_j.update({ _j.update({
"meetups": deepcopy(community_data.MEETUPS), "meetups": deepcopy(community_data.MEETUPS),
"discussions": deepcopy(community_data.DISCUSSIONS), "discussions": deepcopy(community_data.DISCUSSIONS),
"replies": deepcopy(community_data.DISCUSSION_REPLIES), "replies": deepcopy(community_data.DISCUSSION_REPLIES),
"gigs": deepcopy(getattr(community_data, "GIGS", [])), "gigs": deepcopy(getattr(community_data, "GIGS", [])),
"discussion_likes": keep_likes, "discussion_likes": {},
"rsvps": keep_rsvp, "rsvps": {},
"gig_apps": keep_apps, "gig_apps": [],
"notifications": keep_notif, "notifications": {},
"feedback": keep_feedback, "feedback": [],
"views": keep_views, "views": {},
"seeded": True, "seeded": True,
"seed_version": _SEED_VERSION,
}) })
_jsave() _jsave()
@ -755,7 +739,6 @@ def _json_apply_gig(gig_id: str, user_id: str, user_name: str, message: str) ->
"ok": True, "ok": True,
"message": "申请已提交", "message": "申请已提交",
"poster_id": gig.get("poster_id", ""), "poster_id": gig.get("poster_id", ""),
"poster_email": gig.get("poster_email", ""),
"title": gig.get("title", ""), "title": gig.get("title", ""),
} }

View File

@ -73,14 +73,6 @@ def is_vip(user_id: str) -> bool:
return bool(m and (m.get("expires_at") or 0) > int(time.time())) return bool(m and (m.get("expires_at") or 0) > int(time.time()))
def membership_expires_at(user_id: str) -> int:
if use_pb():
row = safe_first("memberships", filter=f"userId={q(user_id)}")
return int((row or {}).get("expiresAt") or 0)
_jload()
return int(((_j.get("memberships") or {}).get(user_id) or {}).get("expires_at") or 0)
def ensure_membership(user_id: str, days: int = 365) -> None: def ensure_membership(user_id: str, days: int = 365) -> None:
exp = int(time.time()) + days * 86400 exp = int(time.time()) + days * 86400
if use_pb(): if use_pb():
@ -140,7 +132,7 @@ def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict:
def join_member(user_id: str, name: str, payload: dict) -> dict: def join_member(user_id: str, name: str, payload: dict) -> dict:
profile = get_or_create_profile( return get_or_create_profile(
user_id, user_id,
name, name,
location=payload.get("city", "全球"), location=payload.get("city", "全球"),
@ -151,92 +143,17 @@ def join_member(user_id: str, name: str, payload: dict) -> dict:
lookingFor=payload.get("lookingFor", ["friends", "explore"]), lookingFor=payload.get("lookingFor", ["friends", "explore"]),
photo=payload.get("photo", "🧑‍💻"), photo=payload.get("photo", "🧑‍💻"),
) )
try:
seed_demo_social_activity(user_id)
except Exception:
pass
return profile
def get_public_profile(user_id: str) -> dict | None: def get_public_profile(user_id: str) -> dict | None:
if not user_id:
return None
# Accept /members/user-{id} style links
lookup = user_id[5:] if user_id.startswith("user-") else user_id
if use_pb(): if use_pb():
row = safe_first("profiles", filter=f"userId={q(lookup)}") row = safe_first("profiles", filter=f"userId={q(user_id)}")
if row: if not row:
return { return None
"id": row.get("id") or f"user-{lookup}", return {**row, "vip": is_vip(user_id)}
"userId": row.get("userId") or lookup,
"name": row.get("name") or "游民",
"location": row.get("location") or "",
"citySlug": row.get("citySlug") or "",
"gender": row.get("gender") or "",
"single": row.get("single") or "",
"bio": row.get("bio") or "",
"photo": row.get("photo") or "🧑‍💻",
"tags": row.get("tags") or [],
"lookingFor": _profile_looking_for(row),
"vip": is_vip(lookup),
}
else:
_jload() _jload()
p = (_j.get("profiles") or {}).get(lookup) p = (_j.get("profiles") or {}).get(user_id)
if p: return {**p, "vip": is_vip(user_id)} if p else None
return {
"id": f"user-{lookup}",
"userId": lookup,
"name": p.get("name") or "游民",
"location": p.get("location") or "",
"citySlug": p.get("citySlug") or "",
"gender": p.get("gender") or "",
"single": p.get("single") or "",
"bio": p.get("bio") or "",
"photo": p.get("photo") or "🧑‍💻",
"tags": p.get("tags") or [],
"lookingFor": _profile_looking_for(p),
"vip": is_vip(lookup),
}
for cand in CANDIDATE_PROFILES:
if cand.get("userId") == lookup or cand.get("id") == lookup or cand.get("id") == user_id:
return {
"id": cand.get("id") or f"user-{cand.get('userId', lookup)}",
"userId": cand.get("userId") or lookup,
"name": cand.get("name") or "游民",
"location": cand.get("location") or "",
"citySlug": cand.get("citySlug") or "",
"gender": cand.get("gender") or "",
"single": cand.get("single") or "",
"bio": cand.get("bio") or "",
"photo": cand.get("photo") or "🧑‍💻",
"tags": cand.get("tags") or [],
"lookingFor": _profile_looking_for(cand),
"vip": False,
}
return None
def normalize_peer(peer: dict | None) -> dict | None:
if not peer:
return None
uid = peer.get("userId") or ""
return {
"id": peer.get("id") or (f"user-{uid}" if uid else "unknown"),
"userId": uid,
"name": peer.get("name") or "游民",
"location": peer.get("location") or "",
"citySlug": peer.get("citySlug") or "",
"gender": peer.get("gender") or "",
"single": peer.get("single") or "",
"bio": peer.get("bio") or "",
"photo": peer.get("photo") or "🧑‍💻",
"tags": peer.get("tags") or [],
"lookingFor": _profile_looking_for(peer),
"intent": peer.get("intent") or "",
}
# ── Candidates / swipes ──────────────────────────────────────────────── # ── Candidates / swipes ────────────────────────────────────────────────
@ -581,23 +498,19 @@ def list_conversations(user_id: str) -> list[dict]:
rows = safe_list("conversations", filter=f"userAId={q(user_id)} || userBId={q(user_id)}") rows = safe_list("conversations", filter=f"userAId={q(user_id)} || userBId={q(user_id)}")
rows.sort(key=lambda c: c.get("lastMessageAt") or c.get("created", ""), reverse=True) rows.sort(key=lambda c: c.get("lastMessageAt") or c.get("created", ""), reverse=True)
for c in rows: for c in rows:
if c.get("userAId") == c.get("userBId"):
continue
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
read_at = (c.get("readState") or {}).get(user_id, "") read_at = (c.get("readState") or {}).get(user_id, "")
unread = 1 if (c.get("lastMessageAt") or "") > read_at else 0 unread = 1 if (c.get("lastMessageAt") or "") > read_at else 0
items.append({**c, "peer": normalize_peer(_peer_profile(peer_id)), "unreadCount": unread}) items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread})
return items return items
_jload() _jload()
for c in _j.get("conversations") or []: for c in _j.get("conversations") or []:
if user_id not in (c.get("userAId"), c.get("userBId")): if user_id not in (c.get("userAId"), c.get("userBId")):
continue continue
if c.get("userAId") == c.get("userBId"):
continue
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
read_at = (c.get("readState") or {}).get(user_id, "") read_at = (c.get("readState") or {}).get(user_id, "")
unread = 1 if c.get("lastMessageAt", "") > read_at else 0 unread = 1 if c.get("lastMessageAt", "") > read_at else 0
items.append({**c, "peer": normalize_peer(_peer_profile(peer_id)), "unreadCount": unread}) items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread})
items.sort(key=lambda x: x.get("lastMessageAt", ""), reverse=True) items.sort(key=lambda x: x.get("lastMessageAt", ""), reverse=True)
return items return items
@ -608,12 +521,12 @@ def get_conversation(conv_id: str, user_id: str) -> dict | None:
if not row or user_id not in (row.get("userAId"), row.get("userBId")): if not row or user_id not in (row.get("userAId"), row.get("userBId")):
return None return None
peer_id = row["userBId"] if row["userAId"] == user_id else row["userAId"] peer_id = row["userBId"] if row["userAId"] == user_id else row["userAId"]
return {**row, "peer": normalize_peer(_peer_profile(peer_id))} return {**row, "peer": _peer_profile(peer_id)}
_jload() _jload()
for c in _j.get("conversations") or []: for c in _j.get("conversations") or []:
if c["id"] == conv_id and user_id in (c.get("userAId"), c.get("userBId")): if c["id"] == conv_id and user_id in (c.get("userAId"), c.get("userBId")):
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
return {**c, "peer": normalize_peer(_peer_profile(peer_id))} return {**c, "peer": _peer_profile(peer_id)}
return None return None
@ -749,178 +662,6 @@ def mark_order_paid(order_id: str) -> dict | None:
return order return order
def seed_demo_social_activity(user_id: str) -> None:
"""Give a real user a few incoming likes + one chat so Connect feels alive."""
if not user_id:
return
my_pid = f"user-{user_id}"
peers = [p for p in CANDIDATE_PROFILES if p.get("userId")][:4]
if not peers:
return
buddy = peers[0]
buddy_uid = buddy["userId"]
already = False
if use_pb():
already = bool(safe_first("swipes", filter=f"userId={q(buddy_uid)} && profileId={q(my_pid)}"))
else:
_jload()
already = bool((_j.get("demo_seeded") or {}).get(user_id)) or any(
s.get("userId") == buddy_uid and s.get("profileId") == my_pid for s in (_j.get("swipes") or [])
)
if already:
return
# Incoming likes from candidates toward this user
for p in peers[:3]:
peer_uid = p["userId"]
if use_pb():
if not safe_first("swipes", filter=f"userId={q(peer_uid)} && profileId={q(my_pid)}"):
safe_create(
"swipes",
{
"userId": peer_uid,
"profileId": my_pid,
"action": "like",
"intent": "friends",
"swipeDate": _today_key(),
},
)
else:
_jload()
swipes = _j.setdefault("swipes", [])
if not any(s.get("userId") == peer_uid and s.get("profileId") == my_pid for s in swipes):
swipes.append(
{
"id": secrets.token_hex(6),
"userId": peer_uid,
"profileId": my_pid,
"action": "like",
"intent": "friends",
"date": _today_key(),
"createdAt": _now_iso(),
}
)
# Mutual match + messages with first peer
buddy_pid = buddy["id"]
if use_pb():
if not safe_first("swipes", filter=f"userId={q(user_id)} && profileId={q(buddy_pid)}"):
safe_create(
"swipes",
{
"userId": user_id,
"profileId": buddy_pid,
"action": "like",
"intent": "friends",
"swipeDate": _today_key(),
},
)
else:
_jload()
swipes = _j.setdefault("swipes", [])
if not any(s.get("userId") == user_id and s.get("profileId") == buddy_pid for s in swipes):
swipes.append(
{
"id": secrets.token_hex(6),
"userId": user_id,
"profileId": buddy_pid,
"action": "like",
"intent": "friends",
"date": _today_key(),
"createdAt": _now_iso(),
}
)
match = _ensure_match(user_id, buddy, "friends")
conv_id = (match or {}).get("conversationId") or ""
if not conv_id:
conv = _ensure_conversation(user_id, buddy_uid, intent="friends", match=True)
conv_id = conv.get("id", "")
hello = f"嗨!我在{buddy.get('location', '旅途中')},看到你也在用 nomadro,交个朋友呀 👋"
reply = "你好!我也刚到,有机会一起去联合办公或 meetup~"
if conv_id:
if use_pb():
msgs = safe_list("messages", filter=f"conversationId={q(conv_id)}")
if not msgs:
safe_create("messages", {"conversationId": conv_id, "senderId": buddy_uid, "body": hello})
safe_create("messages", {"conversationId": conv_id, "senderId": user_id, "body": reply})
safe_update(
"conversations",
conv_id,
{
"lastMessageAt": _now_iso(),
"lastMessagePreview": reply[:80],
"readState": {user_id: _now_iso(), buddy_uid: _now_iso()},
},
)
else:
_jload()
msgs = [m for m in (_j.get("messages") or []) if m.get("conversationId") == conv_id]
if not msgs:
_j.setdefault("messages", []).extend(
[
{
"id": secrets.token_hex(8),
"conversationId": conv_id,
"senderId": buddy_uid,
"body": hello,
"createdAt": _now_iso(),
},
{
"id": secrets.token_hex(8),
"conversationId": conv_id,
"senderId": user_id,
"body": reply,
"createdAt": _now_iso(),
},
]
)
for c in _j.get("conversations") or []:
if c.get("id") == conv_id:
c["lastMessageAt"] = _now_iso()
c["lastMessagePreview"] = reply[:80]
break
try:
from app.services import community_store
from app.services.notify import notify_user
notify_user(
user_id,
"有人喜欢了你",
f"{buddy.get('name', '游民')} 等人对你感兴趣,去看看吧",
"/dating/likes",
"match",
)
if conv_id:
notify_user(
user_id,
"新消息",
f"{buddy.get('name', '游民')}: {hello[:40]}",
f"/chat/{conv_id}",
"chat",
)
community_store.add_notification(
user_id,
"欢迎来到匹配",
"左滑跳过、右滑喜欢、上滑超级喜欢。匹配成功可私信。",
"/dating",
"system",
)
except Exception:
pass
if not use_pb():
_jload()
_j.setdefault("demo_seeded", {})[user_id] = True
_jsave()
else:
pass
# ── JSON fallback ────────────────────────────────────────────────────── # ── JSON fallback ──────────────────────────────────────────────────────
def _jload() -> dict: def _jload() -> dict:

View File

@ -1,57 +0,0 @@
"""WeChat mini-program helpers (jscode2session)."""
from __future__ import annotations
import hashlib
import os
from typing import Any
import httpx
WECHAT_APPID = os.getenv("WECHAT_APPID", "").strip()
WECHAT_SECRET = os.getenv("WECHAT_SECRET", "").strip()
WECHAT_ALLOW_DEVTOOLS = os.getenv("WECHAT_ALLOW_DEVTOOLS", "true").lower() in {
"1", "true", "yes", "on",
}
async def code2session(js_code: str) -> dict[str, Any]:
code = (js_code or "").strip()
if not code:
raise RuntimeError("缺少微信登录 code")
# Local / 开发者工具联调:无密钥或 mock code 时降级
mock = code in {"dev", "the code is a mock one"} or code.startswith("mock")
if mock or (not WECHAT_APPID or not WECHAT_SECRET):
if WECHAT_ALLOW_DEVTOOLS or mock:
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()[:16]
return {
"openid": f"devtools_{digest}",
"session_key": f"devtools_session_{digest}",
"unionid": None,
}
raise RuntimeError("未配置 WECHAT_APPID / WECHAT_SECRET")
url = "https://api.weixin.qq.com/sns/jscode2session"
params = {
"appid": WECHAT_APPID,
"secret": WECHAT_SECRET,
"js_code": code,
"grant_type": "authorization_code",
}
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url, params=params)
data = r.json()
err = data.get("errcode")
if err:
if WECHAT_ALLOW_DEVTOOLS and int(err) in {40029, 40163, 40252}:
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()[:16]
return {
"openid": f"devtools_{digest}",
"session_key": f"devtools_session_{digest}",
"unionid": None,
}
raise RuntimeError(f"code2session failed: {data}")
if not data.get("openid"):
raise RuntimeError(f"code2session missing openid: {data}")
return data

View File

@ -2,14 +2,11 @@ lounge.nomadro.com {
encode gzip encode gzip
reverse_proxy https://lounge.nomadro.cn { reverse_proxy https://lounge.nomadro.cn {
header_up Host lounge.nomadro.cn header_up Host lounge.nomadro.cn
header_down -Content-Security-Policy
header_down -X-Frame-Options
transport http { transport http {
tls_insecure_skip_verify tls_insecure_skip_verify
} }
} }
header { header {
Content-Security-Policy "frame-ancestors 'self' https://nomadweb.nomadro.com https://nomadro.cn https://www.nomadro.cn http://localhost:3001 http://127.0.0.1:3001" Content-Security-Policy "frame-ancestors 'self' https://nomadweb.nomadro.com https://nomadro.cn https://www.nomadro.cn http://localhost:3001 http://127.0.0.1:3001"
-X-Frame-Options
} }
} }

View File

@ -2,15 +2,11 @@ mirotalk.nomadro.com {
encode gzip encode gzip
reverse_proxy https://mirotalk.nomadro.cn { reverse_proxy https://mirotalk.nomadro.cn {
header_up Host mirotalk.nomadro.cn header_up Host mirotalk.nomadro.cn
# Upstream CSP omits nomadweb.nomadro.com; strip so our frame-ancestors win.
header_down -Content-Security-Policy
header_down -X-Frame-Options
transport http { transport http {
tls_insecure_skip_verify tls_insecure_skip_verify
} }
} }
header { header {
Content-Security-Policy "frame-ancestors 'self' https://nomadweb.nomadro.com https://nomadro.cn https://www.nomadro.cn http://localhost:3001 http://127.0.0.1:3001" Content-Security-Policy "frame-ancestors 'self' https://nomadweb.nomadro.com https://nomadro.cn https://www.nomadro.cn http://localhost:3001 http://127.0.0.1:3001"
-X-Frame-Options
} }
} }

View File

@ -46,10 +46,5 @@ XORPAY_CASHIER_URL=https://xorpay.com/api/cashier
XORPAY_QUERY_URL=https://xorpay.com/api/query2 XORPAY_QUERY_URL=https://xorpay.com/api/query2
XORPAY_NOTIFY_URL=https://nomadweb.nomadro.com/api/v1/pay/xorpay_notify XORPAY_NOTIFY_URL=https://nomadweb.nomadro.com/api/v1/pay/xorpay_notify
# 微信小程序登录(jscode2session)
WECHAT_APPID=wx11f6afc44ea1532f
WECHAT_SECRET=d1814f60f900cfdee1813ccc6e00bc2d
WECHAT_ALLOW_DEVTOOLS=true
MIROTALK_URL=https://mirotalk.nomadro.com MIROTALK_URL=https://mirotalk.nomadro.com
LOUNGE_URL=https://lounge.nomadro.com LOUNGE_URL=https://lounge.nomadro.com

View File

@ -1,15 +0,0 @@
# dymini · nomadro 抖音小程序
参考 Desktop/dymini(Taro 4 + React / tt),对接:
`https://nomadweb.nomadro.com/api/v1`
## 开发
```bash
cd dymini
npm install --legacy-peer-deps
npm run dev:tt
```
抖音开发者工具打开 `dymini/dist`。请替换 `appid` 并配置合法域名。

View File

@ -1,3 +0,0 @@
module.exports = {
presets: [['taro', { framework: 'react', ts: true, compiler: 'webpack5' }]],
}

View File

@ -1,5 +0,0 @@
export default {
defineConstants: {
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'http://127.0.0.1:8000/api/v1'),
},
}

View File

@ -1,37 +0,0 @@
import path from 'path'
import { defineConfig, type UserConfigExport } from '@tarojs/cli'
export default defineConfig(async (merge) => {
const baseConfig: UserConfigExport = {
projectName: 'dymini',
date: '2026-9-27',
designWidth: 750,
deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 },
sourceRoot: 'src',
outputRoot: 'dist',
plugins: ['@tarojs/plugin-framework-react'],
defineConstants: {
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'https://nomadweb.nomadro.com/api/v1'),
},
copy: { patterns: [], options: {} },
framework: 'react',
compiler: { type: 'webpack5', prebundle: { enable: false } },
cache: { enable: false },
alias: { '@': path.resolve(__dirname, '..', 'src') },
mini: {
postcss: {
pxtransform: { enable: true, config: {} },
cssModules: { enable: false },
},
},
h5: {
publicPath: '/',
staticDirectory: 'static',
postcss: { autoprefixer: { enable: true }, cssModules: { enable: false } },
},
}
if (process.env.NODE_ENV === 'development') {
return merge({}, baseConfig, { logger: { quiet: false, stats: true } })
}
return merge({}, baseConfig)
})

View File

@ -1,5 +0,0 @@
export default {
defineConstants: {
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'https://nomadweb.nomadro.com/api/v1'),
},
}

View File

@ -1,33 +0,0 @@
{
"name": "nomadweb-dymini",
"version": "1.0.0",
"private": true,
"description": "nomadro 抖音小程序",
"scripts": {
"build:tt": "taro build --type tt",
"dev:tt": "npm run build:tt -- --watch"
},
"dependencies": {
"@babel/runtime": "^7.26.0",
"@tarojs/components": "4.0.9",
"@tarojs/helper": "4.0.9",
"@tarojs/plugin-framework-react": "4.0.9",
"@tarojs/plugin-platform-tt": "4.0.9",
"@tarojs/react": "4.0.9",
"@tarojs/runtime": "4.0.9",
"@tarojs/shared": "4.0.9",
"@tarojs/taro": "4.0.9",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@tarojs/cli": "4.0.9",
"@tarojs/webpack5-runner": "4.0.9",
"@types/react": "^18.3.12",
"babel-preset-taro": "4.0.9",
"sass": "^1.81.0",
"typescript": "^5.6.3",
"webpack": "5.91.0"
}
}

View File

@ -1,12 +0,0 @@
{
"miniprogramRoot": "dist/",
"projectname": "nomadweb-dymini",
"description": "nomadro 抖音小程序",
"appid": "ttXXXXXXXX",
"setting": {
"urlCheck": false,
"es6": false,
"minified": false
},
"compileType": "microApp"
}

View File

@ -1,10 +0,0 @@
{
"miniprogramRoot": "./",
"projectname": "nomadweb-dymini",
"description": "nomadro 抖音小程序",
"appid": "ttXXXXXXXX",
"setting": {
"urlCheck": false
},
"compileType": "microApp"
}

View File

@ -1,28 +0,0 @@
export default defineAppConfig({
pages: [
'pages/home/index',
'pages/destinations/index',
'pages/destination/index',
'pages/meetups/index',
'pages/mine/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#0c0b12',
navigationBarTitleText: 'nomadro',
navigationBarTextStyle: 'white',
backgroundColor: '#0c0b12',
},
tabBar: {
color: '#8a8694',
selectedColor: '#f0c674',
backgroundColor: '#12111a',
borderStyle: 'black',
list: [
{ pagePath: 'pages/home/index', text: '首页' },
{ pagePath: 'pages/destinations/index', text: '目的地' },
{ pagePath: 'pages/meetups/index', text: '活动' },
{ pagePath: 'pages/mine/index', text: '我的' },
],
},
})

View File

@ -1,5 +0,0 @@
page {
background: #0c0b12;
color: #f4f0ea;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

View File

@ -1,8 +0,0 @@
import { PropsWithChildren } from 'react'
import './app.scss'
function App({ children }: PropsWithChildren) {
return children
}
export default App

View File

@ -1 +0,0 @@
export default definePageConfig({ navigationBarTitleText: '城市详情' })

View File

@ -1,10 +0,0 @@
.detail { padding: 40px 32px; }
.name { display: block; font-size: 48px; font-weight: 700; }
.meta { display: block; margin-top: 8px; color: #a8a3b3; }
.tag { display: block; margin-top: 16px; color: #cfc9d8; }
.grid { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 40px; }
.cell { width: calc(50% - 8px); background: rgba(255,255,255,0.06); border-radius: 16px; padding: 24px; box-sizing: border-box; }
.k { display: block; color: #8a8694; font-size: 22px; }
.v { display: block; margin-top: 8px; font-size: 30px; font-weight: 600; color: #f0c674; }
.err { color: #e88; }

View File

@ -1,33 +0,0 @@
import { useState } from 'react'
import { View, Text } from '@tarojs/components'
import { useLoad } from '@tarojs/taro'
import { fetchDestination, type Destination } from '../../services/api'
import './index.scss'
export default function DestinationPage() {
const [d, setD] = useState<Destination | null>(null)
const [err, setErr] = useState('')
useLoad((q) => {
const slug = String(q.slug || '')
if (!slug) { setErr('缺少城市'); return }
fetchDestination(slug).then(setD).catch((e) => setErr(e.message || '加载失败'))
})
if (err) return <View className='detail'><Text className='err'>{err}</Text></View>
if (!d) return <View className='detail'><Text>加载中…</Text></View>
return (
<View className='detail'>
<Text className='name'>{d.name}</Text>
<Text className='meta'>{d.country}</Text>
<Text className='tag'>{d.tagline}</Text>
<View className='grid'>
<View className='cell'><Text className='k'>月生活费</Text><Text className='v'>¥{d.cost ?? '—'}</Text></View>
<View className='cell'><Text className='k'>网速</Text><Text className='v'>{d.speed ?? '—'} Mbps</Text></View>
<View className='cell'><Text className='k'>评分</Text><Text className='v'>{d.rating ?? '—'}</Text></View>
<View className='cell'><Text className='k'>气候</Text><Text className='v'>{d.climate || '—'}</Text></View>
</View>
</View>
)
}

View File

@ -1 +0,0 @@
export default definePageConfig({ navigationBarTitleText: '目的地' })

View File

@ -1,9 +0,0 @@
.list { height: 100vh; padding: 32px 28px 80px; box-sizing: border-box; }
.h { display: block; font-size: 40px; font-weight: 700; margin-bottom: 28px; }
.err { display: block; color: #e88; margin-bottom: 16px; }
.empty { display: block; color: #8a8694; margin-top: 40px; text-align: center; }
.card { background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px; margin-bottom: 20px; }
.name { display: block; font-size: 32px; font-weight: 600; }
.meta { display: block; margin-top: 10px; color: #a8a3b3; font-size: 24px; }
.tag { display: block; margin-top: 8px; color: #8a8694; font-size: 22px; }

View File

@ -1,34 +0,0 @@
import { useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { fetchDestinations, type Destination } from '../../services/api'
import './index.scss'
export default function DestinationsPage() {
const [items, setItems] = useState<Destination[]>([])
const [err, setErr] = useState('')
useLoad(() => {
fetchDestinations()
.then(setItems)
.catch((e) => setErr(e.message || '加载失败'))
})
return (
<ScrollView scrollY className='list'>
<Text className='h'>热门目的地</Text>
{err ? <Text className='err'>{err}</Text> : null}
{items.map((d) => (
<View
key={d.slug}
className='card'
onClick={() => Taro.navigateTo({ url: `/pages/destination/index?slug=${d.slug}` })}
>
<Text className='name'>{d.name}</Text>
<Text className='meta'>{d.country || ''} · ¥{d.cost ?? '—'} /月 · ★{d.rating ?? '—'}</Text>
<Text className='tag'>{d.tagline || d.climate || ''}</Text>
</View>
))}
</ScrollView>
)
}

View File

@ -1 +0,0 @@
export default definePageConfig({ navigationBarTitleText: '首页' })

View File

@ -1,12 +0,0 @@
.home { padding: 64px 40px 80px; min-height: 100vh; box-sizing: border-box; }
.brand { display: block; font-size: 56px; font-weight: 700; letter-spacing: 2px; }
.tagline { display: block; margin-top: 12px; color: #a8a3b3; font-size: 28px; }
.err { display: block; margin-top: 24px; color: #e88; font-size: 24px; }
.stats { display: flex; gap: 24px; margin: 56px 0 48px; }
.stat { flex: 1; background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px 16px; text-align: center; }
.n { display: block; font-size: 40px; font-weight: 700; color: #f0c674; }
.l { display: block; margin-top: 8px; font-size: 22px; color: #8a8694; }
.cta { margin-bottom: 20px; background: #f0c674 !important; color: #1a1520 !important; border-radius: 999px !important; font-weight: 600; }
.cta.ghost { background: transparent !important; color: #f4f0ea !important; border: 1px solid rgba(255,255,255,0.2) !important; }
.web { display: block; margin-top: 40px; text-align: center; color: #6a6575; font-size: 22px; }

View File

@ -1,32 +0,0 @@
import { useState } from 'react'
import { View, Text, Button } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { fetchStats } from '../../services/api'
import './index.scss'
export default function HomePage() {
const [stats, setStats] = useState<{ destinations?: number; members?: number; meetups?: number } | null>(null)
const [err, setErr] = useState('')
useLoad(() => {
fetchStats()
.then(setStats)
.catch((e) => setErr(e.message || '加载失败'))
})
return (
<View className='home'>
<Text className='brand'>nomadro</Text>
<Text className='tagline'>用一行代码环游世界</Text>
{err ? <Text className='err'>{err}</Text> : null}
<View className='stats'>
<View className='stat'><Text className='n'>{stats?.destinations ?? '—'}</Text><Text className='l'>目的地</Text></View>
<View className='stat'><Text className='n'>{stats?.members ?? '—'}</Text><Text className='l'>游民</Text></View>
<View className='stat'><Text className='n'>{stats?.meetups ?? '—'}</Text><Text className='l'>活动</Text></View>
</View>
<Button className='cta' onClick={() => Taro.switchTab({ url: '/pages/destinations/index' })}>探索城市</Button>
<Button className='cta ghost' onClick={() => Taro.switchTab({ url: '/pages/meetups/index' })}>同城活动</Button>
<Text className='web'>网页版 nomadweb.nomadro.com</Text>
</View>
)
}

View File

@ -1 +0,0 @@
export default definePageConfig({ navigationBarTitleText: '活动' })

View File

@ -1,9 +0,0 @@
.list { height: 100vh; padding: 32px 28px 80px; box-sizing: border-box; }
.h { display: block; font-size: 40px; font-weight: 700; margin-bottom: 28px; }
.err { display: block; color: #e88; margin-bottom: 16px; }
.empty { display: block; color: #8a8694; margin-top: 40px; text-align: center; }
.card { background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px; margin-bottom: 20px; }
.name { display: block; font-size: 32px; font-weight: 600; }
.meta { display: block; margin-top: 10px; color: #a8a3b3; font-size: 24px; }
.tag { display: block; margin-top: 8px; color: #8a8694; font-size: 22px; }

View File

@ -1,28 +0,0 @@
import { useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import { useLoad } from '@tarojs/taro'
import { fetchMeetups, type Meetup } from '../../services/api'
import './index.scss'
export default function MeetupsPage() {
const [items, setItems] = useState<Meetup[]>([])
const [err, setErr] = useState('')
useLoad(() => {
fetchMeetups().then(setItems).catch((e) => setErr(e.message || '加载失败'))
})
return (
<ScrollView scrollY className='list'>
<Text className='h'>游民活动</Text>
{err ? <Text className='err'>{err}</Text> : null}
{items.map((m) => (
<View key={m.id} className='card'>
<Text className='name'>{m.title}</Text>
<Text className='meta'>{m.city || '全球'} · {m.date || '待定'} · {m.attendees ?? 0} 人</Text>
</View>
))}
{!err && items.length === 0 ? <Text className='empty'>暂无活动</Text> : null}
</ScrollView>
)
}

View File

@ -1 +0,0 @@
export default definePageConfig({ navigationBarTitleText: '我的' })

View File

@ -1,5 +0,0 @@
.mine { padding: 48px 32px; }
.h { display: block; font-size: 40px; font-weight: 700; }
.hint { display: block; margin: 20px 0 40px; color: #8a8694; font-size: 26px; }
.cta { background: #f0c674 !important; color: #1a1520 !important; border-radius: 999px !important; }

View File

@ -1,15 +0,0 @@
import { View, Text, Button } from '@tarojs/components'
import Taro from '@tarojs/taro'
import './index.scss'
export default function MinePage() {
return (
<View className='mine'>
<Text className='h'>我的</Text>
<Text className='hint'>登录与会员请使用网页版完整能力</Text>
<Button className='cta' onClick={() => Taro.setClipboardData({ data: 'https://nomadweb.nomadro.com' })}>
复制网站链接
</Button>
</View>
)
}

View File

@ -1,38 +0,0 @@
import { request } from '../utils/request'
export type Destination = {
slug: string
name: string
country?: string
cost?: number
speed?: number
rating?: number
tagline?: string
image?: string
climate?: string
}
export type Meetup = {
id: string
title: string
city?: string
date?: string
cover?: string
attendees?: number
}
export function fetchDestinations() {
return request<Destination[]>('/destinations')
}
export function fetchDestination(slug: string) {
return request<Destination>(`/destinations/${encodeURIComponent(slug)}`)
}
export function fetchMeetups() {
return request<Meetup[]>('/meetups')
}
export function fetchStats() {
return request<{ destinations?: number; members?: number; meetups?: number }>('/stats')
}

View File

@ -1,8 +0,0 @@
declare const TARO_APP_API: string
export const API_BASE =
typeof TARO_APP_API === 'string' && TARO_APP_API
? TARO_APP_API
: 'https://nomadweb.nomadro.com/api/v1'
export const STORAGE_TOKEN = 'dymini_token'

View File

@ -1,28 +0,0 @@
import Taro from '@tarojs/taro'
import { API_BASE, STORAGE_TOKEN } from './config'
type Opts = {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
data?: Record<string, unknown>
header?: Record<string, string>
}
export async function request<T = unknown>(options: Opts): Promise<T> {
const token = Taro.getStorageSync(STORAGE_TOKEN)
const res = await Taro.request({
url: `${API_BASE}${options.url}`,
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.header || {}),
},
})
if (res.statusCode >= 400) {
const msg = (res.data as any)?.detail || (res.data as any)?.message || '请求失败'
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg))
}
return res.data as T
}

View File

@ -1,24 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"jsx": "react-jsx",
"strictNullChecks": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"typeRoots": [
"node_modules/@types"
]
},
"include": [
"./src",
"./types",
"./config"
]
}

View File

@ -1,3 +0,0 @@
declare function defineAppConfig(config: Record<string, unknown>): Record<string, unknown>
declare function definePageConfig(config: Record<string, unknown>): Record<string, unknown>
declare const TARO_APP_API: string

View File

@ -1,4 +1,2 @@
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Optional — shows Google Sign-In on /login when set
# NEXT_PUBLIC_GOOGLE_CLIENT_ID=

View File

@ -1,11 +1,62 @@
import Link from "next/link";
import type { Metadata } from "next"; import type { Metadata } from "next";
import AboutClient from "@/components/AboutClient"; import SiteShell from "@/components/SiteShell";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "关于 nomadro · About", title: "关于 nomadro",
description: "nomadro —— 用一行代码环游世界,数字游民旅居指南", description: "nomadro —— 用一行代码环游世界,数字游民旅居指南",
}; };
export default function AboutPage() { export default function AboutPage() {
return <AboutClient />; return (
<SiteShell>
<div className="legal-page about-page">
<nav className="detail-nav">
<Link href="/">← 返回首页</Link>
</nav>
<article className="legal-content">
<span className="section-tag">🌏 ABOUT</span>
<h1>关于 nomadro</h1>
<p className="legal-updated">用一行代码环游世界</p>
<section>
<h2>我们是谁</h2>
<p>
nomadro 是一个面向数字游民的旅居决策平台。我们把目的地数据、签证信息、费用估算、时区协作与社区经验集中在一起,帮你更快做出「下一站去哪」的决定。
</p>
</section>
<section>
<h2>能做什么</h2>
<ul className="about-list">
<li>🗺️ 探索全球热门游民城市与对比工具</li>
<li>🧮 计算生活成本、机票与启动金目标</li>
<li>📋 签证匹配、保险与上网方案参考</li>
<li>🗓️ 规划多城行程并分享给伙伴</li>
<li>🗣️ 落地短语、行李清单与活动日历</li>
</ul>
</section>
<section>
<h2>联系我们</h2>
<p>
合作与反馈:
<a href="mailto:hello@nomadro.com"> hello@nomadro.com</a>
</p>
<p>
开源仓库:
<a href="https://gitea.dsx2020.com/eric/nomadweb" target="_blank" rel="noopener noreferrer">
gitea.dsx2020.com/eric/nomadweb
</a>
</p>
</section>
<div className="about-actions">
<Link href="/#destinations" className="btn btn-primary">开始探索 🌍</Link>
<Link href="/login" className="btn btn-ghost">加入社区</Link>
</div>
</article>
</div>
</SiteShell>
);
} }

View File

@ -1,5 +1,4 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
import AiAssistantClient from "@/components/AiAssistantClient"; import AiAssistantClient from "@/components/AiAssistantClient";
@ -8,13 +7,10 @@ export const metadata: Metadata = {
description: "智能推荐下一站游民城市", description: "智能推荐下一站游民城市",
}; };
export const revalidate = 60; export default function AiPage() {
export default async function AiPage() {
const destinations = await api.getDestinations().catch(() => []);
return ( return (
<SiteShell showFooter> <SiteShell showFooter>
<AiAssistantClient destinations={destinations} /> <AiAssistantClient />
</SiteShell> </SiteShell>
); );
} }

View File

@ -1,27 +1,70 @@
import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
import BlogMarkdown from "@/components/BlogMarkdown";
import BlogReadingProgress from "@/components/BlogReadingProgress"; import BlogReadingProgress from "@/components/BlogReadingProgress";
import BlogDetailClient from "@/components/BlogDetailClient"; import ShareButton from "@/components/ShareButton";
export default async function BlogDetailPage({ params }: { params: Promise<{ slug: string }> }) { export default async function BlogDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; const { slug } = await params;
let post; let post;
let allPosts; let allPosts;
try { try {
[post, allPosts] = await Promise.all([api.getBlogPost(slug), api.getBlog()]); [post, allPosts] = await Promise.all([
api.getBlogPost(slug),
api.getBlog(),
]);
} catch { } catch {
notFound(); notFound();
} }
const related = allPosts const related = allPosts
.filter((p) => p.slug !== slug && p.tags.some((tag) => post.tags.includes(tag))) .filter((p) => p.slug !== slug && p.tags.some((t) => post.tags.includes(t)))
.slice(0, 3); .slice(0, 3);
return ( return (
<SiteShell> <SiteShell>
<BlogReadingProgress /> <BlogReadingProgress />
<BlogDetailClient post={post} related={related} /> <div className="detail-page">
<nav className="detail-nav">
<Link href="/#blog">← 返回博客</Link>
<ShareButton title={post.title} compact />
</nav>
<article className="blog-detail">
<div className="blog-detail-header">
<span className="blog-detail-emoji">{post.emoji}</span>
<div className="blog-meta">
<span>📅 {post.published_at}</span>
<span>⏱️ {post.read_time} 分钟阅读</span>
<span>✍️ {post.author}</span>
</div>
<h1>{post.title}</h1>
<div className="blog-tags">
{post.tags.map((t) => <span key={t} className="blog-tag">{t}</span>)}
</div>
</div>
<div className="blog-detail-content">
<BlogMarkdown content={post.content} />
</div>
</article>
{related.length > 0 && (
<section className="blog-related">
<h2>📚 相关文章</h2>
<div className="blog-related-grid">
{related.map((r) => (
<Link key={r.slug} href={`/blog/${r.slug}`} className="blog-related-card">
<span className="blog-related-emoji">{r.emoji}</span>
<h3>{r.title}</h3>
<p>{r.excerpt}</p>
<span className="blog-read-more">阅读 →</span>
</Link>
))}
</div>
</section>
)}
</div>
</SiteShell> </SiteShell>
); );
} }

View File

@ -1,28 +1,18 @@
"use client"; "use client";
import { Suspense, useEffect, useMemo, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
type DlFile = { format: string; label: string; url: string };
function isRealDownload(f: DlFile) {
const fmt = (f.format || "").toLowerCase();
return fmt === "pdf" || fmt === "epub" || (Boolean(f.url) && !f.url.startsWith("/book/read"));
}
function BookThanksInner() { function BookThanksInner() {
const { token } = useAuth(); const { token } = useAuth();
const { t } = useI18n();
const b = t.bookThanks;
const router = useRouter(); const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
const [status, setStatus] = useState<"checking" | "ok" | "pending" | "need-login">("checking"); const [status, setStatus] = useState<"checking" | "ok" | "pending" | "need-login">("checking");
const [files, setFiles] = useState<DlFile[]>([]); const [files, setFiles] = useState<{ format: string; label: string; url: string }[]>([]);
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
useEffect(() => { useEffect(() => {
@ -52,63 +42,45 @@ function BookThanksInner() {
} }
if (!cancelled) { if (!cancelled) {
setStatus("pending"); setStatus("pending");
setMsg(b.pending); setMsg("支付确认中,请稍后刷新;或联系支持核对订单。");
} }
} catch (e) { } catch (e) {
if (!cancelled) { if (!cancelled) {
setStatus("pending"); setStatus("pending");
setMsg(e instanceof Error ? e.message : b.statusFail); setMsg(e instanceof Error ? e.message : "无法确认购买状态");
} }
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [token, params, b.pending, b.statusFail]); }, [token, params]);
const downloads = useMemo(() => files.filter(isRealDownload), [files]);
const hasOnlineOnly = status === "ok" && downloads.length === 0;
return ( return (
<div className="container" style={{ padding: "48px 24px", maxWidth: 560 }}> <div className="container" style={{ padding: "48px 24px", maxWidth: 560 }}>
<nav className="detail-nav"> <nav className="detail-nav">
<Link href="/book">← {t.nav.ebook}</Link> <Link href="/book">← 电子书</Link>
</nav> </nav>
<h1>{b.title}</h1> <h1>下载版</h1>
{status === "need-login" && ( {status === "need-login" && (
<p> <p>
{b.needLogin}{" "} 请先 <Link href="/login?next=/book/thanks">登录</Link> 查看下载。
<Link href="/login?next=/book/thanks">{t.nav.login}</Link>
</p> </p>
)} )}
{status === "checking" && <p>{b.checking}</p>} {status === "checking" && <p>正在确认订单…</p>}
{status === "pending" && ( {status === "pending" && (
<div> <div>
<p>{msg}</p> <p>{msg}</p>
<button type="button" className="btn btn-primary" onClick={() => router.refresh()}> <button type="button" className="btn btn-primary" onClick={() => router.refresh()}>
{b.refresh} 刷新状态
</button> </button>
</div> </div>
)} )}
{hasOnlineOnly && ( {status === "ok" && (
<div> <div>
<p>{b.confirmedNoFiles}</p> <p>购买成功,可下载:</p>
<p className="section-desc">{b.confirmedHint}</p>
<div className="dating-empty-actions">
<Link href="/book/read" className="btn btn-primary">
{b.continueRead}
</Link>
<Link href="/feedback" className="btn btn-ghost">
{b.contact}
</Link>
</div>
</div>
)}
{status === "ok" && downloads.length > 0 && (
<div>
<p>{b.ready}</p>
<ul className="ebook-download-list"> <ul className="ebook-download-list">
{downloads.map((f) => ( {files.map((f) => (
<li key={f.format}> <li key={f.format}>
<a href={f.url} className="btn btn-primary btn-sm" target="_blank" rel="noreferrer"> <a href={f.url} className="btn btn-primary btn-sm" target="_blank" rel="noreferrer">
{f.label} {f.label}
@ -117,7 +89,7 @@ function BookThanksInner() {
))} ))}
</ul> </ul>
<p style={{ marginTop: 16 }}> <p style={{ marginTop: 16 }}>
<Link href="/book/read">{b.continueRead} →</Link> <Link href="/book/read">继续在线阅读 →</Link>
</p> </p>
</div> </div>
)} )}
@ -126,10 +98,9 @@ function BookThanksInner() {
} }
export default function BookThanksPage() { export default function BookThanksPage() {
const { t } = useI18n();
return ( return (
<SiteShell> <SiteShell>
<Suspense fallback={<div className="container" style={{ padding: 48 }}>{t.bookThanks.checking}</div>}> <Suspense fallback={<div className="container" style={{ padding: 48 }}>确认中…</div>}>
<BookThanksInner /> <BookThanksInner />
</Suspense> </Suspense>
</SiteShell> </SiteShell>

View File

@ -8,321 +8,6 @@ export const metadata: Metadata = {
}; };
const LOGS = [ const LOGS = [
{
date: "2026-09-04",
tag: "转化/信任打磨",
items: [
"订阅条中英校验与说明;Join 保存资料后直达匹配;定价免费 CTA 改走完善资料",
"VIP/电子书支付说明更诚实(支付宝微信、无文件时写清在线解锁);登录可接 Google(需配置 Client ID)",
"移动端隐藏易裁切的 hero 浮卡;活动后下一步补齐私信入口",
],
},
{
date: "2026-09-04",
tag: "首页/关于页",
items: [
"首页恢复「游民一天」与「游民心声」区块,中英文化并直达七天指南/活动/社区",
"关于页改为可切换语言,CTA 指向下一站、社区与旅居计划;静态帮助页主按钮高亮",
],
},
{
date: "2026-09-04",
tag: "学院/博客/社区/电子书",
items: [
"每日指南打通 7 天并中英导航;博客详情与电子书购买页中英文化",
"社区回复支持 Ctrl+Enter、字数与滚动定位;点赞失败可提示;假下载改为在线阅读引导",
],
},
{
date: "2026-09-04",
tag: "全站闭环增强",
items: [
"签证向导中英文化 + 结果直达城市/计划/咨询;课程跨模块上下一课;岗位申请改走赏金页",
"赏金申请通知发布者;视频去掉无效外链;探索导航增加游民地图入口",
],
},
{
date: "2026-09-04",
tag: "直播报名 · 私信角标",
items: [
"线上直播需登录并报名后进入;支持一键报名进房;显示活动时间;手机默认视频布局",
"右上角私信图标显示未读数",
],
},
{
date: "2026-09-04",
tag: "直播 iframe 修复",
items: [
"修复 MiroTalk/Lounge 被上游 CSP 拦截导致直播页空白;可新窗口打开视频/聊天",
],
},
{
date: "2026-09-04",
tag: "匹配资料 · 报名日历",
items: [
"匹配卡片可「查看资料」;喜欢页标题改为中性「喜欢」",
"线下报名成功自动下载日历;个人中心每场 RSVP 可加入日历",
],
},
{
date: "2026-09-04",
tag: "导航精简",
items: [
"私信与通知移出 Connect 菜单/页脚/个人中心快捷入口,统一用右上角 ✉️ / 🔔 图标进入",
],
},
{
date: "2026-09-04",
tag: "活动报名 · Connect 数据",
items: [
"报名成功 toast:线上/混合直达直播间,线下引导导出日历;报名确认写入通知箱",
"个人中心新增 Connect 数据条(喜欢我的 / 互相喜欢 / 已报名),可点进对应页面",
],
},
{
date: "2026-09-04",
tag: "匹配资料 · 私信头",
items: [
"会员页支持候选档案 /members/u1,可回赞并直达聊天;私信线程显示对方头像与资料入口",
"登录即注入匹配来赞与演示私信;会话 peer 字段规范化避免列表 500",
],
},
{
date: "2026-09-04",
tag: "匹配滑动 · 喜欢我的",
items: [
"匹配卡片支持左右/上滑手势与方向键;喜欢页区分「喜欢我的 / 我喜欢的」",
"加入匹配时自动注入演示私信与来赞,讨论回复补全",
],
},
{
date: "2026-09-04",
tag: "匹配页修复",
items: [
"修复 /dating 因后端错误检查不存在的 _profiles 而一直卡在「一键加入匹配」",
"登录后自动创建匹配资料并进入滑卡;VIP 状态读取同步修正",
],
},
{
date: "2026-09-04",
tag: "业务数据充实",
items: [
"目的地增至 12 城,签证/博客/活动/讨论/零活/岗位/视频/服务/会员地图全面扩容(均 10+)",
"种子 12 个演示账号(密码 demo123)含收藏与旅居计划;修复社区空库不重灌的问题",
],
},
{
date: "2026-09-03",
tag: "城市工具决策出口",
items: [
"命运转盘、网速、住宿、机票、周末灵感、落地清单、城市笔记结果区可写入计划并深链活动",
"落地清单 Meetup 任务直达同城活动;最近浏览补同城活动与计划入口;匹配器关闭清除 #matcher",
],
},
{
date: "2026-09-03",
tag: "工具转化 · 登录 CTA · 快捷键",
items: [
"启动金目标 / 签证停留倒计时可写入计划并深链城市;下一站加入计划区分已在行程",
"登录成功 toast 可直达回流页;悬浮反馈支持 Esc;站内 M 键打开首页智能匹配(/#matcher)",
],
},
{
date: "2026-09-03",
tag: "登录回流 · 匹配器体验",
items: [
"登录页识别数字游民/视频/岗位/AI/下一站/地图等回流目标,不再误标成个人中心",
"跑道计算器套用城市后可写入计划;首页智能匹配预加载 + 加载壳,支持 Esc 关闭",
],
},
{
date: "2026-09-03",
tag: "计算器 · 视频 · 分享回流",
items: [
"费用/对比/首月成本工具结果可写入计划并深链城市详情与同城活动",
"视频详情按城市接活动环;岗位收藏 toast、分享复制 toast、私信空态补下一跳",
],
},
{
date: "2026-09-03",
tag: "报告 · 工具 · 发布回流",
items: [
"数据报告:快照 toast 去下一站;排名/会员城市深链计划与同城活动",
"工具运行页结果旁增加计划/下一站/活动出口;afterTools 补活动环",
"发布赏金/发起活动/报名/加入匹配成功 toast 接回数字游民、社区或匹配;课程末课接赏金与活动",
],
},
{
date: "2026-09-03",
tag: "对比 · 收藏 · 反馈回流",
items: [
"对比写入计划 / 收藏城市 / 个人中心收藏入计划:toast 可打开计划或我的收藏",
"反馈、投稿、悬浮反馈成功后可去社区;视频稍后再看空态与收藏 toast 回流",
"通知页登录门补活动与社区出口;旅居助手写入计划 toast 可打开计划中心",
],
},
{
date: "2026-09-03",
tag: "地图 · 匹配 · 会员资料",
items: [
"智能匹配 / 下一站 / 世界地图写入计划后 toast 可打开计划中心;地图面板增加同城活动",
"会员资料与游民地图城市深链同城活动;加入资料 / 服务咨询成功 toast 接回匹配或社区",
],
},
{
date: "2026-09-03",
tag: "Toast CTA · Connect/Grow 出口",
items: [
"Toast 支持行动链接(写入计划/报名/发帖后可直达下一站);城市页加入计划可打开计划中心",
"社区帖文识别城市名深链同城活动;匹配/私信空态与登录门增加活动与社区出口",
"Grow 环:赏金→计划/活动,数字游民学院用 afterDigital 接回探索",
],
},
{
date: "2026-09-03",
tag: "Explore ↔ Connect 漏斗",
items: [
"城市页/对比/计划/下一站深链到同城活动;线上活动直达直播间",
"对比胜出城市一键活动 + 计划;NextStop / Live / 计划 RingNext 按情境分流",
"报名后展示同城社交建议;首页紧凑「接着探索」摘要(无足迹时隐藏)",
],
},
{
date: "2026-09-03",
tag: "工具箱 · AI 草稿",
items: [
"工具箱目录与分类标签支持中英切换;全局搜索工具结果随语言匹配",
"页脚品牌标语与下一站预算单位接入 i18n;旅居助手提问草稿本机保存",
],
},
{
date: "2026-09-03",
tag: "智能匹配 · 首页 chrome",
items: [
"首页智能匹配向导步骤/选项/结果与写入计划 toast 全面中英文化",
"快捷键帮助、游民小贴士、Cookie 条、新手引导、FAQ 空态接入 i18n",
],
},
{
date: "2026-09-03",
tag: "全局搜索 · 反馈 · 草稿",
items: [
"全局搜索:触发器/占位/结果类型与路径快捷入口全面中英文化",
"悬浮反馈、分享/收藏按钮、地图加计划 toast、登录校验接入 i18n",
"私信与服务咨询支持本机草稿;反馈 FAB 与反馈页共用草稿键",
],
},
{
date: "2026-09-03",
tag: "个人中心 · 计划导出",
items: [
"个人中心:成就徽章、统计、收藏/行程空态、快捷入口与头像提示全面中英文化",
"旅居计划:Markdown/纯文本导出与签证字段标签随语言切换",
"讨论回复支持本机草稿;匹配加入表单默认城市改为空白占位",
],
},
{
date: "2026-09-03",
tag: "城市深度 · 草稿 · 计划",
items: [
"城市详情深度区块(指南/画像/成本/评价等)全面中英文化;快照模板随语言切换",
"发起活动与意见反馈表单支持本机草稿自动保存",
"下一站匹配理由、对比快照、计划空态/排序按钮、私信线程 RingNext 补齐 i18n",
],
},
{
date: "2026-09-03",
tag: "下一站 · 社区 · 城市",
items: [
"下一站决策:预算/网速/气候/标签偏好本机记住;标签与气候中英展示",
"社区分类筛选与发帖分类中英标签;讨论回复提示与空态接入 i18n",
"城市详情:加入计划/分享/超预算提示与操作按钮全面中英文化",
],
},
{
date: "2026-09-03",
tag: "匹配 · 岗位 · 会员",
items: [
"远程岗位支持搜索/标签筛选与本机收藏",
"匹配意图、配额、空态与私信列表全面中英文化",
"会员资料表单草稿自动保存",
],
},
{
date: "2026-09-03",
tag: "赏金 · 服务 · 报告",
items: [
"赏金可收藏、申请说明本机草稿;发布赏金/内容投稿自动保存草稿",
"服务咨询与数据报告空态文案中英齐全;报告支持复制快照",
"赏金/投稿/服务校验提示全面接入 i18n",
],
},
{
date: "2026-09-03",
tag: "摘要 · 稍后再看 · 草稿",
items: [
"视频「稍后再看」本机收藏;列表与详情可一键存取",
"个人中心「今日游民摘要」汇总计划就绪、报名、视频与最近 AI 提问",
"社区发帖草稿自动保存;通知/反馈/地图/视频空态文案中英齐全",
],
},
{
date: "2026-09-03",
tag: "日历 · 对比 · 搜索",
items: [
"活动报名可导出 ICS(整表/单场);个人中心 RSVP 一键导入日历",
"城市对比:最近对比记录可恢复、复制对比快照",
"全局搜索空态展示最近浏览城市;通知设置补齐 RingNext 与文案",
],
},
{
date: "2026-09-03",
tag: "继续探索与个人中心",
items: [
"最近浏览城市:详情页自动记录,首页与个人中心可续看、一键对比",
"个人中心展示已报名活动(含直播入口)与环导航",
"旅居助手:本地提问历史、中英文案与对比/写入计划闭环",
"城市详情「复制快照」;VIP 支付成功页补齐下一步",
],
},
{
date: "2026-09-01",
tag: "环导航与文案",
items: [
"工具详情、发起活动、会员定价、学院课时、喜欢列表、成员主页补齐 RingNext",
"直播间门禁/布局、工具箱空态与搜索、活动登录提示接入中英文案",
"私信空态与发送失败提示、学院 VIP 提示与每日指南 i18n",
],
},
{
date: "2026-08-30",
tag: "决策与发现闭环",
items: [
"首页挂载快捷工具条;地图选城可对比/加计划;下一站活动按推荐城市筛选",
"赏金未登录直接引导登录;活动页支持 ?city= 筛选;视频详情 RingNext",
"电子书感谢页无下载链时引导在线阅读;社区发帖/发赏金/通知设置登录门",
],
},
{
date: "2026-08-30",
tag: "三环登录与导航",
items: [
"Connect:个人中心三环快捷入口、匹配加载态、发起活动/发赏金登录门、私信与登录提示",
"Explore:全站快捷键、首页 hash 锚点、对比空态、AI 推荐真正写入计划",
"Grow:导航补全服务/投稿、表单校验、学院空态与 VIP 课时直达开通",
],
},
{
date: "2026-08-30",
tag: "三环可用深化",
items: [
"产品信息架构:Explore / Connect / Grow 渐进披露,导航与空态对齐三条环",
"Connect:RSVP 需登录、通知铃铛、匹配配额、私信与 VIP 支付加固",
"Grow:学院 VIP 课时客户端解锁、赏金/投稿表单校验、电子书购买闭环",
"Explore:目的地对比入口、AI 快捷提问、计划空态、周报订阅诚实提示",
"边缘体验:404/错误页环导航、sitemap 补全、匹配空态、快捷键与贴士持久化",
],
},
{ {
date: "2026-08-29", date: "2026-08-29",
tag: "电子书融合", tag: "电子书融合",

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
import DigitalLessonClient from "@/components/DigitalLessonClient"; import DigitalLessonClient from "@/components/DigitalLessonClient";
import { api } from "@/lib/api";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "课程 · nomadro 学院", title: "课程 · nomadro 学院",
@ -14,9 +15,16 @@ export default async function DigitalLessonPage({
const { m, l } = await params; const { m, l } = await params;
const mi = Number(m); const mi = Number(m);
const li = Number(l); const li = Number(l);
let lesson = null;
let locked = false;
try {
lesson = await api.getDigitalLesson(mi, li);
} catch {
locked = true;
}
return ( return (
<SiteShell showFooter> <SiteShell showFooter>
<DigitalLessonClient moduleIndex={mi} lessonIndex={li} /> <DigitalLessonClient lesson={lesson} locked={locked} moduleIndex={mi} lessonIndex={li} />
</SiteShell> </SiteShell>
); );
} }

View File

@ -1,23 +1,29 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
import DigitalDayClient from "@/components/DigitalDayClient"; import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const day = await api.getDigitalDay(id).catch(() => null); const day = await api.getDigitalDay(id).catch(() => null);
return { title: day ? `${day.title} · nomadro` : "Daily guide · nomadro" }; return { title: day ? `${day.title} · nomadro` : "每日指南" };
} }
export default async function DigitalDayPage({ params }: { params: Promise<{ id: string }> }) { export default async function DigitalDayPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const day = await api.getDigitalDay(id).catch(() => null); const day = await api.getDigitalDay(id).catch(() => null);
if (!day) notFound(); if (!day) notFound();
return ( return (
<SiteShell showFooter> <SiteShell showFooter>
<DigitalDayClient id={id} title={day.title} body={day.body} /> <div className="legal-page">
<nav className="detail-nav"><Link href="/digital">← 游民学院</Link></nav>
<article className="legal-content reveal">
<span className="section-tag">📅 DAY {id}</span>
<h1>{day.title}</h1>
<p>{day.body}</p>
</article>
</div>
</SiteShell> </SiteShell>
); );
} }

View File

@ -1,40 +0,0 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import SiteShell from "@/components/SiteShell";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<SiteShell showFooter>
<div className="not-found-page">
<h1 className="not-found-title" style={{ fontSize: "2.5rem" }}>
出错了
</h1>
<p>页面暂时无法加载,可以重试或换一条航线</p>
<p className="not-found-sub">{error.digest ? `参考码 ${error.digest}` : "请稍后再试"}</p>
<div className="not-found-actions">
<button type="button" className="btn btn-primary" onClick={reset}>
重试
</button>
<Link href="/" className="btn">
首页
</Link>
<Link href="/feedback" className="btn btn-ghost">
反馈问题
</Link>
</div>
</div>
</SiteShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -9,10 +9,9 @@ import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { resolveAuthNext } from "@/lib/authNext"; import { resolveAuthNext } from "@/lib/authNext";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
import { GoogleSignInButton } from "@/components/GoogleSignInButton";
function LoginForm() { function LoginForm() {
const { login, register, demoLogin, googleLogin } = useAuth(); const { login, register, demoLogin } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
@ -28,27 +27,17 @@ function LoginForm() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const goAfterAuth = (msg: string) => { const goAfterAuth = (msg: string) => {
toast(msg, "success", { href: meta.path, label: meta.label }); toast(msg);
setTimeout(() => { router.replace(meta.path); }, 400); setTimeout(() => { router.replace(meta.path); }, 400);
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
if (mode === "register") {
if (!name.trim() || name.trim().length < 2) {
setError(t.login.nameMin);
return;
}
if (password.length < 6) {
setError(t.login.pwdMin);
return;
}
}
setLoading(true); setLoading(true);
const ok = mode === "login" const ok = mode === "login"
? await login(email, password) ? await login(email, password)
: await register(email, password, name.trim()); : await register(email, password, name);
setLoading(false); setLoading(false);
if (ok) { if (ok) {
goAfterAuth( goAfterAuth(
@ -66,15 +55,6 @@ function LoginForm() {
if (ok) goAfterAuth(t.login.demoToast); if (ok) goAfterAuth(t.login.demoToast);
}; };
const handleGoogle = async (idToken: string) => {
setError("");
setLoading(true);
const ok = await googleLogin(idToken);
setLoading(false);
if (ok) goAfterAuth(meta.welcomeToast);
else setError(t.login.googleFail);
};
const headerHint = mode === "login" ? meta.hint : t.login.registerHint; const headerHint = mode === "login" ? meta.hint : t.login.registerHint;
return ( return (
@ -132,21 +112,10 @@ function LoginForm() {
minLength={6} minLength={6}
autoComplete={mode === "login" ? "current-password" : "new-password"} autoComplete={mode === "login" ? "current-password" : "new-password"}
/> />
<button type="button" className="auth-pwd-toggle" onClick={() => setShowPwd(!showPwd)} aria-label={t.login.showPwd}> <button type="button" className="auth-pwd-toggle" onClick={() => setShowPwd(!showPwd)} aria-label="显示密码">
{showPwd ? "🙈" : "👁️"} {showPwd ? "🙈" : "👁️"}
</button> </button>
</div> </div>
{mode === "register" && (
<p className="auth-pwd-hint">
{password.length === 0
? t.login.pwdHint
: password.length < 6
? t.login.pwdNeed.replace("{n}", String(6 - password.length))
: password.length < 10
? t.login.pwdOk
: t.login.pwdStrong}
</p>
)}
</div> </div>
{error && <p className="auth-error">{error}</p>} {error && <p className="auth-error">{error}</p>}
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}> <button type="submit" className="btn btn-primary auth-submit" disabled={loading}>
@ -155,7 +124,6 @@ function LoginForm() {
</form> </form>
<div className="auth-divider"><span>{t.login.or}</span></div> <div className="auth-divider"><span>{t.login.or}</span></div>
<GoogleSignInButton disabled={loading} onCredential={handleGoogle} />
<button className="btn btn-ghost auth-demo-btn" type="button" onClick={handleDemo} disabled={loading}> <button className="btn btn-ghost auth-demo-btn" type="button" onClick={handleDemo} disabled={loading}>
{t.login.demo} {t.login.demo}
</button> </button>
@ -165,19 +133,10 @@ function LoginForm() {
); );
} }
function LoginFallback() {
const { t } = useI18n();
return (
<div className="auth-page">
<p className="plan-loading">{t.common.loading}</p>
</div>
);
}
export default function LoginPage() { export default function LoginPage() {
return ( return (
<SiteShell> <SiteShell>
<Suspense fallback={<LoginFallback />}> <Suspense fallback={<div className="auth-page"><p className="plan-loading">加载中…</p></div>}>
<LoginForm /> <LoginForm />
</Suspense> </Suspense>
</SiteShell> </SiteShell>

View File

@ -3,8 +3,8 @@ import SiteShell from "@/components/SiteShell";
import MemberMapClient from "@/components/MemberMapClient"; import MemberMapClient from "@/components/MemberMapClient";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Nomad map · 游民地图 · nomadro", title: "游民地图 · nomadro",
description: "Global nomad heatmap and live city weather · 全球游民分布与目的地实时天气", description: "全球游民分布与目的地实时天气",
}; };
export default function MapPage() { export default function MapPage() {

View File

@ -14,12 +14,7 @@ export default async function MeetupLivePage({ params }: { params: Promise<{ id:
return ( return (
<SiteShell showFooter={false}> <SiteShell showFooter={false}>
<MeetupLiveClient <MeetupLiveClient meetupId={id} title={title} />
meetupId={id}
title={title}
date={meetup?.date || ""}
time={meetup?.time || ""}
/>
</SiteShell> </SiteShell>
); );
} }

View File

@ -1,4 +1,3 @@
import { Suspense } from "react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
@ -16,9 +15,7 @@ export default async function MeetupsPage() {
return ( return (
<SiteShell showFooter> <SiteShell showFooter>
<Suspense fallback={<div className="container"><p className="dest-empty">加载活动…</p></div>}>
<MeetupsClient meetups={meetups} /> <MeetupsClient meetups={meetups} />
</Suspense>
</SiteShell> </SiteShell>
); );
} }

View File

@ -1,4 +1,3 @@
import { Suspense } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
@ -10,9 +9,7 @@ export default async function MemberPage({ params }: { params: Promise<{ id: str
if (!profile) notFound(); if (!profile) notFound();
return ( return (
<SiteShell showFooter> <SiteShell showFooter>
<Suspense fallback={<div className="container dest-empty">…</div>}>
<MemberProfileClient profile={profile} /> <MemberProfileClient profile={profile} />
</Suspense>
</SiteShell> </SiteShell>
); );
} }

View File

@ -3,7 +3,7 @@ import SiteShell from "@/components/SiteShell";
export default function NotFound() { export default function NotFound() {
return ( return (
<SiteShell showFooter> <SiteShell>
<div className="not-found-page"> <div className="not-found-page">
<div className="not-found-compass" aria-hidden="true"> <div className="not-found-compass" aria-hidden="true">
<span className="compass-ring">🧭</span> <span className="compass-ring">🧭</span>
@ -11,23 +11,10 @@ export default function NotFound() {
</div> </div>
<h1 className="not-found-title">404</h1> <h1 className="not-found-title">404</h1>
<p>哎呀,这片海域没有标记的岛屿</p> <p>哎呀,这片海域没有标记的岛屿</p>
<p className="not-found-sub">你可能走错了航线,从三条产品环任选一条继续</p> <p className="not-found-sub">你可能走错了航线,让我们帮你回到正轨</p>
<div className="not-found-actions"> <div className="not-found-actions">
<Link href="/" className="btn btn-primary"> <Link href="/" className="btn btn-primary">返回首页 🏠</Link>
返回首页 <Link href="/#destinations" className="btn btn-ghost">探索目的地 🌍</Link>
</Link>
<Link href="/next-stop" className="btn">
智能下一站
</Link>
<Link href="/meetups" className="btn">
游民活动
</Link>
<Link href="/digital" className="btn">
游民学院
</Link>
<Link href="/help" className="btn btn-ghost">
帮助中心
</Link>
</div> </div>
</div> </div>
</SiteShell> </SiteShell>

View File

@ -5,21 +5,14 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { Destination, Meetup, ProfileStats, TripItem } from "@/lib/types"; import type { Destination, ProfileStats, TripItem } from "@/lib/types";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
import FavoriteButton from "@/components/FavoriteButton"; import FavoriteButton from "@/components/FavoriteButton";
import RecentlyViewed from "@/components/RecentlyViewed";
import NomadDigest from "@/components/NomadDigest";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
import { useI18n } from "@/lib/i18n";
import type { Dict } from "@/lib/i18n";
import { loadTrip } from "@/lib/tripStorage"; import { loadTrip } from "@/lib/tripStorage";
import { loadPlanMeta, MOVE_CHECKLIST, type PlanMeta } from "@/lib/planMeta"; import { loadPlanMeta, MOVE_CHECKLIST, type PlanMeta } from "@/lib/planMeta";
import { PLAN_SYNC_EVENT } from "@/lib/planSync"; import { PLAN_SYNC_EVENT } from "@/lib/planSync";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { buildMeetupIcs, downloadMeetupIcs } from "@/lib/meetupIcs";
interface Badge { interface Badge {
emoji: string; emoji: string;
@ -28,26 +21,15 @@ interface Badge {
earned: boolean; earned: boolean;
} }
function getBadges( function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: number, readiness: number): Badge[] {
p: Dict["profile"], const tripMonths = trip.reduce((s, t) => s + t.months, 0);
stats: ProfileStats | null,
trip: TripItem[],
favCount: number,
readiness: number
): Badge[] {
const tripMonths = trip.reduce((s, item) => s + item.months, 0);
return [ return [
{ emoji: "🌱", label: p.badgeNewbie, desc: p.badgeNewbieDesc, earned: true }, { emoji: "🌱", label: "初出茅庐", desc: "加入 nomadro", earned: true },
{ emoji: "❤️", label: p.badgeCollector, desc: p.badgeCollectorDesc, earned: favCount >= 3 }, { emoji: "❤️", label: "收藏家", desc: "收藏 3+ 目的地", earned: favCount >= 3 },
{ emoji: "🗺️", label: p.badgePlanner, desc: p.badgePlannerDesc, earned: trip.length >= 3 }, { emoji: "🗺️", label: "行程达人", desc: "规划 3+ 城市行程", earned: trip.length >= 3 },
{ emoji: "📅", label: p.badgeLongStay, desc: p.badgeLongStayDesc, earned: tripMonths >= 6 }, { emoji: "📅", label: "长期旅居", desc: "行程总计 6+ 个月", earned: tripMonths >= 6 },
{ emoji: "✅", label: p.badgeReady, desc: p.badgeReadyDesc, earned: readiness >= 50 }, { emoji: "✅", label: "出发就绪", desc: "就绪清单 ≥ 50%", earned: readiness >= 50 },
{ { emoji: "🌍", label: "环球游民", desc: "探索 5+ 城市", earned: (stats?.destinations_explored ?? 0) >= 5 },
emoji: "🌍",
label: p.badgeGlobal,
desc: p.badgeGlobalDesc,
earned: (stats?.destinations_explored ?? 0) >= 5,
},
]; ];
} }
@ -55,24 +37,19 @@ export default function ProfilePage() {
const { user, token, favorites, logout, planSyncStatus, refreshUser } = useAuth(); const { user, token, favorites, logout, planSyncStatus, refreshUser } = useAuth();
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n();
const rings = useRingSteps();
const [stats, setStats] = useState<ProfileStats | null>(null); const [stats, setStats] = useState<ProfileStats | null>(null);
const [favoriteDests, setFavoriteDests] = useState<Destination[]>([]); const [favoriteDests, setFavoriteDests] = useState<Destination[]>([]);
const [rsvps, setRsvps] = useState<Meetup[]>([]);
const [connect, setConnect] = useState({ likes: 0, matches: 0 });
const [trip, setTrip] = useState<TripItem[]>([]); const [trip, setTrip] = useState<TripItem[]>([]);
const [meta, setMeta] = useState<PlanMeta | null>(null); const [meta, setMeta] = useState<PlanMeta | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploadingAvatar, setUploadingAvatar] = useState(false); const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [vip, setVip] = useState(false);
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
e.target.value = ""; e.target.value = "";
if (!file || !token) return; if (!file || !token) return;
if (file.size > 5 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
toast(t.profile.avatarTooBig, "error"); toast("图片请小于 5MB", "error");
return; return;
} }
setUploadingAvatar(true); setUploadingAvatar(true);
@ -80,9 +57,9 @@ export default function ProfilePage() {
const uploaded = await api.uploadMedia(token, file, "avatar"); const uploaded = await api.uploadMedia(token, file, "avatar");
await api.updateProfile(token, { avatar: uploaded.url }); await api.updateProfile(token, { avatar: uploaded.url });
await refreshUser?.(); await refreshUser?.();
toast(t.profile.avatarOk, "success"); toast("头像已更新", "success");
} catch (err) { } catch (err) {
toast(err instanceof Error ? err.message : t.profile.avatarFail, "error"); toast(err instanceof Error ? err.message : "上传失败", "error");
} finally { } finally {
setUploadingAvatar(false); setUploadingAvatar(false);
} }
@ -95,34 +72,16 @@ export default function ProfilePage() {
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
router.push("/login?next=/profile"); router.push("/login");
return; return;
} }
setLoading(true); setLoading(true);
Promise.all([ Promise.all([
api.getProfileStats(token), api.getProfileStats(token),
favorites.length > 0 ? api.getFavoriteDestinations(token) : Promise.resolve([]), favorites.length > 0 ? api.getFavoriteDestinations(token) : Promise.resolve([]),
api.getVipStatus(token).catch(() => ({ vip: false, expires_at: 0 })), ]).then(([s, f]) => {
Promise.all([
api.getMyRsvps(token).catch(() => ({ ids: [] as string[] })),
api.getMeetups().catch(() => [] as Meetup[]),
]).then(([mine, all]) => {
const idSet = new Set(mine.ids || []);
return all.filter((m) => idSet.has(m.id)).slice(0, 6);
}),
Promise.all([
api.getMatchLikesReceived(token).catch(() => []),
api.getMutualMatches(token).catch(() => ({ items: [] })),
]).then(([likes, matches]) => ({
likes: likes.length,
matches: matches.items?.length ?? 0,
})),
]).then(([s, f, v, myMeetups, connectStats]) => {
setStats(s); setStats(s);
setFavoriteDests(f); setFavoriteDests(f);
setVip(Boolean(v.vip));
setRsvps(myMeetups);
setConnect(connectStats);
}).catch(() => {}).finally(() => setLoading(false)); }).catch(() => {}).finally(() => setLoading(false));
refreshPlan(); refreshPlan();
@ -130,37 +89,31 @@ export default function ProfilePage() {
return () => window.removeEventListener(PLAN_SYNC_EVENT, refreshPlan); return () => window.removeEventListener(PLAN_SYNC_EVENT, refreshPlan);
}, [token, favorites, router]); }, [token, favorites, router]);
const totalMonths = trip.reduce((s, item) => s + item.months, 0); const totalMonths = trip.reduce((s, t) => s + t.months, 0);
const totalCost = trip.reduce((s, item) => s + item.cost * item.months, 0); const totalCost = trip.reduce((s, t) => s + t.cost * t.months, 0);
const avgMonth = totalMonths > 0 ? Math.round(totalCost / totalMonths) : 0; const avgMonth = totalMonths > 0 ? Math.round(totalCost / totalMonths) : 0;
const checked = MOVE_CHECKLIST.filter((c) => meta?.checklist[c.id]).length; const checked = MOVE_CHECKLIST.filter((c) => meta?.checklist[c.id]).length;
const readiness = Math.round((checked / MOVE_CHECKLIST.length) * 100); const readiness = Math.round((checked / MOVE_CHECKLIST.length) * 100);
const budgetOk = !meta || meta.monthlyBudget <= 0 || avgMonth <= 0 || avgMonth <= meta.monthlyBudget; const budgetOk = !meta || meta.monthlyBudget <= 0 || avgMonth <= 0 || avgMonth <= meta.monthlyBudget;
const badges = useMemo( const badges = useMemo(
() => getBadges(t.profile, stats, trip, favoriteDests.length, readiness), () => getBadges(stats, trip, favoriteDests.length, readiness),
[t.profile, stats, trip, favoriteDests.length, readiness] [stats, trip, favoriteDests.length, readiness]
); );
const earnedCount = badges.filter((b) => b.earned).length; const earnedCount = badges.filter((b) => b.earned).length;
const favsNotInTrip = favoriteDests.filter((d) => !trip.some((item) => item.slug === d.slug)); const favsNotInTrip = favoriteDests.filter((d) => !trip.some((t) => t.slug === d.slug));
const addFavsToPlan = () => { const addFavsToPlan = () => {
if (favsNotInTrip.length === 0) { if (favsNotInTrip.length === 0) {
toast(t.profile.favsAlready, "info", { href: "/plan", label: t.profile.openPlan }); toast("收藏城市都已在计划中", "info");
return; return;
} }
const { added } = mergeDestinationsIntoTrip(favsNotInTrip, 1); const { added } = mergeDestinationsIntoTrip(favsNotInTrip, 1);
refreshPlan(); refreshPlan();
toast(t.profile.favsAdded.replace("{n}", String(added)), "success", { toast(`已将 ${added} 座收藏城市写入计划`);
href: "/plan",
label: t.profile.openPlan,
});
}; };
const planTitle =
meta?.title && meta.title !== "我的旅居计划" ? meta.title : t.plan.defaultTitle;
if (!user) return null; if (!user) return null;
return ( return (
@ -175,7 +128,7 @@ export default function ProfilePage() {
<div className="profile-avatar">{user.avatar || "🧑‍💻"}</div> <div className="profile-avatar">{user.avatar || "🧑‍💻"}</div>
)} )}
<label className="profile-avatar-upload"> <label className="profile-avatar-upload">
{uploadingAvatar ? t.profile.uploading : t.profile.changeAvatar} {uploadingAvatar ? "上传中…" : "更换头像"}
<input <input
type="file" type="file"
accept="image/jpeg,image/png,image/webp,image/gif" accept="image/jpeg,image/png,image/webp,image/gif"
@ -188,19 +141,16 @@ export default function ProfilePage() {
<div className="profile-info"> <div className="profile-info">
<h1>{user.name}</h1> <h1>{user.name}</h1>
<p>{user.email}</p> <p>{user.email}</p>
<div className="profile-badges-row">
{stats && <span className="profile-level">{stats.nomad_level}</span>} {stats && <span className="profile-level">{stats.nomad_level}</span>}
{vip && <span className="profile-vip-badge">✨ VIP</span>}
</div>
<span className={`plan-sync-pill ${planSyncStatus}`} style={{ marginTop: 8, display: "inline-flex" }}> <span className={`plan-sync-pill ${planSyncStatus}`} style={{ marginTop: 8, display: "inline-flex" }}>
{planSyncStatus === "syncing" && t.profile.syncing} {planSyncStatus === "syncing" && "计划同步中…"}
{planSyncStatus === "synced" && t.profile.synced} {planSyncStatus === "synced" && "✓ 计划已云端同步"}
{planSyncStatus === "offline" && t.profile.offline} {planSyncStatus === "offline" && "计划仅本机"}
{planSyncStatus === "idle" && t.profile.loggedIn} {planSyncStatus === "idle" && "账号已登录"}
</span> </span>
</div> </div>
<button className="btn btn-ghost" onClick={() => { logout(); router.push("/"); }}> <button className="btn btn-ghost" onClick={() => { logout(); router.push("/"); }}>
{t.profile.logout} 退出登录
</button> </button>
</div> </div>
@ -209,120 +159,82 @@ export default function ProfilePage() {
<div className="profile-stat-card"> <div className="profile-stat-card">
<span className="stat-emoji">❤️</span> <span className="stat-emoji">❤️</span>
<strong>{stats.favorites_count}</strong> <strong>{stats.favorites_count}</strong>
<span>{t.profile.statFavs}</span> <span>收藏目的地</span>
</div> </div>
<div className="profile-stat-card"> <div className="profile-stat-card">
<span className="stat-emoji">🗓️</span> <span className="stat-emoji">🗓️</span>
<strong>{trip.length}</strong> <strong>{trip.length}</strong>
<span>{t.profile.statCities}</span> <span>计划城市</span>
</div> </div>
<div className="profile-stat-card"> <div className="profile-stat-card">
<span className="stat-emoji">✅</span> <span className="stat-emoji">✅</span>
<strong>{readiness}%</strong> <strong>{readiness}%</strong>
<span>{t.profile.statReady}</span> <span>出发就绪</span>
</div> </div>
<div className="profile-stat-card"> <div className="profile-stat-card">
<span className="stat-emoji">🏅</span> <span className="stat-emoji">🏅</span>
<strong>{earnedCount}/{badges.length}</strong> <strong>{earnedCount}/{badges.length}</strong>
<span>{t.profile.statBadges}</span> <span>成就徽章</span>
</div> </div>
</div> </div>
)} )}
<div className="profile-connect-block">
<h2 className="profile-connect-title">{t.profile.connectStrip}</h2>
<div className="profile-stats-grid profile-connect-stats">
<Link href="/dating/likes" className="profile-stat-card profile-stat-link">
<span className="stat-emoji">💕</span>
<strong>{connect.likes}</strong>
<span>{t.profile.statLikes}</span>
</Link>
<Link href="/dating/likes" className="profile-stat-card profile-stat-link">
<span className="stat-emoji">✨</span>
<strong>{connect.matches}</strong>
<span>{t.profile.statMatches}</span>
</Link>
<Link href="/meetups" className="profile-stat-card profile-stat-link">
<span className="stat-emoji">🎉</span>
<strong>{rsvps.length}</strong>
<span>{t.profile.statRsvps}</span>
</Link>
</div>
</div>
<NomadDigest rsvpCount={rsvps.length} upcoming={rsvps} />
<section className="profile-trip"> <section className="profile-trip">
<div className="profile-section-head"> <div className="profile-section-head">
<h2>🗓️ {planTitle}</h2> <h2>🗓️ {meta?.title || "我的旅居计划"}</h2>
<Link href="/plan" className="btn btn-primary btn-sm">{t.profile.openPlan}</Link> <Link href="/plan" className="btn btn-primary btn-sm">打开计划中心</Link>
</div> </div>
{trip.length === 0 ? ( {trip.length === 0 ? (
<div className="profile-empty"> <div className="profile-empty">
<span style={{ fontSize: "2.5rem" }}>🗺️</span> <span style={{ fontSize: "2.5rem" }}>🗺️</span>
<p>{t.profile.tripEmpty}</p> <p>还没有规划行程</p>
<Link href="/plan" className="btn btn-primary">{t.profile.goPlan}</Link> <Link href="/plan" className="btn btn-primary">去规划 →</Link>
</div> </div>
) : ( ) : (
<div className="profile-trip-list"> <div className="profile-trip-list">
{(meta?.startMonth || meta?.monthlyBudget) && ( {(meta?.startMonth || meta?.monthlyBudget) && (
<div className="profile-plan-meta"> <div className="profile-plan-meta">
{meta?.startMonth && ( {meta?.startMonth && <span>出发 {meta.startMonth}</span>}
<span>{t.profile.startLabel.replace("{month}", meta.startMonth)}</span>
)}
{meta && meta.monthlyBudget > 0 && ( {meta && meta.monthlyBudget > 0 && (
<span className={!budgetOk ? "plan-warn-text" : undefined}> <span className={!budgetOk ? "plan-warn-text" : undefined}>
{t.profile.budgetLabel.replace("{budget}", meta.monthlyBudget.toLocaleString())} 月预算 ¥{meta.monthlyBudget.toLocaleString()}
{avgMonth > 0 && {avgMonth > 0 && ` · 均月 ¥${avgMonth.toLocaleString()}`}
t.profile.avgMonthLabel.replace("{avg}", avgMonth.toLocaleString())}
</span> </span>
)} )}
<span> <span>就绪 {checked}/{MOVE_CHECKLIST.length}</span>
{t.profile.readyCount
.replace("{checked}", String(checked))
.replace("{total}", String(MOVE_CHECKLIST.length))}
</span>
</div> </div>
)} )}
{trip.map((item, i) => ( {trip.map((t, i) => (
<div key={item.slug} className="profile-trip-item"> <div key={t.slug} className="profile-trip-item">
<span className="trip-item-num">{i + 1}</span> <span className="trip-item-num">{i + 1}</span>
<span>{item.emoji}</span> <span>{t.emoji}</span>
<div> <div>
<strong>{item.name}, {item.country}</strong> <strong>{t.name}, {t.country}</strong>
<span> <span>{t.months} 个月 · ¥{(t.cost * t.months).toLocaleString()}</span>
{t.profile.monthsCost
.replace("{months}", String(item.months))
.replace("{cost}", (item.cost * item.months).toLocaleString())}
</span>
</div> </div>
</div> </div>
))} ))}
<div className="profile-trip-total"> <div className="profile-trip-total">
{t.profile.totalLine}{" "} 总计 <strong>{totalMonths} 个月</strong> ·
<strong>
{totalMonths} {t.profile.monthsUnit}
</strong>{" "}
·
<strong className="gradient-text"> ¥{totalCost.toLocaleString()}</strong> <strong className="gradient-text"> ¥{totalCost.toLocaleString()}</strong>
</div> </div>
<div className="profile-trip-actions"> <div className="profile-trip-actions">
{trip.length >= 2 && ( {trip.length >= 2 && (
<Link <Link
href={`/compare?cities=${trip.map((item) => item.slug).slice(0, 4).join(",")}`} href={`/compare?cities=${trip.map((t) => t.slug).slice(0, 4).join(",")}`}
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"
> >
{t.profile.compareTrip} 对比行程城市
</Link> </Link>
)} )}
<Link href="/plan" className="btn btn-ghost btn-sm">{t.profile.keepEditing}</Link> <Link href="/plan" className="btn btn-ghost btn-sm">继续编辑 →</Link>
</div> </div>
</div> </div>
)} )}
</section> </section>
<section className="profile-badges"> <section className="profile-badges">
<h2>{t.profile.achievements}</h2> <h2>🏅 游民成就</h2>
<div className="badge-grid"> <div className="badge-grid">
{badges.map((b) => ( {badges.map((b) => (
<div key={b.label} className={`badge-card${b.earned ? " earned" : " locked"}`} title={b.desc}> <div key={b.label} className={`badge-card${b.earned ? " earned" : " locked"}`} title={b.desc}>
@ -336,20 +248,20 @@ export default function ProfilePage() {
<section className="profile-favorites"> <section className="profile-favorites">
<div className="profile-section-head"> <div className="profile-section-head">
<h2>{t.profile.myFavs}</h2> <h2>❤️ 我的收藏</h2>
{favsNotInTrip.length > 0 && ( {favsNotInTrip.length > 0 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={addFavsToPlan}> <button type="button" className="btn btn-ghost btn-sm" onClick={addFavsToPlan}>
{t.profile.favsToPlan.replace("{n}", String(favsNotInTrip.length))} 收藏写入计划({favsNotInTrip.length})
</button> </button>
)} )}
</div> </div>
{loading ? ( {loading ? (
<p className="profile-loading">{t.profile.loading}</p> <p className="profile-loading">加载中...</p>
) : favoriteDests.length === 0 ? ( ) : favoriteDests.length === 0 ? (
<div className="profile-empty"> <div className="profile-empty">
<span style={{ fontSize: "3rem" }}>🌍</span> <span style={{ fontSize: "3rem" }}>🌍</span>
<p>{t.profile.favsEmpty}</p> <p>还没有收藏目的地</p>
<Link href="/#destinations" className="btn btn-primary">{t.profile.goExplore}</Link> <Link href="/#destinations" className="btn btn-primary">去探索 →</Link>
</div> </div>
) : ( ) : (
<div className="profile-fav-grid"> <div className="profile-fav-grid">
@ -358,10 +270,7 @@ export default function ProfilePage() {
<Link href={`/destinations/${d.slug}`}> <Link href={`/destinations/${d.slug}`}>
<span className="fav-emoji">{d.emoji}</span> <span className="fav-emoji">{d.emoji}</span>
<h3>{d.name}, {d.country}</h3> <h3>{d.name}, {d.country}</h3>
<p> <p>💰 ¥{d.cost.toLocaleString()}/月 · ⭐ {d.rating}</p>
💰 ¥{d.cost.toLocaleString()}
{t.profile.perMonth} · ⭐ {d.rating}
</p>
</Link> </Link>
<FavoriteButton slug={d.slug} /> <FavoriteButton slug={d.slug} />
</div> </div>
@ -370,98 +279,17 @@ export default function ProfilePage() {
)} )}
</section> </section>
<section className="profile-rsvps">
<div className="profile-section-head">
<h2>🎉 {t.profile.myRsvps}</h2>
<div className="profile-section-actions">
{rsvps.length > 0 && (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
const ics = buildMeetupIcs(rsvps, "nomadro RSVPs");
if (!ics) {
toast(t.meetups.exportCalFail, "info");
return;
}
downloadMeetupIcs("nomadro-rsvps.ics", ics);
toast(t.meetups.exportCalDone);
}}
>
📅 {t.profile.exportRsvps}
</button>
)}
<Link href="/meetups" className="btn btn-ghost btn-sm">{t.nav.meetups}</Link>
</div>
</div>
{rsvps.length === 0 ? (
<div className="profile-empty">
<p>{t.profile.rsvpEmpty}</p>
<Link href="/meetups" className="btn btn-primary btn-sm">{t.profile.goMeetups}</Link>
</div>
) : (
<div className="profile-rsvp-list">
{rsvps.map((m) => (
<div key={m.id} className="profile-rsvp-card">
<span aria-hidden>{m.emoji}</span>
<div>
<strong>{m.title}</strong>
<p>
{m.city} · {m.date} {m.time}
</p>
</div>
<div className="profile-rsvp-actions">
{(m.mode === "online" || m.mode === "hybrid") && (
<Link href={`/meetups/${m.id}/live`} className="btn btn-sm btn-primary">
{t.profile.openLive}
</Link>
)}
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={() => {
const ics = buildMeetupIcs([m], m.title);
if (!ics) {
toast(t.meetups.exportCalFail, "info");
return;
}
downloadMeetupIcs(`nomadro-${m.id}.ics`, ics);
toast(t.meetups.exportCalDone);
}}
>
{t.meetups.addToCal}
</button>
</div>
</div>
))}
</div>
)}
</section>
<RecentlyViewed compact />
<section className="profile-quick"> <section className="profile-quick">
<h2>{t.profile.quickLinks}</h2> <h2>🚀 快捷入口</h2>
<div className="profile-quick-grid"> <div className="profile-quick-grid">
<Link href="/#destinations" className="quick-card">{t.profile.qDest}</Link> <Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
<Link href="/plan" className="quick-card">{t.profile.qPlan}</Link> <Link href="/plan" className="quick-card">🗓️ 旅居计划</Link>
<Link href="/compare" className="quick-card">{t.profile.qCompare}</Link> <Link href="/compare" className="quick-card">⚖️ 城市对比</Link>
<Link href="/next-stop" className="quick-card">{t.profile.qNext}</Link> <Link href="/#visa" className="quick-card">📋 签证指南</Link>
<Link href="/meetups" className="quick-card">{t.profile.qMeetups}</Link> <Link href="/#blog" className="quick-card">📝 阅读博客</Link>
<Link href="/dating" className="quick-card">{t.profile.qDating}</Link> <Link href="/tools" className="quick-card">🛠️ 工具箱</Link>
<Link href="/join" className="quick-card">{vip ? t.profile.qVipMember : t.profile.qVip}</Link>
<Link href="/digital" className="quick-card">{t.profile.qDigital}</Link>
<Link href="/ai" className="quick-card">{t.profile.qAi}</Link>
<Link href="/tools" className="quick-card">{t.profile.qTools}</Link>
</div> </div>
</section> </section>
<RingNext
steps={[
...rings.afterDiscover,
{ href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
].slice(0, 3)}
/>
</div> </div>
</SiteShell> </SiteShell>
); );

View File

@ -35,24 +35,11 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: `${SITE}/contact`, lastModified: now, changeFrequency: "yearly", priority: 0.4 }, { url: `${SITE}/contact`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
{ url: `${SITE}/help`, lastModified: now, changeFrequency: "yearly", priority: 0.4 }, { url: `${SITE}/help`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
{ url: `${SITE}/terms`, lastModified: now, changeFrequency: "yearly", priority: 0.3 }, { url: `${SITE}/terms`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
{ url: `${SITE}/digital`, lastModified: now, changeFrequency: "weekly", priority: 0.85 }, { url: `${SITE}/join`, lastModified: now, changeFrequency: "monthly", priority: 0.75 },
{ url: `${SITE}/digital/course`, lastModified: now, changeFrequency: "weekly", priority: 0.8 }, { url: `${SITE}/digital/course`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE}/digital/jobs`, lastModified: now, changeFrequency: "weekly", priority: 0.75 }, { url: `${SITE}/digital/jobs`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
{ url: `${SITE}/digital/day/1`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/2`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/3`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/4`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/5`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/6`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/7`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/book`, lastModified: now, changeFrequency: "weekly", priority: 0.85 }, { url: `${SITE}/book`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
{ url: `${SITE}/book/read`, lastModified: now, changeFrequency: "weekly", priority: 0.8 }, { url: `${SITE}/book/read`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE}/support`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${SITE}/careers`, lastModified: now, changeFrequency: "monthly", priority: 0.45 },
{ url: `${SITE}/safety`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
{ url: `${SITE}/guidelines`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
{ url: `${SITE}/cookies`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
{ url: `${SITE}/press`, lastModified: now, changeFrequency: "yearly", priority: 0.35 },
{ url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, { url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 }, { url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
{ url: `${SITE}/about`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, { url: `${SITE}/about`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },

View File

@ -2,8 +2,8 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { localizedToolCategories, localizedToolLinks } from "@/lib/toolCopy";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
@ -12,28 +12,25 @@ const FEATURED = ["weekend", "day-plan", "bill-split", "mood", "scam-alerts", "d
/** Utility drawer — not a product mega-menu. */ /** Utility drawer — not a product mega-menu. */
export default function ToolsHubPage() { export default function ToolsHubPage() {
const { t, locale } = useI18n(); const { t } = useI18n();
const rings = useRingSteps(); const rings = useRingSteps();
const [filter, setFilter] = useState("all"); const [filter, setFilter] = useState("all");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const tools = useMemo(() => localizedToolLinks(locale), [locale]);
const categories = useMemo(() => localizedToolCategories(locale), [locale]);
const featured = useMemo( const featured = useMemo(
() => FEATURED.map((id) => tools.find((tool) => tool.id === id)).filter((tool): tool is NonNullable<typeof tool> => !!tool), () => FEATURED.map((id) => TOOL_LINKS.find((tool) => tool.id === id)).filter((tool): tool is NonNullable<typeof tool> => !!tool),
[tools] []
); );
const filtered = useMemo(() => { const filtered = useMemo(() => {
let list = filter === "all" ? tools : tools.filter((tool) => tool.category === filter); let list = filter === "all" ? TOOL_LINKS : TOOL_LINKS.filter((tool) => tool.category === filter);
if (q.trim()) { if (q.trim()) {
const s = q.toLowerCase(); const s = q.toLowerCase();
list = list.filter((tool) => tool.title.toLowerCase().includes(s) || tool.desc.toLowerCase().includes(s)); list = list.filter((tool) => tool.title.toLowerCase().includes(s) || tool.desc.toLowerCase().includes(s));
} }
return list; return list;
}, [filter, q, tools]); }, [filter, q]);
const visible = showAll || q.trim() || filter !== "all" ? filtered : filtered.slice(0, 12); const visible = showAll || q.trim() || filter !== "all" ? filtered : filtered.slice(0, 12);
@ -55,7 +52,7 @@ export default function ToolsHubPage() {
<div className="tools-featured"> <div className="tools-featured">
<div className="tools-featured-head"> <div className="tools-featured-head">
<strong>✨ {t.tools.tag}</strong> <strong>✨ {t.tools.tag}</strong>
<span>{featured.length} · {t.tools.featuredHint}</span> <span>{featured.length} · 先用这几个</span>
</div> </div>
<div className="tools-featured-grid tools-featured-grid-compact"> <div className="tools-featured-grid tools-featured-grid-compact">
{featured.map((tool) => ( {featured.map((tool) => (
@ -73,12 +70,12 @@ export default function ToolsHubPage() {
<div className="tools-hub-toolbar"> <div className="tools-hub-toolbar">
<input <input
className="tools-hub-search" className="tools-hub-search"
placeholder={t.tools.searchPlaceholder} placeholder="搜索工具,如「签证」「机票」「笔记」…"
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
/> />
<div className="tools-hub-filters"> <div className="tools-hub-filters">
{categories.map((c) => ( {TOOL_CATEGORIES.map((c) => (
<button <button
key={c.key} key={c.key}
className={`filter-btn${filter === c.key ? " active" : ""}`} className={`filter-btn${filter === c.key ? " active" : ""}`}
@ -96,38 +93,18 @@ export default function ToolsHubPage() {
<span className="tools-hub-emoji">{tool.emoji}</span> <span className="tools-hub-emoji">{tool.emoji}</span>
<h3>{tool.title}</h3> <h3>{tool.title}</h3>
<p>{tool.desc}</p> <p>{tool.desc}</p>
<span className="tools-hub-go">{t.tools.open}</span> <span className="tools-hub-go">打开 →</span>
</Link> </Link>
))} ))}
</div> </div>
{!showAll && !q.trim() && filter === "all" && filtered.length > 12 && ( {!showAll && !q.trim() && filter === "all" && filtered.length > 12 && (
<div className="tools-hub-more"> <div className="tools-hub-more">
<button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}> <button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}>
{t.tools.showAll.replace("{n}", String(filtered.length))} 展开全部 {filtered.length} 个工具
</button> </button>
</div> </div>
)} )}
{filtered.length === 0 && ( {filtered.length === 0 && <p className="dest-empty">没有匹配的工具,试试其他关键词</p>}
<div className="notif-empty">
<p className="dest-empty">{t.tools.empty}</p>
<div className="dating-empty-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => {
setQ("");
setFilter("all");
setShowAll(false);
}}
>
{t.tools.clearFilter}
</button>
<Link href="/next-stop" className="btn btn-primary btn-sm">
{t.tools.goNextStop}
</Link>
</div>
</div>
)}
</div> </div>
</div> </div>
</SiteShell> </SiteShell>

View File

@ -1,68 +0,0 @@
"use client";
import Link from "next/link";
import SiteShell from "@/components/SiteShell";
import { useI18n } from "@/lib/i18n";
export default function AboutClient() {
const { t } = useI18n();
const A = t.aboutPage;
return (
<SiteShell>
<div className="legal-page about-page">
<nav className="detail-nav">
<Link href="/">{t.common.backHome}</Link>
</nav>
<article className="legal-content">
<span className="section-tag">{A.tag}</span>
<h1>{A.title}</h1>
<p className="legal-updated">{A.subtitle}</p>
<section>
<h2>{A.whoTitle}</h2>
<p>{A.whoBody}</p>
</section>
<section>
<h2>{A.whatTitle}</h2>
<ul className="about-list">
<li>{A.what1}</li>
<li>{A.what2}</li>
<li>{A.what3}</li>
<li>{A.what4}</li>
<li>{A.what5}</li>
</ul>
</section>
<section>
<h2>{A.contactTitle}</h2>
<p>
{A.contactBody}
<a href="mailto:hello@nomadro.com"> hello@nomadro.com</a>
</p>
<p>
{A.repoBody}
<a href="https://gitea.dsx2020.com/eric/nomadweb" target="_blank" rel="noopener noreferrer">
{" "}
gitea.dsx2020.com/eric/nomadweb
</a>
</p>
</section>
<div className="about-actions">
<Link href="/next-stop" className="btn btn-primary">
{A.ctaExplore}
</Link>
<Link href="/community" className="btn btn-ghost">
{A.ctaCommunity}
</Link>
<Link href="/plan" className="btn btn-ghost">
{A.ctaPlan}
</Link>
</div>
</article>
</div>
</SiteShell>
);
}

View File

@ -1,211 +1,55 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { clearAiHistory, loadAiHistory, pushAiHistory, type AiHistoryItem } from "@/lib/aiHistory";
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
import type { Destination } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
const DRAFT_KEY = "nomadro-ai-compose-draft"; export default function AiAssistantClient() {
interface Props {
destinations: Destination[];
}
export default function AiAssistantClient({ destinations }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const { toast } = useToast();
const router = useRouter();
const rings = useRingSteps();
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [reply, setReply] = useState(""); const [reply, setReply] = useState("");
const [cities, setCities] = useState<{ slug: string; name: string; emoji: string }[]>([]); const [cities, setCities] = useState<{ slug: string; name: string; emoji: string }[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [history, setHistory] = useState<AiHistoryItem[]>([]);
const [draftReady, setDraftReady] = useState(false);
const tips = [t.ai.tip1, t.ai.tip2, t.ai.tip3]; const ask = async () => {
if (!message.trim()) return;
useEffect(() => {
setHistory(loadAiHistory());
const draft = loadDraft<{ text?: string }>(DRAFT_KEY);
if (draft?.text?.trim()) {
setMessage(draft.text);
toast(t.ai.draftRestored, "info");
}
setDraftReady(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!draftReady) return;
const id = window.setTimeout(() => {
if (!message.trim()) {
clearDraft(DRAFT_KEY);
return;
}
saveDraft(DRAFT_KEY, { text: message });
}, 400);
return () => window.clearTimeout(id);
}, [message, draftReady]);
const ask = async (text?: string) => {
const q = (text ?? message).trim();
if (!q) {
toast(t.ai.needQuestion, "info");
return;
}
setMessage(q);
setLoading(true); setLoading(true);
try { try {
const res = await api.askAssistant(q); const res = await api.askAssistant(message.trim());
setReply(res.reply); setReply(res.reply);
const nextCities = res.cities || []; setCities(res.cities || []);
setCities(nextCities);
setHistory(pushAiHistory({ question: q, reply: res.reply, cities: nextCities }));
clearDraft(DRAFT_KEY);
} catch { } catch {
setReply(t.ai.fail); setReply(t.ai.fail);
setCities([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const restore = (item: AiHistoryItem) => {
setMessage(item.question);
setReply(item.reply);
setCities(item.cities);
};
const addToPlan = () => {
const dests = cities
.map((c) => destinations.find((d) => d.slug === c.slug))
.filter((d): d is Destination => Boolean(d));
const planAction = { href: "/plan", label: t.strip.openPlan };
if (dests.length === 0) {
toast(t.ai.noCityData, "info", planAction);
router.push("/plan");
return;
}
const { added, skipped } = mergeDestinationsIntoTrip(dests, 1);
if (added === 0) {
toast(skipped ? t.compare.alreadyInPlan : t.ai.writeFail, "info", planAction);
} else {
toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}`, "success", planAction);
}
router.push("/plan");
};
return ( return (
<div className="community-page"> <div className="community-page">
<div className="container"> <div className="container">
<nav className="detail-nav"> <nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
<Link href="/">{t.common.backHome}</Link>
<span> · </span>
<Link href="/next-stop">{t.ai.nextStop}</Link>
</nav>
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.ai.tag}</span> <span className="section-tag">{t.ai.tag}</span>
<h1>{t.ai.title}</h1> <h1>{t.ai.title}</h1>
<p>{t.ai.subtitle}</p> <p>{t.ai.subtitle}</p>
</div> </div>
<div className="ai-suggestions reveal">
{tips.map((s) => (
<button key={s} type="button" className="filter-btn" disabled={loading} onClick={() => void ask(s)}>
{s}
</button>
))}
</div>
<div className="community-compose reveal"> <div className="community-compose reveal">
<textarea <textarea rows={4} placeholder={t.ai.placeholder} value={message} onChange={(e) => setMessage(e.target.value)} />
rows={4} <button type="button" className="btn btn-primary" disabled={loading} onClick={ask}>{t.ai.ask}</button>
placeholder={t.ai.placeholder}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void ask();
}}
/>
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void ask()}>
{loading ? t.ai.thinking : t.ai.ask}
</button>
<p className="section-desc">{t.ai.sendHint} · {t.ai.draftHint}</p>
</div> </div>
{reply && ( {reply && (
<div className="ai-reply reveal"> <div className="ai-reply reveal">
<p>{reply}</p> <p>{reply}</p>
<div className="ai-cities"> <div className="ai-cities">
{cities.map((c) => ( {cities.map((c) => (
<Link key={c.slug} href={`/destinations/${c.slug}`} className="btn btn-sm"> <Link key={c.slug} href={`/destinations/${c.slug}`} className="btn btn-sm">{c.emoji} {c.name}</Link>
{c.emoji} {c.name}
</Link>
))} ))}
<Link href="/next-stop" className="btn btn-sm"> <Link href="/next-stop" className="btn btn-sm">{t.ai.nextStop}</Link>
{t.ai.nextStop}
</Link>
<Link
href={
cities.length
? `/compare?cities=${cities
.slice(0, 4)
.map((c) => c.slug)
.join(",")}`
: "/compare"
}
className="btn btn-sm"
>
{t.ai.compareCities}
</Link>
{cities.length > 0 && (
<button type="button" className="btn btn-sm btn-primary" onClick={addToPlan}>
{t.ai.writePlan}
</button>
)}
</div> </div>
</div> </div>
)} )}
{history.length > 0 && (
<section className="ai-history reveal">
<div className="ai-history-head">
<h2>{t.ai.history}</h2>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
clearAiHistory();
setHistory([]);
}}
>
{t.ai.clearHistory}
</button>
</div>
<ul className="ai-history-list">
{history.map((h) => (
<li key={h.id}>
<button type="button" className="ai-history-item" onClick={() => restore(h)}>
<strong>{h.question}</strong>
<span>
{h.cities.length
? h.cities.map((c) => `${c.emoji} ${c.name}`).join(" · ")
: h.reply.slice(0, 72)}
</span>
</button>
</li>
))}
</ul>
</section>
)}
<RingNext steps={rings.afterDiscover} />
</div> </div>
</div> </div>
); );

View File

@ -2,9 +2,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import ToolCityExits from "@/components/ToolCityExits";
import { meetupCityHref } from "@/lib/meetupLinks";
import Link from "next/link";
interface Task { interface Task {
id: string; id: string;
@ -113,11 +110,6 @@ export default function ArrivalChecklist({ destinations }: Props) {
<span className="packing-check">{checked[t.id] ? "✓" : ""}</span> <span className="packing-check">{checked[t.id] ? "✓" : ""}</span>
<span className="packing-emoji">{t.emoji}</span> <span className="packing-emoji">{t.emoji}</span>
<span className="packing-label">{t.label}</span> <span className="packing-label">{t.label}</span>
{t.id === "meetup" && dest && (
<Link href={meetupCityHref(dest.name)} className="arrival-meetup-link">
→
</Link>
)}
</label> </label>
</li> </li>
))} ))}
@ -125,7 +117,6 @@ export default function ArrivalChecklist({ destinations }: Props) {
</div> </div>
))} ))}
</div> </div>
{dest && <ToolCityExits dest={dest} />}
</div> </div>
</div> </div>
</section> </section>

View File

@ -1,82 +0,0 @@
"use client";
import Link from "next/link";
import BlogMarkdown from "@/components/BlogMarkdown";
import ShareButton from "@/components/ShareButton";
import { useI18n } from "@/lib/i18n";
import type { BlogPost, BlogPostDetail } from "@/lib/types";
export default function BlogDetailClient({
post,
related,
}: {
post: BlogPostDetail;
related: BlogPost[];
}) {
const { t } = useI18n();
const b = t.blogDetail;
return (
<div className="detail-page">
<nav className="detail-nav">
<Link href="/#blog">← {b.back}</Link>
<ShareButton title={post.title} compact />
</nav>
<article className="blog-detail">
<div className="blog-detail-header">
<span className="blog-detail-emoji">{post.emoji}</span>
<div className="blog-meta">
<span>📅 {post.published_at}</span>
<span>⏱️ {b.readTime.replace("{n}", String(post.read_time))}</span>
<span>✍️ {post.author}</span>
</div>
<h1>{post.title}</h1>
<div className="blog-tags">
{post.tags.map((tag) => (
<span key={tag} className="blog-tag">
{tag}
</span>
))}
</div>
</div>
<div className="blog-detail-content">
<BlogMarkdown content={post.content} />
</div>
</article>
{related.length > 0 && (
<section className="blog-related">
<h2>📚 {b.related}</h2>
<div className="blog-related-grid">
{related.map((r) => (
<Link key={r.slug} href={`/blog/${r.slug}`} className="blog-related-card">
<span className="blog-related-emoji">{r.emoji}</span>
<h3>{r.title}</h3>
<p>{r.excerpt}</p>
<span className="blog-read-more">{b.readMore}</span>
</Link>
))}
</div>
</section>
)}
<section className="blog-related" style={{ marginTop: "2rem" }}>
<h2>{b.explore}</h2>
<div className="dating-empty-actions" style={{ justifyContent: "flex-start" }}>
<Link href="/community" className="btn btn-primary btn-sm">
{t.nav.community}
</Link>
<Link href="/book" className="btn btn-sm">
{t.nav.ebook}
</Link>
<Link href="/next-stop" className="btn btn-sm">
{t.nav.nextStop}
</Link>
<Link href="/submit" className="btn btn-ghost btn-sm">
{b.submit}
</Link>
</div>
</section>
</div>
);
}

View File

@ -1,27 +1,18 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { meetupCityHref } from "@/lib/meetupLinks";
import type { CostResult, Destination } from "@/lib/types"; import type { CostResult, Destination } from "@/lib/types";
interface Props { destinations: Destination[] } interface Props { destinations: Destination[] }
export default function Calculator({ destinations }: Props) { export default function Calculator({ destinations }: Props) {
const { t } = useI18n();
const { toast } = useToast();
const [slug, setSlug] = useState(destinations[0]?.slug || ""); const [slug, setSlug] = useState(destinations[0]?.slug || "");
const [housing, setHousing] = useState("mid"); const [housing, setHousing] = useState("mid");
const [months, setMonths] = useState(3); const [months, setMonths] = useState(3);
const [result, setResult] = useState<CostResult | null>(null); const [result, setResult] = useState<CostResult | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const selected = destinations.find((d) => d.slug === slug);
const calculate = async () => { const calculate = async () => {
setLoading(true); setLoading(true);
try { try {
@ -34,21 +25,6 @@ export default function Calculator({ destinations }: Props) {
} }
}; };
const addToPlan = () => {
if (!selected) return;
const { added } = mergeDestinationsIntoTrip([selected], months);
toast(
added
? t.common.addedToPlanMonths
.replace("{emoji}", selected.emoji)
.replace("{city}", selected.name)
.replace("{months}", String(months))
: t.common.alreadyInPlan,
added ? "success" : "info",
{ href: "/plan", label: t.strip.openPlan }
);
};
return ( return (
<section className="section calculator-section" id="calculator"> <section className="section calculator-section" id="calculator">
<div className="container"> <div className="container">
@ -109,19 +85,6 @@ export default function Calculator({ destinations }: Props) {
<div><span>月均</span><strong>¥{result.per_month.toLocaleString()}</strong></div> <div><span>月均</span><strong>¥{result.per_month.toLocaleString()}</strong></div>
<div><span>总计</span><strong className="gradient-text">¥{result.total.toLocaleString()}</strong></div> <div><span>总计</span><strong className="gradient-text">¥{result.total.toLocaleString()}</strong></div>
</div> </div>
{selected && (
<div className="dating-empty-actions" style={{ marginTop: "1rem" }}>
<button type="button" className="btn btn-primary btn-sm" onClick={addToPlan}>
🗓️ {t.nav.plan}
</button>
<Link href={`/destinations/${selected.slug}`} className="btn btn-sm">
{t.common.detail}
</Link>
<Link href={meetupCityHref(selected.name)} className="btn btn-sm btn-ghost">
{t.common.cityMeetups}
</Link>
</div>
)}
</div> </div>
) : ( ) : (
<div className="result-placeholder"> <div className="result-placeholder">

View File

@ -6,37 +6,15 @@ import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { ConversationItem } from "@/lib/types"; import type { ConversationItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
function Avatar({ photo, name }: { photo?: string; name?: string }) {
if (photo?.startsWith("http")) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={photo} alt="" className="chat-avatar-img" />
);
}
return <span className="chat-avatar">{photo || name?.[0] || "💬"}</span>;
}
export default function ChatClient() { export default function ChatClient() {
const { user, token } = useAuth(); const { user, token } = useAuth();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const [items, setItems] = useState<ConversationItem[]>([]); const [items, setItems] = useState<ConversationItem[]>([]);
const [loading, setLoading] = useState(Boolean(token));
useEffect(() => { useEffect(() => {
if (!token) { if (!token) return;
setLoading(false); api.getConversations(token).then(setItems).catch(() => setItems([]));
return;
}
setLoading(true);
api
.getConversations(token)
.then(setItems)
.catch(() => setItems([]))
.finally(() => setLoading(false));
}, [token]); }, [token]);
if (!user) { if (!user) {
@ -45,12 +23,7 @@ export default function ChatClient() {
<div className="container"> <div className="container">
<div className="dating-gate reveal"> <div className="dating-gate reveal">
<h2>{t.chat.loginTitle}</h2> <h2>{t.chat.loginTitle}</h2>
<p>{t.chat.loginDesc}</p>
<div className="dating-empty-actions">
<Link href="/login?next=/chat" className="btn btn-primary">{t.nav.login}</Link> <Link href="/login?next=/chat" className="btn btn-primary">{t.nav.login}</Link>
<Link href="/meetups" className="btn btn-ghost">{t.dating.meetAtEvents}</Link>
<Link href="/dating" className="btn btn-ghost">{t.chat.goDating}</Link>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -68,41 +41,19 @@ export default function ChatClient() {
<h1>{t.chat.title}</h1> <h1>{t.chat.title}</h1>
</div> </div>
<div className="chat-list"> <div className="chat-list">
{loading && <p className="dest-empty">{t.chat.loadingList}</p>} {items.map((c) => (
{!loading && <Link key={c.id} href={`/chat/${c.id}`} className={`chat-list-item reveal${c.unreadCount > 0 ? " unread" : ""}`}>
items.map((c) => ( <span className="chat-avatar">{c.peer?.photo || "💬"}</span>
<Link
key={c.id}
href={`/chat/${c.id}`}
className={`chat-list-item reveal${c.unreadCount > 0 ? " unread" : ""}`}
>
<Avatar photo={c.peer?.photo} name={c.peer?.name} />
<div> <div>
<strong>{c.peer?.name || t.chat.peerFallback}</strong> <strong>{c.peer?.name || "游民"}</strong>
<p>{c.lastMessagePreview || t.chat.noMessages}</p> <p>{c.lastMessagePreview || t.chat.noMessages}</p>
</div> </div>
{c.unreadCount > 0 && <em className="chat-unread">{c.unreadCount}</em>} {c.unreadCount > 0 && <em className="chat-unread">{c.unreadCount}</em>}
</Link> </Link>
))} ))}
{!loading && items.length === 0 && ( {items.length === 0 && <p className="dest-empty">{t.chat.empty}</p>}
<div className="notif-empty reveal">
<p className="dest-empty">{t.chat.empty}</p>
<div className="dating-empty-actions">
<Link href="/dating" className="btn btn-primary btn-sm">
{t.chat.goDating}
</Link>
<Link href="/meetups" className="btn btn-sm">
{t.dating.meetAtEvents}
</Link>
<Link href="/community" className="btn btn-sm">
{t.nav.community}
</Link>
</div> </div>
</div> </div>
)}
</div>
<RingNext steps={rings.afterDating} />
</div>
</div> </div>
); );
} }

View File

@ -5,156 +5,46 @@ import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast"; import type { ChatMessage } from "@/lib/types";
import type { ChatMessage, ConversationItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
export default function ChatThreadClient({ convId }: { convId: string }) { export default function ChatThreadClient({ convId }: { convId: string }) {
const { token, user } = useAuth(); const { token } = useAuth();
const { t } = useI18n(); const { t } = useI18n();
const { toast } = useToast();
const rings = useRingSteps();
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [conv, setConv] = useState<ConversationItem | null>(null);
const [text, setText] = useState(""); const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(Boolean(token));
const [draftReady, setDraftReady] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const draftKey = `nomadro-chat-draft:${convId}`;
const load = () => { const load = () => {
if (!token) return; if (!token) return;
api api.getMessages(token, convId).then(setMessages).catch(() => setMessages([]));
.getMessages(token, convId)
.then(setMessages)
.catch(() => setMessages([]))
.finally(() => setLoading(false));
}; };
useEffect(() => { useEffect(() => {
if (!token) {
setLoading(false);
return;
}
api.getConversation(token, convId).then(setConv).catch(() => setConv(null));
load(); load();
const id = setInterval(load, 4000); const id = setInterval(load, 4000);
return () => clearInterval(id); return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, convId]); }, [token, convId]);
useEffect(() => {
const draft = loadDraft<{ text?: string }>(draftKey);
if (draft?.text?.trim()) {
setText(draft.text);
toast(t.chat.draftRestored, "info");
}
setDraftReady(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [convId]);
useEffect(() => {
if (!draftReady || !token) return;
const id = window.setTimeout(() => {
if (!text.trim()) {
clearDraft(draftKey);
return;
}
saveDraft(draftKey, { text });
}, 400);
return () => window.clearTimeout(id);
}, [text, draftReady, token, draftKey]);
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]); }, [messages]);
const send = async () => { const send = async () => {
if (!token || !text.trim() || sending) return; if (!token || !text.trim()) return;
setSending(true);
try { try {
await api.sendMessage(token, convId, text.trim()); await api.sendMessage(token, convId, text.trim());
setText(""); setText("");
clearDraft(draftKey);
load(); load();
} catch { } catch { /* ignore */ }
toast(t.chat.sendFail, "error");
} finally {
setSending(false);
}
}; };
const peer = conv?.peer;
const peerHref = peer?.userId ? `/members/${peer.userId}` : peer?.id ? `/members/${peer.id}` : "/dating";
if (!user || !token) {
return (
<div className="chat-thread-page">
<div className="container">
<div className="dating-gate reveal">
<h2>{t.chat.loginTitle}</h2>
<p>{t.chat.loginDesc}</p>
<div className="dating-empty-actions">
<Link href={`/login?next=/chat/${convId}`} className="btn btn-primary">
{t.nav.login}
</Link>
<Link href="/meetups" className="btn btn-ghost">
{t.dating.meetAtEvents}
</Link>
<Link href="/dating" className="btn btn-ghost">
{t.nav.dating}
</Link>
</div>
</div>
</div>
</div>
);
}
return ( return (
<div className="chat-thread-page"> <div className="chat-thread-page">
<div className="container chat-thread-wrap"> <div className="container chat-thread-wrap">
<nav className="detail-nav"> <nav className="detail-nav">
<Link href="/chat">← {t.chat.back}</Link> <Link href="/chat">← {t.chat.back}</Link>
<span> · </span>
<Link href="/dating">{t.nav.dating}</Link>
<span> · </span>
<Link href="/meetups">{t.nav.meetups}</Link>
</nav> </nav>
{peer && (
<div className="chat-thread-peer reveal">
<Link href={peerHref} className="chat-thread-peer-link">
<span className="chat-avatar">{peer.photo || peer.name?.[0] || "💬"}</span>
<div>
<strong>{peer.name}</strong>
<p className="dating-meta">{peer.location || t.dating.globalNomad}</p>
</div>
</Link>
<Link href={peerHref} className="btn btn-ghost btn-sm">
{t.dating.viewProfile}
</Link>
</div>
)}
<div className="chat-thread-messages"> <div className="chat-thread-messages">
{loading && messages.length === 0 && <p className="dest-empty">{t.chat.loadingMessages}</p>}
{!loading && messages.length === 0 && (
<div className="notif-empty">
<p className="dest-empty">{t.chat.sayHi}</p>
<div className="dating-empty-actions">
<Link href="/meetups" className="btn btn-sm">
{t.dating.meetAtEvents}
</Link>
<Link href="/dating" className="btn btn-sm">
{t.nav.dating}
</Link>
</div>
</div>
)}
{messages.map((m) => ( {messages.map((m) => (
<div key={m.id} className={`chat-bubble${m.mine ? " mine" : ""}`}> <div key={m.id} className={`chat-bubble${m.mine ? " mine" : ""}`}>
{m.body} {m.body}
@ -167,14 +57,10 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
placeholder={t.chat.placeholder} placeholder={t.chat.placeholder}
onKeyDown={(e) => e.key === "Enter" && void send()} onKeyDown={(e) => e.key === "Enter" && send()}
disabled={sending}
/> />
<button type="button" className="btn btn-primary" disabled={sending || !text.trim()} onClick={() => void send()}> <button type="button" className="btn btn-primary" onClick={send}>{t.chat.send}</button>
{sending ? "…" : t.chat.send}
</button>
</div> </div>
<RingNext steps={rings.afterDating} />
</div> </div>
</div> </div>
); );

View File

@ -1,10 +1,8 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import ToolCityExits from "@/components/ToolCityExits";
const STORAGE_KEY = "nomadro-city-notes"; const STORAGE_KEY = "nomadro-city-notes";
@ -13,7 +11,6 @@ interface Props {
} }
export default function CityNotes({ destinations }: Props) { export default function CityNotes({ destinations }: Props) {
const { t } = useI18n();
const { toast } = useToast(); const { toast } = useToast();
const [slug, setSlug] = useState(destinations[0]?.slug || "bali"); const [slug, setSlug] = useState(destinations[0]?.slug || "bali");
const [notes, setNotes] = useState<Record<string, string>>({}); const [notes, setNotes] = useState<Record<string, string>>({});
@ -25,9 +22,7 @@ export default function CityNotes({ destinations }: Props) {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (raw) setNotes(JSON.parse(raw)); if (raw) setNotes(JSON.parse(raw));
} catch { } catch { /* ignore */ }
/* ignore */
}
setReady(true); setReady(true);
}, []); }, []);
@ -40,10 +35,7 @@ export default function CityNotes({ destinations }: Props) {
const next = { ...notes, [slug]: text }; const next = { ...notes, [slug]: text };
setNotes(next); setNotes(next);
localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
toast(`${dest?.emoji || ""} 笔记已保存`, "success", { toast(`${dest?.emoji || ""} 笔记已保存`);
href: "/plan",
label: t.strip.openPlan,
});
}; };
const clear = () => { const clear = () => {
@ -74,9 +66,7 @@ export default function CityNotes({ destinations }: Props) {
className={`notes-city${slug === d.slug ? " active" : ""}`} className={`notes-city${slug === d.slug ? " active" : ""}`}
onClick={() => setSlug(d.slug)} onClick={() => setSlug(d.slug)}
> >
<span> <span>{d.emoji} {d.name}</span>
{d.emoji} {d.name}
</span>
{notes[d.slug]?.trim() && <span className="notes-dot" />} {notes[d.slug]?.trim() && <span className="notes-dot" />}
</button> </button>
))} ))}
@ -84,9 +74,7 @@ export default function CityNotes({ destinations }: Props) {
</div> </div>
<div className="notes-editor"> <div className="notes-editor">
<h3> <h3>{dest?.emoji} {dest?.name}</h3>
{dest?.emoji} {dest?.name}
</h3>
<textarea <textarea
className="notes-textarea" className="notes-textarea"
rows={10} rows={10}
@ -95,14 +83,9 @@ export default function CityNotes({ destinations }: Props) {
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
/> />
<div className="notes-actions"> <div className="notes-actions">
<button className="btn btn-primary" onClick={save}> <button className="btn btn-primary" onClick={save}>💾 保存</button>
💾 保存 <button className="btn btn-ghost" onClick={clear} disabled={!text.trim()}>清空</button>
</button>
<button className="btn btn-ghost" onClick={clear} disabled={!text.trim()}>
清空
</button>
</div> </div>
{dest && <ToolCityExits dest={dest} />}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,48 +1,58 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { api } from "@/lib/api";
import NewsletterSubscribe from "@/components/NewsletterSubscribe";
import type { Testimonial } from "@/lib/types"; import type { Testimonial } from "@/lib/types";
export default function Community({ testimonials }: { testimonials: Testimonial[] }) { export default function Community({ testimonials }: { testimonials: Testimonial[] }) {
const { t } = useI18n(); const { toast } = useToast();
const C = t.homeCommunity;
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
useEffect(() => { useEffect(() => {
if (testimonials.length <= 1 || paused) return; if (testimonials.length <= 1 || paused) return;
const id = setInterval(() => setActive((i) => (i + 1) % testimonials.length), 4500); const t = setInterval(() => setActive((i) => (i + 1) % testimonials.length), 4500);
return () => clearInterval(id); return () => clearInterval(t);
}, [testimonials.length, paused]); }, [testimonials.length, paused]);
const cur = testimonials[active]; const handleSubscribe = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
try {
await api.subscribe(email);
toast("订阅成功!欢迎加入 nomadro 社区 🎉");
form.reset();
} catch { /* ignore */ }
};
const t = testimonials[active];
return ( return (
<section className="section community" id="voices"> <>
<section className="section community" id="community">
<div className="container"> <div className="container">
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{C.tag}</span> <span className="section-tag">🤝 COMMUNITY</span>
<h2>{C.title}</h2> <h2>游民心声</h2>
<p>{C.subtitle}</p> <p>来自全球数字游民的真实故事</p>
</div> </div>
{cur && ( {t && (
<div <div
className="testimonial-carousel reveal" className="testimonial-carousel reveal"
onMouseEnter={() => setPaused(true)} onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)} onMouseLeave={() => setPaused(false)}
> >
<div className="testimonial-featured" key={cur.id}> <div className="testimonial-featured" key={t.id}>
<div className="testimonial-avatar">{cur.avatar}</div> <div className="testimonial-avatar">{t.avatar}</div>
<div className="testimonial-content"> <div className="testimonial-content">
<div className="testimonial-stars">{"⭐".repeat(cur.rating)}</div> <div className="testimonial-stars">{"⭐".repeat(t.rating)}</div>
<p>&ldquo;{cur.content}&rdquo;</p> <p>&ldquo;{t.content}&rdquo;</p>
<div className="testimonial-author"> <div className="testimonial-author">
<strong>{cur.author}</strong> <strong>{t.author}</strong>
<span>{cur.role}</span> <span>{t.role}</span>
</div> </div>
</div> </div>
</div> </div>
@ -50,10 +60,9 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
{testimonials.map((item, i) => ( {testimonials.map((item, i) => (
<button <button
key={item.id} key={item.id}
type="button"
className={`testimonial-dot${i === active ? " active" : ""}`} className={`testimonial-dot${i === active ? " active" : ""}`}
onClick={() => setActive(i)} onClick={() => setActive(i)}
aria-label={`${i + 1}`} aria-label={`评价 ${i + 1}`}
/> />
))} ))}
</div> </div>
@ -78,20 +87,16 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
<div className="cta-banner reveal"> <div className="cta-banner reveal">
<div className="cta-content"> <div className="cta-content">
<h2>{C.ctaTitle}</h2> <h2>准备好开始你的旅居之旅了吗? 🌍</h2>
<p>{C.ctaDesc}</p> <p>加入 50,000+ 数字游民社区,获取目的地指南、签证攻略与独家优惠</p>
<div className="dating-empty-actions" style={{ marginTop: 12, justifyContent: "flex-start" }}>
<Link href="/community" className="btn btn-primary btn-sm">
{t.nav.community}
</Link>
<Link href="/meetups" className="btn btn-sm">
{t.nav.meetups}
</Link>
</div> </div>
</div> <form className="cta-form" onSubmit={handleSubscribe}>
<NewsletterSubscribe source="home-voices" /> <input type="email" name="email" placeholder="输入你的邮箱 📧" required />
<button type="submit" className="btn btn-primary"><span>免费订阅</span><span>🚀</span></button>
</form>
</div> </div>
</div> </div>
</section> </section>
</>
); );
} }

View File

@ -7,7 +7,7 @@ import type { Discussion, DiscussionDetail } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
const CATEGORY_VALUES = ["全部", "签证", "远程工作", "住宿", "安全", "社区"] as const; const CATEGORIES = ["全部", "签证", "远程工作", "住宿", "安全", "社区"];
interface Props { interface Props {
discussions: Discussion[]; discussions: Discussion[];
@ -20,18 +20,6 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
const [category, setCategory] = useState("全部"); const [category, setCategory] = useState("全部");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const catLabel = (value: string) => {
const map: Record<string, string> = {
全部: t.community.catAll,
签证: t.community.catVisa,
远程工作: t.community.catRemote,
住宿: t.community.catHousing,
安全: t.community.catSafety,
社区: t.community.catCommunity,
};
return map[value] || value;
};
const filtered = useMemo(() => { const filtered = useMemo(() => {
let list = discussions; let list = discussions;
if (category !== "全部") list = list.filter((d) => d.category === category); if (category !== "全部") list = list.filter((d) => d.category === category);
@ -96,14 +84,14 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
/> />
<div className="tools-hub-filters"> <div className="tools-hub-filters">
{CATEGORY_VALUES.map((c) => ( {CATEGORIES.map((c) => (
<button <button
key={c} key={c}
type="button" type="button"
className={`filter-btn${category === c ? " active" : ""}`} className={`filter-btn${category === c ? " active" : ""}`}
onClick={() => setCategory(c)} onClick={() => setCategory(c)}
> >
{catLabel(c)} {c}
</button> </button>
))} ))}
</div> </div>
@ -118,7 +106,7 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
<div> <div>
<h3>{d.title}</h3> <h3>{d.title}</h3>
<p className="community-meta"> <p className="community-meta">
{d.author} · {catLabel(d.category)} · {d.created_at} {d.author} · {d.category} · {d.created_at}
</p> </p>
</div> </div>
</div> </div>
@ -135,29 +123,10 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
</div> </div>
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="notif-empty reveal">
<p className="dest-empty">{t.community.empty}</p> <p className="dest-empty">{t.community.empty}</p>
<div className="dating-empty-actions">
{(category !== "全部" || q.trim()) && (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
setCategory("全部");
setQ("");
}}
>
{t.community.catAll}
</button>
)}
<Link href="/community/new" className="btn btn-primary btn-sm">
{t.community.newTopic}
</Link>
</div>
</div>
)} )}
<RingNext steps={rings.afterCommunity()} /> <RingNext steps={rings.afterCommunity} />
</div> </div>
</div> </div>
); );

View File

@ -1,119 +1,33 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
import {
clearCommunityDraft,
loadCommunityDraft,
saveCommunityDraft,
} from "@/lib/communityDraft";
import { inferMeetupCity, meetupCityHref } from "@/lib/meetupLinks";
import type { Destination } from "@/lib/types";
export default function CommunityNewClient() { export default function CommunityNewClient() {
const { token } = useAuth(); const { token } = useAuth();
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [category, setCategory] = useState("社区"); const [category, setCategory] = useState("社区");
const [busy, setBusy] = useState(false);
const [draftReady, setDraftReady] = useState(false);
const [cities, setCities] = useState<Destination[]>([]);
useEffect(() => {
const draft = loadCommunityDraft();
if (draft) {
setTitle(draft.title || "");
setContent(draft.content || "");
setCategory(draft.category || "社区");
if (draft.title || draft.content) {
toast(t.community.draftRestored, "info");
}
}
setDraftReady(true);
api
.getDestinations()
.then(setCities)
.catch(() => setCities([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!draftReady || !token) return;
const id = window.setTimeout(() => {
if (!title.trim() && !content.trim()) {
clearCommunityDraft();
return;
}
saveCommunityDraft({ title, content, category });
}, 400);
return () => window.clearTimeout(id);
}, [title, content, category, draftReady, token]);
if (!token) {
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/community">← {t.community.back}</Link></nav>
<div className="dating-gate reveal">
<h2>{t.community.newTitle}</h2>
<p>{t.community.loginDesc}</p>
<div className="dating-empty-actions">
<Link href="/login?next=/community/new" className="btn btn-primary">{t.nav.login}</Link>
<Link href="/meetups" className="btn btn-ghost">{t.dating.meetAtEvents}</Link>
</div>
</div>
<RingNext steps={rings.afterCommunity()} />
</div>
</div>
);
}
const submit = async () => { const submit = async () => {
if (!token) { if (!token) {
router.push("/login?next=/community/new"); router.push("/login?next=/community/new");
return; return;
} }
const tTitle = title.trim();
const body = content.trim();
if (!tTitle || body.length < 10) {
toast(t.community.draftNeed, "info");
return;
}
setBusy(true);
try { try {
const excerpt = body.slice(0, 1000); const res = await api.createDiscussion(token, { title, content, excerpt: content, category });
const res = await api.createDiscussion(token, { toast(t.community.createOk, "success");
title: tTitle, router.push(`/community/${res.discussion.id}`);
content: body,
excerpt,
category,
});
clearCommunityDraft();
const city = inferMeetupCity(
`${tTitle} ${body} ${category}`,
cities.map((d) => d.name)
);
toast(t.community.createOk, "success", {
href: city ? meetupCityHref(city) : "/meetups",
label: city ? t.common.cityMeetups : t.dating.meetAtEvents,
});
const id = res.discussion?.id;
router.push(id ? `/community/${id}` : "/community");
} catch { } catch {
toast(t.community.createFail, "error"); toast(t.community.createFail, "error");
} finally {
setBusy(false);
} }
}; };
@ -123,34 +37,18 @@ export default function CommunityNewClient() {
<nav className="detail-nav"><Link href="/community">← {t.community.back}</Link></nav> <nav className="detail-nav"><Link href="/community">← {t.community.back}</Link></nav>
<div className="section-header reveal"> <div className="section-header reveal">
<h1>{t.community.newTitle}</h1> <h1>{t.community.newTitle}</h1>
<p className="section-desc">{t.community.draftHint}</p>
</div> </div>
<div className="join-card reveal"> <div className="join-card reveal">
<label>{t.community.newSubject}</label> <label>{t.community.newSubject}</label>
<input value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} /> <input value={title} onChange={(e) => setTitle(e.target.value)} />
<label>{t.community.newCategory}</label> <label>{t.community.newCategory}</label>
<select value={category} onChange={(e) => setCategory(e.target.value)}> <select value={category} onChange={(e) => setCategory(e.target.value)}>
{( {["签证", "远程工作", "住宿", "安全", "社区"].map((c) => <option key={c}>{c}</option>)}
[
["签证", t.community.catVisa],
["远程工作", t.community.catRemote],
["住宿", t.community.catHousing],
["安全", t.community.catSafety],
["社区", t.community.catCommunity],
] as const
).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select> </select>
<label>{t.community.newBody}</label> <label>{t.community.newBody}</label>
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={6} maxLength={4000} /> <textarea value={content} onChange={(e) => setContent(e.target.value)} rows={6} />
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void submit()}> <button type="button" className="btn btn-primary" onClick={submit}>{t.community.newSubmit}</button>
{busy ? t.meetups.hosting : t.community.newSubmit}
</button>
</div> </div>
<RingNext steps={rings.afterCommunity()} />
</div> </div>
</div> </div>
); );

View File

@ -11,18 +11,11 @@ import {
overallScore, overallScore,
parseCompareSlugs, parseCompareSlugs,
} from "@/lib/compareScore"; } from "@/lib/compareScore";
import {
clearCompareHistory,
loadCompareHistory,
pushCompareHistory,
type ComparePreset,
} from "@/lib/compareHistory";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import { meetupCityHref } from "@/lib/meetupLinks";
interface Props { interface Props {
destinations: Destination[]; destinations: Destination[];
@ -35,11 +28,6 @@ export default function CompareClient({ destinations }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps(); const rings = useRingSteps();
const [picked, setPicked] = useState<string[]>([]); const [picked, setPicked] = useState<string[]>([]);
const [history, setHistory] = useState<ComparePreset[]>([]);
useEffect(() => {
setHistory(loadCompareHistory());
}, []);
useEffect(() => { useEffect(() => {
const fromUrl = parseCompareSlugs(searchParams.get("cities")); const fromUrl = parseCompareSlugs(searchParams.get("cities"));
@ -58,16 +46,6 @@ export default function CompareClient({ destinations }: Props) {
const scores = selected.map(overallScore); const scores = selected.map(overallScore);
const winnerIdx = scores.length ? scores.indexOf(Math.max(...scores)) : -1; const winnerIdx = scores.length ? scores.indexOf(Math.max(...scores)) : -1;
useEffect(() => {
if (selected.length < 2) return;
setHistory(
pushCompareHistory(
selected.map((d) => d.slug),
selected.map((d) => `${d.emoji} ${d.name}`)
)
);
}, [selected]);
const syncUrl = (slugs: string[]) => { const syncUrl = (slugs: string[]) => {
const href = compareUrl(slugs); const href = compareUrl(slugs);
router.replace(href, { scroll: false }); router.replace(href, { scroll: false });
@ -89,17 +67,12 @@ export default function CompareClient({ destinations }: Props) {
const addAll = () => { const addAll = () => {
if (selected.length < 1) return; if (selected.length < 1) return;
const { added, skipped } = mergeDestinationsIntoTrip(selected, 1); const { added, skipped } = mergeDestinationsIntoTrip(selected, 1);
const planAction = { href: "/plan", label: t.strip.openPlan };
if (added === 0) { if (added === 0) {
toast(skipped ? t.compare.alreadyInPlan : t.compare.alreadyOne, "info", planAction); toast(skipped ? t.compare.alreadyInPlan : t.compare.alreadyOne, "info");
router.push("/plan"); router.push("/plan");
return; return;
} }
toast( toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}${skipped ? `(${t.compare.skipped} ${skipped})` : ""}`);
`${t.compare.addedN} ${added} ${t.compare.citiesUnit}${skipped ? `(${t.compare.skipped} ${skipped})` : ""}`,
"success",
planAction
);
router.push("/plan"); router.push("/plan");
}; };
@ -107,11 +80,7 @@ export default function CompareClient({ destinations }: Props) {
if (winnerIdx < 0) return; if (winnerIdx < 0) return;
const d = selected[winnerIdx]; const d = selected[winnerIdx];
const { added } = mergeDestinationsIntoTrip([d], 1); const { added } = mergeDestinationsIntoTrip([d], 1);
toast( toast(added ? `${d.emoji} ${d.name} ${t.compare.wrotePlan}` : t.compare.alreadyOne, added ? "success" : "info");
added ? `${d.emoji} ${d.name} ${t.compare.wrotePlan}` : t.compare.alreadyOne,
added ? "success" : "info",
{ href: "/plan", label: t.strip.openPlan }
);
if (added) router.push("/plan"); if (added) router.push("/plan");
}; };
@ -125,30 +94,6 @@ export default function CompareClient({ destinations }: Props) {
toast(t.compare.linkCopied); toast(t.compare.linkCopied);
}; };
const copySnapshot = async () => {
if (selected.length < 2) {
toast(t.compare.needShare, "info");
return;
}
const lines = [
t.compare.snapshotTitle,
...selected.map((d, i) => {
const mark = i === winnerIdx ? "👑 " : "";
const scoreText = t.compare.scoreLabel.replace("{score}", String(scores[i]));
return `${mark}${d.emoji} ${d.name}, ${d.country} · ¥${d.cost.toLocaleString()}/mo · ${d.speed}Mbps · ⭐${d.rating} · ${scoreText}`;
}),
`${typeof window !== "undefined" ? window.location.origin : ""}${compareUrl(selected.map((d) => d.slug))}`,
];
await navigator.clipboard.writeText(lines.join("\n"));
toast(t.compare.snapshotOk);
};
const restorePreset = (preset: ComparePreset) => {
const next = preset.slugs.filter((s) => destinations.some((d) => d.slug === s)).slice(0, 4);
setPicked(next);
syncUrl(next);
};
return ( return (
<div className="compare-page"> <div className="compare-page">
<div className="container"> <div className="container">
@ -164,51 +109,16 @@ export default function CompareClient({ destinations }: Props) {
<p>{t.compare.subtitle}</p> <p>{t.compare.subtitle}</p>
</header> </header>
{history.length > 0 && (
<section className="compare-history reveal">
<div className="compare-history-head">
<h2>{t.compare.history}</h2>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
clearCompareHistory();
setHistory([]);
}}
>
{t.compare.clearHistory}
</button>
</div>
<div className="compare-history-list">
{history.map((h) => (
<button
key={`${h.at}-${h.slugs.join("-")}`}
type="button"
className="compare-history-chip"
onClick={() => restorePreset(h)}
>
<span>{h.labels.join(" vs ")}</span>
<strong>{t.compare.restore}</strong>
</button>
))}
</div>
</section>
)}
<section className="compare-pick plan-panel"> <section className="compare-pick plan-panel">
<div className="plan-panel-head"> <div className="plan-panel-head">
<h2>{t.compare.pick}({picked.length}/4)</h2> <h2>{t.compare.pick}({picked.length}/4)</h2>
<div className="plan-actions"> <div className="plan-actions">
<button type="button" className="btn btn-ghost btn-sm" disabled={picked.length === 0} onClick={() => { setPicked([]); syncUrl([]); }}>{t.compare.clear}</button> <button type="button" className="btn btn-ghost btn-sm" disabled={picked.length === 0} onClick={() => { setPicked([]); syncUrl([]); }}>{t.compare.clear}</button>
<button type="button" className="btn btn-ghost btn-sm" disabled={selected.length < 2} onClick={share}>{t.compare.copyLink}</button> <button type="button" className="btn btn-ghost btn-sm" disabled={selected.length < 2} onClick={share}>{t.compare.copyLink}</button>
<button type="button" className="btn btn-ghost btn-sm" disabled={selected.length < 2} onClick={() => void copySnapshot()}>{t.compare.snapshot}</button>
</div> </div>
</div> </div>
<div className="compare-pick-grid"> <div className="compare-pick-grid">
{destinations.length === 0 ? ( {destinations.map((d) => {
<p className="dest-empty">{t.compare.loadingDest}</p>
) : (
destinations.map((d) => {
const on = picked.includes(d.slug); const on = picked.includes(d.slug);
return ( return (
<button <button
@ -223,8 +133,7 @@ export default function CompareClient({ destinations }: Props) {
<small>¥{d.cost.toLocaleString()}</small> <small>¥{d.cost.toLocaleString()}</small>
</button> </button>
); );
}) })}
)}
</div> </div>
</section> </section>
@ -232,11 +141,7 @@ export default function CompareClient({ destinations }: Props) {
<div className="plan-empty compare-page-empty"> <div className="plan-empty compare-page-empty">
<span className="plan-empty-icon">⚖️</span> <span className="plan-empty-icon">⚖️</span>
<p>{t.compare.needMore} {Math.max(0, 2 - selected.length)} {t.compare.needMoreSuffix}</p> <p>{t.compare.needMore} {Math.max(0, 2 - selected.length)} {t.compare.needMoreSuffix}</p>
<div className="dating-empty-actions">
<Link href="/#destinations" className="btn btn-ghost">{t.compare.backDest}</Link> <Link href="/#destinations" className="btn btn-ghost">{t.compare.backDest}</Link>
<Link href="/next-stop" className="btn">{t.nav.nextStop}</Link>
<Link href="/ai" className="btn btn-ghost">{t.nav.ai}</Link>
</div>
</div> </div>
) : ( ) : (
<> <>
@ -302,26 +207,12 @@ export default function CompareClient({ destinations }: Props) {
</div> </div>
<div className="compare-page-actions"> <div className="compare-page-actions">
<button type="button" className="btn btn-ghost" onClick={() => void copySnapshot()}>
{t.compare.snapshot}
</button>
<button type="button" className="btn btn-ghost" onClick={addWinner} disabled={winnerIdx < 0}> <button type="button" className="btn btn-ghost" onClick={addWinner} disabled={winnerIdx < 0}>
{t.compare.addWinner} {t.compare.addWinner}
</button> </button>
<button type="button" className="btn btn-primary" onClick={addAll}> <button type="button" className="btn btn-primary" onClick={addAll}>
{t.compare.addAll} {t.compare.addAll}
</button> </button>
<Link href="/plan" className="btn btn-ghost">
{t.compare.openPlan}
</Link>
{winnerIdx >= 0 && selected[winnerIdx] && (
<Link
href={meetupCityHref(selected[winnerIdx].name)}
className="btn btn-ghost"
>
{t.compare.winnerMeetups.replace("{city}", selected[winnerIdx].name)}
</Link>
)}
</div> </div>
</section> </section>
@ -337,12 +228,7 @@ export default function CompareClient({ destinations }: Props) {
</> </>
)} )}
<RingNext <RingNext steps={rings.afterCity} />
steps={rings.afterCompare(
picked,
winnerIdx >= 0 && selected[winnerIdx] ? selected[winnerIdx].name : undefined
)}
/>
</div> </div>
</div> </div>
); );

View File

@ -7,154 +7,53 @@ import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
const DRAFT_KEY = "nomadro-submit-draft";
type SubmitDraft = { title: string; url: string; notes: string; contentType: string };
export default function ContentSubmitClient() { export default function ContentSubmitClient() {
const { token } = useAuth(); const { token } = useAuth();
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [contentType, setContentType] = useState("article"); const [contentType, setContentType] = useState("article");
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => {
const draft = loadDraft<SubmitDraft>(DRAFT_KEY);
if (draft) {
setTitle(draft.title || "");
setUrl(draft.url || "");
setNotes(draft.notes || "");
setContentType(draft.contentType || "article");
}
setReady(true);
}, []);
useEffect(() => {
if (!ready) return;
const id = window.setTimeout(() => {
if (!title && !url && !notes) {
clearDraft(DRAFT_KEY);
return;
}
saveDraft(DRAFT_KEY, { title, url, notes, contentType });
}, 400);
return () => window.clearTimeout(id);
}, [title, url, notes, contentType, ready]);
const submit = async () => { const submit = async () => {
if (!token) { if (!token) {
router.push("/login?next=/submit"); router.push("/login?next=/submit");
return; return;
} }
if (!title.trim() || title.trim().length < 4) {
toast(t.submit.needTitle, "info");
return;
}
if ((contentType === "article" || contentType === "video") && !/^https?:\/\/.+/.test(url.trim())) {
toast(t.submit.needUrl, "info");
return;
}
setBusy(true);
try { try {
await api.submitContent( await api.submitContent({ title, url, notes, content_type: contentType, name: "" }, token);
{ toast(t.submit.ok, "success");
title: title.trim(),
url: url.trim(),
notes: notes.trim(),
content_type: contentType,
name: "",
},
token
);
clearDraft(DRAFT_KEY);
toast(t.submit.ok, "success", {
href: "/community",
label: t.submit.goCommunity,
});
setTitle(""); setTitle("");
setUrl(""); setUrl("");
setNotes(""); setNotes("");
setDone(true);
} catch { } catch {
toast(t.submit.fail, "error"); toast(t.submit.fail, "error");
} finally {
setBusy(false);
} }
}; };
return ( return (
<div className="community-page"> <div className="community-page">
<div className="container"> <div className="container">
<nav className="detail-nav"> <nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
<Link href="/">{t.common.backHome}</Link>
</nav>
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.submit.tag}</span> <span className="section-tag">{t.submit.tag}</span>
<h1>{t.submit.title}</h1> <h1>{t.submit.title}</h1>
<p>{t.submit.subtitle}</p> <p>{t.submit.subtitle}</p>
{!done && <p className="section-desc">{t.submit.draftHint}</p>}
</div> </div>
{done ? (
<div className="dating-gate reveal">
<h2>{t.submit.thanksTitle}</h2>
<p>{t.submit.thanksDesc}</p>
<div className="dating-empty-actions">
<button type="button" className="btn btn-primary" onClick={() => setDone(false)}>
{t.submit.writeAgain}
</button>
<Link href="/community" className="btn">
{t.submit.goCommunity}
</Link>
</div>
</div>
) : (
<div className="community-compose reveal"> <div className="community-compose reveal">
<select value={contentType} onChange={(e) => setContentType(e.target.value)}> <select value={contentType} onChange={(e) => setContentType(e.target.value)}>
<option value="article">{t.submit.article}</option> <option value="article">{t.submit.article}</option>
<option value="video">{t.submit.video}</option> <option value="video">{t.submit.video}</option>
<option value="ebook">{t.submit.ebook}</option> <option value="ebook">{t.submit.ebook}</option>
</select> </select>
<input <input placeholder={t.submit.subject} value={title} onChange={(e) => setTitle(e.target.value)} />
placeholder={t.submit.subject} <input placeholder={t.submit.url} value={url} onChange={(e) => setUrl(e.target.value)} />
value={title} <textarea rows={4} placeholder={t.submit.notes} value={notes} onChange={(e) => setNotes(e.target.value)} />
onChange={(e) => setTitle(e.target.value)} <button type="button" className="btn btn-primary" onClick={submit}>{t.submit.send}</button>
maxLength={120}
/>
<input
placeholder={t.submit.url}
value={url}
onChange={(e) => setUrl(e.target.value)}
maxLength={500}
/>
<textarea
rows={4}
placeholder={t.submit.notes}
value={notes}
onChange={(e) => setNotes(e.target.value)}
maxLength={2000}
/>
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void submit()}>
{busy ? t.submit.submitting : t.submit.send}
</button>
{!token && (
<p className="section-desc">
<Link href="/login?next=/submit">{t.nav.login}</Link> · {t.gigs.loginFirst}
</p>
)}
</div> </div>
)}
<RingNext steps={rings.afterGigs} />
</div> </div>
</div> </div>
); );

View File

@ -2,10 +2,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n";
export default function CookieConsent() { export default function CookieConsent() {
const { t } = useI18n();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
useEffect(() => { useEffect(() => {
@ -23,16 +21,15 @@ export default function CookieConsent() {
if (!visible) return null; if (!visible) return null;
return ( return (
<div className="cookie-consent" role="dialog" aria-live="polite"> <div className="cookie-consent">
<div className="cookie-consent-inner">
<span className="cookie-emoji">🍪</span>
<p> <p>
{t.cookies.body}{" "} 我们使用 localStorage 保存主题偏好和行程数据,不会追踪你的浏览行为。
<Link href="/privacy">{t.cookies.privacy}</Link> <Link href="/privacy">隐私政策</Link>
{" · "}
<Link href="/cookies">{t.cookies.cookies}</Link>
</p> </p>
<button type="button" className="btn btn-primary btn-sm" onClick={accept}> <button className="btn btn-primary btn-sm" onClick={accept}>知道了 ✓</button>
{t.cookies.ok} </div>
</button>
</div> </div>
); );
} }

View File

@ -1,12 +1,7 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { meetupCityHref } from "@/lib/meetupLinks";
const HOME_CITIES = [ const HOME_CITIES = [
{ name: "上海", cost: 12000, emoji: "🏙️" }, { name: "上海", cost: 12000, emoji: "🏙️" },
@ -21,8 +16,6 @@ interface Props {
} }
export default function CostCompare({ destinations }: Props) { export default function CostCompare({ destinations }: Props) {
const { t } = useI18n();
const { toast } = useToast();
const [homeIdx, setHomeIdx] = useState(0); const [homeIdx, setHomeIdx] = useState(0);
const [destSlug, setDestSlug] = useState(destinations[0]?.slug || ""); const [destSlug, setDestSlug] = useState(destinations[0]?.slug || "");
@ -41,15 +34,6 @@ export default function CostCompare({ destinations }: Props) {
const maxCost = Math.max(home.cost, dest.cost); const maxCost = Math.max(home.cost, dest.cost);
const addToPlan = () => {
const { added } = mergeDestinationsIntoTrip([dest], 1);
toast(
added ? `${dest.emoji} ${dest.name} ${t.plan.addedToPlan}` : t.common.alreadyInPlan,
added ? "success" : "info",
{ href: "/plan", label: t.strip.openPlan }
);
};
return ( return (
<section className="section cost-compare-section" id="cost-compare"> <section className="section cost-compare-section" id="cost-compare">
<div className="container"> <div className="container">
@ -128,17 +112,6 @@ export default function CostCompare({ destinations }: Props) {
</> </>
)} )}
</div> </div>
<div className="dating-empty-actions" style={{ marginTop: "1rem" }}>
<button type="button" className="btn btn-primary btn-sm" onClick={addToPlan}>
🗓️ {t.nav.plan}
</button>
<Link href={`/destinations/${dest.slug}`} className="btn btn-sm">
{t.common.detail}
</Link>
<Link href={meetupCityHref(dest.name)} className="btn btn-sm btn-ghost">
{t.common.cityMeetups}
</Link>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@ -11,8 +11,16 @@ import type { MatchIntent, MatchProfile, MatchQuota } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
const SWIPE_THRESHOLD = 110; const INTENTS: { key: MatchIntent; label: string; emoji: string }[] = [
const SUPER_THRESHOLD = -120; { key: "friends", label: "交朋友", emoji: "🤝" },
{ key: "dating", label: "约会", emoji: "💕" },
{ key: "partner", label: "伴侣", emoji: "💑" },
{ key: "roommate", label: "合租", emoji: "🏠" },
{ key: "cofounder", label: "联创", emoji: "🚀" },
{ key: "explore", label: "探索", emoji: "🌍" },
];
const PRIMARY_INTENTS = INTENTS.slice(0, 3);
export default function DatingClient() { export default function DatingClient() {
const { user, token } = useAuth(); const { user, token } = useAuth();
@ -26,177 +34,64 @@ export default function DatingClient() {
const [quota, setQuota] = useState<MatchQuota | null>(null); const [quota, setQuota] = useState<MatchQuota | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [joined, setJoined] = useState(false); const [joined, setJoined] = useState(false);
const [error, setError] = useState("");
const [matchModal, setMatchModal] = useState<{ conversationId?: string; name: string } | null>(null); const [matchModal, setMatchModal] = useState<{ conversationId?: string; name: string } | null>(null);
const [drag, setDrag] = useState({ x: 0, y: 0, active: false, flying: "" });
const startRef = useRef<{ x: number; y: number } | null>(null);
const dragRef = useRef({ x: 0, y: 0 });
const swipingRef = useRef(false);
const cardRef = useRef<HTMLDivElement | null>(null);
const intents = useMemo(
() =>
[
{ key: "friends" as const, label: t.dating.intentFriends, emoji: "🤝" },
{ key: "dating" as const, label: t.dating.intentDating, emoji: "💕" },
{ key: "partner" as const, label: t.dating.intentPartner, emoji: "💑" },
{ key: "roommate" as const, label: t.dating.intentRoommate, emoji: "🏠" },
{ key: "cofounder" as const, label: t.dating.intentCofounder, emoji: "🚀" },
{ key: "explore" as const, label: t.dating.intentExplore, emoji: "🌍" },
],
[t]
);
const primary = intents.slice(0, 3);
const quotaBlocked = Boolean(quota && !quota.vip && quota.remaining <= 0);
const ensureJoined = useCallback(async () => {
if (!token) return;
try {
await api.socialJoin(token, {
city: t.dating.defaultCity,
lookingFor: [intent, "explore"],
bio: t.dating.defaultBio,
});
} catch {
// Profile may already exist.
}
}, [token, intent, t]);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!token) { if (!token) return;
setLoading(false);
return;
}
setLoading(true); setLoading(true);
setError("");
try { try {
await ensureJoined();
const [candidates, q] = await Promise.all([ const [candidates, q] = await Promise.all([
api.getMatchCandidates(token, { intent }), api.getMatchCandidates(token, { intent }),
api.getMatchQuota(token), api.getMatchQuota(token),
]); ]);
setDeck(Array.isArray(candidates) ? candidates : []); setDeck(candidates);
setQuota(q); setQuota(q);
setJoined(true); setJoined(true);
setDrag({ x: 0, y: 0, active: false, flying: "" }); } catch {
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
setJoined(false); setJoined(false);
setDeck([]); setDeck([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [token, intent, ensureJoined]); }, [token, intent]);
useEffect(() => { useEffect(() => { void load(); }, [load]);
void load();
}, [load]);
const quickJoin = async () => { const quickJoin = async () => {
if (!token) { if (!token) {
router.push("/login?next=/dating"); router.push("/login?next=/dating");
return; return;
} }
setLoading(true);
setError("");
try { try {
await api.socialJoin(token, { await api.socialJoin(token, {
city: t.dating.defaultCity, city: "全球",
lookingFor: [intent, "explore"], lookingFor: [intent, "explore"],
bio: t.dating.defaultBio, bio: "nomadro 游民",
});
toast(t.dating.joinOk, "success", {
href: "/meetups",
label: t.dating.meetAtEvents,
}); });
toast("资料已完善,开始匹配", "success");
await load(); await load();
} catch (err) { } catch {
const msg = err instanceof Error ? err.message : t.dating.joinFail; toast("加入失败", "error");
setError(msg);
toast(t.dating.joinFail, "error");
setLoading(false);
} }
}; };
const swipe = useCallback( const swipe = async (action: "like" | "dislike" | "superlike") => {
async (action: "like" | "dislike" | "superlike") => { if (!token || !deck[0]) return;
if (!token || !deck[0] || swipingRef.current || quotaBlocked) return;
swipingRef.current = true;
const current = deck[0]; const current = deck[0];
const fly = action === "like" ? "right" : action === "dislike" ? "left" : "up";
setDrag({ x: 0, y: 0, active: false, flying: fly });
try { try {
const res = await api.swipe(token, current.id, action, intent); const res = await api.swipe(token, current.id, action, intent);
if (res.matched && res.match?.conversationId) { if (res.matched && res.match?.conversationId) {
setMatchModal({ conversationId: res.match.conversationId, name: current.name }); setMatchModal({ conversationId: res.match.conversationId, name: current.name });
} }
window.setTimeout(() => {
setDeck((d) => d.slice(1)); setDeck((d) => d.slice(1));
setDrag({ x: 0, y: 0, active: false, flying: "" });
swipingRef.current = false;
}, 220);
const q = await api.getMatchQuota(token); const q = await api.getMatchQuota(token);
setQuota(q); setQuota(q);
} catch { } catch {
setDrag({ x: 0, y: 0, active: false, flying: "" }); toast("滑动失败,请检查登录或配额", "error");
swipingRef.current = false;
toast(t.dating.swipeFail, "error");
} }
},
[token, deck, intent, quotaBlocked, toast, t]
);
const onPointerDown = (e: React.PointerEvent) => {
if (quotaBlocked || swipingRef.current) return;
const target = e.target as HTMLElement;
if (target.closest("button,a,input")) return;
startRef.current = { x: e.clientX, y: e.clientY };
dragRef.current = { x: 0, y: 0 };
setDrag({ x: 0, y: 0, active: true, flying: "" });
cardRef.current?.setPointerCapture(e.pointerId);
}; };
const onPointerMove = (e: React.PointerEvent) => {
if (!startRef.current || !drag.active) return;
const x = e.clientX - startRef.current.x;
const y = e.clientY - startRef.current.y;
dragRef.current = { x, y };
setDrag({ x, y, active: true, flying: "" });
};
const endDrag = () => {
if (!startRef.current) return;
const { x, y } = dragRef.current;
startRef.current = null;
if (Math.abs(x) > SWIPE_THRESHOLD) {
void swipe(x > 0 ? "like" : "dislike");
return;
}
if (y < SUPER_THRESHOLD) {
void swipe("superlike");
return;
}
setDrag({ x: 0, y: 0, active: false, flying: "" });
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!deck[0] || matchModal || quotaBlocked) return;
if (e.key === "ArrowLeft") void swipe("dislike");
if (e.key === "ArrowRight") void swipe("like");
if (e.key === "ArrowUp") void swipe("superlike");
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [deck, matchModal, quotaBlocked, swipe]);
const top = deck[0]; const top = deck[0];
const next = deck[1];
const rotate = drag.x * 0.06;
const likeOpacity = Math.min(1, Math.max(0, drag.x / SWIPE_THRESHOLD));
const nopeOpacity = Math.min(1, Math.max(0, -drag.x / SWIPE_THRESHOLD));
const superOpacity = Math.min(1, Math.max(0, -drag.y / Math.abs(SUPER_THRESHOLD)));
if (!user) { if (!user) {
return ( return (
@ -205,11 +100,7 @@ export default function DatingClient() {
<div className="dating-gate reveal"> <div className="dating-gate reveal">
<h2>{t.dating.loginTitle}</h2> <h2>{t.dating.loginTitle}</h2>
<p>{t.dating.loginDesc}</p> <p>{t.dating.loginDesc}</p>
<div className="dating-empty-actions">
<Link href="/login?next=/dating" className="btn btn-primary">{t.nav.login}</Link> <Link href="/login?next=/dating" className="btn btn-primary">{t.nav.login}</Link>
<Link href="/meetups" className="btn btn-ghost">{t.dating.meetAtEvents}</Link>
<Link href="/community" className="btn btn-ghost">{t.nav.community}</Link>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -225,8 +116,6 @@ export default function DatingClient() {
<Link href="/chat">{t.nav.chat}</Link> <Link href="/chat">{t.nav.chat}</Link>
<span> · </span> <span> · </span>
<Link href="/dating/likes">{t.dating.likesTitle}</Link> <Link href="/dating/likes">{t.dating.likesTitle}</Link>
<span> · </span>
<Link href="/meetups">{t.nav.meetups}</Link>
</nav> </nav>
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.dating.tag}</span> <span className="section-tag">{t.dating.tag}</span>
@ -235,7 +124,7 @@ export default function DatingClient() {
</div> </div>
<div className="dating-intents reveal"> <div className="dating-intents reveal">
{(moreIntents ? intents : primary).map((i) => ( {(moreIntents ? INTENTS : PRIMARY_INTENTS).map((i) => (
<button <button
key={i.key} key={i.key}
type="button" type="button"
@ -247,171 +136,46 @@ export default function DatingClient() {
))} ))}
{!moreIntents && ( {!moreIntents && (
<button type="button" className="filter-btn" onClick={() => setMoreIntents(true)}> <button type="button" className="filter-btn" onClick={() => setMoreIntents(true)}>
{t.dating.moreIntents} 更多意图…
</button> </button>
)} )}
</div> </div>
{quota && ( {quota && (
<p className="dating-quota reveal"> <p className="dating-quota reveal">
{quota.vip {quota.vip ? "✨ VIP 无限滑" : `今日剩余 ${quota.remaining}/${quota.limit} 次`}
? t.dating.vipUnlimited
: t.dating.quotaLeft
.replace("{remaining}", String(quota.remaining))
.replace("{limit}", String(quota.limit))}
{!quota.vip && quota.remaining <= 0 && (
<>
{" · "}
<Link href="/join">{t.dating.vipUnlock}</Link>
</>
)}
</p>
)}
{loading && (
<p className="dest-empty reveal">
{joined ? t.dating.loadingCandidates : t.dating.loadingProfile}
</p> </p>
)} )}
{!joined && !loading && ( {!joined && !loading && (
<div className="dating-gate reveal"> <div className="dating-gate reveal">
<p>{t.dating.joinFirst}</p> <p>{t.dating.joinFirst}</p>
{error && <p className="dest-empty" style={{ marginTop: "0.5rem" }}>{error}</p>} <button type="button" className="btn btn-primary" onClick={quickJoin}>{t.dating.joinBtn}</button>
<div className="dating-empty-actions">
<button type="button" className="btn btn-primary" onClick={() => void quickJoin()}>
{t.dating.joinBtn}
</button>
<button type="button" className="btn btn-ghost" onClick={() => void load()}>
{t.dating.refresh}
</button>
</div>
</div> </div>
)} )}
{top && ( {top && (
<> <div className="dating-card reveal">
<p className="dating-swipe-hint reveal">{t.dating.swipeHint}</p>
<div className="dating-deck reveal">
{next && (
<div className="dating-card dating-card-next" aria-hidden>
<div className="dating-card-photo">{next.photo || "🧑‍💻"}</div>
<h2>{next.name}</h2>
</div>
)}
<div
ref={cardRef}
className={`dating-card dating-card-top${drag.active ? " dragging" : ""}${drag.flying ? ` fly-${drag.flying}` : ""}`}
style={
drag.flying
? undefined
: {
transform: `translate(${drag.x}px, ${drag.y}px) rotate(${rotate}deg)`,
transition: drag.active ? "none" : "transform 0.25s ease",
}
}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<span className="dating-stamp like" style={{ opacity: likeOpacity }}>
LIKE
</span>
<span className="dating-stamp nope" style={{ opacity: nopeOpacity }}>
NOPE
</span>
<span className="dating-stamp super" style={{ opacity: superOpacity }}>
SUPER
</span>
<div className="dating-card-photo">{top.photo || "🧑‍💻"}</div> <div className="dating-card-photo">{top.photo || "🧑‍💻"}</div>
<h2>{top.name}</h2> <h2>{top.name}</h2>
<p className="dating-meta"> <p className="dating-meta">{top.location} · {top.gender} {top.single ? `· ${top.single}` : ""}</p>
{top.location} · {top.gender} {top.single ? `· ${top.single}` : ""}
</p>
<p>{top.bio}</p> <p>{top.bio}</p>
<div className="dating-tags"> <div className="dating-tags">
{(top.tags || []).map((tag) => ( {(top.tags || []).map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)}
<span key={tag} className="meetup-tag">
{tag}
</span>
))}
</div> </div>
<Link <div className="dating-actions">
href={`/members/${top.userId || top.id}`} <button type="button" className="dating-btn pass" onClick={() => swipe("dislike")}>✕</button>
className="dating-view-profile" <button type="button" className="dating-btn super" onClick={() => swipe("superlike")}>⭐</button>
onPointerDown={(e) => e.stopPropagation()} <button type="button" className="dating-btn like" onClick={() => swipe("like")}>♥</button>
onClick={(e) => e.stopPropagation()}
>
{t.dating.viewProfile} →
</Link>
</div>
</div>
<div className="dating-actions reveal">
<button
type="button"
className="dating-btn pass"
onClick={() => void swipe("dislike")}
disabled={quotaBlocked}
aria-label={t.dating.swipeLeft}
>
✕
</button>
<button
type="button"
className="dating-btn super"
onClick={() => void swipe("superlike")}
disabled={quotaBlocked}
aria-label={t.dating.swipeUp}
>
⭐
</button>
<button
type="button"
className="dating-btn like"
onClick={() => void swipe("like")}
disabled={quotaBlocked}
aria-label={t.dating.swipeRight}
>
♥
</button>
{token && ( {token && (
<button <button type="button" className="btn btn-sm" title={t.dating.undo} onClick={() => api.undoSwipe(token).then(() => load())}>↩</button>
type="button"
className="btn btn-sm"
title={t.dating.undo}
onClick={() => void api.undoSwipe(token).then(() => load())}
>
↩
</button>
)} )}
</div> </div>
</> </div>
)} )}
{!loading && joined && !top && ( {!loading && joined && !top && (
<div className="dating-empty reveal"> <p className="dest-empty reveal">{t.dating.empty}</p>
<p className="dest-empty">{t.dating.empty}</p>
<div className="dating-empty-actions">
<button type="button" className="btn btn-sm" onClick={() => void load()}>
{t.dating.refresh}
</button>
<Link href="/meetups" className="btn btn-sm">
{t.dating.meetAtEvents}
</Link>
<Link href="/community" className="btn btn-sm">
{t.nav.community}
</Link>
<Link href="/chat" className="btn btn-sm">
{t.dating.viewChats}
</Link>
{quota && !quota.vip && (
<Link href="/join" className="btn btn-primary btn-sm">
{t.dating.openVip}
</Link>
)}
</div>
</div>
)} )}
<RingNext steps={rings.afterDating} /> <RingNext steps={rings.afterDating} />
@ -420,21 +184,12 @@ export default function DatingClient() {
{matchModal && ( {matchModal && (
<div className="dating-match-modal" role="dialog"> <div className="dating-match-modal" role="dialog">
<div className="dating-match-box"> <div className="dating-match-box">
<h3> <h3>🎉 {t.dating.matched} {matchModal.name}!</h3>
🎉 {t.dating.matched} {matchModal.name}!
</h3>
<div className="dating-match-actions"> <div className="dating-match-actions">
<button type="button" className="btn" onClick={() => setMatchModal(null)}> <button type="button" className="btn" onClick={() => setMatchModal(null)}>{t.dating.keepSwiping}</button>
{t.dating.keepSwiping}
</button>
{matchModal.conversationId && ( {matchModal.conversationId && (
<Link href={`/chat/${matchModal.conversationId}`} className="btn btn-primary"> <Link href={`/chat/${matchModal.conversationId}`} className="btn btn-primary">{t.dating.chatNow}</Link>
{t.dating.chatNow}
</Link>
)} )}
<Link href="/meetups" className="btn btn-ghost" onClick={() => setMatchModal(null)}>
{t.dating.meetAtEvents}
</Link>
</div> </div>
</div> </div>
</div> </div>

View File

@ -6,49 +6,24 @@ import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { MatchProfile } from "@/lib/types"; import type { MatchProfile } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
type Tab = "liked" | "received";
export default function DatingLikesClient() { export default function DatingLikesClient() {
const { user, token } = useAuth(); const { user, token } = useAuth();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps(); const [likes, setLikes] = useState<MatchProfile[]>([]);
const [tab, setTab] = useState<Tab>("received");
const [liked, setLiked] = useState<MatchProfile[]>([]);
const [received, setReceived] = useState<MatchProfile[]>([]);
const [loading, setLoading] = useState(Boolean(token));
useEffect(() => { useEffect(() => {
if (!token) { if (!token) return;
setLoading(false); api.getMatchLikes(token).then(setLikes).catch(() => setLikes([]));
return;
}
setLoading(true);
Promise.all([
api.getMatchLikes(token).catch(() => [] as MatchProfile[]),
api.getMatchLikesReceived(token).catch(() => [] as MatchProfile[]),
])
.then(([mine, incoming]) => {
setLiked(mine);
setReceived(incoming);
if (incoming.length === 0 && mine.length > 0) setTab("liked");
})
.finally(() => setLoading(false));
}, [token]); }, [token]);
const list = tab === "liked" ? liked : received;
if (!user) { if (!user) {
return ( return (
<div className="dating-page"> <div className="dating-page">
<div className="container"> <div className="container">
<div className="dating-gate reveal"> <div className="dating-gate reveal">
<h2>{t.dating.loginTitle}</h2> <h2>{t.dating.loginTitle}</h2>
<Link href="/login?next=/dating/likes" className="btn btn-primary"> <Link href="/login?next=/dating/likes" className="btn btn-primary">{t.nav.login}</Link>
{t.nav.login}
</Link>
</div> </div>
</div> </div>
</div> </div>
@ -60,79 +35,22 @@ export default function DatingLikesClient() {
<div className="container"> <div className="container">
<nav className="detail-nav"> <nav className="detail-nav">
<Link href="/dating">{t.dating.backSwipe}</Link> <Link href="/dating">{t.dating.backSwipe}</Link>
<span> · </span>
<Link href="/chat">{t.nav.chat}</Link>
</nav> </nav>
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.dating.tag}</span> <span className="section-tag">{t.dating.tag}</span>
<h1>{t.dating.likesTitle}</h1> <h1>{t.dating.likesTitle}</h1>
<p>{tab === "liked" ? t.dating.likesSubtitle : t.dating.likesReceivedSubtitle}</p> <p>{t.dating.likesSubtitle}</p>
</div> </div>
<div className="dating-intents reveal">
<button
type="button"
className={`filter-btn${tab === "received" ? " active" : ""}`}
onClick={() => setTab("received")}
>
{t.dating.likesReceivedTab}
{received.length ? ` (${received.length})` : ""}
</button>
<button
type="button"
className={`filter-btn${tab === "liked" ? " active" : ""}`}
onClick={() => setTab("liked")}
>
{t.dating.likesMineTab}
{liked.length ? ` (${liked.length})` : ""}
</button>
</div>
{loading && <p className="dest-empty">{t.common.loading}</p>}
{!loading && list.length === 0 && (
<div className="notif-empty reveal">
<p className="dest-empty">
{tab === "liked" ? t.dating.likesEmpty : t.dating.likesReceivedEmpty}
</p>
<div className="dating-empty-actions">
<Link href="/dating" className="btn btn-primary btn-sm">
{t.dating.goSwipe}
</Link>
<Link href="/meetups" className="btn btn-sm">
{t.dating.meetAtEvents}
</Link>
</div>
</div>
)}
<div className="dating-likes-grid"> <div className="dating-likes-grid">
{!loading && {likes.map((p) => (
list.map((p) => { <div key={p.id} className="dating-like-card reveal">
const photo = p.photo || ""; <div className="dating-like-photo">{p.photo || "🧑‍💻"}</div>
const href = p.userId
? `/members/${p.userId}?from=likes`
: p.id
? `/members/${p.id}?from=likes`
: "/dating";
return (
<Link key={p.id || p.userId || p.name} href={href} className="dating-like-card reveal">
<div className="dating-like-photo">
{photo.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={photo} alt="" className="member-photo-img" />
) : (
photo || "🧑‍💻"
)}
</div>
<strong>{p.name}</strong> <strong>{p.name}</strong>
<p className="dating-meta">{p.location || t.dating.globalNomad}</p> <p className="dating-meta">{p.location}</p>
{tab === "received" && ( </div>
<span className="meetup-tag">{t.dating.likesYou}</span> ))}
)} {likes.length === 0 && <p className="dest-empty">{t.dating.likesEmpty}</p>}
</Link>
);
})}
</div> </div>
<RingNext steps={rings.afterDating} />
</div> </div>
</div> </div>
); );

View File

@ -6,13 +6,10 @@ import { useToast } from "@/lib/toast";
import { loadTrip } from "@/lib/tripStorage"; import { loadTrip } from "@/lib/tripStorage";
import { loadPlanMeta, stayHint } from "@/lib/planMeta"; import { loadPlanMeta, stayHint } from "@/lib/planMeta";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { trackRecentDestination } from "@/lib/recentDestinations";
import { api, type CityContentItem, type CityDetailPayload } from "@/lib/api"; import { api, type CityContentItem, type CityDetailPayload } from "@/lib/api";
import type { Destination, Discussion, Meetup, TripItem } from "@/lib/types"; import type { Destination, Discussion, Meetup, TripItem } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import { useI18n } from "@/lib/i18n";
import { meetupCityHref, meetupEventHref } from "@/lib/meetupLinks";
const TZ_LABELS: Record<string, string> = { const TZ_LABELS: Record<string, string> = {
bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7", bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7",
@ -24,9 +21,19 @@ interface Props {
allDestinations: Destination[]; allDestinations: Destination[];
} }
function getScores(d: Destination) {
const nomads = parseInt(d.nomads_count.replace(/[^0-9]/g, ""), 10) || 0;
return [
{ label: "性价比", value: Math.round((12000 - d.cost) / 120), emoji: "💰" },
{ label: "网速", value: Math.round(d.speed / 2), emoji: "📶" },
{ label: "气候", value: Math.round(100 - Math.abs(d.temperature - 24) * 4), emoji: "🌡️" },
{ label: "评分", value: Math.round(d.rating * 10), emoji: "⭐" },
{ label: "社区", value: Math.min(Math.round(nomads / 150), 100), emoji: "🤝" },
];
}
export default function DestinationDetailClient({ dest, allDestinations }: Props) { export default function DestinationDetailClient({ dest, allDestinations }: Props) {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n();
const rings = useRingSteps(); const rings = useRingSteps();
const [added, setAdded] = useState(false); const [added, setAdded] = useState(false);
const [months, setMonths] = useState(1); const [months, setMonths] = useState(1);
@ -34,19 +41,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
const [content, setContent] = useState<CityContentItem[]>([]); const [content, setContent] = useState<CityContentItem[]>([]);
const [meetups, setMeetups] = useState<Meetup[]>([]); const [meetups, setMeetups] = useState<Meetup[]>([]);
const [discussions, setDiscussions] = useState<Discussion[]>([]); const [discussions, setDiscussions] = useState<Discussion[]>([]);
const [depthLoading, setDepthLoading] = useState(true); const scores = getScores(dest);
const scores = useMemo(() => {
const nomads = parseInt(dest.nomads_count.replace(/[^0-9]/g, ""), 10) || 0;
return [
{ label: t.destDetail.scoreValue, value: Math.round((12000 - dest.cost) / 120), emoji: "💰" },
{ label: t.destDetail.scoreSpeed, value: Math.round(dest.speed / 2), emoji: "📶" },
{ label: t.destDetail.scoreClimate, value: Math.round(100 - Math.abs(dest.temperature - 24) * 4), emoji: "🌡️" },
{ label: t.destDetail.scoreRating, value: Math.round(dest.rating * 10), emoji: "⭐" },
{ label: t.destDetail.scoreCommunity, value: Math.min(Math.round(nomads / 150), 100), emoji: "🤝" },
];
}, [dest, t]);
const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]); const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]);
const budget = useMemo(() => loadPlanMeta().monthlyBudget, []); const budget = useMemo(() => loadPlanMeta().monthlyBudget, []);
const overBudget = budget > 0 && dest.cost > budget; const overBudget = budget > 0 && dest.cost > budget;
@ -56,39 +51,18 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
.slice(0, 3); .slice(0, 3);
useEffect(() => { useEffect(() => {
trackRecentDestination({ api.getDestinationFull(dest.slug).then((full) => {
slug: dest.slug,
name: dest.name,
country: dest.country,
emoji: dest.emoji,
cost: dest.cost,
rating: dest.rating,
});
}, [dest.slug, dest.name, dest.country, dest.emoji, dest.cost, dest.rating]);
useEffect(() => {
setDepthLoading(true);
api
.getDestinationFull(dest.slug)
.then((full) => {
setDetail(full.detail); setDetail(full.detail);
setContent(full.content || []); setContent(full.content || []);
setMeetups(full.meetups || []); setMeetups(full.meetups || []);
setDiscussions(full.discussions || []); setDiscussions(full.discussions || []);
}) }).catch(() => {});
.catch(() => {
setDetail(null);
setContent([]);
setMeetups([]);
setDiscussions([]);
})
.finally(() => setDepthLoading(false));
}, [dest.slug]); }, [dest.slug]);
const addToTrip = () => { const addToTrip = () => {
const trip = loadTrip<TripItem>(); const trip = loadTrip<TripItem>();
if (trip.some((item) => item.slug === dest.slug)) { if (trip.some((t) => t.slug === dest.slug)) {
toast(t.common.alreadyInTrip, "info", { href: "/plan", label: t.strip.openPlan }); toast("该城市已在行程中", "info");
setAdded(true); setAdded(true);
return; return;
} }
@ -96,13 +70,9 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
setAdded(true); setAdded(true);
toast( toast(
n > 0 n > 0
? t.common.addedToPlanMonths ? `${dest.emoji} ${dest.name} 已加入旅居计划(${months} 个月)`
.replace("{emoji}", dest.emoji) : "该城市已在计划中",
.replace("{city}", dest.name) n > 0 ? "success" : "info"
.replace("{months}", String(months))
: t.common.alreadyInPlan,
n > 0 ? "success" : "info",
{ href: "/plan", label: t.strip.openPlan }
); );
}; };
@ -112,36 +82,13 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
await navigator.share({ title: `${dest.name} - nomadro`, url }); await navigator.share({ title: `${dest.name} - nomadro`, url });
} else { } else {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
toast(t.common.linkCopied); toast("链接已复制到剪贴板 📋");
}
};
const copySnapshot = async () => {
const url = typeof window !== "undefined" ? window.location.href : `/destinations/${dest.slug}`;
const lines = [
`${dest.emoji} ${dest.name}, ${dest.country}`,
t.destDetail.snapCost
.replace("{cost}", dest.cost.toLocaleString())
.replace("{speed}", String(dest.speed))
.replace("{rating}", String(dest.rating)),
t.destDetail.snapClimate
.replace("{temp}", String(dest.temperature))
.replace("{nomads}", dest.nomads_count),
visa ? t.destDetail.snapVisa.replace("{visa}", visa.text) : "",
t.destDetail.snapDetail.replace("{url}", url),
"— via nomadro",
].filter(Boolean);
try {
await navigator.clipboard.writeText(lines.join("\n"));
toast(t.profile.snapshotOk);
} catch {
toast(t.profile.snapshotOk, "info");
} }
}; };
useEffect(() => { useEffect(() => {
const trip = loadTrip<TripItem>(); const trip = loadTrip<TripItem>();
setAdded(trip.some((item) => item.slug === dest.slug)); setAdded(trip.some((t) => t.slug === dest.slug));
}, [dest.slug]); }, [dest.slug]);
const costBreak = detail?.cost?.breakdown || []; const costBreak = detail?.cost?.breakdown || [];
@ -152,11 +99,11 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
<> <>
<div className="dest-detail-actions"> <div className="dest-detail-actions">
{added ? ( {added ? (
<Link href="/plan" className="btn btn-primary">{t.common.inPlanOpen}</Link> <Link href="/plan" className="btn btn-primary">✓ 已在计划中 · 打开 →</Link>
) : ( ) : (
<> <>
<label className="dest-months-pick"> <label className="dest-months-pick">
{t.common.stayLabel} 停留
<input <input
type="number" type="number"
min={1} min={1}
@ -164,22 +111,15 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
value={months} value={months}
onChange={(e) => setMonths(Math.max(1, Math.min(24, +e.target.value || 1)))} onChange={(e) => setMonths(Math.max(1, Math.min(24, +e.target.value || 1)))}
/> />
{t.common.monthsUnit} 月
</label> </label>
<button type="button" className="btn btn-primary" onClick={addToTrip}>{t.common.addToPlan}</button> <button className="btn btn-primary" onClick={addToTrip}>🗓️ 加入旅居计划</button>
</> </>
)} )}
<Link href={`/compare?cities=${dest.slug}`} className="btn btn-ghost">{t.common.goCompare}</Link> <Link href="/meetups" className="btn btn-ghost">🍹 同城活动</Link>
<Link href="/next-stop" className="btn btn-ghost">{t.common.findNext}</Link> <button className="btn btn-ghost" onClick={share}>📤 分享</button>
<Link href={meetupCityHref(dest.name)} className="btn btn-ghost">{t.common.cityMeetups}</Link>
<button type="button" className="btn btn-ghost" onClick={share}>{t.common.shareBtn}</button>
<button type="button" className="btn btn-ghost" onClick={() => void copySnapshot()}>
{t.profile.snapshotBtn}
</button>
</div> </div>
{depthLoading && <p className="dest-depth-loading">{t.common.loadingDepth}</p>}
{(visa || overBudget) && ( {(visa || overBudget) && (
<div className="dest-plan-hints"> <div className="dest-plan-hints">
{visa && ( {visa && (
@ -187,9 +127,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
)} )}
{overBudget && ( {overBudget && (
<p className="plan-visa warn"> <p className="plan-visa warn">
{t.common.cityOverBudget 💰 月生活费 ¥{dest.cost.toLocaleString()} 高于你的计划月预算 ¥{budget.toLocaleString()}
.replace("{cost}", dest.cost.toLocaleString())
.replace("{budget}", budget.toLocaleString())}
</p> </p>
)} )}
</div> </div>
@ -197,11 +135,11 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{guide?.summary && ( {guide?.summary && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>{t.destDetail.guide}</h3> <h3>🧭 落地指南</h3>
<p>{guide.summary}</p> <p>{guide.summary}</p>
{guide.workSetup && <p className="dest-depth-muted">{guide.workSetup}</p>} {guide.workSetup && <p className="dest-depth-muted">{guide.workSetup}</p>}
{!!guide.bestFor?.length && ( {!!guide.bestFor?.length && (
<p className="dest-depth-tags">{t.destDetail.bestFor}{guide.bestFor.join(" · ")}</p> <p className="dest-depth-tags">适合:{guide.bestFor.join(" · ")}</p>
)} )}
{!!guide.arrivalChecklist?.length && ( {!!guide.arrivalChecklist?.length && (
<ul className="dest-depth-list"> <ul className="dest-depth-list">
@ -213,7 +151,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
<div className="dest-detail-extras"> <div className="dest-detail-extras">
<div className="dest-radar-card"> <div className="dest-radar-card">
<h3>{t.destDetail.radar}</h3> <h3>📊 城市画像</h3>
<div className="dest-radar-bars"> <div className="dest-radar-bars">
{scores.map((s) => ( {scores.map((s) => (
<div key={s.label} className="dest-radar-row"> <div key={s.label} className="dest-radar-row">
@ -231,26 +169,24 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
<div className="dest-info-mini"> <div className="dest-info-mini">
<span>🕐</span> <span>🕐</span>
<div> <div>
<strong>{t.destDetail.timezone}</strong> <strong>时区</strong>
<span>{TZ_LABELS[dest.slug] || t.common.viewTimezone}</span> <span>{TZ_LABELS[dest.slug] || "查看时区看板"}</span>
</div> </div>
</div> </div>
<div className="dest-info-mini"> <div className="dest-info-mini">
<span>👥</span> <span>👥</span>
<div> <div>
<strong>{t.destDetail.nomadCommunity}</strong> <strong>游民社区</strong>
<span>{detail?.people?.nomadsNow?.toLocaleString() || dest.nomads_count} {t.destDetail.active}</span> <span>{detail?.people?.nomadsNow?.toLocaleString() || dest.nomads_count} 活跃</span>
</div> </div>
</div> </div>
<div className="dest-info-mini"> <div className="dest-info-mini">
<span>🌡️</span> <span>🌡️</span>
<div> <div>
<strong>{t.destDetail.climate}</strong> <strong>气候</strong>
<span> <span>
{detail?.weather?.temperature ?? dest.temperature}°C {detail?.weather?.temperature ?? dest.temperature}°C
{detail?.weather?.bestMonths {detail?.weather?.bestMonths ? ` · 宜居 ${detail.weather.bestMonths.join("/")}` : ""}
? ` · ${t.destDetail.livable} ${detail.weather.bestMonths.join("/")}`
: ""}
</span> </span>
</div> </div>
</div> </div>
@ -259,7 +195,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!costBreak.length && ( {!!costBreak.length && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>{t.destDetail.costBreak}</h3> <h3>💸 月度成本拆解</h3>
<div className="dest-cost-grid"> <div className="dest-cost-grid">
{costBreak.map((row) => ( {costBreak.map((row) => (
<div key={row.label} className="dest-cost-row"> <div key={row.label} className="dest-cost-row">
@ -275,11 +211,11 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{(pros?.pros || pros?.cons) && ( {(pros?.pros || pros?.cons) && (
<section className="dest-depth-block dest-pros-cons"> <section className="dest-depth-block dest-pros-cons">
<div> <div>
<h3>{t.destDetail.pros}</h3> <h3>👍 优势</h3>
<ul>{(pros?.pros || []).map((p) => <li key={p}>{p}</li>)}</ul> <ul>{(pros?.pros || []).map((p) => <li key={p}>{p}</li>)}</ul>
</div> </div>
<div> <div>
<h3>{t.destDetail.cons}</h3> <h3>👎 注意</h3>
<ul>{(pros?.cons || []).map((p) => <li key={p}>{p}</li>)}</ul> <ul>{(pros?.cons || []).map((p) => <li key={p}>{p}</li>)}</ul>
</div> </div>
</section> </section>
@ -287,7 +223,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!detail?.reviews?.items?.length && ( {!!detail?.reviews?.items?.length && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>{t.destDetail.reviews} · {detail.reviews.rating}/10</h3> <h3>💬 游民评价 · {detail.reviews.rating}/10</h3>
<div className="dest-review-grid"> <div className="dest-review-grid">
{detail.reviews.items.map((r) => ( {detail.reviews.items.map((r) => (
<blockquote key={`${r.author}-${r.text.slice(0, 12)}`}> <blockquote key={`${r.author}-${r.text.slice(0, 12)}`}>
@ -301,26 +237,24 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!detail?.chat?.channels?.length && ( {!!detail?.chat?.channels?.length && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>{t.destDetail.localChat}</h3> <h3>💬 同城社群</h3>
<ul className="dest-depth-list"> <ul className="dest-depth-list">
{detail.chat.channels.map((c) => ( {detail.chat.channels.map((c) => (
<li key={c.name}>{c.name} · {c.members} {t.destDetail.peopleUnit} · {c.status}</li> <li key={c.name}>{c.name} · {c.members} 人 · {c.status}</li>
))} ))}
</ul> </ul>
{!!detail.chat.latestTopics?.length && ( {!!detail.chat.latestTopics?.length && (
<p className="dest-depth-muted"> <p className="dest-depth-muted">近期话题:{detail.chat.latestTopics.join(" · ")}</p>
{t.destDetail.recentTopics}{detail.chat.latestTopics.join(" · ")}
</p>
)} )}
</section> </section>
)} )}
{!!meetups.length && ( {!!meetups.length && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>{t.destDetail.relatedMeetups}</h3> <h3>🍹 相关活动</h3>
<div className="dest-related-grid"> <div className="dest-related-grid">
{meetups.slice(0, 4).map((m) => ( {meetups.slice(0, 4).map((m) => (
<Link key={m.id} href={meetupEventHref(m)} className="dest-related-card"> <Link key={m.id} href="/meetups" className="dest-related-card">
<span>{m.emoji || "🎉"}</span> <span>{m.emoji || "🎉"}</span>
<div> <div>
<strong>{m.title}</strong> <strong>{m.title}</strong>
@ -334,7 +268,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!discussions.length && ( {!!discussions.length && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>{t.destDetail.relatedDiscussions}</h3> <h3>🧵 相关讨论</h3>
<ul className="dest-depth-list"> <ul className="dest-depth-list">
{discussions.slice(0, 5).map((d) => ( {discussions.slice(0, 5).map((d) => (
<li key={d.id}> <li key={d.id}>
@ -347,14 +281,14 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!content.length && ( {!!content.length && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>{t.destDetail.relatedContent}</h3> <h3>📚 相关内容</h3>
<div className="dest-related-grid"> <div className="dest-related-grid">
{content.map((c) => ( {content.map((c) => (
<Link key={c.slug} href={c.targetUrl || "/digital"} className="dest-related-card"> <Link key={c.slug} href={c.targetUrl || "/digital"} className="dest-related-card">
<span>📄</span> <span>📄</span>
<div> <div>
<strong>{c.title}</strong> <strong>{c.title}</strong>
<span>{c.subtitle || c.ctaLabel || t.destDetail.viewFallback}</span> <span>{c.subtitle || c.ctaLabel || "查看"}</span>
</div> </div>
</Link> </Link>
))} ))}
@ -364,14 +298,14 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{related.length > 0 && ( {related.length > 0 && (
<div className="dest-related"> <div className="dest-related">
<h3>{t.destDetail.relatedRegion}</h3> <h3>🔗 同区域推荐</h3>
<div className="dest-related-grid"> <div className="dest-related-grid">
{related.map((d) => ( {related.map((d) => (
<Link key={d.slug} href={`/destinations/${d.slug}`} className="dest-related-card"> <Link key={d.slug} href={`/destinations/${d.slug}`} className="dest-related-card">
<span>{d.emoji}</span> <span>{d.emoji}</span>
<div> <div>
<strong>{d.name}</strong> <strong>{d.name}</strong>
<span>¥{d.cost.toLocaleString()}{t.destDetail.perMonth} · ⭐ {d.rating}</span> <span>¥{d.cost.toLocaleString()}/月 · ⭐ {d.rating}</span>
</div> </div>
</Link> </Link>
))} ))}
@ -379,7 +313,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
</div> </div>
)} )}
<RingNext steps={rings.afterCity(dest.name)} /> <RingNext steps={rings.afterCity} />
</> </>
); );
} }

View File

@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import { compareUrl } from "@/lib/compareScore"; import { compareUrl } from "@/lib/compareScore";
import { import {
EMPTY_MATCHER_PREFS, EMPTY_MATCHER_PREFS,
@ -27,7 +26,38 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
const STEP_KEYS = ["budget", "climate", "priority", "region"] as const; const STEPS = [
{ title: "你的月预算?", subtitle: "包含住宿、餐饮、交通等日常开销", key: "budget" as const },
{ title: "偏好什么气候?", subtitle: "选择最让你舒适的环境", key: "climate" as const },
{ title: "最看重什么?", subtitle: "我们会据此为你智能排序", key: "priority" as const },
{ title: "想去哪个区域?", subtitle: "可以选「不限」探索全球", key: "region" as const },
];
const OPTIONS = {
budget: [
{ value: "low" as MatcherBudget, emoji: "💰", label: "精打细算", desc: "¥5,000 以下/月" },
{ value: "medium" as MatcherBudget, emoji: "💳", label: "舒适适中", desc: "¥5,000 – 9,000/月" },
{ value: "high" as MatcherBudget, emoji: "💎", label: "品质优先", desc: "¥9,000 以上/月" },
],
climate: [
{ value: "warm" as MatcherClimate, emoji: "☀️", label: "热带温暖", desc: "25°C 以上,阳光沙滩" },
{ value: "mild" as MatcherClimate, emoji: "🌤️", label: "温和宜人", desc: "18–25°C,四季舒适" },
{ value: "cool" as MatcherClimate, emoji: "🍂", label: "凉爽清爽", desc: "18°C 以下,清爽干燥" },
],
priority: [
{ value: "cost" as MatcherPriority, emoji: "💰", label: "生活成本", desc: "花最少的钱过最好的生活" },
{ value: "speed" as MatcherPriority, emoji: "📶", label: "网络速度", desc: "稳定高速,会议不掉线" },
{ value: "community" as MatcherPriority, emoji: "🤝", label: "游民社区", desc: "结识同行,快速融入" },
{ value: "lifestyle" as MatcherPriority, emoji: "🎨", label: "生活方式", desc: "文化、美食与体验" },
],
region: [
{ value: "any" as MatcherRegion, emoji: "🌏", label: "不限", desc: "全球探索" },
{ value: "sea" as MatcherRegion, emoji: "🌴", label: "东南亚", desc: "性价比之王" },
{ value: "europe" as MatcherRegion, emoji: "🏰", label: "欧洲", desc: "历史与签证友好" },
{ value: "latam" as MatcherRegion, emoji: "🌮", label: "拉美", desc: "活力与北美时区" },
{ value: "asia" as MatcherRegion, emoji: "🏯", label: "东亚", desc: "安全高效现代" },
],
};
function parseNomads(n: string): number { function parseNomads(n: string): number {
return parseInt(n.replace(/[^0-9]/g, ""), 10) || 0; return parseInt(n.replace(/[^0-9]/g, ""), 10) || 0;
@ -62,75 +92,23 @@ function scoreDestination(
export default function DestinationMatcher({ destinations, open, onClose }: Props) { export default function DestinationMatcher({ destinations, open, onClose }: Props) {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n();
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [prefs, setPrefs] = useState<MatcherPrefs>(EMPTY_MATCHER_PREFS); const [prefs, setPrefs] = useState<MatcherPrefs>(EMPTY_MATCHER_PREFS);
const [direction, setDirection] = useState<"forward" | "back">("forward"); const [direction, setDirection] = useState<"forward" | "back">("forward");
const [hydrated, setHydrated] = useState(false); const [hydrated, setHydrated] = useState(false);
const steps = useMemo(
() => [
{ title: t.matcher.budgetTitle, subtitle: t.matcher.budgetSub, key: "budget" as const },
{ title: t.matcher.climateTitle, subtitle: t.matcher.climateSub, key: "climate" as const },
{ title: t.matcher.priorityTitle, subtitle: t.matcher.prioritySub, key: "priority" as const },
{ title: t.matcher.regionTitle, subtitle: t.matcher.regionSub, key: "region" as const },
],
[t]
);
const options = useMemo(
() => ({
budget: [
{ value: "low" as MatcherBudget, emoji: "💰", label: t.matcher.budgetLow, desc: t.matcher.budgetLowDesc },
{ value: "medium" as MatcherBudget, emoji: "💳", label: t.matcher.budgetMed, desc: t.matcher.budgetMedDesc },
{ value: "high" as MatcherBudget, emoji: "💎", label: t.matcher.budgetHigh, desc: t.matcher.budgetHighDesc },
],
climate: [
{ value: "warm" as MatcherClimate, emoji: "☀️", label: t.matcher.climateWarm, desc: t.matcher.climateWarmDesc },
{ value: "mild" as MatcherClimate, emoji: "🌤️", label: t.matcher.climateMild, desc: t.matcher.climateMildDesc },
{ value: "cool" as MatcherClimate, emoji: "🍂", label: t.matcher.climateCool, desc: t.matcher.climateCoolDesc },
],
priority: [
{ value: "cost" as MatcherPriority, emoji: "💰", label: t.matcher.priCost, desc: t.matcher.priCostDesc },
{ value: "speed" as MatcherPriority, emoji: "📶", label: t.matcher.priSpeed, desc: t.matcher.priSpeedDesc },
{ value: "community" as MatcherPriority, emoji: "🤝", label: t.matcher.priCommunity, desc: t.matcher.priCommunityDesc },
{ value: "lifestyle" as MatcherPriority, emoji: "🎨", label: t.matcher.priLifestyle, desc: t.matcher.priLifestyleDesc },
],
region: [
{ value: "any" as MatcherRegion, emoji: "🌏", label: t.matcher.regionAny, desc: t.matcher.regionAnyDesc },
{ value: "sea" as MatcherRegion, emoji: "🌴", label: t.matcher.regionSea, desc: t.matcher.regionSeaDesc },
{ value: "europe" as MatcherRegion, emoji: "🏰", label: t.matcher.regionEurope, desc: t.matcher.regionEuropeDesc },
{ value: "latam" as MatcherRegion, emoji: "🌮", label: t.matcher.regionLatam, desc: t.matcher.regionLatamDesc },
{ value: "asia" as MatcherRegion, emoji: "🏯", label: t.matcher.regionAsia, desc: t.matcher.regionAsiaDesc },
],
}),
[t]
);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) return;
setHydrated(false);
return;
}
const saved = loadMatcherPrefs(); const saved = loadMatcherPrefs();
setPrefs(saved); setPrefs(saved);
if (matcherPrefsComplete(saved)) setStep(STEP_KEYS.length); if (matcherPrefsComplete(saved)) setStep(STEPS.length);
else { else {
const filled = STEP_KEYS.findIndex((k) => !saved[k]); const filled = STEPS.findIndex((s) => !saved[s.key]);
setStep(filled === -1 ? 0 : filled); setStep(filled === -1 ? 0 : filled);
} }
setHydrated(true); setHydrated(true);
}, [open]); }, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
const results = useMemo(() => { const results = useMemo(() => {
if (!matcherPrefsComplete(prefs)) return []; if (!matcherPrefsComplete(prefs)) return [];
return [...destinations] return [...destinations]
@ -140,9 +118,9 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
const top3 = results.slice(0, 3).map((r) => r.dest); const top3 = results.slice(0, 3).map((r) => r.dest);
const currentKey = steps[step]?.key; const currentKey = STEPS[step]?.key;
const isComplete = step >= STEP_KEYS.length; const isComplete = step >= STEPS.length;
const progress = isComplete ? 100 : ((step + 1) / STEP_KEYS.length) * 100; const progress = isComplete ? 100 : ((step + 1) / STEPS.length) * 100;
const select = (value: string) => { const select = (value: string) => {
if (!currentKey) return; if (!currentKey) return;
@ -174,19 +152,11 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
const { added, skipped } = mergeDestinationsIntoTrip(top3, 1); const { added, skipped } = mergeDestinationsIntoTrip(top3, 1);
onClose(); onClose();
if (added === 0) { if (added === 0) {
toast(skipped ? t.matcher.alreadyAll : t.matcher.addFail, "info", { toast(skipped ? "这几座城已在计划中" : "未能加入计划", "info");
href: "/plan",
label: t.strip.openPlan,
});
router.push("/plan"); router.push("/plan");
return; return;
} }
toast( toast(`已将 Top ${added} 城写入旅居计划${skipped ? `(跳过 ${skipped} 座已有)` : ""}`);
t.matcher.addedTop.replace("{n}", String(added)) +
(skipped ? t.matcher.skipped.replace("{n}", String(skipped)) : ""),
"success",
{ href: "/plan", label: t.strip.openPlan }
);
router.push("/plan"); router.push("/plan");
}; };
@ -194,52 +164,30 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const { added } = mergeDestinationsIntoTrip([dest], 1); const { added } = mergeDestinationsIntoTrip([dest], 1);
toast( toast(added ? `${dest.emoji} ${dest.name} 已加入计划` : "该城已在计划中", added ? "success" : "info");
added ? `${dest.emoji} ${dest.name} ${t.plan.addedToPlan}` : t.common.alreadyInPlan,
added ? "success" : "info",
{ href: "/plan", label: t.strip.openPlan }
);
}; };
if (!open) return null; if (!open || !hydrated) return null;
return ( return (
<div <div className="modal-overlay open matcher-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
className="modal-overlay open matcher-overlay"
role="dialog"
aria-modal="true"
aria-label={t.matcher.tag.replace("{n}", "1").replace("{total}", String(STEP_KEYS.length))}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
{!hydrated ? (
<div className="modal matcher-modal"> <div className="modal matcher-modal">
<button className="modal-close" onClick={onClose} aria-label={t.matcher.close}> <button className="modal-close" onClick={onClose} aria-label="关闭">✕</button>
✕
</button>
<p className="dest-empty">{t.common.loading}</p>
</div>
) : (
<div className="modal matcher-modal">
<button className="modal-close" onClick={onClose} aria-label={t.matcher.close}>✕</button>
<div className="matcher-progress"> <div className="matcher-progress">
<div className="matcher-progress-bar" style={{ width: `${progress}%` }} /> <div className="matcher-progress-bar" style={{ width: `${progress}%` }} />
</div> </div>
<div className="matcher-body"> <div className="matcher-body">
{!isComplete && currentKey ? ( {!isComplete ? (
<div className={`matcher-step matcher-${direction}`} key={step}> <div className={`matcher-step matcher-${direction}`} key={step}>
<div className="matcher-step-meta"> <div className="matcher-step-meta">
<span className="section-tag"> <span className="section-tag">🎯 MATCHER · {step + 1}/{STEPS.length}</span>
{t.matcher.tag <h2>{STEPS[step].title}</h2>
.replace("{n}", String(step + 1)) <p>{STEPS[step].subtitle}</p>
.replace("{total}", String(STEP_KEYS.length))}
</span>
<h2>{steps[step].title}</h2>
<p>{steps[step].subtitle}</p>
</div> </div>
<div className="matcher-options"> <div className="matcher-options">
{options[currentKey].map((opt) => ( {OPTIONS[currentKey].map((opt) => (
<button <button
key={opt.value} key={opt.value}
className={`matcher-option${prefs[currentKey] === opt.value ? " selected" : ""}`} className={`matcher-option${prefs[currentKey] === opt.value ? " selected" : ""}`}
@ -257,32 +205,18 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
))} ))}
</div> </div>
<button className="matcher-back" onClick={goBack}> <button className="matcher-back" onClick={goBack}>
{step === 0 ? t.matcher.cancel : t.matcher.back} {step === 0 ? "取消" : "← 上一步"}
</button> </button>
</div> </div>
) : ( ) : (
<div className="matcher-results matcher-forward"> <div className="matcher-results matcher-forward">
<div className="matcher-step-meta"> <div className="matcher-step-meta">
<span className="section-tag">{t.matcher.resultsTag}</span> <span className="section-tag">✨ YOUR MATCHES</span>
<h2>{t.matcher.resultsTitle}</h2> <h2>为你推荐的目的地</h2>
<p>{t.matcher.resultsSub}</p> <p>偏好已保存。可一键写入旅居计划,或先对比再决定。</p>
</div> </div>
{results.length === 0 ? (
<div className="notif-empty">
<p className="dest-empty">{t.matcher.empty}</p>
<div className="dating-empty-actions">
<button type="button" className="btn btn-ghost" onClick={reset}>
{t.matcher.retry}
</button>
<Link href="/next-stop" className="btn btn-primary" onClick={onClose}>
{t.matcher.goNextStop}
</Link>
</div>
</div>
) : (
<>
<div className="matcher-result-list"> <div className="matcher-result-list">
{results.slice(0, 8).map(({ dest, score }, i) => ( {results.map(({ dest, score }, i) => (
<div key={dest.slug} className={`matcher-result-card${i === 0 ? " top" : ""}`}> <div key={dest.slug} className={`matcher-result-card${i === 0 ? " top" : ""}`}>
<Link href={`/destinations/${dest.slug}`} className="matcher-result-main" onClick={onClose}> <Link href={`/destinations/${dest.slug}`} className="matcher-result-main" onClick={onClose}>
<div className="matcher-result-rank"> <div className="matcher-result-rank">
@ -297,12 +231,7 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
<div className="matcher-score-bar"> <div className="matcher-score-bar">
<div className="matcher-score-fill" style={{ width: `${Math.min((score / (results[0]?.score || 1)) * 100, 100)}%` }} /> <div className="matcher-score-fill" style={{ width: `${Math.min((score / (results[0]?.score || 1)) * 100, 100)}%` }} />
</div> </div>
<span> <span>{Math.min(Math.round((score / (results[0]?.score || 1)) * 100), 100)}% 匹配</span>
{t.matcher.matchPct.replace(
"{n}",
String(Math.min(Math.round((score / (results[0]?.score || 1)) * 100), 100))
)}
</span>
</div> </div>
<div className="matcher-result-tags"> <div className="matcher-result-tags">
<span>💰 ¥{dest.cost.toLocaleString()}</span> <span>💰 ¥{dest.cost.toLocaleString()}</span>
@ -311,33 +240,30 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
</div> </div>
</Link> </Link>
<button type="button" className="matcher-add-btn" onClick={(e) => addOne(dest, e)}> <button type="button" className="matcher-add-btn" onClick={(e) => addOne(dest, e)}>
{t.matcher.addPlan} + 计划
</button> </button>
</div> </div>
))} ))}
</div> </div>
<div className="matcher-actions"> <div className="matcher-actions">
<button type="button" className="btn btn-ghost" onClick={reset}>{t.matcher.retryBtn}</button> <button className="btn btn-ghost" onClick={reset}>🔄 重新测试</button>
{top3.length >= 2 && ( {top3.length >= 2 && (
<Link <Link
href={compareUrl(top3.map((d) => d.slug))} href={compareUrl(top3.map((d) => d.slug))}
className="btn btn-ghost" className="btn btn-ghost"
onClick={onClose} onClick={onClose}
> >
{t.matcher.compareTop.replace("{n}", String(top3.length))} ⚖️ 对比 Top {top3.length}
</Link> </Link>
)} )}
<button type="button" className="btn btn-primary" onClick={buildPlanFromTop} disabled={top3.length === 0}> <button className="btn btn-primary" onClick={buildPlanFromTop} disabled={top3.length === 0}>
{t.matcher.writeTop.replace("{n}", String(Math.min(3, top3.length)))} 把 Top {Math.min(3, top3.length)} 写入计划 →
</button> </button>
</div> </div>
</>
)}
</div> </div>
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
); );
} }

View File

@ -113,30 +113,7 @@ export default function Destinations({ destinations, onOpenMatcher }: Props) {
</div> </div>
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="notif-empty reveal">
<p className="dest-empty">{t.home.destEmpty}</p> <p className="dest-empty">{t.home.destEmpty}</p>
<div className="dating-empty-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => {
setFilter("all");
setSearch("");
setExpanded(false);
}}
>
清除筛选
</button>
{onOpenMatcher && (
<button type="button" className="btn btn-primary btn-sm" onClick={onOpenMatcher}>
🎯 {t.home.destMatcher}
</button>
)}
<Link href="/next-stop" className="btn btn-sm">
智能下一站
</Link>
</div>
</div>
)} )}
{canExpand && ( {canExpand && (

View File

@ -1,27 +1,11 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useAuth } from "@/lib/auth";
import { api } from "@/lib/api";
import type { DigitalCourse } from "@/lib/types"; import type { DigitalCourse } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function DigitalCourseClient({ course }: { course: DigitalCourse }) { export default function DigitalCourseClient({ course }: { course: DigitalCourse }) {
const { t } = useI18n(); const { t } = useI18n();
const { token } = useAuth();
const rings = useRingSteps();
const [vip, setVip] = useState(false);
useEffect(() => {
if (!token) {
setVip(false);
return;
}
api.getVipStatus(token).then((s) => setVip(Boolean(s.vip))).catch(() => setVip(false));
}, [token]);
return ( return (
<div className="digital-page"> <div className="digital-page">
@ -32,58 +16,27 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.digital.courseTag}</span> <span className="section-tag">{t.digital.courseTag}</span>
<h1>{t.digital.course}</h1> <h1>{t.digital.course}</h1>
{vip ? (
<p className="join-vip-badge">{t.digital.vipUnlocked}</p>
) : (
<p className="section-desc">
{t.digital.vipHint} · <Link href="/join">{t.nav.join}</Link>
</p>
)}
</div> </div>
{course.modules.length === 0 ? (
<div className="notif-empty reveal">
<p className="dest-empty">{t.digital.courseEmpty}</p>
<div className="dating-empty-actions">
<Link href="/digital" className="btn">{t.digital.back}</Link>
<Link href="/gigs" className="btn btn-ghost">{t.nav.gigs}</Link>
<Link href="/join" className="btn btn-primary">{t.nav.join}</Link>
</div>
</div>
) : (
<div className="digital-modules reveal"> <div className="digital-modules reveal">
{course.modules.map((mod, mi) => ( {course.modules.map((mod, mi) => (
<section key={mod.title} className="digital-module"> <section key={mod.title} className="digital-module">
<h2>{mod.title}</h2> <h2>{mod.title}</h2>
<ul className="digital-lesson-list"> <ul className="digital-lesson-list">
{mod.lessons.map((lesson, li) => { {mod.lessons.map((lesson, li) => (
const locked = !lesson.free && !vip;
return (
<li key={lesson.title}> <li key={lesson.title}>
{locked ? (
<Link href="/join" className="digital-lesson-locked">
<span>🔒 {lesson.title}</span>
<span className="digital-lesson-meta">
{lesson.duration} · VIP · {t.digital.unlockVip}
</span>
</Link>
) : (
<Link href={`/digital/course/${mi}/${li}`}> <Link href={`/digital/course/${mi}/${li}`}>
<span>{lesson.title}</span> <span>{lesson.title}</span>
<span className="digital-lesson-meta"> <span className="digital-lesson-meta">
{lesson.duration} {lesson.duration}
{lesson.free ? ` · ${t.digital.free}` : " · VIP"} {lesson.free ? ` · ${t.digital.free}` : ` · VIP`}
</span> </span>
</Link> </Link>
)}
</li> </li>
); ))}
})}
</ul> </ul>
</section> </section>
))} ))}
</div> </div>
)}
<RingNext steps={rings.afterGigs} />
</div> </div>
</div> </div>
); );

View File

@ -1,73 +0,0 @@
"use client";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
const MAX_DAY = 7;
export default function DigitalDayClient({
id,
title,
body,
}: {
id: string;
title: string;
body: string;
}) {
const { t } = useI18n();
const rings = useRingSteps();
const n = Number(id);
const prev = Number.isFinite(n) && n > 1 ? String(n - 1) : null;
const next = Number.isFinite(n) && n < MAX_DAY ? String(n + 1) : null;
return (
<div className="digital-page">
<div className="container" style={{ maxWidth: 720 }}>
<nav className="detail-nav">
<Link href="/digital">← {t.digital.back}</Link>
</nav>
<article className="digital-lesson reveal">
<span className="section-tag">📅 DAY {id}</span>
<h1>{title}</h1>
<div className="digital-lesson-body">
{body
.split("\n")
.filter(Boolean)
.map((p, i) => (
<p key={`${i}-${p.slice(0, 12)}`}>{p}</p>
))}
</div>
<footer className="digital-lesson-nav">
{prev && (
<Link href={`/digital/day/${prev}`} className="btn">
← {t.digital.prev}
</Link>
)}
{next ? (
<Link href={`/digital/day/${next}`} className="btn btn-primary">
{t.digital.next} →
</Link>
) : (
<div className="dating-empty-actions">
<Link href="/digital/course" className="btn btn-primary">
{t.digital.backCatalog}
</Link>
<Link href="/meetups" className="btn btn-ghost">
{t.nav.meetups}
</Link>
</div>
)}
{next && (
<Link href="/digital/course" className="btn btn-ghost">
{t.digital.backCourse}
</Link>
)}
</footer>
</article>
<RingNext steps={rings.afterDigital} />
</div>
</div>
);
}

View File

@ -4,11 +4,9 @@ import Link from "next/link";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { DigitalCourse } from "@/lib/types"; import type { DigitalCourse } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function DigitalHomeClient({ course }: { course: DigitalCourse }) { export default function DigitalHomeClient({ course }: { course: DigitalCourse }) {
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const lessonCount = course.modules.reduce((n, m) => n + m.lessons.length, 0); const lessonCount = course.modules.reduce((n, m) => n + m.lessons.length, 0);
return ( return (
@ -45,14 +43,14 @@ export default function DigitalHomeClient({ course }: { course: DigitalCourse })
<h3>{t.digital.vip}</h3> <h3>{t.digital.vip}</h3>
<p>{t.digital.vipDesc}</p> <p>{t.digital.vipDesc}</p>
</Link> </Link>
<Link href="/digital/day/1" className="digital-card">
<span className="digital-card-emoji">📅</span>
<h3>{t.digital.dayGuide}</h3>
<p>{t.digital.dayGuideDesc}</p>
</Link>
</div> </div>
<RingNext steps={rings.afterDigital} /> <RingNext
steps={[
{ href: "/gigs", emoji: "💼", label: t.nav.gigs, desc: t.ring.communityWork },
{ href: "/community", emoji: "💬", label: t.ring.community, desc: t.ring.communityDesc },
]}
/>
</div> </div>
</div> </div>
); );

View File

@ -1,70 +1,11 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import type { DigitalJob } from "@/lib/types"; import type { DigitalJob } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
import {
clearSavedJobs,
loadSavedJobs,
toggleSavedJob,
type SavedJob,
} from "@/lib/savedJobs";
export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) { export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
const { t } = useI18n(); const { t } = useI18n();
const { toast } = useToast();
const rings = useRingSteps();
const [saved, setSaved] = useState<SavedJob[]>([]);
const [q, setQ] = useState("");
const [tag, setTag] = useState("all");
useEffect(() => {
setSaved(loadSavedJobs());
}, []);
const tags = useMemo(() => {
const set = new Set<string>();
jobs.forEach((j) => j.tags.forEach((x) => set.add(x)));
return Array.from(set).slice(0, 8);
}, [jobs]);
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
return jobs.filter((j) => {
if (tag !== "all" && !j.tags.includes(tag)) return false;
if (!s) return true;
return (
j.title.toLowerCase().includes(s) ||
j.company.toLowerCase().includes(s) ||
j.location.toLowerCase().includes(s) ||
j.tags.some((x) => x.toLowerCase().includes(s))
);
});
}, [jobs, q, tag]);
const onToggle = (job: DigitalJob) => {
const next = toggleSavedJob({
id: job.id,
title: job.title,
company: job.company,
location: job.location,
salary: job.salary,
url: job.url,
});
const now = next.some((x) => x.id === job.id);
setSaved(next);
toast(
now ? t.digital.jobsSaved : t.digital.jobsUnsaved,
"success",
now
? { href: "/digital/jobs", label: t.digital.jobsSavedList }
: { href: "/gigs", label: t.nav.gigs }
);
};
return ( return (
<div className="digital-page"> <div className="digital-page">
@ -76,121 +17,23 @@ export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
<span className="section-tag">{t.digital.jobsTag}</span> <span className="section-tag">{t.digital.jobsTag}</span>
<h1>{t.digital.jobs}</h1> <h1>{t.digital.jobs}</h1>
<p>{t.digital.jobsSubtitle}</p> <p>{t.digital.jobsSubtitle}</p>
<Link href="/gigs" className="btn btn-sm">{t.nav.gigs}</Link>
</div> </div>
{saved.length > 0 && (
<section className="saved-jobs-panel reveal">
<div className="saved-jobs-head">
<h2>
{t.digital.jobsSavedList} · {saved.length}
</h2>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
clearSavedJobs();
setSaved([]);
}}
>
{t.digital.jobsClearSaved}
</button>
</div>
<div className="saved-jobs-list">
{saved.map((j) => (
<a key={j.id} href={j.url} target="_blank" rel="noopener noreferrer" className="saved-job-chip">
<strong>{j.title}</strong>
<small>
{j.company} · {j.location}
</small>
</a>
))}
</div>
</section>
)}
{jobs.length > 0 && (
<div className="jobs-toolbar reveal">
<input
className="tools-hub-search"
placeholder={t.digital.jobsSearch}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="tools-hub-filters">
<button
type="button"
className={`filter-btn${tag === "all" ? " active" : ""}`}
onClick={() => setTag("all")}
>
{t.digital.jobsFilterAll}
</button>
{tags.map((x) => (
<button
key={x}
type="button"
className={`filter-btn${tag === x ? " active" : ""}`}
onClick={() => setTag(x)}
>
{x}
</button>
))}
</div>
</div>
)}
{jobs.length === 0 ? (
<div className="notif-empty reveal">
<p className="dest-empty">{t.digital.jobsEmpty}</p>
<div className="dating-empty-actions">
<Link href="/gigs" className="btn btn-primary btn-sm">{t.digital.jobsGoGigs}</Link>
<Link href="/community" className="btn btn-sm">{t.digital.jobsGoCommunity}</Link>
</div>
</div>
) : filtered.length === 0 ? (
<div className="notif-empty reveal">
<p className="dest-empty">{t.digital.jobsEmpty}</p>
<button type="button" className="btn btn-sm" onClick={() => { setQ(""); setTag("all"); }}>
{t.common.clear}
</button>
</div>
) : (
<div className="digital-jobs-grid reveal"> <div className="digital-jobs-grid reveal">
{filtered.map((job) => { {jobs.map((job) => (
const isSaved = saved.some((s) => s.id === job.id);
return (
<article key={job.id} className="digital-job-card"> <article key={job.id} className="digital-job-card">
<div className="gig-card-top">
<h3>{job.title}</h3> <h3>{job.title}</h3>
<button
type="button"
className={`btn btn-sm${isSaved ? " btn-primary" : " btn-ghost"}`}
onClick={() => onToggle(job)}
>
{isSaved ? `★ ${t.digital.jobsSaved}` : `☆ ${t.digital.jobsSave}`}
</button>
</div>
<p className="digital-job-company">{job.company} · {job.location}</p> <p className="digital-job-company">{job.company} · {job.location}</p>
<p className="digital-job-meta">{job.type} · {job.salary}</p> <p className="digital-job-meta">{job.type} · {job.salary}</p>
<div className="meetup-tags"> <div className="meetup-tags">
{job.tags.map((item) => ( {job.tags.map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)}
<span key={item} className="meetup-tag">{item}</span>
))}
</div> </div>
<a <a href={job.url} target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm">
href={job.url.startsWith("http") ? job.url : job.url || "/gigs"} {t.digital.apply}
target={job.url.startsWith("http") ? "_blank" : undefined}
rel={job.url.startsWith("http") ? "noopener noreferrer" : undefined}
className="btn btn-primary btn-sm"
>
{job.url.startsWith("http") ? t.digital.apply : t.digital.jobsGoGigs}
</a> </a>
</article> </article>
); ))}
})}
</div> </div>
)} {jobs.length === 0 && <p className="dest-empty">{t.digital.jobsEmpty}</p>}
<RingNext steps={rings.afterGigs} />
</div> </div>
</div> </div>
); );

View File

@ -1,123 +1,21 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useAuth } from "@/lib/auth"; import type { DigitalLesson } from "@/lib/types";
import { api } from "@/lib/api";
import type { DigitalCourse, DigitalLesson } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function DigitalLessonClient({ export default function DigitalLessonClient({
lesson,
locked,
moduleIndex, moduleIndex,
lessonIndex, lessonIndex,
}: { }: {
lesson: DigitalLesson | null;
locked: boolean;
moduleIndex: number; moduleIndex: number;
lessonIndex: number; lessonIndex: number;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { token } = useAuth();
const rings = useRingSteps();
const [lesson, setLesson] = useState<DigitalLesson | null>(null);
const [locked, setLocked] = useState(false);
const [missing, setMissing] = useState(false);
const [loading, setLoading] = useState(true);
const [hasNext, setHasNext] = useState(false);
const [nextHref, setNextHref] = useState<string | null>(null);
const [prevHref, setPrevHref] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
(async () => {
try {
const course: DigitalCourse = await api.getDigitalCourse();
const modules = course.modules || [];
const mod = modules[moduleIndex];
const meta = mod?.lessons?.[lessonIndex];
if (!meta) {
if (!cancelled) {
setMissing(true);
setLocked(false);
setLesson(null);
setHasNext(false);
setNextHref(null);
setPrevHref(null);
}
return;
}
let next: string | null = null;
if (mod.lessons[lessonIndex + 1]) {
next = `/digital/course/${moduleIndex}/${lessonIndex + 1}`;
} else if (modules[moduleIndex + 1]?.lessons?.length) {
next = `/digital/course/${moduleIndex + 1}/0`;
}
let prev: string | null = null;
if (lessonIndex > 0) {
prev = `/digital/course/${moduleIndex}/${lessonIndex - 1}`;
} else if (moduleIndex > 0) {
const prevMod = modules[moduleIndex - 1];
const last = (prevMod?.lessons?.length || 1) - 1;
if (prevMod?.lessons?.length) {
prev = `/digital/course/${moduleIndex - 1}/${last}`;
}
}
if (!cancelled) {
setHasNext(Boolean(next));
setNextHref(next);
setPrevHref(prev);
}
try {
const data = await api.getDigitalLesson(moduleIndex, lessonIndex, token || undefined);
if (!cancelled) {
setLesson(data);
setLocked(false);
setMissing(false);
}
} catch {
if (!cancelled) {
setLesson(null);
setLocked(!meta.free);
setMissing(meta.free);
}
}
} catch {
if (!cancelled) {
setMissing(true);
setLesson(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [moduleIndex, lessonIndex, token]);
if (loading) {
return (
<div className="digital-page">
<div className="container">
<p className="dest-empty">{t.digital.loadingLesson}</p>
</div>
</div>
);
}
if (missing) {
return (
<div className="digital-page">
<div className="container">
<div className="digital-gate reveal">
<h2>{t.digital.missingLesson}</h2>
<Link href="/digital/course" className="btn btn-primary">{t.digital.backCourse}</Link>
</div>
</div>
</div>
);
}
if (locked || !lesson) { if (locked || !lesson) {
return ( return (
@ -126,24 +24,16 @@ export default function DigitalLessonClient({
<div className="digital-gate reveal"> <div className="digital-gate reveal">
<h2>{t.digital.locked}</h2> <h2>{t.digital.locked}</h2>
<p>{t.digital.lockedDesc}</p> <p>{t.digital.lockedDesc}</p>
<div className="dating-empty-actions">
{!token && (
<Link href={`/login?next=/digital/course/${moduleIndex}/${lessonIndex}`} className="btn">
{t.nav.login}
</Link>
)}
<Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link> <Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link>
<Link href="/digital/course" className="btn">{t.digital.backCourse}</Link> <Link href="/digital/course" className="btn">{t.digital.backCourse}</Link>
</div> </div>
</div> </div>
<RingNext steps={rings.afterGigs} />
</div>
</div> </div>
); );
} }
const prev = prevHref; const prev = lessonIndex > 0 ? `/digital/course/${moduleIndex}/${lessonIndex - 1}` : null;
const next = hasNext ? nextHref : null; const next = `/digital/course/${moduleIndex}/${lessonIndex + 1}`;
return ( return (
<div className="digital-page"> <div className="digital-page">
@ -155,36 +45,15 @@ export default function DigitalLessonClient({
<span className="section-tag">{lesson.duration}</span> <span className="section-tag">{lesson.duration}</span>
<h1>{lesson.title}</h1> <h1>{lesson.title}</h1>
<div className="digital-lesson-body"> <div className="digital-lesson-body">
{lesson.content.split("\n").filter(Boolean).map((p, i) => ( {lesson.content.split("\n").map((p) => (
<p key={`${i}-${p.slice(0, 16)}`}>{p}</p> <p key={p.slice(0, 24)}>{p}</p>
))} ))}
</div> </div>
<footer className="digital-lesson-nav"> <footer className="digital-lesson-nav">
{prev && ( {prev && <Link href={prev} className="btn">← {t.digital.prev}</Link>}
<Link href={prev} className="btn"> <Link href={next} className="btn btn-primary">{t.digital.next} →</Link>
← {t.digital.prev}
</Link>
)}
{next ? (
<Link href={next} className="btn btn-primary">
{t.digital.next} →
</Link>
) : (
<div className="dating-empty-actions">
<Link href="/digital/course" className="btn btn-primary">
{t.digital.backCatalog}
</Link>
<Link href="/gigs" className="btn">
{t.nav.gigs}
</Link>
<Link href="/meetups" className="btn btn-ghost">
{t.nav.meetups}
</Link>
</div>
)}
</footer> </footer>
</article> </article>
<RingNext steps={next ? rings.afterGigs : rings.afterDigital} />
</div> </div>
</div> </div>
); );

View File

@ -1,88 +1,22 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
import { inferMeetupCity, meetupCityHref } from "@/lib/meetupLinks";
export default function DiscussionDetailClient({ export default function DiscussionDetailClient({ discussionId, initial }: { discussionId: string; initial: import("@/lib/types").DiscussionDetail }) {
discussionId,
initial,
}: {
discussionId: string;
initial: import("@/lib/types").DiscussionDetail;
}) {
const { user, token } = useAuth(); const { user, token } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const router = useRouter(); const router = useRouter();
const [discussion, setDiscussion] = useState(initial); const [discussion, setDiscussion] = useState(initial);
const [reply, setReply] = useState(""); const [reply, setReply] = useState("");
const [likes, setLikes] = useState(discussion.like_count); const [likes, setLikes] = useState(discussion.like_count);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [draftReady, setDraftReady] = useState(false);
const [cityNames, setCityNames] = useState<string[]>([]);
const draftKey = `nomadro-reply-draft:${discussionId}`;
const catLabel = (value: string) => {
const map: Record<string, string> = {
全部: t.community.catAll,
签证: t.community.catVisa,
远程工作: t.community.catRemote,
住宿: t.community.catHousing,
安全: t.community.catSafety,
社区: t.community.catCommunity,
};
return map[value] || value;
};
useEffect(() => {
const draft = loadDraft<{ text?: string }>(draftKey);
if (draft?.text?.trim()) {
setReply(draft.text);
toast(t.community.replyDraftRestored, "info");
}
setDraftReady(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [discussionId]);
useEffect(() => {
api
.getDestinations()
.then((list) => setCityNames(list.map((d) => d.name).filter(Boolean)))
.catch(() => setCityNames([]));
}, []);
useEffect(() => {
if (!draftReady || !token) return;
const id = window.setTimeout(() => {
if (!reply.trim()) {
clearDraft(draftKey);
return;
}
saveDraft(draftKey, { text: reply });
}, 400);
return () => window.clearTimeout(id);
}, [reply, draftReady, token, draftKey]);
const body = discussion.excerpt || "";
const inferredCity = useMemo(
() =>
inferMeetupCity(
`${discussion.title} ${body} ${discussion.category || ""}`,
cityNames
),
[discussion.title, discussion.category, body, cityNames]
);
const meetupsHref = inferredCity ? meetupCityHref(inferredCity) : "/meetups";
const refresh = async () => { const refresh = async () => {
const d = await api.getDiscussion(discussionId); const d = await api.getDiscussion(discussionId);
@ -91,26 +25,13 @@ export default function DiscussionDetailClient({
}; };
const submitReply = async () => { const submitReply = async () => {
if (!token) { if (!token || !reply.trim()) return;
router.push(`/login?next=/community/${discussionId}`);
return;
}
if (!reply.trim()) {
toast(t.community.replyNeed, "info");
return;
}
setLoading(true); setLoading(true);
try { try {
await api.postDiscussionReply(token, discussionId, reply.trim()); await api.postDiscussionReply(token, discussionId, reply.trim());
setReply(""); setReply("");
clearDraft(draftKey);
await refresh(); await refresh();
toast(t.community.replyOk, "success"); toast(t.community.replyOk, "success");
requestAnimationFrame(() => {
const nodes = document.querySelectorAll(".community-reply");
const last = nodes[nodes.length - 1];
last?.scrollIntoView({ behavior: "smooth", block: "center" });
});
} catch { } catch {
toast(t.community.replyFail, "error"); toast(t.community.replyFail, "error");
} finally { } finally {
@ -126,9 +47,7 @@ export default function DiscussionDetailClient({
try { try {
const res = await api.likeDiscussion(token, discussionId); const res = await api.likeDiscussion(token, discussionId);
setLikes(res.like_count); setLikes(res.like_count);
} catch { } catch { /* ignore */ }
toast(t.community.likeFail, "error");
}
}; };
return ( return (
@ -136,48 +55,31 @@ export default function DiscussionDetailClient({
<div className="container"> <div className="container">
<nav className="detail-nav"> <nav className="detail-nav">
<Link href="/community">← {t.community.back}</Link> <Link href="/community">← {t.community.back}</Link>
<span> · </span>
<Link href={meetupsHref}>
{inferredCity ? `${t.common.cityMeetups} · ${inferredCity}` : t.nav.meetups}
</Link>
</nav> </nav>
<article className="community-detail reveal"> <article className="community-detail reveal">
<header> <header>
<span className="community-detail-cat">{catLabel(discussion.category)}</span> <span className="community-detail-cat">{discussion.category}</span>
<h1>{discussion.title}</h1> <h1>{discussion.title}</h1>
<p className="community-meta"> <p className="community-meta">
{discussion.author_emoji} {discussion.author} · {discussion.created_at} {discussion.author_emoji} {discussion.author} · {discussion.created_at}
</p> </p>
</header> </header>
<div className="community-detail-body"> <div className="community-detail-body">
{body.split("\n").filter(Boolean).map((p, i) => ( <p>{discussion.excerpt}</p>
<p key={`${i}-${p.slice(0, 12)}`}>{p}</p>
))}
</div> </div>
<div className="community-detail-stats"> <div className="community-detail-stats">
<span> <span>💬 {discussion.reply_count} {t.community.replies}</span>
💬 {discussion.reply_count} {t.community.replies} <button type="button" className="btn btn-sm" onClick={toggleLike}>❤️ {likes}</button>
</span>
<button type="button" className="btn btn-sm" onClick={toggleLike}>
❤️ {likes}
</button>
</div> </div>
</article> </article>
<section className="community-replies reveal"> <section className="community-replies reveal">
<h2> <h2>{t.community.replies} ({discussion.replies.length})</h2>
{t.community.replies} ({discussion.replies.length})
</h2>
{discussion.replies.length === 0 && (
<p className="dest-empty">{t.community.noRepliesYet}</p>
)}
{discussion.replies.map((r) => ( {discussion.replies.map((r) => (
<div key={r.id} className="community-reply"> <div key={r.id} className="community-reply">
<div className="community-reply-head"> <div className="community-reply-head">
<span> <span>{r.author_emoji} {r.author}</span>
{r.author_emoji} {r.author}
</span>
<time>{r.created_at}</time> <time>{r.created_at}</time>
</div> </div>
<p>{r.content}</p> <p>{r.content}</p>
@ -187,44 +89,14 @@ export default function DiscussionDetailClient({
{user && token ? ( {user && token ? (
<div className="community-compose reveal"> <div className="community-compose reveal">
<textarea <textarea value={reply} onChange={(e) => setReply(e.target.value)} rows={3} placeholder={t.community.replyPlaceholder} />
value={reply} <button type="button" className="btn btn-primary" disabled={loading} onClick={submitReply}>{t.community.replySend}</button>
onChange={(e) => setReply(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
void submitReply();
}
}}
rows={3}
placeholder={t.community.replyPlaceholder}
maxLength={2000}
/>
<div className="community-compose-foot">
<span className="community-compose-count">
{reply.length}/2000
</span>
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void submitReply()}>
{loading ? t.community.replySending : t.community.replySend}
</button>
</div>
<p className="community-compose-hint">{t.community.replyShortcut}</p>
</div> </div>
) : ( ) : (
<div className="community-reply-hint reveal"> <div className="community-reply-hint reveal">
<p>{t.community.replyHint}</p> <Link href={`/login?next=/community/${discussionId}`} className="btn btn-primary">{t.nav.login}</Link>
<div className="dating-empty-actions">
<Link href={`/login?next=/community/${discussionId}`} className="btn btn-primary">
{t.nav.login}
</Link>
<Link href={meetupsHref} className="btn btn-ghost">
{t.dating.meetAtEvents}
</Link>
</div>
</div> </div>
)} )}
<RingNext steps={rings.afterCommunity(inferredCity)} />
</div> </div>
</div> </div>
); );

View File

@ -10,7 +10,7 @@ interface Props {
} }
export default function FAQSection({ faqs, limit }: Props) { export default function FAQSection({ faqs, limit }: Props) {
const { t } = useI18n(); const { t, locale } = useI18n();
const [active, setActive] = useState<string | null>(null); const [active, setActive] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [showAll, setShowAll] = useState(!limit); const [showAll, setShowAll] = useState(!limit);
@ -62,38 +62,17 @@ export default function FAQSection({ faqs, limit }: Props) {
))} ))}
</div> </div>
{filtered.length === 0 && (
<div className="notif-empty reveal">
<p className="dest-empty">{t.faqUi.empty}</p>
<div className="dating-empty-actions">
<button type="button" className="btn btn-sm" onClick={() => setSearch("")}>
{t.faqUi.clear}
</button>
<a href="/help" className="btn btn-sm btn-ghost">
{t.faqUi.help}
</a>
<a href="/ai" className="btn btn-sm btn-ghost">
{t.faqUi.askAi}
</a>
</div>
</div>
)}
{limit && !showAll && sorted.length > limit && ( {limit && !showAll && sorted.length > limit && (
<div className="dest-expand-wrap reveal"> <div className="dest-expand-wrap reveal">
<button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}> <button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}>
{t.faqUi.showAll.replace("{n}", String(sorted.length))} {locale === "en" ? `Show all ${sorted.length} questions` : `展开全部 ${sorted.length} 个问题`}
</button> </button>
</div> </div>
)} )}
<p className="faq-plan-hint reveal"> <p className="faq-plan-hint reveal">
{t.faqUi.still} {locale === "en" ? "Still exploring? " : "还在选城?"}
<a href="#destinations">{t.faqUi.browse}</a> <a href="#destinations">{locale === "en" ? "Browse destinations" : "继续看目的地"}</a>
{" · "}
<a href="/help">{t.faqUi.help}</a>
{" · "}
<a href="/ai">{t.faqUi.askAi}</a>
</p> </p>
</div> </div>
</section> </section>

View File

@ -1,51 +1,24 @@
"use client"; "use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { loginNextFrom } from "@/lib/authNext"; import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
export default function FavoriteButton({ slug }: { slug: string }) { export default function FavoriteButton({ slug }: { slug: string }) {
const { user, isFavorite, toggleFavorite } = useAuth(); const { user, isFavorite, toggleFavorite } = useAuth();
const { t } = useI18n();
const { toast } = useToast();
const pathname = usePathname();
const next = loginNextFrom(pathname || `/destinations/${slug}`);
if (!user) { if (!user) {
return ( return (
<Link <Link href="/login" className="btn btn-ghost" style={{ fontSize: "0.85rem" }}>
href={`/login?next=${encodeURIComponent(next)}`} 🤍 登录收藏
className="btn btn-ghost"
style={{ fontSize: "0.85rem" }}
>
{t.common.loginFavorite}
</Link> </Link>
); );
} }
const fav = isFavorite(slug); const fav = isFavorite(slug);
return ( return (
<button <button className={`btn ${fav ? "btn-primary" : "btn-ghost"}`}
type="button" onClick={() => toggleFavorite(slug)} style={{ fontSize: "0.85rem" }}>
className={`btn ${fav ? "btn-primary" : "btn-ghost"}`} {fav ? "❤️ 已收藏" : "🤍 收藏"}
onClick={() => {
const adding = !fav;
void toggleFavorite(slug).then(() => {
if (adding) {
toast(t.common.favorited, "success", {
href: "/profile",
label: t.profile.myFavs,
});
}
});
}}
style={{ fontSize: "0.85rem" }}
aria-pressed={fav}
>
{fav ? t.common.favorited : t.common.favorite}
</button> </button>
); );
} }

View File

@ -1,70 +1,27 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
const DRAFT_KEY = "nomadro-feedback-draft";
type FeedbackDraft = { content: string; category: string };
export default function FeedbackClient() { export default function FeedbackClient() {
const { token } = useAuth(); const { token } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [category, setCategory] = useState("general"); const [category, setCategory] = useState("general");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => {
const draft = loadDraft<FeedbackDraft>(DRAFT_KEY);
if (draft) {
setContent(draft.content || "");
setCategory(draft.category || "general");
if (draft.content?.trim()) {
toast(t.feedback.draftRestored, "info");
}
}
setReady(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!ready || done) return;
const id = window.setTimeout(() => {
if (!content.trim()) {
clearDraft(DRAFT_KEY);
return;
}
saveDraft(DRAFT_KEY, { content, category });
}, 400);
return () => window.clearTimeout(id);
}, [content, category, ready, done]);
const submit = async () => { const submit = async () => {
if (!content.trim() || content.trim().length < 5) { if (!content.trim()) return;
toast(t.feedback.minLength, "info");
return;
}
setLoading(true); setLoading(true);
try { try {
await api.submitFeedback(content.trim(), token || undefined, category); await api.submitFeedback(content.trim(), token || undefined, category);
clearDraft(DRAFT_KEY); toast(t.feedback.ok, "success");
toast(t.feedback.ok, "success", {
href: "/community",
label: t.feedback.goCommunity,
});
setContent(""); setContent("");
setDone(true);
} catch { } catch {
toast(t.feedback.fail, "error"); toast(t.feedback.fail, "error");
} finally { } finally {
@ -75,50 +32,22 @@ export default function FeedbackClient() {
return ( return (
<div className="community-page"> <div className="community-page">
<div className="container"> <div className="container">
<nav className="detail-nav"> <nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
<Link href="/">{t.common.backHome}</Link>
</nav>
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.feedback.tag}</span> <span className="section-tag">{t.feedback.tag}</span>
<h1>{t.feedback.title}</h1> <h1>{t.feedback.title}</h1>
<p>{t.feedback.subtitle}</p> <p>{t.feedback.subtitle}</p>
</div> </div>
{done ? (
<div className="dating-gate reveal">
<h2>{t.feedback.thanksTitle}</h2>
<p>{t.feedback.thanksDesc}</p>
<div className="dating-empty-actions">
<button type="button" className="btn btn-primary" onClick={() => setDone(false)}>
{t.feedback.writeAgain}
</button>
<Link href="/community" className="btn">
{t.feedback.goCommunity}
</Link>
</div>
</div>
) : (
<div className="community-compose reveal"> <div className="community-compose reveal">
<p className="section-desc">{t.feedback.draftHint}</p>
<select value={category} onChange={(e) => setCategory(e.target.value)}> <select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="general">{t.feedback.catGeneral}</option> <option value="general">{t.feedback.catGeneral}</option>
<option value="bug">{t.feedback.catBug}</option> <option value="bug">{t.feedback.catBug}</option>
<option value="feature">{t.feedback.catFeature}</option> <option value="feature">{t.feedback.catFeature}</option>
<option value="safety">{t.feedback.catSafety}</option> <option value="safety">{t.feedback.catSafety}</option>
</select> </select>
<textarea <textarea rows={6} placeholder={t.feedback.placeholder} value={content} onChange={(e) => setContent(e.target.value)} />
rows={6} <button type="button" className="btn btn-primary" disabled={loading} onClick={submit}>{t.feedback.submit}</button>
placeholder={t.feedback.placeholder}
value={content}
onChange={(e) => setContent(e.target.value)}
maxLength={2000}
/>
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void submit()}>
{loading ? t.feedback.submitting : t.feedback.submit}
</button>
{!token && <p className="section-desc">{t.feedback.guestHint}</p>}
</div> </div>
)}
<RingNext steps={rings.afterDiscover} />
</div> </div>
</div> </div>
); );

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