feat: NomadCNA community layer, social matching, payments, and ebook
Port meetups, discussions, gigs, dating/chat, VIP pay (ZPay/XorPay), MiroTalk live, digital academy, and ebook reader. Add persistent community_store, git-first deploy docs, and env template for production secrets. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
99ebbed799
commit
ff7b721acb
11
.gitignore
vendored
11
.gitignore
vendored
@ -8,6 +8,17 @@ frontend/.env.local
|
|||||||
|
|
||||||
# Auth / plan user store (runtime)
|
# Auth / plan user store (runtime)
|
||||||
backend/app/data/user_store.json
|
backend/app/data/user_store.json
|
||||||
|
backend/app/data/social_store.json
|
||||||
|
backend/app/data/community_store.json
|
||||||
|
|
||||||
|
# Production secrets (use deploy/nomadro-api.env.example)
|
||||||
|
deploy/nomadro-api.env
|
||||||
|
|
||||||
|
# Local screenshots / temp
|
||||||
|
book-*.png
|
||||||
|
tmp-screenshots/
|
||||||
|
scripts/_shell_out.txt
|
||||||
|
scripts/_*.py
|
||||||
|
|
||||||
# PocketBase
|
# PocketBase
|
||||||
pocketbase/pb_data/*
|
pocketbase/pb_data/*
|
||||||
|
|||||||
35
README.md
35
README.md
@ -167,6 +167,33 @@ NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1
|
|||||||
NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
|
NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Git 工作流
|
||||||
|
|
||||||
|
仓库:[gitea.dsx2020.com/eric/nomadweb](https://gitea.dsx2020.com/eric/nomadweb) · 主开发分支 **`dev1`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 日常开发
|
||||||
|
git checkout dev1
|
||||||
|
git pull origin dev1
|
||||||
|
# … 改代码 …
|
||||||
|
git add -A
|
||||||
|
git status # 确认未包含 .env、密钥、运行时 json
|
||||||
|
git commit -m "feat: 简述改动"
|
||||||
|
git push origin dev1
|
||||||
|
|
||||||
|
# 部署到生产(服务器 git pull + 增量构建)
|
||||||
|
python scripts/deploy_update.py
|
||||||
|
```
|
||||||
|
|
||||||
|
| 步骤 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `dev1` | 功能开发、联调、上线均在此分支 |
|
||||||
|
| `main` | 稳定快照,按需从 `dev1` 合并 |
|
||||||
|
| 密钥 | `deploy/nomadro-api.env` 仅放服务器,模板见 `deploy/nomadro-api.env.example` |
|
||||||
|
| 运行时数据 | `user_store.json` / `social_store.json` / `community_store.json` 不提交 |
|
||||||
|
|
||||||
|
**不要用 SFTP 全量同步当主流程** — `deploy_direct_sync.py` 仅作 Gitea 不可用时的应急备份(慢且易漏文件)。
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
### 生产(已上线)
|
### 生产(已上线)
|
||||||
@ -189,16 +216,16 @@ NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
|
|||||||
|
|
||||||
单元:`deploy/systemd/nomadro-web.service`、`nomadro-api.service`。
|
单元:`deploy/systemd/nomadro-web.service`、`nomadro-api.service`。
|
||||||
|
|
||||||
**推荐更新:**
|
**推荐更新(Git):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 需能 git pull 到 origin/dev1 时
|
git push origin dev1
|
||||||
python scripts/deploy_update.py
|
python scripts/deploy_update.py
|
||||||
```
|
```
|
||||||
|
|
||||||
拉取 → 按变更增量构建前后端 → `systemctl restart`;会停掉占用端口的旧 Docker Web/API 容器。
|
服务器执行 `git pull` → 按变更只重建前端或后端 → `systemctl restart`;比 SFTP 全量同步快且可回滚。
|
||||||
|
|
||||||
**Gitea 不可用 / 仅本地有最新提交时:**
|
**应急(Gitea 不可用 / 未 push 的本地提交):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/deploy_direct_sync.py
|
python scripts/deploy_direct_sync.py
|
||||||
|
|||||||
264
backend/app/data/community_data.py
Normal file
264
backend/app/data/community_data.py
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
"""Community & events mock data — adapted from NomadCNA business logic."""
|
||||||
|
|
||||||
|
MEETUPS = [
|
||||||
|
{
|
||||||
|
"id": "online-roundtable",
|
||||||
|
"title": "远程工作节奏圆桌",
|
||||||
|
"city": "线上",
|
||||||
|
"destination_slug": "",
|
||||||
|
"emoji": "🎙️",
|
||||||
|
"date": "2026-09-12",
|
||||||
|
"time": "20:00",
|
||||||
|
"venue": "nomadro 线上活动厅",
|
||||||
|
"description": "聊聊异步协作、时区管理和旅居城市选择。登录后可 RSVP,活动前会收到提醒。",
|
||||||
|
"mode": "online",
|
||||||
|
"access_level": "public",
|
||||||
|
"mirotalkRoom": "nomadro-online-roundtable",
|
||||||
|
"loungeChannel": "#nomadro-online",
|
||||||
|
"meetingUrl": "",
|
||||||
|
"rsvp_count": 38,
|
||||||
|
"max_attendees": 80,
|
||||||
|
"organizer": "nomadro 社区",
|
||||||
|
"tags": ["远程", "协作", "新手友好"],
|
||||||
|
"is_upcoming": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chiangmai-cowork",
|
||||||
|
"title": "清迈联合办公下午茶",
|
||||||
|
"city": "清迈",
|
||||||
|
"destination_slug": "chiang-mai",
|
||||||
|
"emoji": "☕",
|
||||||
|
"date": "2026-09-18",
|
||||||
|
"time": "15:00",
|
||||||
|
"venue": "Nimman 区 Co-working",
|
||||||
|
"description": "一起办公、分享签证经验和住宿踩坑。线下为主,也开放线上旁听链接。",
|
||||||
|
"mode": "hybrid",
|
||||||
|
"access_level": "public",
|
||||||
|
"mirotalkRoom": "nomadro-chiangmai-cowork",
|
||||||
|
"loungeChannel": "#chiangmai-cowork",
|
||||||
|
"rsvp_count": 14,
|
||||||
|
"max_attendees": 24,
|
||||||
|
"organizer": "清迈游民小组",
|
||||||
|
"tags": ["线下", "社交", "东南亚"],
|
||||||
|
"is_upcoming": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lisbon-sunset",
|
||||||
|
"title": "里斯本日落漫步",
|
||||||
|
"city": "里斯本",
|
||||||
|
"destination_slug": "lisbon",
|
||||||
|
"emoji": "🌅",
|
||||||
|
"date": "2026-09-22",
|
||||||
|
"time": "18:30",
|
||||||
|
"venue": "Miradouro 观景点集合",
|
||||||
|
"description": "工作一周后,和同城游民一起看日落、交换欧洲签证情报。",
|
||||||
|
"mode": "offline",
|
||||||
|
"access_level": "public",
|
||||||
|
"rsvp_count": 9,
|
||||||
|
"max_attendees": 16,
|
||||||
|
"organizer": "Lisbon Nomads",
|
||||||
|
"tags": ["欧洲", "户外", "社交"],
|
||||||
|
"is_upcoming": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bali-surf-morning",
|
||||||
|
"title": "巴厘岛晨间冲浪 + 办公",
|
||||||
|
"city": "巴厘岛",
|
||||||
|
"destination_slug": "bali",
|
||||||
|
"emoji": "🏄",
|
||||||
|
"date": "2026-10-05",
|
||||||
|
"time": "07:00",
|
||||||
|
"venue": "Canggu Beach",
|
||||||
|
"description": "早起冲浪,10 点回咖啡馆集中办公。适合想体验 Work-Life 平衡的游民。",
|
||||||
|
"mode": "offline",
|
||||||
|
"access_level": "public",
|
||||||
|
"rsvp_count": 11,
|
||||||
|
"max_attendees": 20,
|
||||||
|
"organizer": "Bali Remote Crew",
|
||||||
|
"tags": ["运动", "生活方式"],
|
||||||
|
"is_upcoming": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tokyo-tax-talk",
|
||||||
|
"title": "日本税务与居留线上分享",
|
||||||
|
"city": "线上",
|
||||||
|
"destination_slug": "tokyo",
|
||||||
|
"emoji": "🧾",
|
||||||
|
"date": "2026-10-12",
|
||||||
|
"time": "19:30",
|
||||||
|
"venue": "线上直播",
|
||||||
|
"description": "特邀长期旅居日本的游民分享税务申报、保险和银行开户经验(非法律建议)。",
|
||||||
|
"mode": "online",
|
||||||
|
"access_level": "members",
|
||||||
|
"rsvp_count": 52,
|
||||||
|
"max_attendees": 120,
|
||||||
|
"organizer": "nomadro 社区",
|
||||||
|
"tags": ["税务", "日本", "干货"],
|
||||||
|
"is_upcoming": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
DISCUSSIONS = [
|
||||||
|
{
|
||||||
|
"id": "visa-sea-2026",
|
||||||
|
"title": "2026 东南亚长期停留签证怎么选?",
|
||||||
|
"excerpt": "泰国 DTV、马来西亚 DE Rantau、印尼第二家园……大家最近实际办下来体验如何?",
|
||||||
|
"author": "小林",
|
||||||
|
"author_emoji": "🧳",
|
||||||
|
"category": "签证",
|
||||||
|
"reply_count": 23,
|
||||||
|
"like_count": 47,
|
||||||
|
"is_pinned": True,
|
||||||
|
"created_at": "2026-08-20",
|
||||||
|
"tags": ["签证", "东南亚"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "async-remote",
|
||||||
|
"title": "异步团队怎么写日报才不烦人?",
|
||||||
|
"excerpt": "我们团队跨 5 个时区,想收集团队节奏模板和工具推荐。",
|
||||||
|
"author": "Marco",
|
||||||
|
"author_emoji": "💻",
|
||||||
|
"category": "远程工作",
|
||||||
|
"reply_count": 15,
|
||||||
|
"like_count": 31,
|
||||||
|
"is_pinned": False,
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"tags": ["远程", "协作"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "coliving-tips",
|
||||||
|
"title": "第一次租 Coliving 必问房东的 10 个问题",
|
||||||
|
"excerpt": "押金、网速、清洁、访客政策……欢迎补充你的踩坑经历。",
|
||||||
|
"author": "阿静",
|
||||||
|
"author_emoji": "🏡",
|
||||||
|
"category": "住宿",
|
||||||
|
"reply_count": 19,
|
||||||
|
"like_count": 56,
|
||||||
|
"is_pinned": False,
|
||||||
|
"created_at": "2026-08-25",
|
||||||
|
"tags": ["住宿", "经验"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "health-insurance",
|
||||||
|
"title": "数字游民国际医疗保险对比",
|
||||||
|
"excerpt": "SafetyWing、Genki、本地险……按年龄段和目的地聊聊性价比。",
|
||||||
|
"author": "Yuki",
|
||||||
|
"author_emoji": "🏥",
|
||||||
|
"category": "安全",
|
||||||
|
"reply_count": 11,
|
||||||
|
"like_count": 28,
|
||||||
|
"is_pinned": False,
|
||||||
|
"created_at": "2026-08-27",
|
||||||
|
"tags": ["保险", "安全"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "meetup-feedback",
|
||||||
|
"title": "你希望 nomadro 线下活动开在哪些城市?",
|
||||||
|
"excerpt": "我们在规划 Q4 活动路线,投票 + 留言告诉我们你的城市!",
|
||||||
|
"author": "nomadro",
|
||||||
|
"author_emoji": "🌍",
|
||||||
|
"category": "社区",
|
||||||
|
"reply_count": 34,
|
||||||
|
"like_count": 72,
|
||||||
|
"is_pinned": True,
|
||||||
|
"created_at": "2026-08-28",
|
||||||
|
"tags": ["活动", "投票"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
GIGS = [
|
||||||
|
{
|
||||||
|
"id": "logo-design",
|
||||||
|
"title": "为游民社区设计 Logo",
|
||||||
|
"description": "需要扁平风格,含地球+代码元素,交付 SVG。",
|
||||||
|
"budget": "¥800",
|
||||||
|
"deadline": "2026-09-15",
|
||||||
|
"tags": ["设计", "远程"],
|
||||||
|
"poster": "nomadro",
|
||||||
|
"status": "open",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "meetup-host",
|
||||||
|
"title": "深圳线下活动主持人",
|
||||||
|
"description": "协助组织 20 人规模的游民交流会,有经验优先。",
|
||||||
|
"budget": "¥500/场",
|
||||||
|
"deadline": "2026-09-20",
|
||||||
|
"tags": ["活动", "深圳"],
|
||||||
|
"poster": "深圳湾区主理人",
|
||||||
|
"status": "open",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "content-translate",
|
||||||
|
"title": "签证指南英文润色",
|
||||||
|
"description": "将 3 篇中文签证攻略翻译润色为英文博客。",
|
||||||
|
"budget": "¥1200",
|
||||||
|
"deadline": "2026-10-01",
|
||||||
|
"tags": ["翻译", "内容"],
|
||||||
|
"poster": "nomadro",
|
||||||
|
"status": "open",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
DISCUSSION_REPLIES = {
|
||||||
|
"visa-sea-2026": [
|
||||||
|
{
|
||||||
|
"id": "r1",
|
||||||
|
"author": "阿Ken",
|
||||||
|
"author_emoji": "🇹🇭",
|
||||||
|
"content": "DTV 刚办下来,材料比想象中简单,关键是银行流水和远程工作证明。",
|
||||||
|
"created_at": "2026-08-21",
|
||||||
|
"like_count": 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "r2",
|
||||||
|
"author": "Sara",
|
||||||
|
"author_emoji": "🇲🇾",
|
||||||
|
"content": "DE Rantau 审批大概 3 周,适合想待吉隆坡的。",
|
||||||
|
"created_at": "2026-08-21",
|
||||||
|
"like_count": 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"async-remote": [
|
||||||
|
{
|
||||||
|
"id": "r1",
|
||||||
|
"author": "Devon",
|
||||||
|
"author_emoji": "🕐",
|
||||||
|
"content": "我们用 Loom 录屏 + 简短文字摘要,比长日报友好很多。",
|
||||||
|
"created_at": "2026-08-23",
|
||||||
|
"like_count": 9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
NOMAD_ROUTES = [
|
||||||
|
{
|
||||||
|
"id": "sea-slow",
|
||||||
|
"slug": "sea-slow-trail",
|
||||||
|
"title": "东南亚慢旅三角",
|
||||||
|
"duration_days": 90,
|
||||||
|
"budget": 18000,
|
||||||
|
"description": "清迈 → 巴厘岛 → 吉隆坡,低成本 + 高速网络 + 成熟游民社区。",
|
||||||
|
"stops": ["chiang-mai", "bali", "kuala-lumpur"],
|
||||||
|
"emoji": "🌴",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "euro-culture",
|
||||||
|
"slug": "euro-culture-loop",
|
||||||
|
"title": "欧洲文化环线",
|
||||||
|
"duration_days": 60,
|
||||||
|
"budget": 35000,
|
||||||
|
"description": "里斯本 → 巴塞罗那 → 柏林,签证友好、文化丰富。",
|
||||||
|
"stops": ["lisbon", "barcelona", "berlin"],
|
||||||
|
"emoji": "🏰",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "latam-budget",
|
||||||
|
"slug": "latam-budget-run",
|
||||||
|
"title": "拉美性价比冲刺",
|
||||||
|
"duration_days": 45,
|
||||||
|
"budget": 12000,
|
||||||
|
"description": "墨西哥城 → 麦德林,贴近北美时区、生活成本低。",
|
||||||
|
"stops": ["mexico-city", "medellin"],
|
||||||
|
"emoji": "🌮",
|
||||||
|
},
|
||||||
|
]
|
||||||
120
backend/app/data/digital_content.py
Normal file
120
backend/app/data/digital_content.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""Digital nomad academy content — nomadro /digital sub-site."""
|
||||||
|
|
||||||
|
SITE_ID = "nomadro-digital"
|
||||||
|
|
||||||
|
COURSE_MODULES = [
|
||||||
|
{
|
||||||
|
"title": "启程:远程工作基础",
|
||||||
|
"lessons": [
|
||||||
|
{"title": "什么是数字游民", "duration": "8 分钟", "free": True},
|
||||||
|
{"title": "异步协作入门", "duration": "12 分钟", "free": True},
|
||||||
|
{"title": "时区与会议管理", "duration": "15 分钟", "free": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "签证与税务",
|
||||||
|
"lessons": [
|
||||||
|
{"title": "东南亚长期签证概览", "duration": "18 分钟", "free": False},
|
||||||
|
{"title": "183 天规则与税务居民", "duration": "14 分钟", "free": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Homelab 与连接",
|
||||||
|
"lessons": [
|
||||||
|
{"title": "把家装进一个机柜", "duration": "20 分钟", "free": False},
|
||||||
|
{"title": "无论在哪都能连回家", "duration": "16 分钟", "free": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
LESSONS = {
|
||||||
|
"0-0": {
|
||||||
|
"title": "什么是数字游民",
|
||||||
|
"moduleIndex": 0,
|
||||||
|
"lessonIndex": 0,
|
||||||
|
"duration": "8 分钟",
|
||||||
|
"free": True,
|
||||||
|
"content": "数字游民是一种工作方式:产出不绑定在某个地理坐标上。你可以在任何有网络的地方完成交付。",
|
||||||
|
},
|
||||||
|
"0-1": {
|
||||||
|
"title": "异步协作入门",
|
||||||
|
"moduleIndex": 0,
|
||||||
|
"lessonIndex": 1,
|
||||||
|
"duration": "12 分钟",
|
||||||
|
"free": True,
|
||||||
|
"content": "异步沟通不是「随时在线」,而是「明确预期」:写清楚截止时间、交付物和决策人。",
|
||||||
|
},
|
||||||
|
"0-2": {
|
||||||
|
"title": "时区与会议管理",
|
||||||
|
"moduleIndex": 0,
|
||||||
|
"lessonIndex": 2,
|
||||||
|
"duration": "15 分钟",
|
||||||
|
"free": False,
|
||||||
|
"content": "用 overlap 窗口安排会议,非重叠时段留给深度工作。工具箱里的「会议黄金时段」可以帮你算。",
|
||||||
|
},
|
||||||
|
"1-0": {
|
||||||
|
"title": "东南亚长期签证概览",
|
||||||
|
"moduleIndex": 1,
|
||||||
|
"lessonIndex": 0,
|
||||||
|
"duration": "18 分钟",
|
||||||
|
"free": False,
|
||||||
|
"content": "泰国 DTV、马来西亚 DE Rantau、印尼第二家园……各国有不同的门槛与材料清单。",
|
||||||
|
},
|
||||||
|
"1-1": {
|
||||||
|
"title": "183 天规则与税务居民",
|
||||||
|
"moduleIndex": 1,
|
||||||
|
"lessonIndex": 1,
|
||||||
|
"duration": "14 分钟",
|
||||||
|
"free": False,
|
||||||
|
"content": "多数国家用 183 天判定税务居民。用 nomadro 税居天数工具追踪停留。",
|
||||||
|
},
|
||||||
|
"2-0": {
|
||||||
|
"title": "把家装进一个机柜",
|
||||||
|
"moduleIndex": 2,
|
||||||
|
"lessonIndex": 0,
|
||||||
|
"duration": "20 分钟",
|
||||||
|
"free": False,
|
||||||
|
"content": "Homelab 让你在旅途中拥有可控的 NAS、VPN 和开发环境。",
|
||||||
|
},
|
||||||
|
"2-1": {
|
||||||
|
"title": "无论在哪都能连回家",
|
||||||
|
"moduleIndex": 2,
|
||||||
|
"lessonIndex": 1,
|
||||||
|
"duration": "16 分钟",
|
||||||
|
"free": False,
|
||||||
|
"content": "WireGuard + 动态 DNS,把家里的服务安全暴露给在外的你。",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
JOBS = [
|
||||||
|
{
|
||||||
|
"id": "j1",
|
||||||
|
"title": "Senior Frontend Engineer",
|
||||||
|
"company": "Remote First Co",
|
||||||
|
"location": "全球远程",
|
||||||
|
"type": "全职",
|
||||||
|
"salary": "$80k–120k",
|
||||||
|
"tags": ["React", "TypeScript", "远程"],
|
||||||
|
"url": "https://nomadro.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "j2",
|
||||||
|
"title": "DevOps / SRE",
|
||||||
|
"company": "Nomad Labs",
|
||||||
|
"location": "欧洲时区",
|
||||||
|
"type": "合同",
|
||||||
|
"salary": "€60–90/h",
|
||||||
|
"tags": ["K8s", "AWS", "异步"],
|
||||||
|
"url": "https://nomadro.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "j3",
|
||||||
|
"title": "内容运营(中文)",
|
||||||
|
"company": "nomadro",
|
||||||
|
"location": "东南亚友好",
|
||||||
|
"type": "兼职",
|
||||||
|
"salary": "面议",
|
||||||
|
"tags": ["社区", "写作", "游民"],
|
||||||
|
"url": "https://nomadro.com",
|
||||||
|
},
|
||||||
|
]
|
||||||
54
backend/app/data/social_profiles.py
Normal file
54
backend/app/data/social_profiles.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""Nomad matching candidate profiles — adapted from NomadCNA business logic."""
|
||||||
|
|
||||||
|
MATCH_INTENTS = ("friends", "dating", "partner", "roommate", "cofounder", "explore")
|
||||||
|
|
||||||
|
CANDIDATE_PROFILES = [
|
||||||
|
{
|
||||||
|
"id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiang-mai",
|
||||||
|
"gender": "女", "single": "是", "bio": "远程前端,喜欢咖啡和徒步。",
|
||||||
|
"photo": "🧳", "tags": ["远程", "咖啡", "徒步"],
|
||||||
|
"lookingFor": ["friends", "dating", "explore"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "p2", "userId": "u2", "name": "Marco", "location": "里斯本", "citySlug": "lisbon",
|
||||||
|
"gender": "男", "single": "是", "bio": "全栈工程师,欧洲旅居第三年。",
|
||||||
|
"photo": "💻", "tags": ["全栈", "欧洲", "冲浪"],
|
||||||
|
"lookingFor": ["friends", "cofounder", "explore"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "p3", "userId": "u3", "name": "Yuki", "location": "巴厘岛", "citySlug": "bali",
|
||||||
|
"gender": "女", "single": "否", "bio": "设计师,寻找合租室友。",
|
||||||
|
"photo": "🎨", "tags": ["设计", "瑜伽", "合租"],
|
||||||
|
"lookingFor": ["roommate", "friends"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "p4", "userId": "u4", "name": "Alex", "location": "墨西哥城", "citySlug": "mexico-city",
|
||||||
|
"gender": "男", "single": "是", "bio": "独立开发者,拉美时区常驻。",
|
||||||
|
"photo": "🚀", "tags": ["独立开发", "拉美"],
|
||||||
|
"lookingFor": ["cofounder", "friends", "dating"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "p5", "userId": "u5", "name": "Sara", "location": "巴塞罗那", "citySlug": "barcelona",
|
||||||
|
"gender": "女", "single": "是", "bio": "产品运营,热爱美食与市集。",
|
||||||
|
"photo": "☕", "tags": ["产品", "美食"],
|
||||||
|
"lookingFor": ["dating", "partner", "friends"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "p6", "userId": "u6", "name": "Ken", "location": "东京", "citySlug": "tokyo",
|
||||||
|
"gender": "男", "single": "是", "bio": "数据分析师,东亚签证达人。",
|
||||||
|
"photo": "📊", "tags": ["数据", "签证"],
|
||||||
|
"lookingFor": ["friends", "explore"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "p7", "userId": "u7", "name": "Lina", "location": "大理", "citySlug": "dali",
|
||||||
|
"gender": "女", "single": "是", "bio": "内容创作者,慢生活倡导者。",
|
||||||
|
"photo": "📷", "tags": ["内容", "慢生活"],
|
||||||
|
"lookingFor": ["friends", "dating", "roommate"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "p8", "userId": "u8", "name": "Devon", "location": "柏林", "citySlug": "berlin",
|
||||||
|
"gender": "非二元", "single": "是", "bio": "DevOps,开源社区活跃成员。",
|
||||||
|
"photo": "🖥️", "tags": ["DevOps", "开源"],
|
||||||
|
"lookingFor": ["cofounder", "friends"],
|
||||||
|
},
|
||||||
|
]
|
||||||
@ -2,7 +2,7 @@
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routers import api, auth
|
from app.routers import api, auth, community, payment, social
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="nomadro API",
|
title="nomadro API",
|
||||||
@ -20,6 +20,8 @@ app.add_middleware(
|
|||||||
|
|
||||||
app.include_router(api.router, prefix="/api/v1")
|
app.include_router(api.router, prefix="/api/v1")
|
||||||
app.include_router(auth.router, prefix="/api/v1")
|
app.include_router(auth.router, prefix="/api/v1")
|
||||||
|
app.include_router(social.router, prefix="/api/v1")
|
||||||
|
app.include_router(community.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -1,12 +1,23 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query, Header
|
||||||
|
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
BlogPost, BlogPostDetail, ChartData, CompareRequest, CostCalculatorRequest,
|
BlogPost, BlogPostDetail, ChartData, CompareRequest, CostCalculatorRequest,
|
||||||
CostCalculatorResponse, Destination, FAQ, SearchResult, StatsResponse,
|
CostCalculatorResponse, Destination, Discussion, DiscussionDetail, FAQ,
|
||||||
|
Meetup, MeetupRsvpRequest, MeetupSession, NextStopRequest, NextStopResponse, NomadRoute,
|
||||||
|
DigitalLesson, DigitalJob,
|
||||||
|
RecommendedDestination, SearchResult, StatsResponse,
|
||||||
SubscribeRequest, SubscribeResponse, Testimonial, Tool, Visa,
|
SubscribeRequest, SubscribeResponse, Testimonial, Tool, Visa,
|
||||||
)
|
)
|
||||||
from app.services.pocketbase import pb
|
from app.services.pocketbase import pb
|
||||||
from app.data import mock_data
|
from app.data import mock_data
|
||||||
|
from app.data import community_data
|
||||||
|
from app.services.recommendations import recommend_destinations
|
||||||
|
from app.services.meetup_live import (
|
||||||
|
build_lounge_chat_url, build_mirotalk_join_url, can_access_meetup, create_mirotalk_room_slug,
|
||||||
|
)
|
||||||
|
from app.services.auth import get_user_by_token
|
||||||
|
from app.services import community_store, social_store
|
||||||
|
from app.data import digital_content
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@ -213,3 +224,135 @@ async def global_search(q: str = Query(..., min_length=1)):
|
|||||||
))
|
))
|
||||||
|
|
||||||
return results[:12]
|
return results[:12]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Community (ported from NomadCNA, adapted for nomadro) ──
|
||||||
|
|
||||||
|
@router.get("/meetups", response_model=list[Meetup])
|
||||||
|
async def list_meetups(upcoming: bool = True):
|
||||||
|
return [Meetup(**m) for m in community_store.list_meetups(upcoming)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meetups/{meetup_id}", response_model=Meetup)
|
||||||
|
async def get_meetup(meetup_id: str):
|
||||||
|
m = community_store.get_meetup(meetup_id)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "活动不存在")
|
||||||
|
return Meetup(**m)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/meetups/rsvp")
|
||||||
|
async def rsvp_meetup(body: MeetupRsvpRequest, authorization: str | None = Header(None)):
|
||||||
|
user_id = None
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
user = get_user_by_token(authorization[7:])
|
||||||
|
if user:
|
||||||
|
user_id = user["id"]
|
||||||
|
res = community_store.rsvp_meetup(body.meetup_id, user_id)
|
||||||
|
if not res.get("ok"):
|
||||||
|
if res.get("error") == "not_found":
|
||||||
|
raise HTTPException(404, "活动不存在")
|
||||||
|
raise HTTPException(400, "活动已满员")
|
||||||
|
return {"success": True, "message": res["message"], "rsvp_count": res["rsvp_count"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/discussions", response_model=list[Discussion])
|
||||||
|
async def list_discussions(category: str | None = Query(None)):
|
||||||
|
return [Discussion(**d) for d in community_store.list_discussions(category)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/discussions/{discussion_id}", response_model=DiscussionDetail)
|
||||||
|
async def get_discussion(discussion_id: str):
|
||||||
|
d = community_store.get_discussion(discussion_id)
|
||||||
|
if not d:
|
||||||
|
raise HTTPException(404, "讨论不存在")
|
||||||
|
return DiscussionDetail(**d)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/routes", response_model=list[NomadRoute])
|
||||||
|
async def list_routes():
|
||||||
|
return [NomadRoute(**r) for r in community_data.NOMAD_ROUTES]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recommendations/next-stop", response_model=NextStopResponse)
|
||||||
|
async def next_stop_recommendations(body: NextStopRequest):
|
||||||
|
dests = await pb.get_destinations()
|
||||||
|
recommended = recommend_destinations(
|
||||||
|
dests,
|
||||||
|
budget=body.budget,
|
||||||
|
internet=body.internet,
|
||||||
|
climate=body.climate,
|
||||||
|
tags=body.tags,
|
||||||
|
priority=body.priority,
|
||||||
|
limit=body.limit,
|
||||||
|
)
|
||||||
|
return NextStopResponse(
|
||||||
|
items=[RecommendedDestination(**d) for d in recommended],
|
||||||
|
routes=[NomadRoute(**r) for r in community_data.NOMAD_ROUTES],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meetups/{meetup_id}/session", response_model=MeetupSession)
|
||||||
|
async def meetup_session(meetup_id: str, authorization: str | None = Header(None)):
|
||||||
|
meetup = community_store.get_meetup(meetup_id)
|
||||||
|
if not meetup:
|
||||||
|
raise HTTPException(404, "活动不存在")
|
||||||
|
|
||||||
|
user = None
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
user = get_user_by_token(authorization[7:])
|
||||||
|
|
||||||
|
is_vip = social_store.is_vip(user["id"]) if user else False
|
||||||
|
can_join, lock = can_access_meetup(meetup, user, is_vip)
|
||||||
|
mode = meetup.get("mode", "offline")
|
||||||
|
display = (user or {}).get("name", "访客")
|
||||||
|
|
||||||
|
session = MeetupSession(
|
||||||
|
canJoin=can_join and mode in ("online", "hybrid"),
|
||||||
|
lockReason=lock if not can_join else "",
|
||||||
|
displayName=display,
|
||||||
|
mode=mode,
|
||||||
|
mirotalkRoom=meetup.get("mirotalkRoom") or create_mirotalk_room_slug(meetup),
|
||||||
|
loungeChannel=meetup.get("loungeChannel", ""),
|
||||||
|
)
|
||||||
|
if session.canJoin:
|
||||||
|
session.videoUrl = build_mirotalk_join_url(meetup, display)
|
||||||
|
session.chatUrl = build_lounge_chat_url(meetup)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# ── Digital academy content ──
|
||||||
|
|
||||||
|
@router.get("/digital/course")
|
||||||
|
async def digital_course():
|
||||||
|
return {"modules": digital_content.COURSE_MODULES, "siteId": digital_content.SITE_ID}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/digital/lessons/{module_index}/{lesson_index}", response_model=DigitalLesson)
|
||||||
|
async def digital_lesson(module_index: int, lesson_index: int, authorization: str | None = Header(None)):
|
||||||
|
key = f"{module_index}-{lesson_index}"
|
||||||
|
lesson = digital_content.LESSONS.get(key)
|
||||||
|
if not lesson:
|
||||||
|
raise HTTPException(404, "课时不存在")
|
||||||
|
if not lesson.get("free"):
|
||||||
|
user = None
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
user = get_user_by_token(authorization[7:])
|
||||||
|
if not user or not social_store.is_vip(user["id"]):
|
||||||
|
raise HTTPException(403, "需要 VIP 会员解锁")
|
||||||
|
return DigitalLesson(**lesson)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/digital/jobs", response_model=list[DigitalJob])
|
||||||
|
async def digital_jobs():
|
||||||
|
return [DigitalJob(**j) for j in digital_content.JOBS]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/digital/vip/check")
|
||||||
|
async def digital_vip_check(authorization: str | None = Header(None)):
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
return {"vip": False}
|
||||||
|
user = get_user_by_token(authorization[7:])
|
||||||
|
if not user:
|
||||||
|
return {"vip": False}
|
||||||
|
return {"vip": social_store.is_vip(user["id"])}
|
||||||
|
|||||||
121
backend/app/routers/community.py
Normal file
121
backend/app/routers/community.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
"""Community mutations — discussions, meetups, gigs, notifications."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Header, HTTPException, Query
|
||||||
|
|
||||||
|
from app.schemas import (
|
||||||
|
CreateDiscussionRequest, CreateMeetupRequest, CreateReplyRequest,
|
||||||
|
FeedbackRequest, GigApplyRequest, GigItem, NotificationItem, StatsOverview,
|
||||||
|
)
|
||||||
|
from app.services.auth import get_user_by_token
|
||||||
|
from app.services import community_store, social_store
|
||||||
|
|
||||||
|
router = APIRouter(tags=["community"])
|
||||||
|
|
||||||
|
|
||||||
|
def _user(authorization: str | None) -> dict:
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
user = get_user_by_token(authorization[7:])
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/discussions")
|
||||||
|
async def create_discussion(body: CreateDiscussionRequest, authorization: str | None = Header(None)):
|
||||||
|
user = _user(authorization)
|
||||||
|
d = community_store.create_discussion(user["id"], user["name"], body.model_dump())
|
||||||
|
return {"success": True, "discussion": d}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/discussions/{discussion_id}/replies")
|
||||||
|
async def post_reply(
|
||||||
|
discussion_id: str,
|
||||||
|
body: CreateReplyRequest,
|
||||||
|
authorization: str | None = Header(None),
|
||||||
|
):
|
||||||
|
user = _user(authorization)
|
||||||
|
reply = community_store.add_reply(discussion_id, user["id"], user["name"], body.content, body.author_emoji)
|
||||||
|
if not reply:
|
||||||
|
raise HTTPException(404, "讨论不存在")
|
||||||
|
return {"success": True, "reply": reply}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/discussions/{discussion_id}/like")
|
||||||
|
async def like_discussion(discussion_id: str, authorization: str | None = Header(None)):
|
||||||
|
user = _user(authorization)
|
||||||
|
return community_store.toggle_discussion_like(discussion_id, user["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/discussions/{discussion_id}/pin")
|
||||||
|
async def pin_discussion(discussion_id: str, authorization: str | None = Header(None)):
|
||||||
|
user = _user(authorization)
|
||||||
|
d = community_store.toggle_pin(discussion_id, user["id"])
|
||||||
|
if not d:
|
||||||
|
raise HTTPException(403, "仅作者可置顶")
|
||||||
|
return {"success": True, "discussion": d}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/meetups")
|
||||||
|
async def create_meetup(body: CreateMeetupRequest, authorization: str | None = Header(None)):
|
||||||
|
user = _user(authorization)
|
||||||
|
m = community_store.create_meetup(user["id"], user["name"], body.model_dump())
|
||||||
|
return {"success": True, "meetup": m}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meetups/my-rsvp")
|
||||||
|
async def my_rsvps(authorization: str | None = Header(None)):
|
||||||
|
user = _user(authorization)
|
||||||
|
return {"ids": list(community_store.user_rsvp_ids(user["id"]))}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/gigs", response_model=list[GigItem])
|
||||||
|
async def list_gigs():
|
||||||
|
return [GigItem(**g) for g in community_store.list_gigs()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/gigs/{gig_id}/apply")
|
||||||
|
async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | None = Header(None)):
|
||||||
|
user = _user(authorization)
|
||||||
|
res = community_store.apply_gig(gig_id, user["id"], user["name"], body.message)
|
||||||
|
if not res.get("ok"):
|
||||||
|
raise HTTPException(400, res.get("error", "申请失败"))
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications", response_model=list[NotificationItem])
|
||||||
|
async def list_notifications(authorization: str | None = Header(None)):
|
||||||
|
user = _user(authorization)
|
||||||
|
return [NotificationItem(**n) for n in community_store.list_notifications(user["id"])]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notifications/read")
|
||||||
|
async def mark_read(authorization: str | None = Header(None)):
|
||||||
|
user = _user(authorization)
|
||||||
|
community_store.mark_notifications_read(user["id"])
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/feedback")
|
||||||
|
async def submit_feedback(body: FeedbackRequest, authorization: str | None = Header(None)):
|
||||||
|
user = None
|
||||||
|
name = body.name or "访客"
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
user = get_user_by_token(authorization[7:])
|
||||||
|
if user:
|
||||||
|
name = user["name"]
|
||||||
|
item = community_store.add_feedback(user["id"] if user else None, name, body.content, body.category)
|
||||||
|
return {"success": True, "id": item["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/overview", response_model=StatsOverview)
|
||||||
|
async def stats_overview():
|
||||||
|
return StatsOverview(**community_store.stats_overview())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/members/{user_id}")
|
||||||
|
async def public_member(user_id: str):
|
||||||
|
profile = social_store.get_public_profile(user_id)
|
||||||
|
if not profile:
|
||||||
|
raise HTTPException(404, "用户不存在")
|
||||||
|
return profile
|
||||||
132
backend/app/routers/payment.py
Normal file
132
backend/app/routers/payment.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"""Payment + order fulfillment — ZPay/XorPay from NomadCNA."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
|
from app.schemas import PayCreateRequest, PayCreateResponse, PayStatusResponse
|
||||||
|
from app.services.auth import get_user_by_token
|
||||||
|
from app.services import social_store
|
||||||
|
from app.services.payment_service import (
|
||||||
|
DEV_AUTO_PAY,
|
||||||
|
PAYMENT_DEFAULT_AMOUNT,
|
||||||
|
PAYMENT_JOIN_AMOUNT,
|
||||||
|
create_pay_redirect,
|
||||||
|
parse_notify_order_id,
|
||||||
|
query_order_paid,
|
||||||
|
verify_notify,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/pay", tags=["payment"])
|
||||||
|
|
||||||
|
|
||||||
|
def _token_user(authorization: str | None) -> dict:
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
user = get_user_by_token(authorization[7:])
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
forwarded = request.headers.get("x-forwarded-for", "")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
if request.client:
|
||||||
|
return request.client.host
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create", response_model=PayCreateResponse)
|
||||||
|
async def create_payment(
|
||||||
|
body: PayCreateRequest,
|
||||||
|
request: Request,
|
||||||
|
authorization: str | None = Header(None),
|
||||||
|
):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
amount = PAYMENT_JOIN_AMOUNT if body.pay_type == "join" else PAYMENT_DEFAULT_AMOUNT
|
||||||
|
order = social_store.create_order(user["id"], body.pay_type, amount, dev_auto_pay=DEV_AUTO_PAY)
|
||||||
|
return_url = body.return_url or "https://nomadweb.nomadro.com/join/paid"
|
||||||
|
ua = request.headers.get("user-agent", "")
|
||||||
|
device = "wechat" if "micromessenger" in ua.lower() else "pc"
|
||||||
|
redirect = await create_pay_redirect(
|
||||||
|
order["id"],
|
||||||
|
amount,
|
||||||
|
return_url,
|
||||||
|
body.provider or "",
|
||||||
|
channel="wxpay" if device == "wechat" else "alipay",
|
||||||
|
device=device,
|
||||||
|
user_agent=ua,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
)
|
||||||
|
return PayCreateResponse(
|
||||||
|
order_id=order["id"],
|
||||||
|
amount=amount,
|
||||||
|
redirect_url=redirect,
|
||||||
|
status=order["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=PayStatusResponse)
|
||||||
|
async def pay_status(order_id: str = Query(...), authorization: str | None = Header(None)):
|
||||||
|
_token_user(authorization)
|
||||||
|
order = social_store.get_order(order_id)
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(404, "订单不存在")
|
||||||
|
if order["status"] != "paid":
|
||||||
|
if DEV_AUTO_PAY or await query_order_paid(order_id):
|
||||||
|
social_store.mark_order_paid(order_id)
|
||||||
|
order = social_store.get_order(order_id)
|
||||||
|
return PayStatusResponse(
|
||||||
|
order_id=order_id,
|
||||||
|
status=order["status"],
|
||||||
|
paid=order["status"] == "paid",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/complete")
|
||||||
|
async def complete_order(
|
||||||
|
order_id: str = Query(...),
|
||||||
|
authorization: str | None = Header(None),
|
||||||
|
):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
order = social_store.get_order(order_id)
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(404, "订单不存在")
|
||||||
|
if order["userId"] != user["id"]:
|
||||||
|
raise HTTPException(403, "无权操作此订单")
|
||||||
|
if order["status"] != "paid":
|
||||||
|
if DEV_AUTO_PAY or await query_order_paid(order_id):
|
||||||
|
social_store.mark_order_paid(order_id)
|
||||||
|
else:
|
||||||
|
raise HTTPException(402, "订单未支付")
|
||||||
|
return {"success": True, "vip": social_store.is_vip(user["id"])}
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_handler(provider: str, request: Request) -> PlainTextResponse:
|
||||||
|
if request.method == "GET":
|
||||||
|
data = dict(request.query_params)
|
||||||
|
else:
|
||||||
|
form = await request.form()
|
||||||
|
data = dict(form)
|
||||||
|
if not verify_notify(provider, data):
|
||||||
|
raise HTTPException(400, "验签失败")
|
||||||
|
oid = parse_notify_order_id(provider, data)
|
||||||
|
if oid:
|
||||||
|
social_store.mark_order_paid(oid)
|
||||||
|
return PlainTextResponse("success" if provider == "zpay" else "ok")
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/zpay_notify", methods=["GET", "POST"])
|
||||||
|
async def zpay_notify(request: Request):
|
||||||
|
return await _notify_handler("zpay", request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/xorpay_notify", methods=["GET", "POST"])
|
||||||
|
async def xorpay_notify(request: Request):
|
||||||
|
return await _notify_handler("xorpay", request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/notify", methods=["GET", "POST"])
|
||||||
|
async def pay_notify_legacy(request: Request, provider: str = Query("zpay")):
|
||||||
|
return await _notify_handler(provider, request)
|
||||||
134
backend/app/routers/social.py
Normal file
134
backend/app/routers/social.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"""Matching, chat, join, VIP — adapted from NomadCNA."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Header, HTTPException, Query
|
||||||
|
|
||||||
|
from app.schemas import (
|
||||||
|
ChatMessage, ConversationItem, JoinMemberRequest, MatchProfile, MatchQuota,
|
||||||
|
SendMessageRequest, SwipeRequest, SwipeResponse, VipStatus,
|
||||||
|
)
|
||||||
|
from app.services.auth import get_user_by_token
|
||||||
|
from app.services import social_store
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/social", tags=["social"])
|
||||||
|
|
||||||
|
|
||||||
|
def _token_user(authorization: str | None) -> dict:
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
user = get_user_by_token(authorization[7:])
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/join")
|
||||||
|
async def join_member(body: JoinMemberRequest, authorization: str | None = Header(None)):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
profile = social_store.join_member(user["id"], user["name"], body.model_dump())
|
||||||
|
return {"success": True, "profile": profile}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/join/status")
|
||||||
|
async def join_status(authorization: str | None = Header(None)):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
profile = social_store._profiles.get(user["id"])
|
||||||
|
return {"complete": bool(profile), "profile": profile}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/matches/candidates", response_model=list[MatchProfile])
|
||||||
|
async def match_candidates(
|
||||||
|
authorization: str | None = Header(None),
|
||||||
|
intent: str = Query("friends"),
|
||||||
|
city: str = Query(""),
|
||||||
|
gender: str = Query(""),
|
||||||
|
single: str = Query(""),
|
||||||
|
exclude_swiped: bool = Query(True),
|
||||||
|
):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
if user["id"] not in social_store._profiles:
|
||||||
|
raise HTTPException(400, "请先完成加入资料")
|
||||||
|
return [MatchProfile(**p) for p in social_store.list_candidates(
|
||||||
|
user["id"], intent=intent, city=city, gender=gender, single=single, exclude_swiped=exclude_swiped
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/matches/quota", response_model=MatchQuota)
|
||||||
|
async def match_quota(authorization: str | None = Header(None)):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
return MatchQuota(**social_store.get_quota(user["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/matches/swipes/undo")
|
||||||
|
async def undo_swipe(authorization: str | None = Header(None)):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
res = social_store.undo_last_swipe(user["id"])
|
||||||
|
if not res.get("ok"):
|
||||||
|
raise HTTPException(400, "没有可撤销的滑动")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/matches/likes", response_model=list[MatchProfile])
|
||||||
|
async def match_likes(authorization: str | None = Header(None)):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
return [MatchProfile(**p) for p in social_store.list_likes(user["id"])]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/matches/mutual")
|
||||||
|
async def mutual_matches(authorization: str | None = Header(None)):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
return {"items": social_store.list_mutual(user["id"])}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/matches/swipes", response_model=SwipeResponse)
|
||||||
|
async def record_swipe(body: SwipeRequest, authorization: str | None = Header(None)):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
result = social_store.record_swipe(user["id"], body.profileId, body.action, body.intent)
|
||||||
|
if not result.get("ok"):
|
||||||
|
err = result.get("error")
|
||||||
|
if err == "quota_exceeded":
|
||||||
|
raise HTTPException(429, "今日滑动次数已用完")
|
||||||
|
if err == "vip_required":
|
||||||
|
raise HTTPException(403, "超级喜欢需要 VIP")
|
||||||
|
if err == "duplicate":
|
||||||
|
raise HTTPException(409, "已滑动过该用户")
|
||||||
|
raise HTTPException(400, err or "滑动失败")
|
||||||
|
return SwipeResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversations", response_model=list[ConversationItem])
|
||||||
|
async def list_conversations(authorization: str | None = Header(None)):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
items = social_store.list_conversations(user["id"])
|
||||||
|
return [ConversationItem(**c) for c in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversations/{conv_id}")
|
||||||
|
async def get_conversation(conv_id: str, authorization: str | None = Header(None)):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
conv = social_store.get_conversation(conv_id, user["id"])
|
||||||
|
if not conv:
|
||||||
|
raise HTTPException(404, "会话不存在")
|
||||||
|
return conv
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversations/{conv_id}/messages", response_model=list[ChatMessage])
|
||||||
|
async def get_messages(conv_id: str, authorization: str | None = Header(None), limit: int = 50):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
return [ChatMessage(**m) for m in social_store.list_messages(conv_id, user["id"], limit)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/conversations/{conv_id}/messages", response_model=ChatMessage)
|
||||||
|
async def post_message(conv_id: str, body: SendMessageRequest, authorization: str | None = Header(None)):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
msg = social_store.send_message(conv_id, user["id"], body.body)
|
||||||
|
if not msg:
|
||||||
|
raise HTTPException(400, "发送失败")
|
||||||
|
return ChatMessage(**msg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vip/check", response_model=VipStatus)
|
||||||
|
async def vip_check(authorization: str | None = Header(None)):
|
||||||
|
user = _token_user(authorization)
|
||||||
|
vip = social_store.is_vip(user["id"])
|
||||||
|
exp = (social_store._memberships.get(user["id"]) or {}).get("expires_at", 0)
|
||||||
|
return VipStatus(vip=vip, expires_at=exp)
|
||||||
@ -185,3 +185,288 @@ class SearchResult(BaseModel):
|
|||||||
subtitle: str
|
subtitle: str
|
||||||
emoji: str
|
emoji: str
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class Meetup(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
city: str
|
||||||
|
destination_slug: str = ""
|
||||||
|
emoji: str
|
||||||
|
date: str
|
||||||
|
time: str
|
||||||
|
venue: str
|
||||||
|
description: str
|
||||||
|
mode: str # online | offline | hybrid
|
||||||
|
access_level: str = "public"
|
||||||
|
mirotalkRoom: str = ""
|
||||||
|
loungeChannel: str = ""
|
||||||
|
rsvp_count: int = 0
|
||||||
|
max_attendees: int = 50
|
||||||
|
organizer: str = ""
|
||||||
|
tags: list[str] = []
|
||||||
|
is_upcoming: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class Discussion(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
excerpt: str
|
||||||
|
author: str
|
||||||
|
author_emoji: str = "🧑💻"
|
||||||
|
category: str
|
||||||
|
reply_count: int = 0
|
||||||
|
like_count: int = 0
|
||||||
|
is_pinned: bool = False
|
||||||
|
created_at: str
|
||||||
|
tags: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class DiscussionReply(BaseModel):
|
||||||
|
id: str
|
||||||
|
author: str
|
||||||
|
author_emoji: str = "🧑💻"
|
||||||
|
content: str
|
||||||
|
created_at: str
|
||||||
|
like_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class DiscussionDetail(Discussion):
|
||||||
|
replies: list[DiscussionReply] = []
|
||||||
|
|
||||||
|
|
||||||
|
class NomadRoute(BaseModel):
|
||||||
|
id: str
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
duration_days: int
|
||||||
|
budget: int
|
||||||
|
description: str
|
||||||
|
stops: list[str]
|
||||||
|
emoji: str = "🗺️"
|
||||||
|
|
||||||
|
|
||||||
|
class NextStopRequest(BaseModel):
|
||||||
|
budget: int = Field(default=8000, ge=2000, le=50000)
|
||||||
|
internet: int = Field(default=50, ge=10, le=500)
|
||||||
|
climate: str = "mild" # warm | mild | cool
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
priority: str = "balanced" # cost | speed | community | balanced
|
||||||
|
limit: int = Field(default=12, ge=1, le=24)
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendedDestination(Destination):
|
||||||
|
match_score: float = 0
|
||||||
|
match_reasons: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class NextStopResponse(BaseModel):
|
||||||
|
items: list[RecommendedDestination]
|
||||||
|
routes: list[NomadRoute] = []
|
||||||
|
|
||||||
|
|
||||||
|
class MeetupRsvpRequest(BaseModel):
|
||||||
|
meetup_id: str
|
||||||
|
name: str = Field(default="访客", min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Social / matching / chat ──
|
||||||
|
|
||||||
|
class MatchProfile(BaseModel):
|
||||||
|
id: str
|
||||||
|
userId: str = ""
|
||||||
|
name: str
|
||||||
|
location: str = ""
|
||||||
|
citySlug: str = ""
|
||||||
|
gender: str = ""
|
||||||
|
single: str = ""
|
||||||
|
bio: str = ""
|
||||||
|
photo: str = "🧑💻"
|
||||||
|
tags: list[str] = []
|
||||||
|
lookingFor: list[str] = []
|
||||||
|
intent: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class SwipeRequest(BaseModel):
|
||||||
|
profileId: str
|
||||||
|
action: str = "like" # like | dislike | superlike
|
||||||
|
intent: str = "friends"
|
||||||
|
|
||||||
|
|
||||||
|
class SwipeResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
matched: bool = False
|
||||||
|
match: dict | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MatchQuota(BaseModel):
|
||||||
|
vip: bool
|
||||||
|
limit: int
|
||||||
|
used: int
|
||||||
|
remaining: int
|
||||||
|
|
||||||
|
|
||||||
|
class JoinMemberRequest(BaseModel):
|
||||||
|
city: str = ""
|
||||||
|
citySlug: str = ""
|
||||||
|
gender: str = ""
|
||||||
|
single: str = ""
|
||||||
|
bio: str = ""
|
||||||
|
lookingFor: list[str] = Field(default_factory=lambda: ["friends", "explore"])
|
||||||
|
photo: str = "🧑💻"
|
||||||
|
|
||||||
|
|
||||||
|
class ChatPeer(BaseModel):
|
||||||
|
id: str = ""
|
||||||
|
userId: str = ""
|
||||||
|
name: str = ""
|
||||||
|
photo: str = "🧑💻"
|
||||||
|
location: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
type: str = "direct"
|
||||||
|
intent: str = ""
|
||||||
|
lastMessageAt: str = ""
|
||||||
|
lastMessagePreview: str = ""
|
||||||
|
unreadCount: int = 0
|
||||||
|
peer: MatchProfile | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
id: str
|
||||||
|
conversationId: str = ""
|
||||||
|
senderId: str
|
||||||
|
body: str
|
||||||
|
createdAt: str = ""
|
||||||
|
mine: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SendMessageRequest(BaseModel):
|
||||||
|
body: str = Field(min_length=1, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class VipStatus(BaseModel):
|
||||||
|
vip: bool
|
||||||
|
expires_at: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class PayCreateRequest(BaseModel):
|
||||||
|
pay_type: str = "join" # join | vip | meetup
|
||||||
|
return_url: str = ""
|
||||||
|
provider: str = "zpay"
|
||||||
|
|
||||||
|
|
||||||
|
class PayCreateResponse(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
amount: int
|
||||||
|
redirect_url: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class PayStatusResponse(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
status: str
|
||||||
|
paid: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MeetupSession(BaseModel):
|
||||||
|
canJoin: bool
|
||||||
|
lockReason: str = ""
|
||||||
|
displayName: str = ""
|
||||||
|
videoUrl: str = ""
|
||||||
|
chatUrl: str = ""
|
||||||
|
loungeChannel: str = ""
|
||||||
|
mirotalkRoom: str = ""
|
||||||
|
meetingProvider: str = "mirotalk"
|
||||||
|
mode: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DigitalLesson(BaseModel):
|
||||||
|
title: str
|
||||||
|
moduleIndex: int
|
||||||
|
lessonIndex: int
|
||||||
|
duration: str = ""
|
||||||
|
free: bool = False
|
||||||
|
content: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DigitalJob(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
company: str
|
||||||
|
location: str
|
||||||
|
type: str
|
||||||
|
salary: str
|
||||||
|
tags: list[str] = []
|
||||||
|
url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class CreateDiscussionRequest(BaseModel):
|
||||||
|
title: str = Field(min_length=4, max_length=120)
|
||||||
|
excerpt: str = ""
|
||||||
|
content: str = ""
|
||||||
|
category: str = "社区"
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
author_emoji: str = "🧑💻"
|
||||||
|
|
||||||
|
|
||||||
|
class CreateReplyRequest(BaseModel):
|
||||||
|
content: str = Field(min_length=1, max_length=4000)
|
||||||
|
author_emoji: str = "🧑💻"
|
||||||
|
|
||||||
|
|
||||||
|
class CreateMeetupRequest(BaseModel):
|
||||||
|
title: str = Field(min_length=4, max_length=120)
|
||||||
|
city: str = "线上"
|
||||||
|
destination_slug: str = ""
|
||||||
|
emoji: str = "🎉"
|
||||||
|
date: str = ""
|
||||||
|
time: str = "19:00"
|
||||||
|
venue: str = ""
|
||||||
|
description: str = ""
|
||||||
|
mode: str = "offline"
|
||||||
|
access_level: str = "public"
|
||||||
|
max_attendees: int = 30
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
mirotalkRoom: str = ""
|
||||||
|
loungeChannel: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class GigItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
budget: str
|
||||||
|
deadline: str
|
||||||
|
tags: list[str] = []
|
||||||
|
poster: str = ""
|
||||||
|
status: str = "open"
|
||||||
|
|
||||||
|
|
||||||
|
class GigApplyRequest(BaseModel):
|
||||||
|
message: str = Field(min_length=4, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
body: str = ""
|
||||||
|
link: str = ""
|
||||||
|
read: bool = False
|
||||||
|
created_at: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackRequest(BaseModel):
|
||||||
|
content: str = Field(min_length=4, max_length=4000)
|
||||||
|
category: str = "general"
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class StatsOverview(BaseModel):
|
||||||
|
meetups: int = 0
|
||||||
|
discussions: int = 0
|
||||||
|
gigs: int = 0
|
||||||
|
members_active: int = 0
|
||||||
@ -109,8 +109,22 @@ def login_demo() -> dict[str, Any] | None:
|
|||||||
return {"token": token, "user": _user_profile(user)}
|
return {"token": token, "user": _user_profile(user)}
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_sessions() -> None:
|
||||||
|
if not STORE_PATH.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||||
|
global _sessions
|
||||||
|
_sessions.update(data.get("sessions") or {})
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_token(token: str) -> dict | None:
|
def get_user_by_token(token: str) -> dict | None:
|
||||||
uid = _sessions.get(token)
|
uid = _sessions.get(token)
|
||||||
|
if not uid:
|
||||||
|
_reload_sessions()
|
||||||
|
uid = _sessions.get(token)
|
||||||
if not uid:
|
if not uid:
|
||||||
return None
|
return None
|
||||||
for user in _users.values():
|
for user in _users.values():
|
||||||
|
|||||||
394
backend/app/services/community_store.py
Normal file
394
backend/app/services/community_store.py
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
"""Persistent community data: discussions, meetups, gigs, notifications."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.data import community_data
|
||||||
|
|
||||||
|
STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "community_store.json"
|
||||||
|
|
||||||
|
_meetups: list[dict] = []
|
||||||
|
_discussions: list[dict] = []
|
||||||
|
_replies: dict[str, list[dict]] = {}
|
||||||
|
_discussion_likes: dict[str, set[str]] = {} # discussion_id -> user_ids
|
||||||
|
_reply_likes: dict[str, set[str]] = {}
|
||||||
|
_rsvps: dict[str, set[str]] = {} # meetup_id -> user_ids
|
||||||
|
_gigs: list[dict] = []
|
||||||
|
_gig_apps: list[dict] = []
|
||||||
|
_notifications: dict[str, list[dict]] = {} # user_id -> items
|
||||||
|
_feedback: list[dict] = []
|
||||||
|
_seeded = False
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(text: str) -> str:
|
||||||
|
s = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", text.lower()).strip("-")
|
||||||
|
return (s[:48] or secrets.token_hex(4))
|
||||||
|
|
||||||
|
|
||||||
|
def _persist() -> None:
|
||||||
|
STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
STORE_PATH.write_text(
|
||||||
|
json.dumps({
|
||||||
|
"meetups": _meetups,
|
||||||
|
"discussions": _discussions,
|
||||||
|
"replies": _replies,
|
||||||
|
"discussion_likes": {k: list(v) for k, v in _discussion_likes.items()},
|
||||||
|
"reply_likes": {k: list(v) for k, v in _reply_likes.items()},
|
||||||
|
"rsvps": {k: list(v) for k, v in _rsvps.items()},
|
||||||
|
"gigs": _gigs,
|
||||||
|
"gig_apps": _gig_apps,
|
||||||
|
"notifications": _notifications,
|
||||||
|
"feedback": _feedback,
|
||||||
|
"seeded": _seeded,
|
||||||
|
}, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore() -> None:
|
||||||
|
global _meetups, _discussions, _replies, _discussion_likes, _reply_likes
|
||||||
|
global _rsvps, _gigs, _gig_apps, _notifications, _feedback, _seeded
|
||||||
|
if not STORE_PATH.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
_meetups = data.get("meetups") or []
|
||||||
|
_discussions = data.get("discussions") or []
|
||||||
|
_replies = data.get("replies") or {}
|
||||||
|
_discussion_likes = {k: set(v) for k, v in (data.get("discussion_likes") or {}).items()}
|
||||||
|
_reply_likes = {k: set(v) for k, v in (data.get("reply_likes") or {}).items()}
|
||||||
|
_rsvps = {k: set(v) for k, v in (data.get("rsvps") or {}).items()}
|
||||||
|
_gigs = data.get("gigs") or []
|
||||||
|
_gig_apps = data.get("gig_apps") or []
|
||||||
|
_notifications = data.get("notifications") or {}
|
||||||
|
_feedback = data.get("feedback") or []
|
||||||
|
_seeded = bool(data.get("seeded"))
|
||||||
|
|
||||||
|
|
||||||
|
def _reload() -> None:
|
||||||
|
_restore()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_if_needed() -> None:
|
||||||
|
global _seeded
|
||||||
|
_reload()
|
||||||
|
if _seeded:
|
||||||
|
return
|
||||||
|
_meetups = deepcopy(community_data.MEETUPS)
|
||||||
|
_discussions = deepcopy(community_data.DISCUSSIONS)
|
||||||
|
_replies = deepcopy(community_data.DISCUSSION_REPLIES)
|
||||||
|
_gigs = deepcopy(getattr(community_data, "GIGS", _default_gigs()))
|
||||||
|
_seeded = True
|
||||||
|
_persist()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_gigs() -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "logo-design",
|
||||||
|
"title": "为游民社区设计 Logo",
|
||||||
|
"description": "需要扁平风格,含地球+代码元素,交付 SVG。",
|
||||||
|
"budget": "¥800",
|
||||||
|
"deadline": "2026-09-15",
|
||||||
|
"tags": ["设计", "远程"],
|
||||||
|
"poster": "nomadro",
|
||||||
|
"status": "open",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "meetup-host",
|
||||||
|
"title": "深圳线下活动主持人",
|
||||||
|
"description": "协助组织 20 人规模的游民交流会,有经验优先。",
|
||||||
|
"budget": "¥500/场",
|
||||||
|
"deadline": "2026-09-20",
|
||||||
|
"tags": ["活动", "深圳"],
|
||||||
|
"poster": "深圳湾区主理人",
|
||||||
|
"status": "open",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "content-translate",
|
||||||
|
"title": "签证指南英文润色",
|
||||||
|
"description": "将 3 篇中文签证攻略翻译润色为英文博客。",
|
||||||
|
"budget": "¥1200",
|
||||||
|
"deadline": "2026-10-01",
|
||||||
|
"tags": ["翻译", "内容"],
|
||||||
|
"poster": "nomadro",
|
||||||
|
"status": "open",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_seed_if_needed()
|
||||||
|
|
||||||
|
|
||||||
|
def _discussion_like_count(did: str) -> int:
|
||||||
|
return len(_discussion_likes.get(did, set()))
|
||||||
|
|
||||||
|
|
||||||
|
def _reply_like_count(rid: str) -> int:
|
||||||
|
return len(_reply_likes.get(rid, set()))
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_discussion(d: dict) -> dict:
|
||||||
|
did = d["id"]
|
||||||
|
replies = _replies.get(did, [])
|
||||||
|
base_likes = d.get("like_count", 0)
|
||||||
|
stored = _discussion_like_count(did)
|
||||||
|
return {
|
||||||
|
**d,
|
||||||
|
"reply_count": len(replies),
|
||||||
|
"like_count": max(base_likes, stored) if stored == 0 else base_likes + stored,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_meetups(upcoming: bool = True) -> list[dict]:
|
||||||
|
_seed_if_needed()
|
||||||
|
items = list(_meetups)
|
||||||
|
if upcoming:
|
||||||
|
items = [m for m in items if m.get("is_upcoming", True)]
|
||||||
|
for m in items:
|
||||||
|
mid = m["id"]
|
||||||
|
m["rsvp_count"] = max(m.get("rsvp_count", 0), len(_rsvps.get(mid, set())))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def get_meetup(meetup_id: str) -> dict | None:
|
||||||
|
_seed_if_needed()
|
||||||
|
for m in _meetups:
|
||||||
|
if m["id"] == meetup_id:
|
||||||
|
out = dict(m)
|
||||||
|
out["rsvp_count"] = max(out.get("rsvp_count", 0), len(_rsvps.get(meetup_id, set())))
|
||||||
|
return out
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_meetup(user_id: str, user_name: str, payload: dict) -> dict:
|
||||||
|
_seed_if_needed()
|
||||||
|
mid = _slugify(payload.get("title", "meetup")) + "-" + secrets.token_hex(3)
|
||||||
|
mode = payload.get("mode", "offline")
|
||||||
|
room = payload.get("mirotalkRoom") or ""
|
||||||
|
if mode in ("online", "hybrid") and not room:
|
||||||
|
room = f"nomadro-{_slugify(payload.get('city', 'online'))}-{secrets.token_hex(2)}"
|
||||||
|
lounge = payload.get("loungeChannel") or (f"#{room}" if room else "")
|
||||||
|
meetup = {
|
||||||
|
"id": mid,
|
||||||
|
"title": payload["title"],
|
||||||
|
"city": payload.get("city", "线上"),
|
||||||
|
"destination_slug": payload.get("destination_slug", ""),
|
||||||
|
"emoji": payload.get("emoji", "🎉"),
|
||||||
|
"date": payload.get("date", _now_iso()),
|
||||||
|
"time": payload.get("time", "19:00"),
|
||||||
|
"venue": payload.get("venue", "待定"),
|
||||||
|
"description": payload.get("description", ""),
|
||||||
|
"mode": mode,
|
||||||
|
"access_level": payload.get("access_level", "public"),
|
||||||
|
"mirotalkRoom": room,
|
||||||
|
"loungeChannel": lounge,
|
||||||
|
"meetingUrl": "",
|
||||||
|
"rsvp_count": 0,
|
||||||
|
"max_attendees": int(payload.get("max_attendees", 30)),
|
||||||
|
"organizer": user_name,
|
||||||
|
"organizer_id": user_id,
|
||||||
|
"tags": payload.get("tags", []),
|
||||||
|
"is_upcoming": True,
|
||||||
|
}
|
||||||
|
_meetups.insert(0, meetup)
|
||||||
|
_persist()
|
||||||
|
return meetup
|
||||||
|
|
||||||
|
|
||||||
|
def rsvp_meetup(meetup_id: str, user_id: str | None = None) -> dict:
|
||||||
|
_seed_if_needed()
|
||||||
|
m = get_meetup(meetup_id)
|
||||||
|
if not m:
|
||||||
|
return {"ok": False, "error": "not_found"}
|
||||||
|
uid = user_id or f"guest-{secrets.token_hex(4)}"
|
||||||
|
rset = _rsvps.setdefault(meetup_id, set())
|
||||||
|
if uid in rset:
|
||||||
|
return {"ok": True, "message": "已报名", "rsvp_count": len(rset)}
|
||||||
|
if len(rset) >= m["max_attendees"]:
|
||||||
|
return {"ok": False, "error": "full"}
|
||||||
|
rset.add(uid)
|
||||||
|
_persist()
|
||||||
|
return {"ok": True, "message": f"已报名「{m['title']}」", "rsvp_count": len(rset)}
|
||||||
|
|
||||||
|
|
||||||
|
def user_rsvp_ids(user_id: str) -> set[str]:
|
||||||
|
_seed_if_needed()
|
||||||
|
return {mid for mid, uids in _rsvps.items() if user_id in uids}
|
||||||
|
|
||||||
|
|
||||||
|
def list_discussions(category: str | None = None) -> list[dict]:
|
||||||
|
_seed_if_needed()
|
||||||
|
items = [_enrich_discussion(d) for d in _discussions]
|
||||||
|
if category:
|
||||||
|
items = [d for d in items if d["category"] == category]
|
||||||
|
pinned = sorted([d for d in items if d.get("is_pinned")], key=lambda x: x["created_at"], reverse=True)
|
||||||
|
rest = sorted([d for d in items if not d.get("is_pinned")], key=lambda x: x["created_at"], reverse=True)
|
||||||
|
return pinned + rest
|
||||||
|
|
||||||
|
|
||||||
|
def get_discussion(discussion_id: str) -> dict | None:
|
||||||
|
_seed_if_needed()
|
||||||
|
for d in _discussions:
|
||||||
|
if d["id"] == discussion_id:
|
||||||
|
enriched = _enrich_discussion(d)
|
||||||
|
replies = []
|
||||||
|
for r in _replies.get(discussion_id, []):
|
||||||
|
replies.append({**r, "like_count": r.get("like_count", 0) + _reply_like_count(r["id"])})
|
||||||
|
return {**enriched, "replies": replies}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_discussion(user_id: str, user_name: str, payload: dict) -> dict:
|
||||||
|
_seed_if_needed()
|
||||||
|
did = _slugify(payload["title"]) + "-" + secrets.token_hex(3)
|
||||||
|
d = {
|
||||||
|
"id": did,
|
||||||
|
"title": payload["title"],
|
||||||
|
"excerpt": payload.get("excerpt") or payload.get("content", "")[:200],
|
||||||
|
"author": user_name,
|
||||||
|
"author_id": user_id,
|
||||||
|
"author_emoji": payload.get("author_emoji", "🧑💻"),
|
||||||
|
"category": payload.get("category", "社区"),
|
||||||
|
"reply_count": 0,
|
||||||
|
"like_count": 0,
|
||||||
|
"is_pinned": False,
|
||||||
|
"created_at": _now_iso(),
|
||||||
|
"tags": payload.get("tags", []),
|
||||||
|
}
|
||||||
|
_discussions.insert(0, d)
|
||||||
|
_replies[did] = []
|
||||||
|
_persist()
|
||||||
|
return _enrich_discussion(d)
|
||||||
|
|
||||||
|
|
||||||
|
def add_reply(discussion_id: str, user_id: str, user_name: str, content: str, emoji: str = "🧑💻") -> dict | None:
|
||||||
|
_seed_if_needed()
|
||||||
|
if not any(d["id"] == discussion_id for d in _discussions):
|
||||||
|
return None
|
||||||
|
rid = secrets.token_hex(6)
|
||||||
|
reply = {
|
||||||
|
"id": rid,
|
||||||
|
"author": user_name,
|
||||||
|
"author_id": user_id,
|
||||||
|
"author_emoji": emoji,
|
||||||
|
"content": content,
|
||||||
|
"created_at": _now_iso(),
|
||||||
|
"like_count": 0,
|
||||||
|
}
|
||||||
|
_replies.setdefault(discussion_id, []).append(reply)
|
||||||
|
_persist()
|
||||||
|
return reply
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_discussion_like(discussion_id: str, user_id: str) -> dict:
|
||||||
|
_seed_if_needed()
|
||||||
|
likes = _discussion_likes.setdefault(discussion_id, set())
|
||||||
|
if user_id in likes:
|
||||||
|
likes.discard(user_id)
|
||||||
|
liked = False
|
||||||
|
else:
|
||||||
|
likes.add(user_id)
|
||||||
|
liked = True
|
||||||
|
_persist()
|
||||||
|
return {"liked": liked, "like_count": _discussion_like_count(discussion_id)}
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_pin(discussion_id: str, user_id: str) -> dict | None:
|
||||||
|
_seed_if_needed()
|
||||||
|
for d in _discussions:
|
||||||
|
if d["id"] == discussion_id and d.get("author_id") == user_id:
|
||||||
|
d["is_pinned"] = not d.get("is_pinned", False)
|
||||||
|
_persist()
|
||||||
|
return _enrich_discussion(d)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def list_gigs() -> list[dict]:
|
||||||
|
_seed_if_needed()
|
||||||
|
return [g for g in _gigs if g.get("status", "open") == "open"]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
|
||||||
|
_seed_if_needed()
|
||||||
|
gig = next((g for g in _gigs if g["id"] == gig_id), None)
|
||||||
|
if not gig:
|
||||||
|
return {"ok": False, "error": "not_found"}
|
||||||
|
for a in _gig_apps:
|
||||||
|
if a["gig_id"] == gig_id and a["user_id"] == user_id:
|
||||||
|
return {"ok": False, "error": "duplicate"}
|
||||||
|
_gig_apps.append({
|
||||||
|
"id": secrets.token_hex(6),
|
||||||
|
"gig_id": gig_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"user_name": user_name,
|
||||||
|
"message": message,
|
||||||
|
"created_at": _now_iso(),
|
||||||
|
})
|
||||||
|
_persist()
|
||||||
|
return {"ok": True, "message": "申请已提交"}
|
||||||
|
|
||||||
|
|
||||||
|
def add_notification(user_id: str, title: str, body: str, link: str = "") -> None:
|
||||||
|
items = _notifications.setdefault(user_id, [])
|
||||||
|
items.insert(0, {
|
||||||
|
"id": secrets.token_hex(6),
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"link": link,
|
||||||
|
"read": False,
|
||||||
|
"created_at": _now_iso(),
|
||||||
|
})
|
||||||
|
items[:] = items[:50]
|
||||||
|
_persist()
|
||||||
|
|
||||||
|
|
||||||
|
def list_notifications(user_id: str) -> list[dict]:
|
||||||
|
_reload()
|
||||||
|
return _notifications.get(user_id, [])
|
||||||
|
|
||||||
|
|
||||||
|
def mark_notifications_read(user_id: str) -> None:
|
||||||
|
_reload()
|
||||||
|
for n in _notifications.get(user_id, []):
|
||||||
|
n["read"] = True
|
||||||
|
_persist()
|
||||||
|
|
||||||
|
|
||||||
|
def add_feedback(user_id: str | None, user_name: str, content: str, category: str = "general") -> dict:
|
||||||
|
_seed_if_needed()
|
||||||
|
item = {
|
||||||
|
"id": secrets.token_hex(6),
|
||||||
|
"user_id": user_id or "",
|
||||||
|
"user_name": user_name,
|
||||||
|
"content": content,
|
||||||
|
"category": category,
|
||||||
|
"created_at": _now_iso(),
|
||||||
|
}
|
||||||
|
_feedback.append(item)
|
||||||
|
_persist()
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def stats_overview() -> dict:
|
||||||
|
_seed_if_needed()
|
||||||
|
return {
|
||||||
|
"meetups": len(_meetups),
|
||||||
|
"discussions": len(_discussions),
|
||||||
|
"gigs": len(_gigs),
|
||||||
|
"members_active": len(_rsvps) + len(_gig_apps),
|
||||||
|
}
|
||||||
68
backend/app/services/meetup_live.py
Normal file
68
backend/app/services/meetup_live.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"""Meetup live room URL builders — ported from NomadCNA."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
MIROTALK_URL = os.getenv("MIROTALK_URL", "https://mirotalk.nomadro.com")
|
||||||
|
LOUNGE_URL = os.getenv("LOUNGE_URL", "https://lounge.nomadro.com")
|
||||||
|
|
||||||
|
VIP_ROLES = frozenset({"admin", "owner", "host", "organizer", "moderator"})
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_room(value: str) -> str:
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", "-", (value or "").lower()).strip("-")
|
||||||
|
return slug[:64] or "nomadro-live"
|
||||||
|
|
||||||
|
|
||||||
|
def create_mirotalk_room_slug(meetup: dict) -> str:
|
||||||
|
city = normalize_room(meetup.get("city") or "online")
|
||||||
|
date = (meetup.get("date") or "").replace("-", "")[:8]
|
||||||
|
time_part = (meetup.get("time") or "0000").replace(":", "")[:4]
|
||||||
|
mid = normalize_room(meetup.get("id") or "room")
|
||||||
|
return f"nomadro-{city}-{date}-{time_part}-{mid}"
|
||||||
|
|
||||||
|
|
||||||
|
def create_lounge_channel(meetup: dict) -> str:
|
||||||
|
if meetup.get("loungeChannel"):
|
||||||
|
ch = meetup["loungeChannel"].strip()
|
||||||
|
return ch if ch.startswith("#") else f"#{ch}"
|
||||||
|
if meetup.get("mirotalkRoom"):
|
||||||
|
return f"#{normalize_room(meetup['mirotalkRoom'])}"
|
||||||
|
return f"#{create_mirotalk_room_slug(meetup)}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_mirotalk_join_url(meetup: dict, display_name: str = "nomadro") -> str:
|
||||||
|
explicit = (meetup.get("meetingUrl") or "").strip()
|
||||||
|
if explicit.startswith("http"):
|
||||||
|
return explicit
|
||||||
|
room = meetup.get("mirotalkRoom") or create_mirotalk_room_slug(meetup)
|
||||||
|
return f"{MIROTALK_URL.rstrip('/')}/join?{urlencode({'room': room, 'name': display_name})}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_lounge_chat_url(meetup: dict) -> str:
|
||||||
|
channel = create_lounge_channel(meetup).lstrip("#")
|
||||||
|
return f"{LOUNGE_URL.rstrip('/')}/#/chan-{channel}"
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_meetup(meetup: dict, user: dict | None, is_vip: bool = False) -> tuple[bool, str]:
|
||||||
|
level = meetup.get("access_level") or "public"
|
||||||
|
if level == "public":
|
||||||
|
return True, ""
|
||||||
|
if not user:
|
||||||
|
return False, "login_required"
|
||||||
|
if level == "members":
|
||||||
|
return True, ""
|
||||||
|
if level == "vip":
|
||||||
|
role = (user.get("role") or "").lower()
|
||||||
|
if is_vip or role in VIP_ROLES:
|
||||||
|
return True, ""
|
||||||
|
return False, "vip_required"
|
||||||
|
if level == "hosts":
|
||||||
|
role = (user.get("role") or "").lower()
|
||||||
|
if role in VIP_ROLES:
|
||||||
|
return True, ""
|
||||||
|
return False, "host_required"
|
||||||
|
return True, ""
|
||||||
237
backend/app/services/payment_providers.py
Normal file
237
backend/app/services/payment_providers.py
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
"""ZPay / XorPay providers — ported from NomadCNA backend/payment.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def _md5(value: str) -> str:
|
||||||
|
return hashlib.md5(value.encode("utf-8")).hexdigest().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_internal_ip(ip: str | None) -> bool:
|
||||||
|
if not ip:
|
||||||
|
return True
|
||||||
|
value = ip.strip().lower()
|
||||||
|
if value in {"127.0.0.1", "localhost", "::1"}:
|
||||||
|
return True
|
||||||
|
if value.startswith("10.") or value.startswith("192.168."):
|
||||||
|
return True
|
||||||
|
if value.startswith("172."):
|
||||||
|
parts = value.split(".")
|
||||||
|
if len(parts) > 1 and parts[1].isdigit():
|
||||||
|
return 16 <= int(parts[1]) <= 31
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_channel(channel: str | None) -> str:
|
||||||
|
return "wxpay" if str(channel or "").lower() in {"", "wx", "wechat", "wxpay"} else "alipay"
|
||||||
|
|
||||||
|
|
||||||
|
class ZPayProvider:
|
||||||
|
name = "zpay"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.pid = os.getenv("ZPAY_PID", "2025121809351743")
|
||||||
|
self.key = os.getenv("ZPAY_KEY", "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89")
|
||||||
|
self.submit_url = os.getenv("ZPAY_SUBMIT_URL", "https://zpayz.cn/submit.php")
|
||||||
|
self.mapi_url = os.getenv("ZPAY_MAPI_URL", "https://zpayz.cn/mapi.php")
|
||||||
|
self.query_url = os.getenv("ZPAY_QUERY_URL", "https://zpayz.cn/api.php")
|
||||||
|
|
||||||
|
def sign(self, params: dict[str, Any]) -> str:
|
||||||
|
filtered = {
|
||||||
|
key: value
|
||||||
|
for key, value in params.items()
|
||||||
|
if key not in {"sign", "sign_type"} and value is not None and str(value) != ""
|
||||||
|
}
|
||||||
|
raw = "&".join(f"{key}={filtered[key]}" for key in sorted(filtered)) + self.key
|
||||||
|
return _md5(raw)
|
||||||
|
|
||||||
|
def create_order(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
order_id: str,
|
||||||
|
name: str,
|
||||||
|
amount_yuan: str,
|
||||||
|
channel: str,
|
||||||
|
notify_url: str,
|
||||||
|
return_url: str,
|
||||||
|
client_ip: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"pid": self.pid,
|
||||||
|
"type": resolve_channel(channel),
|
||||||
|
"out_trade_no": order_id,
|
||||||
|
"notify_url": notify_url,
|
||||||
|
"return_url": return_url,
|
||||||
|
"name": name,
|
||||||
|
"money": amount_yuan,
|
||||||
|
"sign_type": "MD5",
|
||||||
|
}
|
||||||
|
if client_ip and not _is_internal_ip(client_ip):
|
||||||
|
params["clientip"] = client_ip
|
||||||
|
params["sign"] = self.sign(params)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"provider": self.name,
|
||||||
|
"params": params,
|
||||||
|
"pay_url": self.submit_url,
|
||||||
|
"submit_method": "POST",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def create_order_api(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
order_id: str,
|
||||||
|
name: str,
|
||||||
|
amount_yuan: str,
|
||||||
|
channel: str,
|
||||||
|
notify_url: str,
|
||||||
|
return_url: str,
|
||||||
|
client_ip: str = "",
|
||||||
|
device: str = "pc",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"pid": self.pid,
|
||||||
|
"type": resolve_channel(channel),
|
||||||
|
"out_trade_no": order_id,
|
||||||
|
"notify_url": notify_url,
|
||||||
|
"return_url": return_url,
|
||||||
|
"name": name,
|
||||||
|
"money": amount_yuan,
|
||||||
|
"device": device,
|
||||||
|
"sign_type": "MD5",
|
||||||
|
}
|
||||||
|
if client_ip and not _is_internal_ip(client_ip):
|
||||||
|
params["clientip"] = client_ip
|
||||||
|
params["sign"] = self.sign(params)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0, trust_env=False) as client:
|
||||||
|
res = await client.post(self.mapi_url, data=params)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "error", "provider": self.name, "msg": str(exc)}
|
||||||
|
|
||||||
|
result: Any
|
||||||
|
try:
|
||||||
|
result = res.json()
|
||||||
|
except Exception:
|
||||||
|
result = {}
|
||||||
|
if isinstance(result, str):
|
||||||
|
try:
|
||||||
|
result = json.loads(result)
|
||||||
|
except Exception:
|
||||||
|
result = {}
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
result = {}
|
||||||
|
msg = result.get("msg")
|
||||||
|
if isinstance(msg, str) and msg.strip().startswith("{"):
|
||||||
|
try:
|
||||||
|
inner = json.loads(msg)
|
||||||
|
if isinstance(inner, dict):
|
||||||
|
result = inner
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
code = int(float(result.get("code", 0)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
code = 0
|
||||||
|
if code == 1:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"provider": self.name,
|
||||||
|
"payurl": result.get("payurl"),
|
||||||
|
"payurl2": result.get("payurl2"),
|
||||||
|
"qrcode": result.get("qrcode"),
|
||||||
|
"img": result.get("img"),
|
||||||
|
"trade_no": result.get("trade_no"),
|
||||||
|
}
|
||||||
|
return {"status": "error", "provider": self.name, "msg": result.get("msg") or res.text[:500]}
|
||||||
|
|
||||||
|
def verify_notify(self, data: dict[str, Any]) -> bool:
|
||||||
|
return self.sign(data) == str(data.get("sign", ""))
|
||||||
|
|
||||||
|
async def query_order_status(self, order_id: str, timeout: float = 8.0) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
||||||
|
res = await client.get(
|
||||||
|
self.query_url,
|
||||||
|
params={"act": "order", "pid": self.pid, "key": self.key, "out_trade_no": order_id},
|
||||||
|
)
|
||||||
|
data = res.json() if res.is_success else {}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"paid": False, "error": str(exc)}
|
||||||
|
try:
|
||||||
|
code = int(float(data.get("code", 0)))
|
||||||
|
status = int(float(data.get("status", 0)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {"paid": False, "msg": data.get("msg", "查询失败")}
|
||||||
|
pay_price = data.get("money") or data.get("pay_price") or ""
|
||||||
|
return {"paid": code == 1 and status == 1, "status": status, "pay_price": str(pay_price)}
|
||||||
|
|
||||||
|
|
||||||
|
class XorPayProvider:
|
||||||
|
name = "xorpay"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.aid = os.getenv("XORPAY_AID", "8220")
|
||||||
|
self.secret = os.getenv("XORPAY_SECRET", "afcacd99570945f88de62624aaa3578e")
|
||||||
|
self.cashier_url = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cashier").rstrip("/")
|
||||||
|
self.query_url = os.getenv("XORPAY_QUERY_URL", "https://xorpay.com/api/query2").rstrip("/")
|
||||||
|
|
||||||
|
def create_order(self, *, order_id: str, name: str, amount_yuan: str, notify_url: str) -> dict[str, Any]:
|
||||||
|
params = {
|
||||||
|
"name": name,
|
||||||
|
"pay_type": "jsapi",
|
||||||
|
"price": amount_yuan,
|
||||||
|
"order_id": order_id,
|
||||||
|
"notify_url": notify_url,
|
||||||
|
}
|
||||||
|
params["sign"] = _md5(
|
||||||
|
params["name"] + params["pay_type"] + params["price"] + params["order_id"] + params["notify_url"] + self.secret
|
||||||
|
)
|
||||||
|
return {"status": "ok", "provider": self.name, "params": params, "pay_url": f"{self.cashier_url}/{self.aid}"}
|
||||||
|
|
||||||
|
def verify_notify(self, data: dict[str, Any]) -> bool:
|
||||||
|
raw = (
|
||||||
|
str(data.get("aoid", ""))
|
||||||
|
+ str(data.get("order_id", ""))
|
||||||
|
+ str(data.get("pay_price", ""))
|
||||||
|
+ str(data.get("pay_time", ""))
|
||||||
|
+ self.secret
|
||||||
|
)
|
||||||
|
return _md5(raw) == str(data.get("sign", ""))
|
||||||
|
|
||||||
|
async def query_order_status(self, order_id: str, timeout: float = 8.0) -> dict[str, Any]:
|
||||||
|
sign = _md5(order_id + self.secret)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
||||||
|
res = await client.get(f"{self.query_url}/{self.aid}", params={"order_id": order_id, "sign": sign})
|
||||||
|
data = res.json() if res.is_success else {}
|
||||||
|
status = str(data.get("status", "")).lower()
|
||||||
|
return {
|
||||||
|
"paid": status in {"payed", "success"},
|
||||||
|
"status": status,
|
||||||
|
"pay_price": data.get("pay_price") or data.get("price") or "",
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"paid": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def provider_for(device: str = "pc", requested: str = "", user_agent: str = "") -> ZPayProvider | XorPayProvider:
|
||||||
|
req = requested.strip().lower()
|
||||||
|
default = os.getenv("PAYMENT_PROVIDER", "zpay").lower()
|
||||||
|
if req == "xorpay":
|
||||||
|
return XorPayProvider()
|
||||||
|
if req == "zpay":
|
||||||
|
return ZPayProvider()
|
||||||
|
if default == "xorpay":
|
||||||
|
return XorPayProvider()
|
||||||
|
if device.lower() == "wechat" or "micromessenger" in user_agent.lower():
|
||||||
|
return XorPayProvider()
|
||||||
|
return ZPayProvider()
|
||||||
96
backend/app/services/payment_service.py
Normal file
96
backend/app/services/payment_service.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"""Payment helpers — ZPay/XorPay from NomadCNA + DEV_AUTO_PAY toggle."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from app.services.payment_providers import XorPayProvider, ZPayProvider, provider_for
|
||||||
|
|
||||||
|
DEV_AUTO_PAY = os.getenv("DEV_AUTO_PAY", "false").lower() in ("1", "true", "yes")
|
||||||
|
PAYMENT_JOIN_AMOUNT = int(os.getenv("PAYMENT_JOIN_AMOUNT", "10000")) # fen (100 yuan)
|
||||||
|
PAYMENT_DEFAULT_AMOUNT = int(os.getenv("PAYMENT_DEFAULT_AMOUNT", "10000"))
|
||||||
|
SITE_BASE = os.getenv("SITE_BASE_URL", "https://nomadweb.nomadro.com").rstrip("/")
|
||||||
|
PAY_NOTIFY_URL = os.getenv("PAY_NOTIFY_URL", f"{SITE_BASE}/api/v1/pay/zpay_notify")
|
||||||
|
|
||||||
|
|
||||||
|
def amount_yuan(amount_fen: int) -> str:
|
||||||
|
return f"{amount_fen / 100:.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def notify_url_for(provider_name: str) -> str:
|
||||||
|
if provider_name == "xorpay":
|
||||||
|
return os.getenv("XORPAY_NOTIFY_URL", f"{SITE_BASE}/api/v1/pay/xorpay_notify")
|
||||||
|
return os.getenv("ZPAY_NOTIFY_URL", PAY_NOTIFY_URL)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_pay_redirect(
|
||||||
|
order_id: str,
|
||||||
|
amount: int,
|
||||||
|
return_url: str,
|
||||||
|
provider: str = "",
|
||||||
|
*,
|
||||||
|
channel: str = "alipay",
|
||||||
|
device: str = "pc",
|
||||||
|
user_agent: str = "",
|
||||||
|
client_ip: str = "",
|
||||||
|
) -> str:
|
||||||
|
if DEV_AUTO_PAY:
|
||||||
|
sep = "&" if "?" in return_url else "?"
|
||||||
|
return f"{return_url}{sep}order_id={order_id}&status=paid"
|
||||||
|
|
||||||
|
pay_provider = provider_for(device, provider or os.getenv("PAYMENT_PROVIDER", "zpay"), user_agent)
|
||||||
|
yuan = amount_yuan(amount)
|
||||||
|
name = "nomadro会员"
|
||||||
|
notify = notify_url_for(pay_provider.name)
|
||||||
|
|
||||||
|
if isinstance(pay_provider, XorPayProvider):
|
||||||
|
created = pay_provider.create_order(
|
||||||
|
order_id=order_id, name=name, amount_yuan=yuan, notify_url=notify,
|
||||||
|
)
|
||||||
|
return f"{created['pay_url']}?{urlencode(created['params'])}"
|
||||||
|
|
||||||
|
api_result = await pay_provider.create_order_api(
|
||||||
|
order_id=order_id,
|
||||||
|
name=name,
|
||||||
|
amount_yuan=yuan,
|
||||||
|
channel=channel,
|
||||||
|
notify_url=notify,
|
||||||
|
return_url=return_url,
|
||||||
|
client_ip=client_ip,
|
||||||
|
device=device,
|
||||||
|
)
|
||||||
|
if api_result.get("status") == "ok":
|
||||||
|
pay_url = api_result.get("payurl2") if device == "h5" and api_result.get("payurl2") else api_result.get("payurl")
|
||||||
|
if pay_url:
|
||||||
|
return str(pay_url)
|
||||||
|
|
||||||
|
created = pay_provider.create_order(
|
||||||
|
order_id=order_id,
|
||||||
|
name=name,
|
||||||
|
amount_yuan=yuan,
|
||||||
|
channel=channel,
|
||||||
|
notify_url=notify,
|
||||||
|
return_url=return_url,
|
||||||
|
client_ip=client_ip,
|
||||||
|
)
|
||||||
|
return f"{created['pay_url']}?{urlencode(created['params'])}"
|
||||||
|
|
||||||
|
|
||||||
|
async def query_order_paid(order_id: str) -> bool:
|
||||||
|
zpay, xorpay = await ZPayProvider().query_order_status(order_id), await XorPayProvider().query_order_status(order_id)
|
||||||
|
return bool(zpay.get("paid") or xorpay.get("paid"))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_notify(provider: str, data: dict) -> bool:
|
||||||
|
if DEV_AUTO_PAY:
|
||||||
|
return True
|
||||||
|
if provider == "xorpay":
|
||||||
|
return XorPayProvider().verify_notify(data)
|
||||||
|
return ZPayProvider().verify_notify(data)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_notify_order_id(provider: str, data: dict) -> str:
|
||||||
|
if provider == "xorpay":
|
||||||
|
return str(data.get("order_id") or "")
|
||||||
|
return str(data.get("out_trade_no") or data.get("order_id") or "")
|
||||||
125
backend/app/services/recommendations.py
Normal file
125
backend/app/services/recommendations.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""Next-stop recommendation scoring — ported from NomadCNA logic, adapted to nomadro destinations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_nomads_count(value: str) -> int:
|
||||||
|
digits = "".join(ch for ch in str(value) if ch.isdigit())
|
||||||
|
return int(digits) if digits else 0
|
||||||
|
|
||||||
|
|
||||||
|
def score_destination(
|
||||||
|
dest: dict[str, Any],
|
||||||
|
*,
|
||||||
|
budget: int,
|
||||||
|
internet: int,
|
||||||
|
climate: str,
|
||||||
|
tags: list[str],
|
||||||
|
priority: str = "balanced",
|
||||||
|
) -> tuple[float, list[str]]:
|
||||||
|
"""Return (score, match_reasons) for a destination dict."""
|
||||||
|
cost = int(dest.get("cost") or 0)
|
||||||
|
speed = int(dest.get("speed") or 0)
|
||||||
|
temp = int(dest.get("temperature") or 20)
|
||||||
|
rating = float(dest.get("rating") or 0)
|
||||||
|
nomads = _parse_nomads_count(dest.get("nomads_count") or "0")
|
||||||
|
highlights = dest.get("highlights") or []
|
||||||
|
tag = str(dest.get("tag") or "")
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
score = rating * 8
|
||||||
|
|
||||||
|
# Budget fit (monthly cost in CNY-ish units)
|
||||||
|
if cost <= budget:
|
||||||
|
score += 28
|
||||||
|
reasons.append("预算内")
|
||||||
|
elif cost <= budget * 1.2:
|
||||||
|
score += 14
|
||||||
|
reasons.append("略超预算")
|
||||||
|
else:
|
||||||
|
score += max(0, 10 - (cost - budget) / 800)
|
||||||
|
|
||||||
|
# Internet
|
||||||
|
if speed >= internet:
|
||||||
|
score += 22
|
||||||
|
reasons.append("网络达标")
|
||||||
|
else:
|
||||||
|
score += max(0, 22 - (internet - speed) / 4)
|
||||||
|
reasons.append("需确认住处网络")
|
||||||
|
|
||||||
|
# Climate preference
|
||||||
|
if climate == "warm":
|
||||||
|
score += 18 if temp >= 24 else max(0, 18 - (24 - temp) * 2)
|
||||||
|
if temp >= 24:
|
||||||
|
reasons.append("气候偏暖")
|
||||||
|
elif climate == "cool":
|
||||||
|
score += 18 if temp <= 18 else max(0, 18 - (temp - 18) * 2)
|
||||||
|
if temp <= 18:
|
||||||
|
reasons.append("气候清爽")
|
||||||
|
else: # mild
|
||||||
|
score += 18 if 18 <= temp <= 26 else max(0, 18 - abs(temp - 22) * 2)
|
||||||
|
if 18 <= temp <= 26:
|
||||||
|
reasons.append("气候温和")
|
||||||
|
|
||||||
|
# Tag overlap (NomadCNA-style lifestyle tags)
|
||||||
|
dest_tags = set(highlights + [tag])
|
||||||
|
overlap = [t for t in tags if any(t in dt or dt in t for dt in dest_tags)]
|
||||||
|
if overlap:
|
||||||
|
score += min(15, len(overlap) * 5)
|
||||||
|
reasons.append(f"标签匹配:{overlap[0]}")
|
||||||
|
|
||||||
|
# Community density
|
||||||
|
if nomads >= 8000:
|
||||||
|
score += 12
|
||||||
|
reasons.append("社区活跃")
|
||||||
|
elif nomads >= 3000:
|
||||||
|
score += 6
|
||||||
|
|
||||||
|
# Priority boost
|
||||||
|
if priority == "cost":
|
||||||
|
score += (14000 - cost) / 120
|
||||||
|
elif priority == "speed":
|
||||||
|
score += speed / 5
|
||||||
|
elif priority == "community":
|
||||||
|
score += nomads / 400
|
||||||
|
|
||||||
|
if not reasons:
|
||||||
|
reasons.append("综合评分推荐")
|
||||||
|
|
||||||
|
return round(score, 1), reasons[:3]
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_destinations(
|
||||||
|
destinations: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
budget: int = 8000,
|
||||||
|
internet: int = 50,
|
||||||
|
climate: str = "mild",
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
priority: str = "balanced",
|
||||||
|
limit: int = 12,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
tags = tags or []
|
||||||
|
scored: list[tuple[float, dict[str, Any], list[str]]] = []
|
||||||
|
for dest in destinations:
|
||||||
|
s, reasons = score_destination(
|
||||||
|
dest,
|
||||||
|
budget=budget,
|
||||||
|
internet=internet,
|
||||||
|
climate=climate,
|
||||||
|
tags=tags,
|
||||||
|
priority=priority,
|
||||||
|
)
|
||||||
|
scored.append((s, dest, reasons))
|
||||||
|
|
||||||
|
scored.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
items = []
|
||||||
|
for s, dest, reasons in scored[:limit]:
|
||||||
|
items.append({
|
||||||
|
**dest,
|
||||||
|
"match_score": s,
|
||||||
|
"match_reasons": reasons,
|
||||||
|
})
|
||||||
|
return items
|
||||||
431
backend/app/services/social_store.py
Normal file
431
backend/app/services/social_store.py
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
"""Social graph: profiles, swipes, matches, DMs — file-backed store."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.data.social_profiles import CANDIDATE_PROFILES, MATCH_INTENTS
|
||||||
|
|
||||||
|
STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "social_store.json"
|
||||||
|
|
||||||
|
LIKE_ACTIONS = frozenset({"like", "right", "superlike"})
|
||||||
|
FREE_SWIPE_DAILY_LIMIT = 25
|
||||||
|
VIP_SWIPE_LIMIT = 9999
|
||||||
|
|
||||||
|
_profiles: dict[str, dict] = {} # user_id -> profile
|
||||||
|
_swipes: list[dict] = []
|
||||||
|
_matches: list[dict] = []
|
||||||
|
_conversations: list[dict] = []
|
||||||
|
_messages: list[dict] = []
|
||||||
|
_memberships: dict[str, dict] = {} # user_id -> {expires_at, plan}
|
||||||
|
_orders: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _today_key() -> str:
|
||||||
|
return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _persist() -> None:
|
||||||
|
STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
STORE_PATH.write_text(
|
||||||
|
json.dumps({
|
||||||
|
"profiles": _profiles,
|
||||||
|
"swipes": _swipes,
|
||||||
|
"matches": _matches,
|
||||||
|
"conversations": _conversations,
|
||||||
|
"messages": _messages,
|
||||||
|
"memberships": _memberships,
|
||||||
|
"orders": _orders,
|
||||||
|
}, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore() -> None:
|
||||||
|
global _profiles, _swipes, _matches, _conversations, _messages, _memberships, _orders
|
||||||
|
if not STORE_PATH.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
_profiles = data.get("profiles") or {}
|
||||||
|
_swipes = data.get("swipes") or []
|
||||||
|
_matches = data.get("matches") or []
|
||||||
|
_conversations = data.get("conversations") or []
|
||||||
|
_messages = data.get("messages") or []
|
||||||
|
_memberships = data.get("memberships") or {}
|
||||||
|
_orders = data.get("orders") or {}
|
||||||
|
|
||||||
|
|
||||||
|
_restore()
|
||||||
|
|
||||||
|
|
||||||
|
def _reload() -> None:
|
||||||
|
_restore()
|
||||||
|
|
||||||
|
|
||||||
|
def is_vip(user_id: str) -> bool:
|
||||||
|
_reload()
|
||||||
|
m = _memberships.get(user_id)
|
||||||
|
if not m:
|
||||||
|
return False
|
||||||
|
exp = m.get("expires_at") or 0
|
||||||
|
return exp > int(time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_membership(user_id: str, days: int = 365) -> None:
|
||||||
|
_memberships[user_id] = {
|
||||||
|
"plan": "vip",
|
||||||
|
"expires_at": int(time.time()) + days * 86400,
|
||||||
|
"updated_at": _now_iso(),
|
||||||
|
}
|
||||||
|
_persist()
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict:
|
||||||
|
if user_id not in _profiles:
|
||||||
|
_profiles[user_id] = {
|
||||||
|
"userId": user_id,
|
||||||
|
"name": name,
|
||||||
|
"location": extra.get("location", "全球"),
|
||||||
|
"citySlug": extra.get("citySlug", ""),
|
||||||
|
"gender": extra.get("gender", ""),
|
||||||
|
"single": extra.get("single", ""),
|
||||||
|
"bio": extra.get("bio", ""),
|
||||||
|
"photo": extra.get("photo", "🧑💻"),
|
||||||
|
"tags": extra.get("tags", []),
|
||||||
|
"lookingFor": extra.get("lookingFor", ["friends", "explore"]),
|
||||||
|
"createdAt": _now_iso(),
|
||||||
|
}
|
||||||
|
_persist()
|
||||||
|
return _profiles[user_id]
|
||||||
|
|
||||||
|
|
||||||
|
def join_member(user_id: str, name: str, payload: dict) -> dict:
|
||||||
|
_reload()
|
||||||
|
profile = get_or_create_profile(
|
||||||
|
user_id,
|
||||||
|
name,
|
||||||
|
location=payload.get("city", "全球"),
|
||||||
|
citySlug=payload.get("citySlug", ""),
|
||||||
|
gender=payload.get("gender", ""),
|
||||||
|
single=payload.get("single", ""),
|
||||||
|
bio=payload.get("bio", ""),
|
||||||
|
lookingFor=payload.get("lookingFor", ["friends", "explore"]),
|
||||||
|
photo=payload.get("photo", "🧑💻"),
|
||||||
|
)
|
||||||
|
_persist()
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_looking_for(record: dict) -> list[str]:
|
||||||
|
raw = record.get("lookingFor") or []
|
||||||
|
cleaned = [x for x in raw if x in MATCH_INTENTS]
|
||||||
|
return cleaned or ["friends", "explore"]
|
||||||
|
|
||||||
|
|
||||||
|
def list_candidates(
|
||||||
|
user_id: str,
|
||||||
|
intent: str = "friends",
|
||||||
|
city: str = "",
|
||||||
|
gender: str = "",
|
||||||
|
single: str = "",
|
||||||
|
exclude_swiped: bool = True,
|
||||||
|
) -> list[dict]:
|
||||||
|
_reload()
|
||||||
|
my_profile = _profiles.get(user_id)
|
||||||
|
swiped_ids = set()
|
||||||
|
if exclude_swiped:
|
||||||
|
for s in _swipes:
|
||||||
|
if s.get("userId") == user_id:
|
||||||
|
swiped_ids.add(s.get("profileId"))
|
||||||
|
|
||||||
|
pool = list(CANDIDATE_PROFILES)
|
||||||
|
if my_profile:
|
||||||
|
pool = [p for p in pool if p.get("userId") != user_id]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for p in pool:
|
||||||
|
if p["id"] in swiped_ids:
|
||||||
|
continue
|
||||||
|
if intent and intent not in _profile_looking_for(p):
|
||||||
|
continue
|
||||||
|
if city and city not in (p.get("location") or "") and city != p.get("citySlug"):
|
||||||
|
continue
|
||||||
|
if gender and is_vip(user_id) and gender and p.get("gender") != gender:
|
||||||
|
continue
|
||||||
|
if single and is_vip(user_id) and single and p.get("single") != single:
|
||||||
|
continue
|
||||||
|
results.append({**p, "intent": intent})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def swipe_count_today(user_id: str) -> int:
|
||||||
|
today = _today_key()
|
||||||
|
return sum(1 for s in _swipes if s.get("userId") == user_id and s.get("date") == today)
|
||||||
|
|
||||||
|
|
||||||
|
def get_quota(user_id: str) -> dict:
|
||||||
|
_reload()
|
||||||
|
vip = is_vip(user_id)
|
||||||
|
limit = VIP_SWIPE_LIMIT if vip else FREE_SWIPE_DAILY_LIMIT
|
||||||
|
used = swipe_count_today(user_id)
|
||||||
|
return {"vip": vip, "limit": limit, "used": used, "remaining": max(0, limit - used)}
|
||||||
|
|
||||||
|
|
||||||
|
def _pair_key(a: str, b: str) -> str:
|
||||||
|
return "|".join(sorted([a, b]))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_reciprocal_like(target_user_id: str, my_profile_id: str) -> bool:
|
||||||
|
target_profile = next((p for p in CANDIDATE_PROFILES if p.get("userId") == target_user_id), None)
|
||||||
|
if not target_profile:
|
||||||
|
return False
|
||||||
|
target_pid = target_profile["id"]
|
||||||
|
for s in _swipes:
|
||||||
|
if s.get("userId") == target_user_id and s.get("profileId") == my_profile_id:
|
||||||
|
if s.get("action") in LIKE_ACTIONS:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_match(user_id: str, profile: dict, intent: str) -> dict | None:
|
||||||
|
target_uid = profile.get("userId")
|
||||||
|
if not target_uid:
|
||||||
|
return None
|
||||||
|
pk = _pair_key(user_id, target_uid)
|
||||||
|
for m in _matches:
|
||||||
|
if m.get("pairKey") == pk:
|
||||||
|
return m
|
||||||
|
conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True)
|
||||||
|
match = {
|
||||||
|
"id": secrets.token_hex(6),
|
||||||
|
"pairKey": pk,
|
||||||
|
"userAId": user_id,
|
||||||
|
"userBId": target_uid,
|
||||||
|
"profileAId": _profiles.get(user_id, {}).get("id", ""),
|
||||||
|
"profileBId": profile.get("id"),
|
||||||
|
"intent": intent,
|
||||||
|
"conversationId": conv["id"],
|
||||||
|
"matchedAt": _now_iso(),
|
||||||
|
}
|
||||||
|
_matches.append(match)
|
||||||
|
_persist()
|
||||||
|
return match
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_conversation(user_a: str, user_b: str, intent: str = "friends", match: bool = False) -> dict:
|
||||||
|
pk = _pair_key(user_a, user_b)
|
||||||
|
for c in _conversations:
|
||||||
|
if c.get("pairKey") == pk:
|
||||||
|
return c
|
||||||
|
conv = {
|
||||||
|
"id": secrets.token_hex(8),
|
||||||
|
"pairKey": pk,
|
||||||
|
"type": "match" if match else "direct",
|
||||||
|
"userAId": user_a,
|
||||||
|
"userBId": user_b,
|
||||||
|
"intent": intent,
|
||||||
|
"lastMessageAt": _now_iso(),
|
||||||
|
"lastMessagePreview": "",
|
||||||
|
"readState": {user_a: _now_iso(), user_b: _now_iso()},
|
||||||
|
}
|
||||||
|
_conversations.append(conv)
|
||||||
|
_persist()
|
||||||
|
return conv
|
||||||
|
|
||||||
|
|
||||||
|
def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dict:
|
||||||
|
_reload()
|
||||||
|
if action not in LIKE_ACTIONS and action != "dislike":
|
||||||
|
action = "dislike"
|
||||||
|
if action == "superlike" and not is_vip(user_id):
|
||||||
|
return {"ok": False, "error": "vip_required"}
|
||||||
|
q = get_quota(user_id)
|
||||||
|
if not q["vip"] and q["remaining"] <= 0 and action in LIKE_ACTIONS:
|
||||||
|
return {"ok": False, "error": "quota_exceeded"}
|
||||||
|
|
||||||
|
for s in _swipes:
|
||||||
|
if s.get("userId") == user_id and s.get("profileId") == profile_id:
|
||||||
|
return {"ok": False, "error": "duplicate"}
|
||||||
|
|
||||||
|
profile = next((p for p in CANDIDATE_PROFILES if p["id"] == profile_id), None)
|
||||||
|
if not profile:
|
||||||
|
return {"ok": False, "error": "not_found"}
|
||||||
|
|
||||||
|
_swipes.append({
|
||||||
|
"id": secrets.token_hex(6),
|
||||||
|
"userId": user_id,
|
||||||
|
"profileId": profile_id,
|
||||||
|
"action": action,
|
||||||
|
"intent": intent,
|
||||||
|
"date": _today_key(),
|
||||||
|
"createdAt": _now_iso(),
|
||||||
|
})
|
||||||
|
_persist()
|
||||||
|
|
||||||
|
matched = None
|
||||||
|
if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id):
|
||||||
|
matched = _ensure_match(user_id, profile, intent)
|
||||||
|
if matched:
|
||||||
|
from app.services import community_store
|
||||||
|
|
||||||
|
my_name = _profiles.get(user_id, {}).get("name", "游民")
|
||||||
|
peer_name = profile.get("name", "游民")
|
||||||
|
conv_id = matched.get("conversationId", "")
|
||||||
|
link = f"/chat/{conv_id}" if conv_id else "/chat"
|
||||||
|
community_store.add_notification(
|
||||||
|
user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link
|
||||||
|
)
|
||||||
|
peer_uid = profile.get("userId")
|
||||||
|
if peer_uid:
|
||||||
|
community_store.add_notification(
|
||||||
|
peer_uid, "匹配成功 🎉", f"你和 {my_name} 互相喜欢了,快去聊天吧", link
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"matched": bool(matched),
|
||||||
|
"match": matched,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_profile(user_id: str) -> dict | None:
|
||||||
|
_reload()
|
||||||
|
p = _profiles.get(user_id)
|
||||||
|
if not p:
|
||||||
|
return None
|
||||||
|
return {**p, "vip": is_vip(user_id)}
|
||||||
|
|
||||||
|
|
||||||
|
def undo_last_swipe(user_id: str) -> dict:
|
||||||
|
_reload()
|
||||||
|
for i in range(len(_swipes) - 1, -1, -1):
|
||||||
|
if _swipes[i].get("userId") == user_id:
|
||||||
|
removed = _swipes.pop(i)
|
||||||
|
_persist()
|
||||||
|
return {"ok": True, "removed": removed}
|
||||||
|
return {"ok": False, "error": "nothing_to_undo"}
|
||||||
|
|
||||||
|
|
||||||
|
def list_likes(user_id: str) -> list[dict]:
|
||||||
|
_reload()
|
||||||
|
liked_ids = [s["profileId"] for s in _swipes if s.get("userId") == user_id and s.get("action") in LIKE_ACTIONS]
|
||||||
|
return [p for p in CANDIDATE_PROFILES if p["id"] in liked_ids]
|
||||||
|
|
||||||
|
|
||||||
|
def list_mutual(user_id: str) -> list[dict]:
|
||||||
|
_reload()
|
||||||
|
items = []
|
||||||
|
for m in _matches:
|
||||||
|
if user_id in (m.get("userAId"), m.get("userBId")):
|
||||||
|
peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"]
|
||||||
|
peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None)
|
||||||
|
items.append({**m, "peer": peer, "conversationId": m.get("conversationId")})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def list_conversations(user_id: str) -> list[dict]:
|
||||||
|
_reload()
|
||||||
|
items = []
|
||||||
|
for c in _conversations:
|
||||||
|
if user_id not in (c.get("userAId"), c.get("userBId")):
|
||||||
|
continue
|
||||||
|
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
|
||||||
|
peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None)
|
||||||
|
read_at = (c.get("readState") or {}).get(user_id, "")
|
||||||
|
unread = 1 if c.get("lastMessageAt", "") > read_at else 0
|
||||||
|
items.append({
|
||||||
|
**c,
|
||||||
|
"peer": peer,
|
||||||
|
"unreadCount": unread,
|
||||||
|
})
|
||||||
|
items.sort(key=lambda x: x.get("lastMessageAt", ""), reverse=True)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversation(conv_id: str, user_id: str) -> dict | None:
|
||||||
|
for c in _conversations:
|
||||||
|
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 = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None)
|
||||||
|
return {**c, "peer": peer}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def list_messages(conv_id: str, user_id: str, limit: int = 50) -> list[dict]:
|
||||||
|
_reload()
|
||||||
|
conv = get_conversation(conv_id, user_id)
|
||||||
|
if not conv:
|
||||||
|
return []
|
||||||
|
msgs = [m for m in _messages if m.get("conversationId") == conv_id]
|
||||||
|
msgs.sort(key=lambda x: x.get("createdAt", ""))
|
||||||
|
if conv.get("readState") is not None:
|
||||||
|
conv["readState"][user_id] = _now_iso()
|
||||||
|
_persist()
|
||||||
|
return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]]
|
||||||
|
|
||||||
|
|
||||||
|
def send_message(conv_id: str, user_id: str, body: str) -> dict | None:
|
||||||
|
conv = get_conversation(conv_id, user_id)
|
||||||
|
if not conv or not body.strip():
|
||||||
|
return None
|
||||||
|
body = body.strip()[:2000]
|
||||||
|
msg = {
|
||||||
|
"id": secrets.token_hex(8),
|
||||||
|
"conversationId": conv_id,
|
||||||
|
"senderId": user_id,
|
||||||
|
"body": body,
|
||||||
|
"createdAt": _now_iso(),
|
||||||
|
}
|
||||||
|
_messages.append(msg)
|
||||||
|
for c in _conversations:
|
||||||
|
if c["id"] == conv_id:
|
||||||
|
c["lastMessageAt"] = _now_iso()
|
||||||
|
c["lastMessagePreview"] = body[:80]
|
||||||
|
break
|
||||||
|
_persist()
|
||||||
|
return {**msg, "mine": True}
|
||||||
|
|
||||||
|
|
||||||
|
def create_order(user_id: str, pay_type: str, amount: int, dev_auto_pay: bool = False) -> dict:
|
||||||
|
order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{secrets.token_hex(2)}"
|
||||||
|
status = "paid" if dev_auto_pay else "pending"
|
||||||
|
order = {
|
||||||
|
"id": order_id,
|
||||||
|
"userId": user_id,
|
||||||
|
"payType": pay_type,
|
||||||
|
"amount": amount,
|
||||||
|
"status": status,
|
||||||
|
"createdAt": _now_iso(),
|
||||||
|
}
|
||||||
|
_orders[order_id] = order
|
||||||
|
if dev_auto_pay:
|
||||||
|
ensure_membership(user_id)
|
||||||
|
_persist()
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def get_order(order_id: str) -> dict | None:
|
||||||
|
_reload()
|
||||||
|
return _orders.get(order_id)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_order_paid(order_id: str) -> dict | None:
|
||||||
|
order = _orders.get(order_id)
|
||||||
|
if not order:
|
||||||
|
return None
|
||||||
|
order["status"] = "paid"
|
||||||
|
ensure_membership(order["userId"])
|
||||||
|
_persist()
|
||||||
|
return order
|
||||||
12
deploy/caddy/lounge.nomadro.com.caddy
Normal file
12
deploy/caddy/lounge.nomadro.com.caddy
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
lounge.nomadro.com {
|
||||||
|
encode gzip
|
||||||
|
reverse_proxy https://lounge.nomadro.cn {
|
||||||
|
header_up Host lounge.nomadro.cn
|
||||||
|
transport http {
|
||||||
|
tls_insecure_skip_verify
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
deploy/caddy/mirotalk.nomadro.com.caddy
Normal file
12
deploy/caddy/mirotalk.nomadro.com.caddy
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
mirotalk.nomadro.com {
|
||||||
|
encode gzip
|
||||||
|
reverse_proxy https://mirotalk.nomadro.cn {
|
||||||
|
header_up Host mirotalk.nomadro.cn
|
||||||
|
transport http {
|
||||||
|
tls_insecure_skip_verify
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
deploy/nomadro-api.env.example
Normal file
22
deploy/nomadro-api.env.example
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Copy to nomadro-api.env on the server — DO NOT commit real keys
|
||||||
|
DEV_AUTO_PAY=false
|
||||||
|
PAYMENT_PROVIDER=zpay
|
||||||
|
PAYMENT_JOIN_AMOUNT=10000
|
||||||
|
PAYMENT_DEFAULT_AMOUNT=10000
|
||||||
|
SITE_BASE_URL=https://nomadweb.nomadro.com
|
||||||
|
|
||||||
|
ZPAY_PID=
|
||||||
|
ZPAY_KEY=
|
||||||
|
ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php
|
||||||
|
ZPAY_MAPI_URL=https://zpayz.cn/mapi.php
|
||||||
|
ZPAY_QUERY_URL=https://zpayz.cn/api.php
|
||||||
|
ZPAY_NOTIFY_URL=https://nomadweb.nomadro.com/api/v1/pay/zpay_notify
|
||||||
|
|
||||||
|
XORPAY_AID=
|
||||||
|
XORPAY_SECRET=
|
||||||
|
XORPAY_CASHIER_URL=https://xorpay.com/api/cashier
|
||||||
|
XORPAY_QUERY_URL=https://xorpay.com/api/query2
|
||||||
|
XORPAY_NOTIFY_URL=https://nomadweb.nomadro.com/api/v1/pay/xorpay_notify
|
||||||
|
|
||||||
|
MIROTALK_URL=https://mirotalk.nomadro.com
|
||||||
|
LOUNGE_URL=https://lounge.nomadro.com
|
||||||
@ -5,6 +5,7 @@ After=network.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=/opt/nomadweb/backend
|
WorkingDirectory=/opt/nomadweb/backend
|
||||||
|
EnvironmentFile=/opt/nomadweb/deploy/nomadro-api.env
|
||||||
Environment=POCKETBASE_URL=http://127.0.0.1:8090
|
Environment=POCKETBASE_URL=http://127.0.0.1:8090
|
||||||
Environment=POCKETBASE_ADMIN_EMAIL=admin@nomadro.com
|
Environment=POCKETBASE_ADMIN_EMAIL=admin@nomadro.com
|
||||||
Environment=POCKETBASE_ADMIN_PASSWORD=admin123456
|
Environment=POCKETBASE_ADMIN_PASSWORD=admin123456
|
||||||
|
|||||||
73
frontend/content/book/book.json
Normal file
73
frontend/content/book/book.json
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"title": "游牧代码",
|
||||||
|
"subtitle": "数字游民与 Homelab 生存指南",
|
||||||
|
"author": "林远",
|
||||||
|
"description": "一个程序员离开工位、带上电脑环游世界,同时把「家」装进机柜的故事。",
|
||||||
|
"tagline": "在任意城市,过你想要的生活。",
|
||||||
|
"coverGradient": "from-violet-950 via-fuchsia-900 to-orange-900",
|
||||||
|
"readingMinutes": 45,
|
||||||
|
"viralTags": ["数字游民", "Homelab", "远程办公", "Tailscale", "自由职业"],
|
||||||
|
"stats": [
|
||||||
|
{ "emoji": "🌍", "value": "7", "label": "章精华" },
|
||||||
|
{ "emoji": "⏱️", "value": "45", "label": "分钟读完" },
|
||||||
|
{ "emoji": "📍", "value": "3+", "label": "城市灵感" },
|
||||||
|
{ "emoji": "🔥", "value": "12", "label": "书摘片段" }
|
||||||
|
],
|
||||||
|
"hooks": [
|
||||||
|
{ "emoji": "💻", "text": "远程办公的第一周,效率奇高。第二周,你开始分不清「工作结束」和「生活开始」。" },
|
||||||
|
{ "emoji": "🌴", "text": "清迈的周二早晨:flat white + VS Code,旁边有人在写旅行博客。" },
|
||||||
|
{ "emoji": "🗄️", "text": "Homelab 给你的锚,不是地理上的,是数字上的——文件在,服务在,随时等你回来。" },
|
||||||
|
{ "emoji": "🔑", "text": "Tailscale:无论 IP 怎么变,你的设备永远在「同一个局域网」。" }
|
||||||
|
],
|
||||||
|
"chapters": [
|
||||||
|
{
|
||||||
|
"slug": "00-preface",
|
||||||
|
"title": "序:我为什么开始游牧",
|
||||||
|
"emoji": "✈️",
|
||||||
|
"hook": "我想要的不是更好的工位,而是可以选择坐在哪里。",
|
||||||
|
"shareQuote": "数字游民是一种工作方式:产出不绑定在某个地理坐标上。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "01-first-remote",
|
||||||
|
"title": "第一次远程办公的那个周五",
|
||||||
|
"emoji": "🏠",
|
||||||
|
"hook": "穿着睡衣开 Zoom,可能是当代最魔幻的上班方式。",
|
||||||
|
"shareQuote": "异步沟通:不是「随时在线」,而是「明确预期」。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "02-chiangmai",
|
||||||
|
"title": "清迈的周二早晨",
|
||||||
|
"emoji": "☕",
|
||||||
|
"hook": "游牧不是一个人流浪,是在流动中找到同类。",
|
||||||
|
"shareQuote": "在这里,旅游和工作本来就不矛盾。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "03-gear",
|
||||||
|
"title": "行囊里真正重要的东西",
|
||||||
|
"emoji": "🎒",
|
||||||
|
"hook": "带多了是负担,带少了会后悔——这是游牧者的永恒命题。",
|
||||||
|
"shareQuote": "网络,比护照更重要。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "04-homelab",
|
||||||
|
"title": "Homelab:把家装进一个机柜",
|
||||||
|
"emoji": "🖥️",
|
||||||
|
"hook": "几百块的旧电脑,可以成为整个数字生活的中枢。",
|
||||||
|
"shareQuote": "完美是优秀的敌人。先跑起来,再迭代。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "05-network",
|
||||||
|
"title": "无论在哪,都能连回家",
|
||||||
|
"emoji": "🔗",
|
||||||
|
"hook": "人在巴厘岛,手在家里。",
|
||||||
|
"shareQuote": "Homelab 服务默认不暴露公网——这是安全感的前提。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "06-epilogue",
|
||||||
|
"title": "终章:自由是一种日常",
|
||||||
|
"emoji": "🌅",
|
||||||
|
"hook": "自由不是不用工作,而是可以选择在哪里、以什么节奏工作。",
|
||||||
|
"shareQuote": "小步验证,比宏大计划更可靠。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
31
frontend/content/book/chapters/00-preface.md
Normal file
31
frontend/content/book/chapters/00-preface.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# 序:我为什么开始游牧
|
||||||
|
|
||||||
|
二〇二三年秋天,我在公司工位上坐满了三年。
|
||||||
|
|
||||||
|
显示器左边是 Jira,右边是 Slack,中间是我已经改过十七遍的需求文档。某个加班的深夜,我刷到一张清迈咖啡馆的照片——阳光从百叶窗缝里漏进来,一台 MacBook 和一杯冰美式,背景里有人在用泰语点单。
|
||||||
|
|
||||||
|
那一刻我突然意识到:**我想要的不是更好的工位,而是可以选择坐在哪里。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 这不是逃离
|
||||||
|
|
||||||
|
很多人把数字游民理解成「辞职、环游世界、发 Instagram」。不是的。
|
||||||
|
|
||||||
|
数字游民是一种**工作方式**:你的产出不绑定在某个地理坐标上。你可以在上海写代码,也可以在里斯本写代码——只要网络稳定、时区能协调、交付不掉链子。
|
||||||
|
|
||||||
|
Homelab 则是另一种自由:无论你在哪个时区醒来,你的文件、你的服务、你的数据,都在一个你完全掌控的地方等着你。
|
||||||
|
|
||||||
|
这本书写的,就是这两件事如何交织在一起。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 你会读到什么
|
||||||
|
|
||||||
|
这不是技术文档,也不是旅行攻略。这是一个普通程序员,从第一次远程办公,到在东南亚租下月付公寓,再到把家里那台旧服务器变成「随时能连回去的数字基地」的真实过程。
|
||||||
|
|
||||||
|
有踩过的坑,也有意外的好运。
|
||||||
|
|
||||||
|
如果你也在想:**能不能不辞职,但换一种活法?**
|
||||||
|
|
||||||
|
那就从下一章开始吧。
|
||||||
39
frontend/content/book/chapters/01-first-remote.md
Normal file
39
frontend/content/book/chapters/01-first-remote.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# 第一次远程办公的那个周五
|
||||||
|
|
||||||
|
周五下午三点,Team Leader 在群里发了一条消息:
|
||||||
|
|
||||||
|
> 「下周开始,技术组全员 remote,有问题的私聊 HR。」
|
||||||
|
|
||||||
|
我盯着这条消息看了很久。不是担心,是一种奇怪的安静——像考试结束铃响之前,你已经写完了最后一道题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一天
|
||||||
|
|
||||||
|
周一早上九点,我穿着睡衣坐在餐桌前,打开 Zoom。
|
||||||
|
|
||||||
|
同事们的脸出现在格子里,有人还在刷牙,有人背景是乱糟糟的卧室。会议结束后,我泡了杯咖啡,走到阳台。
|
||||||
|
|
||||||
|
楼下有人在遛狗,阳光很好。
|
||||||
|
|
||||||
|
**原来工作日也可以是这样。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 效率的幻觉
|
||||||
|
|
||||||
|
远程的第一周,我效率奇高。没有通勤,没有工位旁的闲聊,没有「开完这个会再写代码」的拖延。
|
||||||
|
|
||||||
|
第二周,我开始分不清「工作结束」和「生活开始」的边界。晚上十一点,Slack 还亮着绿点。周末,邮箱里躺着三条「不急,周一前看就行」的消息。
|
||||||
|
|
||||||
|
我学到一个词:**异步沟通**。不是「随时在线」,而是「明确预期」——什么时候回复、什么算紧急、什么可以等到下一个工作日。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 那个周五的后续
|
||||||
|
|
||||||
|
三个月后,HR 发邮件说办公室租约到期,技术组永久 remote。
|
||||||
|
|
||||||
|
我没有回「太好了」,也没有回「怎么办」。我只是打开地图,搜索了「清迈 月租 公寓 WiFi」。
|
||||||
|
|
||||||
|
有些改变,不是突然发生的。是你某天回头看,发现路已经走了一半。
|
||||||
44
frontend/content/book/chapters/02-chiangmai.md
Normal file
44
frontend/content/book/chapters/02-chiangmai.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# 清迈的周二早晨
|
||||||
|
|
||||||
|
七点半,我被摩托车声叫醒。
|
||||||
|
|
||||||
|
不是闹钟——是楼下送菜的小贩,喇叭里循环播放泰语,我一个字也听不懂,但已经习惯了。这是我在清迈的第三周。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 咖啡馆即办公室
|
||||||
|
|
||||||
|
我住的公寓步行十分钟有一家叫 **Graph Table** 的咖啡馆。二层靠窗的位置,能看到一条安静的小巷。WiFi 密码写在收银台的小黑板上,下载速度稳定在 80 Mbps 左右。
|
||||||
|
|
||||||
|
我点一杯 flat white,打开 VS Code。
|
||||||
|
|
||||||
|
旁边的荷兰设计师在画 Figma,日本背包客在写旅行博客。没有人问「你是来旅游的还是来工作的」——在这里,这两件事本来就不矛盾。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 时区的艺术
|
||||||
|
|
||||||
|
我在东七区,团队大部分人在东八区。这意味着:
|
||||||
|
|
||||||
|
- 我的「早上十点 standup」,是清迈的九点
|
||||||
|
- 他们的「下班前 review」,是我的下午五点——刚好收工去夜市
|
||||||
|
|
||||||
|
我学会把深度工作放在本地上午,把会议和沟通压缩到下午。不是妥协,是**设计**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 孤独与社群
|
||||||
|
|
||||||
|
数字游民最容易被忽视的问题,不是签证,不是网络,是**孤独**。
|
||||||
|
|
||||||
|
我在 Nomad List 的 Discord 里认识了几个常驻清迈的开发者。我们每周三晚上在 Nimman 区聚餐,聊技术、聊签证、聊哪个 co-working 空间的空调更冷。
|
||||||
|
|
||||||
|
**游牧不是一个人流浪,是在流动中找到同类。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 周二的结尾
|
||||||
|
|
||||||
|
晚上八点,我骑摩托车回公寓。路过塔佩门,游客正在拍夜景。我停了一分钟,然后继续往前。
|
||||||
|
|
||||||
|
明天还有 standup。但此刻,清迈的风是温的。
|
||||||
45
frontend/content/book/chapters/03-gear.md
Normal file
45
frontend/content/book/chapters/03-gear.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# 行囊里真正重要的东西
|
||||||
|
|
||||||
|
出发前,我在 Reddit 的 r/digitalnomad 翻了三天装备帖。最后发现:**带多了是负担,带少了会后悔。**
|
||||||
|
|
||||||
|
这是我在路上一年后,精简再精简的清单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 电脑:唯一不能妥协的
|
||||||
|
|
||||||
|
一台 14 寸 MacBook Pro,M 系列芯片,16GB 内存。不是最新款,但足够跑 Docker、开十几个 Chrome 标签、同时 Zoom 和写代码。
|
||||||
|
|
||||||
|
外接显示器?我试过,最后卖了。游牧的核心是**移动**,不是把工位复制到每一个城市。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 网络:比护照更重要
|
||||||
|
|
||||||
|
我随身带三样东西:
|
||||||
|
|
||||||
|
1. **本地 SIM 卡** — 落地当天买,流量包月
|
||||||
|
2. **便携路由器** — GL.iNet 那类,支持 4G 备用
|
||||||
|
3. **Tailscale** — 后面会细讲,这是连回 Homelab 的钥匙
|
||||||
|
|
||||||
|
在清迈、巴厘岛、里斯本,我学会的第一件事永远是:先测 WiFi,再 unpack。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 其他:能省则省
|
||||||
|
|
||||||
|
- 降噪耳机:飞机、咖啡馆、合租房必备
|
||||||
|
- 万能转换插头:一个就够
|
||||||
|
- 折叠键盘:可选,我最后只用笔记本自带
|
||||||
|
|
||||||
|
衣服?一个 40L 背包,七天的量,到哪里都能洗。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 真正重要的
|
||||||
|
|
||||||
|
装备可以列清单,但有三样东西清单上写不下:
|
||||||
|
|
||||||
|
**稳定的收入、可预期的工作节奏、以及一个随时可以回去的地方。**
|
||||||
|
|
||||||
|
第三个,Homelab 会帮你解决。
|
||||||
48
frontend/content/book/chapters/04-homelab.md
Normal file
48
frontend/content/book/chapters/04-homelab.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Homelab:把家装进一个机柜
|
||||||
|
|
||||||
|
二〇二二年,我还住在租来的单间里,床底下藏着一台 Dell OptiPlex 小主机。
|
||||||
|
|
||||||
|
当时只是想跑个 Plex,把电影串流到手机。没想到,这台几百块的机器,后来成了我整个数字生活的中枢。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 什么是 Homelab
|
||||||
|
|
||||||
|
Homelab,字面意思是「家庭实验室」。简单说,就是**你自己搭的、自己管的服务器环境**。
|
||||||
|
|
||||||
|
不是云服务那种「按月订阅、数据在别人机房」。是你的 NAS、你的 Git 仓库、你的笔记同步、你的自动化脚本——物理上可能就在你国内那个出租屋里,逻辑上却跟着你走遍世界。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 我的第一套配置
|
||||||
|
|
||||||
|
| 组件 | 选择 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| 主机 | Dell OptiPlex 7040 | 7×24 低功耗运行 |
|
||||||
|
| 系统 | Proxmox | 虚拟机管理 |
|
||||||
|
| 存储 | 4TB 机械 + 256GB SSD | 文件 + 系统 |
|
||||||
|
| 服务 | Jellyfin, Nextcloud, Gitea | 媒体、同步、代码 |
|
||||||
|
|
||||||
|
总花费不到三千块。比一台新 iPhone 便宜,但价值在于:**它是你的。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么游牧者需要 Homelab
|
||||||
|
|
||||||
|
人在清迈,文件在上海的硬盘里——听起来很蠢,对吧?
|
||||||
|
|
||||||
|
但云盘有容量限制,有审查风险,有「服务条款变更」的隐患。Homelab 给你的,是**数据主权**和**不受地点限制的访问权**。
|
||||||
|
|
||||||
|
配合 Tailscale 或 WireGuard,你在任何 WiFi 下,都能像坐在家里一样访问自己的服务。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 从一台旧电脑开始
|
||||||
|
|
||||||
|
你不需要一上来就买机柜、交换机、UPS。
|
||||||
|
|
||||||
|
找一台闲置笔记本,装 Linux,跑一个 Nextcloud 或 FileBrowser。能远程访问,就算 Homelab 入门了。
|
||||||
|
|
||||||
|
**完美是优秀的敌人。先跑起来,再迭代。**
|
||||||
|
|
||||||
|
下一章,我们聊怎么「连回家」。
|
||||||
59
frontend/content/book/chapters/05-network.md
Normal file
59
frontend/content/book/chapters/05-network.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# 无论在哪,都能连回家
|
||||||
|
|
||||||
|
清迈咖啡馆的 WiFi 突然断了。
|
||||||
|
|
||||||
|
我切换到手机热点,打开终端,输入:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh nas
|
||||||
|
```
|
||||||
|
|
||||||
|
三秒后,我出现在家里那台 Proxmox 主机的 shell 里。文件在,服务在,就像从未离开过。
|
||||||
|
|
||||||
|
靠的是 **Tailscale**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 虚拟局域网
|
||||||
|
|
||||||
|
Tailscale 基于 WireGuard,把你在不同网络的设备,组成一个加密的虚拟局域网(Tailnet)。
|
||||||
|
|
||||||
|
家里跑 Tailscale 的 NAS,笔记本跑 Tailscale 客户端——无论 IP 怎么变,它们永远在「同一个局域网」里。
|
||||||
|
|
||||||
|
配置过程:
|
||||||
|
|
||||||
|
1. 在 Homelab 主机安装 Tailscale
|
||||||
|
2. 笔记本、手机同样安装并登录同一账号
|
||||||
|
3. 用 MagicDNS 给设备起名字,比如 `nas.tail-xxxxx.ts.net`
|
||||||
|
|
||||||
|
不需要公网 IP,不需要端口转发,不需要折腾路由器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 我常用的远程场景
|
||||||
|
|
||||||
|
**同步文件** — Nextcloud 客户端走 Tailscale 内网,速度比公网快,也不暴露服务到互联网。
|
||||||
|
|
||||||
|
**看代码** — Gitea 跑在内网,浏览器打开 `http://gitea:3000`,只有 Tailnet 里的设备能访问。
|
||||||
|
|
||||||
|
**紧急修复** — 生产环境告警,SSH 进去,改配置,重启服务。人在巴厘岛,手在家里。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全习惯
|
||||||
|
|
||||||
|
- Homelab 服务**默认不暴露公网**
|
||||||
|
- 敏感操作走 SSH 密钥,禁用密码登录
|
||||||
|
- 定期备份到另一块硬盘,异地再备一份
|
||||||
|
|
||||||
|
网络自由的前提,是**安全意识**不能掉线。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 连回家,意味着什么
|
||||||
|
|
||||||
|
游牧者容易有一种漂流感:今天这条街,明天那座城,没有锚。
|
||||||
|
|
||||||
|
Homelab 给我的锚,不是地理上的,是**数字上的**——我知道有一个地方,存着我的东西,跑着我的服务,随时等我回来。
|
||||||
|
|
||||||
|
这种确定感,和清迈的咖啡一样,是/nomad 生活的基础设施。
|
||||||
49
frontend/content/book/chapters/06-epilogue.md
Normal file
49
frontend/content/book/chapters/06-epilogue.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# 终章:自由是一种日常
|
||||||
|
|
||||||
|
写这本书时,我人在里斯本。
|
||||||
|
|
||||||
|
公寓窗外是电车轨道,偶尔有铃声。桌上有半杯 bica,电脑屏幕里是未提交的 commit。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自由不是假期
|
||||||
|
|
||||||
|
很多人把数字游民想象成「永远在度假」。实际上,周二依然是周二,需求依然会改,bug 依然会在上线前出现。
|
||||||
|
|
||||||
|
**自由不是不用工作,而是可以选择在哪里、以什么节奏工作。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Homelab 与游牧的共生
|
||||||
|
|
||||||
|
这两件事看起来方向相反:
|
||||||
|
|
||||||
|
- 游牧是**流动**
|
||||||
|
- Homelab 是**固定**
|
||||||
|
|
||||||
|
但组合在一起,逻辑就通了:你在物理世界移动,数字世界的「家」保持不动。像蜗牛的壳,不是负担,是随身携带的秩序。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 给正在考虑的你
|
||||||
|
|
||||||
|
如果你还在工位上读这本书,不必立刻辞职,不必立刻买票。
|
||||||
|
|
||||||
|
你可以:
|
||||||
|
|
||||||
|
1. 先和团队谈一次 remote 的可能性
|
||||||
|
2. 用一台旧电脑搭一个最小 Homelab
|
||||||
|
3. 选一个周末,带着电脑去邻城住两晚,测试「在别处工作」的感觉
|
||||||
|
|
||||||
|
**小步验证,比宏大计划更可靠。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最后
|
||||||
|
|
||||||
|
游牧代码,写的不是「如何逃离」,而是「如何建造」——建造一种你真正想要的生活,并用技术和纪律去支撑它。
|
||||||
|
|
||||||
|
书到这里结束。你的下一章,在自己手里。
|
||||||
|
|
||||||
|
—— 林远
|
||||||
|
写于里斯本,一个普通的周二
|
||||||
73
frontend/content/book/en/book.json
Normal file
73
frontend/content/book/en/book.json
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"title": "Nomad Code",
|
||||||
|
"subtitle": "A Digital Nomad & Homelab Survival Guide",
|
||||||
|
"author": "Lin Yuan",
|
||||||
|
"description": "A programmer leaves the office, travels with a laptop, and packs \"home\" into a rack.",
|
||||||
|
"tagline": "Live the life you want — from any city.",
|
||||||
|
"coverGradient": "from-violet-950 via-fuchsia-900 to-orange-900",
|
||||||
|
"readingMinutes": 45,
|
||||||
|
"viralTags": ["DigitalNomad", "Homelab", "RemoteWork", "Tailscale", "Freelance"],
|
||||||
|
"stats": [
|
||||||
|
{ "emoji": "🌍", "value": "7", "label": "Chapters" },
|
||||||
|
{ "emoji": "⏱️", "value": "45", "label": "Min read" },
|
||||||
|
{ "emoji": "📍", "value": "3+", "label": "City ideas" },
|
||||||
|
{ "emoji": "🔥", "value": "12", "label": "Quotes" }
|
||||||
|
],
|
||||||
|
"hooks": [
|
||||||
|
{ "emoji": "💻", "text": "Week one of remote work: productivity soars. Week two: you can't tell when work ends and life begins." },
|
||||||
|
{ "emoji": "🌴", "text": "Tuesday morning in Chiang Mai: flat white + VS Code, someone writing a travel blog nearby." },
|
||||||
|
{ "emoji": "🗄️", "text": "Homelab is your anchor — not geographic, but digital. Files stay, services stay, waiting for you." },
|
||||||
|
{ "emoji": "🔑", "text": "Tailscale: no matter how IPs change, your devices stay on the same LAN." }
|
||||||
|
],
|
||||||
|
"chapters": [
|
||||||
|
{
|
||||||
|
"slug": "00-preface",
|
||||||
|
"title": "Preface: Why I Started Nomading",
|
||||||
|
"emoji": "✈️",
|
||||||
|
"hook": "I didn't want a better desk — I wanted to choose where I sit.",
|
||||||
|
"shareQuote": "Digital nomadism is a way of working: output isn't tied to a place."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "01-first-remote",
|
||||||
|
"title": "That First Remote Friday",
|
||||||
|
"emoji": "🏠",
|
||||||
|
"hook": "Zoom in pajamas might be the most surreal way to work today.",
|
||||||
|
"shareQuote": "Async communication: not \"always online\", but \"clear expectations\"."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "02-chiangmai",
|
||||||
|
"title": "Tuesday Morning in Chiang Mai",
|
||||||
|
"emoji": "☕",
|
||||||
|
"hook": "Nomading isn't wandering alone — it's finding your people on the move.",
|
||||||
|
"shareQuote": "Here, travel and work were never at odds."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "03-gear",
|
||||||
|
"title": "What Actually Matters in Your Bag",
|
||||||
|
"emoji": "🎒",
|
||||||
|
"hook": "Pack too much and you're burdened; too little and you'll regret it.",
|
||||||
|
"shareQuote": "Connectivity matters more than passports."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "04-homelab",
|
||||||
|
"title": "Homelab: Home in a Rack",
|
||||||
|
"emoji": "🖥️",
|
||||||
|
"hook": "An old PC can become the hub of your digital life.",
|
||||||
|
"shareQuote": "Perfect is the enemy of good. Ship first, iterate later."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "05-network",
|
||||||
|
"title": "Connect Home from Anywhere",
|
||||||
|
"emoji": "🔗",
|
||||||
|
"hook": "Body in Bali, hands at home.",
|
||||||
|
"shareQuote": "Homelab services shouldn't face the public internet — that's the baseline for safety."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "06-epilogue",
|
||||||
|
"title": "Epilogue: Freedom as a Daily Practice",
|
||||||
|
"emoji": "🌅",
|
||||||
|
"hook": "Freedom isn't not working — it's choosing where and at what pace you work.",
|
||||||
|
"shareQuote": "Small experiments beat grand plans."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
94
frontend/content/book/features.json
Normal file
94
frontend/content/book/features.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./features.schema.json",
|
||||||
|
"$comment": "功能模块总开关 — 关闭任何模块不影响其余功能",
|
||||||
|
|
||||||
|
"analytics": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "ga4",
|
||||||
|
"measurementId": ""
|
||||||
|
},
|
||||||
|
|
||||||
|
"ads": {
|
||||||
|
"enabled": false,
|
||||||
|
"provider": "adsense",
|
||||||
|
"clientId": "",
|
||||||
|
"slots": {
|
||||||
|
"cover": "",
|
||||||
|
"readerMid": "",
|
||||||
|
"readerEnd": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"payment": {
|
||||||
|
"crypto": {
|
||||||
|
"enabled": false,
|
||||||
|
"provider": "nowpayments",
|
||||||
|
"tiers": [
|
||||||
|
{ "amount": 5, "label": "Coffee" },
|
||||||
|
{ "amount": 10, "label": "Lunch" },
|
||||||
|
{ "amount": 20, "label": "Sponsor" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"checkout": {
|
||||||
|
"enabled": false,
|
||||||
|
"provider": "creem",
|
||||||
|
"buttonText": "Buy Now — $29",
|
||||||
|
"productName": "Download Edition (PDF & EPUB)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"feedback": {
|
||||||
|
"enabled": false,
|
||||||
|
"title": "这本书对你有帮助吗?",
|
||||||
|
"subtitle": "欢迎提建议、纠错或合作联系,我会认真看每一条。"
|
||||||
|
},
|
||||||
|
|
||||||
|
"affiliate": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"hosts": ["amazon.com", "amzn.to", "amazon.cn"],
|
||||||
|
"param": "tag=your-amazon-tag-20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hosts": ["taobao.com", "tmall.com"],
|
||||||
|
"rewrite": "https://s.click.taobao.com/t?eurl={encoded}&union_lens=lensId"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"share": {
|
||||||
|
"enabled": true,
|
||||||
|
"campaign": "nomad-code",
|
||||||
|
"defaultHashtags": ["数字游民", "Homelab", "远程办公"],
|
||||||
|
"viralPrompt": {
|
||||||
|
"enabled": true,
|
||||||
|
"triggerPercent": 42
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"reader": {
|
||||||
|
"bookmarks": { "enabled": true },
|
||||||
|
"highlights": { "enabled": true },
|
||||||
|
"search": { "enabled": true },
|
||||||
|
"progress": { "enabled": true },
|
||||||
|
"zen": { "enabled": true },
|
||||||
|
"shortcuts": { "enabled": true },
|
||||||
|
"themeToggle": { "enabled": true },
|
||||||
|
"fontControl": { "enabled": true }
|
||||||
|
},
|
||||||
|
|
||||||
|
"userdata": {
|
||||||
|
"enabled": true,
|
||||||
|
"syncToServer": false,
|
||||||
|
"$comment": "syncToServer=true 时书签/划线/进度同步到 PocketBase,否则仅 localStorage"
|
||||||
|
},
|
||||||
|
|
||||||
|
"backend": {
|
||||||
|
"pocketbase": { "enabled": true },
|
||||||
|
"email": {
|
||||||
|
"enabled": false,
|
||||||
|
"$comment": "需要配置 SMTP 环境变量才能发送购买邮件"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/content/book/site.json
Normal file
8
frontend/content/book/site.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$comment": "品牌身份信息 — 模块开关和配置见 features.json",
|
||||||
|
"brand": {
|
||||||
|
"name": "游牧代码",
|
||||||
|
"xHandle": "@linyuan",
|
||||||
|
"contactEmail": "hello@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
3263
frontend/package-lock.json
generated
3263
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,14 +4,26 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.3.2",
|
||||||
|
"@ant-design/nextjs-registry": "^1.3.0",
|
||||||
|
"antd": "^6.6.1",
|
||||||
|
"github-slugger": "^2.0.0",
|
||||||
|
"highlight.js": "^11.9.0",
|
||||||
"next": "16.3.3",
|
"next": "16.3.3",
|
||||||
|
"next-themes": "^0.4.4",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"react-markdown": "^9.0.3",
|
||||||
|
"rehype-highlight": "^7.0.1",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"tailwindcss": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
13
frontend/public/icons/icon.svg
Normal file
13
frontend/public/icons/icon.svg
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="nomadro">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#FF6B6B"/>
|
||||||
|
<stop offset="50%" stop-color="#FFE66D"/>
|
||||||
|
<stop offset="100%" stop-color="#4ECDC4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="96" fill="#0a0e17"/>
|
||||||
|
<circle cx="256" cy="256" r="168" fill="none" stroke="url(#g)" stroke-width="28"/>
|
||||||
|
<path d="M160 280c48-96 144-96 192 0" fill="none" stroke="url(#g)" stroke-width="24" stroke-linecap="round"/>
|
||||||
|
<circle cx="352" cy="176" r="28" fill="url(#g)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 650 B |
56
frontend/public/sw.js
Normal file
56
frontend/public/sw.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/* Simple offline-capable shell for nomadro PWA */
|
||||||
|
const CACHE = "nomadro-shell-v1";
|
||||||
|
const PRECACHE = ["/", "/plan", "/compare", "/tools", "/manifest.webmanifest", "/icons/icon.svg"];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== "GET") return;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// API: network only
|
||||||
|
if (url.pathname.startsWith("/api/")) return;
|
||||||
|
|
||||||
|
// Navigations: network first, fall back to cache / home
|
||||||
|
if (req.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(req)
|
||||||
|
.then((res) => {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE).then((c) => c.put(req, copy));
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(req).then((r) => r || caches.match("/")))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets: cache first
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(req).then(
|
||||||
|
(cached) =>
|
||||||
|
cached ||
|
||||||
|
fetch(req).then((res) => {
|
||||||
|
if (res.ok && (url.pathname.startsWith("/_next/") || url.pathname.startsWith("/icons/"))) {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE).then((c) => c.put(req, copy));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
3187
frontend/src/app/book/ebook-globals.css
Normal file
3187
frontend/src/app/book/ebook-globals.css
Normal file
File diff suppressed because it is too large
Load Diff
64
frontend/src/app/book/layout.tsx
Normal file
64
frontend/src/app/book/layout.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { AntdRegistry } from "@ant-design/nextjs-registry";
|
||||||
|
import { Providers } from "@/components/ebook/Providers";
|
||||||
|
import { buildBookMetadata } from "@/lib/ebook/metadata";
|
||||||
|
import { getSiteConfig } from "@/lib/ebook/site-config.server";
|
||||||
|
import { getServerFeatures } from "@/lib/ebook/features.server";
|
||||||
|
import { getLocale } from "@/lib/ebook/i18n/server";
|
||||||
|
import "./ebook-globals.css";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const locale = await getLocale();
|
||||||
|
return buildBookMetadata(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Book section is a self-contained surface inside nomadweb:
|
||||||
|
* - Own visual system (ebook-globals), isolated under .ebook-section
|
||||||
|
* - Locale bridged with main-site cookie/localStorage via getLocale + LocaleProvider
|
||||||
|
* - Theme driven by ThemeProvider on <html> (not a forced .dark wrapper)
|
||||||
|
* - No main Navbar / SiteShell — immersive like book.nomadro.com
|
||||||
|
* - Entry from main nav `/book`; escape via cover "← nomadro"
|
||||||
|
*/
|
||||||
|
export default async function BookLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const site = getSiteConfig();
|
||||||
|
const f = getServerFeatures();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ebook-section" data-ebook-root>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `window.__EBOOK_FEATURES__=${JSON.stringify(f).replace(/</g, "\\u003c")};window.__LOCALE__=${JSON.stringify(locale)};(function(){try{var t=localStorage.getItem("theme");if(t==="light"||t==="sepia"||t==="dark"){document.documentElement.classList.remove("light","dark","sepia");document.documentElement.classList.add(t);}else{document.documentElement.classList.add("dark");}}catch(e){document.documentElement.classList.add("dark");}document.documentElement.style.scrollPaddingTop="0";})();`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AntdRegistry>
|
||||||
|
<Providers locale={locale}>
|
||||||
|
{children}
|
||||||
|
{f.feedback.enabled && (
|
||||||
|
<FeedbackLazy
|
||||||
|
title={f.feedback.title}
|
||||||
|
subtitle={f.feedback.subtitle}
|
||||||
|
contactEmail={site.brand.contactEmail}
|
||||||
|
xHandle={site.brand.xHandle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Providers>
|
||||||
|
</AntdRegistry>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedbackLazy(props: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
contactEmail: string;
|
||||||
|
xHandle: string;
|
||||||
|
}) {
|
||||||
|
const { FeedbackPanel } = require("@/components/ebook/FeedbackPanel");
|
||||||
|
return <FeedbackPanel {...props} />;
|
||||||
|
}
|
||||||
7
frontend/src/app/book/loading.tsx
Normal file
7
frontend/src/app/book/loading.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default function BookLoading() {
|
||||||
|
return (
|
||||||
|
<div className="ebook-section" data-ebook-root style={{ minHeight: "100vh", display: "grid", placeItems: "center" }}>
|
||||||
|
<p style={{ color: "#b4a9c8", fontSize: "0.9rem", letterSpacing: "0.08em" }}>…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
frontend/src/app/book/opengraph-image.tsx
Normal file
98
frontend/src/app/book/opengraph-image.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { getBook } from "@/lib/ebook/book.server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const alt = "Ebook cover";
|
||||||
|
export const size = { width: 1200, height: 630 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
async function loadFont() {
|
||||||
|
const urls = [
|
||||||
|
"https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-sc@5.0.0/chinese-simplified-700-normal.woff",
|
||||||
|
"https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuFuYAZ9hiJ-Ek-_EeA.woff2",
|
||||||
|
];
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { cache: "force-cache" });
|
||||||
|
if (res.ok) return { data: await res.arrayBuffer(), name: url.includes("noto") ? "Noto Sans SC" : "Inter" };
|
||||||
|
} catch {
|
||||||
|
/* try next */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inter = await fetch(
|
||||||
|
"https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuFuYAZ9hiJ-Ek-_EeA.woff2"
|
||||||
|
);
|
||||||
|
return { data: await inter.arrayBuffer(), name: "Inter" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OgImage() {
|
||||||
|
const book = getBook();
|
||||||
|
const font = await loadFont();
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "56px 64px",
|
||||||
|
background: "linear-gradient(135deg, #1a0b2e 0%, #5b21b6 42%, #db2777 78%, #ea580c 100%)",
|
||||||
|
color: "#fff",
|
||||||
|
fontFamily: font ? font.name : "sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||||
|
{book.viralTags.slice(0, 4).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
padding: "8px 18px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "rgba(255,255,255,0.14)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.22)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<div style={{ fontSize: 28, opacity: 0.75, letterSpacing: 4 }}>FREE EBOOK</div>
|
||||||
|
<div style={{ fontSize: 72, fontWeight: 700, lineHeight: 1.1, maxWidth: 980 }}>
|
||||||
|
{book.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 34, opacity: 0.88, maxWidth: 900 }}>{book.subtitle}</div>
|
||||||
|
<div style={{ fontSize: 26, opacity: 0.72, maxWidth: 860 }}>{book.tagline}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end" }}>
|
||||||
|
<div style={{ fontSize: 24, opacity: 0.7 }}>
|
||||||
|
{book.chapters.length} 章 · 约 {book.readingMinutes} 分钟 · by {book.author}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
padding: "14px 28px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "#fff",
|
||||||
|
color: "#4c1d95",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
点击免费阅读 →
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...size,
|
||||||
|
fonts: [{ name: font.name, data: font.data, style: "normal" as const, weight: 700 }],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/app/book/page.tsx
Normal file
31
frontend/src/app/book/page.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getAllChapters, getBook } from "@/lib/ebook/book.server";
|
||||||
|
import { CoverPage } from "@/components/ebook/CoverPage";
|
||||||
|
import { buildBookMetadata } from "@/lib/ebook/metadata";
|
||||||
|
import { getSiteConfig, getAdSlot } from "@/lib/ebook/site-config.server";
|
||||||
|
import { getServerFeatures } from "@/lib/ebook/features.server";
|
||||||
|
import { getLocale } from "@/lib/ebook/i18n/server";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const locale = await getLocale();
|
||||||
|
return buildBookMetadata(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BookCoverPage() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const book = getBook(locale);
|
||||||
|
const site = getSiteConfig();
|
||||||
|
const f = getServerFeatures();
|
||||||
|
const chapterMinutes = Object.fromEntries(
|
||||||
|
getAllChapters(locale).map((chapter) => [chapter.slug, chapter.readingMinutes])
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CoverPage
|
||||||
|
book={book}
|
||||||
|
site={site}
|
||||||
|
chapterMinutes={chapterMinutes}
|
||||||
|
coverAdSlot={f.ads.enabled ? getAdSlot("coverSlot") : ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/app/book/read/[slug]/page.tsx
Normal file
31
frontend/src/app/book/read/[slug]/page.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getChapter, getChapterSlugs } from "@/lib/ebook/book.server";
|
||||||
|
import { buildChapterMetadata } from "@/lib/ebook/metadata";
|
||||||
|
import { getLocale } from "@/lib/ebook/i18n/server";
|
||||||
|
import { ebookPath } from "@/lib/ebook/paths";
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return getChapterSlugs().map((slug) => ({ slug }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const locale = await getLocale();
|
||||||
|
return buildChapterMetadata(slug, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ReadSlugRedirect({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const locale = await getLocale();
|
||||||
|
const chapter = getChapter(slug, locale);
|
||||||
|
if (!chapter) notFound();
|
||||||
|
redirect(`${ebookPath("/read")}#${slug}`);
|
||||||
|
}
|
||||||
31
frontend/src/app/book/read/page.tsx
Normal file
31
frontend/src/app/book/read/page.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { getBook, getAllChapters } from "@/lib/ebook/book.server";
|
||||||
|
import { ScrollReaderShell } from "@/components/ebook/ScrollReaderShell";
|
||||||
|
import { buildBookMetadata } from "@/lib/ebook/metadata";
|
||||||
|
import { getAdSlot } from "@/lib/ebook/site-config.server";
|
||||||
|
import { getServerFeatures } from "@/lib/ebook/features.server";
|
||||||
|
import { getLocale } from "@/lib/ebook/i18n/server";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const locale = await getLocale();
|
||||||
|
return buildBookMetadata(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BookReadPage() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const book = getBook(locale);
|
||||||
|
const chapters = getAllChapters(locale);
|
||||||
|
const f = getServerFeatures();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollReaderShell
|
||||||
|
book={book}
|
||||||
|
chapters={chapters}
|
||||||
|
affiliateRules={f.affiliate.enabled ? f.affiliate.rules : []}
|
||||||
|
adSlots={{
|
||||||
|
mid: f.ads.enabled ? getAdSlot("readerMidSlot") : "",
|
||||||
|
end: f.ads.enabled ? getAdSlot("readerEndSlot") : "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,6 +8,59 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LOGS = [
|
const LOGS = [
|
||||||
|
{
|
||||||
|
date: "2026-08-29",
|
||||||
|
tag: "电子书融合",
|
||||||
|
items: [
|
||||||
|
"电子书封面改用独立 cover-* 布局 CSS,避免主站全局样式打乱双栏排版",
|
||||||
|
"语言与主站 nomadro-locale 互通;封面保留「← nomadro」返回入口",
|
||||||
|
"恢复「继续阅读」主 CTA + 进度卡并排,对齐 book.nomadro.com 构图",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2026-08-29",
|
||||||
|
tag: "电子书融入",
|
||||||
|
items: [
|
||||||
|
"导航新增「电子书」,接入《游牧代码》完整阅读体验(/book)",
|
||||||
|
"支持封面、连续阅读、章节锚点、书签划线与中英切换",
|
||||||
|
"内容来自 ebook 项目,与主站语言偏好同步",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2026-08-29",
|
||||||
|
tag: "首页聚焦发现",
|
||||||
|
items: [
|
||||||
|
"首页移除计划与对比模块,专注探索:地图 → 目的地 → 签证 → 内容",
|
||||||
|
"路径条改为探索 → 智能匹配 → 签证 → 工具箱;计划/对比保留在导航栏独立页面",
|
||||||
|
"登录页重写:跳转目标显示中文名称而非 / 路径,默认进入个人中心",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2026-08-29",
|
||||||
|
tag: "首页收敛",
|
||||||
|
items: [
|
||||||
|
"首页从超长工具墙收成短路径,微工具全部下沉到 /tools",
|
||||||
|
"新增 Journey 路径条,Hero 主 CTA 收敛为探索 + 智能匹配",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2026-08-29",
|
||||||
|
tag: "漏斗打磨",
|
||||||
|
items: [
|
||||||
|
"路径条提前到 Hero 下方,先看清步骤再探索",
|
||||||
|
"目的地默认展示 8 城可展开,卡片支持收藏与详情",
|
||||||
|
"签证/博客/FAQ 折叠展示;地图/目的地/签证等壳层文案中英双语",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2026-08-29",
|
||||||
|
tag: "体验升级",
|
||||||
|
items: [
|
||||||
|
"PC / H5 响应式增强:语言切换、计划与对比台、底部安全区与触控目标",
|
||||||
|
"PWA:可安装到主屏幕,离线可打开计划 / 对比入口(manifest + Service Worker)",
|
||||||
|
"中英双语:默认中文,导航一键切换英语;计划 / 对比 / 登录 / 工具箱壳层文案同步",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
date: "2026-08-29",
|
date: "2026-08-29",
|
||||||
tag: "产品打磨",
|
tag: "产品打磨",
|
||||||
|
|||||||
16
frontend/src/app/chat/[id]/page.tsx
Normal file
16
frontend/src/app/chat/[id]/page.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import ChatThreadClient from "@/components/ChatThreadClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "对话 · nomadro",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ChatThreadPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<ChatThreadClient convId={id} />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/app/chat/page.tsx
Normal file
16
frontend/src/app/chat/page.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import ChatClient from "@/components/ChatClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "私信 · nomadro",
|
||||||
|
description: "与匹配成功的游民私信聊天。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<ChatClient />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/app/community/[id]/page.tsx
Normal file
37
frontend/src/app/community/[id]/page.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import DiscussionDetailClient from "@/components/DiscussionDetailClient";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
try {
|
||||||
|
const d = await api.getDiscussion(id);
|
||||||
|
return { title: `${d.title} · nomadro 社区` };
|
||||||
|
} catch {
|
||||||
|
return { title: "讨论 · nomadro" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DiscussionPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const discussion = await api.getDiscussion(id).catch(() => null);
|
||||||
|
if (!discussion) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<DiscussionDetailClient discussionId={id} initial={discussion} />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/app/community/new/page.tsx
Normal file
10
frontend/src/app/community/new/page.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import CommunityNewClient from "@/components/CommunityNewClient";
|
||||||
|
|
||||||
|
export default function CommunityNewPage() {
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<CommunityNewClient />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
frontend/src/app/community/page.tsx
Normal file
24
frontend/src/app/community/page.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import CommunityHubClient from "@/components/CommunityHubClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "社区讨论 · nomadro",
|
||||||
|
description: "签证、远程工作、住宿与安全 —— 和游民们交流真实经验。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export default async function CommunityPage() {
|
||||||
|
const discussions = await api.getDiscussions().catch(() => []);
|
||||||
|
const featured = discussions[0]
|
||||||
|
? await api.getDiscussion(discussions[0].id).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<CommunityHubClient discussions={discussions} featured={featured} />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/app/dating/likes/page.tsx
Normal file
16
frontend/src/app/dating/likes/page.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import DatingLikesClient from "@/components/DatingLikesClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "我喜欢的 · nomadro",
|
||||||
|
description: "查看你右滑喜欢的游民卡片。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DatingLikesPage() {
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<DatingLikesClient />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/app/dating/page.tsx
Normal file
16
frontend/src/app/dating/page.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import DatingClient from "@/components/DatingClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "游民匹配 · nomadro",
|
||||||
|
description: "按意图滑动匹配同路游民,交朋友、找室友、联创伙伴。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DatingPage() {
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<DatingClient />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/app/digital/course/[m]/[l]/page.tsx
Normal file
30
frontend/src/app/digital/course/[m]/[l]/page.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import DigitalLessonClient from "@/components/DigitalLessonClient";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "课程 · nomadro 学院",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DigitalLessonPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ m: string; l: string }>;
|
||||||
|
}) {
|
||||||
|
const { m, l } = await params;
|
||||||
|
const mi = Number(m);
|
||||||
|
const li = Number(l);
|
||||||
|
let lesson = null;
|
||||||
|
let locked = false;
|
||||||
|
try {
|
||||||
|
lesson = await api.getDigitalLesson(mi, li);
|
||||||
|
} catch {
|
||||||
|
locked = true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<DigitalLessonClient lesson={lesson} locked={locked} moduleIndex={mi} lessonIndex={li} />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/app/digital/course/page.tsx
Normal file
19
frontend/src/app/digital/course/page.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import DigitalCourseClient from "@/components/DigitalCourseClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "课程目录 · nomadro 学院",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export default async function DigitalCoursePage() {
|
||||||
|
const course = await api.getDigitalCourse();
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<DigitalCourseClient course={course} />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/app/digital/jobs/page.tsx
Normal file
19
frontend/src/app/digital/jobs/page.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import DigitalJobsClient from "@/components/DigitalJobsClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "远程岗位 · nomadro",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export default async function DigitalJobsPage() {
|
||||||
|
const jobs = await api.getDigitalJobs();
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<DigitalJobsClient jobs={jobs} />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/src/app/digital/page.tsx
Normal file
20
frontend/src/app/digital/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import DigitalHomeClient from "@/components/DigitalHomeClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "数字游民学院 · nomadro",
|
||||||
|
description: "课程、电子书与远程岗位 —— nomadro 数字游民成长站。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export default async function DigitalPage() {
|
||||||
|
const course = await api.getDigitalCourse();
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<DigitalHomeClient course={course} />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/src/app/gigs/page.tsx
Normal file
14
frontend/src/app/gigs/page.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { api } from "@/lib/api";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import GigsClient from "@/components/GigsClient";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export default async function GigsPage() {
|
||||||
|
const gigs = await api.getGigs().catch(() => []);
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<GigsClient gigs={gigs} />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
16
frontend/src/app/join/page.tsx
Normal file
16
frontend/src/app/join/page.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import JoinClient from "@/components/JoinClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "加入会员 · nomadro",
|
||||||
|
description: "完善游民资料,开通 VIP 解锁匹配、直播与课程。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function JoinPage() {
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<JoinClient />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
frontend/src/app/join/paid/page.tsx
Normal file
18
frontend/src/app/join/paid/page.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import JoinPaidClient from "@/components/JoinPaidClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "支付完成 · nomadro",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function JoinPaidPage() {
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<Suspense fallback={<div className="join-page"><div className="container"><p className="plan-loading">处理中…</p></div></div>}>
|
||||||
|
<JoinPaidClient />
|
||||||
|
</Suspense>
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,13 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Outfit } from "next/font/google";
|
import { Outfit } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AuthProvider } from "@/lib/auth";
|
import { AuthProvider } from "@/lib/auth";
|
||||||
import { ToastProvider } from "@/lib/toast";
|
import { ToastProvider } from "@/lib/toast";
|
||||||
|
import { I18nProvider } from "@/lib/i18n";
|
||||||
|
import PwaRegister from "@/components/PwaRegister";
|
||||||
|
|
||||||
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
||||||
|
|
||||||
/** Display font self-hosted; CJK uses system stack to avoid multi‑MB webfont. */
|
|
||||||
const outfit = Outfit({
|
const outfit = Outfit({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500", "600", "700"],
|
weight: ["400", "500", "600", "700"],
|
||||||
@ -19,11 +20,19 @@ export const metadata: Metadata = {
|
|||||||
title: "nomadro · 数字游民旅居指南",
|
title: "nomadro · 数字游民旅居指南",
|
||||||
description: "探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。",
|
description: "探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。",
|
||||||
keywords: ["数字游民", "旅居", "远程工作", "nomad", "digital nomad"],
|
keywords: ["数字游民", "旅居", "远程工作", "nomad", "digital nomad"],
|
||||||
|
applicationName: "nomadro",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "black-translucent",
|
||||||
|
title: "nomadro",
|
||||||
|
},
|
||||||
|
formatDetection: { telephone: false },
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "nomadro · 数字游民旅居指南",
|
title: "nomadro · 数字游民旅居指南",
|
||||||
description: "用一行代码环游世界 🌏",
|
description: "用一行代码环游世界 🌏",
|
||||||
type: "website",
|
type: "website",
|
||||||
locale: "zh_CN",
|
locale: "zh_CN",
|
||||||
|
alternateLocale: ["en_US"],
|
||||||
url: SITE,
|
url: SITE,
|
||||||
siteName: "nomadro",
|
siteName: "nomadro",
|
||||||
},
|
},
|
||||||
@ -33,15 +42,39 @@ export const metadata: Metadata = {
|
|||||||
description: "用一行代码环游世界 🌏",
|
description: "用一行代码环游世界 🌏",
|
||||||
},
|
},
|
||||||
manifest: "/manifest.webmanifest",
|
manifest: "/manifest.webmanifest",
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: "/icons/icon.svg", type: "image/svg+xml" },
|
||||||
|
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||||
|
{ url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||||
|
],
|
||||||
|
apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: [
|
||||||
|
{ media: "(prefers-color-scheme: dark)", color: "#0a0e17" },
|
||||||
|
{ media: "(prefers-color-scheme: light)", color: "#4ECDC4" },
|
||||||
|
],
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 5,
|
||||||
|
viewportFit: "cover",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN" className={outfit.variable}>
|
<html lang="zh-CN" className={outfit.variable} suppressHydrationWarning>
|
||||||
<body>
|
<body>
|
||||||
<AuthProvider>
|
<I18nProvider>
|
||||||
<ToastProvider>{children}</ToastProvider>
|
<AuthProvider>
|
||||||
</AuthProvider>
|
<ToastProvider>
|
||||||
|
{children}
|
||||||
|
<PwaRegister />
|
||||||
|
</ToastProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</I18nProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,24 +1,22 @@
|
|||||||
"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, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
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 { resolveAuthNext } from "@/lib/authNext";
|
||||||
import SiteShell from "@/components/SiteShell";
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
|
||||||
function safeNext(raw: string | null): string {
|
|
||||||
if (!raw || !raw.startsWith("/") || raw.startsWith("//")) return "/plan";
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoginForm() {
|
function LoginForm() {
|
||||||
const { login, register, demoLogin } = useAuth();
|
const { login, register, demoLogin } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const next = safeNext(searchParams.get("next"));
|
const meta = resolveAuthNext(searchParams.get("next"), t.login);
|
||||||
|
|
||||||
const [mode, setMode] = useState<"login" | "register">("login");
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@ -30,7 +28,7 @@ function LoginForm() {
|
|||||||
|
|
||||||
const goAfterAuth = (msg: string) => {
|
const goAfterAuth = (msg: string) => {
|
||||||
toast(msg);
|
toast(msg);
|
||||||
setTimeout(() => { router.replace(next); }, 400);
|
setTimeout(() => { router.replace(meta.path); }, 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@ -43,12 +41,10 @@ function LoginForm() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
goAfterAuth(
|
goAfterAuth(
|
||||||
mode === "login"
|
mode === "login" ? meta.welcomeToast : t.login.registerToast
|
||||||
? "欢迎回来!计划已开始同步 🗓️"
|
|
||||||
: "注册成功,旅居计划可跨设备同步 ✨"
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setError(mode === "login" ? "邮箱或密码错误" : "注册失败,邮箱可能已存在");
|
setError(mode === "login" ? t.login.loginError : t.login.registerError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -56,9 +52,11 @@ function LoginForm() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
const ok = await demoLogin();
|
const ok = await demoLogin();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (ok) goAfterAuth("演示账号已登录 · 正在同步旅居计划 🎮");
|
if (ok) goAfterAuth(t.login.demoToast);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headerHint = mode === "login" ? meta.hint : t.login.registerHint;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<div className="auth-bg-shapes" aria-hidden="true">
|
<div className="auth-bg-shapes" aria-hidden="true">
|
||||||
@ -67,48 +65,52 @@ function LoginForm() {
|
|||||||
<span className="auth-shape auth-shape-3">🏝️</span>
|
<span className="auth-shape auth-shape-3">🏝️</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<Link href="/" className="auth-back">← 返回首页</Link>
|
<Link href="/" className="auth-back">{t.common.backHome}</Link>
|
||||||
<div className="auth-header">
|
<div className="auth-header">
|
||||||
<span className="auth-logo-emoji">🌍</span>
|
<span className="auth-logo-emoji">🌍</span>
|
||||||
<h1>{mode === "login" ? "欢迎回来" : "加入 nomadro"}</h1>
|
<h1>{mode === "login" ? t.login.welcome : t.login.join}</h1>
|
||||||
<p>
|
<p>{headerHint}</p>
|
||||||
{mode === "login"
|
|
||||||
? "登录后同步旅居计划、收藏与就绪清单"
|
|
||||||
: "注册即可跨设备保存你的旅居计划"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="auth-sync-note">
|
{meta.showRedirect && (
|
||||||
🗓️ 登录后自动把本机计划与账号合并 · 完成后将前往
|
<div className="auth-redirect-card">
|
||||||
<Link href={next}>{next === "/plan" ? "旅居计划中心" : next}</Link>
|
{meta.showPlanMerge && (
|
||||||
</div>
|
<p className="auth-redirect-merge">{t.login.planMergeNote}</p>
|
||||||
|
)}
|
||||||
|
<p className="auth-redirect-dest">
|
||||||
|
<span>{t.login.redirectTo}</span>
|
||||||
|
<strong className="auth-redirect-badge">{meta.label}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="auth-tabs">
|
<div className="auth-tabs">
|
||||||
<button className={mode === "login" ? "active" : ""} onClick={() => { setMode("login"); setError(""); }}>登录</button>
|
<button className={mode === "login" ? "active" : ""} onClick={() => { setMode("login"); setError(""); }}>{t.login.tabLogin}</button>
|
||||||
<button className={mode === "register" ? "active" : ""} onClick={() => { setMode("register"); setError(""); }}>注册</button>
|
<button className={mode === "register" ? "active" : ""} onClick={() => { setMode("register"); setError(""); }}>{t.login.tabRegister}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="auth-form">
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
{mode === "register" && (
|
{mode === "register" && (
|
||||||
<div className="calc-field">
|
<div className="calc-field">
|
||||||
<label>👤 昵称</label>
|
<label>{t.login.nickname}</label>
|
||||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="你的游民昵称" required />
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t.login.nickPlaceholder} required />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="calc-field">
|
<div className="calc-field">
|
||||||
<label>📧 邮箱</label>
|
<label>{t.login.email}</label>
|
||||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" required />
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" required autoComplete="email" />
|
||||||
</div>
|
</div>
|
||||||
<div className="calc-field">
|
<div className="calc-field">
|
||||||
<label>🔒 密码</label>
|
<label>{t.login.password}</label>
|
||||||
<div className="auth-pwd-wrap">
|
<div className="auth-pwd-wrap">
|
||||||
<input
|
<input
|
||||||
type={showPwd ? "text" : "password"}
|
type={showPwd ? "text" : "password"}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="至少 6 位"
|
placeholder={t.login.pwdPlaceholder}
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
|
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="auth-pwd-toggle" onClick={() => setShowPwd(!showPwd)} aria-label="显示密码">
|
<button type="button" className="auth-pwd-toggle" onClick={() => setShowPwd(!showPwd)} aria-label="显示密码">
|
||||||
{showPwd ? "🙈" : "👁️"}
|
{showPwd ? "🙈" : "👁️"}
|
||||||
@ -117,15 +119,15 @@ function LoginForm() {
|
|||||||
</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}>
|
||||||
{loading ? <span className="auth-loading">处理中...</span> : (mode === "login" ? "🚀 登录并同步" : "✨ 注册并开始")}
|
{loading ? <span className="auth-loading">{t.login.processing}</span> : (mode === "login" ? meta.submitLogin : t.login.submitRegister)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="auth-divider"><span>或</span></div>
|
<div className="auth-divider"><span>{t.login.or}</span></div>
|
||||||
<button className="btn btn-ghost" style={{ width: "100%" }} onClick={handleDemo} disabled={loading}>
|
<button className="btn btn-ghost auth-demo-btn" type="button" onClick={handleDemo} disabled={loading}>
|
||||||
🎮 一键体验演示账号
|
{t.login.demo}
|
||||||
</button>
|
</button>
|
||||||
<p className="auth-hint">演示账号:demo@nomadro.com / demo123 · 登录后进入计划中心</p>
|
<p className="auth-hint">{meta.demoHint}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,18 +1,27 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
|
||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: "nomadro · 数字游民旅居指南",
|
name: "nomadro",
|
||||||
short_name: "nomadro",
|
short_name: "nomadro",
|
||||||
description: "探索全球旅居生活,发现最适合数字游民的目的地",
|
description: "Digital nomad move planning — destinations, compare, and trip plans",
|
||||||
start_url: "/",
|
start_url: "/",
|
||||||
|
scope: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
|
orientation: "any",
|
||||||
background_color: "#0a0e17",
|
background_color: "#0a0e17",
|
||||||
theme_color: "#4ECDC4",
|
theme_color: "#4ECDC4",
|
||||||
|
lang: "zh-CN",
|
||||||
|
categories: ["travel", "lifestyle", "productivity"],
|
||||||
icons: [
|
icons: [
|
||||||
{ src: "/globe.svg", sizes: "any", type: "image/svg+xml" },
|
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icons/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
|
||||||
|
],
|
||||||
|
shortcuts: [
|
||||||
|
{ name: "Move plan / 旅居计划", short_name: "Plan", url: "/plan", icons: [{ src: "/icons/icon-192.png", sizes: "192x192" }] },
|
||||||
|
{ name: "Compare / 对比", short_name: "Compare", url: "/compare", icons: [{ src: "/icons/icon-192.png", sizes: "192x192" }] },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
20
frontend/src/app/meetups/[id]/live/page.tsx
Normal file
20
frontend/src/app/meetups/[id]/live/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import MeetupLiveClient from "@/components/MeetupLiveClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "活动直播 · nomadro",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function MeetupLivePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const meetup = await api.getMeetup(id).catch(() => null);
|
||||||
|
const title = meetup?.title || "游民活动直播";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter={false}>
|
||||||
|
<MeetupLiveClient meetupId={id} title={title} />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/app/meetups/host/page.tsx
Normal file
10
frontend/src/app/meetups/host/page.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import MeetupsHostClient from "@/components/MeetupsHostClient";
|
||||||
|
|
||||||
|
export default function MeetupsHostPage() {
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<MeetupsHostClient />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/src/app/meetups/page.tsx
Normal file
21
frontend/src/app/meetups/page.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import MeetupsClient from "@/components/MeetupsClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "游民活动 · nomadro",
|
||||||
|
description: "线上圆桌、线下聚会与混合活动 —— 和全球数字游民一起连接。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export default async function MeetupsPage() {
|
||||||
|
const meetups = await api.getMeetups().catch(() => []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<MeetupsClient meetups={meetups} />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/app/members/[id]/page.tsx
Normal file
15
frontend/src/app/members/[id]/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import MemberProfileClient from "@/components/MemberProfileClient";
|
||||||
|
|
||||||
|
export default async function MemberPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const profile = await api.getMemberProfile(id).catch(() => null);
|
||||||
|
if (!profile) notFound();
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<MemberProfileClient profile={profile} />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
frontend/src/app/next-stop/page.tsx
Normal file
29
frontend/src/app/next-stop/page.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import NextStopClient from "@/components/NextStopClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "下一站决策 · nomadro",
|
||||||
|
description: "根据预算、网络、气候和偏好,智能推荐下一座旅居城市与路线。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export default async function NextStopPage() {
|
||||||
|
const [destinations, meetups, routes] = await Promise.all([
|
||||||
|
api.getDestinations().catch(() => []),
|
||||||
|
api.getMeetups().catch(() => []),
|
||||||
|
api.getRoutes().catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<NextStopClient
|
||||||
|
destinations={destinations}
|
||||||
|
initialMeetups={meetups}
|
||||||
|
initialRoutes={routes}
|
||||||
|
/>
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/app/notifications/page.tsx
Normal file
10
frontend/src/app/notifications/page.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import NotificationsClient from "@/components/NotificationsClient";
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<NotificationsClient />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/app/pricing/page.tsx
Normal file
10
frontend/src/app/pricing/page.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import PricingClient from "@/components/PricingClient";
|
||||||
|
|
||||||
|
export default function PricingPage() {
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<PricingClient />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,6 +12,22 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
{ url: `${SITE}/plan`, lastModified: now, changeFrequency: "weekly", priority: 0.95 },
|
{ url: `${SITE}/plan`, lastModified: now, changeFrequency: "weekly", priority: 0.95 },
|
||||||
{ url: `${SITE}/compare`, lastModified: now, changeFrequency: "weekly", priority: 0.9 },
|
{ url: `${SITE}/compare`, lastModified: now, changeFrequency: "weekly", priority: 0.9 },
|
||||||
{ url: `${SITE}/tools`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
|
{ url: `${SITE}/tools`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
|
||||||
|
{ url: `${SITE}/next-stop`, lastModified: now, changeFrequency: "weekly", priority: 0.9 },
|
||||||
|
{ url: `${SITE}/meetups`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
|
||||||
|
{ url: `${SITE}/community`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
|
||||||
|
{ url: `${SITE}/dating`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
|
||||||
|
{ url: `${SITE}/chat`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
|
||||||
|
{ url: `${SITE}/gigs`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
|
||||||
|
{ url: `${SITE}/pricing`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
|
||||||
|
{ url: `${SITE}/notifications`, lastModified: now, changeFrequency: "weekly", priority: 0.6 },
|
||||||
|
{ url: `${SITE}/community/new`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
|
||||||
|
{ url: `${SITE}/meetups/host`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
|
||||||
|
{ url: `${SITE}/dating/likes`, lastModified: now, changeFrequency: "weekly", priority: 0.7 },
|
||||||
|
{ url: `${SITE}/digital`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
|
||||||
|
{ url: `${SITE}/digital/course`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
|
||||||
|
{ url: `${SITE}/digital/jobs`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
|
||||||
|
{ url: `${SITE}/book`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
|
||||||
|
{ url: `${SITE}/book/read`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
|
||||||
{ 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 },
|
||||||
|
|||||||
44
frontend/src/app/tools/[id]/page.tsx
Normal file
44
frontend/src/app/tools/[id]/page.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import ToolRunnerClient from "@/components/ToolRunnerClient";
|
||||||
|
import { TOOL_LINKS } from "@/lib/tools";
|
||||||
|
import { isRunnableTool } from "@/lib/toolsRegistry";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return TOOL_LINKS.filter((t) => isRunnableTool(t.id)).map((t) => ({ id: t.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const tool = TOOL_LINKS.find((t) => t.id === id);
|
||||||
|
if (!tool) return { title: "工具 · nomadro" };
|
||||||
|
return {
|
||||||
|
title: `${tool.title} · nomadro 工具箱`,
|
||||||
|
description: tool.desc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ToolPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
if (!isRunnableTool(id)) notFound();
|
||||||
|
|
||||||
|
const destinations = await api.getDestinations().catch(() => []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<ToolRunnerClient toolId={id} destinations={destinations} />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,11 +3,13 @@
|
|||||||
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 { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import SiteShell from "@/components/SiteShell";
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
|
||||||
const FEATURED = ["weekend", "day-plan", "bill-split", "mood", "scam-alerts", "deposit-return", "airport-transfer", "invoice-fx", "hydration", "sleep", "coliving-qa", "rainy-day"];
|
const FEATURED = ["weekend", "day-plan", "bill-split", "mood", "scam-alerts", "deposit-return", "airport-transfer", "invoice-fx", "hydration", "sleep", "coliving-qa", "rainy-day"];
|
||||||
|
|
||||||
export default function ToolsHubPage() {
|
export default function ToolsHubPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
|
|
||||||
@ -30,34 +32,89 @@ export default function ToolsHubPage() {
|
|||||||
<div className="tools-hub-page">
|
<div className="tools-hub-page">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<nav className="detail-nav">
|
<nav className="detail-nav">
|
||||||
<Link href="/">← 返回首页</Link>
|
<Link href="/">{t.common.backHome}</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<span className="section-tag">🛠️ TOOLKIT</span>
|
<span className="section-tag">{t.tools.tag}</span>
|
||||||
<h1>nomadro 工具箱</h1>
|
<h1>{t.tools.title}</h1>
|
||||||
<p>规划、金钱、工作、生活与安全——一站直达所有旅居工具</p>
|
<p>{t.tools.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tools-core-banner">
|
<div className="tools-core-banner">
|
||||||
|
<Link href="/next-stop" className="tools-core-card">
|
||||||
|
<span>🧭</span>
|
||||||
|
<div>
|
||||||
|
<strong>{t.tools.hubNextStop}</strong>
|
||||||
|
<p>{t.tools.hubNextStopDesc}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link href="/meetups" className="tools-core-card">
|
||||||
|
<span>🎉</span>
|
||||||
|
<div>
|
||||||
|
<strong>{t.tools.hubMeetups}</strong>
|
||||||
|
<p>{t.tools.hubMeetupsDesc}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link href="/community" className="tools-core-card">
|
||||||
|
<span>💬</span>
|
||||||
|
<div>
|
||||||
|
<strong>{t.tools.hubCommunity}</strong>
|
||||||
|
<p>{t.tools.hubCommunityDesc}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tools-core-banner tools-core-banner-secondary">
|
||||||
|
<Link href="/dating" className="tools-core-card">
|
||||||
|
<span>💕</span>
|
||||||
|
<div>
|
||||||
|
<strong>{t.tools.hubDating}</strong>
|
||||||
|
<p>{t.tools.hubDatingDesc}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link href="/chat" className="tools-core-card">
|
||||||
|
<span>✉️</span>
|
||||||
|
<div>
|
||||||
|
<strong>{t.tools.hubChat}</strong>
|
||||||
|
<p>{t.tools.hubChatDesc}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link href="/digital" className="tools-core-card">
|
||||||
|
<span>🎓</span>
|
||||||
|
<div>
|
||||||
|
<strong>{t.tools.hubDigital}</strong>
|
||||||
|
<p>{t.tools.hubDigitalDesc}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link href="/join" className="tools-core-card">
|
||||||
|
<span>✨</span>
|
||||||
|
<div>
|
||||||
|
<strong>{t.tools.hubJoin}</strong>
|
||||||
|
<p>{t.tools.hubJoinDesc}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tools-core-banner tools-core-banner-secondary">
|
||||||
<Link href="/plan" className="tools-core-card">
|
<Link href="/plan" className="tools-core-card">
|
||||||
<span>🗓️</span>
|
<span>🗓️</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>旅居计划中心</strong>
|
<strong>{t.tools.corePlan}</strong>
|
||||||
<p>时间轴 · 签证 · 预算 · 日历导出</p>
|
<p>{t.tools.corePlanDesc}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/compare" className="tools-core-card">
|
<Link href="/compare" className="tools-core-card">
|
||||||
<span>⚖️</span>
|
<span>⚖️</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>城市对比台</strong>
|
<strong>{t.tools.coreCompare}</strong>
|
||||||
<p>并排指标 · 一键写入计划</p>
|
<p>{t.tools.coreCompareDesc}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/login?next=/plan" className="tools-core-card">
|
<Link href="/login?next=/plan" className="tools-core-card">
|
||||||
<span>☁️</span>
|
<span>☁️</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>登录同步</strong>
|
<strong>{t.tools.coreSync}</strong>
|
||||||
<p>跨设备保存计划与收藏</p>
|
<p>{t.tools.coreSyncDesc}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -68,12 +125,12 @@ export default function ToolsHubPage() {
|
|||||||
<span>一次上新 12+ 工具 · 周末/分账/防坑…</span>
|
<span>一次上新 12+ 工具 · 周末/分账/防坑…</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="tools-featured-grid">
|
<div className="tools-featured-grid">
|
||||||
{featured.map((t) => (
|
{featured.map((tool) => (
|
||||||
<Link key={t.id} href={t.href} className="tools-featured-card">
|
<Link key={tool.id} href={tool.href} className="tools-featured-card">
|
||||||
<span>{t.emoji}</span>
|
<span>{tool.emoji}</span>
|
||||||
<div>
|
<div>
|
||||||
<h3>{t.title}</h3>
|
<h3>{tool.title}</h3>
|
||||||
<p>{t.desc}</p>
|
<p>{tool.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@ -101,11 +158,11 @@ export default function ToolsHubPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tools-hub-grid">
|
<div className="tools-hub-grid">
|
||||||
{filtered.map((t) => (
|
{filtered.map((tool) => (
|
||||||
<Link key={t.id} href={t.href} className="tools-hub-card">
|
<Link key={tool.id} href={tool.href} className="tools-hub-card">
|
||||||
<span className="tools-hub-emoji">{t.emoji}</span>
|
<span className="tools-hub-emoji">{tool.emoji}</span>
|
||||||
<h3>{t.title}</h3>
|
<h3>{tool.title}</h3>
|
||||||
<p>{t.desc}</p>
|
<p>{tool.desc}</p>
|
||||||
<span className="tools-hub-go">打开 →</span>
|
<span className="tools-hub-go">打开 →</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -2,39 +2,47 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { BlogPost } from "@/lib/types";
|
import type { BlogPost } from "@/lib/types";
|
||||||
|
|
||||||
export default function BlogSection({ posts }: { posts: BlogPost[] }) {
|
interface Props {
|
||||||
|
posts: BlogPost[];
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlogSection({ posts, limit }: Props) {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
const allTags = useMemo(() => {
|
const allTags = useMemo(() => {
|
||||||
const tags = new Set<string>();
|
const tags = new Set<string>();
|
||||||
posts.forEach((p) => p.tags.forEach((t) => tags.add(t)));
|
posts.forEach((p) => p.tags.forEach((tag) => tags.add(tag)));
|
||||||
return Array.from(tags);
|
return Array.from(tags);
|
||||||
}, [posts]);
|
}, [posts]);
|
||||||
|
|
||||||
const [activeTag, setActiveTag] = useState("all");
|
const [activeTag, setActiveTag] = useState("all");
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (activeTag === "all") return posts;
|
let list = activeTag === "all" ? posts : posts.filter((p) => p.tags.includes(activeTag));
|
||||||
return posts.filter((p) => p.tags.includes(activeTag));
|
if (limit) list = list.slice(0, limit);
|
||||||
}, [posts, activeTag]);
|
return list;
|
||||||
|
}, [posts, activeTag, limit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="section blog-section" id="blog">
|
<section className="section blog-section section-compact" id="blog">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="section-header reveal">
|
<div className="section-header reveal">
|
||||||
<span className="section-tag">📝 BLOG</span>
|
<span className="section-tag">{t.home.blogTag}</span>
|
||||||
<h2>游民博客</h2>
|
<h2>{t.home.blogTitle}</h2>
|
||||||
<p>深度攻略、签证指南与旅居经验分享</p>
|
<p>{t.home.blogSubtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{allTags.length > 0 && (
|
{!limit && allTags.length > 0 && (
|
||||||
<div className="blog-filters reveal">
|
<div className="blog-filters reveal">
|
||||||
<button className={`filter-btn${activeTag === "all" ? " active" : ""}`} onClick={() => setActiveTag("all")}>
|
<button type="button" className={`filter-btn${activeTag === "all" ? " active" : ""}`} onClick={() => setActiveTag("all")}>
|
||||||
📚 全部
|
📚 {locale === "en" ? "All" : "全部"}
|
||||||
</button>
|
</button>
|
||||||
{allTags.map((t) => (
|
{allTags.map((tag) => (
|
||||||
<button key={t} className={`filter-btn${activeTag === t ? " active" : ""}`} onClick={() => setActiveTag(t)}>
|
<button key={tag} type="button" className={`filter-btn${activeTag === tag ? " active" : ""}`} onClick={() => setActiveTag(tag)}>
|
||||||
{t}
|
{tag}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -46,20 +54,14 @@ export default function BlogSection({ posts }: { posts: BlogPost[] }) {
|
|||||||
<div className="blog-emoji">{post.emoji}</div>
|
<div className="blog-emoji">{post.emoji}</div>
|
||||||
<div className="blog-meta">
|
<div className="blog-meta">
|
||||||
<span>📅 {post.published_at}</span>
|
<span>📅 {post.published_at}</span>
|
||||||
<span>⏱️ {post.read_time} 分钟阅读</span>
|
<span>⏱️ {post.read_time} min</span>
|
||||||
</div>
|
</div>
|
||||||
<h3>{post.title}</h3>
|
<h3>{post.title}</h3>
|
||||||
<p>{post.excerpt}</p>
|
<p>{post.excerpt}</p>
|
||||||
<div className="blog-tags">
|
<span className="blog-read-more">{t.common.learnMore}</span>
|
||||||
{post.tags.map((t) => <span key={t} className="blog-tag">{t}</span>)}
|
|
||||||
</div>
|
|
||||||
<span className="blog-read-more">阅读全文 →</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{filtered.length === 0 && (
|
|
||||||
<p className="dest-empty reveal">该分类暂无文章</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
59
frontend/src/components/ChatClient.tsx
Normal file
59
frontend/src/components/ChatClient.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import type { ConversationItem } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function ChatClient() {
|
||||||
|
const { user, token } = useAuth();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [items, setItems] = useState<ConversationItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
api.getConversations(token).then(setItems).catch(() => setItems([]));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="chat-page">
|
||||||
|
<div className="container">
|
||||||
|
<div className="dating-gate reveal">
|
||||||
|
<h2>{t.chat.loginTitle}</h2>
|
||||||
|
<Link href="/login?next=/chat" className="btn btn-primary">{t.nav.login}</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/dating">{t.nav.dating}</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">{t.chat.tag}</span>
|
||||||
|
<h1>{t.chat.title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="chat-list">
|
||||||
|
{items.map((c) => (
|
||||||
|
<Link key={c.id} href={`/chat/${c.id}`} className={`chat-list-item reveal${c.unreadCount > 0 ? " unread" : ""}`}>
|
||||||
|
<span className="chat-avatar">{c.peer?.photo || "💬"}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{c.peer?.name || "游民"}</strong>
|
||||||
|
<p>{c.lastMessagePreview || t.chat.noMessages}</p>
|
||||||
|
</div>
|
||||||
|
{c.unreadCount > 0 && <em className="chat-unread">{c.unreadCount}</em>}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && <p className="dest-empty">{t.chat.empty}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/components/ChatThreadClient.tsx
Normal file
67
frontend/src/components/ChatThreadClient.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import type { ChatMessage } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function ChatThreadClient({ convId }: { convId: string }) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
if (!token) return;
|
||||||
|
api.getMessages(token, convId).then(setMessages).catch(() => setMessages([]));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const id = setInterval(load, 4000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [token, convId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!token || !text.trim()) return;
|
||||||
|
try {
|
||||||
|
await api.sendMessage(token, convId, text.trim());
|
||||||
|
setText("");
|
||||||
|
load();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-thread-page">
|
||||||
|
<div className="container chat-thread-wrap">
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/chat">← {t.chat.back}</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="chat-thread-messages">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div key={m.id} className={`chat-bubble${m.mine ? " mine" : ""}`}>
|
||||||
|
{m.body}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
<div className="chat-compose">
|
||||||
|
<input
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder={t.chat.placeholder}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={send}>{t.chat.send}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
frontend/src/components/CommunityHubClient.tsx
Normal file
128
frontend/src/components/CommunityHubClient.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import type { Discussion, DiscussionDetail } from "@/lib/types";
|
||||||
|
|
||||||
|
const CATEGORIES = ["全部", "签证", "远程工作", "住宿", "安全", "社区"];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
discussions: Discussion[];
|
||||||
|
featured?: DiscussionDetail | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommunityHubClient({ discussions, featured }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [category, setCategory] = useState("全部");
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let list = discussions;
|
||||||
|
if (category !== "全部") list = list.filter((d) => d.category === category);
|
||||||
|
if (q.trim()) {
|
||||||
|
const s = q.toLowerCase();
|
||||||
|
list = list.filter(
|
||||||
|
(d) =>
|
||||||
|
d.title.toLowerCase().includes(s) ||
|
||||||
|
d.excerpt.toLowerCase().includes(s) ||
|
||||||
|
d.tags.some((tag) => tag.toLowerCase().includes(s))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [discussions, category, q]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="community-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/">{t.common.backHome}</Link>
|
||||||
|
<span> · </span>
|
||||||
|
<Link href="/meetups">{t.nav.meetups}</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">{t.community.tag}</span>
|
||||||
|
<h1>{t.community.title}</h1>
|
||||||
|
<p>{t.community.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="community-banner reveal">
|
||||||
|
<div>
|
||||||
|
<strong>{t.community.bannerTitle}</strong>
|
||||||
|
<p>{t.community.bannerDesc}</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/next-stop" className="btn btn-primary">{t.community.bannerCta}</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{featured && featured.replies.length > 0 && (
|
||||||
|
<section className="community-featured reveal">
|
||||||
|
<span className="community-pinned">📌 {t.community.hot}</span>
|
||||||
|
<h3>{featured.title}</h3>
|
||||||
|
<p>{featured.excerpt}</p>
|
||||||
|
<div className="community-replies-preview">
|
||||||
|
{featured.replies.slice(0, 2).map((r) => (
|
||||||
|
<blockquote key={r.id}>
|
||||||
|
<span>{r.author_emoji} {r.author}</span>
|
||||||
|
{r.content}
|
||||||
|
</blockquote>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Link href={`/community/${featured.id}`}>{t.community.readMore} →</Link>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="community-toolbar reveal">
|
||||||
|
<Link href="/community/new" className="btn btn-primary btn-sm">{t.community.newTopic}</Link>
|
||||||
|
<input
|
||||||
|
className="tools-hub-search"
|
||||||
|
placeholder={t.community.search}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="tools-hub-filters">
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
className={`filter-btn${category === c ? " active" : ""}`}
|
||||||
|
onClick={() => setCategory(c)}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="community-list">
|
||||||
|
{filtered.map((d) => (
|
||||||
|
<Link key={d.id} href={`/community/${d.id}`} className="community-card reveal">
|
||||||
|
{d.is_pinned && <span className="community-pinned">📌</span>}
|
||||||
|
<div className="community-card-head">
|
||||||
|
<span>{d.author_emoji}</span>
|
||||||
|
<div>
|
||||||
|
<h3>{d.title}</h3>
|
||||||
|
<p className="community-meta">
|
||||||
|
{d.author} · {d.category} · {d.created_at}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="community-excerpt">{d.excerpt}</p>
|
||||||
|
<footer>
|
||||||
|
<span>💬 {d.reply_count}</span>
|
||||||
|
<span>❤️ {d.like_count}</span>
|
||||||
|
{d.tags.map((tag) => (
|
||||||
|
<span key={tag} className="community-tag">{tag}</span>
|
||||||
|
))}
|
||||||
|
</footer>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="dest-empty">{t.community.empty}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
frontend/src/components/CommunityNewClient.tsx
Normal file
55
frontend/src/components/CommunityNewClient.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useToast } from "@/lib/toast";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export default function CommunityNewClient() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [category, setCategory] = useState("社区");
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!token) {
|
||||||
|
router.push("/login?next=/community/new");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.createDiscussion(token, { title, content, excerpt: content, category });
|
||||||
|
toast(t.community.createOk, "success");
|
||||||
|
router.push(`/community/${res.discussion.id}`);
|
||||||
|
} catch {
|
||||||
|
toast(t.community.createFail, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="community-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav"><Link href="/community">← {t.community.back}</Link></nav>
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<h1>{t.community.newTitle}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="join-card reveal">
|
||||||
|
<label>{t.community.newSubject}</label>
|
||||||
|
<input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
<label>{t.community.newCategory}</label>
|
||||||
|
<select value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||||
|
{["签证", "远程工作", "住宿", "安全", "社区"].map((c) => <option key={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
<label>{t.community.newBody}</label>
|
||||||
|
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={6} />
|
||||||
|
<button type="button" className="btn btn-primary" onClick={submit}>{t.community.newSubmit}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
parseCompareSlugs,
|
parseCompareSlugs,
|
||||||
} from "@/lib/compareScore";
|
} from "@/lib/compareScore";
|
||||||
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Destination } from "@/lib/types";
|
import type { Destination } from "@/lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -22,6 +23,7 @@ export default function CompareClient({ destinations }: Props) {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { t } = useI18n();
|
||||||
const [picked, setPicked] = useState<string[]>([]);
|
const [picked, setPicked] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -51,7 +53,7 @@ export default function CompareClient({ destinations }: Props) {
|
|||||||
let next: string[];
|
let next: string[];
|
||||||
if (prev.includes(slug)) next = prev.filter((s) => s !== slug);
|
if (prev.includes(slug)) next = prev.filter((s) => s !== slug);
|
||||||
else if (prev.length >= 4) {
|
else if (prev.length >= 4) {
|
||||||
toast("最多对比 4 座城市", "info");
|
toast(t.compare.maxCities, "info");
|
||||||
return prev;
|
return prev;
|
||||||
} else next = [...prev, slug];
|
} else next = [...prev, slug];
|
||||||
syncUrl(next);
|
syncUrl(next);
|
||||||
@ -63,11 +65,11 @@ export default function CompareClient({ destinations }: Props) {
|
|||||||
if (selected.length < 1) return;
|
if (selected.length < 1) return;
|
||||||
const { added, skipped } = mergeDestinationsIntoTrip(selected, 1);
|
const { added, skipped } = mergeDestinationsIntoTrip(selected, 1);
|
||||||
if (added === 0) {
|
if (added === 0) {
|
||||||
toast(skipped ? "这些城市已在计划中" : "未能加入", "info");
|
toast(skipped ? t.compare.alreadyInPlan : t.compare.alreadyOne, "info");
|
||||||
router.push("/plan");
|
router.push("/plan");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast(`已加入 ${added} 座城市${skipped ? `(跳过 ${skipped})` : ""}`);
|
toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}${skipped ? `(${t.compare.skipped} ${skipped})` : ""}`);
|
||||||
router.push("/plan");
|
router.push("/plan");
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -75,41 +77,41 @@ 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(added ? `${d.emoji} ${d.name} 已写入计划` : "该城已在计划中", added ? "success" : "info");
|
toast(added ? `${d.emoji} ${d.name} ${t.compare.wrotePlan}` : t.compare.alreadyOne, added ? "success" : "info");
|
||||||
if (added) router.push("/plan");
|
if (added) router.push("/plan");
|
||||||
};
|
};
|
||||||
|
|
||||||
const share = async () => {
|
const share = async () => {
|
||||||
if (selected.length < 2) {
|
if (selected.length < 2) {
|
||||||
toast("至少选 2 座城市再分享", "info");
|
toast(t.compare.needShare, "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const url = `${window.location.origin}${compareUrl(selected.map((d) => d.slug))}`;
|
const url = `${window.location.origin}${compareUrl(selected.map((d) => d.slug))}`;
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
toast("对比链接已复制");
|
toast(t.compare.linkCopied);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="compare-page">
|
<div className="compare-page">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<nav className="detail-nav">
|
<nav className="detail-nav">
|
||||||
<Link href="/">← 返回首页</Link>
|
<Link href="/">{t.common.backHome}</Link>
|
||||||
<Link href="/#destinations">目的地</Link>
|
<Link href="/#destinations">{t.compare.dest}</Link>
|
||||||
<Link href="/plan">旅居计划</Link>
|
<Link href="/plan">{t.compare.plan}</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<header className="compare-page-hero">
|
<header className="compare-page-hero">
|
||||||
<span className="section-tag">⚖️ COMPARE</span>
|
<span className="section-tag">{t.compare.tag}</span>
|
||||||
<h1>城市对比台</h1>
|
<h1>{t.compare.title}</h1>
|
||||||
<p>最多 4 城并排对比费用、网速、气候与评分。决定后一键写入旅居计划。</p>
|
<p>{t.compare.subtitle}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="compare-pick plan-panel">
|
<section className="compare-pick plan-panel">
|
||||||
<div className="plan-panel-head">
|
<div className="plan-panel-head">
|
||||||
<h2>选择城市({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([]); }}>清空</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}>复制链接</button>
|
<button type="button" className="btn btn-ghost btn-sm" disabled={selected.length < 2} onClick={share}>{t.compare.copyLink}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="compare-pick-grid">
|
<div className="compare-pick-grid">
|
||||||
@ -135,8 +137,8 @@ export default function CompareClient({ destinations }: Props) {
|
|||||||
{selected.length < 2 ? (
|
{selected.length < 2 ? (
|
||||||
<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>再选至少 {Math.max(0, 2 - selected.length)} 座城市开始对比</p>
|
<p>{t.compare.needMore} {Math.max(0, 2 - selected.length)} {t.compare.needMoreSuffix}</p>
|
||||||
<Link href="/#destinations" className="btn btn-ghost">回首页勾选对比</Link>
|
<Link href="/#destinations" className="btn btn-ghost">{t.compare.backDest}</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -144,7 +146,7 @@ export default function CompareClient({ destinations }: Props) {
|
|||||||
<div className="compare-cards">
|
<div className="compare-cards">
|
||||||
{selected.map((d, i) => (
|
{selected.map((d, i) => (
|
||||||
<div key={d.slug} className={`compare-city-card${i === winnerIdx ? " winner" : ""}`}>
|
<div key={d.slug} className={`compare-city-card${i === winnerIdx ? " winner" : ""}`}>
|
||||||
{i === winnerIdx && <span className="compare-winner-badge">👑 综合推荐</span>}
|
{i === winnerIdx && <span className="compare-winner-badge">{t.compare.winner}</span>}
|
||||||
<span className="compare-city-emoji">{d.emoji}</span>
|
<span className="compare-city-emoji">{d.emoji}</span>
|
||||||
<h3>{d.name}</h3>
|
<h3>{d.name}</h3>
|
||||||
<span className="compare-city-country">{d.country}</span>
|
<span className="compare-city-country">{d.country}</span>
|
||||||
@ -161,7 +163,7 @@ export default function CompareClient({ destinations }: Props) {
|
|||||||
</svg>
|
</svg>
|
||||||
<span className="compare-score-num">{scores[i]}</span>
|
<span className="compare-score-num">{scores[i]}</span>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/destinations/${d.slug}`} className="compare-city-link">查看详情 →</Link>
|
<Link href={`/destinations/${d.slug}`} className="compare-city-link">{t.common.detail}</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -203,10 +205,10 @@ export default function CompareClient({ destinations }: Props) {
|
|||||||
|
|
||||||
<div className="compare-page-actions">
|
<div className="compare-page-actions">
|
||||||
<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}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-primary" onClick={addAll}>
|
<button type="button" className="btn btn-primary" onClick={addAll}>
|
||||||
全部写入旅居计划 →
|
{t.compare.addAll}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
186
frontend/src/components/DatingClient.tsx
Normal file
186
frontend/src/components/DatingClient.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useToast } from "@/lib/toast";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import type { MatchIntent, MatchProfile, MatchQuota } from "@/lib/types";
|
||||||
|
|
||||||
|
const INTENTS: { key: MatchIntent; label: string; emoji: string }[] = [
|
||||||
|
{ key: "friends", label: "交朋友", emoji: "🤝" },
|
||||||
|
{ key: "dating", label: "约会", emoji: "💕" },
|
||||||
|
{ key: "partner", label: "伴侣", emoji: "💑" },
|
||||||
|
{ key: "roommate", label: "合租", emoji: "🏠" },
|
||||||
|
{ key: "cofounder", label: "联创", emoji: "🚀" },
|
||||||
|
{ key: "explore", label: "探索", emoji: "🌍" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DatingClient() {
|
||||||
|
const { user, token } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [intent, setIntent] = useState<MatchIntent>("friends");
|
||||||
|
const [deck, setDeck] = useState<MatchProfile[]>([]);
|
||||||
|
const [quota, setQuota] = useState<MatchQuota | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [joined, setJoined] = useState(false);
|
||||||
|
const [matchModal, setMatchModal] = useState<{ conversationId?: string; name: string } | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [candidates, q] = await Promise.all([
|
||||||
|
api.getMatchCandidates(token, { intent }),
|
||||||
|
api.getMatchQuota(token),
|
||||||
|
]);
|
||||||
|
setDeck(candidates);
|
||||||
|
setQuota(q);
|
||||||
|
setJoined(true);
|
||||||
|
} catch {
|
||||||
|
setJoined(false);
|
||||||
|
setDeck([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, intent]);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
const quickJoin = async () => {
|
||||||
|
if (!token) {
|
||||||
|
router.push("/login?next=/dating");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.socialJoin(token, {
|
||||||
|
city: "全球",
|
||||||
|
lookingFor: [intent, "explore"],
|
||||||
|
bio: "nomadro 游民",
|
||||||
|
});
|
||||||
|
toast("资料已完善,开始匹配", "success");
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
toast("加入失败", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const swipe = async (action: "like" | "dislike" | "superlike") => {
|
||||||
|
if (!token || !deck[0]) return;
|
||||||
|
const current = deck[0];
|
||||||
|
try {
|
||||||
|
const res = await api.swipe(token, current.id, action, intent);
|
||||||
|
if (res.matched && res.match?.conversationId) {
|
||||||
|
setMatchModal({ conversationId: res.match.conversationId, name: current.name });
|
||||||
|
}
|
||||||
|
setDeck((d) => d.slice(1));
|
||||||
|
const q = await api.getMatchQuota(token);
|
||||||
|
setQuota(q);
|
||||||
|
} catch {
|
||||||
|
toast("滑动失败,请检查登录或配额", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const top = deck[0];
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="dating-page">
|
||||||
|
<div className="container">
|
||||||
|
<div className="dating-gate reveal">
|
||||||
|
<h2>{t.dating.loginTitle}</h2>
|
||||||
|
<p>{t.dating.loginDesc}</p>
|
||||||
|
<Link href="/login?next=/dating" className="btn btn-primary">{t.nav.login}</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dating-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/">{t.common.backHome}</Link>
|
||||||
|
<span> · </span>
|
||||||
|
<Link href="/chat">{t.nav.chat}</Link>
|
||||||
|
<span> · </span>
|
||||||
|
<Link href="/dating/likes">{t.dating.likesTitle}</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">{t.dating.tag}</span>
|
||||||
|
<h1>{t.dating.title}</h1>
|
||||||
|
<p>{t.dating.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dating-intents reveal">
|
||||||
|
{INTENTS.map((i) => (
|
||||||
|
<button
|
||||||
|
key={i.key}
|
||||||
|
type="button"
|
||||||
|
className={`filter-btn${intent === i.key ? " active" : ""}`}
|
||||||
|
onClick={() => setIntent(i.key)}
|
||||||
|
>
|
||||||
|
{i.emoji} {i.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{quota && (
|
||||||
|
<p className="dating-quota reveal">
|
||||||
|
{quota.vip ? "✨ VIP 无限滑" : `今日剩余 ${quota.remaining}/${quota.limit} 次`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!joined && !loading && (
|
||||||
|
<div className="dating-gate reveal">
|
||||||
|
<p>{t.dating.joinFirst}</p>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={quickJoin}>{t.dating.joinBtn}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{top && (
|
||||||
|
<div className="dating-card reveal">
|
||||||
|
<div className="dating-card-photo">{top.photo || "🧑💻"}</div>
|
||||||
|
<h2>{top.name}</h2>
|
||||||
|
<p className="dating-meta">{top.location} · {top.gender} {top.single ? `· ${top.single}` : ""}</p>
|
||||||
|
<p>{top.bio}</p>
|
||||||
|
<div className="dating-tags">
|
||||||
|
{(top.tags || []).map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="dating-actions">
|
||||||
|
<button type="button" className="dating-btn pass" onClick={() => swipe("dislike")}>✕</button>
|
||||||
|
<button type="button" className="dating-btn super" onClick={() => swipe("superlike")}>⭐</button>
|
||||||
|
<button type="button" className="dating-btn like" onClick={() => swipe("like")}>♥</button>
|
||||||
|
{token && (
|
||||||
|
<button type="button" className="btn btn-sm" title={t.dating.undo} onClick={() => api.undoSwipe(token).then(() => load())}>↩</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && joined && !top && (
|
||||||
|
<p className="dest-empty reveal">{t.dating.empty}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{matchModal && (
|
||||||
|
<div className="dating-match-modal" role="dialog">
|
||||||
|
<div className="dating-match-box">
|
||||||
|
<h3>🎉 {t.dating.matched} {matchModal.name}!</h3>
|
||||||
|
<div className="dating-match-actions">
|
||||||
|
<button type="button" className="btn" onClick={() => setMatchModal(null)}>{t.dating.keepSwiping}</button>
|
||||||
|
{matchModal.conversationId && (
|
||||||
|
<Link href={`/chat/${matchModal.conversationId}`} className="btn btn-primary">{t.dating.chatNow}</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
frontend/src/components/DatingLikesClient.tsx
Normal file
57
frontend/src/components/DatingLikesClient.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import type { MatchProfile } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function DatingLikesClient() {
|
||||||
|
const { user, token } = useAuth();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [likes, setLikes] = useState<MatchProfile[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
api.getMatchLikes(token).then(setLikes).catch(() => setLikes([]));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="dating-page">
|
||||||
|
<div className="container">
|
||||||
|
<div className="dating-gate reveal">
|
||||||
|
<h2>{t.dating.loginTitle}</h2>
|
||||||
|
<Link href="/login?next=/dating/likes" className="btn btn-primary">{t.nav.login}</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dating-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/dating">{t.dating.backSwipe}</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">{t.dating.tag}</span>
|
||||||
|
<h1>{t.dating.likesTitle}</h1>
|
||||||
|
<p>{t.dating.likesSubtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="dating-likes-grid">
|
||||||
|
{likes.map((p) => (
|
||||||
|
<div key={p.id} className="dating-like-card reveal">
|
||||||
|
<div className="dating-like-photo">{p.photo || "🧑💻"}</div>
|
||||||
|
<strong>{p.name}</strong>
|
||||||
|
<p className="dating-meta">{p.location}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{likes.length === 0 && <p className="dest-empty">{t.dating.likesEmpty}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,28 +2,31 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Destination } from "@/lib/types";
|
import type { Destination } from "@/lib/types";
|
||||||
import FavoriteButton from "./FavoriteButton";
|
import FavoriteButton from "./FavoriteButton";
|
||||||
|
|
||||||
|
const INITIAL = 8;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
destinations: Destination[];
|
destinations: Destination[];
|
||||||
onCompare?: (slug: string) => void;
|
|
||||||
compareList?: string[];
|
|
||||||
onOpenMatcher?: () => void;
|
onOpenMatcher?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FILTERS = [
|
export default function Destinations({ destinations, onOpenMatcher }: Props) {
|
||||||
{ key: "all", label: "🌏 全部" },
|
const { t } = useI18n();
|
||||||
{ key: "sea", label: "🌴 东南亚" },
|
|
||||||
{ key: "europe", label: "🏰 欧洲" },
|
|
||||||
{ key: "latam", label: "🌮 拉美" },
|
|
||||||
{ key: "asia", label: "🏯 东亚" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Destinations({ destinations, onCompare, compareList = [], onOpenMatcher }: Props) {
|
|
||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [sort, setSort] = useState("rating");
|
const [sort, setSort] = useState("rating");
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const FILTERS = [
|
||||||
|
{ key: "all", label: t.home.filterAll },
|
||||||
|
{ key: "sea", label: t.home.filterSea },
|
||||||
|
{ key: "europe", label: t.home.filterEurope },
|
||||||
|
{ key: "latam", label: t.home.filterLatam },
|
||||||
|
{ key: "asia", label: t.home.filterAsia },
|
||||||
|
];
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let list = [...destinations];
|
let list = [...destinations];
|
||||||
@ -39,54 +42,48 @@ export default function Destinations({ destinations, onCompare, compareList = []
|
|||||||
return list;
|
return list;
|
||||||
}, [destinations, filter, search, sort]);
|
}, [destinations, filter, search, sort]);
|
||||||
|
|
||||||
|
const visible = expanded ? filtered : filtered.slice(0, INITIAL);
|
||||||
|
const canExpand = filtered.length > INITIAL;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="section destinations" id="destinations">
|
<section className="section destinations section-compact" id="destinations">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="section-header reveal">
|
<div className="section-header reveal">
|
||||||
<span className="section-tag">🌴 TOP DESTINATIONS</span>
|
<span className="section-tag">{t.home.destTag}</span>
|
||||||
<h2>热门旅居目的地</h2>
|
<h2>{t.home.destTitle}</h2>
|
||||||
<p>精选全球最适合远程工作的城市,点击查看详情,勾选对比</p>
|
<p>{t.home.destSubtitle}</p>
|
||||||
{onOpenMatcher && (
|
{onOpenMatcher && (
|
||||||
<button className="btn btn-primary dest-matcher-btn" onClick={onOpenMatcher}>
|
<button type="button" className="btn btn-primary dest-matcher-btn" onClick={onOpenMatcher}>
|
||||||
🎯 不知道去哪?智能匹配
|
🎯 {t.home.destMatcher}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dest-filters reveal">
|
<div className="dest-filters reveal">
|
||||||
<div className="filter-search">
|
<div className="filter-search">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg>
|
||||||
<input placeholder="搜索城市..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
<input placeholder={t.home.destSearch} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="filter-tags">
|
<div className="filter-tags">
|
||||||
{FILTERS.map((f) => (
|
{FILTERS.map((f) => (
|
||||||
<button key={f.key} className={`filter-btn${filter === f.key ? " active" : ""}`}
|
<button key={f.key} type="button" className={`filter-btn${filter === f.key ? " active" : ""}`}
|
||||||
onClick={() => setFilter(f.key)}>{f.label}</button>
|
onClick={() => { setFilter(f.key); setExpanded(false); }}>{f.label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="filter-sort">
|
<div className="filter-sort">
|
||||||
<select value={sort} onChange={(e) => setSort(e.target.value)}>
|
<select value={sort} onChange={(e) => setSort(e.target.value)}>
|
||||||
<option value="rating">⭐ 评分排序</option>
|
<option value="rating">{t.home.sortRating}</option>
|
||||||
<option value="cost-asc">💰 价格从低到高</option>
|
<option value="cost-asc">{t.home.sortCostAsc}</option>
|
||||||
<option value="cost-desc">💰 价格从高到低</option>
|
<option value="cost-desc">{t.home.sortCostDesc}</option>
|
||||||
<option value="speed">📶 网速排序</option>
|
<option value="speed">{t.home.sortSpeed}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dest-grid" id="dest-grid">
|
<div className="dest-grid" id="dest-grid">
|
||||||
{filtered.map((d) => (
|
{visible.map((d) => (
|
||||||
<article key={d.slug} className="dest-card reveal" style={{ position: "relative" }}>
|
<article key={d.slug} className="dest-card reveal">
|
||||||
{onCompare && (
|
<Link href={`/destinations/${d.slug}`} className="dest-card-link">
|
||||||
<button className="compare-check" onClick={() => onCompare(d.slug)}
|
|
||||||
style={{
|
|
||||||
position: "absolute", top: 12, right: 12, zIndex: 2,
|
|
||||||
background: compareList.includes(d.slug) ? "var(--accent-3)" : "var(--bg-glass)",
|
|
||||||
border: "var(--border-glass)", borderRadius: "50%", width: 32, height: 32,
|
|
||||||
cursor: "pointer", fontSize: "0.8rem",
|
|
||||||
}}>
|
|
||||||
{compareList.includes(d.slug) ? "✓" : "⚖️"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Link href={`/destinations/${d.slug}`} style={{ textDecoration: "none", color: "inherit" }}>
|
|
||||||
<div className="dest-img" style={{ "--hue": d.hue } as React.CSSProperties}>
|
<div className="dest-img" style={{ "--hue": d.hue } as React.CSSProperties}>
|
||||||
<div className="dest-emoji">{d.emoji}</div>
|
<div className="dest-emoji">{d.emoji}</div>
|
||||||
<div className="dest-overlay"><span className="dest-tag">{d.tag}</span></div>
|
<div className="dest-overlay"><span className="dest-tag">{d.tag}</span></div>
|
||||||
@ -95,26 +92,40 @@ export default function Destinations({ destinations, onCompare, compareList = []
|
|||||||
<h3>{d.name}, {d.country}</h3>
|
<h3>{d.name}, {d.country}</h3>
|
||||||
<p>{d.description}</p>
|
<p>{d.description}</p>
|
||||||
<div className="dest-meta">
|
<div className="dest-meta">
|
||||||
<span>💰 ¥{d.cost.toLocaleString()}/月</span>
|
<span>💰 ¥{d.cost.toLocaleString()}</span>
|
||||||
<span>📶 {d.speed}Mbps</span>
|
<span>📶 {d.speed}Mbps</span>
|
||||||
<span>🌡️ {d.temperature}°C</span>
|
<span>🌡️ {d.temperature}°C</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="dest-rating">
|
<div className="dest-rating">
|
||||||
<span className="stars">{"⭐".repeat(Math.round(d.rating / 2))}</span>
|
<span className="stars">{"⭐".repeat(Math.round(d.rating / 2))}</span>
|
||||||
<span>{d.rating} 分</span>
|
<span>{d.rating}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div style={{ padding: "0 20px 20px", display: "flex", gap: 8, alignItems: "center" }}>
|
<div className="dest-card-actions">
|
||||||
<div onClick={(e) => e.preventDefault()}>
|
<FavoriteButton slug={d.slug} />
|
||||||
<FavoriteButton slug={d.slug} />
|
<Link href={`/destinations/${d.slug}`} className="btn btn-ghost btn-sm">
|
||||||
</div>
|
{t.common.detail}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<p className="dest-empty">😢 没有找到匹配的目的地,试试其他筛选条件</p>
|
<p className="dest-empty">{t.home.destEmpty}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canExpand && (
|
||||||
|
<div className="dest-expand-wrap reveal">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{expanded ? t.home.destShowLess : `${t.home.destShowMore} (${filtered.length - INITIAL})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
43
frontend/src/components/DigitalCourseClient.tsx
Normal file
43
frontend/src/components/DigitalCourseClient.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import type { DigitalCourse } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function DigitalCourseClient({ course }: { course: DigitalCourse }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="digital-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/digital">← {t.digital.back}</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">{t.digital.courseTag}</span>
|
||||||
|
<h1>{t.digital.course}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="digital-modules reveal">
|
||||||
|
{course.modules.map((mod, mi) => (
|
||||||
|
<section key={mod.title} className="digital-module">
|
||||||
|
<h2>{mod.title}</h2>
|
||||||
|
<ul className="digital-lesson-list">
|
||||||
|
{mod.lessons.map((lesson, li) => (
|
||||||
|
<li key={lesson.title}>
|
||||||
|
<Link href={`/digital/course/${mi}/${li}`}>
|
||||||
|
<span>{lesson.title}</span>
|
||||||
|
<span className="digital-lesson-meta">
|
||||||
|
{lesson.duration}
|
||||||
|
{lesson.free ? ` · ${t.digital.free}` : ` · VIP`}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
frontend/src/components/DigitalHomeClient.tsx
Normal file
47
frontend/src/components/DigitalHomeClient.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import type { DigitalCourse } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function DigitalHomeClient({ course }: { course: DigitalCourse }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const lessonCount = course.modules.reduce((n, m) => n + m.lessons.length, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="digital-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/">{t.common.backHome}</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="digital-hero reveal">
|
||||||
|
<span className="section-tag">{t.digital.tag}</span>
|
||||||
|
<h1>{t.digital.title}</h1>
|
||||||
|
<p>{t.digital.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="digital-grid reveal">
|
||||||
|
<Link href="/digital/course" className="digital-card">
|
||||||
|
<span className="digital-card-emoji">🎓</span>
|
||||||
|
<h3>{t.digital.course}</h3>
|
||||||
|
<p>{course.modules.length} {t.digital.modules} · {lessonCount} {t.digital.lessons}</p>
|
||||||
|
</Link>
|
||||||
|
<Link href="/book" className="digital-card">
|
||||||
|
<span className="digital-card-emoji">📖</span>
|
||||||
|
<h3>{t.digital.ebook}</h3>
|
||||||
|
<p>{t.digital.ebookDesc}</p>
|
||||||
|
</Link>
|
||||||
|
<Link href="/digital/jobs" className="digital-card">
|
||||||
|
<span className="digital-card-emoji">💼</span>
|
||||||
|
<h3>{t.digital.jobs}</h3>
|
||||||
|
<p>{t.digital.jobsDesc}</p>
|
||||||
|
</Link>
|
||||||
|
<Link href="/join" className="digital-card digital-card-vip">
|
||||||
|
<span className="digital-card-emoji">✨</span>
|
||||||
|
<h3>{t.digital.vip}</h3>
|
||||||
|
<p>{t.digital.vipDesc}</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/components/DigitalJobsClient.tsx
Normal file
40
frontend/src/components/DigitalJobsClient.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import type { DigitalJob } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="digital-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/digital">← {t.digital.back}</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">{t.digital.jobsTag}</span>
|
||||||
|
<h1>{t.digital.jobs}</h1>
|
||||||
|
<p>{t.digital.jobsSubtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="digital-jobs-grid reveal">
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<article key={job.id} className="digital-job-card">
|
||||||
|
<h3>{job.title}</h3>
|
||||||
|
<p className="digital-job-company">{job.company} · {job.location}</p>
|
||||||
|
<p className="digital-job-meta">{job.type} · {job.salary}</p>
|
||||||
|
<div className="meetup-tags">
|
||||||
|
{job.tags.map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)}
|
||||||
|
</div>
|
||||||
|
<a href={job.url} target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm">
|
||||||
|
{t.digital.apply}
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{jobs.length === 0 && <p className="dest-empty">{t.digital.jobsEmpty}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/components/DigitalLessonClient.tsx
Normal file
60
frontend/src/components/DigitalLessonClient.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import type { DigitalLesson } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function DigitalLessonClient({
|
||||||
|
lesson,
|
||||||
|
locked,
|
||||||
|
moduleIndex,
|
||||||
|
lessonIndex,
|
||||||
|
}: {
|
||||||
|
lesson: DigitalLesson | null;
|
||||||
|
locked: boolean;
|
||||||
|
moduleIndex: number;
|
||||||
|
lessonIndex: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
if (locked || !lesson) {
|
||||||
|
return (
|
||||||
|
<div className="digital-page">
|
||||||
|
<div className="container">
|
||||||
|
<div className="digital-gate reveal">
|
||||||
|
<h2>{t.digital.locked}</h2>
|
||||||
|
<p>{t.digital.lockedDesc}</p>
|
||||||
|
<Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link>
|
||||||
|
<Link href="/digital/course" className="btn">{t.digital.backCourse}</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = lessonIndex > 0 ? `/digital/course/${moduleIndex}/${lessonIndex - 1}` : null;
|
||||||
|
const next = `/digital/course/${moduleIndex}/${lessonIndex + 1}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="digital-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/digital/course">← {t.digital.backCourse}</Link>
|
||||||
|
</nav>
|
||||||
|
<article className="digital-lesson reveal">
|
||||||
|
<span className="section-tag">{lesson.duration}</span>
|
||||||
|
<h1>{lesson.title}</h1>
|
||||||
|
<div className="digital-lesson-body">
|
||||||
|
{lesson.content.split("\n").map((p) => (
|
||||||
|
<p key={p.slice(0, 24)}>{p}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<footer className="digital-lesson-nav">
|
||||||
|
{prev && <Link href={prev} className="btn">← {t.digital.prev}</Link>}
|
||||||
|
<Link href={next} className="btn btn-primary">{t.digital.next} →</Link>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
frontend/src/components/DiscussionDetailClient.tsx
Normal file
103
frontend/src/components/DiscussionDetailClient.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useToast } from "@/lib/toast";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export default function DiscussionDetailClient({ discussionId, initial }: { discussionId: string; initial: import("@/lib/types").DiscussionDetail }) {
|
||||||
|
const { user, token } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const [discussion, setDiscussion] = useState(initial);
|
||||||
|
const [reply, setReply] = useState("");
|
||||||
|
const [likes, setLikes] = useState(discussion.like_count);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
const d = await api.getDiscussion(discussionId);
|
||||||
|
setDiscussion(d);
|
||||||
|
setLikes(d.like_count);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitReply = async () => {
|
||||||
|
if (!token || !reply.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.postDiscussionReply(token, discussionId, reply.trim());
|
||||||
|
setReply("");
|
||||||
|
await refresh();
|
||||||
|
toast(t.community.replyOk, "success");
|
||||||
|
} catch {
|
||||||
|
toast(t.community.replyFail, "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLike = async () => {
|
||||||
|
if (!token) {
|
||||||
|
router.push(`/login?next=/community/${discussionId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.likeDiscussion(token, discussionId);
|
||||||
|
setLikes(res.like_count);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="community-detail-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/community">← {t.community.back}</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<article className="community-detail reveal">
|
||||||
|
<header>
|
||||||
|
<span className="community-detail-cat">{discussion.category}</span>
|
||||||
|
<h1>{discussion.title}</h1>
|
||||||
|
<p className="community-meta">
|
||||||
|
{discussion.author_emoji} {discussion.author} · {discussion.created_at}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div className="community-detail-body">
|
||||||
|
<p>{discussion.excerpt}</p>
|
||||||
|
</div>
|
||||||
|
<div className="community-detail-stats">
|
||||||
|
<span>💬 {discussion.reply_count} {t.community.replies}</span>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={toggleLike}>❤️ {likes}</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<section className="community-replies reveal">
|
||||||
|
<h2>{t.community.replies} ({discussion.replies.length})</h2>
|
||||||
|
{discussion.replies.map((r) => (
|
||||||
|
<div key={r.id} className="community-reply">
|
||||||
|
<div className="community-reply-head">
|
||||||
|
<span>{r.author_emoji} {r.author}</span>
|
||||||
|
<time>{r.created_at}</time>
|
||||||
|
</div>
|
||||||
|
<p>{r.content}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{user && token ? (
|
||||||
|
<div className="community-compose reveal">
|
||||||
|
<textarea value={reply} onChange={(e) => setReply(e.target.value)} rows={3} placeholder={t.community.replyPlaceholder} />
|
||||||
|
<button type="button" className="btn btn-primary" disabled={loading} onClick={submitReply}>{t.community.replySend}</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="community-reply-hint reveal">
|
||||||
|
<Link href={`/login?next=/community/${discussionId}`} className="btn btn-primary">{t.nav.login}</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,32 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { FAQ } from "@/lib/types";
|
import type { FAQ } from "@/lib/types";
|
||||||
|
|
||||||
export default function FAQSection({ faqs }: { faqs: FAQ[] }) {
|
interface Props {
|
||||||
|
faqs: FAQ[];
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FAQSection({ faqs, limit }: Props) {
|
||||||
|
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 sorted = useMemo(() => [...faqs].sort((a, b) => a.order - b.order), [faqs]);
|
const sorted = useMemo(() => [...faqs].sort((a, b) => a.order - b.order), [faqs]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search.trim()) return sorted;
|
let list = sorted;
|
||||||
const q = search.toLowerCase();
|
if (search.trim()) {
|
||||||
return sorted.filter((f) => f.question.toLowerCase().includes(q) || f.answer.toLowerCase().includes(q));
|
const q = search.toLowerCase();
|
||||||
}, [sorted, search]);
|
list = list.filter((f) => f.question.toLowerCase().includes(q) || f.answer.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
if (limit && !showAll) list = list.slice(0, limit);
|
||||||
|
return list;
|
||||||
|
}, [sorted, search, limit, showAll]);
|
||||||
|
|
||||||
const expandAll = () => setActive("__all__");
|
const isOpen = (id: string) => active === id;
|
||||||
const collapseAll = () => setActive(null);
|
|
||||||
|
|
||||||
const isOpen = (id: string) => active === "__all__" || active === id;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="section faq-section" id="faq">
|
<section className="section faq-section section-compact" id="faq">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="section-header reveal">
|
<div className="section-header reveal">
|
||||||
<span className="section-tag">❓ FAQ</span>
|
<span className="section-tag">{t.home.faqTag}</span>
|
||||||
<h2>常见问题</h2>
|
<h2>{t.home.faqTitle}</h2>
|
||||||
<p>关于数字游民生活,你可能想知道的一切</p>
|
<p>{t.home.faqSubtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="faq-toolbar reveal">
|
<div className="faq-toolbar reveal">
|
||||||
@ -34,32 +43,37 @@ export default function FAQSection({ faqs }: { faqs: FAQ[] }) {
|
|||||||
<span className="faq-search-icon">🔍</span>
|
<span className="faq-search-icon">🔍</span>
|
||||||
<input
|
<input
|
||||||
className="faq-search"
|
className="faq-search"
|
||||||
placeholder="搜索问题,如「签证」「保险」「税务」..."
|
placeholder={t.home.faqSearch}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{search && <button className="faq-search-clear" onClick={() => setSearch("")}>✕</button>}
|
|
||||||
</div>
|
|
||||||
<div className="faq-toolbar-actions">
|
|
||||||
<button className="btn btn-ghost btn-sm" onClick={expandAll}>展开全部</button>
|
|
||||||
<button className="btn btn-ghost btn-sm" onClick={collapseAll}>收起全部</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="faq-list">
|
<div className="faq-list">
|
||||||
{filtered.map((f) => (
|
{filtered.map((f) => (
|
||||||
<div key={f.id} className={`faq-item reveal${isOpen(f.id) ? " active" : ""}`}>
|
<div key={f.id} className={`faq-item reveal${isOpen(f.id) ? " active" : ""}`}>
|
||||||
<button className="faq-question" onClick={() => setActive(active === f.id ? null : f.id)}>
|
<button type="button" className="faq-question" onClick={() => setActive(active === f.id ? null : f.id)}>
|
||||||
<span>{f.question}</span>
|
<span>{f.question}</span>
|
||||||
<svg className="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
|
<svg className="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="faq-answer"><p>{f.answer}</p></div>
|
<div className="faq-answer"><p>{f.answer}</p></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && (
|
|
||||||
<p className="faq-empty">没有找到相关问题,试试其他关键词 🔎</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{limit && !showAll && sorted.length > limit && (
|
||||||
|
<div className="dest-expand-wrap reveal">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}>
|
||||||
|
{locale === "en" ? `Show all ${sorted.length} questions` : `展开全部 ${sorted.length} 个问题`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="faq-plan-hint reveal">
|
||||||
|
{locale === "en" ? "Still exploring? " : "还在选城?"}
|
||||||
|
<a href="#destinations">{locale === "en" ? "Browse destinations" : "继续看目的地"}</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import Link from "next/link";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import { loginNextFrom } from "@/lib/authNext";
|
||||||
|
|
||||||
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
||||||
|
|
||||||
@ -23,13 +28,17 @@ function BrandLogo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isHome = pathname === "/";
|
||||||
|
const loginNext = loginNextFrom(pathname);
|
||||||
return (
|
return (
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="footer-grid">
|
<div className="footer-grid">
|
||||||
<div className="footer-brand">
|
<div className="footer-brand">
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
<p>让每个人都能自由地工作和生活在这个星球上 🌏</p>
|
<p>{locale === "en" ? "Work and live freely anywhere on this planet 🌏" : "让每个人都能自由地工作和生活在这个星球上 🌏"}</p>
|
||||||
<div className="social-links">
|
<div className="social-links">
|
||||||
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer" aria-label="Twitter">𝕏</a>
|
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer" aria-label="Twitter">𝕏</a>
|
||||||
<a href="https://instagram.com" target="_blank" rel="noopener noreferrer" aria-label="Instagram">📷</a>
|
<a href="https://instagram.com" target="_blank" rel="noopener noreferrer" aria-label="Instagram">📷</a>
|
||||||
@ -38,28 +47,46 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<h4>探索</h4>
|
<h4>{t.footer.explore}</h4>
|
||||||
<Link href="/#destinations">目的地</Link>
|
<Link href="/#destinations">{t.footer.destinations}</Link>
|
||||||
<Link href="/plan">旅居计划</Link>
|
{!isHome && (
|
||||||
<Link href="/compare">城市对比</Link>
|
<>
|
||||||
<Link href="/#visa">签证指南</Link>
|
<Link href="/plan">{t.footer.plan}</Link>
|
||||||
<Link href="/#blog">博客</Link>
|
<Link href="/compare">{t.footer.compare}</Link>
|
||||||
<Link href="/about">关于 nomadro</Link>
|
</>
|
||||||
|
)}
|
||||||
|
<Link href="/#visa">{t.footer.visa}</Link>
|
||||||
|
<Link href="/#blog">{t.footer.blog}</Link>
|
||||||
|
<Link href="/next-stop">{t.footer.nextStop}</Link>
|
||||||
|
<Link href="/meetups">{t.footer.meetups}</Link>
|
||||||
|
<Link href="/community">{t.footer.community}</Link>
|
||||||
|
<Link href="/dating">{t.footer.dating}</Link>
|
||||||
|
<Link href="/gigs">{t.footer.gigs}</Link>
|
||||||
|
<Link href="/digital">{t.footer.digital}</Link>
|
||||||
|
<Link href="/pricing">{t.footer.pricing}</Link>
|
||||||
|
<Link href="/about">{t.footer.about}</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<h4>工具</h4>
|
<h4>{t.footer.tools}</h4>
|
||||||
<Link href="/tools">全部工具箱</Link>
|
<Link href="/tools">{t.footer.toolkit}</Link>
|
||||||
<Link href="/plan">计划中心</Link>
|
<Link href="/book">{t.footer.ebook}</Link>
|
||||||
<Link href="/compare">对比台</Link>
|
{!isHome && (
|
||||||
<Link href="/#calculator">费用计算器</Link>
|
<>
|
||||||
<Link href="/changelog">更新日志</Link>
|
<Link href="/plan">{t.footer.planHub}</Link>
|
||||||
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
|
<Link href="/compare">{t.footer.compareHub}</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Link href="/changelog">{t.footer.changelog}</Link>
|
||||||
|
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<h4>账户</h4>
|
<h4>{t.footer.account}</h4>
|
||||||
<Link href="/login">登录 / 注册</Link>
|
<Link href={`/login?next=${encodeURIComponent(loginNext)}`}>{t.footer.login}</Link>
|
||||||
<Link href="/profile">用户中心</Link>
|
<Link href="/profile">{t.footer.profile}</Link>
|
||||||
<Link href="/privacy">隐私政策</Link>
|
<Link href="/join">{t.footer.join}</Link>
|
||||||
|
<Link href="/chat">{t.footer.chat}</Link>
|
||||||
|
<Link href="/notifications">{t.footer.notifications}</Link>
|
||||||
|
<Link href="/privacy">{t.footer.privacy}</Link>
|
||||||
<a href="mailto:hello@nomadro.com">hello@nomadro.com</a>
|
<a href="mailto:hello@nomadro.com">hello@nomadro.com</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
55
frontend/src/components/GigsClient.tsx
Normal file
55
frontend/src/components/GigsClient.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useToast } from "@/lib/toast";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import type { GigItem } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [msg, setMsg] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const apply = async (id: string) => {
|
||||||
|
if (!token) {
|
||||||
|
toast(t.gigs.loginFirst, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = msg[id]?.trim();
|
||||||
|
if (!message) return;
|
||||||
|
try {
|
||||||
|
await api.applyGig(token, id, message);
|
||||||
|
toast(t.gigs.applyOk, "success");
|
||||||
|
} catch {
|
||||||
|
toast(t.gigs.applyFail, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gigs-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav"><Link href="/tools">← {t.nav.tools}</Link></nav>
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">{t.gigs.tag}</span>
|
||||||
|
<h1>{t.gigs.title}</h1>
|
||||||
|
<p>{t.gigs.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="digital-jobs-grid reveal">
|
||||||
|
{gigs.map((g) => (
|
||||||
|
<article key={g.id} className="digital-job-card">
|
||||||
|
<h3>{g.title}</h3>
|
||||||
|
<p>{g.description}</p>
|
||||||
|
<p className="digital-job-meta">{g.poster} · {g.budget} · {g.deadline}</p>
|
||||||
|
<textarea rows={2} placeholder={t.gigs.applyPlaceholder} value={msg[g.id] || ""} onChange={(e) => setMsg({ ...msg, [g.id]: e.target.value })} />
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={() => apply(g.id)}>{t.gigs.apply}</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { TOOL_LINKS } from "@/lib/tools";
|
import { TOOL_LINKS } from "@/lib/tools";
|
||||||
import type { SearchResult } from "@/lib/types";
|
import type { SearchResult } from "@/lib/types";
|
||||||
@ -18,16 +18,27 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
const PAGE_RESULTS: SearchResult[] = [
|
const PAGE_RESULTS: SearchResult[] = [
|
||||||
{ type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" },
|
{ type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" },
|
||||||
{ type: "page", title: "城市对比台", subtitle: "并排对比费用网速气候,写入计划", emoji: "⚖️", url: "/compare" },
|
{ type: "page", title: "城市对比台", subtitle: "并排对比费用网速气候,写入计划", emoji: "⚖️", url: "/compare" },
|
||||||
|
{ type: "page", title: "下一站决策", subtitle: "预算网速气候智能推荐", emoji: "🧭", url: "/next-stop" },
|
||||||
|
{ type: "page", title: "游民活动", subtitle: "线上圆桌与线下聚会", emoji: "🎉", url: "/meetups" },
|
||||||
|
{ type: "page", title: "社区讨论", subtitle: "签证远程住宿经验交流", emoji: "💬", url: "/community" },
|
||||||
|
{ type: "page", title: "游民匹配", subtitle: "滑动匹配同路游民", emoji: "💕", url: "/dating" },
|
||||||
|
{ type: "page", title: "私信", subtitle: "与匹配成功的游民聊天", emoji: "✉️", url: "/chat" },
|
||||||
|
{ type: "page", title: "游民学院", subtitle: "课程电子书与远程岗位", emoji: "🎓", url: "/digital" },
|
||||||
|
{ type: "page", title: "赏金任务", subtitle: "远程小任务接单", emoji: "💼", url: "/gigs" },
|
||||||
|
{ type: "page", title: "会员定价", subtitle: "免费版与 VIP 方案", emoji: "💎", url: "/pricing" },
|
||||||
|
{ type: "page", title: "通知中心", subtitle: "匹配与社区动态", emoji: "🔔", url: "/notifications" },
|
||||||
|
{ type: "page", title: "开通会员", subtitle: "VIP 匹配直播与课程", emoji: "✨", url: "/join" },
|
||||||
{ type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" },
|
{ type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" },
|
||||||
{ type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" },
|
{ type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" },
|
||||||
{ type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },
|
{ type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },
|
||||||
{ type: "page", title: "隐私政策", subtitle: "Cookie 与数据说明", emoji: "🔒", url: "/privacy" },
|
{ type: "page", title: "隐私政策", subtitle: "Cookie 与数据说明", emoji: "🔒", url: "/privacy" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function searchLocal(q: string): SearchResult[] {
|
function searchLocal(q: string, hidePlanCompare: boolean): SearchResult[] {
|
||||||
const s = q.toLowerCase();
|
const s = q.toLowerCase();
|
||||||
const tools = TOOL_LINKS
|
const tools = TOOL_LINKS
|
||||||
.filter((t) => t.title.toLowerCase().includes(s) || t.desc.toLowerCase().includes(s) || t.id.includes(s))
|
.filter((t) => t.title.toLowerCase().includes(s) || t.desc.toLowerCase().includes(s) || t.id.includes(s))
|
||||||
|
.filter((t) => !hidePlanCompare || (t.href !== "/plan" && t.href !== "/compare"))
|
||||||
.map((t) => ({
|
.map((t) => ({
|
||||||
type: "tool",
|
type: "tool",
|
||||||
title: t.title,
|
title: t.title,
|
||||||
@ -37,7 +48,7 @@ function searchLocal(q: string): SearchResult[] {
|
|||||||
}));
|
}));
|
||||||
const pages = PAGE_RESULTS.filter(
|
const pages = PAGE_RESULTS.filter(
|
||||||
(p) => p.title.toLowerCase().includes(s) || p.subtitle.toLowerCase().includes(s)
|
(p) => p.title.toLowerCase().includes(s) || p.subtitle.toLowerCase().includes(s)
|
||||||
);
|
).filter((p) => !hidePlanCompare || (p.url !== "/plan" && p.url !== "/compare"));
|
||||||
return [...tools, ...pages];
|
return [...tools, ...pages];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,6 +59,7 @@ export default function GlobalSearch() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [activeIdx, setActiveIdx] = useState(0);
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isHome = usePathname() === "/";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@ -64,7 +76,7 @@ export default function GlobalSearch() {
|
|||||||
const doSearch = useCallback(async (q: string) => {
|
const doSearch = useCallback(async (q: string) => {
|
||||||
if (!q.trim()) { setResults([]); return; }
|
if (!q.trim()) { setResults([]); return; }
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const local = searchLocal(q);
|
const local = searchLocal(q, isHome);
|
||||||
try {
|
try {
|
||||||
const data = await api.search(q);
|
const data = await api.search(q);
|
||||||
const merged = [...local, ...data].slice(0, 20);
|
const merged = [...local, ...data].slice(0, 20);
|
||||||
@ -76,7 +88,7 @@ export default function GlobalSearch() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isHome]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => doSearch(query), 250);
|
const timer = setTimeout(() => doSearch(query), 250);
|
||||||
|
|||||||
@ -2,20 +2,34 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import type { Stats } from "@/lib/types";
|
import type { Stats } from "@/lib/types";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
const PHRASES = [
|
const PHRASES_ZH = [
|
||||||
"数字游民不是逃离生活,而是用科技重新定义生活。",
|
"数字游民不是逃离生活,而是用科技重新定义生活。",
|
||||||
"在巴厘岛写代码,在里斯本开会议,在清迈看日落 ☀️",
|
"在巴厘岛写代码,在里斯本开会议,在清迈看日落 ☀️",
|
||||||
"世界那么大,你的办公室可以在任何地方 🌍",
|
"世界那么大,你的办公室可以在任何地方 🌍",
|
||||||
"远程工作 + 全球旅居 = 无限可能 ✨",
|
"远程工作 + 全球旅居 = 无限可能 ✨",
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props { stats: Stats }
|
const PHRASES_EN = [
|
||||||
|
"Digital nomad life isn't escape — it's redesigning how you live.",
|
||||||
|
"Ship from Bali, meet from Lisbon, watch sunsets in Chiang Mai ☀️",
|
||||||
|
"The world is big. Your office can be anywhere 🌍",
|
||||||
|
"Remote work + global living = endless options ✨",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stats: Stats;
|
||||||
|
onOpenMatcher?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Hero({ stats, onOpenMatcher }: Props) {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
export default function Hero({ stats }: Props) {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = document.getElementById("typewriter");
|
const el = document.getElementById("typewriter");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
const PHRASES = locale === "en" ? PHRASES_EN : PHRASES_ZH;
|
||||||
let phraseIdx = 0, charIdx = 0, deleting = false, timer: ReturnType<typeof setTimeout>;
|
let phraseIdx = 0, charIdx = 0, deleting = false, timer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const type = () => {
|
const type = () => {
|
||||||
@ -32,9 +46,9 @@ export default function Hero({ stats }: Props) {
|
|||||||
timer = setTimeout(type, 30);
|
timer = setTimeout(type, 30);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
timer = setTimeout(type, 2000);
|
timer = setTimeout(type, 400);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, [locale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const counters = document.querySelectorAll(".stat-number");
|
const counters = document.querySelectorAll(".stat-number");
|
||||||
@ -69,10 +83,10 @@ export default function Hero({ stats }: Props) {
|
|||||||
<div className="hero-content">
|
<div className="hero-content">
|
||||||
<div className="hero-badge reveal">
|
<div className="hero-badge reveal">
|
||||||
<span className="badge-dot" />
|
<span className="badge-dot" />
|
||||||
nomadro · 全球 {stats.total_nomads} 数字游民正在路上
|
nomadro · {stats.total_nomads} {t.hero.badge}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="hero-title reveal">
|
<h1 className="hero-title reveal">
|
||||||
用一行代码<br /><span className="gradient-text">环游世界</span> 🌏
|
{t.hero.titleLine1}<br /><span className="gradient-text">{t.hero.titleLine2}</span> 🌏
|
||||||
</h1>
|
</h1>
|
||||||
<p className="hero-subtitle reveal">
|
<p className="hero-subtitle reveal">
|
||||||
<span id="typewriter" /><span className="typewriter-cursor">|</span>
|
<span id="typewriter" /><span className="typewriter-cursor">|</span>
|
||||||
@ -105,9 +119,9 @@ export default function Hero({ stats }: Props) {
|
|||||||
<path d="M180 155 Q220 140 260 215" fill="none" stroke="url(#orbitGrad)" strokeWidth="2" strokeDasharray="6 4" className="flight-path" />
|
<path d="M180 155 Q220 140 260 215" fill="none" stroke="url(#orbitGrad)" strokeWidth="2" strokeDasharray="6 4" className="flight-path" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="floating-cards">
|
<div className="floating-cards">
|
||||||
<div className="float-card fc-1">🏝️ 巴厘岛<br /><small>WiFi 98Mbps</small></div>
|
<div className="float-card fc-1">🏝️ Bali<br /><small>WiFi 98Mbps</small></div>
|
||||||
<div className="float-card fc-2">☕ 里斯本<br /><small>€800/月</small></div>
|
<div className="float-card fc-2">☕ Lisbon<br /><small>€800/mo</small></div>
|
||||||
<div className="float-card fc-3">🏔️ 清迈<br /><small>⭐ 4.9</small></div>
|
<div className="float-card fc-3">🏔️ Chiang Mai<br /><small>⭐ 4.9</small></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -116,36 +130,37 @@ export default function Hero({ stats }: Props) {
|
|||||||
<div className="hero-bottom reveal">
|
<div className="hero-bottom reveal">
|
||||||
<div className="hero-actions">
|
<div className="hero-actions">
|
||||||
<a href="#destinations" className="btn btn-primary">
|
<a href="#destinations" className="btn btn-primary">
|
||||||
<span>探索目的地</span>
|
<span>{t.hero.ctaExplore}</span>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
|
||||||
</a>
|
</a>
|
||||||
<a href="/plan" className="btn btn-ghost"><span>🗓️ 旅居计划</span></a>
|
{onOpenMatcher && (
|
||||||
<a href="/compare" className="btn btn-ghost"><span>⚖️ 城市对比</span></a>
|
<button type="button" className="btn btn-ghost hero-matcher-btn" onClick={onOpenMatcher}>
|
||||||
<button className="btn btn-ghost hero-matcher-btn" onClick={() => {
|
<span>{t.hero.ctaMatch}</span>
|
||||||
window.dispatchEvent(new CustomEvent("open-matcher"));
|
</button>
|
||||||
}}><span>🎯 智能匹配</span></button>
|
)}
|
||||||
|
<a href="#path" className="btn btn-ghost hero-path-link"><span>{t.journey.title}</span></a>
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-stats">
|
<div className="hero-stats">
|
||||||
<div className="stat-item">
|
<div className="stat-item">
|
||||||
<span className="stat-number" data-target={stats.countries}>0</span>
|
<span className="stat-number" data-target={stats.countries}>0</span>
|
||||||
<span className="stat-label">🗺️ 可探索国家</span>
|
<span className="stat-label">{t.hero.statCountries}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-divider" />
|
<div className="stat-divider" />
|
||||||
<div className="stat-item">
|
<div className="stat-item">
|
||||||
<span className="stat-number" data-target={stats.avg_cost}>0</span>
|
<span className="stat-number" data-target={stats.avg_cost}>0</span>
|
||||||
<span className="stat-label">💰 平均月生活费 (千元)</span>
|
<span className="stat-label">{t.hero.statCost}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-divider" />
|
<div className="stat-divider" />
|
||||||
<div className="stat-item">
|
<div className="stat-item">
|
||||||
<span className="stat-number" data-target={stats.satisfaction}>0</span>
|
<span className="stat-number" data-target={stats.satisfaction}>0</span>
|
||||||
<span className="stat-label">😊 生活满意度 %</span>
|
<span className="stat-label">{t.hero.statHappy}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="scroll-indicator">
|
<div className="scroll-indicator">
|
||||||
<span>向下滚动</span>
|
<span>{t.hero.scroll}</span>
|
||||||
<div className="scroll-arrow">
|
<div className="scroll-arrow">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12l7 7 7-7" /></svg>
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12l7 7 7-7" /></svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, type ComponentType } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types";
|
import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types";
|
||||||
import Loader from "./Loader";
|
import Loader from "./Loader";
|
||||||
@ -9,6 +8,7 @@ import ParticleCanvas from "./ParticleCanvas";
|
|||||||
import Navbar from "./Navbar";
|
import Navbar from "./Navbar";
|
||||||
import TickerBar from "./TickerBar";
|
import TickerBar from "./TickerBar";
|
||||||
import Hero from "./Hero";
|
import Hero from "./Hero";
|
||||||
|
import JourneyStrip from "./JourneyStrip";
|
||||||
import WorldMap from "./WorldMap";
|
import WorldMap from "./WorldMap";
|
||||||
import Destinations from "./Destinations";
|
import Destinations from "./Destinations";
|
||||||
import GlobalSearch from "./GlobalSearch";
|
import GlobalSearch from "./GlobalSearch";
|
||||||
@ -19,69 +19,12 @@ import BackToTop from "./BackToTop";
|
|||||||
import MatcherTrigger from "./MatcherTrigger";
|
import MatcherTrigger from "./MatcherTrigger";
|
||||||
import WhenVisible from "./WhenVisible";
|
import WhenVisible from "./WhenVisible";
|
||||||
|
|
||||||
const lazy = <P extends object>(loader: () => Promise<{ default: ComponentType<P> }>) =>
|
const lazy = <P extends object>(loader: () => Promise<{ default: import("react").ComponentType<P> }>) =>
|
||||||
dynamic(loader, { ssr: false, loading: () => null });
|
dynamic(loader, { ssr: false, loading: () => null });
|
||||||
|
|
||||||
const SpinGlobe = lazy(() => import("./SpinGlobe"));
|
|
||||||
const DailyQuote = lazy(() => import("./DailyQuote"));
|
|
||||||
const ToolsStrip = lazy(() => import("./ToolsStrip"));
|
|
||||||
const TripPlanner = lazy(() => import("./TripPlanner"));
|
|
||||||
const Calculator = lazy(() => import("./Calculator"));
|
|
||||||
const TimezoneBoard = lazy(() => import("./TimezoneBoard"));
|
|
||||||
const JetLagEstimator = lazy(() => import("./JetLagEstimator"));
|
|
||||||
const CostCompare = lazy(() => import("./CostCompare"));
|
|
||||||
const SavingsGoal = lazy(() => import("./SavingsGoal"));
|
|
||||||
const RunwayCalculator = lazy(() => import("./RunwayCalculator"));
|
|
||||||
const FirstMonthCost = lazy(() => import("./FirstMonthCost"));
|
|
||||||
const ExpenseTracker = lazy(() => import("./ExpenseTracker"));
|
|
||||||
const TaxDayCounter = lazy(() => import("./TaxDayCounter"));
|
|
||||||
const FlightCost = lazy(() => import("./FlightCost"));
|
|
||||||
const CurrencyConverter = lazy(() => import("./CurrencyConverter"));
|
|
||||||
const MeetingWindow = lazy(() => import("./MeetingWindow"));
|
|
||||||
const DaylightWorkHours = lazy(() => import("./DaylightWorkHours"));
|
|
||||||
const WifiWorkScore = lazy(() => import("./WifiWorkScore"));
|
|
||||||
const WorkSpotCost = lazy(() => import("./WorkSpotCost"));
|
|
||||||
const DataBudgetPlanner = lazy(() => import("./DataBudgetPlanner"));
|
|
||||||
const FocusTimer = lazy(() => import("./FocusTimer"));
|
|
||||||
const NomadScore = lazy(() => import("./NomadScore"));
|
|
||||||
const Lifestyle = lazy(() => import("./Lifestyle"));
|
|
||||||
const Charts = lazy(() => import("./Charts"));
|
|
||||||
const VisaSection = lazy(() => import("./VisaSection"));
|
const VisaSection = lazy(() => import("./VisaSection"));
|
||||||
const VisaStayCountdown = lazy(() => import("./VisaStayCountdown"));
|
|
||||||
const InsuranceGuide = lazy(() => import("./InsuranceGuide"));
|
|
||||||
const EmergencyContacts = lazy(() => import("./EmergencyContacts"));
|
|
||||||
const AtmCashGuide = lazy(() => import("./AtmCashGuide"));
|
|
||||||
const FoodWaterTips = lazy(() => import("./FoodWaterTips"));
|
|
||||||
const CoworkingGuide = lazy(() => import("./CoworkingGuide"));
|
|
||||||
const HousingGuide = lazy(() => import("./HousingGuide"));
|
|
||||||
const SimGuide = lazy(() => import("./SimGuide"));
|
|
||||||
const PowerPlugGuide = lazy(() => import("./PowerPlugGuide"));
|
|
||||||
const CafeGuide = lazy(() => import("./CafeGuide"));
|
|
||||||
const SeasonGuide = lazy(() => import("./SeasonGuide"));
|
|
||||||
const PackingChecklist = lazy(() => import("./PackingChecklist"));
|
|
||||||
const BagWeightEstimator = lazy(() => import("./BagWeightEstimator"));
|
|
||||||
const ArrivalChecklist = lazy(() => import("./ArrivalChecklist"));
|
|
||||||
const Phrasebook = lazy(() => import("./Phrasebook"));
|
|
||||||
const CityNotes = lazy(() => import("./CityNotes"));
|
|
||||||
const NomadEvents = lazy(() => import("./NomadEvents"));
|
|
||||||
const WeekendIdeas = lazy(() => import("./WeekendIdeas"));
|
|
||||||
const DepositReturnChecklist = lazy(() => import("./DepositReturnChecklist"));
|
|
||||||
const HydrationTracker = lazy(() => import("./HydrationTracker"));
|
|
||||||
const TipGuide = lazy(() => import("./TipGuide"));
|
|
||||||
const BillSplit = lazy(() => import("./BillSplit"));
|
|
||||||
const AirportTransferPick = lazy(() => import("./AirportTransferPick"));
|
|
||||||
const NomadMoodCheck = lazy(() => import("./NomadMoodCheck"));
|
|
||||||
const RemoteDayPlan = lazy(() => import("./RemoteDayPlan"));
|
|
||||||
const LaundryDay = lazy(() => import("./LaundryDay"));
|
|
||||||
const LocalScamAlerts = lazy(() => import("./LocalScamAlerts"));
|
|
||||||
const ColivingQuestions = lazy(() => import("./ColivingQuestions"));
|
|
||||||
const SleepWindDown = lazy(() => import("./SleepWindDown"));
|
|
||||||
const InvoiceFxHelper = lazy(() => import("./InvoiceFxHelper"));
|
|
||||||
const RainyDayBackup = lazy(() => import("./RainyDayBackup"));
|
|
||||||
const Tools = lazy(() => import("./Tools"));
|
|
||||||
const BlogSection = lazy(() => import("./BlogSection"));
|
const BlogSection = lazy(() => import("./BlogSection"));
|
||||||
const FAQSection = lazy(() => import("./FAQSection"));
|
const FAQSection = lazy(() => import("./FAQSection"));
|
||||||
const Community = lazy(() => import("./Community"));
|
|
||||||
const DestinationMatcher = lazy(() => import("./DestinationMatcher"));
|
const DestinationMatcher = lazy(() => import("./DestinationMatcher"));
|
||||||
const CookieConsent = lazy(() => import("./CookieConsent"));
|
const CookieConsent = lazy(() => import("./CookieConsent"));
|
||||||
const NomadTips = lazy(() => import("./NomadTips"));
|
const NomadTips = lazy(() => import("./NomadTips"));
|
||||||
@ -102,7 +45,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HomeClient(props: Props) {
|
export default function HomeClient(props: Props) {
|
||||||
const [compareList, setCompareList] = useState<string[]>([]);
|
|
||||||
const [matcherOpen, setMatcherOpen] = useState(false);
|
const [matcherOpen, setMatcherOpen] = useState(false);
|
||||||
const [chromeReady, setChromeReady] = useState(false);
|
const [chromeReady, setChromeReady] = useState(false);
|
||||||
|
|
||||||
@ -117,28 +59,22 @@ export default function HomeClient(props: Props) {
|
|||||||
const ric = window.requestIdleCallback
|
const ric = window.requestIdleCallback
|
||||||
? window.requestIdleCallback(start, { timeout: 2800 })
|
? window.requestIdleCallback(start, { timeout: 2800 })
|
||||||
: 0;
|
: 0;
|
||||||
const t = window.setTimeout(start, 1200);
|
const timer = window.setTimeout(start, 1200);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(t);
|
clearTimeout(timer);
|
||||||
if (ric && window.cancelIdleCallback) window.cancelIdleCallback(ric);
|
if (ric && window.cancelIdleCallback) window.cancelIdleCallback(ric);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Above-fold reveal; keep watching briefly for late client mounts
|
|
||||||
const mark = (root: ParentNode = document) => {
|
|
||||||
root.querySelectorAll(".reveal:not(.visible)").forEach((el, i) => {
|
|
||||||
(el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.08}s`;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
mark();
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => entries.forEach((e) => {
|
(entries) =>
|
||||||
if (e.isIntersecting) {
|
entries.forEach((e) => {
|
||||||
e.target.classList.add("visible");
|
if (e.isIntersecting) {
|
||||||
observer.unobserve(e.target);
|
e.target.classList.add("visible");
|
||||||
}
|
observer.unobserve(e.target);
|
||||||
}),
|
}
|
||||||
|
}),
|
||||||
{ threshold: 0.08, rootMargin: "0px 0px -40px 0px" }
|
{ threshold: 0.08, rootMargin: "0px 0px -40px 0px" }
|
||||||
);
|
);
|
||||||
const observeAll = () => {
|
const observeAll = () => {
|
||||||
@ -153,122 +89,54 @@ export default function HomeClient(props: Props) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleCompare = (slug: string) => {
|
const openMatcher = () => setMatcherOpen(true);
|
||||||
setCompareList((prev) => {
|
|
||||||
if (prev.includes(slug)) return prev.filter((s) => s !== slug);
|
|
||||||
if (prev.length >= 4) return prev;
|
|
||||||
return [...prev, slug];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const runCompare = () => {
|
|
||||||
if (compareList.length < 2) return;
|
|
||||||
window.location.href = `/compare?cities=${compareList.join(",")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="home-flow">
|
||||||
<ScrollProgress />
|
<ScrollProgress />
|
||||||
<Loader />
|
<Loader />
|
||||||
<ParticleCanvas />
|
<ParticleCanvas />
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<GlobalSearch />
|
<GlobalSearch />
|
||||||
<TickerBar messages={props.ticker} />
|
<TickerBar messages={props.ticker} />
|
||||||
<Hero stats={props.stats} />
|
|
||||||
|
<Hero stats={props.stats} onOpenMatcher={openMatcher} />
|
||||||
|
<JourneyStrip onOpenMatcher={openMatcher} />
|
||||||
<WorldMap destinations={props.destinations} />
|
<WorldMap destinations={props.destinations} />
|
||||||
<Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} />
|
<Destinations destinations={props.destinations} onOpenMatcher={openMatcher} />
|
||||||
<WhenVisible minHeight={280}><SpinGlobe destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible minHeight={120}><DailyQuote /></WhenVisible>
|
<WhenVisible minHeight={280}>
|
||||||
<WhenVisible minHeight={140}><ToolsStrip /></WhenVisible>
|
<VisaSection visas={props.visas} />
|
||||||
<WhenVisible><TimezoneBoard destinations={props.destinations} /></WhenVisible>
|
</WhenVisible>
|
||||||
<WhenVisible><JetLagEstimator destinations={props.destinations} /></WhenVisible>
|
<WhenVisible minHeight={220}>
|
||||||
<WhenVisible minHeight={420}><TripPlanner destinations={props.destinations} /></WhenVisible>
|
<BlogSection posts={props.blog} limit={3} />
|
||||||
<WhenVisible minHeight={360}><Calculator destinations={props.destinations} /></WhenVisible>
|
</WhenVisible>
|
||||||
<WhenVisible><CostCompare destinations={props.destinations} /></WhenVisible>
|
<WhenVisible minHeight={200}>
|
||||||
<WhenVisible><SavingsGoal destinations={props.destinations} /></WhenVisible>
|
<FAQSection faqs={props.faqs} limit={5} />
|
||||||
<WhenVisible><RunwayCalculator destinations={props.destinations} /></WhenVisible>
|
</WhenVisible>
|
||||||
<WhenVisible><FirstMonthCost destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><ExpenseTracker /></WhenVisible>
|
|
||||||
<WhenVisible><TaxDayCounter /></WhenVisible>
|
|
||||||
<WhenVisible><FlightCost destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><CurrencyConverter /></WhenVisible>
|
|
||||||
<WhenVisible><MeetingWindow destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><DaylightWorkHours destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><WifiWorkScore destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><WorkSpotCost destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><DataBudgetPlanner /></WhenVisible>
|
|
||||||
<WhenVisible><FocusTimer /></WhenVisible>
|
|
||||||
<WhenVisible><RemoteDayPlan /></WhenVisible>
|
|
||||||
<WhenVisible><SleepWindDown /></WhenVisible>
|
|
||||||
<WhenVisible><NomadMoodCheck /></WhenVisible>
|
|
||||||
<WhenVisible><HydrationTracker destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><NomadScore /></WhenVisible>
|
|
||||||
<WhenVisible><Lifestyle /></WhenVisible>
|
|
||||||
<WhenVisible><Charts {...props.charts} /></WhenVisible>
|
|
||||||
<WhenVisible><VisaSection visas={props.visas} /></WhenVisible>
|
|
||||||
<WhenVisible><VisaStayCountdown destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><InsuranceGuide /></WhenVisible>
|
|
||||||
<WhenVisible><EmergencyContacts destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><AtmCashGuide destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><FoodWaterTips destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><LocalScamAlerts destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><TipGuide destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><BillSplit /></WhenVisible>
|
|
||||||
<WhenVisible><InvoiceFxHelper /></WhenVisible>
|
|
||||||
<WhenVisible><LaundryDay destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><CoworkingGuide /></WhenVisible>
|
|
||||||
<WhenVisible><HousingGuide destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><ColivingQuestions /></WhenVisible>
|
|
||||||
<WhenVisible><DepositReturnChecklist /></WhenVisible>
|
|
||||||
<WhenVisible><SimGuide destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><PowerPlugGuide destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><CafeGuide destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><SeasonGuide destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><WeekendIdeas destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><RainyDayBackup destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><PackingChecklist /></WhenVisible>
|
|
||||||
<WhenVisible><BagWeightEstimator /></WhenVisible>
|
|
||||||
<WhenVisible><ArrivalChecklist destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><AirportTransferPick destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><Phrasebook destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><CityNotes destinations={props.destinations} /></WhenVisible>
|
|
||||||
<WhenVisible><NomadEvents /></WhenVisible>
|
|
||||||
<WhenVisible><Tools tools={props.tools} /></WhenVisible>
|
|
||||||
<WhenVisible><BlogSection posts={props.blog} /></WhenVisible>
|
|
||||||
<WhenVisible><FAQSection faqs={props.faqs} /></WhenVisible>
|
|
||||||
<WhenVisible><Community testimonials={props.testimonials} /></WhenVisible>
|
|
||||||
<Footer />
|
<Footer />
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
<SectionNav />
|
<SectionNav />
|
||||||
<MatcherTrigger onClick={() => setMatcherOpen(true)} />
|
<MatcherTrigger onClick={openMatcher} />
|
||||||
|
|
||||||
{matcherOpen && (
|
{matcherOpen && (
|
||||||
<DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} />
|
<DestinationMatcher
|
||||||
|
destinations={props.destinations}
|
||||||
|
open={matcherOpen}
|
||||||
|
onClose={() => setMatcherOpen(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{chromeReady && (
|
{chromeReady && (
|
||||||
<>
|
<>
|
||||||
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
|
<KeyboardShortcuts onOpenMatcher={openMatcher} />
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
<NomadTips />
|
<NomadTips />
|
||||||
<FeedbackWidget />
|
<FeedbackWidget />
|
||||||
<OnboardingTour />
|
<OnboardingTour />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{compareList.length > 0 && (
|
|
||||||
<div className="compare-bar">
|
|
||||||
<span>⚖️ 已选 {compareList.length}/4</span>
|
|
||||||
<button className="btn btn-primary" onClick={runCompare} disabled={compareList.length < 2}>打开对比台</button>
|
|
||||||
<Link
|
|
||||||
href={compareList.length >= 2 ? `/compare?cities=${compareList.join(",")}` : "/compare"}
|
|
||||||
className={`btn btn-ghost${compareList.length < 2 ? " disabled" : ""}`}
|
|
||||||
aria-disabled={compareList.length < 2}
|
|
||||||
onClick={(e) => { if (compareList.length < 2) e.preventDefault(); }}
|
|
||||||
>
|
|
||||||
完整对比页
|
|
||||||
</Link>
|
|
||||||
<button className="btn btn-ghost" onClick={() => setCompareList([])}>清空</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
91
frontend/src/components/JoinClient.tsx
Normal file
91
frontend/src/components/JoinClient.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useToast } from "@/lib/toast";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export default function JoinClient() {
|
||||||
|
const { user, token } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [city, setCity] = useState("清迈");
|
||||||
|
const [bio, setBio] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!token) {
|
||||||
|
router.push("/login?next=/join");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.socialJoin(token, { city, bio, lookingFor: ["friends", "explore"] });
|
||||||
|
toast(t.join.profileOk, "success");
|
||||||
|
} catch {
|
||||||
|
toast(t.join.profileFail, "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pay = async () => {
|
||||||
|
if (!token) {
|
||||||
|
router.push("/login?next=/join");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.createPayment(token, `${window.location.origin}/join/paid`, "join");
|
||||||
|
localStorage.setItem("nomadro-pay-order", res.order_id);
|
||||||
|
window.location.href = res.redirect_url;
|
||||||
|
} catch {
|
||||||
|
toast(t.join.payFail, "error");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="join-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">{t.join.tag}</span>
|
||||||
|
<h1>{t.join.title}</h1>
|
||||||
|
<p>{t.join.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="join-grid reveal">
|
||||||
|
<div className="join-card">
|
||||||
|
<h3>{t.join.step1}</h3>
|
||||||
|
<label>{t.join.city}</label>
|
||||||
|
<input value={city} onChange={(e) => setCity(e.target.value)} />
|
||||||
|
<label>{t.join.bio}</label>
|
||||||
|
<textarea value={bio} onChange={(e) => setBio(e.target.value)} rows={3} />
|
||||||
|
<button type="button" className="btn btn-primary" disabled={loading} onClick={submit}>
|
||||||
|
{t.join.saveProfile}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="join-card join-card-vip">
|
||||||
|
<h3>{t.join.step2}</h3>
|
||||||
|
<p>{t.join.vipDesc}</p>
|
||||||
|
<ul className="join-perks">
|
||||||
|
<li>✨ {t.join.perk1}</li>
|
||||||
|
<li>💬 {t.join.perk2}</li>
|
||||||
|
<li>🎙️ {t.join.perk3}</li>
|
||||||
|
<li>📚 {t.join.perk4}</li>
|
||||||
|
</ul>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={loading} onClick={pay}>
|
||||||
|
{t.join.payBtn}
|
||||||
|
</button>
|
||||||
|
{!user && <Link href="/login?next=/join" className="join-login-hint">{t.nav.login}</Link>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/src/components/JoinPaidClient.tsx
Normal file
59
frontend/src/components/JoinPaidClient.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export default function JoinPaidClient() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [status, setStatus] = useState<"loading" | "ok" | "fail">("loading");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const orderId = searchParams.get("order_id") || localStorage.getItem("nomadro-pay-order");
|
||||||
|
if (!token || !orderId) {
|
||||||
|
setStatus("fail");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tries = 0;
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
await api.completePayment(token, orderId);
|
||||||
|
localStorage.removeItem("nomadro-pay-order");
|
||||||
|
setStatus("ok");
|
||||||
|
} catch {
|
||||||
|
tries += 1;
|
||||||
|
if (tries < 5) setTimeout(run, 800);
|
||||||
|
else setStatus("fail");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void run();
|
||||||
|
}, [token, searchParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="join-page">
|
||||||
|
<div className="container">
|
||||||
|
<div className="join-paid reveal">
|
||||||
|
{status === "loading" && <p>{t.join.paidLoading}</p>}
|
||||||
|
{status === "ok" && (
|
||||||
|
<>
|
||||||
|
<h2>🎉 {t.join.paidOk}</h2>
|
||||||
|
<Link href="/dating" className="btn btn-primary">{t.join.goDating}</Link>
|
||||||
|
<Link href="/digital" className="btn">{t.join.goDigital}</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "fail" && (
|
||||||
|
<>
|
||||||
|
<h2>{t.join.paidFail}</h2>
|
||||||
|
<Link href="/join" className="btn btn-primary">{t.join.retry}</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
frontend/src/components/JourneyStrip.tsx
Normal file
86
frontend/src/components/JourneyStrip.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onOpenMatcher: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Homepage discovery path — no plan/compare on home */
|
||||||
|
export default function JourneyStrip({ onOpenMatcher }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
n: "01",
|
||||||
|
emoji: "🌍",
|
||||||
|
title: t.journey.step1Title,
|
||||||
|
desc: t.journey.step1Desc,
|
||||||
|
href: "/#destinations",
|
||||||
|
cta: t.journey.step1Cta,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: "02",
|
||||||
|
emoji: "🎯",
|
||||||
|
title: t.journey.step2Title,
|
||||||
|
desc: t.journey.step2Desc,
|
||||||
|
onClick: onOpenMatcher,
|
||||||
|
cta: t.journey.matchCta,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: "03",
|
||||||
|
emoji: "📋",
|
||||||
|
title: t.journey.step3Title,
|
||||||
|
desc: t.journey.step3Desc,
|
||||||
|
href: "/#visa",
|
||||||
|
cta: t.journey.step3Cta,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: "04",
|
||||||
|
emoji: "🛠️",
|
||||||
|
title: t.journey.step4Title,
|
||||||
|
desc: t.journey.step4Desc,
|
||||||
|
href: "/tools",
|
||||||
|
cta: t.journey.step4Cta,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="journey-strip section-compact" id="path" aria-label={t.journey.title}>
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header journey-head reveal">
|
||||||
|
<span className="section-tag">{t.journey.tag}</span>
|
||||||
|
<h2>{t.journey.title}</h2>
|
||||||
|
<p>{t.journey.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<ol className="journey-rail reveal">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<li key={s.n} className="journey-step">
|
||||||
|
{i > 0 && <span className="journey-connector" aria-hidden />}
|
||||||
|
<div className="journey-card">
|
||||||
|
<div className="journey-card-top">
|
||||||
|
<span className="journey-n">{s.n}</span>
|
||||||
|
<span className="journey-emoji">{s.emoji}</span>
|
||||||
|
</div>
|
||||||
|
<h3>{s.title}</h3>
|
||||||
|
<p>{s.desc}</p>
|
||||||
|
<div className="journey-actions">
|
||||||
|
{"href" in s && s.href ? (
|
||||||
|
<Link href={s.href} className="btn btn-primary btn-sm">
|
||||||
|
{s.cta}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={s.onClick}>
|
||||||
|
{s.cta}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user