Add spin globe, flight estimator, events calendar, and privacy page

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-28 23:10:54 -05:00
parent bf654f76e6
commit f13722123a
12 changed files with 725 additions and 2 deletions

View File

@ -105,6 +105,9 @@ docker compose up -d
| 💻 联合办公 | 精选 Co-working Space,城市/评分/网速排序 |
| 🌤️ 最佳月份 | 12 月气候适宜度热力表,避开雨季 |
| 🧳 行李清单 | 可勾选打包清单,localStorage 持久化 |
| 🎲 命运转盘 | 随机目的地抽选,趣味决策 |
| ✈️ 机票估算 | 出发城 × 季节 × 人数粗算往返票价 |
| 📅 活动日历 | Meetup / 工作坊 / 线上活动 |
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
| ❓ FAQ | 搜索 + 展开/收起全部 |
@ -119,8 +122,9 @@ docker compose up -d
- 🔔 全局 Toast 通知
- 💡 游民小贴士浮动提示
- 🍪 Cookie 同意横幅
- ⌨️ 快捷键:`M` 智能匹配、`?` 帮助面板
- ⌨️ 快捷键:`M` 智能匹配、`G` 命运转盘、`?` 帮助面板
- 📱 响应式布局,移动端适配
- 🔒 隐私政策页 `/privacy`
### 用户系统

View File

@ -5795,3 +5795,310 @@ img { max-width: 100%; display: block; }
.nomad-tips-dots span.active { background: var(--accent-3); }
/* ===== Spin Globe ===== */
.spin-card {
max-width: 520px;
margin: 0 auto;
padding: 40px 28px;
text-align: center;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
backdrop-filter: blur(20px);
}
.spin-orb {
position: relative;
width: 140px;
height: 140px;
margin: 0 auto 20px;
display: flex;
align-items: center;
justify-content: center;
}
.spin-emoji {
font-size: 4rem;
position: relative;
z-index: 2;
transition: transform 0.1s;
}
.spin-orb.spinning .spin-emoji {
animation: spinBounce 0.18s ease infinite;
}
@keyframes spinBounce {
0%, 100% { transform: scale(1) rotate(-4deg); }
50% { transform: scale(1.12) rotate(4deg); }
}
.spin-orb-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px dashed rgba(78, 205, 196, 0.35);
animation: compassSpin 12s linear infinite;
}
.spin-orb-ring-2 {
inset: 12px;
border-style: solid;
border-color: rgba(255, 230, 109, 0.25);
animation-direction: reverse;
animation-duration: 8s;
}
.spin-orb.spinning .spin-orb-ring {
animation-duration: 1.2s;
border-color: var(--accent-3);
}
.spin-city-name {
font-size: 1.5rem;
margin-bottom: 8px;
}
.spin-hint {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 20px;
}
.spin-btn { min-width: 200px; }
.spin-result {
margin-top: 8px;
animation: toastIn 0.4s ease;
}
.spin-result-tag {
color: var(--accent-2);
font-weight: 600;
margin-bottom: 10px;
}
.spin-result-meta {
display: flex;
justify-content: center;
gap: 14px;
flex-wrap: wrap;
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 12px;
}
.spin-result-desc {
font-size: 0.9rem;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 18px;
}
.spin-result-actions {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
/* ===== Flight Cost ===== */
.flight-card {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 28px;
padding: 28px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
backdrop-filter: blur(20px);
}
.flight-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.flight-arrow {
text-align: center;
font-size: 1.4rem;
color: var(--accent-3);
opacity: 0.7;
}
.flight-result {
display: flex;
flex-direction: column;
justify-content: center;
padding: 24px;
background: rgba(78, 205, 196, 0.06);
border: 1px solid rgba(78, 205, 196, 0.15);
border-radius: var(--radius-lg);
}
.flight-route {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
font-weight: 600;
margin-bottom: 20px;
flex-wrap: wrap;
}
.flight-plane {
animation: planeOrbit 3s ease-in-out infinite;
}
.flight-nums {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.flight-nums div {
text-align: center;
}
.flight-nums small {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 4px;
}
.flight-nums strong {
font-size: 1.25rem;
}
.flight-total {
grid-column: 1 / -1;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.flight-total strong {
font-size: 1.75rem;
}
.flight-note {
margin-top: 16px;
font-size: 0.75rem;
color: var(--text-secondary);
text-align: center;
}
/* ===== Nomad Events ===== */
.events-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-bottom: 28px;
}
.events-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.event-card {
padding: 22px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-lg);
backdrop-filter: blur(16px);
transition: transform 0.3s, box-shadow 0.3s;
}
.event-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-card);
}
.event-card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.event-emoji { font-size: 2rem; }
.event-badge {
font-size: 0.75rem;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.06);
border-radius: 20px;
color: var(--text-secondary);
}
.event-badge.online {
background: rgba(78, 205, 196, 0.12);
color: var(--accent-3);
}
.event-card h3 {
font-size: 1.05rem;
margin-bottom: 6px;
}
.event-date {
font-size: 0.85rem;
color: var(--accent-2);
margin-bottom: 8px;
}
.event-desc {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 14px;
}
/* ===== Legal / Privacy ===== */
.legal-page {
max-width: 720px;
margin: 0 auto;
padding: calc(var(--nav-height) + 40px) 24px 80px;
position: relative;
z-index: 1;
}
.legal-content .section-tag { margin-bottom: 12px; display: inline-block; }
.legal-content h1 {
font-size: 2rem;
margin-bottom: 8px;
}
.legal-updated {
color: var(--text-secondary);
font-size: 0.85rem;
margin-bottom: 32px;
}
.legal-content section {
margin-bottom: 28px;
}
.legal-content h2 {
font-size: 1.2rem;
margin-bottom: 10px;
color: var(--accent-3);
}
.legal-content p {
color: var(--text-secondary);
line-height: 1.7;
}
.legal-content a { color: var(--accent-3); }
@media (max-width: 768px) {
.flight-card { grid-template-columns: 1fr; }
.spin-card { padding: 28px 20px; }
}

