diff --git a/README.md b/README.md index 7e7274d..de63283 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,10 @@ docker compose up -d | 📒 支出账本 | 旅居花销分类记录与汇总 | | 📱 上网方案 | 各城 SIM / eSIM 推荐 | | 🌏 关于页 | `/about` 品牌介绍 | +| 🧾 税居天数 | 183 天规则停留天数追踪 | +| 🛬 到站清单 | 落地第一周按天勾选 | +| ☕ 咖啡馆 | 办公友好咖啡馆推荐 | +| 👋 新手引导 | 首次访问四步导览 | | 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 | | ❓ FAQ | 搜索 + 展开/收起全部 | diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index f32a2c7..9c2b040 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -6822,3 +6822,262 @@ img { max-width: 100%; display: block; } .expense-card { grid-template-columns: 1fr; } .expense-bar-row { grid-template-columns: 60px 1fr 60px; } } + +/* ===== Tax Day Counter ===== */ +.tax-card { + padding: 28px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-xl); + backdrop-filter: blur(20px); +} + +.tax-meter { + display: flex; + gap: 28px; + align-items: center; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.tax-ring { + width: 140px; + height: 140px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.tax-risk-warn { background: conic-gradient(#FFE66D var(--pct, 50%), rgba(255,255,255,0.08) 0) !important; } +.tax-risk-high { background: conic-gradient(#FF6B6B var(--pct, 50%), rgba(255,255,255,0.08) 0) !important; } + +.tax-ring-inner { + width: 110px; + height: 110px; + border-radius: 50%; + background: var(--bg-card); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.tax-ring-inner strong { font-size: 1.8rem; } +.tax-ring-inner small { color: var(--text-secondary); font-size: 0.75rem; } + +.tax-stats { + flex: 1; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + min-width: 240px; +} + +.tax-stats > div small { + display: block; + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 2px; +} + +.tax-stats > div strong { font-size: 1.2rem; } +.tax-danger { color: #FF6B6B; } +.tax-hint { + grid-column: 1 / -1; + font-size: 0.85rem; + color: var(--text-secondary); + margin: 4px 0 0; +} + +.tax-form { + display: flex; + flex-direction: column; + gap: 14px; + margin-bottom: 20px; + padding-top: 16px; + border-top: 1px solid rgba(255,255,255,0.06); +} + +.tax-log { + list-style: none; + padding: 0; + margin: 0; + max-height: 220px; + overflow-y: auto; +} + +.tax-log li { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid rgba(255,255,255,0.05); + font-size: 0.9rem; +} + +.tax-log li button { + margin-left: auto; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; +} + +.tax-empty { + justify-content: center; + color: var(--text-secondary); + border: none !important; +} + +/* ===== Arrival Checklist ===== */ +.arrival-card { + padding: 28px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-xl); + backdrop-filter: blur(20px); +} + +.arrival-progress { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.arrival-progress h3 { font-size: 1.15rem; margin-bottom: 4px; } +.arrival-progress p { color: var(--text-secondary); font-size: 0.9rem; } + +.arrival-days { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 18px; +} + +.arrival-day h4 { + font-size: 0.95rem; + color: var(--accent-3); + margin-bottom: 10px; +} + +.arrival-day ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* ===== Cafe Guide ===== */ +.cafe-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; +} + +.cafe-card { + padding: 20px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-lg); + backdrop-filter: blur(16px); + transition: transform 0.3s; +} + +.cafe-card:hover { transform: translateY(-3px); } + +.cafe-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + margin-bottom: 10px; +} + +.cafe-top h4 { font-size: 1.05rem; } +.cafe-vibe { + font-size: 0.75rem; + padding: 4px 8px; + background: rgba(255,230,109,0.12); + color: var(--accent-2); + border-radius: 12px; + white-space: nowrap; +} + +.cafe-tags { + display: flex; + gap: 10px; + flex-wrap: wrap; + font-size: 0.8rem; + color: var(--accent-3); + margin-bottom: 8px; +} + +.cafe-card p { + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.45; +} + +/* ===== Onboarding ===== */ +.onboard-card { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 9200; + width: min(380px, calc(100% - 32px)); + padding: 20px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-xl); + backdrop-filter: blur(20px); + box-shadow: var(--shadow-card); + animation: toastIn 0.45s cubic-bezier(0.34, 1.56, 0.64, 1); + text-align: center; +} + +.onboard-skip { + position: absolute; + top: 10px; + right: 12px; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.8rem; +} + +.onboard-emoji { font-size: 2rem; display: block; margin-bottom: 8px; } +.onboard-step strong { display: block; font-size: 1.15rem; margin-bottom: 6px; } +.onboard-step p { color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 14px; } + +.onboard-dots { + display: flex; + justify-content: center; + gap: 6px; + margin-bottom: 14px; +} + +.onboard-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(255,255,255,0.2); +} + +.onboard-dots span.active { + width: 18px; + border-radius: 4px; + background: var(--accent-3); +} + +@media (max-width: 768px) { + .tax-stats { grid-template-columns: 1fr 1fr 1fr; } + .onboard-card { bottom: 90px; } +} diff --git a/frontend/src/components/ArrivalChecklist.tsx b/frontend/src/components/ArrivalChecklist.tsx new file mode 100644 index 0000000..fc15a44 --- /dev/null +++ b/frontend/src/components/ArrivalChecklist.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { Destination } from "@/lib/types"; + +interface Task { + id: string; + emoji: string; + label: string; + day: number; +} + +const TASKS: Task[] = [ + { id: "sim", emoji: "📱", label: "办本地 SIM / 激活 eSIM", day: 1 }, + { id: "cash", emoji: "💵", label: "换少量现金 + 测试银行卡", day: 1 }, + { id: "wifi", emoji: "📶", label: "测网速,确认可视频会议", day: 1 }, + { id: "grocery", emoji: "🛒", label: "采购日用品与饮水", day: 1 }, + { id: "maps", emoji: "🗺️", label: "标记医院 / 警局 / 超市", day: 2 }, + { id: "cowork", emoji: "💻", label: "探访 1–2 个联合办公", day: 2 }, + { id: "cafe", emoji: "☕", label: "找到常去的工作咖啡馆", day: 3 }, + { id: "meetup", emoji: "🤝", label: "加本地游民群 / 报名 Meetup", day: 3 }, + { id: "housing", emoji: "🏡", label: "确认长租或续住决策", day: 5 }, + { id: "routine", emoji: "🕐", label: "固定作息与深度工作时段", day: 7 }, + { id: "backup", emoji: "🔐", label: "备份证件照片到云盘", day: 2 }, + { id: "insurance", emoji: "🏥", label: "确认保险客服与紧急电话", day: 1 }, +]; + +const STORAGE_PREFIX = "nomadro-arrival-"; + +interface Props { + destinations: Destination[]; +} + +export default function ArrivalChecklist({ destinations }: Props) { + const [slug, setSlug] = useState(destinations[0]?.slug || "bali"); + const [checked, setChecked] = useState>({}); + const [ready, setReady] = useState(false); + const dest = destinations.find((d) => d.slug === slug); + + useEffect(() => { + try { + const raw = localStorage.getItem(STORAGE_PREFIX + slug); + setChecked(raw ? JSON.parse(raw) : {}); + } catch { + setChecked({}); + } + setReady(true); + }, [slug]); + + useEffect(() => { + if (!ready) return; + localStorage.setItem(STORAGE_PREFIX + slug, JSON.stringify(checked)); + }, [checked, slug, ready]); + + const done = Object.values(checked).filter(Boolean).length; + const pct = Math.round((done / TASKS.length) * 100); + + const byDay = useMemo(() => { + const map: Record = {}; + TASKS.forEach((t) => { + if (!map[t.day]) map[t.day] = []; + map[t.day].push(t); + }); + return Object.entries(map).sort((a, b) => Number(a[0]) - Number(b[0])); + }, []); + + const toggle = (id: string) => setChecked((p) => ({ ...p, [id]: !p[id] })); + + return ( +
+
+
+ 🛬 ARRIVAL +

落地第一周清单

+

从落地到进入工作节奏,按天勾选不慌乱

+
+ +
+ {destinations.map((d) => ( + + ))} +
+ +
+
+
+

{dest?.emoji} {dest?.name} · 到站进度

+

{done}/{TASKS.length} 完成 · {pct}%

+
+
+
+
+
+ +
+ {byDay.map(([day, tasks]) => ( +
+

Day {day}

+
    + {tasks.map((t) => ( +
  • + +
  • + ))} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/CafeGuide.tsx b/frontend/src/components/CafeGuide.tsx new file mode 100644 index 0000000..b318b82 --- /dev/null +++ b/frontend/src/components/CafeGuide.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { Destination } from "@/lib/types"; + +interface Cafe { + name: string; + vibe: string; + wifi: string; + power: string; + tip: string; +} + +const CAFES: Record = { + bali: [ + { name: "Seniman Coffee", vibe: "乌布创意", wifi: "快", power: "充足", tip: "适合上午深度工作" }, + { name: "Revolver Espresso", vibe: "精致澳式", wifi: "稳", power: "有限", tip: "偏短会与头脑风暴" }, + { name: "Anomali Coffee", vibe: "连锁便利", wifi: "中", power: "充足", tip: "多点可选,备选办公" }, + ], + chiangmai: [ + { name: "Ristr8to", vibe: "拉花圣地", wifi: "快", power: "中等", tip: "拍照友好,下午较满" }, + { name: "Graph Cafe", vibe: "极简静谧", wifi: "很快", power: "充足", tip: "写代码首选" }, + { name: "The Lab", vibe: "实验风", wifi: "稳", power: "充足", tip: "长坐友好" }, + ], + lisbon: [ + { name: "Copenhagen Coffee Lab", vibe: "北欧风", wifi: "快", power: "中等", tip: "适合上午冲刺" }, + { name: "Fábrica Coffee Roasters", vibe: "烘焙香", wifi: "稳", power: "有限", tip: "短时办公 + 咖啡" }, + { name: "Hello, Kristof", vibe: "设计感", wifi: "快", power: "充足", tip: "会议间隙好去处" }, + ], + barcelona: [ + { name: "Satan's Coffee Corner", vibe: "潮酷", wifi: "稳", power: "中等", tip: "人气高,早点去" }, + { name: "Nomad Coffee", vibe: "专注", wifi: "快", power: "充足", tip: "名副其实的游民店" }, + { name: "Syra Coffee", vibe: "社区感", wifi: "快", power: "充足", tip: "多门店可轮换" }, + ], + mexico: [ + { name: "Quince Letras", vibe: "文艺", wifi: "稳", power: "中等", tip: "Roma 区散步办公" }, + { name: "Blend Station", vibe: "明亮", wifi: "快", power: "充足", tip: "长坐友好" }, + { name: "Cardinal", vibe: "精致", wifi: "快", power: "有限", tip: "适合客户会面前" }, + ], + tokyo: [ + { name: " % Arabica", vibe: "极简", wifi: "中", power: "少", tip: "短时灵感,不适合长坐" }, + { name: "Blue Bottle", vibe: "安静", wifi: "快", power: "中等", tip: "上午工作节奏佳" }, + { name: "Fuglen Tokyo", vibe: "北欧夜色", wifi: "稳", power: "中等", tip: "傍晚切换心情" }, + ], +}; + +interface Props { + destinations: Destination[]; +} + +export default function CafeGuide({ destinations }: Props) { + const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai"); + const list = useMemo(() => CAFES[slug] || CAFES.chiangmai, [slug]); + const dest = destinations.find((d) => d.slug === slug); + + return ( +
+
+
+ ☕ CAFES +

办公友好咖啡馆

+

网速、插座、氛围——游民真正关心的三件事

+
+ +
+ {destinations.map((d) => ( + + ))} +
+ +

{dest?.emoji} {dest?.name} 精选

+ +
+ {list.map((c, i) => ( +
+
+

☕ {c.name}

+ {c.vibe} +
+
+ 📶 WiFi {c.wifi} + 🔌 插座 {c.power} +
+

{c.tip}

+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index fe8ea1d..7d3ef04 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -42,17 +42,17 @@ export default function Footer() { 目的地 命运转盘 网速评级 + 咖啡馆 + 到站清单 保险指南 上网方案 - 住宿指南 - 生存短语 博客

工具

行程规划 支出账本 - 时差估算 + 税居天数 启动金目标 费用计算器 API 文档 diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx index 518d972..07267fd 100644 --- a/frontend/src/components/HomeClient.tsx +++ b/frontend/src/components/HomeClient.tsx @@ -47,6 +47,10 @@ import FeedbackWidget from "./FeedbackWidget"; import InsuranceGuide from "./InsuranceGuide"; import ExpenseTracker from "./ExpenseTracker"; import SimGuide from "./SimGuide"; +import TaxDayCounter from "./TaxDayCounter"; +import ArrivalChecklist from "./ArrivalChecklist"; +import CafeGuide from "./CafeGuide"; +import OnboardingTour from "./OnboardingTour"; import KeyboardShortcuts from "./KeyboardShortcuts"; import BackToTop from "./BackToTop"; @@ -125,6 +129,7 @@ export default function HomeClient(props: Props) { + @@ -136,8 +141,10 @@ export default function HomeClient(props: Props) { + + @@ -153,6 +160,7 @@ export default function HomeClient(props: Props) { + {compareList.length > 0 && (
diff --git a/frontend/src/components/OnboardingTour.tsx b/frontend/src/components/OnboardingTour.tsx new file mode 100644 index 0000000..31a4496 --- /dev/null +++ b/frontend/src/components/OnboardingTour.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const STEPS = [ + { id: "destinations", emoji: "🌍", title: "探索目的地", desc: "筛选城市、收藏与对比" }, + { id: "matcher", emoji: "🎯", title: "智能匹配", desc: "问卷推荐最适合你的城市" }, + { id: "trip", emoji: "🗓️", title: "规划行程", desc: "多城串联并估算预算" }, + { id: "tools", emoji: "🛠️", title: "用好工具", desc: "签证、保险、网速与账本" }, +]; + +export default function OnboardingTour() { + const [open, setOpen] = useState(false); + const [step, setStep] = useState(0); + + useEffect(() => { + if (localStorage.getItem("nomadro-onboarded")) return; + const t = setTimeout(() => setOpen(true), 2500); + return () => clearTimeout(t); + }, []); + + const finish = () => { + localStorage.setItem("nomadro-onboarded", "1"); + setOpen(false); + }; + + const next = () => { + if (step >= STEPS.length - 1) { + finish(); + return; + } + const s = STEPS[step]; + if (s.id === "matcher") { + window.dispatchEvent(new CustomEvent("open-matcher")); + } else { + document.getElementById(s.id)?.scrollIntoView({ behavior: "smooth" }); + } + setStep((n) => n + 1); + }; + + if (!open) return null; + + const current = STEPS[step]; + + return ( +
+ +
+ {current.emoji} + {current.title} +

{current.desc}

+
+ {STEPS.map((_, i) => ( + + ))} +
+ +
+
+ ); +} diff --git a/frontend/src/components/SectionNav.tsx b/frontend/src/components/SectionNav.tsx index 2f9cd74..29d6325 100644 --- a/frontend/src/components/SectionNav.tsx +++ b/frontend/src/components/SectionNav.tsx @@ -13,6 +13,7 @@ const SECTIONS = [ { id: "cost-compare", emoji: "💰", label: "省钱" }, { id: "savings", emoji: "🎯", label: "存款" }, { id: "expenses", emoji: "📒", label: "账本" }, + { id: "tax-days", emoji: "🧾", label: "税居" }, { id: "flight", emoji: "✈️", label: "机票" }, { id: "wifi", emoji: "📶", label: "网速" }, { id: "lifestyle", emoji: "💻", label: "生活" }, @@ -21,8 +22,10 @@ const SECTIONS = [ { id: "coworking", emoji: "💻", label: "办公" }, { id: "housing", emoji: "🏡", label: "住宿" }, { id: "sim", emoji: "📱", label: "上网" }, + { id: "cafes", emoji: "☕", label: "咖啡" }, { id: "season", emoji: "🌤️", label: "季节" }, { id: "packing", emoji: "🧳", label: "行李" }, + { id: "arrival", emoji: "🛬", label: "到站" }, { id: "phrases", emoji: "🗣️", label: "短语" }, { id: "events", emoji: "📅", label: "活动" }, { id: "blog", emoji: "📝", label: "博客" }, diff --git a/frontend/src/components/TaxDayCounter.tsx b/frontend/src/components/TaxDayCounter.tsx new file mode 100644 index 0000000..206400f --- /dev/null +++ b/frontend/src/components/TaxDayCounter.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useToast } from "@/lib/toast"; + +const STORAGE_KEY = "nomadro-tax-days"; + +interface DayLog { + date: string; + country: string; + emoji: string; +} + +const COUNTRIES = [ + { key: "CN", emoji: "🇨🇳", label: "中国" }, + { key: "TH", emoji: "🇹🇭", label: "泰国" }, + { key: "ID", emoji: "🇮🇩", label: "印尼" }, + { key: "PT", emoji: "🇵🇹", label: "葡萄牙" }, + { key: "ES", emoji: "🇪🇸", label: "西班牙" }, + { key: "MX", emoji: "🇲🇽", label: "墨西哥" }, + { key: "JP", emoji: "🇯🇵", label: "日本" }, + { key: "OTHER", emoji: "🌍", label: "其他" }, +]; + +export default function TaxDayCounter() { + const { toast } = useToast(); + const [logs, setLogs] = useState([]); + const [ready, setReady] = useState(false); + const [country, setCountry] = useState("TH"); + const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10)); + + useEffect(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) setLogs(JSON.parse(raw)); + } catch { /* ignore */ } + setReady(true); + }, []); + + useEffect(() => { + if (!ready) return; + localStorage.setItem(STORAGE_KEY, JSON.stringify(logs)); + }, [logs, ready]); + + const year = new Date().getFullYear(); + const yearLogs = useMemo( + () => logs.filter((l) => l.date.startsWith(String(year))), + [logs, year] + ); + + const abroadDays = useMemo( + () => yearLogs.filter((l) => l.country !== "CN").length, + [yearLogs] + ); + const homeDays = useMemo( + () => yearLogs.filter((l) => l.country === "CN").length, + [yearLogs] + ); + const threshold = 183; + const remaining = Math.max(threshold - abroadDays, 0); + const pct = Math.min((abroadDays / threshold) * 100, 100); + const risk = abroadDays >= threshold ? "high" : abroadDays >= threshold - 30 ? "warn" : "ok"; + + const ringColor = risk === "high" ? "#FF6B6B" : risk === "warn" ? "#FFE66D" : "var(--accent-3)"; + + const addDay = () => { + if (logs.some((l) => l.date === date)) { + toast("该日期已记录", "info"); + return; + } + const c = COUNTRIES.find((x) => x.key === country)!; + setLogs((prev) => [...prev, { date, country: country, emoji: c.emoji }].sort((a, b) => b.date.localeCompare(a.date))); + toast(`已记录 ${date} · ${c.emoji} ${c.label}`); + }; + + const remove = (d: string) => setLogs((prev) => prev.filter((l) => l.date !== d)); + + return ( +
+
+
+ 🧾 TAX DAYS +

税务居民天数计数

+

追踪 {year} 年境外停留天数,靠近 183 天规则时提前规划

+
+ +
+
+
+
+ {abroadDays} + / {threshold} 天 +
+
+
+
境外天数{abroadDays}
+
境内天数{homeDays}
+
+ 距 183 天 + + {abroadDays >= threshold ? "已越过" : `还差 ${remaining}`} + +
+

+ {risk === "high" && "⚠️ 已达常见税务居民门槛,建议咨询专业顾问"} + {risk === "warn" && "⏰ 接近 183 天,留意停留与签证策略"} + {risk === "ok" && "✅ 目前相对安全,继续记录每一天"} +

+
+
+ +
+
+ + setDate(e.target.value)} /> +
+
+ +
+ {COUNTRIES.map((c) => ( + + ))} +
+
+ +
+ +
    + {yearLogs.slice(0, 10).map((l) => ( +
  • + {l.emoji} + {l.date} + {COUNTRIES.find((c) => c.key === l.country)?.label} + +
  • + ))} + {yearLogs.length === 0 &&
  • 还没有记录,从今天开始打卡
  • } +
+
+
+
+ ); +}