Add 14 nomad tools in one batch: weekend, money, life, and safety kits.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9b04246f0d
commit
d857d5b9b2
@ -8,30 +8,29 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LOGS = [
|
const LOGS = [
|
||||||
|
{
|
||||||
|
date: "2026-08-29",
|
||||||
|
tag: "大更新",
|
||||||
|
items: [
|
||||||
|
"一次上新 14 个工具:周末灵感、雨天备选、远程日规划、睡眠周期、心情签到、饮水打卡",
|
||||||
|
"聚餐分账、收款汇率、小费速查、洗衣预算、机场接驳、合租看房问题、押金退还清单、当地防坑",
|
||||||
|
"工具箱推荐位与快捷条同步更新,可搜索全部新工具",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
date: "2026-08-29",
|
date: "2026-08-29",
|
||||||
tag: "新工具",
|
tag: "新工具",
|
||||||
items: [
|
items: [
|
||||||
"落地首月成本:房租押金 SIM 接机一次算清",
|
"落地首月成本、流量预算、签证停留倒计时、办公点成本对比",
|
||||||
"流量预算规划:按办公习惯估算月 GB 与套餐档",
|
|
||||||
"签证停留倒计时:入境日 + 额度,剩余天数环形提醒",
|
|
||||||
"办公点成本对比:联合办公月卡 vs 咖啡馆日刷",
|
|
||||||
"日照办公窗、行李重量、旅居跑道、会议黄金时段",
|
"日照办公窗、行李重量、旅居跑道、会议黄金时段",
|
||||||
"快捷工具条直达首月 / 流量 / 签证等核心工具",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: "2026-08-29",
|
date: "2026-08-29",
|
||||||
tag: "体验优化",
|
tag: "体验优化",
|
||||||
items: [
|
items: [
|
||||||
"全局搜索覆盖全部工具与页面",
|
"首页视口内按需挂载;粒子减负;Loader 更快;原生 systemd 部署",
|
||||||
"首页常用工具快捷条 + 下方模块懒加载",
|
"全局搜索覆盖工具与页面;行程 Markdown 导出",
|
||||||
"行程支持下载 Markdown 文件",
|
|
||||||
"新增饮食饮水注意事项",
|
|
||||||
"修复生产环境 CSS 解析失败导致的页面异常",
|
|
||||||
"部署改为原生 systemd(非 Docker 优先),增量构建更快",
|
|
||||||
"首页视口内按需挂载下方模块,加快首屏;粒子动画减负;Loader 更快消失",
|
|
||||||
"专注时钟增加累计专注块统计",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -8546,3 +8546,137 @@ img { max-width: 100%; display: block; }
|
|||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.data-budget-grid { grid-template-columns: 1fr; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import Link from "next/link";
|
|||||||
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
|
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
|
||||||
import SiteShell from "@/components/SiteShell";
|
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() {
|
export default function ToolsHubPage() {
|
||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all");
|
||||||
@ -41,7 +41,7 @@ export default function ToolsHubPage() {
|
|||||||
<div className="tools-featured">
|
<div className="tools-featured">
|
||||||
<div className="tools-featured-head">
|
<div className="tools-featured-head">
|
||||||
<strong>✨ 本周推荐</strong>
|
<strong>✨ 本周推荐</strong>
|
||||||
<span>新上线 · 首月成本 & 流量预算</span>
|
<span>一次上新 12+ 工具 · 周末/分账/防坑…</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="tools-featured-grid">
|
<div className="tools-featured-grid">
|
||||||
{featured.map((t) => (
|
{featured.map((t) => (
|
||||||
|
|||||||
94
frontend/src/components/AirportTransferPick.tsx
Normal file
94
frontend/src/components/AirportTransferPick.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
|
||||||
|
const OPTIONS: Record<string, { mode: string; emoji: string; time: string; cost: number; note: string }[]> = {
|
||||||
|
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 (
|
||||||
|
<section className="section kit-section" id="airport-transfer">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🛬 TRANSFER</span>
|
||||||
|
<h2>机场接驳选择</h2>
|
||||||
|
<p>落地第一程,按预算/速度/省心排序</p>
|
||||||
|
</div>
|
||||||
|
<div className="kit-card reveal">
|
||||||
|
<div className="kit-row">
|
||||||
|
<label className="kit-field"><span>城市</span>
|
||||||
|
<select value={slug} onChange={(e) => setSlug(e.target.value)}>
|
||||||
|
{destinations.map((d) => <option key={d.slug} value={d.slug}>{d.emoji} {d.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="kit-chips">
|
||||||
|
{([["cheap", "省钱"], ["fast", "尽快"], ["easy", "省心"]] as const).map(([k, l]) => (
|
||||||
|
<button key={k} type="button" className={`kit-chip${prefer === k ? " active" : ""}`} onClick={() => setPrefer(k)}>{l}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kit-hero">
|
||||||
|
<span>{best.emoji}</span>
|
||||||
|
<div>
|
||||||
|
<small>{dest?.name} 推荐</small>
|
||||||
|
<strong>{best.mode}</strong>
|
||||||
|
<p>{best.time} · 约 ¥{best.cost} · {best.note}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kit-list">
|
||||||
|
{ranked.map((o) => (
|
||||||
|
<div key={o.mode} className="kit-list-item">
|
||||||
|
<span>{o.emoji}</span>
|
||||||
|
<div><strong>{o.mode}</strong><small>{o.time} · ¥{o.cost}</small></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/components/BillSplit.tsx
Normal file
51
frontend/src/components/BillSplit.tsx
Normal file
@ -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 (
|
||||||
|
<section className="section kit-section" id="bill-split">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">➗ SPLIT</span>
|
||||||
|
<h2>聚餐分账</h2>
|
||||||
|
<p>和游民朋友吃饭,秒算每人多少</p>
|
||||||
|
</div>
|
||||||
|
<div className="kit-card reveal">
|
||||||
|
<div className="kit-row">
|
||||||
|
<label className="kit-field"><span>总金额 ¥</span>
|
||||||
|
<input type="number" min={0} value={total} onChange={(e) => setTotal(Math.max(0, +e.target.value || 0))} />
|
||||||
|
</label>
|
||||||
|
<label className="kit-field"><span>人数</span>
|
||||||
|
<input type="number" min={1} max={20} value={people} onChange={(e) => setPeople(Math.max(1, +e.target.value || 1))} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="kit-field"><span>小费 {tipPct}%</span>
|
||||||
|
<input type="range" min={0} max={20} value={tipPct} onChange={(e) => setTipPct(+e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kit-field"><span>我额外点了 ¥</span>
|
||||||
|
<input type="number" min={0} value={meExtra} onChange={(e) => setMeExtra(Math.max(0, +e.target.value || 0))} />
|
||||||
|
</label>
|
||||||
|
<div className="kit-grid-3">
|
||||||
|
<div><small>人均</small><strong>¥{per.each}</strong></div>
|
||||||
|
<div><small>我应付</small><strong>¥{per.me}</strong></div>
|
||||||
|
<div><small>含小费总计</small><strong>¥{per.grand}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/components/ColivingQuestions.tsx
Normal file
66
frontend/src/components/ColivingQuestions.tsx
Normal file
@ -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<string[]>([]);
|
||||||
|
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 (
|
||||||
|
<section className="section kit-section" id="coliving-qa">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🏠 COLIVING Q&A</span>
|
||||||
|
<h2>合租看房问题单</h2>
|
||||||
|
<p>勾选你要问的,一键复制发给房东/管家</p>
|
||||||
|
</div>
|
||||||
|
<div className="kit-card reveal">
|
||||||
|
<div className="kit-list">
|
||||||
|
{QUESTIONS.map((q) => {
|
||||||
|
const on = checked.includes(q.id);
|
||||||
|
return (
|
||||||
|
<button key={q.id} type="button" className={`kit-list-item btnish${on ? " on" : ""}`} onClick={() => toggle(q.id)}>
|
||||||
|
<span>{q.emoji}</span>
|
||||||
|
<div><strong>{q.q}</strong></div>
|
||||||
|
<i>{on ? "✓" : "+"}</i>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="kit-actions">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={copy}>复制问题单</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setChecked([])}>清空</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
frontend/src/components/DepositReturnChecklist.tsx
Normal file
137
frontend/src/components/DepositReturnChecklist.tsx
Normal file
@ -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<string[]>([]);
|
||||||
|
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 (
|
||||||
|
<section className="section deposit-section" id="deposit-return">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🔐 DEPOSIT</span>
|
||||||
|
<h2>押金退还清单</h2>
|
||||||
|
<p>退房前逐项勾选,把押金拿回来更稳</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="deposit-card reveal">
|
||||||
|
<div className="deposit-top">
|
||||||
|
<div className="deposit-meter">
|
||||||
|
<div className="deposit-gauge"><i style={{ width: `${pct}%`, background: status.color }} /></div>
|
||||||
|
<div className="deposit-meta">
|
||||||
|
<strong style={{ color: status.color }}>{pct}%</strong>
|
||||||
|
<span>{status.label} · 还剩 {left} 项</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="deposit-fields">
|
||||||
|
<label>
|
||||||
|
<span>押金金额(¥)</span>
|
||||||
|
<input type="number" min={0} step={100} value={amount} onChange={(e) => setAmount(Math.max(0, +e.target.value || 0))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>预计退还日</span>
|
||||||
|
<input type="date" value={due} onChange={(e) => setDue(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="deposit-grid">
|
||||||
|
{ITEMS.map((item) => {
|
||||||
|
const on = checked.includes(item.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={`deposit-item${on ? " on" : ""}`}
|
||||||
|
onClick={() => toggle(item.id)}
|
||||||
|
aria-pressed={on}
|
||||||
|
>
|
||||||
|
<span>{item.emoji}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{item.label}</strong>
|
||||||
|
<small>{item.tip}</small>
|
||||||
|
</div>
|
||||||
|
<i>{on ? "✓" : ""}</i>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="deposit-actions">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={copySummary}>复制核对摘要</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => { setChecked([]); toast("已清空勾选"); }}>清空</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -70,6 +70,20 @@ const ArrivalChecklist = lazy(() => import("./ArrivalChecklist"));
|
|||||||
const Phrasebook = lazy(() => import("./Phrasebook"));
|
const Phrasebook = lazy(() => import("./Phrasebook"));
|
||||||
const CityNotes = lazy(() => import("./CityNotes"));
|
const CityNotes = lazy(() => import("./CityNotes"));
|
||||||
const NomadEvents = lazy(() => import("./NomadEvents"));
|
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 Tools = lazy(() => import("./Tools"));
|
||||||
const BlogSection = lazy(() => import("./BlogSection"));
|
const BlogSection = lazy(() => import("./BlogSection"));
|
||||||
const FAQSection = lazy(() => import("./FAQSection"));
|
const FAQSection = lazy(() => import("./FAQSection"));
|
||||||
@ -186,6 +200,10 @@ export default function HomeClient(props: Props) {
|
|||||||
<WhenVisible><WorkSpotCost destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><WorkSpotCost destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><DataBudgetPlanner /></WhenVisible>
|
<WhenVisible><DataBudgetPlanner /></WhenVisible>
|
||||||
<WhenVisible><FocusTimer /></WhenVisible>
|
<WhenVisible><FocusTimer /></WhenVisible>
|
||||||
|
<WhenVisible><RemoteDayPlan /></WhenVisible>
|
||||||
|
<WhenVisible><SleepWindDown /></WhenVisible>
|
||||||
|
<WhenVisible><NomadMoodCheck /></WhenVisible>
|
||||||
|
<WhenVisible><HydrationTracker destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><NomadScore /></WhenVisible>
|
<WhenVisible><NomadScore /></WhenVisible>
|
||||||
<WhenVisible><Lifestyle /></WhenVisible>
|
<WhenVisible><Lifestyle /></WhenVisible>
|
||||||
<WhenVisible><Charts {...props.charts} /></WhenVisible>
|
<WhenVisible><Charts {...props.charts} /></WhenVisible>
|
||||||
@ -195,15 +213,25 @@ export default function HomeClient(props: Props) {
|
|||||||
<WhenVisible><EmergencyContacts destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><EmergencyContacts destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><AtmCashGuide destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><AtmCashGuide destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><FoodWaterTips destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><FoodWaterTips destinations={props.destinations} /></WhenVisible>
|
||||||
|
<WhenVisible><LocalScamAlerts destinations={props.destinations} /></WhenVisible>
|
||||||
|
<WhenVisible><TipGuide destinations={props.destinations} /></WhenVisible>
|
||||||
|
<WhenVisible><BillSplit /></WhenVisible>
|
||||||
|
<WhenVisible><InvoiceFxHelper /></WhenVisible>
|
||||||
|
<WhenVisible><LaundryDay destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><CoworkingGuide /></WhenVisible>
|
<WhenVisible><CoworkingGuide /></WhenVisible>
|
||||||
<WhenVisible><HousingGuide destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><HousingGuide destinations={props.destinations} /></WhenVisible>
|
||||||
|
<WhenVisible><ColivingQuestions /></WhenVisible>
|
||||||
|
<WhenVisible><DepositReturnChecklist /></WhenVisible>
|
||||||
<WhenVisible><SimGuide destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><SimGuide destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><PowerPlugGuide destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><PowerPlugGuide destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><CafeGuide destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><CafeGuide destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><SeasonGuide destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><SeasonGuide destinations={props.destinations} /></WhenVisible>
|
||||||
|
<WhenVisible><WeekendIdeas destinations={props.destinations} /></WhenVisible>
|
||||||
|
<WhenVisible><RainyDayBackup destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><PackingChecklist /></WhenVisible>
|
<WhenVisible><PackingChecklist /></WhenVisible>
|
||||||
<WhenVisible><BagWeightEstimator /></WhenVisible>
|
<WhenVisible><BagWeightEstimator /></WhenVisible>
|
||||||
<WhenVisible><ArrivalChecklist destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><ArrivalChecklist destinations={props.destinations} /></WhenVisible>
|
||||||
|
<WhenVisible><AirportTransferPick destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><Phrasebook destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><Phrasebook destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><CityNotes destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><CityNotes destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><NomadEvents /></WhenVisible>
|
<WhenVisible><NomadEvents /></WhenVisible>
|
||||||
|
|||||||
66
frontend/src/components/HydrationTracker.tsx
Normal file
66
frontend/src/components/HydrationTracker.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
|
||||||
|
const CLIMATE: Record<string, number> = {
|
||||||
|
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 (
|
||||||
|
<section className="section kit-section" id="hydration">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">💧 HYDRATE</span>
|
||||||
|
<h2>饮水打卡</h2>
|
||||||
|
<p>按城市气候调整目标,今天喝够了吗?</p>
|
||||||
|
</div>
|
||||||
|
<div className="kit-card reveal">
|
||||||
|
<div className="kit-row">
|
||||||
|
<label className="kit-field">
|
||||||
|
<span>城市</span>
|
||||||
|
<select value={slug} onChange={(e) => setSlug(e.target.value)}>
|
||||||
|
{destinations.map((d) => <option key={d.slug} value={d.slug}>{d.emoji} {d.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="kit-stat">
|
||||||
|
<strong>{cups}/{goal}</strong>
|
||||||
|
<small>{dest?.emoji} 杯 · 约 {cups * 250}ml</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kit-bar"><i style={{ width: `${pct}%` }} /></div>
|
||||||
|
<div className="kit-actions">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setCups((c) => Math.min(c + 1, 20))}>+1 杯</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setCups((c) => Math.max(c - 1, 0))}>-1</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setCups(0)}>重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/InvoiceFxHelper.tsx
Normal file
48
frontend/src/components/InvoiceFxHelper.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const RATES: Record<string, number> = { 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 (
|
||||||
|
<section className="section kit-section" id="invoice-fx">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">💼 INVOICE FX</span>
|
||||||
|
<h2>收款汇率估算</h2>
|
||||||
|
<p>远程开票到账,扣掉手续费还剩多少</p>
|
||||||
|
</div>
|
||||||
|
<div className="kit-card reveal">
|
||||||
|
<div className="kit-row">
|
||||||
|
<label className="kit-field"><span>开票金额</span>
|
||||||
|
<input type="number" min={0} value={amount} onChange={(e) => setAmount(Math.max(0, +e.target.value || 0))} />
|
||||||
|
</label>
|
||||||
|
<label className="kit-field"><span>币种</span>
|
||||||
|
<select value={currency} onChange={(e) => setCurrency(e.target.value)}>
|
||||||
|
{Object.keys(RATES).map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="kit-field"><span>平台/汇损约 {feePct}%</span>
|
||||||
|
<input type="range" min={0} max={8} step={0.5} value={feePct} onChange={(e) => setFeePct(+e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="kit-grid-3">
|
||||||
|
<div><small>毛估入账</small><strong>¥{cny.raw.toLocaleString()}</strong></div>
|
||||||
|
<div><small>费用</small><strong>¥{cny.fee.toLocaleString()}</strong></div>
|
||||||
|
<div><small>净到手</small><strong>¥{cny.net.toLocaleString()}</strong></div>
|
||||||
|
</div>
|
||||||
|
<p className="kit-note">汇率为示意,实际以到账日为准</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/components/LaundryDay.tsx
Normal file
60
frontend/src/components/LaundryDay.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
|
||||||
|
const RATES: Record<string, { wash: number; dry: number; fold: number; note: string }> = {
|
||||||
|
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 (
|
||||||
|
<section className="section kit-section" id="laundry">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">👕 LAUNDRY</span>
|
||||||
|
<h2>洗衣日预算</h2>
|
||||||
|
<p>出差旅居最容易忘的一笔小开销</p>
|
||||||
|
</div>
|
||||||
|
<div className="kit-card reveal">
|
||||||
|
<div className="kit-row">
|
||||||
|
<label className="kit-field"><span>城市</span>
|
||||||
|
<select value={slug} onChange={(e) => setSlug(e.target.value)}>
|
||||||
|
{destinations.map((d) => <option key={d.slug} value={d.slug}>{d.emoji} {d.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="kit-field"><span>重量 {kg} kg</span>
|
||||||
|
<input type="range" min={1} max={10} value={kg} onChange={(e) => setKg(+e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="kit-chips">
|
||||||
|
<button type="button" className={`kit-chip${dry ? " active" : ""}`} onClick={() => setDry((v) => !v)}>烘干</button>
|
||||||
|
<button type="button" className={`kit-chip${fold ? " active" : ""}`} onClick={() => setFold((v) => !v)}>叠衣</button>
|
||||||
|
</div>
|
||||||
|
<div className="kit-hero">
|
||||||
|
<span>🧺</span>
|
||||||
|
<div><small>预估</small><strong>¥{total}</strong><p>{rate.note}</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
frontend/src/components/LocalScamAlerts.tsx
Normal file
70
frontend/src/components/LocalScamAlerts.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
|
||||||
|
const ALERTS: Record<string, { emoji: string; title: string; detail: string }[]> = {
|
||||||
|
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 (
|
||||||
|
<section className="section kit-section" id="scam-alerts">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🚨 SCAM WATCH</span>
|
||||||
|
<h2>当地防坑提醒</h2>
|
||||||
|
<p>高频套路速览,落地少交学费</p>
|
||||||
|
</div>
|
||||||
|
<div className="kit-card reveal">
|
||||||
|
<label className="kit-field"><span>城市</span>
|
||||||
|
<select value={slug} onChange={(e) => setSlug(e.target.value)}>
|
||||||
|
{destinations.map((d) => <option key={d.slug} value={d.slug}>{d.emoji} {d.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="kit-list">
|
||||||
|
{list.map((a) => (
|
||||||
|
<div key={a.title} className="kit-list-item">
|
||||||
|
<span>{a.emoji}</span>
|
||||||
|
<div><strong>{a.title}</strong><small>{a.detail}</small></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
frontend/src/components/NomadMoodCheck.tsx
Normal file
74
frontend/src/components/NomadMoodCheck.tsx
Normal file
@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<section className="section kit-section" id="mood">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🪞 MOOD</span>
|
||||||
|
<h2>游民心情签到</h2>
|
||||||
|
<p>旅居情绪也需要被看见,一分钟记录就好</p>
|
||||||
|
</div>
|
||||||
|
<div className="kit-card reveal">
|
||||||
|
<div className="kit-chips wrap">
|
||||||
|
{MOODS.map((m) => (
|
||||||
|
<button key={m.key} type="button" className={`kit-chip${mood === m.key ? " active" : ""}`} onClick={() => setMood(m.key)}>
|
||||||
|
{m.emoji} {m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="kit-note">{TIPS[mood]}</p>
|
||||||
|
<label className="kit-field">
|
||||||
|
<span>一句话(可选)</span>
|
||||||
|
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="今天最想记住的事…" maxLength={80} />
|
||||||
|
</label>
|
||||||
|
<div className="kit-actions">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={save}>{saved ? "已保存,再更新" : "保存今日心情"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
frontend/src/components/RainyDayBackup.tsx
Normal file
70
frontend/src/components/RainyDayBackup.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
|
||||||
|
const INDOOR: Record<string, { emoji: string; title: string; detail: string }[]> = {
|
||||||
|
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 (
|
||||||
|
<section className="section kit-section" id="rainy-day">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🌧️ RAINY DAY</span>
|
||||||
|
<h2>雨天备选计划</h2>
|
||||||
|
<p>下雨别荒废周末,室内也能过得很满</p>
|
||||||
|
</div>
|
||||||
|
<div className="kit-card reveal">
|
||||||
|
<label className="kit-field"><span>城市</span>
|
||||||
|
<select value={slug} onChange={(e) => setSlug(e.target.value)}>
|
||||||
|
{destinations.map((d) => <option key={d.slug} value={d.slug}>{d.emoji} {d.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="kit-list">
|
||||||
|
{list.map((x) => (
|
||||||
|
<div key={x.title} className="kit-list-item">
|
||||||
|
<span>{x.emoji}</span>
|
||||||
|
<div><strong>{x.title}</strong><small>{x.detail}</small></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
frontend/src/components/RemoteDayPlan.tsx
Normal file
64
frontend/src/components/RemoteDayPlan.tsx
Normal file
@ -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 (
|
||||||
|
<section className="section kit-section" id="day-plan">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🗓️ DAY PLAN</span>
|
||||||
|
<h2>远程日规划</h2>
|
||||||
|
<p>拼出今天的工作块,时间轴自动排好</p>
|
||||||
|
</div>
|
||||||
|
<div className="kit-card reveal">
|
||||||
|
<label className="kit-field"><span>开始时间 {String(start).padStart(2, "0")}:00</span>
|
||||||
|
<input type="range" min={6} max={12} value={start} onChange={(e) => setStart(+e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="kit-chips wrap">
|
||||||
|
{BLOCKS.map((b) => (
|
||||||
|
<button key={b.id} type="button" className={`kit-chip${selected.includes(b.id) ? " active" : ""}`} onClick={() => toggle(b.id)}>
|
||||||
|
{b.emoji} {b.label} · {b.mins}m
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="kit-note">合计约 <b>{Math.round(total / 60 * 10) / 10} 小时</b></p>
|
||||||
|
<div className="kit-list">
|
||||||
|
{timeline.map((t) => (
|
||||||
|
<div key={t.id} className="kit-list-item">
|
||||||
|
<span>{t.emoji}</span>
|
||||||
|
<div><strong>{t.label}</strong><small>{t.from} – {t.to}</small></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
frontend/src/components/SleepWindDown.tsx
Normal file
44
frontend/src/components/SleepWindDown.tsx
Normal file
@ -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 (
|
||||||
|
<section className="section kit-section" id="sleep">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">😴 SLEEP</span>
|
||||||
|
<h2>睡眠周期入睡点</h2>
|
||||||
|
<p>按起床时间倒推,睡够完整周期少浑噩</p>
|
||||||
|
</div>
|
||||||
|
<div className="kit-card reveal">
|
||||||
|
<label className="kit-field"><span>想几点起 {String(wake).padStart(2, "0")}:00</span>
|
||||||
|
<input type="range" min={5} max={11} value={wake} onChange={(e) => setWake(+e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kit-field"><span>睡眠周期 {cycles} × 90 分钟</span>
|
||||||
|
<input type="range" min={3} max={6} value={cycles} onChange={(e) => setCycles(+e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="kit-hero">
|
||||||
|
<span>🛏️</span>
|
||||||
|
<div>
|
||||||
|
<small>建议入睡</small>
|
||||||
|
<strong>{bed}</strong>
|
||||||
|
<p>含约 15 分钟入睡缓冲 · 共约 {cycles * 1.5} 小时</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
frontend/src/components/TipGuide.tsx
Normal file
55
frontend/src/components/TipGuide.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
|
||||||
|
const TIPS: Record<string, { restaurant: string; taxi: string; coffee: string; note: string }> = {
|
||||||
|
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 (
|
||||||
|
<section className="section kit-section" id="tipping">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🪙 TIPPING</span>
|
||||||
|
<h2>小费速查</h2>
|
||||||
|
<p>别用错文化习惯,当地人怎么给就怎么给</p>
|
||||||
|
</div>
|
||||||
|
<div className="kit-card reveal">
|
||||||
|
<div className="kit-row">
|
||||||
|
<label className="kit-field">
|
||||||
|
<span>城市</span>
|
||||||
|
<select value={slug} onChange={(e) => setSlug(e.target.value)}>
|
||||||
|
{destinations.map((d) => <option key={d.slug} value={d.slug}>{d.emoji} {d.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="kit-field">
|
||||||
|
<span>账单(¥估)</span>
|
||||||
|
<input type="number" min={0} value={bill} onChange={(e) => setBill(Math.max(0, +e.target.value || 0))} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="kit-grid-3">
|
||||||
|
<div><small>餐厅</small><strong>{tip.restaurant}</strong></div>
|
||||||
|
<div><small>打车</small><strong>{tip.taxi}</strong></div>
|
||||||
|
<div><small>咖啡</small><strong>{tip.coffee}</strong></div>
|
||||||
|
</div>
|
||||||
|
<p className="kit-note">{dest?.emoji} {tip.note}</p>
|
||||||
|
<p className="kit-note">参考小费约 <b>¥{suggest}</b>(仅估算)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,13 +3,13 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
const QUICK = [
|
const QUICK = [
|
||||||
{ href: "/#first-month", emoji: "🛬", label: "首月" },
|
{ href: "/#weekend", emoji: "🎉", label: "周末" },
|
||||||
{ href: "/#data-budget", emoji: "📶", label: "流量" },
|
{ href: "/#day-plan", emoji: "🧩", label: "日程" },
|
||||||
{ href: "/#visa-stay", emoji: "🛂", label: "签证" },
|
{ href: "/#bill-split", emoji: "➗", label: "分账" },
|
||||||
{ href: "/#workspot", emoji: "💼", label: "办公" },
|
{ href: "/#mood", emoji: "🪞", label: "心情" },
|
||||||
{ href: "/#runway", emoji: "🚀", label: "跑道" },
|
{ href: "/#scam-alerts", emoji: "🚨", label: "防坑" },
|
||||||
{ href: "/#trip", emoji: "🗓️", label: "行程" },
|
{ href: "/#deposit-return", emoji: "🔐", label: "押金" },
|
||||||
{ href: "/#focus", emoji: "⏱️", label: "专注" },
|
{ href: "/#airport-transfer", emoji: "🚕", label: "接驳" },
|
||||||
{ href: "/tools", emoji: "🛠️", label: "全部" },
|
{ href: "/tools", emoji: "🛠️", label: "全部" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
145
frontend/src/components/WeekendIdeas.tsx
Normal file
145
frontend/src/components/WeekendIdeas.tsx
Normal file
@ -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<string, Idea[]> = {
|
||||||
|
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 (
|
||||||
|
<section className="section weekend-section" id="weekend">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🎉 WEEKEND</span>
|
||||||
|
<h2>周末活动灵感</h2>
|
||||||
|
<p>旅居不是只有工位,周末也要会玩会恢复</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="weekend-card reveal">
|
||||||
|
<div className="weekend-controls">
|
||||||
|
<label>
|
||||||
|
<span>城市</span>
|
||||||
|
<select
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSlug(e.target.value);
|
||||||
|
setPicked(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{destinations.map((d) => (
|
||||||
|
<option key={d.slug} value={d.slug}>{d.emoji} {d.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="weekend-energy">
|
||||||
|
{([
|
||||||
|
["chill", "😌 轻松恢复"],
|
||||||
|
["active", "🏃 动起来"],
|
||||||
|
["social", "🍻 社交局"],
|
||||||
|
] as const).map(([k, label]) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
className={`weekend-chip${energy === k ? " active" : ""}`}
|
||||||
|
onClick={() => { setEnergy(k); setPicked(0); }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="weekend-hero">
|
||||||
|
<span className="weekend-emoji">{current.emoji}</span>
|
||||||
|
<div>
|
||||||
|
<small>{dest?.emoji} {dest?.name} · 周末提案</small>
|
||||||
|
<h3>{current.title}</h3>
|
||||||
|
<p>{current.vibe}</p>
|
||||||
|
<em>预算约 {current.cost}</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="weekend-actions">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={shuffle}>换一个灵感 🎲</button>
|
||||||
|
<div className="weekend-list">
|
||||||
|
{list.map((idea, i) => (
|
||||||
|
<button
|
||||||
|
key={idea.title}
|
||||||
|
type="button"
|
||||||
|
className={`weekend-mini${i === picked % list.length ? " on" : ""}`}
|
||||||
|
onClick={() => setPicked(i)}
|
||||||
|
>
|
||||||
|
{idea.emoji} {idea.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,39 +12,53 @@ export const TOOL_LINKS: ToolLink[] = [
|
|||||||
{ id: "spin", emoji: "🎲", title: "命运转盘", desc: "随机抽下一站", href: "/#spin", category: "plan" },
|
{ id: "spin", emoji: "🎲", title: "命运转盘", desc: "随机抽下一站", href: "/#spin", category: "plan" },
|
||||||
{ id: "matcher", emoji: "🎯", title: "智能匹配", desc: "问卷推荐目的地", href: "/#destinations", category: "plan" },
|
{ id: "matcher", emoji: "🎯", title: "智能匹配", desc: "问卷推荐目的地", href: "/#destinations", category: "plan" },
|
||||||
{ id: "season", emoji: "🌤️", title: "最佳月份", desc: "避开雨季与旺季", href: "/#season", 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: "daylight", emoji: "☀️", title: "日照办公窗", desc: "日出日落与深度工作", href: "/#daylight", category: "work" },
|
||||||
{ id: "calculator", emoji: "🧮", title: "费用计算器", desc: "按住宿档次估算", href: "/#calculator", category: "money" },
|
{ id: "calculator", emoji: "🧮", title: "费用计算器", desc: "按住宿档次估算", href: "/#calculator", category: "money" },
|
||||||
{ id: "cost-compare", emoji: "💰", title: "省钱对比", desc: "家乡 vs 旅居成本", href: "/#cost-compare", category: "money" },
|
{ id: "cost-compare", emoji: "💰", title: "省钱对比", desc: "家乡 vs 旅居成本", href: "/#cost-compare", category: "money" },
|
||||||
{ id: "savings", emoji: "🎯", title: "启动金目标", desc: "存款进度追踪", href: "/#savings", category: "money" },
|
{ id: "savings", emoji: "🎯", title: "启动金目标", desc: "存款进度追踪", href: "/#savings", category: "money" },
|
||||||
{ id: "runway", emoji: "🚀", title: "旅居跑道", desc: "存款能撑几个月", href: "/#runway", 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: "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: "flight", emoji: "✈️", title: "机票估算", desc: "往返票价粗算", href: "/#flight", category: "money" },
|
||||||
{ id: "currency", emoji: "💱", title: "汇率换算", desc: "多币种互转", href: "/#currency", category: "money" },
|
{ id: "currency", emoji: "💱", title: "汇率换算", desc: "多币种互转", href: "/#currency", category: "money" },
|
||||||
{ id: "tax-days", emoji: "🧾", title: "税居天数", desc: "183 天规则追踪", href: "/#tax-days", category: "money" },
|
{ id: "tax-days", emoji: "🧾", title: "税居天数", desc: "183 天规则追踪", href: "/#tax-days", category: "money" },
|
||||||
{ id: "timezone", emoji: "🕐", title: "时区看板", desc: "工作重叠分析", href: "/#timezone", category: "work" },
|
{ id: "timezone", emoji: "🕐", title: "时区看板", desc: "工作重叠分析", href: "/#timezone", category: "work" },
|
||||||
{ id: "meetings", emoji: "🤝", title: "会议黄金时段", desc: "对齐可开会窗口", href: "/#meetings", category: "work" },
|
{ id: "meetings", emoji: "🤝", title: "会议黄金时段", desc: "对齐可开会窗口", href: "/#meetings", category: "work" },
|
||||||
{ id: "jetlag", emoji: "😴", title: "时差恢复", desc: "恢复期建议", href: "/#jetlag", category: "work" },
|
{ id: "jetlag", emoji: "🥱", title: "时差恢复", desc: "恢复期建议", href: "/#jetlag", category: "work" },
|
||||||
{ id: "wifi", emoji: "📶", title: "网速评级", desc: "远程办公是否够用", href: "/#wifi", 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: "workspot", emoji: "💼", title: "办公点成本", desc: "月卡 vs 咖啡馆划算度", href: "/#workspot", category: "work" },
|
||||||
{ id: "data-budget", emoji: "📶", title: "流量预算", desc: "月流量与套餐建议", href: "/#data-budget", category: "work" },
|
{ id: "data-budget", emoji: "📶", title: "流量预算", desc: "月流量与套餐建议", href: "/#data-budget", category: "work" },
|
||||||
{ id: "focus", emoji: "⏱️", title: "专注时钟", desc: "番茄钟深度工作", href: "/#focus", 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: "coworking", emoji: "💻", title: "联合办公", desc: "热门 Co-working", href: "/#coworking", category: "work" },
|
||||||
{ id: "cafes", emoji: "☕", title: "咖啡馆", desc: "办公友好咖啡店", href: "/#cafes", category: "work" },
|
{ id: "cafes", emoji: "☕", title: "咖啡馆", desc: "办公友好咖啡店", href: "/#cafes", category: "work" },
|
||||||
{ id: "visa", emoji: "📋", title: "签证指南", desc: "政策与智能匹配", href: "/#visa", category: "life" },
|
{ id: "visa", emoji: "📋", title: "签证指南", desc: "政策与智能匹配", href: "/#visa", category: "life" },
|
||||||
{ id: "visa-stay", emoji: "🛂", title: "停留倒计时", desc: "签证剩余天数提醒", href: "/#visa-stay", category: "life" },
|
{ id: "visa-stay", emoji: "🛂", title: "停留倒计时", desc: "签证剩余天数提醒", href: "/#visa-stay", category: "life" },
|
||||||
{ id: "housing", emoji: "🏡", title: "住宿指南", desc: "Coliving / 公寓方案", href: "/#housing", 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: "sim", emoji: "📱", title: "上网方案", desc: "SIM / eSIM 推荐", href: "/#sim", category: "life" },
|
||||||
{ id: "plugs", emoji: "🔌", title: "电源插座", desc: "电压与插头类型", href: "/#plugs", category: "life" },
|
{ id: "plugs", emoji: "🔌", title: "电源插座", desc: "电压与插头类型", href: "/#plugs", category: "life" },
|
||||||
{ id: "packing", emoji: "🧳", title: "行李清单", desc: "出发前逐项勾选", href: "/#packing", category: "life" },
|
{ id: "packing", emoji: "🧳", title: "行李清单", desc: "出发前逐项勾选", href: "/#packing", category: "life" },
|
||||||
{ id: "bag-weight", emoji: "⚖️", title: "行李重量", desc: "对照航司限额估算", href: "/#bag-weight", category: "life" },
|
{ id: "bag-weight", emoji: "⚖️", title: "行李重量", desc: "对照航司限额估算", href: "/#bag-weight", category: "life" },
|
||||||
{ id: "arrival", emoji: "🛬", title: "到站清单", desc: "落地第一周", href: "/#arrival", 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: "phrases", emoji: "🗣️", title: "生存短语", desc: "常用语一键复制", href: "/#phrases", category: "life" },
|
||||||
{ id: "notes", emoji: "📝", title: "城市笔记", desc: "本地私密记录", href: "/#notes", category: "life" },
|
{ id: "notes", emoji: "📝", title: "城市笔记", desc: "本地私密记录", href: "/#notes", category: "life" },
|
||||||
{ id: "insurance", emoji: "🏥", title: "保险指南", desc: "国际医疗保险对比", href: "/#insurance", category: "safety" },
|
{ id: "insurance", emoji: "🏥", title: "保险指南", desc: "国际医疗保险对比", href: "/#insurance", category: "safety" },
|
||||||
{ id: "emergency", emoji: "🆘", title: "紧急联系", desc: "急救与领事馆", href: "/#emergency", category: "safety" },
|
{ id: "emergency", emoji: "🆘", title: "紧急联系", desc: "急救与领事馆", href: "/#emergency", category: "safety" },
|
||||||
{ id: "atm", emoji: "🏧", title: "取现避坑", desc: "ATM 与换汇建议", href: "/#atm", category: "safety" },
|
{ id: "atm", emoji: "🏧", title: "取现避坑", desc: "ATM 与换汇建议", href: "/#atm", category: "safety" },
|
||||||
{ id: "food-water", emoji: "🥗", title: "饮食饮水", desc: "落地肠胃与饮水注意", href: "/#food-water", 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 = [
|
export const TOOL_CATEGORIES = [
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user