feat: NomadCNA community layer, social matching, payments, and ebook

Port meetups, discussions, gigs, dating/chat, VIP pay (ZPay/XorPay), MiroTalk live, digital academy, and ebook reader. Add persistent community_store, git-first deploy docs, and env template for production secrets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 09:38:13 -05:00
parent 99ebbed799
commit ff7b721acb
224 changed files with 23277 additions and 748 deletions

11
.gitignore vendored
View File

@ -8,6 +8,17 @@ frontend/.env.local
# Auth / plan user store (runtime) # Auth / plan user store (runtime)
backend/app/data/user_store.json backend/app/data/user_store.json
backend/app/data/social_store.json
backend/app/data/community_store.json
# Production secrets (use deploy/nomadro-api.env.example)
deploy/nomadro-api.env
# Local screenshots / temp
book-*.png
tmp-screenshots/
scripts/_shell_out.txt
scripts/_*.py
# PocketBase # PocketBase
pocketbase/pb_data/* pocketbase/pb_data/*

View File

@ -167,6 +167,33 @@ NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1
NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
``` ```
## Git 工作流
仓库:[gitea.dsx2020.com/eric/nomadweb](https://gitea.dsx2020.com/eric/nomadweb) · 主开发分支 **`dev1`**
```bash
# 日常开发
git checkout dev1
git pull origin dev1
# … 改代码 …
git add -A
git status # 确认未包含 .env、密钥、运行时 json
git commit -m "feat: 简述改动"
git push origin dev1
# 部署到生产(服务器 git pull + 增量构建)
python scripts/deploy_update.py
```
| 步骤 | 说明 |
|------|------|
| `dev1` | 功能开发、联调、上线均在此分支 |
| `main` | 稳定快照,按需从 `dev1` 合并 |
| 密钥 | `deploy/nomadro-api.env` 仅放服务器,模板见 `deploy/nomadro-api.env.example` |
| 运行时数据 | `user_store.json` / `social_store.json` / `community_store.json` 不提交 |
**不要用 SFTP 全量同步当主流程** — `deploy_direct_sync.py` 仅作 Gitea 不可用时的应急备份(慢且易漏文件)。
## 部署 ## 部署
### 生产(已上线) ### 生产(已上线)
@ -189,16 +216,16 @@ NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
单元:`deploy/systemd/nomadro-web.service`、`nomadro-api.service`。 单元:`deploy/systemd/nomadro-web.service`、`nomadro-api.service`。
**推荐更新:** **推荐更新(Git):**
```bash ```bash
# 需能 git pull 到 origin/dev1 时 git push origin dev1
python scripts/deploy_update.py python scripts/deploy_update.py
``` ```
拉取 → 按变更增量构建前后端 → `systemctl restart`;会停掉占用端口的旧 Docker Web/API 容器。 服务器执行 `git pull` → 按变更只重建前端或后端 → `systemctl restart`;比 SFTP 全量同步快且可回滚。
**Gitea 不可用 / 仅本地有最新提交时:** **应急(Gitea 不可用 / 未 push 的本地提交):**
```bash ```bash
python scripts/deploy_direct_sync.py python scripts/deploy_direct_sync.py

View File

@ -0,0 +1,264 @@
"""Community & events mock data — adapted from NomadCNA business logic."""
MEETUPS = [
{
"id": "online-roundtable",
"title": "远程工作节奏圆桌",
"city": "线上",
"destination_slug": "",
"emoji": "🎙️",
"date": "2026-09-12",
"time": "20:00",
"venue": "nomadro 线上活动厅",
"description": "聊聊异步协作、时区管理和旅居城市选择。登录后可 RSVP,活动前会收到提醒。",
"mode": "online",
"access_level": "public",
"mirotalkRoom": "nomadro-online-roundtable",
"loungeChannel": "#nomadro-online",
"meetingUrl": "",
"rsvp_count": 38,
"max_attendees": 80,
"organizer": "nomadro 社区",
"tags": ["远程", "协作", "新手友好"],
"is_upcoming": True,
},
{
"id": "chiangmai-cowork",
"title": "清迈联合办公下午茶",
"city": "清迈",
"destination_slug": "chiang-mai",
"emoji": "☕",
"date": "2026-09-18",
"time": "15:00",
"venue": "Nimman 区 Co-working",
"description": "一起办公、分享签证经验和住宿踩坑。线下为主,也开放线上旁听链接。",
"mode": "hybrid",
"access_level": "public",
"mirotalkRoom": "nomadro-chiangmai-cowork",
"loungeChannel": "#chiangmai-cowork",
"rsvp_count": 14,
"max_attendees": 24,
"organizer": "清迈游民小组",
"tags": ["线下", "社交", "东南亚"],
"is_upcoming": True,
},
{
"id": "lisbon-sunset",
"title": "里斯本日落漫步",
"city": "里斯本",
"destination_slug": "lisbon",
"emoji": "🌅",
"date": "2026-09-22",
"time": "18:30",
"venue": "Miradouro 观景点集合",
"description": "工作一周后,和同城游民一起看日落、交换欧洲签证情报。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 9,
"max_attendees": 16,
"organizer": "Lisbon Nomads",
"tags": ["欧洲", "户外", "社交"],
"is_upcoming": True,
},
{
"id": "bali-surf-morning",
"title": "巴厘岛晨间冲浪 + 办公",
"city": "巴厘岛",
"destination_slug": "bali",
"emoji": "🏄",
"date": "2026-10-05",
"time": "07:00",
"venue": "Canggu Beach",
"description": "早起冲浪,10 点回咖啡馆集中办公。适合想体验 Work-Life 平衡的游民。",
"mode": "offline",
"access_level": "public",
"rsvp_count": 11,
"max_attendees": 20,
"organizer": "Bali Remote Crew",
"tags": ["运动", "生活方式"],
"is_upcoming": True,
},
{
"id": "tokyo-tax-talk",
"title": "日本税务与居留线上分享",
"city": "线上",
"destination_slug": "tokyo",
"emoji": "🧾",
"date": "2026-10-12",
"time": "19:30",
"venue": "线上直播",
"description": "特邀长期旅居日本的游民分享税务申报、保险和银行开户经验(非法律建议)。",
"mode": "online",
"access_level": "members",
"rsvp_count": 52,
"max_attendees": 120,
"organizer": "nomadro 社区",
"tags": ["税务", "日本", "干货"],
"is_upcoming": True,
},
]
DISCUSSIONS = [
{
"id": "visa-sea-2026",
"title": "2026 东南亚长期停留签证怎么选?",
"excerpt": "泰国 DTV、马来西亚 DE Rantau、印尼第二家园……大家最近实际办下来体验如何?",
"author": "小林",
"author_emoji": "🧳",
"category": "签证",
"reply_count": 23,
"like_count": 47,
"is_pinned": True,
"created_at": "2026-08-20",
"tags": ["签证", "东南亚"],
},
{
"id": "async-remote",
"title": "异步团队怎么写日报才不烦人?",
"excerpt": "我们团队跨 5 个时区,想收集团队节奏模板和工具推荐。",
"author": "Marco",
"author_emoji": "💻",
"category": "远程工作",
"reply_count": 15,
"like_count": 31,
"is_pinned": False,
"created_at": "2026-08-22",
"tags": ["远程", "协作"],
},
{
"id": "coliving-tips",
"title": "第一次租 Coliving 必问房东的 10 个问题",
"excerpt": "押金、网速、清洁、访客政策……欢迎补充你的踩坑经历。",
"author": "阿静",
"author_emoji": "🏡",
"category": "住宿",
"reply_count": 19,
"like_count": 56,
"is_pinned": False,
"created_at": "2026-08-25",
"tags": ["住宿", "经验"],
},
{
"id": "health-insurance",
"title": "数字游民国际医疗保险对比",
"excerpt": "SafetyWing、Genki、本地险……按年龄段和目的地聊聊性价比。",
"author": "Yuki",
"author_emoji": "🏥",
"category": "安全",
"reply_count": 11,
"like_count": 28,
"is_pinned": False,
"created_at": "2026-08-27",
"tags": ["保险", "安全"],
},
{
"id": "meetup-feedback",
"title": "你希望 nomadro 线下活动开在哪些城市?",
"excerpt": "我们在规划 Q4 活动路线,投票 + 留言告诉我们你的城市!",
"author": "nomadro",
"author_emoji": "🌍",
"category": "社区",
"reply_count": 34,
"like_count": 72,
"is_pinned": True,
"created_at": "2026-08-28",
"tags": ["活动", "投票"],
},
]
GIGS = [
{
"id": "logo-design",
"title": "为游民社区设计 Logo",
"description": "需要扁平风格,含地球+代码元素,交付 SVG。",
"budget": "¥800",
"deadline": "2026-09-15",
"tags": ["设计", "远程"],
"poster": "nomadro",
"status": "open",
},
{
"id": "meetup-host",
"title": "深圳线下活动主持人",
"description": "协助组织 20 人规模的游民交流会,有经验优先。",
"budget": "¥500/场",
"deadline": "2026-09-20",
"tags": ["活动", "深圳"],
"poster": "深圳湾区主理人",
"status": "open",
},
{
"id": "content-translate",
"title": "签证指南英文润色",
"description": "将 3 篇中文签证攻略翻译润色为英文博客。",
"budget": "¥1200",
"deadline": "2026-10-01",
"tags": ["翻译", "内容"],
"poster": "nomadro",
"status": "open",
},
]
DISCUSSION_REPLIES = {
"visa-sea-2026": [
{
"id": "r1",
"author": "阿Ken",
"author_emoji": "🇹🇭",
"content": "DTV 刚办下来,材料比想象中简单,关键是银行流水和远程工作证明。",
"created_at": "2026-08-21",
"like_count": 12,
},
{
"id": "r2",
"author": "Sara",
"author_emoji": "🇲🇾",
"content": "DE Rantau 审批大概 3 周,适合想待吉隆坡的。",
"created_at": "2026-08-21",
"like_count": 8,
},
],
"async-remote": [
{
"id": "r1",
"author": "Devon",
"author_emoji": "🕐",
"content": "我们用 Loom 录屏 + 简短文字摘要,比长日报友好很多。",
"created_at": "2026-08-23",
"like_count": 9,
},
],
}
NOMAD_ROUTES = [
{
"id": "sea-slow",
"slug": "sea-slow-trail",
"title": "东南亚慢旅三角",
"duration_days": 90,
"budget": 18000,
"description": "清迈 → 巴厘岛 → 吉隆坡,低成本 + 高速网络 + 成熟游民社区。",
"stops": ["chiang-mai", "bali", "kuala-lumpur"],
"emoji": "🌴",
},
{
"id": "euro-culture",
"slug": "euro-culture-loop",
"title": "欧洲文化环线",
"duration_days": 60,
"budget": 35000,
"description": "里斯本 → 巴塞罗那 → 柏林,签证友好、文化丰富。",
"stops": ["lisbon", "barcelona", "berlin"],
"emoji": "🏰",
},
{
"id": "latam-budget",
"slug": "latam-budget-run",
"title": "拉美性价比冲刺",
"duration_days": 45,
"budget": 12000,
"description": "墨西哥城 → 麦德林,贴近北美时区、生活成本低。",
"stops": ["mexico-city", "medellin"],
"emoji": "🌮",
},
]

View File

@ -0,0 +1,120 @@
"""Digital nomad academy content — nomadro /digital sub-site."""
SITE_ID = "nomadro-digital"
COURSE_MODULES = [
{
"title": "启程:远程工作基础",
"lessons": [
{"title": "什么是数字游民", "duration": "8 分钟", "free": True},
{"title": "异步协作入门", "duration": "12 分钟", "free": True},
{"title": "时区与会议管理", "duration": "15 分钟", "free": False},
],
},
{
"title": "签证与税务",
"lessons": [
{"title": "东南亚长期签证概览", "duration": "18 分钟", "free": False},
{"title": "183 天规则与税务居民", "duration": "14 分钟", "free": False},
],
},
{
"title": "Homelab 与连接",
"lessons": [
{"title": "把家装进一个机柜", "duration": "20 分钟", "free": False},
{"title": "无论在哪都能连回家", "duration": "16 分钟", "free": False},
],
},
]
LESSONS = {
"0-0": {
"title": "什么是数字游民",
"moduleIndex": 0,
"lessonIndex": 0,
"duration": "8 分钟",
"free": True,
"content": "数字游民是一种工作方式:产出不绑定在某个地理坐标上。你可以在任何有网络的地方完成交付。",
},
"0-1": {
"title": "异步协作入门",
"moduleIndex": 0,
"lessonIndex": 1,
"duration": "12 分钟",
"free": True,
"content": "异步沟通不是「随时在线」,而是「明确预期」:写清楚截止时间、交付物和决策人。",
},
"0-2": {
"title": "时区与会议管理",
"moduleIndex": 0,
"lessonIndex": 2,
"duration": "15 分钟",
"free": False,
"content": "用 overlap 窗口安排会议,非重叠时段留给深度工作。工具箱里的「会议黄金时段」可以帮你算。",
},
"1-0": {
"title": "东南亚长期签证概览",
"moduleIndex": 1,
"lessonIndex": 0,
"duration": "18 分钟",
"free": False,
"content": "泰国 DTV、马来西亚 DE Rantau、印尼第二家园……各国有不同的门槛与材料清单。",
},
"1-1": {
"title": "183 天规则与税务居民",
"moduleIndex": 1,
"lessonIndex": 1,
"duration": "14 分钟",
"free": False,
"content": "多数国家用 183 天判定税务居民。用 nomadro 税居天数工具追踪停留。",
},
"2-0": {
"title": "把家装进一个机柜",
"moduleIndex": 2,
"lessonIndex": 0,
"duration": "20 分钟",
"free": False,
"content": "Homelab 让你在旅途中拥有可控的 NAS、VPN 和开发环境。",
},
"2-1": {
"title": "无论在哪都能连回家",
"moduleIndex": 2,
"lessonIndex": 1,
"duration": "16 分钟",
"free": False,
"content": "WireGuard + 动态 DNS,把家里的服务安全暴露给在外的你。",
},
}
JOBS = [
{
"id": "j1",
"title": "Senior Frontend Engineer",
"company": "Remote First Co",
"location": "全球远程",
"type": "全职",
"salary": "$80k–120k",
"tags": ["React", "TypeScript", "远程"],
"url": "https://nomadro.com",
},
{
"id": "j2",
"title": "DevOps / SRE",
"company": "Nomad Labs",
"location": "欧洲时区",
"type": "合同",
"salary": "€60–90/h",
"tags": ["K8s", "AWS", "异步"],
"url": "https://nomadro.com",
},
{
"id": "j3",
"title": "内容运营(中文)",
"company": "nomadro",
"location": "东南亚友好",
"type": "兼职",
"salary": "面议",
"tags": ["社区", "写作", "游民"],
"url": "https://nomadro.com",
},
]

View File

@ -0,0 +1,54 @@
"""Nomad matching candidate profiles — adapted from NomadCNA business logic."""
MATCH_INTENTS = ("friends", "dating", "partner", "roommate", "cofounder", "explore")
CANDIDATE_PROFILES = [
{
"id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiang-mai",
"gender": "女", "single": "是", "bio": "远程前端,喜欢咖啡和徒步。",
"photo": "🧳", "tags": ["远程", "咖啡", "徒步"],
"lookingFor": ["friends", "dating", "explore"],
},
{
"id": "p2", "userId": "u2", "name": "Marco", "location": "里斯本", "citySlug": "lisbon",
"gender": "男", "single": "是", "bio": "全栈工程师,欧洲旅居第三年。",
"photo": "💻", "tags": ["全栈", "欧洲", "冲浪"],
"lookingFor": ["friends", "cofounder", "explore"],
},
{
"id": "p3", "userId": "u3", "name": "Yuki", "location": "巴厘岛", "citySlug": "bali",
"gender": "女", "single": "否", "bio": "设计师,寻找合租室友。",
"photo": "🎨", "tags": ["设计", "瑜伽", "合租"],
"lookingFor": ["roommate", "friends"],
},
{
"id": "p4", "userId": "u4", "name": "Alex", "location": "墨西哥城", "citySlug": "mexico-city",
"gender": "男", "single": "是", "bio": "独立开发者,拉美时区常驻。",
"photo": "🚀", "tags": ["独立开发", "拉美"],
"lookingFor": ["cofounder", "friends", "dating"],
},
{
"id": "p5", "userId": "u5", "name": "Sara", "location": "巴塞罗那", "citySlug": "barcelona",
"gender": "女", "single": "是", "bio": "产品运营,热爱美食与市集。",
"photo": "☕", "tags": ["产品", "美食"],
"lookingFor": ["dating", "partner", "friends"],
},
{
"id": "p6", "userId": "u6", "name": "Ken", "location": "东京", "citySlug": "tokyo",
"gender": "男", "single": "是", "bio": "数据分析师,东亚签证达人。",
"photo": "📊", "tags": ["数据", "签证"],
"lookingFor": ["friends", "explore"],
},
{
"id": "p7", "userId": "u7", "name": "Lina", "location": "大理", "citySlug": "dali",
"gender": "女", "single": "是", "bio": "内容创作者,慢生活倡导者。",
"photo": "📷", "tags": ["内容", "慢生活"],
"lookingFor": ["friends", "dating", "roommate"],
},
{
"id": "p8", "userId": "u8", "name": "Devon", "location": "柏林", "citySlug": "berlin",
"gender": "非二元", "single": "是", "bio": "DevOps,开源社区活跃成员。",
"photo": "🖥️", "tags": ["DevOps", "开源"],
"lookingFor": ["cofounder", "friends"],
},
]

View File

@ -2,7 +2,7 @@
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import api, auth from app.routers import api, auth, community, payment, social
app = FastAPI( app = FastAPI(
title="nomadro API", title="nomadro API",
@ -20,6 +20,8 @@ app.add_middleware(
app.include_router(api.router, prefix="/api/v1") app.include_router(api.router, prefix="/api/v1")
app.include_router(auth.router, prefix="/api/v1") app.include_router(auth.router, prefix="/api/v1")
app.include_router(social.router, prefix="/api/v1")
app.include_router(community.router, prefix="/api/v1")
@app.get("/") @app.get("/")

View File

@ -1,12 +1,23 @@
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query, Header
from app.schemas import ( from app.schemas import (
BlogPost, BlogPostDetail, ChartData, CompareRequest, CostCalculatorRequest, BlogPost, BlogPostDetail, ChartData, CompareRequest, CostCalculatorRequest,
CostCalculatorResponse, Destination, FAQ, SearchResult, StatsResponse, CostCalculatorResponse, Destination, Discussion, DiscussionDetail, FAQ,
Meetup, MeetupRsvpRequest, MeetupSession, NextStopRequest, NextStopResponse, NomadRoute,
DigitalLesson, DigitalJob,
RecommendedDestination, SearchResult, StatsResponse,
SubscribeRequest, SubscribeResponse, Testimonial, Tool, Visa, SubscribeRequest, SubscribeResponse, Testimonial, Tool, Visa,
) )
from app.services.pocketbase import pb from app.services.pocketbase import pb
from app.data import mock_data from app.data import mock_data
from app.data import community_data
from app.services.recommendations import recommend_destinations
from app.services.meetup_live import (
build_lounge_chat_url, build_mirotalk_join_url, can_access_meetup, create_mirotalk_room_slug,
)
from app.services.auth import get_user_by_token
from app.services import community_store, social_store
from app.data import digital_content
router = APIRouter() router = APIRouter()
@ -213,3 +224,135 @@ async def global_search(q: str = Query(..., min_length=1)):
)) ))
return results[:12] return results[:12]
# ── Community (ported from NomadCNA, adapted for nomadro) ──
@router.get("/meetups", response_model=list[Meetup])
async def list_meetups(upcoming: bool = True):
return [Meetup(**m) for m in community_store.list_meetups(upcoming)]
@router.get("/meetups/{meetup_id}", response_model=Meetup)
async def get_meetup(meetup_id: str):
m = community_store.get_meetup(meetup_id)
if not m:
raise HTTPException(404, "活动不存在")
return Meetup(**m)
@router.post("/meetups/rsvp")
async def rsvp_meetup(body: MeetupRsvpRequest, authorization: str | None = Header(None)):
user_id = None
if authorization and authorization.startswith("Bearer "):
user = get_user_by_token(authorization[7:])
if user:
user_id = user["id"]
res = community_store.rsvp_meetup(body.meetup_id, user_id)
if not res.get("ok"):
if res.get("error") == "not_found":
raise HTTPException(404, "活动不存在")
raise HTTPException(400, "活动已满员")
return {"success": True, "message": res["message"], "rsvp_count": res["rsvp_count"]}
@router.get("/discussions", response_model=list[Discussion])
async def list_discussions(category: str | None = Query(None)):
return [Discussion(**d) for d in community_store.list_discussions(category)]
@router.get("/discussions/{discussion_id}", response_model=DiscussionDetail)
async def get_discussion(discussion_id: str):
d = community_store.get_discussion(discussion_id)
if not d:
raise HTTPException(404, "讨论不存在")
return DiscussionDetail(**d)
@router.get("/routes", response_model=list[NomadRoute])
async def list_routes():
return [NomadRoute(**r) for r in community_data.NOMAD_ROUTES]
@router.post("/recommendations/next-stop", response_model=NextStopResponse)
async def next_stop_recommendations(body: NextStopRequest):
dests = await pb.get_destinations()
recommended = recommend_destinations(
dests,
budget=body.budget,
internet=body.internet,
climate=body.climate,
tags=body.tags,
priority=body.priority,
limit=body.limit,
)
return NextStopResponse(
items=[RecommendedDestination(**d) for d in recommended],
routes=[NomadRoute(**r) for r in community_data.NOMAD_ROUTES],
)
@router.get("/meetups/{meetup_id}/session", response_model=MeetupSession)
async def meetup_session(meetup_id: str, authorization: str | None = Header(None)):
meetup = community_store.get_meetup(meetup_id)
if not meetup:
raise HTTPException(404, "活动不存在")
user = None
if authorization and authorization.startswith("Bearer "):
user = get_user_by_token(authorization[7:])
is_vip = social_store.is_vip(user["id"]) if user else False
can_join, lock = can_access_meetup(meetup, user, is_vip)
mode = meetup.get("mode", "offline")
display = (user or {}).get("name", "访客")
session = MeetupSession(
canJoin=can_join and mode in ("online", "hybrid"),
lockReason=lock if not can_join else "",
displayName=display,
mode=mode,
mirotalkRoom=meetup.get("mirotalkRoom") or create_mirotalk_room_slug(meetup),
loungeChannel=meetup.get("loungeChannel", ""),
)
if session.canJoin:
session.videoUrl = build_mirotalk_join_url(meetup, display)
session.chatUrl = build_lounge_chat_url(meetup)
return session
# ── Digital academy content ──
@router.get("/digital/course")
async def digital_course():
return {"modules": digital_content.COURSE_MODULES, "siteId": digital_content.SITE_ID}
@router.get("/digital/lessons/{module_index}/{lesson_index}", response_model=DigitalLesson)
async def digital_lesson(module_index: int, lesson_index: int, authorization: str | None = Header(None)):
key = f"{module_index}-{lesson_index}"
lesson = digital_content.LESSONS.get(key)
if not lesson:
raise HTTPException(404, "课时不存在")
if not lesson.get("free"):
user = None
if authorization and authorization.startswith("Bearer "):
user = get_user_by_token(authorization[7:])
if not user or not social_store.is_vip(user["id"]):
raise HTTPException(403, "需要 VIP 会员解锁")
return DigitalLesson(**lesson)
@router.get("/digital/jobs", response_model=list[DigitalJob])
async def digital_jobs():
return [DigitalJob(**j) for j in digital_content.JOBS]
@router.get("/digital/vip/check")
async def digital_vip_check(authorization: str | None = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
return {"vip": False}
user = get_user_by_token(authorization[7:])
if not user:
return {"vip": False}
return {"vip": social_store.is_vip(user["id"])}

View File

@ -0,0 +1,121 @@
"""Community mutations — discussions, meetups, gigs, notifications."""
from fastapi import APIRouter, Header, HTTPException, Query
from app.schemas import (
CreateDiscussionRequest, CreateMeetupRequest, CreateReplyRequest,
FeedbackRequest, GigApplyRequest, GigItem, NotificationItem, StatsOverview,
)
from app.services.auth import get_user_by_token
from app.services import community_store, social_store
router = APIRouter(tags=["community"])
def _user(authorization: str | None) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "未登录")
user = get_user_by_token(authorization[7:])
if not user:
raise HTTPException(401, "未登录")
return user
@router.post("/discussions")
async def create_discussion(body: CreateDiscussionRequest, authorization: str | None = Header(None)):
user = _user(authorization)
d = community_store.create_discussion(user["id"], user["name"], body.model_dump())
return {"success": True, "discussion": d}
@router.post("/discussions/{discussion_id}/replies")
async def post_reply(
discussion_id: str,
body: CreateReplyRequest,
authorization: str | None = Header(None),
):
user = _user(authorization)
reply = community_store.add_reply(discussion_id, user["id"], user["name"], body.content, body.author_emoji)
if not reply:
raise HTTPException(404, "讨论不存在")
return {"success": True, "reply": reply}
@router.post("/discussions/{discussion_id}/like")
async def like_discussion(discussion_id: str, authorization: str | None = Header(None)):
user = _user(authorization)
return community_store.toggle_discussion_like(discussion_id, user["id"])
@router.post("/discussions/{discussion_id}/pin")
async def pin_discussion(discussion_id: str, authorization: str | None = Header(None)):
user = _user(authorization)
d = community_store.toggle_pin(discussion_id, user["id"])
if not d:
raise HTTPException(403, "仅作者可置顶")
return {"success": True, "discussion": d}
@router.post("/meetups")
async def create_meetup(body: CreateMeetupRequest, authorization: str | None = Header(None)):
user = _user(authorization)
m = community_store.create_meetup(user["id"], user["name"], body.model_dump())
return {"success": True, "meetup": m}
@router.get("/meetups/my-rsvp")
async def my_rsvps(authorization: str | None = Header(None)):
user = _user(authorization)
return {"ids": list(community_store.user_rsvp_ids(user["id"]))}
@router.get("/gigs", response_model=list[GigItem])
async def list_gigs():
return [GigItem(**g) for g in community_store.list_gigs()]
@router.post("/gigs/{gig_id}/apply")
async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | None = Header(None)):
user = _user(authorization)
res = community_store.apply_gig(gig_id, user["id"], user["name"], body.message)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "申请失败"))
return res
@router.get("/notifications", response_model=list[NotificationItem])
async def list_notifications(authorization: str | None = Header(None)):
user = _user(authorization)
return [NotificationItem(**n) for n in community_store.list_notifications(user["id"])]
@router.post("/notifications/read")
async def mark_read(authorization: str | None = Header(None)):
user = _user(authorization)
community_store.mark_notifications_read(user["id"])
return {"success": True}
@router.post("/feedback")
async def submit_feedback(body: FeedbackRequest, authorization: str | None = Header(None)):
user = None
name = body.name or "访客"
if authorization and authorization.startswith("Bearer "):
user = get_user_by_token(authorization[7:])
if user:
name = user["name"]
item = community_store.add_feedback(user["id"] if user else None, name, body.content, body.category)
return {"success": True, "id": item["id"]}
@router.get("/stats/overview", response_model=StatsOverview)
async def stats_overview():
return StatsOverview(**community_store.stats_overview())
@router.get("/members/{user_id}")
async def public_member(user_id: str):
profile = social_store.get_public_profile(user_id)
if not profile:
raise HTTPException(404, "用户不存在")
return profile

View File

@ -0,0 +1,132 @@
"""Payment + order fulfillment — ZPay/XorPay from NomadCNA."""
from fastapi import APIRouter, Header, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from app.schemas import PayCreateRequest, PayCreateResponse, PayStatusResponse
from app.services.auth import get_user_by_token
from app.services import social_store
from app.services.payment_service import (
DEV_AUTO_PAY,
PAYMENT_DEFAULT_AMOUNT,
PAYMENT_JOIN_AMOUNT,
create_pay_redirect,
parse_notify_order_id,
query_order_paid,
verify_notify,
)
router = APIRouter(prefix="/pay", tags=["payment"])
def _token_user(authorization: str | None) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "未登录")
user = get_user_by_token(authorization[7:])
if not user:
raise HTTPException(401, "未登录")
return user
def _client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return ""
@router.post("/create", response_model=PayCreateResponse)
async def create_payment(
body: PayCreateRequest,
request: Request,
authorization: str | None = Header(None),
):
user = _token_user(authorization)
amount = PAYMENT_JOIN_AMOUNT if body.pay_type == "join" else PAYMENT_DEFAULT_AMOUNT
order = social_store.create_order(user["id"], body.pay_type, amount, dev_auto_pay=DEV_AUTO_PAY)
return_url = body.return_url or "https://nomadweb.nomadro.com/join/paid"
ua = request.headers.get("user-agent", "")
device = "wechat" if "micromessenger" in ua.lower() else "pc"
redirect = await create_pay_redirect(
order["id"],
amount,
return_url,
body.provider or "",
channel="wxpay" if device == "wechat" else "alipay",
device=device,
user_agent=ua,
client_ip=_client_ip(request),
)
return PayCreateResponse(
order_id=order["id"],
amount=amount,
redirect_url=redirect,
status=order["status"],
)
@router.get("/status", response_model=PayStatusResponse)
async def pay_status(order_id: str = Query(...), authorization: str | None = Header(None)):
_token_user(authorization)
order = social_store.get_order(order_id)
if not order:
raise HTTPException(404, "订单不存在")
if order["status"] != "paid":
if DEV_AUTO_PAY or await query_order_paid(order_id):
social_store.mark_order_paid(order_id)
order = social_store.get_order(order_id)
return PayStatusResponse(
order_id=order_id,
status=order["status"],
paid=order["status"] == "paid",
)
@router.post("/complete")
async def complete_order(
order_id: str = Query(...),
authorization: str | None = Header(None),
):
user = _token_user(authorization)
order = social_store.get_order(order_id)
if not order:
raise HTTPException(404, "订单不存在")
if order["userId"] != user["id"]:
raise HTTPException(403, "无权操作此订单")
if order["status"] != "paid":
if DEV_AUTO_PAY or await query_order_paid(order_id):
social_store.mark_order_paid(order_id)
else:
raise HTTPException(402, "订单未支付")
return {"success": True, "vip": social_store.is_vip(user["id"])}
async def _notify_handler(provider: str, request: Request) -> PlainTextResponse:
if request.method == "GET":
data = dict(request.query_params)
else:
form = await request.form()
data = dict(form)
if not verify_notify(provider, data):
raise HTTPException(400, "验签失败")
oid = parse_notify_order_id(provider, data)
if oid:
social_store.mark_order_paid(oid)
return PlainTextResponse("success" if provider == "zpay" else "ok")
@router.api_route("/zpay_notify", methods=["GET", "POST"])
async def zpay_notify(request: Request):
return await _notify_handler("zpay", request)
@router.api_route("/xorpay_notify", methods=["GET", "POST"])
async def xorpay_notify(request: Request):
return await _notify_handler("xorpay", request)
@router.api_route("/notify", methods=["GET", "POST"])
async def pay_notify_legacy(request: Request, provider: str = Query("zpay")):
return await _notify_handler(provider, request)

View File

@ -0,0 +1,134 @@
"""Matching, chat, join, VIP — adapted from NomadCNA."""
from fastapi import APIRouter, Header, HTTPException, Query
from app.schemas import (
ChatMessage, ConversationItem, JoinMemberRequest, MatchProfile, MatchQuota,
SendMessageRequest, SwipeRequest, SwipeResponse, VipStatus,
)
from app.services.auth import get_user_by_token
from app.services import social_store
router = APIRouter(prefix="/social", tags=["social"])
def _token_user(authorization: str | None) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "未登录")
user = get_user_by_token(authorization[7:])
if not user:
raise HTTPException(401, "未登录")
return user
@router.post("/join")
async def join_member(body: JoinMemberRequest, authorization: str | None = Header(None)):
user = _token_user(authorization)
profile = social_store.join_member(user["id"], user["name"], body.model_dump())
return {"success": True, "profile": profile}
@router.get("/join/status")
async def join_status(authorization: str | None = Header(None)):
user = _token_user(authorization)
profile = social_store._profiles.get(user["id"])
return {"complete": bool(profile), "profile": profile}
@router.get("/matches/candidates", response_model=list[MatchProfile])
async def match_candidates(
authorization: str | None = Header(None),
intent: str = Query("friends"),
city: str = Query(""),
gender: str = Query(""),
single: str = Query(""),
exclude_swiped: bool = Query(True),
):
user = _token_user(authorization)
if user["id"] not in social_store._profiles:
raise HTTPException(400, "请先完成加入资料")
return [MatchProfile(**p) for p in social_store.list_candidates(
user["id"], intent=intent, city=city, gender=gender, single=single, exclude_swiped=exclude_swiped
)]
@router.get("/matches/quota", response_model=MatchQuota)
async def match_quota(authorization: str | None = Header(None)):
user = _token_user(authorization)
return MatchQuota(**social_store.get_quota(user["id"]))
@router.post("/matches/swipes/undo")
async def undo_swipe(authorization: str | None = Header(None)):
user = _token_user(authorization)
res = social_store.undo_last_swipe(user["id"])
if not res.get("ok"):
raise HTTPException(400, "没有可撤销的滑动")
return res
@router.get("/matches/likes", response_model=list[MatchProfile])
async def match_likes(authorization: str | None = Header(None)):
user = _token_user(authorization)
return [MatchProfile(**p) for p in social_store.list_likes(user["id"])]
@router.get("/matches/mutual")
async def mutual_matches(authorization: str | None = Header(None)):
user = _token_user(authorization)
return {"items": social_store.list_mutual(user["id"])}
@router.post("/matches/swipes", response_model=SwipeResponse)
async def record_swipe(body: SwipeRequest, authorization: str | None = Header(None)):
user = _token_user(authorization)
result = social_store.record_swipe(user["id"], body.profileId, body.action, body.intent)
if not result.get("ok"):
err = result.get("error")
if err == "quota_exceeded":
raise HTTPException(429, "今日滑动次数已用完")
if err == "vip_required":
raise HTTPException(403, "超级喜欢需要 VIP")
if err == "duplicate":
raise HTTPException(409, "已滑动过该用户")
raise HTTPException(400, err or "滑动失败")
return SwipeResponse(**result)
@router.get("/conversations", response_model=list[ConversationItem])
async def list_conversations(authorization: str | None = Header(None)):
user = _token_user(authorization)
items = social_store.list_conversations(user["id"])
return [ConversationItem(**c) for c in items]
@router.get("/conversations/{conv_id}")
async def get_conversation(conv_id: str, authorization: str | None = Header(None)):
user = _token_user(authorization)
conv = social_store.get_conversation(conv_id, user["id"])
if not conv:
raise HTTPException(404, "会话不存在")
return conv
@router.get("/conversations/{conv_id}/messages", response_model=list[ChatMessage])
async def get_messages(conv_id: str, authorization: str | None = Header(None), limit: int = 50):
user = _token_user(authorization)
return [ChatMessage(**m) for m in social_store.list_messages(conv_id, user["id"], limit)]
@router.post("/conversations/{conv_id}/messages", response_model=ChatMessage)
async def post_message(conv_id: str, body: SendMessageRequest, authorization: str | None = Header(None)):
user = _token_user(authorization)
msg = social_store.send_message(conv_id, user["id"], body.body)
if not msg:
raise HTTPException(400, "发送失败")
return ChatMessage(**msg)
@router.get("/vip/check", response_model=VipStatus)
async def vip_check(authorization: str | None = Header(None)):
user = _token_user(authorization)
vip = social_store.is_vip(user["id"])
exp = (social_store._memberships.get(user["id"]) or {}).get("expires_at", 0)
return VipStatus(vip=vip, expires_at=exp)

View File

@ -185,3 +185,288 @@ class SearchResult(BaseModel):
subtitle: str subtitle: str
emoji: str emoji: str
url: str url: str
class Meetup(BaseModel):
id: str
title: str
city: str
destination_slug: str = ""
emoji: str
date: str
time: str
venue: str
description: str
mode: str # online | offline | hybrid
access_level: str = "public"
mirotalkRoom: str = ""
loungeChannel: str = ""
rsvp_count: int = 0
max_attendees: int = 50
organizer: str = ""
tags: list[str] = []
is_upcoming: bool = True
class Discussion(BaseModel):
id: str
title: str
excerpt: str
author: str
author_emoji: str = "🧑‍💻"
category: str
reply_count: int = 0
like_count: int = 0
is_pinned: bool = False
created_at: str
tags: list[str] = []
class DiscussionReply(BaseModel):
id: str
author: str
author_emoji: str = "🧑‍💻"
content: str
created_at: str
like_count: int = 0
class DiscussionDetail(Discussion):
replies: list[DiscussionReply] = []
class NomadRoute(BaseModel):
id: str
slug: str
title: str
duration_days: int
budget: int
description: str
stops: list[str]
emoji: str = "🗺️"
class NextStopRequest(BaseModel):
budget: int = Field(default=8000, ge=2000, le=50000)
internet: int = Field(default=50, ge=10, le=500)
climate: str = "mild" # warm | mild | cool
tags: list[str] = Field(default_factory=list)
priority: str = "balanced" # cost | speed | community | balanced
limit: int = Field(default=12, ge=1, le=24)
class RecommendedDestination(Destination):
match_score: float = 0
match_reasons: list[str] = []
class NextStopResponse(BaseModel):
items: list[RecommendedDestination]
routes: list[NomadRoute] = []
class MeetupRsvpRequest(BaseModel):
meetup_id: str
name: str = Field(default="访客", min_length=1)
# ── Social / matching / chat ──
class MatchProfile(BaseModel):
id: str
userId: str = ""
name: str
location: str = ""
citySlug: str = ""
gender: str = ""
single: str = ""
bio: str = ""
photo: str = "🧑‍💻"
tags: list[str] = []
lookingFor: list[str] = []
intent: str = ""
class SwipeRequest(BaseModel):
profileId: str
action: str = "like" # like | dislike | superlike
intent: str = "friends"
class SwipeResponse(BaseModel):
ok: bool
matched: bool = False
match: dict | None = None
error: str | None = None
class MatchQuota(BaseModel):
vip: bool
limit: int
used: int
remaining: int
class JoinMemberRequest(BaseModel):
city: str = ""
citySlug: str = ""
gender: str = ""
single: str = ""
bio: str = ""
lookingFor: list[str] = Field(default_factory=lambda: ["friends", "explore"])
photo: str = "🧑‍💻"
class ChatPeer(BaseModel):
id: str = ""
userId: str = ""
name: str = ""
photo: str = "🧑‍💻"
location: str = ""
class ConversationItem(BaseModel):
id: str
type: str = "direct"
intent: str = ""
lastMessageAt: str = ""
lastMessagePreview: str = ""
unreadCount: int = 0
peer: MatchProfile | None = None
class ChatMessage(BaseModel):
id: str
conversationId: str = ""
senderId: str
body: str
createdAt: str = ""
mine: bool = False
class SendMessageRequest(BaseModel):
body: str = Field(min_length=1, max_length=2000)
class VipStatus(BaseModel):
vip: bool
expires_at: int = 0
class PayCreateRequest(BaseModel):
pay_type: str = "join" # join | vip | meetup
return_url: str = ""
provider: str = "zpay"
class PayCreateResponse(BaseModel):
order_id: str
amount: int
redirect_url: str
status: str
class PayStatusResponse(BaseModel):
order_id: str
status: str
paid: bool
class MeetupSession(BaseModel):
canJoin: bool
lockReason: str = ""
displayName: str = ""
videoUrl: str = ""
chatUrl: str = ""
loungeChannel: str = ""
mirotalkRoom: str = ""
meetingProvider: str = "mirotalk"
mode: str = ""
class DigitalLesson(BaseModel):
title: str
moduleIndex: int
lessonIndex: int
duration: str = ""
free: bool = False
content: str = ""
class DigitalJob(BaseModel):
id: str
title: str
company: str
location: str
type: str
salary: str
tags: list[str] = []
url: str = ""
class CreateDiscussionRequest(BaseModel):
title: str = Field(min_length=4, max_length=120)
excerpt: str = ""
content: str = ""
category: str = "社区"
tags: list[str] = Field(default_factory=list)
author_emoji: str = "🧑‍💻"
class CreateReplyRequest(BaseModel):
content: str = Field(min_length=1, max_length=4000)
author_emoji: str = "🧑‍💻"
class CreateMeetupRequest(BaseModel):
title: str = Field(min_length=4, max_length=120)
city: str = "线上"
destination_slug: str = ""
emoji: str = "🎉"
date: str = ""
time: str = "19:00"
venue: str = ""
description: str = ""
mode: str = "offline"
access_level: str = "public"
max_attendees: int = 30
tags: list[str] = Field(default_factory=list)
mirotalkRoom: str = ""
loungeChannel: str = ""
class GigItem(BaseModel):
id: str
title: str
description: str
budget: str
deadline: str
tags: list[str] = []
poster: str = ""
status: str = "open"
class GigApplyRequest(BaseModel):
message: str = Field(min_length=4, max_length=2000)
class NotificationItem(BaseModel):
id: str
title: str
body: str = ""
link: str = ""
read: bool = False
created_at: str = ""
class FeedbackRequest(BaseModel):
content: str = Field(min_length=4, max_length=4000)
category: str = "general"
name: str = ""
class StatsOverview(BaseModel):
meetups: int = 0
discussions: int = 0
gigs: int = 0
members_active: int = 0

View File

@ -109,8 +109,22 @@ def login_demo() -> dict[str, Any] | None:
return {"token": token, "user": _user_profile(user)} return {"token": token, "user": _user_profile(user)}
def _reload_sessions() -> None:
if not STORE_PATH.exists():
return
try:
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
global _sessions
_sessions.update(data.get("sessions") or {})
except (OSError, json.JSONDecodeError):
return
def get_user_by_token(token: str) -> dict | None: def get_user_by_token(token: str) -> dict | None:
uid = _sessions.get(token) uid = _sessions.get(token)
if not uid:
_reload_sessions()
uid = _sessions.get(token)
if not uid: if not uid:
return None return None
for user in _users.values(): for user in _users.values():

View File

@ -0,0 +1,394 @@
"""Persistent community data: discussions, meetups, gigs, notifications."""
from __future__ import annotations
import json
import re
import secrets
import time
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.data import community_data
STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "community_store.json"
_meetups: list[dict] = []
_discussions: list[dict] = []
_replies: dict[str, list[dict]] = {}
_discussion_likes: dict[str, set[str]] = {} # discussion_id -> user_ids
_reply_likes: dict[str, set[str]] = {}
_rsvps: dict[str, set[str]] = {} # meetup_id -> user_ids
_gigs: list[dict] = []
_gig_apps: list[dict] = []
_notifications: dict[str, list[dict]] = {} # user_id -> items
_feedback: list[dict] = []
_seeded = False
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def _slugify(text: str) -> str:
s = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", text.lower()).strip("-")
return (s[:48] or secrets.token_hex(4))
def _persist() -> None:
STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
STORE_PATH.write_text(
json.dumps({
"meetups": _meetups,
"discussions": _discussions,
"replies": _replies,
"discussion_likes": {k: list(v) for k, v in _discussion_likes.items()},
"reply_likes": {k: list(v) for k, v in _reply_likes.items()},
"rsvps": {k: list(v) for k, v in _rsvps.items()},
"gigs": _gigs,
"gig_apps": _gig_apps,
"notifications": _notifications,
"feedback": _feedback,
"seeded": _seeded,
}, ensure_ascii=False),
encoding="utf-8",
)
def _restore() -> None:
global _meetups, _discussions, _replies, _discussion_likes, _reply_likes
global _rsvps, _gigs, _gig_apps, _notifications, _feedback, _seeded
if not STORE_PATH.exists():
return
try:
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
_meetups = data.get("meetups") or []
_discussions = data.get("discussions") or []
_replies = data.get("replies") or {}
_discussion_likes = {k: set(v) for k, v in (data.get("discussion_likes") or {}).items()}
_reply_likes = {k: set(v) for k, v in (data.get("reply_likes") or {}).items()}
_rsvps = {k: set(v) for k, v in (data.get("rsvps") or {}).items()}
_gigs = data.get("gigs") or []
_gig_apps = data.get("gig_apps") or []
_notifications = data.get("notifications") or {}
_feedback = data.get("feedback") or []
_seeded = bool(data.get("seeded"))
def _reload() -> None:
_restore()
def _seed_if_needed() -> None:
global _seeded
_reload()
if _seeded:
return
_meetups = deepcopy(community_data.MEETUPS)
_discussions = deepcopy(community_data.DISCUSSIONS)
_replies = deepcopy(community_data.DISCUSSION_REPLIES)
_gigs = deepcopy(getattr(community_data, "GIGS", _default_gigs()))
_seeded = True
_persist()
def _default_gigs() -> list[dict]:
return [
{
"id": "logo-design",
"title": "为游民社区设计 Logo",
"description": "需要扁平风格,含地球+代码元素,交付 SVG。",
"budget": "¥800",
"deadline": "2026-09-15",
"tags": ["设计", "远程"],
"poster": "nomadro",
"status": "open",
},
{
"id": "meetup-host",
"title": "深圳线下活动主持人",
"description": "协助组织 20 人规模的游民交流会,有经验优先。",
"budget": "¥500/场",
"deadline": "2026-09-20",
"tags": ["活动", "深圳"],
"poster": "深圳湾区主理人",
"status": "open",
},
{
"id": "content-translate",
"title": "签证指南英文润色",
"description": "将 3 篇中文签证攻略翻译润色为英文博客。",
"budget": "¥1200",
"deadline": "2026-10-01",
"tags": ["翻译", "内容"],
"poster": "nomadro",
"status": "open",
},
]
_seed_if_needed()
def _discussion_like_count(did: str) -> int:
return len(_discussion_likes.get(did, set()))
def _reply_like_count(rid: str) -> int:
return len(_reply_likes.get(rid, set()))
def _enrich_discussion(d: dict) -> dict:
did = d["id"]
replies = _replies.get(did, [])
base_likes = d.get("like_count", 0)
stored = _discussion_like_count(did)
return {
**d,
"reply_count": len(replies),
"like_count": max(base_likes, stored) if stored == 0 else base_likes + stored,
}
def list_meetups(upcoming: bool = True) -> list[dict]:
_seed_if_needed()
items = list(_meetups)
if upcoming:
items = [m for m in items if m.get("is_upcoming", True)]
for m in items:
mid = m["id"]
m["rsvp_count"] = max(m.get("rsvp_count", 0), len(_rsvps.get(mid, set())))
return items
def get_meetup(meetup_id: str) -> dict | None:
_seed_if_needed()
for m in _meetups:
if m["id"] == meetup_id:
out = dict(m)
out["rsvp_count"] = max(out.get("rsvp_count", 0), len(_rsvps.get(meetup_id, set())))
return out
return None
def create_meetup(user_id: str, user_name: str, payload: dict) -> dict:
_seed_if_needed()
mid = _slugify(payload.get("title", "meetup")) + "-" + secrets.token_hex(3)
mode = payload.get("mode", "offline")
room = payload.get("mirotalkRoom") or ""
if mode in ("online", "hybrid") and not room:
room = f"nomadro-{_slugify(payload.get('city', 'online'))}-{secrets.token_hex(2)}"
lounge = payload.get("loungeChannel") or (f"#{room}" if room else "")
meetup = {
"id": mid,
"title": payload["title"],
"city": payload.get("city", "线上"),
"destination_slug": payload.get("destination_slug", ""),
"emoji": payload.get("emoji", "🎉"),
"date": payload.get("date", _now_iso()),
"time": payload.get("time", "19:00"),
"venue": payload.get("venue", "待定"),
"description": payload.get("description", ""),
"mode": mode,
"access_level": payload.get("access_level", "public"),
"mirotalkRoom": room,
"loungeChannel": lounge,
"meetingUrl": "",
"rsvp_count": 0,
"max_attendees": int(payload.get("max_attendees", 30)),
"organizer": user_name,
"organizer_id": user_id,
"tags": payload.get("tags", []),
"is_upcoming": True,
}
_meetups.insert(0, meetup)
_persist()
return meetup
def rsvp_meetup(meetup_id: str, user_id: str | None = None) -> dict:
_seed_if_needed()
m = get_meetup(meetup_id)
if not m:
return {"ok": False, "error": "not_found"}
uid = user_id or f"guest-{secrets.token_hex(4)}"
rset = _rsvps.setdefault(meetup_id, set())
if uid in rset:
return {"ok": True, "message": "已报名", "rsvp_count": len(rset)}
if len(rset) >= m["max_attendees"]:
return {"ok": False, "error": "full"}
rset.add(uid)
_persist()
return {"ok": True, "message": f"已报名「{m['title']}」", "rsvp_count": len(rset)}
def user_rsvp_ids(user_id: str) -> set[str]:
_seed_if_needed()
return {mid for mid, uids in _rsvps.items() if user_id in uids}
def list_discussions(category: str | None = None) -> list[dict]:
_seed_if_needed()
items = [_enrich_discussion(d) for d in _discussions]
if category:
items = [d for d in items if d["category"] == category]
pinned = sorted([d for d in items if d.get("is_pinned")], key=lambda x: x["created_at"], reverse=True)
rest = sorted([d for d in items if not d.get("is_pinned")], key=lambda x: x["created_at"], reverse=True)
return pinned + rest
def get_discussion(discussion_id: str) -> dict | None:
_seed_if_needed()
for d in _discussions:
if d["id"] == discussion_id:
enriched = _enrich_discussion(d)
replies = []
for r in _replies.get(discussion_id, []):
replies.append({**r, "like_count": r.get("like_count", 0) + _reply_like_count(r["id"])})
return {**enriched, "replies": replies}
return None
def create_discussion(user_id: str, user_name: str, payload: dict) -> dict:
_seed_if_needed()
did = _slugify(payload["title"]) + "-" + secrets.token_hex(3)
d = {
"id": did,
"title": payload["title"],
"excerpt": payload.get("excerpt") or payload.get("content", "")[:200],
"author": user_name,
"author_id": user_id,
"author_emoji": payload.get("author_emoji", "🧑‍💻"),
"category": payload.get("category", "社区"),
"reply_count": 0,
"like_count": 0,
"is_pinned": False,
"created_at": _now_iso(),
"tags": payload.get("tags", []),
}
_discussions.insert(0, d)
_replies[did] = []
_persist()
return _enrich_discussion(d)
def add_reply(discussion_id: str, user_id: str, user_name: str, content: str, emoji: str = "🧑‍💻") -> dict | None:
_seed_if_needed()
if not any(d["id"] == discussion_id for d in _discussions):
return None
rid = secrets.token_hex(6)
reply = {
"id": rid,
"author": user_name,
"author_id": user_id,
"author_emoji": emoji,
"content": content,
"created_at": _now_iso(),
"like_count": 0,
}
_replies.setdefault(discussion_id, []).append(reply)
_persist()
return reply
def toggle_discussion_like(discussion_id: str, user_id: str) -> dict:
_seed_if_needed()
likes = _discussion_likes.setdefault(discussion_id, set())
if user_id in likes:
likes.discard(user_id)
liked = False
else:
likes.add(user_id)
liked = True
_persist()
return {"liked": liked, "like_count": _discussion_like_count(discussion_id)}
def toggle_pin(discussion_id: str, user_id: str) -> dict | None:
_seed_if_needed()
for d in _discussions:
if d["id"] == discussion_id and d.get("author_id") == user_id:
d["is_pinned"] = not d.get("is_pinned", False)
_persist()
return _enrich_discussion(d)
return None
def list_gigs() -> list[dict]:
_seed_if_needed()
return [g for g in _gigs if g.get("status", "open") == "open"]
def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
_seed_if_needed()
gig = next((g for g in _gigs if g["id"] == gig_id), None)
if not gig:
return {"ok": False, "error": "not_found"}
for a in _gig_apps:
if a["gig_id"] == gig_id and a["user_id"] == user_id:
return {"ok": False, "error": "duplicate"}
_gig_apps.append({
"id": secrets.token_hex(6),
"gig_id": gig_id,
"user_id": user_id,
"user_name": user_name,
"message": message,
"created_at": _now_iso(),
})
_persist()
return {"ok": True, "message": "申请已提交"}
def add_notification(user_id: str, title: str, body: str, link: str = "") -> None:
items = _notifications.setdefault(user_id, [])
items.insert(0, {
"id": secrets.token_hex(6),
"title": title,
"body": body,
"link": link,
"read": False,
"created_at": _now_iso(),
})
items[:] = items[:50]
_persist()
def list_notifications(user_id: str) -> list[dict]:
_reload()
return _notifications.get(user_id, [])
def mark_notifications_read(user_id: str) -> None:
_reload()
for n in _notifications.get(user_id, []):
n["read"] = True
_persist()
def add_feedback(user_id: str | None, user_name: str, content: str, category: str = "general") -> dict:
_seed_if_needed()
item = {
"id": secrets.token_hex(6),
"user_id": user_id or "",
"user_name": user_name,
"content": content,
"category": category,
"created_at": _now_iso(),
}
_feedback.append(item)
_persist()
return item
def stats_overview() -> dict:
_seed_if_needed()
return {
"meetups": len(_meetups),
"discussions": len(_discussions),
"gigs": len(_gigs),
"members_active": len(_rsvps) + len(_gig_apps),
}

View File

@ -0,0 +1,68 @@
"""Meetup live room URL builders — ported from NomadCNA."""
from __future__ import annotations
import os
import re
from urllib.parse import urlencode
MIROTALK_URL = os.getenv("MIROTALK_URL", "https://mirotalk.nomadro.com")
LOUNGE_URL = os.getenv("LOUNGE_URL", "https://lounge.nomadro.com")
VIP_ROLES = frozenset({"admin", "owner", "host", "organizer", "moderator"})
def normalize_room(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", (value or "").lower()).strip("-")
return slug[:64] or "nomadro-live"
def create_mirotalk_room_slug(meetup: dict) -> str:
city = normalize_room(meetup.get("city") or "online")
date = (meetup.get("date") or "").replace("-", "")[:8]
time_part = (meetup.get("time") or "0000").replace(":", "")[:4]
mid = normalize_room(meetup.get("id") or "room")
return f"nomadro-{city}-{date}-{time_part}-{mid}"
def create_lounge_channel(meetup: dict) -> str:
if meetup.get("loungeChannel"):
ch = meetup["loungeChannel"].strip()
return ch if ch.startswith("#") else f"#{ch}"
if meetup.get("mirotalkRoom"):
return f"#{normalize_room(meetup['mirotalkRoom'])}"
return f"#{create_mirotalk_room_slug(meetup)}"
def build_mirotalk_join_url(meetup: dict, display_name: str = "nomadro") -> str:
explicit = (meetup.get("meetingUrl") or "").strip()
if explicit.startswith("http"):
return explicit
room = meetup.get("mirotalkRoom") or create_mirotalk_room_slug(meetup)
return f"{MIROTALK_URL.rstrip('/')}/join?{urlencode({'room': room, 'name': display_name})}"
def build_lounge_chat_url(meetup: dict) -> str:
channel = create_lounge_channel(meetup).lstrip("#")
return f"{LOUNGE_URL.rstrip('/')}/#/chan-{channel}"
def can_access_meetup(meetup: dict, user: dict | None, is_vip: bool = False) -> tuple[bool, str]:
level = meetup.get("access_level") or "public"
if level == "public":
return True, ""
if not user:
return False, "login_required"
if level == "members":
return True, ""
if level == "vip":
role = (user.get("role") or "").lower()
if is_vip or role in VIP_ROLES:
return True, ""
return False, "vip_required"
if level == "hosts":
role = (user.get("role") or "").lower()
if role in VIP_ROLES:
return True, ""
return False, "host_required"
return True, ""

View File

@ -0,0 +1,237 @@
"""ZPay / XorPay providers — ported from NomadCNA backend/payment.py."""
from __future__ import annotations
import hashlib
import json
import os
from typing import Any
import httpx
def _md5(value: str) -> str:
return hashlib.md5(value.encode("utf-8")).hexdigest().lower()
def _is_internal_ip(ip: str | None) -> bool:
if not ip:
return True
value = ip.strip().lower()
if value in {"127.0.0.1", "localhost", "::1"}:
return True
if value.startswith("10.") or value.startswith("192.168."):
return True
if value.startswith("172."):
parts = value.split(".")
if len(parts) > 1 and parts[1].isdigit():
return 16 <= int(parts[1]) <= 31
return False
def resolve_channel(channel: str | None) -> str:
return "wxpay" if str(channel or "").lower() in {"", "wx", "wechat", "wxpay"} else "alipay"
class ZPayProvider:
name = "zpay"
def __init__(self) -> None:
self.pid = os.getenv("ZPAY_PID", "2025121809351743")
self.key = os.getenv("ZPAY_KEY", "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89")
self.submit_url = os.getenv("ZPAY_SUBMIT_URL", "https://zpayz.cn/submit.php")
self.mapi_url = os.getenv("ZPAY_MAPI_URL", "https://zpayz.cn/mapi.php")
self.query_url = os.getenv("ZPAY_QUERY_URL", "https://zpayz.cn/api.php")
def sign(self, params: dict[str, Any]) -> str:
filtered = {
key: value
for key, value in params.items()
if key not in {"sign", "sign_type"} and value is not None and str(value) != ""
}
raw = "&".join(f"{key}={filtered[key]}" for key in sorted(filtered)) + self.key
return _md5(raw)
def create_order(
self,
*,
order_id: str,
name: str,
amount_yuan: str,
channel: str,
notify_url: str,
return_url: str,
client_ip: str = "",
) -> dict[str, Any]:
params: dict[str, Any] = {
"pid": self.pid,
"type": resolve_channel(channel),
"out_trade_no": order_id,
"notify_url": notify_url,
"return_url": return_url,
"name": name,
"money": amount_yuan,
"sign_type": "MD5",
}
if client_ip and not _is_internal_ip(client_ip):
params["clientip"] = client_ip
params["sign"] = self.sign(params)
return {
"status": "ok",
"provider": self.name,
"params": params,
"pay_url": self.submit_url,
"submit_method": "POST",
}
async def create_order_api(
self,
*,
order_id: str,
name: str,
amount_yuan: str,
channel: str,
notify_url: str,
return_url: str,
client_ip: str = "",
device: str = "pc",
) -> dict[str, Any]:
params: dict[str, Any] = {
"pid": self.pid,
"type": resolve_channel(channel),
"out_trade_no": order_id,
"notify_url": notify_url,
"return_url": return_url,
"name": name,
"money": amount_yuan,
"device": device,
"sign_type": "MD5",
}
if client_ip and not _is_internal_ip(client_ip):
params["clientip"] = client_ip
params["sign"] = self.sign(params)
try:
async with httpx.AsyncClient(timeout=15.0, trust_env=False) as client:
res = await client.post(self.mapi_url, data=params)
except Exception as exc:
return {"status": "error", "provider": self.name, "msg": str(exc)}
result: Any
try:
result = res.json()
except Exception:
result = {}
if isinstance(result, str):
try:
result = json.loads(result)
except Exception:
result = {}
if not isinstance(result, dict):
result = {}
msg = result.get("msg")
if isinstance(msg, str) and msg.strip().startswith("{"):
try:
inner = json.loads(msg)
if isinstance(inner, dict):
result = inner
except Exception:
pass
try:
code = int(float(result.get("code", 0)))
except (TypeError, ValueError):
code = 0
if code == 1:
return {
"status": "ok",
"provider": self.name,
"payurl": result.get("payurl"),
"payurl2": result.get("payurl2"),
"qrcode": result.get("qrcode"),
"img": result.get("img"),
"trade_no": result.get("trade_no"),
}
return {"status": "error", "provider": self.name, "msg": result.get("msg") or res.text[:500]}
def verify_notify(self, data: dict[str, Any]) -> bool:
return self.sign(data) == str(data.get("sign", ""))
async def query_order_status(self, order_id: str, timeout: float = 8.0) -> dict[str, Any]:
try:
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
res = await client.get(
self.query_url,
params={"act": "order", "pid": self.pid, "key": self.key, "out_trade_no": order_id},
)
data = res.json() if res.is_success else {}
except Exception as exc:
return {"paid": False, "error": str(exc)}
try:
code = int(float(data.get("code", 0)))
status = int(float(data.get("status", 0)))
except (TypeError, ValueError):
return {"paid": False, "msg": data.get("msg", "查询失败")}
pay_price = data.get("money") or data.get("pay_price") or ""
return {"paid": code == 1 and status == 1, "status": status, "pay_price": str(pay_price)}
class XorPayProvider:
name = "xorpay"
def __init__(self) -> None:
self.aid = os.getenv("XORPAY_AID", "8220")
self.secret = os.getenv("XORPAY_SECRET", "afcacd99570945f88de62624aaa3578e")
self.cashier_url = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cashier").rstrip("/")
self.query_url = os.getenv("XORPAY_QUERY_URL", "https://xorpay.com/api/query2").rstrip("/")
def create_order(self, *, order_id: str, name: str, amount_yuan: str, notify_url: str) -> dict[str, Any]:
params = {
"name": name,
"pay_type": "jsapi",
"price": amount_yuan,
"order_id": order_id,
"notify_url": notify_url,
}
params["sign"] = _md5(
params["name"] + params["pay_type"] + params["price"] + params["order_id"] + params["notify_url"] + self.secret
)
return {"status": "ok", "provider": self.name, "params": params, "pay_url": f"{self.cashier_url}/{self.aid}"}
def verify_notify(self, data: dict[str, Any]) -> bool:
raw = (
str(data.get("aoid", ""))
+ str(data.get("order_id", ""))
+ str(data.get("pay_price", ""))
+ str(data.get("pay_time", ""))
+ self.secret
)
return _md5(raw) == str(data.get("sign", ""))
async def query_order_status(self, order_id: str, timeout: float = 8.0) -> dict[str, Any]:
sign = _md5(order_id + self.secret)
try:
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
res = await client.get(f"{self.query_url}/{self.aid}", params={"order_id": order_id, "sign": sign})
data = res.json() if res.is_success else {}
status = str(data.get("status", "")).lower()
return {
"paid": status in {"payed", "success"},
"status": status,
"pay_price": data.get("pay_price") or data.get("price") or "",
}
except Exception as exc:
return {"paid": False, "error": str(exc)}
def provider_for(device: str = "pc", requested: str = "", user_agent: str = "") -> ZPayProvider | XorPayProvider:
req = requested.strip().lower()
default = os.getenv("PAYMENT_PROVIDER", "zpay").lower()
if req == "xorpay":
return XorPayProvider()
if req == "zpay":
return ZPayProvider()
if default == "xorpay":
return XorPayProvider()
if device.lower() == "wechat" or "micromessenger" in user_agent.lower():
return XorPayProvider()
return ZPayProvider()

View File

@ -0,0 +1,96 @@
"""Payment helpers — ZPay/XorPay from NomadCNA + DEV_AUTO_PAY toggle."""
from __future__ import annotations
import os
from urllib.parse import urlencode
from app.services.payment_providers import XorPayProvider, ZPayProvider, provider_for
DEV_AUTO_PAY = os.getenv("DEV_AUTO_PAY", "false").lower() in ("1", "true", "yes")
PAYMENT_JOIN_AMOUNT = int(os.getenv("PAYMENT_JOIN_AMOUNT", "10000")) # fen (100 yuan)
PAYMENT_DEFAULT_AMOUNT = int(os.getenv("PAYMENT_DEFAULT_AMOUNT", "10000"))
SITE_BASE = os.getenv("SITE_BASE_URL", "https://nomadweb.nomadro.com").rstrip("/")
PAY_NOTIFY_URL = os.getenv("PAY_NOTIFY_URL", f"{SITE_BASE}/api/v1/pay/zpay_notify")
def amount_yuan(amount_fen: int) -> str:
return f"{amount_fen / 100:.2f}"
def notify_url_for(provider_name: str) -> str:
if provider_name == "xorpay":
return os.getenv("XORPAY_NOTIFY_URL", f"{SITE_BASE}/api/v1/pay/xorpay_notify")
return os.getenv("ZPAY_NOTIFY_URL", PAY_NOTIFY_URL)
async def create_pay_redirect(
order_id: str,
amount: int,
return_url: str,
provider: str = "",
*,
channel: str = "alipay",
device: str = "pc",
user_agent: str = "",
client_ip: str = "",
) -> str:
if DEV_AUTO_PAY:
sep = "&" if "?" in return_url else "?"
return f"{return_url}{sep}order_id={order_id}&status=paid"
pay_provider = provider_for(device, provider or os.getenv("PAYMENT_PROVIDER", "zpay"), user_agent)
yuan = amount_yuan(amount)
name = "nomadro会员"
notify = notify_url_for(pay_provider.name)
if isinstance(pay_provider, XorPayProvider):
created = pay_provider.create_order(
order_id=order_id, name=name, amount_yuan=yuan, notify_url=notify,
)
return f"{created['pay_url']}?{urlencode(created['params'])}"
api_result = await pay_provider.create_order_api(
order_id=order_id,
name=name,
amount_yuan=yuan,
channel=channel,
notify_url=notify,
return_url=return_url,
client_ip=client_ip,
device=device,
)
if api_result.get("status") == "ok":
pay_url = api_result.get("payurl2") if device == "h5" and api_result.get("payurl2") else api_result.get("payurl")
if pay_url:
return str(pay_url)
created = pay_provider.create_order(
order_id=order_id,
name=name,
amount_yuan=yuan,
channel=channel,
notify_url=notify,
return_url=return_url,
client_ip=client_ip,
)
return f"{created['pay_url']}?{urlencode(created['params'])}"
async def query_order_paid(order_id: str) -> bool:
zpay, xorpay = await ZPayProvider().query_order_status(order_id), await XorPayProvider().query_order_status(order_id)
return bool(zpay.get("paid") or xorpay.get("paid"))
def verify_notify(provider: str, data: dict) -> bool:
if DEV_AUTO_PAY:
return True
if provider == "xorpay":
return XorPayProvider().verify_notify(data)
return ZPayProvider().verify_notify(data)
def parse_notify_order_id(provider: str, data: dict) -> str:
if provider == "xorpay":
return str(data.get("order_id") or "")
return str(data.get("out_trade_no") or data.get("order_id") or "")

View File

@ -0,0 +1,125 @@
"""Next-stop recommendation scoring — ported from NomadCNA logic, adapted to nomadro destinations."""
from __future__ import annotations
from typing import Any
def _parse_nomads_count(value: str) -> int:
digits = "".join(ch for ch in str(value) if ch.isdigit())
return int(digits) if digits else 0
def score_destination(
dest: dict[str, Any],
*,
budget: int,
internet: int,
climate: str,
tags: list[str],
priority: str = "balanced",
) -> tuple[float, list[str]]:
"""Return (score, match_reasons) for a destination dict."""
cost = int(dest.get("cost") or 0)
speed = int(dest.get("speed") or 0)
temp = int(dest.get("temperature") or 20)
rating = float(dest.get("rating") or 0)
nomads = _parse_nomads_count(dest.get("nomads_count") or "0")
highlights = dest.get("highlights") or []
tag = str(dest.get("tag") or "")
reasons: list[str] = []
score = rating * 8
# Budget fit (monthly cost in CNY-ish units)
if cost <= budget:
score += 28
reasons.append("预算内")
elif cost <= budget * 1.2:
score += 14
reasons.append("略超预算")
else:
score += max(0, 10 - (cost - budget) / 800)
# Internet
if speed >= internet:
score += 22
reasons.append("网络达标")
else:
score += max(0, 22 - (internet - speed) / 4)
reasons.append("需确认住处网络")
# Climate preference
if climate == "warm":
score += 18 if temp >= 24 else max(0, 18 - (24 - temp) * 2)
if temp >= 24:
reasons.append("气候偏暖")
elif climate == "cool":
score += 18 if temp <= 18 else max(0, 18 - (temp - 18) * 2)
if temp <= 18:
reasons.append("气候清爽")
else: # mild
score += 18 if 18 <= temp <= 26 else max(0, 18 - abs(temp - 22) * 2)
if 18 <= temp <= 26:
reasons.append("气候温和")
# Tag overlap (NomadCNA-style lifestyle tags)
dest_tags = set(highlights + [tag])
overlap = [t for t in tags if any(t in dt or dt in t for dt in dest_tags)]
if overlap:
score += min(15, len(overlap) * 5)
reasons.append(f"标签匹配:{overlap[0]}")
# Community density
if nomads >= 8000:
score += 12
reasons.append("社区活跃")
elif nomads >= 3000:
score += 6
# Priority boost
if priority == "cost":
score += (14000 - cost) / 120
elif priority == "speed":
score += speed / 5
elif priority == "community":
score += nomads / 400
if not reasons:
reasons.append("综合评分推荐")
return round(score, 1), reasons[:3]
def recommend_destinations(
destinations: list[dict[str, Any]],
*,
budget: int = 8000,
internet: int = 50,
climate: str = "mild",
tags: list[str] | None = None,
priority: str = "balanced",
limit: int = 12,
) -> list[dict[str, Any]]:
tags = tags or []
scored: list[tuple[float, dict[str, Any], list[str]]] = []
for dest in destinations:
s, reasons = score_destination(
dest,
budget=budget,
internet=internet,
climate=climate,
tags=tags,
priority=priority,
)
scored.append((s, dest, reasons))
scored.sort(key=lambda x: x[0], reverse=True)
items = []
for s, dest, reasons in scored[:limit]:
items.append({
**dest,
"match_score": s,
"match_reasons": reasons,
})
return items

View File

@ -0,0 +1,431 @@
"""Social graph: profiles, swipes, matches, DMs — file-backed store."""
from __future__ import annotations
import json
import secrets
import time
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any
from app.data.social_profiles import CANDIDATE_PROFILES, MATCH_INTENTS
STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "social_store.json"
LIKE_ACTIONS = frozenset({"like", "right", "superlike"})
FREE_SWIPE_DAILY_LIMIT = 25
VIP_SWIPE_LIMIT = 9999
_profiles: dict[str, dict] = {} # user_id -> profile
_swipes: list[dict] = []
_matches: list[dict] = []
_conversations: list[dict] = []
_messages: list[dict] = []
_memberships: dict[str, dict] = {} # user_id -> {expires_at, plan}
_orders: dict[str, dict] = {}
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _today_key() -> str:
return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d")
def _persist() -> None:
STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
STORE_PATH.write_text(
json.dumps({
"profiles": _profiles,
"swipes": _swipes,
"matches": _matches,
"conversations": _conversations,
"messages": _messages,
"memberships": _memberships,
"orders": _orders,
}, ensure_ascii=False),
encoding="utf-8",
)
def _restore() -> None:
global _profiles, _swipes, _matches, _conversations, _messages, _memberships, _orders
if not STORE_PATH.exists():
return
try:
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
_profiles = data.get("profiles") or {}
_swipes = data.get("swipes") or []
_matches = data.get("matches") or []
_conversations = data.get("conversations") or []
_messages = data.get("messages") or []
_memberships = data.get("memberships") or {}
_orders = data.get("orders") or {}
_restore()
def _reload() -> None:
_restore()
def is_vip(user_id: str) -> bool:
_reload()
m = _memberships.get(user_id)
if not m:
return False
exp = m.get("expires_at") or 0
return exp > int(time.time())
def ensure_membership(user_id: str, days: int = 365) -> None:
_memberships[user_id] = {
"plan": "vip",
"expires_at": int(time.time()) + days * 86400,
"updated_at": _now_iso(),
}
_persist()
def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict:
if user_id not in _profiles:
_profiles[user_id] = {
"userId": user_id,
"name": name,
"location": extra.get("location", "全球"),
"citySlug": extra.get("citySlug", ""),
"gender": extra.get("gender", ""),
"single": extra.get("single", ""),
"bio": extra.get("bio", ""),
"photo": extra.get("photo", "🧑‍💻"),
"tags": extra.get("tags", []),
"lookingFor": extra.get("lookingFor", ["friends", "explore"]),
"createdAt": _now_iso(),
}
_persist()
return _profiles[user_id]
def join_member(user_id: str, name: str, payload: dict) -> dict:
_reload()
profile = get_or_create_profile(
user_id,
name,
location=payload.get("city", "全球"),
citySlug=payload.get("citySlug", ""),
gender=payload.get("gender", ""),
single=payload.get("single", ""),
bio=payload.get("bio", ""),
lookingFor=payload.get("lookingFor", ["friends", "explore"]),
photo=payload.get("photo", "🧑‍💻"),
)
_persist()
return profile
def _profile_looking_for(record: dict) -> list[str]:
raw = record.get("lookingFor") or []
cleaned = [x for x in raw if x in MATCH_INTENTS]
return cleaned or ["friends", "explore"]
def list_candidates(
user_id: str,
intent: str = "friends",
city: str = "",
gender: str = "",
single: str = "",
exclude_swiped: bool = True,
) -> list[dict]:
_reload()
my_profile = _profiles.get(user_id)
swiped_ids = set()
if exclude_swiped:
for s in _swipes:
if s.get("userId") == user_id:
swiped_ids.add(s.get("profileId"))
pool = list(CANDIDATE_PROFILES)
if my_profile:
pool = [p for p in pool if p.get("userId") != user_id]
results = []
for p in pool:
if p["id"] in swiped_ids:
continue
if intent and intent not in _profile_looking_for(p):
continue
if city and city not in (p.get("location") or "") and city != p.get("citySlug"):
continue
if gender and is_vip(user_id) and gender and p.get("gender") != gender:
continue
if single and is_vip(user_id) and single and p.get("single") != single:
continue
results.append({**p, "intent": intent})
return results
def swipe_count_today(user_id: str) -> int:
today = _today_key()
return sum(1 for s in _swipes if s.get("userId") == user_id and s.get("date") == today)
def get_quota(user_id: str) -> dict:
_reload()
vip = is_vip(user_id)
limit = VIP_SWIPE_LIMIT if vip else FREE_SWIPE_DAILY_LIMIT
used = swipe_count_today(user_id)
return {"vip": vip, "limit": limit, "used": used, "remaining": max(0, limit - used)}
def _pair_key(a: str, b: str) -> str:
return "|".join(sorted([a, b]))
def _has_reciprocal_like(target_user_id: str, my_profile_id: str) -> bool:
target_profile = next((p for p in CANDIDATE_PROFILES if p.get("userId") == target_user_id), None)
if not target_profile:
return False
target_pid = target_profile["id"]
for s in _swipes:
if s.get("userId") == target_user_id and s.get("profileId") == my_profile_id:
if s.get("action") in LIKE_ACTIONS:
return True
return False
def _ensure_match(user_id: str, profile: dict, intent: str) -> dict | None:
target_uid = profile.get("userId")
if not target_uid:
return None
pk = _pair_key(user_id, target_uid)
for m in _matches:
if m.get("pairKey") == pk:
return m
conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True)
match = {
"id": secrets.token_hex(6),
"pairKey": pk,
"userAId": user_id,
"userBId": target_uid,
"profileAId": _profiles.get(user_id, {}).get("id", ""),
"profileBId": profile.get("id"),
"intent": intent,
"conversationId": conv["id"],
"matchedAt": _now_iso(),
}
_matches.append(match)
_persist()
return match
def _ensure_conversation(user_a: str, user_b: str, intent: str = "friends", match: bool = False) -> dict:
pk = _pair_key(user_a, user_b)
for c in _conversations:
if c.get("pairKey") == pk:
return c
conv = {
"id": secrets.token_hex(8),
"pairKey": pk,
"type": "match" if match else "direct",
"userAId": user_a,
"userBId": user_b,
"intent": intent,
"lastMessageAt": _now_iso(),
"lastMessagePreview": "",
"readState": {user_a: _now_iso(), user_b: _now_iso()},
}
_conversations.append(conv)
_persist()
return conv
def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dict:
_reload()
if action not in LIKE_ACTIONS and action != "dislike":
action = "dislike"
if action == "superlike" and not is_vip(user_id):
return {"ok": False, "error": "vip_required"}
q = get_quota(user_id)
if not q["vip"] and q["remaining"] <= 0 and action in LIKE_ACTIONS:
return {"ok": False, "error": "quota_exceeded"}
for s in _swipes:
if s.get("userId") == user_id and s.get("profileId") == profile_id:
return {"ok": False, "error": "duplicate"}
profile = next((p for p in CANDIDATE_PROFILES if p["id"] == profile_id), None)
if not profile:
return {"ok": False, "error": "not_found"}
_swipes.append({
"id": secrets.token_hex(6),
"userId": user_id,
"profileId": profile_id,
"action": action,
"intent": intent,
"date": _today_key(),
"createdAt": _now_iso(),
})
_persist()
matched = None
if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id):
matched = _ensure_match(user_id, profile, intent)
if matched:
from app.services import community_store
my_name = _profiles.get(user_id, {}).get("name", "游民")
peer_name = profile.get("name", "游民")
conv_id = matched.get("conversationId", "")
link = f"/chat/{conv_id}" if conv_id else "/chat"
community_store.add_notification(
user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link
)
peer_uid = profile.get("userId")
if peer_uid:
community_store.add_notification(
peer_uid, "匹配成功 🎉", f"你和 {my_name} 互相喜欢了,快去聊天吧", link
)
return {
"ok": True,
"matched": bool(matched),
"match": matched,
}
def get_public_profile(user_id: str) -> dict | None:
_reload()
p = _profiles.get(user_id)
if not p:
return None
return {**p, "vip": is_vip(user_id)}
def undo_last_swipe(user_id: str) -> dict:
_reload()
for i in range(len(_swipes) - 1, -1, -1):
if _swipes[i].get("userId") == user_id:
removed = _swipes.pop(i)
_persist()
return {"ok": True, "removed": removed}
return {"ok": False, "error": "nothing_to_undo"}
def list_likes(user_id: str) -> list[dict]:
_reload()
liked_ids = [s["profileId"] for s in _swipes if s.get("userId") == user_id and s.get("action") in LIKE_ACTIONS]
return [p for p in CANDIDATE_PROFILES if p["id"] in liked_ids]
def list_mutual(user_id: str) -> list[dict]:
_reload()
items = []
for m in _matches:
if user_id in (m.get("userAId"), m.get("userBId")):
peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"]
peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None)
items.append({**m, "peer": peer, "conversationId": m.get("conversationId")})
return items
def list_conversations(user_id: str) -> list[dict]:
_reload()
items = []
for c in _conversations:
if user_id not in (c.get("userAId"), c.get("userBId")):
continue
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None)
read_at = (c.get("readState") or {}).get(user_id, "")
unread = 1 if c.get("lastMessageAt", "") > read_at else 0
items.append({
**c,
"peer": peer,
"unreadCount": unread,
})
items.sort(key=lambda x: x.get("lastMessageAt", ""), reverse=True)
return items
def get_conversation(conv_id: str, user_id: str) -> dict | None:
for c in _conversations:
if c["id"] == conv_id and user_id in (c.get("userAId"), c.get("userBId")):
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None)
return {**c, "peer": peer}
return None
def list_messages(conv_id: str, user_id: str, limit: int = 50) -> list[dict]:
_reload()
conv = get_conversation(conv_id, user_id)
if not conv:
return []
msgs = [m for m in _messages if m.get("conversationId") == conv_id]
msgs.sort(key=lambda x: x.get("createdAt", ""))
if conv.get("readState") is not None:
conv["readState"][user_id] = _now_iso()
_persist()
return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]]
def send_message(conv_id: str, user_id: str, body: str) -> dict | None:
conv = get_conversation(conv_id, user_id)
if not conv or not body.strip():
return None
body = body.strip()[:2000]
msg = {
"id": secrets.token_hex(8),
"conversationId": conv_id,
"senderId": user_id,
"body": body,
"createdAt": _now_iso(),
}
_messages.append(msg)
for c in _conversations:
if c["id"] == conv_id:
c["lastMessageAt"] = _now_iso()
c["lastMessagePreview"] = body[:80]
break
_persist()
return {**msg, "mine": True}
def create_order(user_id: str, pay_type: str, amount: int, dev_auto_pay: bool = False) -> dict:
order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{secrets.token_hex(2)}"
status = "paid" if dev_auto_pay else "pending"
order = {
"id": order_id,
"userId": user_id,
"payType": pay_type,
"amount": amount,
"status": status,
"createdAt": _now_iso(),
}
_orders[order_id] = order
if dev_auto_pay:
ensure_membership(user_id)
_persist()
return order
def get_order(order_id: str) -> dict | None:
_reload()
return _orders.get(order_id)
def mark_order_paid(order_id: str) -> dict | None:
order = _orders.get(order_id)
if not order:
return None
order["status"] = "paid"
ensure_membership(order["userId"])
_persist()
return order

View File

@ -0,0 +1,12 @@
lounge.nomadro.com {
encode gzip
reverse_proxy https://lounge.nomadro.cn {
header_up Host lounge.nomadro.cn
transport http {
tls_insecure_skip_verify
}
}
header {
Content-Security-Policy "frame-ancestors 'self' https://nomadweb.nomadro.com https://nomadro.cn https://www.nomadro.cn http://localhost:3001 http://127.0.0.1:3001"
}
}

View File

@ -0,0 +1,12 @@
mirotalk.nomadro.com {
encode gzip
reverse_proxy https://mirotalk.nomadro.cn {
header_up Host mirotalk.nomadro.cn
transport http {
tls_insecure_skip_verify
}
}
header {
Content-Security-Policy "frame-ancestors 'self' https://nomadweb.nomadro.com https://nomadro.cn https://www.nomadro.cn http://localhost:3001 http://127.0.0.1:3001"
}
}

View File

@ -0,0 +1,22 @@
# Copy to nomadro-api.env on the server — DO NOT commit real keys
DEV_AUTO_PAY=false
PAYMENT_PROVIDER=zpay
PAYMENT_JOIN_AMOUNT=10000
PAYMENT_DEFAULT_AMOUNT=10000
SITE_BASE_URL=https://nomadweb.nomadro.com
ZPAY_PID=
ZPAY_KEY=
ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php
ZPAY_MAPI_URL=https://zpayz.cn/mapi.php
ZPAY_QUERY_URL=https://zpayz.cn/api.php
ZPAY_NOTIFY_URL=https://nomadweb.nomadro.com/api/v1/pay/zpay_notify
XORPAY_AID=
XORPAY_SECRET=
XORPAY_CASHIER_URL=https://xorpay.com/api/cashier
XORPAY_QUERY_URL=https://xorpay.com/api/query2
XORPAY_NOTIFY_URL=https://nomadweb.nomadro.com/api/v1/pay/xorpay_notify
MIROTALK_URL=https://mirotalk.nomadro.com
LOUNGE_URL=https://lounge.nomadro.com

View File

@ -5,6 +5,7 @@ After=network.target
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=/opt/nomadweb/backend WorkingDirectory=/opt/nomadweb/backend
EnvironmentFile=/opt/nomadweb/deploy/nomadro-api.env
Environment=POCKETBASE_URL=http://127.0.0.1:8090 Environment=POCKETBASE_URL=http://127.0.0.1:8090
Environment=POCKETBASE_ADMIN_EMAIL=admin@nomadro.com Environment=POCKETBASE_ADMIN_EMAIL=admin@nomadro.com
Environment=POCKETBASE_ADMIN_PASSWORD=admin123456 Environment=POCKETBASE_ADMIN_PASSWORD=admin123456

View File

@ -0,0 +1,73 @@
{
"title": "游牧代码",
"subtitle": "数字游民与 Homelab 生存指南",
"author": "林远",
"description": "一个程序员离开工位、带上电脑环游世界,同时把「家」装进机柜的故事。",
"tagline": "在任意城市,过你想要的生活。",
"coverGradient": "from-violet-950 via-fuchsia-900 to-orange-900",
"readingMinutes": 45,
"viralTags": ["数字游民", "Homelab", "远程办公", "Tailscale", "自由职业"],
"stats": [
{ "emoji": "🌍", "value": "7", "label": "章精华" },
{ "emoji": "⏱️", "value": "45", "label": "分钟读完" },
{ "emoji": "📍", "value": "3+", "label": "城市灵感" },
{ "emoji": "🔥", "value": "12", "label": "书摘片段" }
],
"hooks": [
{ "emoji": "💻", "text": "远程办公的第一周,效率奇高。第二周,你开始分不清「工作结束」和「生活开始」。" },
{ "emoji": "🌴", "text": "清迈的周二早晨:flat white + VS Code,旁边有人在写旅行博客。" },
{ "emoji": "🗄️", "text": "Homelab 给你的锚,不是地理上的,是数字上的——文件在,服务在,随时等你回来。" },
{ "emoji": "🔑", "text": "Tailscale:无论 IP 怎么变,你的设备永远在「同一个局域网」。" }
],
"chapters": [
{
"slug": "00-preface",
"title": "序:我为什么开始游牧",
"emoji": "✈️",
"hook": "我想要的不是更好的工位,而是可以选择坐在哪里。",
"shareQuote": "数字游民是一种工作方式:产出不绑定在某个地理坐标上。"
},
{
"slug": "01-first-remote",
"title": "第一次远程办公的那个周五",
"emoji": "🏠",
"hook": "穿着睡衣开 Zoom,可能是当代最魔幻的上班方式。",
"shareQuote": "异步沟通:不是「随时在线」,而是「明确预期」。"
},
{
"slug": "02-chiangmai",
"title": "清迈的周二早晨",
"emoji": "☕",
"hook": "游牧不是一个人流浪,是在流动中找到同类。",
"shareQuote": "在这里,旅游和工作本来就不矛盾。"
},
{
"slug": "03-gear",
"title": "行囊里真正重要的东西",
"emoji": "🎒",
"hook": "带多了是负担,带少了会后悔——这是游牧者的永恒命题。",
"shareQuote": "网络,比护照更重要。"
},
{
"slug": "04-homelab",
"title": "Homelab:把家装进一个机柜",
"emoji": "🖥️",
"hook": "几百块的旧电脑,可以成为整个数字生活的中枢。",
"shareQuote": "完美是优秀的敌人。先跑起来,再迭代。"
},
{
"slug": "05-network",
"title": "无论在哪,都能连回家",
"emoji": "🔗",
"hook": "人在巴厘岛,手在家里。",
"shareQuote": "Homelab 服务默认不暴露公网——这是安全感的前提。"
},
{
"slug": "06-epilogue",
"title": "终章:自由是一种日常",
"emoji": "🌅",
"hook": "自由不是不用工作,而是可以选择在哪里、以什么节奏工作。",
"shareQuote": "小步验证,比宏大计划更可靠。"
}
]
}

View File

@ -0,0 +1,31 @@
# 序:我为什么开始游牧
二〇二三年秋天,我在公司工位上坐满了三年。
显示器左边是 Jira,右边是 Slack,中间是我已经改过十七遍的需求文档。某个加班的深夜,我刷到一张清迈咖啡馆的照片——阳光从百叶窗缝里漏进来,一台 MacBook 和一杯冰美式,背景里有人在用泰语点单。
那一刻我突然意识到:**我想要的不是更好的工位,而是可以选择坐在哪里。**
---
## 这不是逃离
很多人把数字游民理解成「辞职、环游世界、发 Instagram」。不是的。
数字游民是一种**工作方式**:你的产出不绑定在某个地理坐标上。你可以在上海写代码,也可以在里斯本写代码——只要网络稳定、时区能协调、交付不掉链子。
Homelab 则是另一种自由:无论你在哪个时区醒来,你的文件、你的服务、你的数据,都在一个你完全掌控的地方等着你。
这本书写的,就是这两件事如何交织在一起。
---
## 你会读到什么
这不是技术文档,也不是旅行攻略。这是一个普通程序员,从第一次远程办公,到在东南亚租下月付公寓,再到把家里那台旧服务器变成「随时能连回去的数字基地」的真实过程。
有踩过的坑,也有意外的好运。
如果你也在想:**能不能不辞职,但换一种活法?**
那就从下一章开始吧。

View File

@ -0,0 +1,39 @@
# 第一次远程办公的那个周五
周五下午三点,Team Leader 在群里发了一条消息:
> 「下周开始,技术组全员 remote,有问题的私聊 HR。」
我盯着这条消息看了很久。不是担心,是一种奇怪的安静——像考试结束铃响之前,你已经写完了最后一道题。
---
## 第一天
周一早上九点,我穿着睡衣坐在餐桌前,打开 Zoom。
同事们的脸出现在格子里,有人还在刷牙,有人背景是乱糟糟的卧室。会议结束后,我泡了杯咖啡,走到阳台。
楼下有人在遛狗,阳光很好。
**原来工作日也可以是这样。**
---
## 效率的幻觉
远程的第一周,我效率奇高。没有通勤,没有工位旁的闲聊,没有「开完这个会再写代码」的拖延。
第二周,我开始分不清「工作结束」和「生活开始」的边界。晚上十一点,Slack 还亮着绿点。周末,邮箱里躺着三条「不急,周一前看就行」的消息。
我学到一个词:**异步沟通**。不是「随时在线」,而是「明确预期」——什么时候回复、什么算紧急、什么可以等到下一个工作日。
---
## 那个周五的后续
三个月后,HR 发邮件说办公室租约到期,技术组永久 remote。
我没有回「太好了」,也没有回「怎么办」。我只是打开地图,搜索了「清迈 月租 公寓 WiFi」。
有些改变,不是突然发生的。是你某天回头看,发现路已经走了一半。

View File

@ -0,0 +1,44 @@
# 清迈的周二早晨
七点半,我被摩托车声叫醒。
不是闹钟——是楼下送菜的小贩,喇叭里循环播放泰语,我一个字也听不懂,但已经习惯了。这是我在清迈的第三周。
---
## 咖啡馆即办公室
我住的公寓步行十分钟有一家叫 **Graph Table** 的咖啡馆。二层靠窗的位置,能看到一条安静的小巷。WiFi 密码写在收银台的小黑板上,下载速度稳定在 80 Mbps 左右。
我点一杯 flat white,打开 VS Code。
旁边的荷兰设计师在画 Figma,日本背包客在写旅行博客。没有人问「你是来旅游的还是来工作的」——在这里,这两件事本来就不矛盾。
---
## 时区的艺术
我在东七区,团队大部分人在东八区。这意味着:
- 我的「早上十点 standup」,是清迈的九点
- 他们的「下班前 review」,是我的下午五点——刚好收工去夜市
我学会把深度工作放在本地上午,把会议和沟通压缩到下午。不是妥协,是**设计**。
---
## 孤独与社群
数字游民最容易被忽视的问题,不是签证,不是网络,是**孤独**。
我在 Nomad List 的 Discord 里认识了几个常驻清迈的开发者。我们每周三晚上在 Nimman 区聚餐,聊技术、聊签证、聊哪个 co-working 空间的空调更冷。
**游牧不是一个人流浪,是在流动中找到同类。**
---
## 周二的结尾
晚上八点,我骑摩托车回公寓。路过塔佩门,游客正在拍夜景。我停了一分钟,然后继续往前。
明天还有 standup。但此刻,清迈的风是温的。

View File

@ -0,0 +1,45 @@
# 行囊里真正重要的东西
出发前,我在 Reddit 的 r/digitalnomad 翻了三天装备帖。最后发现:**带多了是负担,带少了会后悔。**
这是我在路上一年后,精简再精简的清单。
---
## 电脑:唯一不能妥协的
一台 14 寸 MacBook Pro,M 系列芯片,16GB 内存。不是最新款,但足够跑 Docker、开十几个 Chrome 标签、同时 Zoom 和写代码。
外接显示器?我试过,最后卖了。游牧的核心是**移动**,不是把工位复制到每一个城市。
---
## 网络:比护照更重要
我随身带三样东西:
1. **本地 SIM 卡** — 落地当天买,流量包月
2. **便携路由器** — GL.iNet 那类,支持 4G 备用
3. **Tailscale** — 后面会细讲,这是连回 Homelab 的钥匙
在清迈、巴厘岛、里斯本,我学会的第一件事永远是:先测 WiFi,再 unpack。
---
## 其他:能省则省
- 降噪耳机:飞机、咖啡馆、合租房必备
- 万能转换插头:一个就够
- 折叠键盘:可选,我最后只用笔记本自带
衣服?一个 40L 背包,七天的量,到哪里都能洗。
---
## 真正重要的
装备可以列清单,但有三样东西清单上写不下:
**稳定的收入、可预期的工作节奏、以及一个随时可以回去的地方。**
第三个,Homelab 会帮你解决。

View File

@ -0,0 +1,48 @@
# Homelab:把家装进一个机柜
二〇二二年,我还住在租来的单间里,床底下藏着一台 Dell OptiPlex 小主机。
当时只是想跑个 Plex,把电影串流到手机。没想到,这台几百块的机器,后来成了我整个数字生活的中枢。
---
## 什么是 Homelab
Homelab,字面意思是「家庭实验室」。简单说,就是**你自己搭的、自己管的服务器环境**。
不是云服务那种「按月订阅、数据在别人机房」。是你的 NAS、你的 Git 仓库、你的笔记同步、你的自动化脚本——物理上可能就在你国内那个出租屋里,逻辑上却跟着你走遍世界。
---
## 我的第一套配置
| 组件 | 选择 | 用途 |
|------|------|------|
| 主机 | Dell OptiPlex 7040 | 7×24 低功耗运行 |
| 系统 | Proxmox | 虚拟机管理 |
| 存储 | 4TB 机械 + 256GB SSD | 文件 + 系统 |
| 服务 | Jellyfin, Nextcloud, Gitea | 媒体、同步、代码 |
总花费不到三千块。比一台新 iPhone 便宜,但价值在于:**它是你的。**
---
## 为什么游牧者需要 Homelab
人在清迈,文件在上海的硬盘里——听起来很蠢,对吧?
但云盘有容量限制,有审查风险,有「服务条款变更」的隐患。Homelab 给你的,是**数据主权**和**不受地点限制的访问权**。
配合 Tailscale 或 WireGuard,你在任何 WiFi 下,都能像坐在家里一样访问自己的服务。
---
## 从一台旧电脑开始
你不需要一上来就买机柜、交换机、UPS。
找一台闲置笔记本,装 Linux,跑一个 Nextcloud 或 FileBrowser。能远程访问,就算 Homelab 入门了。
**完美是优秀的敌人。先跑起来,再迭代。**
下一章,我们聊怎么「连回家」。

View File

@ -0,0 +1,59 @@
# 无论在哪,都能连回家
清迈咖啡馆的 WiFi 突然断了。
我切换到手机热点,打开终端,输入:
```bash
ssh nas
```
三秒后,我出现在家里那台 Proxmox 主机的 shell 里。文件在,服务在,就像从未离开过。
靠的是 **Tailscale**。
---
## 虚拟局域网
Tailscale 基于 WireGuard,把你在不同网络的设备,组成一个加密的虚拟局域网(Tailnet)。
家里跑 Tailscale 的 NAS,笔记本跑 Tailscale 客户端——无论 IP 怎么变,它们永远在「同一个局域网」里。
配置过程:
1. 在 Homelab 主机安装 Tailscale
2. 笔记本、手机同样安装并登录同一账号
3. 用 MagicDNS 给设备起名字,比如 `nas.tail-xxxxx.ts.net`
不需要公网 IP,不需要端口转发,不需要折腾路由器。
---
## 我常用的远程场景
**同步文件** — Nextcloud 客户端走 Tailscale 内网,速度比公网快,也不暴露服务到互联网。
**看代码** — Gitea 跑在内网,浏览器打开 `http://gitea:3000`,只有 Tailnet 里的设备能访问。
**紧急修复** — 生产环境告警,SSH 进去,改配置,重启服务。人在巴厘岛,手在家里。
---
## 安全习惯
- Homelab 服务**默认不暴露公网**
- 敏感操作走 SSH 密钥,禁用密码登录
- 定期备份到另一块硬盘,异地再备一份
网络自由的前提,是**安全意识**不能掉线。
---
## 连回家,意味着什么
游牧者容易有一种漂流感:今天这条街,明天那座城,没有锚。
Homelab 给我的锚,不是地理上的,是**数字上的**——我知道有一个地方,存着我的东西,跑着我的服务,随时等我回来。
这种确定感,和清迈的咖啡一样,是/nomad 生活的基础设施。

View File

@ -0,0 +1,49 @@
# 终章:自由是一种日常
写这本书时,我人在里斯本。
公寓窗外是电车轨道,偶尔有铃声。桌上有半杯 bica,电脑屏幕里是未提交的 commit。
---
## 自由不是假期
很多人把数字游民想象成「永远在度假」。实际上,周二依然是周二,需求依然会改,bug 依然会在上线前出现。
**自由不是不用工作,而是可以选择在哪里、以什么节奏工作。**
---
## Homelab 与游牧的共生
这两件事看起来方向相反:
- 游牧是**流动**
- Homelab 是**固定**
但组合在一起,逻辑就通了:你在物理世界移动,数字世界的「家」保持不动。像蜗牛的壳,不是负担,是随身携带的秩序。
---
## 给正在考虑的你
如果你还在工位上读这本书,不必立刻辞职,不必立刻买票。
你可以:
1. 先和团队谈一次 remote 的可能性
2. 用一台旧电脑搭一个最小 Homelab
3. 选一个周末,带着电脑去邻城住两晚,测试「在别处工作」的感觉
**小步验证,比宏大计划更可靠。**
---
## 最后
游牧代码,写的不是「如何逃离」,而是「如何建造」——建造一种你真正想要的生活,并用技术和纪律去支撑它。
书到这里结束。你的下一章,在自己手里。
—— 林远
写于里斯本,一个普通的周二

View File

@ -0,0 +1,73 @@
{
"title": "Nomad Code",
"subtitle": "A Digital Nomad & Homelab Survival Guide",
"author": "Lin Yuan",
"description": "A programmer leaves the office, travels with a laptop, and packs \"home\" into a rack.",
"tagline": "Live the life you want — from any city.",
"coverGradient": "from-violet-950 via-fuchsia-900 to-orange-900",
"readingMinutes": 45,
"viralTags": ["DigitalNomad", "Homelab", "RemoteWork", "Tailscale", "Freelance"],
"stats": [
{ "emoji": "🌍", "value": "7", "label": "Chapters" },
{ "emoji": "⏱️", "value": "45", "label": "Min read" },
{ "emoji": "📍", "value": "3+", "label": "City ideas" },
{ "emoji": "🔥", "value": "12", "label": "Quotes" }
],
"hooks": [
{ "emoji": "💻", "text": "Week one of remote work: productivity soars. Week two: you can't tell when work ends and life begins." },
{ "emoji": "🌴", "text": "Tuesday morning in Chiang Mai: flat white + VS Code, someone writing a travel blog nearby." },
{ "emoji": "🗄️", "text": "Homelab is your anchor — not geographic, but digital. Files stay, services stay, waiting for you." },
{ "emoji": "🔑", "text": "Tailscale: no matter how IPs change, your devices stay on the same LAN." }
],
"chapters": [
{
"slug": "00-preface",
"title": "Preface: Why I Started Nomading",
"emoji": "✈️",
"hook": "I didn't want a better desk — I wanted to choose where I sit.",
"shareQuote": "Digital nomadism is a way of working: output isn't tied to a place."
},
{
"slug": "01-first-remote",
"title": "That First Remote Friday",
"emoji": "🏠",
"hook": "Zoom in pajamas might be the most surreal way to work today.",
"shareQuote": "Async communication: not \"always online\", but \"clear expectations\"."
},
{
"slug": "02-chiangmai",
"title": "Tuesday Morning in Chiang Mai",
"emoji": "☕",
"hook": "Nomading isn't wandering alone — it's finding your people on the move.",
"shareQuote": "Here, travel and work were never at odds."
},
{
"slug": "03-gear",
"title": "What Actually Matters in Your Bag",
"emoji": "🎒",
"hook": "Pack too much and you're burdened; too little and you'll regret it.",
"shareQuote": "Connectivity matters more than passports."
},
{
"slug": "04-homelab",
"title": "Homelab: Home in a Rack",
"emoji": "🖥️",
"hook": "An old PC can become the hub of your digital life.",
"shareQuote": "Perfect is the enemy of good. Ship first, iterate later."
},
{
"slug": "05-network",
"title": "Connect Home from Anywhere",
"emoji": "🔗",
"hook": "Body in Bali, hands at home.",
"shareQuote": "Homelab services shouldn't face the public internet — that's the baseline for safety."
},
{
"slug": "06-epilogue",
"title": "Epilogue: Freedom as a Daily Practice",
"emoji": "🌅",
"hook": "Freedom isn't not working — it's choosing where and at what pace you work.",
"shareQuote": "Small experiments beat grand plans."
}
]
}

View File

@ -0,0 +1,94 @@
{
"$schema": "./features.schema.json",
"$comment": "功能模块总开关 — 关闭任何模块不影响其余功能",
"analytics": {
"enabled": true,
"provider": "ga4",
"measurementId": ""
},
"ads": {
"enabled": false,
"provider": "adsense",
"clientId": "",
"slots": {
"cover": "",
"readerMid": "",
"readerEnd": ""
}
},
"payment": {
"crypto": {
"enabled": false,
"provider": "nowpayments",
"tiers": [
{ "amount": 5, "label": "Coffee" },
{ "amount": 10, "label": "Lunch" },
{ "amount": 20, "label": "Sponsor" }
]
},
"checkout": {
"enabled": false,
"provider": "creem",
"buttonText": "Buy Now — $29",
"productName": "Download Edition (PDF & EPUB)"
}
},
"feedback": {
"enabled": false,
"title": "这本书对你有帮助吗?",
"subtitle": "欢迎提建议、纠错或合作联系,我会认真看每一条。"
},
"affiliate": {
"enabled": true,
"rules": [
{
"hosts": ["amazon.com", "amzn.to", "amazon.cn"],
"param": "tag=your-amazon-tag-20"
},
{
"hosts": ["taobao.com", "tmall.com"],
"rewrite": "https://s.click.taobao.com/t?eurl={encoded}&union_lens=lensId"
}
]
},
"share": {
"enabled": true,
"campaign": "nomad-code",
"defaultHashtags": ["数字游民", "Homelab", "远程办公"],
"viralPrompt": {
"enabled": true,
"triggerPercent": 42
}
},
"reader": {
"bookmarks": { "enabled": true },
"highlights": { "enabled": true },
"search": { "enabled": true },
"progress": { "enabled": true },
"zen": { "enabled": true },
"shortcuts": { "enabled": true },
"themeToggle": { "enabled": true },
"fontControl": { "enabled": true }
},
"userdata": {
"enabled": true,
"syncToServer": false,
"$comment": "syncToServer=true 时书签/划线/进度同步到 PocketBase,否则仅 localStorage"
},
"backend": {
"pocketbase": { "enabled": true },
"email": {
"enabled": false,
"$comment": "需要配置 SMTP 环境变量才能发送购买邮件"
}
}
}

View File

@ -0,0 +1,8 @@
{
"$comment": "品牌身份信息 — 模块开关和配置见 features.json",
"brand": {
"name": "游牧代码",
"xHandle": "@linyuan",
"contactEmail": "hello@example.com"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,14 +4,26 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2",
"@ant-design/nextjs-registry": "^1.3.0",
"antd": "^6.6.1",
"github-slugger": "^2.0.0",
"highlight.js": "^11.9.0",
"next": "16.3.3", "next": "16.3.3",
"next-themes": "^0.4.4",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8",
"react-markdown": "^9.0.3",
"rehype-highlight": "^7.0.1",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"tailwindcss": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="nomadro">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#FF6B6B"/>
<stop offset="50%" stop-color="#FFE66D"/>
<stop offset="100%" stop-color="#4ECDC4"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="96" fill="#0a0e17"/>
<circle cx="256" cy="256" r="168" fill="none" stroke="url(#g)" stroke-width="28"/>
<path d="M160 280c48-96 144-96 192 0" fill="none" stroke="url(#g)" stroke-width="24" stroke-linecap="round"/>
<circle cx="352" cy="176" r="28" fill="url(#g)"/>
</svg>

After

Width:  |  Height:  |  Size: 650 B

56
frontend/public/sw.js Normal file
View File

@ -0,0 +1,56 @@
/* Simple offline-capable shell for nomadro PWA */
const CACHE = "nomadro-shell-v1";
const PRECACHE = ["/", "/plan", "/compare", "/tools", "/manifest.webmanifest", "/icons/icon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
).then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// API: network only
if (url.pathname.startsWith("/api/")) return;
// Navigations: network first, fall back to cache / home
if (req.mode === "navigate") {
event.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
return res;
})
.catch(() => caches.match(req).then((r) => r || caches.match("/")))
);
return;
}
// Static assets: cache first
event.respondWith(
caches.match(req).then(
(cached) =>
cached ||
fetch(req).then((res) => {
if (res.ok && (url.pathname.startsWith("/_next/") || url.pathname.startsWith("/icons/"))) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return res;
})
)
);
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,64 @@
import type { Metadata } from "next";
import { AntdRegistry } from "@ant-design/nextjs-registry";
import { Providers } from "@/components/ebook/Providers";
import { buildBookMetadata } from "@/lib/ebook/metadata";
import { getSiteConfig } from "@/lib/ebook/site-config.server";
import { getServerFeatures } from "@/lib/ebook/features.server";
import { getLocale } from "@/lib/ebook/i18n/server";
import "./ebook-globals.css";
export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale();
return buildBookMetadata(locale);
}
/**
* Book section is a self-contained surface inside nomadweb:
* - Own visual system (ebook-globals), isolated under .ebook-section
* - Locale bridged with main-site cookie/localStorage via getLocale + LocaleProvider
* - Theme driven by ThemeProvider on <html> (not a forced .dark wrapper)
* - No main Navbar / SiteShell — immersive like book.nomadro.com
* - Entry from main nav `/book`; escape via cover "← nomadro"
*/
export default async function BookLayout({
children,
}: {
children: React.ReactNode;
}) {
const locale = await getLocale();
const site = getSiteConfig();
const f = getServerFeatures();
return (
<div className="ebook-section" data-ebook-root>
<script
dangerouslySetInnerHTML={{
__html: `window.__EBOOK_FEATURES__=${JSON.stringify(f).replace(/</g, "\\u003c")};window.__LOCALE__=${JSON.stringify(locale)};(function(){try{var t=localStorage.getItem("theme");if(t==="light"||t==="sepia"||t==="dark"){document.documentElement.classList.remove("light","dark","sepia");document.documentElement.classList.add(t);}else{document.documentElement.classList.add("dark");}}catch(e){document.documentElement.classList.add("dark");}document.documentElement.style.scrollPaddingTop="0";})();`,
}}
/>
<AntdRegistry>
<Providers locale={locale}>
{children}
{f.feedback.enabled && (
<FeedbackLazy
title={f.feedback.title}
subtitle={f.feedback.subtitle}
contactEmail={site.brand.contactEmail}
xHandle={site.brand.xHandle}
/>
)}
</Providers>
</AntdRegistry>
</div>
);
}
function FeedbackLazy(props: {
title: string;
subtitle: string;
contactEmail: string;
xHandle: string;
}) {
const { FeedbackPanel } = require("@/components/ebook/FeedbackPanel");
return <FeedbackPanel {...props} />;
}

