Add cost compare, blog tables, cookie banner, and polish UI/SEO

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-28 21:43:08 -05:00
parent aba3f7e2ec
commit 8d018d34e2
17 changed files with 674 additions and 54 deletions

View File

@ -31,6 +31,7 @@ services:
context: ./frontend
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://nomadweb.nomadro.com/api/v1}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://nomadweb.nomadro.com}
container_name: nomadflow-web
ports:
- "127.0.0.1:3055:3000"

View File

@ -1 +1,2 @@
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
NEXT_PUBLIC_SITE_URL=http://localhost:3000

View File

@ -10,7 +10,9 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
RUN npm run build
FROM base AS runner

View File

@ -2,21 +2,9 @@ import Link from "next/link";
import { api } from "@/lib/api";
import { notFound } from "next/navigation";
import SiteShell from "@/components/SiteShell";
import BlogMarkdown from "@/components/BlogMarkdown";
import BlogReadingProgress from "@/components/BlogReadingProgress";
function renderMarkdown(content: string) {
return content.split("\n").map((line, i) => {
if (line.startsWith("## ")) return <h2 key={i}>{line.slice(3)}</h2>;
if (line.startsWith("### ")) return <h3 key={i}>{line.slice(4)}</h3>;
if (line.startsWith("| ")) return null; // skip table rows for simplicity
if (line.startsWith("- ")) return <li key={i}>{line.slice(2)}</li>;
if (line.match(/^\d+\. /)) return <li key={i}>{line.replace(/^\d+\. /, "")}</li>;
if (line.trim() === "") return <br key={i} />;
if (line.startsWith("**Q:")) return <p key={i}><strong>{line}</strong></p>;
return <p key={i}>{line}</p>;
});
}
export default async function BlogDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let post;
@ -47,7 +35,7 @@ export default async function BlogDetailPage({ params }: { params: Promise<{ slu
</div>
</div>
<div className="blog-detail-content">
{renderMarkdown(post.content)}
<BlogMarkdown content={post.content} />
</div>
</article>
</div>

View File

@ -191,7 +191,21 @@ img { max-width: 100%; display: block; }
height: 2px;
background: var(--text-primary);
border-radius: 2px;
transition: all var(--transition);
transition: all 0.3s ease;
transform-origin: center;
}
.mobile-menu-btn.open span:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.mobile-menu-btn.open span:nth-child(2) {
opacity: 0;
transform: scaleX(0);
}
.mobile-menu-btn.open span:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
/* ===== Hero ===== */
@ -1167,9 +1181,8 @@ img { max-width: 100%; display: block; }
}
@media (max-width: 768px) {
.nav-links { display: none; }
.nav-links.open {
display: flex;
.nav-links {
display: none;
flex-direction: column;
position: absolute;
top: var(--nav-height);
@ -1178,6 +1191,16 @@ img { max-width: 100%; display: block; }
background: var(--bg-secondary);
padding: 20px;
border-bottom: var(--border-glass);
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
transition: all 0.3s ease;
}
.nav-links.open {
display: flex;
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.mobile-menu-btn { display: flex; }
@ -4505,3 +4528,263 @@ img { max-width: 100%; display: block; }
.currency-result-amount { font-size: 1.8rem; }
}
/* ===== Cost Compare ===== */
.cost-compare-section {
background: linear-gradient(180deg, transparent, rgba(78, 205, 196, 0.03), transparent);
}
.cost-compare-card {
max-width: 720px;
margin: 0 auto;
padding: 32px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
backdrop-filter: blur(20px);
}
.cost-compare-selectors {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 28px;
}
.cost-compare-select label {
display: block;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 10px;
}
.cost-city-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cost-city-chip {
padding: 8px 14px;
border-radius: 50px;
border: var(--border-glass);
background: var(--bg-glass);
color: var(--text-secondary);
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
}
.cost-city-chip:hover,
.cost-city-chip.active {
border-color: rgba(78, 205, 196, 0.4);
background: rgba(78, 205, 196, 0.12);
color: var(--accent-3);
}
.cost-compare-select select {
width: 100%;
padding: 12px 16px;
border-radius: var(--radius-md);
border: var(--border-glass);
background: var(--bg-glass);
color: var(--text-primary);
font-family: inherit;
cursor: pointer;
}
.cost-compare-bars {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.cost-bar-label {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 0.9rem;
}
.cost-bar-track {
height: 12px;
background: rgba(255, 255, 255, 0.06);
border-radius: 6px;
overflow: hidden;
}
.cost-bar-fill {
height: 100%;
border-radius: 6px;
transition: width 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.cost-bar-fill.home {
background: linear-gradient(90deg, #FF6B6B, #FFE66D);
}
.cost-bar-fill.dest {
background: rgba(255, 255, 255, 0.2);
}
.cost-bar-fill.dest.cheaper {
background: linear-gradient(90deg, #4ECDC4, #6ee7b7);
}
.cost-compare-result {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-glass);
border: var(--border-glass);
border-radius: var(--radius-lg);
}
.cost-compare-result.saving {
background: rgba(78, 205, 196, 0.08);
border-color: rgba(78, 205, 196, 0.3);
}
.cost-result-emoji { font-size: 2rem; }
.cost-compare-result strong {
display: block;
font-family: var(--font-display);
font-size: 1.15rem;
margin-bottom: 4px;
}
.cost-compare-result p {
font-size: 0.85rem;
color: var(--text-muted);
margin: 0;
}
/* ===== Tools Filters ===== */
.tools-filters {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 28px;
}
.tool-links {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0;
}
.tool-link {
font-size: 0.8rem;
color: var(--accent-3);
text-decoration: none;
padding: 4px 10px;
background: rgba(78, 205, 196, 0.08);
border-radius: 20px;
transition: all 0.2s ease;
}
.tool-link:hover {
background: rgba(78, 205, 196, 0.2);
}
.tool-link-static {
font-size: 0.8rem;
color: var(--text-muted);
padding: 4px 10px;
background: var(--bg-glass);
border-radius: 20px;
}
/* ===== Blog Tables ===== */
.blog-table-wrap {
overflow-x: auto;
margin: 20px 0;
border-radius: var(--radius-md);
border: var(--border-glass);
}
.blog-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.blog-table th,
.blog-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.blog-table th {
background: rgba(78, 205, 196, 0.08);
font-weight: 600;
color: var(--accent-3);
}
.blog-table tr:last-child td { border-bottom: none; }
.blog-table tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
/* ===== Cookie Consent ===== */
.cookie-consent {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 9000;
width: min(560px, calc(100% - 32px));
animation: toastIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.cookie-consent-inner {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 20px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-lg);
backdrop-filter: blur(20px);
box-shadow: var(--shadow-card);
}
.cookie-emoji { font-size: 1.5rem; flex-shrink: 0; }
.cookie-consent-inner p {
flex: 1;
font-size: 0.85rem;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.cookie-consent-inner a {
color: var(--accent-3);
margin-left: 4px;
}
@media (max-width: 768px) {
.cookie-consent-inner {
flex-wrap: wrap;
justify-content: center;
text-align: center;
}
.cost-compare-card { padding: 20px; }
.nav-links.open {
animation: menuSlide 0.3s ease;
}
}
@keyframes menuSlide {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}

View File

@ -3,7 +3,10 @@ import "./globals.css";
import { AuthProvider } from "@/lib/auth";
import { ToastProvider } from "@/lib/toast";
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
export const metadata: Metadata = {
metadataBase: new URL(SITE),
title: "NomadFlow · 数字游民旅居指南",
description: "探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。",
keywords: ["数字游民", "旅居", "远程工作", "nomad", "digital nomad"],
@ -12,7 +15,15 @@ export const metadata: Metadata = {
description: "用一行代码环游世界 🌏",
type: "website",
locale: "zh_CN",
url: SITE,
siteName: "NomadFlow",
},
twitter: {
card: "summary_large_image",
title: "NomadFlow · 数字游民旅居指南",
description: "用一行代码环游世界 🌏",
},
manifest: "/manifest.webmanifest",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {

View File

@ -0,0 +1,18 @@
import type { MetadataRoute } from "next";
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "NomadFlow · 数字游民旅居指南",
short_name: "NomadFlow",
description: "探索全球旅居生活,发现最适合数字游民的目的地",
start_url: "/",
display: "standalone",
background_color: "#0a0e17",
theme_color: "#4ECDC4",
icons: [
{ src: "/globe.svg", sizes: "any", type: "image/svg+xml" },
],
};
}

View File

@ -1,8 +1,10 @@
import type { MetadataRoute } from "next";
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/" },
sitemap: "https://nomadflow.io/sitemap.xml",
sitemap: `${SITE}/sitemap.xml`,
};
}

View File

@ -1,24 +1,23 @@
import type { MetadataRoute } from "next";
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
const DESTINATIONS = ["bali", "lisbon", "chiangmai", "mexico", "barcelona", "tokyo"];
const BLOGS = ["chiangmai-guide-2026", "portugal-d7-visa", "nomad-tax-basics"];
export default function sitemap(): MetadataRoute.Sitemap {
const base = "https://nomadflow.io";
const now = new Date();
return [
{ url: base, lastModified: now, changeFrequency: "weekly", priority: 1 },
{ url: `${base}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${base}/profile`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: SITE, lastModified: now, changeFrequency: "weekly", priority: 1 },
{ url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
...DESTINATIONS.map((slug) => ({
url: `${base}/destinations/${slug}`,
url: `${SITE}/destinations/${slug}`,
lastModified: now,
changeFrequency: "weekly" as const,
priority: 0.8,
})),
...BLOGS.map((slug) => ({
url: `${base}/blog/${slug}`,
url: `${SITE}/blog/${slug}`,
lastModified: now,
changeFrequency: "monthly" as const,
priority: 0.7,

View File

@ -0,0 +1,85 @@
"use client";
import { useMemo } from "react";
function parseTableRow(line: string): string[] {
return line.split("|").map((c) => c.trim()).filter(Boolean);
}
export default function BlogMarkdown({ content }: { content: string }) {
const elements = useMemo(() => {
const lines = content.split("\n");
const result: React.ReactNode[] = [];
let listItems: string[] = [];
let tableRows: string[][] = [];
let key = 0;
const flushList = () => {
if (listItems.length === 0) return;
result.push(
<ul key={`ul-${key++}`}>
{listItems.map((item, i) => <li key={i}>{item}</li>)}
</ul>
);
listItems = [];
};
const flushTable = () => {
if (tableRows.length < 2) { tableRows = []; return; }
const [header, , ...body] = tableRows;
result.push(
<div key={`table-${key++}`} className="blog-table-wrap">
<table className="blog-table">
<thead>
<tr>{header.map((h, i) => <th key={i}>{h}</th>)}</tr>
</thead>
<tbody>
{body.map((row, ri) => (
<tr key={ri}>{row.map((cell, ci) => <td key={ci}>{cell}</td>)}</tr>
))}
</tbody>
</table>
</div>
);
tableRows = [];
};
for (const line of lines) {
if (line.startsWith("|")) {
flushList();
const cells = parseTableRow(line);
if (cells.every((c) => /^[-:]+$/.test(c))) continue;
tableRows.push(cells);
continue;
}
flushTable();
if (line.startsWith("## ")) {
flushList();
result.push(<h2 key={key++}>{line.slice(3)}</h2>);
} else if (line.startsWith("### ")) {
flushList();
result.push(<h3 key={key++}>{line.slice(4)}</h3>);
} else if (line.startsWith("- ")) {
listItems.push(line.slice(2));
} else if (line.match(/^\d+\. /)) {
listItems.push(line.replace(/^\d+\. /, ""));
} else if (line.trim() === "") {
flushList();
} else if (line.startsWith("**")) {
flushList();
result.push(<p key={key++}><strong>{line.replace(/\*\*/g, "")}</strong></p>);
} else {
flushList();
result.push(<p key={key++}>{line}</p>);
}
}
flushList();
flushTable();
return result;
}, [content]);
return <>{elements}</>;
}

View File

@ -0,0 +1,35 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
export default function CookieConsent() {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!localStorage.getItem("nomad-cookie-consent")) {
const timer = setTimeout(() => setVisible(true), 2000);
return () => clearTimeout(timer);
}
}, []);
const accept = () => {
localStorage.setItem("nomad-cookie-consent", "1");
setVisible(false);
};
if (!visible) return null;
return (
<div className="cookie-consent">
<div className="cookie-consent-inner">
<span className="cookie-emoji">🍪</span>
<p>
我们使用 localStorage 保存主题偏好和行程数据,不会追踪你的浏览行为。
<Link href="/#faq">了解更多</Link>
</p>
<button className="btn btn-primary btn-sm" onClick={accept}>知道了 ✓</button>
</div>
</div>
);
}

View File

@ -0,0 +1,119 @@
"use client";
import { useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
const HOME_CITIES = [
{ name: "上海", cost: 12000, emoji: "🏙️" },
{ name: "北京", cost: 11500, emoji: "🏛️" },
{ name: "深圳", cost: 11000, emoji: "🌆" },
{ name: "广州", cost: 9000, emoji: "🌃" },
{ name: "成都", cost: 7000, emoji: "🐼" },
];
interface Props {
destinations: Destination[];
}
export default function CostCompare({ destinations }: Props) {
const [homeIdx, setHomeIdx] = useState(0);
const [destSlug, setDestSlug] = useState(destinations[0]?.slug || "");
const home = HOME_CITIES[homeIdx];
const dest = destinations.find((d) => d.slug === destSlug);
const comparison = useMemo(() => {
if (!dest) return null;
const diff = home.cost - dest.cost;
const pct = Math.round((diff / home.cost) * 100);
const yearlySave = diff * 12;
return { diff, pct, yearlySave, cheaper: diff > 0 };
}, [home, dest]);
if (!dest || !comparison) return null;
const maxCost = Math.max(home.cost, dest.cost);
return (
<section className="section cost-compare-section" id="cost-compare">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">💰 SAVINGS</span>
<h2>旅居能省多少钱?</h2>
<p>对比国内城市与海外旅居目的地的生活成本差异</p>
</div>
<div className="cost-compare-card reveal">
<div className="cost-compare-selectors">
<div className="cost-compare-select">
<label>🏠 国内基准城市</label>
<div className="cost-city-chips">
{HOME_CITIES.map((c, i) => (
<button
key={c.name}
className={`cost-city-chip${homeIdx === i ? " active" : ""}`}
onClick={() => setHomeIdx(i)}
>
{c.emoji} {c.name}
</button>
))}
</div>
</div>
<div className="cost-compare-select">
<label>🌍 旅居目的地</label>
<select value={destSlug} onChange={(e) => setDestSlug(e.target.value)}>
{destinations.map((d) => (
<option key={d.slug} value={d.slug}>{d.emoji} {d.name}, {d.country}</option>
))}
</select>
</div>
</div>
<div className="cost-compare-bars">
<div className="cost-bar-row">
<div className="cost-bar-label">
<span>{home.emoji} {home.name}</span>
<strong>¥{home.cost.toLocaleString()}/月</strong>
</div>
<div className="cost-bar-track">
<div className="cost-bar-fill home" style={{ width: `${(home.cost / maxCost) * 100}%` }} />
</div>
</div>
<div className="cost-bar-row">
<div className="cost-bar-label">
<span>{dest.emoji} {dest.name}</span>
<strong>¥{dest.cost.toLocaleString()}/月</strong>
</div>
<div className="cost-bar-track">
<div
className={`cost-bar-fill dest${comparison.cheaper ? " cheaper" : ""}`}
style={{ width: `${(dest.cost / maxCost) * 100}%` }}
/>
</div>
</div>
</div>
<div className={`cost-compare-result${comparison.cheaper ? " saving" : ""}`}>
{comparison.cheaper ? (
<>
<span className="cost-result-emoji">🎉</span>
<div>
<strong>每月可省 ¥{comparison.diff.toLocaleString()}</strong>
<p>比{home.name}便宜 {comparison.pct}% · 一年省 ¥{comparison.yearlySave.toLocaleString()}</p>
</div>
</>
) : (
<>
<span className="cost-result-emoji">💎</span>
<div>
<strong>每月多花 ¥{Math.abs(comparison.diff).toLocaleString()}</strong>
<p>但你能获得更好的生活质量、气候和游民社区 ✨</p>
</div>
</>
)}
</div>
</div>
</div>
</section>
);
}

View File

@ -1,42 +1,48 @@
import Link from "next/link";
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
export default function Footer() {
return (
<footer className="footer">
<div className="container">
<div className="footer-grid">
<div className="footer-brand">
<a href="#" className="logo"><span>NomadFlow</span></a>
<Link href="/" className="logo"><span>NomadFlow</span></Link>
<p>让每个人都能自由地工作和生活在这个星球上 🌏</p>
<div className="social-links">
<a href="#" aria-label="Twitter">𝕏</a>
<a href="#" aria-label="Instagram">📷</a>
<a href="#" aria-label="YouTube">▶️</a>
<a href="#" aria-label="Discord">💬</a>
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer" aria-label="Twitter">𝕏</a>
<a href="https://instagram.com" target="_blank" rel="noopener noreferrer" aria-label="Instagram">📷</a>
<a href="https://youtube.com" target="_blank" rel="noopener noreferrer" aria-label="YouTube">▶️</a>
<a href="https://discord.com" target="_blank" rel="noopener noreferrer" aria-label="Discord">💬</a>
</div>
</div>
<div className="footer-links">
<h4>探索</h4>
<a href="#destinations">目的地</a>
<a href="#calculator">费用计算器</a>
<a href="#visa">签证指南</a>
<a href="#blog">博客</a>
<Link href="/#destinations">目的地</Link>
<Link href="/#cost-compare">省钱对比</Link>
<Link href="/#calculator">费用计算器</Link>
<Link href="/#visa">签证指南</Link>
<Link href="/#blog">博客</Link>
</div>
<div className="footer-links">
<h4>技术栈</h4>
<a href="#">Next.js</a>
<a href="#">FastAPI</a>
<a href="#">PocketBase</a>
<a href="/api/docs" target="_blank">API 文档</a>
<h4>工具</h4>
<Link href="/#trip">行程规划</Link>
<Link href="/#timezone">时区看板</Link>
<Link href="/#nomad-score">就绪度测评</Link>
<Link href="/#currency">汇率换算</Link>
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
</div>
<div className="footer-links">
<h4>联系</h4>
<a href="#">关于我们</a>
<a href="#">合作伙伴</a>
<a href="#">隐私政策</a>
<h4>账户</h4>
<Link href="/login">登录 / 注册</Link>
<Link href="/profile">用户中心</Link>
<a href="mailto:hello@nomadflow.io">hello@nomadflow.io</a>
<a href="https://gitea.dsx2020.com/eric/nomadweb" target="_blank" rel="noopener noreferrer">开源仓库 ↗</a>
</div>
</div>
<div className="footer-bottom">
<p>© 2026 NomadFlow. Made with ❤️ for Digital Nomads everywhere.</p>
<p>© 2026 NomadFlow · <a href={SITE}>{SITE.replace("https://", "")}</a></p>
<p className="footer-emoji-bar">🌴 ☀️ 🏄‍♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅</p>
</div>
</div>

View File

@ -28,6 +28,8 @@ import ScrollProgress from "./ScrollProgress";
import SectionNav from "./SectionNav";
import CurrencyConverter from "./CurrencyConverter";
import NomadScore from "./NomadScore";
import CostCompare from "./CostCompare";
import CookieConsent from "./CookieConsent";
import KeyboardShortcuts from "./KeyboardShortcuts";
import BackToTop from "./BackToTop";
@ -100,6 +102,7 @@ export default function HomeClient(props: Props) {
<TimezoneBoard destinations={props.destinations} />
<TripPlanner destinations={props.destinations} />
<Calculator destinations={props.destinations} />
<CostCompare destinations={props.destinations} />
<CurrencyConverter />
<NomadScore />
<Lifestyle />
@ -115,6 +118,7 @@ export default function HomeClient(props: Props) {
<MatcherTrigger onClick={() => setMatcherOpen(true)} />
<DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} />
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
<CookieConsent />
{compareList.length > 0 && (
<div className="compare-bar">

View File

@ -50,6 +50,11 @@ export default function Navbar() {
return () => observer.disconnect();
}, [isHome]);
useEffect(() => {
document.body.style.overflow = menuOpen ? "hidden" : "";
return () => { document.body.style.overflow = ""; };
}, [menuOpen]);
const toggleTheme = () => {
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
@ -94,7 +99,7 @@ export default function Navbar() {
) : (
<Link href="/login" className="nav-login-btn">登录</Link>
)}
<button className="mobile-menu-btn" onClick={() => setMenuOpen(!menuOpen)} aria-label="菜单">
<button className={`mobile-menu-btn${menuOpen ? " open" : ""}`} onClick={() => setMenuOpen(!menuOpen)} aria-label="菜单">
<span /><span /><span />
</button>
</div>

View File

@ -8,6 +8,7 @@ const SECTIONS = [
{ id: "timezone", emoji: "🕐", label: "时区" },
{ id: "trip", emoji: "🗓️", label: "行程" },
{ id: "calculator", emoji: "🧮", label: "计算器" },
{ id: "cost-compare", emoji: "💰", label: "省钱" },
{ id: "lifestyle", emoji: "💻", label: "生活" },
{ id: "visa", emoji: "📋", label: "签证" },
{ id: "blog", emoji: "📝", label: "博客" },

View File

@ -1,28 +1,88 @@
"use client";
import { useMemo, useState } from "react";
import type { Tool } from "@/lib/types";
const ICONS: Record<string, string> = {
work: "💼", travel: "✈️", finance: "💳", connect: "🤝", health: "🏥", learn: "📚",
};
const CATEGORIES = [
{ key: "all", label: "🌏 全部" },
{ key: "work", label: "💼 协作" },
{ key: "travel", label: "✈️ 旅行" },
{ key: "finance", label: "💳 财务" },
{ key: "connect", label: "🤝 社交" },
{ key: "health", label: "🏥 健康" },
{ key: "learn", label: "📚 学习" },
];
const LINKS: Record<string, string> = {
"Slack": "https://slack.com",
"Notion": "https://notion.so",
"Figma": "https://figma.com",
"Zoom": "https://zoom.us",
"Skyscanner": "https://skyscanner.com",
"Nomad List": "https://nomadlist.com",
"SafetyWing": "https://safetywing.com",
"Wise": "https://wise.com",
"Revolut": "https://revolut.com",
"Coursera": "https://coursera.org",
"Duolingo": "https://duolingo.com",
};
export default function Tools({ tools }: { tools: Tool[] }) {
const [filter, setFilter] = useState("all");
const filtered = useMemo(() =>
filter === "all" ? tools : tools.filter((t) => t.category === filter),
[tools, filter]);
return (
<section className="section tools" id="tools">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🛠️ TOOLKIT</span>
<h2>游民必备工具箱</h2>
<p>精选远程工作与旅居生活的效率神器</p>
<p>精选远程工作与旅居生活的效率神器 — 点击标签筛选</p>
</div>
<div className="tools-grid">
{tools.map((t) => (
<div key={t.id} className="tool-card reveal" data-category={t.category}>
<span className="tool-emoji">{t.emoji || ICONS[t.category]}</span>
<h3>{t.name}</h3>
<p>{t.description}</p>
<div className="tool-tags">{t.tags.map((tag) => <span key={tag}>{tag}</span>)}</div>
</div>
<div className="tools-filters reveal">
{CATEGORIES.map((c) => (
<button
key={c.key}
className={`filter-btn${filter === c.key ? " active" : ""}`}
onClick={() => setFilter(c.key)}
>
{c.label}
</button>
))}
</div>
<div className="tools-grid">
{filtered.map((t, i) => {
const parts = t.description.split(" · ");
return (
<div key={t.id} className="tool-card reveal visible" style={{ animationDelay: `${i * 0.06}s` }}>
<span className="tool-emoji">{t.emoji || ICONS[t.category]}</span>
<h3>{t.name}</h3>
<div className="tool-links">
{parts.map((name) => {
const url = LINKS[name.trim()];
return url ? (
<a key={name} href={url} target="_blank" rel="noopener noreferrer" className="tool-link">
{name.trim()} ↗
</a>
) : (
<span key={name} className="tool-link-static">{name.trim()}</span>
);
})}
</div>
<div className="tool-tags">{t.tags.map((tag) => <span key={tag}>{tag}</span>)}</div>
</div>
);
})}
</div>
</div>
</section>
);