Add insurance, expenses, SIM guide, about page; unify trip storage

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-29 00:09:10 -05:00
parent d1bc4af87f
commit 341fcdd15e
15 changed files with 706 additions and 28 deletions

View File

@ -115,6 +115,10 @@ docker compose up -d
| 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 | | 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 |
| 🗣️ 生存短语 | 多语言常用语一键复制 | | 🗣️ 生存短语 | 多语言常用语一键复制 |
| 💬 反馈入口 | 浮动反馈表单 | | 💬 反馈入口 | 浮动反馈表单 |
| 🏥 保险指南 | 国际医疗保险方案对比 |
| 📒 支出账本 | 旅居花销分类记录与汇总 |
| 📱 上网方案 | 各城 SIM / eSIM 推荐 |
| 🌏 关于页 | `/about` 品牌介绍 |
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 | | 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
| ❓ FAQ | 搜索 + 展开/收起全部 | | ❓ FAQ | 搜索 + 展开/收起全部 |

View File

@ -0,0 +1,62 @@
import Link from "next/link";
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
export const metadata: Metadata = {
title: "关于 nomadro",
description: "nomadro —— 用一行代码环游世界,数字游民旅居指南",
};
export default function AboutPage() {
return (
<SiteShell>
<div className="legal-page about-page">
<nav className="detail-nav">
<Link href="/">← 返回首页</Link>
</nav>
<article className="legal-content">
<span className="section-tag">🌏 ABOUT</span>
<h1>关于 nomadro</h1>
<p className="legal-updated">用一行代码环游世界</p>
<section>
<h2>我们是谁</h2>
<p>
nomadro 是一个面向数字游民的旅居决策平台。我们把目的地数据、签证信息、费用估算、时区协作与社区经验集中在一起,帮你更快做出「下一站去哪」的决定。
</p>
</section>
<section>
<h2>能做什么</h2>
<ul className="about-list">
<li>🗺️ 探索全球热门游民城市与对比工具</li>
<li>🧮 计算生活成本、机票与启动金目标</li>
<li>📋 签证匹配、保险与上网方案参考</li>
<li>🗓️ 规划多城行程并分享给伙伴</li>
<li>🗣️ 落地短语、行李清单与活动日历</li>
</ul>
</section>
<section>
<h2>联系我们</h2>
<p>
合作与反馈:
<a href="mailto:hello@nomadro.com"> hello@nomadro.com</a>
</p>
<p>
开源仓库:
<a href="https://gitea.dsx2020.com/eric/nomadweb" target="_blank" rel="noopener noreferrer">
gitea.dsx2020.com/eric/nomadweb
</a>
</p>
</section>
<div className="about-actions">
<Link href="/#destinations" className="btn btn-primary">开始探索 🌍</Link>
<Link href="/login" className="btn btn-ghost">加入社区</Link>
</div>
</article>
</div>
</SiteShell>
);
}

View File

