Add jet lag, housing guide, phrasebook, and feedback widget

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-28 23:28:52 -05:00
parent cfc3588d7d
commit d1bc4af87f
10 changed files with 685 additions and 3 deletions

View File

@ -111,6 +111,10 @@ docker compose up -d
| 📶 网速评级 | 远程办公 WiFi 评级与排行 |
| 🎯 启动金目标 | 存款进度追踪,预估出发时间 |
| ✨ 今日金句 | 每日游民灵感语录 |
| 😴 时差估算 | 时差跨度与恢复期建议 |
| 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 |
| 🗣️ 生存短语 | 多语言常用语一键复制 |
| 💬 反馈入口 | 浮动反馈表单 |
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
| ❓ FAQ | 搜索 + 展开/收起全部 |

View File

@ -6377,3 +6377,234 @@ img { max-width: 100%; display: block; }
grid-template-columns: 1fr;
}
}
/* ===== Jet Lag ===== */
.jetlag-card {
display: grid;
grid-template-columns: 1fr 1.1fr;
gap: 28px;
padding: 28px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
backdrop-filter: blur(20px);
}
.jetlag-form { display: flex; flex-direction: column; gap: 16px; }
.jetlag-result {
padding: 24px;
background: rgba(167, 139, 250, 0.06);
border: 1px solid rgba(167, 139, 250, 0.15);
border-radius: var(--radius-lg);
}
.jetlag-diff {
text-align: center;
margin-bottom: 20px;
}
.jetlag-emoji { font-size: 2.5rem; display: block; margin-bottom: 8px; }
.jetlag-diff strong { font-size: 1.8rem; display: block; }
.jetlag-diff small { color: var(--text-secondary); font-size: 0.85rem; }
.jetlag-recovery {
text-align: center;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.jetlag-recovery span {
display: block;
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: 4px;
}
.jetlag-recovery strong { font-size: 1.5rem; }
.jetlag-note {
margin-top: 8px;
font-size: 0.8rem;
color: var(--accent-2);
}
.jetlag-tips {
list-style: none;
padding: 0;
margin: 0;
}
.jetlag-tips li {
font-size: 0.9rem;
color: var(--text-secondary);
padding: 6px 0;
line-height: 1.45;
}
/* ===== Housing ===== */
.housing-cities {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-bottom: 24px;
}
.housing-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.housing-header h3 { font-size: 1.2rem; }
.housing-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 18px;
}
.housing-card {
padding: 22px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-lg);
backdrop-filter: blur(16px);
transition: transform 0.3s;
}
.housing-card:hover { transform: translateY(-4px); }
.housing-card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.housing-emoji { font-size: 1.8rem; }
.housing-price { font-size: 0.85rem; color: var(--accent-2); font-weight: 600; }
.housing-card h4 { font-size: 1.05rem; margin-bottom: 6px; }
.housing-best { font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 12px; }
.housing-pros { display: flex; flex-wrap: wrap; gap: 6px; }
.housing-pro {
font-size: 0.75rem;
padding: 4px 10px;
background: rgba(255,255,255,0.05);
border-radius: 20px;
color: var(--text-secondary);
}
/* ===== Phrasebook ===== */
.phrase-card {
padding: 28px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
backdrop-filter: blur(20px);
}
.phrase-lang {
text-align: center;
font-weight: 600;
margin-bottom: 20px;
color: var(--accent-3);
}
.phrase-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.phrase-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 14px 16px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: var(--radius-md);
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: all 0.25s;
}
.phrase-item:hover {
border-color: rgba(78, 205, 196, 0.4);
background: rgba(78, 205, 196, 0.08);
transform: translateY(-2px);
}
.phrase-item strong { font-size: 1rem; }
.phrase-pinyin { font-size: 0.75rem; color: var(--accent-3); }
.phrase-zh { font-size: 0.85rem; color: var(--text-secondary); }
/* ===== Feedback Widget ===== */
.feedback-fab {
position: fixed;
bottom: 100px;
right: 24px;
z-index: 8600;
width: 48px;
height: 48px;
border-radius: 50%;
border: var(--border-glass);
background: var(--bg-card);
backdrop-filter: blur(16px);
font-size: 1.3rem;
cursor: pointer;
box-shadow: var(--shadow-card);
transition: transform 0.25s;
}
.feedback-fab:hover { transform: scale(1.08); }
.feedback-modal { max-width: 420px; }
.feedback-body {
padding: 8px 4px 4px;
text-align: center;
}
.feedback-body h2 { margin: 12px 0 8px; font-size: 1.4rem; }
.feedback-body > p { color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 20px; }
.feedback-types {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 14px;
flex-wrap: wrap;
}
.feedback-textarea {
width: 100%;
padding: 14px;
margin-bottom: 14px;
background: rgba(255,255,255,0.04);
border: var(--border-glass);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 0.9rem;
resize: vertical;
font-family: inherit;
}
.feedback-textarea:focus {
outline: none;
border-color: rgba(78, 205, 196, 0.5);
}
@media (max-width: 768px) {
.jetlag-card { grid-template-columns: 1fr; }
.feedback-fab { bottom: 160px; right: 16px; }
}

