From d857d5b9b2369c4c3f15bc784753086693c04518 Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 29 Aug 2026 02:32:54 -0500 Subject: [PATCH] Add 14 nomad tools in one batch: weekend, money, life, and safety kits. Co-authored-by: Cursor --- frontend/src/app/changelog/page.tsx | 25 ++- frontend/src/app/globals.css | 134 ++++++++++++++++ frontend/src/app/tools/page.tsx | 4 +- .../src/components/AirportTransferPick.tsx | 94 ++++++++++++ frontend/src/components/BillSplit.tsx | 51 ++++++ frontend/src/components/ColivingQuestions.tsx | 66 ++++++++ .../src/components/DepositReturnChecklist.tsx | 137 +++++++++++++++++ frontend/src/components/HomeClient.tsx | 28 ++++ frontend/src/components/HydrationTracker.tsx | 66 ++++++++ frontend/src/components/InvoiceFxHelper.tsx | 48 ++++++ frontend/src/components/LaundryDay.tsx | 60 ++++++++ frontend/src/components/LocalScamAlerts.tsx | 70 +++++++++ frontend/src/components/NomadMoodCheck.tsx | 74 +++++++++ frontend/src/components/RainyDayBackup.tsx | 70 +++++++++ frontend/src/components/RemoteDayPlan.tsx | 64 ++++++++ frontend/src/components/SleepWindDown.tsx | 44 ++++++ frontend/src/components/TipGuide.tsx | 55 +++++++ frontend/src/components/ToolsStrip.tsx | 14 +- frontend/src/components/WeekendIdeas.tsx | 145 ++++++++++++++++++ frontend/src/lib/tools.ts | 20 ++- 20 files changed, 1244 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/AirportTransferPick.tsx create mode 100644 frontend/src/components/BillSplit.tsx create mode 100644 frontend/src/components/ColivingQuestions.tsx create mode 100644 frontend/src/components/DepositReturnChecklist.tsx create mode 100644 frontend/src/components/HydrationTracker.tsx create mode 100644 frontend/src/components/InvoiceFxHelper.tsx create mode 100644 frontend/src/components/LaundryDay.tsx create mode 100644 frontend/src/components/LocalScamAlerts.tsx create mode 100644 frontend/src/components/NomadMoodCheck.tsx create mode 100644 frontend/src/components/RainyDayBackup.tsx create mode 100644 frontend/src/components/RemoteDayPlan.tsx create mode 100644 frontend/src/components/SleepWindDown.tsx create mode 100644 frontend/src/components/TipGuide.tsx create mode 100644 frontend/src/components/WeekendIdeas.tsx 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 +

洗衣日预算

+

出差旅居最容易忘的一笔小开销

+
+
+
+ + +
+
+ + +
+
+ 🧺 +
预估¥{total}

{rate.note}

+
+
+
+
+ ); +} 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 = [