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

View File

@ -1,9 +1,9 @@
from pydantic_settings import BaseSettings
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_email: str = "admin@nomadro.com"
pocketbase_admin_password: str = "admin123456"
api_host: str = "0.0.0.0"
api_port: int = 8000

View File

@ -1,4 +1,4 @@
"""Fallback data when PocketBase is unavailable."""
"""Fallback data when PocketBase is unavailable."""
DESTINATIONS = [
{
@ -91,9 +91,9 @@ TOOLS = [
]
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": ["税务", "法律", "指南"]},
{"id": "1", "slug": "chiangmai-guide-2026", "title": "清迈数字游民完全指南 2026", "excerpt": "从签证到住宿,从咖啡馆到 Co-working,一篇搞定清迈旅居。", "emoji": "🏔️", "author": "nomadro", "published_at": "2026-01-15", "read_time": 8, "tags": ["清迈", "指南", "东南亚"]},
{"id": "2", "slug": "portugal-d7-visa", "title": "葡萄牙 D7 签证申请全攻略", "excerpt": "手把手教你申请欧洲最受欢迎的远程工作签证。", "emoji": "🇵🇹", "author": "nomadro", "published_at": "2026-02-03", "read_time": 12, "tags": ["签证", "葡萄牙", "欧洲"]},
{"id": "3", "slug": "nomad-tax-basics", "title": "数字游民税务入门:你需要知道的 5 件事", "excerpt": "183 天规则、双重征税、税务居民身份——一文理清。", "emoji": "🧾", "author": "nomadro", "published_at": "2026-02-20", "read_time": 10, "tags": ["税务", "法律", "指南"]},
]
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 app.config import settings
from app.routers import api, auth
app = FastAPI(
title="NomadFlow API",
title="nomadro API",
description="数字游民旅居平台后端 API",
version="1.0.0",
)
@ -25,7 +25,7 @@ app.include_router(auth.router, prefix="/api/v1")
@app.get("/")
async def root():
return {
"name": "NomadFlow API",
"name": "nomadro API",
"docs": "/docs",
"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 secrets
from typing import Any
@ -79,5 +79,5 @@ def _user_profile(user: dict) -> dict:
}
# Demo account
_demo = register_user("demo@nomadflow.io", "demo123", "演示用户")
_demo = register_user("demo@nomadro.com", "demo123", "演示用户")
DEMO_TOKEN = _demo["token"] if _demo else ""

View File

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

View File

@ -18,7 +18,7 @@ services:
- "127.0.0.1:8055:8000"
environment:
- 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}
- CORS_ORIGINS=${CORS_ORIGINS:-https://nomadweb.nomadro.com,http://nomadweb.nomadro.com}
depends_on:

View File

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

View File

@ -121,14 +121,23 @@ img { max-width: 100%; display: block; }
align-items: center;
gap: 10px;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.25rem;
font-weight: 800;
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 {
width: 36px;
height: 36px;
animation: logoSpin 20s linear infinite;
flex-shrink: 0;
}
@keyframes logoSpin {
@ -6102,3 +6111,269 @@ img { max-width: 100%; display: block; }
.flight-card { grid-template-columns: 1fr; }
.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 { AuthProvider } from "@/lib/auth";
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 = {
metadataBase: new URL(SITE),
title: "NomadFlow · 数字游民旅居指南",
title: "nomadro · 数字游民旅居指南",
description: "探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。",
keywords: ["数字游民", "旅居", "远程工作", "nomad", "digital nomad"],
openGraph: {
title: "NomadFlow · 数字游民旅居指南",
title: "nomadro · 数字游民旅居指南",
description: "用一行代码环游世界 🌏",
type: "website",
locale: "zh_CN",
url: SITE,
siteName: "NomadFlow",
siteName: "nomadro",
},
twitter: {
card: "summary_large_image",
title: "NomadFlow · 数字游民旅居指南",
title: "nomadro · 数字游民旅居指南",
description: "用一行代码环游世界 🌏",
},
manifest: "/manifest.webmanifest",

View File

@ -1,4 +1,4 @@
"use client";
"use client";
import { useState } from "react";
import { useAuth } from "@/lib/auth";
@ -55,7 +55,7 @@ export default function LoginPage() {
<Link href="/" className="auth-back">← 返回首页</Link>
<div className="auth-header">
<span className="auth-logo-emoji">🌍</span>
<h1>{mode === "login" ? "欢迎回来" : "加入 NomadFlow"}</h1>
<h1>{mode === "login" ? "欢迎回来" : "加入 nomadro"}</h1>
<p>{mode === "login" ? "登录你的游民账户" : "开始你的旅居之旅"}</p>
</div>
@ -101,7 +101,7 @@ export default function LoginPage() {
<button className="btn btn-ghost" style={{ width: "100%" }} onClick={handleDemo} disabled={loading}>
🎮 一键体验演示账号
</button>
<p className="auth-hint">演示账号:demo@nomadflow.io / demo123</p>
<p className="auth-hint">演示账号:demo@nomadro.com / demo123</p>
</div>
</div>
</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";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "NomadFlow · 数字游民旅居指南",
short_name: "NomadFlow",
name: "nomadro · 数字游民旅居指南",
short_name: "nomadro",
description: "探索全球旅居生活,发现最适合数字游民的目的地",
start_url: "/",
display: "standalone",

View File

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

View File

@ -1,4 +1,4 @@
"use client";
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
@ -9,7 +9,7 @@ import type { Destination, ProfileStats, TripItem } from "@/lib/types";
import SiteShell from "@/components/SiteShell";
import FavoriteButton from "@/components/FavoriteButton";
const TRIP_KEY = "nomadflow-trip";
const TRIP_KEY = "nomadro-trip";
interface Badge {
emoji: string;
@ -21,7 +21,7 @@ interface Badge {
function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: number): Badge[] {
const tripMonths = trip.reduce((s, t) => s + t.months, 0);
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: trip.length >= 3 },
{ emoji: "📅", label: "长期旅居", desc: "行程总计 6+ 个月", earned: tripMonths >= 6 },

View File

@ -1,4 +1,4 @@
"use client";
"use client";
import { useEffect, useState } from "react";
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;
try {
await api.subscribe(email);
toast("订阅成功!欢迎加入 NomadFlow 社区 🎉");
toast("订阅成功!欢迎加入 nomadro 社区 🎉");
form.reset();
} 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 Link from "next/link";
import { useToast } from "@/lib/toast";
import type { Destination } from "@/lib/types";
const STORAGE_KEY = "nomadflow-trip";
const STORAGE_KEY = "nomadro-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",
@ -52,7 +52,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
const share = async () => {
const url = window.location.href;
if (navigator.share) {
await navigator.share({ title: `${dest.name} - NomadFlow`, url });
await navigator.share({ title: `${dest.name} - nomadro`, url });
} else {
await navigator.clipboard.writeText(url);
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";
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() {
return (
<footer className="footer">
<div className="container">
<div className="footer-grid">
<div className="footer-brand">
<Link href="/" className="logo"><span>NomadFlow</span></Link>
<BrandLogo />
<p>让每个人都能自由地工作和生活在这个星球上 🌏</p>
<div className="social-links">
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer" aria-label="Twitter">𝕏</a>
@ -21,19 +41,18 @@ export default function Footer() {
<h4>探索</h4>
<Link href="/#destinations">目的地</Link>
<Link href="/#spin">命运转盘</Link>
<Link href="/#cost-compare">省钱对比</Link>
<Link href="/#calculator">费用计算器</Link>
<Link href="/#wifi">网速评级</Link>
<Link href="/#visa">签证指南</Link>
<Link href="/#events">活动日历</Link>
<Link href="/#blog">博客</Link>
</div>
<div className="footer-links">
<h4>工具</h4>
<Link href="/#trip">行程规划</Link>
<Link href="/#savings">启动金目标</Link>
<Link href="/#flight">机票估算</Link>
<Link href="/#calculator">费用计算器</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>
</div>
<div className="footer-links">
@ -41,12 +60,12 @@ export default function Footer() {
<Link href="/login">登录 / 注册</Link>
<Link href="/profile">用户中心</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>
</div>
</div>
<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>
</div>
</div>

View File

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

View File

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

View File

@ -1,4 +1,4 @@
"use client";
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
@ -79,7 +79,7 @@ export default function Navbar() {
</linearGradient>
</defs>
</svg>
<span>NomadFlow</span>
<span>nomadro</span>
</Link>
<nav className={`nav-links${menuOpen ? " open" : ""}`}>
{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: "calculator", emoji: "🧮", label: "计算器" },
{ id: "cost-compare", emoji: "💰", label: "省钱" },
{ id: "savings", emoji: "🎯", label: "存款" },
{ id: "flight", emoji: "✈️", label: "机票" },
{ id: "wifi", emoji: "📶", label: "网速" },
{ id: "lifestyle", emoji: "💻", label: "生活" },
{ id: "visa", 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">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NomadFlow · 数字游民旅居指南</title>
<title>nomadro · 数字游民旅居指南</title>
<meta name="description" content="探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@ -24,7 +24,7 @@
</linearGradient>
</defs>
</svg>
<p class="loader-text">NomadFlow</p>
<p class="loader-text">nomadro</p>
<div class="loader-bar"><div class="loader-progress"></div></div>
</div>
</div>
@ -48,7 +48,7 @@
</linearGradient>
</defs>
</svg>
<span>NomadFlow</span>
<span>nomadro</span>
</a>
<nav class="nav-links">
<a href="#map" data-section="map">🗺️ 地图</a>
@ -844,7 +844,7 @@
<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>
<span>nomadro</span>
</a>
<p>让每个人都能自由地工作和生活在这个星球上 🌏</p>
<div class="social-links">
@ -873,11 +873,11 @@
<a href="#">关于我们</a>
<a href="#">合作伙伴</a>
<a href="#">隐私政策</a>
<a href="#">hello@nomadflow.io</a>
<a href="#">hello@nomadro.com</a>
</div>
</div>
<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>
</div>
</div>
@ -886,7 +886,7 @@
<!-- Toast -->
<div class="toast" id="toast">
<span class="toast-icon">✅</span>
<span class="toast-msg">订阅成功!欢迎加入 NomadFlow 社区 🎉</span>
<span class="toast-msg">订阅成功!欢迎加入 nomadro 社区 🎉</span>
</div>
<!-- 目的地详情弹窗 -->

View File

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