Add jet lag, housing guide, phrasebook, and feedback widget
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cfc3588d7d
commit
d1bc4af87f
@ -111,6 +111,10 @@ docker compose up -d
|
|||||||
| 📶 网速评级 | 远程办公 WiFi 评级与排行 |
|
| 📶 网速评级 | 远程办公 WiFi 评级与排行 |
|
||||||
| 🎯 启动金目标 | 存款进度追踪,预估出发时间 |
|
| 🎯 启动金目标 | 存款进度追踪,预估出发时间 |
|
||||||
| ✨ 今日金句 | 每日游民灵感语录 |
|
| ✨ 今日金句 | 每日游民灵感语录 |
|
||||||
|
| 😴 时差估算 | 时差跨度与恢复期建议 |
|
||||||
|
| 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 |
|
||||||
|
| 🗣️ 生存短语 | 多语言常用语一键复制 |
|
||||||
|
| 💬 反馈入口 | 浮动反馈表单 |
|
||||||
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
|
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
|
||||||
| ❓ FAQ | 搜索 + 展开/收起全部 |
|
| ❓ FAQ | 搜索 + 展开/收起全部 |
|
||||||
|
|
||||||
|
|||||||
@ -6377,3 +6377,234 @@ img { max-width: 100%; display: block; }
|
|||||||
grid-template-columns: 1fr;
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
88
frontend/src/components/FeedbackWidget.tsx
Normal file
88
frontend/src/components/FeedbackWidget.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -42,17 +42,18 @@ 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="/#housing">住宿指南</Link>
|
||||||
|
<Link href="/#phrases">生存短语</Link>
|
||||||
<Link href="/#visa">签证指南</Link>
|
<Link href="/#visa">签证指南</Link>
|
||||||
<Link href="/#events">活动日历</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="/#jetlag">时差估算</Link>
|
||||||
<Link href="/#savings">启动金目标</Link>
|
<Link href="/#savings">启动金目标</Link>
|
||||||
<Link href="/#flight">机票估算</Link>
|
<Link href="/#flight">机票估算</Link>
|
||||||
<Link href="/#calculator">费用计算器</Link>
|
<Link href="/#calculator">费用计算器</Link>
|
||||||
<Link href="/#timezone">时区看板</Link>
|
|
||||||
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
|
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export default function Hero({ stats }: Props) {
|
|||||||
<div className="hero-content">
|
<div className="hero-content">
|
||||||
<div className="hero-badge reveal">
|
<div className="hero-badge reveal">
|
||||||
<span className="badge-dot" />
|
<span className="badge-dot" />
|
||||||
🚀 全球 {stats.total_nomads} 数字游民正在路上
|
nomadro · 全球 {stats.total_nomads} 数字游民正在路上
|
||||||
</div>
|
</div>
|
||||||
<h1 className="hero-title reveal">
|
<h1 className="hero-title reveal">
|
||||||
用一行代码<br /><span className="gradient-text">环游世界</span> 🌏
|
用一行代码<br /><span className="gradient-text">环游世界</span> 🌏
|
||||||
|
|||||||
@ -40,6 +40,10 @@ import NomadEvents from "./NomadEvents";
|
|||||||
import WifiWorkScore from "./WifiWorkScore";
|
import WifiWorkScore from "./WifiWorkScore";
|
||||||
import SavingsGoal from "./SavingsGoal";
|
import SavingsGoal from "./SavingsGoal";
|
||||||
import DailyQuote from "./DailyQuote";
|
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 KeyboardShortcuts from "./KeyboardShortcuts";
|
||||||
import BackToTop from "./BackToTop";
|
import BackToTop from "./BackToTop";
|
||||||
|
|
||||||
@ -112,6 +116,7 @@ export default function HomeClient(props: Props) {
|
|||||||
<SpinGlobe destinations={props.destinations} />
|
<SpinGlobe destinations={props.destinations} />
|
||||||
<DailyQuote />
|
<DailyQuote />
|
||||||
<TimezoneBoard destinations={props.destinations} />
|
<TimezoneBoard destinations={props.destinations} />
|
||||||
|
<JetLagEstimator destinations={props.destinations} />
|
||||||
<TripPlanner destinations={props.destinations} />
|
<TripPlanner destinations={props.destinations} />
|
||||||
<Calculator destinations={props.destinations} />
|
<Calculator destinations={props.destinations} />
|
||||||
<CostCompare destinations={props.destinations} />
|
<CostCompare destinations={props.destinations} />
|
||||||
@ -124,8 +129,10 @@ export default function HomeClient(props: Props) {
|
|||||||
<Charts {...props.charts} />
|
<Charts {...props.charts} />
|
||||||
<VisaSection visas={props.visas} />
|
<VisaSection visas={props.visas} />
|
||||||
<CoworkingGuide />
|
<CoworkingGuide />
|
||||||
|
<HousingGuide destinations={props.destinations} />
|
||||||
<SeasonGuide destinations={props.destinations} />
|
<SeasonGuide destinations={props.destinations} />
|
||||||
<PackingChecklist />
|
<PackingChecklist />
|
||||||
|
<Phrasebook destinations={props.destinations} />
|
||||||
<NomadEvents />
|
<NomadEvents />
|
||||||
<Tools tools={props.tools} />
|
<Tools tools={props.tools} />
|
||||||
<BlogSection posts={props.blog} />
|
<BlogSection posts={props.blog} />
|
||||||
@ -139,6 +146,7 @@ export default function HomeClient(props: Props) {
|
|||||||
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
|
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
<NomadTips />
|
<NomadTips />
|
||||||
|
<FeedbackWidget />
|
||||||
|
|
||||||
{compareList.length > 0 && (
|
{compareList.length > 0 && (
|
||||||
<div className="compare-bar">
|
<div className="compare-bar">
|
||||||
|
|||||||
101
frontend/src/components/HousingGuide.tsx
Normal file
101
frontend/src/components/HousingGuide.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/src/components/JetLagEstimator.tsx
Normal file
111
frontend/src/components/JetLagEstimator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
frontend/src/components/Phrasebook.tsx
Normal file
135
frontend/src/components/Phrasebook.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ const SECTIONS = [
|
|||||||
{ id: "destinations", emoji: "🌍", label: "目的地" },
|
{ id: "destinations", emoji: "🌍", label: "目的地" },
|
||||||
{ id: "spin", emoji: "🎲", label: "转盘" },
|
{ id: "spin", emoji: "🎲", label: "转盘" },
|
||||||
{ id: "timezone", emoji: "🕐", label: "时区" },
|
{ id: "timezone", emoji: "🕐", label: "时区" },
|
||||||
|
{ id: "jetlag", emoji: "😴", label: "时差" },
|
||||||
{ id: "trip", emoji: "🗓️", label: "行程" },
|
{ id: "trip", emoji: "🗓️", label: "行程" },
|
||||||
{ id: "calculator", emoji: "🧮", label: "计算器" },
|
{ id: "calculator", emoji: "🧮", label: "计算器" },
|
||||||
{ id: "cost-compare", emoji: "💰", label: "省钱" },
|
{ id: "cost-compare", emoji: "💰", label: "省钱" },
|
||||||
@ -16,8 +17,10 @@ const SECTIONS = [
|
|||||||
{ id: "lifestyle", emoji: "💻", label: "生活" },
|
{ id: "lifestyle", emoji: "💻", label: "生活" },
|
||||||
{ id: "visa", emoji: "📋", label: "签证" },
|
{ id: "visa", emoji: "📋", label: "签证" },
|
||||||
{ id: "coworking", emoji: "💻", label: "办公" },
|
{ id: "coworking", emoji: "💻", label: "办公" },
|
||||||
|
{ id: "housing", emoji: "🏡", label: "住宿" },
|
||||||
{ id: "season", emoji: "🌤️", label: "季节" },
|
{ id: "season", emoji: "🌤️", label: "季节" },
|
||||||
{ id: "packing", emoji: "🧳", label: "行李" },
|
{ id: "packing", emoji: "🧳", label: "行李" },
|
||||||
|
{ id: "phrases", emoji: "🗣️", label: "短语" },
|
||||||
{ id: "events", emoji: "📅", label: "活动" },
|
{ id: "events", emoji: "📅", label: "活动" },
|
||||||
{ id: "blog", emoji: "📝", label: "博客" },
|
{ id: "blog", emoji: "📝", label: "博客" },
|
||||||
];
|
];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user