View File

@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import { useToast } from "@/lib/toast";
const TYPES = [
{ key: "idea", label: "💡 想法" },
{ key: "bug", label: "🐛 问题" },
{ key: "love", label: "❤️ 喜欢" },
];
export default function FeedbackWidget() {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [type, setType] = useState("idea");
const [msg, setMsg] = useState("");
const [sending, setSending] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!msg.trim()) return;
setSending(true);
// Persist locally for demo; can wire to API later
try {
const key = "nomadro-feedback";
const prev = JSON.parse(localStorage.getItem(key) || "[]");
prev.push({ type, msg: msg.trim(), at: new Date().toISOString() });
localStorage.setItem(key, JSON.stringify(prev.slice(-50)));
toast("感谢反馈!我们会认真看的 🙏");
setMsg("");
setOpen(false);
} finally {
setSending(false);
}
};
return (
<>
<button
className="feedback-fab"
onClick={() => setOpen(true)}
aria-label="反馈"
title="反馈建议"
>
💬
</button>
{open && (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && setOpen(false)}>
<div className="modal feedback-modal">
<button className="modal-close" onClick={() => setOpen(false)} aria-label="关闭">✕</button>
<div className="feedback-body">
<span className="section-tag">💬 FEEDBACK</span>
<h2>给 nomadro 提建议</h2>
<p>功能想法、体验问题,或只是想说喜欢</p>
<form onSubmit={submit}>
<div className="feedback-types">
{TYPES.map((t) => (
<button
key={t.key}
type="button"
className={`filter-btn${type === t.key ? " active" : ""}`}
onClick={() => setType(t.key)}
>
{t.label}
</button>
))}
</div>
<textarea
className="feedback-textarea"
rows={4}
placeholder="写下你的想法…"
value={msg}
onChange={(e) => setMsg(e.target.value)}
required
maxLength={500}
/>
<button type="submit" className="btn btn-primary" disabled={sending || !msg.trim()} style={{ width: "100%" }}>
{sending ? "发送中…" : "发送反馈 🚀"}
</button>
</form>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -42,17 +42,18 @@ export default function Footer() {
<Link href="/#destinations">目的地</Link>
<Link href="/#spin">命运转盘</Link>
<Link href="/#wifi">网速评级</Link>
<Link href="/#housing">住宿指南</Link>
<Link href="/#phrases">生存短语</Link>
<Link href="/#visa">签证指南</Link>
<Link href="/#events">活动日历</Link>
<Link href="/#blog">博客</Link>
</div>
<div className="footer-links">
<h4>工具</h4>
<Link href="/#trip">行程规划</Link>
<Link href="/#jetlag">时差估算</Link>
<Link href="/#savings">启动金目标</Link>
<Link href="/#flight">机票估算</Link>
<Link href="/#calculator">费用计算器</Link>
<Link href="/#timezone">时区看板</Link>
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
</div>
<div className="footer-links">

View File

@ -69,7 +69,7 @@ export default function Hero({ stats }: Props) {
<div className="hero-content">
<div className="hero-badge reveal">
<span className="badge-dot" />
🚀 全球 {stats.total_nomads} 数字游民正在路上
nomadro · 全球 {stats.total_nomads} 数字游民正在路上
</div>
<h1 className="hero-title reveal">
用一行代码<br /><span className="gradient-text">环游世界</span> 🌏

View File

@ -40,6 +40,10 @@ import NomadEvents from "./NomadEvents";
import WifiWorkScore from "./WifiWorkScore";
import SavingsGoal from "./SavingsGoal";
import DailyQuote from "./DailyQuote";
import JetLagEstimator from "./JetLagEstimator";
import HousingGuide from "./HousingGuide";
import Phrasebook from "./Phrasebook";
import FeedbackWidget from "./FeedbackWidget";
import KeyboardShortcuts from "./KeyboardShortcuts";
import BackToTop from "./BackToTop";
@ -112,6 +116,7 @@ export default function HomeClient(props: Props) {
<SpinGlobe destinations={props.destinations} />
<DailyQuote />
<TimezoneBoard destinations={props.destinations} />
<JetLagEstimator destinations={props.destinations} />
<TripPlanner destinations={props.destinations} />
<Calculator destinations={props.destinations} />
<CostCompare destinations={props.destinations} />
@ -124,8 +129,10 @@ export default function HomeClient(props: Props) {
<Charts {...props.charts} />
<VisaSection visas={props.visas} />
<CoworkingGuide />
<HousingGuide destinations={props.destinations} />
<SeasonGuide destinations={props.destinations} />
<PackingChecklist />
<Phrasebook destinations={props.destinations} />
<NomadEvents />
<Tools tools={props.tools} />
<BlogSection posts={props.blog} />
@ -139,6 +146,7 @@ export default function HomeClient(props: Props) {
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
<CookieConsent />
<NomadTips />
<FeedbackWidget />
{compareList.length > 0 && (
<div className="compare-bar">

View File

@ -0,0 +1,101 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types";
interface HousingOption {
type: string;
emoji: string;
priceRange: string;
pros: string[];
bestFor: string;
}
const HOUSING: Record<string, HousingOption[]> = {
bali: [
{ type: "Villa / 别墅合租", emoji: "🏡", priceRange: "¥2,500–5,000/月", pros: ["私密", "泳池", "安静"], bestFor: "中长期旅居" },
{ type: "Coliving", emoji: "🏠", priceRange: "¥3,000–6,000/月", pros: ["社区", "含网", "活动多"], bestFor: "社交型游民" },
{ type: "酒店式公寓", emoji: "🏨", priceRange: "¥4,000–8,000/月", pros: ["灵活", "干净", "服务"], bestFor: "短期过渡" },
],
chiangmai: [
{ type: "服务式公寓", emoji: "🏢", priceRange: "¥1,800–3,500/月", pros: ["性价比", "市中心", "含家具"], bestFor: "预算友好" },
{ type: "Condo", emoji: "🏙️", priceRange: "¥2,200–4,000/月", pros: ["泳池", "健身房", "安全"], bestFor: "稳定办公" },
{ type: "民宿长住", emoji: "🛏️", priceRange: "¥1,500–2,800/月", pros: ["本地感", "便宜", "灵活"], bestFor: "探路期" },
],
lisbon: [
{ type: "Coliving", emoji: "🏠", priceRange: "€600–1,200/月", pros: ["社区", "家具齐全", "签证友好区"], bestFor: "新到欧洲" },
{ type: "整租公寓", emoji: "🔑", priceRange: "€900–1,600/月", pros: ["私密", "可做饭", "长期"], bestFor: "D7 定居" },
{ type: "房间合租", emoji: "🚪", priceRange: "€450–750/月", pros: ["省钱", "社交", "灵活"], bestFor: "预算优先" },
],
barcelona: [
{ type: "房间合租", emoji: "🚪", priceRange: "€500–850/月", pros: ["市中心", "社交", "灵活"], bestFor: "短期探索" },
{ type: "整租公寓", emoji: "🔑", priceRange: "€1,100–1,800/月", pros: ["私密", "舒适", "长期"], bestFor: "Nomad Visa" },
{ type: "Coliving", emoji: "🏠", priceRange: "€700–1,300/月", pros: ["活动", "家具", "国际社区"], bestFor: "社交办公" },
],
mexico: [
{ type: "Airbnb 长住", emoji: "🛏️", priceRange: "$500–1,000/月", pros: ["灵活", "家具", "厨房"], bestFor: "探路 1–2 月" },
{ type: "Coliving", emoji: "🏠", priceRange: "$600–1,200/月", pros: ["社区", "活动", "时区友好"], bestFor: "对接北美" },
{ type: "本地公寓", emoji: "🔑", priceRange: "$400–800/月", pros: ["便宜", "本地感", "长期"], bestFor: "沉浸生活" },
],
tokyo: [
{ type: "月租公寓", emoji: "🏢", priceRange: "¥8,000–15,000/月", pros: ["干净", "交通便利", "安全"], bestFor: "高效都市" },
{ type: "胶囊 / 旅舍长住", emoji: "🛌", priceRange: "¥4,000–7,000/月", pros: ["极简", "便宜", "灵活"], bestFor: "短期出差" },
{ type: "Share House", emoji: "🏠", priceRange: "¥5,500–9,000/月", pros: ["社交", "含家具", "省押金"], bestFor: "初到日本" },
],
};
interface Props {
destinations: Destination[];
}
export default function HousingGuide({ destinations }: Props) {
const [slug, setSlug] = useState(destinations[0]?.slug || "bali");
const options = useMemo(() => HOUSING[slug] || HOUSING.bali, [slug]);
const dest = destinations.find((d) => d.slug === slug);
return (
<section className="section housing-section" id="housing">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🏡 HOUSING</span>
<h2>住宿选择指南</h2>
<p>按城市对比 Villa、Coliving、公寓等方案,快速找到落脚点</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="housing-header reveal">
<h3>{dest?.emoji} {dest?.name} · 住宿方案</h3>
<Link href={`/destinations/${slug}`} className="btn btn-ghost btn-sm">城市详情 →</Link>
</div>
<div className="housing-grid">
{options.map((o, i) => (
<div key={o.type} className="housing-card reveal" style={{ transitionDelay: `${i * 0.08}s` }}>
<div className="housing-card-top">
<span className="housing-emoji">{o.emoji}</span>
<span className="housing-price">{o.priceRange}</span>
</div>
<h4>{o.type}</h4>
<p className="housing-best">适合:{o.bestFor}</p>
<div className="housing-pros">
{o.pros.map((p) => <span key={p} className="housing-pro">{p}</span>)}
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,111 @@
"use client";
import { useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
const TZ_OFFSET: Record<string, number> = {
bali: 8, lisbon: 0, chiangmai: 7, mexico: -6, barcelona: 1, tokyo: 9,
};
const HOME_OFFSETS = [
{ key: "cn", label: "🇨🇳 中国 (UTC+8)", offset: 8 },
{ key: "us_east", label: "🇺🇸 美东 (UTC-5)", offset: -5 },
{ key: "us_west", label: "🇺🇸 美西 (UTC-8)", offset: -8 },
{ key: "uk", label: "🇬🇧 英国 (UTC+0)", offset: 0 },
{ key: "eu", label: "🇪🇺 中欧 (UTC+1)", offset: 1 },
];
function tipsForDiff(absDiff: number): string[] {
if (absDiff <= 2) return ["时差很小,几乎无感", "抵达当天可正常作息", "保持常规睡眠即可"];
if (absDiff <= 5) return ["建议提前 2–3 天微调作息", "抵达后当天晒太阳帮助校准", "避免落地立刻开长会"];
if (absDiff <= 8) return ["东飞更伤,预留 2–3 天恢复", "第一晚尽量睡满 7 小时", "褪黑素/镁片可辅助(遵医嘱)"];
return ["跨半球长途,预留 4–5 天缓冲", "分段飞行比直达更易恢复", "前两天安排轻量工作即可"];
}
interface Props {
destinations: Destination[];
}
export default function JetLagEstimator({ destinations }: Props) {
const [home, setHome] = useState("cn");
const [slug, setSlug] = useState(destinations[0]?.slug || "bali");
const [direction, setDirection] = useState<"east" | "west">("east");
const result = useMemo(() => {
const homeOff = HOME_OFFSETS.find((h) => h.key === home)?.offset ?? 8;
const destOff = TZ_OFFSET[slug] ?? 8;
let diff = destOff - homeOff;
if (diff > 12) diff -= 24;
if (diff < -12) diff += 24;
const abs = Math.abs(diff);
const dir = diff >= 0 ? "east" : "west";
const recovery = Math.max(1, Math.ceil(abs / 2));
return { diff, abs, dir, recovery, tips: tipsForDiff(abs) };
}, [home, slug]);
const dest = destinations.find((d) => d.slug === slug);
return (
<section className="section jetlag-section" id="jetlag">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">😴 JET LAG</span>
<h2>时差恢复估算</h2>
<p>算出时差跨度,提前安排恢复期与工作节奏</p>
</div>
<div className="jetlag-card reveal">
<div className="jetlag-form">
<div className="calc-field">
<label>🏠 当前时区</label>
<select value={home} onChange={(e) => setHome(e.target.value)}>
{HOME_OFFSETS.map((h) => (
<option key={h.key} value={h.key}>{h.label}</option>
))}
</select>
</div>
<div className="calc-field">
<label>✈️ 目的地</label>
<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>
</div>
<div className="calc-field">
<label>🧭 飞行方向(主观)</label>
<div className="calc-options">
<button className={`calc-option${direction === "east" ? " active" : ""}`} onClick={() => setDirection("east")}>🌅 向东飞</button>
<button className={`calc-option${direction === "west" ? " active" : ""}`} onClick={() => setDirection("west")}>🌇 向西飞</button>
</div>
</div>
</div>
<div className="jetlag-result">
<div className="jetlag-diff">
<span className="jetlag-emoji">{dest?.emoji || "🌍"}</span>
<strong>{result.abs === 0 ? "无时差" : `${result.abs} 小时`}</strong>
<small>
{result.diff === 0
? "与当前时区相同"
: result.diff > 0
? `目的地快 ${result.abs} 小时`
: `目的地慢 ${result.abs} 小时`}
</small>
</div>
<div className="jetlag-recovery">
<span>建议恢复期</span>
<strong className="gradient-text">{result.recovery} 天</strong>
{(direction === "east" || result.dir === "east") && result.abs > 3 && (
<p className="jetlag-note">向东飞行通常更难适应,可多留一天缓冲</p>
)}
</div>
<ul className="jetlag-tips">
{result.tips.map((t) => <li key={t}>💡 {t}</li>)}
</ul>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,135 @@
"use client";
import { useMemo, useState } from "react";
import { useToast } from "@/lib/toast";
import type { Destination } from "@/lib/types";
interface Phrase {
local: string;
pinyin?: string;
zh: string;
}
const PHRASES: Record<string, { lang: string; flag: string; items: Phrase[] }> = {
bali: {
lang: "印尼语", flag: "🇮🇩",
items: [
{ local: "Halo", zh: "你好" },
{ local: "Terima kasih", zh: "谢谢" },
{ local: "Berapa harganya?", zh: "这个多少钱?" },
{ local: "WiFi-nya bagus?", zh: "网速好吗?" },
{ local: "Saya mau sewa sebulan", zh: "我想租一个月" },
{ local: "Di mana toilet?", zh: "洗手间在哪?" },
],
},
chiangmai: {
lang: "泰语", flag: "🇹🇭",
items: [
{ local: "สวัสดี", pinyin: "Sawatdee", zh: "你好" },
{ local: "ขอบคุณ", pinyin: "Khop khun", zh: "谢谢" },
{ local: "เท่าไหร่", pinyin: "Tao rai", zh: "多少钱?" },
{ local: "WiFi แรงไหม", pinyin: "WiFi raeng mai", zh: "网快吗?" },
{ local: "อร่อยมาก", pinyin: "Aroi mak", zh: "很好吃" },
{ local: "ไม่เผ็ด", pinyin: "Mai phet", zh: "不要辣" },
],
},
lisbon: {
lang: "葡萄牙语", flag: "🇵🇹",
items: [
{ local: "Olá", zh: "你好" },
{ local: "Obrigado / Obrigada", zh: "谢谢(男/女)" },
{ local: "Quanto custa?", zh: "多少钱?" },
{ local: "O WiFi é bom?", zh: "网好吗?" },
{ local: "Uma cerveja, por favor", zh: "请来一杯啤酒" },
{ local: "Onde fica o coworking?", zh: "联合办公在哪?" },
],
},
barcelona: {
lang: "西班牙语", flag: "🇪🇸",
items: [
{ local: "Hola", zh: "你好" },
{ local: "Gracias", zh: "谢谢" },
{ local: "¿Cuánto cuesta?", zh: "多少钱?" },
{ local: "¿Hay buen WiFi?", zh: "有好网吗?" },
{ local: "La cuenta, por favor", zh: "买单" },
{ local: "¿Dónde está el metro?", zh: "地铁在哪?" },
],
},
mexico: {
lang: "西班牙语", flag: "🇲🇽",
items: [
{ local: "Qué onda", zh: "嘿(口语)" },
{ local: "Gracias", zh: "谢谢" },
{ local: "¿Cuánto cuesta?", zh: "多少钱?" },
{ local: "Sin chile, por favor", zh: "不要辣" },
{ local: "¿Tienen WiFi?", zh: "有 WiFi 吗?" },
{ local: "La cuenta, por favor", zh: "买单" },
],
},
tokyo: {
lang: "日语", flag: "🇯🇵",
items: [
{ local: "こんにちは", pinyin: "Konnichiwa", zh: "你好" },
{ local: "ありがとう", pinyin: "Arigatou", zh: "谢谢" },
{ local: "いくらですか", pinyin: "Ikura desu ka", zh: "多少钱?" },
{ local: "Wi-Fiはありますか", pinyin: "Wi-Fi wa arimasu ka", zh: "有 WiFi 吗?" },
{ local: "おいしい", pinyin: "Oishii", zh: "好吃" },
{ local: "すみません", pinyin: "Sumimasen", zh: "不好意思 / 借过" },
],
},
};
interface Props {
destinations: Destination[];
}
export default function Phrasebook({ destinations }: Props) {
const { toast } = useToast();
const [slug, setSlug] = useState(destinations[0]?.slug || "bali");
const book = useMemo(() => PHRASES[slug] || PHRASES.bali, [slug]);
const dest = destinations.find((d) => d.slug === slug);
const copy = async (text: string) => {
await navigator.clipboard.writeText(text);
toast(`已复制:${text}`);
};
return (
<section className="section phrase-section" id="phrases">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🗣️ PHRASES</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="phrase-card reveal">
<div className="phrase-lang">
{book.flag} {dest?.name} · {book.lang}
</div>
<div className="phrase-grid">
{book.items.map((p) => (
<button key={p.local} className="phrase-item" onClick={() => copy(p.local)} title="点击复制">
<strong>{p.local}</strong>
{p.pinyin && <span className="phrase-pinyin">{p.pinyin}</span>}
<span className="phrase-zh">{p.zh}</span>
</button>
))}
</div>
</div>
</div>
</section>
);
}

View File

@ -7,6 +7,7 @@ const SECTIONS = [
{ id: "destinations", emoji: "🌍", label: "目的地" },
{ id: "spin", emoji: "🎲", label: "转盘" },
{ id: "timezone", emoji: "🕐", label: "时区" },
{ id: "jetlag", emoji: "😴", label: "时差" },
{ id: "trip", emoji: "🗓️", label: "行程" },
{ id: "calculator", emoji: "🧮", label: "计算器" },
{ id: "cost-compare", emoji: "💰", label: "省钱" },
@ -16,8 +17,10 @@ const SECTIONS = [
{ id: "lifestyle", emoji: "💻", label: "生活" },
{ id: "visa", emoji: "📋", label: "签证" },
{ id: "coworking", emoji: "💻", label: "办公" },
{ id: "housing", emoji: "🏡", label: "住宿" },
{ id: "season", emoji: "🌤️", label: "季节" },
{ id: "packing", emoji: "🧳", label: "行李" },
{ id: "phrases", emoji: "🗣️", label: "短语" },
{ id: "events", emoji: "📅", label: "活动" },
{ id: "blog", emoji: "📝", label: "博客" },
];