Add spin globe, flight estimator, events calendar, and privacy page
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bf654f76e6
commit
f13722123a
@ -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`
|
||||
|
||||
### 用户系统
|
||||
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
54
frontend/src/app/privacy/page.tsx
Normal file
54
frontend/src/app/privacy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
128
frontend/src/components/FlightCost.tsx
Normal file
128
frontend/src/components/FlightCost.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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);
|
||||
|
||||
119
frontend/src/components/NomadEvents.tsx
Normal file
119
frontend/src/components/NomadEvents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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: "博客" },
|
||||
];
|
||||
|
||||
|
||||
91
frontend/src/components/SpinGlobe.tsx
Normal file
91
frontend/src/components/SpinGlobe.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user