View File

@ -0,0 +1,7 @@
export default function BookLoading() {
return (
<div className="ebook-section" data-ebook-root style={{ minHeight: "100vh", display: "grid", placeItems: "center" }}>
<p style={{ color: "#b4a9c8", fontSize: "0.9rem", letterSpacing: "0.08em" }}>…</p>
</div>
);
}

View File

@ -0,0 +1,98 @@
import { ImageResponse } from "next/og";
import { getBook } from "@/lib/ebook/book.server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const alt = "Ebook cover";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
async function loadFont() {
const urls = [
"https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-sc@5.0.0/chinese-simplified-700-normal.woff",
"https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuFuYAZ9hiJ-Ek-_EeA.woff2",
];
for (const url of urls) {
try {
const res = await fetch(url, { cache: "force-cache" });
if (res.ok) return { data: await res.arrayBuffer(), name: url.includes("noto") ? "Noto Sans SC" : "Inter" };
} catch {
/* try next */
}
}
const inter = await fetch(
"https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuFuYAZ9hiJ-Ek-_EeA.woff2"
);
return { data: await inter.arrayBuffer(), name: "Inter" };
}
export default async function OgImage() {
const book = getBook();
const font = await loadFont();
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "56px 64px",
background: "linear-gradient(135deg, #1a0b2e 0%, #5b21b6 42%, #db2777 78%, #ea580c 100%)",
color: "#fff",
fontFamily: font ? font.name : "sans-serif",
}}
>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
{book.viralTags.slice(0, 4).map((tag) => (
<span
key={tag}
style={{
fontSize: 22,
padding: "8px 18px",
borderRadius: 999,
background: "rgba(255,255,255,0.14)",
border: "1px solid rgba(255,255,255,0.22)",
}}
>
#{tag}
</span>
))}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ fontSize: 28, opacity: 0.75, letterSpacing: 4 }}>FREE EBOOK</div>
<div style={{ fontSize: 72, fontWeight: 700, lineHeight: 1.1, maxWidth: 980 }}>
{book.title}
</div>
<div style={{ fontSize: 34, opacity: 0.88, maxWidth: 900 }}>{book.subtitle}</div>
<div style={{ fontSize: 26, opacity: 0.72, maxWidth: 860 }}>{book.tagline}</div>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end" }}>
<div style={{ fontSize: 24, opacity: 0.7 }}>
{book.chapters.length} 章 · 约 {book.readingMinutes} 分钟 · by {book.author}
</div>
<div
style={{
fontSize: 22,
padding: "14px 28px",
borderRadius: 999,
background: "#fff",
color: "#4c1d95",
fontWeight: 700,
}}
>
点击免费阅读 →
</div>
</div>
</div>
),
{
...size,
fonts: [{ name: font.name, data: font.data, style: "normal" as const, weight: 700 }],
}
);
}