View File

@ -0,0 +1,54 @@
import Link from "next/link";
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
export const metadata: Metadata = {
title: "隐私政策 · NomadFlow",
description: "NomadFlow 隐私政策与 Cookie 使用说明",
};
export default function PrivacyPage() {
return (
<SiteShell>
<div className="legal-page">
<nav className="detail-nav">
<Link href="/">← 返回首页</Link>
</nav>
<article className="legal-content">
<span className="section-tag">🔒 PRIVACY</span>
<h1>隐私政策</h1>
<p className="legal-updated">最后更新:2026-08-28</p>
<section>
<h2>我们收集什么</h2>
<p>
NomadFlow 可能收集你主动提供的信息(如邮箱订阅、注册账号),以及必要的技术信息(浏览器类型、访问页面)用于改进体验。
</p>
</section>
<section>
<h2>Cookie 使用</h2>
<p>
我们使用 Cookie / localStorage 记住主题偏好、行程规划、行李清单勾选状态与 Cookie 同意选择。这些数据仅保存在你的设备本地,不会上传用于广告追踪。
</p>
</section>
<section>
<h2>第三方服务</h2>
<p>
网站可能嵌入图表 CDN、地图相关资源等第三方脚本。请参考对应服务商的隐私政策。我们不会出售你的个人信息。
</p>
</section>
<section>
<h2>联系我们</h2>
<p>
如有隐私相关问题,请联系:
<a href="mailto:hello@nomadflow.io"> hello@nomadflow.io</a>
</p>
</section>
</article>
</div>
</SiteShell>
);
}

View File

