diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 13b0997..a4690bc 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -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"
diff --git a/frontend/.env.local.example b/frontend/.env.local.example
index 47e32cf..0ec0a2d 100644
--- a/frontend/.env.local.example
+++ b/frontend/.env.local.example
@@ -1 +1,2 @@
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
+NEXT_PUBLIC_SITE_URL=http://localhost:3000
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 9e7e0bd..c47a9ed 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -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
diff --git a/frontend/src/app/blog/[slug]/page.tsx b/frontend/src/app/blog/[slug]/page.tsx
index 8e80368..0e69b45 100644
--- a/frontend/src/app/blog/[slug]/page.tsx
+++ b/frontend/src/app/blog/[slug]/page.tsx
@@ -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
{line.slice(3)}
;
- if (line.startsWith("### ")) return {line.slice(4)}
;
- if (line.startsWith("| ")) return null; // skip table rows for simplicity
- if (line.startsWith("- ")) return {line.slice(2)};
- if (line.match(/^\d+\. /)) return {line.replace(/^\d+\. /, "")};
- if (line.trim() === "") return
;
- if (line.startsWith("**Q:")) return {line}
;
- return {line}
;
- });
-}
-
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
- {renderMarkdown(post.content)}
+
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 11f428e..23f90d2 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -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); }
+}
+
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index d44b373..904ebb8 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -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 }) {
diff --git a/frontend/src/app/manifest.ts b/frontend/src/app/manifest.ts
new file mode 100644
index 0000000..08663ae
--- /dev/null
+++ b/frontend/src/app/manifest.ts
@@ -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" },
+ ],
+ };
+}
diff --git a/frontend/src/app/robots.ts b/frontend/src/app/robots.ts
index 8233163..d41b15d 100644
--- a/frontend/src/app/robots.ts
+++ b/frontend/src/app/robots.ts
@@ -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`,
};
}
diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts
index e4c32f7..42705ee 100644
--- a/frontend/src/app/sitemap.ts
+++ b/frontend/src/app/sitemap.ts
@@ -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,
diff --git a/frontend/src/components/BlogMarkdown.tsx b/frontend/src/components/BlogMarkdown.tsx
new file mode 100644
index 0000000..be627f7
--- /dev/null
+++ b/frontend/src/components/BlogMarkdown.tsx
@@ -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(
+
+ {listItems.map((item, i) => - {item}
)}
+
+ );
+ listItems = [];
+ };
+
+ const flushTable = () => {
+ if (tableRows.length < 2) { tableRows = []; return; }
+ const [header, , ...body] = tableRows;
+ result.push(
+
+
+
+ {header.map((h, i) => | {h} | )}
+
+
+ {body.map((row, ri) => (
+ {row.map((cell, ci) => | {cell} | )}
+ ))}
+
+
+
+ );
+ 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({line.slice(3)}
);
+ } else if (line.startsWith("### ")) {
+ flushList();
+ result.push({line.slice(4)}
);
+ } 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({line.replace(/\*\*/g, "")}
);
+ } else {
+ flushList();
+ result.push({line}
);
+ }
+ }
+
+ flushList();
+ flushTable();
+ return result;
+ }, [content]);
+
+ return <>{elements}>;
+}
diff --git a/frontend/src/components/CookieConsent.tsx b/frontend/src/components/CookieConsent.tsx
new file mode 100644
index 0000000..ec25d2e
--- /dev/null
+++ b/frontend/src/components/CookieConsent.tsx
@@ -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 (
+
+
+
🍪
+
+ 我们使用 localStorage 保存主题偏好和行程数据,不会追踪你的浏览行为。
+ 了解更多
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/CostCompare.tsx b/frontend/src/components/CostCompare.tsx
new file mode 100644
index 0000000..1603732
--- /dev/null
+++ b/frontend/src/components/CostCompare.tsx
@@ -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 (
+
+
+
+
💰 SAVINGS
+
旅居能省多少钱?
+
对比国内城市与海外旅居目的地的生活成本差异
+
+
+
+
+
+
+
+ {HOME_CITIES.map((c, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ {home.emoji} {home.name}
+ ¥{home.cost.toLocaleString()}/月
+
+
+
+
+
+ {dest.emoji} {dest.name}
+ ¥{dest.cost.toLocaleString()}/月
+
+
+
+
+
+
+ {comparison.cheaper ? (
+ <>
+
🎉
+
+
每月可省 ¥{comparison.diff.toLocaleString()}
+
比{home.name}便宜 {comparison.pct}% · 一年省 ¥{comparison.yearlySave.toLocaleString()}
+
+ >
+ ) : (
+ <>
+
💎
+
+
每月多花 ¥{Math.abs(comparison.diff).toLocaleString()}
+
但你能获得更好的生活质量、气候和游民社区 ✨
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx
index 50ec1fe..474622c 100644
--- a/frontend/src/components/Footer.tsx
+++ b/frontend/src/components/Footer.tsx
@@ -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 (