View File

@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { getAllChapters, getBook } from "@/lib/ebook/book.server";
import { CoverPage } from "@/components/ebook/CoverPage";
import { buildBookMetadata } from "@/lib/ebook/metadata";
import { getSiteConfig, getAdSlot } from "@/lib/ebook/site-config.server";
import { getServerFeatures } from "@/lib/ebook/features.server";
import { getLocale } from "@/lib/ebook/i18n/server";
export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale();
return buildBookMetadata(locale);
}
export default async function BookCoverPage() {
const locale = await getLocale();
const book = getBook(locale);
const site = getSiteConfig();
const f = getServerFeatures();
const chapterMinutes = Object.fromEntries(
getAllChapters(locale).map((chapter) => [chapter.slug, chapter.readingMinutes])
);
return (
<CoverPage
book={book}
site={site}
chapterMinutes={chapterMinutes}
coverAdSlot={f.ads.enabled ? getAdSlot("coverSlot") : ""}
/>
);
}

View File

@ -0,0 +1,31 @@
import { notFound, redirect } from "next/navigation";
import { getChapter, getChapterSlugs } from "@/lib/ebook/book.server";
import { buildChapterMetadata } from "@/lib/ebook/metadata";
import { getLocale } from "@/lib/ebook/i18n/server";
import { ebookPath } from "@/lib/ebook/paths";
export function generateStaticParams() {
return getChapterSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const locale = await getLocale();
return buildChapterMetadata(slug, locale);
}
export default async function ReadSlugRedirect({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const locale = await getLocale();
const chapter = getChapter(slug, locale);
if (!chapter) notFound();
redirect(`${ebookPath("/read")}#${slug}`);
}

View File

@ -0,0 +1,31 @@
import { getBook, getAllChapters } from "@/lib/ebook/book.server";
import { ScrollReaderShell } from "@/components/ebook/ScrollReaderShell";
import { buildBookMetadata } from "@/lib/ebook/metadata";
import { getAdSlot } from "@/lib/ebook/site-config.server";
import { getServerFeatures } from "@/lib/ebook/features.server";
import { getLocale } from "@/lib/ebook/i18n/server";
import type { Metadata } from "next";
export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale();
return buildBookMetadata(locale);
}
export default async function BookReadPage() {
const locale = await getLocale();
const book = getBook(locale);
const chapters = getAllChapters(locale);
const f = getServerFeatures();
return (
<ScrollReaderShell
book={book}
chapters={chapters}
affiliateRules={f.affiliate.enabled ? f.affiliate.rules : []}
adSlots={{
mid: f.ads.enabled ? getAdSlot("readerMidSlot") : "",
end: f.ads.enabled ? getAdSlot("readerEndSlot") : "",
}}
/>
);
}

