Add power plugs, emergency contacts, city notes, and focus timer
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
916ce30733
commit
04f8c14ed5
@ -123,6 +123,10 @@ docker compose up -d
|
||||
| 🛬 到站清单 | 落地第一周按天勾选 |
|
||||
| ☕ 咖啡馆 | 办公友好咖啡馆推荐 |
|
||||
| 👋 新手引导 | 首次访问四步导览 |
|
||||
| 🔌 电源插座 | 插头类型 / 电压 / 适配建议 |
|
||||
| 🆘 紧急联系 | 急救、医院、领事馆速查 |
|
||||
| 📝 城市笔记 | 按城本地记录避坑与灵感 |
|
||||
| ⏱️ 专注时钟 | 番茄钟深度工作计时 |
|
||||
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
|
||||
| ❓ FAQ | 搜索 + 展开/收起全部 |
|
||||
|
||||
|
||||
@ -7081,3 +7081,249 @@ img { max-width: 100%; display: block; }
|
||||
.tax-stats { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.onboard-card { bottom: 90px; }
|
||||
}
|
||||
|
||||
/* ===== Power Plug ===== */
|
||||
.plug-card {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
background: var(--bg-card);
|
||||
border: var(--border-glass);
|
||||
border-radius: var(--radius-xl);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.plug-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plug-city { font-size: 1.3rem; font-weight: 700; }
|
||||
|
||||
.plug-types { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.plug-type {
|
||||
padding: 6px 12px;
|
||||
background: rgba(78, 205, 196, 0.12);
|
||||
color: var(--accent-3);
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plug-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.plug-grid div {
|
||||
padding: 14px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.plug-grid small {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.plug-grid strong { font-size: 0.95rem; }
|
||||
|
||||
.plug-tip {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ===== Emergency ===== */
|
||||
.emergency-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.emergency-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
background: var(--bg-card);
|
||||
border: var(--border-glass);
|
||||
border-radius: var(--radius-lg);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.emergency-emoji { font-size: 1.8rem; }
|
||||
.emergency-card h4 { font-size: 0.9rem; margin-bottom: 4px; color: var(--text-secondary); }
|
||||
.emergency-value { font-size: 1.1rem; display: block; margin-bottom: 4px; }
|
||||
.emergency-card p { font-size: 0.8rem; color: var(--text-secondary); margin: 0; }
|
||||
|
||||
.emergency-disclaimer {
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* ===== City Notes ===== */
|
||||
.notes-card {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
background: var(--bg-card);
|
||||
border: var(--border-glass);
|
||||
border-radius: var(--radius-xl);
|
||||
backdrop-filter: blur(20px);
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.notes-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border-right: 1px solid rgba(255,255,255,0.06);
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.notes-city {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.notes-city:hover,
|
||||
.notes-city.active {
|
||||
background: rgba(78, 205, 196, 0.1);
|
||||
border-color: rgba(78, 205, 196, 0.25);
|
||||
}
|
||||
|
||||
.notes-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-3);
|
||||
}
|
||||
|
||||
.notes-count {
|
||||
margin-top: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 4px 0;
|
||||
}
|
||||
|
||||
.notes-editor h3 { font-size: 1.15rem; margin-bottom: 12px; }
|
||||
|
||||
.notes-textarea {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: var(--border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
line-height: 1.55;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.notes-textarea:focus {
|
||||
outline: none;
|
||||
border-color: rgba(78, 205, 196, 0.45);
|
||||
}
|
||||
|
||||
.notes-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* ===== Focus Timer ===== */
|
||||
.focus-card {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding: 36px 28px;
|
||||
text-align: center;
|
||||
background: var(--bg-card);
|
||||
border: var(--border-glass);
|
||||
border-radius: var(--radius-xl);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.focus-ring {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.focus-ring-inner {
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.focus-ring-inner strong {
|
||||
font-size: 2.4rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.focus-ring-inner small {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.focus-presets {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.focus-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.focus-done {
|
||||
margin-top: 14px;
|
||||
color: var(--accent-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.plug-grid { grid-template-columns: 1fr; }
|
||||
.notes-card { grid-template-columns: 1fr; }
|
||||
.notes-sidebar {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
padding-right: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.notes-count { width: 100%; margin-top: 4px; }
|
||||
}
|
||||
|
||||
94
frontend/src/components/CityNotes.tsx
Normal file
94
frontend/src/components/CityNotes.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import type { Destination } from "@/lib/types";
|
||||
|
||||
const STORAGE_KEY = "nomadro-city-notes";
|
||||
|
||||
interface Props {
|
||||
destinations: Destination[];
|
||||
}
|
||||
|
||||
export default function CityNotes({ destinations }: Props) {
|
||||
const { toast } = useToast();
|
||||
const [slug, setSlug] = useState(destinations[0]?.slug || "bali");
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
const [text, setText] = useState("");
|
||||
const [ready, setReady] = useState(false);
|
||||
const dest = destinations.find((d) => d.slug === slug);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) setNotes(JSON.parse(raw));
|
||||
} catch { /* ignore */ }
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
setText(notes[slug] || "");
|
||||
}, [slug, notes, ready]);
|
||||
|
||||
const save = () => {
|
||||
const next = { ...notes, [slug]: text };
|
||||
setNotes(next);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
toast(`${dest?.emoji || ""} 笔记已保存`);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
const next = { ...notes };
|
||||
delete next[slug];
|
||||
setNotes(next);
|
||||
setText("");
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
toast("已清空该城笔记", "info");
|
||||
};
|
||||
|
||||
const filled = Object.keys(notes).filter((k) => notes[k]?.trim()).length;
|
||||
|
||||
return (
|
||||
<section className="section notes-section" id="notes">
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">📝 NOTES</span>
|
||||
<h2>城市旅居笔记</h2>
|
||||
<p>记录咖啡馆坐标、房东微信、避坑心得——只存在你的浏览器里</p>
|
||||
</div>
|
||||
|
||||
<div className="notes-card reveal">
|
||||
<div className="notes-sidebar">
|
||||
{destinations.map((d) => (
|
||||
<button
|
||||
key={d.slug}
|
||||
className={`notes-city${slug === d.slug ? " active" : ""}`}
|
||||
onClick={() => setSlug(d.slug)}
|
||||
>
|
||||
<span>{d.emoji} {d.name}</span>
|
||||
{notes[d.slug]?.trim() && <span className="notes-dot" />}
|
||||
</button>
|
||||
))}
|
||||
<p className="notes-count">已记录 {filled} 座城市</p>
|
||||
</div>
|
||||
|
||||
<div className="notes-editor">
|
||||
<h3>{dest?.emoji} {dest?.name}</h3>
|
||||
<textarea
|
||||
className="notes-textarea"
|
||||
rows={10}
|
||||
placeholder={`写下关于 ${dest?.name || "这座城"} 的笔记…\n例如:好用的咖啡馆、长租价格、避坑提醒`}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
<div className="notes-actions">
|
||||
<button className="btn btn-primary" onClick={save}>💾 保存</button>
|
||||
<button className="btn btn-ghost" onClick={clear} disabled={!text.trim()}>清空</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/EmergencyContacts.tsx
Normal file
100
frontend/src/components/EmergencyContacts.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Destination } from "@/lib/types";
|
||||
|
||||
interface Contact {
|
||||
emoji: string;
|
||||
label: string;
|
||||
value: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
const CONTACTS: Record<string, Contact[]> = {
|
||||
bali: [
|
||||
{ emoji: "🚨", label: "报警 / 急救", value: "110 / 118", note: "也可拨 112" },
|
||||
{ emoji: "🏥", label: "BIMC 医院", value: "+62 361 761263", note: "游民常用私立医院" },
|
||||
{ emoji: "🇨🇳", label: "中国驻登巴萨总领馆", value: "+62 361 234765" },
|
||||
{ emoji: "🚕", label: "Grab / Bluebird", value: "App 呼叫", note: "避免路边随意打车" },
|
||||
],
|
||||
chiangmai: [
|
||||
{ emoji: "🚨", label: "报警 / 急救", value: "191 / 1669" },
|
||||
{ emoji: "🏥", label: "曼谷医院清迈", value: "+66 52 089 888" },
|
||||
{ emoji: "🇨🇳", label: "中国驻清迈总领馆", value: "+66 53 280380" },
|
||||
{ emoji: "🚕", label: "Grab / Bolt", value: "App 呼叫" },
|
||||
],
|
||||
lisbon: [
|
||||
{ emoji: "🚨", label: "欧洲紧急号码", value: "112" },
|
||||
{ emoji: "🏥", label: "急救 / SNS 24", value: "112 / 808 24 24 24" },
|
||||
{ emoji: "🇨🇳", label: "中国驻葡大使馆", value: "+351 21 392 8400" },
|
||||
{ emoji: "🚕", label: "Uber / Bolt", value: "App 呼叫" },
|
||||
],
|
||||
barcelona: [
|
||||
{ emoji: "🚨", label: "欧洲紧急号码", value: "112" },
|
||||
{ emoji: "🏥", label: "医疗急救", value: "061" },
|
||||
{ emoji: "🇨🇳", label: "中国驻巴塞罗那总领馆", value: "+34 93 206 6686" },
|
||||
{ emoji: "🚕", label: "Free Now / Cabify", value: "App 呼叫" },
|
||||
],
|
||||
mexico: [
|
||||
{ emoji: "🚨", label: "紧急求助", value: "911" },
|
||||
{ emoji: "🏥", label: "红十字会", value: "065" },
|
||||
{ emoji: "🇨🇳", label: "中国驻墨大使馆", value: "+52 55 5616 0908" },
|
||||
{ emoji: "🚕", label: "Uber / DiDi", value: "App 呼叫", note: "尽量避免路边黑车" },
|
||||
],
|
||||
tokyo: [
|
||||
{ emoji: "🚨", label: "警察 / 急救消防", value: "110 / 119" },
|
||||
{ emoji: "🏥", label: "东京英语医疗咨询", value: "03-5285-8181" },
|
||||
{ emoji: "🇨🇳", label: "中国驻日大使馆", value: "+81 3-3403-3388" },
|
||||
{ emoji: "🚕", label: "GO / Uber", value: "App 呼叫" },
|
||||
],
|
||||
};
|
||||
|
||||
interface Props {
|
||||
destinations: Destination[];
|
||||
}
|
||||
|
||||
export default function EmergencyContacts({ destinations }: Props) {
|
||||
const [slug, setSlug] = useState(destinations[0]?.slug || "bali");
|
||||
const list = useMemo(() => CONTACTS[slug] || CONTACTS.bali, [slug]);
|
||||
const dest = destinations.find((d) => d.slug === slug);
|
||||
|
||||
return (
|
||||
<section className="section emergency-section" id="emergency">
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">🆘 EMERGENCY</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="emergency-grid">
|
||||
{list.map((c, i) => (
|
||||
<div key={c.label} className="emergency-card reveal" style={{ transitionDelay: `${i * 0.06}s` }}>
|
||||
<span className="emergency-emoji">{c.emoji}</span>
|
||||
<div>
|
||||
<h4>{c.label}</h4>
|
||||
<strong className="emergency-value">{c.value}</strong>
|
||||
{c.note && <p>{c.note}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="emergency-disclaimer">* 号码可能变更,出发前请再核实官方信息</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/FocusTimer.tsx
Normal file
81
frontend/src/components/FocusTimer.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const PRESETS = [25, 45, 60];
|
||||
|
||||
export default function FocusTimer() {
|
||||
const [minutes, setMinutes] = useState(25);
|
||||
const [secondsLeft, setSecondsLeft] = useState(25 * 60);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
if (secondsLeft <= 0) {
|
||||
setRunning(false);
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [running, secondsLeft]);
|
||||
|
||||
const mm = String(Math.floor(secondsLeft / 60)).padStart(2, "0");
|
||||
const ss = String(secondsLeft % 60).padStart(2, "0");
|
||||
const pct = ((minutes * 60 - secondsLeft) / (minutes * 60)) * 100;
|
||||
|
||||
const pick = (m: number) => {
|
||||
setMinutes(m);
|
||||
setSecondsLeft(m * 60);
|
||||
setRunning(false);
|
||||
setDone(false);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setSecondsLeft(minutes * 60);
|
||||
setRunning(false);
|
||||
setDone(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section focus-section" id="focus">
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">⏱️ FOCUS</span>
|
||||
<h2>游民专注时钟</h2>
|
||||
<p>咖啡馆里开一轮番茄钟,进入深度工作</p>
|
||||
</div>
|
||||
|
||||
<div className="focus-card reveal">
|
||||
<div className="focus-ring" style={{ background: `conic-gradient(var(--accent-3) ${pct}%, rgba(255,255,255,0.08) 0)` }}>
|
||||
<div className="focus-ring-inner">
|
||||
<strong>{mm}:{ss}</strong>
|
||||
<small>{done ? "完成!" : running ? "专注中" : "就绪"}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="focus-presets">
|
||||
{PRESETS.map((m) => (
|
||||
<button key={m} className={`filter-btn${minutes === m && !running ? " active" : ""}`} onClick={() => pick(m)} disabled={running}>
|
||||
{m} 分钟
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="focus-actions">
|
||||
{!running ? (
|
||||
<button className="btn btn-primary" onClick={() => { setDone(false); setRunning(true); if (secondsLeft === 0) setSecondsLeft(minutes * 60); }}>
|
||||
{done ? "再来一轮" : "开始专注"}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-ghost" onClick={() => setRunning(false)}>暂停</button>
|
||||
)}
|
||||
<button className="btn btn-ghost" onClick={reset}>重置</button>
|
||||
</div>
|
||||
{done && <p className="focus-done">🎉 完成一个专注块,起来走走吧</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -43,17 +43,17 @@ export default function Footer() {
|
||||
<Link href="/#spin">命运转盘</Link>
|
||||
<Link href="/#wifi">网速评级</Link>
|
||||
<Link href="/#cafes">咖啡馆</Link>
|
||||
<Link href="/#arrival">到站清单</Link>
|
||||
<Link href="/#insurance">保险指南</Link>
|
||||
<Link href="/#sim">上网方案</Link>
|
||||
<Link href="/#emergency">紧急联系</Link>
|
||||
<Link href="/#plugs">电源插座</Link>
|
||||
<Link href="/#notes">城市笔记</Link>
|
||||
<Link href="/#blog">博客</Link>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<h4>工具</h4>
|
||||
<Link href="/#trip">行程规划</Link>
|
||||
<Link href="/#focus">专注时钟</Link>
|
||||
<Link href="/#expenses">支出账本</Link>
|
||||
<Link href="/#tax-days">税居天数</Link>
|
||||
<Link href="/#savings">启动金目标</Link>
|
||||
<Link href="/#calculator">费用计算器</Link>
|
||||
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
|
||||
</div>
|
||||
|
||||
@ -51,6 +51,10 @@ import TaxDayCounter from "./TaxDayCounter";
|
||||
import ArrivalChecklist from "./ArrivalChecklist";
|
||||
import CafeGuide from "./CafeGuide";
|
||||
import OnboardingTour from "./OnboardingTour";
|
||||
import PowerPlugGuide from "./PowerPlugGuide";
|
||||
import EmergencyContacts from "./EmergencyContacts";
|
||||
import CityNotes from "./CityNotes";
|
||||
import FocusTimer from "./FocusTimer";
|
||||
import KeyboardShortcuts from "./KeyboardShortcuts";
|
||||
import BackToTop from "./BackToTop";
|
||||
|
||||
@ -133,19 +137,23 @@ export default function HomeClient(props: Props) {
|
||||
<FlightCost destinations={props.destinations} />
|
||||
<CurrencyConverter />
|
||||
<WifiWorkScore destinations={props.destinations} />
|
||||
<FocusTimer />
|
||||
<NomadScore />
|
||||
<Lifestyle />
|
||||
<Charts {...props.charts} />
|
||||
<VisaSection visas={props.visas} />
|
||||
<InsuranceGuide />
|
||||
<EmergencyContacts destinations={props.destinations} />
|
||||
<CoworkingGuide />
|
||||
<HousingGuide destinations={props.destinations} />
|
||||
<SimGuide destinations={props.destinations} />
|
||||
<PowerPlugGuide destinations={props.destinations} />
|
||||
<CafeGuide destinations={props.destinations} />
|
||||
<SeasonGuide destinations={props.destinations} />
|
||||
<PackingChecklist />
|
||||
<ArrivalChecklist destinations={props.destinations} />
|
||||
<Phrasebook destinations={props.destinations} />
|
||||
<CityNotes destinations={props.destinations} />
|
||||
<NomadEvents />
|
||||
<Tools tools={props.tools} />
|
||||
<BlogSection posts={props.blog} />
|
||||
|
||||
99
frontend/src/components/PowerPlugGuide.tsx
Normal file
99
frontend/src/components/PowerPlugGuide.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Destination } from "@/lib/types";
|
||||
|
||||
interface PlugInfo {
|
||||
types: string[];
|
||||
voltage: string;
|
||||
frequency: string;
|
||||
tip: string;
|
||||
adapter: string;
|
||||
}
|
||||
|
||||
const PLUGS: Record<string, PlugInfo> = {
|
||||
bali: {
|
||||
types: ["C", "F"], voltage: "230V", frequency: "50Hz",
|
||||
tip: "欧标两圆脚,多数插座兼容。", adapter: "中国游客建议带 欧标转换头",
|
||||
},
|
||||
chiangmai: {
|
||||
types: ["A", "B", "C", "O"], voltage: "220V", frequency: "50Hz",
|
||||
tip: "插座混杂,扁脚/圆脚都常见。", adapter: "万能转换头最省事",
|
||||
},
|
||||
lisbon: {
|
||||
types: ["C", "F"], voltage: "230V", frequency: "50Hz",
|
||||
tip: "欧标标准,酒店通常自带 USB。", adapter: "欧标两圆脚即可",
|
||||
},
|
||||
barcelona: {
|
||||
types: ["C", "F"], voltage: "230V", frequency: "50Hz",
|
||||
tip: "与葡萄牙相同欧标。", adapter: "欧标两圆脚即可",
|
||||
},
|
||||
mexico: {
|
||||
types: ["A", "B"], voltage: "127V", frequency: "60Hz",
|
||||
tip: "美标扁脚,电压低于中国设备习惯。", adapter: "注意部分电器功率兼容性",
|
||||
},
|
||||
tokyo: {
|
||||
types: ["A", "B"], voltage: "100V", frequency: "50/60Hz",
|
||||
tip: "电压仅 100V,高功率设备可能偏弱。", adapter: "美标扁脚;吹风机慎用",
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
destinations: Destination[];
|
||||
}
|
||||
|
||||
export default function PowerPlugGuide({ destinations }: Props) {
|
||||
const [slug, setSlug] = useState(destinations[0]?.slug || "bali");
|
||||
const info = useMemo(() => PLUGS[slug] || PLUGS.bali, [slug]);
|
||||
const dest = destinations.find((d) => d.slug === slug);
|
||||
|
||||
return (
|
||||
<section className="section plug-section" id="plugs">
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">🔌 POWER</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="plug-card reveal">
|
||||
<div className="plug-hero">
|
||||
<span className="plug-city">{dest?.emoji} {dest?.name}</span>
|
||||
<div className="plug-types">
|
||||
{info.types.map((t) => (
|
||||
<span key={t} className="plug-type">Type {t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="plug-grid">
|
||||
<div>
|
||||
<small>电压</small>
|
||||
<strong>{info.voltage}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>频率</small>
|
||||
<strong>{info.frequency}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>适配建议</small>
|
||||
<strong>{info.adapter}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p className="plug-tip">💡 {info.tip}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -16,17 +16,21 @@ const SECTIONS = [
|
||||
{ id: "tax-days", emoji: "🧾", label: "税居" },
|
||||
{ id: "flight", emoji: "✈️", label: "机票" },
|
||||
{ id: "wifi", emoji: "📶", label: "网速" },
|
||||
{ id: "focus", emoji: "⏱️", label: "专注" },
|
||||
{ id: "lifestyle", emoji: "💻", label: "生活" },
|
||||
{ id: "visa", emoji: "📋", label: "签证" },
|
||||
{ id: "insurance", emoji: "🏥", label: "保险" },
|
||||
{ id: "emergency", emoji: "🆘", label: "紧急" },
|
||||
{ id: "coworking", emoji: "💻", label: "办公" },
|
||||
{ id: "housing", emoji: "🏡", label: "住宿" },
|
||||
{ id: "sim", emoji: "📱", label: "上网" },
|
||||
{ id: "plugs", emoji: "🔌", label: "插座" },
|
||||
{ id: "cafes", emoji: "☕", label: "咖啡" },
|
||||
{ id: "season", emoji: "🌤️", label: "季节" },
|
||||
{ id: "packing", emoji: "🧳", label: "行李" },
|
||||
{ id: "arrival", emoji: "🛬", label: "到站" },
|
||||
{ id: "phrases", emoji: "🗣️", label: "短语" },
|
||||
{ id: "notes", emoji: "📝", label: "笔记" },
|
||||
{ id: "events", emoji: "📅", label: "活动" },
|
||||
{ id: "blog", emoji: "📝", label: "博客" },
|
||||
];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user