From f13722123ab0cc60e0752ba6a0101cf6c2780a13 Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 28 Aug 2026 23:10:54 -0500 Subject: [PATCH] Add spin globe, flight estimator, events calendar, and privacy page Co-authored-by: Cursor --- README.md | 6 +- frontend/src/app/globals.css | 307 ++++++++++++++++++ frontend/src/app/privacy/page.tsx | 54 +++ frontend/src/app/sitemap.ts | 2 + frontend/src/components/CookieConsent.tsx | 2 +- frontend/src/components/FlightCost.tsx | 128 ++++++++ frontend/src/components/Footer.tsx | 4 + frontend/src/components/HomeClient.tsx | 6 + frontend/src/components/KeyboardShortcuts.tsx | 5 + frontend/src/components/NomadEvents.tsx | 119 +++++++ frontend/src/components/SectionNav.tsx | 3 + frontend/src/components/SpinGlobe.tsx | 91 ++++++ 12 files changed, 725 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/privacy/page.tsx create mode 100644 frontend/src/components/FlightCost.tsx create mode 100644 frontend/src/components/NomadEvents.tsx create mode 100644 frontend/src/components/SpinGlobe.tsx diff --git a/README.md b/README.md index 16a1602..7ef79e7 100644 --- a/README.md +++ b/README.md @@ -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` ### 用户系统 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 9e714c8..4bb8e0a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; } +} diff --git a/frontend/src/app/privacy/page.tsx b/frontend/src/app/privacy/page.tsx new file mode 100644 index 0000000..4ed26e7 --- /dev/null +++ b/frontend/src/app/privacy/page.tsx @@ -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 ( + +
+ +
+ 🔒 PRIVACY +

隐私政策

+

最后更新:2026-08-28

+ +
+

我们收集什么

+

+ NomadFlow 可能收集你主动提供的信息(如邮箱订阅、注册账号),以及必要的技术信息(浏览器类型、访问页面)用于改进体验。 +

+
+ +
+

Cookie 使用

+

+ 我们使用 Cookie / localStorage 记住主题偏好、行程规划、行李清单勾选状态与 Cookie 同意选择。这些数据仅保存在你的设备本地,不会上传用于广告追踪。 +

+
+ +
+

第三方服务

+

+ 网站可能嵌入图表 CDN、地图相关资源等第三方脚本。请参考对应服务商的隐私政策。我们不会出售你的个人信息。 +

+
+ +
+

联系我们

+

+ 如有隐私相关问题,请联系: + hello@nomadflow.io +

+
+
+
+
+ ); +} diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts index 42705ee..9e61b0e 100644 --- a/frontend/src/app/sitemap.ts +++ b/frontend/src/app/sitemap.ts @@ -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, diff --git a/frontend/src/components/CookieConsent.tsx b/frontend/src/components/CookieConsent.tsx index ec25d2e..8b76cab 100644 --- a/frontend/src/components/CookieConsent.tsx +++ b/frontend/src/components/CookieConsent.tsx @@ -26,7 +26,7 @@ export default function CookieConsent() { 🍪

我们使用 localStorage 保存主题偏好和行程数据,不会追踪你的浏览行为。 - 了解更多 + 隐私政策

diff --git a/frontend/src/components/FlightCost.tsx b/frontend/src/components/FlightCost.tsx new file mode 100644 index 0000000..026015b --- /dev/null +++ b/frontend/src/components/FlightCost.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { Destination } from "@/lib/types"; + +/** 粗略往返机票估算(CNY),基于城市对距离感 */ +const FLIGHT_BASE: Record = { + 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 ( +
+
+
+ ✈️ FLIGHT +

机票预算估算

+

粗略估算往返机票,帮你把旅居启动成本算清楚

+
+ +
+
+
+ + +
+
→
+
+ + +
+
+ +
+ {SEASON_FACTOR.map((s) => ( + + ))} +
+
+
+ + setPassengers(+e.target.value)} + /> +
+
+ +
+
+ {FROM_CITIES.find((c) => c.key === from)?.label} + ✈️ + {dest?.emoji} {dest?.name} +
+
+
+ 单程参考 + ¥{estimate.oneWay.toLocaleString()} +
+
+ 往返参考 + ¥{estimate.roundTrip.toLocaleString()} +
+
+ 总计 ({passengers}人) + ¥{estimate.total.toLocaleString()} +
+
+

* 估算仅供参考,实际票价随航司与预订时间波动较大

+
+
+
+
+ ); +} diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 474622c..912bba7 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -20,6 +20,7 @@ export default function Footer() {

探索

目的地 + 命运转盘 省钱对比 费用计算器 签证指南 @@ -28,7 +29,9 @@ export default function Footer() {

工具

行程规划 + 机票估算 时区看板 + 活动日历 就绪度测评 汇率换算 API 文档 @@ -37,6 +40,7 @@ export default function Footer() {

账户

登录 / 注册 用户中心 + 隐私政策 hello@nomadflow.io 开源仓库 ↗
diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx index 6b52c84..55f2fd8 100644 --- a/frontend/src/components/HomeClient.tsx +++ b/frontend/src/components/HomeClient.tsx @@ -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) { setMatcherOpen(true)} /> + + @@ -115,6 +120,7 @@ export default function HomeClient(props: Props) { + diff --git a/frontend/src/components/KeyboardShortcuts.tsx b/frontend/src/components/KeyboardShortcuts.tsx index 2b13221..e27282b 100644 --- a/frontend/src/components/KeyboardShortcuts.tsx +++ b/frontend/src/components/KeyboardShortcuts.tsx @@ -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); diff --git a/frontend/src/components/NomadEvents.tsx b/frontend/src/components/NomadEvents.tsx new file mode 100644 index 0000000..2c91ac7 --- /dev/null +++ b/frontend/src/components/NomadEvents.tsx @@ -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>({}); + + 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 ( +
+
+
+ 📅 EVENTS +

游民活动日历

+

线下 Meetup、线上工作坊,找到你的下一场社交

+
+ +
+ {FILTERS.map((f) => ( + + ))} +
+ +
+ {filtered.map((e, i) => ( +
+
+ {e.emoji} + + {e.online ? "💻 线上" : `📍 ${e.city}`} + +
+

{e.title}

+

📅 {e.date}

+

{e.desc}

+ +
+ ))} +
+ {filtered.length === 0 && ( +

该分类暂无活动

+ )} +
+
+ ); +} diff --git a/frontend/src/components/SectionNav.tsx b/frontend/src/components/SectionNav.tsx index ce6d46c..445524e 100644 --- a/frontend/src/components/SectionNav.tsx +++ b/frontend/src/components/SectionNav.tsx @@ -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: "博客" }, ]; diff --git a/frontend/src/components/SpinGlobe.tsx b/frontend/src/components/SpinGlobe.tsx new file mode 100644 index 0000000..959451a --- /dev/null +++ b/frontend/src/components/SpinGlobe.tsx @@ -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(null); + const [display, setDisplay] = useState(null); + const timerRef = useRef | 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 ( +
+
+
+ 🎲 SPIN +

命运转盘 · 下一站去哪?

+

不知道去哪?让命运帮你选一座游民城市

+
+ +
+
+ {shown?.emoji || "🌍"} +
+
+
+ +

+ {shown ? `${shown.name}, ${shown.country}` : "准备就绪"} +

+ {shown && !spinning && !result && ( +

点击下方按钮,开启随机旅居冒险

+ )} + {spinning &&

正在穿越时区… ✈️

} + + {result && !spinning && ( +
+

🎯 命运指引你去

+
+ 💰 ¥{result.cost.toLocaleString()}/月 + 📶 {result.speed}Mbps + ⭐ {result.rating} +
+

{result.description}

+
+ + 查看详情 → + + +
+
+ )} + + {!result && ( + + )} +
+
+
+ ); +}