Add visa wizard, coworking, packing, season guide, and UX polish
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8d018d34e2
commit
bf654f76e6
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
|
||||
|
||||
20
README.md
20
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 生产配置:
|
||||
|
||||
@ -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 (
|
||||
<SiteShell>
|
||||
<BlogReadingProgress />
|
||||
<div className="detail-page">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/#blog">← 返回博客</Link>
|
||||
<ShareButton title={post.title} compact />
|
||||
</nav>
|
||||
<article className="blog-detail">
|
||||
<div className="blog-detail-header">
|
||||
@ -38,6 +48,22 @@ export default async function BlogDetailPage({ params }: { params: Promise<{ slu
|
||||
<BlogMarkdown content={post.content} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="blog-related">
|
||||
<h2>📚 相关文章</h2>
|
||||
<div className="blog-related-grid">
|
||||
{related.map((r) => (
|
||||
<Link key={r.slug} href={`/blog/${r.slug}`} className="blog-related-card">
|
||||
<span className="blog-related-emoji">{r.emoji}</span>
|
||||
<h3>{r.title}</h3>
|
||||
<p>{r.excerpt}</p>
|
||||
<span className="blog-read-more">阅读 →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 (
|
||||
<SiteShell>
|
||||
<div className="auth-page">
|
||||
<div className="auth-bg-shapes" aria-hidden="true">
|
||||
<span className="auth-shape auth-shape-1">🌍</span>
|
||||
<span className="auth-shape auth-shape-2">✈️</span>
|
||||
<span className="auth-shape auth-shape-3">🏝️</span>
|
||||
</div>
|
||||
<div className="auth-card">
|
||||
<Link href="/" className="auth-back">← 返回首页</Link>
|
||||
<div className="auth-header">
|
||||
<span style={{ fontSize: "3rem" }}>🌍</span>
|
||||
<span className="auth-logo-emoji">🌍</span>
|
||||
<h1>{mode === "login" ? "欢迎回来" : "加入 NomadFlow"}</h1>
|
||||
<p>{mode === "login" ? "登录你的游民账户" : "开始你的旅居之旅"}</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-tabs">
|
||||
<button className={mode === "login" ? "active" : ""} onClick={() => setMode("login")}>登录</button>
|
||||
<button className={mode === "register" ? "active" : ""} onClick={() => setMode("register")}>注册</button>
|
||||
<button className={mode === "login" ? "active" : ""} onClick={() => { setMode("login"); setError(""); }}>登录</button>
|
||||
<button className={mode === "register" ? "active" : ""} onClick={() => { setMode("register"); setError(""); }}>注册</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
@ -62,11 +77,23 @@ export default function LoginPage() {
|
||||
</div>
|
||||
<div className="calc-field">
|
||||
<label>🔒 密码</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="至少 6 位" required minLength={6} />
|
||||
<div className="auth-pwd-wrap">
|
||||
<input
|
||||
type={showPwd ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="至少 6 位"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<button type="button" className="auth-pwd-toggle" onClick={() => setShowPwd(!showPwd)} aria-label="显示密码">
|
||||
{showPwd ? "🙈" : "👁️"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary" style={{ width: "100%" }} disabled={loading}>
|
||||
{loading ? "处理中..." : mode === "login" ? "🚀 登录" : "✨ 注册"}
|
||||
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}>
|
||||
{loading ? <span className="auth-loading">处理中...</span> : (mode === "login" ? "🚀 登录" : "✨ 注册")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@ -5,10 +5,17 @@ export default function NotFound() {
|
||||
return (
|
||||
<SiteShell>
|
||||
<div className="not-found-page">
|
||||
<span className="not-found-emoji">🧭</span>
|
||||
<h1>404</h1>
|
||||
<div className="not-found-compass" aria-hidden="true">
|
||||
<span className="compass-ring">🧭</span>
|
||||
<span className="compass-plane">✈️</span>
|
||||
</div>
|
||||
<h1 className="not-found-title">404</h1>
|
||||
<p>哎呀,这片海域没有标记的岛屿</p>
|
||||
<p className="not-found-sub">你可能走错了航线,让我们帮你回到正轨</p>
|
||||
<div className="not-found-actions">
|
||||
<Link href="/" className="btn btn-primary">返回首页 🏠</Link>
|
||||
<Link href="/#destinations" className="btn btn-ghost">探索目的地 🌍</Link>
|
||||
</div>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
|
||||
@ -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() {
|
||||
<span>加入年份</span>
|
||||
</div>
|
||||
<div className="profile-stat-card">
|
||||
<span className="stat-emoji">🧮</span>
|
||||
<Link href="/#calculator" className="profile-link">费用计算器 →</Link>
|
||||
<span className="stat-emoji">🏅</span>
|
||||
<strong>{earnedCount}/{badges.length}</strong>
|
||||
<span>成就徽章</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="profile-badges">
|
||||
<h2>🏅 游民成就</h2>
|
||||
<div className="badge-grid">
|
||||
{badges.map((b) => (
|
||||
<div key={b.label} className={`badge-card${b.earned ? " earned" : " locked"}`} title={b.desc}>
|
||||
<span className="badge-emoji">{b.earned ? b.emoji : "🔒"}</span>
|
||||
<strong>{b.label}</strong>
|
||||
<small>{b.desc}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="profile-favorites">
|
||||
<h2>❤️ 我的收藏</h2>
|
||||
{loading ? (
|
||||
@ -138,6 +174,7 @@ export default function ProfilePage() {
|
||||
<div className="profile-quick-grid">
|
||||
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
|
||||
<Link href="/#visa" className="quick-card">📋 签证指南</Link>
|
||||
<Link href="/#coworking" className="quick-card">💻 联合办公</Link>
|
||||
<Link href="/#blog" className="quick-card">📝 阅读博客</Link>
|
||||
<Link href="/#trip" className="quick-card">🗓️ 行程规划</Link>
|
||||
<Link href="/#nomad-score" className="quick-card">📊 就绪度测评</Link>
|
||||
|
||||
@ -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<string>();
|
||||
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 (
|
||||
<section className="section blog-section" id="blog">
|
||||
<div className="container">
|
||||
@ -10,8 +26,22 @@ export default function BlogSection({ posts }: { posts: BlogPost[] }) {
|
||||
<h2>游民博客</h2>
|
||||
<p>深度攻略、签证指南与旅居经验分享</p>
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<div className="blog-filters reveal">
|
||||
<button className={`filter-btn${activeTag === "all" ? " active" : ""}`} onClick={() => setActiveTag("all")}>
|
||||
📚 全部
|
||||
</button>
|
||||
{allTags.map((t) => (
|
||||
<button key={t} className={`filter-btn${activeTag === t ? " active" : ""}`} onClick={() => setActiveTag(t)}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="blog-grid">
|
||||
{posts.map((post) => (
|
||||
{filtered.map((post) => (
|
||||
<Link key={post.slug} href={`/blog/${post.slug}`} className="blog-card reveal">
|
||||
<div className="blog-emoji">{post.emoji}</div>
|
||||
<div className="blog-meta">
|
||||
@ -27,6 +57,9 @@ export default function BlogSection({ posts }: { posts: BlogPost[] }) {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<p className="dest-empty reveal">该分类暂无文章</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -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<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
@ -18,6 +27,8 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const t = testimonials[active];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="section community" id="community">
|
||||
@ -27,9 +38,14 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
|
||||
<h2>游民心声</h2>
|
||||
<p>来自全球数字游民的真实故事</p>
|
||||
</div>
|
||||
<div className="testimonials">
|
||||
{testimonials.map((t) => (
|
||||
<div key={t.id} className="testimonial-card reveal">
|
||||
|
||||
{t && (
|
||||
<div
|
||||
className="testimonial-carousel reveal"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<div className="testimonial-featured" key={t.id}>
|
||||
<div className="testimonial-avatar">{t.avatar}</div>
|
||||
<div className="testimonial-content">
|
||||
<div className="testimonial-stars">{"⭐".repeat(t.rating)}</div>
|
||||
@ -40,8 +56,35 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="testimonial-dots">
|
||||
{testimonials.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`testimonial-dot${i === active ? " active" : ""}`}
|
||||
onClick={() => setActive(i)}
|
||||
aria-label={`评价 ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="testimonials testimonials-grid">
|
||||
{testimonials.map((item) => (
|
||||
<div key={item.id} className="testimonial-card reveal">
|
||||
<div className="testimonial-avatar">{item.avatar}</div>
|
||||
<div className="testimonial-content">
|
||||
<div className="testimonial-stars">{"⭐".repeat(item.rating)}</div>
|
||||
<p>“{item.content}”</p>
|
||||
<div className="testimonial-author">
|
||||
<strong>{item.author}</strong>
|
||||
<span>{item.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="cta-banner reveal">
|
||||
<div className="cta-content">
|
||||
<h2>准备好开始你的旅居之旅了吗? 🌍</h2>
|
||||
|
||||
78
frontend/src/components/CoworkingGuide.tsx
Normal file
78
frontend/src/components/CoworkingGuide.tsx
Normal file
@ -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 (
|
||||
<section className="section coworking-section" id="coworking">
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">💻 COWORKING</span>
|
||||
<h2>联合办公空间指南</h2>
|
||||
<p>精选全球游民热门城市的 Co-working Space,高速 WiFi 与社区氛围兼备</p>
|
||||
</div>
|
||||
|
||||
<div className="coworking-controls reveal">
|
||||
<div className="coworking-filters">
|
||||
{CITY_FILTERS.map((f) => (
|
||||
<button key={f.key} className={`filter-btn${filter === f.key ? " active" : ""}`} onClick={() => setFilter(f.key)}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select className="coworking-sort" value={sort} onChange={(e) => setSort(e.target.value as typeof sort)}>
|
||||
<option value="rating">⭐ 评分优先</option>
|
||||
<option value="wifi">📶 网速优先</option>
|
||||
<option value="price">💰 价格优先</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="coworking-grid">
|
||||
{filtered.map((space, i) => (
|
||||
<div key={space.id} className="coworking-card reveal" style={{ transitionDelay: `${(i % 4) * 0.08}s` }}>
|
||||
<div className="coworking-card-top">
|
||||
<span className="coworking-emoji">{space.emoji}</span>
|
||||
<div className="coworking-rating">
|
||||
<span>⭐ {space.rating}</span>
|
||||
<span className="coworking-wifi">📶 {space.wifi}Mbps</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3>{space.name}</h3>
|
||||
<p className="coworking-city">{space.city} · {space.vibe}</p>
|
||||
<div className="coworking-price">{space.price}</div>
|
||||
<div className="coworking-perks">
|
||||
{space.perks.map((p) => <span key={p} className="coworking-perk">{p}</span>)}
|
||||
</div>
|
||||
<Link href={`/destinations/${space.slug}`} className="coworking-link">
|
||||
查看 {space.city} 详情 →
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -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<string | null>(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 (
|
||||
<section className="section faq-section" id="faq">
|
||||
@ -15,9 +28,27 @@ export default function FAQSection({ faqs }: { faqs: FAQ[] }) {
|
||||
<h2>常见问题</h2>
|
||||
<p>关于数字游民生活,你可能想知道的一切</p>
|
||||
</div>
|
||||
|
||||
<div className="faq-toolbar reveal">
|
||||
<div className="faq-search-wrap">
|
||||
<span className="faq-search-icon">🔍</span>
|
||||
<input
|
||||
className="faq-search"
|
||||
placeholder="搜索问题,如「签证」「保险」「税务」..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && <button className="faq-search-clear" onClick={() => setSearch("")}>✕</button>}
|
||||
</div>
|
||||
<div className="faq-toolbar-actions">
|
||||
<button className="btn btn-ghost btn-sm" onClick={expandAll}>展开全部</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={collapseAll}>收起全部</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="faq-list">
|
||||
{sorted.map((f) => (
|
||||
<div key={f.id} className={`faq-item reveal${active === f.id ? " active" : ""}`}>
|
||||
{filtered.map((f) => (
|
||||
<div key={f.id} className={`faq-item reveal${isOpen(f.id) ? " active" : ""}`}>
|
||||
<button className="faq-question" onClick={() => setActive(active === f.id ? null : f.id)}>
|
||||
<span>{f.question}</span>
|
||||
<svg className="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||
@ -25,6 +56,9 @@ export default function FAQSection({ faqs }: { faqs: FAQ[] }) {
|
||||
<div className="faq-answer"><p>{f.answer}</p></div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="faq-empty">没有找到相关问题,试试其他关键词 🔎</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -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) {
|
||||
<Lifestyle />
|
||||
<Charts {...props.charts} />
|
||||
<VisaSection visas={props.visas} />
|
||||
<CoworkingGuide />
|
||||
<SeasonGuide destinations={props.destinations} />
|
||||
<PackingChecklist />
|
||||
<Tools tools={props.tools} />
|
||||
<BlogSection posts={props.blog} />
|
||||
<FAQSection faqs={props.faqs} />
|
||||
@ -119,6 +126,7 @@ export default function HomeClient(props: Props) {
|
||||
<DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} />
|
||||
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
|
||||
<CookieConsent />
|
||||
<NomadTips />
|
||||
|
||||
{compareList.length > 0 && (
|
||||
<div className="compare-bar">
|
||||
|
||||
48
frontend/src/components/NomadTips.tsx
Normal file
48
frontend/src/components/NomadTips.tsx
Normal file
@ -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 (
|
||||
<div className="nomad-tips" role="status">
|
||||
<button className="nomad-tips-close" onClick={() => setVisible(false)} aria-label="关闭">✕</button>
|
||||
<span className="nomad-tips-emoji">{tip.emoji}</span>
|
||||
<div className="nomad-tips-body">
|
||||
<strong>游民小贴士</strong>
|
||||
<p key={idx}>{tip.text}</p>
|
||||
</div>
|
||||
<div className="nomad-tips-dots">
|
||||
{TIPS.map((_, i) => (
|
||||
<span key={i} className={i === idx ? "active" : ""} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
frontend/src/components/PackingChecklist.tsx
Normal file
125
frontend/src/components/PackingChecklist.tsx
Normal file
@ -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<Record<string, boolean>>({});
|
||||
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<string, boolean> = {};
|
||||
ITEMS.forEach((i) => { all[i.id] = true; });
|
||||
setChecked(all);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section packing-section" id="packing">
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">🧳 PACKING</span>
|
||||
<h2>数字游民行李清单</h2>
|
||||
<p>出发前逐项勾选,确保远程工作与旅居无忧</p>
|
||||
</div>
|
||||
|
||||
<div className="packing-card reveal">
|
||||
<div className="packing-progress">
|
||||
<div className="packing-progress-info">
|
||||
<span>完成度</span>
|
||||
<strong className="gradient-text">{pct}%</strong>
|
||||
<span className="packing-count">{done}/{total}</span>
|
||||
</div>
|
||||
<div className="packing-bar">
|
||||
<div className="packing-bar-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{pct === 100 && <p className="packing-ready">🎉 全部就绪,可以出发了!</p>}
|
||||
</div>
|
||||
|
||||
<div className="packing-filters">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button key={c.key} className={`filter-btn${filter === c.key ? " active" : ""}`} onClick={() => setFilter(c.key)}>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="packing-actions">
|
||||
<button className="btn btn-ghost btn-sm" onClick={checkAll}>全选</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={reset}>重置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="packing-list">
|
||||
{filtered.map((item) => (
|
||||
<li key={item.id}>
|
||||
<label className={`packing-item${checked[item.id] ? " checked" : ""}`}>
|
||||
<input type="checkbox" checked={!!checked[item.id]} onChange={() => toggle(item.id)} />
|
||||
<span className="packing-check" aria-hidden="true">{checked[item.id] ? "✓" : ""}</span>
|
||||
<span className="packing-emoji">{item.emoji}</span>
|
||||
<span className="packing-label">{item.label}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/SeasonGuide.tsx
Normal file
100
frontend/src/components/SeasonGuide.tsx
Normal file
@ -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<string, number[]> = {
|
||||
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 (
|
||||
<section className="section season-section" id="season">
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">🌤️ SEASON</span>
|
||||
<h2>最佳旅居月份</h2>
|
||||
<p>避开雨季与旺季,选对时间开启旅居生活</p>
|
||||
</div>
|
||||
|
||||
<div className="season-card reveal">
|
||||
<div className="season-cities">
|
||||
{destinations.map((d) => (
|
||||
<button
|
||||
key={d.slug}
|
||||
className={`season-city-btn${slug === d.slug ? " active" : ""}`}
|
||||
onClick={() => setSlug(d.slug)}
|
||||
>
|
||||
{d.emoji} {d.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="season-header-row">
|
||||
<div>
|
||||
<h3>{dest?.emoji} {dest?.name}</h3>
|
||||
<p>最佳月份:{bestMonths.join(" · ") || "全年适宜"}</p>
|
||||
</div>
|
||||
<Link href={`/destinations/${slug}`} className="btn btn-ghost btn-sm">查看详情 →</Link>
|
||||
</div>
|
||||
|
||||
<div className="season-grid">
|
||||
{MONTHS.map((m, i) => {
|
||||
const level = scores[i] ?? 1;
|
||||
const info = LEVEL[level];
|
||||
const isNow = i === nowMonth;
|
||||
return (
|
||||
<div
|
||||
key={m}
|
||||
className={`season-month${isNow ? " now" : ""}`}
|
||||
style={{ "--season-color": info.color } as CSSProperties}
|
||||
title={info.label}
|
||||
>
|
||||
<span className="season-month-emoji">{info.emoji}</span>
|
||||
<strong>{m}</strong>
|
||||
<small>{info.label}</small>
|
||||
{isNow && <span className="season-now-tag">本月</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="season-legend">
|
||||
{LEVEL.map((l) => (
|
||||
<span key={l.label} className="season-legend-item">
|
||||
<i style={{ background: l.color }} /> {l.emoji} {l.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -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: "博客" },
|
||||
];
|
||||
|
||||
|
||||
67
frontend/src/components/ShareButton.tsx
Normal file
67
frontend/src/components/ShareButton.tsx
Normal file
@ -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 (
|
||||
<button className={`share-btn-compact ${className}`} onClick={nativeShare} aria-label="分享">
|
||||
📤
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`share-dropdown ${className}`}>
|
||||
<button className="btn btn-ghost share-trigger" onClick={() => setOpen(!open)}>
|
||||
📤 分享
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="share-backdrop" onClick={() => setOpen(false)} />
|
||||
<div className="share-menu">
|
||||
<button onClick={nativeShare}>📱 系统分享</button>
|
||||
<button onClick={copyLink}>📋 复制链接</button>
|
||||
<button onClick={shareTwitter}>🐦 分享到 X</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 && (
|
||||
<p className="dest-empty reveal">暂无匹配的签证类型,试试其他筛选条件</p>
|
||||
)}
|
||||
<VisaWizard visas={visas} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
179
frontend/src/components/VisaWizard.tsx
Normal file
179
frontend/src/components/VisaWizard.tsx
Normal file
@ -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<string, number> = {
|
||||
"1": 6000, "2": 17000, "3": 2000, "4": 47000, "5": 18000, "6": 28000,
|
||||
};
|
||||
|
||||
const VISA_REGION: Record<string, string> = {
|
||||
"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 (
|
||||
<div className="visa-wizard reveal">
|
||||
<div className="visa-wizard-header">
|
||||
<span className="section-tag">🧙 VISA WIZARD</span>
|
||||
<h3>签证智能匹配</h3>
|
||||
<p>回答 3 个问题,找到最适合你的远程工作签证</p>
|
||||
</div>
|
||||
|
||||
{!showResults ? (
|
||||
<div className="visa-wizard-body">
|
||||
<div className="visa-wizard-steps">
|
||||
{["月收入", "停留时长", "目的地"].map((label, i) => (
|
||||
<div key={label} className={`wizard-step-dot${i <= step ? " active" : ""}${i < step ? " done" : ""}`}>
|
||||
<span>{i < step ? "✓" : i + 1}</span>
|
||||
<small>{label}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 0 && (
|
||||
<div className="wizard-options">
|
||||
{INCOME_OPTIONS.map((o) => (
|
||||
<button key={o.key} className={`wizard-option${income === o.key ? " selected" : ""}`} onClick={() => setIncome(o.key)}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="wizard-options">
|
||||
{DURATION_OPTIONS.map((o) => (
|
||||
<button key={o.key} className={`wizard-option${duration === o.key ? " selected" : ""}`} onClick={() => setDuration(o.key)}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="wizard-options">
|
||||
{REGION_OPTIONS.map((o) => (
|
||||
<button key={o.key} className={`wizard-option${region === o.key ? " selected" : ""}`} onClick={() => setRegion(o.key)}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="wizard-nav">
|
||||
{step > 0 && <button className="btn btn-ghost" onClick={() => setStep(step - 1)}>← 上一步</button>}
|
||||
{step < 2 ? (
|
||||
<button className="btn btn-primary" disabled={!canNext} onClick={() => setStep(step + 1)}>下一步 →</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={handleFinish}>查看推荐 ✨</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="visa-wizard-results">
|
||||
{results.length === 0 ? (
|
||||
<p className="wizard-empty">暂无完全匹配的签证,建议咨询专业移民顾问或放宽条件重试</p>
|
||||
) : (
|
||||
<div className="wizard-result-cards">
|
||||
{results.map(({ visa, score }, i) => (
|
||||
<div key={visa.id} className={`wizard-result-card${i === 0 ? " winner" : ""}`}>
|
||||
{i === 0 && <span className="wizard-winner-badge">🏆 最佳匹配</span>}
|
||||
<div className="wizard-result-top">
|
||||
<span className="wizard-result-flag">{visa.flag}</span>
|
||||
<div>
|
||||
<h4>{visa.name}</h4>
|
||||
<span className="wizard-match-score">{score}% 匹配</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li>💰 {visa.income_req}</li>
|
||||
<li>📅 {visa.duration}</li>
|
||||
<li>⏱️ {visa.approval_time}</li>
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-ghost" onClick={reset}>🔄 重新匹配</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/src/lib/coworking.ts
Normal file
60
frontend/src/lib/coworking.ts
Normal file
@ -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: "高效都市",
|
||||
},
|
||||
];
|
||||
Loading…
Reference in New Issue
Block a user