Add first-month landing cost and mobile data budget planners.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-29 02:16:45 -05:00
parent 45b4aaef53
commit 90abeac6f6
9 changed files with 443 additions and 5 deletions

View File

@ -116,6 +116,8 @@ docker compose up -d
| ⚖️ 行李重量 | 对照航司限额估算装备重量 |
| 🛂 签证停留倒计时 | 入境日与剩余天数提醒 |
| 💼 办公点成本 | 月卡 vs 咖啡馆划算度对比 |
| 🛬 落地首月成本 | 房租押金 SIM 接机估算 |
| 📶 流量预算 | 月流量与套餐档建议 |
| ✨ 今日金句 | 每日游民灵感语录 |
| 😴 时差估算 | 时差跨度与恢复期建议 |
| 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 |

View File

@ -12,10 +12,12 @@ const LOGS = [
date: "2026-08-29",
tag: "新工具",
items: [
"落地首月成本:房租押金 SIM 接机一次算清",
"流量预算规划:按办公习惯估算月 GB 与套餐档",
"签证停留倒计时:入境日 + 额度,剩余天数环形提醒",
"办公点成本对比:联合办公月卡 vs 咖啡馆日刷",
"日照办公窗、行李重量、旅居跑道、会议黄金时段",
"快捷工具条直达签证 / 办公 / 跑道等核心工具",
"快捷工具条直达首月 / 流量 / 签证等核心工具",
],
},
{

View File

@ -8297,3 +8297,212 @@ img { max-width: 100%; display: block; }
.workspot-compare { grid-template-columns: 1fr; }
.visa-stay-stats { grid-template-columns: 1fr; }
}
/* ===== First Month Cost ===== */
.firstmonth-card {
display: grid;
grid-template-columns: 0.9fr 1.2fr;
gap: 22px;
padding: 24px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
backdrop-filter: blur(16px);
}
.firstmonth-controls {
display: flex;
flex-direction: column;
gap: 14px;
}
.firstmonth-controls > label {
display: flex;
flex-direction: column;
gap: 6px;
}
.firstmonth-controls > label > span {
font-size: 0.85rem;
color: var(--text-secondary);
}
.firstmonth-controls 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;
}
.firstmonth-housing {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.firstmonth-chip {
padding: 10px 8px;
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-weight: 600;
font-size: 0.85rem;
transition: all 0.2s;
}
.firstmonth-chip:hover,
.firstmonth-chip.active {
border-color: rgba(78, 205, 196, 0.4);
background: rgba(78, 205, 196, 0.1);
}
.firstmonth-check {
flex-direction: row !important;
align-items: center;
gap: 10px !important;
font-size: 0.9rem;
color: var(--text-secondary);
}
.firstmonth-total {
padding: 18px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, rgba(255,107,107,0.12), rgba(78,205,196,0.1));
border: 1px solid rgba(255,255,255,0.08);
margin-bottom: 14px;
}
.firstmonth-total small { color: var(--text-muted); display: block; }
.firstmonth-total strong {
font-family: var(--font-display);
font-size: 2.2rem;
display: block;
margin: 4px 0 6px;
}
.firstmonth-total span { font-size: 0.85rem; color: var(--text-secondary); }
.firstmonth-bars { display: flex; flex-direction: column; gap: 10px; }
.firstmonth-bar-row {
display: grid;
grid-template-columns: 110px 1fr 72px;
gap: 10px;
align-items: center;
font-size: 0.88rem;
}
.firstmonth-bar-track {
height: 8px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
overflow: hidden;
}
.firstmonth-bar-track i {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #FF6B6B, #4ECDC4);
}
.firstmonth-bar-row em {
font-style: normal;
text-align: right;
font-weight: 600;
color: var(--text-secondary);
}
/* ===== Data Budget ===== */
.data-budget-card {
padding: 24px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
backdrop-filter: blur(16px);
display: flex;
flex-direction: column;
gap: 18px;
}
.data-budget-gauge {
height: 10px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
overflow: hidden;
}
.data-budget-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #4ECDC4, #A78BFA);
transition: width 0.3s ease;
}
.data-budget-hero {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 10px;
}
.data-budget-hero strong {
font-family: var(--font-display);
font-size: 2.2rem;
}
.data-budget-hero span { color: var(--text-muted); }
.data-budget-plan {
margin: 6px 0 0;
color: var(--text-secondary);
font-size: 0.92rem;
}
.data-budget-plan b { color: var(--accent-3); }
.data-budget-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.data-budget-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: var(--radius-md);
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.03);
cursor: pointer;
color: inherit;
text-align: left;
transition: all 0.2s;
}
.data-budget-item:hover { transform: translateY(-1px); }
.data-budget-item.on {
border-color: rgba(167, 139, 250, 0.45);
background: rgba(167, 139, 250, 0.12);
}
.data-budget-item > span { font-size: 1.25rem; }
.data-budget-item strong { display: block; font-size: 0.88rem; }
.data-budget-item small { color: var(--text-muted); }
.data-budget-item i {
margin-left: auto;
font-style: normal;
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255,255,255,0.08);
font-size: 0.75rem;
}
.data-budget-item.on i { background: #A78BFA; color: #0a0e17; font-weight: 700; }
.data-budget-sliders {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.data-budget-sliders label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.data-budget-sliders input[type=range] {
width: 100%;
accent-color: #A78BFA;
}
@media (max-width: 900px) {
.firstmonth-card { grid-template-columns: 1fr; }
.data-budget-grid { grid-template-columns: repeat(2, 1fr); }
.data-budget-sliders { grid-template-columns: 1fr; }
.firstmonth-bar-row { grid-template-columns: 1fr; gap: 4px; }
.firstmonth-bar-row em { text-align: left; }
}
@media (max-width: 560px) {
.data-budget-grid { grid-template-columns: 1fr; }
}

View File

@ -5,7 +5,7 @@ import Link from "next/link";
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
import SiteShell from "@/components/SiteShell";
const FEATURED = ["visa-stay", "workspot", "runway", "daylight", "meetings", "bag-weight"];
const FEATURED = ["first-month", "data-budget", "visa-stay", "workspot", "runway", "daylight"];
export default function ToolsHubPage() {
const [filter, setFilter] = useState("all");
@ -41,7 +41,7 @@ export default function ToolsHubPage() {
<div className="tools-featured">
<div className="tools-featured-head">
<strong>✨ 本周推荐</strong>
<span>新上线 · 停留倒计时 & 办公成本</span>
<span>新上线 · 首月成本 & 流量预算</span>
</div>
<div className="tools-featured-grid">
{featured.map((t) => (

View File

@ -0,0 +1,103 @@
"use client";
import { useMemo, useState } from "react";
const WORK_GB = [
{ id: "docs", emoji: "📄", label: "文档/邮件", gb: 3 },
{ id: "meet", emoji: "🎥", label: "视频会议", gb: 12 },
{ id: "code", emoji: "💻", label: "云开发/同步", gb: 8 },
{ id: "stream", emoji: "🎬", label: "娱乐流媒体", gb: 15 },
{ id: "maps", emoji: "🗺️", label: "地图导航", gb: 2 },
{ id: "backup", emoji: "☁️", label: "相册备份", gb: 10 },
];
const PLANS = [
{ gb: 10, label: "轻量", tip: "以咖啡馆/办公网为主" },
{ gb: 30, label: "日常", tip: "远程办公够用" },
{ gb: 50, label: "充足", tip: "会议多也不慌" },
{ gb: 100, label: "豪横", tip: "流媒体+备份无压力" },
];
export default function DataBudgetPlanner() {
const [selected, setSelected] = useState(["docs", "meet", "code", "maps"]);
const [meetHours, setMeetHours] = useState(8);
const [hotspotDays, setHotspotDays] = useState(6);
const total = useMemo(() => {
let gb = WORK_GB.filter((w) => selected.includes(w.id)).reduce((s, w) => s + w.gb, 0);
// scale video by meeting hours (base assumes ~8h)
if (selected.includes("meet")) {
gb += Math.max(0, (meetHours - 8) * 1.2);
}
// hotspot premium days add overhead
gb += hotspotDays * 0.8;
return Math.round(gb * 10) / 10;
}, [selected, meetHours, hotspotDays]);
const plan = PLANS.find((p) => total <= p.gb) || PLANS[PLANS.length - 1];
const pct = Math.min((total / 100) * 100, 100);
const toggle = (id: string) => {
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
return (
<section className="section data-budget-section" id="data-budget">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">📶 DATA BUDGET</span>
<h2>流量预算规划</h2>
<p>按办公习惯估算月流量,选 SIM / eSIM 套餐更有数</p>
</div>
<div className="data-budget-card reveal">
<div className="data-budget-meter">
<div className="data-budget-gauge">
<div className="data-budget-fill" style={{ width: `${pct}%` }} />
</div>
<div className="data-budget-hero">
<strong>{total} GB</strong>
<span>/ 月估算</span>
</div>
<p className="data-budget-plan">
建议套餐档:<b>{plan.gb}GB · {plan.label}</b> — {plan.tip}
</p>
</div>
<div className="data-budget-grid">
{WORK_GB.map((w) => {
const on = selected.includes(w.id);
return (
<button
key={w.id}
type="button"
className={`data-budget-item${on ? " on" : ""}`}
onClick={() => toggle(w.id)}
aria-pressed={on}
>
<span>{w.emoji}</span>
<div>
<strong>{w.label}</strong>
<small>~{w.gb} GB</small>
</div>
<i>{on ? "✓" : "+"}</i>
</button>
);
})}
</div>
<div className="data-budget-sliders">
<label>
<span>视频会议时长:{meetHours} 小时/月</span>
<input type="range" min={0} max={40} value={meetHours} onChange={(e) => setMeetHours(+e.target.value)} />
</label>
<label>
<span>纯热点办公天数:{hotspotDays} 天/月</span>
<input type="range" min={0} max={20} value={hotspotDays} onChange={(e) => setHotspotDays(+e.target.value)} />
</label>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,116 @@
"use client";
import { useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
/** First-month one-time + rent estimates in CNY. */
const LANDING: Record<string, { rent: number; depositMult: number; sim: number; transfer: number; misc: number }> = {
chiangmai: { rent: 2800, depositMult: 1, sim: 80, transfer: 120, misc: 400 },
bali: { rent: 3500, depositMult: 1, sim: 100, transfer: 200, misc: 500 },
lisbon: { rent: 6500, depositMult: 2, sim: 150, transfer: 180, misc: 800 },
barcelona: { rent: 7000, depositMult: 2, sim: 150, transfer: 200, misc: 900 },
mexico: { rent: 4500, depositMult: 1, sim: 120, transfer: 250, misc: 600 },
tokyo: { rent: 9000, depositMult: 2, sim: 200, transfer: 300, misc: 1200 },
};
interface Props {
destinations: Destination[];
}
export default function FirstMonthCost({ destinations }: Props) {
const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai");
const [housing, setHousing] = useState<"budget" | "mid" | "nice">("mid");
const [includeDeposit, setIncludeDeposit] = useState(true);
const base = LANDING[slug] || { rent: 4000, depositMult: 1, sim: 100, transfer: 150, misc: 500 };
const dest = destinations.find((d) => d.slug === slug);
const rentFactor = housing === "budget" ? 0.75 : housing === "nice" ? 1.35 : 1;
const breakdown = useMemo(() => {
const rent = Math.round(base.rent * rentFactor);
const deposit = includeDeposit ? Math.round(rent * base.depositMult) : 0;
const sim = base.sim;
const transfer = base.transfer;
const misc = Math.round(base.misc * rentFactor);
const total = rent + deposit + sim + transfer + misc;
return [
{ key: "rent", emoji: "🏡", label: "首月房租", amount: rent },
{ key: "deposit", emoji: "🔐", label: `押金 ×${base.depositMult}`, amount: deposit },
{ key: "sim", emoji: "📱", label: "上网卡", amount: sim },
{ key: "transfer", emoji: "🚕", label: "机场接驳", amount: transfer },
{ key: "misc", emoji: "🛒", label: "日用启动", amount: misc },
{ key: "total", emoji: "💳", label: "合计启动金", amount: total },
];
}, [base, rentFactor, includeDeposit]);
const total = breakdown.find((b) => b.key === "total")!.amount;
const maxBar = Math.max(...breakdown.filter((b) => b.key !== "total").map((b) => b.amount), 1);
return (
<section className="section firstmonth-section" id="first-month">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🛬 FIRST MONTH</span>
<h2>落地首月成本</h2>
<p>房租、押金、SIM、接机一次算清,避免到了才慌</p>
</div>
<div className="firstmonth-card reveal">
<div className="firstmonth-controls">
<label>
<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="firstmonth-housing">
{([
["budget", "💰 经济"],
["mid", "🏠 舒适"],
["nice", "✨ 品质"],
] as const).map(([k, label]) => (
<button
key={k}
type="button"
className={`firstmonth-chip${housing === k ? " active" : ""}`}
onClick={() => setHousing(k)}
>
{label}
</button>
))}
</div>
<label className="firstmonth-check">
<input
type="checkbox"
checked={includeDeposit}
onChange={(e) => setIncludeDeposit(e.target.checked)}
/>
<span>计入押金(当地常见 {base.depositMult} 个月)</span>
</label>
</div>
<div className="firstmonth-result">
<div className="firstmonth-total">
<small>{dest?.emoji} 建议准备</small>
<strong>¥{total.toLocaleString()}</strong>
<span>含首月硬成本,不含机票与签证费</span>
</div>
<div className="firstmonth-bars">
{breakdown.filter((b) => b.key !== "total" && b.amount > 0).map((b) => (
<div key={b.key} className="firstmonth-bar-row">
<span>{b.emoji} {b.label}</span>
<div className="firstmonth-bar-track">
<i style={{ width: `${(b.amount / maxBar) * 100}%` }} />
</div>
<em>¥{b.amount.toLocaleString()}</em>
</div>
))}
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -37,6 +37,7 @@ const JetLagEstimator = lazy(() => import("./JetLagEstimator"));
const CostCompare = lazy(() => import("./CostCompare"));
const SavingsGoal = lazy(() => import("./SavingsGoal"));
const RunwayCalculator = lazy(() => import("./RunwayCalculator"));
const FirstMonthCost = lazy(() => import("./FirstMonthCost"));
const ExpenseTracker = lazy(() => import("./ExpenseTracker"));
const TaxDayCounter = lazy(() => import("./TaxDayCounter"));
const FlightCost = lazy(() => import("./FlightCost"));
@ -45,6 +46,7 @@ const MeetingWindow = lazy(() => import("./MeetingWindow"));
const DaylightWorkHours = lazy(() => import("./DaylightWorkHours"));
const WifiWorkScore = lazy(() => import("./WifiWorkScore"));
const WorkSpotCost = lazy(() => import("./WorkSpotCost"));
const DataBudgetPlanner = lazy(() => import("./DataBudgetPlanner"));
const FocusTimer = lazy(() => import("./FocusTimer"));
const NomadScore = lazy(() => import("./NomadScore"));
const Lifestyle = lazy(() => import("./Lifestyle"));
@ -172,6 +174,7 @@ export default function HomeClient(props: Props) {
<CostCompare destinations={props.destinations} />
<SavingsGoal destinations={props.destinations} />
<RunwayCalculator destinations={props.destinations} />
<FirstMonthCost destinations={props.destinations} />
<ExpenseTracker />
<TaxDayCounter />
<FlightCost destinations={props.destinations} />
@ -180,6 +183,7 @@ export default function HomeClient(props: Props) {
<DaylightWorkHours destinations={props.destinations} />
<WifiWorkScore destinations={props.destinations} />
<WorkSpotCost destinations={props.destinations} />
<DataBudgetPlanner />
<FocusTimer />
<NomadScore />
<Lifestyle />

View File

@ -3,11 +3,11 @@
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: "/#daylight", emoji: "☀️", label: "日照" },
{ href: "/#meetings", emoji: "🤝", label: "会议" },
{ href: "/#trip", emoji: "🗓️", label: "行程" },
{ href: "/#focus", emoji: "⏱️", label: "专注" },
{ href: "/tools", emoji: "🛠️", label: "全部" },

View File

@ -17,6 +17,7 @@ export const TOOL_LINKS: ToolLink[] = [
{ 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: "expenses", emoji: "📒", title: "支出账本", desc: "分类记账汇总", href: "/#expenses", category: "money" },
{ id: "flight", emoji: "✈️", title: "机票估算", desc: "往返票价粗算", href: "/#flight", category: "money" },
{ id: "currency", emoji: "💱", title: "汇率换算", desc: "多币种互转", href: "/#currency", category: "money" },
@ -26,6 +27,7 @@ export const TOOL_LINKS: ToolLink[] = [
{ id: "jetlag", emoji: "😴", title: "时差恢复", desc: "恢复期建议", href: "/#jetlag", 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: "coworking", emoji: "💻", title: "联合办公", desc: "热门 Co-working", href: "/#coworking", category: "work" },
{ id: "cafes", emoji: "☕", title: "咖啡馆", desc: "办公友好咖啡店", href: "/#cafes", category: "work" },