Rebrand to nomadro and add wifi score, savings goal, daily quote

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-28 23:19:27 -05:00
parent f13722123a
commit 18fb3318b1
27 changed files with 620 additions and 71 deletions

View File

@ -1,7 +1,7 @@
# NomadFlow · 数字游民旅居平台 # nomadro · 数字游民旅居平台
全栈数字游民旅居指南,采用 **Next.js + FastAPI + PocketBase** 架构。 全栈数字游民旅居指南,采用 **Next.js + FastAPI + PocketBase** 架构。
品牌:**NomadFlow** — 用一行代码环游世界 🌏 品牌:**nomadro** — 用一行代码环游世界 🌏
## 技术栈 ## 技术栈
@ -108,6 +108,9 @@ docker compose up -d
| 🎲 命运转盘 | 随机目的地抽选,趣味决策 | | 🎲 命运转盘 | 随机目的地抽选,趣味决策 |
| ✈️ 机票估算 | 出发城 × 季节 × 人数粗算往返票价 | | ✈️ 机票估算 | 出发城 × 季节 × 人数粗算往返票价 |
| 📅 活动日历 | Meetup / 工作坊 / 线上活动 | | 📅 活动日历 | Meetup / 工作坊 / 线上活动 |
| 📶 网速评级 | 远程办公 WiFi 评级与排行 |
| 🎯 启动金目标 | 存款进度追踪,预估出发时间 |
| ✨ 今日金句 | 每日游民灵感语录 |
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 | | 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
| ❓ FAQ | 搜索 + 展开/收起全部 | | ❓ FAQ | 搜索 + 展开/收起全部 |
@ -131,7 +134,7 @@ docker compose up -d
- 🔐 登录 / 注册 / 一键演示账号 - 🔐 登录 / 注册 / 一键演示账号
- ❤️ 收藏目的地(同步 API) - ❤️ 收藏目的地(同步 API)
- 👤 用户中心:统计、收藏、行程、成就徽章 - 👤 用户中心:统计、收藏、行程、成就徽章
- 演示账号:`demo@nomadflow.io` / `demo123` - 演示账号:`demo@nomadro.com` / `demo123`
### 后端 API ### 后端 API
@ -169,7 +172,7 @@ docker compose up -d
```env ```env
POCKETBASE_URL=http://127.0.0.1:8090 POCKETBASE_URL=http://127.0.0.1:8090
POCKETBASE_ADMIN_EMAIL=admin@nomadflow.io POCKETBASE_ADMIN_EMAIL=admin@nomadro.com
POCKETBASE_ADMIN_PASSWORD=admin123456 POCKETBASE_ADMIN_PASSWORD=admin123456
CORS_ORIGINS=http://localhost:3000 CORS_ORIGINS=http://localhost:3000
``` ```

View File

@ -1,9 +1,9 @@
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
pocketbase_url: str = "http://127.0.0.1:8090" pocketbase_url: str = "http://127.0.0.1:8090"
pocketbase_admin_email: str = "admin@nomadflow.io" pocketbase_admin_email: str = "admin@nomadro.com"
pocketbase_admin_password: str = "admin123456" pocketbase_admin_password: str = "admin123456"
api_host: str = "0.0.0.0" api_host: str = "0.0.0.0"
api_port: int = 8000 api_port: int = 8000

View File

