Initial commit: NomadFlow full-stack digital nomad platform with Next.js, FastAPI, and PocketBase.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-28 19:49:10 -05:00
commit 9df4444cc5
92 changed files with 19696 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Dependencies
node_modules/
frontend/node_modules/
backend/__pycache__/
backend/**/__pycache__/
backend/.env
frontend/.env.local
# PocketBase
pocketbase/pb_data/*
!pocketbase/pb_data/.gitkeep
# Next.js
frontend/.next/
frontend/out/
# Python
*.pyc
.venv/
venv/
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/

205
README.md Normal file
View File

@ -0,0 +1,205 @@
# NomadFlow · 数字游民旅居平台
全栈数字游民旅居指南,采用 **Next.js + FastAPI + PocketBase** 架构。
品牌:**NomadFlow** — 用一行代码环游世界 🌏
## 技术栈
| 层级 | 技术 | 说明 |
|------|------|------|
| 前端 | Next.js 16 + React 19 | SSR/ISR、组件化 UI、Turbopack |
| 后端 | FastAPI | REST API、业务逻辑 |
| 数据库 | PocketBase | 数据持久化、管理后台 |
| 图表 | Chart.js (CDN) | 数据可视化 |
| 部署 | Vercel + Railway + Docker | 前后端分离部署 |
## 项目结构
```
nomadweb/
├── frontend/ # Next.js 前端应用
│ ├── src/app/ # 页面路由 (App Router)
│ ├── src/components/ # UI 组件 (30+)
│ └── src/lib/ # API 客户端、Auth、Toast
├── backend/ # FastAPI 后端
│ ├── app/routers/ # API 路由
│ ├── app/services/ # PocketBase、Auth 服务
│ └── app/data/ # Mock 降级数据
├── pocketbase/ # PocketBase 数据目录
├── scripts/ # 开发启动脚本 (dev.ps1 / dev.sh)
├── docker-compose.yml # 一键启动全部服务
├── index.html # 原始静态版(保留)
└── README.md
```
## 快速开始
### 方式一:本地开发(推荐)
**1. 启动 PocketBase(可选)**
```bash
# 下载: https://pocketbase.io/docs/
# 或使用 Docker:
docker run -d -p 8090:8090 -v ./pocketbase/pb_data:/pb_data ghcr.io/muchobien/pocketbase:latest
```
访问 http://localhost:8090/_/ 创建管理员(可选,不启动则自动使用 Mock 数据)。
**2. 启动 FastAPI 后端**
```bash
cd backend
pip install -r requirements.txt
cp .env.example .env # 按需修改
uvicorn app.main:app --reload --port 8000
```
API 文档: http://localhost:8000/docs
**3. 启动 Next.js 前端**
```bash
cd frontend
cp .env.local.example .env.local
npm install --legacy-peer-deps
npm run dev
```
访问 http://localhost:3000
### 方式二:一键脚本(Windows)
```powershell
.\scripts\dev.ps1
```
### 方式三:Docker Compose
```bash
docker compose up -d
```
| 服务 | 地址 |
|------|------|
| 前端 | http://localhost:3000 |
| API | http://localhost:8000/docs |
| PocketBase | http://localhost:8090/_/ |
## 功能特性
### 首页核心板块
| 板块 | 说明 |
|------|------|
| 🗺️ 世界地图 | 交互式 SVG 热力图,点击查看城市详情 |
| 🌍 目的地 | 筛选/搜索/排序,收藏,最多 4 城对比 |
| 🎯 智能匹配 | 4 步问卷,推荐最适合的旅居城市 |
| 🕐 时区看板 | 实时时钟 + 与国内团队工作时间重叠分析 |
| 🗓️ 行程规划 | 多城市规划,预算汇总,分享/导入链接 |
| 🧮 费用计算器 | 按目的地、住宿档次、月数估算预算 |
| 💱 多币种换算 | CNY/USD/EUR/THB 等 8 种货币互转 |
| 📊 就绪度测评 | 5 题评估你的数字游民准备程度 |
| 💻 生活方式 | 交互式「游民一天」时间轴 |
| 📋 签证指南 | 难度筛选 + 进度条可视化 |
| 📝 博客 | 文章列表 + 阅读进度条详情页 |
| ❓ FAQ | 手风琴问答 |
### 交互与体验
- ✨ Canvas 粒子背景 + 品牌加载动画
- 🌓 深色/浅色主题切换(localStorage 持久化)
- 🔍 全局搜索 `Ctrl+K`(目的地/博客/签证/FAQ)
- 🧭 右侧浮动章节导航
- 📊 顶部滚动进度条
- ⚖️ 可视化城市对比(评分圆环 + 条形图)
- 🔔 全局 Toast 通知
- ⌨️ 快捷键:`M` 智能匹配、`?` 帮助面板
- 📱 响应式布局,移动端适配
### 用户系统
- 🔐 登录 / 注册 / 一键演示账号
- ❤️ 收藏目的地(同步 API)
- 👤 用户中心:统计、收藏、行程展示
- 演示账号:`demo@nomadflow.io` / `demo123`
### 后端 API
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/v1/destinations` | GET | 目的地列表(region/search/sort) |
| `/api/v1/destinations/{slug}` | GET | 目的地详情 |
| `/api/v1/destinations/compare` | POST | 城市对比(2–4 城) |
| `/api/v1/calculator` | POST | 费用计算 |
| `/api/v1/search` | GET | 全局搜索 |
| `/api/v1/blog` | GET | 博客列表 |
| `/api/v1/blog/{slug}` | GET | 博客详情 |
| `/api/v1/auth/register` | POST | 注册 |
| `/api/v1/auth/login` | POST | 登录 |
| `/api/v1/auth/demo` | GET | 演示登录 |
| `/api/v1/auth/favorites` | GET/POST | 收藏管理 |
| `/api/v1/charts/*` | GET | 图表数据 |
| `/api/v1/subscribe` | POST | 邮件订阅 |
> PocketBase 不可用时,API 自动降级到 `backend/app/data/mock_data.py`。
## 页面路由
| 路径 | 说明 |
|------|------|
| `/` | 首页(全部板块) |
| `/login` | 登录 / 注册 |
| `/profile` | 用户中心 |
| `/destinations/[slug]` | 目的地详情(城市画像、加入行程) |
| `/blog/[slug]` | 博客详情(阅读进度) |
## 环境变量
### Backend (`backend/.env`)
```env
POCKETBASE_URL=http://127.0.0.1:8090
POCKETBASE_ADMIN_EMAIL=admin@nomadflow.io
POCKETBASE_ADMIN_PASSWORD=admin123456
CORS_ORIGINS=http://localhost:3000
```
### Frontend (`frontend/.env.local`)
```env
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
```
## 部署
### 前端 → Vercel
1. 导入 `frontend/` 目录
2. 环境变量:`NEXT_PUBLIC_API_URL=https://your-api.example.com/api/v1`
3. 自动构建部署
### 后端 → Railway
1. 连接 `backend/` 目录
2. 配置 `POCKETBASE_URL`、`CORS_ORIGINS`
3. 读取 `railway.toml` 自动部署
### 全栈 → Docker Compose
```bash
docker compose up -d
```
## 快捷键
| 按键 | 功能 |
|------|------|
| `Ctrl+K` | 全局搜索 |
| `M` | 智能目的地匹配 |
| `?` | 快捷键帮助 |
| `Esc` | 关闭弹窗 |
## License
MIT

6
backend/.env.example Normal file
View File

@ -0,0 +1,6 @@
POCKETBASE_URL=http://127.0.0.1:8090
POCKETBASE_ADMIN_EMAIL=admin@nomadflow.io
POCKETBASE_ADMIN_PASSWORD=admin123456
API_HOST=0.0.0.0
API_PORT=8000
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000

7
backend/Dockerfile Normal file
View File

@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View File

20
backend/app/config.py Normal file
View File

@ -0,0 +1,20 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
pocketbase_url: str = "http://127.0.0.1:8090"
pocketbase_admin_email: str = "admin@nomadflow.io"
pocketbase_admin_password: str = "admin123456"
api_host: str = "0.0.0.0"
api_port: int = 8000
cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000"
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
class Config:
env_file = ".env"
settings = Settings()

View File

View File

@ -0,0 +1,229 @@
"""Fallback data when PocketBase is unavailable."""
DESTINATIONS = [
{
"id": "1", "slug": "bali", "name": "巴厘岛", "country": "印尼", "emoji": "🏝️",
"tag": "东南亚 · 热带天堂", "description": "乌布的数字游民社区闻名全球,稻田间的 Co-working Space 和瑜伽文化让这里成为游民圣地。",
"region": "sea", "cost": 4500, "speed": 85, "temperature": 28, "rating": 9.2, "hue": 170,
"nomads_count": "12,000+", "highlights": ["🏄 冲浪与海滩生活", "🧘 瑜伽冥想文化", "💰 东南亚性价比之王", "🌴 热带气候全年温暖"],
"map_x": 720, "map_y": 310,
},
{
"id": "2", "slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊",
"tag": "欧洲 · 海滨明珠", "description": "D7 签证友好,阳光海岸与悠久历史的完美融合,欧洲数字游民的首选基地。",
"region": "europe", "cost": 9000, "speed": 120, "temperature": 22, "rating": 9.5, "hue": 220,
"nomads_count": "8,500+", "highlights": ["📋 D7 签证门槛低", "☀️ 300天阳光", "🎵 Fado 音乐文化", "🚋 复古有轨电车"],
"map_x": 430, "map_y": 195,
},
{
"id": "3", "slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️",
"tag": "东南亚 · 文化古城", "description": "数字游民大本营,咖啡文化与夜市生活的天堂,全球性价比最高的游民城市。",
"region": "sea", "cost": 3800, "speed": 95, "temperature": 30, "rating": 9.4, "hue": 45,
"nomads_count": "15,000+", "highlights": ["☕ 咖啡馆文化浓厚", "🏮 夜市与寺庙", "💰 月生活费最低", "🤝 游民社区最活跃"],
"map_x": 700, "map_y": 240,
},
{
"id": "4", "slug": "mexico", "name": "墨西哥城", "country": "墨西哥", "emoji": "🌃",
"tag": "拉美 · 活力之都", "description": "艺术、美食与科技交织,时区便利对接北美市场,拉美最具活力的游民城市。",
"region": "latam", "cost": 6500, "speed": 75, "temperature": 18, "rating": 8.8, "hue": 300,
"nomads_count": "5,200+", "highlights": ["🎨 街头艺术天堂", "🌮 世界美食之都", "🕐 北美时区友好", "💃 丰富夜生活"],
"map_x": 220, "map_y": 240,
},
{
"id": "5", "slug": "barcelona", "name": "巴塞罗那", "country": "西班牙", "emoji": "🏖️",
"tag": "欧洲 · 地中海", "description": "高迪建筑与创业生态并存,Nomad Visa 政策领先,地中海生活的理想之选。",
"region": "europe", "cost": 10500, "speed": 150, "temperature": 20, "rating": 9.1, "hue": 130,
"nomads_count": "6,800+", "highlights": ["🏛️ 高迪建筑奇迹", "🏖️ 地中海海滩", "📋 Nomad Visa 便利", "🍷 美食与夜生活"],
"map_x": 460, "map_y": 200,
},
{
"id": "6", "slug": "tokyo", "name": "东京", "country": "日本", "emoji": "🗼",
"tag": "亚洲 · 现代都市", "description": "极致效率与安全,适合追求高品质生活的远程工作者,亚洲科技之都。",
"region": "asia", "cost": 12000, "speed": 200, "temperature": 15, "rating": 8.6, "hue": 10,
"nomads_count": "4,100+", "highlights": ["🚄 极致公共交通", "🛡️ 全球最安全城市", "📶 网速亚洲第一", "🍣 美食文化巅峰"],
"map_x": 820, "map_y": 210,
},
]
VISAS = [
{"id": "1", "country": "葡萄牙", "flag": "🇵🇹", "name": "葡萄牙 D7 签证", "badge": "⭐ 推荐", "badge_type": "easy",
"duration": "2年,可续签", "income_req": "€760/月", "approval_time": "3-6 个月", "extra": "🏥 可享欧盟医疗", "difficulty": 35, "difficulty_label": "简单"},
{"id": "2", "country": "西班牙", "flag": "🇪🇸", "name": "西班牙 Nomad Visa", "badge": "🔥 热门", "badge_type": "hot",
"duration": "1年,可续3年", "income_req": "€2,160/月", "approval_time": "1-3 个月", "extra": "🌍 可申根区旅行", "difficulty": 45, "difficulty_label": "中等"},
{"id": "3", "country": "印尼", "flag": "🇮🇩", "name": "印尼 B211A 签证", "badge": "💰 低成本", "badge_type": "budget",
"duration": "60天,可延期", "income_req": "约 ¥2,000", "approval_time": "5-10 天", "extra": "🏝️ 适合巴厘岛旅居", "difficulty": 25, "difficulty_label": "简单"},
{"id": "4", "country": "泰国", "flag": "🇹🇭", "name": "泰国 LTR 签证", "badge": "🆕 新政策", "badge_type": "new",
"duration": "10年", "income_req": "$80,000/年", "approval_time": "1-2 个月", "extra": "✈️ 多次入境", "difficulty": 60, "difficulty_label": "中等"},
{"id": "5", "country": "墨西哥", "flag": "🇲🇽", "name": "墨西哥 Temporary Resident", "badge": "💰 低成本", "badge_type": "budget",
"duration": "1-4年", "income_req": "$2,500/月", "approval_time": "2-4 周", "extra": "🌮 北美时区友好", "difficulty": 30, "difficulty_label": "简单"},
{"id": "6", "country": "爱沙尼亚", "flag": "🇪🇪", "name": "爱沙尼亚 DNV", "badge": "🚀 先锋", "badge_type": "pioneer",
"duration": "1年", "income_req": "€3,504/月", "approval_time": "2-4 周", "extra": "💻 全球首个数字游民签证", "difficulty": 40, "difficulty_label": "中等"},
]
FAQS = [
{"id": "1", "question": "💰 做数字游民需要多少启动资金?", "order": 1,
"answer": "建议准备 3-6 个月的生活费作为缓冲。以东南亚为例,¥15,000-30,000 即可开始。包括机票、首月住宿、签证费用和应急资金。欧洲目的地建议准备 ¥50,000 以上。"},
{"id": "2", "question": "📶 如何确保远程工作的网络稳定?", "order": 2,
"answer": "选择网络评分高的城市,入住前用 Speedtest 测试。备用方案:本地 SIM 卡热点、随身 WiFi 设备、附近 Co-working Space。推荐携带 USB 网卡和 VPN 作为双保险。"},
{"id": "3", "question": "🏥 旅居期间的保险怎么办?", "order": 3,
"answer": "推荐 SafetyWing 或 World Nomads 等国际医疗保险,月费约 $40-80,覆盖全球(部分国家除外)。长期旅居者可考虑目的地国家的本地保险,费用更低、报销更方便。"},
{"id": "4", "question": "🧾 税务问题如何处理?", "order": 4,
"answer": "税务居民身份取决于居住天数(通常 183 天规则)。建议咨询专业税务顾问,了解双重征税协定。很多游民选择税务友好的国家(如葡萄牙、格鲁吉亚)作为基地。"},
{"id": "5", "question": "👨‍👩‍👧 可以带娃一起做数字游民吗?", "order": 5,
"answer": "完全可以!巴厘岛、清迈、里斯本都有成熟的数字游民家庭社区。关键是选择教育资源丰富、医疗条件好的目的地,以及保持稳定的工作节奏,给孩子规律的生活。"},
{"id": "6", "question": "🤝 如何快速融入当地游民社区?", "order": 6,
"answer": "加入 Nomad List、Facebook 群组和本地 Meetup 活动。入住游民友好的 Co-living 空间,参加每周的 Coworking 社交日。大部分游民社区非常开放,主动打招呼就能结识朋友。"},
]
TESTIMONIALS = [
{"id": "1", "avatar": "👩‍💻", "content": "在清迈住了 8 个月,月花费不到 4000 元,但生活质量比国内一线城市高太多了。每天早上骑摩托去咖啡馆,这种感觉无法形容。", "author": "小林", "role": "前端开发 · 清迈 🇹🇭", "rating": 5},
{"id": "2", "avatar": "👨‍🎨", "content": "里斯本的 D7 签证让我在欧洲有了基地。白天在 Alfama 区的共享办公空间工作,周末去 Sintra 徒步,完美平衡。", "author": "Marco", "role": "UI 设计师 · 里斯本 🇵🇹", "rating": 5},
{"id": "3", "avatar": "🧑‍💼", "content": "带着家人做数字游民听起来疯狂,但在巴厘岛乌布,孩子们上国际学校,我和妻子远程工作,这是我们做过最正确的决定。", "author": "张家", "role": "产品经理 · 巴厘岛 🇮🇩", "rating": 5},
]
TOOLS = [
{"id": "1", "emoji": "💼", "name": "远程协作", "description": "Slack · Notion · Figma · Zoom", "tags": ["团队", "设计", "沟通"], "category": "work"},
{"id": "2", "emoji": "✈️", "name": "旅行规划", "description": "Skyscanner · Nomad List · SafetyWing", "tags": ["机票", "签证", "保险"], "category": "travel"},
{"id": "3", "emoji": "💳", "name": "财务管理", "description": "Wise · Revolut · Xero · 多币种账户", "tags": ["汇款", "记账", "税务"], "category": "finance"},
{"id": "4", "emoji": "🤝", "name": "社群网络", "description": "Nomad List · Remote Year · 本地 Meetup", "tags": ["社交", "活动", "合租"], "category": "connect"},
{"id": "5", "emoji": "🏥", "name": "健康保障", "description": "SafetyWing · World Nomads · 运动 App", "tags": ["保险", "健身", "心理"], "category": "health"},
{"id": "6", "emoji": "📚", "name": "持续学习", "description": "Coursera · Duolingo · 当地语言班", "tags": ["技能", "语言", "文化"], "category": "learn"},
]
BLOG_POSTS = [
{"id": "1", "slug": "chiangmai-guide-2026", "title": "清迈数字游民完全指南 2026", "excerpt": "从签证到住宿,从咖啡馆到 Co-working,一篇搞定清迈旅居。", "emoji": "🏔️", "author": "NomadFlow", "published_at": "2026-01-15", "read_time": 8, "tags": ["清迈", "指南", "东南亚"]},
{"id": "2", "slug": "portugal-d7-visa", "title": "葡萄牙 D7 签证申请全攻略", "excerpt": "手把手教你申请欧洲最受欢迎的远程工作签证。", "emoji": "🇵🇹", "author": "NomadFlow", "published_at": "2026-02-03", "read_time": 12, "tags": ["签证", "葡萄牙", "欧洲"]},
{"id": "3", "slug": "nomad-tax-basics", "title": "数字游民税务入门:你需要知道的 5 件事", "excerpt": "183 天规则、双重征税、税务居民身份——一文理清。", "emoji": "🧾", "author": "NomadFlow", "published_at": "2026-02-20", "read_time": 10, "tags": ["税务", "法律", "指南"]},
]
BLOG_CONTENT: dict[str, str] = {
"chiangmai-guide-2026": """## 为什么选择清迈?
清迈是公认的全球数字游民之都。低廉的生活成本、完善的 Co-working 生态、友善的本地人和丰富的文化活动,让它成为新手游民的最佳起点。
## 签证方案
- **旅游签**:落地签 15 天,或提前办旅游签 60 天
- **DTV 签证**:2024 年推出的 Destination Thailand Visa,适合远程工作者
- **学生签/精英签**:长期旅居的进阶选择
## 住宿推荐
| 区域 | 月租 | 特点 |
|------|------|------|
| Nimman | ¥2,500-4,000 | 咖啡馆、餐厅、年轻人多 |
| Old City | ¥1,500-3,000 | 文化氛围浓,步行可达寺庙 |
| Hang Dong | ¥2,000-3,500 | 安静,适合深度工作 |
## 最佳 Co-working
1. **Punspace** — Nimman 区经典,日票 ¥60
2. **CAMP** — Maya 商场顶楼,免费(消费即可)
3. **Hub53** — 安静专业,月票 ¥800
## 月均预算
- 住宿:¥2,500
- 餐饮:¥1,200
- 交通:¥300(租摩托)
- Co-working:¥400
- 其他:¥400
- **合计:约 ¥3,800-4,500**
## 实用 Tips
☕ 推荐咖啡馆:Ristr8to(世界冠军)、Graph One Nimman
🏍️ 租摩托月费约 ¥400,注意戴头盔
📱 推荐 AIS 或 TrueMove 无限流量套餐
🤝 每周四有 Nomad Meetup,关注 Facebook 群组""",
"portugal-d7-visa": """## D7 签证是什么?
葡萄牙 D7 签证(Passive Income Visa)最初为退休者设计,但因收入要求低、审批相对简单,成为数字游民进入欧盟的最佳通道之一。
## 申请条件
- 月收入不低于 **€760**(葡萄牙最低工资)
- 银行存款建议 **€9,120+**(12个月生活费)
- 无犯罪记录
- 葡萄牙本地银行账户
- 健康保险
## 申请流程
1. **准备材料**(2-4 周)
- 护照、照片、收入证明、银行流水
- 葡萄牙税号(NIF)
- 住宿证明(租房合同或酒店预订)
2. **递交申请**
- 在中国:葡萄牙驻华使馆
- 或入境葡萄牙后转居留许可
3. **等待审批**(3-6 个月)
4. **登陆葡萄牙**
- 领取居留卡
- 登记住址
## 费用预算
- 签证费:约 €90
- 律师费(可选):€500-1,500
- NIF + 银行开户:€200-300
- 首月生活费:€800-1,200
## 里斯本生活成本
- 一居室公寓:€800-1,200/月
- Co-working:€150-250/月
- 餐饮:€300-500/月
- **月均总花费:€1,200-1,800**
## 常见问题
**Q: D7 可以工作吗?**
A: 可以远程为海外雇主工作,但不能在葡萄牙本地公司就职。
**Q: 5年后能拿护照吗?**
A: 满足居住要求后可以申请永居或入籍。""",
"nomad-tax-basics": """## 1. 什么是税务居民?
大多数国家用 **183 天规则** 判定税务居民:在一个自然年内居住超过 183 天,即成为该国税务居民,需在该国申报全球收入。
## 2. 双重征税怎么办?
如果两个国家都认为你是税务居民,查 **双边税收协定**(DTA)。中国已与 100+ 国家签署 DTA,可避免重复缴税。
## 3. 数字游民常见税务策略
| 策略 | 说明 | 适合人群 |
|------|------|----------|
| 零税务居民 | 不在任何国家住满 183 天 | 短期旅居者 |
| 税务友好国 | 葡萄牙 NHR、格鲁吉亚 1% 税 | 长期旅居者 |
| 原籍国申报 | 回国期间申报 | 兼职游民 |
## 4. 中国税务居民注意
- 中国公民默认是中国税务居民(全球征税)
- 海外收入也需申报(可抵免境外已缴税)
- 建议咨询专业税务师
## 5. 实用建议
1. 📋 记录每个国家的入境/出境日期
2. 🧾 保留所有收入凭证和银行流水
3. 🏦 使用 Wise 等工具便于跨境汇款记录
4. 👨‍💼 收入超过一定金额建议聘请税务顾问
5. 📱 推荐工具:Xero(记账)、TaxScouts(报税)""",
}
TICKER_MESSAGES = [
"🌴 小林 刚刚抵达清迈",
"💻 Marco 在里斯本完成了 Sprint",
"✈️ 今日新增 127 位游民出发",
"🏝️ 巴厘岛 Co-working 今日 89% 满座",
"📶 清迈平均网速 95Mbps",
"🌅 东京远程工作者满意度 9.1",
]

31
backend/app/main.py Normal file
View File

@ -0,0 +1,31 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import api, auth
app = FastAPI(
title="NomadFlow API",
description="数字游民旅居平台后端 API",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api.router, prefix="/api/v1")
app.include_router(auth.router, prefix="/api/v1")
@app.get("/")
async def root():
return {
"name": "NomadFlow API",
"docs": "/docs",
"health": "/api/v1/health",
}

View File

215
backend/app/routers/api.py Normal file
View File

@ -0,0 +1,215 @@
from fastapi import APIRouter, HTTPException, Query
from app.schemas import (
BlogPost, BlogPostDetail, ChartData, CompareRequest, CostCalculatorRequest,
CostCalculatorResponse, Destination, FAQ, SearchResult, StatsResponse,
SubscribeRequest, SubscribeResponse, Testimonial, Tool, Visa,
)
from app.services.pocketbase import pb
from app.data import mock_data
router = APIRouter()
@router.get("/health")
async def health():
pb_ok = await pb.is_available()
return {"status": "ok", "pocketbase": "connected" if pb_ok else "fallback"}
@router.get("/stats", response_model=StatsResponse)
async def get_stats():
dests = await pb.get_destinations()
avg = sum(d["cost"] for d in dests) // len(dests) if dests else 42
return StatsResponse(
countries=195,
avg_cost=avg // 100,
satisfaction=87,
total_nomads="35.6M",
active_today=127,
)
@router.get("/ticker")
async def get_ticker():
return {"messages": mock_data.TICKER_MESSAGES}
@router.get("/destinations", response_model=list[Destination])
async def list_destinations(
region: str | None = Query(None),
search: str | None = Query(None),
sort: str = Query("rating"),
):
data = await pb.get_destinations(region=region, search=search, sort=sort)
return [Destination(**d) for d in data]
@router.get("/destinations/{slug}", response_model=Destination)
async def get_destination(slug: str):
data = await pb.get_destination(slug)
if not data:
raise HTTPException(404, "目的地不存在")
return Destination(**data)
@router.post("/destinations/compare", response_model=list[Destination])
async def compare_destinations(body: CompareRequest):
all_dests = await pb.get_destinations()
result = [d for d in all_dests if d["slug"] in body.slugs]
if len(result) < 2:
raise HTTPException(400, "至少需要 2 个有效目的地")
return [Destination(**d) for d in result]
@router.get("/visas", response_model=list[Visa])
async def list_visas():
return [Visa(**v) for v in await pb.get_visas()]
@router.get("/faqs", response_model=list[FAQ])
async def list_faqs():
return [FAQ(**f) for f in await pb.get_faqs()]
@router.get("/testimonials", response_model=list[Testimonial])
async def list_testimonials():
return [Testimonial(**t) for t in await pb.get_testimonials()]
@router.get("/tools", response_model=list[Tool])
async def list_tools():
return [Tool(**t) for t in await pb.get_tools()]
@router.get("/blog", response_model=list[BlogPost])
async def list_blog():
return [BlogPost(**b) for b in await pb.get_blog_posts()]
@router.get("/blog/{slug}", response_model=BlogPostDetail)
async def get_blog(slug: str):
post = await pb.get_blog_post(slug)
if not post:
raise HTTPException(404, "文章不存在")
return BlogPostDetail(**post)
@router.post("/subscribe", response_model=SubscribeResponse)
async def subscribe(body: SubscribeRequest):
ok, msg = await pb.subscribe(body.email)
return SubscribeResponse(success=ok, message=msg)
@router.post("/calculator", response_model=CostCalculatorResponse)
async def cost_calculator(body: CostCalculatorRequest):
dest = await pb.get_destination(body.destination_slug)
if not dest:
raise HTTPException(404, "目的地不存在")
multipliers = {"budget": 0.7, "mid": 1.0, "premium": 1.5}
m = multipliers.get(body.housing, 1.0)
base = dest["cost"]
breakdown = {
"🏠 住宿": int(base * 0.35 * m),
"🍜 餐饮": int(base * 0.25 * m),
"🚗 交通": int(base * 0.10),
"📶 网络/办公": int(base * 0.10),
"🎉 娱乐社交": int(base * 0.12 * m),
"🏥 保险医疗": int(base * 0.08),
}
per_month = sum(breakdown.values())
total = per_month * body.months
return CostCalculatorResponse(
destination=f"{dest['name']}, {dest['country']}",
emoji=dest["emoji"],
months=body.months,
breakdown=breakdown,
total=total,
per_month=per_month,
)
@router.get("/charts/cost", response_model=ChartData)
async def chart_cost():
dests = await pb.get_destinations()
dests = sorted(dests, key=lambda x: x["cost"])
return ChartData(
labels=[f"{d['name']} {d['emoji']}" for d in dests],
datasets=[{
"label": "月生活费 (元)",
"data": [d["cost"] for d in dests],
}],
)
@router.get("/charts/speed", response_model=ChartData)
async def chart_speed():
dests = await pb.get_destinations()
dests = sorted(dests, key=lambda x: x["speed"], reverse=True)
return ChartData(
labels=[f"{d['name']} {d['speed']}Mbps" for d in dests],
datasets=[{"data": [d["speed"] for d in dests]}],
)
@router.get("/charts/growth", response_model=ChartData)
async def chart_growth():
return ChartData(
labels=["2019", "2020", "2021", "2022", "2023", "2024", "2025", "2026"],
datasets=[{
"label": "全球数字游民 (百万人)",
"data": [7.3, 10.9, 15.5, 24.0, 28.5, 31.2, 33.8, 35.6],
}],
)
@router.get("/charts/radar", response_model=ChartData)
async def chart_radar():
return ChartData(
labels=["生活成本", "网络速度", "安全性", "社群活跃", "气候环境", "签证便利"],
datasets=[
{"label": "清迈", "data": [95, 80, 85, 95, 70, 90]},
{"label": "里斯本", "data": [60, 90, 92, 80, 85, 95]},
],
)
@router.get("/search", response_model=list[SearchResult])
async def global_search(q: str = Query(..., min_length=1)):
query = q.lower().strip()
results: list[SearchResult] = []
for d in await pb.get_destinations(search=query):
results.append(SearchResult(
type="destination",
title=f"{d['name']}, {d['country']}",
subtitle=f"¥{d['cost']}/月 · ⭐{d['rating']}",
emoji=d["emoji"],
url=f"/destinations/{d['slug']}",
))
for b in await pb.get_blog_posts():
if query in b["title"].lower() or query in b.get("excerpt", "").lower():
results.append(SearchResult(
type="blog", title=b["title"], subtitle=b["excerpt"][:60],
emoji=b["emoji"], url=f"/blog/{b['slug']}",
))
for v in await pb.get_visas():
if query in v["name"].lower() or query in v["country"].lower():
results.append(SearchResult(
type="visa", title=v["name"], subtitle=v["country"],
emoji=v["flag"], url="/#visa",
))
for f in await pb.get_faqs():
if query in f["question"].lower() or query in f["answer"].lower():
results.append(SearchResult(
type="faq", title=f["question"], subtitle=f["answer"][:60],
emoji="❓", url="/#faq",
))
return results[:12]

103
backend/app/routers/auth.py Normal file
View File

@ -0,0 +1,103 @@
from fastapi import APIRouter, Header, HTTPException
from app.schemas import (
AuthLogin, AuthRegister, AuthResponse, Destination,
FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile,
)
from app.services.auth import (
DEMO_TOKEN, get_favorites, get_user_by_token,
login_user, register_user, toggle_favorite,
)
from app.services.pocketbase import pb
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=AuthResponse)
async def register(body: AuthRegister):
result = register_user(body.email, body.password, body.name)
if not result:
raise HTTPException(400, "该邮箱已注册")
return AuthResponse(**result)
@router.post("/login", response_model=AuthResponse)
async def login(body: AuthLogin):
result = login_user(body.email, body.password)
if not result:
raise HTTPException(401, "邮箱或密码错误")
return AuthResponse(**result)
@router.get("/me", response_model=UserProfile)
async def me(authorization: str | None = Header(None)):
token = _extract_token(authorization)
user = get_user_by_token(token)
if not user:
raise HTTPException(401, "未登录")
return UserProfile(**user)
@router.get("/demo", response_model=AuthResponse)
async def demo_login():
"""一键体验演示账号"""
user = get_user_by_token(DEMO_TOKEN)
if not user:
raise HTTPException(500, "演示账号不可用")
return AuthResponse(token=DEMO_TOKEN, user=UserProfile(**user))
@router.get("/favorites", response_model=FavoriteResponse)
async def list_favorites(authorization: str | None = Header(None)):
token = _extract_token(authorization)
if not get_user_by_token(token):
raise HTTPException(401, "未登录")
return FavoriteResponse(slugs=get_favorites(token))
@router.get("/favorites/detail", response_model=list[Destination])
async def favorite_details(authorization: str | None = Header(None)):
token = _extract_token(authorization)
user = get_user_by_token(token)
if not user:
raise HTTPException(401, "未登录")
slugs = get_favorites(token)
all_dests = await pb.get_destinations()
return [Destination(**d) for d in all_dests if d["slug"] in slugs]
@router.get("/profile/stats", response_model=ProfileStats)
async def profile_stats(authorization: str | None = Header(None)):
token = _extract_token(authorization)
user = get_user_by_token(token)
if not user:
raise HTTPException(401, "未登录")
favs = get_favorites(token)
levels = [(0, "🌱 新手游民"), (1, "🎒 背包客"), (2, "✈️ 飞行游民"), (3, "🌍 环球游民")]
level = levels[min(len(favs), 3)][1]
return ProfileStats(
favorites_count=len(favs),
destinations_explored=len(favs),
member_since="2026",
nomad_level=level,
)
@router.post("/favorites", response_model=FavoriteResponse)
async def add_favorite(body: FavoriteRequest, authorization: str | None = Header(None)):
token = _extract_token(authorization)
if not get_user_by_token(token):
raise HTTPException(401, "未登录")
dest = await pb.get_destination(body.destination_slug)
if not dest:
raise HTTPException(404, "目的地不存在")
slugs = toggle_favorite(token, body.destination_slug)
return FavoriteResponse(slugs=slugs)
def _extract_token(authorization: str | None) -> str:
if not authorization:
return ""
if authorization.startswith("Bearer "):
return authorization[7:]
return authorization

164
backend/app/schemas.py Normal file
View File

@ -0,0 +1,164 @@
from pydantic import BaseModel, EmailStr, Field
class Destination(BaseModel):
id: str
slug: str
name: str
country: str
emoji: str
tag: str
description: str
region: str
cost: int
speed: int
temperature: int
rating: float
hue: int = 170
nomads_count: str = "5,000+"
highlights: list[str] = []
map_x: float = 0
map_y: float = 0
class Visa(BaseModel):
id: str
country: str
flag: str
name: str
badge: str
badge_type: str
duration: str
income_req: str
approval_time: str
extra: str
difficulty: int
difficulty_label: str
class FAQ(BaseModel):
id: str
question: str
answer: str
order: int = 0
class Testimonial(BaseModel):
id: str
avatar: str
content: str
author: str
role: str
rating: int = 5
class Tool(BaseModel):
id: str
emoji: str
name: str
description: str
tags: list[str]
category: str
class SubscribeRequest(BaseModel):
email: EmailStr
class SubscribeResponse(BaseModel):
success: bool
message: str
class CostCalculatorRequest(BaseModel):
destination_slug: str
housing: str = "mid" # budget | mid | premium
months: int = Field(default=1, ge=1, le=24)
class CostCalculatorResponse(BaseModel):
destination: str
emoji: str
months: int
breakdown: dict[str, int]
total: int
per_month: int
class CompareRequest(BaseModel):
slugs: list[str] = Field(..., min_length=2, max_length=4)
class StatsResponse(BaseModel):
countries: int
avg_cost: int
satisfaction: int
total_nomads: str
active_today: int
class ChartData(BaseModel):
labels: list[str]
datasets: list[dict]
class BlogPost(BaseModel):
id: str
slug: str
title: str
excerpt: str
emoji: str
author: str
published_at: str
read_time: int
tags: list[str] = []
class BlogPostDetail(BlogPost):
content: str
class AuthRegister(BaseModel):
email: EmailStr
password: str = Field(min_length=6)
name: str = Field(min_length=1)
class AuthLogin(BaseModel):
email: EmailStr
password: str
class UserProfile(BaseModel):
id: str
email: str
name: str
avatar: str = "🧑‍💻"
class AuthResponse(BaseModel):
token: str
user: UserProfile
class FavoriteRequest(BaseModel):
destination_slug: str
class FavoriteResponse(BaseModel):
slugs: list[str]
class ProfileStats(BaseModel):
favorites_count: int
destinations_explored: int
member_since: str
nomad_level: str
class SearchResult(BaseModel):
type: str # destination | blog | visa | faq
title: str
subtitle: str
emoji: str
url: str

View File

View File

@ -0,0 +1,83 @@
"""Simple auth service with in-memory fallback when PocketBase users unavailable."""
import hashlib
import secrets
from typing import Any
from app.data import mock_data
# In-memory store for demo mode
_users: dict[str, dict] = {}
_sessions: dict[str, str] = {} # token -> user_id
_favorites: dict[str, list[str]] = {} # user_id -> [slugs]
def _hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def register_user(email: str, password: str, name: str) -> dict[str, Any] | None:
if email in _users:
return None
uid = secrets.token_hex(8)
_users[email] = {
"id": uid,
"email": email,
"password_hash": _hash_password(password),
"name": name,
"avatar": "🧑‍💻",
}
_favorites[uid] = []
token = secrets.token_urlsafe(32)
_sessions[token] = uid
return {"token": token, "user": _user_profile(_users[email])}
def login_user(email: str, password: str) -> dict[str, Any] | None:
user = _users.get(email)
if not user or user["password_hash"] != _hash_password(password):
return None
token = secrets.token_urlsafe(32)
_sessions[token] = user["id"]
return {"token": token, "user": _user_profile(user)}
def get_user_by_token(token: str) -> dict | None:
uid = _sessions.get(token)
if not uid:
return None
for user in _users.values():
if user["id"] == uid:
return _user_profile(user)
return None
def get_favorites(token: str) -> list[str]:
uid = _sessions.get(token)
if not uid:
return []
return _favorites.get(uid, [])
def toggle_favorite(token: str, slug: str) -> list[str]:
uid = _sessions.get(token)
if not uid:
return []
favs = _favorites.setdefault(uid, [])
if slug in favs:
favs.remove(slug)
else:
favs.append(slug)
return favs
def _user_profile(user: dict) -> dict:
return {
"id": user["id"],
"email": user["email"],
"name": user["name"],
"avatar": user.get("avatar", "🧑‍💻"),
}
# Demo account
_demo = register_user("demo@nomadflow.io", "demo123", "演示用户")
DEMO_TOKEN = _demo["token"] if _demo else ""

View File

@ -0,0 +1,183 @@
import httpx
from typing import Any
from app.config import settings
from app.data import mock_data
class PocketBaseService:
def __init__(self):
self.base_url = settings.pocketbase_url.rstrip("/")
self._token: str | None = None
self._available: bool | None = None
async def is_available(self) -> bool:
if self._available is not None:
return self._available
try:
async with httpx.AsyncClient(timeout=1.0) as client:
r = await client.get(f"{self.base_url}/api/health")
self._available = r.status_code == 200
except Exception:
self._available = False
return self._available
async def _auth(self, client: httpx.AsyncClient) -> bool:
if self._token:
return True
try:
r = await client.post(
f"{self.base_url}/api/admins/auth-with-password",
json={
"identity": settings.pocketbase_admin_email,
"password": settings.pocketbase_admin_password,
},
)
if r.status_code == 200:
self._token = r.json().get("token")
return True
except Exception:
pass
return False
async def list_records(self, collection: str, sort: str = "") -> list[dict[str, Any]]:
if not await self.is_available():
return []
params: dict[str, str] = {"perPage": "200"}
if sort:
params["sort"] = sort
try:
async with httpx.AsyncClient(timeout=10.0) as client:
headers = {}
if await self._auth(client):
headers["Authorization"] = self._token or ""
r = await client.get(
f"{self.base_url}/api/collections/{collection}/records",
params=params,
headers=headers,
)
if r.status_code == 200:
items = r.json().get("items", [])
return items if items else []
except Exception:
pass
return []
async def create_record(self, collection: str, data: dict[str, Any]) -> dict[str, Any] | None:
if not await self.is_available():
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
headers = {}
if await self._auth(client):
headers["Authorization"] = self._token or ""
r = await client.post(
f"{self.base_url}/api/collections/{collection}/records",
json=data,
headers=headers,
)
if r.status_code in (200, 201):
return r.json()
except Exception:
pass
return None
async def get_destinations(
self, region: str | None = None, search: str | None = None, sort: str = "rating"
) -> list[dict]:
records = await self.list_records("destinations", sort=f"-{sort}" if sort != "cost" else "cost")
if not records:
records = mock_data.DESTINATIONS
else:
records = [self._map_destination(r) for r in records]
if region and region != "all":
records = [d for d in records if d.get("region") == region]
if search:
q = search.lower()
records = [
d for d in records
if q in d.get("name", "").lower() or q in d.get("country", "").lower()
]
return self._sort_destinations(records, sort)
async def get_destination(self, slug: str) -> dict | None:
records = await self.get_destinations()
return next((d for d in records if d.get("slug") == slug), None)
async def get_visas(self) -> list[dict]:
records = await self.list_records("visas", sort="difficulty")
return records if records else mock_data.VISAS
async def get_faqs(self) -> list[dict]:
records = await self.list_records("faqs", sort="order")
return records if records else mock_data.FAQS
async def get_testimonials(self) -> list[dict]:
records = await self.list_records("testimonials")
return records if records else mock_data.TESTIMONIALS
async def get_tools(self) -> list[dict]:
records = await self.list_records("tools")
return records if records else mock_data.TOOLS
async def get_blog_posts(self) -> list[dict]:
records = await self.list_records("blog_posts", sort="-published_at")
return records if records else mock_data.BLOG_POSTS
async def get_blog_post(self, slug: str) -> dict | None:
posts = await self.get_blog_posts()
post = next((p for p in posts if p.get("slug") == slug), None)
if not post:
return None
content = post.get("content") or mock_data.BLOG_CONTENT.get(slug, "")
return {**post, "content": content}
async def subscribe(self, email: str) -> tuple[bool, str]:
existing = await self.list_records("subscriptions")
if any(s.get("email") == email for s in existing):
return True, "你已经订阅过了,欢迎回来!"
result = await self.create_record("subscriptions", {"email": email})
if result:
return True, "订阅成功!欢迎加入 NomadFlow 社区 🎉"
# fallback: always succeed in demo mode
return True, "订阅成功!欢迎加入 NomadFlow 社区 🎉"
def _map_destination(self, r: dict) -> dict:
return {
"id": r.get("id", ""),
"slug": r.get("slug", ""),
"name": r.get("name", ""),
"country": r.get("country", ""),
"emoji": r.get("emoji", ""),
"tag": r.get("tag", ""),
"description": r.get("description", ""),
"region": r.get("region", ""),
"cost": r.get("cost", 0),
"speed": r.get("speed", 0),
"temperature": r.get("temperature", 0),
"rating": r.get("rating", 0),
"hue": r.get("hue", 170),
"nomads_count": r.get("nomads_count", ""),
"highlights": r.get("highlights", []),
"map_x": r.get("map_x", 0),
"map_y": r.get("map_y", 0),
}
def _sort_destinations(self, records: list[dict], sort: str) -> list[dict]:
if sort == "cost-asc":
return sorted(records, key=lambda x: x.get("cost", 0))
if sort == "cost-desc":
return sorted(records, key=lambda x: x.get("cost", 0), reverse=True)
if sort == "speed":
return sorted(records, key=lambda x: x.get("speed", 0), reverse=True)
return sorted(records, key=lambda x: x.get("rating", 0), reverse=True)
pb = PocketBaseService()

8
backend/railway.toml Normal file
View File

@ -0,0 +1,8 @@
[build]
builder = "DOCKERFILE"
dockerfilePath = "Dockerfile"
[deploy]
startCommand = "uvicorn app.main:app --host 0.0.0.0 --port $PORT"
healthcheckPath = "/api/v1/health"
restartPolicyType = "ON_FAILURE"

7
backend/requirements.txt Normal file
View File

@ -0,0 +1,7 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
pydantic[email]==2.10.4
pydantic-settings==2.7.0
python-dotenv==1.0.1
email-validator==2.2.0

142
backend/seed_pocketbase.py Normal file
View File

@ -0,0 +1,142 @@
"""
Seed PocketBase collections with NomadFlow data.
Run after PocketBase is started: python seed_pocketbase.py
"""
import asyncio
import json
import httpx
from app.config import settings
from app.data.mock_data import DESTINATIONS, VISAS, FAQS, TESTIMONIALS, TOOLS, BLOG_POSTS
COLLECTIONS = {
"destinations": {
"schema": [
{"name": "slug", "type": "text", "required": True},
{"name": "name", "type": "text", "required": True},
{"name": "country", "type": "text"},
{"name": "emoji", "type": "text"},
{"name": "tag", "type": "text"},
{"name": "description", "type": "text"},
{"name": "region", "type": "text"},
{"name": "cost", "type": "number"},
{"name": "speed", "type": "number"},
{"name": "temperature", "type": "number"},
{"name": "rating", "type": "number"},
{"name": "hue", "type": "number"},
{"name": "nomads_count", "type": "text"},
{"name": "highlights", "type": "json"},
{"name": "map_x", "type": "number"},
{"name": "map_y", "type": "number"},
],
"data": DESTINATIONS,
"key": "slug",
},
"visas": {
"schema": [
{"name": "country", "type": "text"}, {"name": "flag", "type": "text"},
{"name": "name", "type": "text"}, {"name": "badge", "type": "text"},
{"name": "badge_type", "type": "text"}, {"name": "duration", "type": "text"},
{"name": "income_req", "type": "text"}, {"name": "approval_time", "type": "text"},
{"name": "extra", "type": "text"}, {"name": "difficulty", "type": "number"},
{"name": "difficulty_label", "type": "text"},
],
"data": VISAS,
"key": "name",
},
"faqs": {
"schema": [
{"name": "question", "type": "text"}, {"name": "answer", "type": "text"},
{"name": "order", "type": "number"},
],
"data": FAQS,
"key": "question",
},
"testimonials": {
"schema": [
{"name": "avatar", "type": "text"}, {"name": "content", "type": "text"},
{"name": "author", "type": "text"}, {"name": "role", "type": "text"},
{"name": "rating", "type": "number"},
],
"data": TESTIMONIALS,
"key": "author",
},
"tools": {
"schema": [
{"name": "emoji", "type": "text"}, {"name": "name", "type": "text"},
{"name": "description", "type": "text"}, {"name": "tags", "type": "json"},
{"name": "category", "type": "text"},
],
"data": TOOLS,
"key": "name",
},
"blog_posts": {
"schema": [
{"name": "slug", "type": "text"}, {"name": "title", "type": "text"},
{"name": "excerpt", "type": "text"}, {"name": "content", "type": "text"},
{"name": "emoji", "type": "text"}, {"name": "author", "type": "text"},
{"name": "published_at", "type": "text"}, {"name": "read_time", "type": "number"},
{"name": "tags", "type": "json"},
],
"data": [{**p, "content": mock_data.BLOG_CONTENT.get(p["slug"], "")} for p in BLOG_POSTS],
"key": "slug",
},
"subscriptions": {
"schema": [{"name": "email", "type": "email", "required": True}],
"data": [],
"key": "email",
},
}
async def main():
base = settings.pocketbase_url.rstrip("/")
async with httpx.AsyncClient(timeout=15.0) as client:
# Auth
auth = await client.post(
f"{base}/api/admins/auth-with-password",
json={"identity": settings.pocketbase_admin_email, "password": settings.pocketbase_admin_password},
)
if auth.status_code != 200:
print("❌ PocketBase 认证失败,请先在 Admin UI 创建管理员账号")
print(f" 访问 {base}/_/ 创建账号: {settings.pocketbase_admin_email}")
return
token = auth.json()["token"]
headers = {"Authorization": token}
for coll_name, config in COLLECTIONS.items():
# Check if collection exists
check = await client.get(f"{base}/api/collections/{coll_name}", headers=headers)
if check.status_code == 404:
body = {
"name": coll_name,
"type": "base",
"schema": config["schema"],
}
create = await client.post(f"{base}/api/collections", json=body, headers=headers)
if create.status_code in (200, 201):
print(f"✅ 创建集合: {coll_name}")
else:
print(f"⚠️ 集合 {coll_name} 创建失败: {create.text}")
continue
else:
print(f"📦 集合已存在: {coll_name}")
# Seed records
for item in config["data"]:
payload = {k: v for k, v in item.items() if k != "id"}
r = await client.post(
f"{base}/api/collections/{coll_name}/records",
json=payload,
headers=headers,
)
if r.status_code in (200, 201):
print(f" + {item.get(config['key'], '')}")
elif "already" in r.text.lower() or r.status_code == 400:
print(f" ~ {item.get(config['key'], '')} (已存在)")
print("\n🎉 数据种子完成!")
if __name__ == "__main__":
asyncio.run(main())

1912
css/style.css Normal file

File diff suppressed because it is too large Load Diff

40
docker-compose.yml Normal file
View File

@ -0,0 +1,40 @@
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: nomadflow-pb
ports:
- "8090:8090"
volumes:
- ./pocketbase/pb_data:/pb_data
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
interval: 10s
timeout: 5s
retries: 5
backend:
build: ./backend
container_name: nomadflow-api
ports:
- "8000:8000"
environment:
- POCKETBASE_URL=http://pocketbase:8090
- POCKETBASE_ADMIN_EMAIL=admin@nomadflow.io
- POCKETBASE_ADMIN_PASSWORD=admin123456
- CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
depends_on:
pocketbase:
condition: service_healthy
restart: unless-stopped
frontend:
build: ./frontend
container_name: nomadflow-web
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
depends_on:
- backend
restart: unless-stopped

View File

@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1

41
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

9
frontend/AGENTS.md Normal file
View File

@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

1
frontend/CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

26
frontend/Dockerfile Normal file
View File

@ -0,0 +1,26 @@
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

36
frontend/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

15
frontend/next.config.ts Normal file
View File

@ -0,0 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${process.env.API_URL || "http://localhost:8000"}/api/:path*`,
},
];
},
};
export default nextConfig;

6234
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.3.3",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.3",
"typescript": "^5"
}
}

1
frontend/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,56 @@
import Link from "next/link";
import { api } from "@/lib/api";
import { notFound } from "next/navigation";
import SiteShell from "@/components/SiteShell";
import BlogReadingProgress from "@/components/BlogReadingProgress";
function renderMarkdown(content: string) {
return content.split("\n").map((line, i) => {
if (line.startsWith("## ")) return <h2 key={i}>{line.slice(3)}</h2>;
if (line.startsWith("### ")) return <h3 key={i}>{line.slice(4)}</h3>;
if (line.startsWith("| ")) return null; // skip table rows for simplicity
if (line.startsWith("- ")) return <li key={i}>{line.slice(2)}</li>;
if (line.match(/^\d+\. /)) return <li key={i}>{line.replace(/^\d+\. /, "")}</li>;
if (line.trim() === "") return <br key={i} />;
if (line.startsWith("**Q:")) return <p key={i}><strong>{line}</strong></p>;
return <p key={i}>{line}</p>;
});
}
export default async function BlogDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let post;
try {
post = await api.getBlogPost(slug);
} catch {
notFound();
}
return (
<SiteShell>
<BlogReadingProgress />
<div className="detail-page">
<nav className="detail-nav">
<Link href="/#blog">← 返回博客</Link>
</nav>
<article className="blog-detail">
<div className="blog-detail-header">
<span className="blog-detail-emoji">{post.emoji}</span>
<div className="blog-meta">
<span>📅 {post.published_at}</span>
<span>⏱️ {post.read_time} 分钟阅读</span>
<span>✍️ {post.author}</span>
</div>
<h1>{post.title}</h1>
<div className="blog-tags">
{post.tags.map((t) => <span key={t} className="blog-tag">{t}</span>)}
</div>
</div>
<div className="blog-detail-content">
{renderMarkdown(post.content)}
</div>
</article>
</div>
</SiteShell>
);
}

View File

@ -0,0 +1,56 @@
import Link from "next/link";
import { api } from "@/lib/api";
import { notFound } from "next/navigation";
import FavoriteButton from "@/components/FavoriteButton";
import DestinationDetailClient from "@/components/DestinationDetailClient";
import SiteShell from "@/components/SiteShell";
export default async function DestinationPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let dest;
let allDestinations;
try {
[dest, allDestinations] = await Promise.all([
api.getDestination(slug),
api.getDestinations(),
]);
} catch {
notFound();
}
return (
<SiteShell>
<div className="detail-page">
<nav className="detail-nav">
<Link href="/#destinations">← 返回目的地</Link>
</nav>
<div className="dest-detail">
<div className="dest-detail-hero" style={{ background: `linear-gradient(135deg,hsl(${dest.hue},60%,20%),hsl(${dest.hue + 40},50%,30%))` }}>
<span className="dest-detail-emoji">{dest.emoji}</span>
</div>
<div className="dest-detail-body">
<div className="dest-detail-top">
<div>
<span className="modal-tag">{dest.tag}</span>
<h1>{dest.name}, {dest.country}</h1>
</div>
<FavoriteButton slug={dest.slug} />
</div>
<p className="dest-detail-desc">{dest.description}</p>
<div className="modal-stats">
<div className="modal-stat"><span className="stat-emoji">💰</span><strong>¥{dest.cost.toLocaleString()}</strong><span>月生活费</span></div>
<div className="modal-stat"><span className="stat-emoji">📶</span><strong>{dest.speed}Mbps</strong><span>网速</span></div>
<div className="modal-stat"><span className="stat-emoji">🌡️</span><strong>{dest.temperature}°C</strong><span>均温</span></div>
<div className="modal-stat"><span className="stat-emoji">⭐</span><strong>{dest.rating}</strong><span>评分</span></div>
</div>
<div className="modal-highlights">
<h4>✨ 亮点特色</h4>
<ul>{dest.highlights.map((h) => <li key={h}>{h}</li>)}</ul>
</div>
<DestinationDetailClient dest={dest} allDestinations={allDestinations} />
</div>
</div>
</div>
</SiteShell>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

4479
frontend/src/app/globals.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/lib/auth";
import { ToastProvider } from "@/lib/toast";
export const metadata: Metadata = {
title: "NomadFlow · 数字游民旅居指南",
description: "探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。",
keywords: ["数字游民", "旅居", "远程工作", "nomad", "digital nomad"],
openGraph: {
title: "NomadFlow · 数字游民旅居指南",
description: "用一行代码环游世界 🌏",
type: "website",
locale: "zh_CN",
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet" />
</head>
<body>
<AuthProvider>
<ToastProvider>{children}</ToastProvider>
</AuthProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,8 @@
export default function Loading() {
return (
<div className="page-loading">
<div className="page-loading-spinner" />
<p>加载中... 🌍</p>
</div>
);
}

View File

@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
import SiteShell from "@/components/SiteShell";
export default function LoginPage() {
const { login, register, demoLogin } = useAuth();
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
const ok = mode === "login"
? await login(email, password)
: await register(email, password, name);
setLoading(false);
if (ok) window.location.href = "/";
else setError(mode === "login" ? "邮箱或密码错误" : "注册失败,邮箱可能已存在");
};
const handleDemo = async () => {
setLoading(true);
const ok = await demoLogin();
setLoading(false);
if (ok) window.location.href = "/";
};
return (
<SiteShell>
<div className="auth-page">
<div className="auth-card">
<Link href="/" className="auth-back">← 返回首页</Link>
<div className="auth-header">
<span style={{ fontSize: "3rem" }}>🌍</span>
<h1>{mode === "login" ? "欢迎回来" : "加入 NomadFlow"}</h1>
<p>{mode === "login" ? "登录你的游民账户" : "开始你的旅居之旅"}</p>
</div>
<div className="auth-tabs">
<button className={mode === "login" ? "active" : ""} onClick={() => setMode("login")}>登录</button>
<button className={mode === "register" ? "active" : ""} onClick={() => setMode("register")}>注册</button>
</div>
<form onSubmit={handleSubmit} className="auth-form">
{mode === "register" && (
<div className="calc-field">
<label>👤 昵称</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="你的游民昵称" required />
</div>
)}
<div className="calc-field">
<label>📧 邮箱</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" required />
</div>
<div className="calc-field">
<label>🔒 密码</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="至少 6 位" required minLength={6} />
</div>
{error && <p className="auth-error">{error}</p>}
<button type="submit" className="btn btn-primary" style={{ width: "100%" }} disabled={loading}>
{loading ? "处理中..." : mode === "login" ? "🚀 登录" : "✨ 注册"}
</button>
</form>
<div className="auth-divider"><span>或</span></div>
<button className="btn btn-ghost" style={{ width: "100%" }} onClick={handleDemo} disabled={loading}>
🎮 一键体验演示账号
</button>
<p className="auth-hint">演示账号:demo@nomadflow.io / demo123</p>
</div>
</div>
</SiteShell>
);
}

View File

@ -0,0 +1,15 @@
import Link from "next/link";
import SiteShell from "@/components/SiteShell";
export default function NotFound() {
return (
<SiteShell>
<div className="not-found-page">
<span className="not-found-emoji">🧭</span>
<h1>404</h1>
<p>哎呀,这片海域没有标记的岛屿</p>
<Link href="/" className="btn btn-primary">返回首页 🏠</Link>
</div>
</SiteShell>
);
}

60
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,60 @@
import { api } from "@/lib/api";
import HomeClient from "@/components/HomeClient";
export const revalidate = 60;
export default async function Home() {
try {
const [stats, ticker, destinations, visas, faqs, testimonials, tools, blog, cost, speed, growth, radar] =
await Promise.all([
api.getStats(),
api.getTicker(),
api.getDestinations(),
api.getVisas(),
api.getFaqs(),
api.getTestimonials(),
api.getTools(),
api.getBlog(),
api.getChartCost(),
api.getChartSpeed(),
api.getChartGrowth(),
api.getChartRadar(),
]);
return (
<HomeClient
stats={stats}
ticker={ticker.messages}
destinations={destinations}
visas={visas}
faqs={faqs}
testimonials={testimonials}
tools={tools}
blog={blog}
charts={{ cost, speed, growth, radar }}
/>
);
} catch {
// Minimal fallback if API completely unavailable
const stats = await api.getStats();
const ticker = await api.getTicker();
return (
<HomeClient
stats={stats}
ticker={ticker.messages}
destinations={[]}
visas={[]}
faqs={[]}
testimonials={[]}
tools={[]}
blog={[]}
charts={{
cost: { labels: [], datasets: [{ data: [] }] },
speed: { labels: [], datasets: [{ data: [] }] },
growth: { labels: [], datasets: [{ data: [] }] },
radar: { labels: [], datasets: [] },
}}
/>
);
}
}

View File

@ -0,0 +1,149 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { api } from "@/lib/api";
import type { Destination, ProfileStats, TripItem } from "@/lib/types";
import SiteShell from "@/components/SiteShell";
import FavoriteButton from "@/components/FavoriteButton";
const TRIP_KEY = "nomadflow-trip";
export default function ProfilePage() {
const { user, token, favorites, logout } = useAuth();
const router = useRouter();
const [stats, setStats] = useState<ProfileStats | null>(null);
const [favoriteDests, setFavoriteDests] = useState<Destination[]>([]);
const [trip, setTrip] = useState<TripItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!token) {
router.push("/login");
return;
}
setLoading(true);
Promise.all([
api.getProfileStats(token),
favorites.length > 0 ? api.getFavoriteDestinations(token) : Promise.resolve([]),
]).then(([s, f]) => {
setStats(s);
setFavoriteDests(f);
}).catch(() => {}).finally(() => setLoading(false));
const saved = localStorage.getItem(TRIP_KEY);
if (saved) setTrip(JSON.parse(saved));
}, [token, favorites, router]);
if (!user) return null;
return (
<SiteShell>
<div className="profile-page">
<div className="profile-header">
<div className="profile-avatar">{user.avatar}</div>
<div className="profile-info">
<h1>{user.name}</h1>
<p>{user.email}</p>
{stats && <span className="profile-level">{stats.nomad_level}</span>}
</div>
<button className="btn btn-ghost" onClick={() => { logout(); router.push("/"); }}>
退出登录
</button>
</div>
{stats && (
<div className="profile-stats-grid">
<div className="profile-stat-card">
<span className="stat-emoji">❤️</span>
<strong>{stats.favorites_count}</strong>
<span>收藏目的地</span>
</div>
<div className="profile-stat-card">
<span className="stat-emoji">🗺️</span>
<strong>{stats.destinations_explored}</strong>
<span>探索城市</span>
</div>
<div className="profile-stat-card">
<span className="stat-emoji">📅</span>
<strong>{stats.member_since}</strong>
<span>加入年份</span>
</div>
<div className="profile-stat-card">
<span className="stat-emoji">🧮</span>
<Link href="/#calculator" className="profile-link">费用计算器 →</Link>
</div>
</div>
)}
<section className="profile-favorites">
<h2>❤️ 我的收藏</h2>
{loading ? (
<p className="profile-loading">加载中...</p>
) : favoriteDests.length === 0 ? (
<div className="profile-empty">
<span style={{ fontSize: "3rem" }}>🌍</span>
<p>还没有收藏目的地</p>
<Link href="/#destinations" className="btn btn-primary">去探索 →</Link>
</div>
) : (
<div className="profile-fav-grid">
{favoriteDests.map((d) => (
<div key={d.slug} className="profile-fav-card">
<Link href={`/destinations/${d.slug}`}>
<span className="fav-emoji">{d.emoji}</span>
<h3>{d.name}, {d.country}</h3>
<p>💰 ¥{d.cost.toLocaleString()}/月 · ⭐ {d.rating}</p>
</Link>
<FavoriteButton slug={d.slug} />
</div>
))}
</div>
)}
</section>
<section className="profile-trip">
<h2>🗓️ 我的行程</h2>
{trip.length === 0 ? (
<div className="profile-empty">
<span style={{ fontSize: "2.5rem" }}>🗺️</span>
<p>还没有规划行程</p>
<Link href="/#trip" className="btn btn-primary">去规划 →</Link>
</div>
) : (
<div className="profile-trip-list">
{trip.map((t, i) => (
<div key={t.slug} className="profile-trip-item">
<span className="trip-item-num">{i + 1}</span>
<span>{t.emoji}</span>
<div>
<strong>{t.name}, {t.country}</strong>
<span>{t.months} 个月 · ¥{(t.cost * t.months).toLocaleString()}</span>
</div>
</div>
))}
<div className="profile-trip-total">
总计 <strong>{trip.reduce((s, t) => s + t.months, 0)} 个月</strong> ·
<strong className="gradient-text"> ¥{trip.reduce((s, t) => s + t.cost * t.months, 0).toLocaleString()}</strong>
</div>
<Link href="/#trip" className="btn btn-ghost">编辑行程 →</Link>
</div>
)}
</section>
<section className="profile-quick">
<h2>🚀 快捷入口</h2>
<div className="profile-quick-grid">
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
<Link href="/#visa" className="quick-card">📋 签证指南</Link>
<Link href="/#blog" className="quick-card">📝 阅读博客</Link>
<Link href="/#trip" className="quick-card">🗓️ 行程规划</Link>
<Link href="/#nomad-score" className="quick-card">📊 就绪度测评</Link>
</div>
</section>
</div>
</SiteShell>
);
}

View File

@ -0,0 +1,8 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/" },
sitemap: "https://nomadflow.io/sitemap.xml",
};
}

View File

@ -0,0 +1,27 @@
import type { MetadataRoute } from "next";
const DESTINATIONS = ["bali", "lisbon", "chiangmai", "mexico", "barcelona", "tokyo"];
const BLOGS = ["chiangmai-guide-2026", "portugal-d7-visa", "nomad-tax-basics"];
export default function sitemap(): MetadataRoute.Sitemap {
const base = "https://nomadflow.io";
const now = new Date();
return [
{ url: base, lastModified: now, changeFrequency: "weekly", priority: 1 },
{ url: `${base}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${base}/profile`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
...DESTINATIONS.map((slug) => ({
url: `${base}/destinations/${slug}`,
lastModified: now,
changeFrequency: "weekly" as const,
priority: 0.8,
})),
...BLOGS.map((slug) => ({
url: `${base}/blog/${slug}`,
lastModified: now,
changeFrequency: "monthly" as const,
priority: 0.7,
})),
];
}

View File

@ -0,0 +1,21 @@
"use client";
import { useEffect, useState } from "react";
export default function BackToTop() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const onScroll = () => setVisible(window.scrollY > 600);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<button className={`back-to-top${visible ? " visible" : ""}`}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
aria-label="返回顶部">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
</button>
);
}

View File

@ -0,0 +1,31 @@
"use client";
import { useEffect, useState } from "react";
export default function BlogReadingProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const onScroll = () => {
const article = document.querySelector(".blog-detail-content");
if (!article) return;
const rect = article.getBoundingClientRect();
const articleTop = rect.top + window.scrollY;
const articleHeight = article.scrollHeight;
const scrolled = window.scrollY - articleTop + window.innerHeight * 0.3;
setProgress(Math.min(Math.max((scrolled / articleHeight) * 100, 0), 100));
};
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<div className="blog-reading-progress" aria-hidden="true">
<div className="blog-reading-bar" style={{ width: `${progress}%` }} />
{progress > 5 && progress < 98 && (
<span className="blog-reading-pct">{Math.round(progress)}%</span>
)}
</div>
);
}

View File

@ -0,0 +1,33 @@
import Link from "next/link";
import type { BlogPost } from "@/lib/types";
export default function BlogSection({ posts }: { posts: BlogPost[] }) {
return (
<section className="section blog-section" id="blog">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">📝 BLOG</span>
<h2>游民博客</h2>
<p>深度攻略、签证指南与旅居经验分享</p>
</div>
<div className="blog-grid">
{posts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`} className="blog-card reveal">
<div className="blog-emoji">{post.emoji}</div>
<div className="blog-meta">
<span>📅 {post.published_at}</span>
<span>⏱️ {post.read_time} 分钟阅读</span>
</div>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
<div className="blog-tags">
{post.tags.map((t) => <span key={t} className="blog-tag">{t}</span>)}
</div>
<span className="blog-read-more">阅读全文 →</span>
</Link>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api";
import type { CostResult, Destination } from "@/lib/types";
interface Props { destinations: Destination[] }
export default function Calculator({ destinations }: Props) {
const [slug, setSlug] = useState(destinations[0]?.slug || "");
const [housing, setHousing] = useState("mid");
const [months, setMonths] = useState(3);
const [result, setResult] = useState<CostResult | null>(null);
const [loading, setLoading] = useState(false);
const calculate = async () => {
setLoading(true);
try {
const data = await api.calculateCost({ destination_slug: slug, housing, months });
setResult(data);
} catch {
setResult(null);
} finally {
setLoading(false);
}
};
return (
<section className="section calculator-section" id="calculator">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🧮 CALCULATOR</span>
<h2>旅居费用计算器</h2>
<p>根据目的地和生活方式,精准估算你的旅居预算</p>
</div>
<div className="calculator-wrapper reveal">
<div className="calculator-form">
<div className="calc-field">
<label>🏙️ 目的地</label>
<select value={slug} onChange={(e) => setSlug(e.target.value)}>
{destinations.map((d) => (
<option key={d.slug} value={d.slug}>{d.emoji} {d.name}, {d.country}</option>
))}
</select>
</div>
<div className="calc-field">
<label>🏠 住宿档次</label>
<div className="calc-options">
{[{ v: "budget", l: "💰 经济" }, { v: "mid", l: "🏠 舒适" }, { v: "premium", l: "✨ 豪华" }].map((o) => (
<button key={o.v} className={`calc-option${housing === o.v ? " active" : ""}`}
onClick={() => setHousing(o.v)}>{o.l}</button>
))}
</div>
</div>
<div className="calc-field">
<label>📅 旅居月数: {months} 个月</label>
<input type="range" min={1} max={12} value={months} onChange={(e) => setMonths(+e.target.value)} />
</div>
<button className="btn btn-primary" onClick={calculate} disabled={loading}>
{loading ? "计算中..." : "🧮 开始计算"}
</button>
</div>
<div className="calculator-result">
{result ? (
<div className="result-card">
<div className="result-header">
<span style={{ fontSize: "3rem" }}>{result.emoji}</span>
<div>
<h3>{result.destination}</h3>
<p>{result.months} 个月旅居预算</p>
</div>
</div>
<div className="result-breakdown">
{Object.entries(result.breakdown).map(([k, v]) => (
<div key={k} className="breakdown-item">
<span>{k}</span>
<div className="breakdown-bar">
<div className="breakdown-fill" style={{ width: `${(v / result.per_month) * 100}%` }} />
</div>
<strong>¥{v.toLocaleString()}</strong>
</div>
))}
</div>
<div className="result-total">
<div><span>月均</span><strong>¥{result.per_month.toLocaleString()}</strong></div>
<div><span>总计</span><strong className="gradient-text">¥{result.total.toLocaleString()}</strong></div>
</div>
</div>
) : (
<div className="result-placeholder">
<span style={{ fontSize: "4rem" }}>🧮</span>
<p>选择目的地和参数,点击计算查看预算明细</p>
</div>
)}
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,109 @@
"use client";
import { useEffect, useRef } from "react";
import type { ChartData } from "@/lib/types";
interface Props {
cost: ChartData;
speed: ChartData;
growth: ChartData;
radar: ChartData;
}
const COLORS = ["#FF6B6B", "#FFE66D", "#4ECDC4", "#A78BFA", "#F472B6", "#60A5FA"];
function loadChartJS(): Promise<typeof import("chart.js")> {
return new Promise((resolve, reject) => {
if (typeof window !== "undefined" && (window as unknown as { Chart?: unknown }).Chart) {
resolve({ Chart: (window as unknown as { Chart: typeof import("chart.js").Chart }).Chart } as typeof import("chart.js"));
return;
}
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js";
script.onload = () => {
const Chart = (window as unknown as { Chart: typeof import("chart.js").Chart }).Chart;
resolve({ Chart } as typeof import("chart.js"));
};
script.onerror = reject;
document.head.appendChild(script);
});
}
export default function Charts({ cost, speed, growth, radar }: Props) {
const costRef = useRef<HTMLCanvasElement>(null);
const speedRef = useRef<HTMLCanvasElement>(null);
const growthRef = useRef<HTMLCanvasElement>(null);
const radarRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
let charts: InstanceType<typeof import("chart.js").Chart>[] = [];
loadChartJS().then(({ Chart }) => {
const isLight = document.documentElement.getAttribute("data-theme") === "light";
const text = isLight ? "#475569" : "#94a3b8";
const grid = isLight ? "rgba(0,0,0,0.06)" : "rgba(255,255,255,0.06)";
if (costRef.current && cost.datasets[0]?.data.length) {
charts.push(new Chart(costRef.current, {
type: "bar",
data: {
labels: cost.labels,
datasets: [{ label: "月生活费 (元)", data: cost.datasets[0].data,
backgroundColor: COLORS.map((c) => c + "99"), borderColor: COLORS, borderWidth: 2, borderRadius: 8 }],
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, grid: { color: grid }, ticks: { color: text } }, x: { grid: { display: false }, ticks: { color: text } } } },
}));
}
if (speedRef.current && speed.datasets[0]?.data.length) {
charts.push(new Chart(speedRef.current, {
type: "doughnut",
data: { labels: speed.labels, datasets: [{ data: speed.datasets[0].data, backgroundColor: COLORS }] },
options: { responsive: true, maintainAspectRatio: false, cutout: "65%", plugins: { legend: { position: "right", labels: { color: text } } } },
}));
}
if (growthRef.current && growth.datasets[0]?.data.length) {
charts.push(new Chart(growthRef.current, {
type: "line",
data: { labels: growth.labels, datasets: [{ ...growth.datasets[0], borderColor: COLORS[2], backgroundColor: COLORS[2] + "20", fill: true, tension: 0.4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, grid: { color: grid }, ticks: { color: text } }, x: { grid: { display: false }, ticks: { color: text } } } },
}));
}
if (radarRef.current && radar.datasets.length) {
charts.push(new Chart(radarRef.current, {
type: "radar",
data: {
labels: radar.labels,
datasets: radar.datasets.map((ds, i) => ({
...ds, borderColor: COLORS[i], backgroundColor: COLORS[i] + "30", pointBackgroundColor: COLORS[i],
})),
},
options: { responsive: true, maintainAspectRatio: false,
scales: { r: { beginAtZero: true, max: 100, grid: { color: grid }, angleLines: { color: grid }, pointLabels: { color: text }, ticks: { display: false } } },
plugins: { legend: { position: "bottom", labels: { color: text } } } },
}));
}
});
return () => charts.forEach((c) => c.destroy());
}, [cost, speed, growth, radar]);
return (
<section className="section data-section" id="data">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">📊 DATA INSIGHTS</span>
<h2>旅居数据洞察</h2>
<p>用数据帮你做出更明智的目的地选择</p>
</div>
<div className="charts-grid">
<div className="chart-card reveal"><h3>💰 月度生活成本对比</h3><div className="chart-wrap"><canvas ref={costRef} /></div></div>
<div className="chart-card reveal"><h3>📶 网络速度排行</h3><div className="chart-wrap"><canvas ref={speedRef} /></div></div>
<div className="chart-card chart-wide reveal"><h3>📈 数字游民增长趋势 (2019-2026)</h3><div className="chart-wrap"><canvas ref={growthRef} /></div></div>
<div className="chart-card reveal"><h3>🎯 选择因素权重</h3><div className="chart-wrap chart-radar"><canvas ref={radarRef} /></div></div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,59 @@
"use client";
import { useToast } from "@/lib/toast";
import { api } from "@/lib/api";
import type { Testimonial } from "@/lib/types";
export default function Community({ testimonials }: { testimonials: Testimonial[] }) {
const { toast } = useToast();
const handleSubscribe = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
try {
await api.subscribe(email);
toast("订阅成功!欢迎加入 NomadFlow 社区 🎉");
form.reset();
} catch { /* ignore */ }
};
return (
<>
<section className="section community" id="community">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🤝 COMMUNITY</span>
<h2>游民心声</h2>
<p>来自全球数字游民的真实故事</p>
</div>
<div className="testimonials">
{testimonials.map((t) => (
<div key={t.id} className="testimonial-card reveal">
<div className="testimonial-avatar">{t.avatar}</div>
<div className="testimonial-content">
<div className="testimonial-stars">{"⭐".repeat(t.rating)}</div>
<p>&ldquo;{t.content}&rdquo;</p>
<div className="testimonial-author">
<strong>{t.author}</strong>
<span>{t.role}</span>
</div>
</div>
</div>
))}
</div>
<div className="cta-banner reveal">
<div className="cta-content">
<h2>准备好开始你的旅居之旅了吗? 🌍</h2>
<p>加入 50,000+ 数字游民社区,获取目的地指南、签证攻略与独家优惠</p>
</div>
<form className="cta-form" onSubmit={handleSubscribe}>
<input type="email" name="email" placeholder="输入你的邮箱 📧" required />
<button type="submit" className="btn btn-primary"><span>免费订阅</span><span>🚀</span></button>
</form>
</div>
</div>
</section>
</>
);
}