View File

@ -8,6 +8,59 @@ export const metadata: Metadata = {
}; };
const LOGS = [ const LOGS = [
{
date: "2026-08-29",
tag: "电子书融合",
items: [
"电子书封面改用独立 cover-* 布局 CSS,避免主站全局样式打乱双栏排版",
"语言与主站 nomadro-locale 互通;封面保留「← nomadro」返回入口",
"恢复「继续阅读」主 CTA + 进度卡并排,对齐 book.nomadro.com 构图",
],
},
{
date: "2026-08-29",
tag: "电子书融入",
items: [
"导航新增「电子书」,接入《游牧代码》完整阅读体验(/book)",
"支持封面、连续阅读、章节锚点、书签划线与中英切换",
"内容来自 ebook 项目,与主站语言偏好同步",
],
},
{
date: "2026-08-29",
tag: "首页聚焦发现",
items: [
"首页移除计划与对比模块,专注探索:地图 → 目的地 → 签证 → 内容",
"路径条改为探索 → 智能匹配 → 签证 → 工具箱;计划/对比保留在导航栏独立页面",
"登录页重写:跳转目标显示中文名称而非 / 路径,默认进入个人中心",
],
},
{
date: "2026-08-29",
tag: "首页收敛",
items: [
"首页从超长工具墙收成短路径,微工具全部下沉到 /tools",
"新增 Journey 路径条,Hero 主 CTA 收敛为探索 + 智能匹配",
],
},
{
date: "2026-08-29",
tag: "漏斗打磨",
items: [
"路径条提前到 Hero 下方,先看清步骤再探索",
"目的地默认展示 8 城可展开,卡片支持收藏与详情",
"签证/博客/FAQ 折叠展示;地图/目的地/签证等壳层文案中英双语",
],
},
{
date: "2026-08-29",
tag: "体验升级",
items: [
"PC / H5 响应式增强:语言切换、计划与对比台、底部安全区与触控目标",
"PWA:可安装到主屏幕,离线可打开计划 / 对比入口(manifest + Service Worker)",
"中英双语:默认中文,导航一键切换英语;计划 / 对比 / 登录 / 工具箱壳层文案同步",
],
},
{ {
date: "2026-08-29", date: "2026-08-29",
tag: "产品打磨", tag: "产品打磨",

View File

@ -0,0 +1,16 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import ChatThreadClient from "@/components/ChatThreadClient";
export const metadata: Metadata = {
title: "对话 · nomadro",
};
export default async function ChatThreadPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<SiteShell showFooter>
<ChatThreadClient convId={id} />
</SiteShell>
);
}

View File

@ -0,0 +1,16 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import ChatClient from "@/components/ChatClient";
export const metadata: Metadata = {
title: "私信 · nomadro",
description: "与匹配成功的游民私信聊天。",
};
export default function ChatPage() {
return (
<SiteShell showFooter>
<ChatClient />
</SiteShell>
);
}

View File

@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell";
import DiscussionDetailClient from "@/components/DiscussionDetailClient";
export const revalidate = 60;
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
try {
const d = await api.getDiscussion(id);
return { title: `${d.title} · nomadro 社区` };
} catch {
return { title: "讨论 · nomadro" };
}
}
export default async function DiscussionPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const discussion = await api.getDiscussion(id).catch(() => null);
if (!discussion) notFound();
return (
<SiteShell showFooter>
<DiscussionDetailClient discussionId={id} initial={discussion} />
</SiteShell>
);
}

