Add visa wizard, coworking, packing, season guide, and UX polish

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-28 23:05:48 -05:00
parent 8d018d34e2
commit bf654f76e6
20 changed files with 1922 additions and 31 deletions

1
.gitignore vendored
View File

@ -31,6 +31,7 @@ Thumbs.db
scripts/remote_deploy.py
scripts/redeploy_server.py
scripts/upload_and_deploy.py
scripts/deploy_update.py
scripts/check_server.py
scripts/check_ports.py
scripts/find_caddy.py

View File

@ -101,9 +101,12 @@ docker compose up -d
| 💱 多币种换算 | CNY/USD/EUR/THB 等 8 种货币互转 |
| 📊 就绪度测评 | 5 题评估你的数字游民准备程度 |
| 💻 生活方式 | 交互式「游民一天」时间轴 |
| 📋 签证指南 | 难度筛选 + 进度条可视化 |
| 📝 博客 | 文章列表 + 阅读进度条详情页 |
| ❓ FAQ | 手风琴问答 |
| 📋 签证指南 | 难度筛选 + 签证智能匹配向导 |
| 💻 联合办公 | 精选 Co-working Space,城市/评分/网速排序 |
| 🌤️ 最佳月份 | 12 月气候适宜度热力表,避开雨季 |
| 🧳 行李清单 | 可勾选打包清单,localStorage 持久化 |
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
| ❓ FAQ | 搜索 + 展开/收起全部 |
### 交互与体验
@ -114,6 +117,8 @@ docker compose up -d
- 📊 顶部滚动进度条
- ⚖️ 可视化城市对比(评分圆环 + 条形图)
- 🔔 全局 Toast 通知
- 💡 游民小贴士浮动提示
- 🍪 Cookie 同意横幅
- ⌨️ 快捷键:`M` 智能匹配、`?` 帮助面板
- 📱 响应式布局,移动端适配
@ -121,7 +126,7 @@ docker compose up -d
- 🔐 登录 / 注册 / 一键演示账号
- ❤️ 收藏目的地(同步 API)
- 👤 用户中心:统计、收藏、行程展示
- 👤 用户中心:统计、收藏、行程、成就徽章
- 演示账号:`demo@nomadflow.io` / `demo123`
### 后端 API
@ -192,13 +197,14 @@ NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
**更新部署:**
```bash
# SSH 登录服务器后
# SSH 登录服务器后(当前开发分支为 dev1)
cd /opt/nomadweb
git pull origin main
git fetch origin && git checkout -B dev1 origin/dev1
export DOMAIN=nomadweb.nomadro.com
export NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1
export NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
export CORS_ORIGINS=https://nomadweb.nomadro.com
bash scripts/deploy-production.sh
docker compose -f docker-compose.prod.yml up -d --build
```
或使用 Docker Compose 生产配置:

View File

