Compare commits
No commits in common. "dev2" and "dev1" have entirely different histories.
7
.gitignore
vendored
7
.gitignore
vendored
@ -29,13 +29,6 @@ pocketbase/pb_data/*
|
||||
frontend/.next/
|
||||
frontend/out/
|
||||
|
||||
# Mini programs
|
||||
wemini/node_modules/
|
||||
wemini/dist/
|
||||
dymini/node_modules/
|
||||
dymini/dist/
|
||||
redmini/dist/
|
||||
|
||||
# Python
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
26
README.md
26
README.md
@ -46,24 +46,11 @@ nomadweb/
|
||||
│ ├── app/routers/ # REST
|
||||
│ ├── app/services/ # PocketBase、S3、城市深度等
|
||||
│ └── app/data/ # Mock 降级(开发)
|
||||
├── wemini/ # 微信小程序(Taro → weapp,可联网)
|
||||
├── dymini/ # 抖音小程序(Taro → tt,可联网)
|
||||
├── redmini/ # 小红书 miniTool(纯前端离线,禁止联网)
|
||||
├── deploy/ # Caddy、systemd、env 模板
|
||||
├── scripts/ # 本地开发与生产更新
|
||||
└── pocketbase/ # PB 数据目录(本地可选)
|
||||
```
|
||||
|
||||
### 小程序
|
||||
|
||||
| 目录 | 平台 | 联网 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `wemini/` | 微信 | ✅ → `nomadweb.nomadro.com/api/v1` | `npm run dev:weapp` |
|
||||
| `dymini/` | 抖音 | ✅ → 同上 | `npm run dev:tt` |
|
||||
| `redmini/` | 小红书 | ❌ 纯离线 | `python scripts/assemble.py && python scripts/pack.py` |
|
||||
|
||||
Git 远程:`origin`(gitea)与 `nomadro`([git.nomadro.cn/hackrobot/nomadweb](https://git.nomadro.cn/hackrobot/nomadweb.git))。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. PocketBase(可选)
|
||||
@ -162,18 +149,13 @@ NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
|
||||
|
||||
## Git 与部署
|
||||
|
||||
仓库:
|
||||
- [gitea.dsx2020.com/eric/nomadweb](https://gitea.dsx2020.com/eric/nomadweb)(`origin`)
|
||||
- [git.nomadro.cn/hackrobot/nomadweb](https://git.nomadro.cn/hackrobot/nomadweb.git)(`nomadro`)
|
||||
|
||||
当前开发分支 **`dev2`**(亦保留 `dev1` / `main`)。
|
||||
仓库:[gitea.dsx2020.com/eric/nomadweb](https://gitea.dsx2020.com/eric/nomadweb) · 分支 **`dev1`**
|
||||
|
||||
```bash
|
||||
git checkout dev2
|
||||
git pull origin dev2
|
||||
git checkout dev1
|
||||
git pull origin dev1
|
||||
# … 改代码 …
|
||||
git push origin dev2
|
||||
git push nomadro dev2
|
||||
git push origin dev1
|
||||
python scripts/deploy_update.py # 服务器 pull + 增量构建重启
|
||||
```
|
||||
|
||||
|
||||
@ -187,71 +187,6 @@ EXTRA_ROUTES: list[dict[str, Any]] = [
|
||||
"status": "published",
|
||||
"emoji": "🚄",
|
||||
},
|
||||
{
|
||||
"slug": "tax-base-loop",
|
||||
"title": "税务友好试住线",
|
||||
"titleEn": "Tax Base Loop",
|
||||
"citySlugs": ["tbilisi", "dubai"],
|
||||
"durationDays": 75,
|
||||
"budget": 22000,
|
||||
"description": "第比利斯试住 + 迪拜枢纽月,适合评估税务与中转策略。",
|
||||
"descriptionEn": "Tbilisi base trial plus Dubai hub month.",
|
||||
"stops": ["第比利斯 45 天", "迪拜 30 天"],
|
||||
"status": "published",
|
||||
"emoji": "🧾",
|
||||
},
|
||||
{
|
||||
"slug": "latam-spring-trail",
|
||||
"title": "拉美春城双城",
|
||||
"titleEn": "LATAM Spring Trail",
|
||||
"citySlugs": ["medellin", "mexico"],
|
||||
"durationDays": 60,
|
||||
"budget": 15000,
|
||||
"description": "麦德林 → 墨西哥城,北美时区远程友好。",
|
||||
"descriptionEn": "Medellín to Mexico City for NA timezones.",
|
||||
"stops": ["麦德林 30 天", "墨西哥城 30 天"],
|
||||
"status": "published",
|
||||
"emoji": "🌺",
|
||||
},
|
||||
{
|
||||
"slug": "create-slow-china",
|
||||
"title": "国内慢创作线",
|
||||
"titleEn": "China Slow Create",
|
||||
"citySlugs": ["dali"],
|
||||
"durationDays": 45,
|
||||
"budget": 9000,
|
||||
"description": "大理深度创作,适合内容创作者与独立开发者。",
|
||||
"descriptionEn": "Dali deep create stay.",
|
||||
"stops": ["大理 45 天"],
|
||||
"status": "published",
|
||||
"emoji": "📷",
|
||||
},
|
||||
{
|
||||
"slug": "euro-oss-sprint",
|
||||
"title": "欧洲开源冲刺线",
|
||||
"titleEn": "Euro OSS Sprint",
|
||||
"citySlugs": ["berlin", "lisbon"],
|
||||
"durationDays": 50,
|
||||
"budget": 32000,
|
||||
"description": "柏林技术社群 + 里斯本阳光回调。",
|
||||
"descriptionEn": "Berlin tech then Lisbon sun.",
|
||||
"stops": ["柏林 25 天", "里斯本 25 天"],
|
||||
"status": "published",
|
||||
"emoji": "🖥️",
|
||||
},
|
||||
{
|
||||
"slug": "east-asia-net",
|
||||
"title": "东亚网速双城",
|
||||
"titleEn": "East Asia Net Duo",
|
||||
"citySlugs": ["seoul", "tokyo"],
|
||||
"durationDays": 35,
|
||||
"budget": 30000,
|
||||
"description": "首尔与东京高强度协作月,适合产品迭代。",
|
||||
"descriptionEn": "Seoul and Tokyo sprint months.",
|
||||
"stops": ["首尔 18 天", "东京 17 天"],
|
||||
"status": "published",
|
||||
"emoji": "📶",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -299,45 +234,4 @@ CONTENT_ITEMS: list[dict[str, Any]] = [
|
||||
"status": "published",
|
||||
"authorName": "nomadro",
|
||||
},
|
||||
{
|
||||
"slug": "medellin-trial",
|
||||
"type": "guide",
|
||||
"title": "麦德林试住手册",
|
||||
"titleEn": "Medellín Trial Guide",
|
||||
"subtitle": "安全区、西语与北美时区",
|
||||
"description": "30 天试住清单与预算模板。",
|
||||
"targetUrl": "/destinations/medellin",
|
||||
"ctaLabel": "看城市",
|
||||
"citySlugs": ["medellin"],
|
||||
"sortOrder": 4,
|
||||
"status": "published",
|
||||
"authorName": "Sofia",
|
||||
},
|
||||
{
|
||||
"slug": "tbilisi-tax-note",
|
||||
"type": "guide",
|
||||
"title": "第比利斯税务笔记",
|
||||
"titleEn": "Tbilisi Tax Notes",
|
||||
"subtitle": "远程签证与开户提醒",
|
||||
"description": "非正式法律意见,帮助你先做试住决策。",
|
||||
"targetUrl": "/destinations/tbilisi",
|
||||
"ctaLabel": "打开笔记",
|
||||
"citySlugs": ["tbilisi"],
|
||||
"sortOrder": 5,
|
||||
"status": "published",
|
||||
"authorName": "Devon",
|
||||
},
|
||||
{
|
||||
"slug": "first-month-video",
|
||||
"type": "video",
|
||||
"title": "旅居第一月稳定输出",
|
||||
"subtitle": "SIM、短租、社群五步法",
|
||||
"description": "新手最容易踩坑的第一周怎么过。",
|
||||
"targetUrl": "/videos/first-month",
|
||||
"ctaLabel": "看视频",
|
||||
"citySlugs": ["chiangmai", "lisbon", "bali"],
|
||||
"sortOrder": 6,
|
||||
"status": "published",
|
||||
"authorName": "nomadro",
|
||||
},
|
||||
]
|
||||
|
||||
@ -26,7 +26,7 @@ MEETUPS = [
|
||||
"id": "chiangmai-cowork",
|
||||
"title": "清迈联合办公下午茶",
|
||||
"city": "清迈",
|
||||
"destination_slug": "chiangmai",
|
||||
"destination_slug": "chiang-mai",
|
||||
"emoji": "☕",
|
||||
"date": "2026-09-18",
|
||||
"time": "15:00",
|
||||
@ -96,133 +96,6 @@ MEETUPS = [
|
||||
"tags": ["税务", "日本", "干货"],
|
||||
"is_upcoming": True,
|
||||
},
|
||||
{
|
||||
"id": "berlin-opensource",
|
||||
"title": "柏林开源与 DevOps 夜谈",
|
||||
"city": "柏林",
|
||||
"destination_slug": "berlin",
|
||||
"emoji": "🖥️",
|
||||
"date": "2026-09-28",
|
||||
"time": "19:00",
|
||||
"venue": "Kreuzberg Co-working",
|
||||
"description": "分享自托管、CI 与远程团队协作实践,会后可继续社交。",
|
||||
"mode": "hybrid",
|
||||
"access_level": "public",
|
||||
"mirotalkRoom": "nomadro-berlin-oss",
|
||||
"rsvp_count": 22,
|
||||
"max_attendees": 40,
|
||||
"organizer": "Berlin Remote",
|
||||
"tags": ["开源", "技术", "欧洲"],
|
||||
"is_upcoming": True,
|
||||
},
|
||||
{
|
||||
"id": "dali-create-walk",
|
||||
"title": "大理洱海创作散步",
|
||||
"city": "大理",
|
||||
"destination_slug": "dali",
|
||||
"emoji": "📷",
|
||||
"date": "2026-10-02",
|
||||
"time": "16:00",
|
||||
"venue": "才村码头集合",
|
||||
"description": "边走边拍,分享内容选题与变现路径,适合创作者与慢旅居者。",
|
||||
"mode": "offline",
|
||||
"access_level": "public",
|
||||
"rsvp_count": 8,
|
||||
"max_attendees": 18,
|
||||
"organizer": "大理创作者局",
|
||||
"tags": ["内容", "户外", "国内"],
|
||||
"is_upcoming": True,
|
||||
},
|
||||
{
|
||||
"id": "medellin-spanish",
|
||||
"title": "麦德林西语咖啡局",
|
||||
"city": "麦德林",
|
||||
"destination_slug": "medellin",
|
||||
"emoji": "☕",
|
||||
"date": "2026-10-08",
|
||||
"time": "10:30",
|
||||
"venue": "Laureles 咖啡馆",
|
||||
"description": "初级西语练习 + 安全区与租房情报交换,新手友好。",
|
||||
"mode": "offline",
|
||||
"access_level": "public",
|
||||
"rsvp_count": 16,
|
||||
"max_attendees": 20,
|
||||
"organizer": "Medellín Nomads",
|
||||
"tags": ["语言", "拉美", "社交"],
|
||||
"is_upcoming": True,
|
||||
},
|
||||
{
|
||||
"id": "seoul-night-office",
|
||||
"title": "首尔深夜联合办公",
|
||||
"city": "首尔",
|
||||
"destination_slug": "seoul",
|
||||
"emoji": "🌃",
|
||||
"date": "2026-10-15",
|
||||
"time": "21:00",
|
||||
"venue": "江南共享办公",
|
||||
"description": "适合东亚时区冲刺交付的游民,自带电脑即可。",
|
||||
"mode": "offline",
|
||||
"access_level": "public",
|
||||
"rsvp_count": 12,
|
||||
"max_attendees": 25,
|
||||
"organizer": "Seoul Workation",
|
||||
"tags": ["办公", "韩国", "效率"],
|
||||
"is_upcoming": True,
|
||||
},
|
||||
{
|
||||
"id": "dubai-hub-brunch",
|
||||
"title": "迪拜枢纽早午餐",
|
||||
"city": "迪拜",
|
||||
"destination_slug": "dubai",
|
||||
"emoji": "🥂",
|
||||
"date": "2026-10-18",
|
||||
"time": "11:00",
|
||||
"venue": "Downtown 咖啡厅",
|
||||
"description": "中转游民见面:签证、航班与下一站城市选择。",
|
||||
"mode": "offline",
|
||||
"access_level": "public",
|
||||
"rsvp_count": 10,
|
||||
"max_attendees": 16,
|
||||
"organizer": "Dubai Hub Crew",
|
||||
"tags": ["中东", "社交", "跳板"],
|
||||
"is_upcoming": True,
|
||||
},
|
||||
{
|
||||
"id": "tbilisi-tax-cafe",
|
||||
"title": "第比利斯税务咖啡聊",
|
||||
"city": "第比利斯",
|
||||
"destination_slug": "tbilisi",
|
||||
"emoji": "🍷",
|
||||
"date": "2026-10-22",
|
||||
"time": "15:00",
|
||||
"venue": "Vake 区咖啡馆",
|
||||
"description": "交流远程签证与本地公司实务(非正式法律意见),会后可徒步旧城。",
|
||||
"mode": "offline",
|
||||
"access_level": "members",
|
||||
"rsvp_count": 19,
|
||||
"max_attendees": 22,
|
||||
"organizer": "Tbilisi Base",
|
||||
"tags": ["税务", "欧洲", "干货"],
|
||||
"is_upcoming": True,
|
||||
},
|
||||
{
|
||||
"id": "mexico-food-walk",
|
||||
"title": "墨西哥城街头美食漫步",
|
||||
"city": "墨西哥城",
|
||||
"destination_slug": "mexico",
|
||||
"emoji": "🌮",
|
||||
"date": "2026-10-26",
|
||||
"time": "17:30",
|
||||
"venue": "Roma Norte 集合",
|
||||
"description": "边吃边聊北美时区接单与安全出行经验。",
|
||||
"mode": "offline",
|
||||
"access_level": "public",
|
||||
"rsvp_count": 13,
|
||||
"max_attendees": 18,
|
||||
"organizer": "CDMX Nomads",
|
||||
"tags": ["美食", "拉美", "社交"],
|
||||
"is_upcoming": True,
|
||||
},
|
||||
]
|
||||
|
||||
DISCUSSIONS = [
|
||||
@ -291,90 +164,6 @@ DISCUSSIONS = [
|
||||
"created_at": "2026-08-28",
|
||||
"tags": ["活动", "投票"],
|
||||
},
|
||||
{
|
||||
"id": "medellin-safety",
|
||||
"title": "麦德林哪些街区适合第一次长住?",
|
||||
"excerpt": "Laureles / Poblado / Envigado 的网速、噪音和夜间出行体验求分享。",
|
||||
"author": "Sofia",
|
||||
"author_emoji": "🌺",
|
||||
"category": "住宿",
|
||||
"reply_count": 17,
|
||||
"like_count": 41,
|
||||
"created_at": "2026-08-30",
|
||||
"tags": ["麦德林", "安全"],
|
||||
},
|
||||
{
|
||||
"id": "tbilisi-bank",
|
||||
"title": "第比利斯开户与收美元的实操经验",
|
||||
"excerpt": "需要本地号码吗?Wise 能不能直接用?求最新流程。",
|
||||
"author": "Devon",
|
||||
"author_emoji": "🍷",
|
||||
"category": "财务",
|
||||
"reply_count": 14,
|
||||
"like_count": 36,
|
||||
"created_at": "2026-09-01",
|
||||
"tags": ["格鲁吉亚", "银行"],
|
||||
},
|
||||
{
|
||||
"id": "berlin-winter",
|
||||
"title": "柏林冬天怎么保持产出和社交?",
|
||||
"excerpt": "日照短、天气冷,有没有室内运动 + meetup 推荐。",
|
||||
"author": "Ken",
|
||||
"author_emoji": "🎨",
|
||||
"category": "生活方式",
|
||||
"reply_count": 9,
|
||||
"like_count": 22,
|
||||
"created_at": "2026-09-02",
|
||||
"tags": ["柏林", "健康"],
|
||||
},
|
||||
{
|
||||
"id": "dali-wifi",
|
||||
"title": "大理合租找房:网速坑位合集",
|
||||
"excerpt": "哪些区域光纤稳定?有没有房东靠谱名单。",
|
||||
"author": "Lina",
|
||||
"author_emoji": "📷",
|
||||
"category": "住宿",
|
||||
"reply_count": 21,
|
||||
"like_count": 48,
|
||||
"created_at": "2026-09-03",
|
||||
"tags": ["大理", "网速"],
|
||||
},
|
||||
{
|
||||
"id": "dubai-summer",
|
||||
"title": "迪拜夏天还能正常远程办公吗?",
|
||||
"excerpt": "空调联合办公、作息调整和预算增加幅度求真实数据。",
|
||||
"author": "Omar",
|
||||
"author_emoji": "🏙️",
|
||||
"category": "生活方式",
|
||||
"reply_count": 8,
|
||||
"like_count": 19,
|
||||
"created_at": "2026-09-04",
|
||||
"tags": ["迪拜", "气候"],
|
||||
},
|
||||
{
|
||||
"id": "seoul-visa",
|
||||
"title": "韩国 Workation 签证材料清单核对",
|
||||
"excerpt": "收入证明、保险、行程……有人最近过签吗?",
|
||||
"author": "Yuki",
|
||||
"author_emoji": "🇰🇷",
|
||||
"category": "签证",
|
||||
"reply_count": 12,
|
||||
"like_count": 33,
|
||||
"created_at": "2026-09-05",
|
||||
"tags": ["韩国", "签证"],
|
||||
},
|
||||
{
|
||||
"id": "cowork-month-pass",
|
||||
"title": "各城市联合办公月票性价比对比",
|
||||
"excerpt": "清迈 / 里斯本 / 柏林 / 麦德林,欢迎贴价目与座位体验。",
|
||||
"author": "Alex",
|
||||
"author_emoji": "🚀",
|
||||
"category": "远程工作",
|
||||
"reply_count": 26,
|
||||
"like_count": 61,
|
||||
"created_at": "2026-09-06",
|
||||
"tags": ["办公", "成本"],
|
||||
},
|
||||
]
|
||||
|
||||
GIGS = [
|
||||
@ -386,10 +175,7 @@ GIGS = [
|
||||
"deadline": "2026-09-15",
|
||||
"tags": ["设计", "远程"],
|
||||
"poster": "nomadro",
|
||||
"poster_email": "demo@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "设计",
|
||||
"location": "远程",
|
||||
},
|
||||
{
|
||||
"id": "meetup-host",
|
||||
@ -399,10 +185,7 @@ GIGS = [
|
||||
"deadline": "2026-09-20",
|
||||
"tags": ["活动", "深圳"],
|
||||
"poster": "深圳湾区主理人",
|
||||
"poster_email": "demo@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "活动",
|
||||
"location": "深圳",
|
||||
},
|
||||
{
|
||||
"id": "content-translate",
|
||||
@ -412,127 +195,7 @@ GIGS = [
|
||||
"deadline": "2026-10-01",
|
||||
"tags": ["翻译", "内容"],
|
||||
"poster": "nomadro",
|
||||
"poster_email": "demo@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "内容",
|
||||
"location": "远程",
|
||||
},
|
||||
{
|
||||
"id": "landing-page",
|
||||
"title": "城市落地页前端实现",
|
||||
"description": "Next.js + Tailwind,按 Figma 还原清迈/里斯本两套落地页。",
|
||||
"budget": "¥3500",
|
||||
"deadline": "2026-10-10",
|
||||
"tags": ["前端", "Next.js"],
|
||||
"poster": "Marco",
|
||||
"poster_email": "marco@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "开发",
|
||||
"location": "远程",
|
||||
},
|
||||
{
|
||||
"id": "video-edit",
|
||||
"title": "游民访谈短视频剪辑",
|
||||
"description": "4 条 60–90 秒竖版视频,含字幕与封面。",
|
||||
"budget": "¥1500",
|
||||
"deadline": "2026-10-12",
|
||||
"tags": ["视频", "剪辑"],
|
||||
"poster": "Lina",
|
||||
"poster_email": "lina@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "内容",
|
||||
"location": "远程",
|
||||
},
|
||||
{
|
||||
"id": "seo-audit",
|
||||
"title": "目的地内容 SEO 审计",
|
||||
"description": "审计 10 篇城市指南关键词与内链结构,输出改进清单。",
|
||||
"budget": "¥900",
|
||||
"deadline": "2026-10-08",
|
||||
"tags": ["SEO", "内容"],
|
||||
"poster": "nomadro",
|
||||
"poster_email": "demo@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "运营",
|
||||
"location": "远程",
|
||||
},
|
||||
{
|
||||
"id": "photo-pack",
|
||||
"title": "大理旅居照片素材包",
|
||||
"description": "需 30 张可商用风格照片(咖啡馆、洱海、合租场景)。",
|
||||
"budget": "¥600",
|
||||
"deadline": "2026-10-05",
|
||||
"tags": ["摄影", "大理"],
|
||||
"poster": "Lina",
|
||||
"poster_email": "lina@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "设计",
|
||||
"location": "大理",
|
||||
},
|
||||
{
|
||||
"id": "tax-checklist",
|
||||
"title": "格鲁吉亚税务清单校对",
|
||||
"description": "把英文政策摘要整理成中文 checklist(非法律意见)。",
|
||||
"budget": "¥700",
|
||||
"deadline": "2026-10-18",
|
||||
"tags": ["税务", "文档"],
|
||||
"poster": "Devon",
|
||||
"poster_email": "devon@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "内容",
|
||||
"location": "远程",
|
||||
},
|
||||
{
|
||||
"id": "meetup-photo",
|
||||
"title": "麦德林活动跟拍",
|
||||
"description": "一场线下咖啡局跟拍 + 当天出 15 张精修。",
|
||||
"budget": "$80",
|
||||
"deadline": "2026-10-08",
|
||||
"tags": ["摄影", "活动"],
|
||||
"poster": "Sofia",
|
||||
"poster_email": "sofia@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "活动",
|
||||
"location": "麦德林",
|
||||
},
|
||||
{
|
||||
"id": "chatbot-faq",
|
||||
"title": "签证 FAQ 聊天机器人语料",
|
||||
"description": "整理 50 条问答并标注意图,供客服机器人训练。",
|
||||
"budget": "¥1100",
|
||||
"deadline": "2026-10-20",
|
||||
"tags": ["AI", "内容"],
|
||||
"poster": "Alex",
|
||||
"poster_email": "alex@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "开发",
|
||||
"location": "远程",
|
||||
},
|
||||
{
|
||||
"id": "ux-test",
|
||||
"title": "下一站推荐工具可用性测试",
|
||||
"description": "招募 5 名游民完成任务并录制反馈(远程)。",
|
||||
"budget": "¥80/人",
|
||||
"deadline": "2026-10-15",
|
||||
"tags": ["UX", "研究"],
|
||||
"poster": "Sara",
|
||||
"poster_email": "sara@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "产品",
|
||||
"location": "远程",
|
||||
},
|
||||
{
|
||||
"id": "community-mod",
|
||||
"title": "周末社区值班版主",
|
||||
"description": "处理讨论区举报与欢迎新人,需中英双语。",
|
||||
"budget": "¥300/周末",
|
||||
"deadline": "2026-10-30",
|
||||
"tags": ["社区", "兼职"],
|
||||
"poster": "nomadro",
|
||||
"poster_email": "demo@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "运营",
|
||||
"location": "远程",
|
||||
},
|
||||
]
|
||||
|
||||
@ -554,14 +217,6 @@ DISCUSSION_REPLIES = {
|
||||
"created_at": "2026-08-21",
|
||||
"like_count": 8,
|
||||
},
|
||||
{
|
||||
"id": "r3",
|
||||
"author": "Yuki",
|
||||
"author_emoji": "🇮🇩",
|
||||
"content": "巴厘岛短期用 B211A 足够,长期再看第二家园成本是否划算。",
|
||||
"created_at": "2026-08-22",
|
||||
"like_count": 6,
|
||||
},
|
||||
],
|
||||
"async-remote": [
|
||||
{
|
||||
@ -572,130 +227,6 @@ DISCUSSION_REPLIES = {
|
||||
"created_at": "2026-08-23",
|
||||
"like_count": 9,
|
||||
},
|
||||
{
|
||||
"id": "r2",
|
||||
"author": "Alex",
|
||||
"author_emoji": "🚀",
|
||||
"content": "模板三要素:昨天完成 / 今天计划 / 卡点,别写散文。",
|
||||
"created_at": "2026-08-23",
|
||||
"like_count": 11,
|
||||
},
|
||||
],
|
||||
"coliving-tips": [
|
||||
{
|
||||
"id": "r1",
|
||||
"author": "小林",
|
||||
"author_emoji": "🧳",
|
||||
"content": "一定要问清押金退还条件和网速实测截图,别只看宣传。",
|
||||
"created_at": "2026-08-26",
|
||||
"like_count": 14,
|
||||
},
|
||||
],
|
||||
"medellin-safety": [
|
||||
{
|
||||
"id": "r1",
|
||||
"author": "Alex",
|
||||
"author_emoji": "🌮",
|
||||
"content": "第一次建议 Laureles,生活成本与便利平衡;夜间少拿手机逛街。",
|
||||
"created_at": "2026-08-31",
|
||||
"like_count": 10,
|
||||
},
|
||||
],
|
||||
"tbilisi-bank": [
|
||||
{
|
||||
"id": "r1",
|
||||
"author": "Omar",
|
||||
"author_emoji": "🏙️",
|
||||
"content": "先办本地 SIM 再去银行,材料准备护照+地址证明更顺利。",
|
||||
"created_at": "2026-09-01",
|
||||
"like_count": 7,
|
||||
},
|
||||
],
|
||||
"dali-wifi": [
|
||||
{
|
||||
"id": "r1",
|
||||
"author": "阿静",
|
||||
"author_emoji": "🏡",
|
||||
"content": "才村/喜洲合租差别大,入住前用手机热点对照测速最靠谱。",
|
||||
"created_at": "2026-09-03",
|
||||
"like_count": 9,
|
||||
},
|
||||
],
|
||||
"cowork-month-pass": [
|
||||
{
|
||||
"id": "r1",
|
||||
"author": "Marco",
|
||||
"author_emoji": "💻",
|
||||
"content": "里斯本月票大约 €150–250;清迈很多空间 ¥600–1000 就够用。",
|
||||
"created_at": "2026-09-06",
|
||||
"like_count": 15,
|
||||
},
|
||||
],
|
||||
"health-insurance": [
|
||||
{
|
||||
"id": "r1",
|
||||
"author": "Sofia",
|
||||
"author_emoji": "🏥",
|
||||
"content": "30 岁以下 SafetyWing 够用;有慢性病建议加本地门诊险。",
|
||||
"created_at": "2026-08-28",
|
||||
"like_count": 10,
|
||||
},
|
||||
{
|
||||
"id": "r2",
|
||||
"author": "Ken",
|
||||
"author_emoji": "🇯🇵",
|
||||
"content": "在日本长期停留我叠了本地国民健康保险,报销更稳。",
|
||||
"created_at": "2026-08-28",
|
||||
"like_count": 7,
|
||||
},
|
||||
],
|
||||
"meetup-feedback": [
|
||||
{
|
||||
"id": "r1",
|
||||
"author": "Lina",
|
||||
"author_emoji": "📷",
|
||||
"content": "大理 + 清迈希望能固定每月一场创作局。",
|
||||
"created_at": "2026-08-29",
|
||||
"like_count": 18,
|
||||
},
|
||||
{
|
||||
"id": "r2",
|
||||
"author": "Omar",
|
||||
"author_emoji": "🏙️",
|
||||
"content": "迪拜适合枢纽月见面,建议放周末早午餐。",
|
||||
"created_at": "2026-08-29",
|
||||
"like_count": 9,
|
||||
},
|
||||
],
|
||||
"berlin-winter": [
|
||||
{
|
||||
"id": "r1",
|
||||
"author": "Devon",
|
||||
"author_emoji": "🖥️",
|
||||
"content": "冬天把联合办公当社交主场,再加一周一次室内攀岩/跑步局。",
|
||||
"created_at": "2026-09-02",
|
||||
"like_count": 11,
|
||||
},
|
||||
],
|
||||
"dubai-summer": [
|
||||
{
|
||||
"id": "r1",
|
||||
"author": "Nina",
|
||||
"author_emoji": "🍷",
|
||||
"content": "夏天基本只在空调空间办公,预算里把交通和水电预留高一点。",
|
||||
"created_at": "2026-09-04",
|
||||
"like_count": 6,
|
||||
},
|
||||
],
|
||||
"seoul-visa": [
|
||||
{
|
||||
"id": "r1",
|
||||
"author": "Jin",
|
||||
"author_emoji": "🇰🇷",
|
||||
"content": "最近材料重点是收入证明和保险;审批大约 3–6 周,建议提前办。",
|
||||
"created_at": "2026-09-05",
|
||||
"like_count": 8,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@ -740,44 +271,4 @@ NOMAD_ROUTES = [
|
||||
"stops": ["tokyo", "chiangmai"],
|
||||
"emoji": "🚄",
|
||||
},
|
||||
{
|
||||
"id": "tax-base-loop",
|
||||
"slug": "tax-base-loop",
|
||||
"title": "税务友好试住线",
|
||||
"duration_days": 75,
|
||||
"budget": 22000,
|
||||
"description": "第比利斯试住 + 迪拜枢纽月,适合评估税务与中转策略。",
|
||||
"stops": ["tbilisi", "dubai"],
|
||||
"emoji": "🧾",
|
||||
},
|
||||
{
|
||||
"id": "latam-spring",
|
||||
"slug": "latam-spring-trail",
|
||||
"title": "拉美春城双城",
|
||||
"duration_days": 60,
|
||||
"budget": 15000,
|
||||
"description": "麦德林 → 墨西哥城,北美时区远程友好。",
|
||||
"stops": ["medellin", "mexico"],
|
||||
"emoji": "🌺",
|
||||
},
|
||||
{
|
||||
"id": "create-slow",
|
||||
"slug": "create-slow-china",
|
||||
"title": "国内慢创作线",
|
||||
"duration_days": 45,
|
||||
"budget": 9000,
|
||||
"description": "大理深度创作 + 短途补给,适合内容创作者。",
|
||||
"stops": ["dali"],
|
||||
"emoji": "📷",
|
||||
},
|
||||
{
|
||||
"id": "euro-oss",
|
||||
"slug": "euro-oss-sprint",
|
||||
"title": "欧洲开源冲刺线",
|
||||
"duration_days": 50,
|
||||
"budget": 32000,
|
||||
"description": "柏林技术社群 + 里斯本阳光回调。",
|
||||
"stops": ["berlin", "lisbon"],
|
||||
"emoji": "🖥️",
|
||||
},
|
||||
]
|
||||
|
||||
@ -95,7 +95,7 @@ JOBS = [
|
||||
"type": "全职",
|
||||
"salary": "$80k–120k",
|
||||
"tags": ["React", "TypeScript", "远程"],
|
||||
"url": "/gigs",
|
||||
"url": "https://nomadro.com",
|
||||
},
|
||||
{
|
||||
"id": "j2",
|
||||
@ -105,7 +105,7 @@ JOBS = [
|
||||
"type": "合同",
|
||||
"salary": "€60–90/h",
|
||||
"tags": ["K8s", "AWS", "异步"],
|
||||
"url": "/gigs",
|
||||
"url": "https://nomadro.com",
|
||||
},
|
||||
{
|
||||
"id": "j3",
|
||||
@ -115,96 +115,6 @@ JOBS = [
|
||||
"type": "兼职",
|
||||
"salary": "面议",
|
||||
"tags": ["社区", "写作", "游民"],
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j4",
|
||||
"title": "Product Designer",
|
||||
"company": "Atlantic Remote",
|
||||
"location": "欧盟时区 ±3h",
|
||||
"type": "全职",
|
||||
"salary": "€55k–75k",
|
||||
"tags": ["Figma", "B2B", "远程"],
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j5",
|
||||
"title": "Growth Marketer",
|
||||
"company": "Hub Ventures",
|
||||
"location": "中东/欧洲友好",
|
||||
"type": "合同",
|
||||
"salary": "$4k–6k/月",
|
||||
"tags": ["SEO", "内容", "增长"],
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j6",
|
||||
"title": "Backend Engineer (Python)",
|
||||
"company": "Latam Cloud",
|
||||
"location": "北美时区",
|
||||
"type": "全职",
|
||||
"salary": "$90k–130k",
|
||||
"tags": ["Python", "FastAPI", "Postgres"],
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j7",
|
||||
"title": "Community Manager",
|
||||
"company": "nomadro",
|
||||
"location": "全球远程",
|
||||
"type": "兼职",
|
||||
"salary": "¥8k–12k/月",
|
||||
"tags": ["社区", "活动", "中英"],
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j8",
|
||||
"title": "Mobile Engineer (iOS/Android)",
|
||||
"company": "Seoul Soft",
|
||||
"location": "东亚时区",
|
||||
"type": "全职",
|
||||
"salary": "$70k–100k",
|
||||
"tags": ["Flutter", "移动端"],
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j9",
|
||||
"title": "Technical Writer",
|
||||
"company": "Docs Anywhere",
|
||||
"location": "异步优先",
|
||||
"type": "合同",
|
||||
"salary": "$40–70/h",
|
||||
"tags": ["文档", "开发者关系"],
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j10",
|
||||
"title": "Data Analyst",
|
||||
"company": "Wander Metrics",
|
||||
"location": "全球远程",
|
||||
"type": "全职",
|
||||
"salary": "$65k–95k",
|
||||
"tags": ["SQL", "看板", "增长"],
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j11",
|
||||
"title": "Customer Success(签证顾问支持)",
|
||||
"company": "nomadro",
|
||||
"location": "中国时区友好",
|
||||
"type": "兼职",
|
||||
"salary": "¥6k–10k/月",
|
||||
"tags": ["客服", "签证", "中文"],
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j12",
|
||||
"title": "Full-stack Engineer",
|
||||
"company": "Indie Nomad Tools",
|
||||
"location": "全球远程",
|
||||
"type": "全职",
|
||||
"salary": "$85k–115k",
|
||||
"tags": ["Next.js", "Python", "独立产品"],
|
||||
"url": "/gigs",
|
||||
"url": "https://nomadro.com",
|
||||
},
|
||||
]
|
||||
|
||||
@ -43,48 +43,6 @@ DESTINATIONS = [
|
||||
"nomads_count": "4,100+", "highlights": ["🚄 极致公共交通", "🛡️ 全球最安全城市", "📶 网速亚洲第一", "🍣 美食文化巅峰"],
|
||||
"map_x": 820, "map_y": 210,
|
||||
},
|
||||
{
|
||||
"id": "7", "slug": "berlin", "name": "柏林", "country": "德国", "emoji": "🎨",
|
||||
"tag": "欧洲 · 创意之都", "description": "开放的创业与开源氛围,租金相对西欧友好,夜生活与艺术场景极具吸引力。",
|
||||
"region": "europe", "cost": 9800, "speed": 140, "temperature": 12, "rating": 8.9, "hue": 250,
|
||||
"nomads_count": "7,200+", "highlights": ["🎨 艺术与街头文化", "💻 创业生态活跃", "🚲 骑行友好", "🍺 社交场景丰富"],
|
||||
"map_x": 500, "map_y": 160,
|
||||
},
|
||||
{
|
||||
"id": "8", "slug": "dali", "name": "大理", "country": "中国", "emoji": "🏔️",
|
||||
"tag": "国内 · 慢生活", "description": "苍山洱海之间的内容创作者聚集地,生活节奏慢、社交松弛,适合深度创作与试住。",
|
||||
"region": "asia", "cost": 4200, "speed": 80, "temperature": 18, "rating": 8.7, "hue": 190,
|
||||
"nomads_count": "3,600+", "highlights": ["📷 内容创作友好", "🏞️ 自然风光", "🍵 咖啡馆密集", "🏡 合租成本低"],
|
||||
"map_x": 730, "map_y": 220,
|
||||
},
|
||||
{
|
||||
"id": "9", "slug": "seoul", "name": "首尔", "country": "韩国", "emoji": "🏙️",
|
||||
"tag": "亚洲 · 潮流都市", "description": "高速网络与深夜城市活力并存,适合对接东亚市场与产品迭代。",
|
||||
"region": "asia", "cost": 11000, "speed": 220, "temperature": 14, "rating": 8.5, "hue": 330,
|
||||
"nomads_count": "3,200+", "highlights": ["📶 全球顶尖网速", "🍜 美食与便利店文化", "🛍️ 潮流消费", "🚇 地铁覆盖极强"],
|
||||
"map_x": 800, "map_y": 200,
|
||||
},
|
||||
{
|
||||
"id": "10", "slug": "medellin", "name": "麦德林", "country": "哥伦比亚", "emoji": "🌺",
|
||||
"tag": "拉美 · 春城", "description": "四季如春、生活成本友好,英语游民社区成熟,适合北美时区远程工作。",
|
||||
"region": "latam", "cost": 5200, "speed": 90, "temperature": 23, "rating": 9.0, "hue": 140,
|
||||
"nomads_count": "6,400+", "highlights": ["🌤️ 四季如春", "💰 高性价比", "🕐 北美时区", "🤝 社区活动密集"],
|
||||
"map_x": 260, "map_y": 300,
|
||||
},
|
||||
{
|
||||
"id": "11", "slug": "dubai", "name": "迪拜", "country": "阿联酋", "emoji": "🏙️",
|
||||
"tag": "中东 · 枢纽都市", "description": "免税与签证便利吸引远程高收入人群,国际航班枢纽,适合中东/欧亚跳板。",
|
||||
"region": "asia", "cost": 14000, "speed": 180, "temperature": 32, "rating": 8.4, "hue": 40,
|
||||
"nomads_count": "4,800+", "highlights": ["🧾 税务友好", "✈️ 航线枢纽", "🏗️ 现代基础设施", "🌐 多元国际社区"],
|
||||
"map_x": 600, "map_y": 240,
|
||||
},
|
||||
{
|
||||
"id": "12", "slug": "tbilisi", "name": "第比利斯", "country": "格鲁吉亚", "emoji": "🍷",
|
||||
"tag": "欧亚 · 税务友好", "description": "1% 小企业税与远程友好签证,旧城氛围浓厚,东欧游民新基地。",
|
||||
"region": "europe", "cost": 4800, "speed": 70, "temperature": 16, "rating": 8.8, "hue": 15,
|
||||
"nomads_count": "5,100+", "highlights": ["🧾 1% 小企业税", "🏰 旧城与温泉", "🍷 葡萄酒文化", "💰 低生活成本"],
|
||||
"map_x": 560, "map_y": 190,
|
||||
},
|
||||
]
|
||||
|
||||
VISAS = [
|
||||
@ -100,18 +58,6 @@ VISAS = [
|
||||
"duration": "1-4年", "income_req": "$2,500/月", "approval_time": "2-4 周", "extra": "🌮 北美时区友好", "difficulty": 30, "difficulty_label": "简单"},
|
||||
{"id": "6", "country": "爱沙尼亚", "flag": "🇪🇪", "name": "爱沙尼亚 DNV", "badge": "🚀 先锋", "badge_type": "pioneer",
|
||||
"duration": "1年", "income_req": "€3,504/月", "approval_time": "2-4 周", "extra": "💻 全球首个数字游民签证", "difficulty": 40, "difficulty_label": "中等"},
|
||||
{"id": "7", "country": "格鲁吉亚", "flag": "🇬🇪", "name": "格鲁吉亚 Remotely from Georgia", "badge": "⭐ 推荐", "badge_type": "easy",
|
||||
"duration": "1年", "income_req": "$2,000/月", "approval_time": "约 10 天", "extra": "🧾 可搭配小企业 1% 税", "difficulty": 28, "difficulty_label": "简单"},
|
||||
{"id": "8", "country": "阿联酋", "flag": "🇦🇪", "name": "迪拜 Virtual Working Programme", "badge": "🔥 热门", "badge_type": "hot",
|
||||
"duration": "1年", "income_req": "$3,500/月", "approval_time": "2-4 周", "extra": "✈️ 中东航线枢纽", "difficulty": 42, "difficulty_label": "中等"},
|
||||
{"id": "9", "country": "哥伦比亚", "flag": "🇨🇴", "name": "哥伦比亚 V Visa (Digital Nomad)", "badge": "💰 低成本", "badge_type": "budget",
|
||||
"duration": "最长 2 年", "income_req": "约 3× 最低工资", "approval_time": "2-6 周", "extra": "🌺 适合麦德林春城", "difficulty": 32, "difficulty_label": "简单"},
|
||||
{"id": "10", "country": "韩国", "flag": "🇰🇷", "name": "韩国 Workation Visa (C-3-8)", "badge": "🆕 新政策", "badge_type": "new",
|
||||
"duration": "最长 2 年", "income_req": "约 $65,000/年", "approval_time": "1-2 个月", "extra": "🏙️ 对接首尔与济州", "difficulty": 55, "difficulty_label": "中等"},
|
||||
{"id": "11", "country": "德国", "flag": "🇩🇪", "name": "德国 Freelance / Self-employment", "badge": "🚀 先锋", "badge_type": "pioneer",
|
||||
"duration": "1-3年", "income_req": "需商业计划与客户证明", "approval_time": "2-4 个月", "extra": "🎨 适合柏林自由职业", "difficulty": 65, "difficulty_label": "较难"},
|
||||
{"id": "12", "country": "泰国", "flag": "🇹🇭", "name": "泰国 DTV 签证", "badge": "⭐ 推荐", "badge_type": "easy",
|
||||
"duration": "最长 5 年(多次入境)", "income_req": "约 50 万泰铢存款证明", "approval_time": "2-6 周", "extra": "🤝 远程工作友好", "difficulty": 38, "difficulty_label": "中等"},
|
||||
]
|
||||
|
||||
FAQS = [
|
||||
@ -133,11 +79,6 @@ TESTIMONIALS = [
|
||||
{"id": "1", "avatar": "👩💻", "content": "在清迈住了 8 个月,月花费不到 4000 元,但生活质量比国内一线城市高太多了。每天早上骑摩托去咖啡馆,这种感觉无法形容。", "author": "小林", "role": "前端开发 · 清迈 🇹🇭", "rating": 5},
|
||||
{"id": "2", "avatar": "👨🎨", "content": "里斯本的 D7 签证让我在欧洲有了基地。白天在 Alfama 区的共享办公空间工作,周末去 Sintra 徒步,完美平衡。", "author": "Marco", "role": "UI 设计师 · 里斯本 🇵🇹", "rating": 5},
|
||||
{"id": "3", "avatar": "🧑💼", "content": "带着家人做数字游民听起来疯狂,但在巴厘岛乌布,孩子们上国际学校,我和妻子远程工作,这是我们做过最正确的决定。", "author": "张家", "role": "产品经理 · 巴厘岛 🇮🇩", "rating": 5},
|
||||
{"id": "4", "avatar": "👩🎨", "content": "麦德林四季如春,英语社区很活跃。我用北美时区接单,下午还能去公园运动,效率反而更高。", "author": "Sofia", "role": "自由职业设计 · 麦德林 🇨🇴", "rating": 5},
|
||||
{"id": "5", "avatar": "👨💻", "content": "第比利斯生活成本低,还能开本地公司走 1% 税。旧城咖啡馆写代码,周末去卡兹别吉徒步,节奏很舒服。", "author": "Devon", "role": "DevOps · 第比利斯 🇬🇪", "rating": 5},
|
||||
{"id": "6", "avatar": "📷", "content": "大理适合慢创作。合租便宜、风景治愈,但要主动找同频伙伴,别只待在民宿里。", "author": "Lina", "role": "内容创作者 · 大理 🇨🇳", "rating": 4},
|
||||
{"id": "7", "avatar": "🧑🚀", "content": "柏林创业氛围强,开源 meetup 特别多。冬天冷、德语门槛真实存在,但找到圈子后很值得。", "author": "Ken", "role": "数据分析 · 柏林 🇩🇪", "rating": 4},
|
||||
{"id": "8", "avatar": "🏙️", "content": "迪拜当跳板很香:航班方便、签证路径清晰。夏天太热,建议把深度工作时间放在室内联合办公。", "author": "Omar", "role": "增长顾问 · 迪拜 🇦🇪", "rating": 4},
|
||||
]
|
||||
|
||||
TOOLS = [
|
||||
@ -153,13 +94,6 @@ BLOG_POSTS = [
|
||||
{"id": "1", "slug": "chiangmai-guide-2026", "title": "清迈数字游民完全指南 2026", "excerpt": "从签证到住宿,从咖啡馆到 Co-working,一篇搞定清迈旅居。", "emoji": "🏔️", "author": "nomadro", "published_at": "2026-01-15", "read_time": 8, "tags": ["清迈", "指南", "东南亚"]},
|
||||
{"id": "2", "slug": "portugal-d7-visa", "title": "葡萄牙 D7 签证申请全攻略", "excerpt": "手把手教你申请欧洲最受欢迎的远程工作签证。", "emoji": "🇵🇹", "author": "nomadro", "published_at": "2026-02-03", "read_time": 12, "tags": ["签证", "葡萄牙", "欧洲"]},
|
||||
{"id": "3", "slug": "nomad-tax-basics", "title": "数字游民税务入门:你需要知道的 5 件事", "excerpt": "183 天规则、双重征税、税务居民身份——一文理清。", "emoji": "🧾", "author": "nomadro", "published_at": "2026-02-20", "read_time": 10, "tags": ["税务", "法律", "指南"]},
|
||||
{"id": "4", "slug": "medellin-spring-city", "title": "麦德林春城试住 30 天笔记", "excerpt": "时区、安全区、联合办公与西语社区初体验。", "emoji": "🌺", "author": "Sofia", "published_at": "2026-03-05", "read_time": 9, "tags": ["麦德林", "拉美", "试住"]},
|
||||
{"id": "5", "slug": "tbilisi-tax-base", "title": "第比利斯当税务基地靠谱吗?", "excerpt": "远程签证、小企业税与旧城生活成本实测。", "emoji": "🍷", "author": "Devon", "published_at": "2026-03-18", "read_time": 11, "tags": ["格鲁吉亚", "税务", "欧洲"]},
|
||||
{"id": "6", "slug": "dali-slow-create", "title": "大理慢创作:合租、网速与社群", "excerpt": "国内游民城市怎么住得久又不散漫。", "emoji": "🏔️", "author": "Lina", "published_at": "2026-04-02", "read_time": 7, "tags": ["大理", "内容", "国内"]},
|
||||
{"id": "7", "slug": "berlin-startup-nomad", "title": "柏林自由职业与开源社群指南", "excerpt": "签证路径、共享办公与每周活动清单。", "emoji": "🎨", "author": "Ken", "published_at": "2026-04-20", "read_time": 10, "tags": ["柏林", "创业", "开源"]},
|
||||
{"id": "8", "slug": "dubai-hub-month", "title": "迪拜一个月跳板旅居怎么排期", "excerpt": "签证、炎热季节、航班中转与办公点选择。", "emoji": "🏙️", "author": "Omar", "published_at": "2026-05-01", "read_time": 8, "tags": ["迪拜", "中东", "枢纽"]},
|
||||
{"id": "9", "slug": "seoul-workation", "title": "首尔 Workation:网速、便利与成本", "excerpt": "C-3-8 签证与江南区办公踩点。", "emoji": "🏙️", "author": "Yuki", "published_at": "2026-05-16", "read_time": 9, "tags": ["首尔", "韩国", "网速"]},
|
||||
{"id": "10", "slug": "first-month-checklist", "title": "旅居第一月清单:从落地到稳定输出", "excerpt": "SIM、短租、联合办公、社群与预算五步走。", "emoji": "✅", "author": "nomadro", "published_at": "2026-06-01", "read_time": 6, "tags": ["清单", "新手", "落地"]},
|
||||
]
|
||||
|
||||
BLOG_CONTENT: dict[str, str] = {
|
||||
@ -283,102 +217,6 @@ A: 满足居住要求后可以申请永居或入籍。""",
|
||||
3. 🏦 使用 Wise 等工具便于跨境汇款记录
|
||||
4. 👨💼 收入超过一定金额建议聘请税务顾问
|
||||
5. 📱 推荐工具:Xero(记账)、TaxScouts(报税)""",
|
||||
|
||||
"medellin-spring-city": """## 为什么麦德林?
|
||||
|
||||
四季如春、生活成本可控,且贴近北美时区,适合接美加客户。Laureles / El Poblado 是游民常见落脚区。
|
||||
|
||||
## 30 天试住建议
|
||||
|
||||
1. 前 7 天住短租,实测网速与噪音
|
||||
2. 办本地 SIM,备份热点
|
||||
3. 参加 2 场线下 meetup,验证社区匹配度
|
||||
4. 确定联合办公月票再签长租
|
||||
|
||||
## 预算参考(月)
|
||||
|
||||
- 住宿:¥2,000–3,500
|
||||
- 餐饮:¥1,200–1,800
|
||||
- 交通:¥300–500
|
||||
- 办公:¥400–800""",
|
||||
|
||||
"tbilisi-tax-base": """## 远程签证 + 小企业税
|
||||
|
||||
格鲁吉亚对远程工作者友好,许多人会评估本地公司与 1% 税路径(需自行核实最新政策)。
|
||||
|
||||
## 生活感受
|
||||
|
||||
旧城与 Vake 区咖啡馆多,适合深度工作;冬季偏冷,夏天舒服。
|
||||
|
||||
## 注意
|
||||
|
||||
- 银行开户与本地手机号流程可能变动
|
||||
- 税务方案务必咨询持证顾问
|
||||
- 把「试住 30 天」和「税务落地」分开决策""",
|
||||
|
||||
"dali-slow-create": """## 适合谁
|
||||
|
||||
内容创作者、独立开发者、想放慢节奏但仍保持产出的人。
|
||||
|
||||
## 实操建议
|
||||
|
||||
- 合租优先选稳定 WiFi 与独立工作桌
|
||||
- 用固定作息对抗「度假感」
|
||||
- 主动加入本地创作者局,避免社交孤岛
|
||||
|
||||
## 成本
|
||||
|
||||
月生活费常可控制在 ¥3,500–5,000,视合租与出行频率而定。""",
|
||||
|
||||
"berlin-startup-nomad": """## 柏林为什么吸引游民
|
||||
|
||||
开源、设计、创业活动密度高,共享办公选择多。
|
||||
|
||||
## 落地要点
|
||||
|
||||
- 提前规划居留/自由职业路径
|
||||
- 冬天日照短,准备补光与室内运动
|
||||
- 德语不是必须,但能明显提升生活便利
|
||||
|
||||
## 每周节奏
|
||||
|
||||
2 天联合办公 + 1 场 meetup + 周末短途,比纯咖啡馆更稳。""",
|
||||
|
||||
"dubai-hub-month": """## 适合当跳板
|
||||
|
||||
国际航班密集,签证路径相对清晰,适合中东/欧亚中转月。
|
||||
|
||||
## 炎热季节
|
||||
|
||||
夏季高温,深度工作尽量安排在空调联合办公;户外活动放早晚。
|
||||
|
||||
## 预算
|
||||
|
||||
整体偏高,建议按「枢纽月」而非「低成本旅居」来规划。""",
|
||||
|
||||
"seoul-workation": """## 网速与便利
|
||||
|
||||
首尔网速与城市便利度极强,适合需要高频协作的产品/工程角色。
|
||||
|
||||
## 成本与节奏
|
||||
|
||||
住宿与餐饮成本接近一线城市;用便利店与共享办公控制变量。
|
||||
|
||||
## 签证
|
||||
|
||||
关注 Workation / 相关短期远程路径的最新材料要求。""",
|
||||
|
||||
"first-month-checklist": """## 落地五步
|
||||
|
||||
1. **连接**:落地办 SIM,测公寓与办公点网速
|
||||
2. **住处**:先短租 7–14 天,再决定长租
|
||||
3. **办公**:固定 1 个主点 + 2 个备份咖啡馆
|
||||
4. **社群**:报名 1 场 meetup,加 1 个本地群
|
||||
5. **预算**:用第一周真实账单校准月预算
|
||||
|
||||
## 输出稳定
|
||||
|
||||
每天保护 4 小时深度工作,旅行探索放在下午后段。""",
|
||||
}
|
||||
|
||||
TICKER_MESSAGES = [
|
||||
@ -388,10 +226,4 @@ TICKER_MESSAGES = [
|
||||
"🏝️ 巴厘岛 Co-working 今日 89% 满座",
|
||||
"📶 清迈平均网速 95Mbps",
|
||||
"🌅 东京远程工作者满意度 9.1",
|
||||
"🌺 Sofia 在麦德林办完本地 SIM",
|
||||
"🍷 Devon 把下一站设为第比利斯",
|
||||
"🎨 Ken 报名柏林开源 Meetup",
|
||||
"🏙️ Omar 从迪拜中转飞往里斯本",
|
||||
"📷 Lina 在大理发布本周创作日志",
|
||||
"🇰🇷 Yuki 实测首尔联合办公 220Mbps",
|
||||
]
|
||||
|
||||
@ -37,60 +37,6 @@ SERVICES = [
|
||||
"category": "职业",
|
||||
"provider": "HR 志愿者",
|
||||
},
|
||||
{
|
||||
"id": "coliving-match",
|
||||
"title": "合租匹配咨询",
|
||||
"description": "按城市、预算与作息匹配合租对象,附沟通话术模板。",
|
||||
"emoji": "🏡",
|
||||
"price": "¥149 起",
|
||||
"category": "住宿",
|
||||
"provider": "社区主理人",
|
||||
},
|
||||
{
|
||||
"id": "insurance-pick",
|
||||
"title": "国际保险选型",
|
||||
"description": "按年龄、目的地与既往史对比 SafetyWing / Genki / 本地险。",
|
||||
"emoji": "🏥",
|
||||
"price": "¥99 起",
|
||||
"category": "安全",
|
||||
"provider": "保险顾问",
|
||||
},
|
||||
{
|
||||
"id": "content-pack",
|
||||
"title": "城市内容素材包",
|
||||
"description": "可商用照片 + 短视频 B-roll,覆盖咖啡馆与联合办公场景。",
|
||||
"emoji": "📷",
|
||||
"price": "¥259 起",
|
||||
"category": "内容",
|
||||
"provider": "创作者网络",
|
||||
},
|
||||
{
|
||||
"id": "plan-coach",
|
||||
"title": "90 天旅居计划辅导",
|
||||
"description": "一起排城市顺序、预算与签证节点,输出可执行计划表。",
|
||||
"emoji": "🗓️",
|
||||
"price": "¥499 起",
|
||||
"category": "规划",
|
||||
"provider": "nomadro 教练",
|
||||
},
|
||||
{
|
||||
"id": "bank-setup",
|
||||
"title": "跨境账户开户辅导",
|
||||
"description": "Wise / Revolut / 本地银行材料清单与常见拒因规避。",
|
||||
"emoji": "💳",
|
||||
"price": "¥179 起",
|
||||
"category": "财务",
|
||||
"provider": "财务志愿者",
|
||||
},
|
||||
{
|
||||
"id": "meetup-host-coach",
|
||||
"title": "线下活动主办陪跑",
|
||||
"description": "从选题、场地到 RSVP 转化,帮你办成第一场同城局。",
|
||||
"emoji": "🎉",
|
||||
"price": "¥399 起",
|
||||
"category": "社区",
|
||||
"provider": "活动主理人",
|
||||
},
|
||||
]
|
||||
|
||||
VIDEOS = [
|
||||
@ -105,7 +51,7 @@ VIDEOS = [
|
||||
"city": "清迈",
|
||||
"published_at": "2026-03-12",
|
||||
"tags": ["泰国", "成本", "社区"],
|
||||
"video_url": "",
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
},
|
||||
{
|
||||
"id": "lisbon-d7",
|
||||
@ -118,7 +64,7 @@ VIDEOS = [
|
||||
"city": "里斯本",
|
||||
"published_at": "2026-02-08",
|
||||
"tags": ["葡萄牙", "签证"],
|
||||
"video_url": "",
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
},
|
||||
{
|
||||
"id": "bali-cowork",
|
||||
@ -131,124 +77,7 @@ VIDEOS = [
|
||||
"city": "巴厘岛",
|
||||
"published_at": "2026-01-20",
|
||||
"tags": ["印尼", "办公"],
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "medellin-30d",
|
||||
"slug": "medellin-30d",
|
||||
"title": "麦德林 30 天试住 vlog",
|
||||
"excerpt": "安全区、西语学习与北美时区接单节奏。",
|
||||
"emoji": "🌺",
|
||||
"duration": "16:20",
|
||||
"guest": "Sofia",
|
||||
"city": "麦德林",
|
||||
"published_at": "2026-03-28",
|
||||
"tags": ["哥伦比亚", "试住"],
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "tbilisi-tax",
|
||||
"slug": "tbilisi-tax",
|
||||
"title": "第比利斯税务基地访谈",
|
||||
"excerpt": "远程签证、开户与旧城生活成本。",
|
||||
"emoji": "🍷",
|
||||
"duration": "21:05",
|
||||
"guest": "Devon",
|
||||
"city": "第比利斯",
|
||||
"published_at": "2026-04-05",
|
||||
"tags": ["格鲁吉亚", "税务"],
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "berlin-oss",
|
||||
"slug": "berlin-oss",
|
||||
"title": "柏林开源 Meetup 一日",
|
||||
"excerpt": "创业办公空间与技术社交怎么玩。",
|
||||
"emoji": "🎨",
|
||||
"duration": "14:48",
|
||||
"guest": "Ken",
|
||||
"city": "柏林",
|
||||
"published_at": "2026-04-22",
|
||||
"tags": ["德国", "开源"],
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "dali-create",
|
||||
"slug": "dali-create",
|
||||
"title": "大理慢创作一周",
|
||||
"excerpt": "合租、网速与内容产出作息。",
|
||||
"emoji": "📷",
|
||||
"duration": "12:55",
|
||||
"guest": "Lina",
|
||||
"city": "大理",
|
||||
"published_at": "2026-05-03",
|
||||
"tags": ["大理", "内容"],
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "dubai-hub",
|
||||
"slug": "dubai-hub",
|
||||
"title": "迪拜枢纽月怎么排",
|
||||
"excerpt": "签证、炎热季节与航班中转策略。",
|
||||
"emoji": "🏙️",
|
||||
"duration": "13:40",
|
||||
"guest": "Omar",
|
||||
"city": "迪拜",
|
||||
"published_at": "2026-05-18",
|
||||
"tags": ["迪拜", "中东"],
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "seoul-net",
|
||||
"slug": "seoul-net",
|
||||
"title": "首尔网速与深夜办公",
|
||||
"excerpt": "江南联合办公实测与 Workation 签证提示。",
|
||||
"emoji": "🇰🇷",
|
||||
"duration": "11:22",
|
||||
"guest": "Yuki",
|
||||
"city": "首尔",
|
||||
"published_at": "2026-06-02",
|
||||
"tags": ["韩国", "网速"],
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "mexico-food",
|
||||
"slug": "mexico-food",
|
||||
"title": "墨西哥城美食与办公平衡",
|
||||
"excerpt": "Roma Norte 咖啡馆踩点与时区协作。",
|
||||
"emoji": "🌮",
|
||||
"duration": "15:10",
|
||||
"guest": "Alex",
|
||||
"city": "墨西哥城",
|
||||
"published_at": "2026-06-15",
|
||||
"tags": ["墨西哥", "美食"],
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "first-month",
|
||||
"slug": "first-month",
|
||||
"title": "旅居第一月:从慌乱到稳定",
|
||||
"excerpt": "SIM、短租、社群与预算五步法。",
|
||||
"emoji": "✅",
|
||||
"duration": "10:05",
|
||||
"guest": "nomadro",
|
||||
"city": "线上",
|
||||
"published_at": "2026-06-28",
|
||||
"tags": ["新手", "清单"],
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "barcelona-visa",
|
||||
"slug": "barcelona-visa",
|
||||
"title": "巴塞罗那 Nomad Visa 体验",
|
||||
"excerpt": "材料、审批周期与地中海生活成本。",
|
||||
"emoji": "🏖️",
|
||||
"duration": "19:30",
|
||||
"guest": "Sara",
|
||||
"city": "巴塞罗那",
|
||||
"published_at": "2026-07-08",
|
||||
"tags": ["西班牙", "签证"],
|
||||
"video_url": "",
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
},
|
||||
]
|
||||
|
||||
@ -261,14 +90,6 @@ MEMBER_MAP = [
|
||||
{"name": "Ken", "city": "东京", "lat": 35.68, "lng": 139.69, "emoji": "🗼"},
|
||||
{"name": "Lina", "city": "大理", "lat": 25.60, "lng": 100.27, "emoji": "🏔️"},
|
||||
{"name": "Omar", "city": "迪拜", "lat": 25.20, "lng": 55.27, "emoji": "🏙️"},
|
||||
{"name": "Devon", "city": "柏林", "lat": 52.52, "lng": 13.40, "emoji": "🖥️"},
|
||||
{"name": "Sara", "city": "麦德林", "lat": 6.25, "lng": -75.56, "emoji": "🌺"},
|
||||
{"name": "Nina", "city": "第比利斯", "lat": 41.72, "lng": 44.79, "emoji": "🍷"},
|
||||
{"name": "Jin", "city": "首尔", "lat": 37.57, "lng": 126.98, "emoji": "🇰🇷"},
|
||||
{"name": "Priya", "city": "里斯本", "lat": 38.74, "lng": -9.15, "emoji": "💻"},
|
||||
{"name": "Tom", "city": "清迈", "lat": 18.80, "lng": 98.97, "emoji": "☕"},
|
||||
{"name": "Mia", "city": "巴厘岛", "lat": -8.65, "lng": 115.14, "emoji": "🧘"},
|
||||
{"name": "Leo", "city": "墨西哥城", "lat": 19.42, "lng": -99.16, "emoji": "🚀"},
|
||||
]
|
||||
|
||||
CITY_RANKING = [
|
||||
@ -277,23 +98,12 @@ CITY_RANKING = [
|
||||
{"slug": "bali", "name": "巴厘岛", "score": 87, "nomads": "15k+"},
|
||||
{"slug": "mexico", "name": "墨西哥城", "score": 85, "nomads": "6k+"},
|
||||
{"slug": "barcelona", "name": "巴塞罗那", "score": 84, "nomads": "7k+"},
|
||||
{"slug": "medellin", "name": "麦德林", "score": 88, "nomads": "6k+"},
|
||||
{"slug": "tbilisi", "name": "第比利斯", "score": 86, "nomads": "5k+"},
|
||||
{"slug": "berlin", "name": "柏林", "score": 83, "nomads": "7k+"},
|
||||
{"slug": "dali", "name": "大理", "score": 82, "nomads": "4k+"},
|
||||
{"slug": "seoul", "name": "首尔", "score": 81, "nomads": "3k+"},
|
||||
{"slug": "tokyo", "name": "东京", "score": 80, "nomads": "4k+"},
|
||||
{"slug": "dubai", "name": "迪拜", "score": 79, "nomads": "5k+"},
|
||||
]
|
||||
|
||||
DAILY_TIPS = {
|
||||
"1": {"title": "第一天:确定税务居民身份", "body": "出发前了解母国与目标国的 183 天规则,避免双重征税意外。"},
|
||||
"2": {"title": "第二天:准备多币种账户", "body": "Wise / Revolut 等跨境账户可大幅降低取现与换汇成本。"},
|
||||
"3": {"title": "第三天:测试远程办公网速", "body": "落地先住 3 天短租,实测联合办公与公寓 WiFi 再签长租。"},
|
||||
"4": {"title": "第四天:加入同城社群", "body": "报名一场 meetup,比独自逛咖啡馆更快建立支持网络。"},
|
||||
"5": {"title": "第五天:备份通讯方案", "body": "本地 SIM + eSIM 热点双备份,开会前先压测上行带宽。"},
|
||||
"6": {"title": "第六天:校准月预算", "body": "用第一周真实账单更新计划表,别只信网上平均值。"},
|
||||
"7": {"title": "第七天:固定作息", "body": "保护每日 4 小时深度工作,探索放在下午后段。"},
|
||||
}
|
||||
|
||||
WEATHER_CITIES = {
|
||||
@ -303,10 +113,4 @@ WEATHER_CITIES = {
|
||||
"mexico": {"lat": 19.43, "lng": -99.13, "name": "墨西哥城"},
|
||||
"barcelona": {"lat": 41.39, "lng": 2.17, "name": "巴塞罗那"},
|
||||
"tokyo": {"lat": 35.68, "lng": 139.69, "name": "东京"},
|
||||
"berlin": {"lat": 52.52, "lng": 13.40, "name": "柏林"},
|
||||
"dali": {"lat": 25.60, "lng": 100.27, "name": "大理"},
|
||||
"seoul": {"lat": 37.57, "lng": 126.98, "name": "首尔"},
|
||||
"medellin": {"lat": 6.25, "lng": -75.56, "name": "麦德林"},
|
||||
"dubai": {"lat": 25.20, "lng": 55.27, "name": "迪拜"},
|
||||
"tbilisi": {"lat": 41.72, "lng": 44.79, "name": "第比利斯"},
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ MATCH_INTENTS = ("friends", "dating", "partner", "roommate", "cofounder", "explo
|
||||
|
||||
CANDIDATE_PROFILES = [
|
||||
{
|
||||
"id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiangmai",
|
||||
"id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiang-mai",
|
||||
"gender": "女", "single": "是", "bio": "远程前端,喜欢咖啡和徒步。",
|
||||
"photo": "🧳", "tags": ["远程", "咖啡", "徒步"],
|
||||
"lookingFor": ["friends", "dating", "explore"],
|
||||
@ -22,7 +22,7 @@ CANDIDATE_PROFILES = [
|
||||
"lookingFor": ["roommate", "friends"],
|
||||
},
|
||||
{
|
||||
"id": "p4", "userId": "u4", "name": "Alex", "location": "墨西哥城", "citySlug": "mexico",
|
||||
"id": "p4", "userId": "u4", "name": "Alex", "location": "墨西哥城", "citySlug": "mexico-city",
|
||||
"gender": "男", "single": "是", "bio": "独立开发者,拉美时区常驻。",
|
||||
"photo": "🚀", "tags": ["独立开发", "拉美"],
|
||||
"lookingFor": ["cofounder", "friends", "dating"],
|
||||
@ -51,52 +51,4 @@ CANDIDATE_PROFILES = [
|
||||
"photo": "🖥️", "tags": ["DevOps", "开源"],
|
||||
"lookingFor": ["cofounder", "friends"],
|
||||
},
|
||||
{
|
||||
"id": "p9", "userId": "u9", "name": "Sofia", "location": "麦德林", "citySlug": "medellin",
|
||||
"gender": "女", "single": "是", "bio": "自由设计师,北美时区接单,爱西语咖啡局。",
|
||||
"photo": "🌺", "tags": ["设计", "西语", "拉美"],
|
||||
"lookingFor": ["friends", "dating", "explore"],
|
||||
},
|
||||
{
|
||||
"id": "p10", "userId": "u10", "name": "Omar", "location": "迪拜", "citySlug": "dubai",
|
||||
"gender": "男", "single": "是", "bio": "增长顾问,常把迪拜当欧亚跳板。",
|
||||
"photo": "🏙️", "tags": ["增长", "中东", "航班"],
|
||||
"lookingFor": ["friends", "partner", "explore"],
|
||||
},
|
||||
{
|
||||
"id": "p11", "userId": "u11", "name": "Nina", "location": "第比利斯", "citySlug": "tbilisi",
|
||||
"gender": "女", "single": "是", "bio": "独立顾问,研究税务友好基地与旧城生活。",
|
||||
"photo": "🍷", "tags": ["税务", "顾问", "欧洲"],
|
||||
"lookingFor": ["friends", "cofounder"],
|
||||
},
|
||||
{
|
||||
"id": "p12", "userId": "u12", "name": "Jin", "location": "首尔", "citySlug": "seoul",
|
||||
"gender": "男", "single": "是", "bio": "移动端工程师,沉迷深夜联合办公。",
|
||||
"photo": "🇰🇷", "tags": ["移动端", "效率", "网速"],
|
||||
"lookingFor": ["friends", "roommate", "explore"],
|
||||
},
|
||||
{
|
||||
"id": "p13", "userId": "u13", "name": "Priya", "location": "里斯本", "citySlug": "lisbon",
|
||||
"gender": "女", "single": "是", "bio": "产品设计师,喜欢海边徒步与博物馆。",
|
||||
"photo": "🌊", "tags": ["产品设计", "徒步"],
|
||||
"lookingFor": ["dating", "friends"],
|
||||
},
|
||||
{
|
||||
"id": "p14", "userId": "u14", "name": "Tom", "location": "清迈", "citySlug": "chiangmai",
|
||||
"gender": "男", "single": "是", "bio": "后端工程师,咖啡店常客,周末骑摩托。",
|
||||
"photo": "☕", "tags": ["后端", "咖啡", "摩托"],
|
||||
"lookingFor": ["friends", "cofounder", "explore"],
|
||||
},
|
||||
{
|
||||
"id": "p15", "userId": "u15", "name": "Mia", "location": "巴厘岛", "citySlug": "bali",
|
||||
"gender": "女", "single": "是", "bio": "瑜伽老师兼内容创作者,寻找同频合租。",
|
||||
"photo": "🧘", "tags": ["瑜伽", "内容", "合租"],
|
||||
"lookingFor": ["roommate", "friends", "partner"],
|
||||
},
|
||||
{
|
||||
"id": "p16", "userId": "u16", "name": "Leo", "location": "墨西哥城", "citySlug": "mexico",
|
||||
"gender": "男", "single": "是", "bio": "创业者,做拉美远程协作工具。",
|
||||
"photo": "🌮", "tags": ["创业", "拉美", "SaaS"],
|
||||
"lookingFor": ["cofounder", "friends", "dating"],
|
||||
},
|
||||
]
|
||||
|
||||
@ -13,8 +13,7 @@ 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_lounge_channel, create_mirotalk_room_slug,
|
||||
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
|
||||
@ -276,35 +275,15 @@ async def rsvp_meetup(body: MeetupRsvpRequest, authorization: str | None = Heade
|
||||
raise HTTPException(400, "活动已满员")
|
||||
|
||||
meetup = community_store.get_meetup(body.meetup_id) or {}
|
||||
title = meetup.get("title") or "活动"
|
||||
mode = meetup.get("mode") or ""
|
||||
organizer_id = meetup.get("organizer_id") or ""
|
||||
if organizer_id and organizer_id != user_id:
|
||||
from app.services.notify import notify_user
|
||||
|
||||
if organizer_id and organizer_id != user_id:
|
||||
notify_user(
|
||||
organizer_id,
|
||||
"新的活动报名",
|
||||
f"{user.get('name', '游民')} 报名了「{title}」",
|
||||
"/meetups",
|
||||
"meetup",
|
||||
)
|
||||
|
||||
# Confirm to attendee with next-step deep link (live room or profile calendar)
|
||||
if mode in ("online", "hybrid"):
|
||||
notify_user(
|
||||
user_id,
|
||||
"报名成功",
|
||||
f"你已报名「{title}」,活动开始时可进入直播间",
|
||||
f"/meetups/{body.meetup_id}/live",
|
||||
"meetup",
|
||||
)
|
||||
else:
|
||||
notify_user(
|
||||
user_id,
|
||||
"报名成功",
|
||||
f"你已报名「{title}」,可在个人中心导出日历",
|
||||
"/profile",
|
||||
f"{user.get('name', '游民')} 报名了「{meetup.get('title', '活动')}」",
|
||||
f"/meetups",
|
||||
"meetup",
|
||||
)
|
||||
return {"success": True, "message": res["message"], "rsvp_count": res["rsvp_count"]}
|
||||
@ -369,37 +348,17 @@ async def meetup_session(meetup_id: str, authorization: str | None = Header(None
|
||||
mode = meetup.get("mode", "offline")
|
||||
display = (user or {}).get("name", "访客")
|
||||
|
||||
if mode not in ("online", "hybrid"):
|
||||
can_join = False
|
||||
lock = lock or "offline"
|
||||
elif can_join:
|
||||
# Live rooms need login + RSVP (organizer exempt)
|
||||
if not user:
|
||||
can_join = False
|
||||
lock = "login_required"
|
||||
else:
|
||||
organizer_id = meetup.get("organizer_id") or ""
|
||||
is_host = bool(organizer_id) and organizer_id == user["id"]
|
||||
if not is_host:
|
||||
rsvps = community_store.user_rsvp_ids(user["id"])
|
||||
mid = meetup.get("id") or meetup_id
|
||||
if mid not in rsvps and meetup_id not in rsvps:
|
||||
can_join = False
|
||||
lock = "rsvp_required"
|
||||
|
||||
session = MeetupSession(
|
||||
canJoin=can_join,
|
||||
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") or "",
|
||||
loungeChannel=meetup.get("loungeChannel", ""),
|
||||
)
|
||||
if session.canJoin:
|
||||
session.videoUrl = build_mirotalk_join_url(meetup, display)
|
||||
session.chatUrl = build_lounge_chat_url(meetup)
|
||||
if not session.loungeChannel:
|
||||
session.loungeChannel = create_lounge_channel(meetup)
|
||||
return session
|
||||
|
||||
|
||||
|
||||
@ -14,29 +14,16 @@ from app.services.auth import (
|
||||
update_profile,
|
||||
)
|
||||
from app.services.pocketbase import pb
|
||||
from app.services import social_store
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _with_social_seed(result: dict | None) -> dict | None:
|
||||
if not result:
|
||||
return result
|
||||
try:
|
||||
uid = (result.get("user") or {}).get("id")
|
||||
if uid:
|
||||
social_store.seed_demo_social_activity(uid)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/register", response_model=AuthResponse)
|
||||
async def register(body: AuthRegister):
|
||||
result = register_user(body.email, body.password, body.name)
|
||||
if not result:
|
||||
raise HTTPException(400, "该邮箱已注册")
|
||||
return AuthResponse(**_with_social_seed(result))
|
||||
return AuthResponse(**result)
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthResponse)
|
||||
@ -44,7 +31,7 @@ async def login(body: AuthLogin):
|
||||
result = login_user(body.email, body.password)
|
||||
if not result:
|
||||
raise HTTPException(401, "邮箱或密码错误")
|
||||
return AuthResponse(**_with_social_seed(result))
|
||||
return AuthResponse(**result)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserProfile)
|
||||
@ -82,7 +69,7 @@ async def demo_login():
|
||||
result = login_demo()
|
||||
if not result:
|
||||
raise HTTPException(500, "演示账号不可用")
|
||||
return AuthResponse(**_with_social_seed(result))
|
||||
return AuthResponse(**result)
|
||||
|
||||
|
||||
@router.post("/google", response_model=AuthResponse)
|
||||
@ -105,7 +92,7 @@ async def google_login(body: GoogleLoginRequest):
|
||||
result = login_google(email, name)
|
||||
if not result:
|
||||
raise HTTPException(500, "登录失败")
|
||||
return AuthResponse(**_with_social_seed(result))
|
||||
return AuthResponse(**result)
|
||||
|
||||
|
||||
@router.get("/favorites", response_model=FavoriteResponse)
|
||||
|
||||
@ -100,14 +100,6 @@ async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | Non
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "申请失败"))
|
||||
poster_id = res.get("poster_id") or ""
|
||||
if not poster_id:
|
||||
email = (res.get("poster_email") or "").strip().lower()
|
||||
if email:
|
||||
from app.services.auth import find_user_public_by_email
|
||||
|
||||
peer = find_user_public_by_email(email)
|
||||
if peer:
|
||||
poster_id = peer["id"]
|
||||
if poster_id and poster_id != user["id"]:
|
||||
from app.services.notify import notify_user
|
||||
|
||||
|
||||
@ -23,21 +23,8 @@ def _user(authorization: str | None) -> dict:
|
||||
async def ebook_status(authorization: str | None = Header(None)):
|
||||
user = _user(authorization)
|
||||
owned = social_store.has_ebook(user["id"])
|
||||
has_files = bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)
|
||||
return {
|
||||
"owned": owned,
|
||||
"has_files": has_files,
|
||||
"amount_fen": PAYMENT_EBOOK_AMOUNT,
|
||||
"amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/product")
|
||||
async def ebook_product():
|
||||
"""Public product info — no auth (buy CTA honesty)."""
|
||||
has_files = bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)
|
||||
return {
|
||||
"has_files": has_files,
|
||||
"amount_fen": PAYMENT_EBOOK_AMOUNT,
|
||||
"amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}",
|
||||
}
|
||||
@ -54,5 +41,5 @@ async def ebook_downloads(authorization: str | None = Header(None)):
|
||||
if EBOOK_EPUB_URL:
|
||||
files.append({"format": "epub", "label": "EPUB", "url": EBOOK_EPUB_URL})
|
||||
if not files:
|
||||
files.append({"format": "online", "label": "Online reading", "url": "/book/read"})
|
||||
return {"owned": True, "files": files, "has_files": bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)}
|
||||
files.append({"format": "online", "label": "在线阅读", "url": "/book/read"})
|
||||
return {"owned": True, "files": files}
|
||||
|
||||
@ -25,36 +25,14 @@ def _token_user(authorization: str | None) -> dict:
|
||||
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_payload(profile, user["id"])}
|
||||
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.get_public_profile(user["id"])
|
||||
return {"complete": bool(profile), "profile": _profile_payload(profile, user["id"]) if profile else None}
|
||||
|
||||
|
||||
def _profile_payload(profile: dict | None, user_id: str) -> dict:
|
||||
if not profile:
|
||||
return {}
|
||||
return {
|
||||
"id": profile.get("id") or f"user-{user_id}",
|
||||
"userId": profile.get("userId") or user_id,
|
||||
"name": profile.get("name") or "游民",
|
||||
"location": profile.get("location") or "",
|
||||
"citySlug": profile.get("citySlug") or "",
|
||||
"gender": profile.get("gender") or "",
|
||||
"single": profile.get("single") or "",
|
||||
"bio": profile.get("bio") or "",
|
||||
"photo": profile.get("photo") or "🧑💻",
|
||||
"tags": profile.get("tags") or [],
|
||||
"lookingFor": profile.get("lookingFor") or profile.get("looking_for") or [],
|
||||
}
|
||||
|
||||
|
||||
def _has_match_profile(user_id: str) -> bool:
|
||||
return social_store.get_public_profile(user_id) is not None
|
||||
profile = social_store._profiles.get(user["id"])
|
||||
return {"complete": bool(profile), "profile": profile}
|
||||
|
||||
|
||||
@router.get("/matches/candidates", response_model=list[MatchProfile])
|
||||
@ -67,13 +45,8 @@ async def match_candidates(
|
||||
exclude_swiped: bool = Query(True),
|
||||
):
|
||||
user = _token_user(authorization)
|
||||
if not _has_match_profile(user["id"]):
|
||||
# Soft-join so dating is usable after login without a stuck gate
|
||||
social_store.join_member(
|
||||
user["id"],
|
||||
user.get("name") or "游民",
|
||||
{"city": "全球", "lookingFor": [intent, "explore"], "bio": "刚加入 nomadro 匹配"},
|
||||
)
|
||||
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
|
||||
)]
|
||||
@ -163,5 +136,5 @@ async def post_message(conv_id: str, body: SendMessageRequest, authorization: st
|
||||
async def vip_check(authorization: str | None = Header(None)):
|
||||
user = _token_user(authorization)
|
||||
vip = social_store.is_vip(user["id"])
|
||||
exp = social_store.membership_expires_at(user["id"])
|
||||
exp = (social_store._memberships.get(user["id"]) or {}).get("expires_at", 0)
|
||||
return VipStatus(vip=vip, expires_at=exp)
|
||||
|
||||
@ -400,111 +400,13 @@ def find_user_public_by_email(email: str) -> dict | None:
|
||||
return _user_profile(user) if user else None
|
||||
|
||||
|
||||
# Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase
|
||||
_DEMO_SEED_USERS = [
|
||||
("demo@nomadro.com", "demo123", "演示用户", "🧑💻"),
|
||||
("xiaolin@nomadro.com", "demo123", "小林", "🧳"),
|
||||
("marco@nomadro.com", "demo123", "Marco", "💻"),
|
||||
("yuki@nomadro.com", "demo123", "Yuki", "🎨"),
|
||||
("alex@nomadro.com", "demo123", "Alex", "🚀"),
|
||||
("sara@nomadro.com", "demo123", "Sara", "☕"),
|
||||
("ken@nomadro.com", "demo123", "Ken", "📊"),
|
||||
("lina@nomadro.com", "demo123", "Lina", "📷"),
|
||||
("devon@nomadro.com", "demo123", "Devon", "🖥️"),
|
||||
("sofia@nomadro.com", "demo123", "Sofia", "🌺"),
|
||||
("omar@nomadro.com", "demo123", "Omar", "🏙️"),
|
||||
("nina@nomadro.com", "demo123", "Nina", "🍷"),
|
||||
]
|
||||
|
||||
_DEMO_FAVORITES = {
|
||||
"演示用户": ["chiangmai", "lisbon", "bali"],
|
||||
"小林": ["chiangmai", "dali", "bali"],
|
||||
"Marco": ["lisbon", "barcelona", "berlin"],
|
||||
"Yuki": ["bali", "seoul", "tokyo"],
|
||||
"Alex": ["mexico", "medellin", "lisbon"],
|
||||
"Sara": ["barcelona", "lisbon", "medellin"],
|
||||
"Ken": ["tokyo", "seoul", "berlin"],
|
||||
"Lina": ["dali", "chiangmai", "bali"],
|
||||
"Devon": ["berlin", "tbilisi", "lisbon"],
|
||||
"Sofia": ["medellin", "mexico", "barcelona"],
|
||||
"Omar": ["dubai", "lisbon", "tbilisi"],
|
||||
"Nina": ["tbilisi", "berlin", "dubai"],
|
||||
}
|
||||
|
||||
_DEMO_PLANS = {
|
||||
"演示用户": {
|
||||
"items": [
|
||||
{"slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️", "cost": 3800, "months": 2, "note": "先稳住节奏"},
|
||||
{"slug": "bali", "name": "巴厘岛", "country": "印尼", "emoji": "🏝️", "cost": 4500, "months": 1, "note": ""},
|
||||
{"slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊", "cost": 9000, "months": 2, "note": "评估 D7"},
|
||||
],
|
||||
"meta": {"title": "演示旅居计划", "startMonth": "2026-10", "monthlyBudget": 8000, "checklist": {"visa": True, "sim": True}},
|
||||
"updated_at": 1,
|
||||
},
|
||||
"小林": {
|
||||
"items": [
|
||||
{"slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️", "cost": 3800, "months": 3, "note": ""},
|
||||
{"slug": "dali", "name": "大理", "country": "中国", "emoji": "🏔️", "cost": 4200, "months": 1, "note": "慢创作"},
|
||||
],
|
||||
"meta": {"title": "东南亚慢旅", "startMonth": "2026-09", "monthlyBudget": 4500, "checklist": {}},
|
||||
"updated_at": 1,
|
||||
},
|
||||
"Marco": {
|
||||
"items": [
|
||||
{"slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊", "cost": 9000, "months": 4, "note": ""},
|
||||
{"slug": "barcelona", "name": "巴塞罗那", "country": "西班牙", "emoji": "🏖️", "cost": 10500, "months": 1, "note": ""},
|
||||
],
|
||||
"meta": {"title": "欧洲基地", "startMonth": "2026-08", "monthlyBudget": 12000, "checklist": {"visa": True}},
|
||||
"updated_at": 1,
|
||||
},
|
||||
"Sofia": {
|
||||
"items": [
|
||||
{"slug": "medellin", "name": "麦德林", "country": "哥伦比亚", "emoji": "🌺", "cost": 5200, "months": 2, "note": ""},
|
||||
{"slug": "mexico", "name": "墨西哥城", "country": "墨西哥", "emoji": "🌃", "cost": 6500, "months": 1, "note": ""},
|
||||
],
|
||||
"meta": {"title": "拉美春城", "startMonth": "2026-10", "monthlyBudget": 5500, "checklist": {}},
|
||||
"updated_at": 1,
|
||||
},
|
||||
"Devon": {
|
||||
"items": [
|
||||
{"slug": "tbilisi", "name": "第比利斯", "country": "格鲁吉亚", "emoji": "🍷", "cost": 4800, "months": 3, "note": ""},
|
||||
{"slug": "berlin", "name": "柏林", "country": "德国", "emoji": "🎨", "cost": 9800, "months": 1, "note": ""},
|
||||
],
|
||||
"meta": {"title": "税务友好试住", "startMonth": "2026-11", "monthlyBudget": 6000, "checklist": {}},
|
||||
"updated_at": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _seed_demo_users() -> None:
|
||||
for email, password, name, avatar in _DEMO_SEED_USERS:
|
||||
if email not in _users:
|
||||
created = register_user(email, password, name)
|
||||
if not created:
|
||||
continue
|
||||
uid = _users[email]["id"]
|
||||
_users[email]["avatar"] = avatar
|
||||
favs = _DEMO_FAVORITES.get(name)
|
||||
if favs and not (_favorites.get(uid) or []):
|
||||
_favorites[uid] = list(favs)
|
||||
plan = _DEMO_PLANS.get(name)
|
||||
if plan and not ((_plans.get(uid) or {}).get("items") or []):
|
||||
_plans[uid] = plan
|
||||
_persist_json()
|
||||
|
||||
|
||||
# Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase
|
||||
if not use_pb():
|
||||
if not _restore_json():
|
||||
login_demo()
|
||||
elif "demo@nomadro.com" not in _users:
|
||||
login_demo()
|
||||
_seed_demo_users()
|
||||
else:
|
||||
_migrate_json_to_pb()
|
||||
if not _get_account_by_email("demo@nomadro.com"):
|
||||
register_user("demo@nomadro.com", "demo123", "演示用户")
|
||||
# Best-effort: ensure a few named demo accounts exist in PocketBase too
|
||||
for email, password, name, _avatar in _DEMO_SEED_USERS:
|
||||
if not _get_account_by_email(email):
|
||||
register_user(email, password, name)
|
||||
|
||||
@ -263,7 +263,7 @@ def create_discussion(user_id: str, user_name: str, payload: dict) -> dict:
|
||||
{
|
||||
"legacyId": did,
|
||||
"title": payload["title"],
|
||||
"excerpt": (payload.get("excerpt") or payload.get("content") or "")[:1000],
|
||||
"excerpt": payload.get("excerpt") or payload.get("content", "")[:200],
|
||||
"author": user_name,
|
||||
"authorUserId": user_id,
|
||||
"authorEmoji": payload.get("author_emoji", "🧑💻"),
|
||||
@ -361,7 +361,6 @@ def _gig_from_pb(r: dict) -> dict:
|
||||
"tags": r.get("tags") or [],
|
||||
"poster": r.get("posterName", ""),
|
||||
"poster_id": r.get("posterId", ""),
|
||||
"poster_email": r.get("posterEmail", ""),
|
||||
"status": r.get("status", "open"),
|
||||
"category": r.get("category", ""),
|
||||
"location": r.get("location", ""),
|
||||
@ -417,7 +416,6 @@ def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
|
||||
"ok": True,
|
||||
"message": "申请已提交",
|
||||
"poster_id": g.get("posterId", ""),
|
||||
"poster_email": g.get("posterEmail", ""),
|
||||
"title": g.get("title", ""),
|
||||
}
|
||||
|
||||
@ -570,9 +568,6 @@ _JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "community_store.jso
|
||||
_j: dict[str, Any] = {}
|
||||
|
||||
|
||||
_SEED_VERSION = 4
|
||||
|
||||
|
||||
def _jload() -> None:
|
||||
global _j
|
||||
if _j:
|
||||
@ -582,30 +577,19 @@ def _jload() -> None:
|
||||
_j.update(json.loads(_JSON_PATH.read_text(encoding="utf-8")))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
needs_seed = not _j.get("seeded") or not (
|
||||
(_j.get("meetups") or []) and (_j.get("discussions") or []) and (_j.get("gigs") or [])
|
||||
)
|
||||
version_bump = int(_j.get("seed_version") or 0) < _SEED_VERSION
|
||||
if needs_seed or version_bump:
|
||||
keep_notif = _j.get("notifications") or {}
|
||||
keep_rsvp = _j.get("rsvps") or {}
|
||||
keep_apps = _j.get("gig_apps") or []
|
||||
keep_feedback = _j.get("feedback") or []
|
||||
keep_views = _j.get("views") or {}
|
||||
keep_likes = _j.get("discussion_likes") or {}
|
||||
if not _j.get("seeded"):
|
||||
_j.update({
|
||||
"meetups": deepcopy(community_data.MEETUPS),
|
||||
"discussions": deepcopy(community_data.DISCUSSIONS),
|
||||
"replies": deepcopy(community_data.DISCUSSION_REPLIES),
|
||||
"gigs": deepcopy(getattr(community_data, "GIGS", [])),
|
||||
"discussion_likes": keep_likes,
|
||||
"rsvps": keep_rsvp,
|
||||
"gig_apps": keep_apps,
|
||||
"notifications": keep_notif,
|
||||
"feedback": keep_feedback,
|
||||
"views": keep_views,
|
||||
"discussion_likes": {},
|
||||
"rsvps": {},
|
||||
"gig_apps": [],
|
||||
"notifications": {},
|
||||
"feedback": [],
|
||||
"views": {},
|
||||
"seeded": True,
|
||||
"seed_version": _SEED_VERSION,
|
||||
})
|
||||
_jsave()
|
||||
|
||||
@ -755,7 +739,6 @@ def _json_apply_gig(gig_id: str, user_id: str, user_name: str, message: str) ->
|
||||
"ok": True,
|
||||
"message": "申请已提交",
|
||||
"poster_id": gig.get("poster_id", ""),
|
||||
"poster_email": gig.get("poster_email", ""),
|
||||
"title": gig.get("title", ""),
|
||||
}
|
||||
|
||||
|
||||
@ -73,14 +73,6 @@ def is_vip(user_id: str) -> bool:
|
||||
return bool(m and (m.get("expires_at") or 0) > int(time.time()))
|
||||
|
||||
|
||||
def membership_expires_at(user_id: str) -> int:
|
||||
if use_pb():
|
||||
row = safe_first("memberships", filter=f"userId={q(user_id)}")
|
||||
return int((row or {}).get("expiresAt") or 0)
|
||||
_jload()
|
||||
return int(((_j.get("memberships") or {}).get(user_id) or {}).get("expires_at") or 0)
|
||||
|
||||
|
||||
def ensure_membership(user_id: str, days: int = 365) -> None:
|
||||
exp = int(time.time()) + days * 86400
|
||||
if use_pb():
|
||||
@ -140,7 +132,7 @@ def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict:
|
||||
|
||||
|
||||
def join_member(user_id: str, name: str, payload: dict) -> dict:
|
||||
profile = get_or_create_profile(
|
||||
return get_or_create_profile(
|
||||
user_id,
|
||||
name,
|
||||
location=payload.get("city", "全球"),
|
||||
@ -151,92 +143,17 @@ def join_member(user_id: str, name: str, payload: dict) -> dict:
|
||||
lookingFor=payload.get("lookingFor", ["friends", "explore"]),
|
||||
photo=payload.get("photo", "🧑💻"),
|
||||
)
|
||||
try:
|
||||
seed_demo_social_activity(user_id)
|
||||
except Exception:
|
||||
pass
|
||||
return profile
|
||||
|
||||
|
||||
def get_public_profile(user_id: str) -> dict | None:
|
||||
if not user_id:
|
||||
return None
|
||||
# Accept /members/user-{id} style links
|
||||
lookup = user_id[5:] if user_id.startswith("user-") else user_id
|
||||
|
||||
if use_pb():
|
||||
row = safe_first("profiles", filter=f"userId={q(lookup)}")
|
||||
if row:
|
||||
return {
|
||||
"id": row.get("id") or f"user-{lookup}",
|
||||
"userId": row.get("userId") or lookup,
|
||||
"name": row.get("name") or "游民",
|
||||
"location": row.get("location") or "",
|
||||
"citySlug": row.get("citySlug") or "",
|
||||
"gender": row.get("gender") or "",
|
||||
"single": row.get("single") or "",
|
||||
"bio": row.get("bio") or "",
|
||||
"photo": row.get("photo") or "🧑💻",
|
||||
"tags": row.get("tags") or [],
|
||||
"lookingFor": _profile_looking_for(row),
|
||||
"vip": is_vip(lookup),
|
||||
}
|
||||
else:
|
||||
row = safe_first("profiles", filter=f"userId={q(user_id)}")
|
||||
if not row:
|
||||
return None
|
||||
return {**row, "vip": is_vip(user_id)}
|
||||
_jload()
|
||||
p = (_j.get("profiles") or {}).get(lookup)
|
||||
if p:
|
||||
return {
|
||||
"id": f"user-{lookup}",
|
||||
"userId": lookup,
|
||||
"name": p.get("name") or "游民",
|
||||
"location": p.get("location") or "",
|
||||
"citySlug": p.get("citySlug") or "",
|
||||
"gender": p.get("gender") or "",
|
||||
"single": p.get("single") or "",
|
||||
"bio": p.get("bio") or "",
|
||||
"photo": p.get("photo") or "🧑💻",
|
||||
"tags": p.get("tags") or [],
|
||||
"lookingFor": _profile_looking_for(p),
|
||||
"vip": is_vip(lookup),
|
||||
}
|
||||
|
||||
for cand in CANDIDATE_PROFILES:
|
||||
if cand.get("userId") == lookup or cand.get("id") == lookup or cand.get("id") == user_id:
|
||||
return {
|
||||
"id": cand.get("id") or f"user-{cand.get('userId', lookup)}",
|
||||
"userId": cand.get("userId") or lookup,
|
||||
"name": cand.get("name") or "游民",
|
||||
"location": cand.get("location") or "",
|
||||
"citySlug": cand.get("citySlug") or "",
|
||||
"gender": cand.get("gender") or "",
|
||||
"single": cand.get("single") or "",
|
||||
"bio": cand.get("bio") or "",
|
||||
"photo": cand.get("photo") or "🧑💻",
|
||||
"tags": cand.get("tags") or [],
|
||||
"lookingFor": _profile_looking_for(cand),
|
||||
"vip": False,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def normalize_peer(peer: dict | None) -> dict | None:
|
||||
if not peer:
|
||||
return None
|
||||
uid = peer.get("userId") or ""
|
||||
return {
|
||||
"id": peer.get("id") or (f"user-{uid}" if uid else "unknown"),
|
||||
"userId": uid,
|
||||
"name": peer.get("name") or "游民",
|
||||
"location": peer.get("location") or "",
|
||||
"citySlug": peer.get("citySlug") or "",
|
||||
"gender": peer.get("gender") or "",
|
||||
"single": peer.get("single") or "",
|
||||
"bio": peer.get("bio") or "",
|
||||
"photo": peer.get("photo") or "🧑💻",
|
||||
"tags": peer.get("tags") or [],
|
||||
"lookingFor": _profile_looking_for(peer),
|
||||
"intent": peer.get("intent") or "",
|
||||
}
|
||||
p = (_j.get("profiles") or {}).get(user_id)
|
||||
return {**p, "vip": is_vip(user_id)} if p else None
|
||||
|
||||
|
||||
# ── Candidates / swipes ────────────────────────────────────────────────
|
||||
@ -581,23 +498,19 @@ def list_conversations(user_id: str) -> list[dict]:
|
||||
rows = safe_list("conversations", filter=f"userAId={q(user_id)} || userBId={q(user_id)}")
|
||||
rows.sort(key=lambda c: c.get("lastMessageAt") or c.get("created", ""), reverse=True)
|
||||
for c in rows:
|
||||
if c.get("userAId") == c.get("userBId"):
|
||||
continue
|
||||
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
|
||||
read_at = (c.get("readState") or {}).get(user_id, "")
|
||||
unread = 1 if (c.get("lastMessageAt") or "") > read_at else 0
|
||||
items.append({**c, "peer": normalize_peer(_peer_profile(peer_id)), "unreadCount": unread})
|
||||
items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread})
|
||||
return items
|
||||
_jload()
|
||||
for c in _j.get("conversations") or []:
|
||||
if user_id not in (c.get("userAId"), c.get("userBId")):
|
||||
continue
|
||||
if c.get("userAId") == c.get("userBId"):
|
||||
continue
|
||||
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
|
||||
read_at = (c.get("readState") or {}).get(user_id, "")
|
||||
unread = 1 if c.get("lastMessageAt", "") > read_at else 0
|
||||
items.append({**c, "peer": normalize_peer(_peer_profile(peer_id)), "unreadCount": unread})
|
||||
items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread})
|
||||
items.sort(key=lambda x: x.get("lastMessageAt", ""), reverse=True)
|
||||
return items
|
||||
|
||||
@ -608,12 +521,12 @@ def get_conversation(conv_id: str, user_id: str) -> dict | None:
|
||||
if not row or user_id not in (row.get("userAId"), row.get("userBId")):
|
||||
return None
|
||||
peer_id = row["userBId"] if row["userAId"] == user_id else row["userAId"]
|
||||
return {**row, "peer": normalize_peer(_peer_profile(peer_id))}
|
||||
return {**row, "peer": _peer_profile(peer_id)}
|
||||
_jload()
|
||||
for c in _j.get("conversations") or []:
|
||||
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"]
|
||||
return {**c, "peer": normalize_peer(_peer_profile(peer_id))}
|
||||
return {**c, "peer": _peer_profile(peer_id)}
|
||||
return None
|
||||
|
||||
|
||||
@ -749,178 +662,6 @@ def mark_order_paid(order_id: str) -> dict | None:
|
||||
return order
|
||||
|
||||
|
||||
def seed_demo_social_activity(user_id: str) -> None:
|
||||
"""Give a real user a few incoming likes + one chat so Connect feels alive."""
|
||||
if not user_id:
|
||||
return
|
||||
my_pid = f"user-{user_id}"
|
||||
peers = [p for p in CANDIDATE_PROFILES if p.get("userId")][:4]
|
||||
if not peers:
|
||||
return
|
||||
|
||||
buddy = peers[0]
|
||||
buddy_uid = buddy["userId"]
|
||||
already = False
|
||||
if use_pb():
|
||||
already = bool(safe_first("swipes", filter=f"userId={q(buddy_uid)} && profileId={q(my_pid)}"))
|
||||
else:
|
||||
_jload()
|
||||
already = bool((_j.get("demo_seeded") or {}).get(user_id)) or any(
|
||||
s.get("userId") == buddy_uid and s.get("profileId") == my_pid for s in (_j.get("swipes") or [])
|
||||
)
|
||||
if already:
|
||||
return
|
||||
|
||||
# Incoming likes from candidates toward this user
|
||||
for p in peers[:3]:
|
||||
peer_uid = p["userId"]
|
||||
if use_pb():
|
||||
if not safe_first("swipes", filter=f"userId={q(peer_uid)} && profileId={q(my_pid)}"):
|
||||
safe_create(
|
||||
"swipes",
|
||||
{
|
||||
"userId": peer_uid,
|
||||
"profileId": my_pid,
|
||||
"action": "like",
|
||||
"intent": "friends",
|
||||
"swipeDate": _today_key(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
_jload()
|
||||
swipes = _j.setdefault("swipes", [])
|
||||
if not any(s.get("userId") == peer_uid and s.get("profileId") == my_pid for s in swipes):
|
||||
swipes.append(
|
||||
{
|
||||
"id": secrets.token_hex(6),
|
||||
"userId": peer_uid,
|
||||
"profileId": my_pid,
|
||||
"action": "like",
|
||||
"intent": "friends",
|
||||
"date": _today_key(),
|
||||
"createdAt": _now_iso(),
|
||||
}
|
||||
)
|
||||
|
||||
# Mutual match + messages with first peer
|
||||
buddy_pid = buddy["id"]
|
||||
if use_pb():
|
||||
if not safe_first("swipes", filter=f"userId={q(user_id)} && profileId={q(buddy_pid)}"):
|
||||
safe_create(
|
||||
"swipes",
|
||||
{
|
||||
"userId": user_id,
|
||||
"profileId": buddy_pid,
|
||||
"action": "like",
|
||||
"intent": "friends",
|
||||
"swipeDate": _today_key(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
_jload()
|
||||
swipes = _j.setdefault("swipes", [])
|
||||
if not any(s.get("userId") == user_id and s.get("profileId") == buddy_pid for s in swipes):
|
||||
swipes.append(
|
||||
{
|
||||
"id": secrets.token_hex(6),
|
||||
"userId": user_id,
|
||||
"profileId": buddy_pid,
|
||||
"action": "like",
|
||||
"intent": "friends",
|
||||
"date": _today_key(),
|
||||
"createdAt": _now_iso(),
|
||||
}
|
||||
)
|
||||
|
||||
match = _ensure_match(user_id, buddy, "friends")
|
||||
conv_id = (match or {}).get("conversationId") or ""
|
||||
if not conv_id:
|
||||
conv = _ensure_conversation(user_id, buddy_uid, intent="friends", match=True)
|
||||
conv_id = conv.get("id", "")
|
||||
|
||||
hello = f"嗨!我在{buddy.get('location', '旅途中')},看到你也在用 nomadro,交个朋友呀 👋"
|
||||
reply = "你好!我也刚到,有机会一起去联合办公或 meetup~"
|
||||
|
||||
if conv_id:
|
||||
if use_pb():
|
||||
msgs = safe_list("messages", filter=f"conversationId={q(conv_id)}")
|
||||
if not msgs:
|
||||
safe_create("messages", {"conversationId": conv_id, "senderId": buddy_uid, "body": hello})
|
||||
safe_create("messages", {"conversationId": conv_id, "senderId": user_id, "body": reply})
|
||||
safe_update(
|
||||
"conversations",
|
||||
conv_id,
|
||||
{
|
||||
"lastMessageAt": _now_iso(),
|
||||
"lastMessagePreview": reply[:80],
|
||||
"readState": {user_id: _now_iso(), buddy_uid: _now_iso()},
|
||||
},
|
||||
)
|
||||
else:
|
||||
_jload()
|
||||
msgs = [m for m in (_j.get("messages") or []) if m.get("conversationId") == conv_id]
|
||||
if not msgs:
|
||||
_j.setdefault("messages", []).extend(
|
||||
[
|
||||
{
|
||||
"id": secrets.token_hex(8),
|
||||
"conversationId": conv_id,
|
||||
"senderId": buddy_uid,
|
||||
"body": hello,
|
||||
"createdAt": _now_iso(),
|
||||
},
|
||||
{
|
||||
"id": secrets.token_hex(8),
|
||||
"conversationId": conv_id,
|
||||
"senderId": user_id,
|
||||
"body": reply,
|
||||
"createdAt": _now_iso(),
|
||||
},
|
||||
]
|
||||
)
|
||||
for c in _j.get("conversations") or []:
|
||||
if c.get("id") == conv_id:
|
||||
c["lastMessageAt"] = _now_iso()
|
||||
c["lastMessagePreview"] = reply[:80]
|
||||
break
|
||||
|
||||
try:
|
||||
from app.services import community_store
|
||||
from app.services.notify import notify_user
|
||||
|
||||
notify_user(
|
||||
user_id,
|
||||
"有人喜欢了你",
|
||||
f"{buddy.get('name', '游民')} 等人对你感兴趣,去看看吧",
|
||||
"/dating/likes",
|
||||
"match",
|
||||
)
|
||||
if conv_id:
|
||||
notify_user(
|
||||
user_id,
|
||||
"新消息",
|
||||
f"{buddy.get('name', '游民')}: {hello[:40]}",
|
||||
f"/chat/{conv_id}",
|
||||
"chat",
|
||||
)
|
||||
community_store.add_notification(
|
||||
user_id,
|
||||
"欢迎来到匹配",
|
||||
"左滑跳过、右滑喜欢、上滑超级喜欢。匹配成功可私信。",
|
||||
"/dating",
|
||||
"system",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not use_pb():
|
||||
_jload()
|
||||
_j.setdefault("demo_seeded", {})[user_id] = True
|
||||
_jsave()
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
# ── JSON fallback ──────────────────────────────────────────────────────
|
||||
|
||||
def _jload() -> dict:
|
||||
|
||||
@ -2,14 +2,11 @@ lounge.nomadro.com {
|
||||
encode gzip
|
||||
reverse_proxy https://lounge.nomadro.cn {
|
||||
header_up Host lounge.nomadro.cn
|
||||
header_down -Content-Security-Policy
|
||||
header_down -X-Frame-Options
|
||||
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"
|
||||
-X-Frame-Options
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,15 +2,11 @@ mirotalk.nomadro.com {
|
||||
encode gzip
|
||||
reverse_proxy https://mirotalk.nomadro.cn {
|
||||
header_up Host mirotalk.nomadro.cn
|
||||
# Upstream CSP omits nomadweb.nomadro.com; strip so our frame-ancestors win.
|
||||
header_down -Content-Security-Policy
|
||||
header_down -X-Frame-Options
|
||||
transport http {
|
||||
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"
|
||||
-X-Frame-Options
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
# dymini · nomadro 抖音小程序
|
||||
|
||||
参考 Desktop/dymini(Taro 4 + React / tt),对接:
|
||||
|
||||
`https://nomadweb.nomadro.com/api/v1`
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
cd dymini
|
||||
npm install --legacy-peer-deps
|
||||
npm run dev:tt
|
||||
```
|
||||
|
||||
抖音开发者工具打开 `dymini/dist`。请替换 `appid` 并配置合法域名。
|
||||
@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [['taro', { framework: 'react', ts: true, compiler: 'webpack5' }]],
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export default {
|
||||
defineConstants: {
|
||||
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'http://127.0.0.1:8000/api/v1'),
|
||||
},
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
import path from 'path'
|
||||
import { defineConfig, type UserConfigExport } from '@tarojs/cli'
|
||||
|
||||
export default defineConfig(async (merge) => {
|
||||
const baseConfig: UserConfigExport = {
|
||||
projectName: 'dymini',
|
||||
date: '2026-9-27',
|
||||
designWidth: 750,
|
||||
deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 },
|
||||
sourceRoot: 'src',
|
||||
outputRoot: 'dist',
|
||||
plugins: ['@tarojs/plugin-framework-react'],
|
||||
defineConstants: {
|
||||
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'https://nomadweb.nomadro.com/api/v1'),
|
||||
},
|
||||
copy: { patterns: [], options: {} },
|
||||
framework: 'react',
|
||||
compiler: { type: 'webpack5', prebundle: { enable: false } },
|
||||
cache: { enable: false },
|
||||
alias: { '@': path.resolve(__dirname, '..', 'src') },
|
||||
mini: {
|
||||
postcss: {
|
||||
pxtransform: { enable: true, config: {} },
|
||||
cssModules: { enable: false },
|
||||
},
|
||||
},
|
||||
h5: {
|
||||
publicPath: '/',
|
||||
staticDirectory: 'static',
|
||||
postcss: { autoprefixer: { enable: true }, cssModules: { enable: false } },
|
||||
},
|
||||
}
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return merge({}, baseConfig, { logger: { quiet: false, stats: true } })
|
||||
}
|
||||
return merge({}, baseConfig)
|
||||
})
|
||||
@ -1,5 +0,0 @@
|
||||
export default {
|
||||
defineConstants: {
|
||||
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'https://nomadweb.nomadro.com/api/v1'),
|
||||
},
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "nomadweb-dymini",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "nomadro 抖音小程序",
|
||||
"scripts": {
|
||||
"build:tt": "taro build --type tt",
|
||||
"dev:tt": "npm run build:tt -- --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@tarojs/components": "4.0.9",
|
||||
"@tarojs/helper": "4.0.9",
|
||||
"@tarojs/plugin-framework-react": "4.0.9",
|
||||
"@tarojs/plugin-platform-tt": "4.0.9",
|
||||
"@tarojs/react": "4.0.9",
|
||||
"@tarojs/runtime": "4.0.9",
|
||||
"@tarojs/shared": "4.0.9",
|
||||
"@tarojs/taro": "4.0.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@tarojs/cli": "4.0.9",
|
||||
"@tarojs/webpack5-runner": "4.0.9",
|
||||
"@types/react": "^18.3.12",
|
||||
"babel-preset-taro": "4.0.9",
|
||||
"sass": "^1.81.0",
|
||||
"typescript": "^5.6.3",
|
||||
"webpack": "5.91.0"
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
{
|
||||
"miniprogramRoot": "dist/",
|
||||
"projectname": "nomadweb-dymini",
|
||||
"description": "nomadro 抖音小程序",
|
||||
"appid": "ttXXXXXXXX",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": false,
|
||||
"minified": false
|
||||
},
|
||||
"compileType": "microApp"
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"miniprogramRoot": "./",
|
||||
"projectname": "nomadweb-dymini",
|
||||
"description": "nomadro 抖音小程序",
|
||||
"appid": "ttXXXXXXXX",
|
||||
"setting": {
|
||||
"urlCheck": false
|
||||
},
|
||||
"compileType": "microApp"
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/home/index',
|
||||
'pages/destinations/index',
|
||||
'pages/destination/index',
|
||||
'pages/meetups/index',
|
||||
'pages/mine/index',
|
||||
],
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
navigationBarBackgroundColor: '#0c0b12',
|
||||
navigationBarTitleText: 'nomadro',
|
||||
navigationBarTextStyle: 'white',
|
||||
backgroundColor: '#0c0b12',
|
||||
},
|
||||
tabBar: {
|
||||
color: '#8a8694',
|
||||
selectedColor: '#f0c674',
|
||||
backgroundColor: '#12111a',
|
||||
borderStyle: 'black',
|
||||
list: [
|
||||
{ pagePath: 'pages/home/index', text: '首页' },
|
||||
{ pagePath: 'pages/destinations/index', text: '目的地' },
|
||||
{ pagePath: 'pages/meetups/index', text: '活动' },
|
||||
{ pagePath: 'pages/mine/index', text: '我的' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@ -1,5 +0,0 @@
|
||||
page {
|
||||
background: #0c0b12;
|
||||
color: #f4f0ea;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
import './app.scss'
|
||||
|
||||
function App({ children }: PropsWithChildren) {
|
||||
return children
|
||||
}
|
||||
|
||||
export default App
|
||||
@ -1 +0,0 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '城市详情' })
|
||||
@ -1,10 +0,0 @@
|
||||
|
||||
.detail { padding: 40px 32px; }
|
||||
.name { display: block; font-size: 48px; font-weight: 700; }
|
||||
.meta { display: block; margin-top: 8px; color: #a8a3b3; }
|
||||
.tag { display: block; margin-top: 16px; color: #cfc9d8; }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 40px; }
|
||||
.cell { width: calc(50% - 8px); background: rgba(255,255,255,0.06); border-radius: 16px; padding: 24px; box-sizing: border-box; }
|
||||
.k { display: block; color: #8a8694; font-size: 22px; }
|
||||
.v { display: block; margin-top: 8px; font-size: 30px; font-weight: 600; color: #f0c674; }
|
||||
.err { color: #e88; }
|
||||
@ -1,33 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { useLoad } from '@tarojs/taro'
|
||||
import { fetchDestination, type Destination } from '../../services/api'
|
||||
import './index.scss'
|
||||
|
||||
export default function DestinationPage() {
|
||||
const [d, setD] = useState<Destination | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useLoad((q) => {
|
||||
const slug = String(q.slug || '')
|
||||
if (!slug) { setErr('缺少城市'); return }
|
||||
fetchDestination(slug).then(setD).catch((e) => setErr(e.message || '加载失败'))
|
||||
})
|
||||
|
||||
if (err) return <View className='detail'><Text className='err'>{err}</Text></View>
|
||||
if (!d) return <View className='detail'><Text>加载中…</Text></View>
|
||||
|
||||
return (
|
||||
<View className='detail'>
|
||||
<Text className='name'>{d.name}</Text>
|
||||
<Text className='meta'>{d.country}</Text>
|
||||
<Text className='tag'>{d.tagline}</Text>
|
||||
<View className='grid'>
|
||||
<View className='cell'><Text className='k'>月生活费</Text><Text className='v'>¥{d.cost ?? '—'}</Text></View>
|
||||
<View className='cell'><Text className='k'>网速</Text><Text className='v'>{d.speed ?? '—'} Mbps</Text></View>
|
||||
<View className='cell'><Text className='k'>评分</Text><Text className='v'>{d.rating ?? '—'}</Text></View>
|
||||
<View className='cell'><Text className='k'>气候</Text><Text className='v'>{d.climate || '—'}</Text></View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '目的地' })
|
||||
@ -1,9 +0,0 @@
|
||||
|
||||
.list { height: 100vh; padding: 32px 28px 80px; box-sizing: border-box; }
|
||||
.h { display: block; font-size: 40px; font-weight: 700; margin-bottom: 28px; }
|
||||
.err { display: block; color: #e88; margin-bottom: 16px; }
|
||||
.empty { display: block; color: #8a8694; margin-top: 40px; text-align: center; }
|
||||
.card { background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px; margin-bottom: 20px; }
|
||||
.name { display: block; font-size: 32px; font-weight: 600; }
|
||||
.meta { display: block; margin-top: 10px; color: #a8a3b3; font-size: 24px; }
|
||||
.tag { display: block; margin-top: 8px; color: #8a8694; font-size: 22px; }
|
||||
@ -1,34 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { fetchDestinations, type Destination } from '../../services/api'
|
||||
import './index.scss'
|
||||
|
||||
export default function DestinationsPage() {
|
||||
const [items, setItems] = useState<Destination[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useLoad(() => {
|
||||
fetchDestinations()
|
||||
.then(setItems)
|
||||
.catch((e) => setErr(e.message || '加载失败'))
|
||||
})
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='list'>
|
||||
<Text className='h'>热门目的地</Text>
|
||||
{err ? <Text className='err'>{err}</Text> : null}
|
||||
{items.map((d) => (
|
||||
<View
|
||||
key={d.slug}
|
||||
className='card'
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/destination/index?slug=${d.slug}` })}
|
||||
>
|
||||
<Text className='name'>{d.name}</Text>
|
||||
<Text className='meta'>{d.country || ''} · ¥{d.cost ?? '—'} /月 · ★{d.rating ?? '—'}</Text>
|
||||
<Text className='tag'>{d.tagline || d.climate || ''}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '首页' })
|
||||
@ -1,12 +0,0 @@
|
||||
|
||||
.home { padding: 64px 40px 80px; min-height: 100vh; box-sizing: border-box; }
|
||||
.brand { display: block; font-size: 56px; font-weight: 700; letter-spacing: 2px; }
|
||||
.tagline { display: block; margin-top: 12px; color: #a8a3b3; font-size: 28px; }
|
||||
.err { display: block; margin-top: 24px; color: #e88; font-size: 24px; }
|
||||
.stats { display: flex; gap: 24px; margin: 56px 0 48px; }
|
||||
.stat { flex: 1; background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px 16px; text-align: center; }
|
||||
.n { display: block; font-size: 40px; font-weight: 700; color: #f0c674; }
|
||||
.l { display: block; margin-top: 8px; font-size: 22px; color: #8a8694; }
|
||||
.cta { margin-bottom: 20px; background: #f0c674 !important; color: #1a1520 !important; border-radius: 999px !important; font-weight: 600; }
|
||||
.cta.ghost { background: transparent !important; color: #f4f0ea !important; border: 1px solid rgba(255,255,255,0.2) !important; }
|
||||
.web { display: block; margin-top: 40px; text-align: center; color: #6a6575; font-size: 22px; }
|
||||
@ -1,32 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { fetchStats } from '../../services/api'
|
||||
import './index.scss'
|
||||
|
||||
export default function HomePage() {
|
||||
const [stats, setStats] = useState<{ destinations?: number; members?: number; meetups?: number } | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useLoad(() => {
|
||||
fetchStats()
|
||||
.then(setStats)
|
||||
.catch((e) => setErr(e.message || '加载失败'))
|
||||
})
|
||||
|
||||
return (
|
||||
<View className='home'>
|
||||
<Text className='brand'>nomadro</Text>
|
||||
<Text className='tagline'>用一行代码环游世界</Text>
|
||||
{err ? <Text className='err'>{err}</Text> : null}
|
||||
<View className='stats'>
|
||||
<View className='stat'><Text className='n'>{stats?.destinations ?? '—'}</Text><Text className='l'>目的地</Text></View>
|
||||
<View className='stat'><Text className='n'>{stats?.members ?? '—'}</Text><Text className='l'>游民</Text></View>
|
||||
<View className='stat'><Text className='n'>{stats?.meetups ?? '—'}</Text><Text className='l'>活动</Text></View>
|
||||
</View>
|
||||
<Button className='cta' onClick={() => Taro.switchTab({ url: '/pages/destinations/index' })}>探索城市</Button>
|
||||
<Button className='cta ghost' onClick={() => Taro.switchTab({ url: '/pages/meetups/index' })}>同城活动</Button>
|
||||
<Text className='web'>网页版 nomadweb.nomadro.com</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '活动' })
|
||||
@ -1,9 +0,0 @@
|
||||
|
||||
.list { height: 100vh; padding: 32px 28px 80px; box-sizing: border-box; }
|
||||
.h { display: block; font-size: 40px; font-weight: 700; margin-bottom: 28px; }
|
||||
.err { display: block; color: #e88; margin-bottom: 16px; }
|
||||
.empty { display: block; color: #8a8694; margin-top: 40px; text-align: center; }
|
||||
.card { background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px; margin-bottom: 20px; }
|
||||
.name { display: block; font-size: 32px; font-weight: 600; }
|
||||
.meta { display: block; margin-top: 10px; color: #a8a3b3; font-size: 24px; }
|
||||
.tag { display: block; margin-top: 8px; color: #8a8694; font-size: 22px; }
|
||||
@ -1,28 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import { useLoad } from '@tarojs/taro'
|
||||
import { fetchMeetups, type Meetup } from '../../services/api'
|
||||
import './index.scss'
|
||||
|
||||
export default function MeetupsPage() {
|
||||
const [items, setItems] = useState<Meetup[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useLoad(() => {
|
||||
fetchMeetups().then(setItems).catch((e) => setErr(e.message || '加载失败'))
|
||||
})
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='list'>
|
||||
<Text className='h'>游民活动</Text>
|
||||
{err ? <Text className='err'>{err}</Text> : null}
|
||||
{items.map((m) => (
|
||||
<View key={m.id} className='card'>
|
||||
<Text className='name'>{m.title}</Text>
|
||||
<Text className='meta'>{m.city || '全球'} · {m.date || '待定'} · {m.attendees ?? 0} 人</Text>
|
||||
</View>
|
||||
))}
|
||||
{!err && items.length === 0 ? <Text className='empty'>暂无活动</Text> : null}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '我的' })
|
||||
@ -1,5 +0,0 @@
|
||||
|
||||
.mine { padding: 48px 32px; }
|
||||
.h { display: block; font-size: 40px; font-weight: 700; }
|
||||
.hint { display: block; margin: 20px 0 40px; color: #8a8694; font-size: 26px; }
|
||||
.cta { background: #f0c674 !important; color: #1a1520 !important; border-radius: 999px !important; }
|
||||
@ -1,15 +0,0 @@
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import './index.scss'
|
||||
|
||||
export default function MinePage() {
|
||||
return (
|
||||
<View className='mine'>
|
||||
<Text className='h'>我的</Text>
|
||||
<Text className='hint'>登录与会员请使用网页版完整能力</Text>
|
||||
<Button className='cta' onClick={() => Taro.setClipboardData({ data: 'https://nomadweb.nomadro.com' })}>
|
||||
复制网站链接
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
import { request } from '../utils/request'
|
||||
|
||||
export type Destination = {
|
||||
slug: string
|
||||
name: string
|
||||
country?: string
|
||||
cost?: number
|
||||
speed?: number
|
||||
rating?: number
|
||||
tagline?: string
|
||||
image?: string
|
||||
climate?: string
|
||||
}
|
||||
|
||||
export type Meetup = {
|
||||
id: string
|
||||
title: string
|
||||
city?: string
|
||||
date?: string
|
||||
cover?: string
|
||||
attendees?: number
|
||||
}
|
||||
|
||||
export function fetchDestinations() {
|
||||
return request<Destination[]>('/destinations')
|
||||
}
|
||||
|
||||
export function fetchDestination(slug: string) {
|
||||
return request<Destination>(`/destinations/${encodeURIComponent(slug)}`)
|
||||
}
|
||||
|
||||
export function fetchMeetups() {
|
||||
return request<Meetup[]>('/meetups')
|
||||
}
|
||||
|
||||
export function fetchStats() {
|
||||
return request<{ destinations?: number; members?: number; meetups?: number }>('/stats')
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
declare const TARO_APP_API: string
|
||||
|
||||
export const API_BASE =
|
||||
typeof TARO_APP_API === 'string' && TARO_APP_API
|
||||
? TARO_APP_API
|
||||
: 'https://nomadweb.nomadro.com/api/v1'
|
||||
|
||||
export const STORAGE_TOKEN = 'dymini_token'
|
||||
@ -1,28 +0,0 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { API_BASE, STORAGE_TOKEN } from './config'
|
||||
|
||||
type Opts = {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
data?: Record<string, unknown>
|
||||
header?: Record<string, string>
|
||||
}
|
||||
|
||||
export async function request<T = unknown>(options: Opts): Promise<T> {
|
||||
const token = Taro.getStorageSync(STORAGE_TOKEN)
|
||||
const res = await Taro.request({
|
||||
url: `${API_BASE}${options.url}`,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.header || {}),
|
||||
},
|
||||
})
|
||||
if (res.statusCode >= 400) {
|
||||
const msg = (res.data as any)?.detail || (res.data as any)?.message || '请求失败'
|
||||
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg))
|
||||
}
|
||||
return res.data as T
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"module": "commonjs",
|
||||
"jsx": "react-jsx",
|
||||
"strictNullChecks": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"typeRoots": [
|
||||
"node_modules/@types"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"./src",
|
||||
"./types",
|
||||
"./config"
|
||||
]
|
||||
}
|
||||
3
dymini/types/global.d.ts
vendored
3
dymini/types/global.d.ts
vendored
@ -1,3 +0,0 @@
|
||||
declare function defineAppConfig(config: Record<string, unknown>): Record<string, unknown>
|
||||
declare function definePageConfig(config: Record<string, unknown>): Record<string, unknown>
|
||||
declare const TARO_APP_API: string
|
||||
@ -1,4 +1,2 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
# Optional — shows Google Sign-In on /login when set
|
||||
# NEXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||
|
||||
@ -1,11 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import AboutClient from "@/components/AboutClient";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "关于 nomadro · About",
|
||||
title: "关于 nomadro",
|
||||
description: "nomadro —— 用一行代码环游世界,数字游民旅居指南",
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
return <AboutClient />;
|
||||
return (
|
||||
<SiteShell>
|
||||
<div className="legal-page about-page">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/">← 返回首页</Link>
|
||||
</nav>
|
||||
<article className="legal-content">
|
||||
<span className="section-tag">🌏 ABOUT</span>
|
||||
<h1>关于 nomadro</h1>
|
||||
<p className="legal-updated">用一行代码环游世界</p>
|
||||
|
||||
<section>
|
||||
<h2>我们是谁</h2>
|
||||
<p>
|
||||
nomadro 是一个面向数字游民的旅居决策平台。我们把目的地数据、签证信息、费用估算、时区协作与社区经验集中在一起,帮你更快做出「下一站去哪」的决定。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>能做什么</h2>
|
||||
<ul className="about-list">
|
||||
<li>🗺️ 探索全球热门游民城市与对比工具</li>
|
||||
<li>🧮 计算生活成本、机票与启动金目标</li>
|
||||
<li>📋 签证匹配、保险与上网方案参考</li>
|
||||
<li>🗓️ 规划多城行程并分享给伙伴</li>
|
||||
<li>🗣️ 落地短语、行李清单与活动日历</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>联系我们</h2>
|
||||
<p>
|
||||
合作与反馈:
|
||||
<a href="mailto:hello@nomadro.com"> hello@nomadro.com</a>
|
||||
</p>
|
||||
<p>
|
||||
开源仓库:
|
||||
<a href="https://gitea.dsx2020.com/eric/nomadweb" target="_blank" rel="noopener noreferrer">
|
||||
gitea.dsx2020.com/eric/nomadweb
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="about-actions">
|
||||
<Link href="/#destinations" className="btn btn-primary">开始探索 🌍</Link>
|
||||
<Link href="/login" className="btn btn-ghost">加入社区</Link>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import { api } from "@/lib/api";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import AiAssistantClient from "@/components/AiAssistantClient";
|
||||
|
||||
@ -8,13 +7,10 @@ export const metadata: Metadata = {
|
||||
description: "智能推荐下一站游民城市",
|
||||
};
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export default async function AiPage() {
|
||||
const destinations = await api.getDestinations().catch(() => []);
|
||||
export default function AiPage() {
|
||||
return (
|
||||
<SiteShell showFooter>
|
||||
<AiAssistantClient destinations={destinations} />
|
||||
<AiAssistantClient />
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,27 +1,70 @@
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { notFound } from "next/navigation";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import BlogMarkdown from "@/components/BlogMarkdown";
|
||||
import BlogReadingProgress from "@/components/BlogReadingProgress";
|
||||
import BlogDetailClient from "@/components/BlogDetailClient";
|
||||
import ShareButton from "@/components/ShareButton";
|
||||
|
||||
export default async function BlogDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
let post;
|
||||
let allPosts;
|
||||
try {
|
||||
[post, allPosts] = await Promise.all([api.getBlogPost(slug), api.getBlog()]);
|
||||
[post, allPosts] = await Promise.all([
|
||||
api.getBlogPost(slug),
|
||||
api.getBlog(),
|
||||
]);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const related = allPosts
|
||||
.filter((p) => p.slug !== slug && p.tags.some((tag) => post.tags.includes(tag)))
|
||||
.filter((p) => p.slug !== slug && p.tags.some((t) => post.tags.includes(t)))
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<SiteShell>
|
||||
<BlogReadingProgress />
|
||||
<BlogDetailClient post={post} related={related} />
|
||||
<div className="detail-page">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/#blog">← 返回博客</Link>
|
||||
<ShareButton title={post.title} compact />
|
||||
</nav>
|
||||
<article className="blog-detail">
|
||||
<div className="blog-detail-header">
|
||||
<span className="blog-detail-emoji">{post.emoji}</span>
|
||||
<div className="blog-meta">
|
||||
<span>📅 {post.published_at}</span>
|
||||
<span>⏱️ {post.read_time} 分钟阅读</span>
|
||||
<span>✍️ {post.author}</span>
|
||||
</div>
|
||||
<h1>{post.title}</h1>
|
||||
<div className="blog-tags">
|
||||
{post.tags.map((t) => <span key={t} className="blog-tag">{t}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="blog-detail-content">
|
||||
<BlogMarkdown content={post.content} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="blog-related">
|
||||
<h2>📚 相关文章</h2>
|
||||
<div className="blog-related-grid">
|
||||
{related.map((r) => (
|
||||
<Link key={r.slug} href={`/blog/${r.slug}`} className="blog-related-card">
|
||||
<span className="blog-related-emoji">{r.emoji}</span>
|
||||
<h3>{r.title}</h3>
|
||||
<p>{r.excerpt}</p>
|
||||
<span className="blog-read-more">阅读 →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,28 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
type DlFile = { format: string; label: string; url: string };
|
||||
|
||||
function isRealDownload(f: DlFile) {
|
||||
const fmt = (f.format || "").toLowerCase();
|
||||
return fmt === "pdf" || fmt === "epub" || (Boolean(f.url) && !f.url.startsWith("/book/read"));
|
||||
}
|
||||
|
||||
function BookThanksInner() {
|
||||
const { token } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const b = t.bookThanks;
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const [status, setStatus] = useState<"checking" | "ok" | "pending" | "need-login">("checking");
|
||||
const [files, setFiles] = useState<DlFile[]>([]);
|
||||
const [files, setFiles] = useState<{ format: string; label: string; url: string }[]>([]);
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@ -52,63 +42,45 @@ function BookThanksInner() {
|
||||
}
|
||||
if (!cancelled) {
|
||||
setStatus("pending");
|
||||
setMsg(b.pending);
|
||||
setMsg("支付确认中,请稍后刷新;或联系支持核对订单。");
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setStatus("pending");
|
||||
setMsg(e instanceof Error ? e.message : b.statusFail);
|
||||
setMsg(e instanceof Error ? e.message : "无法确认购买状态");
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, params, b.pending, b.statusFail]);
|
||||
|
||||
const downloads = useMemo(() => files.filter(isRealDownload), [files]);
|
||||
const hasOnlineOnly = status === "ok" && downloads.length === 0;
|
||||
}, [token, params]);
|
||||
|
||||
return (
|
||||
<div className="container" style={{ padding: "48px 24px", maxWidth: 560 }}>
|
||||
<nav className="detail-nav">
|
||||
<Link href="/book">← {t.nav.ebook}</Link>
|
||||
<Link href="/book">← 电子书</Link>
|
||||
</nav>
|
||||
<h1>{b.title}</h1>
|
||||
<h1>下载版</h1>
|
||||
{status === "need-login" && (
|
||||
<p>
|
||||
{b.needLogin}{" "}
|
||||
<Link href="/login?next=/book/thanks">{t.nav.login}</Link>
|
||||
请先 <Link href="/login?next=/book/thanks">登录</Link> 查看下载。
|
||||
</p>
|
||||
)}
|
||||
{status === "checking" && <p>{b.checking}</p>}
|
||||
{status === "checking" && <p>正在确认订单…</p>}
|
||||
{status === "pending" && (
|
||||
<div>
|
||||
<p>{msg}</p>
|
||||
<button type="button" className="btn btn-primary" onClick={() => router.refresh()}>
|
||||
{b.refresh}
|
||||
刷新状态
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hasOnlineOnly && (
|
||||
{status === "ok" && (
|
||||
<div>
|
||||
<p>{b.confirmedNoFiles}</p>
|
||||
<p className="section-desc">{b.confirmedHint}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/book/read" className="btn btn-primary">
|
||||
{b.continueRead}
|
||||
</Link>
|
||||
<Link href="/feedback" className="btn btn-ghost">
|
||||
{b.contact}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status === "ok" && downloads.length > 0 && (
|
||||
<div>
|
||||
<p>{b.ready}</p>
|
||||
<p>购买成功,可下载:</p>
|
||||
<ul className="ebook-download-list">
|
||||
{downloads.map((f) => (
|
||||
{files.map((f) => (
|
||||
<li key={f.format}>
|
||||
<a href={f.url} className="btn btn-primary btn-sm" target="_blank" rel="noreferrer">
|
||||
{f.label}
|
||||
@ -117,7 +89,7 @@ function BookThanksInner() {
|
||||
))}
|
||||
</ul>
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link href="/book/read">{b.continueRead} →</Link>
|
||||
<Link href="/book/read">继续在线阅读 →</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -126,10 +98,9 @@ function BookThanksInner() {
|
||||
}
|
||||
|
||||
export default function BookThanksPage() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<SiteShell>
|
||||
<Suspense fallback={<div className="container" style={{ padding: 48 }}>{t.bookThanks.checking}</div>}>
|
||||
<Suspense fallback={<div className="container" style={{ padding: 48 }}>确认中…</div>}>
|
||||
<BookThanksInner />
|
||||
</Suspense>
|
||||
</SiteShell>
|
||||
|
||||
@ -8,321 +8,6 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
const LOGS = [
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "转化/信任打磨",
|
||||
items: [
|
||||
"订阅条中英校验与说明;Join 保存资料后直达匹配;定价免费 CTA 改走完善资料",
|
||||
"VIP/电子书支付说明更诚实(支付宝微信、无文件时写清在线解锁);登录可接 Google(需配置 Client ID)",
|
||||
"移动端隐藏易裁切的 hero 浮卡;活动后下一步补齐私信入口",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "首页/关于页",
|
||||
items: [
|
||||
"首页恢复「游民一天」与「游民心声」区块,中英文化并直达七天指南/活动/社区",
|
||||
"关于页改为可切换语言,CTA 指向下一站、社区与旅居计划;静态帮助页主按钮高亮",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "学院/博客/社区/电子书",
|
||||
items: [
|
||||
"每日指南打通 7 天并中英导航;博客详情与电子书购买页中英文化",
|
||||
"社区回复支持 Ctrl+Enter、字数与滚动定位;点赞失败可提示;假下载改为在线阅读引导",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "全站闭环增强",
|
||||
items: [
|
||||
"签证向导中英文化 + 结果直达城市/计划/咨询;课程跨模块上下一课;岗位申请改走赏金页",
|
||||
"赏金申请通知发布者;视频去掉无效外链;探索导航增加游民地图入口",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "直播报名 · 私信角标",
|
||||
items: [
|
||||
"线上直播需登录并报名后进入;支持一键报名进房;显示活动时间;手机默认视频布局",
|
||||
"右上角私信图标显示未读数",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "直播 iframe 修复",
|
||||
items: [
|
||||
"修复 MiroTalk/Lounge 被上游 CSP 拦截导致直播页空白;可新窗口打开视频/聊天",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "匹配资料 · 报名日历",
|
||||
items: [
|
||||
"匹配卡片可「查看资料」;喜欢页标题改为中性「喜欢」",
|
||||
"线下报名成功自动下载日历;个人中心每场 RSVP 可加入日历",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "导航精简",
|
||||
items: [
|
||||
"私信与通知移出 Connect 菜单/页脚/个人中心快捷入口,统一用右上角 ✉️ / 🔔 图标进入",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "活动报名 · Connect 数据",
|
||||
items: [
|
||||
"报名成功 toast:线上/混合直达直播间,线下引导导出日历;报名确认写入通知箱",
|
||||
"个人中心新增 Connect 数据条(喜欢我的 / 互相喜欢 / 已报名),可点进对应页面",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "匹配资料 · 私信头",
|
||||
items: [
|
||||
"会员页支持候选档案 /members/u1,可回赞并直达聊天;私信线程显示对方头像与资料入口",
|
||||
"登录即注入匹配来赞与演示私信;会话 peer 字段规范化避免列表 500",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "匹配滑动 · 喜欢我的",
|
||||
items: [
|
||||
"匹配卡片支持左右/上滑手势与方向键;喜欢页区分「喜欢我的 / 我喜欢的」",
|
||||
"加入匹配时自动注入演示私信与来赞,讨论回复补全",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "匹配页修复",
|
||||
items: [
|
||||
"修复 /dating 因后端错误检查不存在的 _profiles 而一直卡在「一键加入匹配」",
|
||||
"登录后自动创建匹配资料并进入滑卡;VIP 状态读取同步修正",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "业务数据充实",
|
||||
items: [
|
||||
"目的地增至 12 城,签证/博客/活动/讨论/零活/岗位/视频/服务/会员地图全面扩容(均 10+)",
|
||||
"种子 12 个演示账号(密码 demo123)含收藏与旅居计划;修复社区空库不重灌的问题",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "城市工具决策出口",
|
||||
items: [
|
||||
"命运转盘、网速、住宿、机票、周末灵感、落地清单、城市笔记结果区可写入计划并深链活动",
|
||||
"落地清单 Meetup 任务直达同城活动;最近浏览补同城活动与计划入口;匹配器关闭清除 #matcher",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "工具转化 · 登录 CTA · 快捷键",
|
||||
items: [
|
||||
"启动金目标 / 签证停留倒计时可写入计划并深链城市;下一站加入计划区分已在行程",
|
||||
"登录成功 toast 可直达回流页;悬浮反馈支持 Esc;站内 M 键打开首页智能匹配(/#matcher)",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "登录回流 · 匹配器体验",
|
||||
items: [
|
||||
"登录页识别数字游民/视频/岗位/AI/下一站/地图等回流目标,不再误标成个人中心",
|
||||
"跑道计算器套用城市后可写入计划;首页智能匹配预加载 + 加载壳,支持 Esc 关闭",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "计算器 · 视频 · 分享回流",
|
||||
items: [
|
||||
"费用/对比/首月成本工具结果可写入计划并深链城市详情与同城活动",
|
||||
"视频详情按城市接活动环;岗位收藏 toast、分享复制 toast、私信空态补下一跳",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "报告 · 工具 · 发布回流",
|
||||
items: [
|
||||
"数据报告:快照 toast 去下一站;排名/会员城市深链计划与同城活动",
|
||||
"工具运行页结果旁增加计划/下一站/活动出口;afterTools 补活动环",
|
||||
"发布赏金/发起活动/报名/加入匹配成功 toast 接回数字游民、社区或匹配;课程末课接赏金与活动",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "对比 · 收藏 · 反馈回流",
|
||||
items: [
|
||||
"对比写入计划 / 收藏城市 / 个人中心收藏入计划:toast 可打开计划或我的收藏",
|
||||
"反馈、投稿、悬浮反馈成功后可去社区;视频稍后再看空态与收藏 toast 回流",
|
||||
"通知页登录门补活动与社区出口;旅居助手写入计划 toast 可打开计划中心",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "地图 · 匹配 · 会员资料",
|
||||
items: [
|
||||
"智能匹配 / 下一站 / 世界地图写入计划后 toast 可打开计划中心;地图面板增加同城活动",
|
||||
"会员资料与游民地图城市深链同城活动;加入资料 / 服务咨询成功 toast 接回匹配或社区",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "Toast CTA · Connect/Grow 出口",
|
||||
items: [
|
||||
"Toast 支持行动链接(写入计划/报名/发帖后可直达下一站);城市页加入计划可打开计划中心",
|
||||
"社区帖文识别城市名深链同城活动;匹配/私信空态与登录门增加活动与社区出口",
|
||||
"Grow 环:赏金→计划/活动,数字游民学院用 afterDigital 接回探索",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "Explore ↔ Connect 漏斗",
|
||||
items: [
|
||||
"城市页/对比/计划/下一站深链到同城活动;线上活动直达直播间",
|
||||
"对比胜出城市一键活动 + 计划;NextStop / Live / 计划 RingNext 按情境分流",
|
||||
"报名后展示同城社交建议;首页紧凑「接着探索」摘要(无足迹时隐藏)",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "工具箱 · AI 草稿",
|
||||
items: [
|
||||
"工具箱目录与分类标签支持中英切换;全局搜索工具结果随语言匹配",
|
||||
"页脚品牌标语与下一站预算单位接入 i18n;旅居助手提问草稿本机保存",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "智能匹配 · 首页 chrome",
|
||||
items: [
|
||||
"首页智能匹配向导步骤/选项/结果与写入计划 toast 全面中英文化",
|
||||
"快捷键帮助、游民小贴士、Cookie 条、新手引导、FAQ 空态接入 i18n",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "全局搜索 · 反馈 · 草稿",
|
||||
items: [
|
||||
"全局搜索:触发器/占位/结果类型与路径快捷入口全面中英文化",
|
||||
"悬浮反馈、分享/收藏按钮、地图加计划 toast、登录校验接入 i18n",
|
||||
"私信与服务咨询支持本机草稿;反馈 FAB 与反馈页共用草稿键",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "个人中心 · 计划导出",
|
||||
items: [
|
||||
"个人中心:成就徽章、统计、收藏/行程空态、快捷入口与头像提示全面中英文化",
|
||||
"旅居计划:Markdown/纯文本导出与签证字段标签随语言切换",
|
||||
"讨论回复支持本机草稿;匹配加入表单默认城市改为空白占位",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "城市深度 · 草稿 · 计划",
|
||||
items: [
|
||||
"城市详情深度区块(指南/画像/成本/评价等)全面中英文化;快照模板随语言切换",
|
||||
"发起活动与意见反馈表单支持本机草稿自动保存",
|
||||
"下一站匹配理由、对比快照、计划空态/排序按钮、私信线程 RingNext 补齐 i18n",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "下一站 · 社区 · 城市",
|
||||
items: [
|
||||
"下一站决策:预算/网速/气候/标签偏好本机记住;标签与气候中英展示",
|
||||
"社区分类筛选与发帖分类中英标签;讨论回复提示与空态接入 i18n",
|
||||
"城市详情:加入计划/分享/超预算提示与操作按钮全面中英文化",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "匹配 · 岗位 · 会员",
|
||||
items: [
|
||||
"远程岗位支持搜索/标签筛选与本机收藏",
|
||||
"匹配意图、配额、空态与私信列表全面中英文化",
|
||||
"会员资料表单草稿自动保存",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "赏金 · 服务 · 报告",
|
||||
items: [
|
||||
"赏金可收藏、申请说明本机草稿;发布赏金/内容投稿自动保存草稿",
|
||||
"服务咨询与数据报告空态文案中英齐全;报告支持复制快照",
|
||||
"赏金/投稿/服务校验提示全面接入 i18n",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "摘要 · 稍后再看 · 草稿",
|
||||
items: [
|
||||
"视频「稍后再看」本机收藏;列表与详情可一键存取",
|
||||
"个人中心「今日游民摘要」汇总计划就绪、报名、视频与最近 AI 提问",
|
||||
"社区发帖草稿自动保存;通知/反馈/地图/视频空态文案中英齐全",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "日历 · 对比 · 搜索",
|
||||
items: [
|
||||
"活动报名可导出 ICS(整表/单场);个人中心 RSVP 一键导入日历",
|
||||
"城市对比:最近对比记录可恢复、复制对比快照",
|
||||
"全局搜索空态展示最近浏览城市;通知设置补齐 RingNext 与文案",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-03",
|
||||
tag: "继续探索与个人中心",
|
||||
items: [
|
||||
"最近浏览城市:详情页自动记录,首页与个人中心可续看、一键对比",
|
||||
"个人中心展示已报名活动(含直播入口)与环导航",
|
||||
"旅居助手:本地提问历史、中英文案与对比/写入计划闭环",
|
||||
"城市详情「复制快照」;VIP 支付成功页补齐下一步",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-01",
|
||||
tag: "环导航与文案",
|
||||
items: [
|
||||
"工具详情、发起活动、会员定价、学院课时、喜欢列表、成员主页补齐 RingNext",
|
||||
"直播间门禁/布局、工具箱空态与搜索、活动登录提示接入中英文案",
|
||||
"私信空态与发送失败提示、学院 VIP 提示与每日指南 i18n",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-08-30",
|
||||
tag: "决策与发现闭环",
|
||||
items: [
|
||||
"首页挂载快捷工具条;地图选城可对比/加计划;下一站活动按推荐城市筛选",
|
||||
"赏金未登录直接引导登录;活动页支持 ?city= 筛选;视频详情 RingNext",
|
||||
"电子书感谢页无下载链时引导在线阅读;社区发帖/发赏金/通知设置登录门",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-08-30",
|
||||
tag: "三环登录与导航",
|
||||
items: [
|
||||
"Connect:个人中心三环快捷入口、匹配加载态、发起活动/发赏金登录门、私信与登录提示",
|
||||
"Explore:全站快捷键、首页 hash 锚点、对比空态、AI 推荐真正写入计划",
|
||||
"Grow:导航补全服务/投稿、表单校验、学院空态与 VIP 课时直达开通",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-08-30",
|
||||
tag: "三环可用深化",
|
||||
items: [
|
||||
"产品信息架构:Explore / Connect / Grow 渐进披露,导航与空态对齐三条环",
|
||||
"Connect:RSVP 需登录、通知铃铛、匹配配额、私信与 VIP 支付加固",
|
||||
"Grow:学院 VIP 课时客户端解锁、赏金/投稿表单校验、电子书购买闭环",
|
||||
"Explore:目的地对比入口、AI 快捷提问、计划空态、周报订阅诚实提示",
|
||||
"边缘体验:404/错误页环导航、sitemap 补全、匹配空态、快捷键与贴士持久化",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-08-29",
|
||||
tag: "电子书融合",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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 学院",
|
||||
@ -14,9 +15,16 @@ export default async function DigitalLessonPage({
|
||||
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 moduleIndex={mi} lessonIndex={li} />
|
||||
<DigitalLessonClient lesson={lesson} locked={locked} moduleIndex={mi} lessonIndex={li} />
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,23 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import DigitalDayClient from "@/components/DigitalDayClient";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const day = await api.getDigitalDay(id).catch(() => null);
|
||||
return { title: day ? `${day.title} · nomadro` : "Daily guide · nomadro" };
|
||||
return { title: day ? `${day.title} · nomadro` : "每日指南" };
|
||||
}
|
||||
|
||||
export default async function DigitalDayPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const day = await api.getDigitalDay(id).catch(() => null);
|
||||
if (!day) notFound();
|
||||
|
||||
return (
|
||||
<SiteShell showFooter>
|
||||
<DigitalDayClient id={id} title={day.title} body={day.body} />
|
||||
<div className="legal-page">
|
||||
<nav className="detail-nav"><Link href="/digital">← 游民学院</Link></nav>
|
||||
<article className="legal-content reveal">
|
||||
<span className="section-tag">📅 DAY {id}</span>
|
||||
<h1>{day.title}</h1>
|
||||
<p>{day.body}</p>
|
||||
</article>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<SiteShell showFooter>
|
||||
<div className="not-found-page">
|
||||
<h1 className="not-found-title" style={{ fontSize: "2.5rem" }}>
|
||||
出错了
|
||||
</h1>
|
||||
<p>页面暂时无法加载,可以重试或换一条航线</p>
|
||||
<p className="not-found-sub">{error.digest ? `参考码 ${error.digest}` : "请稍后再试"}</p>
|
||||
<div className="not-found-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={reset}>
|
||||
重试
|
||||
</button>
|
||||
<Link href="/" className="btn">
|
||||
首页
|
||||
</Link>
|
||||
<Link href="/feedback" className="btn btn-ghost">
|
||||
反馈问题
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -9,10 +9,9 @@ import { useToast } from "@/lib/toast";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { resolveAuthNext } from "@/lib/authNext";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import { GoogleSignInButton } from "@/components/GoogleSignInButton";
|
||||
|
||||
function LoginForm() {
|
||||
const { login, register, demoLogin, googleLogin } = useAuth();
|
||||
const { login, register, demoLogin } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
@ -28,27 +27,17 @@ function LoginForm() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const goAfterAuth = (msg: string) => {
|
||||
toast(msg, "success", { href: meta.path, label: meta.label });
|
||||
toast(msg);
|
||||
setTimeout(() => { router.replace(meta.path); }, 400);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (mode === "register") {
|
||||
if (!name.trim() || name.trim().length < 2) {
|
||||
setError(t.login.nameMin);
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError(t.login.pwdMin);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setLoading(true);
|
||||
const ok = mode === "login"
|
||||
? await login(email, password)
|
||||
: await register(email, password, name.trim());
|
||||
: await register(email, password, name);
|
||||
setLoading(false);
|
||||
if (ok) {
|
||||
goAfterAuth(
|
||||
@ -66,15 +55,6 @@ function LoginForm() {
|
||||
if (ok) goAfterAuth(t.login.demoToast);
|
||||
};
|
||||
|
||||
const handleGoogle = async (idToken: string) => {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const ok = await googleLogin(idToken);
|
||||
setLoading(false);
|
||||
if (ok) goAfterAuth(meta.welcomeToast);
|
||||
else setError(t.login.googleFail);
|
||||
};
|
||||
|
||||
const headerHint = mode === "login" ? meta.hint : t.login.registerHint;
|
||||
|
||||
return (
|
||||
@ -132,21 +112,10 @@ function LoginForm() {
|
||||
minLength={6}
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
/>
|
||||
<button type="button" className="auth-pwd-toggle" onClick={() => setShowPwd(!showPwd)} aria-label={t.login.showPwd}>
|
||||
<button type="button" className="auth-pwd-toggle" onClick={() => setShowPwd(!showPwd)} aria-label="显示密码">
|
||||
{showPwd ? "🙈" : "👁️"}
|
||||
</button>
|
||||
</div>
|
||||
{mode === "register" && (
|
||||
<p className="auth-pwd-hint">
|
||||
{password.length === 0
|
||||
? t.login.pwdHint
|
||||
: password.length < 6
|
||||
? t.login.pwdNeed.replace("{n}", String(6 - password.length))
|
||||
: password.length < 10
|
||||
? t.login.pwdOk
|
||||
: t.login.pwdStrong}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}>
|
||||
@ -155,7 +124,6 @@ function LoginForm() {
|
||||
</form>
|
||||
|
||||
<div className="auth-divider"><span>{t.login.or}</span></div>
|
||||
<GoogleSignInButton disabled={loading} onCredential={handleGoogle} />
|
||||
<button className="btn btn-ghost auth-demo-btn" type="button" onClick={handleDemo} disabled={loading}>
|
||||
{t.login.demo}
|
||||
</button>
|
||||
@ -165,19 +133,10 @@ function LoginForm() {
|
||||
);
|
||||
}
|
||||
|
||||
function LoginFallback() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<p className="plan-loading">{t.common.loading}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<SiteShell>
|
||||
<Suspense fallback={<LoginFallback />}>
|
||||
<Suspense fallback={<div className="auth-page"><p className="plan-loading">加载中…</p></div>}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</SiteShell>
|
||||
|
||||
@ -3,8 +3,8 @@ import SiteShell from "@/components/SiteShell";
|
||||
import MemberMapClient from "@/components/MemberMapClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Nomad map · 游民地图 · nomadro",
|
||||
description: "Global nomad heatmap and live city weather · 全球游民分布与目的地实时天气",
|
||||
title: "游民地图 · nomadro",
|
||||
description: "全球游民分布与目的地实时天气",
|
||||
};
|
||||
|
||||
export default function MapPage() {
|
||||
|
||||
@ -14,12 +14,7 @@ export default async function MeetupLivePage({ params }: { params: Promise<{ id:
|
||||
|
||||
return (
|
||||
<SiteShell showFooter={false}>
|
||||
<MeetupLiveClient
|
||||
meetupId={id}
|
||||
title={title}
|
||||
date={meetup?.date || ""}
|
||||
time={meetup?.time || ""}
|
||||
/>
|
||||
<MeetupLiveClient meetupId={id} title={title} />
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { api } from "@/lib/api";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
@ -16,9 +15,7 @@ export default async function MeetupsPage() {
|
||||
|
||||
return (
|
||||
<SiteShell showFooter>
|
||||
<Suspense fallback={<div className="container"><p className="dest-empty">加载活动…</p></div>}>
|
||||
<MeetupsClient meetups={meetups} />
|
||||
</Suspense>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { Suspense } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
@ -10,9 +9,7 @@ export default async function MemberPage({ params }: { params: Promise<{ id: str
|
||||
if (!profile) notFound();
|
||||
return (
|
||||
<SiteShell showFooter>
|
||||
<Suspense fallback={<div className="container dest-empty">…</div>}>
|
||||
<MemberProfileClient profile={profile} />
|
||||
</Suspense>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import SiteShell from "@/components/SiteShell";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<SiteShell showFooter>
|
||||
<SiteShell>
|
||||
<div className="not-found-page">
|
||||
<div className="not-found-compass" aria-hidden="true">
|
||||
<span className="compass-ring">🧭</span>
|
||||
@ -11,23 +11,10 @@ export default function NotFound() {
|
||||
</div>
|
||||
<h1 className="not-found-title">404</h1>
|
||||
<p>哎呀,这片海域没有标记的岛屿</p>
|
||||
<p className="not-found-sub">你可能走错了航线,从三条产品环任选一条继续</p>
|
||||
<p className="not-found-sub">你可能走错了航线,让我们帮你回到正轨</p>
|
||||
<div className="not-found-actions">
|
||||
<Link href="/" className="btn btn-primary">
|
||||
返回首页
|
||||
</Link>
|
||||
<Link href="/next-stop" className="btn">
|
||||
智能下一站
|
||||
</Link>
|
||||
<Link href="/meetups" className="btn">
|
||||
游民活动
|
||||
</Link>
|
||||
<Link href="/digital" className="btn">
|
||||
游民学院
|
||||
</Link>
|
||||
<Link href="/help" className="btn btn-ghost">
|
||||
帮助中心
|
||||
</Link>
|
||||
<Link href="/" className="btn btn-primary">返回首页 🏠</Link>
|
||||
<Link href="/#destinations" className="btn btn-ghost">探索目的地 🌍</Link>
|
||||
</div>
|
||||
</div>
|
||||
</SiteShell>
|
||||
|
||||
@ -5,21 +5,14 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Destination, Meetup, ProfileStats, TripItem } from "@/lib/types";
|
||||
import type { Destination, ProfileStats, TripItem } from "@/lib/types";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import FavoriteButton from "@/components/FavoriteButton";
|
||||
import RecentlyViewed from "@/components/RecentlyViewed";
|
||||
import NomadDigest from "@/components/NomadDigest";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Dict } from "@/lib/i18n";
|
||||
import { loadTrip } from "@/lib/tripStorage";
|
||||
import { loadPlanMeta, MOVE_CHECKLIST, type PlanMeta } from "@/lib/planMeta";
|
||||
import { PLAN_SYNC_EVENT } from "@/lib/planSync";
|
||||
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { buildMeetupIcs, downloadMeetupIcs } from "@/lib/meetupIcs";
|
||||
|
||||
interface Badge {
|
||||
emoji: string;
|
||||
@ -28,26 +21,15 @@ interface Badge {
|
||||
earned: boolean;
|
||||
}
|
||||
|
||||
function getBadges(
|
||||
p: Dict["profile"],
|
||||
stats: ProfileStats | null,
|
||||
trip: TripItem[],
|
||||
favCount: number,
|
||||
readiness: number
|
||||
): Badge[] {
|
||||
const tripMonths = trip.reduce((s, item) => s + item.months, 0);
|
||||
function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: number, readiness: number): Badge[] {
|
||||
const tripMonths = trip.reduce((s, t) => s + t.months, 0);
|
||||
return [
|
||||
{ emoji: "🌱", label: p.badgeNewbie, desc: p.badgeNewbieDesc, earned: true },
|
||||
{ emoji: "❤️", label: p.badgeCollector, desc: p.badgeCollectorDesc, earned: favCount >= 3 },
|
||||
{ emoji: "🗺️", label: p.badgePlanner, desc: p.badgePlannerDesc, earned: trip.length >= 3 },
|
||||
{ emoji: "📅", label: p.badgeLongStay, desc: p.badgeLongStayDesc, earned: tripMonths >= 6 },
|
||||
{ emoji: "✅", label: p.badgeReady, desc: p.badgeReadyDesc, earned: readiness >= 50 },
|
||||
{
|
||||
emoji: "🌍",
|
||||
label: p.badgeGlobal,
|
||||
desc: p.badgeGlobalDesc,
|
||||
earned: (stats?.destinations_explored ?? 0) >= 5,
|
||||
},
|
||||
{ emoji: "🌱", label: "初出茅庐", desc: "加入 nomadro", earned: true },
|
||||
{ emoji: "❤️", label: "收藏家", desc: "收藏 3+ 目的地", earned: favCount >= 3 },
|
||||
{ emoji: "🗺️", label: "行程达人", desc: "规划 3+ 城市行程", earned: trip.length >= 3 },
|
||||
{ emoji: "📅", label: "长期旅居", desc: "行程总计 6+ 个月", earned: tripMonths >= 6 },
|
||||
{ emoji: "✅", label: "出发就绪", desc: "就绪清单 ≥ 50%", earned: readiness >= 50 },
|
||||
{ emoji: "🌍", label: "环球游民", desc: "探索 5+ 城市", earned: (stats?.destinations_explored ?? 0) >= 5 },
|
||||
];
|
||||
}
|
||||
|
||||
@ -55,24 +37,19 @@ export default function ProfilePage() {
|
||||
const { user, token, favorites, logout, planSyncStatus, refreshUser } = useAuth();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [stats, setStats] = useState<ProfileStats | null>(null);
|
||||
const [favoriteDests, setFavoriteDests] = useState<Destination[]>([]);
|
||||
const [rsvps, setRsvps] = useState<Meetup[]>([]);
|
||||
const [connect, setConnect] = useState({ likes: 0, matches: 0 });
|
||||
const [trip, setTrip] = useState<TripItem[]>([]);
|
||||
const [meta, setMeta] = useState<PlanMeta | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [vip, setVip] = useState(false);
|
||||
|
||||
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file || !token) return;
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast(t.profile.avatarTooBig, "error");
|
||||
toast("图片请小于 5MB", "error");
|
||||
return;
|
||||
}
|
||||
setUploadingAvatar(true);
|
||||
@ -80,9 +57,9 @@ export default function ProfilePage() {
|
||||
const uploaded = await api.uploadMedia(token, file, "avatar");
|
||||
await api.updateProfile(token, { avatar: uploaded.url });
|
||||
await refreshUser?.();
|
||||
toast(t.profile.avatarOk, "success");
|
||||
toast("头像已更新", "success");
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : t.profile.avatarFail, "error");
|
||||
toast(err instanceof Error ? err.message : "上传失败", "error");
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
@ -95,34 +72,16 @@ export default function ProfilePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
router.push("/login?next=/profile");
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
api.getProfileStats(token),
|
||||
favorites.length > 0 ? api.getFavoriteDestinations(token) : Promise.resolve([]),
|
||||
api.getVipStatus(token).catch(() => ({ vip: false, expires_at: 0 })),
|
||||
Promise.all([
|
||||
api.getMyRsvps(token).catch(() => ({ ids: [] as string[] })),
|
||||
api.getMeetups().catch(() => [] as Meetup[]),
|
||||
]).then(([mine, all]) => {
|
||||
const idSet = new Set(mine.ids || []);
|
||||
return all.filter((m) => idSet.has(m.id)).slice(0, 6);
|
||||
}),
|
||||
Promise.all([
|
||||
api.getMatchLikesReceived(token).catch(() => []),
|
||||
api.getMutualMatches(token).catch(() => ({ items: [] })),
|
||||
]).then(([likes, matches]) => ({
|
||||
likes: likes.length,
|
||||
matches: matches.items?.length ?? 0,
|
||||
})),
|
||||
]).then(([s, f, v, myMeetups, connectStats]) => {
|
||||
]).then(([s, f]) => {
|
||||
setStats(s);
|
||||
setFavoriteDests(f);
|
||||
setVip(Boolean(v.vip));
|
||||
setRsvps(myMeetups);
|
||||
setConnect(connectStats);
|
||||
}).catch(() => {}).finally(() => setLoading(false));
|
||||
|
||||
refreshPlan();
|
||||
@ -130,37 +89,31 @@ export default function ProfilePage() {
|
||||
return () => window.removeEventListener(PLAN_SYNC_EVENT, refreshPlan);
|
||||
}, [token, favorites, router]);
|
||||
|
||||
const totalMonths = trip.reduce((s, item) => s + item.months, 0);
|
||||
const totalCost = trip.reduce((s, item) => s + item.cost * item.months, 0);
|
||||
const totalMonths = trip.reduce((s, t) => s + t.months, 0);
|
||||
const totalCost = trip.reduce((s, t) => s + t.cost * t.months, 0);
|
||||
const avgMonth = totalMonths > 0 ? Math.round(totalCost / totalMonths) : 0;
|
||||
const checked = MOVE_CHECKLIST.filter((c) => meta?.checklist[c.id]).length;
|
||||
const readiness = Math.round((checked / MOVE_CHECKLIST.length) * 100);
|
||||
const budgetOk = !meta || meta.monthlyBudget <= 0 || avgMonth <= 0 || avgMonth <= meta.monthlyBudget;
|
||||
|
||||
const badges = useMemo(
|
||||
() => getBadges(t.profile, stats, trip, favoriteDests.length, readiness),
|
||||
[t.profile, stats, trip, favoriteDests.length, readiness]
|
||||
() => getBadges(stats, trip, favoriteDests.length, readiness),
|
||||
[stats, trip, favoriteDests.length, readiness]
|
||||
);
|
||||
const earnedCount = badges.filter((b) => b.earned).length;
|
||||
|
||||
const favsNotInTrip = favoriteDests.filter((d) => !trip.some((item) => item.slug === d.slug));
|
||||
const favsNotInTrip = favoriteDests.filter((d) => !trip.some((t) => t.slug === d.slug));
|
||||
|
||||
const addFavsToPlan = () => {
|
||||
if (favsNotInTrip.length === 0) {
|
||||
toast(t.profile.favsAlready, "info", { href: "/plan", label: t.profile.openPlan });
|
||||
toast("收藏城市都已在计划中", "info");
|
||||
return;
|
||||
}
|
||||
const { added } = mergeDestinationsIntoTrip(favsNotInTrip, 1);
|
||||
refreshPlan();
|
||||
toast(t.profile.favsAdded.replace("{n}", String(added)), "success", {
|
||||
href: "/plan",
|
||||
label: t.profile.openPlan,
|
||||
});
|
||||
toast(`已将 ${added} 座收藏城市写入计划`);
|
||||
};
|
||||
|
||||
const planTitle =
|
||||
meta?.title && meta.title !== "我的旅居计划" ? meta.title : t.plan.defaultTitle;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
@ -175,7 +128,7 @@ export default function ProfilePage() {
|
||||
<div className="profile-avatar">{user.avatar || "🧑💻"}</div>
|
||||
)}
|
||||
<label className="profile-avatar-upload">
|
||||
{uploadingAvatar ? t.profile.uploading : t.profile.changeAvatar}
|
||||
{uploadingAvatar ? "上传中…" : "更换头像"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
@ -188,19 +141,16 @@ export default function ProfilePage() {
|
||||
<div className="profile-info">
|
||||
<h1>{user.name}</h1>
|
||||
<p>{user.email}</p>
|
||||
<div className="profile-badges-row">
|
||||
{stats && <span className="profile-level">{stats.nomad_level}</span>}
|
||||
{vip && <span className="profile-vip-badge">✨ VIP</span>}
|
||||
</div>
|
||||
<span className={`plan-sync-pill ${planSyncStatus}`} style={{ marginTop: 8, display: "inline-flex" }}>
|
||||
{planSyncStatus === "syncing" && t.profile.syncing}
|
||||
{planSyncStatus === "synced" && t.profile.synced}
|
||||
{planSyncStatus === "offline" && t.profile.offline}
|
||||
{planSyncStatus === "idle" && t.profile.loggedIn}
|
||||
{planSyncStatus === "syncing" && "计划同步中…"}
|
||||
{planSyncStatus === "synced" && "✓ 计划已云端同步"}
|
||||
{planSyncStatus === "offline" && "计划仅本机"}
|
||||
{planSyncStatus === "idle" && "账号已登录"}
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={() => { logout(); router.push("/"); }}>
|
||||
{t.profile.logout}
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -209,120 +159,82 @@ export default function ProfilePage() {
|
||||
<div className="profile-stat-card">
|
||||
<span className="stat-emoji">❤️</span>
|
||||
<strong>{stats.favorites_count}</strong>
|
||||
<span>{t.profile.statFavs}</span>
|
||||
<span>收藏目的地</span>
|
||||
</div>
|
||||
<div className="profile-stat-card">
|
||||
<span className="stat-emoji">🗓️</span>
|
||||
<strong>{trip.length}</strong>
|
||||
<span>{t.profile.statCities}</span>
|
||||
<span>计划城市</span>
|
||||
</div>
|
||||
<div className="profile-stat-card">
|
||||
<span className="stat-emoji">✅</span>
|
||||
<strong>{readiness}%</strong>
|
||||
<span>{t.profile.statReady}</span>
|
||||
<span>出发就绪</span>
|
||||
</div>
|
||||
<div className="profile-stat-card">
|
||||
<span className="stat-emoji">🏅</span>
|
||||
<strong>{earnedCount}/{badges.length}</strong>
|
||||
<span>{t.profile.statBadges}</span>
|
||||
<span>成就徽章</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-connect-block">
|
||||
<h2 className="profile-connect-title">{t.profile.connectStrip}</h2>
|
||||
<div className="profile-stats-grid profile-connect-stats">
|
||||
<Link href="/dating/likes" className="profile-stat-card profile-stat-link">
|
||||
<span className="stat-emoji">💕</span>
|
||||
<strong>{connect.likes}</strong>
|
||||
<span>{t.profile.statLikes}</span>
|
||||
</Link>
|
||||
<Link href="/dating/likes" className="profile-stat-card profile-stat-link">
|
||||
<span className="stat-emoji">✨</span>
|
||||
<strong>{connect.matches}</strong>
|
||||
<span>{t.profile.statMatches}</span>
|
||||
</Link>
|
||||
<Link href="/meetups" className="profile-stat-card profile-stat-link">
|
||||
<span className="stat-emoji">🎉</span>
|
||||
<strong>{rsvps.length}</strong>
|
||||
<span>{t.profile.statRsvps}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NomadDigest rsvpCount={rsvps.length} upcoming={rsvps} />
|
||||
|
||||
<section className="profile-trip">
|
||||
<div className="profile-section-head">
|
||||
<h2>🗓️ {planTitle}</h2>
|
||||
<Link href="/plan" className="btn btn-primary btn-sm">{t.profile.openPlan}</Link>
|
||||
<h2>🗓️ {meta?.title || "我的旅居计划"}</h2>
|
||||
<Link href="/plan" className="btn btn-primary btn-sm">打开计划中心</Link>
|
||||
</div>
|
||||
{trip.length === 0 ? (
|
||||
<div className="profile-empty">
|
||||
<span style={{ fontSize: "2.5rem" }}>🗺️</span>
|
||||
<p>{t.profile.tripEmpty}</p>
|
||||
<Link href="/plan" className="btn btn-primary">{t.profile.goPlan}</Link>
|
||||
<p>还没有规划行程</p>
|
||||
<Link href="/plan" className="btn btn-primary">去规划 →</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="profile-trip-list">
|
||||
{(meta?.startMonth || meta?.monthlyBudget) && (
|
||||
<div className="profile-plan-meta">
|
||||
{meta?.startMonth && (
|
||||
<span>{t.profile.startLabel.replace("{month}", meta.startMonth)}</span>
|
||||
)}
|
||||
{meta?.startMonth && <span>出发 {meta.startMonth}</span>}
|
||||
{meta && meta.monthlyBudget > 0 && (
|
||||
<span className={!budgetOk ? "plan-warn-text" : undefined}>
|
||||
{t.profile.budgetLabel.replace("{budget}", meta.monthlyBudget.toLocaleString())}
|
||||
{avgMonth > 0 &&
|
||||
t.profile.avgMonthLabel.replace("{avg}", avgMonth.toLocaleString())}
|
||||
月预算 ¥{meta.monthlyBudget.toLocaleString()}
|
||||
{avgMonth > 0 && ` · 均月 ¥${avgMonth.toLocaleString()}`}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{t.profile.readyCount
|
||||
.replace("{checked}", String(checked))
|
||||
.replace("{total}", String(MOVE_CHECKLIST.length))}
|
||||
</span>
|
||||
<span>就绪 {checked}/{MOVE_CHECKLIST.length}</span>
|
||||
</div>
|
||||
)}
|
||||
{trip.map((item, i) => (
|
||||
<div key={item.slug} className="profile-trip-item">
|
||||
{trip.map((t, i) => (
|
||||
<div key={t.slug} className="profile-trip-item">
|
||||
<span className="trip-item-num">{i + 1}</span>
|
||||
<span>{item.emoji}</span>
|
||||
<span>{t.emoji}</span>
|
||||
<div>
|
||||
<strong>{item.name}, {item.country}</strong>
|
||||
<span>
|
||||
{t.profile.monthsCost
|
||||
.replace("{months}", String(item.months))
|
||||
.replace("{cost}", (item.cost * item.months).toLocaleString())}
|
||||
</span>
|
||||
<strong>{t.name}, {t.country}</strong>
|
||||
<span>{t.months} 个月 · ¥{(t.cost * t.months).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="profile-trip-total">
|
||||
{t.profile.totalLine}{" "}
|
||||
<strong>
|
||||
{totalMonths} {t.profile.monthsUnit}
|
||||
</strong>{" "}
|
||||
·
|
||||
总计 <strong>{totalMonths} 个月</strong> ·
|
||||
<strong className="gradient-text"> ¥{totalCost.toLocaleString()}</strong>
|
||||
</div>
|
||||
<div className="profile-trip-actions">
|
||||
{trip.length >= 2 && (
|
||||
<Link
|
||||
href={`/compare?cities=${trip.map((item) => item.slug).slice(0, 4).join(",")}`}
|
||||
href={`/compare?cities=${trip.map((t) => t.slug).slice(0, 4).join(",")}`}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
{t.profile.compareTrip}
|
||||
对比行程城市
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/plan" className="btn btn-ghost btn-sm">{t.profile.keepEditing}</Link>
|
||||
<Link href="/plan" className="btn btn-ghost btn-sm">继续编辑 →</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="profile-badges">
|
||||
<h2>{t.profile.achievements}</h2>
|
||||
<h2>🏅 游民成就</h2>
|
||||
<div className="badge-grid">
|
||||
{badges.map((b) => (
|
||||
<div key={b.label} className={`badge-card${b.earned ? " earned" : " locked"}`} title={b.desc}>
|
||||
@ -336,20 +248,20 @@ export default function ProfilePage() {
|
||||
|
||||
<section className="profile-favorites">
|
||||
<div className="profile-section-head">
|
||||
<h2>{t.profile.myFavs}</h2>
|
||||
<h2>❤️ 我的收藏</h2>
|
||||
{favsNotInTrip.length > 0 && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={addFavsToPlan}>
|
||||
{t.profile.favsToPlan.replace("{n}", String(favsNotInTrip.length))}
|
||||
收藏写入计划({favsNotInTrip.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="profile-loading">{t.profile.loading}</p>
|
||||
<p className="profile-loading">加载中...</p>
|
||||
) : favoriteDests.length === 0 ? (
|
||||
<div className="profile-empty">
|
||||
<span style={{ fontSize: "3rem" }}>🌍</span>
|
||||
<p>{t.profile.favsEmpty}</p>
|
||||
<Link href="/#destinations" className="btn btn-primary">{t.profile.goExplore}</Link>
|
||||
<p>还没有收藏目的地</p>
|
||||
<Link href="/#destinations" className="btn btn-primary">去探索 →</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="profile-fav-grid">
|
||||
@ -358,10 +270,7 @@ export default function ProfilePage() {
|
||||
<Link href={`/destinations/${d.slug}`}>
|
||||
<span className="fav-emoji">{d.emoji}</span>
|
||||
<h3>{d.name}, {d.country}</h3>
|
||||
<p>
|
||||
💰 ¥{d.cost.toLocaleString()}
|
||||
{t.profile.perMonth} · ⭐ {d.rating}
|
||||
</p>
|
||||
<p>💰 ¥{d.cost.toLocaleString()}/月 · ⭐ {d.rating}</p>
|
||||
</Link>
|
||||
<FavoriteButton slug={d.slug} />
|
||||
</div>
|
||||
@ -370,98 +279,17 @@ export default function ProfilePage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="profile-rsvps">
|
||||
<div className="profile-section-head">
|
||||
<h2>🎉 {t.profile.myRsvps}</h2>
|
||||
<div className="profile-section-actions">
|
||||
{rsvps.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
const ics = buildMeetupIcs(rsvps, "nomadro RSVPs");
|
||||
if (!ics) {
|
||||
toast(t.meetups.exportCalFail, "info");
|
||||
return;
|
||||
}
|
||||
downloadMeetupIcs("nomadro-rsvps.ics", ics);
|
||||
toast(t.meetups.exportCalDone);
|
||||
}}
|
||||
>
|
||||
📅 {t.profile.exportRsvps}
|
||||
</button>
|
||||
)}
|
||||
<Link href="/meetups" className="btn btn-ghost btn-sm">{t.nav.meetups}</Link>
|
||||
</div>
|
||||
</div>
|
||||
{rsvps.length === 0 ? (
|
||||
<div className="profile-empty">
|
||||
<p>{t.profile.rsvpEmpty}</p>
|
||||
<Link href="/meetups" className="btn btn-primary btn-sm">{t.profile.goMeetups}</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="profile-rsvp-list">
|
||||
{rsvps.map((m) => (
|
||||
<div key={m.id} className="profile-rsvp-card">
|
||||
<span aria-hidden>{m.emoji}</span>
|
||||
<div>
|
||||
<strong>{m.title}</strong>
|
||||
<p>
|
||||
{m.city} · {m.date} {m.time}
|
||||
</p>
|
||||
</div>
|
||||
<div className="profile-rsvp-actions">
|
||||
{(m.mode === "online" || m.mode === "hybrid") && (
|
||||
<Link href={`/meetups/${m.id}/live`} className="btn btn-sm btn-primary">
|
||||
{t.profile.openLive}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => {
|
||||
const ics = buildMeetupIcs([m], m.title);
|
||||
if (!ics) {
|
||||
toast(t.meetups.exportCalFail, "info");
|
||||
return;
|
||||
}
|
||||
downloadMeetupIcs(`nomadro-${m.id}.ics`, ics);
|
||||
toast(t.meetups.exportCalDone);
|
||||
}}
|
||||
>
|
||||
{t.meetups.addToCal}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<RecentlyViewed compact />
|
||||
|
||||
<section className="profile-quick">
|
||||
<h2>{t.profile.quickLinks}</h2>
|
||||
<h2>🚀 快捷入口</h2>
|
||||
<div className="profile-quick-grid">
|
||||
<Link href="/#destinations" className="quick-card">{t.profile.qDest}</Link>
|
||||
<Link href="/plan" className="quick-card">{t.profile.qPlan}</Link>
|
||||
<Link href="/compare" className="quick-card">{t.profile.qCompare}</Link>
|
||||
<Link href="/next-stop" className="quick-card">{t.profile.qNext}</Link>
|
||||
<Link href="/meetups" className="quick-card">{t.profile.qMeetups}</Link>
|
||||
<Link href="/dating" className="quick-card">{t.profile.qDating}</Link>
|
||||
<Link href="/join" className="quick-card">{vip ? t.profile.qVipMember : t.profile.qVip}</Link>
|
||||
<Link href="/digital" className="quick-card">{t.profile.qDigital}</Link>
|
||||
<Link href="/ai" className="quick-card">{t.profile.qAi}</Link>
|
||||
<Link href="/tools" className="quick-card">{t.profile.qTools}</Link>
|
||||
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
|
||||
<Link href="/plan" className="quick-card">🗓️ 旅居计划</Link>
|
||||
<Link href="/compare" className="quick-card">⚖️ 城市对比</Link>
|
||||
<Link href="/#visa" className="quick-card">📋 签证指南</Link>
|
||||
<Link href="/#blog" className="quick-card">📝 阅读博客</Link>
|
||||
<Link href="/tools" className="quick-card">🛠️ 工具箱</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RingNext
|
||||
steps={[
|
||||
...rings.afterDiscover,
|
||||
{ href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
|
||||
].slice(0, 3)}
|
||||
/>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
|
||||
@ -35,24 +35,11 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
{ url: `${SITE}/contact`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
|
||||
{ url: `${SITE}/help`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
|
||||
{ url: `${SITE}/terms`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
|
||||
{ url: `${SITE}/digital`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
|
||||
{ url: `${SITE}/join`, lastModified: now, changeFrequency: "monthly", priority: 0.75 },
|
||||
{ url: `${SITE}/digital/course`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
|
||||
{ url: `${SITE}/digital/jobs`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
|
||||
{ url: `${SITE}/digital/day/1`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/digital/day/2`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/digital/day/3`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/digital/day/4`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/digital/day/5`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/digital/day/6`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/digital/day/7`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/book`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
|
||||
{ url: `${SITE}/book/read`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
|
||||
{ url: `${SITE}/support`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
|
||||
{ url: `${SITE}/careers`, lastModified: now, changeFrequency: "monthly", priority: 0.45 },
|
||||
{ url: `${SITE}/safety`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
|
||||
{ url: `${SITE}/guidelines`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
|
||||
{ url: `${SITE}/cookies`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
|
||||
{ url: `${SITE}/press`, lastModified: now, changeFrequency: "yearly", priority: 0.35 },
|
||||
{ url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
|
||||
{ url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
|
||||
{ url: `${SITE}/about`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { localizedToolCategories, localizedToolLinks } from "@/lib/toolCopy";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
@ -12,28 +12,25 @@ const FEATURED = ["weekend", "day-plan", "bill-split", "mood", "scam-alerts", "d
|
||||
|
||||
/** Utility drawer — not a product mega-menu. */
|
||||
export default function ToolsHubPage() {
|
||||
const { t, locale } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [q, setQ] = useState("");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const tools = useMemo(() => localizedToolLinks(locale), [locale]);
|
||||
const categories = useMemo(() => localizedToolCategories(locale), [locale]);
|
||||
|
||||
const featured = useMemo(
|
||||
() => FEATURED.map((id) => tools.find((tool) => tool.id === id)).filter((tool): tool is NonNullable<typeof tool> => !!tool),
|
||||
[tools]
|
||||
() => FEATURED.map((id) => TOOL_LINKS.find((tool) => tool.id === id)).filter((tool): tool is NonNullable<typeof tool> => !!tool),
|
||||
[]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = filter === "all" ? tools : tools.filter((tool) => tool.category === filter);
|
||||
let list = filter === "all" ? TOOL_LINKS : TOOL_LINKS.filter((tool) => tool.category === filter);
|
||||
if (q.trim()) {
|
||||
const s = q.toLowerCase();
|
||||
list = list.filter((tool) => tool.title.toLowerCase().includes(s) || tool.desc.toLowerCase().includes(s));
|
||||
}
|
||||
return list;
|
||||
}, [filter, q, tools]);
|
||||
}, [filter, q]);
|
||||
|
||||
const visible = showAll || q.trim() || filter !== "all" ? filtered : filtered.slice(0, 12);
|
||||
|
||||
@ -55,7 +52,7 @@ export default function ToolsHubPage() {
|
||||
<div className="tools-featured">
|
||||
<div className="tools-featured-head">
|
||||
<strong>✨ {t.tools.tag}</strong>
|
||||
<span>{featured.length} · {t.tools.featuredHint}</span>
|
||||
<span>{featured.length} · 先用这几个</span>
|
||||
</div>
|
||||
<div className="tools-featured-grid tools-featured-grid-compact">
|
||||
{featured.map((tool) => (
|
||||
@ -73,12 +70,12 @@ export default function ToolsHubPage() {
|
||||
<div className="tools-hub-toolbar">
|
||||
<input
|
||||
className="tools-hub-search"
|
||||
placeholder={t.tools.searchPlaceholder}
|
||||
placeholder="搜索工具,如「签证」「机票」「笔记」…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<div className="tools-hub-filters">
|
||||
{categories.map((c) => (
|
||||
{TOOL_CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
className={`filter-btn${filter === c.key ? " active" : ""}`}
|
||||
@ -96,38 +93,18 @@ export default function ToolsHubPage() {
|
||||
<span className="tools-hub-emoji">{tool.emoji}</span>
|
||||
<h3>{tool.title}</h3>
|
||||
<p>{tool.desc}</p>
|
||||
<span className="tools-hub-go">{t.tools.open}</span>
|
||||
<span className="tools-hub-go">打开 →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{!showAll && !q.trim() && filter === "all" && filtered.length > 12 && (
|
||||
<div className="tools-hub-more">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}>
|
||||
{t.tools.showAll.replace("{n}", String(filtered.length))}
|
||||
展开全部 {filtered.length} 个工具
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{filtered.length === 0 && (
|
||||
<div className="notif-empty">
|
||||
<p className="dest-empty">{t.tools.empty}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setQ("");
|
||||
setFilter("all");
|
||||
setShowAll(false);
|
||||
}}
|
||||
>
|
||||
{t.tools.clearFilter}
|
||||
</button>
|
||||
<Link href="/next-stop" className="btn btn-primary btn-sm">
|
||||
{t.tools.goNextStop}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{filtered.length === 0 && <p className="dest-empty">没有匹配的工具,试试其他关键词</p>}
|
||||
</div>
|
||||
</div>
|
||||
</SiteShell>
|
||||
|
||||
@ -1,68 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export default function AboutClient() {
|
||||
const { t } = useI18n();
|
||||
const A = t.aboutPage;
|
||||
|
||||
return (
|
||||
<SiteShell>
|
||||
<div className="legal-page about-page">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/">{t.common.backHome}</Link>
|
||||
</nav>
|
||||
<article className="legal-content">
|
||||
<span className="section-tag">{A.tag}</span>
|
||||
<h1>{A.title}</h1>
|
||||
<p className="legal-updated">{A.subtitle}</p>
|
||||
|
||||
<section>
|
||||
<h2>{A.whoTitle}</h2>
|
||||
<p>{A.whoBody}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{A.whatTitle}</h2>
|
||||
<ul className="about-list">
|
||||
<li>{A.what1}</li>
|
||||
<li>{A.what2}</li>
|
||||
<li>{A.what3}</li>
|
||||
<li>{A.what4}</li>
|
||||
<li>{A.what5}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{A.contactTitle}</h2>
|
||||
<p>
|
||||
{A.contactBody}
|
||||
<a href="mailto:hello@nomadro.com"> hello@nomadro.com</a>
|
||||
</p>
|
||||
<p>
|
||||
{A.repoBody}
|
||||
<a href="https://gitea.dsx2020.com/eric/nomadweb" target="_blank" rel="noopener noreferrer">
|
||||
{" "}
|
||||
gitea.dsx2020.com/eric/nomadweb
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="about-actions">
|
||||
<Link href="/next-stop" className="btn btn-primary">
|
||||
{A.ctaExplore}
|
||||
</Link>
|
||||
<Link href="/community" className="btn btn-ghost">
|
||||
{A.ctaCommunity}
|
||||
</Link>
|
||||
<Link href="/plan" className="btn btn-ghost">
|
||||
{A.ctaPlan}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@ -1,211 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||||
import { clearAiHistory, loadAiHistory, pushAiHistory, type AiHistoryItem } from "@/lib/aiHistory";
|
||||
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
|
||||
import type { Destination } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
const DRAFT_KEY = "nomadro-ai-compose-draft";
|
||||
|
||||
interface Props {
|
||||
destinations: Destination[];
|
||||
}
|
||||
|
||||
export default function AiAssistantClient({ destinations }: Props) {
|
||||
export default function AiAssistantClient() {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const rings = useRingSteps();
|
||||
const [message, setMessage] = useState("");
|
||||
const [reply, setReply] = useState("");
|
||||
const [cities, setCities] = useState<{ slug: string; name: string; emoji: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [history, setHistory] = useState<AiHistoryItem[]>([]);
|
||||
const [draftReady, setDraftReady] = useState(false);
|
||||
|
||||
const tips = [t.ai.tip1, t.ai.tip2, t.ai.tip3];
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(loadAiHistory());
|
||||
const draft = loadDraft<{ text?: string }>(DRAFT_KEY);
|
||||
if (draft?.text?.trim()) {
|
||||
setMessage(draft.text);
|
||||
toast(t.ai.draftRestored, "info");
|
||||
}
|
||||
setDraftReady(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftReady) return;
|
||||
const id = window.setTimeout(() => {
|
||||
if (!message.trim()) {
|
||||
clearDraft(DRAFT_KEY);
|
||||
return;
|
||||
}
|
||||
saveDraft(DRAFT_KEY, { text: message });
|
||||
}, 400);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [message, draftReady]);
|
||||
|
||||
const ask = async (text?: string) => {
|
||||
const q = (text ?? message).trim();
|
||||
if (!q) {
|
||||
toast(t.ai.needQuestion, "info");
|
||||
return;
|
||||
}
|
||||
setMessage(q);
|
||||
const ask = async () => {
|
||||
if (!message.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.askAssistant(q);
|
||||
const res = await api.askAssistant(message.trim());
|
||||
setReply(res.reply);
|
||||
const nextCities = res.cities || [];
|
||||
setCities(nextCities);
|
||||
setHistory(pushAiHistory({ question: q, reply: res.reply, cities: nextCities }));
|
||||
clearDraft(DRAFT_KEY);
|
||||
setCities(res.cities || []);
|
||||
} catch {
|
||||
setReply(t.ai.fail);
|
||||
setCities([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const restore = (item: AiHistoryItem) => {
|
||||
setMessage(item.question);
|
||||
setReply(item.reply);
|
||||
setCities(item.cities);
|
||||
};
|
||||
|
||||
const addToPlan = () => {
|
||||
const dests = cities
|
||||
.map((c) => destinations.find((d) => d.slug === c.slug))
|
||||
.filter((d): d is Destination => Boolean(d));
|
||||
const planAction = { href: "/plan", label: t.strip.openPlan };
|
||||
if (dests.length === 0) {
|
||||
toast(t.ai.noCityData, "info", planAction);
|
||||
router.push("/plan");
|
||||
return;
|
||||
}
|
||||
const { added, skipped } = mergeDestinationsIntoTrip(dests, 1);
|
||||
if (added === 0) {
|
||||
toast(skipped ? t.compare.alreadyInPlan : t.ai.writeFail, "info", planAction);
|
||||
} else {
|
||||
toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}`, "success", planAction);
|
||||
}
|
||||
router.push("/plan");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="community-page">
|
||||
<div className="container">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/">{t.common.backHome}</Link>
|
||||
<span> · </span>
|
||||
<Link href="/next-stop">{t.ai.nextStop}</Link>
|
||||
</nav>
|
||||
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">{t.ai.tag}</span>
|
||||
<h1>{t.ai.title}</h1>
|
||||
<p>{t.ai.subtitle}</p>
|
||||
</div>
|
||||
<div className="ai-suggestions reveal">
|
||||
{tips.map((s) => (
|
||||
<button key={s} type="button" className="filter-btn" disabled={loading} onClick={() => void ask(s)}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="community-compose reveal">
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder={t.ai.placeholder}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void ask();
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void ask()}>
|
||||
{loading ? t.ai.thinking : t.ai.ask}
|
||||
</button>
|
||||
<p className="section-desc">{t.ai.sendHint} · {t.ai.draftHint}</p>
|
||||
<textarea rows={4} placeholder={t.ai.placeholder} value={message} onChange={(e) => setMessage(e.target.value)} />
|
||||
<button type="button" className="btn btn-primary" disabled={loading} onClick={ask}>{t.ai.ask}</button>
|
||||
</div>
|
||||
{reply && (
|
||||
<div className="ai-reply reveal">
|
||||
<p>{reply}</p>
|
||||
<div className="ai-cities">
|
||||
{cities.map((c) => (
|
||||
<Link key={c.slug} href={`/destinations/${c.slug}`} className="btn btn-sm">
|
||||
{c.emoji} {c.name}
|
||||
</Link>
|
||||
<Link key={c.slug} href={`/destinations/${c.slug}`} className="btn btn-sm">{c.emoji} {c.name}</Link>
|
||||
))}
|
||||
<Link href="/next-stop" className="btn btn-sm">
|
||||
{t.ai.nextStop}
|
||||
</Link>
|
||||
<Link
|
||||
href={
|
||||
cities.length
|
||||
? `/compare?cities=${cities
|
||||
.slice(0, 4)
|
||||
.map((c) => c.slug)
|
||||
.join(",")}`
|
||||
: "/compare"
|
||||
}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
{t.ai.compareCities}
|
||||
</Link>
|
||||
{cities.length > 0 && (
|
||||
<button type="button" className="btn btn-sm btn-primary" onClick={addToPlan}>
|
||||
{t.ai.writePlan}
|
||||
</button>
|
||||
)}
|
||||
<Link href="/next-stop" className="btn btn-sm">{t.ai.nextStop}</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<section className="ai-history reveal">
|
||||
<div className="ai-history-head">
|
||||
<h2>{t.ai.history}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
clearAiHistory();
|
||||
setHistory([]);
|
||||
}}
|
||||
>
|
||||
{t.ai.clearHistory}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="ai-history-list">
|
||||
{history.map((h) => (
|
||||
<li key={h.id}>
|
||||
<button type="button" className="ai-history-item" onClick={() => restore(h)}>
|
||||
<strong>{h.question}</strong>
|
||||
<span>
|
||||
{h.cities.length
|
||||
? h.cities.map((c) => `${c.emoji} ${c.name}`).join(" · ")
|
||||
: h.reply.slice(0, 72)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<RingNext steps={rings.afterDiscover} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,9 +2,6 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Destination } from "@/lib/types";
|
||||
import ToolCityExits from "@/components/ToolCityExits";
|
||||
import { meetupCityHref } from "@/lib/meetupLinks";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
@ -113,11 +110,6 @@ export default function ArrivalChecklist({ destinations }: Props) {
|
||||
<span className="packing-check">{checked[t.id] ? "✓" : ""}</span>
|
||||
<span className="packing-emoji">{t.emoji}</span>
|
||||
<span className="packing-label">{t.label}</span>
|
||||
{t.id === "meetup" && dest && (
|
||||
<Link href={meetupCityHref(dest.name)} className="arrival-meetup-link">
|
||||
→
|
||||
</Link>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
@ -125,7 +117,6 @@ export default function ArrivalChecklist({ destinations }: Props) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{dest && <ToolCityExits dest={dest} />}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import BlogMarkdown from "@/components/BlogMarkdown";
|
||||
import ShareButton from "@/components/ShareButton";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { BlogPost, BlogPostDetail } from "@/lib/types";
|
||||
|
||||
export default function BlogDetailClient({
|
||||
post,
|
||||
related,
|
||||
}: {
|
||||
post: BlogPostDetail;
|
||||
related: BlogPost[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const b = t.blogDetail;
|
||||
|
||||
return (
|
||||
<div className="detail-page">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/#blog">← {b.back}</Link>
|
||||
<ShareButton title={post.title} compact />
|
||||
</nav>
|
||||
<article className="blog-detail">
|
||||
<div className="blog-detail-header">
|
||||
<span className="blog-detail-emoji">{post.emoji}</span>
|
||||
<div className="blog-meta">
|
||||
<span>📅 {post.published_at}</span>
|
||||
<span>⏱️ {b.readTime.replace("{n}", String(post.read_time))}</span>
|
||||
<span>✍️ {post.author}</span>
|
||||
</div>
|
||||
<h1>{post.title}</h1>
|
||||
<div className="blog-tags">
|
||||
{post.tags.map((tag) => (
|
||||
<span key={tag} className="blog-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="blog-detail-content">
|
||||
<BlogMarkdown content={post.content} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="blog-related">
|
||||
<h2>📚 {b.related}</h2>
|
||||
<div className="blog-related-grid">
|
||||
{related.map((r) => (
|
||||
<Link key={r.slug} href={`/blog/${r.slug}`} className="blog-related-card">
|
||||
<span className="blog-related-emoji">{r.emoji}</span>
|
||||
<h3>{r.title}</h3>
|
||||
<p>{r.excerpt}</p>
|
||||
<span className="blog-read-more">{b.readMore}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="blog-related" style={{ marginTop: "2rem" }}>
|
||||
<h2>{b.explore}</h2>
|
||||
<div className="dating-empty-actions" style={{ justifyContent: "flex-start" }}>
|
||||
<Link href="/community" className="btn btn-primary btn-sm">
|
||||
{t.nav.community}
|
||||
</Link>
|
||||
<Link href="/book" className="btn btn-sm">
|
||||
{t.nav.ebook}
|
||||
</Link>
|
||||
<Link href="/next-stop" className="btn btn-sm">
|
||||
{t.nav.nextStop}
|
||||
</Link>
|
||||
<Link href="/submit" className="btn btn-ghost btn-sm">
|
||||
{b.submit}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,27 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||||
import { meetupCityHref } from "@/lib/meetupLinks";
|
||||
import type { CostResult, Destination } from "@/lib/types";
|
||||
|
||||
interface Props { destinations: Destination[] }
|
||||
|
||||
export default function Calculator({ destinations }: Props) {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const [slug, setSlug] = useState(destinations[0]?.slug || "");
|
||||
const [housing, setHousing] = useState("mid");
|
||||
const [months, setMonths] = useState(3);
|
||||
const [result, setResult] = useState<CostResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const selected = destinations.find((d) => d.slug === slug);
|
||||
|
||||
const calculate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@ -34,21 +25,6 @@ export default function Calculator({ destinations }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const addToPlan = () => {
|
||||
if (!selected) return;
|
||||
const { added } = mergeDestinationsIntoTrip([selected], months);
|
||||
toast(
|
||||
added
|
||||
? t.common.addedToPlanMonths
|
||||
.replace("{emoji}", selected.emoji)
|
||||
.replace("{city}", selected.name)
|
||||
.replace("{months}", String(months))
|
||||
: t.common.alreadyInPlan,
|
||||
added ? "success" : "info",
|
||||
{ href: "/plan", label: t.strip.openPlan }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section calculator-section" id="calculator">
|
||||
<div className="container">
|
||||
@ -109,19 +85,6 @@ export default function Calculator({ destinations }: Props) {
|
||||
<div><span>月均</span><strong>¥{result.per_month.toLocaleString()}</strong></div>
|
||||
<div><span>总计</span><strong className="gradient-text">¥{result.total.toLocaleString()}</strong></div>
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="dating-empty-actions" style={{ marginTop: "1rem" }}>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={addToPlan}>
|
||||
🗓️ {t.nav.plan}
|
||||
</button>
|
||||
<Link href={`/destinations/${selected.slug}`} className="btn btn-sm">
|
||||
{t.common.detail}
|
||||
</Link>
|
||||
<Link href={meetupCityHref(selected.name)} className="btn btn-sm btn-ghost">
|
||||
{t.common.cityMeetups}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="result-placeholder">
|
||||
|
||||
@ -6,37 +6,15 @@ import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { ConversationItem } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
function Avatar({ photo, name }: { photo?: string; name?: string }) {
|
||||
if (photo?.startsWith("http")) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={photo} alt="" className="chat-avatar-img" />
|
||||
);
|
||||
}
|
||||
return <span className="chat-avatar">{photo || name?.[0] || "💬"}</span>;
|
||||
}
|
||||
|
||||
export default function ChatClient() {
|
||||
const { user, token } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [items, setItems] = useState<ConversationItem[]>([]);
|
||||
const [loading, setLoading] = useState(Boolean(token));
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
api
|
||||
.getConversations(token)
|
||||
.then(setItems)
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
if (!token) return;
|
||||
api.getConversations(token).then(setItems).catch(() => setItems([]));
|
||||
}, [token]);
|
||||
|
||||
if (!user) {
|
||||
@ -45,12 +23,7 @@ export default function ChatClient() {
|
||||
<div className="container">
|
||||
<div className="dating-gate reveal">
|
||||
<h2>{t.chat.loginTitle}</h2>
|
||||
<p>{t.chat.loginDesc}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/login?next=/chat" className="btn btn-primary">{t.nav.login}</Link>
|
||||
<Link href="/meetups" className="btn btn-ghost">{t.dating.meetAtEvents}</Link>
|
||||
<Link href="/dating" className="btn btn-ghost">{t.chat.goDating}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -68,41 +41,19 @@ export default function ChatClient() {
|
||||
<h1>{t.chat.title}</h1>
|
||||
</div>
|
||||
<div className="chat-list">
|
||||
{loading && <p className="dest-empty">{t.chat.loadingList}</p>}
|
||||
{!loading &&
|
||||
items.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/chat/${c.id}`}
|
||||
className={`chat-list-item reveal${c.unreadCount > 0 ? " unread" : ""}`}
|
||||
>
|
||||
<Avatar photo={c.peer?.photo} name={c.peer?.name} />
|
||||
{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 || t.chat.peerFallback}</strong>
|
||||
<strong>{c.peer?.name || "游民"}</strong>
|
||||
<p>{c.lastMessagePreview || t.chat.noMessages}</p>
|
||||
</div>
|
||||
{c.unreadCount > 0 && <em className="chat-unread">{c.unreadCount}</em>}
|
||||
</Link>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<div className="notif-empty reveal">
|
||||
<p className="dest-empty">{t.chat.empty}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/dating" className="btn btn-primary btn-sm">
|
||||
{t.chat.goDating}
|
||||
</Link>
|
||||
<Link href="/meetups" className="btn btn-sm">
|
||||
{t.dating.meetAtEvents}
|
||||
</Link>
|
||||
<Link href="/community" className="btn btn-sm">
|
||||
{t.nav.community}
|
||||
</Link>
|
||||
{items.length === 0 && <p className="dest-empty">{t.chat.empty}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RingNext steps={rings.afterDating} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,156 +5,46 @@ import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import type { ChatMessage, ConversationItem } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
|
||||
export default function ChatThreadClient({ convId }: { convId: string }) {
|
||||
const { token, user } = useAuth();
|
||||
const { token } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const rings = useRingSteps();
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [conv, setConv] = useState<ConversationItem | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loading, setLoading] = useState(Boolean(token));
|
||||
const [draftReady, setDraftReady] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const draftKey = `nomadro-chat-draft:${convId}`;
|
||||
|
||||
const load = () => {
|
||||
if (!token) return;
|
||||
api
|
||||
.getMessages(token, convId)
|
||||
.then(setMessages)
|
||||
.catch(() => setMessages([]))
|
||||
.finally(() => setLoading(false));
|
||||
api.getMessages(token, convId).then(setMessages).catch(() => setMessages([]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
api.getConversation(token, convId).then(setConv).catch(() => setConv(null));
|
||||
load();
|
||||
const id = setInterval(load, 4000);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, convId]);
|
||||
|
||||
useEffect(() => {
|
||||
const draft = loadDraft<{ text?: string }>(draftKey);
|
||||
if (draft?.text?.trim()) {
|
||||
setText(draft.text);
|
||||
toast(t.chat.draftRestored, "info");
|
||||
}
|
||||
setDraftReady(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [convId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftReady || !token) return;
|
||||
const id = window.setTimeout(() => {
|
||||
if (!text.trim()) {
|
||||
clearDraft(draftKey);
|
||||
return;
|
||||
}
|
||||
saveDraft(draftKey, { text });
|
||||
}, 400);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [text, draftReady, token, draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const send = async () => {
|
||||
if (!token || !text.trim() || sending) return;
|
||||
setSending(true);
|
||||
if (!token || !text.trim()) return;
|
||||
try {
|
||||
await api.sendMessage(token, convId, text.trim());
|
||||
setText("");
|
||||
clearDraft(draftKey);
|
||||
load();
|
||||
} catch {
|
||||
toast(t.chat.sendFail, "error");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const peer = conv?.peer;
|
||||
const peerHref = peer?.userId ? `/members/${peer.userId}` : peer?.id ? `/members/${peer.id}` : "/dating";
|
||||
|
||||
if (!user || !token) {
|
||||
return (
|
||||
<div className="chat-thread-page">
|
||||
<div className="container">
|
||||
<div className="dating-gate reveal">
|
||||
<h2>{t.chat.loginTitle}</h2>
|
||||
<p>{t.chat.loginDesc}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href={`/login?next=/chat/${convId}`} className="btn btn-primary">
|
||||
{t.nav.login}
|
||||
</Link>
|
||||
<Link href="/meetups" className="btn btn-ghost">
|
||||
{t.dating.meetAtEvents}
|
||||
</Link>
|
||||
<Link href="/dating" className="btn btn-ghost">
|
||||
{t.nav.dating}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-thread-page">
|
||||
<div className="container chat-thread-wrap">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/chat">← {t.chat.back}</Link>
|
||||
<span> · </span>
|
||||
<Link href="/dating">{t.nav.dating}</Link>
|
||||
<span> · </span>
|
||||
<Link href="/meetups">{t.nav.meetups}</Link>
|
||||
</nav>
|
||||
|
||||
{peer && (
|
||||
<div className="chat-thread-peer reveal">
|
||||
<Link href={peerHref} className="chat-thread-peer-link">
|
||||
<span className="chat-avatar">{peer.photo || peer.name?.[0] || "💬"}</span>
|
||||
<div>
|
||||
<strong>{peer.name}</strong>
|
||||
<p className="dating-meta">{peer.location || t.dating.globalNomad}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href={peerHref} className="btn btn-ghost btn-sm">
|
||||
{t.dating.viewProfile}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chat-thread-messages">
|
||||
{loading && messages.length === 0 && <p className="dest-empty">{t.chat.loadingMessages}</p>}
|
||||
{!loading && messages.length === 0 && (
|
||||
<div className="notif-empty">
|
||||
<p className="dest-empty">{t.chat.sayHi}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/meetups" className="btn btn-sm">
|
||||
{t.dating.meetAtEvents}
|
||||
</Link>
|
||||
<Link href="/dating" className="btn btn-sm">
|
||||
{t.nav.dating}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className={`chat-bubble${m.mine ? " mine" : ""}`}>
|
||||
{m.body}
|
||||
@ -167,14 +57,10 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={t.chat.placeholder}
|
||||
onKeyDown={(e) => e.key === "Enter" && void send()}
|
||||
disabled={sending}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" disabled={sending || !text.trim()} onClick={() => void send()}>
|
||||
{sending ? "…" : t.chat.send}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={send}>{t.chat.send}</button>
|
||||
</div>
|
||||
<RingNext steps={rings.afterDating} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import type { Destination } from "@/lib/types";
|
||||
import ToolCityExits from "@/components/ToolCityExits";
|
||||
|
||||
const STORAGE_KEY = "nomadro-city-notes";
|
||||
|
||||
@ -13,7 +11,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function CityNotes({ destinations }: Props) {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const [slug, setSlug] = useState(destinations[0]?.slug || "bali");
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
@ -25,9 +22,7 @@ export default function CityNotes({ destinations }: Props) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) setNotes(JSON.parse(raw));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
@ -40,10 +35,7 @@ export default function CityNotes({ destinations }: Props) {
|
||||
const next = { ...notes, [slug]: text };
|
||||
setNotes(next);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
toast(`${dest?.emoji || ""} 笔记已保存`, "success", {
|
||||
href: "/plan",
|
||||
label: t.strip.openPlan,
|
||||
});
|
||||
toast(`${dest?.emoji || ""} 笔记已保存`);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
@ -74,9 +66,7 @@ export default function CityNotes({ destinations }: Props) {
|
||||
className={`notes-city${slug === d.slug ? " active" : ""}`}
|
||||
onClick={() => setSlug(d.slug)}
|
||||
>
|
||||
<span>
|
||||
{d.emoji} {d.name}
|
||||
</span>
|
||||
<span>{d.emoji} {d.name}</span>
|
||||
{notes[d.slug]?.trim() && <span className="notes-dot" />}
|
||||
</button>
|
||||
))}
|
||||
@ -84,9 +74,7 @@ export default function CityNotes({ destinations }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="notes-editor">
|
||||
<h3>
|
||||
{dest?.emoji} {dest?.name}
|
||||
</h3>
|
||||
<h3>{dest?.emoji} {dest?.name}</h3>
|
||||
<textarea
|
||||
className="notes-textarea"
|
||||
rows={10}
|
||||
@ -95,14 +83,9 @@ export default function CityNotes({ destinations }: Props) {
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
<div className="notes-actions">
|
||||
<button className="btn btn-primary" onClick={save}>
|
||||
💾 保存
|
||||
</button>
|
||||
<button className="btn btn-ghost" onClick={clear} disabled={!text.trim()}>
|
||||
清空
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={save}>💾 保存</button>
|
||||
<button className="btn btn-ghost" onClick={clear} disabled={!text.trim()}>清空</button>
|
||||
</div>
|
||||
{dest && <ToolCityExits dest={dest} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,48 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import NewsletterSubscribe from "@/components/NewsletterSubscribe";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Testimonial } from "@/lib/types";
|
||||
|
||||
export default function Community({ testimonials }: { testimonials: Testimonial[] }) {
|
||||
const { t } = useI18n();
|
||||
const C = t.homeCommunity;
|
||||
const { toast } = useToast();
|
||||
const [active, setActive] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (testimonials.length <= 1 || paused) return;
|
||||
const id = setInterval(() => setActive((i) => (i + 1) % testimonials.length), 4500);
|
||||
return () => clearInterval(id);
|
||||
const t = setInterval(() => setActive((i) => (i + 1) % testimonials.length), 4500);
|
||||
return () => clearInterval(t);
|
||||
}, [testimonials.length, paused]);
|
||||
|
||||
const cur = testimonials[active];
|
||||
const handleSubscribe = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
|
||||
try {
|
||||
await api.subscribe(email);
|
||||
toast("订阅成功!欢迎加入 nomadro 社区 🎉");
|
||||
form.reset();
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const t = testimonials[active];
|
||||
|
||||
return (
|
||||
<section className="section community" id="voices">
|
||||
<>
|
||||
<section className="section community" id="community">
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">{C.tag}</span>
|
||||
<h2>{C.title}</h2>
|
||||
<p>{C.subtitle}</p>
|
||||
<span className="section-tag">🤝 COMMUNITY</span>
|
||||
<h2>游民心声</h2>
|
||||
<p>来自全球数字游民的真实故事</p>
|
||||
</div>
|
||||
|
||||
{cur && (
|
||||
{t && (
|
||||
<div
|
||||
className="testimonial-carousel reveal"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<div className="testimonial-featured" key={cur.id}>
|
||||
<div className="testimonial-avatar">{cur.avatar}</div>
|
||||
<div className="testimonial-featured" key={t.id}>
|
||||
<div className="testimonial-avatar">{t.avatar}</div>
|
||||
<div className="testimonial-content">
|
||||
<div className="testimonial-stars">{"⭐".repeat(cur.rating)}</div>
|
||||
<p>“{cur.content}”</p>
|
||||
<div className="testimonial-stars">{"⭐".repeat(t.rating)}</div>
|
||||
<p>“{t.content}”</p>
|
||||
<div className="testimonial-author">
|
||||
<strong>{cur.author}</strong>
|
||||
<span>{cur.role}</span>
|
||||
<strong>{t.author}</strong>
|
||||
<span>{t.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -50,10 +60,9 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
|
||||
{testimonials.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`testimonial-dot${i === active ? " active" : ""}`}
|
||||
onClick={() => setActive(i)}
|
||||
aria-label={`${i + 1}`}
|
||||
aria-label={`评价 ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -78,20 +87,16 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
|
||||
|
||||
<div className="cta-banner reveal">
|
||||
<div className="cta-content">
|
||||
<h2>{C.ctaTitle}</h2>
|
||||
<p>{C.ctaDesc}</p>
|
||||
<div className="dating-empty-actions" style={{ marginTop: 12, justifyContent: "flex-start" }}>
|
||||
<Link href="/community" className="btn btn-primary btn-sm">
|
||||
{t.nav.community}
|
||||
</Link>
|
||||
<Link href="/meetups" className="btn btn-sm">
|
||||
{t.nav.meetups}
|
||||
</Link>
|
||||
<h2>准备好开始你的旅居之旅了吗? 🌍</h2>
|
||||
<p>加入 50,000+ 数字游民社区,获取目的地指南、签证攻略与独家优惠</p>
|
||||
</div>
|
||||
</div>
|
||||
<NewsletterSubscribe source="home-voices" />
|
||||
<form className="cta-form" onSubmit={handleSubscribe}>
|
||||
<input type="email" name="email" placeholder="输入你的邮箱 📧" required />
|
||||
<button type="submit" className="btn btn-primary"><span>免费订阅</span><span>🚀</span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import type { Discussion, DiscussionDetail } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
const CATEGORY_VALUES = ["全部", "签证", "远程工作", "住宿", "安全", "社区"] as const;
|
||||
const CATEGORIES = ["全部", "签证", "远程工作", "住宿", "安全", "社区"];
|
||||
|
||||
interface Props {
|
||||
discussions: Discussion[];
|
||||
@ -20,18 +20,6 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
|
||||
const [category, setCategory] = useState("全部");
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const catLabel = (value: string) => {
|
||||
const map: Record<string, string> = {
|
||||
全部: t.community.catAll,
|
||||
签证: t.community.catVisa,
|
||||
远程工作: t.community.catRemote,
|
||||
住宿: t.community.catHousing,
|
||||
安全: t.community.catSafety,
|
||||
社区: t.community.catCommunity,
|
||||
};
|
||||
return map[value] || value;
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = discussions;
|
||||
if (category !== "全部") list = list.filter((d) => d.category === category);
|
||||
@ -96,14 +84,14 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<div className="tools-hub-filters">
|
||||
{CATEGORY_VALUES.map((c) => (
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`filter-btn${category === c ? " active" : ""}`}
|
||||
onClick={() => setCategory(c)}
|
||||
>
|
||||
{catLabel(c)}
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -118,7 +106,7 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
|
||||
<div>
|
||||
<h3>{d.title}</h3>
|
||||
<p className="community-meta">
|
||||
{d.author} · {catLabel(d.category)} · {d.created_at}
|
||||
{d.author} · {d.category} · {d.created_at}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -135,29 +123,10 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="notif-empty reveal">
|
||||
<p className="dest-empty">{t.community.empty}</p>
|
||||
<div className="dating-empty-actions">
|
||||
{(category !== "全部" || q.trim()) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
setCategory("全部");
|
||||
setQ("");
|
||||
}}
|
||||
>
|
||||
{t.community.catAll}
|
||||
</button>
|
||||
)}
|
||||
<Link href="/community/new" className="btn btn-primary btn-sm">
|
||||
{t.community.newTopic}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RingNext steps={rings.afterCommunity()} />
|
||||
<RingNext steps={rings.afterCommunity} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,119 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
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";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
import {
|
||||
clearCommunityDraft,
|
||||
loadCommunityDraft,
|
||||
saveCommunityDraft,
|
||||
} from "@/lib/communityDraft";
|
||||
import { inferMeetupCity, meetupCityHref } from "@/lib/meetupLinks";
|
||||
import type { Destination } from "@/lib/types";
|
||||
|
||||
export default function CommunityNewClient() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [category, setCategory] = useState("社区");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [draftReady, setDraftReady] = useState(false);
|
||||
const [cities, setCities] = useState<Destination[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const draft = loadCommunityDraft();
|
||||
if (draft) {
|
||||
setTitle(draft.title || "");
|
||||
setContent(draft.content || "");
|
||||
setCategory(draft.category || "社区");
|
||||
if (draft.title || draft.content) {
|
||||
toast(t.community.draftRestored, "info");
|
||||
}
|
||||
}
|
||||
setDraftReady(true);
|
||||
api
|
||||
.getDestinations()
|
||||
.then(setCities)
|
||||
.catch(() => setCities([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftReady || !token) return;
|
||||
const id = window.setTimeout(() => {
|
||||
if (!title.trim() && !content.trim()) {
|
||||
clearCommunityDraft();
|
||||
return;
|
||||
}
|
||||
saveCommunityDraft({ title, content, category });
|
||||
}, 400);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [title, content, category, draftReady, token]);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="community-page">
|
||||
<div className="container">
|
||||
<nav className="detail-nav"><Link href="/community">← {t.community.back}</Link></nav>
|
||||
<div className="dating-gate reveal">
|
||||
<h2>{t.community.newTitle}</h2>
|
||||
<p>{t.community.loginDesc}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/login?next=/community/new" className="btn btn-primary">{t.nav.login}</Link>
|
||||
<Link href="/meetups" className="btn btn-ghost">{t.dating.meetAtEvents}</Link>
|
||||
</div>
|
||||
</div>
|
||||
<RingNext steps={rings.afterCommunity()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!token) {
|
||||
router.push("/login?next=/community/new");
|
||||
return;
|
||||
}
|
||||
const tTitle = title.trim();
|
||||
const body = content.trim();
|
||||
if (!tTitle || body.length < 10) {
|
||||
toast(t.community.draftNeed, "info");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const excerpt = body.slice(0, 1000);
|
||||
const res = await api.createDiscussion(token, {
|
||||
title: tTitle,
|
||||
content: body,
|
||||
excerpt,
|
||||
category,
|
||||
});
|
||||
clearCommunityDraft();
|
||||
const city = inferMeetupCity(
|
||||
`${tTitle} ${body} ${category}`,
|
||||
cities.map((d) => d.name)
|
||||
);
|
||||
toast(t.community.createOk, "success", {
|
||||
href: city ? meetupCityHref(city) : "/meetups",
|
||||
label: city ? t.common.cityMeetups : t.dating.meetAtEvents,
|
||||
});
|
||||
const id = res.discussion?.id;
|
||||
router.push(id ? `/community/${id}` : "/community");
|
||||
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");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -123,34 +37,18 @@ export default function CommunityNewClient() {
|
||||
<nav className="detail-nav"><Link href="/community">← {t.community.back}</Link></nav>
|
||||
<div className="section-header reveal">
|
||||
<h1>{t.community.newTitle}</h1>
|
||||
<p className="section-desc">{t.community.draftHint}</p>
|
||||
</div>
|
||||
<div className="join-card reveal">
|
||||
<label>{t.community.newSubject}</label>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} />
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<label>{t.community.newCategory}</label>
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
{(
|
||||
[
|
||||
["签证", t.community.catVisa],
|
||||
["远程工作", t.community.catRemote],
|
||||
["住宿", t.community.catHousing],
|
||||
["安全", t.community.catSafety],
|
||||
["社区", t.community.catCommunity],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
{["签证", "远程工作", "住宿", "安全", "社区"].map((c) => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
<label>{t.community.newBody}</label>
|
||||
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={6} maxLength={4000} />
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void submit()}>
|
||||
{busy ? t.meetups.hosting : t.community.newSubmit}
|
||||
</button>
|
||||
<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>
|
||||
<RingNext steps={rings.afterCommunity()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -11,18 +11,11 @@ import {
|
||||
overallScore,
|
||||
parseCompareSlugs,
|
||||
} from "@/lib/compareScore";
|
||||
import {
|
||||
clearCompareHistory,
|
||||
loadCompareHistory,
|
||||
pushCompareHistory,
|
||||
type ComparePreset,
|
||||
} from "@/lib/compareHistory";
|
||||
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Destination } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
import { meetupCityHref } from "@/lib/meetupLinks";
|
||||
|
||||
interface Props {
|
||||
destinations: Destination[];
|
||||
@ -35,11 +28,6 @@ export default function CompareClient({ destinations }: Props) {
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [picked, setPicked] = useState<string[]>([]);
|
||||
const [history, setHistory] = useState<ComparePreset[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(loadCompareHistory());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fromUrl = parseCompareSlugs(searchParams.get("cities"));
|
||||
@ -58,16 +46,6 @@ export default function CompareClient({ destinations }: Props) {
|
||||
const scores = selected.map(overallScore);
|
||||
const winnerIdx = scores.length ? scores.indexOf(Math.max(...scores)) : -1;
|
||||
|
||||
useEffect(() => {
|
||||
if (selected.length < 2) return;
|
||||
setHistory(
|
||||
pushCompareHistory(
|
||||
selected.map((d) => d.slug),
|
||||
selected.map((d) => `${d.emoji} ${d.name}`)
|
||||
)
|
||||
);
|
||||
}, [selected]);
|
||||
|
||||
const syncUrl = (slugs: string[]) => {
|
||||
const href = compareUrl(slugs);
|
||||
router.replace(href, { scroll: false });
|
||||
@ -89,17 +67,12 @@ export default function CompareClient({ destinations }: Props) {
|
||||
const addAll = () => {
|
||||
if (selected.length < 1) return;
|
||||
const { added, skipped } = mergeDestinationsIntoTrip(selected, 1);
|
||||
const planAction = { href: "/plan", label: t.strip.openPlan };
|
||||
if (added === 0) {
|
||||
toast(skipped ? t.compare.alreadyInPlan : t.compare.alreadyOne, "info", planAction);
|
||||
toast(skipped ? t.compare.alreadyInPlan : t.compare.alreadyOne, "info");
|
||||
router.push("/plan");
|
||||
return;
|
||||
}
|
||||
toast(
|
||||
`${t.compare.addedN} ${added} ${t.compare.citiesUnit}${skipped ? `(${t.compare.skipped} ${skipped})` : ""}`,
|
||||
"success",
|
||||
planAction
|
||||
);
|
||||
toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}${skipped ? `(${t.compare.skipped} ${skipped})` : ""}`);
|
||||
router.push("/plan");
|
||||
};
|
||||
|
||||
@ -107,11 +80,7 @@ export default function CompareClient({ destinations }: Props) {
|
||||
if (winnerIdx < 0) return;
|
||||
const d = selected[winnerIdx];
|
||||
const { added } = mergeDestinationsIntoTrip([d], 1);
|
||||
toast(
|
||||
added ? `${d.emoji} ${d.name} ${t.compare.wrotePlan}` : t.compare.alreadyOne,
|
||||
added ? "success" : "info",
|
||||
{ href: "/plan", label: t.strip.openPlan }
|
||||
);
|
||||
toast(added ? `${d.emoji} ${d.name} ${t.compare.wrotePlan}` : t.compare.alreadyOne, added ? "success" : "info");
|
||||
if (added) router.push("/plan");
|
||||
};
|
||||
|
||||
@ -125,30 +94,6 @@ export default function CompareClient({ destinations }: Props) {
|
||||
toast(t.compare.linkCopied);
|
||||
};
|
||||
|
||||
const copySnapshot = async () => {
|
||||
if (selected.length < 2) {
|
||||
toast(t.compare.needShare, "info");
|
||||
return;
|
||||
}
|
||||
const lines = [
|
||||
t.compare.snapshotTitle,
|
||||
...selected.map((d, i) => {
|
||||
const mark = i === winnerIdx ? "👑 " : "";
|
||||
const scoreText = t.compare.scoreLabel.replace("{score}", String(scores[i]));
|
||||
return `${mark}${d.emoji} ${d.name}, ${d.country} · ¥${d.cost.toLocaleString()}/mo · ${d.speed}Mbps · ⭐${d.rating} · ${scoreText}`;
|
||||
}),
|
||||
`${typeof window !== "undefined" ? window.location.origin : ""}${compareUrl(selected.map((d) => d.slug))}`,
|
||||
];
|
||||
await navigator.clipboard.writeText(lines.join("\n"));
|
||||
toast(t.compare.snapshotOk);
|
||||
};
|
||||
|
||||
const restorePreset = (preset: ComparePreset) => {
|
||||
const next = preset.slugs.filter((s) => destinations.some((d) => d.slug === s)).slice(0, 4);
|
||||
setPicked(next);
|
||||
syncUrl(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="compare-page">
|
||||
<div className="container">
|
||||
@ -164,51 +109,16 @@ export default function CompareClient({ destinations }: Props) {
|
||||
<p>{t.compare.subtitle}</p>
|
||||
</header>
|
||||
|
||||
{history.length > 0 && (
|
||||
<section className="compare-history reveal">
|
||||
<div className="compare-history-head">
|
||||
<h2>{t.compare.history}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
clearCompareHistory();
|
||||
setHistory([]);
|
||||
}}
|
||||
>
|
||||
{t.compare.clearHistory}
|
||||
</button>
|
||||
</div>
|
||||
<div className="compare-history-list">
|
||||
{history.map((h) => (
|
||||
<button
|
||||
key={`${h.at}-${h.slugs.join("-")}`}
|
||||
type="button"
|
||||
className="compare-history-chip"
|
||||
onClick={() => restorePreset(h)}
|
||||
>
|
||||
<span>{h.labels.join(" vs ")}</span>
|
||||
<strong>{t.compare.restore}</strong>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="compare-pick plan-panel">
|
||||
<div className="plan-panel-head">
|
||||
<h2>{t.compare.pick}({picked.length}/4)</h2>
|
||||
<div className="plan-actions">
|
||||
<button type="button" className="btn btn-ghost btn-sm" disabled={picked.length === 0} onClick={() => { setPicked([]); syncUrl([]); }}>{t.compare.clear}</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" disabled={selected.length < 2} onClick={share}>{t.compare.copyLink}</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" disabled={selected.length < 2} onClick={() => void copySnapshot()}>{t.compare.snapshot}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="compare-pick-grid">
|
||||
{destinations.length === 0 ? (
|
||||
<p className="dest-empty">{t.compare.loadingDest}</p>
|
||||
) : (
|
||||
destinations.map((d) => {
|
||||
{destinations.map((d) => {
|
||||
const on = picked.includes(d.slug);
|
||||
return (
|
||||
<button
|
||||
@ -223,8 +133,7 @@ export default function CompareClient({ destinations }: Props) {
|
||||
<small>¥{d.cost.toLocaleString()}</small>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -232,11 +141,7 @@ export default function CompareClient({ destinations }: Props) {
|
||||
<div className="plan-empty compare-page-empty">
|
||||
<span className="plan-empty-icon">⚖️</span>
|
||||
<p>{t.compare.needMore} {Math.max(0, 2 - selected.length)} {t.compare.needMoreSuffix}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/#destinations" className="btn btn-ghost">{t.compare.backDest}</Link>
|
||||
<Link href="/next-stop" className="btn">{t.nav.nextStop}</Link>
|
||||
<Link href="/ai" className="btn btn-ghost">{t.nav.ai}</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@ -302,26 +207,12 @@ export default function CompareClient({ destinations }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="compare-page-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void copySnapshot()}>
|
||||
{t.compare.snapshot}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={addWinner} disabled={winnerIdx < 0}>
|
||||
{t.compare.addWinner}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={addAll}>
|
||||
{t.compare.addAll}
|
||||
</button>
|
||||
<Link href="/plan" className="btn btn-ghost">
|
||||
{t.compare.openPlan}
|
||||
</Link>
|
||||
{winnerIdx >= 0 && selected[winnerIdx] && (
|
||||
<Link
|
||||
href={meetupCityHref(selected[winnerIdx].name)}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
{t.compare.winnerMeetups.replace("{city}", selected[winnerIdx].name)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -337,12 +228,7 @@ export default function CompareClient({ destinations }: Props) {
|
||||
</>
|
||||
)}
|
||||
|
||||
<RingNext
|
||||
steps={rings.afterCompare(
|
||||
picked,
|
||||
winnerIdx >= 0 && selected[winnerIdx] ? selected[winnerIdx].name : undefined
|
||||
)}
|
||||
/>
|
||||
<RingNext steps={rings.afterCity} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -7,154 +7,53 @@ import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
|
||||
|
||||
const DRAFT_KEY = "nomadro-submit-draft";
|
||||
|
||||
type SubmitDraft = { title: string; url: string; notes: string; contentType: string };
|
||||
|
||||
export default function ContentSubmitClient() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [title, setTitle] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [contentType, setContentType] = useState("article");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const draft = loadDraft<SubmitDraft>(DRAFT_KEY);
|
||||
if (draft) {
|
||||
setTitle(draft.title || "");
|
||||
setUrl(draft.url || "");
|
||||
setNotes(draft.notes || "");
|
||||
setContentType(draft.contentType || "article");
|
||||
}
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const id = window.setTimeout(() => {
|
||||
if (!title && !url && !notes) {
|
||||
clearDraft(DRAFT_KEY);
|
||||
return;
|
||||
}
|
||||
saveDraft(DRAFT_KEY, { title, url, notes, contentType });
|
||||
}, 400);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [title, url, notes, contentType, ready]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!token) {
|
||||
router.push("/login?next=/submit");
|
||||
return;
|
||||
}
|
||||
if (!title.trim() || title.trim().length < 4) {
|
||||
toast(t.submit.needTitle, "info");
|
||||
return;
|
||||
}
|
||||
if ((contentType === "article" || contentType === "video") && !/^https?:\/\/.+/.test(url.trim())) {
|
||||
toast(t.submit.needUrl, "info");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.submitContent(
|
||||
{
|
||||
title: title.trim(),
|
||||
url: url.trim(),
|
||||
notes: notes.trim(),
|
||||
content_type: contentType,
|
||||
name: "",
|
||||
},
|
||||
token
|
||||
);
|
||||
clearDraft(DRAFT_KEY);
|
||||
toast(t.submit.ok, "success", {
|
||||
href: "/community",
|
||||
label: t.submit.goCommunity,
|
||||
});
|
||||
await api.submitContent({ title, url, notes, content_type: contentType, name: "" }, token);
|
||||
toast(t.submit.ok, "success");
|
||||
setTitle("");
|
||||
setUrl("");
|
||||
setNotes("");
|
||||
setDone(true);
|
||||
} catch {
|
||||
toast(t.submit.fail, "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="community-page">
|
||||
<div className="container">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/">{t.common.backHome}</Link>
|
||||
</nav>
|
||||
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">{t.submit.tag}</span>
|
||||
<h1>{t.submit.title}</h1>
|
||||
<p>{t.submit.subtitle}</p>
|
||||
{!done && <p className="section-desc">{t.submit.draftHint}</p>}
|
||||
</div>
|
||||
{done ? (
|
||||
<div className="dating-gate reveal">
|
||||
<h2>{t.submit.thanksTitle}</h2>
|
||||
<p>{t.submit.thanksDesc}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => setDone(false)}>
|
||||
{t.submit.writeAgain}
|
||||
</button>
|
||||
<Link href="/community" className="btn">
|
||||
{t.submit.goCommunity}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="community-compose reveal">
|
||||
<select value={contentType} onChange={(e) => setContentType(e.target.value)}>
|
||||
<option value="article">{t.submit.article}</option>
|
||||
<option value="video">{t.submit.video}</option>
|
||||
<option value="ebook">{t.submit.ebook}</option>
|
||||
</select>
|
||||
<input
|
||||
placeholder={t.submit.subject}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={120}
|
||||
/>
|
||||
<input
|
||||
placeholder={t.submit.url}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
maxLength={500}
|
||||
/>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder={t.submit.notes}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void submit()}>
|
||||
{busy ? t.submit.submitting : t.submit.send}
|
||||
</button>
|
||||
{!token && (
|
||||
<p className="section-desc">
|
||||
<Link href="/login?next=/submit">{t.nav.login}</Link> · {t.gigs.loginFirst}
|
||||
</p>
|
||||
)}
|
||||
<input placeholder={t.submit.subject} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<input placeholder={t.submit.url} value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
<textarea rows={4} placeholder={t.submit.notes} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
<button type="button" className="btn btn-primary" onClick={submit}>{t.submit.send}</button>
|
||||
</div>
|
||||
)}
|
||||
<RingNext steps={rings.afterGigs} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,10 +2,8 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export default function CookieConsent() {
|
||||
const { t } = useI18n();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -23,16 +21,15 @@ export default function CookieConsent() {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="cookie-consent" role="dialog" aria-live="polite">
|
||||
<div className="cookie-consent">
|
||||
<div className="cookie-consent-inner">
|
||||
<span className="cookie-emoji">🍪</span>
|
||||
<p>
|
||||
{t.cookies.body}{" "}
|
||||
<Link href="/privacy">{t.cookies.privacy}</Link>
|
||||
{" · "}
|
||||
<Link href="/cookies">{t.cookies.cookies}</Link>
|
||||
我们使用 localStorage 保存主题偏好和行程数据,不会追踪你的浏览行为。
|
||||
<Link href="/privacy">隐私政策</Link>
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={accept}>
|
||||
{t.cookies.ok}
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={accept}>知道了 ✓</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Destination } from "@/lib/types";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||||
import { meetupCityHref } from "@/lib/meetupLinks";
|
||||
|
||||
const HOME_CITIES = [
|
||||
{ name: "上海", cost: 12000, emoji: "🏙️" },
|
||||
@ -21,8 +16,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function CostCompare({ destinations }: Props) {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const [homeIdx, setHomeIdx] = useState(0);
|
||||
const [destSlug, setDestSlug] = useState(destinations[0]?.slug || "");
|
||||
|
||||
@ -41,15 +34,6 @@ export default function CostCompare({ destinations }: Props) {
|
||||
|
||||
const maxCost = Math.max(home.cost, dest.cost);
|
||||
|
||||
const addToPlan = () => {
|
||||
const { added } = mergeDestinationsIntoTrip([dest], 1);
|
||||
toast(
|
||||
added ? `${dest.emoji} ${dest.name} ${t.plan.addedToPlan}` : t.common.alreadyInPlan,
|
||||
added ? "success" : "info",
|
||||
{ href: "/plan", label: t.strip.openPlan }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section cost-compare-section" id="cost-compare">
|
||||
<div className="container">
|
||||
@ -128,17 +112,6 @@ export default function CostCompare({ destinations }: Props) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="dating-empty-actions" style={{ marginTop: "1rem" }}>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={addToPlan}>
|
||||
🗓️ {t.nav.plan}
|
||||
</button>
|
||||
<Link href={`/destinations/${dest.slug}`} className="btn btn-sm">
|
||||
{t.common.detail}
|
||||
</Link>
|
||||
<Link href={meetupCityHref(dest.name)} className="btn btn-sm btn-ghost">
|
||||
{t.common.cityMeetups}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
@ -11,8 +11,16 @@ import type { MatchIntent, MatchProfile, MatchQuota } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
const SWIPE_THRESHOLD = 110;
|
||||
const SUPER_THRESHOLD = -120;
|
||||
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: "🌍" },
|
||||
];
|
||||
|
||||
const PRIMARY_INTENTS = INTENTS.slice(0, 3);
|
||||
|
||||
export default function DatingClient() {
|
||||
const { user, token } = useAuth();
|
||||
@ -26,177 +34,64 @@ export default function DatingClient() {
|
||||
const [quota, setQuota] = useState<MatchQuota | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [joined, setJoined] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [matchModal, setMatchModal] = useState<{ conversationId?: string; name: string } | null>(null);
|
||||
const [drag, setDrag] = useState({ x: 0, y: 0, active: false, flying: "" });
|
||||
const startRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragRef = useRef({ x: 0, y: 0 });
|
||||
const swipingRef = useRef(false);
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const intents = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ key: "friends" as const, label: t.dating.intentFriends, emoji: "🤝" },
|
||||
{ key: "dating" as const, label: t.dating.intentDating, emoji: "💕" },
|
||||
{ key: "partner" as const, label: t.dating.intentPartner, emoji: "💑" },
|
||||
{ key: "roommate" as const, label: t.dating.intentRoommate, emoji: "🏠" },
|
||||
{ key: "cofounder" as const, label: t.dating.intentCofounder, emoji: "🚀" },
|
||||
{ key: "explore" as const, label: t.dating.intentExplore, emoji: "🌍" },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
const primary = intents.slice(0, 3);
|
||||
const quotaBlocked = Boolean(quota && !quota.vip && quota.remaining <= 0);
|
||||
|
||||
const ensureJoined = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
await api.socialJoin(token, {
|
||||
city: t.dating.defaultCity,
|
||||
lookingFor: [intent, "explore"],
|
||||
bio: t.dating.defaultBio,
|
||||
});
|
||||
} catch {
|
||||
// Profile may already exist.
|
||||
}
|
||||
}, [token, intent, t]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await ensureJoined();
|
||||
const [candidates, q] = await Promise.all([
|
||||
api.getMatchCandidates(token, { intent }),
|
||||
api.getMatchQuota(token),
|
||||
]);
|
||||
setDeck(Array.isArray(candidates) ? candidates : []);
|
||||
setDeck(candidates);
|
||||
setQuota(q);
|
||||
setJoined(true);
|
||||
setDrag({ x: 0, y: 0, active: false, flying: "" });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
} catch {
|
||||
setJoined(false);
|
||||
setDeck([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, intent, ensureJoined]);
|
||||
}, [token, intent]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const quickJoin = async () => {
|
||||
if (!token) {
|
||||
router.push("/login?next=/dating");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.socialJoin(token, {
|
||||
city: t.dating.defaultCity,
|
||||
city: "全球",
|
||||
lookingFor: [intent, "explore"],
|
||||
bio: t.dating.defaultBio,
|
||||
});
|
||||
toast(t.dating.joinOk, "success", {
|
||||
href: "/meetups",
|
||||
label: t.dating.meetAtEvents,
|
||||
bio: "nomadro 游民",
|
||||
});
|
||||
toast("资料已完善,开始匹配", "success");
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t.dating.joinFail;
|
||||
setError(msg);
|
||||
toast(t.dating.joinFail, "error");
|
||||
setLoading(false);
|
||||
} catch {
|
||||
toast("加入失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const swipe = useCallback(
|
||||
async (action: "like" | "dislike" | "superlike") => {
|
||||
if (!token || !deck[0] || swipingRef.current || quotaBlocked) return;
|
||||
swipingRef.current = true;
|
||||
const swipe = async (action: "like" | "dislike" | "superlike") => {
|
||||
if (!token || !deck[0]) return;
|
||||
const current = deck[0];
|
||||
const fly = action === "like" ? "right" : action === "dislike" ? "left" : "up";
|
||||
setDrag({ x: 0, y: 0, active: false, flying: fly });
|
||||
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 });
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
setDeck((d) => d.slice(1));
|
||||
setDrag({ x: 0, y: 0, active: false, flying: "" });
|
||||
swipingRef.current = false;
|
||||
}, 220);
|
||||
const q = await api.getMatchQuota(token);
|
||||
setQuota(q);
|
||||
} catch {
|
||||
setDrag({ x: 0, y: 0, active: false, flying: "" });
|
||||
swipingRef.current = false;
|
||||
toast(t.dating.swipeFail, "error");
|
||||
toast("滑动失败,请检查登录或配额", "error");
|
||||
}
|
||||
},
|
||||
[token, deck, intent, quotaBlocked, toast, t]
|
||||
);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (quotaBlocked || swipingRef.current) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("button,a,input")) return;
|
||||
startRef.current = { x: e.clientX, y: e.clientY };
|
||||
dragRef.current = { x: 0, y: 0 };
|
||||
setDrag({ x: 0, y: 0, active: true, flying: "" });
|
||||
cardRef.current?.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!startRef.current || !drag.active) return;
|
||||
const x = e.clientX - startRef.current.x;
|
||||
const y = e.clientY - startRef.current.y;
|
||||
dragRef.current = { x, y };
|
||||
setDrag({ x, y, active: true, flying: "" });
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
if (!startRef.current) return;
|
||||
const { x, y } = dragRef.current;
|
||||
startRef.current = null;
|
||||
if (Math.abs(x) > SWIPE_THRESHOLD) {
|
||||
void swipe(x > 0 ? "like" : "dislike");
|
||||
return;
|
||||
}
|
||||
if (y < SUPER_THRESHOLD) {
|
||||
void swipe("superlike");
|
||||
return;
|
||||
}
|
||||
setDrag({ x: 0, y: 0, active: false, flying: "" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!deck[0] || matchModal || quotaBlocked) return;
|
||||
if (e.key === "ArrowLeft") void swipe("dislike");
|
||||
if (e.key === "ArrowRight") void swipe("like");
|
||||
if (e.key === "ArrowUp") void swipe("superlike");
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [deck, matchModal, quotaBlocked, swipe]);
|
||||
|
||||
const top = deck[0];
|
||||
const next = deck[1];
|
||||
const rotate = drag.x * 0.06;
|
||||
const likeOpacity = Math.min(1, Math.max(0, drag.x / SWIPE_THRESHOLD));
|
||||
const nopeOpacity = Math.min(1, Math.max(0, -drag.x / SWIPE_THRESHOLD));
|
||||
const superOpacity = Math.min(1, Math.max(0, -drag.y / Math.abs(SUPER_THRESHOLD)));
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
@ -205,11 +100,7 @@ export default function DatingClient() {
|
||||
<div className="dating-gate reveal">
|
||||
<h2>{t.dating.loginTitle}</h2>
|
||||
<p>{t.dating.loginDesc}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/login?next=/dating" className="btn btn-primary">{t.nav.login}</Link>
|
||||
<Link href="/meetups" className="btn btn-ghost">{t.dating.meetAtEvents}</Link>
|
||||
<Link href="/community" className="btn btn-ghost">{t.nav.community}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -225,8 +116,6 @@ export default function DatingClient() {
|
||||
<Link href="/chat">{t.nav.chat}</Link>
|
||||
<span> · </span>
|
||||
<Link href="/dating/likes">{t.dating.likesTitle}</Link>
|
||||
<span> · </span>
|
||||
<Link href="/meetups">{t.nav.meetups}</Link>
|
||||
</nav>
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">{t.dating.tag}</span>
|
||||
@ -235,7 +124,7 @@ export default function DatingClient() {
|
||||
</div>
|
||||
|
||||
<div className="dating-intents reveal">
|
||||
{(moreIntents ? intents : primary).map((i) => (
|
||||
{(moreIntents ? INTENTS : PRIMARY_INTENTS).map((i) => (
|
||||
<button
|
||||
key={i.key}
|
||||
type="button"
|
||||
@ -247,171 +136,46 @@ export default function DatingClient() {
|
||||
))}
|
||||
{!moreIntents && (
|
||||
<button type="button" className="filter-btn" onClick={() => setMoreIntents(true)}>
|
||||
{t.dating.moreIntents}
|
||||
更多意图…
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quota && (
|
||||
<p className="dating-quota reveal">
|
||||
{quota.vip
|
||||
? t.dating.vipUnlimited
|
||||
: t.dating.quotaLeft
|
||||
.replace("{remaining}", String(quota.remaining))
|
||||
.replace("{limit}", String(quota.limit))}
|
||||
{!quota.vip && quota.remaining <= 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href="/join">{t.dating.vipUnlock}</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<p className="dest-empty reveal">
|
||||
{joined ? t.dating.loadingCandidates : t.dating.loadingProfile}
|
||||
{quota.vip ? "✨ VIP 无限滑" : `今日剩余 ${quota.remaining}/${quota.limit} 次`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!joined && !loading && (
|
||||
<div className="dating-gate reveal">
|
||||
<p>{t.dating.joinFirst}</p>
|
||||
{error && <p className="dest-empty" style={{ marginTop: "0.5rem" }}>{error}</p>}
|
||||
<div className="dating-empty-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => void quickJoin()}>
|
||||
{t.dating.joinBtn}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void load()}>
|
||||
{t.dating.refresh}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={quickJoin}>{t.dating.joinBtn}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{top && (
|
||||
<>
|
||||
<p className="dating-swipe-hint reveal">{t.dating.swipeHint}</p>
|
||||
<div className="dating-deck reveal">
|
||||
{next && (
|
||||
<div className="dating-card dating-card-next" aria-hidden>
|
||||
<div className="dating-card-photo">{next.photo || "🧑💻"}</div>
|
||||
<h2>{next.name}</h2>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`dating-card dating-card-top${drag.active ? " dragging" : ""}${drag.flying ? ` fly-${drag.flying}` : ""}`}
|
||||
style={
|
||||
drag.flying
|
||||
? undefined
|
||||
: {
|
||||
transform: `translate(${drag.x}px, ${drag.y}px) rotate(${rotate}deg)`,
|
||||
transition: drag.active ? "none" : "transform 0.25s ease",
|
||||
}
|
||||
}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
>
|
||||
<span className="dating-stamp like" style={{ opacity: likeOpacity }}>
|
||||
LIKE
|
||||
</span>
|
||||
<span className="dating-stamp nope" style={{ opacity: nopeOpacity }}>
|
||||
NOPE
|
||||
</span>
|
||||
<span className="dating-stamp super" style={{ opacity: superOpacity }}>
|
||||
SUPER
|
||||
</span>
|
||||
<div className="dating-card 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 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>
|
||||
))}
|
||||
{(top.tags || []).map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/members/${top.userId || top.id}`}
|
||||
className="dating-view-profile"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t.dating.viewProfile} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dating-actions reveal">
|
||||
<button
|
||||
type="button"
|
||||
className="dating-btn pass"
|
||||
onClick={() => void swipe("dislike")}
|
||||
disabled={quotaBlocked}
|
||||
aria-label={t.dating.swipeLeft}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dating-btn super"
|
||||
onClick={() => void swipe("superlike")}
|
||||
disabled={quotaBlocked}
|
||||
aria-label={t.dating.swipeUp}
|
||||
>
|
||||
⭐
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dating-btn like"
|
||||
onClick={() => void swipe("like")}
|
||||
disabled={quotaBlocked}
|
||||
aria-label={t.dating.swipeRight}
|
||||
>
|
||||
♥
|
||||
</button>
|
||||
<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={() => void api.undoSwipe(token).then(() => load())}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" title={t.dating.undo} onClick={() => api.undoSwipe(token).then(() => load())}>↩</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && joined && !top && (
|
||||
<div className="dating-empty reveal">
|
||||
<p className="dest-empty">{t.dating.empty}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={() => void load()}>
|
||||
{t.dating.refresh}
|
||||
</button>
|
||||
<Link href="/meetups" className="btn btn-sm">
|
||||
{t.dating.meetAtEvents}
|
||||
</Link>
|
||||
<Link href="/community" className="btn btn-sm">
|
||||
{t.nav.community}
|
||||
</Link>
|
||||
<Link href="/chat" className="btn btn-sm">
|
||||
{t.dating.viewChats}
|
||||
</Link>
|
||||
{quota && !quota.vip && (
|
||||
<Link href="/join" className="btn btn-primary btn-sm">
|
||||
{t.dating.openVip}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="dest-empty reveal">{t.dating.empty}</p>
|
||||
)}
|
||||
|
||||
<RingNext steps={rings.afterDating} />
|
||||
@ -420,21 +184,12 @@ export default function DatingClient() {
|
||||
{matchModal && (
|
||||
<div className="dating-match-modal" role="dialog">
|
||||
<div className="dating-match-box">
|
||||
<h3>
|
||||
🎉 {t.dating.matched} {matchModal.name}!
|
||||
</h3>
|
||||
<h3>🎉 {t.dating.matched} {matchModal.name}!</h3>
|
||||
<div className="dating-match-actions">
|
||||
<button type="button" className="btn" onClick={() => setMatchModal(null)}>
|
||||
{t.dating.keepSwiping}
|
||||
</button>
|
||||
<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>
|
||||
<Link href={`/chat/${matchModal.conversationId}`} className="btn btn-primary">{t.dating.chatNow}</Link>
|
||||
)}
|
||||
<Link href="/meetups" className="btn btn-ghost" onClick={() => setMatchModal(null)}>
|
||||
{t.dating.meetAtEvents}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -6,49 +6,24 @@ import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { MatchProfile } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
type Tab = "liked" | "received";
|
||||
|
||||
export default function DatingLikesClient() {
|
||||
const { user, token } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [tab, setTab] = useState<Tab>("received");
|
||||
const [liked, setLiked] = useState<MatchProfile[]>([]);
|
||||
const [received, setReceived] = useState<MatchProfile[]>([]);
|
||||
const [loading, setLoading] = useState(Boolean(token));
|
||||
const [likes, setLikes] = useState<MatchProfile[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
api.getMatchLikes(token).catch(() => [] as MatchProfile[]),
|
||||
api.getMatchLikesReceived(token).catch(() => [] as MatchProfile[]),
|
||||
])
|
||||
.then(([mine, incoming]) => {
|
||||
setLiked(mine);
|
||||
setReceived(incoming);
|
||||
if (incoming.length === 0 && mine.length > 0) setTab("liked");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
if (!token) return;
|
||||
api.getMatchLikes(token).then(setLikes).catch(() => setLikes([]));
|
||||
}, [token]);
|
||||
|
||||
const list = tab === "liked" ? liked : received;
|
||||
|
||||
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>
|
||||
<Link href="/login?next=/dating/likes" className="btn btn-primary">{t.nav.login}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -60,79 +35,22 @@ export default function DatingLikesClient() {
|
||||
<div className="container">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/dating">{t.dating.backSwipe}</Link>
|
||||
<span> · </span>
|
||||
<Link href="/chat">{t.nav.chat}</Link>
|
||||
</nav>
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">{t.dating.tag}</span>
|
||||
<h1>{t.dating.likesTitle}</h1>
|
||||
<p>{tab === "liked" ? t.dating.likesSubtitle : t.dating.likesReceivedSubtitle}</p>
|
||||
<p>{t.dating.likesSubtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="dating-intents reveal">
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-btn${tab === "received" ? " active" : ""}`}
|
||||
onClick={() => setTab("received")}
|
||||
>
|
||||
{t.dating.likesReceivedTab}
|
||||
{received.length ? ` (${received.length})` : ""}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-btn${tab === "liked" ? " active" : ""}`}
|
||||
onClick={() => setTab("liked")}
|
||||
>
|
||||
{t.dating.likesMineTab}
|
||||
{liked.length ? ` (${liked.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <p className="dest-empty">{t.common.loading}</p>}
|
||||
{!loading && list.length === 0 && (
|
||||
<div className="notif-empty reveal">
|
||||
<p className="dest-empty">
|
||||
{tab === "liked" ? t.dating.likesEmpty : t.dating.likesReceivedEmpty}
|
||||
</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/dating" className="btn btn-primary btn-sm">
|
||||
{t.dating.goSwipe}
|
||||
</Link>
|
||||
<Link href="/meetups" className="btn btn-sm">
|
||||
{t.dating.meetAtEvents}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="dating-likes-grid">
|
||||
{!loading &&
|
||||
list.map((p) => {
|
||||
const photo = p.photo || "";
|
||||
const href = p.userId
|
||||
? `/members/${p.userId}?from=likes`
|
||||
: p.id
|
||||
? `/members/${p.id}?from=likes`
|
||||
: "/dating";
|
||||
return (
|
||||
<Link key={p.id || p.userId || p.name} href={href} className="dating-like-card reveal">
|
||||
<div className="dating-like-photo">
|
||||
{photo.startsWith("http") ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={photo} alt="" className="member-photo-img" />
|
||||
) : (
|
||||
photo || "🧑💻"
|
||||
)}
|
||||
</div>
|
||||
{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 || t.dating.globalNomad}</p>
|
||||
{tab === "received" && (
|
||||
<span className="meetup-tag">{t.dating.likesYou}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<p className="dating-meta">{p.location}</p>
|
||||
</div>
|
||||
))}
|
||||
{likes.length === 0 && <p className="dest-empty">{t.dating.likesEmpty}</p>}
|
||||
</div>
|
||||
<RingNext steps={rings.afterDating} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -6,13 +6,10 @@ import { useToast } from "@/lib/toast";
|
||||
import { loadTrip } from "@/lib/tripStorage";
|
||||
import { loadPlanMeta, stayHint } from "@/lib/planMeta";
|
||||
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||||
import { trackRecentDestination } from "@/lib/recentDestinations";
|
||||
import { api, type CityContentItem, type CityDetailPayload } from "@/lib/api";
|
||||
import type { Destination, Discussion, Meetup, TripItem } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { meetupCityHref, meetupEventHref } from "@/lib/meetupLinks";
|
||||
|
||||
const TZ_LABELS: Record<string, string> = {
|
||||
bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7",
|
||||
@ -24,9 +21,19 @@ interface Props {
|
||||
allDestinations: Destination[];
|
||||
}
|
||||
|
||||
function getScores(d: Destination) {
|
||||
const nomads = parseInt(d.nomads_count.replace(/[^0-9]/g, ""), 10) || 0;
|
||||
return [
|
||||
{ label: "性价比", value: Math.round((12000 - d.cost) / 120), emoji: "💰" },
|
||||
{ label: "网速", value: Math.round(d.speed / 2), emoji: "📶" },
|
||||
{ label: "气候", value: Math.round(100 - Math.abs(d.temperature - 24) * 4), emoji: "🌡️" },
|
||||
{ label: "评分", value: Math.round(d.rating * 10), emoji: "⭐" },
|
||||
{ label: "社区", value: Math.min(Math.round(nomads / 150), 100), emoji: "🤝" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function DestinationDetailClient({ dest, allDestinations }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [added, setAdded] = useState(false);
|
||||
const [months, setMonths] = useState(1);
|
||||
@ -34,19 +41,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
const [content, setContent] = useState<CityContentItem[]>([]);
|
||||
const [meetups, setMeetups] = useState<Meetup[]>([]);
|
||||
const [discussions, setDiscussions] = useState<Discussion[]>([]);
|
||||
const [depthLoading, setDepthLoading] = useState(true);
|
||||
|
||||
const scores = useMemo(() => {
|
||||
const nomads = parseInt(dest.nomads_count.replace(/[^0-9]/g, ""), 10) || 0;
|
||||
return [
|
||||
{ label: t.destDetail.scoreValue, value: Math.round((12000 - dest.cost) / 120), emoji: "💰" },
|
||||
{ label: t.destDetail.scoreSpeed, value: Math.round(dest.speed / 2), emoji: "📶" },
|
||||
{ label: t.destDetail.scoreClimate, value: Math.round(100 - Math.abs(dest.temperature - 24) * 4), emoji: "🌡️" },
|
||||
{ label: t.destDetail.scoreRating, value: Math.round(dest.rating * 10), emoji: "⭐" },
|
||||
{ label: t.destDetail.scoreCommunity, value: Math.min(Math.round(nomads / 150), 100), emoji: "🤝" },
|
||||
];
|
||||
}, [dest, t]);
|
||||
|
||||
const scores = getScores(dest);
|
||||
const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]);
|
||||
const budget = useMemo(() => loadPlanMeta().monthlyBudget, []);
|
||||
const overBudget = budget > 0 && dest.cost > budget;
|
||||
@ -56,39 +51,18 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
.slice(0, 3);
|
||||
|
||||
useEffect(() => {
|
||||
trackRecentDestination({
|
||||
slug: dest.slug,
|
||||
name: dest.name,
|
||||
country: dest.country,
|
||||
emoji: dest.emoji,
|
||||
cost: dest.cost,
|
||||
rating: dest.rating,
|
||||
});
|
||||
}, [dest.slug, dest.name, dest.country, dest.emoji, dest.cost, dest.rating]);
|
||||
|
||||
useEffect(() => {
|
||||
setDepthLoading(true);
|
||||
api
|
||||
.getDestinationFull(dest.slug)
|
||||
.then((full) => {
|
||||
api.getDestinationFull(dest.slug).then((full) => {
|
||||
setDetail(full.detail);
|
||||
setContent(full.content || []);
|
||||
setMeetups(full.meetups || []);
|
||||
setDiscussions(full.discussions || []);
|
||||
})
|
||||
.catch(() => {
|
||||
setDetail(null);
|
||||
setContent([]);
|
||||
setMeetups([]);
|
||||
setDiscussions([]);
|
||||
})
|
||||
.finally(() => setDepthLoading(false));
|
||||
}).catch(() => {});
|
||||
}, [dest.slug]);
|
||||
|
||||
const addToTrip = () => {
|
||||
const trip = loadTrip<TripItem>();
|
||||
if (trip.some((item) => item.slug === dest.slug)) {
|
||||
toast(t.common.alreadyInTrip, "info", { href: "/plan", label: t.strip.openPlan });
|
||||
if (trip.some((t) => t.slug === dest.slug)) {
|
||||
toast("该城市已在行程中", "info");
|
||||
setAdded(true);
|
||||
return;
|
||||
}
|
||||
@ -96,13 +70,9 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
setAdded(true);
|
||||
toast(
|
||||
n > 0
|
||||
? t.common.addedToPlanMonths
|
||||
.replace("{emoji}", dest.emoji)
|
||||
.replace("{city}", dest.name)
|
||||
.replace("{months}", String(months))
|
||||
: t.common.alreadyInPlan,
|
||||
n > 0 ? "success" : "info",
|
||||
{ href: "/plan", label: t.strip.openPlan }
|
||||
? `${dest.emoji} ${dest.name} 已加入旅居计划(${months} 个月)`
|
||||
: "该城市已在计划中",
|
||||
n > 0 ? "success" : "info"
|
||||
);
|
||||
};
|
||||
|
||||
@ -112,36 +82,13 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
await navigator.share({ title: `${dest.name} - nomadro`, url });
|
||||
} else {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast(t.common.linkCopied);
|
||||
}
|
||||
};
|
||||
|
||||
const copySnapshot = async () => {
|
||||
const url = typeof window !== "undefined" ? window.location.href : `/destinations/${dest.slug}`;
|
||||
const lines = [
|
||||
`${dest.emoji} ${dest.name}, ${dest.country}`,
|
||||
t.destDetail.snapCost
|
||||
.replace("{cost}", dest.cost.toLocaleString())
|
||||
.replace("{speed}", String(dest.speed))
|
||||
.replace("{rating}", String(dest.rating)),
|
||||
t.destDetail.snapClimate
|
||||
.replace("{temp}", String(dest.temperature))
|
||||
.replace("{nomads}", dest.nomads_count),
|
||||
visa ? t.destDetail.snapVisa.replace("{visa}", visa.text) : "",
|
||||
t.destDetail.snapDetail.replace("{url}", url),
|
||||
"— via nomadro",
|
||||
].filter(Boolean);
|
||||
try {
|
||||
await navigator.clipboard.writeText(lines.join("\n"));
|
||||
toast(t.profile.snapshotOk);
|
||||
} catch {
|
||||
toast(t.profile.snapshotOk, "info");
|
||||
toast("链接已复制到剪贴板 📋");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const trip = loadTrip<TripItem>();
|
||||
setAdded(trip.some((item) => item.slug === dest.slug));
|
||||
setAdded(trip.some((t) => t.slug === dest.slug));
|
||||
}, [dest.slug]);
|
||||
|
||||
const costBreak = detail?.cost?.breakdown || [];
|
||||
@ -152,11 +99,11 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
<>
|
||||
<div className="dest-detail-actions">
|
||||
{added ? (
|
||||
<Link href="/plan" className="btn btn-primary">{t.common.inPlanOpen}</Link>
|
||||
<Link href="/plan" className="btn btn-primary">✓ 已在计划中 · 打开 →</Link>
|
||||
) : (
|
||||
<>
|
||||
<label className="dest-months-pick">
|
||||
{t.common.stayLabel}
|
||||
停留
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@ -164,22 +111,15 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
value={months}
|
||||
onChange={(e) => setMonths(Math.max(1, Math.min(24, +e.target.value || 1)))}
|
||||
/>
|
||||
{t.common.monthsUnit}
|
||||
月
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary" onClick={addToTrip}>{t.common.addToPlan}</button>
|
||||
<button className="btn btn-primary" onClick={addToTrip}>🗓️ 加入旅居计划</button>
|
||||
</>
|
||||
)}
|
||||
<Link href={`/compare?cities=${dest.slug}`} className="btn btn-ghost">{t.common.goCompare}</Link>
|
||||
<Link href="/next-stop" className="btn btn-ghost">{t.common.findNext}</Link>
|
||||
<Link href={meetupCityHref(dest.name)} className="btn btn-ghost">{t.common.cityMeetups}</Link>
|
||||
<button type="button" className="btn btn-ghost" onClick={share}>{t.common.shareBtn}</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void copySnapshot()}>
|
||||
{t.profile.snapshotBtn}
|
||||
</button>
|
||||
<Link href="/meetups" className="btn btn-ghost">🍹 同城活动</Link>
|
||||
<button className="btn btn-ghost" onClick={share}>📤 分享</button>
|
||||
</div>
|
||||
|
||||
{depthLoading && <p className="dest-depth-loading">{t.common.loadingDepth}</p>}
|
||||
|
||||
{(visa || overBudget) && (
|
||||
<div className="dest-plan-hints">
|
||||
{visa && (
|
||||
@ -187,9 +127,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
)}
|
||||
{overBudget && (
|
||||
<p className="plan-visa warn">
|
||||
{t.common.cityOverBudget
|
||||
.replace("{cost}", dest.cost.toLocaleString())
|
||||
.replace("{budget}", budget.toLocaleString())}
|
||||
💰 月生活费 ¥{dest.cost.toLocaleString()} 高于你的计划月预算 ¥{budget.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -197,11 +135,11 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
|
||||
{guide?.summary && (
|
||||
<section className="dest-depth-block">
|
||||
<h3>{t.destDetail.guide}</h3>
|
||||
<h3>🧭 落地指南</h3>
|
||||
<p>{guide.summary}</p>
|
||||
{guide.workSetup && <p className="dest-depth-muted">{guide.workSetup}</p>}
|
||||
{!!guide.bestFor?.length && (
|
||||
<p className="dest-depth-tags">{t.destDetail.bestFor}{guide.bestFor.join(" · ")}</p>
|
||||
<p className="dest-depth-tags">适合:{guide.bestFor.join(" · ")}</p>
|
||||
)}
|
||||
{!!guide.arrivalChecklist?.length && (
|
||||
<ul className="dest-depth-list">
|
||||
@ -213,7 +151,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
|
||||
<div className="dest-detail-extras">
|
||||
<div className="dest-radar-card">
|
||||
<h3>{t.destDetail.radar}</h3>
|
||||
<h3>📊 城市画像</h3>
|
||||
<div className="dest-radar-bars">
|
||||
{scores.map((s) => (
|
||||
<div key={s.label} className="dest-radar-row">
|
||||
@ -231,26 +169,24 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
<div className="dest-info-mini">
|
||||
<span>🕐</span>
|
||||
<div>
|
||||
<strong>{t.destDetail.timezone}</strong>
|
||||
<span>{TZ_LABELS[dest.slug] || t.common.viewTimezone}</span>
|
||||
<strong>时区</strong>
|
||||
<span>{TZ_LABELS[dest.slug] || "查看时区看板"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dest-info-mini">
|
||||
<span>👥</span>
|
||||
<div>
|
||||
<strong>{t.destDetail.nomadCommunity}</strong>
|
||||
<span>{detail?.people?.nomadsNow?.toLocaleString() || dest.nomads_count} {t.destDetail.active}</span>
|
||||
<strong>游民社区</strong>
|
||||
<span>{detail?.people?.nomadsNow?.toLocaleString() || dest.nomads_count} 活跃</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dest-info-mini">
|
||||
<span>🌡️</span>
|
||||
<div>
|
||||
<strong>{t.destDetail.climate}</strong>
|
||||
<strong>气候</strong>
|
||||
<span>
|
||||
{detail?.weather?.temperature ?? dest.temperature}°C
|
||||
{detail?.weather?.bestMonths
|
||||
? ` · ${t.destDetail.livable} ${detail.weather.bestMonths.join("/")}`
|
||||
: ""}
|
||||
{detail?.weather?.bestMonths ? ` · 宜居 ${detail.weather.bestMonths.join("/")}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -259,7 +195,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
|
||||
{!!costBreak.length && (
|
||||
<section className="dest-depth-block">
|
||||
<h3>{t.destDetail.costBreak}</h3>
|
||||
<h3>💸 月度成本拆解</h3>
|
||||
<div className="dest-cost-grid">
|
||||
{costBreak.map((row) => (
|
||||
<div key={row.label} className="dest-cost-row">
|
||||
@ -275,11 +211,11 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
{(pros?.pros || pros?.cons) && (
|
||||
<section className="dest-depth-block dest-pros-cons">
|
||||
<div>
|
||||
<h3>{t.destDetail.pros}</h3>
|
||||
<h3>👍 优势</h3>
|
||||
<ul>{(pros?.pros || []).map((p) => <li key={p}>{p}</li>)}</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>{t.destDetail.cons}</h3>
|
||||
<h3>👎 注意</h3>
|
||||
<ul>{(pros?.cons || []).map((p) => <li key={p}>{p}</li>)}</ul>
|
||||
</div>
|
||||
</section>
|
||||
@ -287,7 +223,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
|
||||
{!!detail?.reviews?.items?.length && (
|
||||
<section className="dest-depth-block">
|
||||
<h3>{t.destDetail.reviews} · {detail.reviews.rating}/10</h3>
|
||||
<h3>💬 游民评价 · {detail.reviews.rating}/10</h3>
|
||||
<div className="dest-review-grid">
|
||||
{detail.reviews.items.map((r) => (
|
||||
<blockquote key={`${r.author}-${r.text.slice(0, 12)}`}>
|
||||
@ -301,26 +237,24 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
|
||||
{!!detail?.chat?.channels?.length && (
|
||||
<section className="dest-depth-block">
|
||||
<h3>{t.destDetail.localChat}</h3>
|
||||
<h3>💬 同城社群</h3>
|
||||
<ul className="dest-depth-list">
|
||||
{detail.chat.channels.map((c) => (
|
||||
<li key={c.name}>{c.name} · {c.members} {t.destDetail.peopleUnit} · {c.status}</li>
|
||||
<li key={c.name}>{c.name} · {c.members} 人 · {c.status}</li>
|
||||
))}
|
||||
</ul>
|
||||
{!!detail.chat.latestTopics?.length && (
|
||||
<p className="dest-depth-muted">
|
||||
{t.destDetail.recentTopics}{detail.chat.latestTopics.join(" · ")}
|
||||
</p>
|
||||
<p className="dest-depth-muted">近期话题:{detail.chat.latestTopics.join(" · ")}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!!meetups.length && (
|
||||
<section className="dest-depth-block">
|
||||
<h3>{t.destDetail.relatedMeetups}</h3>
|
||||
<h3>🍹 相关活动</h3>
|
||||
<div className="dest-related-grid">
|
||||
{meetups.slice(0, 4).map((m) => (
|
||||
<Link key={m.id} href={meetupEventHref(m)} className="dest-related-card">
|
||||
<Link key={m.id} href="/meetups" className="dest-related-card">
|
||||
<span>{m.emoji || "🎉"}</span>
|
||||
<div>
|
||||
<strong>{m.title}</strong>
|
||||
@ -334,7 +268,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
|
||||
{!!discussions.length && (
|
||||
<section className="dest-depth-block">
|
||||
<h3>{t.destDetail.relatedDiscussions}</h3>
|
||||
<h3>🧵 相关讨论</h3>
|
||||
<ul className="dest-depth-list">
|
||||
{discussions.slice(0, 5).map((d) => (
|
||||
<li key={d.id}>
|
||||
@ -347,14 +281,14 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
|
||||
{!!content.length && (
|
||||
<section className="dest-depth-block">
|
||||
<h3>{t.destDetail.relatedContent}</h3>
|
||||
<h3>📚 相关内容</h3>
|
||||
<div className="dest-related-grid">
|
||||
{content.map((c) => (
|
||||
<Link key={c.slug} href={c.targetUrl || "/digital"} className="dest-related-card">
|
||||
<span>📄</span>
|
||||
<div>
|
||||
<strong>{c.title}</strong>
|
||||
<span>{c.subtitle || c.ctaLabel || t.destDetail.viewFallback}</span>
|
||||
<span>{c.subtitle || c.ctaLabel || "查看"}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
@ -364,14 +298,14 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
|
||||
{related.length > 0 && (
|
||||
<div className="dest-related">
|
||||
<h3>{t.destDetail.relatedRegion}</h3>
|
||||
<h3>🔗 同区域推荐</h3>
|
||||
<div className="dest-related-grid">
|
||||
{related.map((d) => (
|
||||
<Link key={d.slug} href={`/destinations/${d.slug}`} className="dest-related-card">
|
||||
<span>{d.emoji}</span>
|
||||
<div>
|
||||
<strong>{d.name}</strong>
|
||||
<span>¥{d.cost.toLocaleString()}{t.destDetail.perMonth} · ⭐ {d.rating}</span>
|
||||
<span>¥{d.cost.toLocaleString()}/月 · ⭐ {d.rating}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
@ -379,7 +313,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RingNext steps={rings.afterCity(dest.name)} />
|
||||
<RingNext steps={rings.afterCity} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { compareUrl } from "@/lib/compareScore";
|
||||
import {
|
||||
EMPTY_MATCHER_PREFS,
|
||||
@ -27,7 +26,38 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const STEP_KEYS = ["budget", "climate", "priority", "region"] as const;
|
||||
const STEPS = [
|
||||
{ title: "你的月预算?", subtitle: "包含住宿、餐饮、交通等日常开销", key: "budget" as const },
|
||||
{ title: "偏好什么气候?", subtitle: "选择最让你舒适的环境", key: "climate" as const },
|
||||
{ title: "最看重什么?", subtitle: "我们会据此为你智能排序", key: "priority" as const },
|
||||
{ title: "想去哪个区域?", subtitle: "可以选「不限」探索全球", key: "region" as const },
|
||||
];
|
||||
|
||||
const OPTIONS = {
|
||||
budget: [
|
||||
{ value: "low" as MatcherBudget, emoji: "💰", label: "精打细算", desc: "¥5,000 以下/月" },
|
||||
{ value: "medium" as MatcherBudget, emoji: "💳", label: "舒适适中", desc: "¥5,000 – 9,000/月" },
|
||||
{ value: "high" as MatcherBudget, emoji: "💎", label: "品质优先", desc: "¥9,000 以上/月" },
|
||||
],
|
||||
climate: [
|
||||
{ value: "warm" as MatcherClimate, emoji: "☀️", label: "热带温暖", desc: "25°C 以上,阳光沙滩" },
|
||||
{ value: "mild" as MatcherClimate, emoji: "🌤️", label: "温和宜人", desc: "18–25°C,四季舒适" },
|
||||
{ value: "cool" as MatcherClimate, emoji: "🍂", label: "凉爽清爽", desc: "18°C 以下,清爽干燥" },
|
||||
],
|
||||
priority: [
|
||||
{ value: "cost" as MatcherPriority, emoji: "💰", label: "生活成本", desc: "花最少的钱过最好的生活" },
|
||||
{ value: "speed" as MatcherPriority, emoji: "📶", label: "网络速度", desc: "稳定高速,会议不掉线" },
|
||||
{ value: "community" as MatcherPriority, emoji: "🤝", label: "游民社区", desc: "结识同行,快速融入" },
|
||||
{ value: "lifestyle" as MatcherPriority, emoji: "🎨", label: "生活方式", desc: "文化、美食与体验" },
|
||||
],
|
||||
region: [
|
||||
{ value: "any" as MatcherRegion, emoji: "🌏", label: "不限", desc: "全球探索" },
|
||||
{ value: "sea" as MatcherRegion, emoji: "🌴", label: "东南亚", desc: "性价比之王" },
|
||||
{ value: "europe" as MatcherRegion, emoji: "🏰", label: "欧洲", desc: "历史与签证友好" },
|
||||
{ value: "latam" as MatcherRegion, emoji: "🌮", label: "拉美", desc: "活力与北美时区" },
|
||||
{ value: "asia" as MatcherRegion, emoji: "🏯", label: "东亚", desc: "安全高效现代" },
|
||||
],
|
||||
};
|
||||
|
||||
function parseNomads(n: string): number {
|
||||
return parseInt(n.replace(/[^0-9]/g, ""), 10) || 0;
|
||||
@ -62,75 +92,23 @@ function scoreDestination(
|
||||
export default function DestinationMatcher({ destinations, open, onClose }: Props) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [step, setStep] = useState(0);
|
||||
const [prefs, setPrefs] = useState<MatcherPrefs>(EMPTY_MATCHER_PREFS);
|
||||
const [direction, setDirection] = useState<"forward" | "back">("forward");
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
const steps = useMemo(
|
||||
() => [
|
||||
{ title: t.matcher.budgetTitle, subtitle: t.matcher.budgetSub, key: "budget" as const },
|
||||
{ title: t.matcher.climateTitle, subtitle: t.matcher.climateSub, key: "climate" as const },
|
||||
{ title: t.matcher.priorityTitle, subtitle: t.matcher.prioritySub, key: "priority" as const },
|
||||
{ title: t.matcher.regionTitle, subtitle: t.matcher.regionSub, key: "region" as const },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
budget: [
|
||||
{ value: "low" as MatcherBudget, emoji: "💰", label: t.matcher.budgetLow, desc: t.matcher.budgetLowDesc },
|
||||
{ value: "medium" as MatcherBudget, emoji: "💳", label: t.matcher.budgetMed, desc: t.matcher.budgetMedDesc },
|
||||
{ value: "high" as MatcherBudget, emoji: "💎", label: t.matcher.budgetHigh, desc: t.matcher.budgetHighDesc },
|
||||
],
|
||||
climate: [
|
||||
{ value: "warm" as MatcherClimate, emoji: "☀️", label: t.matcher.climateWarm, desc: t.matcher.climateWarmDesc },
|
||||
{ value: "mild" as MatcherClimate, emoji: "🌤️", label: t.matcher.climateMild, desc: t.matcher.climateMildDesc },
|
||||
{ value: "cool" as MatcherClimate, emoji: "🍂", label: t.matcher.climateCool, desc: t.matcher.climateCoolDesc },
|
||||
],
|
||||
priority: [
|
||||
{ value: "cost" as MatcherPriority, emoji: "💰", label: t.matcher.priCost, desc: t.matcher.priCostDesc },
|
||||
{ value: "speed" as MatcherPriority, emoji: "📶", label: t.matcher.priSpeed, desc: t.matcher.priSpeedDesc },
|
||||
{ value: "community" as MatcherPriority, emoji: "🤝", label: t.matcher.priCommunity, desc: t.matcher.priCommunityDesc },
|
||||
{ value: "lifestyle" as MatcherPriority, emoji: "🎨", label: t.matcher.priLifestyle, desc: t.matcher.priLifestyleDesc },
|
||||
],
|
||||
region: [
|
||||
{ value: "any" as MatcherRegion, emoji: "🌏", label: t.matcher.regionAny, desc: t.matcher.regionAnyDesc },
|
||||
{ value: "sea" as MatcherRegion, emoji: "🌴", label: t.matcher.regionSea, desc: t.matcher.regionSeaDesc },
|
||||
{ value: "europe" as MatcherRegion, emoji: "🏰", label: t.matcher.regionEurope, desc: t.matcher.regionEuropeDesc },
|
||||
{ value: "latam" as MatcherRegion, emoji: "🌮", label: t.matcher.regionLatam, desc: t.matcher.regionLatamDesc },
|
||||
{ value: "asia" as MatcherRegion, emoji: "🏯", label: t.matcher.regionAsia, desc: t.matcher.regionAsiaDesc },
|
||||
],
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setHydrated(false);
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
const saved = loadMatcherPrefs();
|
||||
setPrefs(saved);
|
||||
if (matcherPrefsComplete(saved)) setStep(STEP_KEYS.length);
|
||||
if (matcherPrefsComplete(saved)) setStep(STEPS.length);
|
||||
else {
|
||||
const filled = STEP_KEYS.findIndex((k) => !saved[k]);
|
||||
const filled = STEPS.findIndex((s) => !saved[s.key]);
|
||||
setStep(filled === -1 ? 0 : filled);
|
||||
}
|
||||
setHydrated(true);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!matcherPrefsComplete(prefs)) return [];
|
||||
return [...destinations]
|
||||
@ -140,9 +118,9 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
|
||||
|
||||
const top3 = results.slice(0, 3).map((r) => r.dest);
|
||||
|
||||
const currentKey = steps[step]?.key;
|
||||
const isComplete = step >= STEP_KEYS.length;
|
||||
const progress = isComplete ? 100 : ((step + 1) / STEP_KEYS.length) * 100;
|
||||
const currentKey = STEPS[step]?.key;
|
||||
const isComplete = step >= STEPS.length;
|
||||
const progress = isComplete ? 100 : ((step + 1) / STEPS.length) * 100;
|
||||
|
||||
const select = (value: string) => {
|
||||
if (!currentKey) return;
|
||||
@ -174,19 +152,11 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
|
||||
const { added, skipped } = mergeDestinationsIntoTrip(top3, 1);
|
||||
onClose();
|
||||
if (added === 0) {
|
||||
toast(skipped ? t.matcher.alreadyAll : t.matcher.addFail, "info", {
|
||||
href: "/plan",
|
||||
label: t.strip.openPlan,
|
||||
});
|
||||
toast(skipped ? "这几座城已在计划中" : "未能加入计划", "info");
|
||||
router.push("/plan");
|
||||
return;
|
||||
}
|
||||
toast(
|
||||
t.matcher.addedTop.replace("{n}", String(added)) +
|
||||
(skipped ? t.matcher.skipped.replace("{n}", String(skipped)) : ""),
|
||||
"success",
|
||||
{ href: "/plan", label: t.strip.openPlan }
|
||||
);
|
||||
toast(`已将 Top ${added} 城写入旅居计划${skipped ? `(跳过 ${skipped} 座已有)` : ""}`);
|
||||
router.push("/plan");
|
||||
};
|
||||
|
||||
@ -194,52 +164,30 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const { added } = mergeDestinationsIntoTrip([dest], 1);
|
||||
toast(
|
||||
added ? `${dest.emoji} ${dest.name} ${t.plan.addedToPlan}` : t.common.alreadyInPlan,
|
||||
added ? "success" : "info",
|
||||
{ href: "/plan", label: t.strip.openPlan }
|
||||
);
|
||||
toast(added ? `${dest.emoji} ${dest.name} 已加入计划` : "该城已在计划中", added ? "success" : "info");
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
if (!open || !hydrated) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay open matcher-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t.matcher.tag.replace("{n}", "1").replace("{total}", String(STEP_KEYS.length))}
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
{!hydrated ? (
|
||||
<div className="modal-overlay open matcher-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal matcher-modal">
|
||||
<button className="modal-close" onClick={onClose} aria-label={t.matcher.close}>
|
||||
✕
|
||||
</button>
|
||||
<p className="dest-empty">{t.common.loading}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="modal matcher-modal">
|
||||
<button className="modal-close" onClick={onClose} aria-label={t.matcher.close}>✕</button>
|
||||
<button className="modal-close" onClick={onClose} aria-label="关闭">✕</button>
|
||||
|
||||
<div className="matcher-progress">
|
||||
<div className="matcher-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="matcher-body">
|
||||
{!isComplete && currentKey ? (
|
||||
{!isComplete ? (
|
||||
<div className={`matcher-step matcher-${direction}`} key={step}>
|
||||
<div className="matcher-step-meta">
|
||||
<span className="section-tag">
|
||||
{t.matcher.tag
|
||||
.replace("{n}", String(step + 1))
|
||||
.replace("{total}", String(STEP_KEYS.length))}
|
||||
</span>
|
||||
<h2>{steps[step].title}</h2>
|
||||
<p>{steps[step].subtitle}</p>
|
||||
<span className="section-tag">🎯 MATCHER · {step + 1}/{STEPS.length}</span>
|
||||
<h2>{STEPS[step].title}</h2>
|
||||
<p>{STEPS[step].subtitle}</p>
|
||||
</div>
|
||||
<div className="matcher-options">
|
||||
{options[currentKey].map((opt) => (
|
||||
{OPTIONS[currentKey].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`matcher-option${prefs[currentKey] === opt.value ? " selected" : ""}`}
|
||||
@ -257,32 +205,18 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
|
||||
))}
|
||||
</div>
|
||||
<button className="matcher-back" onClick={goBack}>
|
||||
{step === 0 ? t.matcher.cancel : t.matcher.back}
|
||||
{step === 0 ? "取消" : "← 上一步"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="matcher-results matcher-forward">
|
||||
<div className="matcher-step-meta">
|
||||
<span className="section-tag">{t.matcher.resultsTag}</span>
|
||||
<h2>{t.matcher.resultsTitle}</h2>
|
||||
<p>{t.matcher.resultsSub}</p>
|
||||
<span className="section-tag">✨ YOUR MATCHES</span>
|
||||
<h2>为你推荐的目的地</h2>
|
||||
<p>偏好已保存。可一键写入旅居计划,或先对比再决定。</p>
|
||||
</div>
|
||||
{results.length === 0 ? (
|
||||
<div className="notif-empty">
|
||||
<p className="dest-empty">{t.matcher.empty}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={reset}>
|
||||
{t.matcher.retry}
|
||||
</button>
|
||||
<Link href="/next-stop" className="btn btn-primary" onClick={onClose}>
|
||||
{t.matcher.goNextStop}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="matcher-result-list">
|
||||
{results.slice(0, 8).map(({ dest, score }, i) => (
|
||||
{results.map(({ dest, score }, i) => (
|
||||
<div key={dest.slug} className={`matcher-result-card${i === 0 ? " top" : ""}`}>
|
||||
<Link href={`/destinations/${dest.slug}`} className="matcher-result-main" onClick={onClose}>
|
||||
<div className="matcher-result-rank">
|
||||
@ -297,12 +231,7 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
|
||||
<div className="matcher-score-bar">
|
||||
<div className="matcher-score-fill" style={{ width: `${Math.min((score / (results[0]?.score || 1)) * 100, 100)}%` }} />
|
||||
</div>
|
||||
<span>
|
||||
{t.matcher.matchPct.replace(
|
||||
"{n}",
|
||||
String(Math.min(Math.round((score / (results[0]?.score || 1)) * 100), 100))
|
||||
)}
|
||||
</span>
|
||||
<span>{Math.min(Math.round((score / (results[0]?.score || 1)) * 100), 100)}% 匹配</span>
|
||||
</div>
|
||||
<div className="matcher-result-tags">
|
||||
<span>💰 ¥{dest.cost.toLocaleString()}</span>
|
||||
@ -311,33 +240,30 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
|
||||
</div>
|
||||
</Link>
|
||||
<button type="button" className="matcher-add-btn" onClick={(e) => addOne(dest, e)}>
|
||||
{t.matcher.addPlan}
|
||||
+ 计划
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="matcher-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={reset}>{t.matcher.retryBtn}</button>
|
||||
<button className="btn btn-ghost" onClick={reset}>🔄 重新测试</button>
|
||||
{top3.length >= 2 && (
|
||||
<Link
|
||||
href={compareUrl(top3.map((d) => d.slug))}
|
||||
className="btn btn-ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t.matcher.compareTop.replace("{n}", String(top3.length))}
|
||||
⚖️ 对比 Top {top3.length}
|
||||
</Link>
|
||||
)}
|
||||
<button type="button" className="btn btn-primary" onClick={buildPlanFromTop} disabled={top3.length === 0}>
|
||||
{t.matcher.writeTop.replace("{n}", String(Math.min(3, top3.length)))}
|
||||
<button className="btn btn-primary" onClick={buildPlanFromTop} disabled={top3.length === 0}>
|
||||
把 Top {Math.min(3, top3.length)} 写入计划 →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -113,30 +113,7 @@ export default function Destinations({ destinations, onOpenMatcher }: Props) {
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="notif-empty reveal">
|
||||
<p className="dest-empty">{t.home.destEmpty}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setFilter("all");
|
||||
setSearch("");
|
||||
setExpanded(false);
|
||||
}}
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
{onOpenMatcher && (
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={onOpenMatcher}>
|
||||
🎯 {t.home.destMatcher}
|
||||
</button>
|
||||
)}
|
||||
<Link href="/next-stop" className="btn btn-sm">
|
||||
智能下一站
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canExpand && (
|
||||
|
||||
@ -1,27 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { api } from "@/lib/api";
|
||||
import type { DigitalCourse } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
export default function DigitalCourseClient({ course }: { course: DigitalCourse }) {
|
||||
const { t } = useI18n();
|
||||
const { token } = useAuth();
|
||||
const rings = useRingSteps();
|
||||
const [vip, setVip] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setVip(false);
|
||||
return;
|
||||
}
|
||||
api.getVipStatus(token).then((s) => setVip(Boolean(s.vip))).catch(() => setVip(false));
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="digital-page">
|
||||
@ -32,58 +16,27 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">{t.digital.courseTag}</span>
|
||||
<h1>{t.digital.course}</h1>
|
||||
{vip ? (
|
||||
<p className="join-vip-badge">{t.digital.vipUnlocked}</p>
|
||||
) : (
|
||||
<p className="section-desc">
|
||||
{t.digital.vipHint} · <Link href="/join">{t.nav.join}</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{course.modules.length === 0 ? (
|
||||
<div className="notif-empty reveal">
|
||||
<p className="dest-empty">{t.digital.courseEmpty}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/digital" className="btn">{t.digital.back}</Link>
|
||||
<Link href="/gigs" className="btn btn-ghost">{t.nav.gigs}</Link>
|
||||
<Link href="/join" className="btn btn-primary">{t.nav.join}</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="digital-modules reveal">
|
||||
{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) => {
|
||||
const locked = !lesson.free && !vip;
|
||||
return (
|
||||
{mod.lessons.map((lesson, li) => (
|
||||
<li key={lesson.title}>
|
||||
{locked ? (
|
||||
<Link href="/join" className="digital-lesson-locked">
|
||||
<span>🔒 {lesson.title}</span>
|
||||
<span className="digital-lesson-meta">
|
||||
{lesson.duration} · VIP · {t.digital.unlockVip}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={`/digital/course/${mi}/${li}`}>
|
||||
<span>{lesson.title}</span>
|
||||
<span className="digital-lesson-meta">
|
||||
{lesson.duration}
|
||||
{lesson.free ? ` · ${t.digital.free}` : " · VIP"}
|
||||
{lesson.free ? ` · ${t.digital.free}` : ` · VIP`}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<RingNext steps={rings.afterGigs} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
const MAX_DAY = 7;
|
||||
|
||||
export default function DigitalDayClient({
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const n = Number(id);
|
||||
const prev = Number.isFinite(n) && n > 1 ? String(n - 1) : null;
|
||||
const next = Number.isFinite(n) && n < MAX_DAY ? String(n + 1) : null;
|
||||
|
||||
return (
|
||||
<div className="digital-page">
|
||||
<div className="container" style={{ maxWidth: 720 }}>
|
||||
<nav className="detail-nav">
|
||||
<Link href="/digital">← {t.digital.back}</Link>
|
||||
</nav>
|
||||
<article className="digital-lesson reveal">
|
||||
<span className="section-tag">📅 DAY {id}</span>
|
||||
<h1>{title}</h1>
|
||||
<div className="digital-lesson-body">
|
||||
{body
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((p, i) => (
|
||||
<p key={`${i}-${p.slice(0, 12)}`}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
<footer className="digital-lesson-nav">
|
||||
{prev && (
|
||||
<Link href={`/digital/day/${prev}`} className="btn">
|
||||
← {t.digital.prev}
|
||||
</Link>
|
||||
)}
|
||||
{next ? (
|
||||
<Link href={`/digital/day/${next}`} className="btn btn-primary">
|
||||
{t.digital.next} →
|
||||
</Link>
|
||||
) : (
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/digital/course" className="btn btn-primary">
|
||||
{t.digital.backCatalog}
|
||||
</Link>
|
||||
<Link href="/meetups" className="btn btn-ghost">
|
||||
{t.nav.meetups}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{next && (
|
||||
<Link href="/digital/course" className="btn btn-ghost">
|
||||
{t.digital.backCourse}
|
||||
</Link>
|
||||
)}
|
||||
</footer>
|
||||
</article>
|
||||
<RingNext steps={rings.afterDigital} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,11 +4,9 @@ import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { DigitalCourse } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
export default function DigitalHomeClient({ course }: { course: DigitalCourse }) {
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const lessonCount = course.modules.reduce((n, m) => n + m.lessons.length, 0);
|
||||
|
||||
return (
|
||||
@ -45,14 +43,14 @@ export default function DigitalHomeClient({ course }: { course: DigitalCourse })
|
||||
<h3>{t.digital.vip}</h3>
|
||||
<p>{t.digital.vipDesc}</p>
|
||||
</Link>
|
||||
<Link href="/digital/day/1" className="digital-card">
|
||||
<span className="digital-card-emoji">📅</span>
|
||||
<h3>{t.digital.dayGuide}</h3>
|
||||
<p>{t.digital.dayGuideDesc}</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<RingNext steps={rings.afterDigital} />
|
||||
<RingNext
|
||||
steps={[
|
||||
{ href: "/gigs", emoji: "💼", label: t.nav.gigs, desc: t.ring.communityWork },
|
||||
{ href: "/community", emoji: "💬", label: t.ring.community, desc: t.ring.communityDesc },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,70 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import type { DigitalJob } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
import {
|
||||
clearSavedJobs,
|
||||
loadSavedJobs,
|
||||
toggleSavedJob,
|
||||
type SavedJob,
|
||||
} from "@/lib/savedJobs";
|
||||
|
||||
export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const rings = useRingSteps();
|
||||
const [saved, setSaved] = useState<SavedJob[]>([]);
|
||||
const [q, setQ] = useState("");
|
||||
const [tag, setTag] = useState("all");
|
||||
|
||||
useEffect(() => {
|
||||
setSaved(loadSavedJobs());
|
||||
}, []);
|
||||
|
||||
const tags = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
jobs.forEach((j) => j.tags.forEach((x) => set.add(x)));
|
||||
return Array.from(set).slice(0, 8);
|
||||
}, [jobs]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
return jobs.filter((j) => {
|
||||
if (tag !== "all" && !j.tags.includes(tag)) return false;
|
||||
if (!s) return true;
|
||||
return (
|
||||
j.title.toLowerCase().includes(s) ||
|
||||
j.company.toLowerCase().includes(s) ||
|
||||
j.location.toLowerCase().includes(s) ||
|
||||
j.tags.some((x) => x.toLowerCase().includes(s))
|
||||
);
|
||||
});
|
||||
}, [jobs, q, tag]);
|
||||
|
||||
const onToggle = (job: DigitalJob) => {
|
||||
const next = toggleSavedJob({
|
||||
id: job.id,
|
||||
title: job.title,
|
||||
company: job.company,
|
||||
location: job.location,
|
||||
salary: job.salary,
|
||||
url: job.url,
|
||||
});
|
||||
const now = next.some((x) => x.id === job.id);
|
||||
setSaved(next);
|
||||
toast(
|
||||
now ? t.digital.jobsSaved : t.digital.jobsUnsaved,
|
||||
"success",
|
||||
now
|
||||
? { href: "/digital/jobs", label: t.digital.jobsSavedList }
|
||||
: { href: "/gigs", label: t.nav.gigs }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="digital-page">
|
||||
@ -76,121 +17,23 @@ export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
|
||||
<span className="section-tag">{t.digital.jobsTag}</span>
|
||||
<h1>{t.digital.jobs}</h1>
|
||||
<p>{t.digital.jobsSubtitle}</p>
|
||||
<Link href="/gigs" className="btn btn-sm">{t.nav.gigs}</Link>
|
||||
</div>
|
||||
|
||||
{saved.length > 0 && (
|
||||
<section className="saved-jobs-panel reveal">
|
||||
<div className="saved-jobs-head">
|
||||
<h2>
|
||||
{t.digital.jobsSavedList} · {saved.length}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
clearSavedJobs();
|
||||
setSaved([]);
|
||||
}}
|
||||
>
|
||||
{t.digital.jobsClearSaved}
|
||||
</button>
|
||||
</div>
|
||||
<div className="saved-jobs-list">
|
||||
{saved.map((j) => (
|
||||
<a key={j.id} href={j.url} target="_blank" rel="noopener noreferrer" className="saved-job-chip">
|
||||
<strong>{j.title}</strong>
|
||||
<small>
|
||||
{j.company} · {j.location}
|
||||
</small>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{jobs.length > 0 && (
|
||||
<div className="jobs-toolbar reveal">
|
||||
<input
|
||||
className="tools-hub-search"
|
||||
placeholder={t.digital.jobsSearch}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<div className="tools-hub-filters">
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-btn${tag === "all" ? " active" : ""}`}
|
||||
onClick={() => setTag("all")}
|
||||
>
|
||||
{t.digital.jobsFilterAll}
|
||||
</button>
|
||||
{tags.map((x) => (
|
||||
<button
|
||||
key={x}
|
||||
type="button"
|
||||
className={`filter-btn${tag === x ? " active" : ""}`}
|
||||
onClick={() => setTag(x)}
|
||||
>
|
||||
{x}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="notif-empty reveal">
|
||||
<p className="dest-empty">{t.digital.jobsEmpty}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/gigs" className="btn btn-primary btn-sm">{t.digital.jobsGoGigs}</Link>
|
||||
<Link href="/community" className="btn btn-sm">{t.digital.jobsGoCommunity}</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="notif-empty reveal">
|
||||
<p className="dest-empty">{t.digital.jobsEmpty}</p>
|
||||
<button type="button" className="btn btn-sm" onClick={() => { setQ(""); setTag("all"); }}>
|
||||
{t.common.clear}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="digital-jobs-grid reveal">
|
||||
{filtered.map((job) => {
|
||||
const isSaved = saved.some((s) => s.id === job.id);
|
||||
return (
|
||||
{jobs.map((job) => (
|
||||
<article key={job.id} className="digital-job-card">
|
||||
<div className="gig-card-top">
|
||||
<h3>{job.title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm${isSaved ? " btn-primary" : " btn-ghost"}`}
|
||||
onClick={() => onToggle(job)}
|
||||
>
|
||||
{isSaved ? `★ ${t.digital.jobsSaved}` : `☆ ${t.digital.jobsSave}`}
|
||||
</button>
|
||||
</div>
|
||||
<p className="digital-job-company">{job.company} · {job.location}</p>
|
||||
<p className="digital-job-meta">{job.type} · {job.salary}</p>
|
||||
<div className="meetup-tags">
|
||||
{job.tags.map((item) => (
|
||||
<span key={item} className="meetup-tag">{item}</span>
|
||||
))}
|
||||
{job.tags.map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)}
|
||||
</div>
|
||||
<a
|
||||
href={job.url.startsWith("http") ? job.url : job.url || "/gigs"}
|
||||
target={job.url.startsWith("http") ? "_blank" : undefined}
|
||||
rel={job.url.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
{job.url.startsWith("http") ? t.digital.apply : t.digital.jobsGoGigs}
|
||||
<a href={job.url} target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm">
|
||||
{t.digital.apply}
|
||||
</a>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<RingNext steps={rings.afterGigs} />
|
||||
{jobs.length === 0 && <p className="dest-empty">{t.digital.jobsEmpty}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,123 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { api } from "@/lib/api";
|
||||
import type { DigitalCourse, DigitalLesson } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
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();
|
||||
const { token } = useAuth();
|
||||
const rings = useRingSteps();
|
||||
const [lesson, setLesson] = useState<DigitalLesson | null>(null);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [missing, setMissing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasNext, setHasNext] = useState(false);
|
||||
const [nextHref, setNextHref] = useState<string | null>(null);
|
||||
const [prevHref, setPrevHref] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const course: DigitalCourse = await api.getDigitalCourse();
|
||||
const modules = course.modules || [];
|
||||
const mod = modules[moduleIndex];
|
||||
const meta = mod?.lessons?.[lessonIndex];
|
||||
if (!meta) {
|
||||
if (!cancelled) {
|
||||
setMissing(true);
|
||||
setLocked(false);
|
||||
setLesson(null);
|
||||
setHasNext(false);
|
||||
setNextHref(null);
|
||||
setPrevHref(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let next: string | null = null;
|
||||
if (mod.lessons[lessonIndex + 1]) {
|
||||
next = `/digital/course/${moduleIndex}/${lessonIndex + 1}`;
|
||||
} else if (modules[moduleIndex + 1]?.lessons?.length) {
|
||||
next = `/digital/course/${moduleIndex + 1}/0`;
|
||||
}
|
||||
let prev: string | null = null;
|
||||
if (lessonIndex > 0) {
|
||||
prev = `/digital/course/${moduleIndex}/${lessonIndex - 1}`;
|
||||
} else if (moduleIndex > 0) {
|
||||
const prevMod = modules[moduleIndex - 1];
|
||||
const last = (prevMod?.lessons?.length || 1) - 1;
|
||||
if (prevMod?.lessons?.length) {
|
||||
prev = `/digital/course/${moduleIndex - 1}/${last}`;
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setHasNext(Boolean(next));
|
||||
setNextHref(next);
|
||||
setPrevHref(prev);
|
||||
}
|
||||
try {
|
||||
const data = await api.getDigitalLesson(moduleIndex, lessonIndex, token || undefined);
|
||||
if (!cancelled) {
|
||||
setLesson(data);
|
||||
setLocked(false);
|
||||
setMissing(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLesson(null);
|
||||
setLocked(!meta.free);
|
||||
setMissing(meta.free);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setMissing(true);
|
||||
setLesson(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [moduleIndex, lessonIndex, token]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="digital-page">
|
||||
<div className="container">
|
||||
<p className="dest-empty">{t.digital.loadingLesson}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (missing) {
|
||||
return (
|
||||
<div className="digital-page">
|
||||
<div className="container">
|
||||
<div className="digital-gate reveal">
|
||||
<h2>{t.digital.missingLesson}</h2>
|
||||
<Link href="/digital/course" className="btn btn-primary">{t.digital.backCourse}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (locked || !lesson) {
|
||||
return (
|
||||
@ -126,24 +24,16 @@ export default function DigitalLessonClient({
|
||||
<div className="digital-gate reveal">
|
||||
<h2>{t.digital.locked}</h2>
|
||||
<p>{t.digital.lockedDesc}</p>
|
||||
<div className="dating-empty-actions">
|
||||
{!token && (
|
||||
<Link href={`/login?next=/digital/course/${moduleIndex}/${lessonIndex}`} className="btn">
|
||||
{t.nav.login}
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link>
|
||||
<Link href="/digital/course" className="btn">{t.digital.backCourse}</Link>
|
||||
</div>
|
||||
</div>
|
||||
<RingNext steps={rings.afterGigs} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const prev = prevHref;
|
||||
const next = hasNext ? nextHref : null;
|
||||
const prev = lessonIndex > 0 ? `/digital/course/${moduleIndex}/${lessonIndex - 1}` : null;
|
||||
const next = `/digital/course/${moduleIndex}/${lessonIndex + 1}`;
|
||||
|
||||
return (
|
||||
<div className="digital-page">
|
||||
@ -155,36 +45,15 @@ export default function DigitalLessonClient({
|
||||
<span className="section-tag">{lesson.duration}</span>
|
||||
<h1>{lesson.title}</h1>
|
||||
<div className="digital-lesson-body">
|
||||
{lesson.content.split("\n").filter(Boolean).map((p, i) => (
|
||||
<p key={`${i}-${p.slice(0, 16)}`}>{p}</p>
|
||||
{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>
|
||||
)}
|
||||
{next ? (
|
||||
<Link href={next} className="btn btn-primary">
|
||||
{t.digital.next} →
|
||||
</Link>
|
||||
) : (
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/digital/course" className="btn btn-primary">
|
||||
{t.digital.backCatalog}
|
||||
</Link>
|
||||
<Link href="/gigs" className="btn">
|
||||
{t.nav.gigs}
|
||||
</Link>
|
||||
<Link href="/meetups" className="btn btn-ghost">
|
||||
{t.nav.meetups}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{prev && <Link href={prev} className="btn">← {t.digital.prev}</Link>}
|
||||
<Link href={next} className="btn btn-primary">{t.digital.next} →</Link>
|
||||
</footer>
|
||||
</article>
|
||||
<RingNext steps={next ? rings.afterGigs : rings.afterDigital} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,88 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
|
||||
import { inferMeetupCity, meetupCityHref } from "@/lib/meetupLinks";
|
||||
|
||||
export default function DiscussionDetailClient({
|
||||
discussionId,
|
||||
initial,
|
||||
}: {
|
||||
discussionId: string;
|
||||
initial: import("@/lib/types").DiscussionDetail;
|
||||
}) {
|
||||
export default function DiscussionDetailClient({ discussionId, initial }: { discussionId: string; initial: import("@/lib/types").DiscussionDetail }) {
|
||||
const { user, token } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
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 [draftReady, setDraftReady] = useState(false);
|
||||
const [cityNames, setCityNames] = useState<string[]>([]);
|
||||
const draftKey = `nomadro-reply-draft:${discussionId}`;
|
||||
|
||||
const catLabel = (value: string) => {
|
||||
const map: Record<string, string> = {
|
||||
全部: t.community.catAll,
|
||||
签证: t.community.catVisa,
|
||||
远程工作: t.community.catRemote,
|
||||
住宿: t.community.catHousing,
|
||||
安全: t.community.catSafety,
|
||||
社区: t.community.catCommunity,
|
||||
};
|
||||
return map[value] || value;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const draft = loadDraft<{ text?: string }>(draftKey);
|
||||
if (draft?.text?.trim()) {
|
||||
setReply(draft.text);
|
||||
toast(t.community.replyDraftRestored, "info");
|
||||
}
|
||||
setDraftReady(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [discussionId]);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getDestinations()
|
||||
.then((list) => setCityNames(list.map((d) => d.name).filter(Boolean)))
|
||||
.catch(() => setCityNames([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftReady || !token) return;
|
||||
const id = window.setTimeout(() => {
|
||||
if (!reply.trim()) {
|
||||
clearDraft(draftKey);
|
||||
return;
|
||||
}
|
||||
saveDraft(draftKey, { text: reply });
|
||||
}, 400);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [reply, draftReady, token, draftKey]);
|
||||
|
||||
const body = discussion.excerpt || "";
|
||||
const inferredCity = useMemo(
|
||||
() =>
|
||||
inferMeetupCity(
|
||||
`${discussion.title} ${body} ${discussion.category || ""}`,
|
||||
cityNames
|
||||
),
|
||||
[discussion.title, discussion.category, body, cityNames]
|
||||
);
|
||||
const meetupsHref = inferredCity ? meetupCityHref(inferredCity) : "/meetups";
|
||||
|
||||
const refresh = async () => {
|
||||
const d = await api.getDiscussion(discussionId);
|
||||
@ -91,26 +25,13 @@ export default function DiscussionDetailClient({
|
||||
};
|
||||
|
||||
const submitReply = async () => {
|
||||
if (!token) {
|
||||
router.push(`/login?next=/community/${discussionId}`);
|
||||
return;
|
||||
}
|
||||
if (!reply.trim()) {
|
||||
toast(t.community.replyNeed, "info");
|
||||
return;
|
||||
}
|
||||
if (!token || !reply.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.postDiscussionReply(token, discussionId, reply.trim());
|
||||
setReply("");
|
||||
clearDraft(draftKey);
|
||||
await refresh();
|
||||
toast(t.community.replyOk, "success");
|
||||
requestAnimationFrame(() => {
|
||||
const nodes = document.querySelectorAll(".community-reply");
|
||||
const last = nodes[nodes.length - 1];
|
||||
last?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
} catch {
|
||||
toast(t.community.replyFail, "error");
|
||||
} finally {
|
||||
@ -126,9 +47,7 @@ export default function DiscussionDetailClient({
|
||||
try {
|
||||
const res = await api.likeDiscussion(token, discussionId);
|
||||
setLikes(res.like_count);
|
||||
} catch {
|
||||
toast(t.community.likeFail, "error");
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
return (
|
||||
@ -136,48 +55,31 @@ export default function DiscussionDetailClient({
|
||||
<div className="container">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/community">← {t.community.back}</Link>
|
||||
<span> · </span>
|
||||
<Link href={meetupsHref}>
|
||||
{inferredCity ? `${t.common.cityMeetups} · ${inferredCity}` : t.nav.meetups}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<article className="community-detail reveal">
|
||||
<header>
|
||||
<span className="community-detail-cat">{catLabel(discussion.category)}</span>
|
||||
<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">
|
||||
{body.split("\n").filter(Boolean).map((p, i) => (
|
||||
<p key={`${i}-${p.slice(0, 12)}`}>{p}</p>
|
||||
))}
|
||||
<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>
|
||||
<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.length === 0 && (
|
||||
<p className="dest-empty">{t.community.noRepliesYet}</p>
|
||||
)}
|
||||
<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>
|
||||
<span>{r.author_emoji} {r.author}</span>
|
||||
<time>{r.created_at}</time>
|
||||
</div>
|
||||
<p>{r.content}</p>
|
||||
@ -187,44 +89,14 @@ export default function DiscussionDetailClient({
|
||||
|
||||
{user && token ? (
|
||||
<div className="community-compose reveal">
|
||||
<textarea
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void submitReply();
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
placeholder={t.community.replyPlaceholder}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<div className="community-compose-foot">
|
||||
<span className="community-compose-count">
|
||||
{reply.length}/2000
|
||||
</span>
|
||||
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void submitReply()}>
|
||||
{loading ? t.community.replySending : t.community.replySend}
|
||||
</button>
|
||||
</div>
|
||||
<p className="community-compose-hint">{t.community.replyShortcut}</p>
|
||||
<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">
|
||||
<p>{t.community.replyHint}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href={`/login?next=/community/${discussionId}`} className="btn btn-primary">
|
||||
{t.nav.login}
|
||||
</Link>
|
||||
<Link href={meetupsHref} className="btn btn-ghost">
|
||||
{t.dating.meetAtEvents}
|
||||
</Link>
|
||||
</div>
|
||||
<Link href={`/login?next=/community/${discussionId}`} className="btn btn-primary">{t.nav.login}</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RingNext steps={rings.afterCommunity(inferredCity)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -10,7 +10,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function FAQSection({ faqs, limit }: Props) {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showAll, setShowAll] = useState(!limit);
|
||||
@ -62,38 +62,17 @@ export default function FAQSection({ faqs, limit }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="notif-empty reveal">
|
||||
<p className="dest-empty">{t.faqUi.empty}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setSearch("")}>
|
||||
{t.faqUi.clear}
|
||||
</button>
|
||||
<a href="/help" className="btn btn-sm btn-ghost">
|
||||
{t.faqUi.help}
|
||||
</a>
|
||||
<a href="/ai" className="btn btn-sm btn-ghost">
|
||||
{t.faqUi.askAi}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{limit && !showAll && sorted.length > limit && (
|
||||
<div className="dest-expand-wrap reveal">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}>
|
||||
{t.faqUi.showAll.replace("{n}", String(sorted.length))}
|
||||
{locale === "en" ? `Show all ${sorted.length} questions` : `展开全部 ${sorted.length} 个问题`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="faq-plan-hint reveal">
|
||||
{t.faqUi.still}
|
||||
<a href="#destinations">{t.faqUi.browse}</a>
|
||||
{" · "}
|
||||
<a href="/help">{t.faqUi.help}</a>
|
||||
{" · "}
|
||||
<a href="/ai">{t.faqUi.askAi}</a>
|
||||
{locale === "en" ? "Still exploring? " : "还在选城?"}
|
||||
<a href="#destinations">{locale === "en" ? "Browse destinations" : "继续看目的地"}</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -1,51 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { loginNextFrom } from "@/lib/authNext";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function FavoriteButton({ slug }: { slug: string }) {
|
||||
const { user, isFavorite, toggleFavorite } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const pathname = usePathname();
|
||||
const next = loginNextFrom(pathname || `/destinations/${slug}`);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Link
|
||||
href={`/login?next=${encodeURIComponent(next)}`}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: "0.85rem" }}
|
||||
>
|
||||
{t.common.loginFavorite}
|
||||
<Link href="/login" className="btn btn-ghost" style={{ fontSize: "0.85rem" }}>
|
||||
🤍 登录收藏
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const fav = isFavorite(slug);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${fav ? "btn-primary" : "btn-ghost"}`}
|
||||
onClick={() => {
|
||||
const adding = !fav;
|
||||
void toggleFavorite(slug).then(() => {
|
||||
if (adding) {
|
||||
toast(t.common.favorited, "success", {
|
||||
href: "/profile",
|
||||
label: t.profile.myFavs,
|
||||
});
|
||||
}
|
||||
});
|
||||
}}
|
||||
style={{ fontSize: "0.85rem" }}
|
||||
aria-pressed={fav}
|
||||
>
|
||||
{fav ? t.common.favorited : t.common.favorite}
|
||||
<button className={`btn ${fav ? "btn-primary" : "btn-ghost"}`}
|
||||
onClick={() => toggleFavorite(slug)} style={{ fontSize: "0.85rem" }}>
|
||||
{fav ? "❤️ 已收藏" : "🤍 收藏"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,70 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
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 RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
|
||||
|
||||
const DRAFT_KEY = "nomadro-feedback-draft";
|
||||
|
||||
type FeedbackDraft = { content: string; category: string };
|
||||
|
||||
export default function FeedbackClient() {
|
||||
const { token } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [content, setContent] = useState("");
|
||||
const [category, setCategory] = useState("general");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const draft = loadDraft<FeedbackDraft>(DRAFT_KEY);
|
||||
if (draft) {
|
||||
setContent(draft.content || "");
|
||||
setCategory(draft.category || "general");
|
||||
if (draft.content?.trim()) {
|
||||
toast(t.feedback.draftRestored, "info");
|
||||
}
|
||||
}
|
||||
setReady(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || done) return;
|
||||
const id = window.setTimeout(() => {
|
||||
if (!content.trim()) {
|
||||
clearDraft(DRAFT_KEY);
|
||||
return;
|
||||
}
|
||||
saveDraft(DRAFT_KEY, { content, category });
|
||||
}, 400);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [content, category, ready, done]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!content.trim() || content.trim().length < 5) {
|
||||
toast(t.feedback.minLength, "info");
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.submitFeedback(content.trim(), token || undefined, category);
|
||||
clearDraft(DRAFT_KEY);
|
||||
toast(t.feedback.ok, "success", {
|
||||
href: "/community",
|
||||
label: t.feedback.goCommunity,
|
||||
});
|
||||
toast(t.feedback.ok, "success");
|
||||
setContent("");
|
||||
setDone(true);
|
||||
} catch {
|
||||
toast(t.feedback.fail, "error");
|
||||
} finally {
|
||||
@ -75,50 +32,22 @@ export default function FeedbackClient() {
|
||||
return (
|
||||
<div className="community-page">
|
||||
<div className="container">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/">{t.common.backHome}</Link>
|
||||
</nav>
|
||||
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">{t.feedback.tag}</span>
|
||||
<h1>{t.feedback.title}</h1>
|
||||
<p>{t.feedback.subtitle}</p>
|
||||
</div>
|
||||
{done ? (
|
||||
<div className="dating-gate reveal">
|
||||
<h2>{t.feedback.thanksTitle}</h2>
|
||||
<p>{t.feedback.thanksDesc}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => setDone(false)}>
|
||||
{t.feedback.writeAgain}
|
||||
</button>
|
||||
<Link href="/community" className="btn">
|
||||
{t.feedback.goCommunity}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="community-compose reveal">
|
||||
<p className="section-desc">{t.feedback.draftHint}</p>
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
<option value="general">{t.feedback.catGeneral}</option>
|
||||
<option value="bug">{t.feedback.catBug}</option>
|
||||
<option value="feature">{t.feedback.catFeature}</option>
|
||||
<option value="safety">{t.feedback.catSafety}</option>
|
||||
</select>
|
||||
<textarea
|
||||
rows={6}
|
||||
placeholder={t.feedback.placeholder}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void submit()}>
|
||||
{loading ? t.feedback.submitting : t.feedback.submit}
|
||||
</button>
|
||||
{!token && <p className="section-desc">{t.feedback.guestHint}</p>}
|
||||
<textarea rows={6} placeholder={t.feedback.placeholder} value={content} onChange={(e) => setContent(e.target.value)} />
|
||||
<button type="button" className="btn btn-primary" disabled={loading} onClick={submit}>{t.feedback.submit}</button>
|
||||
</div>
|
||||
)}
|
||||
<RingNext steps={rings.afterDiscover} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,88 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
|
||||
|
||||
const DRAFT_KEY = "nomadro-feedback-draft";
|
||||
|
||||
export default function FeedbackWidget() {
|
||||
const { token } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [type, setType] = useState("feature");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const types = [
|
||||
{ key: "feature", label: `💡 ${t.feedback.catFeature}` },
|
||||
{ key: "bug", label: `🐛 ${t.feedback.catBug}` },
|
||||
{ key: "general", label: `❤️ ${t.feedback.catGeneral}` },
|
||||
const TYPES = [
|
||||
{ key: "idea", label: "💡 想法" },
|
||||
{ key: "bug", label: "🐛 问题" },
|
||||
{ key: "love", label: "❤️ 喜欢" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const draft = loadDraft<{ content?: string; category?: string }>(DRAFT_KEY);
|
||||
if (draft) {
|
||||
if (draft.content) setMsg(draft.content);
|
||||
if (draft.category) setType(draft.category);
|
||||
}
|
||||
setReady(true);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !ready) return;
|
||||
const id = window.setTimeout(() => {
|
||||
if (!msg.trim()) {
|
||||
clearDraft(DRAFT_KEY);
|
||||
return;
|
||||
}
|
||||
saveDraft(DRAFT_KEY, { content: msg, category: type });
|
||||
}, 400);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [msg, type, open, ready]);
|
||||
export default function FeedbackWidget() {
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [type, setType] = useState("idea");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!msg.trim() || msg.trim().length < 5) {
|
||||
toast(t.feedback.minLength, "info");
|
||||
return;
|
||||
}
|
||||
if (!msg.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await api.submitFeedback(msg.trim(), token || undefined, type);
|
||||
// Persist locally for demo; can wire to API later
|
||||
try {
|
||||
const key = "nomadro-feedback";
|
||||
const prev = JSON.parse(localStorage.getItem(key) || "[]");
|
||||
prev.push({ type, msg: msg.trim(), at: new Date().toISOString() });
|
||||
localStorage.setItem(key, JSON.stringify(prev.slice(-50)));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
clearDraft(DRAFT_KEY);
|
||||
toast(t.feedback.ok, "success", {
|
||||
href: "/community",
|
||||
label: t.feedback.goCommunity,
|
||||
});
|
||||
toast("感谢反馈!我们会认真看的 🙏");
|
||||
setMsg("");
|
||||
setOpen(false);
|
||||
} catch {
|
||||
toast(t.feedback.failRetry, "error");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@ -93,63 +39,45 @@ export default function FeedbackWidget() {
|
||||
<button
|
||||
className="feedback-fab"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={t.feedback.fabAria}
|
||||
title={t.feedback.fabTitle}
|
||||
aria-label="反馈"
|
||||
title="反馈建议"
|
||||
>
|
||||
💬
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t.feedback.widgetTitle}
|
||||
onClick={(e) => e.target === e.currentTarget && setOpen(false)}
|
||||
>
|
||||
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && setOpen(false)}>
|
||||
<div className="modal feedback-modal">
|
||||
<button className="modal-close" onClick={() => setOpen(false)} aria-label={t.feedback.close}>
|
||||
✕
|
||||
</button>
|
||||
<button className="modal-close" onClick={() => setOpen(false)} aria-label="关闭">✕</button>
|
||||
<div className="feedback-body">
|
||||
<span className="section-tag">{t.feedback.tag}</span>
|
||||
<h2>{t.feedback.widgetTitle}</h2>
|
||||
<p>{t.feedback.widgetSubtitle}</p>
|
||||
<form onSubmit={(e) => void submit(e)}>
|
||||
<span className="section-tag">💬 FEEDBACK</span>
|
||||
<h2>给 nomadro 提建议</h2>
|
||||
<p>功能想法、体验问题,或只是想说喜欢</p>
|
||||
<form onSubmit={submit}>
|
||||
<div className="feedback-types">
|
||||
{types.map((item) => (
|
||||
{TYPES.map((t) => (
|
||||
<button
|
||||
key={item.key}
|
||||
key={t.key}
|
||||
type="button"
|
||||
className={`filter-btn${type === item.key ? " active" : ""}`}
|
||||
onClick={() => setType(item.key)}
|
||||
className={`filter-btn${type === t.key ? " active" : ""}`}
|
||||
onClick={() => setType(t.key)}
|
||||
>
|
||||
{item.label}
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className="feedback-textarea"
|
||||
rows={4}
|
||||
placeholder={t.feedback.placeholder}
|
||||
placeholder="写下你的想法…"
|
||||
value={msg}
|
||||
onChange={(e) => setMsg(e.target.value)}
|
||||
required
|
||||
maxLength={500}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={sending || !msg.trim()}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{sending ? t.feedback.submitting : t.feedback.sendBtn}
|
||||
<button type="submit" className="btn btn-primary" disabled={sending || !msg.trim()} style={{ width: "100%" }}>
|
||||
{sending ? "发送中…" : "发送反馈 🚀"}
|
||||
</button>
|
||||
<p className="feedback-more-link">
|
||||
<Link href="/feedback" onClick={() => setOpen(false)}>
|
||||
{t.feedback.openFull}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Destination } from "@/lib/types";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||||
import { meetupCityHref } from "@/lib/meetupLinks";
|
||||
|
||||
/** First-month one-time + rent estimates in CNY. */
|
||||
const LANDING: Record<string, { rent: number; depositMult: number; sim: number; transfer: number; misc: number }> = {
|
||||
@ -23,8 +18,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function FirstMonthCost({ destinations }: Props) {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai");
|
||||
const [housing, setHousing] = useState<"budget" | "mid" | "nice">("mid");
|
||||
const [includeDeposit, setIncludeDeposit] = useState(true);
|
||||
@ -53,16 +46,6 @@ export default function FirstMonthCost({ destinations }: Props) {
|
||||
const total = breakdown.find((b) => b.key === "total")!.amount;
|
||||
const maxBar = Math.max(...breakdown.filter((b) => b.key !== "total").map((b) => b.amount), 1);
|
||||
|
||||
const addToPlan = () => {
|
||||
if (!dest) return;
|
||||
const { added } = mergeDestinationsIntoTrip([dest], 1);
|
||||
toast(
|
||||
added ? `${dest.emoji} ${dest.name} ${t.plan.addedToPlan}` : t.common.alreadyInPlan,
|
||||
added ? "success" : "info",
|
||||
{ href: "/plan", label: t.strip.openPlan }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section firstmonth-section" id="first-month">
|
||||
<div className="container">
|
||||
@ -125,19 +108,6 @@ export default function FirstMonthCost({ destinations }: Props) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{dest && (
|
||||
<div className="dating-empty-actions" style={{ marginTop: "1rem" }}>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={addToPlan}>
|
||||
🗓️ {t.nav.plan}
|
||||
</button>
|
||||
<Link href={`/destinations/${dest.slug}`} className="btn btn-sm">
|
||||
{t.common.detail}
|
||||
</Link>
|
||||
<Link href={meetupCityHref(dest.name)} className="btn btn-sm btn-ghost">
|
||||
{t.common.cityMeetups}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Destination } from "@/lib/types";
|
||||
import ToolCityExits from "@/components/ToolCityExits";
|
||||
|
||||
/** 粗略往返机票估算(CNY),基于城市对距离感 */
|
||||
const FLIGHT_BASE: Record<string, number> = {
|
||||
@ -121,7 +120,6 @@ export default function FlightCost({ destinations }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<p className="flight-note">* 估算仅供参考,实际票价随航司与预订时间波动较大</p>
|
||||
{dest && <ToolCityExits dest={dest} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user