View File

@ -0,0 +1,10 @@
import SiteShell from "@/components/SiteShell";
import CommunityNewClient from "@/components/CommunityNewClient";
export default function CommunityNewPage() {
return (
<SiteShell showFooter>
<CommunityNewClient />
</SiteShell>
);
}

View File

@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell";
import CommunityHubClient from "@/components/CommunityHubClient";
export const metadata: Metadata = {
title: "社区讨论 · nomadro",
description: "签证、远程工作、住宿与安全 —— 和游民们交流真实经验。",
};
export const revalidate = 60;
export default async function CommunityPage() {
const discussions = await api.getDiscussions().catch(() => []);
const featured = discussions[0]
? await api.getDiscussion(discussions[0].id).catch(() => null)
: null;
return (
<SiteShell showFooter>
<CommunityHubClient discussions={discussions} featured={featured} />
</SiteShell>
);
}

View File

@ -0,0 +1,16 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import DatingLikesClient from "@/components/DatingLikesClient";
export const metadata: Metadata = {
title: "我喜欢的 · nomadro",
description: "查看你右滑喜欢的游民卡片。",
};
export default function DatingLikesPage() {
return (
<SiteShell showFooter>
<DatingLikesClient />
</SiteShell>
);
}

View File

@ -0,0 +1,16 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import DatingClient from "@/components/DatingClient";
export const metadata: Metadata = {
title: "游民匹配 · nomadro",
description: "按意图滑动匹配同路游民,交朋友、找室友、联创伙伴。",
};
export default function DatingPage() {
return (
<SiteShell showFooter>
<DatingClient />
</SiteShell>
);
}

View File

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

View File

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell";
import DigitalCourseClient from "@/components/DigitalCourseClient";
export const metadata: Metadata = {
title: "课程目录 · nomadro 学院",
};
export const revalidate = 300;
export default async function DigitalCoursePage() {
const course = await api.getDigitalCourse();
return (
<SiteShell showFooter>
<DigitalCourseClient course={course} />
</SiteShell>
);
}

View File

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell";
import DigitalJobsClient from "@/components/DigitalJobsClient";
export const metadata: Metadata = {
title: "远程岗位 · nomadro",
};
export const revalidate = 300;
export default async function DigitalJobsPage() {
const jobs = await api.getDigitalJobs();
return (
<SiteShell showFooter>
<DigitalJobsClient jobs={jobs} />
</SiteShell>
);
}

View File

@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell";
import DigitalHomeClient from "@/components/DigitalHomeClient";
export const metadata: Metadata = {
title: "数字游民学院 · nomadro",
description: "课程、电子书与远程岗位 —— nomadro 数字游民成长站。",
};
export const revalidate = 300;
export default async function DigitalPage() {
const course = await api.getDigitalCourse();
return (
<SiteShell showFooter>
<DigitalHomeClient course={course} />
</SiteShell>
);
}

View File

