Add insurance, expenses, SIM guide, about page; unify trip storage
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d1bc4af87f
commit
341fcdd15e
@ -115,6 +115,10 @@ docker compose up -d
|
|||||||
| 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 |
|
| 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 |
|
||||||
| 🗣️ 生存短语 | 多语言常用语一键复制 |
|
| 🗣️ 生存短语 | 多语言常用语一键复制 |
|
||||||
| 💬 反馈入口 | 浮动反馈表单 |
|
| 💬 反馈入口 | 浮动反馈表单 |
|
||||||
|
| 🏥 保险指南 | 国际医疗保险方案对比 |
|
||||||
|
| 📒 支出账本 | 旅居花销分类记录与汇总 |
|
||||||
|
| 📱 上网方案 | 各城 SIM / eSIM 推荐 |
|
||||||
|
| 🌏 关于页 | `/about` 品牌介绍 |
|
||||||
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
|
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
|
||||||
| ❓ FAQ | 搜索 + 展开/收起全部 |
|
| ❓ FAQ | 搜索 + 展开/收起全部 |
|
||||||
|
|
||||||
|
|||||||
62
frontend/src/app/about/page.tsx
Normal file
62
frontend/src/app/about/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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}`,
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
158
frontend/src/components/ExpenseTracker.tsx
Normal file
158
frontend/src/components/ExpenseTracker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
106
frontend/src/components/InsuranceGuide.tsx
Normal file
106
frontend/src/components/InsuranceGuide.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@ -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: "短语" },
|
||||||
|
|||||||
95
frontend/src/components/SimGuide.tsx
Normal file
95
frontend/src/components/SimGuide.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 = () => {
|
||||||
|
|||||||
29
frontend/src/lib/tripStorage.ts
Normal file
29
frontend/src/lib/tripStorage.ts
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user