@ -4,22 +4,32 @@ import { notFound } from "next/navigation";
import SiteShell from "@/components/SiteShell";
import BlogMarkdown from "@/components/BlogMarkdown";
import BlogReadingProgress from "@/components/BlogReadingProgress";
import ShareButton from "@/components/ShareButton";
export default async function BlogDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let post;
let allPosts;
try {
post = await api.getBlogPost(slug);
[post, allPosts] = await Promise.all([
api.getBlogPost(slug),
api.getBlog(),
]);
} catch {
notFound();
}
const related = allPosts
.filter((p) => p.slug !== slug && p.tags.some((t) => post.tags.includes(t)))
.slice(0, 3);
return (
<SiteShell>
<BlogReadingProgress />
<div className="detail-page">
<nav className="detail-nav">
<Link href="/#blog">← 返回博客</Link>
<ShareButton title={post.title} compact />
</nav>
<article className="blog-detail">
<div className="blog-detail-header">
@ -38,6 +48,22 @@ export default async function BlogDetailPage({ params }: { params: Promise<{ slu
<BlogMarkdown content={post.content} />
</div>
</article>
{related.length > 0 && (
<section className="blog-related">
<h2>📚 相关文章</h2>
<div className="blog-related-grid">
{related.map((r) => (
<Link key={r.slug} href={`/blog/${r.slug}`} className="blog-related-card">
<span className="blog-related-emoji">{r.emoji}</span>
<h3>{r.title}</h3>
<p>{r.excerpt}</p>
<span className="blog-read-more">阅读 →</span>
</Link>
))}
</div>
</section>
)}
</div>
</SiteShell>
);

File diff suppressed because it is too large Load Diff

View File

@ -2,15 +2,18 @@
import { useState } from "react";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import Link from "next/link";
import SiteShell from "@/components/SiteShell";
export default function LoginPage() {
const { login, register, demoLogin } = useAuth();
const { toast } = useToast();
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [showPwd, setShowPwd] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@ -22,31 +25,43 @@ export default function LoginPage() {
? await login(email, password)
: await register(email, password, name);
setLoading(false);
if (ok) window.location.href = "/";
else setError(mode === "login" ? "邮箱或密码错误" : "注册失败,邮箱可能已存在");
if (ok) {
toast(mode === "login" ? "欢迎回来!🌍" : "注册成功,开启旅居之旅 ✨");
setTimeout(() => { window.location.href = "/"; }, 600);
} else {
setError(mode === "login" ? "邮箱或密码错误" : "注册失败,邮箱可能已存在");
}
};
const handleDemo = async () => {
setLoading(true);
const ok = await demoLogin();
setLoading(false);
if (ok) window.location.href = "/";
if (ok) {
toast("演示账号已登录 🎮");
setTimeout(() => { window.location.href = "/"; }, 600);
}
};
return (
<SiteShell>
<div className="auth-page">
<div className="auth-bg-shapes" aria-hidden="true">
<span className="auth-shape auth-shape-1">🌍</span>
<span className="auth-shape auth-shape-2">✈️</span>
<span className="auth-shape auth-shape-3">🏝️</span>
</div>
<div className="auth-card">
<Link href="/" className="auth-back">← 返回首页</Link>
<div className="auth-header">
<span style={{ fontSize: "3rem" }}>🌍</span>
<span className="auth-logo-emoji">🌍</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>
<button className={mode === "login" ? "active" : ""} onClick={() => { setMode("login"); setError(""); }}>登录</button>
<button className={mode === "register" ? "active" : ""} onClick={() => { setMode("register"); setError(""); }}>注册</button>
</div>
<form onSubmit={handleSubmit} className="auth-form">
@ -62,11 +77,23 @@ export default function LoginPage() {
</div>
<div className="calc-field">
<label>🔒 密码</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="至少 6 位" required minLength={6} />
<div className="auth-pwd-wrap">
<input
type={showPwd ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少 6 位"
required
minLength={6}
/>
<button type="button" className="auth-pwd-toggle" onClick={() => setShowPwd(!showPwd)} aria-label="显示密码">
{showPwd ? "🙈" : "👁️"}
</button>
</div>
</div>
{error && <p className="auth-error">{error}</p>}
<button type="submit" className="btn btn-primary" style={{ width: "100%" }} disabled={loading}>
{loading ? "处理中..." : mode === "login" ? "🚀 登录" : "✨ 注册"}
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}>
{loading ? <span className="auth-loading">处理中...</span> : (mode === "login" ? "🚀 登录" : "✨ 注册")}
</button>
</form>

View File

@ -5,10 +5,17 @@ export default function NotFound() {
return (
<SiteShell>
<div className="not-found-page">
<span className="not-found-emoji">🧭</span>
<h1>404</h1>
<div className="not-found-compass" aria-hidden="true">
<span className="compass-ring">🧭</span>
<span className="compass-plane">✈️</span>
</div>
<h1 className="not-found-title">404</h1>
<p>哎呀,这片海域没有标记的岛屿</p>
<Link href="/" className="btn btn-primary">返回首页 🏠</Link>
<p className="not-found-sub">你可能走错了航线,让我们帮你回到正轨</p>
<div className="not-found-actions">
<Link href="/" className="btn btn-primary">返回首页 🏠</Link>
<Link href="/#destinations" className="btn btn-ghost">探索目的地 🌍</Link>
</div>
</div>
</SiteShell>
);

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
@ -11,6 +11,25 @@ import FavoriteButton from "@/components/FavoriteButton";
const TRIP_KEY = "nomadflow-trip";
interface Badge {
emoji: string;
label: string;
desc: string;
earned: boolean;
}
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: "收藏 3+ 目的地", earned: favCount >= 3 },
{ emoji: "🗺️", label: "行程达人", desc: "规划 3+ 城市行程", earned: trip.length >= 3 },
{ emoji: "📅", label: "长期旅居", desc: "行程总计 6+ 个月", earned: tripMonths >= 6 },
{ emoji: "🌍", label: "环球游民", desc: "探索 5+ 城市", earned: (stats?.destinations_explored ?? 0) >= 5 },
{ emoji: "👑", label: "资深游民", desc: "2024 年前加入", earned: parseInt(stats?.member_since ?? "2026") <= 2024 },
];
}
export default function ProfilePage() {
const { user, token, favorites, logout } = useAuth();
const router = useRouter();
@ -37,6 +56,9 @@ export default function ProfilePage() {
if (saved) setTrip(JSON.parse(saved));
}, [token, favorites, router]);
const badges = useMemo(() => getBadges(stats, trip, favoriteDests.length), [stats, trip, favoriteDests.length]);
const earnedCount = badges.filter((b) => b.earned).length;
if (!user) return null;
return (
@ -72,12 +94,26 @@ export default function ProfilePage() {
<span>加入年份</span>
</div>
<div className="profile-stat-card">
<span className="stat-emoji">🧮</span>
<Link href="/#calculator" className="profile-link">费用计算器 →</Link>
<span className="stat-emoji">🏅</span>
<strong>{earnedCount}/{badges.length}</strong>
<span>成就徽章</span>
</div>
</div>
)}
<section className="profile-badges">
<h2>🏅 游民成就</h2>
<div className="badge-grid">
{badges.map((b) => (
<div key={b.label} className={`badge-card${b.earned ? " earned" : " locked"}`} title={b.desc}>
<span className="badge-emoji">{b.earned ? b.emoji : "🔒"}</span>
<strong>{b.label}</strong>
<small>{b.desc}</small>
</div>
))}
</div>
</section>
<section className="profile-favorites">
<h2>❤️ 我的收藏</h2>
{loading ? (
@ -138,6 +174,7 @@ export default function ProfilePage() {
<div className="profile-quick-grid">
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
<Link href="/#visa" className="quick-card">📋 签证指南</Link>
<Link href="/#coworking" 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>

View File

@ -1,7 +1,23 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { BlogPost } from "@/lib/types";
export default function BlogSection({ posts }: { posts: BlogPost[] }) {
const allTags = useMemo(() => {
const tags = new Set<string>();
posts.forEach((p) => p.tags.forEach((t) => tags.add(t)));
return Array.from(tags);
}, [posts]);
const [activeTag, setActiveTag] = useState("all");
const filtered = useMemo(() => {
if (activeTag === "all") return posts;
return posts.filter((p) => p.tags.includes(activeTag));
}, [posts, activeTag]);
return (
<section className="section blog-section" id="blog">
<div className="container">
@ -10,8 +26,22 @@ export default function BlogSection({ posts }: { posts: BlogPost[] }) {
<h2>游民博客</h2>
<p>深度攻略、签证指南与旅居经验分享</p>
</div>
{allTags.length > 0 && (
<div className="blog-filters reveal">
<button className={`filter-btn${activeTag === "all" ? " active" : ""}`} onClick={() => setActiveTag("all")}>
📚 全部
</button>
{allTags.map((t) => (
<button key={t} className={`filter-btn${activeTag === t ? " active" : ""}`} onClick={() => setActiveTag(t)}>
{t}
</button>
))}
</div>
)}
<div className="blog-grid">
{posts.map((post) => (
{filtered.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">
@ -27,6 +57,9 @@ export default function BlogSection({ posts }: { posts: BlogPost[] }) {
</Link>
))}
</div>
{filtered.length === 0 && (
<p className="dest-empty reveal">该分类暂无文章</p>
)}
</div>
</section>
);

View File

@ -1,11 +1,20 @@
"use client";
import { useEffect, useState } from "react";
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 [active, setActive] = useState(0);
const [paused, setPaused] = useState(false);
useEffect(() => {
if (testimonials.length <= 1 || paused) return;
const t = setInterval(() => setActive((i) => (i + 1) % testimonials.length), 4500);
return () => clearInterval(t);
}, [testimonials.length, paused]);
const handleSubscribe = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@ -18,6 +27,8 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
} catch { /* ignore */ }
};
const t = testimonials[active];
return (
<>
<section className="section community" id="community">
@ -27,9 +38,14 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
<h2>游民心声</h2>
<p>来自全球数字游民的真实故事</p>
</div>
<div className="testimonials">
{testimonials.map((t) => (
<div key={t.id} className="testimonial-card reveal">
{t && (
<div
className="testimonial-carousel reveal"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div className="testimonial-featured" key={t.id}>
<div className="testimonial-avatar">{t.avatar}</div>
<div className="testimonial-content">
<div className="testimonial-stars">{"⭐".repeat(t.rating)}</div>
@ -40,8 +56,35 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
</div>
</div>
</div>
<div className="testimonial-dots">
{testimonials.map((item, i) => (
<button
key={item.id}
className={`testimonial-dot${i === active ? " active" : ""}`}
onClick={() => setActive(i)}
aria-label={`评价 ${i + 1}`}
/>
))}
</div>
</div>
)}
<div className="testimonials testimonials-grid">
{testimonials.map((item) => (
<div key={item.id} className="testimonial-card reveal">
<div className="testimonial-avatar">{item.avatar}</div>
<div className="testimonial-content">
<div className="testimonial-stars">{"⭐".repeat(item.rating)}</div>
<p>&ldquo;{item.content}&rdquo;</p>
<div className="testimonial-author">
<strong>{item.author}</strong>
<span>{item.role}</span>
</div>
</div>
</div>
))}
</div>
<div className="cta-banner reveal">
<div className="cta-content">
<h2>准备好开始你的旅居之旅了吗? 🌍</h2>

View File

@ -0,0 +1,78 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { COWORKING_SPACES } from "@/lib/coworking";
const CITY_FILTERS = [
{ key: "all", label: "🌏 全部" },
{ key: "bali", label: "🏝️ 巴厘岛" },
{ key: "chiangmai", label: "🏔️ 清迈" },
{ key: "lisbon", label: "🌊 里斯本" },
{ key: "barcelona", label: "🏖️ 巴塞罗那" },
{ key: "mexico", label: "🌃 墨西哥城" },
{ key: "tokyo", label: "🗼 东京" },
];
export default function CoworkingGuide() {
const [filter, setFilter] = useState("all");
const [sort, setSort] = useState<"rating" | "wifi" | "price">("rating");
const filtered = useMemo(() => {
let list = filter === "all" ? [...COWORKING_SPACES] : COWORKING_SPACES.filter((s) => s.slug === filter);
if (sort === "wifi") list.sort((a, b) => b.wifi - a.wifi);
else if (sort === "price") list.sort((a, b) => parseInt(a.price.replace(/[^0-9]/g, "")) - parseInt(b.price.replace(/[^0-9]/g, "")));
else list.sort((a, b) => b.rating - a.rating);
return list;
}, [filter, sort]);
return (
<section className="section coworking-section" id="coworking">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">💻 COWORKING</span>
<h2>联合办公空间指南</h2>
<p>精选全球游民热门城市的 Co-working Space,高速 WiFi 与社区氛围兼备</p>
</div>
<div className="coworking-controls reveal">
<div className="coworking-filters">
{CITY_FILTERS.map((f) => (
<button key={f.key} className={`filter-btn${filter === f.key ? " active" : ""}`} onClick={() => setFilter(f.key)}>
{f.label}
</button>
))}
</div>
<select className="coworking-sort" value={sort} onChange={(e) => setSort(e.target.value as typeof sort)}>
<option value="rating">⭐ 评分优先</option>
<option value="wifi">📶 网速优先</option>
<option value="price">💰 价格优先</option>
</select>
</div>
<div className="coworking-grid">
{filtered.map((space, i) => (
<div key={space.id} className="coworking-card reveal" style={{ transitionDelay: `${(i % 4) * 0.08}s` }}>
<div className="coworking-card-top">
<span className="coworking-emoji">{space.emoji}</span>
<div className="coworking-rating">
<span>⭐ {space.rating}</span>
<span className="coworking-wifi">📶 {space.wifi}Mbps</span>
</div>
</div>
<h3>{space.name}</h3>
<p className="coworking-city">{space.city} · {space.vibe}</p>
<div className="coworking-price">{space.price}</div>
<div className="coworking-perks">
{space.perks.map((p) => <span key={p} className="coworking-perk">{p}</span>)}
</div>
<Link href={`/destinations/${space.slug}`} className="coworking-link">
查看 {space.city} 详情 →
</Link>
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -1,11 +1,24 @@
"use client";
import { useState } from "react";
import { useMemo, 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);
const [search, setSearch] = useState("");
const sorted = useMemo(() => [...faqs].sort((a, b) => a.order - b.order), [faqs]);
const filtered = useMemo(() => {
if (!search.trim()) return sorted;
const q = search.toLowerCase();
return sorted.filter((f) => f.question.toLowerCase().includes(q) || f.answer.toLowerCase().includes(q));
}, [sorted, search]);
const expandAll = () => setActive("__all__");
const collapseAll = () => setActive(null);
const isOpen = (id: string) => active === "__all__" || active === id;
return (
<section className="section faq-section" id="faq">
@ -15,9 +28,27 @@ export default function FAQSection({ faqs }: { faqs: FAQ[] }) {
<h2>常见问题</h2>
<p>关于数字游民生活,你可能想知道的一切</p>
</div>
<div className="faq-toolbar reveal">
<div className="faq-search-wrap">
<span className="faq-search-icon">🔍</span>
<input
className="faq-search"
placeholder="搜索问题,如「签证」「保险」「税务」..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && <button className="faq-search-clear" onClick={() => setSearch("")}>✕</button>}
</div>
<div className="faq-toolbar-actions">
<button className="btn btn-ghost btn-sm" onClick={expandAll}>展开全部</button>
<button className="btn btn-ghost btn-sm" onClick={collapseAll}>收起全部</button>
</div>
</div>
<div className="faq-list">
{sorted.map((f) => (
<div key={f.id} className={`faq-item reveal${active === f.id ? " active" : ""}`}>
{filtered.map((f) => (
<div key={f.id} className={`faq-item reveal${isOpen(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>
@ -25,6 +56,9 @@ export default function FAQSection({ faqs }: { faqs: FAQ[] }) {
<div className="faq-answer"><p>{f.answer}</p></div>
</div>
))}
{filtered.length === 0 && (
<p className="faq-empty">没有找到相关问题,试试其他关键词 🔎</p>
)}
</div>
</div>
</section>

View File

@ -30,6 +30,10 @@ import CurrencyConverter from "./CurrencyConverter";
import NomadScore from "./NomadScore";
import CostCompare from "./CostCompare";
import CookieConsent from "./CookieConsent";
import CoworkingGuide from "./CoworkingGuide";
import PackingChecklist from "./PackingChecklist";
import SeasonGuide from "./SeasonGuide";
import NomadTips from "./NomadTips";
import KeyboardShortcuts from "./KeyboardShortcuts";
import BackToTop from "./BackToTop";
@ -108,6 +112,9 @@ export default function HomeClient(props: Props) {
<Lifestyle />
<Charts {...props.charts} />
<VisaSection visas={props.visas} />
<CoworkingGuide />
<SeasonGuide destinations={props.destinations} />
<PackingChecklist />
<Tools tools={props.tools} />
<BlogSection posts={props.blog} />
<FAQSection faqs={props.faqs} />
@ -119,6 +126,7 @@ export default function HomeClient(props: Props) {
<DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} />
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
<CookieConsent />
<NomadTips />
{compareList.length > 0 && (
<div className="compare-bar">

View File

@ -0,0 +1,48 @@
"use client";
import { useEffect, useState } from "react";
const TIPS = [
{ emoji: "📶", text: "入住前用 Speedtest 测网速,低于 20Mbps 慎重签约" },
{ emoji: "💳", text: "准备一张无外汇手续费的信用卡,省下不少换汇成本" },
{ emoji: "🏥", text: "出发前购买 SafetyWing 等国际医疗保险,月费约 $40+" },
{ emoji: "🕐", text: "选时区重叠 ≥4 小时的城市,远程协作更轻松" },
{ emoji: "🛂", text: "护照有效期建议预留 6 个月以上,避免登机被拒" },
{ emoji: "☕", text: "先订 1 周 Airbnb 探路,再决定长期租房" },
];
export default function NomadTips() {
const [idx, setIdx] = useState(0);
const [visible, setVisible] = useState(false);
useEffect(() => {
const show = setTimeout(() => setVisible(true), 8000);
return () => clearTimeout(show);
}, []);
useEffect(() => {
if (!visible) return;
const t = setInterval(() => setIdx((i) => (i + 1) % TIPS.length), 6000);
return () => clearInterval(t);
}, [visible]);
if (!visible) return null;
const tip = TIPS[idx];
return (
<div className="nomad-tips" role="status">
<button className="nomad-tips-close" onClick={() => setVisible(false)} aria-label="关闭">✕</button>
<span className="nomad-tips-emoji">{tip.emoji}</span>
<div className="nomad-tips-body">
<strong>游民小贴士</strong>
<p key={idx}>{tip.text}</p>
</div>
<div className="nomad-tips-dots">
{TIPS.map((_, i) => (
<span key={i} className={i === idx ? "active" : ""} />
))}
</div>
</div>
);
}

View File

@ -0,0 +1,125 @@
"use client";
import { useEffect, useMemo, useState } from "react";
interface PackItem {
id: string;
emoji: string;
label: string;
category: string;
}
const ITEMS: PackItem[] = [
{ id: "laptop", emoji: "💻", label: "笔记本电脑 + 充电器", category: "tech" },
{ id: "adapter", emoji: "🔌", label: "全球旅行转接插头", category: "tech" },
{ id: "headset", emoji: "🎧", label: "降噪耳机", category: "tech" },
{ id: "powerbank", emoji: "🔋", label: "移动电源", category: "tech" },
{ id: "sim", emoji: "📱", label: "eSIM / 本地 SIM 卡", category: "tech" },
{ id: "vpn", emoji: "🔐", label: "VPN 订阅已开通", category: "tech" },
{ id: "passport", emoji: "🛂", label: "护照(有效期 > 6 个月)", category: "docs" },
{ id: "visa", emoji: "📋", label: "签证 / 电子签确认", category: "docs" },
{ id: "insurance", emoji: "🏥", label: "国际旅行保险", category: "docs" },
{ id: "cards", emoji: "💳", label: "银行卡 + 备用卡", category: "docs" },
{ id: "vaccine", emoji: "💉", label: "疫苗证明(如需要)", category: "docs" },
{ id: "clothes", emoji: "👕", label: "轻便衣物 7 天量", category: "daily" },
{ id: "shoes", emoji: "👟", label: "舒适步行鞋", category: "daily" },
{ id: "medicine", emoji: "💊", label: "常用药品", category: "daily" },
{ id: "toiletry", emoji: "🧴", label: "洗漱用品(旅行装)", category: "daily" },
{ id: "umbrella", emoji: "☂️", label: "折叠伞 / 防晒", category: "daily" },
];
const CATEGORIES = [
{ key: "all", label: "📦 全部" },
{ key: "tech", label: "💻 数码" },
{ key: "docs", label: "📄 证件" },
{ key: "daily", label: "🧳 日常" },
];
const STORAGE_KEY = "nomadflow-packing";
export default function PackingChecklist() {
const [checked, setChecked] = useState<Record<string, boolean>>({});
const [filter, setFilter] = useState("all");
const [ready, setReady] = useState(false);
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) setChecked(JSON.parse(saved));
} catch { /* ignore */ }
setReady(true);
}, []);
useEffect(() => {
if (!ready) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(checked));
}, [checked, ready]);
const filtered = useMemo(
() => (filter === "all" ? ITEMS : ITEMS.filter((i) => i.category === filter)),
[filter]
);
const done = Object.values(checked).filter(Boolean).length;
const total = ITEMS.length;
const pct = Math.round((done / total) * 100);
const toggle = (id: string) => setChecked((prev) => ({ ...prev, [id]: !prev[id] }));
const reset = () => setChecked({});
const checkAll = () => {
const all: Record<string, boolean> = {};
ITEMS.forEach((i) => { all[i.id] = true; });
setChecked(all);
};
return (
<section className="section packing-section" id="packing">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🧳 PACKING</span>
<h2>数字游民行李清单</h2>
<p>出发前逐项勾选,确保远程工作与旅居无忧</p>
</div>
<div className="packing-card reveal">
<div className="packing-progress">
<div className="packing-progress-info">
<span>完成度</span>
<strong className="gradient-text">{pct}%</strong>
<span className="packing-count">{done}/{total}</span>
</div>
<div className="packing-bar">
<div className="packing-bar-fill" style={{ width: `${pct}%` }} />
</div>
{pct === 100 && <p className="packing-ready">🎉 全部就绪,可以出发了!</p>}
</div>
<div className="packing-filters">
{CATEGORIES.map((c) => (
<button key={c.key} className={`filter-btn${filter === c.key ? " active" : ""}`} onClick={() => setFilter(c.key)}>
{c.label}
</button>
))}
<div className="packing-actions">
<button className="btn btn-ghost btn-sm" onClick={checkAll}>全选</button>
<button className="btn btn-ghost btn-sm" onClick={reset}>重置</button>
</div>
</div>
<ul className="packing-list">
{filtered.map((item) => (
<li key={item.id}>
<label className={`packing-item${checked[item.id] ? " checked" : ""}`}>
<input type="checkbox" checked={!!checked[item.id]} onChange={() => toggle(item.id)} />
<span className="packing-check" aria-hidden="true">{checked[item.id] ? "✓" : ""}</span>
<span className="packing-emoji">{item.emoji}</span>
<span className="packing-label">{item.label}</span>
</label>
</li>
))}
</ul>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,100 @@
"use client";
import { useMemo, useState, type CSSProperties } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types";
const MONTHS = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];
/** 0=避开 1=一般 2=推荐 3=最佳 */
const SEASON_MAP: Record<string, number[]> = {
bali: [2, 2, 3, 3, 3, 2, 1, 1, 2, 3, 3, 2],
lisbon: [1, 1, 2, 3, 3, 3, 2, 2, 3, 3, 2, 1],
chiangmai: [3, 3, 2, 1, 0, 0, 0, 0, 1, 2, 3, 3],
mexico: [2, 2, 3, 3, 2, 1, 0, 0, 1, 2, 3, 3],
barcelona: [1, 1, 2, 3, 3, 3, 2, 2, 3, 3, 2, 1],
tokyo: [2, 2, 3, 3, 2, 1, 0, 0, 2, 3, 3, 2],
};
const LEVEL = [
{ label: "避开", color: "#FF6B6B", emoji: "🌧️" },
{ label: "一般", color: "#FFE66D", emoji: "☁️" },
{ label: "推荐", color: "#4ECDC4", emoji: "☀️" },
{ label: "最佳", color: "#6ee7b7", emoji: "🌟" },
];
export default function SeasonGuide({ destinations }: { destinations: Destination[] }) {
const [slug, setSlug] = useState(destinations[0]?.slug || "bali");
const nowMonth = new Date().getMonth();
const scores = SEASON_MAP[slug] || SEASON_MAP.bali;
const dest = destinations.find((d) => d.slug === slug);
const bestMonths = useMemo(
() => scores.map((s, i) => ({ i, s })).filter((x) => x.s === 3).map((x) => MONTHS[x.i]),
[scores]
);
return (
<section className="section season-section" id="season">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🌤️ SEASON</span>
<h2>最佳旅居月份</h2>
<p>避开雨季与旺季,选对时间开启旅居生活</p>
</div>
<div className="season-card reveal">
<div className="season-cities">
{destinations.map((d) => (
<button
key={d.slug}
className={`season-city-btn${slug === d.slug ? " active" : ""}`}
onClick={() => setSlug(d.slug)}
>
{d.emoji} {d.name}
</button>
))}
</div>
<div className="season-header-row">
<div>
<h3>{dest?.emoji} {dest?.name}</h3>
<p>最佳月份:{bestMonths.join(" · ") || "全年适宜"}</p>
</div>
<Link href={`/destinations/${slug}`} className="btn btn-ghost btn-sm">查看详情 →</Link>
</div>
<div className="season-grid">
{MONTHS.map((m, i) => {
const level = scores[i] ?? 1;
const info = LEVEL[level];
const isNow = i === nowMonth;
return (
<div
key={m}
className={`season-month${isNow ? " now" : ""}`}
style={{ "--season-color": info.color } as CSSProperties}
title={info.label}
>
<span className="season-month-emoji">{info.emoji}</span>
<strong>{m}</strong>
<small>{info.label}</small>
{isNow && <span className="season-now-tag">本月</span>}
</div>
);
})}
</div>
<div className="season-legend">
{LEVEL.map((l) => (
<span key={l.label} className="season-legend-item">
<i style={{ background: l.color }} /> {l.emoji} {l.label}
</span>
))}
</div>
</div>
</div>
</section>
);
}

View File

@ -11,6 +11,9 @@ const SECTIONS = [
{ id: "cost-compare", emoji: "💰", label: "省钱" },
{ id: "lifestyle", emoji: "💻", label: "生活" },
{ id: "visa", emoji: "📋", label: "签证" },
{ id: "coworking", emoji: "💻", label: "办公" },
{ id: "season", emoji: "🌤️", label: "季节" },
{ id: "packing", emoji: "🧳", label: "行李" },
{ id: "blog", emoji: "📝", label: "博客" },
];

View File

@ -0,0 +1,67 @@
"use client";
import { useState } from "react";
import { useToast } from "@/lib/toast";
interface Props {
title: string;
url?: string;
className?: string;
compact?: boolean;
}
export default function ShareButton({ title, url, className = "", compact = false }: Props) {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const getUrl = () => url ?? (typeof window !== "undefined" ? window.location.href : "");
const copyLink = async () => {
await navigator.clipboard.writeText(getUrl());
toast("链接已复制到剪贴板 📋");
setOpen(false);
};
const nativeShare = async () => {
const shareUrl = getUrl();
if (navigator.share) {
await navigator.share({ title, url: shareUrl });
setOpen(false);
} else {
await copyLink();
}
};
const shareTwitter = () => {
const shareUrl = encodeURIComponent(getUrl());
const text = encodeURIComponent(title);
window.open(`https://twitter.com/intent/tweet?text=${text}&url=${shareUrl}`, "_blank");
setOpen(false);
};
if (compact) {
return (
<button className={`share-btn-compact ${className}`} onClick={nativeShare} aria-label="分享">
📤
</button>
);
}
return (
<div className={`share-dropdown ${className}`}>
<button className="btn btn-ghost share-trigger" onClick={() => setOpen(!open)}>
📤 分享
</button>
{open && (
<>
<div className="share-backdrop" onClick={() => setOpen(false)} />
<div className="share-menu">
<button onClick={nativeShare}>📱 系统分享</button>
<button onClick={copyLink}>📋 复制链接</button>
<button onClick={shareTwitter}>🐦 分享到 X</button>
</div>
</>
)}
</div>
);
}

View File

@ -2,6 +2,7 @@
import { useMemo, useState } from "react";
import type { Visa } from "@/lib/types";
import VisaWizard from "./VisaWizard";
const FILTERS = [
{ key: "all", label: "🌏 全部" },
@ -75,6 +76,7 @@ export default function VisaSection({ visas }: { visas: Visa[] }) {
{filtered.length === 0 && (
<p className="dest-empty reveal">暂无匹配的签证类型,试试其他筛选条件</p>
)}
<VisaWizard visas={visas} />
</div>
</section>
);

View File

@ -0,0 +1,179 @@
"use client";
import { useMemo, useState } from "react";
import type { Visa } from "@/lib/types";
interface Props {
visas: Visa[];
}
const INCOME_OPTIONS = [
{ key: "low", label: "💰 低于 ¥8,000/月", max: 8000 },
{ key: "mid", label: "💼 ¥8,000 – ¥20,000/月", max: 20000 },
{ key: "high", label: "🚀 ¥20,000 – ¥50,000/月", max: 50000 },
{ key: "premium", label: "👑 高于 ¥50,000/月", max: Infinity },
];
const DURATION_OPTIONS = [
{ key: "short", label: "⏱️ 1-3 个月", months: 3 },
{ key: "medium", label: "📅 3-12 个月", months: 12 },
{ key: "long", label: "🏠 1 年以上", months: 24 },
];
const REGION_OPTIONS = [
{ key: "any", label: "🌏 不限" },
{ key: "europe", label: "🏰 欧洲" },
{ key: "sea", label: "🌴 东南亚" },
{ key: "latam", label: "🌮 拉美" },
];
const VISA_INCOME: Record<string, number> = {
"1": 6000, "2": 17000, "3": 2000, "4": 47000, "5": 18000, "6": 28000,
};
const VISA_REGION: Record<string, string> = {
"1": "europe", "2": "europe", "3": "sea", "4": "sea", "5": "latam", "6": "europe",
};
export default function VisaWizard({ visas }: Props) {
const [step, setStep] = useState(0);
const [income, setIncome] = useState("");
const [duration, setDuration] = useState("");
const [region, setRegion] = useState("any");
const [showResults, setShowResults] = useState(false);
const results = useMemo(() => {
if (!income || !duration) return [];
const incomeOpt = INCOME_OPTIONS.find((o) => o.key === income)!;
const durOpt = DURATION_OPTIONS.find((o) => o.key === duration)!;
return visas
.map((v) => {
let score = 0;
const reqIncome = VISA_INCOME[v.id] ?? 15000;
const vRegion = VISA_REGION[v.id] ?? "any";
if (reqIncome <= incomeOpt.max) score += 40;
else if (reqIncome <= incomeOpt.max * 1.5) score += 20;
if (v.difficulty <= 35) score += 25;
else if (v.difficulty <= 55) score += 15;
else score += 5;
if (durOpt.months >= 12 && v.duration.includes("年")) score += 20;
else if (durOpt.months <= 3) score += v.duration.includes("天") ? 20 : 10;
else score += 15;
if (region === "any" || vRegion === region) score += 15;
return { visa: v, score: Math.min(score, 100) };
})
.filter((r) => r.score >= 30)
.sort((a, b) => b.score - a.score)
.slice(0, 3);
}, [visas, income, duration, region]);
const canNext = step === 0 ? !!income : step === 1 ? !!duration : true;
const handleFinish = () => {
setShowResults(true);
setStep(3);
};
const reset = () => {
setStep(0);
setIncome("");
setDuration("");
setRegion("any");
setShowResults(false);
};
return (
<div className="visa-wizard reveal">
<div className="visa-wizard-header">
<span className="section-tag">🧙 VISA WIZARD</span>
<h3>签证智能匹配</h3>
<p>回答 3 个问题,找到最适合你的远程工作签证</p>
</div>
{!showResults ? (
<div className="visa-wizard-body">
<div className="visa-wizard-steps">
{["月收入", "停留时长", "目的地"].map((label, i) => (
<div key={label} className={`wizard-step-dot${i <= step ? " active" : ""}${i < step ? " done" : ""}`}>
<span>{i < step ? "✓" : i + 1}</span>
<small>{label}</small>
</div>
))}
</div>
{step === 0 && (
<div className="wizard-options">
{INCOME_OPTIONS.map((o) => (
<button key={o.key} className={`wizard-option${income === o.key ? " selected" : ""}`} onClick={() => setIncome(o.key)}>
{o.label}
</button>
))}
</div>
)}
{step === 1 && (
<div className="wizard-options">
{DURATION_OPTIONS.map((o) => (
<button key={o.key} className={`wizard-option${duration === o.key ? " selected" : ""}`} onClick={() => setDuration(o.key)}>
{o.label}
</button>
))}
</div>
)}
{step === 2 && (
<div className="wizard-options">
{REGION_OPTIONS.map((o) => (
<button key={o.key} className={`wizard-option${region === o.key ? " selected" : ""}`} onClick={() => setRegion(o.key)}>
{o.label}
</button>
))}
</div>
)}
<div className="wizard-nav">
{step > 0 && <button className="btn btn-ghost" onClick={() => setStep(step - 1)}>← 上一步</button>}
{step < 2 ? (
<button className="btn btn-primary" disabled={!canNext} onClick={() => setStep(step + 1)}>下一步 →</button>
) : (
<button className="btn btn-primary" onClick={handleFinish}>查看推荐 ✨</button>
)}
</div>
</div>
) : (
<div className="visa-wizard-results">
{results.length === 0 ? (
<p className="wizard-empty">暂无完全匹配的签证,建议咨询专业移民顾问或放宽条件重试</p>
) : (
<div className="wizard-result-cards">
{results.map(({ visa, score }, i) => (
<div key={visa.id} className={`wizard-result-card${i === 0 ? " winner" : ""}`}>
{i === 0 && <span className="wizard-winner-badge">🏆 最佳匹配</span>}
<div className="wizard-result-top">
<span className="wizard-result-flag">{visa.flag}</span>
<div>
<h4>{visa.name}</h4>
<span className="wizard-match-score">{score}% 匹配</span>
</div>
</div>
<ul>
<li>💰 {visa.income_req}</li>
<li>📅 {visa.duration}</li>
<li>⏱️ {visa.approval_time}</li>
</ul>
</div>
))}
</div>
)}
<button className="btn btn-ghost" onClick={reset}>🔄 重新匹配</button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,60 @@
export interface CoworkingSpace {
id: string;
city: string;
slug: string;
emoji: string;
name: string;
price: string;
wifi: number;
rating: number;
perks: string[];
vibe: string;
}
export const COWORKING_SPACES: CoworkingSpace[] = [
{
id: "1", city: "巴厘岛", slug: "bali", emoji: "🏝️", name: "Hubud",
price: "¥180/天", wifi: 95, rating: 4.8,
perks: ["稻田景观", "瑜伽课", "社区活动"], vibe: "自然疗愈",
},
{
id: "2", city: "巴厘岛", slug: "bali", emoji: "🏝️", name: "Outpost",
price: "¥2,800/月", wifi: 98, rating: 4.9,
perks: ["泳池", "住宿套餐", "高速光纤"], vibe: "游民大本营",
},
{
id: "3", city: "清迈", slug: "chiangmai", emoji: "🏔️", name: "Punspace",
price: "¥60/天", wifi: 92, rating: 4.7,
perks: ["夜市步行", "空调充足", "打印扫描"], vibe: "性价比之王",
},
{
id: "4", city: "清迈", slug: "chiangmai", emoji: "🏔️", name: "CAMP",
price: "¥80/天", wifi: 100, rating: 4.8,
perks: ["商场内", "24h 开放", "美食广场"], vibe: "都市便利",
},
{
id: "5", city: "里斯本", slug: "lisbon", emoji: "🌊", name: "Second Home",
price: "€25/天", wifi: 150, rating: 4.9,
perks: ["植物花园", "活动丰富", "设计感"], vibe: "创意空间",
},
{
id: "6", city: "里斯本", slug: "lisbon", emoji: "🌊", name: "LACS",
price: "€200/月", wifi: 120, rating: 4.6,
perks: ["海滨位置", "会议室", "咖啡吧"], vibe: "海滨办公",
},
{
id: "7", city: "巴塞罗那", slug: "barcelona", emoji: "🏖️", name: "Aticco",
price: "€22/天", wifi: 140, rating: 4.7,
perks: ["屋顶露台", "活动日历", "会员网络"], vibe: "地中海风情",
},
{
id: "8", city: "墨西哥城", slug: "mexico", emoji: "🌃", name: "WeWork Reforma",
price: "$15/天", wifi: 110, rating: 4.5,
perks: ["市中心", "北美时区", "全球会员"], vibe: "国际连锁",
},
{
id: "9", city: "东京", slug: "tokyo", emoji: "🗼", name: "WeWork 涩谷",
price: "¥3,500/天", wifi: 200, rating: 4.6,
perks: ["极致网速", "地铁直达", "静音舱"], vibe: "高效都市",
},
];