From bf654f76e617f27a3472b45fc63807791223fbed Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 28 Aug 2026 23:05:48 -0500 Subject: [PATCH] Add visa wizard, coworking, packing, season guide, and UX polish Co-authored-by: Cursor --- .gitignore | 1 + README.md | 20 +- frontend/src/app/blog/[slug]/page.tsx | 28 +- frontend/src/app/globals.css | 1007 ++++++++++++++++++ frontend/src/app/login/page.tsx | 45 +- frontend/src/app/not-found.tsx | 13 +- frontend/src/app/profile/page.tsx | 43 +- frontend/src/components/BlogSection.tsx | 35 +- frontend/src/components/Community.tsx | 49 +- frontend/src/components/CoworkingGuide.tsx | 78 ++ frontend/src/components/FAQSection.tsx | 42 +- frontend/src/components/HomeClient.tsx | 8 + frontend/src/components/NomadTips.tsx | 48 + frontend/src/components/PackingChecklist.tsx | 125 +++ frontend/src/components/SeasonGuide.tsx | 100 ++ frontend/src/components/SectionNav.tsx | 3 + frontend/src/components/ShareButton.tsx | 67 ++ frontend/src/components/VisaSection.tsx | 2 + frontend/src/components/VisaWizard.tsx | 179 ++++ frontend/src/lib/coworking.ts | 60 ++ 20 files changed, 1922 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/CoworkingGuide.tsx create mode 100644 frontend/src/components/NomadTips.tsx create mode 100644 frontend/src/components/PackingChecklist.tsx create mode 100644 frontend/src/components/SeasonGuide.tsx create mode 100644 frontend/src/components/ShareButton.tsx create mode 100644 frontend/src/components/VisaWizard.tsx create mode 100644 frontend/src/lib/coworking.ts diff --git a/.gitignore b/.gitignore index 89bf445..3321c2d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ Thumbs.db scripts/remote_deploy.py scripts/redeploy_server.py scripts/upload_and_deploy.py +scripts/deploy_update.py scripts/check_server.py scripts/check_ports.py scripts/find_caddy.py diff --git a/README.md b/README.md index 9061fa9..16a1602 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,12 @@ docker compose up -d | 💱 多币种换算 | CNY/USD/EUR/THB 等 8 种货币互转 | | 📊 就绪度测评 | 5 题评估你的数字游民准备程度 | | 💻 生活方式 | 交互式「游民一天」时间轴 | -| 📋 签证指南 | 难度筛选 + 进度条可视化 | -| 📝 博客 | 文章列表 + 阅读进度条详情页 | -| ❓ FAQ | 手风琴问答 | +| 📋 签证指南 | 难度筛选 + 签证智能匹配向导 | +| 💻 联合办公 | 精选 Co-working Space,城市/评分/网速排序 | +| 🌤️ 最佳月份 | 12 月气候适宜度热力表,避开雨季 | +| 🧳 行李清单 | 可勾选打包清单,localStorage 持久化 | +| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 | +| ❓ FAQ | 搜索 + 展开/收起全部 | ### 交互与体验 @@ -114,6 +117,8 @@ docker compose up -d - 📊 顶部滚动进度条 - ⚖️ 可视化城市对比(评分圆环 + 条形图) - 🔔 全局 Toast 通知 +- 💡 游民小贴士浮动提示 +- 🍪 Cookie 同意横幅 - ⌨️ 快捷键:`M` 智能匹配、`?` 帮助面板 - 📱 响应式布局,移动端适配 @@ -121,7 +126,7 @@ docker compose up -d - 🔐 登录 / 注册 / 一键演示账号 - ❤️ 收藏目的地(同步 API) -- 👤 用户中心:统计、收藏、行程展示 +- 👤 用户中心:统计、收藏、行程、成就徽章 - 演示账号:`demo@nomadflow.io` / `demo123` ### 后端 API @@ -192,13 +197,14 @@ NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 **更新部署:** ```bash -# SSH 登录服务器后 +# SSH 登录服务器后(当前开发分支为 dev1) cd /opt/nomadweb -git pull origin main +git fetch origin && git checkout -B dev1 origin/dev1 export DOMAIN=nomadweb.nomadro.com export NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1 +export NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com export CORS_ORIGINS=https://nomadweb.nomadro.com -bash scripts/deploy-production.sh +docker compose -f docker-compose.prod.yml up -d --build ``` 或使用 Docker Compose 生产配置: diff --git a/frontend/src/app/blog/[slug]/page.tsx b/frontend/src/app/blog/[slug]/page.tsx index 0e69b45..894cb1e 100644 --- a/frontend/src/app/blog/[slug]/page.tsx +++ b/frontend/src/app/blog/[slug]/page.tsx @@ -4,22 +4,32 @@ import { notFound } from "next/navigation"; import SiteShell from "@/components/SiteShell"; import BlogMarkdown from "@/components/BlogMarkdown"; import BlogReadingProgress from "@/components/BlogReadingProgress"; +import ShareButton from "@/components/ShareButton"; export default async function BlogDetailPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; let post; + let allPosts; try { - post = await api.getBlogPost(slug); + [post, allPosts] = await Promise.all([ + api.getBlogPost(slug), + api.getBlog(), + ]); } catch { notFound(); } + const related = allPosts + .filter((p) => p.slug !== slug && p.tags.some((t) => post.tags.includes(t))) + .slice(0, 3); + return (
@@ -38,6 +48,22 @@ export default async function BlogDetailPage({ params }: { params: Promise<{ slu
+ + {related.length > 0 && ( +
+

📚 相关文章

+
+ {related.map((r) => ( + + {r.emoji} +

{r.title}

+

{r.excerpt}

+ 阅读 → + + ))} +
+
+ )}
); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 23f90d2..9e714c8 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4788,3 +4788,1010 @@ img { max-width: 100%; display: block; } to { opacity: 1; transform: translateY(0); } } +/* ===== Visa Wizard ===== */ +.visa-wizard { + margin-top: 48px; + padding: 32px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-xl); + backdrop-filter: blur(20px); +} + +.visa-wizard-header { + text-align: center; + margin-bottom: 28px; +} + +.visa-wizard-header h3 { + font-size: 1.5rem; + margin: 12px 0 8px; +} + +.visa-wizard-header p { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.visa-wizard-steps { + display: flex; + justify-content: center; + gap: 32px; + margin-bottom: 28px; +} + +.wizard-step-dot { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + opacity: 0.4; + transition: opacity 0.3s; +} + +.wizard-step-dot span { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.06); + border: 2px solid rgba(255, 255, 255, 0.1); + font-weight: 600; + font-size: 0.85rem; +} + +.wizard-step-dot small { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.wizard-step-dot.active { opacity: 1; } +.wizard-step-dot.active span { + border-color: var(--accent-3); + background: rgba(78, 205, 196, 0.15); + color: var(--accent-3); +} + +.wizard-step-dot.done span { + background: var(--accent-3); + color: #0a0e17; + border-color: var(--accent-3); +} + +.wizard-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.wizard-option { + padding: 16px 20px; + 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); + font-size: 0.9rem; + cursor: pointer; + transition: all 0.25s; + text-align: left; +} + +.wizard-option:hover { + border-color: rgba(78, 205, 196, 0.4); + background: rgba(78, 205, 196, 0.06); +} + +.wizard-option.selected { + border-color: var(--accent-3); + background: rgba(78, 205, 196, 0.12); + box-shadow: 0 0 20px rgba(78, 205, 196, 0.15); +} + +.wizard-nav { + display: flex; + justify-content: center; + gap: 12px; +} + +.wizard-result-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; + margin-bottom: 20px; +} + +.wizard-result-card { + position: relative; + padding: 20px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-lg); + transition: transform 0.3s; +} + +.wizard-result-card.winner { + border-color: var(--accent-2); + background: rgba(255, 230, 109, 0.06); +} + +.wizard-winner-badge { + position: absolute; + top: -10px; + right: 12px; + font-size: 0.75rem; + padding: 4px 10px; + background: linear-gradient(135deg, #FFE66D, #FF6B6B); + color: #0a0e17; + border-radius: 20px; + font-weight: 600; +} + +.wizard-result-top { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.wizard-result-flag { font-size: 2rem; } + +.wizard-result-card h4 { + font-size: 1rem; + margin-bottom: 4px; +} + +.wizard-match-score { + font-size: 0.8rem; + color: var(--accent-3); + font-weight: 600; +} + +.wizard-result-card ul { + list-style: none; + padding: 0; + margin: 0; +} + +.wizard-result-card li { + font-size: 0.85rem; + color: var(--text-secondary); + padding: 4px 0; +} + +.wizard-empty { + text-align: center; + color: var(--text-secondary); + padding: 20px; +} + +/* ===== Coworking Guide ===== */ +.coworking-section { + background: linear-gradient(180deg, transparent, rgba(78, 205, 196, 0.03)); +} + +.coworking-controls { + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: space-between; + align-items: center; + margin-bottom: 28px; +} + +.coworking-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.coworking-sort { + padding: 8px 14px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.85rem; + cursor: pointer; +} + +.coworking-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; +} + +.coworking-card { + padding: 24px; + 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; +} + +.coworking-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-card); +} + +.coworking-card-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.coworking-emoji { font-size: 1.8rem; } + +.coworking-rating { + display: flex; + flex-direction: column; + align-items: flex-end; + font-size: 0.8rem; + gap: 2px; +} + +.coworking-wifi { color: var(--accent-3); } + +.coworking-card h3 { + font-size: 1.15rem; + margin-bottom: 4px; +} + +.coworking-city { + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 10px; +} + +.coworking-price { + font-size: 1.1rem; + font-weight: 700; + color: var(--accent-2); + margin-bottom: 12px; +} + +.coworking-perks { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 14px; +} + +.coworking-perk { + font-size: 0.75rem; + padding: 4px 10px; + background: rgba(255, 255, 255, 0.05); + border-radius: 20px; + color: var(--text-secondary); +} + +.coworking-link { + font-size: 0.85rem; + color: var(--accent-3); + font-weight: 500; +} + +/* ===== FAQ Toolbar ===== */ +.faq-toolbar { + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.faq-search-wrap { + position: relative; + flex: 1; + min-width: 240px; + max-width: 480px; +} + +.faq-search-icon { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + font-size: 0.9rem; + pointer-events: none; +} + +.faq-search { + width: 100%; + padding: 12px 40px 12px 40px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.9rem; +} + +.faq-search:focus { + outline: none; + border-color: rgba(78, 205, 196, 0.5); +} + +.faq-search-clear { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px 8px; +} + +.faq-toolbar-actions { + display: flex; + gap: 8px; +} + +.btn-sm { + padding: 8px 14px !important; + font-size: 0.8rem !important; +} + +.faq-empty { + text-align: center; + color: var(--text-secondary); + padding: 32px; +} + +/* ===== Blog Filters ===== */ +.blog-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + margin-bottom: 28px; +} + +/* ===== Share Button ===== */ +.share-dropdown { + position: relative; +} + +.share-trigger { font-size: 0.85rem; } + +.share-backdrop { + position: fixed; + inset: 0; + z-index: 100; +} + +.share-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 101; + min-width: 160px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-md); + backdrop-filter: blur(20px); + box-shadow: var(--shadow-card); + overflow: hidden; + animation: toastIn 0.2s ease; +} + +.share-menu button { + display: block; + width: 100%; + padding: 12px 16px; + background: none; + border: none; + color: var(--text-primary); + font-size: 0.85rem; + text-align: left; + cursor: pointer; + transition: background 0.2s; +} + +.share-menu button:hover { + background: rgba(78, 205, 196, 0.1); +} + +.share-btn-compact { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md); + padding: 8px 12px; + font-size: 1rem; + cursor: pointer; + transition: all 0.2s; +} + +.share-btn-compact:hover { + background: rgba(78, 205, 196, 0.12); + border-color: rgba(78, 205, 196, 0.3); +} + +/* ===== Blog Related ===== */ +.blog-related { + margin-top: 48px; + padding-top: 32px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.blog-related h2 { + font-size: 1.3rem; + margin-bottom: 20px; +} + +.blog-related-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} + +.blog-related-card { + padding: 20px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-lg); + transition: transform 0.3s; +} + +.blog-related-card:hover { + transform: translateY(-3px); +} + +.blog-related-emoji { font-size: 2rem; display: block; margin-bottom: 8px; } + +.blog-related-card h3 { + font-size: 1rem; + margin-bottom: 8px; +} + +.blog-related-card p { + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 10px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.detail-nav { + display: flex; + justify-content: space-between; + align-items: center; +} + +/* ===== Profile Badges ===== */ +.profile-badges { + margin: 32px 0; +} + +.profile-badges h2 { + font-size: 1.2rem; + margin-bottom: 16px; +} + +.badge-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; +} + +.badge-card { + padding: 16px; + text-align: center; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-lg); + transition: transform 0.3s; +} + +.badge-card.earned { + border-color: rgba(255, 230, 109, 0.3); + background: rgba(255, 230, 109, 0.05); +} + +.badge-card.locked { + opacity: 0.45; + filter: grayscale(0.5); +} + +.badge-card.earned:hover { + transform: scale(1.04); +} + +.badge-emoji { + font-size: 2rem; + display: block; + margin-bottom: 6px; +} + +.badge-card strong { + display: block; + font-size: 0.85rem; + margin-bottom: 4px; +} + +.badge-card small { + font-size: 0.7rem; + color: var(--text-secondary); +} + +/* ===== Auth Enhancements ===== */ +.auth-bg-shapes { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; +} + +.auth-shape { + position: absolute; + font-size: 3rem; + opacity: 0.08; + animation: authFloat 8s ease-in-out infinite; +} + +.auth-shape-1 { top: 15%; left: 10%; animation-delay: 0s; } +.auth-shape-2 { top: 60%; right: 15%; animation-delay: -3s; font-size: 2.5rem; } +.auth-shape-3 { bottom: 20%; left: 30%; animation-delay: -5s; font-size: 2rem; } + +@keyframes authFloat { + 0%, 100% { transform: translateY(0) rotate(0deg); } + 50% { transform: translateY(-20px) rotate(5deg); } +} + +.auth-logo-emoji { + font-size: 3rem; + display: block; + animation: authPulse 2s ease-in-out infinite; +} + +@keyframes authPulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.08); } +} + +.auth-pwd-wrap { + position: relative; +} + +.auth-pwd-wrap input { + width: 100%; + padding-right: 44px; +} + +.auth-pwd-toggle { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + padding: 4px; +} + +.auth-submit { + width: 100%; +} + +.auth-loading { + display: inline-block; + animation: authPulse 1s ease-in-out infinite; +} + +/* ===== 404 Enhancements ===== */ +.not-found-compass { + position: relative; + width: 120px; + height: 120px; + margin: 0 auto 24px; +} + +.compass-ring { + font-size: 5rem; + display: block; + animation: compassSpin 6s linear infinite; +} + +.compass-plane { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 1.8rem; + animation: planeOrbit 4s ease-in-out infinite; +} + +@keyframes compassSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes planeOrbit { + 0%, 100% { transform: translate(-50%, -50%) translateX(30px); } + 50% { transform: translate(-50%, -50%) translateX(-30px); } +} + +.not-found-title { + font-size: 5rem; + background: linear-gradient(135deg, var(--accent-1), var(--accent-2), var(--accent-3)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.not-found-sub { + color: var(--text-secondary); + margin-bottom: 24px; +} + +.not-found-actions { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + +@media (max-width: 768px) { + .visa-wizard { padding: 20px; } + .visa-wizard-steps { gap: 16px; } + .wizard-step-dot small { display: none; } + .coworking-controls { flex-direction: column; align-items: stretch; } + .faq-toolbar { flex-direction: column; } + .faq-search-wrap { max-width: 100%; } + .badge-grid { grid-template-columns: repeat(3, 1fr); } + .season-grid { grid-template-columns: repeat(3, 1fr); } + .packing-filters { flex-direction: column; align-items: stretch; } + .nomad-tips { left: 12px; right: 12px; bottom: 80px; max-width: none; } +} + +/* ===== Packing Checklist ===== */ +.packing-card { + max-width: 720px; + margin: 0 auto; + padding: 28px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-xl); + backdrop-filter: blur(20px); +} + +.packing-progress { margin-bottom: 24px; } + +.packing-progress-info { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + font-size: 0.9rem; +} + +.packing-progress-info strong { font-size: 1.4rem; } +.packing-count { margin-left: auto; color: var(--text-secondary); } + +.packing-bar { + height: 8px; + background: rgba(255, 255, 255, 0.06); + border-radius: 4px; + overflow: hidden; +} + +.packing-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-1), var(--accent-3)); + border-radius: 4px; + transition: width 0.4s ease; +} + +.packing-ready { + margin-top: 10px; + color: var(--accent-3); + font-weight: 600; + text-align: center; +} + +.packing-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 20px; +} + +.packing-actions { + margin-left: auto; + display: flex; + gap: 6px; +} + +.packing-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.packing-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.25s; +} + +.packing-item:hover { + border-color: rgba(78, 205, 196, 0.3); + background: rgba(78, 205, 196, 0.05); +} + +.packing-item.checked { + opacity: 0.65; + border-color: rgba(78, 205, 196, 0.35); +} + +.packing-item.checked .packing-label { + text-decoration: line-through; + color: var(--text-secondary); +} + +.packing-item input { display: none; } + +.packing-check { + width: 22px; + height: 22px; + border-radius: 6px; + border: 2px solid rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + color: #0a0e17; + flex-shrink: 0; + transition: all 0.2s; +} + +.packing-item.checked .packing-check { + background: var(--accent-3); + border-color: var(--accent-3); +} + +.packing-emoji { font-size: 1.2rem; } +.packing-label { font-size: 0.9rem; } + +/* ===== Season Guide ===== */ +.season-card { + padding: 28px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-xl); + backdrop-filter: blur(20px); +} + +.season-cities { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 24px; + justify-content: center; +} + +.season-city-btn { + padding: 8px 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 20px; + color: var(--text-primary); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.25s; +} + +.season-city-btn:hover, +.season-city-btn.active { + border-color: var(--accent-3); + background: rgba(78, 205, 196, 0.12); +} + +.season-header-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.season-header-row h3 { font-size: 1.3rem; margin-bottom: 4px; } +.season-header-row p { color: var(--text-secondary); font-size: 0.9rem; } + +.season-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 10px; + margin-bottom: 20px; +} + +.season-month { + position: relative; + padding: 14px 8px; + text-align: center; + background: rgba(255, 255, 255, 0.03); + border: 1px solid color-mix(in srgb, var(--season-color) 40%, transparent); + border-radius: var(--radius-md); + border-top: 3px solid var(--season-color); + transition: transform 0.2s; +} + +.season-month:hover { transform: translateY(-2px); } + +.season-month.now { + box-shadow: 0 0 16px color-mix(in srgb, var(--season-color) 30%, transparent); +} + +.season-month-emoji { display: block; font-size: 1.2rem; margin-bottom: 4px; } +.season-month strong { display: block; font-size: 0.9rem; } +.season-month small { color: var(--text-secondary); font-size: 0.7rem; } + +.season-now-tag { + position: absolute; + top: -8px; + right: -4px; + font-size: 0.65rem; + padding: 2px 6px; + background: var(--accent-1); + color: #fff; + border-radius: 8px; + font-weight: 600; +} + +.season-legend { + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: center; +} + +.season-legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.season-legend-item i { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} + +/* ===== Testimonial Carousel ===== */ +.testimonial-carousel { + max-width: 680px; + margin: 0 auto 40px; + text-align: center; +} + +.testimonial-featured { + padding: 32px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-xl); + animation: toastIn 0.4s ease; +} + +.testimonial-featured .testimonial-avatar { + font-size: 3rem; + margin-bottom: 12px; +} + +.testimonial-featured p { + font-size: 1.1rem; + line-height: 1.7; + margin: 12px 0 16px; +} + +.testimonial-dots { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 16px; +} + +.testimonial-dot { + width: 8px; + height: 8px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.2); + cursor: pointer; + padding: 0; + transition: all 0.3s; +} + +.testimonial-dot.active { + width: 24px; + border-radius: 4px; + background: var(--accent-3); +} + +/* ===== Nomad Tips ===== */ +.nomad-tips { + position: fixed; + bottom: 100px; + left: 24px; + z-index: 8500; + max-width: 340px; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px 18px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-lg); + backdrop-filter: blur(20px); + box-shadow: var(--shadow-card); + animation: toastIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.nomad-tips-emoji { font-size: 1.5rem; flex-shrink: 0; } + +.nomad-tips-body strong { + display: block; + font-size: 0.8rem; + color: var(--accent-3); + margin-bottom: 4px; +} + +.nomad-tips-body p { + font-size: 0.85rem; + color: var(--text-secondary); + margin: 0; + line-height: 1.45; + animation: toastIn 0.3s ease; +} + +.nomad-tips-close { + position: absolute; + top: 8px; + right: 10px; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.85rem; + padding: 2px 6px; +} + +.nomad-tips-dots { + position: absolute; + bottom: 8px; + right: 16px; + display: flex; + gap: 4px; +} + +.nomad-tips-dots span { + width: 5px; + height: 5px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); +} + +.nomad-tips-dots span.active { background: var(--accent-3); } + diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index bf3283c..8896e43 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -2,15 +2,18 @@ import { useState } from "react"; import { useAuth } from "@/lib/auth"; +import { useToast } from "@/lib/toast"; import Link from "next/link"; import SiteShell from "@/components/SiteShell"; export default function LoginPage() { const { login, register, demoLogin } = useAuth(); + const { toast } = useToast(); const [mode, setMode] = useState<"login" | "register">("login"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [name, setName] = useState(""); + const [showPwd, setShowPwd] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -22,31 +25,43 @@ export default function LoginPage() { ? await login(email, password) : await register(email, password, name); setLoading(false); - if (ok) window.location.href = "/"; - else setError(mode === "login" ? "邮箱或密码错误" : "注册失败,邮箱可能已存在"); + if (ok) { + toast(mode === "login" ? "欢迎回来!🌍" : "注册成功,开启旅居之旅 ✨"); + setTimeout(() => { window.location.href = "/"; }, 600); + } else { + setError(mode === "login" ? "邮箱或密码错误" : "注册失败,邮箱可能已存在"); + } }; const handleDemo = async () => { setLoading(true); const ok = await demoLogin(); setLoading(false); - if (ok) window.location.href = "/"; + if (ok) { + toast("演示账号已登录 🎮"); + setTimeout(() => { window.location.href = "/"; }, 600); + } }; return (
+
← 返回首页
- 🌍 + 🌍

{mode === "login" ? "欢迎回来" : "加入 NomadFlow"}

{mode === "login" ? "登录你的游民账户" : "开始你的旅居之旅"}

- - + +
@@ -62,11 +77,23 @@ export default function LoginPage() {
- setPassword(e.target.value)} placeholder="至少 6 位" required minLength={6} /> +
+ setPassword(e.target.value)} + placeholder="至少 6 位" + required + minLength={6} + /> + +
{error &&

{error}

} - diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx index d087ae0..4c63d61 100644 --- a/frontend/src/app/not-found.tsx +++ b/frontend/src/app/not-found.tsx @@ -5,10 +5,17 @@ export default function NotFound() { return (
- 🧭 -

404

+ +

404

哎呀,这片海域没有标记的岛屿

- 返回首页 🏠 +

你可能走错了航线,让我们帮你回到正轨

+
+ 返回首页 🏠 + 探索目的地 🌍 +
); diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index cc9285b..5f31856 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth"; @@ -11,6 +11,25 @@ import FavoriteButton from "@/components/FavoriteButton"; const TRIP_KEY = "nomadflow-trip"; +interface Badge { + emoji: string; + label: string; + desc: string; + earned: boolean; +} + +function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: number): Badge[] { + const tripMonths = trip.reduce((s, t) => s + t.months, 0); + return [ + { emoji: "🌱", label: "初出茅庐", desc: "加入 NomadFlow", earned: true }, + { emoji: "❤️", label: "收藏家", desc: "收藏 3+ 目的地", earned: favCount >= 3 }, + { emoji: "🗺️", label: "行程达人", desc: "规划 3+ 城市行程", earned: trip.length >= 3 }, + { emoji: "📅", label: "长期旅居", desc: "行程总计 6+ 个月", earned: tripMonths >= 6 }, + { emoji: "🌍", label: "环球游民", desc: "探索 5+ 城市", earned: (stats?.destinations_explored ?? 0) >= 5 }, + { emoji: "👑", label: "资深游民", desc: "2024 年前加入", earned: parseInt(stats?.member_since ?? "2026") <= 2024 }, + ]; +} + export default function ProfilePage() { const { user, token, favorites, logout } = useAuth(); const router = useRouter(); @@ -37,6 +56,9 @@ export default function ProfilePage() { if (saved) setTrip(JSON.parse(saved)); }, [token, favorites, router]); + const badges = useMemo(() => getBadges(stats, trip, favoriteDests.length), [stats, trip, favoriteDests.length]); + const earnedCount = badges.filter((b) => b.earned).length; + if (!user) return null; return ( @@ -72,12 +94,26 @@ export default function ProfilePage() { 加入年份
- 🧮 - 费用计算器 → + 🏅 + {earnedCount}/{badges.length} + 成就徽章
)} +
+

