diff --git a/README.md b/README.md index 515d00d..7e7274d 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ docker compose up -d | 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 | | 🗣️ 生存短语 | 多语言常用语一键复制 | | 💬 反馈入口 | 浮动反馈表单 | +| 🏥 保险指南 | 国际医疗保险方案对比 | +| 📒 支出账本 | 旅居花销分类记录与汇总 | +| 📱 上网方案 | 各城 SIM / eSIM 推荐 | +| 🌏 关于页 | `/about` 品牌介绍 | | 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 | | ❓ FAQ | 搜索 + 展开/收起全部 | diff --git a/frontend/src/app/about/page.tsx b/frontend/src/app/about/page.tsx new file mode 100644 index 0000000..73e7cbd --- /dev/null +++ b/frontend/src/app/about/page.tsx @@ -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 ( + +
+ +
+ 🌏 ABOUT +

关于 nomadro

+

用一行代码环游世界

+ +
+

我们是谁

+

+ nomadro 是一个面向数字游民的旅居决策平台。我们把目的地数据、签证信息、费用估算、时区协作与社区经验集中在一起,帮你更快做出「下一站去哪」的决定。 +

+
+ +
+

能做什么

+
    +
  • 🗺️ 探索全球热门游民城市与对比工具
  • +
  • 🧮 计算生活成本、机票与启动金目标
  • +
  • 📋 签证匹配、保险与上网方案参考
  • +
  • 🗓️ 规划多城行程并分享给伙伴
  • +
  • 🗣️ 落地短语、行李清单与活动日历
  • +
+
+ +
+

联系我们

+

+ 合作与反馈: + hello@nomadro.com +

+

+ 开源仓库: + + gitea.dsx2020.com/eric/nomadweb + +

+
+ +
+ 开始探索 🌍 + 加入社区 +
+
+
+
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index cef70fc..f32a2c7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -6608,3 +6608,217 @@ img { max-width: 100%; display: block; } .jetlag-card { grid-template-columns: 1fr; } .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; } +} diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index eb4fc76..d301b41 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -8,8 +8,7 @@ import { api } from "@/lib/api"; import type { Destination, ProfileStats, TripItem } from "@/lib/types"; import SiteShell from "@/components/SiteShell"; import FavoriteButton from "@/components/FavoriteButton"; - -const TRIP_KEY = "nomadro-trip"; +import { loadTrip } from "@/lib/tripStorage"; interface Badge { emoji: string; @@ -52,8 +51,7 @@ export default function ProfilePage() { setFavoriteDests(f); }).catch(() => {}).finally(() => setLoading(false)); - const saved = localStorage.getItem(TRIP_KEY); - if (saved) setTrip(JSON.parse(saved)); + setTrip(loadTrip()); }, [token, favorites, router]); const badges = useMemo(() => getBadges(stats, trip, favoriteDests.length), [stats, trip, favoriteDests.length]); diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts index 9e61b0e..5a08603 100644 --- a/frontend/src/app/sitemap.ts +++ b/frontend/src/app/sitemap.ts @@ -11,6 +11,7 @@ export default function sitemap(): MetadataRoute.Sitemap { { url: SITE, lastModified: now, changeFrequency: "weekly", priority: 1 }, { url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, { 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 }, ...DESTINATIONS.map((slug) => ({ url: `${SITE}/destinations/${slug}`, diff --git a/frontend/src/components/DestinationDetailClient.tsx b/frontend/src/components/DestinationDetailClient.tsx index 12c94d3..d484893 100644 --- a/frontend/src/components/DestinationDetailClient.tsx +++ b/frontend/src/components/DestinationDetailClient.tsx @@ -3,9 +3,9 @@ import { useEffect, useState } from "react"; import Link from "next/link"; 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 = { bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7", mexico: "UTC-6", barcelona: "UTC+1", tokyo: "UTC+9", @@ -37,14 +37,13 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props .slice(0, 3); const addToTrip = () => { - const saved = localStorage.getItem(STORAGE_KEY); - const trip = saved ? JSON.parse(saved) : []; - if (trip.some((t: { slug: string }) => t.slug === dest.slug)) { + const trip = loadTrip(); + if (trip.some((t) => t.slug === dest.slug)) { toast("该城市已在行程中", "info"); return; } trip.push({ slug: dest.slug, name: dest.name, country: dest.country, emoji: dest.emoji, cost: dest.cost, months: 1 }); - localStorage.setItem(STORAGE_KEY, JSON.stringify(trip)); + saveTrip(trip); setAdded(true); toast(`${dest.emoji} ${dest.name} 已加入行程 🗓️`); }; @@ -60,11 +59,8 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props }; useEffect(() => { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - const trip = JSON.parse(saved); - setAdded(trip.some((t: { slug: string }) => t.slug === dest.slug)); - } + const trip = loadTrip(); + setAdded(trip.some((t) => t.slug === dest.slug)); }, [dest.slug]); return ( diff --git a/frontend/src/components/ExpenseTracker.tsx b/frontend/src/components/ExpenseTracker.tsx new file mode 100644 index 0000000..3976dcf --- /dev/null +++ b/frontend/src/components/ExpenseTracker.tsx @@ -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([]); + 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 = {}; + 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 ( +
+
+
+ 📒 EXPENSES +

旅居支出小账本

+

随手记一笔,月底对照预算心里有数

+
+ +
+
+
+ + setLabel(e.target.value)} placeholder="如:咖啡馆下午茶" required /> +
+
+ + setAmount(e.target.value)} placeholder="68" required /> +
+
+ +
+ {CATS.map((c) => ( + + ))} +
+
+ +
+ +
+
+ 本账本合计 + ¥{total.toLocaleString()} +
+
+ {CATS.map((c) => { + const v = byCat[c.key] || 0; + const pct = total > 0 ? (v / total) * 100 : 0; + if (v === 0) return null; + return ( +
+ {c.emoji} {c.label} +
+ ¥{v.toLocaleString()} +
+ ); + })} +
+ +
    + {items.length === 0 &&
  • 还没有记录,从今天开始记账吧 ✨
  • } + {items.slice(0, 8).map((i) => ( +
  • + {i.emoji} +
    + {i.label} + {i.date} +
    + ¥{i.amount} + +
  • + ))} +
+ {items.length > 0 && ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index ce467cb..fe8ea1d 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -42,17 +42,18 @@ export default function Footer() { 目的地 命运转盘 网速评级 + 保险指南 + 上网方案 住宿指南 生存短语 - 签证指南 博客

工具

行程规划 + 支出账本 时差估算 启动金目标 - 机票估算 费用计算器 API 文档
@@ -60,9 +61,9 @@ export default function Footer() {

账户

登录 / 注册 用户中心 + 关于 nomadro 隐私政策 hello@nomadro.com - 开源仓库 ↗
diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx index 275e78e..518d972 100644 --- a/frontend/src/components/HomeClient.tsx +++ b/frontend/src/components/HomeClient.tsx @@ -44,6 +44,9 @@ import JetLagEstimator from "./JetLagEstimator"; import HousingGuide from "./HousingGuide"; import Phrasebook from "./Phrasebook"; import FeedbackWidget from "./FeedbackWidget"; +import InsuranceGuide from "./InsuranceGuide"; +import ExpenseTracker from "./ExpenseTracker"; +import SimGuide from "./SimGuide"; import KeyboardShortcuts from "./KeyboardShortcuts"; import BackToTop from "./BackToTop"; @@ -121,6 +124,7 @@ export default function HomeClient(props: Props) { + @@ -128,8 +132,10 @@ export default function HomeClient(props: Props) { + + diff --git a/frontend/src/components/InsuranceGuide.tsx b/frontend/src/components/InsuranceGuide.tsx new file mode 100644 index 0000000..84d9498 --- /dev/null +++ b/frontend/src/components/InsuranceGuide.tsx @@ -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(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 ( +
+
+
+ 🏥 INSURANCE +

旅居保险指南

+

远程工作不等于裸奔——选对国际医疗保险再出发

+
+ +
+ {FILTERS.map((f) => ( + + ))} +
+ +
+ {filtered.map((p, i) => ( +
setPicked(p.id === picked ? null : p.id)} + > +
+ {p.emoji} + {p.price} +
+

{p.name}

+

👍 {p.best}

+
    + {p.cover.map((c) =>
  • ✓ {c}
  • )} +
+

{p.note}

+ {picked === p.id && ✓ 已标记关注} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/PackingChecklist.tsx b/frontend/src/components/PackingChecklist.tsx index 4c2c82e..6f96567 100644 --- a/frontend/src/components/PackingChecklist.tsx +++ b/frontend/src/components/PackingChecklist.tsx @@ -35,7 +35,8 @@ const CATEGORIES = [ { key: "daily", label: "🧳 日常" }, ]; -const STORAGE_KEY = "nomadflow-packing"; +const STORAGE_KEY = "nomadro-packing"; +const LEGACY_KEY = "nomadflow-packing"; export default function PackingChecklist() { const [checked, setChecked] = useState>({}); @@ -44,8 +45,14 @@ export default function PackingChecklist() { useEffect(() => { try { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) setChecked(JSON.parse(saved)); + const saved = localStorage.getItem(STORAGE_KEY) || localStorage.getItem(LEGACY_KEY); + 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 */ } setReady(true); }, []); diff --git a/frontend/src/components/SectionNav.tsx b/frontend/src/components/SectionNav.tsx index 765a1b5..2f9cd74 100644 --- a/frontend/src/components/SectionNav.tsx +++ b/frontend/src/components/SectionNav.tsx @@ -12,12 +12,15 @@ const SECTIONS = [ { id: "calculator", emoji: "🧮", label: "计算器" }, { id: "cost-compare", emoji: "💰", label: "省钱" }, { id: "savings", emoji: "🎯", label: "存款" }, + { id: "expenses", emoji: "📒", label: "账本" }, { id: "flight", emoji: "✈️", label: "机票" }, { id: "wifi", emoji: "📶", label: "网速" }, { id: "lifestyle", emoji: "💻", label: "生活" }, { id: "visa", emoji: "📋", label: "签证" }, + { id: "insurance", emoji: "🏥", label: "保险" }, { id: "coworking", emoji: "💻", label: "办公" }, { id: "housing", emoji: "🏡", label: "住宿" }, + { id: "sim", emoji: "📱", label: "上网" }, { id: "season", emoji: "🌤️", label: "季节" }, { id: "packing", emoji: "🧳", label: "行李" }, { id: "phrases", emoji: "🗣️", label: "短语" }, diff --git a/frontend/src/components/SimGuide.tsx b/frontend/src/components/SimGuide.tsx new file mode 100644 index 0000000..c7a4b8d --- /dev/null +++ b/frontend/src/components/SimGuide.tsx @@ -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 = { + 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 ( +
+
+
+ 📱 SIM / eSIM +

当地上网方案

+

落地第一件事:连上网。实体卡与 eSIM 怎么选

+
+ +
+ {destinations.map((d) => ( + + ))} +
+ +

{dest?.emoji} {dest?.name} 推荐套餐

+ +
+ {plans.map((p, i) => ( +
+ {p.emoji} +

{p.name}

+
+ 📦 {p.data} + {p.price} +
+

{p.tip}

+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/TripPlanner.tsx b/frontend/src/components/TripPlanner.tsx index d6d8f7a..1585202 100644 --- a/frontend/src/components/TripPlanner.tsx +++ b/frontend/src/components/TripPlanner.tsx @@ -2,10 +2,9 @@ import { useEffect, useState } from "react"; import { useToast } from "@/lib/toast"; +import { loadTrip, saveTrip } from "@/lib/tripStorage"; import type { Destination, TripItem } from "@/lib/types"; -const STORAGE_KEY = "nomadflow-trip"; - interface Props { destinations: Destination[] } export default function TripPlanner({ destinations }: Props) { @@ -22,20 +21,19 @@ export default function TripPlanner({ destinations }: Props) { const decoded = JSON.parse(atob(shared)) as TripItem[]; if (Array.isArray(decoded) && decoded.length > 0) { setTrip(decoded); - localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded)); + saveTrip(decoded); toast("已导入分享的行程 🗺️"); window.history.replaceState({}, "", window.location.pathname + "#trip"); } } catch { /* ignore */ } return; } - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) setTrip(JSON.parse(saved)); - }, []); + setTrip(loadTrip()); + }, [toast]); const save = (items: TripItem[]) => { setTrip(items); - localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); + saveTrip(items); }; const addCity = () => { diff --git a/frontend/src/lib/tripStorage.ts b/frontend/src/lib/tripStorage.ts new file mode 100644 index 0000000..bec6bc5 --- /dev/null +++ b/frontend/src/lib/tripStorage.ts @@ -0,0 +1,29 @@ +const TRIP_KEYS = ["nomadro-trip", "nomadflow-trip"] as const; +const PRIMARY = TRIP_KEYS[0]; + +export function loadTrip(): 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;