@ -0,0 +1,14 @@
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell";
import GigsClient from "@/components/GigsClient";
export const revalidate = 60;
export default async function GigsPage() {
const gigs = await api.getGigs().catch(() => []);
return (
<SiteShell showFooter>
<GigsClient gigs={gigs} />
</SiteShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import JoinClient from "@/components/JoinClient";
export const metadata: Metadata = {
title: "加入会员 · nomadro",
description: "完善游民资料,开通 VIP 解锁匹配、直播与课程。",
};
export default function JoinPage() {
return (
<SiteShell showFooter>
<JoinClient />
</SiteShell>
);
}

View File

@ -0,0 +1,18 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import JoinPaidClient from "@/components/JoinPaidClient";
export const metadata: Metadata = {
title: "支付完成 · nomadro",
};
export default function JoinPaidPage() {
return (
<SiteShell showFooter>
<Suspense fallback={<div className="join-page"><div className="container"><p className="plan-loading">处理中…</p></div></div>}>
<JoinPaidClient />
</Suspense>
</SiteShell>
);
}

View File

@ -1,12 +1,13 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { Outfit } from "next/font/google"; import { Outfit } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { AuthProvider } from "@/lib/auth"; import { AuthProvider } from "@/lib/auth";
import { ToastProvider } from "@/lib/toast"; import { ToastProvider } from "@/lib/toast";
import { I18nProvider } from "@/lib/i18n";
import PwaRegister from "@/components/PwaRegister";
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com"; const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
/** Display font self-hosted; CJK uses system stack to avoid multi‑MB webfont. */
const outfit = Outfit({ const outfit = Outfit({
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500", "600", "700"], weight: ["400", "500", "600", "700"],
@ -19,11 +20,19 @@ export const metadata: Metadata = {
title: "nomadro · 数字游民旅居指南", title: "nomadro · 数字游民旅居指南",
description: "探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。", description: "探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。",
keywords: ["数字游民", "旅居", "远程工作", "nomad", "digital nomad"], keywords: ["数字游民", "旅居", "远程工作", "nomad", "digital nomad"],
applicationName: "nomadro",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "nomadro",
},
formatDetection: { telephone: false },
openGraph: { openGraph: {
title: "nomadro · 数字游民旅居指南", title: "nomadro · 数字游民旅居指南",
description: "用一行代码环游世界 🌏", description: "用一行代码环游世界 🌏",
type: "website", type: "website",
locale: "zh_CN", locale: "zh_CN",
alternateLocale: ["en_US"],
url: SITE, url: SITE,
siteName: "nomadro", siteName: "nomadro",
}, },
@ -33,15 +42,39 @@ export const metadata: Metadata = {
description: "用一行代码环游世界 🌏", description: "用一行代码环游世界 🌏",
}, },
manifest: "/manifest.webmanifest", manifest: "/manifest.webmanifest",
icons: {
icon: [
{ url: "/icons/icon.svg", type: "image/svg+xml" },
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
],
apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180" }],
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: dark)", color: "#0a0e17" },
{ media: "(prefers-color-scheme: light)", color: "#4ECDC4" },
],
width: "device-width",
initialScale: 1,
maximumScale: 5,
viewportFit: "cover",
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="zh-CN" className={outfit.variable}> <html lang="zh-CN" className={outfit.variable} suppressHydrationWarning>
<body> <body>
<I18nProvider>
<AuthProvider> <AuthProvider>
<ToastProvider>{children}</ToastProvider> <ToastProvider>
{children}
<PwaRegister />
</ToastProvider>
</AuthProvider> </AuthProvider>
</I18nProvider>
</body> </body>
</html> </html>
); );

View File

@ -1,24 +1,22 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react"; import { Suspense } from "react";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import { resolveAuthNext } from "@/lib/authNext";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
function safeNext(raw: string | null): string {
if (!raw || !raw.startsWith("/") || raw.startsWith("//")) return "/plan";
return raw;
}
function LoginForm() { function LoginForm() {
const { login, register, demoLogin } = useAuth(); const { login, register, demoLogin } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const next = safeNext(searchParams.get("next")); const meta = resolveAuthNext(searchParams.get("next"), t.login);
const [mode, setMode] = useState<"login" | "register">("login"); const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@ -30,7 +28,7 @@ function LoginForm() {
const goAfterAuth = (msg: string) => { const goAfterAuth = (msg: string) => {
toast(msg); toast(msg);
setTimeout(() => { router.replace(next); }, 400); setTimeout(() => { router.replace(meta.path); }, 400);
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -43,12 +41,10 @@ function LoginForm() {
setLoading(false); setLoading(false);
if (ok) { if (ok) {
goAfterAuth( goAfterAuth(
mode === "login" mode === "login" ? meta.welcomeToast : t.login.registerToast
? "欢迎回来!计划已开始同步 🗓️"
: "注册成功,旅居计划可跨设备同步 ✨"
); );
} else { } else {
setError(mode === "login" ? "邮箱或密码错误" : "注册失败,邮箱可能已存在"); setError(mode === "login" ? t.login.loginError : t.login.registerError);
} }
}; };
@ -56,9 +52,11 @@ function LoginForm() {
setLoading(true); setLoading(true);
const ok = await demoLogin(); const ok = await demoLogin();
setLoading(false); setLoading(false);
if (ok) goAfterAuth("演示账号已登录 · 正在同步旅居计划 🎮"); if (ok) goAfterAuth(t.login.demoToast);
}; };
const headerHint = mode === "login" ? meta.hint : t.login.registerHint;
return ( return (
<div className="auth-page"> <div className="auth-page">
<div className="auth-bg-shapes" aria-hidden="true"> <div className="auth-bg-shapes" aria-hidden="true">
@ -67,48 +65,52 @@ function LoginForm() {
<span className="auth-shape auth-shape-3">🏝️</span> <span className="auth-shape auth-shape-3">🏝️</span>
</div> </div>
<div className="auth-card"> <div className="auth-card">
<Link href="/" className="auth-back">← 返回首页</Link> <Link href="/" className="auth-back">{t.common.backHome}</Link>
<div className="auth-header"> <div className="auth-header">
<span className="auth-logo-emoji">🌍</span> <span className="auth-logo-emoji">🌍</span>
<h1>{mode === "login" ? "欢迎回来" : "加入 nomadro"}</h1> <h1>{mode === "login" ? t.login.welcome : t.login.join}</h1>
<p> <p>{headerHint}</p>
{mode === "login" </div>
? "登录后同步旅居计划、收藏与就绪清单"
: "注册即可跨设备保存你的旅居计划"} {meta.showRedirect && (
<div className="auth-redirect-card">
{meta.showPlanMerge && (
<p className="auth-redirect-merge">{t.login.planMergeNote}</p>
)}
<p className="auth-redirect-dest">
<span>{t.login.redirectTo}</span>
<strong className="auth-redirect-badge">{meta.label}</strong>
</p> </p>
</div> </div>
)}
<div className="auth-sync-note">
🗓️ 登录后自动把本机计划与账号合并 · 完成后将前往
<Link href={next}>{next === "/plan" ? "旅居计划中心" : next}</Link>
</div>
<div className="auth-tabs"> <div className="auth-tabs">
<button className={mode === "login" ? "active" : ""} onClick={() => { setMode("login"); setError(""); }}>登录</button> <button className={mode === "login" ? "active" : ""} onClick={() => { setMode("login"); setError(""); }}>{t.login.tabLogin}</button>
<button className={mode === "register" ? "active" : ""} onClick={() => { setMode("register"); setError(""); }}>注册</button> <button className={mode === "register" ? "active" : ""} onClick={() => { setMode("register"); setError(""); }}>{t.login.tabRegister}</button>
</div> </div>
<form onSubmit={handleSubmit} className="auth-form"> <form onSubmit={handleSubmit} className="auth-form">
{mode === "register" && ( {mode === "register" && (
<div className="calc-field"> <div className="calc-field">
<label>👤 昵称</label> <label>{t.login.nickname}</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="你的游民昵称" required /> <input value={name} onChange={(e) => setName(e.target.value)} placeholder={t.login.nickPlaceholder} required />
</div> </div>
)} )}
<div className="calc-field"> <div className="calc-field">
<label>📧 邮箱</label> <label>{t.login.email}</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" required /> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" required autoComplete="email" />
</div> </div>
<div className="calc-field"> <div className="calc-field">
<label>🔒 密码</label> <label>{t.login.password}</label>
<div className="auth-pwd-wrap"> <div className="auth-pwd-wrap">
<input <input
type={showPwd ? "text" : "password"} type={showPwd ? "text" : "password"}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="至少 6 位" placeholder={t.login.pwdPlaceholder}
required required
minLength={6} minLength={6}
autoComplete={mode === "login" ? "current-password" : "new-password"}
/> />
<button type="button" className="auth-pwd-toggle" onClick={() => setShowPwd(!showPwd)} aria-label="显示密码"> <button type="button" className="auth-pwd-toggle" onClick={() => setShowPwd(!showPwd)} aria-label="显示密码">
{showPwd ? "🙈" : "👁️"} {showPwd ? "🙈" : "👁️"}
@ -117,15 +119,15 @@ function LoginForm() {
</div> </div>
{error && <p className="auth-error">{error}</p>} {error && <p className="auth-error">{error}</p>}
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}> <button type="submit" className="btn btn-primary auth-submit" disabled={loading}>
{loading ? <span className="auth-loading">处理中...</span> : (mode === "login" ? "🚀 登录并同步" : "✨ 注册并开始")} {loading ? <span className="auth-loading">{t.login.processing}</span> : (mode === "login" ? meta.submitLogin : t.login.submitRegister)}
</button> </button>
</form> </form>
<div className="auth-divider"><span>或</span></div> <div className="auth-divider"><span>{t.login.or}</span></div>
<button className="btn btn-ghost" style={{ width: "100%" }} onClick={handleDemo} disabled={loading}> <button className="btn btn-ghost auth-demo-btn" type="button" onClick={handleDemo} disabled={loading}>
🎮 一键体验演示账号 {t.login.demo}
</button> </button>
<p className="auth-hint">演示账号:demo@nomadro.com / demo123 · 登录后进入计划中心</p> <p className="auth-hint">{meta.demoHint}</p>
</div> </div>
</div> </div>
); );

View File

@ -1,18 +1,27 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: "nomadro · 数字游民旅居指南", name: "nomadro",
short_name: "nomadro", short_name: "nomadro",
description: "探索全球旅居生活,发现最适合数字游民的目的地", description: "Digital nomad move planning — destinations, compare, and trip plans",
start_url: "/", start_url: "/",
scope: "/",
display: "standalone", display: "standalone",
orientation: "any",
background_color: "#0a0e17", background_color: "#0a0e17",
theme_color: "#4ECDC4", theme_color: "#4ECDC4",
lang: "zh-CN",
categories: ["travel", "lifestyle", "productivity"],
icons: [ icons: [
{ src: "/globe.svg", sizes: "any", type: "image/svg+xml" }, { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
{ src: "/icons/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
],
shortcuts: [
{ name: "Move plan / 旅居计划", short_name: "Plan", url: "/plan", icons: [{ src: "/icons/icon-192.png", sizes: "192x192" }] },
{ name: "Compare / 对比", short_name: "Compare", url: "/compare", icons: [{ src: "/icons/icon-192.png", sizes: "192x192" }] },
], ],
}; };
} }

View File

@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell";
import MeetupLiveClient from "@/components/MeetupLiveClient";
export const metadata: Metadata = {
title: "活动直播 · nomadro",
};
export default async function MeetupLivePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const meetup = await api.getMeetup(id).catch(() => null);
const title = meetup?.title || "游民活动直播";
return (
<SiteShell showFooter={false}>
<MeetupLiveClient meetupId={id} title={title} />
</SiteShell>
);
}

View File

@ -0,0 +1,10 @@
import SiteShell from "@/components/SiteShell";
import MeetupsHostClient from "@/components/MeetupsHostClient";
export default function MeetupsHostPage() {
return (
<SiteShell showFooter>
<MeetupsHostClient />
</SiteShell>
);
}

View File

@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell";
import MeetupsClient from "@/components/MeetupsClient";
export const metadata: Metadata = {
title: "游民活动 · nomadro",
description: "线上圆桌、线下聚会与混合活动 —— 和全球数字游民一起连接。",
};
export const revalidate = 60;
export default async function MeetupsPage() {
const meetups = await api.getMeetups().catch(() => []);
return (
<SiteShell showFooter>
<MeetupsClient meetups={meetups} />
</SiteShell>
);
}

View File

@ -0,0 +1,15 @@
import { notFound } from "next/navigation";
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell";
import MemberProfileClient from "@/components/MemberProfileClient";
export default async function MemberPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const profile = await api.getMemberProfile(id).catch(() => null);
if (!profile) notFound();
return (
<SiteShell showFooter>
<MemberProfileClient profile={profile} />
</SiteShell>
);
}

View File

@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell";
import NextStopClient from "@/components/NextStopClient";
export const metadata: Metadata = {
title: "下一站决策 · nomadro",
description: "根据预算、网络、气候和偏好,智能推荐下一座旅居城市与路线。",
};
export const revalidate = 60;
export default async function NextStopPage() {
const [destinations, meetups, routes] = await Promise.all([
api.getDestinations().catch(() => []),
api.getMeetups().catch(() => []),
api.getRoutes().catch(() => []),
]);
return (
<SiteShell showFooter>
<NextStopClient
destinations={destinations}
initialMeetups={meetups}
initialRoutes={routes}
/>
</SiteShell>
);
}

View File

@ -0,0 +1,10 @@
import SiteShell from "@/components/SiteShell";
import NotificationsClient from "@/components/NotificationsClient";
export default function NotificationsPage() {
return (
<SiteShell showFooter>
<NotificationsClient />
</SiteShell>
);
}

View File

@ -0,0 +1,10 @@
import SiteShell from "@/components/SiteShell";
import PricingClient from "@/components/PricingClient";
export default function PricingPage() {
return (
<SiteShell showFooter>
<PricingClient />
</SiteShell>
);
}

View File

@ -12,6 +12,22 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: `${SITE}/plan`, lastModified: now, changeFrequency: "weekly", priority: 0.95 }, { url: `${SITE}/plan`, lastModified: now, changeFrequency: "weekly", priority: 0.95 },
{ url: `${SITE}/compare`, lastModified: now, changeFrequency: "weekly", priority: 0.9 }, { url: `${SITE}/compare`, lastModified: now, changeFrequency: "weekly", priority: 0.9 },
{ url: `${SITE}/tools`, lastModified: now, changeFrequency: "weekly", priority: 0.8 }, { url: `${SITE}/tools`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE}/next-stop`, lastModified: now, changeFrequency: "weekly", priority: 0.9 },
{ url: `${SITE}/meetups`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
{ url: `${SITE}/community`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
{ url: `${SITE}/dating`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
{ url: `${SITE}/chat`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE}/gigs`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE}/pricing`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
{ url: `${SITE}/notifications`, lastModified: now, changeFrequency: "weekly", priority: 0.6 },
{ url: `${SITE}/community/new`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
{ url: `${SITE}/meetups/host`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
{ url: `${SITE}/dating/likes`, lastModified: now, changeFrequency: "weekly", priority: 0.7 },
{ url: `${SITE}/digital`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
{ url: `${SITE}/digital/course`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE}/digital/jobs`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
{ url: `${SITE}/book`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
{ url: `${SITE}/book/read`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, { url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 }, { url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
{ url: `${SITE}/about`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, { url: `${SITE}/about`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },

View File

@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell";
import ToolRunnerClient from "@/components/ToolRunnerClient";
import { TOOL_LINKS } from "@/lib/tools";
import { isRunnableTool } from "@/lib/toolsRegistry";
export const revalidate = 60;
export function generateStaticParams() {
return TOOL_LINKS.filter((t) => isRunnableTool(t.id)).map((t) => ({ id: t.id }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const tool = TOOL_LINKS.find((t) => t.id === id);
if (!tool) return { title: "工具 · nomadro" };
return {
title: `${tool.title} · nomadro 工具箱`,
description: tool.desc,
};
}
export default async function ToolPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
if (!isRunnableTool(id)) notFound();
const destinations = await api.getDestinations().catch(() => []);
return (
<SiteShell showFooter>
<ToolRunnerClient toolId={id} destinations={destinations} />
</SiteShell>
);
}

View File

@ -3,11 +3,13 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools"; import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
import { useI18n } from "@/lib/i18n";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
const FEATURED = ["weekend", "day-plan", "bill-split", "mood", "scam-alerts", "deposit-return", "airport-transfer", "invoice-fx", "hydration", "sleep", "coliving-qa", "rainy-day"]; const FEATURED = ["weekend", "day-plan", "bill-split", "mood", "scam-alerts", "deposit-return", "airport-transfer", "invoice-fx", "hydration", "sleep", "coliving-qa", "rainy-day"];
export default function ToolsHubPage() { export default function ToolsHubPage() {
const { t } = useI18n();
const [filter, setFilter] = useState("all"); const [filter, setFilter] = useState("all");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
@ -30,34 +32,89 @@ export default function ToolsHubPage() {
<div className="tools-hub-page"> <div className="tools-hub-page">
<div className="container"> <div className="container">
<nav className="detail-nav"> <nav className="detail-nav">
<Link href="/">← 返回首页</Link> <Link href="/">{t.common.backHome}</Link>
</nav> </nav>
<div className="section-header"> <div className="section-header">
<span className="section-tag">🛠️ TOOLKIT</span> <span className="section-tag">{t.tools.tag}</span>
<h1>nomadro 工具箱</h1> <h1>{t.tools.title}</h1>
<p>规划、金钱、工作、生活与安全——一站直达所有旅居工具</p> <p>{t.tools.subtitle}</p>
</div> </div>
<div className="tools-core-banner"> <div className="tools-core-banner">
<Link href="/next-stop" className="tools-core-card">
<span>🧭</span>
<div>
<strong>{t.tools.hubNextStop}</strong>
<p>{t.tools.hubNextStopDesc}</p>
</div>
</Link>
<Link href="/meetups" className="tools-core-card">
<span>🎉</span>
<div>
<strong>{t.tools.hubMeetups}</strong>
<p>{t.tools.hubMeetupsDesc}</p>
</div>
</Link>
<Link href="/community" className="tools-core-card">
<span>💬</span>
<div>
<strong>{t.tools.hubCommunity}</strong>
<p>{t.tools.hubCommunityDesc}</p>
</div>
</Link>
</div>
<div className="tools-core-banner tools-core-banner-secondary">
<Link href="/dating" className="tools-core-card">
<span>💕</span>
<div>
<strong>{t.tools.hubDating}</strong>
<p>{t.tools.hubDatingDesc}</p>
</div>
</Link>
<Link href="/chat" className="tools-core-card">
<span>✉️</span>
<div>
<strong>{t.tools.hubChat}</strong>
<p>{t.tools.hubChatDesc}</p>
</div>
</Link>
<Link href="/digital" className="tools-core-card">
<span>🎓</span>
<div>
<strong>{t.tools.hubDigital}</strong>
<p>{t.tools.hubDigitalDesc}</p>
</div>
</Link>
<Link href="/join" className="tools-core-card">
<span>✨</span>
<div>
<strong>{t.tools.hubJoin}</strong>
<p>{t.tools.hubJoinDesc}</p>
</div>
</Link>
</div>
<div className="tools-core-banner tools-core-banner-secondary">
<Link href="/plan" className="tools-core-card"> <Link href="/plan" className="tools-core-card">
<span>🗓️</span> <span>🗓️</span>
<div> <div>
<strong>旅居计划中心</strong> <strong>{t.tools.corePlan}</strong>
<p>时间轴 · 签证 · 预算 · 日历导出</p> <p>{t.tools.corePlanDesc}</p>
</div> </div>
</Link> </Link>
<Link href="/compare" className="tools-core-card"> <Link href="/compare" className="tools-core-card">
<span>⚖️</span> <span>⚖️</span>
<div> <div>
<strong>城市对比台</strong> <strong>{t.tools.coreCompare}</strong>
<p>并排指标 · 一键写入计划</p> <p>{t.tools.coreCompareDesc}</p>
</div> </div>
</Link> </Link>
<Link href="/login?next=/plan" className="tools-core-card"> <Link href="/login?next=/plan" className="tools-core-card">
<span>☁️</span> <span>☁️</span>
<div> <div>
<strong>登录同步</strong> <strong>{t.tools.coreSync}</strong>
<p>跨设备保存计划与收藏</p> <p>{t.tools.coreSyncDesc}</p>
</div> </div>
</Link> </Link>
</div> </div>
@ -68,12 +125,12 @@ export default function ToolsHubPage() {
<span>一次上新 12+ 工具 · 周末/分账/防坑…</span> <span>一次上新 12+ 工具 · 周末/分账/防坑…</span>
</div> </div>
<div className="tools-featured-grid"> <div className="tools-featured-grid">
{featured.map((t) => ( {featured.map((tool) => (
<Link key={t.id} href={t.href} className="tools-featured-card"> <Link key={tool.id} href={tool.href} className="tools-featured-card">
<span>{t.emoji}</span> <span>{tool.emoji}</span>
<div> <div>
<h3>{t.title}</h3> <h3>{tool.title}</h3>
<p>{t.desc}</p> <p>{tool.desc}</p>
</div> </div>
</Link> </Link>
))} ))}
@ -101,11 +158,11 @@ export default function ToolsHubPage() {
</div> </div>
<div className="tools-hub-grid"> <div className="tools-hub-grid">
{filtered.map((t) => ( {filtered.map((tool) => (
<Link key={t.id} href={t.href} className="tools-hub-card"> <Link key={tool.id} href={tool.href} className="tools-hub-card">
<span className="tools-hub-emoji">{t.emoji}</span> <span className="tools-hub-emoji">{tool.emoji}</span>
<h3>{t.title}</h3> <h3>{tool.title}</h3>
<p>{t.desc}</p> <p>{tool.desc}</p>
<span className="tools-hub-go">打开 →</span> <span className="tools-hub-go">打开 →</span>
</Link> </Link>
))} ))}

View File

@ -2,39 +2,47 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { BlogPost } from "@/lib/types"; import type { BlogPost } from "@/lib/types";
export default function BlogSection({ posts }: { posts: BlogPost[] }) { interface Props {
posts: BlogPost[];
limit?: number;
}
export default function BlogSection({ posts, limit }: Props) {
const { t, locale } = useI18n();
const allTags = useMemo(() => { const allTags = useMemo(() => {
const tags = new Set<string>(); const tags = new Set<string>();
posts.forEach((p) => p.tags.forEach((t) => tags.add(t))); posts.forEach((p) => p.tags.forEach((tag) => tags.add(tag)));
return Array.from(tags); return Array.from(tags);
}, [posts]); }, [posts]);
const [activeTag, setActiveTag] = useState("all"); const [activeTag, setActiveTag] = useState("all");
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (activeTag === "all") return posts; let list = activeTag === "all" ? posts : posts.filter((p) => p.tags.includes(activeTag));
return posts.filter((p) => p.tags.includes(activeTag)); if (limit) list = list.slice(0, limit);
}, [posts, activeTag]); return list;
}, [posts, activeTag, limit]);
return ( return (
<section className="section blog-section" id="blog"> <section className="section blog-section section-compact" id="blog">
<div className="container"> <div className="container">
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">📝 BLOG</span> <span className="section-tag">{t.home.blogTag}</span>
<h2>游民博客</h2> <h2>{t.home.blogTitle}</h2>
<p>深度攻略、签证指南与旅居经验分享</p> <p>{t.home.blogSubtitle}</p>
</div> </div>
{allTags.length > 0 && ( {!limit && allTags.length > 0 && (
<div className="blog-filters reveal"> <div className="blog-filters reveal">
<button className={`filter-btn${activeTag === "all" ? " active" : ""}`} onClick={() => setActiveTag("all")}> <button type="button" className={`filter-btn${activeTag === "all" ? " active" : ""}`} onClick={() => setActiveTag("all")}>
📚 全部 📚 {locale === "en" ? "All" : "全部"}
</button> </button>
{allTags.map((t) => ( {allTags.map((tag) => (
<button key={t} className={`filter-btn${activeTag === t ? " active" : ""}`} onClick={() => setActiveTag(t)}> <button key={tag} type="button" className={`filter-btn${activeTag === tag ? " active" : ""}`} onClick={() => setActiveTag(tag)}>
{t} {tag}
</button> </button>
))} ))}
</div> </div>
@ -46,20 +54,14 @@ export default function BlogSection({ posts }: { posts: BlogPost[] }) {
<div className="blog-emoji">{post.emoji}</div> <div className="blog-emoji">{post.emoji}</div>
<div className="blog-meta"> <div className="blog-meta">
<span>📅 {post.published_at}</span> <span>📅 {post.published_at}</span>
<span>⏱️ {post.read_time} 分钟阅读</span> <span>⏱️ {post.read_time} min</span>
</div> </div>
<h3>{post.title}</h3> <h3>{post.title}</h3>
<p>{post.excerpt}</p> <p>{post.excerpt}</p>
<div className="blog-tags"> <span className="blog-read-more">{t.common.learnMore}</span>
{post.tags.map((t) => <span key={t} className="blog-tag">{t}</span>)}
</div>
<span className="blog-read-more">阅读全文 →</span>
</Link> </Link>
))} ))}
</div> </div>
{filtered.length === 0 && (
<p className="dest-empty reveal">该分类暂无文章</p>
)}
</div> </div>
</section> </section>
); );

View File

@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
import type { ConversationItem } from "@/lib/types";
export default function ChatClient() {
const { user, token } = useAuth();
const { t } = useI18n();
const [items, setItems] = useState<ConversationItem[]>([]);
useEffect(() => {
if (!token) return;
api.getConversations(token).then(setItems).catch(() => setItems([]));
}, [token]);
if (!user) {
return (
<div className="chat-page">
<div className="container">
<div className="dating-gate reveal">
<h2>{t.chat.loginTitle}</h2>
<Link href="/login?next=/chat" className="btn btn-primary">{t.nav.login}</Link>
</div>
</div>
</div>
);
}
return (
<div className="chat-page">
<div className="container">
<nav className="detail-nav">
<Link href="/dating">{t.nav.dating}</Link>
</nav>
<div className="section-header reveal">
<span className="section-tag">{t.chat.tag}</span>
<h1>{t.chat.title}</h1>
</div>
<div className="chat-list">
{items.map((c) => (
<Link key={c.id} href={`/chat/${c.id}`} className={`chat-list-item reveal${c.unreadCount > 0 ? " unread" : ""}`}>
<span className="chat-avatar">{c.peer?.photo || "💬"}</span>
<div>
<strong>{c.peer?.name || "游民"}</strong>
<p>{c.lastMessagePreview || t.chat.noMessages}</p>
</div>
{c.unreadCount > 0 && <em className="chat-unread">{c.unreadCount}</em>}
</Link>
))}
{items.length === 0 && <p className="dest-empty">{t.chat.empty}</p>}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,67 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
import type { ChatMessage } from "@/lib/types";
export default function ChatThreadClient({ convId }: { convId: string }) {
const { token } = useAuth();
const { t } = useI18n();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [text, setText] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
const load = () => {
if (!token) return;
api.getMessages(token, convId).then(setMessages).catch(() => setMessages([]));
};
useEffect(() => {
load();
const id = setInterval(load, 4000);
return () => clearInterval(id);
}, [token, convId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const send = async () => {
if (!token || !text.trim()) return;
try {
await api.sendMessage(token, convId, text.trim());
setText("");
load();
} catch { /* ignore */ }
};
return (
<div className="chat-thread-page">
<div className="container chat-thread-wrap">
<nav className="detail-nav">
<Link href="/chat">← {t.chat.back}</Link>
</nav>
<div className="chat-thread-messages">
{messages.map((m) => (
<div key={m.id} className={`chat-bubble${m.mine ? " mine" : ""}`}>
{m.body}
</div>
))}
<div ref={bottomRef} />
</div>
<div className="chat-compose">
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={t.chat.placeholder}
onKeyDown={(e) => e.key === "Enter" && send()}
/>
<button type="button" className="btn btn-primary" onClick={send}>{t.chat.send}</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,128 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { Discussion, DiscussionDetail } from "@/lib/types";
const CATEGORIES = ["全部", "签证", "远程工作", "住宿", "安全", "社区"];
interface Props {
discussions: Discussion[];
featured?: DiscussionDetail | null;
}
export default function CommunityHubClient({ discussions, featured }: Props) {
const { t } = useI18n();
const [category, setCategory] = useState("全部");
const [q, setQ] = useState("");
const filtered = useMemo(() => {
let list = discussions;
if (category !== "全部") list = list.filter((d) => d.category === category);
if (q.trim()) {
const s = q.toLowerCase();
list = list.filter(
(d) =>
d.title.toLowerCase().includes(s) ||
d.excerpt.toLowerCase().includes(s) ||
d.tags.some((tag) => tag.toLowerCase().includes(s))
);
}
return list;
}, [discussions, category, q]);
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav">
<Link href="/">{t.common.backHome}</Link>
<span> · </span>
<Link href="/meetups">{t.nav.meetups}</Link>
</nav>
<div className="section-header reveal">
<span className="section-tag">{t.community.tag}</span>
<h1>{t.community.title}</h1>
<p>{t.community.subtitle}</p>
</div>
<div className="community-banner reveal">
<div>
<strong>{t.community.bannerTitle}</strong>
<p>{t.community.bannerDesc}</p>
</div>
<Link href="/next-stop" className="btn btn-primary">{t.community.bannerCta}</Link>
</div>
{featured && featured.replies.length > 0 && (
<section className="community-featured reveal">
<span className="community-pinned">📌 {t.community.hot}</span>
<h3>{featured.title}</h3>
<p>{featured.excerpt}</p>
<div className="community-replies-preview">
{featured.replies.slice(0, 2).map((r) => (
<blockquote key={r.id}>
<span>{r.author_emoji} {r.author}</span>
{r.content}
</blockquote>
))}
</div>
<Link href={`/community/${featured.id}`}>{t.community.readMore} →</Link>
</section>
)}
<div className="community-toolbar reveal">
<Link href="/community/new" className="btn btn-primary btn-sm">{t.community.newTopic}</Link>
<input
className="tools-hub-search"
placeholder={t.community.search}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="tools-hub-filters">
{CATEGORIES.map((c) => (
<button
key={c}
type="button"
className={`filter-btn${category === c ? " active" : ""}`}
onClick={() => setCategory(c)}
>
{c}
</button>
))}
</div>
</div>
<div className="community-list">
{filtered.map((d) => (
<Link key={d.id} href={`/community/${d.id}`} className="community-card reveal">
{d.is_pinned && <span className="community-pinned">📌</span>}
<div className="community-card-head">
<span>{d.author_emoji}</span>
<div>
<h3>{d.title}</h3>
<p className="community-meta">
{d.author} · {d.category} · {d.created_at}
</p>
</div>
</div>
<p className="community-excerpt">{d.excerpt}</p>
<footer>
<span>💬 {d.reply_count}</span>
<span>❤️ {d.like_count}</span>
{d.tags.map((tag) => (
<span key={tag} className="community-tag">{tag}</span>
))}
</footer>
</Link>
))}
</div>
{filtered.length === 0 && (
<p className="dest-empty">{t.community.empty}</p>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
export default function CommunityNewClient() {
const { token } = useAuth();
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [category, setCategory] = useState("社区");
const submit = async () => {
if (!token) {
router.push("/login?next=/community/new");
return;
}
try {
const res = await api.createDiscussion(token, { title, content, excerpt: content, category });
toast(t.community.createOk, "success");
router.push(`/community/${res.discussion.id}`);
} catch {
toast(t.community.createFail, "error");
}
};
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/community">← {t.community.back}</Link></nav>
<div className="section-header reveal">
<h1>{t.community.newTitle}</h1>
</div>
<div className="join-card reveal">
<label>{t.community.newSubject}</label>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<label>{t.community.newCategory}</label>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
{["签证", "远程工作", "住宿", "安全", "社区"].map((c) => <option key={c}>{c}</option>)}
</select>
<label>{t.community.newBody}</label>
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={6} />
<button type="button" className="btn btn-primary" onClick={submit}>{t.community.newSubmit}</button>
</div>
</div>
</div>
);
}

View File

@ -12,6 +12,7 @@ import {
parseCompareSlugs, parseCompareSlugs,
} from "@/lib/compareScore"; } from "@/lib/compareScore";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { useI18n } from "@/lib/i18n";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
interface Props { interface Props {
@ -22,6 +23,7 @@ export default function CompareClient({ destinations }: Props) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n();
const [picked, setPicked] = useState<string[]>([]); const [picked, setPicked] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
@ -51,7 +53,7 @@ export default function CompareClient({ destinations }: Props) {
let next: string[]; let next: string[];
if (prev.includes(slug)) next = prev.filter((s) => s !== slug); if (prev.includes(slug)) next = prev.filter((s) => s !== slug);
else if (prev.length >= 4) { else if (prev.length >= 4) {
toast("最多对比 4 座城市", "info"); toast(t.compare.maxCities, "info");
return prev; return prev;
} else next = [...prev, slug]; } else next = [...prev, slug];
syncUrl(next); syncUrl(next);
@ -63,11 +65,11 @@ export default function CompareClient({ destinations }: Props) {
if (selected.length < 1) return; if (selected.length < 1) return;
const { added, skipped } = mergeDestinationsIntoTrip(selected, 1); const { added, skipped } = mergeDestinationsIntoTrip(selected, 1);
if (added === 0) { if (added === 0) {
toast(skipped ? "这些城市已在计划中" : "未能加入", "info"); toast(skipped ? t.compare.alreadyInPlan : t.compare.alreadyOne, "info");
router.push("/plan"); router.push("/plan");
return; return;
} }
toast(`已加入 ${added} 座城市${skipped ? `(跳过 ${skipped})` : ""}`); toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}${skipped ? `(${t.compare.skipped} ${skipped})` : ""}`);
router.push("/plan"); router.push("/plan");
}; };
@ -75,41 +77,41 @@ export default function CompareClient({ destinations }: Props) {
if (winnerIdx < 0) return; if (winnerIdx < 0) return;
const d = selected[winnerIdx]; const d = selected[winnerIdx];
const { added } = mergeDestinationsIntoTrip([d], 1); const { added } = mergeDestinationsIntoTrip([d], 1);
toast(added ? `${d.emoji} ${d.name} 已写入计划` : "该城已在计划中", added ? "success" : "info"); toast(added ? `${d.emoji} ${d.name} ${t.compare.wrotePlan}` : t.compare.alreadyOne, added ? "success" : "info");
if (added) router.push("/plan"); if (added) router.push("/plan");
}; };
const share = async () => { const share = async () => {
if (selected.length < 2) { if (selected.length < 2) {
toast("至少选 2 座城市再分享", "info"); toast(t.compare.needShare, "info");
return; return;
} }
const url = `${window.location.origin}${compareUrl(selected.map((d) => d.slug))}`; const url = `${window.location.origin}${compareUrl(selected.map((d) => d.slug))}`;
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
toast("对比链接已复制"); toast(t.compare.linkCopied);
}; };
return ( return (
<div className="compare-page"> <div className="compare-page">
<div className="container"> <div className="container">
<nav className="detail-nav"> <nav className="detail-nav">
<Link href="/">← 返回首页</Link> <Link href="/">{t.common.backHome}</Link>
<Link href="/#destinations">目的地</Link> <Link href="/#destinations">{t.compare.dest}</Link>
<Link href="/plan">旅居计划</Link> <Link href="/plan">{t.compare.plan}</Link>
</nav> </nav>
<header className="compare-page-hero"> <header className="compare-page-hero">
<span className="section-tag">⚖️ COMPARE</span> <span className="section-tag">{t.compare.tag}</span>
<h1>城市对比台</h1> <h1>{t.compare.title}</h1>
<p>最多 4 城并排对比费用、网速、气候与评分。决定后一键写入旅居计划。</p> <p>{t.compare.subtitle}</p>
</header> </header>
<section className="compare-pick plan-panel"> <section className="compare-pick plan-panel">
<div className="plan-panel-head"> <div className="plan-panel-head">
<h2>选择城市({picked.length}/4)</h2> <h2>{t.compare.pick}({picked.length}/4)</h2>
<div className="plan-actions"> <div className="plan-actions">
<button type="button" className="btn btn-ghost btn-sm" disabled={picked.length === 0} onClick={() => { setPicked([]); syncUrl([]); }}>清空</button> <button type="button" className="btn btn-ghost btn-sm" disabled={picked.length === 0} onClick={() => { setPicked([]); syncUrl([]); }}>{t.compare.clear}</button>
<button type="button" className="btn btn-ghost btn-sm" disabled={selected.length < 2} onClick={share}>复制链接</button> <button type="button" className="btn btn-ghost btn-sm" disabled={selected.length < 2} onClick={share}>{t.compare.copyLink}</button>
</div> </div>
</div> </div>
<div className="compare-pick-grid"> <div className="compare-pick-grid">
@ -135,8 +137,8 @@ export default function CompareClient({ destinations }: Props) {
{selected.length < 2 ? ( {selected.length < 2 ? (
<div className="plan-empty compare-page-empty"> <div className="plan-empty compare-page-empty">
<span className="plan-empty-icon">⚖️</span> <span className="plan-empty-icon">⚖️</span>
<p>再选至少 {Math.max(0, 2 - selected.length)} 座城市开始对比</p> <p>{t.compare.needMore} {Math.max(0, 2 - selected.length)} {t.compare.needMoreSuffix}</p>
<Link href="/#destinations" className="btn btn-ghost">回首页勾选对比</Link> <Link href="/#destinations" className="btn btn-ghost">{t.compare.backDest}</Link>
</div> </div>
) : ( ) : (
<> <>
@ -144,7 +146,7 @@ export default function CompareClient({ destinations }: Props) {
<div className="compare-cards"> <div className="compare-cards">
{selected.map((d, i) => ( {selected.map((d, i) => (
<div key={d.slug} className={`compare-city-card${i === winnerIdx ? " winner" : ""}`}> <div key={d.slug} className={`compare-city-card${i === winnerIdx ? " winner" : ""}`}>
{i === winnerIdx && <span className="compare-winner-badge">👑 综合推荐</span>} {i === winnerIdx && <span className="compare-winner-badge">{t.compare.winner}</span>}
<span className="compare-city-emoji">{d.emoji}</span> <span className="compare-city-emoji">{d.emoji}</span>
<h3>{d.name}</h3> <h3>{d.name}</h3>
<span className="compare-city-country">{d.country}</span> <span className="compare-city-country">{d.country}</span>
@ -161,7 +163,7 @@ export default function CompareClient({ destinations }: Props) {
</svg> </svg>
<span className="compare-score-num">{scores[i]}</span> <span className="compare-score-num">{scores[i]}</span>
</div> </div>
<Link href={`/destinations/${d.slug}`} className="compare-city-link">查看详情 →</Link> <Link href={`/destinations/${d.slug}`} className="compare-city-link">{t.common.detail}</Link>
</div> </div>
))} ))}
</div> </div>
@ -203,10 +205,10 @@ export default function CompareClient({ destinations }: Props) {
<div className="compare-page-actions"> <div className="compare-page-actions">
<button type="button" className="btn btn-ghost" onClick={addWinner} disabled={winnerIdx < 0}> <button type="button" className="btn btn-ghost" onClick={addWinner} disabled={winnerIdx < 0}>
推荐城写入计划 {t.compare.addWinner}
</button> </button>
<button type="button" className="btn btn-primary" onClick={addAll}> <button type="button" className="btn btn-primary" onClick={addAll}>
全部写入旅居计划 → {t.compare.addAll}
</button> </button>
</div> </div>
</section> </section>

View File

@ -0,0 +1,186 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import type { MatchIntent, MatchProfile, MatchQuota } from "@/lib/types";
const INTENTS: { key: MatchIntent; label: string; emoji: string }[] = [
{ key: "friends", label: "交朋友", emoji: "🤝" },
{ key: "dating", label: "约会", emoji: "💕" },
{ key: "partner", label: "伴侣", emoji: "💑" },
{ key: "roommate", label: "合租", emoji: "🏠" },
{ key: "cofounder", label: "联创", emoji: "🚀" },
{ key: "explore", label: "探索", emoji: "🌍" },
];
export default function DatingClient() {
const { user, token } = useAuth();
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
const [intent, setIntent] = useState<MatchIntent>("friends");
const [deck, setDeck] = useState<MatchProfile[]>([]);
const [quota, setQuota] = useState<MatchQuota | null>(null);
const [loading, setLoading] = useState(true);
const [joined, setJoined] = useState(false);
const [matchModal, setMatchModal] = useState<{ conversationId?: string; name: string } | null>(null);
const load = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const [candidates, q] = await Promise.all([
api.getMatchCandidates(token, { intent }),
api.getMatchQuota(token),
]);
setDeck(candidates);
setQuota(q);
setJoined(true);
} catch {
setJoined(false);
setDeck([]);
} finally {
setLoading(false);
}
}, [token, intent]);
useEffect(() => { void load(); }, [load]);
const quickJoin = async () => {
if (!token) {
router.push("/login?next=/dating");
return;
}
try {
await api.socialJoin(token, {
city: "全球",
lookingFor: [intent, "explore"],
bio: "nomadro 游民",
});
toast("资料已完善,开始匹配", "success");
await load();
} catch {
toast("加入失败", "error");
}
};
const swipe = async (action: "like" | "dislike" | "superlike") => {
if (!token || !deck[0]) return;
const current = deck[0];
try {
const res = await api.swipe(token, current.id, action, intent);
if (res.matched && res.match?.conversationId) {
setMatchModal({ conversationId: res.match.conversationId, name: current.name });
}
setDeck((d) => d.slice(1));
const q = await api.getMatchQuota(token);
setQuota(q);
} catch {
toast("滑动失败,请检查登录或配额", "error");
}
};
const top = deck[0];
if (!user) {
return (
<div className="dating-page">
<div className="container">
<div className="dating-gate reveal">
<h2>{t.dating.loginTitle}</h2>
<p>{t.dating.loginDesc}</p>
<Link href="/login?next=/dating" className="btn btn-primary">{t.nav.login}</Link>
</div>
</div>
</div>
);
}
return (
<div className="dating-page">
<div className="container">
<nav className="detail-nav">
<Link href="/">{t.common.backHome}</Link>
<span> · </span>
<Link href="/chat">{t.nav.chat}</Link>
<span> · </span>
<Link href="/dating/likes">{t.dating.likesTitle}</Link>
</nav>
<div className="section-header reveal">
<span className="section-tag">{t.dating.tag}</span>
<h1>{t.dating.title}</h1>
<p>{t.dating.subtitle}</p>
</div>
<div className="dating-intents reveal">
{INTENTS.map((i) => (
<button
key={i.key}
type="button"
className={`filter-btn${intent === i.key ? " active" : ""}`}
onClick={() => setIntent(i.key)}
>
{i.emoji} {i.label}
</button>
))}
</div>
{quota && (
<p className="dating-quota reveal">
{quota.vip ? "✨ VIP 无限滑" : `今日剩余 ${quota.remaining}/${quota.limit} 次`}
</p>
)}
{!joined && !loading && (
<div className="dating-gate reveal">
<p>{t.dating.joinFirst}</p>
<button type="button" className="btn btn-primary" onClick={quickJoin}>{t.dating.joinBtn}</button>
</div>
)}
{top && (
<div className="dating-card reveal">
<div className="dating-card-photo">{top.photo || "🧑‍💻"}</div>
<h2>{top.name}</h2>
<p className="dating-meta">{top.location} · {top.gender} {top.single ? `· ${top.single}` : ""}</p>
<p>{top.bio}</p>
<div className="dating-tags">
{(top.tags || []).map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)}
</div>
<div className="dating-actions">
<button type="button" className="dating-btn pass" onClick={() => swipe("dislike")}>✕</button>
<button type="button" className="dating-btn super" onClick={() => swipe("superlike")}>⭐</button>
<button type="button" className="dating-btn like" onClick={() => swipe("like")}>♥</button>
{token && (
<button type="button" className="btn btn-sm" title={t.dating.undo} onClick={() => api.undoSwipe(token).then(() => load())}>↩</button>
)}
</div>
</div>
)}
{!loading && joined && !top && (
<p className="dest-empty reveal">{t.dating.empty}</p>
)}
</div>
{matchModal && (
<div className="dating-match-modal" role="dialog">
<div className="dating-match-box">
<h3>🎉 {t.dating.matched} {matchModal.name}!</h3>
<div className="dating-match-actions">
<button type="button" className="btn" onClick={() => setMatchModal(null)}>{t.dating.keepSwiping}</button>
{matchModal.conversationId && (
<Link href={`/chat/${matchModal.conversationId}`} className="btn btn-primary">{t.dating.chatNow}</Link>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
import type { MatchProfile } from "@/lib/types";
export default function DatingLikesClient() {
const { user, token } = useAuth();
const { t } = useI18n();
const [likes, setLikes] = useState<MatchProfile[]>([]);
useEffect(() => {
if (!token) return;
api.getMatchLikes(token).then(setLikes).catch(() => setLikes([]));
}, [token]);
if (!user) {
return (
<div className="dating-page">
<div className="container">
<div className="dating-gate reveal">
<h2>{t.dating.loginTitle}</h2>
<Link href="/login?next=/dating/likes" className="btn btn-primary">{t.nav.login}</Link>
</div>
</div>
</div>
);
}
return (
<div className="dating-page">
<div className="container">
<nav className="detail-nav">
<Link href="/dating">{t.dating.backSwipe}</Link>
</nav>
<div className="section-header reveal">
<span className="section-tag">{t.dating.tag}</span>
<h1>{t.dating.likesTitle}</h1>
<p>{t.dating.likesSubtitle}</p>
</div>
<div className="dating-likes-grid">
{likes.map((p) => (
<div key={p.id} className="dating-like-card reveal">
<div className="dating-like-photo">{p.photo || "🧑‍💻"}</div>
<strong>{p.name}</strong>
<p className="dating-meta">{p.location}</p>
</div>
))}
{likes.length === 0 && <p className="dest-empty">{t.dating.likesEmpty}</p>}
</div>
</div>
</div>
);
}

View File

@ -2,28 +2,31 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import FavoriteButton from "./FavoriteButton"; import FavoriteButton from "./FavoriteButton";
const INITIAL = 8;
interface Props { interface Props {
destinations: Destination[]; destinations: Destination[];
onCompare?: (slug: string) => void;
compareList?: string[];
onOpenMatcher?: () => void; onOpenMatcher?: () => void;
} }
const FILTERS = [ export default function Destinations({ destinations, onOpenMatcher }: Props) {
{ key: "all", label: "🌏 全部" }, const { t } = useI18n();
{ key: "sea", label: "🌴 东南亚" },
{ key: "europe", label: "🏰 欧洲" },
{ key: "latam", label: "🌮 拉美" },
{ key: "asia", label: "🏯 东亚" },
];
export default function Destinations({ destinations, onCompare, compareList = [], onOpenMatcher }: Props) {
const [filter, setFilter] = useState("all"); const [filter, setFilter] = useState("all");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [sort, setSort] = useState("rating"); const [sort, setSort] = useState("rating");
const [expanded, setExpanded] = useState(false);
const FILTERS = [
{ key: "all", label: t.home.filterAll },
{ key: "sea", label: t.home.filterSea },
{ key: "europe", label: t.home.filterEurope },
{ key: "latam", label: t.home.filterLatam },
{ key: "asia", label: t.home.filterAsia },
];
const filtered = useMemo(() => { const filtered = useMemo(() => {
let list = [...destinations]; let list = [...destinations];
@ -39,54 +42,48 @@ export default function Destinations({ destinations, onCompare, compareList = []
return list; return list;
}, [destinations, filter, search, sort]); }, [destinations, filter, search, sort]);
const visible = expanded ? filtered : filtered.slice(0, INITIAL);
const canExpand = filtered.length > INITIAL;
return ( return (
<section className="section destinations" id="destinations"> <section className="section destinations section-compact" id="destinations">
<div className="container"> <div className="container">
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">🌴 TOP DESTINATIONS</span> <span className="section-tag">{t.home.destTag}</span>
<h2>热门旅居目的地</h2> <h2>{t.home.destTitle}</h2>
<p>精选全球最适合远程工作的城市,点击查看详情,勾选对比</p> <p>{t.home.destSubtitle}</p>
{onOpenMatcher && ( {onOpenMatcher && (
<button className="btn btn-primary dest-matcher-btn" onClick={onOpenMatcher}> <button type="button" className="btn btn-primary dest-matcher-btn" onClick={onOpenMatcher}>
🎯 不知道去哪?智能匹配 🎯 {t.home.destMatcher}
</button> </button>
)} )}
</div> </div>
<div className="dest-filters reveal"> <div className="dest-filters reveal">
<div className="filter-search"> <div className="filter-search">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg>
<input placeholder="搜索城市..." value={search} onChange={(e) => setSearch(e.target.value)} /> <input placeholder={t.home.destSearch} value={search} onChange={(e) => setSearch(e.target.value)} />
</div> </div>
<div className="filter-tags"> <div className="filter-tags">
{FILTERS.map((f) => ( {FILTERS.map((f) => (
<button key={f.key} className={`filter-btn${filter === f.key ? " active" : ""}`} <button key={f.key} type="button" className={`filter-btn${filter === f.key ? " active" : ""}`}
onClick={() => setFilter(f.key)}>{f.label}</button> onClick={() => { setFilter(f.key); setExpanded(false); }}>{f.label}</button>
))} ))}
</div> </div>
<div className="filter-sort"> <div className="filter-sort">
<select value={sort} onChange={(e) => setSort(e.target.value)}> <select value={sort} onChange={(e) => setSort(e.target.value)}>
<option value="rating">⭐ 评分排序</option> <option value="rating">{t.home.sortRating}</option>
<option value="cost-asc">💰 价格从低到高</option> <option value="cost-asc">{t.home.sortCostAsc}</option>
<option value="cost-desc">💰 价格从高到低</option> <option value="cost-desc">{t.home.sortCostDesc}</option>
<option value="speed">📶 网速排序</option> <option value="speed">{t.home.sortSpeed}</option>
</select> </select>
</div> </div>
</div> </div>
<div className="dest-grid" id="dest-grid"> <div className="dest-grid" id="dest-grid">
{filtered.map((d) => ( {visible.map((d) => (
<article key={d.slug} className="dest-card reveal" style={{ position: "relative" }}> <article key={d.slug} className="dest-card reveal">
{onCompare && ( <Link href={`/destinations/${d.slug}`} className="dest-card-link">
<button className="compare-check" onClick={() => onCompare(d.slug)}
style={{
position: "absolute", top: 12, right: 12, zIndex: 2,
background: compareList.includes(d.slug) ? "var(--accent-3)" : "var(--bg-glass)",
border: "var(--border-glass)", borderRadius: "50%", width: 32, height: 32,
cursor: "pointer", fontSize: "0.8rem",
}}>
{compareList.includes(d.slug) ? "✓" : "⚖️"}
</button>
)}
<Link href={`/destinations/${d.slug}`} style={{ textDecoration: "none", color: "inherit" }}>
<div className="dest-img" style={{ "--hue": d.hue } as React.CSSProperties}> <div className="dest-img" style={{ "--hue": d.hue } as React.CSSProperties}>
<div className="dest-emoji">{d.emoji}</div> <div className="dest-emoji">{d.emoji}</div>
<div className="dest-overlay"><span className="dest-tag">{d.tag}</span></div> <div className="dest-overlay"><span className="dest-tag">{d.tag}</span></div>
@ -95,26 +92,40 @@ export default function Destinations({ destinations, onCompare, compareList = []
<h3>{d.name}, {d.country}</h3> <h3>{d.name}, {d.country}</h3>
<p>{d.description}</p> <p>{d.description}</p>
<div className="dest-meta"> <div className="dest-meta">
<span>💰 ¥{d.cost.toLocaleString()}/月</span> <span>💰 ¥{d.cost.toLocaleString()}</span>
<span>📶 {d.speed}Mbps</span> <span>📶 {d.speed}Mbps</span>
<span>🌡️ {d.temperature}°C</span> <span>🌡️ {d.temperature}°C</span>
</div> </div>
<div className="dest-rating"> <div className="dest-rating">
<span className="stars">{"⭐".repeat(Math.round(d.rating / 2))}</span> <span className="stars">{"⭐".repeat(Math.round(d.rating / 2))}</span>
<span>{d.rating} 分</span> <span>{d.rating}</span>
</div> </div>
</div> </div>
</Link> </Link>
<div style={{ padding: "0 20px 20px", display: "flex", gap: 8, alignItems: "center" }}> <div className="dest-card-actions">
<div onClick={(e) => e.preventDefault()}>
<FavoriteButton slug={d.slug} /> <FavoriteButton slug={d.slug} />
</div> <Link href={`/destinations/${d.slug}`} className="btn btn-ghost btn-sm">
{t.common.detail}
</Link>
</div> </div>
</article> </article>
))} ))}
</div> </div>
{filtered.length === 0 && ( {filtered.length === 0 && (
<p className="dest-empty">😢 没有找到匹配的目的地,试试其他筛选条件</p> <p className="dest-empty">{t.home.destEmpty}</p>
)}
{canExpand && (
<div className="dest-expand-wrap reveal">
<button
type="button"
className="btn btn-ghost"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? t.home.destShowLess : `${t.home.destShowMore} (${filtered.length - INITIAL})`}
</button>
</div>
)} )}
</div> </div>
</section> </section>

View File

@ -0,0 +1,43 @@
"use client";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { DigitalCourse } from "@/lib/types";
export default function DigitalCourseClient({ course }: { course: DigitalCourse }) {
const { t } = useI18n();
return (
<div className="digital-page">
<div className="container">
<nav className="detail-nav">
<Link href="/digital">← {t.digital.back}</Link>
</nav>
<div className="section-header reveal">
<span className="section-tag">{t.digital.courseTag}</span>
<h1>{t.digital.course}</h1>
</div>
<div className="digital-modules reveal">
{course.modules.map((mod, mi) => (
<section key={mod.title} className="digital-module">
<h2>{mod.title}</h2>
<ul className="digital-lesson-list">
{mod.lessons.map((lesson, li) => (
<li key={lesson.title}>
<Link href={`/digital/course/${mi}/${li}`}>
<span>{lesson.title}</span>
<span className="digital-lesson-meta">
{lesson.duration}
{lesson.free ? ` · ${t.digital.free}` : ` · VIP`}
</span>
</Link>
</li>
))}
</ul>
</section>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { DigitalCourse } from "@/lib/types";
export default function DigitalHomeClient({ course }: { course: DigitalCourse }) {
const { t } = useI18n();
const lessonCount = course.modules.reduce((n, m) => n + m.lessons.length, 0);
return (
<div className="digital-page">
<div className="container">
<nav className="detail-nav">
<Link href="/">{t.common.backHome}</Link>
</nav>
<div className="digital-hero reveal">
<span className="section-tag">{t.digital.tag}</span>
<h1>{t.digital.title}</h1>
<p>{t.digital.subtitle}</p>
</div>
<div className="digital-grid reveal">
<Link href="/digital/course" className="digital-card">
<span className="digital-card-emoji">🎓</span>
<h3>{t.digital.course}</h3>
<p>{course.modules.length} {t.digital.modules} · {lessonCount} {t.digital.lessons}</p>
</Link>
<Link href="/book" className="digital-card">
<span className="digital-card-emoji">📖</span>
<h3>{t.digital.ebook}</h3>
<p>{t.digital.ebookDesc}</p>
</Link>
<Link href="/digital/jobs" className="digital-card">
<span className="digital-card-emoji">💼</span>
<h3>{t.digital.jobs}</h3>
<p>{t.digital.jobsDesc}</p>
</Link>
<Link href="/join" className="digital-card digital-card-vip">
<span className="digital-card-emoji">✨</span>
<h3>{t.digital.vip}</h3>
<p>{t.digital.vipDesc}</p>
</Link>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,40 @@
"use client";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { DigitalJob } from "@/lib/types";
export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
const { t } = useI18n();
return (
<div className="digital-page">
<div className="container">
<nav className="detail-nav">
<Link href="/digital">← {t.digital.back}</Link>
</nav>
<div className="section-header reveal">
<span className="section-tag">{t.digital.jobsTag}</span>
<h1>{t.digital.jobs}</h1>
<p>{t.digital.jobsSubtitle}</p>
</div>
<div className="digital-jobs-grid reveal">
{jobs.map((job) => (
<article key={job.id} className="digital-job-card">
<h3>{job.title}</h3>
<p className="digital-job-company">{job.company} · {job.location}</p>
<p className="digital-job-meta">{job.type} · {job.salary}</p>
<div className="meetup-tags">
{job.tags.map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)}
</div>
<a href={job.url} target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm">
{t.digital.apply}
</a>
</article>
))}
</div>
{jobs.length === 0 && <p className="dest-empty">{t.digital.jobsEmpty}</p>}
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { DigitalLesson } from "@/lib/types";
export default function DigitalLessonClient({
lesson,
locked,
moduleIndex,
lessonIndex,
}: {
lesson: DigitalLesson | null;
locked: boolean;
moduleIndex: number;
lessonIndex: number;
}) {
const { t } = useI18n();
if (locked || !lesson) {
return (
<div className="digital-page">
<div className="container">
<div className="digital-gate reveal">
<h2>{t.digital.locked}</h2>
<p>{t.digital.lockedDesc}</p>
<Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link>
<Link href="/digital/course" className="btn">{t.digital.backCourse}</Link>
</div>
</div>
</div>
);
}
const prev = lessonIndex > 0 ? `/digital/course/${moduleIndex}/${lessonIndex - 1}` : null;
const next = `/digital/course/${moduleIndex}/${lessonIndex + 1}`;
return (
<div className="digital-page">
<div className="container">
<nav className="detail-nav">
<Link href="/digital/course">← {t.digital.backCourse}</Link>
</nav>
<article className="digital-lesson reveal">
<span className="section-tag">{lesson.duration}</span>
<h1>{lesson.title}</h1>
<div className="digital-lesson-body">
{lesson.content.split("\n").map((p) => (
<p key={p.slice(0, 24)}>{p}</p>
))}
</div>
<footer className="digital-lesson-nav">
{prev && <Link href={prev} className="btn">← {t.digital.prev}</Link>}
<Link href={next} className="btn btn-primary">{t.digital.next} →</Link>
</footer>
</article>
</div>
</div>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
export default function DiscussionDetailClient({ discussionId, initial }: { discussionId: string; initial: import("@/lib/types").DiscussionDetail }) {
const { user, token } = useAuth();
const { toast } = useToast();
const { t } = useI18n();
const router = useRouter();
const [discussion, setDiscussion] = useState(initial);
const [reply, setReply] = useState("");
const [likes, setLikes] = useState(discussion.like_count);
const [loading, setLoading] = useState(false);
const refresh = async () => {
const d = await api.getDiscussion(discussionId);
setDiscussion(d);
setLikes(d.like_count);
};
const submitReply = async () => {
if (!token || !reply.trim()) return;
setLoading(true);
try {
await api.postDiscussionReply(token, discussionId, reply.trim());
setReply("");
await refresh();
toast(t.community.replyOk, "success");
} catch {
toast(t.community.replyFail, "error");
} finally {
setLoading(false);
}
};
const toggleLike = async () => {
if (!token) {
router.push(`/login?next=/community/${discussionId}`);
return;
}
try {
const res = await api.likeDiscussion(token, discussionId);
setLikes(res.like_count);
} catch { /* ignore */ }
};
return (
<div className="community-detail-page">
<div className="container">
<nav className="detail-nav">
<Link href="/community">← {t.community.back}</Link>
</nav>
<article className="community-detail reveal">
<header>
<span className="community-detail-cat">{discussion.category}</span>
<h1>{discussion.title}</h1>
<p className="community-meta">
{discussion.author_emoji} {discussion.author} · {discussion.created_at}
</p>
</header>
<div className="community-detail-body">
<p>{discussion.excerpt}</p>
</div>
<div className="community-detail-stats">
<span>💬 {discussion.reply_count} {t.community.replies}</span>
<button type="button" className="btn btn-sm" onClick={toggleLike}>❤️ {likes}</button>
</div>
</article>
<section className="community-replies reveal">
<h2>{t.community.replies} ({discussion.replies.length})</h2>
{discussion.replies.map((r) => (
<div key={r.id} className="community-reply">
<div className="community-reply-head">
<span>{r.author_emoji} {r.author}</span>
<time>{r.created_at}</time>
</div>
<p>{r.content}</p>
</div>
))}
</section>
{user && token ? (
<div className="community-compose reveal">
<textarea value={reply} onChange={(e) => setReply(e.target.value)} rows={3} placeholder={t.community.replyPlaceholder} />
<button type="button" className="btn btn-primary" disabled={loading} onClick={submitReply}>{t.community.replySend}</button>
</div>
) : (
<div className="community-reply-hint reveal">
<Link href={`/login?next=/community/${discussionId}`} className="btn btn-primary">{t.nav.login}</Link>
</div>
)}
</div>
</div>
);
}

View File

@ -1,32 +1,41 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { FAQ } from "@/lib/types"; import type { FAQ } from "@/lib/types";
export default function FAQSection({ faqs }: { faqs: FAQ[] }) { interface Props {
faqs: FAQ[];
limit?: number;
}
export default function FAQSection({ faqs, limit }: Props) {
const { t, locale } = useI18n();
const [active, setActive] = useState<string | null>(null); const [active, setActive] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [showAll, setShowAll] = useState(!limit);
const sorted = useMemo(() => [...faqs].sort((a, b) => a.order - b.order), [faqs]); const sorted = useMemo(() => [...faqs].sort((a, b) => a.order - b.order), [faqs]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search.trim()) return sorted; let list = sorted;
if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
return sorted.filter((f) => f.question.toLowerCase().includes(q) || f.answer.toLowerCase().includes(q)); list = list.filter((f) => f.question.toLowerCase().includes(q) || f.answer.toLowerCase().includes(q));
}, [sorted, search]); }
if (limit && !showAll) list = list.slice(0, limit);
return list;
}, [sorted, search, limit, showAll]);
const expandAll = () => setActive("__all__"); const isOpen = (id: string) => active === id;
const collapseAll = () => setActive(null);
const isOpen = (id: string) => active === "__all__" || active === id;
return ( return (
<section className="section faq-section" id="faq"> <section className="section faq-section section-compact" id="faq">
<div className="container"> <div className="container">
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">❓ FAQ</span> <span className="section-tag">{t.home.faqTag}</span>
<h2>常见问题</h2> <h2>{t.home.faqTitle}</h2>
<p>关于数字游民生活,你可能想知道的一切</p> <p>{t.home.faqSubtitle}</p>
</div> </div>
<div className="faq-toolbar reveal"> <div className="faq-toolbar reveal">
@ -34,32 +43,37 @@ export default function FAQSection({ faqs }: { faqs: FAQ[] }) {
<span className="faq-search-icon">🔍</span> <span className="faq-search-icon">🔍</span>
<input <input
className="faq-search" className="faq-search"
placeholder="搜索问题,如「签证」「保险」「税务」..." placeholder={t.home.faqSearch}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
{search && <button className="faq-search-clear" onClick={() => setSearch("")}>✕</button>}
</div>
<div className="faq-toolbar-actions">
<button className="btn btn-ghost btn-sm" onClick={expandAll}>展开全部</button>
<button className="btn btn-ghost btn-sm" onClick={collapseAll}>收起全部</button>
</div> </div>
</div> </div>
<div className="faq-list"> <div className="faq-list">
{filtered.map((f) => ( {filtered.map((f) => (
<div key={f.id} className={`faq-item reveal${isOpen(f.id) ? " active" : ""}`}> <div key={f.id} className={`faq-item reveal${isOpen(f.id) ? " active" : ""}`}>
<button className="faq-question" onClick={() => setActive(active === f.id ? null : f.id)}> <button type="button" className="faq-question" onClick={() => setActive(active === f.id ? null : f.id)}>
<span>{f.question}</span> <span>{f.question}</span>
<svg className="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg> <svg className="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
</button> </button>
<div className="faq-answer"><p>{f.answer}</p></div> <div className="faq-answer"><p>{f.answer}</p></div>
</div> </div>
))} ))}
{filtered.length === 0 && (
<p className="faq-empty">没有找到相关问题,试试其他关键词 🔎</p>
)}
</div> </div>
{limit && !showAll && sorted.length > limit && (
<div className="dest-expand-wrap reveal">
<button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}>
{locale === "en" ? `Show all ${sorted.length} questions` : `展开全部 ${sorted.length} 个问题`}
</button>
</div>
)}
<p className="faq-plan-hint reveal">
{locale === "en" ? "Still exploring? " : "还在选城?"}
<a href="#destinations">{locale === "en" ? "Browse destinations" : "继续看目的地"}</a>
</p>
</div> </div>
</section> </section>
); );

View File

@ -1,4 +1,9 @@
import Link from "next/link"; "use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useI18n } from "@/lib/i18n";
import { loginNextFrom } from "@/lib/authNext";
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com"; const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
@ -23,13 +28,17 @@ function BrandLogo() {
} }
export default function Footer() { export default function Footer() {
const { t, locale } = useI18n();
const pathname = usePathname();
const isHome = pathname === "/";
const loginNext = loginNextFrom(pathname);
return ( return (
<footer className="footer"> <footer className="footer">
<div className="container"> <div className="container">
<div className="footer-grid"> <div className="footer-grid">
<div className="footer-brand"> <div className="footer-brand">
<BrandLogo /> <BrandLogo />
<p>让每个人都能自由地工作和生活在这个星球上 🌏</p> <p>{locale === "en" ? "Work and live freely anywhere on this planet 🌏" : "让每个人都能自由地工作和生活在这个星球上 🌏"}</p>
<div className="social-links"> <div className="social-links">
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer" aria-label="Twitter">𝕏</a> <a href="https://twitter.com" target="_blank" rel="noopener noreferrer" aria-label="Twitter">𝕏</a>
<a href="https://instagram.com" target="_blank" rel="noopener noreferrer" aria-label="Instagram">📷</a> <a href="https://instagram.com" target="_blank" rel="noopener noreferrer" aria-label="Instagram">📷</a>
@ -38,28 +47,46 @@ export default function Footer() {
</div> </div>
</div> </div>
<div className="footer-links"> <div className="footer-links">
<h4>探索</h4> <h4>{t.footer.explore}</h4>
<Link href="/#destinations">目的地</Link> <Link href="/#destinations">{t.footer.destinations}</Link>
<Link href="/plan">旅居计划</Link> {!isHome && (
<Link href="/compare">城市对比</Link> <>
<Link href="/#visa">签证指南</Link> <Link href="/plan">{t.footer.plan}</Link>
<Link href="/#blog">博客</Link> <Link href="/compare">{t.footer.compare}</Link>
<Link href="/about">关于 nomadro</Link> </>
)}
<Link href="/#visa">{t.footer.visa}</Link>
<Link href="/#blog">{t.footer.blog}</Link>
<Link href="/next-stop">{t.footer.nextStop}</Link>
<Link href="/meetups">{t.footer.meetups}</Link>
<Link href="/community">{t.footer.community}</Link>
<Link href="/dating">{t.footer.dating}</Link>
<Link href="/gigs">{t.footer.gigs}</Link>
<Link href="/digital">{t.footer.digital}</Link>
<Link href="/pricing">{t.footer.pricing}</Link>
<Link href="/about">{t.footer.about}</Link>
</div> </div>
<div className="footer-links"> <div className="footer-links">
<h4>工具</h4> <h4>{t.footer.tools}</h4>
<Link href="/tools">全部工具箱</Link> <Link href="/tools">{t.footer.toolkit}</Link>
<Link href="/plan">计划中心</Link> <Link href="/book">{t.footer.ebook}</Link>
<Link href="/compare">对比台</Link> {!isHome && (
<Link href="/#calculator">费用计算器</Link> <>
<Link href="/changelog">更新日志</Link> <Link href="/plan">{t.footer.planHub}</Link>
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a> <Link href="/compare">{t.footer.compareHub}</Link>
</>
)}
<Link href="/changelog">{t.footer.changelog}</Link>
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API</a>
</div> </div>
<div className="footer-links"> <div className="footer-links">
<h4>账户</h4> <h4>{t.footer.account}</h4>
<Link href="/login">登录 / 注册</Link> <Link href={`/login?next=${encodeURIComponent(loginNext)}`}>{t.footer.login}</Link>
<Link href="/profile">用户中心</Link> <Link href="/profile">{t.footer.profile}</Link>
<Link href="/privacy">隐私政策</Link> <Link href="/join">{t.footer.join}</Link>
<Link href="/chat">{t.footer.chat}</Link>
<Link href="/notifications">{t.footer.notifications}</Link>
<Link href="/privacy">{t.footer.privacy}</Link>
<a href="mailto:hello@nomadro.com">hello@nomadro.com</a> <a href="mailto:hello@nomadro.com">hello@nomadro.com</a>
</div> </div>
</div> </div>

View File

@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import type { GigItem } from "@/lib/types";
export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
const { token } = useAuth();
const { toast } = useToast();
const { t } = useI18n();
const [msg, setMsg] = useState<Record<string, string>>({});
const apply = async (id: string) => {
if (!token) {
toast(t.gigs.loginFirst, "info");
return;
}
const message = msg[id]?.trim();
if (!message) return;
try {
await api.applyGig(token, id, message);
toast(t.gigs.applyOk, "success");
} catch {
toast(t.gigs.applyFail, "error");
}
};
return (
<div className="gigs-page">
<div className="container">
<nav className="detail-nav"><Link href="/tools">← {t.nav.tools}</Link></nav>
<div className="section-header reveal">
<span className="section-tag">{t.gigs.tag}</span>
<h1>{t.gigs.title}</h1>
<p>{t.gigs.subtitle}</p>
</div>
<div className="digital-jobs-grid reveal">
{gigs.map((g) => (
<article key={g.id} className="digital-job-card">
<h3>{g.title}</h3>
<p>{g.description}</p>
<p className="digital-job-meta">{g.poster} · {g.budget} · {g.deadline}</p>
<textarea rows={2} placeholder={t.gigs.applyPlaceholder} value={msg[g.id] || ""} onChange={(e) => setMsg({ ...msg, [g.id]: e.target.value })} />
<button type="button" className="btn btn-primary btn-sm" onClick={() => apply(g.id)}>{t.gigs.apply}</button>
</article>
))}
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter, usePathname } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { TOOL_LINKS } from "@/lib/tools"; import { TOOL_LINKS } from "@/lib/tools";
import type { SearchResult } from "@/lib/types"; import type { SearchResult } from "@/lib/types";
@ -18,16 +18,27 @@ const TYPE_LABELS: Record<string, string> = {
const PAGE_RESULTS: SearchResult[] = [ const PAGE_RESULTS: SearchResult[] = [
{ type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" }, { type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" },
{ type: "page", title: "城市对比台", subtitle: "并排对比费用网速气候,写入计划", emoji: "⚖️", url: "/compare" }, { type: "page", title: "城市对比台", subtitle: "并排对比费用网速气候,写入计划", emoji: "⚖️", url: "/compare" },
{ type: "page", title: "下一站决策", subtitle: "预算网速气候智能推荐", emoji: "🧭", url: "/next-stop" },
{ type: "page", title: "游民活动", subtitle: "线上圆桌与线下聚会", emoji: "🎉", url: "/meetups" },
{ type: "page", title: "社区讨论", subtitle: "签证远程住宿经验交流", emoji: "💬", url: "/community" },
{ type: "page", title: "游民匹配", subtitle: "滑动匹配同路游民", emoji: "💕", url: "/dating" },
{ type: "page", title: "私信", subtitle: "与匹配成功的游民聊天", emoji: "✉️", url: "/chat" },
{ type: "page", title: "游民学院", subtitle: "课程电子书与远程岗位", emoji: "🎓", url: "/digital" },
{ type: "page", title: "赏金任务", subtitle: "远程小任务接单", emoji: "💼", url: "/gigs" },
{ type: "page", title: "会员定价", subtitle: "免费版与 VIP 方案", emoji: "💎", url: "/pricing" },
{ type: "page", title: "通知中心", subtitle: "匹配与社区动态", emoji: "🔔", url: "/notifications" },
{ type: "page", title: "开通会员", subtitle: "VIP 匹配直播与课程", emoji: "✨", url: "/join" },
{ type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" }, { type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" },
{ type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" }, { type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" },
{ type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" }, { type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },
{ type: "page", title: "隐私政策", subtitle: "Cookie 与数据说明", emoji: "🔒", url: "/privacy" }, { type: "page", title: "隐私政策", subtitle: "Cookie 与数据说明", emoji: "🔒", url: "/privacy" },
]; ];
function searchLocal(q: string): SearchResult[] { function searchLocal(q: string, hidePlanCompare: boolean): SearchResult[] {
const s = q.toLowerCase(); const s = q.toLowerCase();
const tools = TOOL_LINKS const tools = TOOL_LINKS
.filter((t) => t.title.toLowerCase().includes(s) || t.desc.toLowerCase().includes(s) || t.id.includes(s)) .filter((t) => t.title.toLowerCase().includes(s) || t.desc.toLowerCase().includes(s) || t.id.includes(s))
.filter((t) => !hidePlanCompare || (t.href !== "/plan" && t.href !== "/compare"))
.map((t) => ({ .map((t) => ({
type: "tool", type: "tool",
title: t.title, title: t.title,
@ -37,7 +48,7 @@ function searchLocal(q: string): SearchResult[] {
})); }));
const pages = PAGE_RESULTS.filter( const pages = PAGE_RESULTS.filter(
(p) => p.title.toLowerCase().includes(s) || p.subtitle.toLowerCase().includes(s) (p) => p.title.toLowerCase().includes(s) || p.subtitle.toLowerCase().includes(s)
); ).filter((p) => !hidePlanCompare || (p.url !== "/plan" && p.url !== "/compare"));
return [...tools, ...pages]; return [...tools, ...pages];
} }
@ -48,6 +59,7 @@ export default function GlobalSearch() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [activeIdx, setActiveIdx] = useState(0); const [activeIdx, setActiveIdx] = useState(0);
const router = useRouter(); const router = useRouter();
const isHome = usePathname() === "/";
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@ -64,7 +76,7 @@ export default function GlobalSearch() {
const doSearch = useCallback(async (q: string) => { const doSearch = useCallback(async (q: string) => {
if (!q.trim()) { setResults([]); return; } if (!q.trim()) { setResults([]); return; }
setLoading(true); setLoading(true);
const local = searchLocal(q); const local = searchLocal(q, isHome);
try { try {
const data = await api.search(q); const data = await api.search(q);
const merged = [...local, ...data].slice(0, 20); const merged = [...local, ...data].slice(0, 20);
@ -76,7 +88,7 @@ export default function GlobalSearch() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [isHome]);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => doSearch(query), 250); const timer = setTimeout(() => doSearch(query), 250);

View File

@ -2,20 +2,34 @@
import { useEffect } from "react"; import { useEffect } from "react";
import type { Stats } from "@/lib/types"; import type { Stats } from "@/lib/types";
import { useI18n } from "@/lib/i18n";
const PHRASES = [ const PHRASES_ZH = [
"数字游民不是逃离生活,而是用科技重新定义生活。", "数字游民不是逃离生活,而是用科技重新定义生活。",
"在巴厘岛写代码,在里斯本开会议,在清迈看日落 ☀️", "在巴厘岛写代码,在里斯本开会议,在清迈看日落 ☀️",
"世界那么大,你的办公室可以在任何地方 🌍", "世界那么大,你的办公室可以在任何地方 🌍",
"远程工作 + 全球旅居 = 无限可能 ✨", "远程工作 + 全球旅居 = 无限可能 ✨",
]; ];
interface Props { stats: Stats } const PHRASES_EN = [
"Digital nomad life isn't escape — it's redesigning how you live.",
"Ship from Bali, meet from Lisbon, watch sunsets in Chiang Mai ☀️",
"The world is big. Your office can be anywhere 🌍",
"Remote work + global living = endless options ✨",
];
interface Props {
stats: Stats;
onOpenMatcher?: () => void;
}
export default function Hero({ stats, onOpenMatcher }: Props) {
const { t, locale } = useI18n();
export default function Hero({ stats }: Props) {
useEffect(() => { useEffect(() => {
const el = document.getElementById("typewriter"); const el = document.getElementById("typewriter");
if (!el) return; if (!el) return;
const PHRASES = locale === "en" ? PHRASES_EN : PHRASES_ZH;
let phraseIdx = 0, charIdx = 0, deleting = false, timer: ReturnType<typeof setTimeout>; let phraseIdx = 0, charIdx = 0, deleting = false, timer: ReturnType<typeof setTimeout>;
const type = () => { const type = () => {
@ -32,9 +46,9 @@ export default function Hero({ stats }: Props) {
timer = setTimeout(type, 30); timer = setTimeout(type, 30);
} }
}; };
timer = setTimeout(type, 2000); timer = setTimeout(type, 400);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, [locale]);
useEffect(() => { useEffect(() => {
const counters = document.querySelectorAll(".stat-number"); const counters = document.querySelectorAll(".stat-number");
@ -69,10 +83,10 @@ export default function Hero({ stats }: Props) {
<div className="hero-content"> <div className="hero-content">
<div className="hero-badge reveal"> <div className="hero-badge reveal">
<span className="badge-dot" /> <span className="badge-dot" />
nomadro · 全球 {stats.total_nomads} 数字游民正在路上 nomadro · {stats.total_nomads} {t.hero.badge}
</div> </div>
<h1 className="hero-title reveal"> <h1 className="hero-title reveal">
用一行代码<br /><span className="gradient-text">环游世界</span> 🌏 {t.hero.titleLine1}<br /><span className="gradient-text">{t.hero.titleLine2}</span> 🌏
</h1> </h1>
<p className="hero-subtitle reveal"> <p className="hero-subtitle reveal">
<span id="typewriter" /><span className="typewriter-cursor">|</span> <span id="typewriter" /><span className="typewriter-cursor">|</span>
@ -105,9 +119,9 @@ export default function Hero({ stats }: Props) {
<path d="M180 155 Q220 140 260 215" fill="none" stroke="url(#orbitGrad)" strokeWidth="2" strokeDasharray="6 4" className="flight-path" /> <path d="M180 155 Q220 140 260 215" fill="none" stroke="url(#orbitGrad)" strokeWidth="2" strokeDasharray="6 4" className="flight-path" />
</svg> </svg>
<div className="floating-cards"> <div className="floating-cards">
<div className="float-card fc-1">🏝️ 巴厘岛<br /><small>WiFi 98Mbps</small></div> <div className="float-card fc-1">🏝️ Bali<br /><small>WiFi 98Mbps</small></div>
<div className="float-card fc-2">☕ 里斯本<br /><small>€800/月</small></div> <div className="float-card fc-2">☕ Lisbon<br /><small>€800/mo</small></div>
<div className="float-card fc-3">🏔️ 清迈<br /><small>⭐ 4.9</small></div> <div className="float-card fc-3">🏔️ Chiang Mai<br /><small>⭐ 4.9</small></div>
</div> </div>
</div> </div>
</div> </div>
@ -116,36 +130,37 @@ export default function Hero({ stats }: Props) {
<div className="hero-bottom reveal"> <div className="hero-bottom reveal">
<div className="hero-actions"> <div className="hero-actions">
<a href="#destinations" className="btn btn-primary"> <a href="#destinations" className="btn btn-primary">
<span>探索目的地</span> <span>{t.hero.ctaExplore}</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M12 5l7 7-7 7" /></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
</a> </a>
<a href="/plan" className="btn btn-ghost"><span>🗓️ 旅居计划</span></a> {onOpenMatcher && (
<a href="/compare" className="btn btn-ghost"><span>⚖️ 城市对比</span></a> <button type="button" className="btn btn-ghost hero-matcher-btn" onClick={onOpenMatcher}>
<button className="btn btn-ghost hero-matcher-btn" onClick={() => { <span>{t.hero.ctaMatch}</span>
window.dispatchEvent(new CustomEvent("open-matcher")); </button>
}}><span>🎯 智能匹配</span></button> )}
<a href="#path" className="btn btn-ghost hero-path-link"><span>{t.journey.title}</span></a>
</div> </div>
<div className="hero-stats"> <div className="hero-stats">
<div className="stat-item"> <div className="stat-item">
<span className="stat-number" data-target={stats.countries}>0</span> <span className="stat-number" data-target={stats.countries}>0</span>
<span className="stat-label">🗺️ 可探索国家</span> <span className="stat-label">{t.hero.statCountries}</span>
</div> </div>
<div className="stat-divider" /> <div className="stat-divider" />
<div className="stat-item"> <div className="stat-item">
<span className="stat-number" data-target={stats.avg_cost}>0</span> <span className="stat-number" data-target={stats.avg_cost}>0</span>
<span className="stat-label">💰 平均月生活费 (千元)</span> <span className="stat-label">{t.hero.statCost}</span>
</div> </div>
<div className="stat-divider" /> <div className="stat-divider" />
<div className="stat-item"> <div className="stat-item">
<span className="stat-number" data-target={stats.satisfaction}>0</span> <span className="stat-number" data-target={stats.satisfaction}>0</span>
<span className="stat-label">😊 生活满意度 %</span> <span className="stat-label">{t.hero.statHappy}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="scroll-indicator"> <div className="scroll-indicator">
<span>向下滚动</span> <span>{t.hero.scroll}</span>
<div className="scroll-arrow"> <div className="scroll-arrow">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12l7 7 7-7" /></svg> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12l7 7 7-7" /></svg>
</div> </div>

View File

@ -1,7 +1,6 @@
"use client"; "use client";
import { useEffect, useState, type ComponentType } from "react"; import { useEffect, useState } from "react";
import Link from "next/link";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types"; import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types";
import Loader from "./Loader"; import Loader from "./Loader";
@ -9,6 +8,7 @@ import ParticleCanvas from "./ParticleCanvas";
import Navbar from "./Navbar"; import Navbar from "./Navbar";
import TickerBar from "./TickerBar"; import TickerBar from "./TickerBar";
import Hero from "./Hero"; import Hero from "./Hero";
import JourneyStrip from "./JourneyStrip";
import WorldMap from "./WorldMap"; import WorldMap from "./WorldMap";
import Destinations from "./Destinations"; import Destinations from "./Destinations";
import GlobalSearch from "./GlobalSearch"; import GlobalSearch from "./GlobalSearch";
@ -19,69 +19,12 @@ import BackToTop from "./BackToTop";
import MatcherTrigger from "./MatcherTrigger"; import MatcherTrigger from "./MatcherTrigger";
import WhenVisible from "./WhenVisible"; import WhenVisible from "./WhenVisible";
const lazy = <P extends object>(loader: () => Promise<{ default: ComponentType<P> }>) => const lazy = <P extends object>(loader: () => Promise<{ default: import("react").ComponentType<P> }>) =>
dynamic(loader, { ssr: false, loading: () => null }); dynamic(loader, { ssr: false, loading: () => null });
const SpinGlobe = lazy(() => import("./SpinGlobe"));
const DailyQuote = lazy(() => import("./DailyQuote"));
const ToolsStrip = lazy(() => import("./ToolsStrip"));
const TripPlanner = lazy(() => import("./TripPlanner"));
const Calculator = lazy(() => import("./Calculator"));
const TimezoneBoard = lazy(() => import("./TimezoneBoard"));
const JetLagEstimator = lazy(() => import("./JetLagEstimator"));
const CostCompare = lazy(() => import("./CostCompare"));
const SavingsGoal = lazy(() => import("./SavingsGoal"));
const RunwayCalculator = lazy(() => import("./RunwayCalculator"));
const FirstMonthCost = lazy(() => import("./FirstMonthCost"));
const ExpenseTracker = lazy(() => import("./ExpenseTracker"));
const TaxDayCounter = lazy(() => import("./TaxDayCounter"));
const FlightCost = lazy(() => import("./FlightCost"));
const CurrencyConverter = lazy(() => import("./CurrencyConverter"));
const MeetingWindow = lazy(() => import("./MeetingWindow"));
const DaylightWorkHours = lazy(() => import("./DaylightWorkHours"));
const WifiWorkScore = lazy(() => import("./WifiWorkScore"));
const WorkSpotCost = lazy(() => import("./WorkSpotCost"));
const DataBudgetPlanner = lazy(() => import("./DataBudgetPlanner"));
const FocusTimer = lazy(() => import("./FocusTimer"));
const NomadScore = lazy(() => import("./NomadScore"));
const Lifestyle = lazy(() => import("./Lifestyle"));
const Charts = lazy(() => import("./Charts"));
const VisaSection = lazy(() => import("./VisaSection")); const VisaSection = lazy(() => import("./VisaSection"));
const VisaStayCountdown = lazy(() => import("./VisaStayCountdown"));
const InsuranceGuide = lazy(() => import("./InsuranceGuide"));
const EmergencyContacts = lazy(() => import("./EmergencyContacts"));
const AtmCashGuide = lazy(() => import("./AtmCashGuide"));
const FoodWaterTips = lazy(() => import("./FoodWaterTips"));
const CoworkingGuide = lazy(() => import("./CoworkingGuide"));
const HousingGuide = lazy(() => import("./HousingGuide"));
const SimGuide = lazy(() => import("./SimGuide"));
const PowerPlugGuide = lazy(() => import("./PowerPlugGuide"));
const CafeGuide = lazy(() => import("./CafeGuide"));
const SeasonGuide = lazy(() => import("./SeasonGuide"));
const PackingChecklist = lazy(() => import("./PackingChecklist"));
const BagWeightEstimator = lazy(() => import("./BagWeightEstimator"));
const ArrivalChecklist = lazy(() => import("./ArrivalChecklist"));
const Phrasebook = lazy(() => import("./Phrasebook"));
const CityNotes = lazy(() => import("./CityNotes"));
const NomadEvents = lazy(() => import("./NomadEvents"));
const WeekendIdeas = lazy(() => import("./WeekendIdeas"));
const DepositReturnChecklist = lazy(() => import("./DepositReturnChecklist"));
const HydrationTracker = lazy(() => import("./HydrationTracker"));
const TipGuide = lazy(() => import("./TipGuide"));
const BillSplit = lazy(() => import("./BillSplit"));
const AirportTransferPick = lazy(() => import("./AirportTransferPick"));
const NomadMoodCheck = lazy(() => import("./NomadMoodCheck"));
const RemoteDayPlan = lazy(() => import("./RemoteDayPlan"));
const LaundryDay = lazy(() => import("./LaundryDay"));
const LocalScamAlerts = lazy(() => import("./LocalScamAlerts"));
const ColivingQuestions = lazy(() => import("./ColivingQuestions"));
const SleepWindDown = lazy(() => import("./SleepWindDown"));
const InvoiceFxHelper = lazy(() => import("./InvoiceFxHelper"));
const RainyDayBackup = lazy(() => import("./RainyDayBackup"));
const Tools = lazy(() => import("./Tools"));
const BlogSection = lazy(() => import("./BlogSection")); const BlogSection = lazy(() => import("./BlogSection"));
const FAQSection = lazy(() => import("./FAQSection")); const FAQSection = lazy(() => import("./FAQSection"));
const Community = lazy(() => import("./Community"));
const DestinationMatcher = lazy(() => import("./DestinationMatcher")); const DestinationMatcher = lazy(() => import("./DestinationMatcher"));
const CookieConsent = lazy(() => import("./CookieConsent")); const CookieConsent = lazy(() => import("./CookieConsent"));
const NomadTips = lazy(() => import("./NomadTips")); const NomadTips = lazy(() => import("./NomadTips"));
@ -102,7 +45,6 @@ interface Props {
} }
export default function HomeClient(props: Props) { export default function HomeClient(props: Props) {
const [compareList, setCompareList] = useState<string[]>([]);
const [matcherOpen, setMatcherOpen] = useState(false); const [matcherOpen, setMatcherOpen] = useState(false);
const [chromeReady, setChromeReady] = useState(false); const [chromeReady, setChromeReady] = useState(false);
@ -117,23 +59,17 @@ export default function HomeClient(props: Props) {
const ric = window.requestIdleCallback const ric = window.requestIdleCallback
? window.requestIdleCallback(start, { timeout: 2800 }) ? window.requestIdleCallback(start, { timeout: 2800 })
: 0; : 0;
const t = window.setTimeout(start, 1200); const timer = window.setTimeout(start, 1200);
return () => { return () => {
clearTimeout(t); clearTimeout(timer);
if (ric && window.cancelIdleCallback) window.cancelIdleCallback(ric); if (ric && window.cancelIdleCallback) window.cancelIdleCallback(ric);
}; };
}, []); }, []);
useEffect(() => { useEffect(() => {
// Above-fold reveal; keep watching briefly for late client mounts
const mark = (root: ParentNode = document) => {
root.querySelectorAll(".reveal:not(.visible)").forEach((el, i) => {
(el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.08}s`;
});
};
mark();
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => entries.forEach((e) => { (entries) =>
entries.forEach((e) => {
if (e.isIntersecting) { if (e.isIntersecting) {
e.target.classList.add("visible"); e.target.classList.add("visible");
observer.unobserve(e.target); observer.unobserve(e.target);
@ -153,122 +89,54 @@ export default function HomeClient(props: Props) {
}; };
}, []); }, []);
const toggleCompare = (slug: string) => { const openMatcher = () => setMatcherOpen(true);
setCompareList((prev) => {
if (prev.includes(slug)) return prev.filter((s) => s !== slug);
if (prev.length >= 4) return prev;
return [...prev, slug];
});
};
const runCompare = () => {
if (compareList.length < 2) return;
window.location.href = `/compare?cities=${compareList.join(",")}`;
};
return ( return (
<> <div className="home-flow">
<ScrollProgress /> <ScrollProgress />
<Loader /> <Loader />
<ParticleCanvas /> <ParticleCanvas />
<Navbar /> <Navbar />
<GlobalSearch /> <GlobalSearch />
<TickerBar messages={props.ticker} /> <TickerBar messages={props.ticker} />
<Hero stats={props.stats} />
<Hero stats={props.stats} onOpenMatcher={openMatcher} />
<JourneyStrip onOpenMatcher={openMatcher} />
<WorldMap destinations={props.destinations} /> <WorldMap destinations={props.destinations} />
<Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} /> <Destinations destinations={props.destinations} onOpenMatcher={openMatcher} />
<WhenVisible minHeight={280}><SpinGlobe destinations={props.destinations} /></WhenVisible>
<WhenVisible minHeight={120}><DailyQuote /></WhenVisible> <WhenVisible minHeight={280}>
<WhenVisible minHeight={140}><ToolsStrip /></WhenVisible> <VisaSection visas={props.visas} />
<WhenVisible><TimezoneBoard destinations={props.destinations} /></WhenVisible> </WhenVisible>
<WhenVisible><JetLagEstimator destinations={props.destinations} /></WhenVisible> <WhenVisible minHeight={220}>
<WhenVisible minHeight={420}><TripPlanner destinations={props.destinations} /></WhenVisible> <BlogSection posts={props.blog} limit={3} />
<WhenVisible minHeight={360}><Calculator destinations={props.destinations} /></WhenVisible> </WhenVisible>
<WhenVisible><CostCompare destinations={props.destinations} /></WhenVisible> <WhenVisible minHeight={200}>
<WhenVisible><SavingsGoal destinations={props.destinations} /></WhenVisible> <FAQSection faqs={props.faqs} limit={5} />
<WhenVisible><RunwayCalculator destinations={props.destinations} /></WhenVisible> </WhenVisible>
<WhenVisible><FirstMonthCost destinations={props.destinations} /></WhenVisible>
<WhenVisible><ExpenseTracker /></WhenVisible>
<WhenVisible><TaxDayCounter /></WhenVisible>
<WhenVisible><FlightCost destinations={props.destinations} /></WhenVisible>
<WhenVisible><CurrencyConverter /></WhenVisible>
<WhenVisible><MeetingWindow destinations={props.destinations} /></WhenVisible>
<WhenVisible><DaylightWorkHours destinations={props.destinations} /></WhenVisible>
<WhenVisible><WifiWorkScore destinations={props.destinations} /></WhenVisible>
<WhenVisible><WorkSpotCost destinations={props.destinations} /></WhenVisible>
<WhenVisible><DataBudgetPlanner /></WhenVisible>
<WhenVisible><FocusTimer /></WhenVisible>
<WhenVisible><RemoteDayPlan /></WhenVisible>
<WhenVisible><SleepWindDown /></WhenVisible>
<WhenVisible><NomadMoodCheck /></WhenVisible>
<WhenVisible><HydrationTracker destinations={props.destinations} /></WhenVisible>
<WhenVisible><NomadScore /></WhenVisible>
<WhenVisible><Lifestyle /></WhenVisible>
<WhenVisible><Charts {...props.charts} /></WhenVisible>
<WhenVisible><VisaSection visas={props.visas} /></WhenVisible>
<WhenVisible><VisaStayCountdown destinations={props.destinations} /></WhenVisible>
<WhenVisible><InsuranceGuide /></WhenVisible>
<WhenVisible><EmergencyContacts destinations={props.destinations} /></WhenVisible>
<WhenVisible><AtmCashGuide destinations={props.destinations} /></WhenVisible>
<WhenVisible><FoodWaterTips destinations={props.destinations} /></WhenVisible>
<WhenVisible><LocalScamAlerts destinations={props.destinations} /></WhenVisible>
<WhenVisible><TipGuide destinations={props.destinations} /></WhenVisible>
<WhenVisible><BillSplit /></WhenVisible>
<WhenVisible><InvoiceFxHelper /></WhenVisible>
<WhenVisible><LaundryDay destinations={props.destinations} /></WhenVisible>
<WhenVisible><CoworkingGuide /></WhenVisible>
<WhenVisible><HousingGuide destinations={props.destinations} /></WhenVisible>
<WhenVisible><ColivingQuestions /></WhenVisible>
<WhenVisible><DepositReturnChecklist /></WhenVisible>
<WhenVisible><SimGuide destinations={props.destinations} /></WhenVisible>
<WhenVisible><PowerPlugGuide destinations={props.destinations} /></WhenVisible>
<WhenVisible><CafeGuide destinations={props.destinations} /></WhenVisible>
<WhenVisible><SeasonGuide destinations={props.destinations} /></WhenVisible>
<WhenVisible><WeekendIdeas destinations={props.destinations} /></WhenVisible>
<WhenVisible><RainyDayBackup destinations={props.destinations} /></WhenVisible>
<WhenVisible><PackingChecklist /></WhenVisible>
<WhenVisible><BagWeightEstimator /></WhenVisible>
<WhenVisible><ArrivalChecklist destinations={props.destinations} /></WhenVisible>
<WhenVisible><AirportTransferPick destinations={props.destinations} /></WhenVisible>
<WhenVisible><Phrasebook destinations={props.destinations} /></WhenVisible>
<WhenVisible><CityNotes destinations={props.destinations} /></WhenVisible>
<WhenVisible><NomadEvents /></WhenVisible>
<WhenVisible><Tools tools={props.tools} /></WhenVisible>
<WhenVisible><BlogSection posts={props.blog} /></WhenVisible>
<WhenVisible><FAQSection faqs={props.faqs} /></WhenVisible>
<WhenVisible><Community testimonials={props.testimonials} /></WhenVisible>
<Footer /> <Footer />
<BackToTop /> <BackToTop />
<SectionNav /> <SectionNav />
<MatcherTrigger onClick={() => setMatcherOpen(true)} /> <MatcherTrigger onClick={openMatcher} />
{matcherOpen && ( {matcherOpen && (
<DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} /> <DestinationMatcher
destinations={props.destinations}
open={matcherOpen}
onClose={() => setMatcherOpen(false)}
/>
)} )}
{chromeReady && ( {chromeReady && (
<> <>
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} /> <KeyboardShortcuts onOpenMatcher={openMatcher} />
<CookieConsent /> <CookieConsent />
<NomadTips /> <NomadTips />
<FeedbackWidget /> <FeedbackWidget />
<OnboardingTour /> <OnboardingTour />
</> </>
)} )}
{compareList.length > 0 && (
<div className="compare-bar">
<span>⚖️ 已选 {compareList.length}/4</span>
<button className="btn btn-primary" onClick={runCompare} disabled={compareList.length < 2}>打开对比台</button>
<Link
href={compareList.length >= 2 ? `/compare?cities=${compareList.join(",")}` : "/compare"}
className={`btn btn-ghost${compareList.length < 2 ? " disabled" : ""}`}
aria-disabled={compareList.length < 2}
onClick={(e) => { if (compareList.length < 2) e.preventDefault(); }}
>
完整对比页
</Link>
<button className="btn btn-ghost" onClick={() => setCompareList([])}>清空</button>
</div> </div>
)}
</>
); );
} }

View File

@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
export default function JoinClient() {
const { user, token } = useAuth();
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
const [city, setCity] = useState("清迈");
const [bio, setBio] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!token) {
router.push("/login?next=/join");
return;
}
setLoading(true);
try {
await api.socialJoin(token, { city, bio, lookingFor: ["friends", "explore"] });
toast(t.join.profileOk, "success");
} catch {
toast(t.join.profileFail, "error");
} finally {
setLoading(false);
}
};
const pay = async () => {
if (!token) {
router.push("/login?next=/join");
return;
}
setLoading(true);
try {
const res = await api.createPayment(token, `${window.location.origin}/join/paid`, "join");
localStorage.setItem("nomadro-pay-order", res.order_id);
window.location.href = res.redirect_url;
} catch {
toast(t.join.payFail, "error");
setLoading(false);
}
};
return (
<div className="join-page">
<div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
<div className="section-header reveal">
<span className="section-tag">{t.join.tag}</span>
<h1>{t.join.title}</h1>
<p>{t.join.subtitle}</p>
</div>
<div className="join-grid reveal">
<div className="join-card">
<h3>{t.join.step1}</h3>
<label>{t.join.city}</label>
<input value={city} onChange={(e) => setCity(e.target.value)} />
<label>{t.join.bio}</label>
<textarea value={bio} onChange={(e) => setBio(e.target.value)} rows={3} />
<button type="button" className="btn btn-primary" disabled={loading} onClick={submit}>
{t.join.saveProfile}
</button>
</div>
<div className="join-card join-card-vip">
<h3>{t.join.step2}</h3>
<p>{t.join.vipDesc}</p>
<ul className="join-perks">
<li>✨ {t.join.perk1}</li>
<li>💬 {t.join.perk2}</li>
<li>🎙️ {t.join.perk3}</li>
<li>📚 {t.join.perk4}</li>
</ul>
<button type="button" className="btn btn-primary" disabled={loading} onClick={pay}>
{t.join.payBtn}
</button>
{!user && <Link href="/login?next=/join" className="join-login-hint">{t.nav.login}</Link>}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
export default function JoinPaidClient() {
const { token } = useAuth();
const searchParams = useSearchParams();
const { t } = useI18n();
const [status, setStatus] = useState<"loading" | "ok" | "fail">("loading");
useEffect(() => {
const orderId = searchParams.get("order_id") || localStorage.getItem("nomadro-pay-order");
if (!token || !orderId) {
setStatus("fail");
return;
}
let tries = 0;
const run = async () => {
try {
await api.completePayment(token, orderId);
localStorage.removeItem("nomadro-pay-order");
setStatus("ok");
} catch {
tries += 1;
if (tries < 5) setTimeout(run, 800);
else setStatus("fail");
}
};
void run();
}, [token, searchParams]);
return (
<div className="join-page">
<div className="container">
<div className="join-paid reveal">
{status === "loading" && <p>{t.join.paidLoading}</p>}
{status === "ok" && (
<>
<h2>🎉 {t.join.paidOk}</h2>
<Link href="/dating" className="btn btn-primary">{t.join.goDating}</Link>
<Link href="/digital" className="btn">{t.join.goDigital}</Link>
</>
)}
{status === "fail" && (
<>
<h2>{t.join.paidFail}</h2>
<Link href="/join" className="btn btn-primary">{t.join.retry}</Link>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,86 @@
"use client";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
interface Props {
onOpenMatcher: () => void;
}
/** Homepage discovery path — no plan/compare on home */
export default function JourneyStrip({ onOpenMatcher }: Props) {
const { t } = useI18n();
const steps = [
{
n: "01",
emoji: "🌍",
title: t.journey.step1Title,
desc: t.journey.step1Desc,
href: "/#destinations",
cta: t.journey.step1Cta,
},
{
n: "02",
emoji: "🎯",
title: t.journey.step2Title,
desc: t.journey.step2Desc,
onClick: onOpenMatcher,
cta: t.journey.matchCta,
},
{
n: "03",
emoji: "📋",
title: t.journey.step3Title,
desc: t.journey.step3Desc,
href: "/#visa",
cta: t.journey.step3Cta,
},
{
n: "04",
emoji: "🛠️",
title: t.journey.step4Title,
desc: t.journey.step4Desc,
href: "/tools",
cta: t.journey.step4Cta,
},
];
return (
<section className="journey-strip section-compact" id="path" aria-label={t.journey.title}>
<div className="container">
<div className="section-header journey-head reveal">
<span className="section-tag">{t.journey.tag}</span>
<h2>{t.journey.title}</h2>
<p>{t.journey.subtitle}</p>
</div>
<ol className="journey-rail reveal">
{steps.map((s, i) => (
<li key={s.n} className="journey-step">
{i > 0 && <span className="journey-connector" aria-hidden />}
<div className="journey-card">
<div className="journey-card-top">
<span className="journey-n">{s.n}</span>
<span className="journey-emoji">{s.emoji}</span>
</div>
<h3>{s.title}</h3>
<p>{s.desc}</p>
<div className="journey-actions">
{"href" in s && s.href ? (
<Link href={s.href} className="btn btn-primary btn-sm">
{s.cta}
</Link>
) : (
<button type="button" className="btn btn-primary btn-sm" onClick={s.onClick}>
{s.cta}
</button>
)}
</div>
</div>
</li>
))}
</ol>
</div>
</section>
);
}

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