@ -1,4 +1,4 @@
"""Fallback data when PocketBase is unavailable.""" """Fallback data when PocketBase is unavailable."""
DESTINATIONS = [ DESTINATIONS = [
{ {
@ -91,9 +91,9 @@ TOOLS = [
] ]
BLOG_POSTS = [ 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": "1", "slug": "chiangmai-guide-2026", "title": "清迈数字游民完全指南 2026", "excerpt": "从签证到住宿,从咖啡馆到 Co-working,一篇搞定清迈旅居。", "emoji": "🏔️", "author": "nomadro", "published_at": "2026-01-15", "read_time": 8, "tags": ["清迈", "指南", "东南亚"]},
{"id": "2", "slug": "portugal-d7-visa", "title": "葡萄牙 D7 签证申请全攻略", "excerpt": "手把手教你申请欧洲最受欢迎的远程工作签证。", "emoji": "🇵🇹", "author": "NomadFlow", "published_at": "2026-02-03", "read_time": 12, "tags": ["签证", "葡萄牙", "欧洲"]}, {"id": "2", "slug": "portugal-d7-visa", "title": "葡萄牙 D7 签证申请全攻略", "excerpt": "手把手教你申请欧洲最受欢迎的远程工作签证。", "emoji": "🇵🇹", "author": "nomadro", "published_at": "2026-02-03", "read_time": 12, "tags": ["签证", "葡萄牙", "欧洲"]},
{"id": "3", "slug": "nomad-tax-basics", "title": "数字游民税务入门:你需要知道的 5 件事", "excerpt": "183 天规则、双重征税、税务居民身份——一文理清。", "emoji": "🧾", "author": "NomadFlow", "published_at": "2026-02-20", "read_time": 10, "tags": ["税务", "法律", "指南"]}, {"id": "3", "slug": "nomad-tax-basics", "title": "数字游民税务入门:你需要知道的 5 件事", "excerpt": "183 天规则、双重征税、税务居民身份——一文理清。", "emoji": "🧾", "author": "nomadro", "published_at": "2026-02-20", "read_time": 10, "tags": ["税务", "法律", "指南"]},
] ]
BLOG_CONTENT: dict[str, str] = { BLOG_CONTENT: dict[str, str] = {

View File

@ -1,11 +1,11 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import api, auth from app.routers import api, auth
app = FastAPI( app = FastAPI(
title="NomadFlow API", title="nomadro API",
description="数字游民旅居平台后端 API", description="数字游民旅居平台后端 API",
version="1.0.0", version="1.0.0",
) )
@ -25,7 +25,7 @@ app.include_router(auth.router, prefix="/api/v1")
@app.get("/") @app.get("/")
async def root(): async def root():
return { return {
"name": "NomadFlow API", "name": "nomadro API",
"docs": "/docs", "docs": "/docs",
"health": "/api/v1/health", "health": "/api/v1/health",
} }

View File

@ -1,4 +1,4 @@
"""Simple auth service with in-memory fallback when PocketBase users unavailable.""" """Simple auth service with in-memory fallback when PocketBase users unavailable."""
import hashlib import hashlib
import secrets import secrets
from typing import Any from typing import Any
@ -79,5 +79,5 @@ def _user_profile(user: dict) -> dict:
} }
# Demo account # Demo account
_demo = register_user("demo@nomadflow.io", "demo123", "演示用户") _demo = register_user("demo@nomadro.com", "demo123", "演示用户")
DEMO_TOKEN = _demo["token"] if _demo else "" DEMO_TOKEN = _demo["token"] if _demo else ""

View File

@ -1,4 +1,4 @@
import httpx import httpx
from typing import Any from typing import Any
from app.config import settings from app.config import settings
@ -145,9 +145,9 @@ class PocketBaseService:
result = await self.create_record("subscriptions", {"email": email}) result = await self.create_record("subscriptions", {"email": email})
if result: if result:
return True, "订阅成功!欢迎加入 NomadFlow 社区 🎉" return True, "订阅成功!欢迎加入 nomadro 社区 🎉"
# fallback: always succeed in demo mode # fallback: always succeed in demo mode
return True, "订阅成功!欢迎加入 NomadFlow 社区 🎉" return True, "订阅成功!欢迎加入 nomadro 社区 🎉"
def _map_destination(self, r: dict) -> dict: def _map_destination(self, r: dict) -> dict:
return { return {

View File

@ -1,5 +1,5 @@
""" """
Seed PocketBase collections with NomadFlow data. Seed PocketBase collections with nomadro data.
Run after PocketBase is started: python seed_pocketbase.py Run after PocketBase is started: python seed_pocketbase.py
""" """
import asyncio import asyncio

View File

@ -18,7 +18,7 @@ services:
- "127.0.0.1:8055:8000" - "127.0.0.1:8055:8000"
environment: environment:
- POCKETBASE_URL=http://pocketbase:8090 - POCKETBASE_URL=http://pocketbase:8090
- POCKETBASE_ADMIN_EMAIL=${POCKETBASE_ADMIN_EMAIL:-admin@nomadflow.io} - POCKETBASE_ADMIN_EMAIL=${POCKETBASE_ADMIN_EMAIL:-admin@nomadro.com}
- POCKETBASE_ADMIN_PASSWORD=${POCKETBASE_ADMIN_PASSWORD:-admin123456} - POCKETBASE_ADMIN_PASSWORD=${POCKETBASE_ADMIN_PASSWORD:-admin123456}
- CORS_ORIGINS=${CORS_ORIGINS:-https://nomadweb.nomadro.com,http://nomadweb.nomadro.com} - CORS_ORIGINS=${CORS_ORIGINS:-https://nomadweb.nomadro.com,http://nomadweb.nomadro.com}
depends_on: depends_on:

View File

@ -20,7 +20,7 @@ services:
- "8000:8000" - "8000:8000"
environment: environment:
- POCKETBASE_URL=http://pocketbase:8090 - POCKETBASE_URL=http://pocketbase:8090
- POCKETBASE_ADMIN_EMAIL=admin@nomadflow.io - POCKETBASE_ADMIN_EMAIL=admin@nomadro.com
- POCKETBASE_ADMIN_PASSWORD=admin123456 - POCKETBASE_ADMIN_PASSWORD=admin123456
- CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 - CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
depends_on: depends_on:

View File

@ -121,14 +121,23 @@ img { max-width: 100%; display: block; }
align-items: center; align-items: center;
gap: 10px; gap: 10px;
font-family: var(--font-display); font-family: var(--font-display);
font-weight: 700; font-weight: 800;
font-size: 1.25rem; font-size: 1.35rem;
letter-spacing: -0.02em;
}
.logo span {
background: linear-gradient(135deg, #FF6B6B, #FFE66D, #4ECDC4);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
} }
.logo-icon { .logo-icon {
width: 36px; width: 36px;
height: 36px; height: 36px;
animation: logoSpin 20s linear infinite; animation: logoSpin 20s linear infinite;
flex-shrink: 0;
} }
@keyframes logoSpin { @keyframes logoSpin {
@ -6102,3 +6111,269 @@ img { max-width: 100%; display: block; }
.flight-card { grid-template-columns: 1fr; } .flight-card { grid-template-columns: 1fr; }
.spin-card { padding: 28px 20px; } .spin-card { padding: 28px 20px; }
} }
/* ===== Daily Quote ===== */
.quote-section { padding-top: 40px; padding-bottom: 40px; }
.quote-card {
max-width: 720px;
margin: 0 auto;
padding: 36px 32px;
text-align: center;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
backdrop-filter: blur(20px);
position: relative;
overflow: hidden;
}
.quote-card::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255,107,107,0.06), rgba(78,205,196,0.08));
pointer-events: none;
}
.quote-emoji {
font-size: 2.5rem;
display: block;
margin-bottom: 12px;
position: relative;
}
.quote-card blockquote {
font-family: var(--font-display);
font-size: clamp(1.15rem, 2.5vw, 1.5rem);
font-weight: 600;
line-height: 1.6;
margin: 0 0 20px;
position: relative;
}
.quote-footer {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
flex-wrap: wrap;
position: relative;
}
/* ===== Wifi Work Score ===== */
.wifi-layout {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 24px;
}
.wifi-picker {
padding: 28px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
backdrop-filter: blur(20px);
}
.wifi-meter {
display: flex;
align-items: center;
gap: 24px;
margin-top: 20px;
flex-wrap: wrap;
}
.wifi-meter-ring {
width: 120px;
height: 120px;
border-radius: 50%;
border: 4px solid var(--wifi-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 0 24px color-mix(in srgb, var(--wifi-color) 35%, transparent);
flex-shrink: 0;
}
.wifi-meter-ring strong {
font-size: 1.8rem;
line-height: 1;
}
.wifi-meter-ring small {
color: var(--text-secondary);
font-size: 0.75rem;
}
.wifi-meter-info h3 {
font-size: 1.3rem;
margin-bottom: 6px;
}
.wifi-level {
font-weight: 700;
font-size: 1rem;
display: block;
margin-bottom: 8px;
}
.wifi-meter-info p {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 10px;
}
.wifi-link {
color: var(--accent-3);
font-size: 0.85rem;
}
.wifi-rank {
padding: 24px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
backdrop-filter: blur(20px);
}
.wifi-rank h4 {
margin-bottom: 14px;
font-size: 1rem;
}
.wifi-rank ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.wifi-rank li {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.2s;
font-size: 0.9rem;
}
.wifi-rank li:hover,
.wifi-rank li.active {
background: rgba(78, 205, 196, 0.1);
}
.wifi-rank-num {
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(255,255,255,0.08);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
}
.wifi-rank-speed {
margin-left: auto;
font-weight: 600;
font-size: 0.85rem;
}
/* ===== Savings Goal ===== */
.savings-card {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 28px;
padding: 28px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
backdrop-filter: blur(20px);
}
.savings-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.savings-result {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 20px;
background: rgba(78, 205, 196, 0.05);
border-radius: var(--radius-lg);
}
.savings-ring {
width: 140px;
height: 140px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.savings-ring-inner {
width: 110px;
height: 110px;
border-radius: 50%;
background: var(--bg-card);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.savings-ring-inner strong {
font-size: 1.8rem;
}
.savings-ring-inner small {
color: var(--text-secondary);
font-size: 0.75rem;
}
.savings-stats {
text-align: center;
width: 100%;
}
.savings-stats > div {
margin-bottom: 12px;
}
.savings-stats small {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 2px;
}
.savings-stats strong {
font-size: 1.3rem;
}
.savings-hint {
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.5;
margin-top: 8px;
}
@media (max-width: 768px) {
.wifi-layout,
.savings-card {
grid-template-columns: 1fr;
}
}

View File

@ -1,4 +1,4 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { AuthProvider } from "@/lib/auth"; import { AuthProvider } from "@/lib/auth";
import { ToastProvider } from "@/lib/toast"; import { ToastProvider } from "@/lib/toast";
@ -7,20 +7,20 @@ const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(SITE), metadataBase: new URL(SITE),
title: "NomadFlow · 数字游民旅居指南", title: "nomadro · 数字游民旅居指南",
description: "探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。", description: "探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。",
keywords: ["数字游民", "旅居", "远程工作", "nomad", "digital nomad"], keywords: ["数字游民", "旅居", "远程工作", "nomad", "digital nomad"],
openGraph: { openGraph: {
title: "NomadFlow · 数字游民旅居指南", title: "nomadro · 数字游民旅居指南",
description: "用一行代码环游世界 🌏", description: "用一行代码环游世界 🌏",
type: "website", type: "website",
locale: "zh_CN", locale: "zh_CN",
url: SITE, url: SITE,
siteName: "NomadFlow", siteName: "nomadro",
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "NomadFlow · 数字游民旅居指南", title: "nomadro · 数字游民旅居指南",
description: "用一行代码环游世界 🌏", description: "用一行代码环游世界 🌏",
}, },
manifest: "/manifest.webmanifest", manifest: "/manifest.webmanifest",

View File

@ -1,4 +1,4 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
@ -55,7 +55,7 @@ export default function LoginPage() {
<Link href="/" className="auth-back">← 返回首页</Link> <Link href="/" className="auth-back">← 返回首页</Link>
<div className="auth-header"> <div className="auth-header">
<span className="auth-logo-emoji">🌍</span> <span className="auth-logo-emoji">🌍</span>
<h1>{mode === "login" ? "欢迎回来" : "加入 NomadFlow"}</h1> <h1>{mode === "login" ? "欢迎回来" : "加入 nomadro"}</h1>
<p>{mode === "login" ? "登录你的游民账户" : "开始你的旅居之旅"}</p> <p>{mode === "login" ? "登录你的游民账户" : "开始你的旅居之旅"}</p>
</div> </div>
@ -101,7 +101,7 @@ export default function LoginPage() {
<button className="btn btn-ghost" style={{ width: "100%" }} onClick={handleDemo} disabled={loading}> <button className="btn btn-ghost" style={{ width: "100%" }} onClick={handleDemo} disabled={loading}>
🎮 一键体验演示账号 🎮 一键体验演示账号
</button> </button>
<p className="auth-hint">演示账号:demo@nomadflow.io / demo123</p> <p className="auth-hint">演示账号:demo@nomadro.com / demo123</p>
</div> </div>
</div> </div>
</SiteShell> </SiteShell>

View File

@ -1,11 +1,11 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com"; const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: "NomadFlow · 数字游民旅居指南", name: "nomadro · 数字游民旅居指南",
short_name: "NomadFlow", short_name: "nomadro",
description: "探索全球旅居生活,发现最适合数字游民的目的地", description: "探索全球旅居生活,发现最适合数字游民的目的地",
start_url: "/", start_url: "/",
display: "standalone", display: "standalone",

View File

@ -1,10 +1,10 @@
import Link from "next/link"; import Link from "next/link";
import type { Metadata } from "next"; import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "隐私政策 · NomadFlow", title: "隐私政策 · nomadro",
description: "NomadFlow 隐私政策与 Cookie 使用说明", description: "nomadro 隐私政策与 Cookie 使用说明",
}; };
export default function PrivacyPage() { export default function PrivacyPage() {
@ -22,7 +22,7 @@ export default function PrivacyPage() {
<section> <section>
<h2>我们收集什么</h2> <h2>我们收集什么</h2>
<p> <p>
NomadFlow 可能收集你主动提供的信息(如邮箱订阅、注册账号),以及必要的技术信息(浏览器类型、访问页面)用于改进体验。 nomadro 可能收集你主动提供的信息(如邮箱订阅、注册账号),以及必要的技术信息(浏览器类型、访问页面)用于改进体验。
</p> </p>
</section> </section>
@ -44,7 +44,7 @@ export default function PrivacyPage() {
<h2>联系我们</h2> <h2>联系我们</h2>
<p> <p>
如有隐私相关问题,请联系: 如有隐私相关问题,请联系:
<a href="mailto:hello@nomadflow.io"> hello@nomadflow.io</a> <a href="mailto:hello@nomadro.com"> hello@nomadro.com</a>
</p> </p>
</section> </section>
</article> </article>

View File

@ -1,4 +1,4 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
@ -9,7 +9,7 @@ import type { Destination, ProfileStats, TripItem } from "@/lib/types";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
import FavoriteButton from "@/components/FavoriteButton"; import FavoriteButton from "@/components/FavoriteButton";
const TRIP_KEY = "nomadflow-trip"; const TRIP_KEY = "nomadro-trip";
interface Badge { interface Badge {
emoji: string; emoji: string;
@ -21,7 +21,7 @@ interface Badge {
function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: number): Badge[] { function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: number): Badge[] {
const tripMonths = trip.reduce((s, t) => s + t.months, 0); const tripMonths = trip.reduce((s, t) => s + t.months, 0);
return [ return [
{ emoji: "🌱", label: "初出茅庐", desc: "加入 NomadFlow", earned: true }, { emoji: "🌱", label: "初出茅庐", desc: "加入 nomadro", earned: true },
{ emoji: "❤️", label: "收藏家", desc: "收藏 3+ 目的地", earned: favCount >= 3 }, { emoji: "❤️", label: "收藏家", desc: "收藏 3+ 目的地", earned: favCount >= 3 },
{ emoji: "🗺️", label: "行程达人", desc: "规划 3+ 城市行程", earned: trip.length >= 3 }, { emoji: "🗺️", label: "行程达人", desc: "规划 3+ 城市行程", earned: trip.length >= 3 },
{ emoji: "📅", label: "长期旅居", desc: "行程总计 6+ 个月", earned: tripMonths >= 6 }, { emoji: "📅", label: "长期旅居", desc: "行程总计 6+ 个月", earned: tripMonths >= 6 },

View File

@ -1,4 +1,4 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
@ -22,7 +22,7 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
const email = (form.elements.namedItem("email") as HTMLInputElement).value; const email = (form.elements.namedItem("email") as HTMLInputElement).value;
try { try {
await api.subscribe(email); await api.subscribe(email);
toast("订阅成功!欢迎加入 NomadFlow 社区 🎉"); toast("订阅成功!欢迎加入 nomadro 社区 🎉");
form.reset(); form.reset();
} catch { /* ignore */ } } catch { /* ignore */ }
}; };

View File

@ -0,0 +1,44 @@
"use client";
import { useEffect, useState } from "react";
const QUOTES = [
{ text: "世界那么大,你的办公室可以在任何地方。", emoji: "🌍" },
{ text: "不是逃离生活,而是用科技重新定义生活。", emoji: "💻" },
{ text: "护照是最好的投资,好奇心是最好的指南针。", emoji: "🛂" },
{ text: "在巴厘岛写代码,在里斯本开会议,在清迈看日落。", emoji: "☀️" },
{ text: "慢下来,你会发现远方其实很近。", emoji: "✈️" },
{ text: "远程工作不是躺平,而是选择自己的时区与节奏。", emoji: "🕐" },
{ text: "背包里装的是衣服,心里装的是下一座城。", emoji: "🧳" },
];
export default function DailyQuote() {
const [idx, setIdx] = useState(0);
useEffect(() => {
const day = Math.floor(Date.now() / 86400000);
setIdx(day % QUOTES.length);
}, []);
const q = QUOTES[idx];
return (
<section className="section quote-section" id="quote">
<div className="container">
<div className="quote-card reveal">
<span className="quote-emoji">{q.emoji}</span>
<blockquote>“{q.text}”</blockquote>
<div className="quote-footer">
<span className="section-tag">✨ 今日 nomadro</span>
<button
className="btn btn-ghost btn-sm"
onClick={() => setIdx((i) => (i + 1) % QUOTES.length)}
>
换一句 →
</button>
</div>
</div>
</div>
</section>
);
}

View File

@ -1,11 +1,11 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
const STORAGE_KEY = "nomadflow-trip"; const STORAGE_KEY = "nomadro-trip";
const TZ_LABELS: Record<string, string> = { const TZ_LABELS: Record<string, string> = {
bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7", bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7",
mexico: "UTC-6", barcelona: "UTC+1", tokyo: "UTC+9", mexico: "UTC-6", barcelona: "UTC+1", tokyo: "UTC+9",
@ -52,7 +52,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
const share = async () => { const share = async () => {
const url = window.location.href; const url = window.location.href;
if (navigator.share) { if (navigator.share) {
await navigator.share({ title: `${dest.name} - NomadFlow`, url }); await navigator.share({ title: `${dest.name} - nomadro`, url });
} else { } else {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
toast("链接已复制到剪贴板 📋"); toast("链接已复制到剪贴板 📋");

View File

@ -1,14 +1,34 @@
import Link from "next/link"; import Link from "next/link";
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com"; const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
function BrandLogo() {
return (
<Link href="/" className="logo">
<svg className="logo-icon" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<circle cx="20" cy="20" r="18" stroke="url(#footerLogoGrad)" strokeWidth="2.5" />
<path d="M12 22c4-8 12-8 16 0" stroke="url(#footerLogoGrad)" strokeWidth="2" strokeLinecap="round" />
<circle cx="28" cy="14" r="3" fill="url(#footerLogoGrad)" />
<defs>
<linearGradient id="footerLogoGrad" 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>nomadro</span>
</Link>
);
}
export default function Footer() { export default function Footer() {
return ( return (
<footer className="footer"> <footer className="footer">
<div className="container"> <div className="container">
<div className="footer-grid"> <div className="footer-grid">
<div className="footer-brand"> <div className="footer-brand">
<Link href="/" className="logo"><span>NomadFlow</span></Link> <BrandLogo />
<p>让每个人都能自由地工作和生活在这个星球上 🌏</p> <p>让每个人都能自由地工作和生活在这个星球上 🌏</p>
<div className="social-links"> <div className="social-links">
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer" aria-label="Twitter">𝕏</a> <a href="https://twitter.com" target="_blank" rel="noopener noreferrer" aria-label="Twitter">𝕏</a>
@ -21,19 +41,18 @@ export default function Footer() {
<h4>探索</h4> <h4>探索</h4>
<Link href="/#destinations">目的地</Link> <Link href="/#destinations">目的地</Link>
<Link href="/#spin">命运转盘</Link> <Link href="/#spin">命运转盘</Link>
<Link href="/#cost-compare">省钱对比</Link> <Link href="/#wifi">网速评级</Link>
<Link href="/#calculator">费用计算器</Link>
<Link href="/#visa">签证指南</Link> <Link href="/#visa">签证指南</Link>
<Link href="/#events">活动日历</Link>
<Link href="/#blog">博客</Link> <Link href="/#blog">博客</Link>
</div> </div>
<div className="footer-links"> <div className="footer-links">
<h4>工具</h4> <h4>工具</h4>
<Link href="/#trip">行程规划</Link> <Link href="/#trip">行程规划</Link>
<Link href="/#savings">启动金目标</Link>
<Link href="/#flight">机票估算</Link> <Link href="/#flight">机票估算</Link>
<Link href="/#calculator">费用计算器</Link>
<Link href="/#timezone">时区看板</Link> <Link href="/#timezone">时区看板</Link>
<Link href="/#events">活动日历</Link>
<Link href="/#nomad-score">就绪度测评</Link>
<Link href="/#currency">汇率换算</Link>
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a> <a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
</div> </div>
<div className="footer-links"> <div className="footer-links">
@ -41,12 +60,12 @@ export default function Footer() {
<Link href="/login">登录 / 注册</Link> <Link href="/login">登录 / 注册</Link>
<Link href="/profile">用户中心</Link> <Link href="/profile">用户中心</Link>
<Link href="/privacy">隐私政策</Link> <Link href="/privacy">隐私政策</Link>
<a href="mailto:hello@nomadflow.io">hello@nomadflow.io</a> <a href="mailto:hello@nomadro.com">hello@nomadro.com</a>
<a href="https://gitea.dsx2020.com/eric/nomadweb" target="_blank" rel="noopener noreferrer">开源仓库 ↗</a> <a href="https://gitea.dsx2020.com/eric/nomadweb" target="_blank" rel="noopener noreferrer">开源仓库 ↗</a>
</div> </div>
</div> </div>
<div className="footer-bottom"> <div className="footer-bottom">
<p>© 2026 NomadFlow · <a href={SITE}>{SITE.replace("https://", "")}</a></p> <p>© 2026 nomadro · <a href={SITE}>{SITE.replace("https://", "")}</a></p>
<p className="footer-emoji-bar">🌴 ☀️ 🏄‍♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅</p> <p className="footer-emoji-bar">🌴 ☀️ 🏄‍♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅</p>
</div> </div>
</div> </div>

View File

@ -37,6 +37,9 @@ import NomadTips from "./NomadTips";
import SpinGlobe from "./SpinGlobe"; import SpinGlobe from "./SpinGlobe";
import FlightCost from "./FlightCost"; import FlightCost from "./FlightCost";
import NomadEvents from "./NomadEvents"; import NomadEvents from "./NomadEvents";
import WifiWorkScore from "./WifiWorkScore";
import SavingsGoal from "./SavingsGoal";
import DailyQuote from "./DailyQuote";
import KeyboardShortcuts from "./KeyboardShortcuts"; import KeyboardShortcuts from "./KeyboardShortcuts";
import BackToTop from "./BackToTop"; import BackToTop from "./BackToTop";
@ -107,12 +110,15 @@ export default function HomeClient(props: Props) {
<WorldMap destinations={props.destinations} /> <WorldMap destinations={props.destinations} />
<Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} /> <Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} />
<SpinGlobe destinations={props.destinations} /> <SpinGlobe destinations={props.destinations} />
<DailyQuote />
<TimezoneBoard destinations={props.destinations} /> <TimezoneBoard destinations={props.destinations} />
<TripPlanner destinations={props.destinations} /> <TripPlanner destinations={props.destinations} />
<Calculator destinations={props.destinations} /> <Calculator destinations={props.destinations} />
<CostCompare destinations={props.destinations} /> <CostCompare destinations={props.destinations} />
<SavingsGoal destinations={props.destinations} />
<FlightCost destinations={props.destinations} /> <FlightCost destinations={props.destinations} />
<CurrencyConverter /> <CurrencyConverter />
<WifiWorkScore destinations={props.destinations} />
<NomadScore /> <NomadScore />
<Lifestyle /> <Lifestyle />
<Charts {...props.charts} /> <Charts {...props.charts} />

View File

@ -1,4 +1,4 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
@ -24,7 +24,7 @@ export default function Loader() {
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </svg>
<p className="loader-text">NomadFlow</p> <p className="loader-text">nomadro</p>
<div className="loader-bar"><div className="loader-progress" /></div> <div className="loader-bar"><div className="loader-progress" /></div>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
@ -79,7 +79,7 @@ export default function Navbar() {
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </svg>
<span>NomadFlow</span> <span>nomadro</span>
</Link> </Link>
<nav className={`nav-links${menuOpen ? " open" : ""}`}> <nav className={`nav-links${menuOpen ? " open" : ""}`}>
{LINKS.map((l) => ( {LINKS.map((l) => (

View File

@ -0,0 +1,115 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
const STORAGE_KEY = "nomadro-savings";
interface Props {
destinations: Destination[];
}
export default function SavingsGoal({ destinations }: Props) {
const [target, setTarget] = useState(30000);
const [saved, setSaved] = useState(8000);
const [monthly, setMonthly] = useState(3000);
const [slug, setSlug] = useState(destinations[0]?.slug || "");
const [ready, setReady] = useState(false);
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const data = JSON.parse(raw);
if (data.target) setTarget(data.target);
if (data.saved != null) setSaved(data.saved);
if (data.monthly) setMonthly(data.monthly);
if (data.slug) setSlug(data.slug);
}
} catch { /* ignore */ }
setReady(true);
}, []);
useEffect(() => {
if (!ready) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify({ target, saved, monthly, slug }));
}, [target, saved, monthly, slug, ready]);
const dest = destinations.find((d) => d.slug === slug);
const pct = Math.min(Math.round((saved / Math.max(target, 1)) * 100), 100);
const remain = Math.max(target - saved, 0);
const monthsLeft = monthly > 0 ? Math.ceil(remain / monthly) : 0;
const suggested = useMemo(() => {
if (!dest) return target;
// 机票粗估 + 3 个月生活费
const flight = dest.cost > 8000 ? 6000 : 2800;
return dest.cost * 3 + flight;
}, [dest, target]);
return (
<section className="section savings-section" id="savings">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🎯 SAVINGS</span>
<h2>旅居启动金目标</h2>
<p>设定目标、追踪存款,算算还要多久才能出发</p>
</div>
<div className="savings-card reveal">
<div className="savings-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}</option>
))}
</select>
</div>
<div className="calc-field">
<label>🎯 启动金目标: ¥{target.toLocaleString()}</label>
<input type="range" min={10000} max={100000} step={1000} value={target} onChange={(e) => setTarget(+e.target.value)} />
{dest && (
<button className="btn btn-ghost btn-sm" type="button" onClick={() => setTarget(suggested)}>
用 {dest.emoji} 建议值 ¥{suggested.toLocaleString()}
</button>
)}
</div>
<div className="calc-field">
<label>💰 已存: ¥{saved.toLocaleString()}</label>
<input type="range" min={0} max={target} step={500} value={Math.min(saved, target)} onChange={(e) => setSaved(+e.target.value)} />
</div>
<div className="calc-field">
<label>📈 每月可存: ¥{monthly.toLocaleString()}</label>
<input type="range" min={500} max={20000} step={500} value={monthly} onChange={(e) => setMonthly(+e.target.value)} />
</div>
</div>
<div className="savings-result">
<div className="savings-ring" style={{ background: `conic-gradient(var(--accent-3) ${pct}%, rgba(255,255,255,0.08) 0)` }}>
<div className="savings-ring-inner">
<strong>{pct}%</strong>
<small>完成</small>
</div>
</div>
<div className="savings-stats">
<div>
<small>还差</small>
<strong className="gradient-text">¥{remain.toLocaleString()}</strong>
</div>
<div>
<small>预计出发</small>
<strong>{remain === 0 ? "随时可以!" : `${monthsLeft} 个月后`}</strong>
</div>
{dest && (
<p className="savings-hint">
{dest.emoji} {dest.name} 月均约 ¥{dest.cost.toLocaleString()},建议备 3 个月缓冲 + 机票
</p>
)}
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -10,7 +10,9 @@ const SECTIONS = [
{ id: "trip", emoji: "🗓️", label: "行程" }, { id: "trip", emoji: "🗓️", label: "行程" },
{ id: "calculator", emoji: "🧮", label: "计算器" }, { id: "calculator", emoji: "🧮", label: "计算器" },
{ id: "cost-compare", emoji: "💰", label: "省钱" }, { id: "cost-compare", emoji: "💰", label: "省钱" },
{ id: "savings", emoji: "🎯", label: "存款" },
{ id: "flight", emoji: "✈️", label: "机票" }, { id: "flight", emoji: "✈️", label: "机票" },
{ id: "wifi", emoji: "📶", label: "网速" },
{ id: "lifestyle", emoji: "💻", label: "生活" }, { id: "lifestyle", emoji: "💻", label: "生活" },
{ id: "visa", emoji: "📋", label: "签证" }, { id: "visa", emoji: "📋", label: "签证" },
{ id: "coworking", emoji: "💻", label: "办公" }, { id: "coworking", emoji: "💻", label: "办公" },

View File

@ -0,0 +1,85 @@
"use client";
import { useMemo, useState, type CSSProperties } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types";
function workLevel(speed: number) {
if (speed >= 150) return { label: "极速办公", emoji: "🚀", color: "#6ee7b7", tip: "视频会议、大文件传输无压力" };
if (speed >= 80) return { label: "流畅办公", emoji: "✅", color: "#4ECDC4", tip: "日常远程工作完全够用" };
if (speed >= 40) return { label: "基本可用", emoji: "⚠️", color: "#FFE66D", tip: "建议准备热点备份" };
return { label: "需谨慎", emoji: "🐢", color: "#FF6B6B", tip: "优先选择联合办公或咖啡厅" };
}
interface Props {
destinations: Destination[];
}
export default function WifiWorkScore({ destinations }: Props) {
const [slug, setSlug] = useState(destinations[0]?.slug || "");
const dest = destinations.find((d) => d.slug === slug) || destinations[0];
const level = useMemo(() => workLevel(dest?.speed ?? 0), [dest]);
const ranked = useMemo(
() => [...destinations].sort((a, b) => b.speed - a.speed),
[destinations]
);
return (
<section className="section wifi-section" id="wifi">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">📶 WIFI SCORE</span>
<h2>远程办公网速评级</h2>
<p>选城先看网——评估目的地是否适合稳定远程工作</p>
</div>
<div className="wifi-layout reveal">
<div className="wifi-picker">
<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}</option>
))}
</select>
</div>
{dest && (
<div className="wifi-meter">
<div className="wifi-meter-ring" style={{ "--wifi-color": level.color } as CSSProperties}>
<strong>{dest.speed}</strong>
<small>Mbps</small>
</div>
<div className="wifi-meter-info">
<h3>{dest.emoji} {dest.name}</h3>
<span className="wifi-level" style={{ color: level.color }}>
{level.emoji} {level.label}
</span>
<p>{level.tip}</p>
<Link href={`/destinations/${dest.slug}`} className="wifi-link">查看城市详情 →</Link>
</div>
</div>
)}
</div>
<div className="wifi-rank">
<h4>🏆 网速排行</h4>
<ul>
{ranked.map((d, i) => {
const lv = workLevel(d.speed);
return (
<li key={d.slug} className={d.slug === slug ? "active" : ""} onClick={() => setSlug(d.slug)}>
<span className="wifi-rank-num">{i + 1}</span>
<span>{d.emoji} {d.name}</span>
<span className="wifi-rank-speed" style={{ color: lv.color }}>{d.speed} Mbps</span>
</li>
);
})}
</ul>
</div>
</div>
</div>
</section>
);
}

View File

@ -1,9 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NomadFlow · 数字游民旅居指南</title> <title>nomadro · 数字游民旅居指南</title>
<meta name="description" content="探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。" /> <meta name="description" content="探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@ -24,7 +24,7 @@
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </svg>
<p class="loader-text">NomadFlow</p> <p class="loader-text">nomadro</p>
<div class="loader-bar"><div class="loader-progress"></div></div> <div class="loader-bar"><div class="loader-progress"></div></div>
</div> </div>
</div> </div>
@ -48,7 +48,7 @@
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </svg>
<span>NomadFlow</span> <span>nomadro</span>
</a> </a>
<nav class="nav-links"> <nav class="nav-links">
<a href="#map" data-section="map">🗺️ 地图</a> <a href="#map" data-section="map">🗺️ 地图</a>
@ -844,7 +844,7 @@
<div class="footer-brand"> <div class="footer-brand">
<a href="#" class="logo"> <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> <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> <span>nomadro</span>
</a> </a>
<p>让每个人都能自由地工作和生活在这个星球上 🌏</p> <p>让每个人都能自由地工作和生活在这个星球上 🌏</p>
<div class="social-links"> <div class="social-links">
@ -873,11 +873,11 @@
<a href="#">关于我们</a> <a href="#">关于我们</a>
<a href="#">合作伙伴</a> <a href="#">合作伙伴</a>
<a href="#">隐私政策</a> <a href="#">隐私政策</a>
<a href="#">hello@nomadflow.io</a> <a href="#">hello@nomadro.com</a>
</div> </div>
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">
<p>© 2026 NomadFlow. Made with ❤️ for Digital Nomads everywhere.</p> <p>© 2026 nomadro. Made with ❤️ for Digital Nomads everywhere.</p>
<p class="footer-emoji-bar">🌴 ☀️ 🏄‍♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅</p> <p class="footer-emoji-bar">🌴 ☀️ 🏄‍♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅</p>
</div> </div>
</div> </div>
@ -886,7 +886,7 @@
<!-- Toast --> <!-- Toast -->
<div class="toast" id="toast"> <div class="toast" id="toast">
<span class="toast-icon">✅</span> <span class="toast-icon">✅</span>
<span class="toast-msg">订阅成功!欢迎加入 NomadFlow 社区 🎉</span> <span class="toast-msg">订阅成功!欢迎加入 nomadro 社区 🎉</span>
</div> </div>
<!-- 目的地详情弹窗 --> <!-- 目的地详情弹窗 -->

View File

@ -1,5 +1,5 @@
{ {
"name": "nomadflow", "name": "nomadro",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "powershell -ExecutionPolicy Bypass -File scripts/dev.ps1", "dev": "powershell -ExecutionPolicy Bypass -File scripts/dev.ps1",