View File

@ -0,0 +1,127 @@
"use client";
import Link from "next/link";
import type { Destination } from "@/lib/types";
interface Props {
destinations: Destination[];
onClose: () => void;
}
interface Metric {
label: string;
emoji: string;
get: (d: Destination) => number;
format: (v: number) => string;
lowerBetter?: boolean;
}
const METRICS: Metric[] = [
{ label: "月生活费", emoji: "💰", get: (d) => d.cost, format: (v) => `¥${v.toLocaleString()}`, lowerBetter: true },
{ label: "网速", emoji: "📶", get: (d) => d.speed, format: (v) => `${v} Mbps` },
{ label: "温度", emoji: "🌡️", get: (d) => d.temperature, format: (v) => `${v}°C` },
{ label: "评分", emoji: "⭐", get: (d) => d.rating, format: (v) => v.toFixed(1) },
];
function getWinnerIdx(values: number[], lowerBetter?: boolean): number {
if (values.length === 0) return -1;
const best = lowerBetter ? Math.min(...values) : Math.max(...values);
return values.indexOf(best);
}
function overallScore(d: Destination): number {
const costScore = Math.max(0, 100 - d.cost / 150);
const speedScore = d.speed / 2;
const ratingScore = d.rating * 10;
return Math.round(costScore * 0.35 + speedScore * 0.25 + ratingScore * 0.4);
}
export default function CompareModal({ destinations, onClose }: Props) {
const scores = destinations.map(overallScore);
const winnerIdx = scores.indexOf(Math.max(...scores));
return (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal compare-modal">
<button className="modal-close" onClick={onClose} aria-label="关闭">✕</button>
<div className="modal-body compare-modal-body">
<div className="compare-modal-header">
<span className="section-tag">⚖️ COMPARE</span>
<h2>城市对比分析</h2>
<p>可视化对比关键指标,帮你做出最佳选择</p>
</div>
<div className="compare-cards">
{destinations.map((d, i) => (
<div key={d.slug} className={`compare-city-card${i === winnerIdx ? " winner" : ""}`}>
{i === winnerIdx && <span className="compare-winner-badge">👑 综合推荐</span>}
<span className="compare-city-emoji">{d.emoji}</span>
<h3>{d.name}</h3>
<span className="compare-city-country">{d.country}</span>
<div className="compare-score-ring">
<svg viewBox="0 0 80 80">
<circle cx="40" cy="40" r="34" fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="6" />
<circle
cx="40" cy="40" r="34" fill="none"
stroke="url(#scoreGrad)" strokeWidth="6"
strokeLinecap="round"
strokeDasharray={`${scores[i] * 2.14} 214`}
transform="rotate(-90 40 40)"
/>
</svg>
<span className="compare-score-num">{scores[i]}</span>
</div>
<Link href={`/destinations/${d.slug}`} className="compare-city-link">查看详情 →</Link>
</div>
))}
</div>
<div className="compare-metrics">
{METRICS.map((m) => {
const values = destinations.map(m.get);
const winIdx = getWinnerIdx(values, m.lowerBetter);
const maxVal = Math.max(...values);
return (
<div key={m.label} className="compare-metric-row">
<div className="compare-metric-label">{m.emoji} {m.label}</div>
<div className="compare-metric-bars">
{destinations.map((d, i) => {
const val = m.get(d);
const pct = maxVal > 0 ? (val / maxVal) * 100 : 0;
return (
<div key={d.slug} className="compare-bar-item">
<div className="compare-bar-header">
<span>{d.emoji} {d.name}</span>
<span className={i === winIdx ? "compare-best" : ""}>
{i === winIdx && "🏆 "}{m.format(val)}
</span>
</div>
<div className="compare-bar-track">
<div
className={`compare-bar-fill${i === winIdx ? " best" : ""}`}
style={{ width: `${pct}%`, animationDelay: `${i * 0.1}s` }}
/>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
<svg width="0" height="0">
<defs>
<linearGradient id="scoreGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FF6B6B" />
<stop offset="50%" stopColor="#FFE66D" />
<stop offset="100%" stopColor="#4ECDC4" />
</linearGradient>
</defs>
</svg>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,136 @@
"use client";
import { useMemo, useState } from "react";
const CURRENCIES = [
{ code: "CNY", name: "人民币", flag: "🇨🇳", rate: 1 },
{ code: "USD", name: "美元", flag: "🇺🇸", rate: 0.138 },
{ code: "EUR", name: "欧元", flag: "🇪🇺", rate: 0.127 },
{ code: "THB", name: "泰铢", flag: "🇹🇭", rate: 4.85 },
{ code: "IDR", name: "印尼盾", flag: "🇮🇩", rate: 2180 },
{ code: "JPY", name: "日元", flag: "🇯🇵", rate: 20.5 },
{ code: "MXN", name: "墨西哥比索", flag: "🇲🇽", rate: 2.35 },
{ code: "GBP", name: "英镑", flag: "🇬🇧", rate: 0.109 },
];
const PRESETS = [
{ label: "清迈月租", amount: 2500, from: "CNY", emoji: "🏔️" },
{ label: "巴厘岛生活费", amount: 4500, from: "CNY", emoji: "🏝️" },
{ label: "里斯本月租", amount: 800, from: "EUR", emoji: "🌊" },
{ label: "东京月租", amount: 120000, from: "JPY", emoji: "🗼" },
];
export default function CurrencyConverter() {
const [amount, setAmount] = useState(5000);
const [from, setFrom] = useState("CNY");
const [to, setTo] = useState("USD");
const [swapping, setSwapping] = useState(false);
const result = useMemo(() => {
const fromRate = CURRENCIES.find((c) => c.code === from)?.rate ?? 1;
const toRate = CURRENCIES.find((c) => c.code === to)?.rate ?? 1;
return (amount / fromRate) * toRate;
}, [amount, from, to]);
const swap = () => {
setSwapping(true);
setFrom(to);
setTo(from);
setTimeout(() => setSwapping(false), 400);
};
const applyPreset = (p: typeof PRESETS[0]) => {
setAmount(p.amount);
setFrom(p.from);
setTo("CNY");
};
const fromCur = CURRENCIES.find((c) => c.code === from)!;
const toCur = CURRENCIES.find((c) => c.code === to)!;
return (
<section className="section currency-section" id="currency">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">💱 CURRENCY</span>
<h2>多币种换算器</h2>
<p>快速换算旅居生活费,规划跨国预算更轻松</p>
</div>
<div className="currency-wrapper reveal">
<div className="currency-card">
<div className="currency-input-group">
<label>💰 金额</label>
<div className="currency-input-row">
<input
type="number"
value={amount}
onChange={(e) => setAmount(Math.max(0, +e.target.value || 0))}
min={0}
/>
<select value={from} onChange={(e) => setFrom(e.target.value)}>
{CURRENCIES.map((c) => (
<option key={c.code} value={c.code}>{c.flag} {c.code}</option>
))}
</select>
</div>
</div>
<button className={`currency-swap${swapping ? " swapping" : ""}`} onClick={swap} aria-label="交换货币">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M7 16V4M7 4L3 8M7 4l4 4M17 8v12M17 20l4-4M17 20l-4-4" />
</svg>
</button>
<div className="currency-result">
<label>✨ 换算结果</label>
<div className="currency-result-display">
<span className="currency-result-flag">{toCur.flag}</span>
<span className="currency-result-amount">
{result.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</span>
<span className="currency-result-code">{toCur.code}</span>
</div>
<p className="currency-rate-hint">
1 {fromCur.code} ≈ {((fromCur.rate / toCur.rate)).toLocaleString(undefined, { maximumFractionDigits: 4 })} {toCur.code}
</p>
</div>
<div className="currency-to-select">
<label>兑换为</label>
<div className="currency-chips">
{CURRENCIES.filter((c) => c.code !== from).map((c) => (
<button
key={c.code}
className={`currency-chip${to === c.code ? " active" : ""}`}
onClick={() => setTo(c.code)}
>
{c.flag} {c.code}
</button>
))}
</div>
</div>
</div>
<div className="currency-presets">
<h3>🏙️ 快捷参考</h3>
<p className="currency-presets-desc">一键填入典型旅居开销</p>
<div className="currency-preset-grid">
{PRESETS.map((p) => (
<button key={p.label} className="currency-preset-card" onClick={() => applyPreset(p)}>
<span className="preset-emoji">{p.emoji}</span>
<strong>{p.label}</strong>
<span>{p.amount.toLocaleString()} {p.from}</span>
</button>
))}
</div>
<div className="currency-tip">
<span>💡</span>
<p>实际汇率会波动,建议用 Wise 或 Revolut 获取实时汇率。此处为参考汇率。</p>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,140 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useToast } from "@/lib/toast";
import type { Destination } from "@/lib/types";
const STORAGE_KEY = "nomadflow-trip";
const TZ_LABELS: Record<string, string> = {
bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7",
mexico: "UTC-6", barcelona: "UTC+1", tokyo: "UTC+9",
};
interface Props {
dest: Destination;
allDestinations: Destination[];
}
function getScores(d: Destination) {
const nomads = parseInt(d.nomads_count.replace(/[^0-9]/g, ""), 10) || 0;
return [
{ label: "性价比", value: Math.round((12000 - d.cost) / 120), emoji: "💰" },
{ label: "网速", value: Math.round(d.speed / 2), emoji: "📶" },
{ label: "气候", value: Math.round(100 - Math.abs(d.temperature - 24) * 4), emoji: "🌡️" },
{ label: "评分", value: Math.round(d.rating * 10), emoji: "⭐" },
{ label: "社区", value: Math.min(Math.round(nomads / 150), 100), emoji: "🤝" },
];
}
export default function DestinationDetailClient({ dest, allDestinations }: Props) {
const { toast } = useToast();
const [added, setAdded] = useState(false);
const scores = getScores(dest);
const related = allDestinations
.filter((d) => d.region === dest.region && d.slug !== dest.slug)
.slice(0, 3);
const addToTrip = () => {
const saved = localStorage.getItem(STORAGE_KEY);
const trip = saved ? JSON.parse(saved) : [];
if (trip.some((t: { slug: string }) => t.slug === dest.slug)) {
toast("该城市已在行程中", "info");
return;
}
trip.push({ slug: dest.slug, name: dest.name, country: dest.country, emoji: dest.emoji, cost: dest.cost, months: 1 });
localStorage.setItem(STORAGE_KEY, JSON.stringify(trip));
setAdded(true);
toast(`${dest.emoji} ${dest.name} 已加入行程 🗓️`);
};
const share = async () => {
const url = window.location.href;
if (navigator.share) {
await navigator.share({ title: `${dest.name} - NomadFlow`, url });
} else {
await navigator.clipboard.writeText(url);
toast("链接已复制到剪贴板 📋");
}
};
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const trip = JSON.parse(saved);
setAdded(trip.some((t: { slug: string }) => t.slug === dest.slug));
}
}, [dest.slug]);
return (
<>
<div className="dest-detail-actions">
<button className={`btn ${added ? "btn-ghost" : "btn-primary"}`} onClick={addToTrip} disabled={added}>
{added ? "✓ 已在行程中" : "🗓️ 加入行程"}
</button>
<button className="btn btn-ghost" onClick={share}>📤 分享</button>
<Link href={`/#calculator`} className="btn btn-ghost">🧮 算费用</Link>
<Link href={`/#timezone`} className="btn btn-ghost">🕐 看时区</Link>
</div>
<div className="dest-detail-extras">
<div className="dest-radar-card">
<h3>📊 城市画像</h3>
<div className="dest-radar-bars">
{scores.map((s) => (
<div key={s.label} className="dest-radar-row">
<span className="dest-radar-label">{s.emoji} {s.label}</span>
<div className="dest-radar-track">
<div className="dest-radar-fill" style={{ width: `${Math.min(s.value, 100)}%` }} />
</div>
<span className="dest-radar-val">{Math.min(s.value, 100)}</span>
</div>
))}
</div>
</div>
<div className="dest-info-cards">
<div className="dest-info-mini">
<span>🕐</span>
<div>
<strong>时区</strong>
<span>{TZ_LABELS[dest.slug] || "查看时区看板"}</span>
</div>
</div>
<div className="dest-info-mini">
<span>👥</span>
<div>
<strong>游民社区</strong>
<span>{dest.nomads_count} 活跃</span>
</div>
</div>
<div className="dest-info-mini">
<span>🌏</span>
<div>
<strong>区域</strong>
<span>{dest.tag.split("·")[0]?.trim()}</span>
</div>
</div>
</div>
</div>
{related.length > 0 && (
<div className="dest-related">
<h3>🔗 同区域推荐</h3>
<div className="dest-related-grid">
{related.map((d) => (
<Link key={d.slug} href={`/destinations/${d.slug}`} className="dest-related-card">
<span>{d.emoji}</span>
<div>
<strong>{d.name}</strong>
<span>¥{d.cost.toLocaleString()}/月 · ⭐ {d.rating}</span>
</div>
</Link>
))}
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,217 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types";
interface Props {
destinations: Destination[];
open: boolean;
onClose: () => void;
}
type Budget = "low" | "medium" | "high";
type Climate = "warm" | "mild" | "cool";
type Priority = "cost" | "speed" | "community" | "lifestyle";
type Region = "any" | "sea" | "europe" | "latam" | "asia";
interface Prefs {
budget: Budget | null;
climate: Climate | null;
priority: Priority | null;
region: Region | null;
}
const STEPS = [
{ title: "你的月预算?", subtitle: "包含住宿、餐饮、交通等日常开销", key: "budget" as const },
{ title: "偏好什么气候?", subtitle: "选择最让你舒适的环境", key: "climate" as const },
{ title: "最看重什么?", subtitle: "我们会据此为你智能排序", key: "priority" as const },
{ title: "想去哪个区域?", subtitle: "可以选「不限」探索全球", key: "region" as const },
];
const OPTIONS = {
budget: [
{ value: "low" as Budget, emoji: "💰", label: "精打细算", desc: "¥5,000 以下/月" },
{ value: "medium" as Budget, emoji: "💳", label: "舒适适中", desc: "¥5,000 – 9,000/月" },
{ value: "high" as Budget, emoji: "💎", label: "品质优先", desc: "¥9,000 以上/月" },
],
climate: [
{ value: "warm" as Climate, emoji: "☀️", label: "热带温暖", desc: "25°C 以上,阳光沙滩" },
{ value: "mild" as Climate, emoji: "🌤️", label: "温和宜人", desc: "18–25°C,四季舒适" },
{ value: "cool" as Climate, emoji: "🍂", label: "凉爽清爽", desc: "18°C 以下,清爽干燥" },
],
priority: [
{ value: "cost" as Priority, emoji: "💰", label: "生活成本", desc: "花最少的钱过最好的生活" },
{ value: "speed" as Priority, emoji: "📶", label: "网络速度", desc: "稳定高速,会议不掉线" },
{ value: "community" as Priority, emoji: "🤝", label: "游民社区", desc: "结识同行,快速融入" },
{ value: "lifestyle" as Priority, emoji: "🎨", label: "生活方式", desc: "文化、美食与体验" },
],
region: [
{ value: "any" as Region, emoji: "🌏", label: "不限", desc: "全球探索" },
{ value: "sea" as Region, emoji: "🌴", label: "东南亚", desc: "性价比之王" },
{ value: "europe" as Region, emoji: "🏰", label: "欧洲", desc: "历史与签证友好" },
{ value: "latam" as Region, emoji: "🌮", label: "拉美", desc: "活力与北美时区" },
{ value: "asia" as Region, emoji: "🏯", label: "东亚", desc: "安全高效现代" },
],
};
function parseNomads(n: string): number {
return parseInt(n.replace(/[^0-9]/g, ""), 10) || 0;
}
function scoreDestination(d: Destination, prefs: Required<Pick<Prefs, "budget" | "climate" | "priority" | "region">>): number {
let score = 0;
if (prefs.budget === "low") score += d.cost <= 5000 ? 35 : Math.max(0, 35 - (d.cost - 5000) / 200);
else if (prefs.budget === "medium") score += d.cost >= 4500 && d.cost <= 9500 ? 35 : 35 - Math.abs(d.cost - 7000) / 300;
else score += d.cost >= 9000 ? 35 : Math.max(0, 35 - (9000 - d.cost) / 200);
if (prefs.climate === "warm") score += d.temperature >= 25 ? 25 : Math.max(0, 25 - (25 - d.temperature) * 3);
else if (prefs.climate === "mild") score += d.temperature >= 18 && d.temperature <= 25 ? 25 : 25 - Math.abs(d.temperature - 21) * 2;
else score += d.temperature <= 18 ? 25 : Math.max(0, 25 - (d.temperature - 18) * 3);
if (prefs.priority === "cost") score += (12000 - d.cost) / 80;
else if (prefs.priority === "speed") score += d.speed / 4;
else if (prefs.priority === "community") score += parseNomads(d.nomads_count) / 500;
else score += d.rating * 3;
if (prefs.region === "any" || d.region === prefs.region) score += 20;
else score -= 10;
score += d.rating * 2;
return Math.round(score);
}
export default function DestinationMatcher({ destinations, open, onClose }: Props) {
const [step, setStep] = useState(0);
const [prefs, setPrefs] = useState<Prefs>({ budget: null, climate: null, priority: null, region: null });
const [direction, setDirection] = useState<"forward" | "back">("forward");
const results = useMemo(() => {
if (!prefs.budget || !prefs.climate || !prefs.priority || !prefs.region) return [];
return [...destinations]
.map((d) => ({ dest: d, score: scoreDestination(d, prefs as Required<typeof prefs>) }))
.sort((a, b) => b.score - a.score);
}, [destinations, prefs]);
const currentKey = STEPS[step]?.key;
const isComplete = step >= STEPS.length;
const progress = isComplete ? 100 : ((step + 1) / STEPS.length) * 100;
const select = (value: string) => {
if (!currentKey) return;
setDirection("forward");
setPrefs((p) => ({ ...p, [currentKey]: value }));
setTimeout(() => setStep((s) => s + 1), 280);
};
const goBack = () => {
if (step === 0) { onClose(); return; }
setDirection("back");
setStep((s) => s - 1);
};
const reset = () => {
setStep(0);
setPrefs({ budget: null, climate: null, priority: null, region: null });
setDirection("forward");
};
if (!open) return null;
return (
<div className="modal-overlay open matcher-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal matcher-modal">
<button className="modal-close" onClick={onClose} aria-label="关闭">✕</button>
<div className="matcher-progress">
<div className="matcher-progress-bar" style={{ width: `${progress}%` }} />
</div>
<div className="matcher-body">
{!isComplete ? (
<div className={`matcher-step matcher-${direction}`} key={step}>
<div className="matcher-step-meta">
<span className="section-tag">🎯 MATCHER · {step + 1}/{STEPS.length}</span>
<h2>{STEPS[step].title}</h2>
<p>{STEPS[step].subtitle}</p>
</div>
<div className="matcher-options">
{OPTIONS[currentKey].map((opt) => (
<button
key={opt.value}
className="matcher-option"
onClick={() => select(opt.value)}
>
<span className="matcher-option-emoji">{opt.emoji}</span>
<div>
<strong>{opt.label}</strong>
<span>{opt.desc}</span>
</div>
<svg className="matcher-option-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</button>
))}
</div>
<button className="matcher-back" onClick={goBack}>
{step === 0 ? "取消" : "← 上一步"}
</button>
</div>
) : (
<div className="matcher-results matcher-forward">
<div className="matcher-step-meta">
<span className="section-tag">✨ YOUR MATCHES</span>
<h2>为你推荐的目的地</h2>
<p>根据你的偏好智能匹配,匹配度越高越适合你</p>
</div>
<div className="matcher-result-list">
{results.map(({ dest, score }, i) => (
<Link
key={dest.slug}
href={`/destinations/${dest.slug}`}
className={`matcher-result-card${i === 0 ? " top" : ""}`}
onClick={onClose}
>
<div className="matcher-result-rank">
{i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `#${i + 1}`}
</div>
<span className="matcher-result-emoji">{dest.emoji}</span>
<div className="matcher-result-info">
<strong>{dest.name}</strong>
<span>{dest.country} · {dest.tag}</span>
</div>
<div className="matcher-result-score">
<div className="matcher-score-bar">
<div className="matcher-score-fill" style={{ width: `${Math.min((score / (results[0]?.score || 1)) * 100, 100)}%` }} />
</div>
<span>{Math.min(Math.round((score / (results[0]?.score || 1)) * 100), 100)}% 匹配</span>
</div>
<div className="matcher-result-tags">
<span>💰 ¥{dest.cost.toLocaleString()}</span>
<span>📶 {dest.speed}Mbps</span>
<span>⭐ {dest.rating}</span>
</div>
</Link>
))}
</div>
<div className="matcher-actions">
<button className="btn btn-ghost" onClick={reset}>🔄 重新测试</button>
<button className="btn btn-primary" onClick={onClose}>开始探索 🚀</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
export function MatcherTrigger({ onClick }: { onClick: () => void }) {
return (
<button className="matcher-fab" onClick={onClick} aria-label="智能匹配">
<span className="matcher-fab-icon">🎯</span>
<span className="matcher-fab-text">智能匹配</span>
</button>
);
}

View File

@ -0,0 +1,122 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types";
import FavoriteButton from "./FavoriteButton";
interface Props {
destinations: Destination[];
onCompare?: (slug: string) => void;
compareList?: string[];
onOpenMatcher?: () => void;
}
const FILTERS = [
{ key: "all", label: "🌏 全部" },
{ 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 [search, setSearch] = useState("");
const [sort, setSort] = useState("rating");
const filtered = useMemo(() => {
let list = [...destinations];
if (filter !== "all") list = list.filter((d) => d.region === filter);
if (search) {
const q = search.toLowerCase();
list = list.filter((d) => d.name.toLowerCase().includes(q) || d.country.toLowerCase().includes(q));
}
if (sort === "cost-asc") list.sort((a, b) => a.cost - b.cost);
else if (sort === "cost-desc") list.sort((a, b) => b.cost - a.cost);
else if (sort === "speed") list.sort((a, b) => b.speed - a.speed);
else list.sort((a, b) => b.rating - a.rating);
return list;
}, [destinations, filter, search, sort]);
return (
<section className="section destinations" id="destinations">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🌴 TOP DESTINATIONS</span>
<h2>热门旅居目的地</h2>
<p>精选全球最适合远程工作的城市,点击查看详情,勾选对比</p>
{onOpenMatcher && (
<button className="btn btn-primary dest-matcher-btn" onClick={onOpenMatcher}>
🎯 不知道去哪?智能匹配
</button>
)}
</div>
<div className="dest-filters reveal">
<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>
<input placeholder="搜索城市..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div className="filter-tags">
{FILTERS.map((f) => (
<button key={f.key} className={`filter-btn${filter === f.key ? " active" : ""}`}
onClick={() => setFilter(f.key)}>{f.label}</button>
))}
</div>
<div className="filter-sort">
<select value={sort} onChange={(e) => setSort(e.target.value)}>
<option value="rating">⭐ 评分排序</option>
<option value="cost-asc">💰 价格从低到高</option>
<option value="cost-desc">💰 价格从高到低</option>
<option value="speed">📶 网速排序</option>
</select>
</div>
</div>
<div className="dest-grid" id="dest-grid">
{filtered.map((d) => (
<article key={d.slug} className="dest-card reveal" style={{ position: "relative" }}>
{onCompare && (
<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-emoji">{d.emoji}</div>
<div className="dest-overlay"><span className="dest-tag">{d.tag}</span></div>
</div>
<div className="dest-body">
<h3>{d.name}, {d.country}</h3>
<p>{d.description}</p>
<div className="dest-meta">
<span>💰 ¥{d.cost.toLocaleString()}/月</span>
<span>📶 {d.speed}Mbps</span>
<span>🌡️ {d.temperature}°C</span>
</div>
<div className="dest-rating">
<span className="stars">{"⭐".repeat(Math.round(d.rating / 2))}</span>
<span>{d.rating} 分</span>
</div>
</div>
</Link>
<div style={{ padding: "0 20px 20px", display: "flex", gap: 8, alignItems: "center" }}>
<div onClick={(e) => e.preventDefault()}>
<FavoriteButton slug={d.slug} />
</div>
</div>
</article>
))}
</div>
{filtered.length === 0 && (
<p className="dest-empty">😢 没有找到匹配的目的地,试试其他筛选条件</p>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import { useState } from "react";
import type { FAQ } from "@/lib/types";
export default function FAQSection({ faqs }: { faqs: FAQ[] }) {
const [active, setActive] = useState<string | null>(null);
const sorted = [...faqs].sort((a, b) => a.order - b.order);
return (
<section className="section faq-section" id="faq">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">❓ FAQ</span>
<h2>常见问题</h2>
<p>关于数字游民生活,你可能想知道的一切</p>
</div>
<div className="faq-list">
{sorted.map((f) => (
<div key={f.id} className={`faq-item reveal${active === f.id ? " active" : ""}`}>
<button className="faq-question" onClick={() => setActive(active === f.id ? null : f.id)}>
<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>
</button>
<div className="faq-answer"><p>{f.answer}</p></div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,24 @@
"use client";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
export default function FavoriteButton({ slug }: { slug: string }) {
const { user, isFavorite, toggleFavorite } = useAuth();
if (!user) {
return (
<Link href="/login" className="btn btn-ghost" style={{ fontSize: "0.85rem" }}>
🤍 登录收藏
</Link>
);
}
const fav = isFavorite(slug);
return (
<button className={`btn ${fav ? "btn-primary" : "btn-ghost"}`}
onClick={() => toggleFavorite(slug)} style={{ fontSize: "0.85rem" }}>
{fav ? "❤️ 已收藏" : "🤍 收藏"}
</button>
);
}

View File

@ -0,0 +1,45 @@
export default function Footer() {
return (
<footer className="footer">
<div className="container">
<div className="footer-grid">
<div className="footer-brand">
<a href="#" className="logo"><span>NomadFlow</span></a>
<p>让每个人都能自由地工作和生活在这个星球上 🌏</p>
<div className="social-links">
<a href="#" aria-label="Twitter">𝕏</a>
<a href="#" aria-label="Instagram">📷</a>
<a href="#" aria-label="YouTube">▶️</a>
<a href="#" aria-label="Discord">💬</a>
</div>
</div>
<div className="footer-links">
<h4>探索</h4>
<a href="#destinations">目的地</a>
<a href="#calculator">费用计算器</a>
<a href="#visa">签证指南</a>
<a href="#blog">博客</a>
</div>
<div className="footer-links">
<h4>技术栈</h4>
<a href="#">Next.js</a>
<a href="#">FastAPI</a>
<a href="#">PocketBase</a>
<a href="/api/docs" target="_blank">API 文档</a>
</div>
<div className="footer-links">
<h4>联系</h4>
<a href="#">关于我们</a>
<a href="#">合作伙伴</a>
<a href="#">隐私政策</a>
<a href="mailto:hello@nomadflow.io">hello@nomadflow.io</a>
</div>
</div>
<div className="footer-bottom">
<p>© 2026 NomadFlow. Made with ❤️ for Digital Nomads everywhere.</p>
<p className="footer-emoji-bar">🌴 ☀️ 🏄‍♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅</p>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,129 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import type { SearchResult } from "@/lib/types";
const TYPE_LABELS: Record<string, string> = {
destination: "目的地",
blog: "博客",
visa: "签证",
faq: "FAQ",
};
export default function GlobalSearch() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [activeIdx, setActiveIdx] = useState(0);
const router = useRouter();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((o) => !o);
}
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) { setResults([]); return; }
setLoading(true);
try {
const data = await api.search(q);
setResults(data);
setActiveIdx(0);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const timer = setTimeout(() => doSearch(query), 250);
return () => clearTimeout(timer);
}, [query, doSearch]);
const navigate = (url: string) => {
setOpen(false);
setQuery("");
if (url.startsWith("/#")) {
router.push("/");
setTimeout(() => {
document.querySelector(url.replace("/", ""))?.scrollIntoView({ behavior: "smooth" });
}, 300);
} else {
router.push(url);
}
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx((i) => Math.min(i + 1, results.length - 1)); }
if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx((i) => Math.max(i - 1, 0)); }
if (e.key === "Enter" && results[activeIdx]) navigate(results[activeIdx].url);
};
return (
<>
<button className="search-trigger" onClick={() => setOpen(true)} aria-label="搜索">
<svg width="16" height="16" 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>
<span>搜索...</span>
<kbd>⌘K</kbd>
</button>
{open && (
<div className="search-overlay" onClick={() => setOpen(false)}>
<div className="search-modal" onClick={(e) => e.stopPropagation()}>
<div className="search-input-wrap">
<svg width="20" height="20" 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
autoFocus
placeholder="搜索目的地、博客、签证..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
/>
<button onClick={() => setOpen(false)}>ESC</button>
</div>
<div className="search-results">
{loading && <p className="search-empty">搜索中...</p>}
{!loading && query && results.length === 0 && (
<p className="search-empty">😢 没有找到「{query}」相关结果</p>
)}
{!loading && results.map((r, i) => (
<button key={`${r.type}-${r.url}`}
className={`search-result${i === activeIdx ? " active" : ""}`}
onClick={() => navigate(r.url)}
onMouseEnter={() => setActiveIdx(i)}>
<span className="search-result-emoji">{r.emoji}</span>
<div className="search-result-text">
<strong>{r.title}</strong>
<span>{r.subtitle}</span>
</div>
<span className="search-result-type">{TYPE_LABELS[r.type] || r.type}</span>
</button>
))}
{!query && (
<div className="search-hints">
<p>💡 试试搜索:清迈、签证、税务</p>
<p>⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭</p>
</div>
)}
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,144 @@
"use client";
import { useEffect } from "react";
import type { Stats } from "@/lib/types";
const PHRASES = [
"数字游民不是逃离生活,而是用科技重新定义生活。",
"在巴厘岛写代码,在里斯本开会议,在清迈看日落 ☀️",
"世界那么大,你的办公室可以在任何地方 🌍",
"远程工作 + 全球旅居 = 无限可能 ✨",
];
interface Props { stats: Stats }
export default function Hero({ stats }: Props) {
useEffect(() => {
const el = document.getElementById("typewriter");
if (!el) return;
let phraseIdx = 0, charIdx = 0, deleting = false, timer: ReturnType<typeof setTimeout>;
const type = () => {
const current = PHRASES[phraseIdx];
if (!deleting) {
el.textContent = current.substring(0, charIdx + 1);
charIdx++;
if (charIdx === current.length) { deleting = true; timer = setTimeout(type, 2500); return; }
timer = setTimeout(type, 60);
} else {
el.textContent = current.substring(0, charIdx - 1);
charIdx--;
if (charIdx === 0) { deleting = false; phraseIdx = (phraseIdx + 1) % PHRASES.length; }
timer = setTimeout(type, 30);
}
};
timer = setTimeout(type, 2000);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
const counters = document.querySelectorAll(".stat-number");
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const el = entry.target as HTMLElement;
const target = parseInt(el.dataset.target || "0", 10);
const start = performance.now();
const step = (now: number) => {
const p = Math.min((now - start) / 2000, 1);
el.textContent = String(Math.floor((1 - Math.pow(1 - p, 3)) * target));
if (p < 1) requestAnimationFrame(step);
else el.textContent = String(target);
};
requestAnimationFrame(step);
observer.unobserve(el);
});
}, { threshold: 0.5 });
counters.forEach((c) => observer.observe(c));
return () => observer.disconnect();
}, []);
return (
<section className="hero" id="hero">
<div className="hero-bg-shapes">
<div className="shape shape-1" /><div className="shape shape-2" /><div className="shape shape-3" />
</div>
<div className="hero-content">
<div className="hero-badge reveal">
<span className="badge-dot" />
🚀 全球 {stats.total_nomads} 数字游民正在路上
</div>
<h1 className="hero-title reveal">
用一行代码<br /><span className="gradient-text">环游世界</span> 🌏
</h1>
<p className="hero-subtitle reveal">
<span id="typewriter" /><span className="typewriter-cursor">|</span>
</p>
<div className="hero-actions reveal">
<a href="#destinations" className="btn btn-primary">
<span>探索目的地</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>
</a>
<a href="#calculator" className="btn btn-ghost"><span>🧮 费用计算</span></a>
<button className="btn btn-ghost hero-matcher-btn" onClick={() => {
window.dispatchEvent(new CustomEvent("open-matcher"));
}}><span>🎯 智能匹配</span></button>
</div>
<div className="hero-stats reveal">
<div className="stat-item">
<span className="stat-number" data-target={stats.countries}>0</span>
<span className="stat-label">🗺️ 可探索国家</span>
</div>
<div className="stat-divider" />
<div className="stat-item">
<span className="stat-number" data-target={stats.avg_cost}>0</span>
<span className="stat-label">💰 平均月生活费 (千元)</span>
</div>
<div className="stat-divider" />
<div className="stat-item">
<span className="stat-number" data-target={stats.satisfaction}>0</span>
<span className="stat-label">😊 生活满意度 %</span>
</div>
</div>
</div>
<div className="hero-visual reveal">
<div className="globe-container">
<svg className="globe-svg" viewBox="0 0 400 400">
<defs>
<radialGradient id="globeGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#4ECDC4" stopOpacity="0.3" />
<stop offset="100%" stopColor="#4ECDC4" stopOpacity="0" />
</radialGradient>
<linearGradient id="orbitGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FF6B6B" /><stop offset="100%" stopColor="#4ECDC4" />
</linearGradient>
</defs>
<circle cx="200" cy="200" r="160" fill="url(#globeGlow)" />
<circle cx="200" cy="200" r="120" fill="none" stroke="rgba(78,205,196,0.2)" strokeWidth="1" />
<ellipse cx="200" cy="200" rx="120" ry="40" fill="none" stroke="rgba(78,205,196,0.3)" strokeWidth="1.5" className="orbit-ring" />
<ellipse cx="200" cy="200" rx="40" ry="120" fill="none" stroke="rgba(255,107,107,0.3)" strokeWidth="1.5" className="orbit-ring orbit-ring-2" />
<path d="M160 140 Q180 130 200 135 Q220 128 240 140 Q250 155 245 170 Q235 185 220 190 Q200 195 185 188 Q165 180 160 165 Z" fill="rgba(78,205,196,0.4)" className="continent" />
<g className="nomad-dots">
<circle cx="180" cy="155" r="5" fill="#FF6B6B" className="pulse-dot" />
<circle cx="260" cy="215" r="5" fill="#FFE66D" className="pulse-dot" />
<circle cx="145" cy="225" r="5" fill="#4ECDC4" className="pulse-dot" />
<circle cx="220" cy="175" r="5" fill="#A78BFA" className="pulse-dot" />
</g>
<path d="M180 155 Q220 140 260 215" fill="none" stroke="url(#orbitGrad)" strokeWidth="2" strokeDasharray="6 4" className="flight-path" />
</svg>
<div className="floating-cards">
<div className="float-card fc-1">🏝️ 巴厘岛<br /><small>WiFi 98Mbps</small></div>
<div className="float-card fc-2">☕ 里斯本<br /><small>€800/月</small></div>
<div className="float-card fc-3">🏔️ 清迈<br /><small>⭐ 4.9</small></div>
</div>
</div>
</div>
<div className="scroll-indicator">
<span>向下滚动</span>
<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>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,132 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types";
import Loader from "./Loader";
import ParticleCanvas from "./ParticleCanvas";
import Navbar from "./Navbar";
import TickerBar from "./TickerBar";
import Hero from "./Hero";
import WorldMap from "./WorldMap";
import Destinations from "./Destinations";
import Calculator from "./Calculator";
import TripPlanner from "./TripPlanner";
import GlobalSearch from "./GlobalSearch";
import Lifestyle from "./Lifestyle";
import Charts from "./Charts";
import VisaSection from "./VisaSection";
import Tools from "./Tools";
import BlogSection from "./BlogSection";
import FAQSection from "./FAQSection";
import Community from "./Community";
import Footer from "./Footer";
import CompareModal from "./CompareModal";
import DestinationMatcher, { MatcherTrigger } from "./DestinationMatcher";
import TimezoneBoard from "./TimezoneBoard";
import ScrollProgress from "./ScrollProgress";
import SectionNav from "./SectionNav";
import CurrencyConverter from "./CurrencyConverter";
import NomadScore from "./NomadScore";
import KeyboardShortcuts from "./KeyboardShortcuts";
import BackToTop from "./BackToTop";
interface Props {
stats: Stats;
ticker: string[];
destinations: Destination[];
visas: Visa[];
faqs: FAQ[];
testimonials: Testimonial[];
tools: Tool[];
blog: BlogPost[];
charts: { cost: ChartData; speed: ChartData; growth: ChartData; radar: ChartData };
}
export default function HomeClient(props: Props) {
const [compareList, setCompareList] = useState<string[]>([]);
const [compareResults, setCompareResults] = useState<Destination[] | null>(null);
const [compareOpen, setCompareOpen] = useState(false);
const [matcherOpen, setMatcherOpen] = useState(false);
useEffect(() => {
const onMatcher = () => setMatcherOpen(true);
window.addEventListener("open-matcher", onMatcher);
return () => window.removeEventListener("open-matcher", onMatcher);
}, []);
useEffect(() => {
const reveals = document.querySelectorAll(".reveal");
const observer = new IntersectionObserver(
(entries) => entries.forEach((e) => { if (e.isIntersecting) { e.target.classList.add("visible"); observer.unobserve(e.target); } }),
{ threshold: 0.1, rootMargin: "0px 0px -50px 0px" }
);
reveals.forEach((el, i) => {
(el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.1}s`;
observer.observe(el);
});
return () => observer.disconnect();
}, []);
const toggleCompare = (slug: string) => {
setCompareList((prev) => {
if (prev.includes(slug)) return prev.filter((s) => s !== slug);
if (prev.length >= 4) return prev;
return [...prev, slug];
});
setCompareResults(null);
};
const runCompare = async () => {
if (compareList.length < 2) return;
try {
const data = await api.compareDestinations(compareList);
setCompareResults(data);
setCompareOpen(true);
} catch { /* ignore */ }
};
return (
<>
<ScrollProgress />
<Loader />
<ParticleCanvas />
<Navbar />
<GlobalSearch />
<TickerBar messages={props.ticker} />
<Hero stats={props.stats} />
<WorldMap destinations={props.destinations} />
<Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} />
<TimezoneBoard destinations={props.destinations} />
<TripPlanner destinations={props.destinations} />
<Calculator destinations={props.destinations} />
<CurrencyConverter />
<NomadScore />
<Lifestyle />
<Charts {...props.charts} />
<VisaSection visas={props.visas} />
<Tools tools={props.tools} />
<BlogSection posts={props.blog} />
<FAQSection faqs={props.faqs} />
<Community testimonials={props.testimonials} />
<Footer />
<BackToTop />
<SectionNav />
<MatcherTrigger onClick={() => setMatcherOpen(true)} />
<DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} />
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
{compareList.length > 0 && (
<div className="compare-bar">
<span>⚖️ 已选 {compareList.length}/4</span>
<button className="btn btn-primary" onClick={runCompare} disabled={compareList.length < 2}>对比城市</button>
<button className="btn btn-ghost" onClick={() => { setCompareList([]); setCompareResults(null); }}>清空</button>
</div>
)}
{compareOpen && compareResults && (
<CompareModal destinations={compareResults} onClose={() => setCompareOpen(false)} />
)}
</>
);
}

View File

@ -0,0 +1,65 @@
"use client";
import { useEffect, useState } from "react";
interface Props {
onOpenMatcher?: () => void;
}
const SHORTCUTS = [
{ keys: ["Ctrl", "K"], label: "全局搜索", icon: "🔍" },
{ keys: ["M"], label: "智能匹配", icon: "🎯" },
{ keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
{ keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
{ keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" },
{ keys: ["Enter"], label: "确认选择", icon: "↵" },
];
export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
const [open, setOpen] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
const typing = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
if (e.key === "?" && !typing && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
setOpen((o) => !o);
}
if (e.key === "m" && !typing && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
onOpenMatcher?.();
}
if (e.key === "Escape" && open) setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenMatcher]);
if (!open) return null;
return (
<div className="modal-overlay open shortcuts-overlay" onClick={(e) => e.target === e.currentTarget && setOpen(false)}>
<div className="modal shortcuts-modal">
<button className="modal-close" onClick={() => setOpen(false)} aria-label="关闭">✕</button>
<div className="shortcuts-body">
<span className="section-tag">⌨️ SHORTCUTS</span>
<h2>键盘快捷键</h2>
<p>提升浏览效率,按 <kbd>?</kbd> 随时唤起</p>
<div className="shortcuts-grid">
{SHORTCUTS.map((s) => (
<div key={s.label} className="shortcut-item">
<span className="shortcut-icon">{s.icon}</span>
<span className="shortcut-label">{s.label}</span>
<div className="shortcut-keys">
{s.keys.map((k) => <kbd key={k}>{k}</kbd>)}
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
const ITEMS = [
{ time: "🌅 07:00", icon: "🧘", title: "晨间仪式", desc: "瑜伽、冥想或海边跑步,用身心唤醒开启新的一天", tip: "推荐:清迈河边晨跑、巴厘岛海滩瑜伽" },
{ time: "☕ 09:00", icon: "💻", title: "深度工作", desc: "在 Co-working Space 或咖啡馆专注 4 小时,完成核心任务", tip: "推荐:Punspace 清迈、Outsite 里斯本" },
{ time: "🍜 13:00", icon: "🍽️", title: "本地探索", desc: "品尝地道美食,与当地人交流,感受文化碰撞", tip: "推荐:清迈夜市、巴塞罗那 Tapas 之旅" },
{ time: "🌇 17:00", icon: "📞", title: "跨时区协作", desc: "与全球团队视频会议,灵活安排跨时区沟通", tip: "工具:Calendly 排时区、World Time Buddy" },
{ time: "🌙 20:00", icon: "🎉", title: "社群社交", desc: "参加 Nomad Meetup,结识来自世界各地的同行者", tip: "平台:Nomad List Meetups、Facebook 群组" },
];
export default function Lifestyle() {
const [active, setActive] = useState(0);
return (
<section className="section lifestyle" id="lifestyle">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">💻 NOMAD LIFE</span>
<h2>数字游民的一天</h2>
<p>自由不等于散漫,高效工作才能尽情探索 — 点击卡片查看详情</p>
</div>
<div className="lifestyle-layout reveal">
<div className="timeline">
<div className="timeline-line">
<svg viewBox="0 0 4 600" preserveAspectRatio="none">
<line x1="2" y1="0" x2="2" y2="600" stroke="url(#timelineGrad)" strokeWidth="3" strokeDasharray="8 6" className="timeline-svg-line" />
<defs>
<linearGradient id="timelineGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#FF6B6B" /><stop offset="50%" stopColor="#FFE66D" /><stop offset="100%" stopColor="#4ECDC4" />
</linearGradient>
</defs>
</svg>
</div>
{ITEMS.map((item, i) => (
<div
key={item.time}
className={`timeline-item${active === i ? " active" : ""}`}
onClick={() => setActive(i)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && setActive(i)}
>
<div className="timeline-time">{item.time}</div>
<div className="timeline-card">
<div className="timeline-icon">{item.icon}</div>
<h3>{item.title}</h3>
<p>{item.desc}</p>
</div>
</div>
))}
</div>
<div className="lifestyle-detail">
<div className="lifestyle-detail-card">
<span className="lifestyle-detail-icon">{ITEMS[active].icon}</span>
<span className="lifestyle-detail-time">{ITEMS[active].time}</span>
<h3>{ITEMS[active].title}</h3>
<p>{ITEMS[active].desc}</p>
<div className="lifestyle-tip">
<span>💡</span>
<p>{ITEMS[active].tip}</p>
</div>
<div className="lifestyle-dots">
{ITEMS.map((_, i) => (
<button
key={i}
className={`lifestyle-dot${active === i ? " active" : ""}`}
onClick={() => setActive(i)}
aria-label={`第 ${i + 1} 项`}
/>
))}
</div>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import { useEffect } from "react";
export default function Loader() {
useEffect(() => {
const loader = document.getElementById("loader");
const hide = () => loader?.classList.add("hidden");
window.addEventListener("load", () => setTimeout(hide, 1800));
const t = setTimeout(hide, 3000);
return () => clearTimeout(t);
}, []);
return (
<div className="loader" id="loader">
<div className="loader-inner">
<svg className="loader-globe" viewBox="0 0 80 80" fill="none">
<circle cx="40" cy="40" r="35" stroke="url(#loaderGrad)" strokeWidth="3" strokeDasharray="60 160" className="loader-ring" />
<text x="40" y="48" textAnchor="middle" fontSize="24">🌍</text>
<defs>
<linearGradient id="loaderGrad" x1="0" y1="0" x2="80" y2="80">
<stop offset="0%" stopColor="#FF6B6B" />
<stop offset="100%" stopColor="#4ECDC4" />
</linearGradient>
</defs>
</svg>
<p className="loader-text">NomadFlow</p>
<div className="loader-bar"><div className="loader-progress" /></div>
</div>
</div>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/lib/auth";
const LINKS = [
{ href: "/#map", label: "🗺️ 地图", section: "map" },
{ href: "/#destinations", label: "🌍 目的地", section: "destinations" },
{ href: "/#trip", label: "🗓️ 行程", section: "trip" },
{ href: "/#timezone", label: "🕐 时区", section: "timezone" },
{ href: "/#calculator", label: "🧮 计算器", section: "calculator" },
{ href: "/#visa", label: "📋 签证", section: "visa" },
{ href: "/#blog", label: "📝 博客", section: "blog" },
];
export default function Navbar() {
const { user } = useAuth();
const pathname = usePathname();
const isHome = pathname === "/";
const [theme, setTheme] = useState<"dark" | "light">("dark");
const [menuOpen, setMenuOpen] = useState(false);
const [active, setActive] = useState("");
useEffect(() => {
const saved = localStorage.getItem("nomad-theme");
if (saved === "light") {
setTheme("light");
document.documentElement.setAttribute("data-theme", "light");
}
}, []);
useEffect(() => {
const navbar = document.getElementById("navbar");
const onScroll = () => navbar?.classList.toggle("scrolled", window.scrollY > 50);
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
if (!isHome) return;
const sections = LINKS.map((l) => document.getElementById(l.section)).filter(Boolean);
const observer = new IntersectionObserver(
(entries) => entries.forEach((e) => { if (e.isIntersecting) setActive(e.target.id); }),
{ threshold: 0.3, rootMargin: "-80px 0px -50% 0px" }
);
sections.forEach((s) => observer.observe(s!));
return () => observer.disconnect();
}, [isHome]);
const toggleTheme = () => {
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
if (next === "light") document.documentElement.setAttribute("data-theme", "light");
else document.documentElement.removeAttribute("data-theme");
localStorage.setItem("nomad-theme", next);
};
return (
<header className="navbar" id="navbar">
<div className="nav-container">
<Link href="/" className="logo">
<svg className="logo-icon" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="18" stroke="url(#logoGrad)" strokeWidth="2.5" />
<path d="M12 22c4-8 12-8 16 0" stroke="url(#logoGrad)" strokeWidth="2" strokeLinecap="round" />
<circle cx="28" cy="14" r="3" fill="url(#logoGrad)" />
<defs>
<linearGradient id="logoGrad" x1="0" y1="0" x2="40" y2="40">
<stop offset="0%" stopColor="#FF6B6B" />
<stop offset="50%" stopColor="#FFE66D" />
<stop offset="100%" stopColor="#4ECDC4" />
</linearGradient>
</defs>
</svg>
<span>NomadFlow</span>
</Link>
<nav className={`nav-links${menuOpen ? " open" : ""}`}>
{LINKS.map((l) => (
<Link key={l.section} href={l.href} data-section={l.section}
className={isHome && active === l.section ? "active" : ""}
onClick={() => setMenuOpen(false)}>{l.label}</Link>
))}
</nav>
<button className="nav-cta" onClick={toggleTheme} aria-label="切换主题">
<span className="theme-icon">{theme === "dark" ? "🌙" : "☀️"}</span>
</button>
{user ? (
<Link href="/profile" className="nav-user">
<span>{user.avatar}</span>
<span className="nav-user-name">{user.name}</span>
</Link>
) : (
<Link href="/login" className="nav-login-btn">登录</Link>
)}
<button className="mobile-menu-btn" onClick={() => setMenuOpen(!menuOpen)} aria-label="菜单">
<span /><span /><span />
</button>
</div>
</header>
);
}

View File

@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
const QUESTIONS = [
{
q: "你的远程工作收入是否稳定?",
options: [
{ label: "非常稳定,有固定客户/薪资", score: 25, emoji: "💪" },
{ label: "基本稳定,偶尔波动", score: 18, emoji: "👍" },
{ label: "还在起步阶段", score: 8, emoji: "🌱" },
],
},
{
q: "你的月预算大约多少?",
options: [
{ label: "¥5,000 以下", score: 20, emoji: "💰" },
{ label: "¥5,000 – 10,000", score: 22, emoji: "💳" },
{ label: "¥10,000 以上", score: 25, emoji: "💎" },
],
},
{
q: "你对签证/税务了解多少?",
options: [
{ label: "已研究清楚,有方案", score: 25, emoji: "📋" },
{ label: "了解基础,还需深入", score: 15, emoji: "📖" },
{ label: "完全不了解", score: 5, emoji: "❓" },
],
},
{
q: "你的英语/沟通能力如何?",
options: [
{ label: "流利,可无障碍交流", score: 25, emoji: "🗣️" },
{ label: "基础够用,能应对日常", score: 15, emoji: "💬" },
{ label: "需要提升", score: 8, emoji: "📚" },
],
},
{
q: "你愿意离开舒适区吗?",
options: [
{ label: "非常期待新体验", score: 25, emoji: "🚀" },
{ label: "愿意尝试,略有顾虑", score: 18, emoji: "🌤️" },
{ label: "更喜欢熟悉的环境", score: 8, emoji: "🏠" },
],
},
];
const LEVELS = [
{ min: 0, max: 40, label: "🌱 萌芽游民", desc: "建议先从短期旅居开始,选择清迈或巴厘岛积累经验", color: "#FF6B6B" },
{ min: 41, max: 70, label: "🌤️ 准游民", desc: "你已经具备基础条件,可以规划 3-6 个月的旅居试点", color: "#FFE66D" },
{ min: 71, max: 90, label: "🚀 就绪游民", desc: "条件成熟!可以开始规划你的环球旅居之旅了", color: "#4ECDC4" },
{ min: 91, max: 100, label: "👑 资深游民", desc: "你就是为这种生活而生的!随时可以出发", color: "#A78BFA" },
];
export default function NomadScore() {
const [step, setStep] = useState(0);
const [scores, setScores] = useState<number[]>([]);
const [done, setDone] = useState(false);
const [animating, setAnimating] = useState(false);
const totalScore = scores.reduce((a, b) => a + b, 0);
const maxScore = QUESTIONS.length * 25;
const pct = Math.round((totalScore / maxScore) * 100);
const level = LEVELS.find((l) => pct >= l.min && pct <= l.max) || LEVELS[LEVELS.length - 1];
const select = (score: number) => {
setAnimating(true);
const next = [...scores, score];
setScores(next);
setTimeout(() => {
setAnimating(false);
if (step < QUESTIONS.length - 1) setStep(step + 1);
else setDone(true);
}, 300);
};
const reset = () => {
setStep(0);
setScores([]);
setDone(false);
};
return (
<section className="section nomad-score-section" id="nomad-score">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">📊 READINESS</span>
<h2>游民就绪度测评</h2>
<p>5 个问题,测测你离数字游民生活还有多远</p>
</div>
<div className="nomad-score-card reveal">
{!done ? (
<div className={`nomad-quiz${animating ? " animating" : ""}`}>
<div className="nomad-quiz-progress">
{QUESTIONS.map((_, i) => (
<div key={i} className={`nomad-quiz-dot${i <= step ? " filled" : ""}${i === step ? " current" : ""}`} />
))}
</div>
<span className="nomad-quiz-step">问题 {step + 1} / {QUESTIONS.length}</span>
<h3>{QUESTIONS[step].q}</h3>
<div className="nomad-quiz-options">
{QUESTIONS[step].options.map((opt) => (
<button key={opt.label} className="nomad-quiz-option" onClick={() => select(opt.score)}>
<span>{opt.emoji}</span>
<span>{opt.label}</span>
</button>
))}
</div>
</div>
) : (
<div className="nomad-result">
<div className="nomad-score-ring" style={{ "--score-color": level.color } as React.CSSProperties}>
<svg viewBox="0 0 120 120">
<circle cx="60" cy="60" r="52" fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="8" />
<circle
cx="60" cy="60" r="52" fill="none"
stroke={level.color} strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${pct * 3.27} 327`}
transform="rotate(-90 60 60)"
className="nomad-score-arc"
/>
</svg>
<div className="nomad-score-center">
<strong>{pct}</strong>
<span>就绪度</span>
</div>
</div>
<h3 className="nomad-level-label">{level.label}</h3>
<p className="nomad-level-desc">{level.desc}</p>
<div className="nomad-result-actions">
<button className="btn btn-ghost" onClick={reset}>🔄 重新测评</button>
<a href="#destinations" className="btn btn-primary">探索目的地 🌍</a>
</div>
</div>
)}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,78 @@
"use client";
import { useEffect, useRef } from "react";
export default function ParticleCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animId: number;
let particles: {
x: number; y: number; size: number;
speedX: number; speedY: number; opacity: number; hue: number;
}[] = [];
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
const create = () => {
const count = Math.min(Math.floor(window.innerWidth / 12), 120);
particles = Array.from({ length: count }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
size: Math.random() * 2 + 0.5,
speedX: (Math.random() - 0.5) * 0.3,
speedY: (Math.random() - 0.5) * 0.3,
opacity: Math.random() * 0.5 + 0.1,
hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)],
}));
};
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach((p, i) => {
p.x += p.speedX;
p.y += p.speedY;
if (p.x < 0) p.x = canvas.width;
if (p.x > canvas.width) p.x = 0;
if (p.y < 0) p.y = canvas.height;
if (p.y > canvas.height) p.y = 0;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
ctx.fill();
particles.slice(i + 1).forEach((p2) => {
const dx = p.x - p2.x;
const dy = p.y - p2.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 120) {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.08 * (1 - dist / 120)})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
});
});
animId = requestAnimationFrame(draw);
};
resize();
create();
draw();
window.addEventListener("resize", () => { resize(); create(); });
return () => { cancelAnimationFrame(animId); };
}, []);
return <canvas id="particle-canvas" ref={canvasRef} aria-hidden="true" />;
}

View File

@ -0,0 +1,24 @@
"use client";
import { useEffect, useState } from "react";
export default function ScrollProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const onScroll = () => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
setProgress(docHeight > 0 ? Math.min((scrollTop / docHeight) * 100, 100) : 0);
};
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<div className="scroll-progress" aria-hidden="true">
<div className="scroll-progress-bar" style={{ width: `${progress}%` }} />
</div>
);
}

View File

@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
const SECTIONS = [
{ id: "map", emoji: "🗺️", label: "地图" },
{ id: "destinations", emoji: "🌍", label: "目的地" },
{ id: "timezone", emoji: "🕐", label: "时区" },
{ id: "trip", emoji: "🗓️", label: "行程" },
{ id: "calculator", emoji: "🧮", label: "计算器" },
{ id: "lifestyle", emoji: "💻", label: "生活" },
{ id: "visa", emoji: "📋", label: "签证" },
{ id: "blog", emoji: "📝", label: "博客" },
];
export default function SectionNav() {
const [active, setActive] = useState("");
const [visible, setVisible] = useState(false);
useEffect(() => {
const onScroll = () => setVisible(window.scrollY > 400);
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
const sections = SECTIONS.map((s) => document.getElementById(s.id)).filter(Boolean);
const observer = new IntersectionObserver(
(entries) => entries.forEach((e) => { if (e.isIntersecting) setActive(e.target.id); }),
{ threshold: 0.25, rootMargin: "-80px 0px -55% 0px" }
);
sections.forEach((s) => observer.observe(s!));
return () => observer.disconnect();
}, []);
const scrollTo = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
};
return (
<nav className={`section-nav${visible ? " visible" : ""}`} aria-label="章节导航">
{SECTIONS.map((s) => (
<button
key={s.id}
className={`section-nav-dot${active === s.id ? " active" : ""}`}
onClick={() => scrollTo(s.id)}
aria-label={s.label}
title={s.label}
>
<span className="section-nav-emoji">{s.emoji}</span>
<span className="section-nav-label">{s.label}</span>
</button>
))}
</nav>
);
}

View File

@ -0,0 +1,18 @@
"use client";
import Navbar from "./Navbar";
import ParticleCanvas from "./ParticleCanvas";
import Footer from "./Footer";
import GlobalSearch from "./GlobalSearch";
export default function SiteShell({ children, showFooter = false }: { children: React.ReactNode; showFooter?: boolean }) {
return (
<>
<ParticleCanvas />
<Navbar />
<GlobalSearch />
{children}
{showFooter && <Footer />}
</>
);
}

View File

@ -0,0 +1,12 @@
interface Props { messages: string[] }
export default function TickerBar({ messages }: Props) {
const items = [...messages, ...messages];
return (
<div className="ticker-bar">
<div className="ticker-track">
{items.map((msg, i) => <span key={i}>{msg}</span>)}
</div>
</div>
);
}

View File

@ -0,0 +1,176 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
const TZ_OFFSETS: Record<string, number> = {
bali: 8,
lisbon: 0,
chiangmai: 7,
mexico: -6,
barcelona: 1,
tokyo: 9,
};
const HOME_TZ = 8; // China UTC+8
const WORK_START = 9;
const WORK_END = 18;
interface Props {
destinations: Destination[];
}
function getLocalHour(utcOffset: number): number {
const now = new Date();
const utc = now.getUTCHours() + now.getUTCMinutes() / 60;
return Math.floor((utc + utcOffset + 24) % 24);
}
function formatTime(hour: number, min: number): string {
return `${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
}
function getOverlapHours(destOffset: number, homeStart: number, homeEnd: number): number {
let overlap = 0;
for (let h = homeStart; h < homeEnd; h++) {
const destHour = (h - HOME_TZ + destOffset + 24) % 24;
if (destHour >= 8 && destHour < 20) overlap++;
}
return overlap;
}
function overlapLabel(hours: number): { text: string; class: string } {
if (hours >= 7) return { text: "极佳", class: "excellent" };
if (hours >= 5) return { text: "良好", class: "good" };
if (hours >= 3) return { text: "一般", class: "fair" };
return { text: "困难", class: "poor" };
}
export default function TimezoneBoard({ destinations }: Props) {
const [now, setNow] = useState(new Date());
const [homeStart, setHomeStart] = useState(WORK_START);
const [homeEnd, setHomeEnd] = useState(WORK_END);
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
const cities = useMemo(() =>
destinations
.filter((d) => TZ_OFFSETS[d.slug] !== undefined)
.map((d) => {
const offset = TZ_OFFSETS[d.slug];
const localHour = getLocalHour(offset);
const overlap = getOverlapHours(offset, homeStart, homeEnd);
const label = overlapLabel(overlap);
const isWorkTime = localHour >= 9 && localHour < 18;
return { ...d, offset, localHour, overlap, label, isWorkTime };
})
.sort((a, b) => b.overlap - a.overlap),
[destinations, homeStart, homeEnd]);
const homeTime = formatTime(now.getHours(), now.getMinutes());
const homeSeconds = now.getSeconds();
return (
<section className="section timezone-section" id="timezone">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🕐 TIMEZONE</span>
<h2>时区工作看板</h2>
<p>实时查看各城市当地时间,评估与国内团队的工作时间重叠</p>
</div>
<div className="tz-home-clock reveal">
<div className="tz-home-time">
<span className="tz-live-dot" />
<span className="tz-home-label">🇨🇳 北京时间</span>
<span className="tz-home-digits">
{homeTime}<span className="tz-seconds">:{String(homeSeconds).padStart(2, "0")}</span>
</span>
</div>
<div className="tz-work-slider">
<label>⏰ 你的工作时间: {homeStart}:00 – {homeEnd}:00</label>
<div className="tz-slider-row">
<span>开始</span>
<input type="range" min={6} max={12} value={homeStart}
onChange={(e) => setHomeStart(Math.min(+e.target.value, homeEnd - 1))} />
<span>{homeStart}:00</span>
</div>
<div className="tz-slider-row">
<span>结束</span>
<input type="range" min={13} max={22} value={homeEnd}
onChange={(e) => setHomeEnd(Math.max(+e.target.value, homeStart + 1))} />
<span>{homeEnd}:00</span>
</div>
</div>
</div>
<div className="tz-grid reveal">
{cities.map((city) => {
const localMin = now.getUTCMinutes();
const localTime = formatTime(city.localHour, localMin);
const angle = ((city.localHour % 12) + localMin / 60) * 30;
return (
<div key={city.slug} className={`tz-card${city.isWorkTime ? " working" : ""}`}>
<div className="tz-card-header">
<span className="tz-card-emoji">{city.emoji}</span>
<div>
<strong>{city.name}</strong>
<span>UTC{city.offset >= 0 ? "+" : ""}{city.offset}</span>
</div>
<span className={`tz-overlap-badge ${city.label.class}`}>
{city.label.text}
</span>
</div>
<div className="tz-analog">
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="44" fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="2" />
{[0, 3, 6, 9].map((n) => (
<line key={n}
x1="50" y1="10" x2="50" y2="16"
stroke="rgba(255,255,255,0.15)" strokeWidth="1.5"
transform={`rotate(${n * 30} 50 50)`}
/>
))}
<line x1="50" y1="50" x2="50" y2="22"
stroke="url(#tzHand)" strokeWidth="2.5" strokeLinecap="round"
transform={`rotate(${angle} 50 50)`}
/>
<circle cx="50" cy="50" r="3" fill="#4ECDC4" />
</svg>
<span className="tz-digital">{localTime}</span>
</div>
<div className="tz-overlap-bar">
<div className="tz-overlap-track">
<div
className={`tz-overlap-fill ${city.label.class}`}
style={{ width: `${(city.overlap / Math.max(homeEnd - homeStart, 1)) * 100}%` }}
/>
</div>
<span>重叠 {city.overlap}h / {homeEnd - homeStart}h</span>
</div>
<div className="tz-status">
{city.isWorkTime ? "💼 当地工作时间" : "🌙 当地非工作时间"}
</div>
</div>
);
})}
</div>
<svg width="0" height="0">
<defs>
<linearGradient id="tzHand" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#FF6B6B" />
<stop offset="100%" stopColor="#4ECDC4" />
</linearGradient>
</defs>
</svg>
</div>
</section>
);
}

View File

@ -0,0 +1,29 @@
import type { Tool } from "@/lib/types";
const ICONS: Record<string, string> = {
work: "💼", travel: "✈️", finance: "💳", connect: "🤝", health: "🏥", learn: "📚",
};
export default function Tools({ tools }: { tools: Tool[] }) {
return (
<section className="section tools" id="tools">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🛠️ TOOLKIT</span>
<h2>游民必备工具箱</h2>
<p>精选远程工作与旅居生活的效率神器</p>
</div>
<div className="tools-grid">
{tools.map((t) => (
<div key={t.id} className="tool-card reveal" data-category={t.category}>
<span className="tool-emoji">{t.emoji || ICONS[t.category]}</span>
<h3>{t.name}</h3>
<p>{t.description}</p>
<div className="tool-tags">{t.tags.map((tag) => <span key={tag}>{tag}</span>)}</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,156 @@
"use client";
import { useEffect, useState } from "react";
import { useToast } from "@/lib/toast";
import type { Destination, TripItem } from "@/lib/types";
const STORAGE_KEY = "nomadflow-trip";
interface Props { destinations: Destination[] }
export default function TripPlanner({ destinations }: Props) {
const { toast } = useToast();
const [trip, setTrip] = useState<TripItem[]>([]);
const [selected, setSelected] = useState("");
const [months, setMonths] = useState(1);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const shared = params.get("trip");
if (shared) {
try {
const decoded = JSON.parse(atob(shared)) as TripItem[];
if (Array.isArray(decoded) && decoded.length > 0) {
setTrip(decoded);
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded));
toast("已导入分享的行程 🗺️");
window.history.replaceState({}, "", window.location.pathname + "#trip");
}
} catch { /* ignore */ }
return;
}
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) setTrip(JSON.parse(saved));
}, []);
const save = (items: TripItem[]) => {
setTrip(items);
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
};
const addCity = () => {
const dest = destinations.find((d) => d.slug === selected);
if (!dest || trip.some((t) => t.slug === dest.slug)) return;
save([...trip, {
slug: dest.slug, name: dest.name, country: dest.country,
emoji: dest.emoji, cost: dest.cost, months,
}]);
setSelected("");
setMonths(1);
};
const removeCity = (slug: string) => save(trip.filter((t) => t.slug !== slug));
const updateMonths = (slug: string, m: number) =>
save(trip.map((t) => t.slug === slug ? { ...t, months: m } : t));
const totalCost = trip.reduce((sum, t) => sum + t.cost * t.months, 0);
const totalMonths = trip.reduce((sum, t) => sum + t.months, 0);
const shareTrip = async () => {
const encoded = btoa(JSON.stringify(trip));
const url = `${window.location.origin}/?trip=${encoded}#trip`;
await navigator.clipboard.writeText(url);
toast("分享链接已复制!发送给好友即可导入行程 🔗");
};
const exportTrip = () => {
const text = trip.map((t, i) =>
`${i + 1}. ${t.emoji} ${t.name}, ${t.country} — ${t.months}个月 (¥${(t.cost * t.months).toLocaleString()})`
).join("\n") + `\n\n总计: ${totalMonths}个月 · ¥${totalCost.toLocaleString()}`;
navigator.clipboard.writeText(text);
toast("行程已复制到剪贴板 📋");
};
return (
<section className="section trip-section" id="trip">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🗓️ TRIP PLANNER</span>
<h2>行程规划器</h2>
<p>规划你的环球旅居路线,自动计算总预算</p>
</div>
<div className="trip-wrapper reveal">
<div className="trip-form">
<h3>➕ 添加城市</h3>
<div className="calc-field">
<label>🏙️ 选择目的地</label>
<select value={selected} onChange={(e) => setSelected(e.target.value)}>
<option value="">请选择...</option>
{destinations.filter((d) => !trip.some((t) => t.slug === d.slug)).map((d) => (
<option key={d.slug} value={d.slug}>{d.emoji} {d.name}, {d.country}</option>
))}
</select>
</div>
<div className="calc-field">
<label>📅 停留月数: {months} 个月</label>
<input type="range" min={1} max={12} value={months} onChange={(e) => setMonths(+e.target.value)} />
</div>
<button className="btn btn-primary" onClick={addCity} disabled={!selected}>
添加到行程 🚀
</button>
</div>
<div className="trip-timeline">
{trip.length === 0 ? (
<div className="trip-empty">
<span style={{ fontSize: "3rem" }}>🗺️</span>
<p>还没有添加城市,开始规划你的旅程吧</p>
</div>
) : (
<>
<div className="trip-route">
{trip.map((t, i) => (
<div key={t.slug} className="trip-stop">
<div className="trip-stop-dot">{t.emoji}</div>
{i < trip.length - 1 && <div className="trip-stop-line" />}
<div className="trip-stop-card">
<div className="trip-stop-header">
<strong>{t.name}, {t.country}</strong>
<button onClick={() => removeCity(t.slug)} className="trip-remove">✕</button>
</div>
<div className="trip-stop-controls">
<label>停留</label>
<input type="number" min={1} max={24} value={t.months}
onChange={(e) => updateMonths(t.slug, +e.target.value)} />
<span>个月</span>
<span className="trip-stop-cost">¥{(t.cost * t.months).toLocaleString()}</span>
</div>
</div>
</div>
))}
</div>
<div className="trip-summary">
<div className="trip-summary-stat">
<span>🗓️ 总时长</span>
<strong>{totalMonths} 个月</strong>
</div>
<div className="trip-summary-stat">
<span>💰 总预算</span>
<strong className="gradient-text">¥{totalCost.toLocaleString()}</strong>
</div>
<div className="trip-summary-stat">
<span>🏙️ 城市数</span>
<strong>{trip.length} 个</strong>
</div>
<button className="btn btn-ghost" onClick={exportTrip}>📋 复制行程</button>
<button className="btn btn-ghost" onClick={shareTrip}>🔗 分享行程</button>
<button className="btn btn-ghost" onClick={() => save([])}>🗑️ 清空</button>
</div>
</>
)}
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,81 @@
"use client";
import { useMemo, useState } from "react";
import type { Visa } from "@/lib/types";
const FILTERS = [
{ key: "all", label: "🌏 全部" },
{ key: "easy", label: "✅ 简单" },
{ key: "medium", label: "📋 中等" },
{ key: "budget", label: "💰 低成本" },
];
export default function VisaSection({ visas }: { visas: Visa[] }) {
const [filter, setFilter] = useState("all");
const filtered = useMemo(() => {
if (filter === "all") return visas;
if (filter === "easy") return visas.filter((v) => v.difficulty <= 35);
if (filter === "medium") return visas.filter((v) => v.difficulty > 35 && v.difficulty <= 55);
if (filter === "budget") return visas.filter((v) => v.badge_type === "budget");
return visas;
}, [visas, filter]);
return (
<section className="section visa-section" id="visa">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">📋 VISA GUIDE</span>
<h2>数字游民签证指南</h2>
<p>全球热门远程工作签证政策一览,助你合法旅居</p>
</div>
<div className="visa-filters reveal">
{FILTERS.map((f) => (
<button
key={f.key}
className={`filter-btn${filter === f.key ? " active" : ""}`}
onClick={() => setFilter(f.key)}
>
{f.label}
</button>
))}
</div>
<div className="visa-grid">
{filtered.map((v, i) => (
<div key={v.id} className="visa-card reveal visible" style={{ animationDelay: `${i * 0.08}s` }}>
<div className="visa-flag">{v.flag}</div>
<h3>{v.name}</h3>
<div className={`visa-badge ${v.badge_type}`}>{v.badge}</div>
<ul className="visa-details">
<li>📅 有效期:{v.duration}</li>
<li>💰 收入要求:{v.income_req}</li>
<li>⏱️ 审批:{v.approval_time}</li>
<li>{v.extra}</li>
</ul>
<div className="visa-progress">
<span>难度</span>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${v.difficulty}%`,
background: v.difficulty <= 35
? "linear-gradient(90deg, #4ECDC4, #6ee7b7)"
: v.difficulty <= 55
? "linear-gradient(90deg, #FFE66D, #ffa500)"
: "linear-gradient(90deg, #FF6B6B, #ff8a8a)",
}}
/>
</div>
<span className="progress-label">{v.difficulty_label}</span>
</div>
</div>
))}
</div>
{filtered.length === 0 && (
<p className="dest-empty reveal">暂无匹配的签证类型,试试其他筛选条件</p>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types";
interface Props { destinations: Destination[] }
export default function WorldMap({ destinations }: Props) {
const [selected, setSelected] = useState<Destination | null>(null);
return (
<section className="section map-section" id="map">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🗺️ WORLD MAP</span>
<h2>全球游民热力图</h2>
<p>点击地图上的标记,探索热门数字游民聚集地</p>
</div>
<div className="map-wrapper reveal">
<div className="map-svg-container">
<svg className="world-map" viewBox="0 0 1000 500">
<defs>
<radialGradient id="mapGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#4ECDC4" stopOpacity="0.15" />
<stop offset="100%" stopColor="#4ECDC4" stopOpacity="0" />
</radialGradient>
<filter id="pinGlow">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<rect width="1000" height="500" fill="url(#mapGlow)" rx="20" />
<g className="continents" opacity="0.25">
<path d="M180 120 Q220 100 280 110 Q320 130 310 170 Q290 200 250 210 Q200 220 170 190 Q150 160 180 120Z" fill="#4ECDC4" />
<path d="M420 100 Q480 90 530 110 Q560 140 550 180 Q520 210 470 200 Q430 180 420 140Z" fill="#4ECDC4" />
<path d="M530 130 Q600 120 660 140 Q700 170 690 210 Q650 240 590 230 Q540 210 530 170Z" fill="#4ECDC4" />
<path d="M700 160 Q780 150 830 180 Q860 220 840 260 Q790 280 730 260 Q690 230 700 190Z" fill="#4ECDC4" />
<path d="M200 260 Q250 250 290 280 Q310 320 280 360 Q240 380 200 360 Q170 330 180 290Z" fill="#4ECDC4" />
</g>
{destinations.map((d) => (
<g key={d.slug} className={`map-pin${selected?.slug === d.slug ? " active" : ""}`}
transform={`translate(${d.map_x}, ${d.map_y})`}
onClick={() => setSelected(d)} style={{ cursor: "pointer" }}>
<circle r="20" fill="rgba(255,107,107,0.15)" className="pin-pulse" />
<circle r="8" fill="#FF6B6B" filter="url(#pinGlow)" />
<text y="-16" textAnchor="middle" fontSize="14">{d.emoji}</text>
</g>
))}
</svg>
</div>
<div className="map-info-panel">
{selected ? (
<div className="map-info-city">
<div className="city-emoji">{selected.emoji}</div>
<h3>{selected.name}, {selected.country}</h3>
<span className="modal-tag">{selected.tag}</span>
<p className="city-desc">{selected.description}</p>
<div className="map-info-stats">
<div className="map-stat"><strong>¥{selected.cost.toLocaleString()}</strong>月生活费</div>
<div className="map-stat"><strong>{selected.speed}Mbps</strong>网速</div>
<div className="map-stat"><strong>{selected.temperature}°C</strong>均温</div>
<div className="map-stat"><strong>⭐ {selected.rating}</strong>评分</div>
</div>
<div className="map-info-actions">
<Link href={`/destinations/${selected.slug}`} className="btn btn-primary btn-sm">
查看详情 →
</Link>
<Link href={`/#trip`} className="btn btn-ghost btn-sm">🗓️ 加入行程</Link>
</div>
</div>
) : (
<div className="map-info-default">
<span className="map-info-icon">👆</span>
<h3>选择一个城市</h3>
<p>点击地图上的标记查看详细信息</p>
</div>
)}
</div>
</div>
</div>
</section>
);
}

93
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,93 @@
import type {
AuthResponse, BlogPost, BlogPostDetail, ChartData, CostResult, Destination, FAQ,
ProfileStats, SearchResult, Stats, Testimonial, Tool, Visa,
} from "./types";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
// Fallback data when API is unavailable (build time / offline)
const FALLBACK_STATS: Stats = { countries: 195, avg_cost: 42, satisfaction: 87, total_nomads: "35.6M", active_today: 127 };
const FALLBACK_TICKER = { messages: ["🌴 小林 刚刚抵达清迈", "💻 Marco 在里斯本完成了 Sprint", "✈️ 今日新增 127 位游民出发"] };
async function fetchAPI<T>(path: string, options?: RequestInit): Promise<T> {
const isMutation = options?.method && options.method !== "GET";
try {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: { "Content-Type": "application/json", ...options?.headers },
...(isMutation ? { cache: "no-store" } : { next: { revalidate: 60 } }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
} catch (err) {
if (isMutation) throw err;
throw err;
}
}
async function fetchWithFallback<T>(path: string, fallback: T): Promise<T> {
try {
return await fetchAPI<T>(path);
} catch {
return fallback;
}
}
export const api = {
getStats: () => fetchWithFallback("/stats", FALLBACK_STATS),
getTicker: () => fetchWithFallback("/ticker", FALLBACK_TICKER),
getDestinations: (params?: { region?: string; search?: string; sort?: string }) => {
const q = new URLSearchParams();
if (params?.region) q.set("region", params.region);
if (params?.search) q.set("search", params.search);
if (params?.sort) q.set("sort", params.sort);
const qs = q.toString();
return fetchAPI<Destination[]>(`/destinations${qs ? `?${qs}` : ""}`);
},
getDestination: (slug: string) => fetchAPI<Destination>(`/destinations/${slug}`),
compareDestinations: (slugs: string[]) =>
fetchAPI<Destination[]>("/destinations/compare", {
method: "POST",
body: JSON.stringify({ slugs }),
}),
getVisas: () => fetchWithFallback<Visa[]>("/visas", []),
getFaqs: () => fetchWithFallback<FAQ[]>("/faqs", []),
getTestimonials: () => fetchWithFallback<Testimonial[]>("/testimonials", []),
getTools: () => fetchWithFallback<Tool[]>("/tools", []),
getBlog: () => fetchWithFallback<BlogPost[]>("/blog", []),
getBlogPost: (slug: string) => fetchAPI<BlogPostDetail>(`/blog/${slug}`),
subscribe: (email: string) =>
fetchAPI<{ success: boolean; message: string }>("/subscribe", {
method: "POST",
body: JSON.stringify({ email }),
}),
calculateCost: (data: { destination_slug: string; housing: string; months: number }) =>
fetchAPI<CostResult>("/calculator", { method: "POST", body: JSON.stringify(data) }),
getChartCost: () => fetchWithFallback<ChartData>("/charts/cost", { labels: [], datasets: [{ data: [] }] }),
getChartSpeed: () => fetchWithFallback<ChartData>("/charts/speed", { labels: [], datasets: [{ data: [] }] }),
getChartGrowth: () => fetchWithFallback<ChartData>("/charts/growth", { labels: [], datasets: [{ data: [] }] }),
getChartRadar: () => fetchWithFallback<ChartData>("/charts/radar", { labels: [], datasets: [] }),
// Auth
login: (email: string, password: string) =>
fetchAPI<AuthResponse>("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }),
register: (email: string, password: string, name: string) =>
fetchAPI<AuthResponse>("/auth/register", { method: "POST", body: JSON.stringify({ email, password, name }) }),
demoLogin: () => fetchAPI<AuthResponse>("/auth/demo"),
getFavorites: (token: string) =>
fetchAPI<{ slugs: string[] }>("/auth/favorites", { headers: { Authorization: `Bearer ${token}` } }),
toggleFavorite: (slug: string, token: string) =>
fetchAPI<{ slugs: string[] }>("/auth/favorites", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ destination_slug: slug }),
}),
getFavoriteDestinations: (token: string) =>
fetchAPI<Destination[]>("/auth/favorites/detail", {
headers: { Authorization: `Bearer ${token}` },
}),
getProfileStats: (token: string) =>
fetchAPI<ProfileStats>("/auth/profile/stats", {
headers: { Authorization: `Bearer ${token}` },
}),
search: (q: string) => fetchAPI<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),
};

105
frontend/src/lib/auth.tsx Normal file
View File

@ -0,0 +1,105 @@
"use client";
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import { api } from "@/lib/api";
import type { UserProfile } from "@/lib/types";
interface AuthCtx {
user: UserProfile | null;
token: string | null;
favorites: string[];
login: (email: string, password: string) => Promise<boolean>;
register: (email: string, password: string, name: string) => Promise<boolean>;
demoLogin: () => Promise<boolean>;
logout: () => void;
toggleFavorite: (slug: string) => Promise<void>;
isFavorite: (slug: string) => boolean;
}
const AuthContext = createContext<AuthCtx | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<UserProfile | null>(null);
const [token, setToken] = useState<string | null>(null);
const [favorites, setFavorites] = useState<string[]>([]);
const loadFavorites = useCallback(async (t: string) => {
try {
const data = await api.getFavorites(t);
setFavorites(data.slugs);
} catch { /* ignore */ }
}, []);
useEffect(() => {
const saved = localStorage.getItem("nomad-token");
const savedUser = localStorage.getItem("nomad-user");
if (saved && savedUser) {
setToken(saved);
setUser(JSON.parse(savedUser));
loadFavorites(saved);
}
}, [loadFavorites]);
const persist = (t: string, u: UserProfile) => {
setToken(t);
setUser(u);
localStorage.setItem("nomad-token", t);
localStorage.setItem("nomad-user", JSON.stringify(u));
loadFavorites(t);
};
const login = async (email: string, password: string) => {
try {
const data = await api.login(email, password);
persist(data.token, data.user);
return true;
} catch { return false; }
};
const register = async (email: string, password: string, name: string) => {
try {
const data = await api.register(email, password, name);
persist(data.token, data.user);
return true;
} catch { return false; }
};
const demoLogin = async () => {
try {
const data = await api.demoLogin();
persist(data.token, data.user);
return true;
} catch { return false; }
};
const logout = () => {
setToken(null);
setUser(null);
setFavorites([]);
localStorage.removeItem("nomad-token");
localStorage.removeItem("nomad-user");
};
const toggleFavorite = async (slug: string) => {
if (!token) return;
try {
const data = await api.toggleFavorite(slug, token);
setFavorites(data.slugs);
} catch { /* ignore */ }
};
return (
<AuthContext.Provider value={{
user, token, favorites, login, register, demoLogin, logout,
toggleFavorite, isFavorite: (slug) => favorites.includes(slug),
}}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@ -0,0 +1,52 @@
"use client";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
type ToastType = "success" | "error" | "info";
interface ToastItem {
id: number;
message: string;
type: ToastType;
icon: string;
}
interface ToastContextValue {
toast: (message: string, type?: ToastType) => void;
}
const ICONS: Record<ToastType, string> = {
success: "✅",
error: "❌",
info: "💡",
};
const ToastContext = createContext<ToastContextValue>({ toast: () => {} });
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const toast = useCallback((message: string, type: ToastType = "success") => {
const id = Date.now() + Math.random();
setItems((prev) => [...prev, { id, message, type, icon: ICONS[type] }]);
setTimeout(() => setItems((prev) => prev.filter((t) => t.id !== id)), 3800);
}, []);
return (
<ToastContext.Provider value={{ toast }}>
{children}
<div className="toast-stack" aria-live="polite">
{items.map((t) => (
<div key={t.id} className={`toast toast-${t.type} show`}>
<span className="toast-icon">{t.icon}</span>
<span className="toast-msg">{t.message}</span>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
return useContext(ToastContext);
}

133
frontend/src/lib/types.ts Normal file
View File

@ -0,0 +1,133 @@
export interface Destination {
id: string;
slug: string;
name: string;
country: string;
emoji: string;
tag: string;
description: string;
region: string;
cost: number;
speed: number;
temperature: number;
rating: number;
hue: number;
nomads_count: string;
highlights: string[];
map_x: number;
map_y: number;
}
export interface Visa {
id: string;
country: string;
flag: string;
name: string;
badge: string;
badge_type: string;
duration: string;
income_req: string;
approval_time: string;
extra: string;
difficulty: number;
difficulty_label: string;
}
export interface FAQ {
id: string;
question: string;
answer: string;
order: number;
}
export interface Testimonial {
id: string;
avatar: string;
content: string;
author: string;
role: string;
rating: number;
}
export interface Tool {
id: string;
emoji: string;
name: string;
description: string;
tags: string[];
category: string;
}
export interface BlogPost {
id: string;
slug: string;
title: string;
excerpt: string;
emoji: string;
author: string;
published_at: string;
read_time: number;
tags: string[];
}
export interface BlogPostDetail extends BlogPost {
content: string;
}
export interface UserProfile {
id: string;
email: string;
name: string;
avatar: string;
}
export interface AuthResponse {
token: string;
user: UserProfile;
}
export interface ProfileStats {
favorites_count: number;
destinations_explored: number;
member_since: string;
nomad_level: string;
}
export interface Stats {
countries: number;
avg_cost: number;
satisfaction: number;
total_nomads: string;
active_today: number;
}
export interface CostResult {
destination: string;
emoji: string;
months: number;
breakdown: Record<string, number>;
total: number;
per_month: number;
}
export interface SearchResult {
type: string;
title: string;
subtitle: string;
emoji: string;
url: string;
}
export interface TripItem {
slug: string;
name: string;
country: string;
emoji: string;
cost: number;
months: number;
}
export interface ChartData {
labels: string[];
datasets: { label?: string; data: number[] }[];
}

34
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

7
frontend/vercel.json Normal file
View File

@ -0,0 +1,7 @@
{
"buildCommand": "npm run build",
"devCommand": "npm run dev",
"installCommand": "npm install",
"framework": "nextjs",
"regions": ["hkg1", "sin1"]
}

908
index.html Normal file
View File

@ -0,0 +1,908 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NomadFlow · 数字游民旅居指南</title>
<meta name="description" content="探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<!-- 加载动画 -->
<div class="loader" id="loader">
<div class="loader-inner">
<svg class="loader-globe" viewBox="0 0 80 80" fill="none">
<circle cx="40" cy="40" r="35" stroke="url(#loaderGrad)" stroke-width="3" stroke-dasharray="60 160" class="loader-ring"/>
<text x="40" y="48" text-anchor="middle" font-size="24">🌍</text>
<defs>
<linearGradient id="loaderGrad" x1="0" y1="0" x2="80" y2="80">
<stop offset="0%" stop-color="#FF6B6B"/>
<stop offset="100%" stop-color="#4ECDC4"/>
</linearGradient>
</defs>
</svg>
<p class="loader-text">NomadFlow</p>
<div class="loader-bar"><div class="loader-progress"></div></div>
</div>
</div>
<!-- 粒子背景 -->
<canvas id="particle-canvas" aria-hidden="true"></canvas>
<!-- 导航 -->
<header class="navbar" id="navbar">
<div class="nav-container">
<a href="#" class="logo">
<svg class="logo-icon" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="18" stroke="url(#logoGrad)" stroke-width="2.5"/>
<path d="M12 22c4-8 12-8 16 0" stroke="url(#logoGrad)" stroke-width="2" stroke-linecap="round"/>
<circle cx="28" cy="14" r="3" fill="url(#logoGrad)"/>
<defs>
<linearGradient id="logoGrad" x1="0" y1="0" x2="40" y2="40">
<stop offset="0%" stop-color="#FF6B6B"/>
<stop offset="50%" stop-color="#FFE66D"/>
<stop offset="100%" stop-color="#4ECDC4"/>
</linearGradient>
</defs>
</svg>
<span>NomadFlow</span>
</a>
<nav class="nav-links">
<a href="#map" data-section="map">🗺️ 地图</a>
<a href="#destinations" data-section="destinations">🌍 目的地</a>
<a href="#visa" data-section="visa">📋 签证</a>
<a href="#data" data-section="data">📊 数据</a>
<a href="#faq" data-section="faq">❓ FAQ</a>
<a href="#community" data-section="community">🤝 社区</a>
</nav>
<button class="nav-cta" id="theme-toggle" aria-label="切换主题">
<span class="theme-icon">🌙</span>
</button>
<button class="mobile-menu-btn" id="mobile-menu-btn" aria-label="菜单">
<span></span><span></span><span></span>
</button>
</div>
</header>
<!-- 实时滚动条 -->
<div class="ticker-bar">
<div class="ticker-track">
<span>🌴 小林 刚刚抵达清迈</span>
<span>💻 Marco 在里斯本完成了 Sprint</span>
<span>✈️ 今日新增 127 位游民出发</span>
<span>🏝️ 巴厘岛 Co-working 今日 89% 满座</span>
<span>📶 清迈平均网速 95Mbps</span>
<span>🌅 东京远程工作者满意度 9.1</span>
<span>🌴 小林 刚刚抵达清迈</span>
<span>💻 Marco 在里斯本完成了 Sprint</span>
<span>✈️ 今日新增 127 位游民出发</span>
<span>🏝️ 巴厘岛 Co-working 今日 89% 满座</span>
</div>
</div>
<!-- Hero -->
<section class="hero" id="hero">
<div class="hero-bg-shapes">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
<div class="shape shape-3"></div>
</div>
<div class="hero-content">
<div class="hero-badge reveal">
<span class="badge-dot"></span>
🚀 全球 35M+ 数字游民正在路上
</div>
<h1 class="hero-title reveal">
用一行代码<br/>
<span class="gradient-text">环游世界</span> 🌏
</h1>
<p class="hero-subtitle reveal">
<span id="typewriter"></span><span class="typewriter-cursor">|</span>
</p>
<div class="hero-actions reveal">
<a href="#destinations" class="btn btn-primary">
<span>探索目的地</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</a>
<a href="#data" class="btn btn-ghost">
<span>📈 查看数据</span>
</a>
</div>
<div class="hero-stats reveal">
<div class="stat-item">
<span class="stat-number" data-target="195">0</span>
<span class="stat-label">🗺️ 可探索国家</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-number" data-target="42">0</span>
<span class="stat-label">💰 平均月生活费 (千元)</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-number" data-target="87">0</span>
<span class="stat-label">😊 生活满意度 %</span>
</div>
</div>
</div>
<div class="hero-visual reveal">
<div class="globe-container">
<svg class="globe-svg" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="globeGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#4ECDC4" stop-opacity="0.3"/>
<stop offset="100%" stop-color="#4ECDC4" stop-opacity="0"/>
</radialGradient>
<linearGradient id="orbitGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FF6B6B"/>
<stop offset="100%" stop-color="#4ECDC4"/>
</linearGradient>
</defs>
<circle cx="200" cy="200" r="160" fill="url(#globeGlow)"/>
<circle cx="200" cy="200" r="120" fill="none" stroke="rgba(78,205,196,0.2)" stroke-width="1"/>
<circle cx="200" cy="200" r="90" fill="none" stroke="rgba(78,205,196,0.15)" stroke-width="1"/>
<ellipse cx="200" cy="200" rx="120" ry="40" fill="none" stroke="rgba(78,205,196,0.3)" stroke-width="1.5" class="orbit-ring"/>
<ellipse cx="200" cy="200" rx="40" ry="120" fill="none" stroke="rgba(255,107,107,0.3)" stroke-width="1.5" class="orbit-ring orbit-ring-2"/>
<!-- 大陆轮廓简化 -->
<path d="M160 140 Q180 130 200 135 Q220 128 240 140 Q250 155 245 170 Q235 185 220 190 Q200 195 185 188 Q165 180 160 165 Z" fill="rgba(78,205,196,0.4)" class="continent"/>
<path d="M130 210 Q145 200 160 215 Q155 235 140 240 Q125 235 130 210 Z" fill="rgba(78,205,196,0.3)" class="continent"/>
<path d="M250 200 Q270 195 280 210 Q275 230 260 235 Q245 228 250 200 Z" fill="rgba(78,205,196,0.35)" class="continent"/>
<!-- 游标点 -->
<g class="nomad-dots">
<circle cx="180" cy="155" r="5" fill="#FF6B6B" class="pulse-dot"/>
<circle cx="260" cy="215" r="5" fill="#FFE66D" class="pulse-dot"/>
<circle cx="145" cy="225" r="5" fill="#4ECDC4" class="pulse-dot"/>
<circle cx="220" cy="175" r="5" fill="#A78BFA" class="pulse-dot"/>
</g>
<!-- 飞行轨迹 -->
<path d="M180 155 Q220 140 260 215" fill="none" stroke="url(#orbitGrad)" stroke-width="2" stroke-dasharray="6 4" class="flight-path"/>
</svg>
<div class="floating-cards">
<div class="float-card fc-1">🏝️ 巴厘岛<br/><small>WiFi 98Mbps</small></div>
<div class="float-card fc-2">☕ 里斯本<br/><small>€800/月</small></div>
<div class="float-card fc-3">🏔️ 清迈<br/><small>⭐ 4.9</small></div>
</div>
</div>
</div>
<div class="scroll-indicator">
<span>向下滚动</span>
<div class="scroll-arrow">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
</div>
</div>
</section>
<!-- 交互地图 -->
<section class="section map-section" id="map">
<div class="container">
<div class="section-header reveal">
<span class="section-tag">🗺️ WORLD MAP</span>
<h2>全球游民热力图</h2>
<p>点击地图上的标记,探索热门数字游民聚集地</p>
</div>
<div class="map-wrapper reveal">
<div class="map-svg-container">
<svg class="world-map" viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="mapGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#4ECDC4" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#4ECDC4" stop-opacity="0"/>
</radialGradient>
<filter id="pinGlow">
<feGaussianBlur stdDeviation="3" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<rect width="1000" height="500" fill="url(#mapGlow)" rx="20"/>
<!-- 简化大陆 -->
<g class="continents" opacity="0.25">
<path d="M180 120 Q220 100 280 110 Q320 130 310 170 Q290 200 250 210 Q200 220 170 190 Q150 160 180 120Z" fill="#4ECDC4"/>
<path d="M420 100 Q480 90 530 110 Q560 140 550 180 Q520 210 470 200 Q430 180 420 140Z" fill="#4ECDC4"/>
<path d="M530 130 Q600 120 660 140 Q700 170 690 210 Q650 240 590 230 Q540 210 530 170Z" fill="#4ECDC4"/>
<path d="M700 160 Q780 150 830 180 Q860 220 840 260 Q790 280 730 260 Q690 230 700 190Z" fill="#4ECDC4"/>
<path d="M780 280 Q830 270 870 300 Q890 340 860 370 Q820 390 780 370 Q750 340 760 310Z" fill="#4ECDC4"/>
<path d="M200 260 Q250 250 290 280 Q310 320 280 360 Q240 380 200 360 Q170 330 180 290Z" fill="#4ECDC4"/>
<path d="M300 300 Q360 290 400 320 Q420 360 390 400 Q340 420 300 390 Q270 360 280 330Z" fill="#4ECDC4"/>
</g>
<!-- 网格线 -->
<g class="map-grid" opacity="0.08">
<line x1="0" y1="125" x2="1000" y2="125" stroke="#4ECDC4"/>
<line x1="0" y1="250" x2="1000" y2="250" stroke="#4ECDC4"/>
<line x1="0" y1="375" x2="1000" y2="375" stroke="#4ECDC4"/>
<line x1="250" y1="0" x2="250" y2="500" stroke="#4ECDC4"/>
<line x1="500" y1="0" x2="500" y2="500" stroke="#4ECDC4"/>
<line x1="750" y1="0" x2="750" y2="500" stroke="#4ECDC4"/>
</g>
<!-- 交互标记 -->
<g class="map-pin" data-city="bali" transform="translate(720, 310)">
<circle r="20" fill="rgba(255,107,107,0.15)" class="pin-pulse"/>
<circle r="8" fill="#FF6B6B" filter="url(#pinGlow)"/>
<text y="-16" text-anchor="middle" font-size="14">🏝️</text>
</g>
<g class="map-pin" data-city="lisbon" transform="translate(430, 195)">
<circle r="20" fill="rgba(78,205,196,0.15)" class="pin-pulse"/>
<circle r="8" fill="#4ECDC4" filter="url(#pinGlow)"/>
<text y="-16" text-anchor="middle" font-size="14">🌊</text>
</g>
<g class="map-pin" data-city="chiangmai" transform="translate(700, 240)">
<circle r="20" fill="rgba(255,230,109,0.15)" class="pin-pulse"/>
<circle r="8" fill="#FFE66D" filter="url(#pinGlow)"/>
<text y="-16" text-anchor="middle" font-size="14">🏔️</text>
</g>
<g class="map-pin" data-city="mexico" transform="translate(220, 240)">
<circle r="20" fill="rgba(167,139,250,0.15)" class="pin-pulse"/>
<circle r="8" fill="#A78BFA" filter="url(#pinGlow)"/>
<text y="-16" text-anchor="middle" font-size="14">🌃</text>
</g>
<g class="map-pin" data-city="barcelona" transform="translate(460, 200)">
<circle r="20" fill="rgba(244,114,182,0.15)" class="pin-pulse"/>
<circle r="8" fill="#F472B6" filter="url(#pinGlow)"/>
<text y="-16" text-anchor="middle" font-size="14">🏖️</text>
</g>
<g class="map-pin" data-city="tokyo" transform="translate(820, 210)">
<circle r="20" fill="rgba(96,165,250,0.15)" class="pin-pulse"/>
<circle r="8" fill="#60A5FA" filter="url(#pinGlow)"/>
<text y="-16" text-anchor="middle" font-size="14">🗼</text>
</g>
</svg>
</div>
<div class="map-info-panel" id="map-info-panel">
<div class="map-info-default">
<span class="map-info-icon">👆</span>
<h3>选择一个城市</h3>
<p>点击地图上的标记查看详细信息</p>
</div>
</div>
</div>
</div>
</section>
<!-- 目的地 -->
<section class="section destinations" id="destinations">
<div class="container">
<div class="section-header reveal">
<span class="section-tag">🌴 TOP DESTINATIONS</span>
<h2>热门旅居目的地</h2>
<p>精选全球最适合远程工作的城市,每一站都是一段新的故事</p>
</div>
<div class="dest-filters reveal">
<div class="filter-search">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" id="dest-search" placeholder="搜索城市..." />
</div>
<div class="filter-tags">
<button class="filter-btn active" data-filter="all">🌏 全部</button>
<button class="filter-btn" data-filter="sea">🌴 东南亚</button>
<button class="filter-btn" data-filter="europe">🏰 欧洲</button>
<button class="filter-btn" data-filter="latam">🌮 拉美</button>
<button class="filter-btn" data-filter="asia">🏯 东亚</button>
</div>
<div class="filter-sort">
<select id="dest-sort">
<option value="rating">⭐ 评分排序</option>
<option value="cost-asc">💰 价格从低到高</option>
<option value="cost-desc">💰 价格从高到低</option>
<option value="speed">📶 网速排序</option>
</select>
</div>
</div>
<div class="dest-grid" id="dest-grid">
<article class="dest-card reveal" data-tilt data-id="bali" data-region="sea" data-cost="4500" data-speed="85" data-rating="9.2">
<div class="dest-img" style="--hue: 170">
<div class="dest-emoji">🏝️</div>
<div class="dest-overlay">
<span class="dest-tag">东南亚 · 热带天堂</span>
</div>
</div>
<div class="dest-body">
<h3>巴厘岛, 印尼</h3>
<p>乌布的数字游民社区闻名全球,低成本高品质生活的典范</p>
<div class="dest-meta">
<span>💰 ¥4,500/月</span>
<span>📶 85Mbps</span>
<span>🌡️ 28°C</span>
</div>
<div class="dest-rating">
<span class="stars">⭐⭐⭐⭐⭐</span>
<span>9.2 分</span>
</div>
</div>
</article>
<article class="dest-card reveal" data-tilt data-id="lisbon" data-region="europe" data-cost="9000" data-speed="120" data-rating="9.5">
<div class="dest-img" style="--hue: 220">
<div class="dest-emoji">🌊</div>
<div class="dest-overlay">
<span class="dest-tag">欧洲 · 海滨明珠</span>
</div>
</div>
<div class="dest-body">
<h3>里斯本, 葡萄牙</h3>
<p>D7签证友好,阳光海岸与悠久历史的完美融合</p>
<div class="dest-meta">
<span>💰 ¥9,000/月</span>
<span>📶 120Mbps</span>
<span>🌡️ 22°C</span>
</div>
<div class="dest-rating">
<span class="stars">⭐⭐⭐⭐⭐</span>
<span>9.5 分</span>
</div>
</div>
</article>
<article class="dest-card reveal" data-tilt data-id="chiangmai" data-region="sea" data-cost="3800" data-speed="95" data-rating="9.4">
<div class="dest-img" style="--hue: 45">
<div class="dest-emoji">🏔️</div>
<div class="dest-overlay">
<span class="dest-tag">东南亚 · 文化古城</span>
</div>
</div>
<div class="dest-body">
<h3>清迈, 泰国</h3>
<p>数字游民大本营,咖啡文化与夜市生活的天堂</p>
<div class="dest-meta">
<span>💰 ¥3,800/月</span>
<span>📶 95Mbps</span>
<span>🌡️ 30°C</span>
</div>
<div class="dest-rating">
<span class="stars">⭐⭐⭐⭐⭐</span>
<span>9.4 分</span>
</div>
</div>
</article>
<article class="dest-card reveal" data-tilt data-id="mexico" data-region="latam" data-cost="6500" data-speed="75" data-rating="8.8">
<div class="dest-img" style="--hue: 300">
<div class="dest-emoji">🌃</div>
<div class="dest-overlay">
<span class="dest-tag">拉美 · 活力之都</span>
</div>
</div>
<div class="dest-body">
<h3>墨西哥城, 墨西哥</h3>
<p>艺术、美食与科技交织,时区便利对接北美市场</p>
<div class="dest-meta">
<span>💰 ¥6,500/月</span>
<span>📶 75Mbps</span>
<span>🌡️ 18°C</span>
</div>
<div class="dest-rating">
<span class="stars">⭐⭐⭐⭐</span>
<span>8.8 分</span>
</div>
</div>
</article>
<article class="dest-card reveal" data-tilt data-id="barcelona" data-region="europe" data-cost="10500" data-speed="150" data-rating="9.1">
<div class="dest-img" style="--hue: 130">
<div class="dest-emoji">🏖️</div>
<div class="dest-overlay">
<span class="dest-tag">欧洲 · 地中海</span>
</div>
</div>
<div class="dest-body">
<h3>巴塞罗那, 西班牙</h3>
<p>高迪建筑与创业生态并存,Nomad Visa 政策领先</p>
<div class="dest-meta">
<span>💰 ¥10,500/月</span>
<span>📶 150Mbps</span>
<span>🌡️ 20°C</span>
</div>
<div class="dest-rating">
<span class="stars">⭐⭐⭐⭐⭐</span>
<span>9.1 分</span>
</div>
</div>
</article>
<article class="dest-card reveal" data-tilt data-id="tokyo" data-region="asia" data-cost="12000" data-speed="200" data-rating="8.6">
<div class="dest-img" style="--hue: 10">
<div class="dest-emoji">🗼</div>
<div class="dest-overlay">
<span class="dest-tag">亚洲 · 现代都市</span>
</div>
</div>
<div class="dest-body">
<h3>东京, 日本</h3>
<p>极致效率与安全,适合追求高品质生活的远程工作者</p>
<div class="dest-meta">
<span>💰 ¥12,000/月</span>
<span>📶 200Mbps</span>
<span>🌡️ 15°C</span>
</div>
<div class="dest-rating">
<span class="stars">⭐⭐⭐⭐</span>
<span>8.6 分</span>
</div>
</div>
</article>
</div>
<p class="dest-empty" id="dest-empty" hidden>😢 没有找到匹配的目的地,试试其他筛选条件</p>
</div>
</section>
<!-- 生活方式 -->
<section class="section lifestyle" id="lifestyle">
<div class="container">
<div class="section-header reveal">
<span class="section-tag">💻 NOMAD LIFE</span>
<h2>数字游民的一天</h2>
<p>自由不等于散漫,高效工作才能尽情探索</p>
</div>
<div class="timeline">
<div class="timeline-line">
<svg viewBox="0 0 4 600" preserveAspectRatio="none">
<line x1="2" y1="0" x2="2" y2="600" stroke="url(#timelineGrad)" stroke-width="3" stroke-dasharray="8 6" class="timeline-svg-line"/>
<defs>
<linearGradient id="timelineGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#FF6B6B"/>
<stop offset="50%" stop-color="#FFE66D"/>
<stop offset="100%" stop-color="#4ECDC4"/>
</linearGradient>
</defs>
</svg>
</div>
<div class="timeline-item reveal">
<div class="timeline-time">🌅 07:00</div>
<div class="timeline-card">
<div class="timeline-icon">🧘</div>
<h3>晨间仪式</h3>
<p>瑜伽、冥想或海边跑步,用身心唤醒开启新的一天</p>
</div>
</div>
<div class="timeline-item reveal">
<div class="timeline-time">☕ 09:00</div>
<div class="timeline-card">
<div class="timeline-icon">💻</div>
<h3>深度工作</h3>
<p>在 Co-working Space 或咖啡馆专注 4 小时,完成核心任务</p>
</div>
</div>
<div class="timeline-item reveal">
<div class="timeline-time">🍜 13:00</div>
<div class="timeline-card">
<div class="timeline-icon">🍽️</div>
<h3>本地探索</h3>
<p>品尝地道美食,与当地人交流,感受文化碰撞</p>
</div>
</div>
<div class="timeline-item reveal">
<div class="timeline-time">🌇 17:00</div>
<div class="timeline-card">
<div class="timeline-icon">📞</div>
<h3>跨时区协作</h3>
<p>与全球团队视频会议,灵活安排跨时区沟通</p>
</div>
</div>
<div class="timeline-item reveal">
<div class="timeline-time">🌙 20:00</div>
<div class="timeline-card">
<div class="timeline-icon">🎉</div>
<h3>社群社交</h3>
<p>参加 Nomad Meetup,结识来自世界各地的同行者</p>
</div>
</div>
</div>
</div>
</section>
<!-- 数据洞察 -->
<section class="section data-section" id="data">
<div class="container">
<div class="section-header reveal">
<span class="section-tag">📊 DATA INSIGHTS</span>
<h2>旅居数据洞察</h2>
<p>用数据帮你做出更明智的目的地选择</p>
</div>
<div class="charts-grid">
<div class="chart-card reveal">
<h3>💰 月度生活成本对比</h3>
<div class="chart-wrap">
<canvas id="costChart"></canvas>
</div>
</div>
<div class="chart-card reveal">
<h3>📶 网络速度排行</h3>
<div class="chart-wrap">
<canvas id="speedChart"></canvas>
</div>
</div>
<div class="chart-card chart-wide reveal">
<h3>📈 数字游民增长趋势 (2019-2026)</h3>
<div class="chart-wrap">
<canvas id="growthChart"></canvas>
</div>
</div>
<div class="chart-card reveal">
<h3>🎯 选择因素权重</h3>
<div class="chart-wrap chart-radar">
<canvas id="radarChart"></canvas>
</div>
</div>
</div>
</div>
</section>
<!-- 签证指南 -->
<section class="section visa-section" id="visa">
<div class="container">
<div class="section-header reveal">
<span class="section-tag">📋 VISA GUIDE</span>
<h2>数字游民签证指南</h2>
<p>全球热门远程工作签证政策一览,助你合法旅居</p>
</div>
<div class="visa-grid">
<div class="visa-card reveal">
<div class="visa-flag">🇵🇹</div>
<h3>葡萄牙 D7 签证</h3>
<div class="visa-badge easy">⭐ 推荐</div>
<ul class="visa-details">
<li>📅 有效期:2年,可续签</li>
<li>💰 收入要求:€760/月</li>
<li>⏱️ 审批:3-6 个月</li>
<li>🏥 可享欧盟医疗</li>
</ul>
<div class="visa-progress">
<span>难度</span>
<div class="progress-bar"><div class="progress-fill" style="width:35%"></div></div>
<span class="progress-label">简单</span>
</div>
</div>
<div class="visa-card reveal">
<div class="visa-flag">🇪🇸</div>
<h3>西班牙 Nomad Visa</h3>
<div class="visa-badge hot">🔥 热门</div>
<ul class="visa-details">
<li>📅 有效期:1年,可续3年</li>
<li>💰 收入要求:€2,160/月</li>
<li>⏱️ 审批:1-3 个月</li>
<li>🌍 可申根区旅行</li>
</ul>
<div class="visa-progress">
<span>难度</span>
<div class="progress-bar"><div class="progress-fill" style="width:45%"></div></div>
<span class="progress-label">中等</span>
</div>
</div>
<div class="visa-card reveal">
<div class="visa-flag">🇮🇩</div>
<h3>印尼 B211A 签证</h3>
<div class="visa-badge budget">💰 低成本</div>
<ul class="visa-details">
<li>📅 有效期:60天,可延期</li>
<li>💰 费用:约 ¥2,000</li>
<li>⏱️ 审批:5-10 天</li>
<li>🏝️ 适合巴厘岛旅居</li>
</ul>
<div class="visa-progress">
<span>难度</span>
<div class="progress-bar"><div class="progress-fill" style="width:25%"></div></div>
<span class="progress-label">简单</span>
</div>
</div>
<div class="visa-card reveal">
<div class="visa-flag">🇹🇭</div>
<h3>泰国 LTR 签证</h3>
<div class="visa-badge new">🆕 新政策</div>
<ul class="visa-details">
<li>📅 有效期:10年</li>
<li>💰 收入要求:$80,000/年</li>
<li>⏱️ 审批:1-2 个月</li>
<li>✈️ 多次入境</li>
</ul>
<div class="visa-progress">
<span>难度</span>
<div class="progress-bar"><div class="progress-fill" style="width:60%"></div></div>
<span class="progress-label">中等</span>
</div>
</div>
<div class="visa-card reveal">
<div class="visa-flag">🇲🇽</div>
<h3>墨西哥 Temporary Resident</h3>
<div class="visa-badge budget">💰 低成本</div>
<ul class="visa-details">
<li>📅 有效期:1-4年</li>
<li>💰 收入要求:$2,500/月</li>
<li>⏱️ 审批:2-4 周</li>
<li>🌮 北美时区友好</li>
</ul>
<div class="visa-progress">
<span>难度</span>
<div class="progress-bar"><div class="progress-fill" style="width:30%"></div></div>
<span class="progress-label">简单</span>
</div>
</div>
<div class="visa-card reveal">
<div class="visa-flag">🇪🇪</div>
<h3>爱沙尼亚 DNV</h3>
<div class="visa-badge pioneer">🚀 先锋</div>
<ul class="visa-details">
<li>📅 有效期:1年</li>
<li>💰 收入要求:€3,504/月</li>
<li>⏱️ 审批:2-4 周</li>
<li>💻 全球首个数字游民签证</li>
</ul>
<div class="visa-progress">
<span>难度</span>
<div class="progress-bar"><div class="progress-fill" style="width:40%"></div></div>
<span class="progress-label">中等</span>
</div>
</div>
</div>
</div>
</section>
<!-- 工具箱 -->
<section class="section tools" id="tools">
<div class="container">
<div class="section-header reveal">
<span class="section-tag">🛠️ TOOLKIT</span>
<h2>游民必备工具箱</h2>
<p>精选远程工作与旅居生活的效率神器</p>
</div>
<div class="tools-grid">
<div class="tool-card reveal" data-category="work">
<div class="tool-icon-wrap">
<svg viewBox="0 0 48 48" fill="none"><rect x="6" y="10" width="36" height="26" rx="3" stroke="currentColor" stroke-width="2"/><path d="M6 18h36" stroke="currentColor" stroke-width="2"/><circle cx="24" cy="30" r="3" fill="currentColor"/></svg>
</div>
<span class="tool-emoji">💼</span>
<h3>远程协作</h3>
<p>Slack · Notion · Figma · Zoom</p>
<div class="tool-tags">
<span>团队</span><span>设计</span><span>沟通</span>
</div>
</div>
<div class="tool-card reveal" data-category="travel">
<div class="tool-icon-wrap">
<svg viewBox="0 0 48 48" fill="none"><path d="M24 4L8 14v20l16 10 16-10V14L24 4z" stroke="currentColor" stroke-width="2"/><path d="M8 14l16 10 16-10M24 24v20" stroke="currentColor" stroke-width="2"/></svg>
</div>
<span class="tool-emoji">✈️</span>
<h3>旅行规划</h3>
<p>Skyscanner · Nomad List · SafetyWing</p>
<div class="tool-tags">
<span>机票</span><span>签证</span><span>保险</span>
</div>
</div>
<div class="tool-card reveal" data-category="finance">
<div class="tool-icon-wrap">
<svg viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="18" stroke="currentColor" stroke-width="2"/><path d="M24 14v20M18 20h10a4 4 0 010 8h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</div>
<span class="tool-emoji">💳</span>
<h3>财务管理</h3>
<p>Wise · Revolut · Xero · 多币种账户</p>
<div class="tool-tags">
<span>汇款</span><span>记账</span><span>税务</span>
</div>
</div>
<div class="tool-card reveal" data-category="connect">
<div class="tool-icon-wrap">
<svg viewBox="0 0 48 48" fill="none"><circle cx="16" cy="20" r="6" stroke="currentColor" stroke-width="2"/><circle cx="32" cy="20" r="6" stroke="currentColor" stroke-width="2"/><path d="M8 38c0-6 4-10 8-10s8 4 8 10M24 38c0-6 4-10 8-10s8 4 8 10" stroke="currentColor" stroke-width="2"/></svg>
</div>
<span class="tool-emoji">🤝</span>
<h3>社群网络</h3>
<p>Nomad List · Remote Year · 本地 Meetup</p>
<div class="tool-tags">
<span>社交</span><span>活动</span><span>合租</span>
</div>
</div>
<div class="tool-card reveal" data-category="health">
<div class="tool-icon-wrap">
<svg viewBox="0 0 48 48" fill="none"><path d="M24 8v32M8 24h32" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/><circle cx="24" cy="24" r="18" stroke="currentColor" stroke-width="2"/></svg>
</div>
<span class="tool-emoji">🏥</span>
<h3>健康保障</h3>
<p>SafetyWing · World Nomads · 运动 App</p>
<div class="tool-tags">
<span>保险</span><span>健身</span><span>心理</span>
</div>
</div>
<div class="tool-card reveal" data-category="learn">
<div class="tool-icon-wrap">
<svg viewBox="0 0 48 48" fill="none"><path d="M6 16l18-8 18 8-18 8-18-8z" stroke="currentColor" stroke-width="2"/><path d="M12 22v10c0 4 6 8 12 8s12-4 12-8V22" stroke="currentColor" stroke-width="2"/></svg>
</div>
<span class="tool-emoji">📚</span>
<h3>持续学习</h3>
<p>Coursera · Duolingo · 当地语言班</p>
<div class="tool-tags">
<span>技能</span><span>语言</span><span>文化</span>
</div>
</div>
</div>
</div>
</section>
<!-- FAQ -->
<section class="section faq-section" id="faq">
<div class="container">
<div class="section-header reveal">
<span class="section-tag">❓ FAQ</span>
<h2>常见问题</h2>
<p>关于数字游民生活,你可能想知道的一切</p>
</div>
<div class="faq-list">
<div class="faq-item reveal">
<button class="faq-question">
<span>💰 做数字游民需要多少启动资金?</span>
<svg class="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="faq-answer">
<p>建议准备 3-6 个月的生活费作为缓冲。以东南亚为例,¥15,000-30,000 即可开始。包括机票、首月住宿、签证费用和应急资金。欧洲目的地建议准备 ¥50,000 以上。</p>
</div>
</div>
<div class="faq-item reveal">
<button class="faq-question">
<span>📶 如何确保远程工作的网络稳定?</span>
<svg class="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="faq-answer">
<p>选择网络评分高的城市,入住前用 Speedtest 测试。备用方案:本地 SIM 卡热点、随身 WiFi 设备、附近 Co-working Space。推荐携带 USB 网卡和 VPN 作为双保险。</p>
</div>
</div>
<div class="faq-item reveal">
<button class="faq-question">
<span>🏥 旅居期间的保险怎么办?</span>
<svg class="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="faq-answer">
<p>推荐 SafetyWing 或 World Nomads 等国际医疗保险,月费约 $40-80,覆盖全球(部分国家除外)。长期旅居者可考虑目的地国家的本地保险,费用更低、报销更方便。</p>
</div>
</div>
<div class="faq-item reveal">
<button class="faq-question">
<span>🧾 税务问题如何处理?</span>
<svg class="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="faq-answer">
<p>税务居民身份取决于居住天数(通常 183 天规则)。建议咨询专业税务顾问,了解双重征税协定。很多游民选择税务友好的国家(如葡萄牙、格鲁吉亚)作为基地。</p>
</div>
</div>
<div class="faq-item reveal">
<button class="faq-question">
<span>👨‍👩‍👧 可以带娃一起做数字游民吗?</span>
<svg class="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="faq-answer">
<p>完全可以!巴厘岛、清迈、里斯本都有成熟的数字游民家庭社区。关键是选择教育资源丰富、医疗条件好的目的地,以及保持稳定的工作节奏,给孩子规律的生活。</p>
</div>
</div>
<div class="faq-item reveal">
<button class="faq-question">
<span>🤝 如何快速融入当地游民社区?</span>
<svg class="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="faq-answer">
<p>加入 Nomad List、Facebook 群组和本地 Meetup 活动。入住游民友好的 Co-living 空间,参加每周的 Coworking 社交日。大部分游民社区非常开放,主动打招呼就能结识朋友。</p>
</div>
</div>
</div>
</div>
</section>
<!-- 社区 -->
<section class="section community" id="community">
<div class="container">
<div class="section-header reveal">
<span class="section-tag">🤝 COMMUNITY</span>
<h2>游民心声</h2>
<p>来自全球数字游民的真实故事</p>
</div>
<div class="testimonials">
<div class="testimonial-card reveal">
<div class="testimonial-avatar">👩‍💻</div>
<div class="testimonial-content">
<div class="testimonial-stars">⭐⭐⭐⭐⭐</div>
<p>"在清迈住了 8 个月,月花费不到 4000 元,但生活质量比国内一线城市高太多了。每天早上骑摩托去咖啡馆,这种感觉无法形容。"</p>
<div class="testimonial-author">
<strong>小林</strong>
<span>前端开发 · 清迈 🇹🇭</span>
</div>
</div>
</div>
<div class="testimonial-card reveal">
<div class="testimonial-avatar">👨‍🎨</div>
<div class="testimonial-content">
<div class="testimonial-stars">⭐⭐⭐⭐⭐</div>
<p>"里斯本的 D7 签证让我在欧洲有了基地。白天在 Alfama 区的共享办公空间工作,周末去 Sintra 徒步,完美平衡。"</p>
<div class="testimonial-author">
<strong>Marco</strong>
<span>UI 设计师 · 里斯本 🇵🇹</span>
</div>
</div>
</div>
<div class="testimonial-card reveal">
<div class="testimonial-avatar">🧑‍💼</div>
<div class="testimonial-content">
<div class="testimonial-stars">⭐⭐⭐⭐⭐</div>
<p>"带着家人做数字游民听起来疯狂,但在巴厘岛乌布,孩子们上国际学校,我和妻子远程工作,这是我们做过最正确的决定。"</p>
<div class="testimonial-author">
<strong>张家</strong>
<span>产品经理 · 巴厘岛 🇮🇩</span>
</div>
</div>
</div>
</div>
<div class="cta-banner reveal">
<div class="cta-content">
<h2>准备好开始你的旅居之旅了吗? 🌍</h2>
<p>加入 50,000+ 数字游民社区,获取目的地指南、签证攻略与独家优惠</p>
</div>
<form class="cta-form" id="subscribe-form">
<input type="email" placeholder="输入你的邮箱 📧" required />
<button type="submit" class="btn btn-primary">
<span>免费订阅</span>
<span>🚀</span>
</button>
</form>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<a href="#" class="logo">
<svg class="logo-icon" viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#logoGrad2)" stroke-width="2.5"/><path d="M12 22c4-8 12-8 16 0" stroke="url(#logoGrad2)" stroke-width="2" stroke-linecap="round"/><circle cx="28" cy="14" r="3" fill="url(#logoGrad2)"/><defs><linearGradient id="logoGrad2" x1="0" y1="0" x2="40" y2="40"><stop offset="0%" stop-color="#FF6B6B"/><stop offset="100%" stop-color="#4ECDC4"/></linearGradient></defs></svg>
<span>NomadFlow</span>
</a>
<p>让每个人都能自由地工作和生活在这个星球上 🌏</p>
<div class="social-links">
<a href="#" aria-label="Twitter">𝕏</a>
<a href="#" aria-label="Instagram">📷</a>
<a href="#" aria-label="YouTube">▶️</a>
<a href="#" aria-label="Discord">💬</a>
</div>
</div>
<div class="footer-links">
<h4>探索</h4>
<a href="#destinations">目的地</a>
<a href="#visa">签证指南</a>
<a href="#data">数据报告</a>
<a href="#faq">常见问题</a>
</div>
<div class="footer-links">
<h4>资源</h4>
<a href="#">签证指南</a>
<a href="#">税务攻略</a>
<a href="#">保险对比</a>
<a href="#">博客</a>
</div>
<div class="footer-links">
<h4>联系</h4>
<a href="#">关于我们</a>
<a href="#">合作伙伴</a>
<a href="#">隐私政策</a>
<a href="#">hello@nomadflow.io</a>
</div>
</div>
<div class="footer-bottom">
<p>© 2026 NomadFlow. Made with ❤️ for Digital Nomads everywhere.</p>
<p class="footer-emoji-bar">🌴 ☀️ 🏄‍♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅</p>
</div>
</div>
</footer>
<!-- Toast -->
<div class="toast" id="toast">
<span class="toast-icon">✅</span>
<span class="toast-msg">订阅成功!欢迎加入 NomadFlow 社区 🎉</span>
</div>
<!-- 目的地详情弹窗 -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal" id="dest-modal">
<button class="modal-close" id="modal-close" aria-label="关闭">✕</button>
<div class="modal-content" id="modal-content"></div>
</div>
</div>
<!-- 返回顶部 -->
<button class="back-to-top" id="back-to-top" aria-label="返回顶部">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</button>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="js/main.js"></script>
</body>
</html>

776
js/main.js Normal file
View File

@ -0,0 +1,776 @@
/* ===== NomadFlow - Main JavaScript ===== */
document.addEventListener('DOMContentLoaded', () => {
initLoader();
initParticles();
initNavbar();
initThemeToggle();
initMobileMenu();
initScrollReveal();
initCounters();
initTiltEffect();
initCharts();
initSubscribeForm();
initTypewriter();
initMapPins();
initDestFilters();
initDestModal();
initFAQ();
initBackToTop();
initNavHighlight();
});
/* ----- Loader ----- */
function initLoader() {
const loader = document.getElementById('loader');
if (!loader) return;
window.addEventListener('load', () => {
setTimeout(() => loader.classList.add('hidden'), 1800);
});
setTimeout(() => loader.classList.add('hidden'), 3000);
}
/* ----- Typewriter ----- */
function initTypewriter() {
const el = document.getElementById('typewriter');
if (!el) return;
const phrases = [
'数字游民不是逃离生活,而是用科技重新定义生活。',
'在巴厘岛写代码,在里斯本开会议,在清迈看日落 ☀️',
'世界那么大,你的办公室可以在任何地方 🌍',
'远程工作 + 全球旅居 = 无限可能 ✨',
];
let phraseIdx = 0;
let charIdx = 0;
let deleting = false;
function type() {
const current = phrases[phraseIdx];
if (!deleting) {
el.textContent = current.substring(0, charIdx + 1);
charIdx++;
if (charIdx === current.length) {
deleting = true;
setTimeout(type, 2500);
return;
}
} else {
el.textContent = current.substring(0, charIdx - 1);
charIdx--;
if (charIdx === 0) {
deleting = false;
phraseIdx = (phraseIdx + 1) % phrases.length;
}
}
setTimeout(type, deleting ? 30 : 60);
}
setTimeout(type, 2000);
}
/* ----- City Data ----- */
const CITY_DATA = {
bali: {
name: '巴厘岛, 印尼', emoji: '🏝️', tag: '东南亚 · 热带天堂',
desc: '乌布的数字游民社区闻名全球,稻田间的 Co-working Space 和瑜伽文化让这里成为游民圣地。',
cost: '¥4,500', speed: '85Mbps', temp: '28°C', rating: '9.2',
nomads: '12,000+', highlights: ['🏄 冲浪与海滩生活', '🧘 瑜伽冥想文化', '💰 东南亚性价比之王', '🌴 热带气候全年温暖'],
hue: 170,
},
lisbon: {
name: '里斯本, 葡萄牙', emoji: '🌊', tag: '欧洲 · 海滨明珠',
desc: 'D7 签证友好,阳光海岸与悠久历史的完美融合,欧洲数字游民的首选基地。',
cost: '¥9,000', speed: '120Mbps', temp: '22°C', rating: '9.5',
nomads: '8,500+', highlights: ['📋 D7 签证门槛低', '☀️ 300天阳光', '🎵 Fado 音乐文化', '🚋 复古有轨电车'],
hue: 220,
},
chiangmai: {
name: '清迈, 泰国', emoji: '🏔️', tag: '东南亚 · 文化古城',
desc: '数字游民大本营,咖啡文化与夜市生活的天堂,全球性价比最高的游民城市。',
cost: '¥3,800', speed: '95Mbps', temp: '30°C', rating: '9.4',
nomads: '15,000+', highlights: ['☕ 咖啡馆文化浓厚', '🏮 夜市与寺庙', '💰 月生活费最低', '🤝 游民社区最活跃'],
hue: 45,
},
mexico: {
name: '墨西哥城, 墨西哥', emoji: '🌃', tag: '拉美 · 活力之都',
desc: '艺术、美食与科技交织,时区便利对接北美市场,拉美最具活力的游民城市。',
cost: '¥6,500', speed: '75Mbps', temp: '18°C', rating: '8.8',
nomads: '5,200+', highlights: ['🎨 街头艺术天堂', '🌮 世界美食之都', '🕐 北美时区友好', '💃 丰富夜生活'],
hue: 300,
},
barcelona: {
name: '巴塞罗那, 西班牙', emoji: '🏖️', tag: '欧洲 · 地中海',
desc: '高迪建筑与创业生态并存,Nomad Visa 政策领先,地中海生活的理想之选。',
cost: '¥10,500', speed: '150Mbps', temp: '20°C', rating: '9.1',
nomads: '6,800+', highlights: ['🏛️ 高迪建筑奇迹', '🏖️ 地中海海滩', '📋 Nomad Visa 便利', '🍷 美食与夜生活'],
hue: 130,
},
tokyo: {
name: '东京, 日本', emoji: '🗼', tag: '亚洲 · 现代都市',
desc: '极致效率与安全,适合追求高品质生活的远程工作者,亚洲科技之都。',
cost: '¥12,000', speed: '200Mbps', temp: '15°C', rating: '8.6',
nomads: '4,100+', highlights: ['🚄 极致公共交通', '🛡️ 全球最安全城市', '📶 网速亚洲第一', '🍣 美食文化巅峰'],
hue: 10,
},
};
/* ----- Map Pins ----- */
function initMapPins() {
const pins = document.querySelectorAll('.map-pin');
const panel = document.getElementById('map-info-panel');
if (!pins.length || !panel) return;
pins.forEach((pin) => {
pin.addEventListener('click', () => {
const cityId = pin.dataset.city;
const city = CITY_DATA[cityId];
if (!city) return;
pins.forEach((p) => p.classList.remove('active'));
pin.classList.add('active');
panel.innerHTML = `
<div class="map-info-city">
<div class="city-emoji">${city.emoji}</div>
<h3>${city.name}</h3>
<span class="modal-tag">${city.tag}</span>
<p class="city-desc">${city.desc}</p>
<div class="map-info-stats">
<div class="map-stat"><strong>${city.cost}</strong>月生活费</div>
<div class="map-stat"><strong>${city.speed}</strong>网速</div>
<div class="map-stat"><strong>${city.temp}</strong>均温</div>
<div class="map-stat"><strong>⭐ ${city.rating}</strong>评分</div>
</div>
</div>
`;
});
});
}
/* ----- Destination Filters ----- */
function initDestFilters() {
const grid = document.getElementById('dest-grid');
const search = document.getElementById('dest-search');
const sort = document.getElementById('dest-sort');
const empty = document.getElementById('dest-empty');
const filterBtns = document.querySelectorAll('.filter-btn');
if (!grid) return;
let currentFilter = 'all';
function getCards() {
return [...grid.querySelectorAll('.dest-card')];
}
function applyFilters() {
const query = (search?.value || '').toLowerCase();
const cards = getCards();
let visible = 0;
cards.forEach((card) => {
const name = card.querySelector('h3')?.textContent.toLowerCase() || '';
const region = card.dataset.region;
const matchFilter = currentFilter === 'all' || region === currentFilter;
const matchSearch = !query || name.includes(query);
const show = matchFilter && matchSearch;
card.classList.toggle('hidden-card', !show);
if (show) visible++;
});
if (empty) empty.hidden = visible > 0;
}
function applySort() {
const cards = getCards();
const sortVal = sort?.value || 'rating';
cards.sort((a, b) => {
switch (sortVal) {
case 'cost-asc': return +a.dataset.cost - +b.dataset.cost;
case 'cost-desc': return +b.dataset.cost - +a.dataset.cost;
case 'speed': return +b.dataset.speed - +a.dataset.speed;
default: return +b.dataset.rating - +a.dataset.rating;
}
});
cards.forEach((card) => grid.appendChild(card));
}
filterBtns.forEach((btn) => {
btn.addEventListener('click', () => {
filterBtns.forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = btn.dataset.filter;
applyFilters();
});
});
search?.addEventListener('input', applyFilters);
sort?.addEventListener('change', () => { applySort(); applyFilters(); });
}
/* ----- Destination Modal ----- */
function initDestModal() {
const overlay = document.getElementById('modal-overlay');
const content = document.getElementById('modal-content');
const closeBtn = document.getElementById('modal-close');
const cards = document.querySelectorAll('.dest-card');
if (!overlay || !content) return;
function openModal(cityId) {
const city = CITY_DATA[cityId];
if (!city) return;
content.innerHTML = `
<div class="modal-hero" style="background:linear-gradient(135deg,hsl(${city.hue},60%,20%),hsl(${city.hue + 40},50%,30%))">
${city.emoji}
</div>
<div class="modal-body">
<h2>${city.name}</h2>
<span class="modal-tag">${city.tag}</span>
<p>${city.desc}</p>
<div class="modal-stats">
<div class="modal-stat"><span class="stat-emoji">💰</span><strong>${city.cost}</strong><span>月生活费</span></div>
<div class="modal-stat"><span class="stat-emoji">📶</span><strong>${city.speed}</strong><span>网速</span></div>
<div class="modal-stat"><span class="stat-emoji">🌡️</span><strong>${city.temp}</strong><span>均温</span></div>
</div>
<div class="modal-highlights">
<h4>✨ 亮点特色</h4>
<ul>${city.highlights.map((h) => `<li>${h}</li>`).join('')}</ul>
</div>
<p style="font-size:0.8rem;color:var(--text-muted)">👥 活跃游民: ${city.nomads} · ⭐ 评分: ${city.rating}</p>
</div>
`;
overlay.classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeModal() {
overlay.classList.remove('open');
document.body.style.overflow = '';
}
cards.forEach((card) => {
card.addEventListener('click', () => openModal(card.dataset.id));
});
closeBtn?.addEventListener('click', closeModal);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
}
/* ----- FAQ Accordion ----- */
function initFAQ() {
const items = document.querySelectorAll('.faq-item');
items.forEach((item) => {
const btn = item.querySelector('.faq-question');
btn?.addEventListener('click', () => {
const wasActive = item.classList.contains('active');
items.forEach((i) => i.classList.remove('active'));
if (!wasActive) item.classList.add('active');
});
});
}
/* ----- Back to Top ----- */
function initBackToTop() {
const btn = document.getElementById('back-to-top');
if (!btn) return;
window.addEventListener('scroll', () => {
btn.classList.toggle('visible', window.scrollY > 600);
}, { passive: true });
btn.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
/* ----- Nav Section Highlight ----- */
function initNavHighlight() {
const links = document.querySelectorAll('.nav-links a[data-section]');
const sections = [...links].map((l) => document.getElementById(l.dataset.section)).filter(Boolean);
if (!sections.length) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = entry.target.id;
links.forEach((l) => l.classList.toggle('active', l.dataset.section === id));
}
});
},
{ threshold: 0.3, rootMargin: '-80px 0px -50% 0px' }
);
sections.forEach((s) => observer.observe(s));
}
function initParticles() {
const canvas = document.getElementById('particle-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let particles = [];
let animId;
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
function createParticles() {
const count = Math.min(Math.floor(window.innerWidth / 12), 120);
particles = Array.from({ length: count }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
size: Math.random() * 2 + 0.5,
speedX: (Math.random() - 0.5) * 0.3,
speedY: (Math.random() - 0.5) * 0.3,
opacity: Math.random() * 0.5 + 0.1,
hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)],
}));
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach((p, i) => {
p.x += p.speedX;
p.y += p.speedY;
if (p.x < 0) p.x = canvas.width;
if (p.x > canvas.width) p.x = 0;
if (p.y < 0) p.y = canvas.height;
if (p.y > canvas.height) p.y = 0;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
ctx.fill();
particles.slice(i + 1).forEach((p2) => {
const dx = p.x - p2.x;
const dy = p.y - p2.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 120) {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.08 * (1 - dist / 120)})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
});
});
animId = requestAnimationFrame(draw);
}
resize();
createParticles();
draw();
window.addEventListener('resize', () => {
resize();
createParticles();
});
}
/* ----- Navbar Scroll Effect ----- */
function initNavbar() {
const navbar = document.getElementById('navbar');
if (!navbar) return;
const onScroll = () => {
navbar.classList.toggle('scrolled', window.scrollY > 50);
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
}
/* ----- Theme Toggle ----- */
function initThemeToggle() {
const btn = document.getElementById('theme-toggle');
if (!btn) return;
const saved = localStorage.getItem('nomad-theme');
if (saved === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
btn.querySelector('.theme-icon').textContent = '☀️';
}
btn.addEventListener('click', () => {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
if (isLight) {
document.documentElement.removeAttribute('data-theme');
btn.querySelector('.theme-icon').textContent = '🌙';
localStorage.setItem('nomad-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
btn.querySelector('.theme-icon').textContent = '☀️';
localStorage.setItem('nomad-theme', 'light');
}
updateChartThemes();
});
}
/* ----- Mobile Menu ----- */
function initMobileMenu() {
const btn = document.getElementById('mobile-menu-btn');
const links = document.querySelector('.nav-links');
if (!btn || !links) return;
btn.addEventListener('click', () => {
links.classList.toggle('open');
btn.classList.toggle('active');
});
links.querySelectorAll('a').forEach((a) => {
a.addEventListener('click', () => {
links.classList.remove('open');
btn.classList.remove('active');
});
});
}
/* ----- Scroll Reveal ----- */
function initScrollReveal() {
const reveals = document.querySelectorAll('.reveal');
if (!reveals.length) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry, i) => {
if (entry.isIntersecting) {
const delay = entry.target.dataset.delay || 0;
setTimeout(() => {
entry.target.classList.add('visible');
}, delay);
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
);
reveals.forEach((el, i) => {
el.style.transitionDelay = `${(i % 6) * 0.1}s`;
observer.observe(el);
});
}
/* ----- Animated Counters ----- */
function initCounters() {
const counters = document.querySelectorAll('.stat-number');
if (!counters.length) return;
const animate = (el) => {
const target = parseInt(el.dataset.target, 10);
const duration = 2000;
const start = performance.now();
const step = (now) => {
const progress = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.floor(eased * target);
if (progress < 1) requestAnimationFrame(step);
else el.textContent = target;
};
requestAnimationFrame(step);
};
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
animate(entry.target);
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.5 }
);
counters.forEach((c) => observer.observe(c));
}
/* ----- 3D Tilt Effect ----- */
function initTiltEffect() {
const cards = document.querySelectorAll('[data-tilt]');
cards.forEach((card) => {
card.addEventListener('mousemove', (e) => {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const rotateX = ((y - centerY) / centerY) * -8;
const rotateY = ((x - centerX) / centerX) * 8;
card.style.transform = `perspective(800px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateY(-8px)`;
});
card.addEventListener('mouseleave', () => {
card.style.transform = '';
});
});
}
/* ----- Charts ----- */
let chartInstances = {};
function getChartColors() {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
return {
text: isLight ? '#475569' : '#94a3b8',
grid: isLight ? 'rgba(0,0,0,0.06)' : 'rgba(255,255,255,0.06)',
accent: ['#FF6B6B', '#FFE66D', '#4ECDC4', '#A78BFA', '#F472B6', '#60A5FA'],
};
}
function initCharts() {
if (typeof Chart === 'undefined') return;
Chart.defaults.font.family = "'Outfit', 'Noto Sans SC', sans-serif";
Chart.defaults.color = getChartColors().text;
initCostChart();
initSpeedChart();
initGrowthChart();
initRadarChart();
}
function initCostChart() {
const ctx = document.getElementById('costChart');
if (!ctx) return;
const colors = getChartColors();
chartInstances.cost = new Chart(ctx, {
type: 'bar',
data: {
labels: ['清迈 🇹🇭', '巴厘岛 🇮🇩', '墨西哥城 🇲🇽', '里斯本 🇵🇹', '巴塞罗那 🇪🇸', '东京 🇯🇵'],
datasets: [{
label: '月生活费 (千元人民币)',
data: [3.8, 4.5, 6.5, 9.0, 10.5, 12.0],
backgroundColor: colors.accent.map((c) => c + '99'),
borderColor: colors.accent,
borderWidth: 2,
borderRadius: 8,
borderSkipped: false,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 2000, easing: 'easeOutQuart' },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(17, 24, 39, 0.9)',
padding: 12,
cornerRadius: 8,
callbacks: {
label: (ctx) => ` ¥${ctx.parsed.y.toFixed(1)},000/月`,
},
},
},
scales: {
y: {
beginAtZero: true,
grid: { color: colors.grid },
ticks: { callback: (v) => `¥${v}k` },
},
x: { grid: { display: false } },
},
},
});
}
function initSpeedChart() {
const ctx = document.getElementById('speedChart');
if (!ctx) return;
const colors = getChartColors();
chartInstances.speed = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['东京 200', '巴塞罗那 150', '里斯本 120', '清迈 95', '巴厘岛 85', '墨西哥城 75'],
datasets: [{
data: [200, 150, 120, 95, 85, 75],
backgroundColor: colors.accent,
borderColor: 'transparent',
borderWidth: 0,
hoverOffset: 12,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
animation: { animateRotate: true, duration: 2000 },
plugins: {
legend: {
position: 'right',
labels: { padding: 12, usePointStyle: true, pointStyle: 'circle' },
},
tooltip: {
callbacks: { label: (ctx) => ` ${ctx.parsed} Mbps` },
},
},
},
});
}
function initGrowthChart() {
const ctx = document.getElementById('growthChart');
if (!ctx) return;
const colors = getChartColors();
chartInstances.growth = new Chart(ctx, {
type: 'line',
data: {
labels: ['2019', '2020', '2021', '2022', '2023', '2024', '2025', '2026'],
datasets: [{
label: '全球数字游民 (百万人)',
data: [7.3, 10.9, 15.5, 24.0, 28.5, 31.2, 33.8, 35.6],
borderColor: colors.accent[2],
backgroundColor: colors.accent[2] + '20',
fill: true,
tension: 0.4,
pointRadius: 5,
pointBackgroundColor: colors.accent[2],
pointBorderColor: '#0a0e17',
pointBorderWidth: 2,
pointHoverRadius: 8,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 2500, easing: 'easeOutQuart' },
interaction: { intersect: false, mode: 'index' },
plugins: {
legend: { display: false },
tooltip: {
callbacks: { label: (ctx) => ` ${ctx.parsed.y}M 人` },
},
},
scales: {
y: {
beginAtZero: true,
grid: { color: colors.grid },
ticks: { callback: (v) => `${v}M` },
},
x: { grid: { display: false } },
},
},
});
}
function initRadarChart() {
const ctx = document.getElementById('radarChart');
if (!ctx) return;
const colors = getChartColors();
chartInstances.radar = new Chart(ctx, {
type: 'radar',
data: {
labels: ['生活成本 💰', '网络速度 📶', '安全性 🛡️', '社群活跃 🤝', '气候环境 🌤️', '签证便利 📋'],
datasets: [{
label: '清迈',
data: [95, 80, 85, 95, 70, 90],
borderColor: colors.accent[2],
backgroundColor: colors.accent[2] + '30',
pointBackgroundColor: colors.accent[2],
}, {
label: '里斯本',
data: [60, 90, 92, 80, 85, 95],
borderColor: colors.accent[0],
backgroundColor: colors.accent[0] + '30',
pointBackgroundColor: colors.accent[0],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 2000 },
scales: {
r: {
beginAtZero: true,
max: 100,
grid: { color: colors.grid },
angleLines: { color: colors.grid },
pointLabels: { font: { size: 11 } },
ticks: { display: false },
},
},
plugins: {
legend: {
position: 'bottom',
labels: { padding: 16, usePointStyle: true },
},
},
},
});
}
function updateChartThemes() {
const colors = getChartColors();
Chart.defaults.color = colors.text;
Object.values(chartInstances).forEach((chart) => {
if (!chart) return;
if (chart.options.scales?.y) chart.options.scales.y.grid.color = colors.grid;
if (chart.options.scales?.x) chart.options.scales.x.grid = { color: colors.grid, display: chart.options.scales.x.grid?.display ?? false };
if (chart.options.scales?.r) {
chart.options.scales.r.grid.color = colors.grid;
chart.options.scales.r.angleLines.color = colors.grid;
}
chart.update();
});
}
/* ----- Subscribe Form ----- */
function initSubscribeForm() {
const form = document.getElementById('subscribe-form');
const toast = document.getElementById('toast');
if (!form || !toast) return;
form.addEventListener('submit', (e) => {
e.preventDefault();
const input = form.querySelector('input');
if (!input.value) return;
toast.classList.add('show');
input.value = '';
setTimeout(() => toast.classList.remove('show'), 4000);
});
}
/* ----- Smooth anchor highlight ----- */
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener('click', (e) => {
const target = document.querySelector(anchor.getAttribute('href'));
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth' });
}
});
});

12
package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "nomadflow",
"private": true,
"scripts": {
"dev": "powershell -ExecutionPolicy Bypass -File scripts/dev.ps1",
"dev:api": "cd backend && uvicorn app.main:app --reload --port 8000",
"dev:web": "cd frontend && npm run dev",
"install:all": "cd backend && pip install -r requirements.txt && cd ../frontend && npm install",
"seed": "cd backend && python seed_pocketbase.py",
"build:web": "cd frontend && npm run build"
}
}

View File

32
scripts/dev.ps1 Normal file
View File

@ -0,0 +1,32 @@
# NomadFlow 本地开发启动脚本 (Windows PowerShell)
# 用法: .\scripts\dev.ps1
Write-Host "🌍 NomadFlow 开发环境启动" -ForegroundColor Cyan
# 检查后端依赖
if (-not (Test-Path "backend\.venv")) {
Write-Host "📦 创建 Python 虚拟环境..." -ForegroundColor Yellow
python -m venv backend\.venv
& backend\.venv\Scripts\pip install -r backend\requirements.txt -q
}
# 启动后端
Write-Host "🚀 启动 FastAPI (http://localhost:8000)..." -ForegroundColor Green
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd backend; .\.venv\Scripts\activate; uvicorn app.main:app --reload --port 8000"
Start-Sleep -Seconds 2
# 启动前端
Write-Host "🌐 启动 Next.js (http://localhost:3000)..." -ForegroundColor Green
if (-not (Test-Path "frontend\node_modules\next")) {
Write-Host "📦 安装前端依赖..." -ForegroundColor Yellow
Set-Location frontend
npm install
Set-Location ..
}
Set-Location frontend
if (-not (Test-Path ".env.local")) {
Copy-Item ".env.local.example" ".env.local"
}
npm run dev

39
scripts/dev.sh Normal file
View File

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# NomadFlow 本地开发启动脚本
set -e
echo "🌍 NomadFlow 开发环境启动"
# Backend
if [ ! -d "backend/.venv" ]; then
echo "📦 创建 Python 虚拟环境..."
python3 -m venv backend/.venv
backend/.venv/bin/pip install -r backend/requirements.txt -q
fi
echo "🚀 启动 FastAPI..."
(cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000) &
API_PID=$!
sleep 2
# Frontend
if [ ! -d "frontend/node_modules/next" ]; then
echo "📦 安装前端依赖..."
(cd frontend && npm install)
fi
[ -f frontend/.env.local ] || cp frontend/.env.local.example frontend/.env.local
echo "🌐 启动 Next.js..."
(cd frontend && npm run dev) &
WEB_PID=$!
echo ""
echo "✅ 服务已启动:"
echo " 前端: http://localhost:3000"
echo " API: http://localhost:8000/docs"
echo " 按 Ctrl+C 停止"
trap "kill $API_PID $WEB_PID 2>/dev/null" EXIT
wait