🏅 游民成就

+
+ {badges.map((b) => ( +
+ {b.earned ? b.emoji : "🔒"} + {b.label} + {b.desc} +
+ ))} +
+
+

❤️ 我的收藏

{loading ? ( @@ -138,6 +174,7 @@ export default function ProfilePage() {
🌍 浏览目的地 📋 签证指南 + 💻 联合办公 📝 阅读博客 🗓️ 行程规划 📊 就绪度测评 diff --git a/frontend/src/components/BlogSection.tsx b/frontend/src/components/BlogSection.tsx index 1794510..190121f 100644 --- a/frontend/src/components/BlogSection.tsx +++ b/frontend/src/components/BlogSection.tsx @@ -1,7 +1,23 @@ +"use client"; + +import { useMemo, useState } from "react"; import Link from "next/link"; import type { BlogPost } from "@/lib/types"; export default function BlogSection({ posts }: { posts: BlogPost[] }) { + const allTags = useMemo(() => { + const tags = new Set(); + posts.forEach((p) => p.tags.forEach((t) => tags.add(t))); + return Array.from(tags); + }, [posts]); + + const [activeTag, setActiveTag] = useState("all"); + + const filtered = useMemo(() => { + if (activeTag === "all") return posts; + return posts.filter((p) => p.tags.includes(activeTag)); + }, [posts, activeTag]); + return (
@@ -10,8 +26,22 @@ export default function BlogSection({ posts }: { posts: BlogPost[] }) {

游民博客

深度攻略、签证指南与旅居经验分享

+ + {allTags.length > 0 && ( +
+ + {allTags.map((t) => ( + + ))} +
+ )} +
- {posts.map((post) => ( + {filtered.map((post) => (
{post.emoji}
@@ -27,6 +57,9 @@ export default function BlogSection({ posts }: { posts: BlogPost[] }) { ))}
+ {filtered.length === 0 && ( +

该分类暂无文章

+ )}
); diff --git a/frontend/src/components/Community.tsx b/frontend/src/components/Community.tsx index 5e2e01c..002493c 100644 --- a/frontend/src/components/Community.tsx +++ b/frontend/src/components/Community.tsx @@ -1,11 +1,20 @@ "use client"; +import { useEffect, useState } from "react"; import { useToast } from "@/lib/toast"; import { api } from "@/lib/api"; import type { Testimonial } from "@/lib/types"; export default function Community({ testimonials }: { testimonials: Testimonial[] }) { const { toast } = useToast(); + const [active, setActive] = useState(0); + const [paused, setPaused] = useState(false); + + useEffect(() => { + if (testimonials.length <= 1 || paused) return; + const t = setInterval(() => setActive((i) => (i + 1) % testimonials.length), 4500); + return () => clearInterval(t); + }, [testimonials.length, paused]); const handleSubscribe = async (e: React.FormEvent) => { e.preventDefault(); @@ -18,6 +27,8 @@ export default function Community({ testimonials }: { testimonials: Testimonial[ } catch { /* ignore */ } }; + const t = testimonials[active]; + return ( <>
@@ -27,9 +38,14 @@ export default function Community({ testimonials }: { testimonials: Testimonial[

游民心声

来自全球数字游民的真实故事

-
- {testimonials.map((t) => ( -
+ + {t && ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + > +
{t.avatar}
{"⭐".repeat(t.rating)}
@@ -40,8 +56,35 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
+
+ {testimonials.map((item, i) => ( +
+
+ )} + +
+ {testimonials.map((item) => ( +
+
{item.avatar}
+
+
{"⭐".repeat(item.rating)}
+

“{item.content}”

+
+ {item.author} + {item.role} +
+
+
))}
+

准备好开始你的旅居之旅了吗? 🌍

diff --git a/frontend/src/components/CoworkingGuide.tsx b/frontend/src/components/CoworkingGuide.tsx new file mode 100644 index 0000000..3c6803e --- /dev/null +++ b/frontend/src/components/CoworkingGuide.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { COWORKING_SPACES } from "@/lib/coworking"; + +const CITY_FILTERS = [ + { key: "all", label: "🌏 全部" }, + { key: "bali", label: "🏝️ 巴厘岛" }, + { key: "chiangmai", label: "🏔️ 清迈" }, + { key: "lisbon", label: "🌊 里斯本" }, + { key: "barcelona", label: "🏖️ 巴塞罗那" }, + { key: "mexico", label: "🌃 墨西哥城" }, + { key: "tokyo", label: "🗼 东京" }, +]; + +export default function CoworkingGuide() { + const [filter, setFilter] = useState("all"); + const [sort, setSort] = useState<"rating" | "wifi" | "price">("rating"); + + const filtered = useMemo(() => { + let list = filter === "all" ? [...COWORKING_SPACES] : COWORKING_SPACES.filter((s) => s.slug === filter); + if (sort === "wifi") list.sort((a, b) => b.wifi - a.wifi); + else if (sort === "price") list.sort((a, b) => parseInt(a.price.replace(/[^0-9]/g, "")) - parseInt(b.price.replace(/[^0-9]/g, ""))); + else list.sort((a, b) => b.rating - a.rating); + return list; + }, [filter, sort]); + + return ( +
+
+
+ 💻 COWORKING +

联合办公空间指南

+

精选全球游民热门城市的 Co-working Space,高速 WiFi 与社区氛围兼备

+
+ +
+
+ {CITY_FILTERS.map((f) => ( + + ))} +
+ +
+ +
+ {filtered.map((space, i) => ( +
+
+ {space.emoji} +
+ ⭐ {space.rating} + 📶 {space.wifi}Mbps +
+
+

{space.name}

+

{space.city} · {space.vibe}

+
{space.price}
+
+ {space.perks.map((p) => {p})} +
+ + 查看 {space.city} 详情 → + +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/FAQSection.tsx b/frontend/src/components/FAQSection.tsx index d79d952..8f28faf 100644 --- a/frontend/src/components/FAQSection.tsx +++ b/frontend/src/components/FAQSection.tsx @@ -1,11 +1,24 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import type { FAQ } from "@/lib/types"; export default function FAQSection({ faqs }: { faqs: FAQ[] }) { const [active, setActive] = useState(null); - const sorted = [...faqs].sort((a, b) => a.order - b.order); + const [search, setSearch] = useState(""); + + const sorted = useMemo(() => [...faqs].sort((a, b) => a.order - b.order), [faqs]); + + const filtered = useMemo(() => { + if (!search.trim()) return sorted; + const q = search.toLowerCase(); + return sorted.filter((f) => f.question.toLowerCase().includes(q) || f.answer.toLowerCase().includes(q)); + }, [sorted, search]); + + const expandAll = () => setActive("__all__"); + const collapseAll = () => setActive(null); + + const isOpen = (id: string) => active === "__all__" || active === id; return (
@@ -15,9 +28,27 @@ export default function FAQSection({ faqs }: { faqs: FAQ[] }) {

常见问题

关于数字游民生活,你可能想知道的一切

+ +
+
+ 🔍 + setSearch(e.target.value)} + /> + {search && } +
+
+ + +
+
+
- {sorted.map((f) => ( -
+ {filtered.map((f) => ( +
))} + {filtered.length === 0 && ( +

没有找到相关问题,试试其他关键词 🔎

+ )}
diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx index 63b1194..6b52c84 100644 --- a/frontend/src/components/HomeClient.tsx +++ b/frontend/src/components/HomeClient.tsx @@ -30,6 +30,10 @@ import CurrencyConverter from "./CurrencyConverter"; import NomadScore from "./NomadScore"; import CostCompare from "./CostCompare"; import CookieConsent from "./CookieConsent"; +import CoworkingGuide from "./CoworkingGuide"; +import PackingChecklist from "./PackingChecklist"; +import SeasonGuide from "./SeasonGuide"; +import NomadTips from "./NomadTips"; import KeyboardShortcuts from "./KeyboardShortcuts"; import BackToTop from "./BackToTop"; @@ -108,6 +112,9 @@ export default function HomeClient(props: Props) { + + + @@ -119,6 +126,7 @@ export default function HomeClient(props: Props) { setMatcherOpen(false)} /> setMatcherOpen(true)} /> + {compareList.length > 0 && (
diff --git a/frontend/src/components/NomadTips.tsx b/frontend/src/components/NomadTips.tsx new file mode 100644 index 0000000..1579cf2 --- /dev/null +++ b/frontend/src/components/NomadTips.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const TIPS = [ + { emoji: "📶", text: "入住前用 Speedtest 测网速,低于 20Mbps 慎重签约" }, + { emoji: "💳", text: "准备一张无外汇手续费的信用卡,省下不少换汇成本" }, + { emoji: "🏥", text: "出发前购买 SafetyWing 等国际医疗保险,月费约 $40+" }, + { emoji: "🕐", text: "选时区重叠 ≥4 小时的城市,远程协作更轻松" }, + { emoji: "🛂", text: "护照有效期建议预留 6 个月以上,避免登机被拒" }, + { emoji: "☕", text: "先订 1 周 Airbnb 探路,再决定长期租房" }, +]; + +export default function NomadTips() { + const [idx, setIdx] = useState(0); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const show = setTimeout(() => setVisible(true), 8000); + return () => clearTimeout(show); + }, []); + + useEffect(() => { + if (!visible) return; + const t = setInterval(() => setIdx((i) => (i + 1) % TIPS.length), 6000); + return () => clearInterval(t); + }, [visible]); + + if (!visible) return null; + + const tip = TIPS[idx]; + + return ( +
+ + {tip.emoji} +
+ 游民小贴士 +

{tip.text}

+
+
+ {TIPS.map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/PackingChecklist.tsx b/frontend/src/components/PackingChecklist.tsx new file mode 100644 index 0000000..4c2c82e --- /dev/null +++ b/frontend/src/components/PackingChecklist.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +interface PackItem { + id: string; + emoji: string; + label: string; + category: string; +} + +const ITEMS: PackItem[] = [ + { id: "laptop", emoji: "💻", label: "笔记本电脑 + 充电器", category: "tech" }, + { id: "adapter", emoji: "🔌", label: "全球旅行转接插头", category: "tech" }, + { id: "headset", emoji: "🎧", label: "降噪耳机", category: "tech" }, + { id: "powerbank", emoji: "🔋", label: "移动电源", category: "tech" }, + { id: "sim", emoji: "📱", label: "eSIM / 本地 SIM 卡", category: "tech" }, + { id: "vpn", emoji: "🔐", label: "VPN 订阅已开通", category: "tech" }, + { id: "passport", emoji: "🛂", label: "护照(有效期 > 6 个月)", category: "docs" }, + { id: "visa", emoji: "📋", label: "签证 / 电子签确认", category: "docs" }, + { id: "insurance", emoji: "🏥", label: "国际旅行保险", category: "docs" }, + { id: "cards", emoji: "💳", label: "银行卡 + 备用卡", category: "docs" }, + { id: "vaccine", emoji: "💉", label: "疫苗证明(如需要)", category: "docs" }, + { id: "clothes", emoji: "👕", label: "轻便衣物 7 天量", category: "daily" }, + { id: "shoes", emoji: "👟", label: "舒适步行鞋", category: "daily" }, + { id: "medicine", emoji: "💊", label: "常用药品", category: "daily" }, + { id: "toiletry", emoji: "🧴", label: "洗漱用品(旅行装)", category: "daily" }, + { id: "umbrella", emoji: "☂️", label: "折叠伞 / 防晒", category: "daily" }, +]; + +const CATEGORIES = [ + { key: "all", label: "📦 全部" }, + { key: "tech", label: "💻 数码" }, + { key: "docs", label: "📄 证件" }, + { key: "daily", label: "🧳 日常" }, +]; + +const STORAGE_KEY = "nomadflow-packing"; + +export default function PackingChecklist() { + const [checked, setChecked] = useState>({}); + const [filter, setFilter] = useState("all"); + const [ready, setReady] = useState(false); + + useEffect(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) setChecked(JSON.parse(saved)); + } catch { /* ignore */ } + setReady(true); + }, []); + + useEffect(() => { + if (!ready) return; + localStorage.setItem(STORAGE_KEY, JSON.stringify(checked)); + }, [checked, ready]); + + const filtered = useMemo( + () => (filter === "all" ? ITEMS : ITEMS.filter((i) => i.category === filter)), + [filter] + ); + + const done = Object.values(checked).filter(Boolean).length; + const total = ITEMS.length; + const pct = Math.round((done / total) * 100); + + const toggle = (id: string) => setChecked((prev) => ({ ...prev, [id]: !prev[id] })); + const reset = () => setChecked({}); + const checkAll = () => { + const all: Record = {}; + ITEMS.forEach((i) => { all[i.id] = true; }); + setChecked(all); + }; + + return ( +
+
+
+ 🧳 PACKING +

数字游民行李清单

+

出发前逐项勾选,确保远程工作与旅居无忧

+
+ +
+
+
+ 完成度 + {pct}% + {done}/{total} +
+
+
+
+ {pct === 100 &&

🎉 全部就绪,可以出发了!

} +
+ +
+ {CATEGORIES.map((c) => ( + + ))} +
+ + +
+
+ +
    + {filtered.map((item) => ( +
  • + +
  • + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/SeasonGuide.tsx b/frontend/src/components/SeasonGuide.tsx new file mode 100644 index 0000000..7f97e40 --- /dev/null +++ b/frontend/src/components/SeasonGuide.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useMemo, useState, type CSSProperties } from "react"; +import Link from "next/link"; +import type { Destination } from "@/lib/types"; + +const MONTHS = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"]; + +/** 0=避开 1=一般 2=推荐 3=最佳 */ +const SEASON_MAP: Record = { + bali: [2, 2, 3, 3, 3, 2, 1, 1, 2, 3, 3, 2], + lisbon: [1, 1, 2, 3, 3, 3, 2, 2, 3, 3, 2, 1], + chiangmai: [3, 3, 2, 1, 0, 0, 0, 0, 1, 2, 3, 3], + mexico: [2, 2, 3, 3, 2, 1, 0, 0, 1, 2, 3, 3], + barcelona: [1, 1, 2, 3, 3, 3, 2, 2, 3, 3, 2, 1], + tokyo: [2, 2, 3, 3, 2, 1, 0, 0, 2, 3, 3, 2], +}; + +const LEVEL = [ + { label: "避开", color: "#FF6B6B", emoji: "🌧️" }, + { label: "一般", color: "#FFE66D", emoji: "☁️" }, + { label: "推荐", color: "#4ECDC4", emoji: "☀️" }, + { label: "最佳", color: "#6ee7b7", emoji: "🌟" }, +]; + +export default function SeasonGuide({ destinations }: { destinations: Destination[] }) { + const [slug, setSlug] = useState(destinations[0]?.slug || "bali"); + const nowMonth = new Date().getMonth(); + + const scores = SEASON_MAP[slug] || SEASON_MAP.bali; + const dest = destinations.find((d) => d.slug === slug); + + const bestMonths = useMemo( + () => scores.map((s, i) => ({ i, s })).filter((x) => x.s === 3).map((x) => MONTHS[x.i]), + [scores] + ); + + return ( +
+
+
+ 🌤️ SEASON +

最佳旅居月份

+

避开雨季与旺季,选对时间开启旅居生活

+
+ +
+
+ {destinations.map((d) => ( + + ))} +
+ +
+
+

{dest?.emoji} {dest?.name}

+

最佳月份:{bestMonths.join(" · ") || "全年适宜"}

+
+ 查看详情 → +
+ +
+ {MONTHS.map((m, i) => { + const level = scores[i] ?? 1; + const info = LEVEL[level]; + const isNow = i === nowMonth; + return ( +
+ {info.emoji} + {m} + {info.label} + {isNow && 本月} +
+ ); + })} +
+ +
+ {LEVEL.map((l) => ( + + {l.emoji} {l.label} + + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/SectionNav.tsx b/frontend/src/components/SectionNav.tsx index c5c35ae..ce6d46c 100644 --- a/frontend/src/components/SectionNav.tsx +++ b/frontend/src/components/SectionNav.tsx @@ -11,6 +11,9 @@ const SECTIONS = [ { id: "cost-compare", emoji: "💰", label: "省钱" }, { id: "lifestyle", emoji: "💻", label: "生活" }, { id: "visa", emoji: "📋", label: "签证" }, + { id: "coworking", emoji: "💻", label: "办公" }, + { id: "season", emoji: "🌤️", label: "季节" }, + { id: "packing", emoji: "🧳", label: "行李" }, { id: "blog", emoji: "📝", label: "博客" }, ]; diff --git a/frontend/src/components/ShareButton.tsx b/frontend/src/components/ShareButton.tsx new file mode 100644 index 0000000..2a16491 --- /dev/null +++ b/frontend/src/components/ShareButton.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useState } from "react"; +import { useToast } from "@/lib/toast"; + +interface Props { + title: string; + url?: string; + className?: string; + compact?: boolean; +} + +export default function ShareButton({ title, url, className = "", compact = false }: Props) { + const { toast } = useToast(); + const [open, setOpen] = useState(false); + + const getUrl = () => url ?? (typeof window !== "undefined" ? window.location.href : ""); + + const copyLink = async () => { + await navigator.clipboard.writeText(getUrl()); + toast("链接已复制到剪贴板 📋"); + setOpen(false); + }; + + const nativeShare = async () => { + const shareUrl = getUrl(); + if (navigator.share) { + await navigator.share({ title, url: shareUrl }); + setOpen(false); + } else { + await copyLink(); + } + }; + + const shareTwitter = () => { + const shareUrl = encodeURIComponent(getUrl()); + const text = encodeURIComponent(title); + window.open(`https://twitter.com/intent/tweet?text=${text}&url=${shareUrl}`, "_blank"); + setOpen(false); + }; + + if (compact) { + return ( + + ); + } + + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+ + + +
+ + )} +
+ ); +} diff --git a/frontend/src/components/VisaSection.tsx b/frontend/src/components/VisaSection.tsx index e1db6ec..23a8e73 100644 --- a/frontend/src/components/VisaSection.tsx +++ b/frontend/src/components/VisaSection.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import type { Visa } from "@/lib/types"; +import VisaWizard from "./VisaWizard"; const FILTERS = [ { key: "all", label: "🌏 全部" }, @@ -75,6 +76,7 @@ export default function VisaSection({ visas }: { visas: Visa[] }) { {filtered.length === 0 && (

暂无匹配的签证类型,试试其他筛选条件

)} +
); diff --git a/frontend/src/components/VisaWizard.tsx b/frontend/src/components/VisaWizard.tsx new file mode 100644 index 0000000..b413ab3 --- /dev/null +++ b/frontend/src/components/VisaWizard.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { Visa } from "@/lib/types"; + +interface Props { + visas: Visa[]; +} + +const INCOME_OPTIONS = [ + { key: "low", label: "💰 低于 ¥8,000/月", max: 8000 }, + { key: "mid", label: "💼 ¥8,000 – ¥20,000/月", max: 20000 }, + { key: "high", label: "🚀 ¥20,000 – ¥50,000/月", max: 50000 }, + { key: "premium", label: "👑 高于 ¥50,000/月", max: Infinity }, +]; + +const DURATION_OPTIONS = [ + { key: "short", label: "⏱️ 1-3 个月", months: 3 }, + { key: "medium", label: "📅 3-12 个月", months: 12 }, + { key: "long", label: "🏠 1 年以上", months: 24 }, +]; + +const REGION_OPTIONS = [ + { key: "any", label: "🌏 不限" }, + { key: "europe", label: "🏰 欧洲" }, + { key: "sea", label: "🌴 东南亚" }, + { key: "latam", label: "🌮 拉美" }, +]; + +const VISA_INCOME: Record = { + "1": 6000, "2": 17000, "3": 2000, "4": 47000, "5": 18000, "6": 28000, +}; + +const VISA_REGION: Record = { + "1": "europe", "2": "europe", "3": "sea", "4": "sea", "5": "latam", "6": "europe", +}; + +export default function VisaWizard({ visas }: Props) { + const [step, setStep] = useState(0); + const [income, setIncome] = useState(""); + const [duration, setDuration] = useState(""); + const [region, setRegion] = useState("any"); + const [showResults, setShowResults] = useState(false); + + const results = useMemo(() => { + if (!income || !duration) return []; + const incomeOpt = INCOME_OPTIONS.find((o) => o.key === income)!; + const durOpt = DURATION_OPTIONS.find((o) => o.key === duration)!; + + return visas + .map((v) => { + let score = 0; + const reqIncome = VISA_INCOME[v.id] ?? 15000; + const vRegion = VISA_REGION[v.id] ?? "any"; + + if (reqIncome <= incomeOpt.max) score += 40; + else if (reqIncome <= incomeOpt.max * 1.5) score += 20; + + if (v.difficulty <= 35) score += 25; + else if (v.difficulty <= 55) score += 15; + else score += 5; + + if (durOpt.months >= 12 && v.duration.includes("年")) score += 20; + else if (durOpt.months <= 3) score += v.duration.includes("天") ? 20 : 10; + else score += 15; + + if (region === "any" || vRegion === region) score += 15; + + return { visa: v, score: Math.min(score, 100) }; + }) + .filter((r) => r.score >= 30) + .sort((a, b) => b.score - a.score) + .slice(0, 3); + }, [visas, income, duration, region]); + + const canNext = step === 0 ? !!income : step === 1 ? !!duration : true; + + const handleFinish = () => { + setShowResults(true); + setStep(3); + }; + + const reset = () => { + setStep(0); + setIncome(""); + setDuration(""); + setRegion("any"); + setShowResults(false); + }; + + return ( +
+
+ 🧙 VISA WIZARD +

签证智能匹配

+

回答 3 个问题,找到最适合你的远程工作签证

+
+ + {!showResults ? ( +
+
+ {["月收入", "停留时长", "目的地"].map((label, i) => ( +
+ {i < step ? "✓" : i + 1} + {label} +
+ ))} +
+ + {step === 0 && ( +
+ {INCOME_OPTIONS.map((o) => ( + + ))} +
+ )} + + {step === 1 && ( +
+ {DURATION_OPTIONS.map((o) => ( + + ))} +
+ )} + + {step === 2 && ( +
+ {REGION_OPTIONS.map((o) => ( + + ))} +
+ )} + +
+ {step > 0 && } + {step < 2 ? ( + + ) : ( + + )} +
+
+ ) : ( +
+ {results.length === 0 ? ( +

暂无完全匹配的签证,建议咨询专业移民顾问或放宽条件重试

+ ) : ( +
+ {results.map(({ visa, score }, i) => ( +
+ {i === 0 && 🏆 最佳匹配} +
+ {visa.flag} +
+

{visa.name}

+ {score}% 匹配 +
+
+
    +
  • 💰 {visa.income_req}
  • +
  • 📅 {visa.duration}
  • +
  • ⏱️ {visa.approval_time}
  • +
+
+ ))} +
+ )} + +
+ )} +
+ ); +} diff --git a/frontend/src/lib/coworking.ts b/frontend/src/lib/coworking.ts new file mode 100644 index 0000000..7c7cb9d --- /dev/null +++ b/frontend/src/lib/coworking.ts @@ -0,0 +1,60 @@ +export interface CoworkingSpace { + id: string; + city: string; + slug: string; + emoji: string; + name: string; + price: string; + wifi: number; + rating: number; + perks: string[]; + vibe: string; +} + +export const COWORKING_SPACES: CoworkingSpace[] = [ + { + id: "1", city: "巴厘岛", slug: "bali", emoji: "🏝️", name: "Hubud", + price: "¥180/天", wifi: 95, rating: 4.8, + perks: ["稻田景观", "瑜伽课", "社区活动"], vibe: "自然疗愈", + }, + { + id: "2", city: "巴厘岛", slug: "bali", emoji: "🏝️", name: "Outpost", + price: "¥2,800/月", wifi: 98, rating: 4.9, + perks: ["泳池", "住宿套餐", "高速光纤"], vibe: "游民大本营", + }, + { + id: "3", city: "清迈", slug: "chiangmai", emoji: "🏔️", name: "Punspace", + price: "¥60/天", wifi: 92, rating: 4.7, + perks: ["夜市步行", "空调充足", "打印扫描"], vibe: "性价比之王", + }, + { + id: "4", city: "清迈", slug: "chiangmai", emoji: "🏔️", name: "CAMP", + price: "¥80/天", wifi: 100, rating: 4.8, + perks: ["商场内", "24h 开放", "美食广场"], vibe: "都市便利", + }, + { + id: "5", city: "里斯本", slug: "lisbon", emoji: "🌊", name: "Second Home", + price: "€25/天", wifi: 150, rating: 4.9, + perks: ["植物花园", "活动丰富", "设计感"], vibe: "创意空间", + }, + { + id: "6", city: "里斯本", slug: "lisbon", emoji: "🌊", name: "LACS", + price: "€200/月", wifi: 120, rating: 4.6, + perks: ["海滨位置", "会议室", "咖啡吧"], vibe: "海滨办公", + }, + { + id: "7", city: "巴塞罗那", slug: "barcelona", emoji: "🏖️", name: "Aticco", + price: "€22/天", wifi: 140, rating: 4.7, + perks: ["屋顶露台", "活动日历", "会员网络"], vibe: "地中海风情", + }, + { + id: "8", city: "墨西哥城", slug: "mexico", emoji: "🌃", name: "WeWork Reforma", + price: "$15/天", wifi: 110, rating: 4.5, + perks: ["市中心", "北美时区", "全球会员"], vibe: "国际连锁", + }, + { + id: "9", city: "东京", slug: "tokyo", emoji: "🗼", name: "WeWork 涩谷", + price: "¥3,500/天", wifi: 200, rating: 4.6, + perks: ["极致网速", "地铁直达", "静音舱"], vibe: "高效都市", + }, +];