@ -6608,3 +6608,217 @@ img { max-width: 100%; display: block; }
.jetlag-card { grid-template-columns: 1fr; } .jetlag-card { grid-template-columns: 1fr; }
.feedback-fab { bottom: 160px; right: 16px; } .feedback-fab { bottom: 160px; right: 16px; }
} }
/* ===== Insurance ===== */
.insurance-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 18px;
}
.insurance-card {
padding: 22px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-lg);
backdrop-filter: blur(16px);
cursor: pointer;
transition: transform 0.3s, border-color 0.3s;
}
.insurance-card:hover { transform: translateY(-3px); }
.insurance-card.selected {
border-color: var(--accent-3);
box-shadow: 0 0 20px rgba(78, 205, 196, 0.15);
}
.insurance-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.insurance-emoji { font-size: 1.8rem; }
.insurance-price { font-size: 0.85rem; color: var(--accent-2); font-weight: 600; }
.insurance-card h3 { font-size: 1.05rem; margin-bottom: 6px; }
.insurance-best { font-size: 0.85rem; color: var(--accent-3); margin-bottom: 10px; }
.insurance-cover {
list-style: none;
padding: 0;
margin: 0 0 12px;
}
.insurance-cover li {
font-size: 0.85rem;
color: var(--text-secondary);
padding: 3px 0;
}
.insurance-note {
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.45;
}
.insurance-picked {
display: inline-block;
margin-top: 10px;
font-size: 0.75rem;
color: var(--accent-3);
font-weight: 600;
}
/* ===== Expense Tracker ===== */
.expense-card {
display: grid;
grid-template-columns: 0.95fr 1.05fr;
gap: 28px;
padding: 28px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
backdrop-filter: blur(20px);
}
.expense-form { display: flex; flex-direction: column; gap: 14px; }
.expense-cats { display: flex; flex-wrap: wrap; gap: 6px; }
.expense-total {
text-align: center;
margin-bottom: 16px;
}
.expense-total small {
display: block;
color: var(--text-secondary);
font-size: 0.8rem;
margin-bottom: 4px;
}
.expense-total strong { font-size: 2rem; }
.expense-bars { margin-bottom: 16px; }
.expense-bar-row {
display: grid;
grid-template-columns: 70px 1fr 70px;
gap: 8px;
align-items: center;
margin-bottom: 8px;
font-size: 0.8rem;
}
.expense-bar {
height: 6px;
background: rgba(255,255,255,0.06);
border-radius: 3px;
overflow: hidden;
}
.expense-bar i {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--accent-1), var(--accent-3));
border-radius: 3px;
}
.expense-list {
list-style: none;
padding: 0;
margin: 0 0 12px;
max-height: 280px;
overflow-y: auto;
}
.expense-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
font-size: 0.9rem;
}
.expense-list li div { flex: 1; min-width: 0; }
.expense-list li strong { display: block; }
.expense-list li small { color: var(--text-secondary); font-size: 0.75rem; }
.expense-amt { color: var(--accent-2); }
.expense-del {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 8px;
}
.expense-empty {
text-align: center;
color: var(--text-secondary);
padding: 20px !important;
border: none !important;
justify-content: center;
}
/* ===== SIM Guide ===== */
.sim-city-title {
text-align: center;
font-size: 1.15rem;
margin-bottom: 20px;
}
.sim-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.sim-card {
padding: 22px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-lg);
backdrop-filter: blur(16px);
transition: transform 0.3s;
}
.sim-card:hover { transform: translateY(-3px); }
.sim-emoji { font-size: 1.8rem; display: block; margin-bottom: 8px; }
.sim-card h4 { font-size: 1.05rem; margin-bottom: 10px; }
.sim-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.85rem;
margin-bottom: 10px;
flex-wrap: wrap;
}
.sim-price { color: var(--accent-2); font-weight: 600; }
.sim-card p { font-size: 0.85rem; color: var(--text-secondary); line-height: 1.45; }
/* ===== About ===== */
.about-list {
list-style: none;
padding: 0;
margin: 0;
}
.about-list li {
padding: 8px 0;
color: var(--text-secondary);
line-height: 1.5;
}
.about-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 32px;
}
@media (max-width: 768px) {
.expense-card { grid-template-columns: 1fr; }
.expense-bar-row { grid-template-columns: 60px 1fr 60px; }
}

View File

@ -8,8 +8,7 @@ import { api } from "@/lib/api";
import type { Destination, ProfileStats, TripItem } from "@/lib/types"; 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";
import { loadTrip } from "@/lib/tripStorage";
const TRIP_KEY = "nomadro-trip";
interface Badge { interface Badge {
emoji: string; emoji: string;
@ -52,8 +51,7 @@ export default function ProfilePage() {
setFavoriteDests(f); setFavoriteDests(f);
}).catch(() => {}).finally(() => setLoading(false)); }).catch(() => {}).finally(() => setLoading(false));
const saved = localStorage.getItem(TRIP_KEY); setTrip(loadTrip<TripItem>());
if (saved) setTrip(JSON.parse(saved));
}, [token, favorites, router]); }, [token, favorites, router]);
const badges = useMemo(() => getBadges(stats, trip, favoriteDests.length), [stats, trip, favoriteDests.length]); const badges = useMemo(() => getBadges(stats, trip, favoriteDests.length), [stats, trip, favoriteDests.length]);