@ -10,6 +10,8 @@ export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: SITE, lastModified: now, changeFrequency: "weekly", priority: 1 },
{ url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
{ url: `${SITE}/profile`, lastModified: now, changeFrequency: "monthly", priority: 0.4 },
...DESTINATIONS.map((slug) => ({
url: `${SITE}/destinations/${slug}`,
lastModified: now,

View File

@ -26,7 +26,7 @@ export default function CookieConsent() {
<span className="cookie-emoji">🍪</span>
<p>
我们使用 localStorage 保存主题偏好和行程数据,不会追踪你的浏览行为。
<Link href="/#faq">了解更多</Link>
<Link href="/privacy">隐私政策</Link>
</p>
<button className="btn btn-primary btn-sm" onClick={accept}>知道了 ✓</button>
</div>

View File

@ -0,0 +1,128 @@
"use client";
import { useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
/** 粗略往返机票估算(CNY),基于城市对距离感 */
const FLIGHT_BASE: Record<string, number> = {
bali: 2800, lisbon: 6500, chiangmai: 2200,
mexico: 7800, barcelona: 6200, tokyo: 3500,
};
const FROM_CITIES = [
{ key: "beijing", label: "🇨🇳 北京", factor: 1 },
{ key: "shanghai", label: "🇨🇳 上海", factor: 0.95 },
{ key: "guangzhou", label: "🇨🇳 广州", factor: 0.9 },
{ key: "chengdu", label: "🇨🇳 成都", factor: 1.05 },
{ key: "hongkong", label: "🇭🇰 香港", factor: 0.85 },
];
const SEASON_FACTOR = [
{ key: "low", label: "🍃 淡季", factor: 0.75 },
{ key: "normal", label: "🌤️ 平季", factor: 1 },
{ key: "peak", label: "🔥 旺季", factor: 1.45 },
];
interface Props {
destinations: Destination[];
}
export default function FlightCost({ destinations }: Props) {
const [from, setFrom] = useState("shanghai");
const [to, setTo] = useState(destinations[0]?.slug || "bali");
const [season, setSeason] = useState("normal");
const [passengers, setPassengers] = useState(1);
const estimate = useMemo(() => {
const base = FLIGHT_BASE[to] ?? 4000;
const fromF = FROM_CITIES.find((c) => c.key === from)?.factor ?? 1;
const seasonF = SEASON_FACTOR.find((s) => s.key === season)?.factor ?? 1;
const oneWay = Math.round(base * fromF * seasonF);
const roundTrip = oneWay * 2;
const total = roundTrip * passengers;
return { oneWay, roundTrip, total };
}, [from, to, season, passengers]);
const dest = destinations.find((d) => d.slug === to);
return (
<section className="section flight-section" id="flight">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">✈️ FLIGHT</span>
<h2>机票预算估算</h2>
<p>粗略估算往返机票,帮你把旅居启动成本算清楚</p>
</div>
<div className="flight-card reveal">
<div className="flight-form">
<div className="calc-field">
<label>🛫 出发城市</label>
<select value={from} onChange={(e) => setFrom(e.target.value)}>
{FROM_CITIES.map((c) => (
<option key={c.key} value={c.key}>{c.label}</option>
))}
</select>
</div>
<div className="flight-arrow">→</div>
<div className="calc-field">
<label>🛬 目的地</label>
<select value={to} onChange={(e) => setTo(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">
{SEASON_FACTOR.map((s) => (
<button
key={s.key}
className={`calc-option${season === s.key ? " active" : ""}`}
onClick={() => setSeason(s.key)}
>
{s.label}
</button>
))}
</div>
</div>
<div className="calc-field">
<label>👥 人数: {passengers}</label>
<input
type="range"
min={1}
max={6}
value={passengers}
onChange={(e) => setPassengers(+e.target.value)}
/>
</div>
</div>
<div className="flight-result">
<div className="flight-route">
<span>{FROM_CITIES.find((c) => c.key === from)?.label}</span>
<span className="flight-plane">✈️</span>
<span>{dest?.emoji} {dest?.name}</span>
</div>
<div className="flight-nums">
<div>
<small>单程参考</small>
<strong>¥{estimate.oneWay.toLocaleString()}</strong>
</div>
<div>
<small>往返参考</small>
<strong>¥{estimate.roundTrip.toLocaleString()}</strong>
</div>
<div className="flight-total">
<small>总计 ({passengers}人)</small>
<strong className="gradient-text">¥{estimate.total.toLocaleString()}</strong>
</div>
</div>
<p className="flight-note">* 估算仅供参考,实际票价随航司与预订时间波动较大</p>
</div>
</div>
</div>
</section>
);
}

View File

@ -20,6 +20,7 @@ export default function Footer() {
<div className="footer-links">
<h4>探索</h4>
<Link href="/#destinations">目的地</Link>
<Link href="/#spin">命运转盘</Link>
<Link href="/#cost-compare">省钱对比</Link>
<Link href="/#calculator">费用计算器</Link>
<Link href="/#visa">签证指南</Link>
@ -28,7 +29,9 @@ export default function Footer() {
<div className="footer-links">
<h4>工具</h4>
<Link href="/#trip">行程规划</Link>
<Link href="/#flight">机票估算</Link>
<Link href="/#timezone">时区看板</Link>
<Link href="/#events">活动日历</Link>
<Link href="/#nomad-score">就绪度测评</Link>
<Link href="/#currency">汇率换算</Link>
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
@ -37,6 +40,7 @@ export default function Footer() {
<h4>账户</h4>
<Link href="/login">登录 / 注册</Link>
<Link href="/profile">用户中心</Link>
<Link href="/privacy">隐私政策</Link>
<a href="mailto:hello@nomadflow.io">hello@nomadflow.io</a>
<a href="https://gitea.dsx2020.com/eric/nomadweb" target="_blank" rel="noopener noreferrer">开源仓库 ↗</a>
</div>

View File

@ -34,6 +34,9 @@ import CoworkingGuide from "./CoworkingGuide";
import PackingChecklist from "./PackingChecklist";
import SeasonGuide from "./SeasonGuide";
import NomadTips from "./NomadTips";
import SpinGlobe from "./SpinGlobe";
import FlightCost from "./FlightCost";
import NomadEvents from "./NomadEvents";
import KeyboardShortcuts from "./KeyboardShortcuts";
import BackToTop from "./BackToTop";
@ -103,10 +106,12 @@ export default function HomeClient(props: Props) {
<Hero stats={props.stats} />
<WorldMap destinations={props.destinations} />
<Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} />
<SpinGlobe destinations={props.destinations} />
<TimezoneBoard destinations={props.destinations} />
<TripPlanner destinations={props.destinations} />
<Calculator destinations={props.destinations} />
<CostCompare destinations={props.destinations} />
<FlightCost destinations={props.destinations} />
<CurrencyConverter />
<NomadScore />
<Lifestyle />
@ -115,6 +120,7 @@ export default function HomeClient(props: Props) {
<CoworkingGuide />
<SeasonGuide destinations={props.destinations} />
<PackingChecklist />
<NomadEvents />
<Tools tools={props.tools} />
<BlogSection posts={props.blog} />
<FAQSection faqs={props.faqs} />

View File

@ -9,6 +9,7 @@ interface Props {
const SHORTCUTS = [
{ keys: ["Ctrl", "K"], label: "全局搜索", icon: "🔍" },
{ keys: ["M"], label: "智能匹配", icon: "🎯" },
{ keys: ["G"], label: "命运转盘", icon: "🎲" },
{ keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
{ keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
{ keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" },
@ -31,6 +32,10 @@ export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
e.preventDefault();
onOpenMatcher?.();
}
if (e.key === "g" && !typing && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
document.getElementById("spin")?.scrollIntoView({ behavior: "smooth" });
}
if (e.key === "Escape" && open) setOpen(false);
};
window.addEventListener("keydown", onKey);

View File

@ -0,0 +1,119 @@
"use client";
import { useMemo, useState } from "react";
interface EventItem {
id: string;
emoji: string;
title: string;
city: string;
date: string;
type: string;
desc: string;
online: boolean;
}
const EVENTS: EventItem[] = [
{
id: "1", emoji: "☕", title: "清迈咖啡馆 Meetup", city: "清迈",
date: "2026-09-12", type: "meetup", online: false,
desc: "每周五下午,Punspace 门口集合,结识本地游民与远程开发者。",
},
{
id: "2", emoji: "🏄", title: "巴厘岛冲浪 + Cowork 日", city: "巴厘岛",
date: "2026-09-20", type: "lifestyle", online: false,
desc: "上午 Canggu 冲浪,下午 Hubud 联合办公,傍晚沙滩社交。",
},
{
id: "3", emoji: "💻", title: "远程工作效率工作坊", city: "线上",
date: "2026-09-08", type: "workshop", online: true,
desc: "时区管理、异步协作与深度工作方法分享,Zoom 直播。",
},
{
id: "4", emoji: "🍷", title: "里斯本游民之夜", city: "里斯本",
date: "2026-09-27", type: "meetup", online: false,
desc: "Alfama 区酒窖聚会,葡萄牙 D7 签证经验交流。",
},
{
id: "5", emoji: "🗺️", title: "签证政策 AMA", city: "线上",
date: "2026-10-05", type: "workshop", online: true,
desc: "移民顾问在线答疑:西班牙 Nomad Visa、泰国 LTR、印尼 B211A。",
},
{
id: "6", emoji: "🌮", title: "墨西哥城美食徒步", city: "墨西哥城",
date: "2026-10-11", type: "lifestyle", online: false,
desc: "Roma Norte 街头美食 + 共享办公空间探访。",
},
];
const FILTERS = [
{ key: "all", label: "🌏 全部" },
{ key: "meetup", label: "🤝 聚会" },
{ key: "workshop", label: "📚 工作坊" },
{ key: "lifestyle", label: "🌴 生活方式" },
{ key: "online", label: "💻 线上" },
];
export default function NomadEvents() {
const [filter, setFilter] = useState("all");
const [joined, setJoined] = useState<Record<string, boolean>>({});
const filtered = useMemo(() => {
if (filter === "all") return EVENTS;
if (filter === "online") return EVENTS.filter((e) => e.online);
return EVENTS.filter((e) => e.type === filter);
}, [filter]);
const toggleJoin = (id: string) => {
setJoined((prev) => ({ ...prev, [id]: !prev[id] }));
};
return (
<section className="section events-section" id="events">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">📅 EVENTS</span>
<h2>游民活动日历</h2>
<p>线下 Meetup、线上工作坊,找到你的下一场社交</p>
</div>
<div className="events-filters reveal">
{FILTERS.map((f) => (
<button
key={f.key}
className={`filter-btn${filter === f.key ? " active" : ""}`}
onClick={() => setFilter(f.key)}
>
{f.label}
</button>
))}
</div>
<div className="events-grid">
{filtered.map((e, i) => (
<div key={e.id} className="event-card reveal" style={{ transitionDelay: `${(i % 3) * 0.08}s` }}>
<div className="event-card-top">
<span className="event-emoji">{e.emoji}</span>
<span className={`event-badge${e.online ? " online" : ""}`}>
{e.online ? "💻 线上" : `📍 ${e.city}`}
</span>
</div>
<h3>{e.title}</h3>
<p className="event-date">📅 {e.date}</p>
<p className="event-desc">{e.desc}</p>
<button
className={`btn ${joined[e.id] ? "btn-ghost" : "btn-primary"} btn-sm`}
onClick={() => toggleJoin(e.id)}
>
{joined[e.id] ? "✓ 已感兴趣" : "🙋 我感兴趣"}
</button>
</div>
))}
</div>
{filtered.length === 0 && (
<p className="dest-empty">该分类暂无活动</p>
)}
</div>
</section>
);
}

View File

@ -5,15 +5,18 @@ import { useEffect, useState } from "react";
const SECTIONS = [
{ id: "map", emoji: "🗺️", label: "地图" },
{ id: "destinations", emoji: "🌍", label: "目的地" },
{ id: "spin", emoji: "🎲", label: "转盘" },
{ id: "timezone", emoji: "🕐", label: "时区" },
{ id: "trip", emoji: "🗓️", label: "行程" },
{ id: "calculator", emoji: "🧮", label: "计算器" },
{ id: "cost-compare", emoji: "💰", label: "省钱" },
{ id: "flight", emoji: "✈️", label: "机票" },
{ id: "lifestyle", emoji: "💻", label: "生活" },
{ id: "visa", emoji: "📋", label: "签证" },
{ id: "coworking", emoji: "💻", label: "办公" },
{ id: "season", emoji: "🌤️", label: "季节" },
{ id: "packing", emoji: "🧳", label: "行李" },
{ id: "events", emoji: "📅", label: "活动" },
{ id: "blog", emoji: "📝", label: "博客" },
];

View File

@ -0,0 +1,91 @@
"use client";
import { useRef, useState } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types";
interface Props {
destinations: Destination[];
}
export default function SpinGlobe({ destinations }: Props) {
const [spinning, setSpinning] = useState(false);
const [result, setResult] = useState<Destination | null>(null);
const [display, setDisplay] = useState<Destination | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const spin = () => {
if (spinning || destinations.length === 0) return;
setSpinning(true);
setResult(null);
let ticks = 0;
const total = 18 + Math.floor(Math.random() * 8);
const winner = destinations[Math.floor(Math.random() * destinations.length)];
timerRef.current = setInterval(() => {
ticks++;
setDisplay(destinations[Math.floor(Math.random() * destinations.length)]);
if (ticks >= total) {
if (timerRef.current) clearInterval(timerRef.current);
setDisplay(winner);
setResult(winner);
setSpinning(false);
}
}, 90);
};
const shown = display || destinations[0];
return (
<section className="section spin-section" id="spin">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🎲 SPIN</span>
<h2>命运转盘 · 下一站去哪?</h2>
<p>不知道去哪?让命运帮你选一座游民城市</p>
</div>
<div className="spin-card reveal">
<div className={`spin-orb${spinning ? " spinning" : ""}`}>
<span className="spin-emoji">{shown?.emoji || "🌍"}</span>
<div className="spin-orb-ring" />
<div className="spin-orb-ring spin-orb-ring-2" />
</div>
<h3 className="spin-city-name">
{shown ? `${shown.name}, ${shown.country}` : "准备就绪"}
</h3>
{shown && !spinning && !result && (
<p className="spin-hint">点击下方按钮,开启随机旅居冒险</p>
)}
{spinning && <p className="spin-hint">正在穿越时区… ✈️</p>}
{result && !spinning && (
<div className="spin-result">
<p className="spin-result-tag">🎯 命运指引你去</p>
<div className="spin-result-meta">
<span>💰 ¥{result.cost.toLocaleString()}/月</span>
<span>📶 {result.speed}Mbps</span>
<span>⭐ {result.rating}</span>
</div>
<p className="spin-result-desc">{result.description}</p>
<div className="spin-result-actions">
<Link href={`/destinations/${result.slug}`} className="btn btn-primary">
查看详情 →
</Link>
<button className="btn btn-ghost" onClick={spin}>再转一次 🎲</button>
</div>
</div>
)}
{!result && (
<button className="btn btn-primary spin-btn" onClick={spin} disabled={spinning}>
{spinning ? "旋转中…" : "🎲 转动命运转盘"}
</button>
)}
</div>
</div>
</section>
);
}