diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx
index 30f3084..b536581 100644
--- a/frontend/src/app/changelog/page.tsx
+++ b/frontend/src/app/changelog/page.tsx
@@ -8,30 +8,29 @@ export const metadata: Metadata = {
};
const LOGS = [
+ {
+ date: "2026-08-29",
+ tag: "大更新",
+ items: [
+ "一次上新 14 个工具:周末灵感、雨天备选、远程日规划、睡眠周期、心情签到、饮水打卡",
+ "聚餐分账、收款汇率、小费速查、洗衣预算、机场接驳、合租看房问题、押金退还清单、当地防坑",
+ "工具箱推荐位与快捷条同步更新,可搜索全部新工具",
+ ],
+ },
{
date: "2026-08-29",
tag: "新工具",
items: [
- "落地首月成本:房租押金 SIM 接机一次算清",
- "流量预算规划:按办公习惯估算月 GB 与套餐档",
- "签证停留倒计时:入境日 + 额度,剩余天数环形提醒",
- "办公点成本对比:联合办公月卡 vs 咖啡馆日刷",
+ "落地首月成本、流量预算、签证停留倒计时、办公点成本对比",
"日照办公窗、行李重量、旅居跑道、会议黄金时段",
- "快捷工具条直达首月 / 流量 / 签证等核心工具",
],
},
{
date: "2026-08-29",
tag: "体验优化",
items: [
- "全局搜索覆盖全部工具与页面",
- "首页常用工具快捷条 + 下方模块懒加载",
- "行程支持下载 Markdown 文件",
- "新增饮食饮水注意事项",
- "修复生产环境 CSS 解析失败导致的页面异常",
- "部署改为原生 systemd(非 Docker 优先),增量构建更快",
- "首页视口内按需挂载下方模块,加快首屏;粒子动画减负;Loader 更快消失",
- "专注时钟增加累计专注块统计",
+ "首页视口内按需挂载;粒子减负;Loader 更快;原生 systemd 部署",
+ "全局搜索覆盖工具与页面;行程 Markdown 导出",
],
},
{
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 9b28a9b..56de498 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -8546,3 +8546,137 @@ img { max-width: 100%; display: block; }
@media (max-width: 560px) {
.data-budget-grid { grid-template-columns: 1fr; }
}
+
+
+/* ===== Shared kit cards (batch tools) ===== */
+.kit-card {
+ padding: 22px;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-xl);
+ backdrop-filter: blur(16px);
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.kit-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: end; }
+.kit-field { display: flex; flex-direction: column; gap: 6px; }
+.kit-field > span { font-size: 0.85rem; color: var(--text-secondary); }
+.kit-field input, .kit-field select {
+ background: rgba(255,255,255,0.04);
+ border: 1px solid rgba(255,255,255,0.1);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ padding: 10px 12px;
+}
+.kit-field input[type=range] { accent-color: var(--accent-3); padding: 0; border: 0; background: transparent; }
+.kit-stat { text-align: right; }
+.kit-stat strong { font-family: var(--font-display); font-size: 1.8rem; display: block; }
+.kit-stat small { color: var(--text-muted); }
+.kit-bar { height: 8px; border-radius: 999px; background: rgba(255,255,255,0.08); overflow: hidden; }
+.kit-bar i { display: block; height: 100%; background: linear-gradient(90deg,#4ECDC4,#A78BFA); border-radius: 999px; }
+.kit-actions { display: flex; flex-wrap: wrap; gap: 8px; }
+.kit-grid-3 { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; }
+.kit-grid-3 > div, .kit-list-item {
+ padding: 12px;
+ border-radius: var(--radius-md);
+ background: rgba(255,255,255,0.03);
+ border: 1px solid rgba(255,255,255,0.06);
+}
+.kit-grid-3 small { display: block; color: var(--text-muted); font-size: 0.75rem; }
+.kit-grid-3 strong { font-size: 1rem; }
+.kit-note { margin: 0; color: var(--text-secondary); font-size: 0.9rem; line-height: 1.45; }
+.kit-note b { color: var(--accent-3); }
+.kit-chips { display: flex; flex-wrap: wrap; gap: 8px; }
+.kit-chips.wrap { gap: 8px; }
+.kit-chip {
+ padding: 8px 12px;
+ border-radius: 999px;
+ border: 1px solid rgba(255,255,255,0.08);
+ background: rgba(255,255,255,0.03);
+ color: inherit;
+ cursor: pointer;
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+.kit-chip:hover, .kit-chip.active {
+ border-color: rgba(78,205,196,0.4);
+ background: rgba(78,205,196,0.1);
+}
+.kit-hero { display: flex; gap: 14px; align-items: flex-start; padding: 14px; border-radius: var(--radius-md); background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); }
+.kit-hero > span { font-size: 2rem; line-height: 1; }
+.kit-hero small { color: var(--text-muted); display: block; }
+.kit-hero strong { display: block; font-size: 1.25rem; margin: 4px 0; }
+.kit-hero p { margin: 0; color: var(--text-secondary); font-size: 0.88rem; }
+.kit-list { display: flex; flex-direction: column; gap: 8px; }
+.kit-list-item { display: flex; gap: 12px; align-items: flex-start; text-align: left; }
+.kit-list-item > span { font-size: 1.25rem; }
+.kit-list-item strong { display: block; font-size: 0.92rem; }
+.kit-list-item small { color: var(--text-secondary); font-size: 0.8rem; }
+.kit-list-item.btnish { width: 100%; cursor: pointer; color: inherit; transition: all 0.2s; }
+.kit-list-item.btnish.on { border-color: rgba(78,205,196,0.4); background: rgba(78,205,196,0.1); }
+.kit-list-item i { margin-left: auto; font-style: normal; opacity: 0.7; }
+
+/* Weekend */
+.weekend-card, .deposit-card {
+ padding: 22px;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-xl);
+ backdrop-filter: blur(16px);
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+.weekend-controls { display: flex; flex-direction: column; gap: 12px; }
+.weekend-controls label, .deposit-fields label { display: flex; flex-direction: column; gap: 6px; }
+.weekend-controls span, .deposit-fields span { font-size: 0.85rem; color: var(--text-secondary); }
+.weekend-controls select, .deposit-fields input {
+ background: rgba(255,255,255,0.04);
+ border: 1px solid rgba(255,255,255,0.1);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ padding: 10px 12px;
+}
+.weekend-energy { display: flex; flex-wrap: wrap; gap: 8px; }
+.weekend-chip {
+ padding: 8px 12px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.08);
+ background: rgba(255,255,255,0.03); color: inherit; cursor: pointer; font-weight: 600; font-size: 0.85rem;
+}
+.weekend-chip.active, .weekend-chip:hover { border-color: rgba(255,230,109,0.45); background: rgba(255,230,109,0.12); }
+.weekend-hero { display: flex; gap: 16px; padding: 18px; border-radius: var(--radius-md); background: linear-gradient(135deg, rgba(255,107,107,0.12), rgba(78,205,196,0.1)); }
+.weekend-emoji { font-size: 2.6rem; }
+.weekend-hero small { color: var(--text-muted); }
+.weekend-hero h3 { margin: 4px 0 8px; font-size: 1.35rem; }
+.weekend-hero p { margin: 0 0 6px; color: var(--text-secondary); }
+.weekend-hero em { font-style: normal; color: var(--accent-3); font-weight: 600; }
+.weekend-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
+.weekend-mini {
+ padding: 8px 10px; border-radius: var(--radius-md); border: 1px solid rgba(255,255,255,0.08);
+ background: rgba(255,255,255,0.03); color: inherit; cursor: pointer; font-size: 0.8rem;
+}
+.weekend-mini.on { border-color: rgba(78,205,196,0.4); background: rgba(78,205,196,0.1); }
+
+/* Deposit */
+.deposit-top { display: grid; grid-template-columns: 1.2fr 1fr; gap: 14px; }
+.deposit-gauge { height: 8px; border-radius: 999px; background: rgba(255,255,255,0.08); overflow: hidden; margin-bottom: 8px; }
+.deposit-gauge i { display: block; height: 100%; border-radius: 999px; }
+.deposit-meta strong { font-size: 1.6rem; margin-right: 8px; }
+.deposit-meta span { color: var(--text-secondary); font-size: 0.9rem; }
+.deposit-fields { display: grid; gap: 10px; }
+.deposit-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
+.deposit-item {
+ display: flex; gap: 10px; align-items: flex-start; text-align: left; padding: 12px;
+ border-radius: var(--radius-md); border: 1px solid rgba(255,255,255,0.08);
+ background: rgba(255,255,255,0.03); color: inherit; cursor: pointer;
+}
+.deposit-item.on { border-color: rgba(78,205,196,0.4); background: rgba(78,205,196,0.1); }
+.deposit-item strong { display: block; font-size: 0.88rem; }
+.deposit-item small { color: var(--text-secondary); font-size: 0.75rem; }
+.deposit-item i { margin-left: auto; font-style: normal; }
+.deposit-actions { display: flex; gap: 8px; flex-wrap: wrap; }
+
+@media (max-width: 900px) {
+ .kit-row, .deposit-top, .deposit-grid, .kit-grid-3 { grid-template-columns: 1fr; }
+ .kit-stat { text-align: left; }
+}
diff --git a/frontend/src/app/tools/page.tsx b/frontend/src/app/tools/page.tsx
index 0b62805..fa59491 100644
--- a/frontend/src/app/tools/page.tsx
+++ b/frontend/src/app/tools/page.tsx
@@ -5,7 +5,7 @@ import Link from "next/link";
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
import SiteShell from "@/components/SiteShell";
-const FEATURED = ["first-month", "data-budget", "visa-stay", "workspot", "runway", "daylight"];
+const FEATURED = ["weekend", "day-plan", "bill-split", "mood", "scam-alerts", "deposit-return", "airport-transfer", "invoice-fx", "hydration", "sleep", "coliving-qa", "rainy-day"];
export default function ToolsHubPage() {
const [filter, setFilter] = useState("all");
@@ -41,7 +41,7 @@ export default function ToolsHubPage() {
✨ 本周推荐
- 新上线 · 首月成本 & 流量预算
+ 一次上新 12+ 工具 · 周末/分账/防坑…
{featured.map((t) => (
diff --git a/frontend/src/components/AirportTransferPick.tsx b/frontend/src/components/AirportTransferPick.tsx
new file mode 100644
index 0000000..244fef4
--- /dev/null
+++ b/frontend/src/components/AirportTransferPick.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { Destination } from "@/lib/types";
+
+const OPTIONS: Record
= {
+ chiangmai: [
+ { mode: "Grab/出租车", emoji: "🚕", time: "40–60 分", cost: 180, note: "夜间加价,谈价或用 App" },
+ { mode: "机场大巴", emoji: "🚌", time: "60–90 分", cost: 40, note: "最省,行李多也方便" },
+ { mode: "包车", emoji: "🚐", time: "40–55 分", cost: 220, note: "多人分摊更划算" },
+ ],
+ bali: [
+ { mode: "Grab/Gojek", emoji: "🚕", time: "45–90 分", cost: 160, note: "堵车常见,留余量" },
+ { mode: "机场接机", emoji: "🪧", time: "50–80 分", cost: 200, note: "预订含举牌,适合深夜" },
+ { mode: "酒店车", emoji: "🏨", time: "50–80 分", cost: 250, note: "贵但省心" },
+ ],
+ lisbon: [
+ { mode: "地铁+火车", emoji: "🚇", time: "40–50 分", cost: 30, note: "Aerobus/地铁到市区" },
+ { mode: "Uber", emoji: "🚕", time: "25–40 分", cost: 120, note: "行李多更舒服" },
+ { mode: "机场巴士", emoji: "🚌", time: "30–45 分", cost: 35, note: "到市区枢纽方便" },
+ ],
+ barcelona: [
+ { mode: "Aerobus", emoji: "🚌", time: "35 分", cost: 55, note: "到 Catalunya 很直接" },
+ { mode: "地铁", emoji: "🚇", time: "40–50 分", cost: 40, note: "最省,换线一次" },
+ { mode: "出租车", emoji: "🚕", time: "25–35 分", cost: 180, note: "固定价区间,问清楚" },
+ ],
+ mexico: [
+ { mode: "官方出租车", emoji: "🚕", time: "45–70 分", cost: 200, note: "机场内购票更安全" },
+ { mode: "Uber", emoji: "📱", time: "45–70 分", cost: 150, note: "指定上车点" },
+ { mode: "大巴", emoji: "🚌", time: "60–90 分", cost: 50, note: "到 Terminal 再换乘" },
+ ],
+ tokyo: [
+ { mode: "京急/成田特急", emoji: "🚄", time: "50–90 分", cost: 120, note: "看机场选择线路" },
+ { mode: "利木辛巴士", emoji: "🚌", time: "70–120 分", cost: 180, note: "直达酒店最省心" },
+ { mode: "出租车", emoji: "🚕", time: "40–70 分", cost: 600, note: "很贵,非必要不选" },
+ ],
+};
+
+interface Props { destinations: Destination[] }
+
+export default function AirportTransferPick({ destinations }: Props) {
+ const [slug, setSlug] = useState(destinations[0]?.slug || "lisbon");
+ const [prefer, setPrefer] = useState<"cheap" | "fast" | "easy">("easy");
+ const list = OPTIONS[slug] || OPTIONS.lisbon;
+ const ranked = useMemo(() => {
+ const arr = [...list];
+ if (prefer === "cheap") arr.sort((a, b) => a.cost - b.cost);
+ if (prefer === "fast") arr.sort((a, b) => a.time.localeCompare(b.time));
+ return arr;
+ }, [list, prefer]);
+ const best = ranked[0];
+ const dest = destinations.find((d) => d.slug === slug);
+ return (
+
+
+
+
🛬 TRANSFER
+
机场接驳选择
+
落地第一程,按预算/速度/省心排序
+
+
+
+
+
+ {([["cheap", "省钱"], ["fast", "尽快"], ["easy", "省心"]] as const).map(([k, l]) => (
+
+ ))}
+
+
+
+
{best.emoji}
+
+
{dest?.name} 推荐
+
{best.mode}
+
{best.time} · 约 ¥{best.cost} · {best.note}
+
+
+
+ {ranked.map((o) => (
+
+
{o.emoji}
+
{o.mode}{o.time} · ¥{o.cost}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/BillSplit.tsx b/frontend/src/components/BillSplit.tsx
new file mode 100644
index 0000000..971babc
--- /dev/null
+++ b/frontend/src/components/BillSplit.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+export default function BillSplit() {
+ const [total, setTotal] = useState(480);
+ const [people, setPeople] = useState(3);
+ const [tipPct, setTipPct] = useState(8);
+ const [meExtra, setMeExtra] = useState(0);
+ const per = useMemo(() => {
+ const withTip = total * (1 + tipPct / 100);
+ const base = withTip / Math.max(people, 1);
+ return {
+ each: Math.round(base),
+ me: Math.round(base + meExtra),
+ grand: Math.round(withTip),
+ };
+ }, [total, people, tipPct, meExtra]);
+ return (
+
+
+
+
➗ SPLIT
+
聚餐分账
+
和游民朋友吃饭,秒算每人多少
+
+
+
+
+
+
+
+
+
+
人均¥{per.each}
+
我应付¥{per.me}
+
含小费总计¥{per.grand}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/ColivingQuestions.tsx b/frontend/src/components/ColivingQuestions.tsx
new file mode 100644
index 0000000..c6cfcb0
--- /dev/null
+++ b/frontend/src/components/ColivingQuestions.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useToast } from "@/lib/toast";
+
+const QUESTIONS = [
+ { id: "wifi", emoji: "📶", q: "实际上网速度能到多少?有无高峰限速?" },
+ { id: "noise", emoji: "🔊", q: "晚上安静吗?隔壁会开会/派对吗?" },
+ { id: "desk", emoji: "🪑", q: "是否有独立工位与显示器外接?" },
+ { id: "deposit", emoji: "🔐", q: "押金多久退?扣款条件写清楚了吗?" },
+ { id: "clean", emoji: "🧹", q: "公共区域清洁频率?谁负责厨余?" },
+ { id: "guest", emoji: "👥", q: "访客政策?能否短住朋友?" },
+ { id: "contract", emoji: "📄", q: "最短租期与提前退租违约金?" },
+ { id: "bills", emoji: "💡", q: "水电网是否包?超额怎么算?" },
+];
+
+export default function ColivingQuestions() {
+ const { toast } = useToast();
+ const [checked, setChecked] = useState([]);
+ useEffect(() => {
+ try {
+ const raw = localStorage.getItem("nomadro-coliving-q");
+ if (raw) setChecked(JSON.parse(raw));
+ } catch { /* ignore */ }
+ }, []);
+ useEffect(() => {
+ localStorage.setItem("nomadro-coliving-q", JSON.stringify(checked));
+ }, [checked]);
+ const toggle = (id: string) => setChecked((p) => (p.includes(id) ? p.filter((x) => x !== id) : [...p, id]));
+ const copy = async () => {
+ const text = QUESTIONS.filter((q) => checked.includes(q.id) || checked.length === 0)
+ .map((q, i) => `${i + 1}. ${q.q}`)
+ .join("\n");
+ await navigator.clipboard.writeText(`Coliving 看房问题:\n${text}`);
+ toast("问题清单已复制 📋");
+ };
+ return (
+
+
+
+
🏠 COLIVING Q&A
+
合租看房问题单
+
勾选你要问的,一键复制发给房东/管家
+
+
+
+ {QUESTIONS.map((q) => {
+ const on = checked.includes(q.id);
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/DepositReturnChecklist.tsx b/frontend/src/components/DepositReturnChecklist.tsx
new file mode 100644
index 0000000..a4118ac
--- /dev/null
+++ b/frontend/src/components/DepositReturnChecklist.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { useToast } from "@/lib/toast";
+
+const STORAGE_KEY = "nomadro-deposit-return";
+
+interface Item {
+ id: string;
+ emoji: string;
+ label: string;
+ tip: string;
+}
+
+const ITEMS: Item[] = [
+ { id: "photos", emoji: "📸", label: "入住空房照片/视频", tip: "退房当天取证,避免扯皮" },
+ { id: "inventory", emoji: "📋", label: "对照入住清单核对", tip: "缺件提前补或协商" },
+ { id: "clean", emoji: "🧹", label: "基础清洁完成", tip: "厨房/卫生间最容易被扣" },
+ { id: "keys", emoji: "🔑", label: "钥匙/门卡归还", tip: "拍照确认交接人" },
+ { id: "bills", emoji: "🧾", label: "水电燃气结清", tip: "保留缴费截图" },
+ { id: "wifi", emoji: "📶", label: "路由器/账号移交", tip: "改密或注销自己的账号" },
+ { id: "mail", emoji: "✉️", label: "邮件确认退押金额与日期", tip: "写清账户信息" },
+ { id: "contract", emoji: "📄", label: "合同退租条款已读", tip: "注意提前通知天数" },
+];
+
+export default function DepositReturnChecklist() {
+ const { toast } = useToast();
+ const [checked, setChecked] = useState([]);
+ const [ready, setReady] = useState(false);
+ const [amount, setAmount] = useState(3000);
+ const [due, setDue] = useState("");
+
+ useEffect(() => {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ const s = JSON.parse(raw) as { checked?: string[]; amount?: number; due?: string };
+ if (s.checked) setChecked(s.checked);
+ if (s.amount) setAmount(s.amount);
+ if (s.due) setDue(s.due);
+ }
+ } catch { /* ignore */ }
+ setReady(true);
+ }, []);
+
+ useEffect(() => {
+ if (!ready) return;
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ checked, amount, due }));
+ }, [checked, amount, due, ready]);
+
+ const pct = Math.round((checked.length / ITEMS.length) * 100);
+ const left = ITEMS.length - checked.length;
+
+ const status = useMemo(() => {
+ if (pct === 100) return { label: "可以安心退房", color: "#4ECDC4" };
+ if (pct >= 50) return { label: "进度过半", color: "#FFE66D" };
+ return { label: "继续核对", color: "#F472B6" };
+ }, [pct]);
+
+ const toggle = (id: string) => {
+ setChecked((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
+ };
+
+ const copySummary = async () => {
+ const lines = [
+ "nomadro 押金退还核对",
+ `押金金额:¥${amount.toLocaleString()}`,
+ due ? `预计退还日:${due}` : "",
+ `完成 ${checked.length}/${ITEMS.length}`,
+ "",
+ ...ITEMS.map((i) => `${checked.includes(i.id) ? "[x]" : "[ ]"} ${i.label}`),
+ ].filter(Boolean);
+ await navigator.clipboard.writeText(lines.join("\n"));
+ toast("核对清单已复制 📋");
+ };
+
+ return (
+
+
+
+
🔐 DEPOSIT
+
押金退还清单
+
退房前逐项勾选,把押金拿回来更稳
+
+
+
+
+
+
+
+ {pct}%
+ {status.label} · 还剩 {left} 项
+
+
+
+
+
+
+
+
+
+ {ITEMS.map((item) => {
+ const on = checked.includes(item.id);
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx
index 05ca004..2cfde0d 100644
--- a/frontend/src/components/HomeClient.tsx
+++ b/frontend/src/components/HomeClient.tsx
@@ -70,6 +70,20 @@ const ArrivalChecklist = lazy(() => import("./ArrivalChecklist"));
const Phrasebook = lazy(() => import("./Phrasebook"));
const CityNotes = lazy(() => import("./CityNotes"));
const NomadEvents = lazy(() => import("./NomadEvents"));
+const WeekendIdeas = lazy(() => import("./WeekendIdeas"));
+const DepositReturnChecklist = lazy(() => import("./DepositReturnChecklist"));
+const HydrationTracker = lazy(() => import("./HydrationTracker"));
+const TipGuide = lazy(() => import("./TipGuide"));
+const BillSplit = lazy(() => import("./BillSplit"));
+const AirportTransferPick = lazy(() => import("./AirportTransferPick"));
+const NomadMoodCheck = lazy(() => import("./NomadMoodCheck"));
+const RemoteDayPlan = lazy(() => import("./RemoteDayPlan"));
+const LaundryDay = lazy(() => import("./LaundryDay"));
+const LocalScamAlerts = lazy(() => import("./LocalScamAlerts"));
+const ColivingQuestions = lazy(() => import("./ColivingQuestions"));
+const SleepWindDown = lazy(() => import("./SleepWindDown"));
+const InvoiceFxHelper = lazy(() => import("./InvoiceFxHelper"));
+const RainyDayBackup = lazy(() => import("./RainyDayBackup"));
const Tools = lazy(() => import("./Tools"));
const BlogSection = lazy(() => import("./BlogSection"));
const FAQSection = lazy(() => import("./FAQSection"));
@@ -186,6 +200,10 @@ export default function HomeClient(props: Props) {
+
+
+
+
@@ -195,15 +213,25 @@ export default function HomeClient(props: Props) {
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/HydrationTracker.tsx b/frontend/src/components/HydrationTracker.tsx
new file mode 100644
index 0000000..f50e1d2
--- /dev/null
+++ b/frontend/src/components/HydrationTracker.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { Destination } from "@/lib/types";
+
+const CLIMATE: Record = {
+ chiangmai: 2.8, bali: 3.0, lisbon: 2.4, barcelona: 2.5, mexico: 2.6, tokyo: 2.3,
+};
+
+interface Props { destinations: Destination[] }
+
+export default function HydrationTracker({ destinations }: Props) {
+ const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai");
+ const [cups, setCups] = useState(0);
+ const goal = Math.round((CLIMATE[slug] || 2.5) * 4); // cups ~250ml
+ useEffect(() => {
+ try {
+ const raw = localStorage.getItem("nomadro-hydrate");
+ if (raw) {
+ const s = JSON.parse(raw) as { day?: string; cups?: number; slug?: string };
+ if (s.day === new Date().toDateString()) {
+ setCups(s.cups || 0);
+ if (s.slug) setSlug(s.slug);
+ }
+ }
+ } catch { /* ignore */ }
+ }, []);
+ useEffect(() => {
+ localStorage.setItem("nomadro-hydrate", JSON.stringify({
+ day: new Date().toDateString(), cups, slug,
+ }));
+ }, [cups, slug]);
+ const pct = Math.min((cups / goal) * 100, 100);
+ const dest = destinations.find((d) => d.slug === slug);
+ return (
+
+
+
+
💧 HYDRATE
+
饮水打卡
+
按城市气候调整目标,今天喝够了吗?
+
+
+
+
+
+ {cups}/{goal}
+ {dest?.emoji} 杯 · 约 {cups * 250}ml
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/InvoiceFxHelper.tsx b/frontend/src/components/InvoiceFxHelper.tsx
new file mode 100644
index 0000000..d4078fb
--- /dev/null
+++ b/frontend/src/components/InvoiceFxHelper.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+const RATES: Record = { USD: 7.2, EUR: 7.8, GBP: 9.1, JPY: 0.048, THB: 0.2, IDR: 0.00045 };
+
+export default function InvoiceFxHelper() {
+ const [amount, setAmount] = useState(3000);
+ const [currency, setCurrency] = useState("USD");
+ const [feePct, setFeePct] = useState(2.5);
+ const cny = useMemo(() => {
+ const raw = amount * (RATES[currency] || 1);
+ const fee = raw * (feePct / 100);
+ return { raw: Math.round(raw), fee: Math.round(fee), net: Math.round(raw - fee) };
+ }, [amount, currency, feePct]);
+ return (
+
+
+
+
💼 INVOICE FX
+
收款汇率估算
+
远程开票到账,扣掉手续费还剩多少
+
+
+
+
+
+
+
+
+
毛估入账¥{cny.raw.toLocaleString()}
+
费用¥{cny.fee.toLocaleString()}
+
净到手¥{cny.net.toLocaleString()}
+
+
汇率为示意,实际以到账日为准
+
+
+
+ );
+}
diff --git a/frontend/src/components/LaundryDay.tsx b/frontend/src/components/LaundryDay.tsx
new file mode 100644
index 0000000..c37f933
--- /dev/null
+++ b/frontend/src/components/LaundryDay.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { Destination } from "@/lib/types";
+
+const RATES: Record = {
+ chiangmai: { wash: 25, dry: 20, fold: 15, note: "街边自助洗衣很常见" },
+ bali: { wash: 35, dry: 25, fold: 20, note: "按公斤计费,问清是否含烘干" },
+ lisbon: { wash: 45, dry: 30, fold: 25, note: "自助洗衣店遍布居民区" },
+ barcelona: { wash: 50, dry: 35, fold: 25, note: "旺季可能排队" },
+ mexico: { wash: 40, dry: 30, fold: 20, note: "lavandería 按公斤" },
+ tokyo: { wash: 55, dry: 40, fold: 0, note: "投币洗衣,通常自折" },
+};
+
+interface Props { destinations: Destination[] }
+
+export default function LaundryDay({ destinations }: Props) {
+ const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai");
+ const [kg, setKg] = useState(4);
+ const [dry, setDry] = useState(true);
+ const [fold, setFold] = useState(false);
+ const rate = RATES[slug] || RATES.chiangmai;
+ const total = useMemo(() => {
+ let t = rate.wash * kg;
+ if (dry) t += rate.dry * kg;
+ if (fold) t += rate.fold * kg;
+ return Math.round(t);
+ }, [rate, kg, dry, fold]);
+ return (
+
+
+
+
👕 LAUNDRY
+
洗衣日预算
+
出差旅居最容易忘的一笔小开销
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/LocalScamAlerts.tsx b/frontend/src/components/LocalScamAlerts.tsx
new file mode 100644
index 0000000..843fdb1
--- /dev/null
+++ b/frontend/src/components/LocalScamAlerts.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { useState } from "react";
+import type { Destination } from "@/lib/types";
+
+const ALERTS: Record = {
+ chiangmai: [
+ { emoji: "🚕", title: "未打表宰客车", detail: "优先用 Grab,或先谈好价格" },
+ { emoji: "💎", title: "宝石/导游硬推销", detail: "礼貌拒绝,不跟去“特别店”" },
+ { emoji: "🏧", title: "独立ATM高费率", detail: "选大银行网点机具" },
+ ],
+ bali: [
+ { emoji: "🏍️", title: "租车押金纠纷", detail: "拍车况视频,只用正规店" },
+ { emoji: "💱", title: "兑换假钞", detail: "银行或可靠兑换点" },
+ { emoji: "🏖️", title: "海滩强行按摩收费", detail: "先问价,不需要直接走" },
+ ],
+ lisbon: [
+ { emoji: "🎒", title: "电车小偷", detail: "背包前置,热闹线路更小心" },
+ { emoji: "🎫", title: "假票贩子", detail: "只在官方机/App买票" },
+ { emoji: "🍷", title: "景区餐厅价差", detail: "先看菜单再坐" },
+ ],
+ barcelona: [
+ { emoji: "🤏", title: "兰布拉大街扒手", detail: "手机不外露,人多不停留过久" },
+ { emoji: "🪙", title: "签名人索要小费套路", detail: "不接纸不看花样" },
+ { emoji: "🏠", title: "假短租", detail: "走正规平台,上门前视频确认" },
+ ],
+ mexico: [
+ { emoji: "🚕", title: "非官方机场车", detail: "走官方出租车亭" },
+ { emoji: "🍸", title: "酒水加料传闻", detail: "看管饮品,结伴更安全" },
+ { emoji: "📱", title: "街头抢手机", detail: "少在路边滑手机" },
+ ],
+ tokyo: [
+ { emoji: "🎫", title: "黄牛票", detail: "官方渠道购买热门演出" },
+ { emoji: "🧳", title: "行李寄送冒充", detail: "用站内正规服务" },
+ { emoji: "📞", title: "退税冒充协助", detail: "只跟店铺流程走" },
+ ],
+};
+
+interface Props { destinations: Destination[] }
+
+export default function LocalScamAlerts({ destinations }: Props) {
+ const [slug, setSlug] = useState(destinations[0]?.slug || "barcelona");
+ const list = ALERTS[slug] || ALERTS.lisbon;
+ return (
+
+
+
+
🚨 SCAM WATCH
+
当地防坑提醒
+
高频套路速览,落地少交学费
+
+
+
+
+ {list.map((a) => (
+
+
{a.emoji}
+
{a.title}{a.detail}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/NomadMoodCheck.tsx b/frontend/src/components/NomadMoodCheck.tsx
new file mode 100644
index 0000000..72d629c
--- /dev/null
+++ b/frontend/src/components/NomadMoodCheck.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+const MOODS = [
+ { key: "flow", emoji: "🌊", label: "心流" },
+ { key: "ok", emoji: "🙂", label: "平稳" },
+ { key: "tired", emoji: "😴", label: "疲惫" },
+ { key: "anxious", emoji: "😰", label: "焦虑" },
+ { key: "lonely", emoji: "🌑", label: "孤单" },
+ { key: "excited", emoji: "✨", label: "兴奋" },
+];
+
+const TIPS: Record = {
+ flow: "保持节奏,结束时写下今天的小胜利",
+ ok: "适合处理琐事与规划下周",
+ tired: "缩短会议,优先睡眠与补水",
+ anxious: "出去走走 20 分钟,或发消息给一位朋友",
+ lonely: "去联合办公或咖啡馆坐一会儿,找人气",
+ excited: "把冲劲用在一件重要推进上",
+};
+
+export default function NomadMoodCheck() {
+ const [mood, setMood] = useState("ok");
+ const [note, setNote] = useState("");
+ const [saved, setSaved] = useState(false);
+ useEffect(() => {
+ try {
+ const raw = localStorage.getItem("nomadro-mood");
+ if (raw) {
+ const s = JSON.parse(raw) as { mood?: string; note?: string; day?: string };
+ if (s.day === new Date().toDateString()) {
+ if (s.mood) setMood(s.mood);
+ if (s.note) setNote(s.note);
+ setSaved(true);
+ }
+ }
+ } catch { /* ignore */ }
+ }, []);
+ const save = () => {
+ localStorage.setItem("nomadro-mood", JSON.stringify({
+ mood, note, day: new Date().toDateString(),
+ }));
+ setSaved(true);
+ };
+ return (
+
+
+
+
🪞 MOOD
+
游民心情签到
+
旅居情绪也需要被看见,一分钟记录就好
+
+
+
+ {MOODS.map((m) => (
+
+ ))}
+
+
{TIPS[mood]}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/RainyDayBackup.tsx b/frontend/src/components/RainyDayBackup.tsx
new file mode 100644
index 0000000..c3477c4
--- /dev/null
+++ b/frontend/src/components/RainyDayBackup.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { useState } from "react";
+import type { Destination } from "@/lib/types";
+
+const INDOOR: Record = {
+ chiangmai: [
+ { emoji: "☕", title: "宁曼路咖啡馆接力", detail: "雨天最适合换店写作" },
+ { emoji: "🖼️", title: "美术馆/文创园", detail: "避雨顺便找灵感" },
+ { emoji: "🍜", title: "商场美食层", detail: "空调足,适合视频会议间隙" },
+ ],
+ bali: [
+ { emoji: "🧘", title: "室内瑜伽", detail: "雨季下午常见取消冲浪改瑜伽" },
+ { emoji: "📚", title: "乌布书店发呆", detail: "慢节奏充电" },
+ { emoji: "🍲", title: "烹饪课", detail: "学一道当地菜带回家" },
+ ],
+ lisbon: [
+ { emoji: "🏛️", title: "博物馆半日", detail: "雨天排队通常更短" },
+ { emoji: "🎭", title: "小剧场/Fado", detail: "提前订票" },
+ { emoji: "🛍️", title: "市集室内摊", detail: "试吃与挖宝" },
+ ],
+ barcelona: [
+ { emoji: "🎨", title: "毕加索博物馆", detail: "避开日照也能看展" },
+ { emoji: "🍫", title: "巧克力博物馆/甜品店", detail: "甜蜜避雨" },
+ { emoji: "🏬", title: "大型市场室内", detail: "人多但好玩" },
+ ],
+ mexico: [
+ { emoji: "🖼️", title: "美术馆走廊", detail: "室内恒温友好" },
+ { emoji: "🌮", title: "市场美食探索", detail: "雨天室内摊更安心" },
+ { emoji: "🎬", title: "独立影院", detail: "补一场西语片" },
+ ],
+ tokyo: [
+ { emoji: "📚", title: "书店+咖啡馆", detail: "涩谷/新宿选择极多" },
+ { emoji: "♨️", title: "钱汤", detail: "下雨也暖" },
+ { emoji: "🎮", title: "室内娱乐馆", detail: "发泄与放松" },
+ ],
+};
+
+interface Props { destinations: Destination[] }
+
+export default function RainyDayBackup({ destinations }: Props) {
+ const [slug, setSlug] = useState(destinations[0]?.slug || "lisbon");
+ const list = INDOOR[slug] || INDOOR.lisbon;
+ return (
+
+
+
+
🌧️ RAINY DAY
+
雨天备选计划
+
下雨别荒废周末,室内也能过得很满
+
+
+
+
+ {list.map((x) => (
+
+
{x.emoji}
+
{x.title}{x.detail}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/RemoteDayPlan.tsx b/frontend/src/components/RemoteDayPlan.tsx
new file mode 100644
index 0000000..c034ac0
--- /dev/null
+++ b/frontend/src/components/RemoteDayPlan.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+const BLOCKS = [
+ { id: "deep", emoji: "🧠", label: "深度工作", mins: 90 },
+ { id: "meet", emoji: "📞", label: "会议同步", mins: 60 },
+ { id: "admin", emoji: "🧾", label: "行政琐事", mins: 45 },
+ { id: "learn", emoji: "📚", label: "学习充电", mins: 45 },
+ { id: "move", emoji: "🚶", label: "散步恢复", mins: 30 },
+ { id: "create", emoji: "✍️", label: "创作输出", mins: 60 },
+];
+
+export default function RemoteDayPlan() {
+ const [selected, setSelected] = useState(["deep", "meet", "move", "create"]);
+ const [start, setStart] = useState(9);
+ const total = useMemo(
+ () => BLOCKS.filter((b) => selected.includes(b.id)).reduce((s, b) => s + b.mins, 0),
+ [selected]
+ );
+ const timeline = useMemo(() => {
+ let cursor = start;
+ return BLOCKS.filter((b) => selected.includes(b.id)).map((b) => {
+ const from = cursor;
+ const to = cursor + b.mins / 60;
+ cursor = to;
+ const fmt = (h: number) => `${String(Math.floor(h)).padStart(2, "0")}:${String(Math.round((h % 1) * 60)).padStart(2, "0")}`;
+ return { ...b, from: fmt(from), to: fmt(to) };
+ });
+ }, [selected, start]);
+ const toggle = (id: string) => setSelected((p) => (p.includes(id) ? p.filter((x) => x !== id) : [...p, id]));
+ return (
+
+
+
+
🗓️ DAY PLAN
+
远程日规划
+
拼出今天的工作块,时间轴自动排好
+
+
+
+
+ {BLOCKS.map((b) => (
+
+ ))}
+
+
合计约 {Math.round(total / 60 * 10) / 10} 小时
+
+ {timeline.map((t) => (
+
+
{t.emoji}
+
{t.label}{t.from} – {t.to}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/SleepWindDown.tsx b/frontend/src/components/SleepWindDown.tsx
new file mode 100644
index 0000000..740914e
--- /dev/null
+++ b/frontend/src/components/SleepWindDown.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+export default function SleepWindDown() {
+ const [wake, setWake] = useState(7);
+ const [cycles, setCycles] = useState(5);
+ const bed = useMemo(() => {
+ // 90min cycles + 15min fall asleep
+ const totalMin = cycles * 90 + 15;
+ let h = wake - totalMin / 60;
+ while (h < 0) h += 24;
+ const hh = Math.floor(h);
+ const mm = Math.round((h - hh) * 60);
+ return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
+ }, [wake, cycles]);
+ return (
+
+
+
+
😴 SLEEP
+
睡眠周期入睡点
+
按起床时间倒推,睡够完整周期少浑噩
+
+
+
+
+
+
🛏️
+
+
建议入睡
+
{bed}
+
含约 15 分钟入睡缓冲 · 共约 {cycles * 1.5} 小时
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/TipGuide.tsx b/frontend/src/components/TipGuide.tsx
new file mode 100644
index 0000000..ba7ed13
--- /dev/null
+++ b/frontend/src/components/TipGuide.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { Destination } from "@/lib/types";
+
+const TIPS: Record = {
+ chiangmai: { restaurant: "非必须,可凑整", taxi: "非必须", coffee: "可不给", note: "泰国小费不强制,微笑更重要" },
+ bali: { restaurant: "5–10%", taxi: "凑整即可", coffee: "可不给", note: "旅游区习惯给一点,但别被强行加收吓到" },
+ lisbon: { restaurant: "5–10%", taxi: "凑整", coffee: "圆整到欧元", note: "服务好再给,菜单若含服务费可少给" },
+ barcelona: { restaurant: "5–10%", taxi: "凑整", coffee: "可不给", note: "西班牙小费随意,别按美式 20%" },
+ mexico: { restaurant: "10–15%", taxi: "10%", coffee: "少量硬币", note: "墨西哥小费较常见,餐厅建议留一点" },
+ tokyo: { restaurant: "通常不需要", taxi: "不需要", coffee: "不需要", note: "日本多数场合不收小费,强给可能尴尬" },
+};
+
+interface Props { destinations: Destination[] }
+
+export default function TipGuide({ destinations }: Props) {
+ const [slug, setSlug] = useState(destinations[0]?.slug || "lisbon");
+ const [bill, setBill] = useState(200);
+ const tip = TIPS[slug] || TIPS.lisbon;
+ const dest = destinations.find((d) => d.slug === slug);
+ const suggest = useMemo(() => Math.round(bill * (slug === "tokyo" || slug === "chiangmai" ? 0 : slug === "mexico" ? 0.12 : 0.08)), [bill, slug]);
+ return (
+
+
+
+
🪙 TIPPING
+
小费速查
+
别用错文化习惯,当地人怎么给就怎么给
+
+
+
+
+
+
+
+
餐厅{tip.restaurant}
+
打车{tip.taxi}
+
咖啡{tip.coffee}
+
+
{dest?.emoji} {tip.note}
+
参考小费约 ¥{suggest}(仅估算)
+
+
+
+ );
+}
diff --git a/frontend/src/components/ToolsStrip.tsx b/frontend/src/components/ToolsStrip.tsx
index 2954920..cb16f21 100644
--- a/frontend/src/components/ToolsStrip.tsx
+++ b/frontend/src/components/ToolsStrip.tsx
@@ -3,13 +3,13 @@
import Link from "next/link";
const QUICK = [
- { href: "/#first-month", emoji: "🛬", label: "首月" },
- { href: "/#data-budget", emoji: "📶", label: "流量" },
- { href: "/#visa-stay", emoji: "🛂", label: "签证" },
- { href: "/#workspot", emoji: "💼", label: "办公" },
- { href: "/#runway", emoji: "🚀", label: "跑道" },
- { href: "/#trip", emoji: "🗓️", label: "行程" },
- { href: "/#focus", emoji: "⏱️", label: "专注" },
+ { href: "/#weekend", emoji: "🎉", label: "周末" },
+ { href: "/#day-plan", emoji: "🧩", label: "日程" },
+ { href: "/#bill-split", emoji: "➗", label: "分账" },
+ { href: "/#mood", emoji: "🪞", label: "心情" },
+ { href: "/#scam-alerts", emoji: "🚨", label: "防坑" },
+ { href: "/#deposit-return", emoji: "🔐", label: "押金" },
+ { href: "/#airport-transfer", emoji: "🚕", label: "接驳" },
{ href: "/tools", emoji: "🛠️", label: "全部" },
];
diff --git a/frontend/src/components/WeekendIdeas.tsx b/frontend/src/components/WeekendIdeas.tsx
new file mode 100644
index 0000000..02b7582
--- /dev/null
+++ b/frontend/src/components/WeekendIdeas.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { Destination } from "@/lib/types";
+
+interface Idea {
+ emoji: string;
+ title: string;
+ vibe: string;
+ cost: string;
+}
+
+const IDEAS: Record = {
+ chiangmai: [
+ { emoji: "⛰️", title: "徒步 Doi Suthep", vibe: "清晨清爽,下山喝咖啡续命", cost: "¥40–80" },
+ { emoji: "🛁", title: "热水泉泡汤", vibe: "周末放松肩颈,适合写周报前", cost: "¥30–60" },
+ { emoji: "🍜", title: "周日步行街夜市", vibe: "人多但氛围足,试小吃就好", cost: "¥50–100" },
+ { emoji: "🧘", title: "乌布式瑜伽体验课", vibe: "本地工作室很多试听课", cost: "¥60–120" },
+ ],
+ bali: [
+ { emoji: "🏄", title: "库塔/Canggu 冲浪课", vibe: "初学友好,防晒务必做好", cost: "¥200–350" },
+ { emoji: "🛕", title: "乌布神庙半日游", vibe: "避开正午,早晚光线最好", cost: "¥80–150" },
+ { emoji: "🌾", title: "骑车穿梭梯田", vibe: "租摩托前先熟悉靠左行", cost: "¥100–180" },
+ { emoji: "🌅", title: "乌鲁瓦图日落", vibe: "建议提前占位,注意崖边安全", cost: "¥50–90" },
+ ],
+ lisbon: [
+ { emoji: "🚋", title: "28 路电车+阿尔法玛", vibe: "早上去人少,适合拍照", cost: "¥20–40" },
+ { emoji: "🏖️", title: "去 Cascais 吹海风", vibe: "火车 40 分钟,周末轻松局", cost: "¥60–120" },
+ { emoji: "🎵", title: "Fado 小酒馆之夜", vibe: "预约座位,点一杯就够", cost: "¥150–250" },
+ { emoji: "🏛️", title: "贝伦甜挞巡礼", vibe: "经典但值得,搭配公园散步", cost: "¥40–70" },
+ ],
+ barcelona: [
+ { emoji: "🏖️", title: "Barceloneta 海边慢跑", vibe: "清晨最舒服,避开午后暴晒", cost: "免费" },
+ { emoji: "🎨", title: "公园桂尔半日", vibe: "提前买票,错峰体验更好", cost: "¥120–180" },
+ { emoji: "🍷", title: "Gracia 小酒馆串店", vibe: "当地人周末去处", cost: "¥150–280" },
+ { emoji: "⛰️", title: "Montjuïc 看城景", vibe: "日落时光线绝佳", cost: "¥30–60" },
+ ],
+ mexico: [
+ { emoji: "🌮", title: "街区 taco 巡礼", vibe: "选人气摊,循序渐进试辣", cost: "¥60–120" },
+ { emoji: "🖼️", title: "人类学博物馆", vibe: "室内避暑+高质量展览", cost: "¥50–80" },
+ { emoji: "🛶", title: "Xochimilco 彩色船", vibe: "周末热闹,建议结伴", cost: "¥150–250" },
+ { emoji: "☕", title: "Roma/Condesa 咖啡漫步", vibe: "树荫街道适合边走边聊", cost: "¥40–90" },
+ ],
+ tokyo: [
+ { emoji: "♨️", title: "钱汤/温泉放松", vibe: "带走疲劳,注意礼仪", cost: "¥50–120" },
+ { emoji: "🎌", title: "下北泽古着街", vibe: "周末市集氛围强", cost: "看心情" },
+ { emoji: "🗻", title: "高尾山一日轻徒步", vibe: "电车直达,难度友好", cost: "¥80–150" },
+ { emoji: "🍣", title: "筑地外围早餐", vibe: "早起有回报", cost: "¥100–200" },
+ ],
+};
+
+interface Props {
+ destinations: Destination[];
+}
+
+export default function WeekendIdeas({ destinations }: Props) {
+ const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai");
+ const [energy, setEnergy] = useState<"chill" | "active" | "social">("chill");
+ const [picked, setPicked] = useState(0);
+
+ const list = useMemo(() => {
+ const all = IDEAS[slug] || IDEAS.chiangmai;
+ if (energy === "active") return [...all].sort((a, b) => b.title.length - a.title.length);
+ if (energy === "social") return [...all].reverse();
+ return all;
+ }, [slug, energy]);
+
+ const dest = destinations.find((d) => d.slug === slug);
+ const current = list[picked % list.length];
+
+ const shuffle = () => setPicked((p) => (p + 1) % list.length);
+
+ return (
+
+
+
+
🎉 WEEKEND
+
周末活动灵感
+
旅居不是只有工位,周末也要会玩会恢复
+
+
+
+
+
+
+ {([
+ ["chill", "😌 轻松恢复"],
+ ["active", "🏃 动起来"],
+ ["social", "🍻 社交局"],
+ ] as const).map(([k, label]) => (
+
+ ))}
+
+
+
+
+
{current.emoji}
+
+
{dest?.emoji} {dest?.name} · 周末提案
+
{current.title}
+
{current.vibe}
+
预算约 {current.cost}
+
+
+
+
+
+
+ {list.map((idea, i) => (
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/lib/tools.ts b/frontend/src/lib/tools.ts
index 622c109..5222cf0 100644
--- a/frontend/src/lib/tools.ts
+++ b/frontend/src/lib/tools.ts
@@ -12,39 +12,53 @@ export const TOOL_LINKS: ToolLink[] = [
{ id: "spin", emoji: "🎲", title: "命运转盘", desc: "随机抽下一站", href: "/#spin", category: "plan" },
{ id: "matcher", emoji: "🎯", title: "智能匹配", desc: "问卷推荐目的地", href: "/#destinations", category: "plan" },
{ id: "season", emoji: "🌤️", title: "最佳月份", desc: "避开雨季与旺季", href: "/#season", category: "plan" },
+ { id: "weekend", emoji: "🎉", title: "周末灵感", desc: "按城市与精力推荐活动", href: "/#weekend", category: "plan" },
+ { id: "rainy-day", emoji: "🌧️", title: "雨天备选", desc: "下雨也能玩的室内计划", href: "/#rainy-day", category: "plan" },
+ { id: "day-plan", emoji: "🧩", title: "远程日规划", desc: "拼工作块生成时间轴", href: "/#day-plan", category: "work" },
{ id: "daylight", emoji: "☀️", title: "日照办公窗", desc: "日出日落与深度工作", href: "/#daylight", category: "work" },
{ id: "calculator", emoji: "🧮", title: "费用计算器", desc: "按住宿档次估算", href: "/#calculator", category: "money" },
{ id: "cost-compare", emoji: "💰", title: "省钱对比", desc: "家乡 vs 旅居成本", href: "/#cost-compare", category: "money" },
{ id: "savings", emoji: "🎯", title: "启动金目标", desc: "存款进度追踪", href: "/#savings", category: "money" },
{ id: "runway", emoji: "🚀", title: "旅居跑道", desc: "存款能撑几个月", href: "/#runway", category: "money" },
- { id: "first-month", emoji: "🛬", title: "落地首月成本", desc: "房租押金SIM一次算清", href: "/#first-month", category: "money" },
+ { id: "first-month", emoji: "🛫", title: "落地首月成本", desc: "房租押金SIM一次算清", href: "/#first-month", category: "money" },
{ id: "expenses", emoji: "📒", title: "支出账本", desc: "分类记账汇总", href: "/#expenses", category: "money" },
+ { id: "bill-split", emoji: "➗", title: "聚餐分账", desc: "人均与小费秒算", href: "/#bill-split", category: "money" },
+ { id: "invoice-fx", emoji: "💵", title: "收款汇率", desc: "开票扣费净到手", href: "/#invoice-fx", category: "money" },
+ { id: "tipping", emoji: "🪙", title: "小费速查", desc: "各地小费文化", href: "/#tipping", category: "money" },
+ { id: "laundry", emoji: "👕", title: "洗衣日预算", desc: "按公斤估算开销", href: "/#laundry", category: "money" },
{ id: "flight", emoji: "✈️", title: "机票估算", desc: "往返票价粗算", href: "/#flight", category: "money" },
{ id: "currency", emoji: "💱", title: "汇率换算", desc: "多币种互转", href: "/#currency", category: "money" },
{ id: "tax-days", emoji: "🧾", title: "税居天数", desc: "183 天规则追踪", href: "/#tax-days", category: "money" },
{ id: "timezone", emoji: "🕐", title: "时区看板", desc: "工作重叠分析", href: "/#timezone", category: "work" },
{ id: "meetings", emoji: "🤝", title: "会议黄金时段", desc: "对齐可开会窗口", href: "/#meetings", category: "work" },
- { id: "jetlag", emoji: "😴", title: "时差恢复", desc: "恢复期建议", href: "/#jetlag", category: "work" },
- { id: "wifi", emoji: "📶", title: "网速评级", desc: "远程办公是否够用", href: "/#wifi", category: "work" },
+ { id: "jetlag", emoji: "🥱", title: "时差恢复", desc: "恢复期建议", href: "/#jetlag", category: "work" },
+ { id: "sleep", emoji: "😴", title: "睡眠周期", desc: "倒推最佳入睡点", href: "/#sleep", category: "work" },
+ { id: "wifi", emoji: "📡", title: "网速评级", desc: "远程办公是否够用", href: "/#wifi", category: "work" },
{ id: "workspot", emoji: "💼", title: "办公点成本", desc: "月卡 vs 咖啡馆划算度", href: "/#workspot", category: "work" },
{ id: "data-budget", emoji: "📶", title: "流量预算", desc: "月流量与套餐建议", href: "/#data-budget", category: "work" },
{ id: "focus", emoji: "⏱️", title: "专注时钟", desc: "番茄钟深度工作", href: "/#focus", category: "work" },
+ { id: "mood", emoji: "🪞", title: "心情签到", desc: "旅居情绪一分钟记录", href: "/#mood", category: "work" },
+ { id: "hydration", emoji: "💧", title: "饮水打卡", desc: "按气候调整每日目标", href: "/#hydration", category: "life" },
{ id: "coworking", emoji: "💻", title: "联合办公", desc: "热门 Co-working", href: "/#coworking", category: "work" },
{ id: "cafes", emoji: "☕", title: "咖啡馆", desc: "办公友好咖啡店", href: "/#cafes", category: "work" },
{ id: "visa", emoji: "📋", title: "签证指南", desc: "政策与智能匹配", href: "/#visa", category: "life" },
{ id: "visa-stay", emoji: "🛂", title: "停留倒计时", desc: "签证剩余天数提醒", href: "/#visa-stay", category: "life" },
{ id: "housing", emoji: "🏡", title: "住宿指南", desc: "Coliving / 公寓方案", href: "/#housing", category: "life" },
+ { id: "coliving-qa", emoji: "❓", title: "合租看房问题", desc: "看房必问清单", href: "/#coliving-qa", category: "life" },
+ { id: "deposit-return", emoji: "🔐", title: "押金退还清单", desc: "退房核对少踩坑", href: "/#deposit-return", category: "life" },
{ id: "sim", emoji: "📱", title: "上网方案", desc: "SIM / eSIM 推荐", href: "/#sim", category: "life" },
{ id: "plugs", emoji: "🔌", title: "电源插座", desc: "电压与插头类型", href: "/#plugs", category: "life" },
{ id: "packing", emoji: "🧳", title: "行李清单", desc: "出发前逐项勾选", href: "/#packing", category: "life" },
{ id: "bag-weight", emoji: "⚖️", title: "行李重量", desc: "对照航司限额估算", href: "/#bag-weight", category: "life" },
{ id: "arrival", emoji: "🛬", title: "到站清单", desc: "落地第一周", href: "/#arrival", category: "life" },
+ { id: "airport-transfer", emoji: "🚕", title: "机场接驳", desc: "省钱/尽快/省心排序", href: "/#airport-transfer", category: "life" },
{ id: "phrases", emoji: "🗣️", title: "生存短语", desc: "常用语一键复制", href: "/#phrases", category: "life" },
{ id: "notes", emoji: "📝", title: "城市笔记", desc: "本地私密记录", href: "/#notes", category: "life" },
{ id: "insurance", emoji: "🏥", title: "保险指南", desc: "国际医疗保险对比", href: "/#insurance", category: "safety" },
{ id: "emergency", emoji: "🆘", title: "紧急联系", desc: "急救与领事馆", href: "/#emergency", category: "safety" },
{ id: "atm", emoji: "🏧", title: "取现避坑", desc: "ATM 与换汇建议", href: "/#atm", category: "safety" },
{ id: "food-water", emoji: "🥗", title: "饮食饮水", desc: "落地肠胃与饮水注意", href: "/#food-water", category: "safety" },
+ { id: "scam-alerts", emoji: "🚨", title: "当地防坑", desc: "高频套路速览", href: "/#scam-alerts", category: "safety" },
];
export const TOOL_CATEGORIES = [