From 8d018d34e28d89ad82f6c7122280ebc63846e999 Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 28 Aug 2026 21:43:08 -0500 Subject: [PATCH] Add cost compare, blog tables, cookie banner, and polish UI/SEO Co-authored-by: Cursor --- docker-compose.prod.yml | 1 + frontend/.env.local.example | 1 + frontend/Dockerfile | 2 + frontend/src/app/blog/[slug]/page.tsx | 16 +- frontend/src/app/globals.css | 291 +++++++++++++++++++++- frontend/src/app/layout.tsx | 11 + frontend/src/app/manifest.ts | 18 ++ frontend/src/app/robots.ts | 4 +- frontend/src/app/sitemap.ts | 11 +- frontend/src/components/BlogMarkdown.tsx | 85 +++++++ frontend/src/components/CookieConsent.tsx | 35 +++ frontend/src/components/CostCompare.tsx | 119 +++++++++ frontend/src/components/Footer.tsx | 44 ++-- frontend/src/components/HomeClient.tsx | 4 + frontend/src/components/Navbar.tsx | 7 +- frontend/src/components/SectionNav.tsx | 1 + frontend/src/components/Tools.tsx | 78 +++++- 17 files changed, 674 insertions(+), 54 deletions(-) create mode 100644 frontend/src/app/manifest.ts create mode 100644 frontend/src/components/BlogMarkdown.tsx create mode 100644 frontend/src/components/CookieConsent.tsx create mode 100644 frontend/src/components/CostCompare.tsx 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 = []; + }; + + const flushTable = () => { + if (tableRows.length < 2) { tableRows = []; return; } + const [header, , ...body] = tableRows; + result.push( +
    + + + {header.map((h, i) => )} + + + {body.map((row, ri) => ( + {row.map((cell, ci) => )} + ))} + +
    {h}
    {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 (