Add tax day counter, arrival checklist, cafe guide, and onboarding
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
341fcdd15e
commit
916ce30733
@ -119,6 +119,10 @@ docker compose up -d
|
|||||||
| 📒 支出账本 | 旅居花销分类记录与汇总 |
|
| 📒 支出账本 | 旅居花销分类记录与汇总 |
|
||||||
| 📱 上网方案 | 各城 SIM / eSIM 推荐 |
|
| 📱 上网方案 | 各城 SIM / eSIM 推荐 |
|
||||||
| 🌏 关于页 | `/about` 品牌介绍 |
|
| 🌏 关于页 | `/about` 品牌介绍 |
|
||||||
|
| 🧾 税居天数 | 183 天规则停留天数追踪 |
|
||||||
|
| 🛬 到站清单 | 落地第一周按天勾选 |
|
||||||
|
| ☕ 咖啡馆 | 办公友好咖啡馆推荐 |
|
||||||
|
| 👋 新手引导 | 首次访问四步导览 |
|
||||||
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
|
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
|
||||||
| ❓ FAQ | 搜索 + 展开/收起全部 |
|
| ❓ FAQ | 搜索 + 展开/收起全部 |
|
||||||
|
|
||||||
|
|||||||
@ -6822,3 +6822,262 @@ img { max-width: 100%; display: block; }
|
|||||||
.expense-card { grid-template-columns: 1fr; }
|
.expense-card { grid-template-columns: 1fr; }
|
||||||
.expense-bar-row { grid-template-columns: 60px 1fr 60px; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
124
frontend/src/components/ArrivalChecklist.tsx
Normal file
124
frontend/src/components/ArrivalChecklist.tsx
Normal file
@ -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<Record<string, boolean>>({});
|
||||||
|
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<number, Task[]> = {};
|
||||||
|
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 (
|
||||||
|
<section className="section arrival-section" id="arrival">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🛬 ARRIVAL</span>
|
||||||
|
<h2>落地第一周清单</h2>
|
||||||
|
<p>从落地到进入工作节奏,按天勾选不慌乱</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="housing-cities reveal">
|
||||||
|
{destinations.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.slug}
|
||||||
|
className={`season-city-btn${slug === d.slug ? " active" : ""}`}
|
||||||
|
onClick={() => setSlug(d.slug)}
|
||||||
|
>
|
||||||
|
{d.emoji} {d.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="arrival-card reveal">
|
||||||
|
<div className="arrival-progress">
|
||||||
|
<div>
|
||||||
|
<h3>{dest?.emoji} {dest?.name} · 到站进度</h3>
|
||||||
|
<p>{done}/{TASKS.length} 完成 · {pct}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="packing-bar" style={{ maxWidth: 240, flex: 1 }}>
|
||||||
|
<div className="packing-bar-fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="arrival-days">
|
||||||
|
{byDay.map(([day, tasks]) => (
|
||||||
|
<div key={day} className="arrival-day">
|
||||||
|
<h4>Day {day}</h4>
|
||||||
|
<ul>
|
||||||
|
{tasks.map((t) => (
|
||||||
|
<li key={t.id}>
|
||||||
|
<label className={`packing-item${checked[t.id] ? " checked" : ""}`}>
|
||||||
|
<input type="checkbox" checked={!!checked[t.id]} onChange={() => toggle(t.id)} />
|
||||||
|
<span className="packing-check">{checked[t.id] ? "✓" : ""}</span>
|
||||||
|
<span className="packing-emoji">{t.emoji}</span>
|
||||||
|
<span className="packing-label">{t.label}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
frontend/src/components/CafeGuide.tsx
Normal file
97
frontend/src/components/CafeGuide.tsx
Normal file
@ -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<string, Cafe[]> = {
|
||||||
|
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 (
|
||||||
|
<section className="section cafe-section" id="cafes">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">☕ CAFES</span>
|
||||||
|
<h2>办公友好咖啡馆</h2>
|
||||||
|
<p>网速、插座、氛围——游民真正关心的三件事</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="housing-cities reveal">
|
||||||
|
{destinations.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.slug}
|
||||||
|
className={`season-city-btn${slug === d.slug ? " active" : ""}`}
|
||||||
|
onClick={() => setSlug(d.slug)}
|
||||||
|
>
|
||||||
|
{d.emoji} {d.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="sim-city-title reveal">{dest?.emoji} {dest?.name} 精选</h3>
|
||||||
|
|
||||||
|
<div className="cafe-grid">
|
||||||
|
{list.map((c, i) => (
|
||||||
|
<div key={c.name} className="cafe-card reveal" style={{ transitionDelay: `${i * 0.08}s` }}>
|
||||||
|
<div className="cafe-top">
|
||||||
|
<h4>☕ {c.name}</h4>
|
||||||
|
<span className="cafe-vibe">{c.vibe}</span>
|
||||||
|
</div>
|
||||||
|
<div className="cafe-tags">
|
||||||
|
<span>📶 WiFi {c.wifi}</span>
|
||||||
|
<span>🔌 插座 {c.power}</span>
|
||||||
|
</div>
|
||||||
|
<p>{c.tip}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -42,17 +42,17 @@ export default function Footer() {
|
|||||||
<Link href="/#destinations">目的地</Link>
|
<Link href="/#destinations">目的地</Link>
|
||||||
<Link href="/#spin">命运转盘</Link>
|
<Link href="/#spin">命运转盘</Link>
|
||||||
<Link href="/#wifi">网速评级</Link>
|
<Link href="/#wifi">网速评级</Link>
|
||||||
|
<Link href="/#cafes">咖啡馆</Link>
|
||||||
|
<Link href="/#arrival">到站清单</Link>
|
||||||
<Link href="/#insurance">保险指南</Link>
|
<Link href="/#insurance">保险指南</Link>
|
||||||
<Link href="/#sim">上网方案</Link>
|
<Link href="/#sim">上网方案</Link>
|
||||||
<Link href="/#housing">住宿指南</Link>
|
|
||||||
<Link href="/#phrases">生存短语</Link>
|
|
||||||
<Link href="/#blog">博客</Link>
|
<Link href="/#blog">博客</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<h4>工具</h4>
|
<h4>工具</h4>
|
||||||
<Link href="/#trip">行程规划</Link>
|
<Link href="/#trip">行程规划</Link>
|
||||||
<Link href="/#expenses">支出账本</Link>
|
<Link href="/#expenses">支出账本</Link>
|
||||||
<Link href="/#jetlag">时差估算</Link>
|
<Link href="/#tax-days">税居天数</Link>
|
||||||
<Link href="/#savings">启动金目标</Link>
|
<Link href="/#savings">启动金目标</Link>
|
||||||
<Link href="/#calculator">费用计算器</Link>
|
<Link href="/#calculator">费用计算器</Link>
|
||||||
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
|
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
|
||||||
|
|||||||
@ -47,6 +47,10 @@ import FeedbackWidget from "./FeedbackWidget";
|
|||||||
import InsuranceGuide from "./InsuranceGuide";
|
import InsuranceGuide from "./InsuranceGuide";
|
||||||
import ExpenseTracker from "./ExpenseTracker";
|
import ExpenseTracker from "./ExpenseTracker";
|
||||||
import SimGuide from "./SimGuide";
|
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 KeyboardShortcuts from "./KeyboardShortcuts";
|
||||||
import BackToTop from "./BackToTop";
|
import BackToTop from "./BackToTop";
|
||||||
|
|
||||||
@ -125,6 +129,7 @@ export default function HomeClient(props: Props) {
|
|||||||
<CostCompare destinations={props.destinations} />
|
<CostCompare destinations={props.destinations} />
|
||||||
<SavingsGoal destinations={props.destinations} />
|
<SavingsGoal destinations={props.destinations} />
|
||||||
<ExpenseTracker />
|
<ExpenseTracker />
|
||||||
|
<TaxDayCounter />
|
||||||
<FlightCost destinations={props.destinations} />
|
<FlightCost destinations={props.destinations} />
|
||||||
<CurrencyConverter />
|
<CurrencyConverter />
|
||||||
<WifiWorkScore destinations={props.destinations} />
|
<WifiWorkScore destinations={props.destinations} />
|
||||||
@ -136,8 +141,10 @@ export default function HomeClient(props: Props) {
|
|||||||
<CoworkingGuide />
|
<CoworkingGuide />
|
||||||
<HousingGuide destinations={props.destinations} />
|
<HousingGuide destinations={props.destinations} />
|
||||||
<SimGuide destinations={props.destinations} />
|
<SimGuide destinations={props.destinations} />
|
||||||
|
<CafeGuide destinations={props.destinations} />
|
||||||
<SeasonGuide destinations={props.destinations} />
|
<SeasonGuide destinations={props.destinations} />
|
||||||
<PackingChecklist />
|
<PackingChecklist />
|
||||||
|
<ArrivalChecklist destinations={props.destinations} />
|
||||||
<Phrasebook destinations={props.destinations} />
|
<Phrasebook destinations={props.destinations} />
|
||||||
<NomadEvents />
|
<NomadEvents />
|
||||||
<Tools tools={props.tools} />
|
<Tools tools={props.tools} />
|
||||||
@ -153,6 +160,7 @@ export default function HomeClient(props: Props) {
|
|||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
<NomadTips />
|
<NomadTips />
|
||||||
<FeedbackWidget />
|
<FeedbackWidget />
|
||||||
|
<OnboardingTour />
|
||||||
|
|
||||||
{compareList.length > 0 && (
|
{compareList.length > 0 && (
|
||||||
<div className="compare-bar">
|
<div className="compare-bar">
|
||||||
|
|||||||
63
frontend/src/components/OnboardingTour.tsx
Normal file
63
frontend/src/components/OnboardingTour.tsx
Normal file
@ -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 (
|
||||||
|
<div className="onboard-card" role="dialog" aria-label="新手引导">
|
||||||
|
<button className="onboard-skip" onClick={finish}>跳过</button>
|
||||||
|
<div className="onboard-step">
|
||||||
|
<span className="onboard-emoji">{current.emoji}</span>
|
||||||
|
<strong>{current.title}</strong>
|
||||||
|
<p>{current.desc}</p>
|
||||||
|
<div className="onboard-dots">
|
||||||
|
{STEPS.map((_, i) => (
|
||||||
|
<span key={i} className={i === step ? "active" : ""} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={next}>
|
||||||
|
{step >= STEPS.length - 1 ? "开始探索 🚀" : "下一步 →"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ const SECTIONS = [
|
|||||||
{ id: "cost-compare", emoji: "💰", label: "省钱" },
|
{ id: "cost-compare", emoji: "💰", label: "省钱" },
|
||||||
{ id: "savings", emoji: "🎯", label: "存款" },
|
{ id: "savings", emoji: "🎯", label: "存款" },
|
||||||
{ id: "expenses", emoji: "📒", label: "账本" },
|
{ id: "expenses", emoji: "📒", label: "账本" },
|
||||||
|
{ id: "tax-days", emoji: "🧾", label: "税居" },
|
||||||
{ id: "flight", emoji: "✈️", label: "机票" },
|
{ id: "flight", emoji: "✈️", label: "机票" },
|
||||||
{ id: "wifi", emoji: "📶", label: "网速" },
|
{ id: "wifi", emoji: "📶", label: "网速" },
|
||||||
{ id: "lifestyle", emoji: "💻", label: "生活" },
|
{ id: "lifestyle", emoji: "💻", label: "生活" },
|
||||||
@ -21,8 +22,10 @@ const SECTIONS = [
|
|||||||
{ id: "coworking", emoji: "💻", label: "办公" },
|
{ id: "coworking", emoji: "💻", label: "办公" },
|
||||||
{ id: "housing", emoji: "🏡", label: "住宿" },
|
{ id: "housing", emoji: "🏡", label: "住宿" },
|
||||||
{ id: "sim", emoji: "📱", label: "上网" },
|
{ id: "sim", emoji: "📱", label: "上网" },
|
||||||
|
{ id: "cafes", emoji: "☕", label: "咖啡" },
|
||||||
{ id: "season", emoji: "🌤️", label: "季节" },
|
{ id: "season", emoji: "🌤️", label: "季节" },
|
||||||
{ id: "packing", emoji: "🧳", label: "行李" },
|
{ id: "packing", emoji: "🧳", label: "行李" },
|
||||||
|
{ id: "arrival", emoji: "🛬", label: "到站" },
|
||||||
{ id: "phrases", emoji: "🗣️", label: "短语" },
|
{ id: "phrases", emoji: "🗣️", label: "短语" },
|
||||||
{ id: "events", emoji: "📅", label: "活动" },
|
{ id: "events", emoji: "📅", label: "活动" },
|
||||||
{ id: "blog", emoji: "📝", label: "博客" },
|
{ id: "blog", emoji: "📝", label: "博客" },
|
||||||
|
|||||||
150
frontend/src/components/TaxDayCounter.tsx
Normal file
150
frontend/src/components/TaxDayCounter.tsx
Normal file
@ -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<DayLog[]>([]);
|
||||||
|
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 (
|
||||||
|
<section className="section tax-section" id="tax-days">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🧾 TAX DAYS</span>
|
||||||
|
<h2>税务居民天数计数</h2>
|
||||||
|
<p>追踪 {year} 年境外停留天数,靠近 183 天规则时提前规划</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tax-card reveal">
|
||||||
|
<div className="tax-meter">
|
||||||
|
<div className="tax-ring" style={{ background: `conic-gradient(${ringColor} ${pct}%, rgba(255,255,255,0.08) 0)` }}>
|
||||||
|
<div className="tax-ring-inner">
|
||||||
|
<strong>{abroadDays}</strong>
|
||||||
|
<small>/ {threshold} 天</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="tax-stats">
|
||||||
|
<div><small>境外天数</small><strong>{abroadDays}</strong></div>
|
||||||
|
<div><small>境内天数</small><strong>{homeDays}</strong></div>
|
||||||
|
<div>
|
||||||
|
<small>距 183 天</small>
|
||||||
|
<strong className={risk === "high" ? "tax-danger" : ""}>
|
||||||
|
{abroadDays >= threshold ? "已越过" : `还差 ${remaining}`}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<p className="tax-hint">
|
||||||
|
{risk === "high" && "⚠️ 已达常见税务居民门槛,建议咨询专业顾问"}
|
||||||
|
{risk === "warn" && "⏰ 接近 183 天,留意停留与签证策略"}
|
||||||
|
{risk === "ok" && "✅ 目前相对安全,继续记录每一天"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tax-form">
|
||||||
|
<div className="calc-field">
|
||||||
|
<label>📅 日期</label>
|
||||||
|
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="calc-field">
|
||||||
|
<label>📍 所在国家</label>
|
||||||
|
<div className="expense-cats">
|
||||||
|
{COUNTRIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.key}
|
||||||
|
type="button"
|
||||||
|
className={`filter-btn${country === c.key ? " active" : ""}`}
|
||||||
|
onClick={() => setCountry(c.key)}
|
||||||
|
>
|
||||||
|
{c.emoji} {c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" type="button" onClick={addDay}>➕ 记录当天</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="tax-log">
|
||||||
|
{yearLogs.slice(0, 10).map((l) => (
|
||||||
|
<li key={l.date}>
|
||||||
|
<span>{l.emoji}</span>
|
||||||
|
<strong>{l.date}</strong>
|
||||||
|
<span>{COUNTRIES.find((c) => c.key === l.country)?.label}</span>
|
||||||
|
<button type="button" onClick={() => remove(l.date)} aria-label="删除">✕</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{yearLogs.length === 0 && <li className="tax-empty">还没有记录,从今天开始打卡</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user