From 45b4aaef53bf09ca73ed31b7339f87115e8c0c16 Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 29 Aug 2026 02:09:55 -0500 Subject: [PATCH] Add visa stay countdown and cowork-vs-cafe cost comparator. Co-authored-by: Cursor --- README.md | 2 + frontend/src/app/changelog/page.tsx | 9 +- frontend/src/app/globals.css | 185 ++++++++++++++++++ frontend/src/app/tools/page.tsx | 4 +- frontend/src/components/HomeClient.tsx | 4 + frontend/src/components/ToolsStrip.tsx | 4 +- frontend/src/components/VisaStayCountdown.tsx | 143 ++++++++++++++ frontend/src/components/WorkSpotCost.tsx | 139 +++++++++++++ frontend/src/lib/tools.ts | 2 + 9 files changed, 483 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/VisaStayCountdown.tsx create mode 100644 frontend/src/components/WorkSpotCost.tsx diff --git a/README.md b/README.md index 461a5a4..21f0531 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,8 @@ docker compose up -d | 🤝 会议黄金时段 | 双时区工作重叠可视化 | | ☀️ 日照办公窗 | 日出日落与深度工作建议 | | ⚖️ 行李重量 | 对照航司限额估算装备重量 | +| 🛂 签证停留倒计时 | 入境日与剩余天数提醒 | +| 💼 办公点成本 | 月卡 vs 咖啡馆划算度对比 | | ✨ 今日金句 | 每日游民灵感语录 | | 😴 时差估算 | 时差跨度与恢复期建议 | | 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 | diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index 6eed1b2..fc64a9a 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -12,11 +12,10 @@ const LOGS = [ date: "2026-08-29", tag: "新工具", items: [ - "日照办公窗:按城市月份估算日出日落与深度工作时段", - "行李重量估算:勾选装备对照航司限额", - "旅居跑道计算器:存款 + 月开销一眼算出还能飞多久", - "会议黄金时段:双时区工作重叠可视化时间轴", - "快捷工具条更新,直达日照 / 行李 / 跑道 / 会议", + "签证停留倒计时:入境日 + 额度,剩余天数环形提醒", + "办公点成本对比:联合办公月卡 vs 咖啡馆日刷", + "日照办公窗、行李重量、旅居跑道、会议黄金时段", + "快捷工具条直达签证 / 办公 / 跑道等核心工具", ], }, { diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1356810..4160efa 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -8112,3 +8112,188 @@ img { max-width: 100%; display: block; } .bag-grid { grid-template-columns: 1fr; } .bag-extra { grid-template-columns: 1fr; } } + + +/* ===== Visa Stay Countdown ===== */ +.visa-stay-card { + display: grid; + grid-template-columns: minmax(220px, 280px) 1fr; + gap: 28px; + padding: 28px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-xl); + backdrop-filter: blur(16px); +} +.visa-stay-meter { text-align: center; } +.visa-stay-ring { + width: 168px; + height: 168px; + border-radius: 50%; + margin: 0 auto 16px; + display: grid; + place-items: center; +} +.visa-stay-ring-inner { + width: 128px; + height: 128px; + border-radius: 50%; + background: var(--bg-secondary); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +.visa-stay-ring-inner strong { + font-family: var(--font-display); + font-size: 2.6rem; + line-height: 1; +} +.visa-stay-ring-inner small { color: var(--text-muted); margin-top: 4px; } +.visa-stay-level span { font-weight: 700; font-size: 1.05rem; } +.visa-stay-level p { + color: var(--text-secondary); + font-size: 0.88rem; + margin-top: 6px; + line-height: 1.45; +} +.visa-stay-form { + display: flex; + flex-direction: column; + gap: 14px; +} +.visa-stay-form > label, +.workspot-controls > label { + display: flex; + flex-direction: column; + gap: 6px; +} +.visa-stay-form > label > span, +.workspot-controls > label > span { + font-size: 0.85rem; + color: var(--text-secondary); +} +.visa-stay-form input, +.visa-stay-form select, +.workspot-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; + font-size: 1rem; +} +.visa-stay-hint { + margin: 0; + font-size: 0.85rem; + color: var(--accent-3); +} +.visa-stay-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + padding: 12px; + background: rgba(255,255,255,0.03); + border-radius: var(--radius-md); +} +.visa-stay-stats small { display: block; color: var(--text-muted); } +.visa-stay-stats strong { font-size: 1.05rem; } + +/* ===== Work Spot Cost ===== */ +.workspot-card { + display: grid; + grid-template-columns: 1fr 1.1fr; + gap: 22px; + padding: 24px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-xl); + backdrop-filter: blur(16px); +} +.workspot-controls { + display: flex; + flex-direction: column; + gap: 14px; +} +.workspot-controls input[type=range], +.visa-stay-form input[type=range] { + width: 100%; + accent-color: var(--accent-3); +} +.workspot-modes { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} +.workspot-mode { + 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; +} +.workspot-mode:hover, +.workspot-mode.active { + border-color: rgba(78, 205, 196, 0.4); + background: rgba(78, 205, 196, 0.1); +} +.workspot-result { + display: flex; + flex-direction: column; + gap: 14px; +} +.workspot-hero { + padding: 18px; + border-radius: var(--radius-md); + background: linear-gradient(135deg, rgba(78,205,196,0.12), rgba(255,107,107,0.08)); + border: 1px solid rgba(255,255,255,0.08); +} +.workspot-hero small { color: var(--text-muted); display: block; } +.workspot-hero strong { + font-family: var(--font-display); + font-size: 2.2rem; + display: block; + margin: 4px 0 6px; +} +.workspot-hero > span { font-weight: 600; font-size: 0.92rem; } +.workspot-compare { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} +.workspot-compare > div { + padding: 12px; + border-radius: var(--radius-md); + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.06); + transition: all 0.2s; +} +.workspot-compare > div.on { + border-color: rgba(78, 205, 196, 0.4); + background: rgba(78, 205, 196, 0.1); +} +.workspot-compare small { display: block; color: var(--text-muted); font-size: 0.75rem; } +.workspot-compare strong { display: block; margin: 4px 0; font-size: 1.05rem; } +.workspot-compare em { + font-style: normal; + font-size: 0.75rem; + color: var(--text-secondary); + line-height: 1.35; +} +.workspot-tip { + margin: 0; + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.45; +} + +@media (max-width: 900px) { + .visa-stay-card, + .workspot-card { grid-template-columns: 1fr; } + .workspot-compare { grid-template-columns: 1fr; } + .visa-stay-stats { grid-template-columns: 1fr; } +} diff --git a/frontend/src/app/tools/page.tsx b/frontend/src/app/tools/page.tsx index a3bff3c..7fc29cc 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 = ["daylight", "bag-weight", "runway", "meetings", "trip", "focus"]; +const FEATURED = ["visa-stay", "workspot", "runway", "daylight", "meetings", "bag-weight"]; export default function ToolsHubPage() { const [filter, setFilter] = useState("all"); @@ -41,7 +41,7 @@ export default function ToolsHubPage() {
✨ 本周推荐 - 新上线 · 日照窗 & 行李重量 + 新上线 · 停留倒计时 & 办公成本
{featured.map((t) => ( diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx index 77bc88f..af293a2 100644 --- a/frontend/src/components/HomeClient.tsx +++ b/frontend/src/components/HomeClient.tsx @@ -44,11 +44,13 @@ const CurrencyConverter = lazy(() => import("./CurrencyConverter")); const MeetingWindow = lazy(() => import("./MeetingWindow")); const DaylightWorkHours = lazy(() => import("./DaylightWorkHours")); const WifiWorkScore = lazy(() => import("./WifiWorkScore")); +const WorkSpotCost = lazy(() => import("./WorkSpotCost")); const FocusTimer = lazy(() => import("./FocusTimer")); const NomadScore = lazy(() => import("./NomadScore")); const Lifestyle = lazy(() => import("./Lifestyle")); const Charts = lazy(() => import("./Charts")); const VisaSection = lazy(() => import("./VisaSection")); +const VisaStayCountdown = lazy(() => import("./VisaStayCountdown")); const InsuranceGuide = lazy(() => import("./InsuranceGuide")); const EmergencyContacts = lazy(() => import("./EmergencyContacts")); const AtmCashGuide = lazy(() => import("./AtmCashGuide")); @@ -177,11 +179,13 @@ export default function HomeClient(props: Props) { + + diff --git a/frontend/src/components/ToolsStrip.tsx b/frontend/src/components/ToolsStrip.tsx index 2444c55..ffab6ce 100644 --- a/frontend/src/components/ToolsStrip.tsx +++ b/frontend/src/components/ToolsStrip.tsx @@ -3,12 +3,12 @@ import Link from "next/link"; const QUICK = [ + { href: "/#visa-stay", emoji: "🛂", label: "签证" }, + { href: "/#workspot", emoji: "💼", label: "办公" }, { href: "/#runway", emoji: "🚀", label: "跑道" }, { href: "/#daylight", emoji: "☀️", label: "日照" }, { href: "/#meetings", emoji: "🤝", label: "会议" }, - { href: "/#bag-weight", emoji: "⚖️", label: "行李" }, { href: "/#trip", emoji: "🗓️", label: "行程" }, - { href: "/#calculator", emoji: "🧮", label: "费用" }, { href: "/#focus", emoji: "⏱️", label: "专注" }, { href: "/tools", emoji: "🛠️", label: "全部" }, ]; diff --git a/frontend/src/components/VisaStayCountdown.tsx b/frontend/src/components/VisaStayCountdown.tsx new file mode 100644 index 0000000..d5a96b4 --- /dev/null +++ b/frontend/src/components/VisaStayCountdown.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { Destination } from "@/lib/types"; + +const STORAGE_KEY = "nomadro-visa-stay"; + +const PRESETS: Record = { + chiangmai: { days: 60, label: "免签/落地签常见 60 天" }, + bali: { days: 30, label: "落地签常见 30 天" }, + lisbon: { days: 90, label: "申根短签常见 90 天" }, + barcelona: { days: 90, label: "申根短签常见 90 天" }, + mexico: { days: 180, label: "常见最长约 180 天" }, + tokyo: { days: 90, label: "短期停留常见 90 天" }, +}; + +interface Saved { + slug: string; + entry: string; + days: number; +} + +interface Props { + destinations: Destination[]; +} + +function daysBetween(a: string, b: string): number { + const t0 = new Date(a + "T00:00:00").getTime(); + const t1 = new Date(b + "T00:00:00").getTime(); + return Math.floor((t1 - t0) / 86400000); +} + +export default function VisaStayCountdown({ destinations }: Props) { + const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai"); + const [entry, setEntry] = useState(() => new Date().toISOString().slice(0, 10)); + const [days, setDays] = useState(60); + const [ready, setReady] = useState(false); + + useEffect(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const s = JSON.parse(raw) as Saved; + if (s.slug) setSlug(s.slug); + if (s.entry) setEntry(s.entry); + if (s.days) setDays(s.days); + } + } catch { /* ignore */ } + setReady(true); + }, []); + + useEffect(() => { + if (!ready) return; + localStorage.setItem(STORAGE_KEY, JSON.stringify({ slug, entry, days })); + }, [slug, entry, days, ready]); + + const today = new Date().toISOString().slice(0, 10); + const used = Math.max(0, daysBetween(entry, today)); + const left = days - used; + const exitDate = useMemo(() => { + const d = new Date(entry + "T00:00:00"); + d.setDate(d.getDate() + days - 1); + return d.toISOString().slice(0, 10); + }, [entry, days]); + + const pct = Math.min(Math.max((used / Math.max(days, 1)) * 100, 0), 100); + const risk = + left <= 0 ? { label: "已到期/超期", color: "#FF6B6B", tip: "请立即确认离境或延期,勿依赖本工具作法律依据" } : + left <= 7 ? { label: "紧急窗口", color: "#FF6B6B", tip: "一周内到期,优先订机票与查政策" } : + left <= 21 ? { label: "临近到期", color: "#FFE66D", tip: "开始准备下一站或续签材料" } : + { label: "时间充裕", color: "#4ECDC4", tip: "安心旅居,记得关注政策变动" }; + + const dest = destinations.find((d) => d.slug === slug); + const applyPreset = (s: string) => { + setSlug(s); + const p = PRESETS[s]; + if (p) setDays(p.days); + }; + + return ( +
+
+
+ 🛂 VISA STAY +

签证停留倒计时

+

记录入境日与允许天数,剩余停留一目了然(仅供参考)

+
+ +
+
+
+
+ {left} + 天剩余 +
+
+
+ {risk.label} +

{risk.tip}

+
+
+ +
+ + + + {PRESETS[slug] && ( +

{dest?.emoji} {PRESETS[slug].label}

+ )} + +
+
已停留{used} 天
+
额度{days} 天
+
建议离境前{exitDate}
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/WorkSpotCost.tsx b/frontend/src/components/WorkSpotCost.tsx new file mode 100644 index 0000000..a73a03f --- /dev/null +++ b/frontend/src/components/WorkSpotCost.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { Destination } from "@/lib/types"; + +/** Rough monthly coworking vs cafe day-pass economics (CNY). */ +const CITY_COST: Record = { + chiangmai: { cowork: 900, cafeDay: 35, transitDay: 15 }, + bali: { cowork: 1200, cafeDay: 45, transitDay: 25 }, + lisbon: { cowork: 1800, cafeDay: 55, transitDay: 30 }, + barcelona: { cowork: 2000, cafeDay: 60, transitDay: 35 }, + mexico: { cowork: 1400, cafeDay: 40, transitDay: 20 }, + tokyo: { cowork: 2800, cafeDay: 70, transitDay: 40 }, +}; + +interface Props { + destinations: Destination[]; +} + +export default function WorkSpotCost({ destinations }: Props) { + const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai"); + const [days, setDays] = useState(20); + const [mode, setMode] = useState<"cowork" | "cafe" | "mix">("mix"); + const [mixCoworkDays, setMixCoworkDays] = useState(12); + + const base = CITY_COST[slug] || { cowork: 1500, cafeDay: 45, transitDay: 20 }; + const dest = destinations.find((d) => d.slug === slug); + + const result = useMemo(() => { + const cafeOnly = days * (base.cafeDay + base.transitDay); + const coworkOnly = base.cowork; + const mixDays = Math.min(mixCoworkDays, days); + const cafeDays = Math.max(days - mixDays, 0); + const mix = base.cowork + cafeDays * (base.cafeDay + base.transitDay); + + const pick = mode === "cowork" ? coworkOnly : mode === "cafe" ? cafeOnly : mix; + const winner = + Math.min(coworkOnly, cafeOnly, mix) === coworkOnly ? "联合办公月卡" : + Math.min(coworkOnly, cafeOnly, mix) === cafeOnly ? "咖啡馆日刷" : + "混合策略"; + + return { cafeOnly, coworkOnly, mix, pick, winner }; + }, [base, days, mode, mixCoworkDays]); + + const saveVsCafe = result.cafeOnly - result.pick; + const saveColor = saveVsCafe >= 0 ? "#4ECDC4" : "#FF6B6B"; + + return ( +
+
+
+ 💼 WORK SPOT +

办公点成本对比

+

联合办公月卡 vs 咖啡馆日刷,算出哪种更划算

+
+ +
+
+ + +
+ {([ + ["cowork", "🏢 月卡"], + ["cafe", "☕ 咖啡馆"], + ["mix", "🔀 混合"], + ] as const).map(([k, label]) => ( + + ))} +
+ {mode === "mix" && ( + + )} +
+ +
+
+ {dest?.emoji} 本月估算 + ¥{Math.round(result.pick).toLocaleString()} + + {saveVsCafe >= 0 + ? `比纯咖啡馆省 ¥${Math.round(saveVsCafe).toLocaleString()}` + : `比纯咖啡馆多花 ¥${Math.round(-saveVsCafe).toLocaleString()}`} + +
+ +
+
+ 联合办公月卡 + ¥{base.cowork.toLocaleString()} + 含工位/网速/打印 +
+
+ 咖啡馆×{days}天 + ¥{Math.round(result.cafeOnly).toLocaleString()} + 含饮品+往返约 ¥{base.cafeDay + base.transitDay}/天 +
+
+ 混合方案 + ¥{Math.round(result.mix).toLocaleString()} + 月卡 + 剩余咖啡馆 +
+
+ +

+ 建议优先:{result.winner} + · 参考价会随街区与旺季浮动,落地后再微调 +

+
+
+
+
+ ); +} diff --git a/frontend/src/lib/tools.ts b/frontend/src/lib/tools.ts index 0202f88..9bc81cc 100644 --- a/frontend/src/lib/tools.ts +++ b/frontend/src/lib/tools.ts @@ -25,10 +25,12 @@ export const TOOL_LINKS: ToolLink[] = [ { 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: "workspot", emoji: "💼", title: "办公点成本", desc: "月卡 vs 咖啡馆划算度", href: "/#workspot", 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" }, { 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: "sim", emoji: "📱", title: "上网方案", desc: "SIM / eSIM 推荐", href: "/#sim", category: "life" }, { id: "plugs", emoji: "🔌", title: "电源插座", desc: "电压与插头类型", href: "/#plugs", category: "life" },