View File

@ -11,6 +11,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: SITE, lastModified: now, changeFrequency: "weekly", priority: 1 }, { url: SITE, lastModified: now, changeFrequency: "weekly", priority: 1 },
{ url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, { url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 }, { url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
{ url: `${SITE}/about`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${SITE}/profile`, lastModified: now, changeFrequency: "monthly", priority: 0.4 }, { url: `${SITE}/profile`, lastModified: now, changeFrequency: "monthly", priority: 0.4 },
...DESTINATIONS.map((slug) => ({ ...DESTINATIONS.map((slug) => ({
url: `${SITE}/destinations/${slug}`, url: `${SITE}/destinations/${slug}`,

View File

@ -3,9 +3,9 @@
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 { loadTrip, saveTrip } from "@/lib/tripStorage";
import type { Destination, TripItem } from "@/lib/types";
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",
@ -37,14 +37,13 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
.slice(0, 3); .slice(0, 3);
const addToTrip = () => { const addToTrip = () => {
const saved = localStorage.getItem(STORAGE_KEY); const trip = loadTrip<TripItem>();
const trip = saved ? JSON.parse(saved) : []; if (trip.some((t) => t.slug === dest.slug)) {
if (trip.some((t: { slug: string }) => t.slug === dest.slug)) {
toast("该城市已在行程中", "info"); toast("该城市已在行程中", "info");
return; return;
} }
trip.push({ slug: dest.slug, name: dest.name, country: dest.country, emoji: dest.emoji, cost: dest.cost, months: 1 }); trip.push({ slug: dest.slug, name: dest.name, country: dest.country, emoji: dest.emoji, cost: dest.cost, months: 1 });
localStorage.setItem(STORAGE_KEY, JSON.stringify(trip)); saveTrip(trip);
setAdded(true); setAdded(true);
toast(`${dest.emoji} ${dest.name} 已加入行程 🗓️`); toast(`${dest.emoji} ${dest.name} 已加入行程 🗓️`);
}; };
@ -60,11 +59,8 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
}; };
useEffect(() => { useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY); const trip = loadTrip<TripItem>();
if (saved) { setAdded(trip.some((t) => t.slug === dest.slug));
const trip = JSON.parse(saved);
setAdded(trip.some((t: { slug: string }) => t.slug === dest.slug));
}
}, [dest.slug]); }, [dest.slug]);
return ( return (

View File

@ -0,0 +1,158 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useToast } from "@/lib/toast";
interface Expense {
id: string;
emoji: string;
label: string;
amount: number;
category: string;
date: string;
}
const CATS = [
{ key: "food", emoji: "🍜", label: "餐饮" },
{ key: "stay", emoji: "🏡", label: "住宿" },
{ key: "transport", emoji: "🚕", label: "交通" },
{ key: "cowork", emoji: "💻", label: "办公" },
{ key: "fun", emoji: "🎉", label: "娱乐" },
{ key: "other", emoji: "📦", label: "其他" },
];
const STORAGE_KEY = "nomadro-expenses";
export default function ExpenseTracker() {
const { toast } = useToast();
const [items, setItems] = useState<Expense[]>([]);
const [ready, setReady] = useState(false);
const [label, setLabel] = useState("");
const [amount, setAmount] = useState("");
const [category, setCategory] = useState("food");
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) setItems(JSON.parse(raw));
} catch { /* ignore */ }
setReady(true);
}, []);
useEffect(() => {
if (!ready) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}, [items, ready]);
const total = useMemo(() => items.reduce((s, i) => s + i.amount, 0), [items]);
const byCat = useMemo(() => {
const map: Record<string, number> = {};
items.forEach((i) => { map[i.category] = (map[i.category] || 0) + i.amount; });
return map;
}, [items]);
const add = (e: React.FormEvent) => {
e.preventDefault();
const n = parseFloat(amount);
if (!label.trim() || !n || n <= 0) return;
const cat = CATS.find((c) => c.key === category)!;
setItems((prev) => [
{
id: String(Date.now()),
emoji: cat.emoji,
label: label.trim(),
amount: Math.round(n * 100) / 100,
category,
date: new Date().toISOString().slice(0, 10),
},
...prev,
].slice(0, 100));
setLabel("");
setAmount("");
toast("支出已记录 💰");
};
const remove = (id: string) => setItems((prev) => prev.filter((i) => i.id !== id));
return (
<section className="section expense-section" id="expenses">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">📒 EXPENSES</span>
<h2>旅居支出小账本</h2>
<p>随手记一笔,月底对照预算心里有数</p>
</div>
<div className="expense-card reveal">
<form className="expense-form" onSubmit={add}>
<div className="calc-field">
<label>📝 项目</label>
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="如:咖啡馆下午茶" required />
</div>
<div className="calc-field">
<label>💴 金额 (¥)</label>
<input type="number" min="1" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="68" required />
</div>
<div className="calc-field">
<label>📂 分类</label>
<div className="expense-cats">
{CATS.map((c) => (
<button
key={c.key}
type="button"
className={`filter-btn${category === c.key ? " active" : ""}`}
onClick={() => setCategory(c.key)}
>
{c.emoji} {c.label}
</button>
))}
</div>
</div>
<button type="submit" className="btn btn-primary">➕ 记一笔</button>
</form>
<div className="expense-summary">
<div className="expense-total">
<small>本账本合计</small>
<strong className="gradient-text">¥{total.toLocaleString()}</strong>
</div>
<div className="expense-bars">
{CATS.map((c) => {
const v = byCat[c.key] || 0;
const pct = total > 0 ? (v / total) * 100 : 0;
if (v === 0) return null;
return (
<div key={c.key} className="expense-bar-row">
<span>{c.emoji} {c.label}</span>
<div className="expense-bar"><i style={{ width: `${pct}%` }} /></div>
<strong>¥{v.toLocaleString()}</strong>
</div>
);
})}
</div>
<ul className="expense-list">
{items.length === 0 && <li className="expense-empty">还没有记录,从今天开始记账吧 ✨</li>}
{items.slice(0, 8).map((i) => (
<li key={i.id}>
<span>{i.emoji}</span>
<div>
<strong>{i.label}</strong>
<small>{i.date}</small>
</div>
<strong className="expense-amt">¥{i.amount}</strong>
<button type="button" className="expense-del" onClick={() => remove(i.id)} aria-label="删除">✕</button>
</li>
))}
</ul>
{items.length > 0 && (
<button className="btn btn-ghost btn-sm" onClick={() => { setItems([]); toast("已清空账本", "info"); }}>
清空全部
</button>
)}
</div>
</div>
</div>
</section>
);
}

View File

@ -42,17 +42,18 @@ export default function Footer() {
<Link href="/#destinations">目的地</Link> <Link href="/#destinations">目的地</Link>
<Link href="/#spin">命运转盘</Link> <Link href="/#spin">命运转盘</Link>
<Link href="/#wifi">网速评级</Link> <Link href="/#wifi">网速评级</Link>
<Link href="/#insurance">保险指南</Link>
<Link href="/#sim">上网方案</Link>
<Link href="/#housing">住宿指南</Link> <Link href="/#housing">住宿指南</Link>
<Link href="/#phrases">生存短语</Link> <Link href="/#phrases">生存短语</Link>
<Link href="/#visa">签证指南</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="/#expenses">支出账本</Link>
<Link href="/#jetlag">时差估算</Link> <Link href="/#jetlag">时差估算</Link>
<Link href="/#savings">启动金目标</Link> <Link href="/#savings">启动金目标</Link>
<Link href="/#flight">机票估算</Link>
<Link href="/#calculator">费用计算器</Link> <Link href="/#calculator">费用计算器</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>
@ -60,9 +61,9 @@ export default function Footer() {
<h4>账户</h4> <h4>账户</h4>
<Link href="/login">登录 / 注册</Link> <Link href="/login">登录 / 注册</Link>
<Link href="/profile">用户中心</Link> <Link href="/profile">用户中心</Link>
<Link href="/about">关于 nomadro</Link>
<Link href="/privacy">隐私政策</Link> <Link href="/privacy">隐私政策</Link>
<a href="mailto:hello@nomadro.com">hello@nomadro.com</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> </div>
<div className="footer-bottom"> <div className="footer-bottom">

View File

@ -44,6 +44,9 @@ import JetLagEstimator from "./JetLagEstimator";
import HousingGuide from "./HousingGuide"; import HousingGuide from "./HousingGuide";
import Phrasebook from "./Phrasebook"; import Phrasebook from "./Phrasebook";
import FeedbackWidget from "./FeedbackWidget"; import FeedbackWidget from "./FeedbackWidget";
import InsuranceGuide from "./InsuranceGuide";
import ExpenseTracker from "./ExpenseTracker";
import SimGuide from "./SimGuide";
import KeyboardShortcuts from "./KeyboardShortcuts"; import KeyboardShortcuts from "./KeyboardShortcuts";
import BackToTop from "./BackToTop"; import BackToTop from "./BackToTop";
@ -121,6 +124,7 @@ export default function HomeClient(props: Props) {
<Calculator destinations={props.destinations} /> <Calculator destinations={props.destinations} />
<CostCompare destinations={props.destinations} /> <CostCompare destinations={props.destinations} />
<SavingsGoal destinations={props.destinations} /> <SavingsGoal destinations={props.destinations} />
<ExpenseTracker />
<FlightCost destinations={props.destinations} /> <FlightCost destinations={props.destinations} />
<CurrencyConverter /> <CurrencyConverter />
<WifiWorkScore destinations={props.destinations} /> <WifiWorkScore destinations={props.destinations} />
@ -128,8 +132,10 @@ export default function HomeClient(props: Props) {
<Lifestyle /> <Lifestyle />
<Charts {...props.charts} /> <Charts {...props.charts} />
<VisaSection visas={props.visas} /> <VisaSection visas={props.visas} />
<InsuranceGuide />
<CoworkingGuide /> <CoworkingGuide />
<HousingGuide destinations={props.destinations} /> <HousingGuide destinations={props.destinations} />
<SimGuide destinations={props.destinations} />
<SeasonGuide destinations={props.destinations} /> <SeasonGuide destinations={props.destinations} />
<PackingChecklist /> <PackingChecklist />
<Phrasebook destinations={props.destinations} /> <Phrasebook destinations={props.destinations} />

View File

@ -0,0 +1,106 @@
"use client";
import { useMemo, useState } from "react";
interface Plan {
id: string;
emoji: string;
name: string;
price: string;
cover: string[];
best: string;
note: string;
}
const PLANS: Plan[] = [
{
id: "safetywing", emoji: "🛡️", name: "SafetyWing Nomad",
price: "$45–80/月", best: "多数数字游民首选",
cover: ["门诊/住院", "紧急医疗运送", "旅行延误"],
note: "覆盖全球多数国家,按月订阅灵活",
},
{
id: "worldnomads", emoji: "🌍", name: "World Nomads",
price: "按行程报价", best: "多国跳跃旅行",
cover: ["冒险运动", "行李丢失", "紧急救援"],
note: "适合含户外活动的行程,按段购买",
},
{
id: "genki", emoji: "🇯🇵", name: "Genki",
price: "€60–100/月", best: "欧洲长期旅居",
cover: ["欧盟医疗", "牙科可选", "签证材料支持"],
note: "对申根签证申请材料较友好",
},
{
id: "local", emoji: "🏥", name: "目的地本地保险",
price: "因地而异", best: "停留 6 个月+",
cover: ["本地医院", "报销便捷", "费用通常更低"],
note: "需了解当地条款与语言,适合长期定居",
},
];
const FILTERS = [
{ key: "all", label: "🌏 全部" },
{ key: "flex", label: "📅 按月灵活" },
{ key: "trip", label: "✈️ 按行程" },
{ key: "long", label: "🏠 长期" },
];
export default function InsuranceGuide() {
const [filter, setFilter] = useState("all");
const [picked, setPicked] = useState<string | null>(null);
const filtered = useMemo(() => {
if (filter === "all") return PLANS;
if (filter === "flex") return PLANS.filter((p) => p.id === "safetywing" || p.id === "genki");
if (filter === "trip") return PLANS.filter((p) => p.id === "worldnomads");
return PLANS.filter((p) => p.id === "local" || p.id === "genki");
}, [filter]);
return (
<section className="section insurance-section" id="insurance">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🏥 INSURANCE</span>
<h2>旅居保险指南</h2>
<p>远程工作不等于裸奔——选对国际医疗保险再出发</p>
</div>
<div className="events-filters reveal">
{FILTERS.map((f) => (
<button
key={f.key}
className={`filter-btn${filter === f.key ? " active" : ""}`}
onClick={() => setFilter(f.key)}
>
{f.label}
</button>
))}
</div>
<div className="insurance-grid">
{filtered.map((p, i) => (
<div
key={p.id}
className={`insurance-card reveal${picked === p.id ? " selected" : ""}`}
style={{ transitionDelay: `${i * 0.08}s` }}
onClick={() => setPicked(p.id === picked ? null : p.id)}
>
<div className="insurance-top">
<span className="insurance-emoji">{p.emoji}</span>
<span className="insurance-price">{p.price}</span>
</div>
<h3>{p.name}</h3>
<p className="insurance-best">👍 {p.best}</p>
<ul className="insurance-cover">
{p.cover.map((c) => <li key={c}>✓ {c}</li>)}
</ul>
<p className="insurance-note">{p.note}</p>
{picked === p.id && <span className="insurance-picked">✓ 已标记关注</span>}
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -35,7 +35,8 @@ const CATEGORIES = [
{ key: "daily", label: "🧳 日常" }, { key: "daily", label: "🧳 日常" },
]; ];
const STORAGE_KEY = "nomadflow-packing"; const STORAGE_KEY = "nomadro-packing";
const LEGACY_KEY = "nomadflow-packing";
export default function PackingChecklist() { export default function PackingChecklist() {
const [checked, setChecked] = useState<Record<string, boolean>>({}); const [checked, setChecked] = useState<Record<string, boolean>>({});
@ -44,8 +45,14 @@ export default function PackingChecklist() {
useEffect(() => { useEffect(() => {
try { try {
const saved = localStorage.getItem(STORAGE_KEY); const saved = localStorage.getItem(STORAGE_KEY) || localStorage.getItem(LEGACY_KEY);
if (saved) setChecked(JSON.parse(saved)); if (saved) {
setChecked(JSON.parse(saved));
if (!localStorage.getItem(STORAGE_KEY) && localStorage.getItem(LEGACY_KEY)) {
localStorage.setItem(STORAGE_KEY, saved);
localStorage.removeItem(LEGACY_KEY);
}
}
} catch { /* ignore */ } } catch { /* ignore */ }
setReady(true); setReady(true);
}, []); }, []);

View File

@ -12,12 +12,15 @@ const SECTIONS = [
{ id: "calculator", emoji: "🧮", label: "计算器" }, { id: "calculator", emoji: "🧮", label: "计算器" },
{ id: "cost-compare", emoji: "💰", label: "省钱" }, { id: "cost-compare", emoji: "💰", label: "省钱" },
{ id: "savings", emoji: "🎯", label: "存款" }, { id: "savings", emoji: "🎯", label: "存款" },
{ id: "expenses", emoji: "📒", label: "账本" },
{ id: "flight", emoji: "✈️", label: "机票" }, { id: "flight", emoji: "✈️", label: "机票" },
{ id: "wifi", emoji: "📶", label: "网速" }, { id: "wifi", emoji: "📶", label: "网速" },
{ id: "lifestyle", emoji: "💻", label: "生活" }, { id: "lifestyle", emoji: "💻", label: "生活" },
{ id: "visa", emoji: "📋", label: "签证" }, { id: "visa", emoji: "📋", label: "签证" },
{ id: "insurance", emoji: "🏥", label: "保险" },
{ id: "coworking", emoji: "💻", label: "办公" }, { id: "coworking", emoji: "💻", label: "办公" },
{ id: "housing", emoji: "🏡", label: "住宿" }, { id: "housing", emoji: "🏡", label: "住宿" },
{ id: "sim", emoji: "📱", label: "上网" },
{ id: "season", emoji: "🌤️", label: "季节" }, { id: "season", emoji: "🌤️", label: "季节" },
{ id: "packing", emoji: "🧳", label: "行李" }, { id: "packing", emoji: "🧳", label: "行李" },
{ id: "phrases", emoji: "🗣️", label: "短语" }, { id: "phrases", emoji: "🗣️", label: "短语" },

View File

@ -0,0 +1,95 @@
"use client";
import { useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
interface SimPlan {
name: string;
emoji: string;
data: string;
price: string;
tip: string;
}
const SIMS: Record<string, SimPlan[]> = {
bali: [
{ name: "Telkomsel", emoji: "📶", data: "50GB / 30天", price: "约 ¥60", tip: "覆盖最好,机场可办" },
{ name: "XL / Axis", emoji: "📱", data: "40GB / 30天", price: "约 ¥40", tip: "性价比高,市区够用" },
{ name: "eSIM Airalo", emoji: "✈️", data: "按需充值", price: "约 $8 起", tip: "落地即用,免换卡" },
],
chiangmai: [
{ name: "AIS", emoji: "📶", data: "无限量套餐", price: "约 ¥80/月", tip: "网速稳,游民常用" },
{ name: "TrueMove", emoji: "📱", data: "大流量包", price: "约 ¥60/月", tip: "7-Eleven 随处可买" },
{ name: "eSIM Holafly", emoji: "✈️", data: "无限流量", price: "约 $19/周", tip: "适合短期跳岛" },
],
lisbon: [
{ name: "Vodafone PT", emoji: "📶", data: "20–50GB", price: "€15–25", tip: "覆盖全国,店内可办" },
{ name: "NOS / MEO", emoji: "📱", data: "Prepaid", price: "€10–20", tip: "预付费灵活" },
{ name: "eSIM Airalo", emoji: "✈️", data: "按需", price: "$5 起", tip: "抵达前先装好" },
],
barcelona: [
{ name: "Orange / Movistar", emoji: "📶", data: "20–50GB", price: "€10–20", tip: "申根区漫游友好" },
{ name: "Digi", emoji: "📱", data: "大流量便宜", price: "€5–15", tip: "预算优先之选" },
{ name: "eSIM", emoji: "✈️", data: "按需", price: "$6 起", tip: "免排队办卡" },
],
mexico: [
{ name: "Telcel", emoji: "📶", data: "Amigo 套餐", price: "$10–20", tip: "覆盖最广" },
{ name: "AT&T MX", emoji: "📱", data: "预付费", price: "$10–18", tip: "市中心信号好" },
{ name: "eSIM", emoji: "✈️", data: "按需", price: "$8 起", tip: "对接北美出差方便" },
],
tokyo: [
{ name: "IIJmio / SoftBank", emoji: "📶", data: "10–20GB", price: "¥2,000–4,000", tip: "便利店可买" },
{ name: "Sakura Mobile", emoji: "📱", data: "游民友好", price: "¥3,000+", tip: "支持英文客服" },
{ name: "eSIM Airalo", emoji: "✈️", data: "按需", price: "$6 起", tip: "机场落地即连" },
],
};
interface Props {
destinations: Destination[];
}
export default function SimGuide({ destinations }: Props) {
const [slug, setSlug] = useState(destinations[0]?.slug || "bali");
const plans = useMemo(() => SIMS[slug] || SIMS.bali, [slug]);
const dest = destinations.find((d) => d.slug === slug);
return (
<section className="section sim-section" id="sim">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">📱 SIM / eSIM</span>
<h2>当地上网方案</h2>
<p>落地第一件事:连上网。实体卡与 eSIM 怎么选</p>
</div>
<div className="housing-cities reveal">
{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>
<h3 className="sim-city-title reveal">{dest?.emoji} {dest?.name} 推荐套餐</h3>
<div className="sim-grid">
{plans.map((p, i) => (
<div key={p.name} className="sim-card reveal" style={{ transitionDelay: `${i * 0.08}s` }}>
<span className="sim-emoji">{p.emoji}</span>
<h4>{p.name}</h4>
<div className="sim-meta">
<span>📦 {p.data}</span>
<span className="sim-price">{p.price}</span>
</div>
<p>{p.tip}</p>
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -2,10 +2,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { loadTrip, saveTrip } from "@/lib/tripStorage";
import type { Destination, TripItem } from "@/lib/types"; import type { Destination, TripItem } from "@/lib/types";
const STORAGE_KEY = "nomadflow-trip";
interface Props { destinations: Destination[] } interface Props { destinations: Destination[] }
export default function TripPlanner({ destinations }: Props) { export default function TripPlanner({ destinations }: Props) {
@ -22,20 +21,19 @@ export default function TripPlanner({ destinations }: Props) {
const decoded = JSON.parse(atob(shared)) as TripItem[]; const decoded = JSON.parse(atob(shared)) as TripItem[];
if (Array.isArray(decoded) && decoded.length > 0) { if (Array.isArray(decoded) && decoded.length > 0) {
setTrip(decoded); setTrip(decoded);
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded)); saveTrip(decoded);
toast("已导入分享的行程 🗺️"); toast("已导入分享的行程 🗺️");
window.history.replaceState({}, "", window.location.pathname + "#trip"); window.history.replaceState({}, "", window.location.pathname + "#trip");
} }
} catch { /* ignore */ } } catch { /* ignore */ }
return; return;
} }
const saved = localStorage.getItem(STORAGE_KEY); setTrip(loadTrip<TripItem>());
if (saved) setTrip(JSON.parse(saved)); }, [toast]);
}, []);
const save = (items: TripItem[]) => { const save = (items: TripItem[]) => {
setTrip(items); setTrip(items);
localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); saveTrip(items);
}; };
const addCity = () => { const addCity = () => {

View File

@ -0,0 +1,29 @@
const TRIP_KEYS = ["nomadro-trip", "nomadflow-trip"] as const;
const PRIMARY = TRIP_KEYS[0];
export function loadTrip<T>(): T[] {
if (typeof window === "undefined") return [];
for (const key of TRIP_KEYS) {
const raw = localStorage.getItem(key);
if (!raw) continue;
try {
const data = JSON.parse(raw);
if (Array.isArray(data)) {
if (key !== PRIMARY) {
localStorage.setItem(PRIMARY, raw);
localStorage.removeItem(key);
}
return data;
}
} catch { /* ignore */ }
}
return [];
}
export function saveTrip(items: unknown[]) {
if (typeof window === "undefined") return;
localStorage.setItem(PRIMARY, JSON.stringify(items));
localStorage.removeItem("nomadflow-trip");
}
export const TRIP_STORAGE_KEY = PRIMARY;