From 8ab9b9b9a13385ac01647a1e6eb457d0bb93c08f Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 29 Aug 2026 04:17:50 -0500 Subject: [PATCH] Polish discovery funnel: surface plan/compare, login next redirect, SEO metadata. Co-authored-by: Cursor --- frontend/src/app/changelog/page.tsx | 10 ++ frontend/src/app/compare/page.tsx | 11 +++ frontend/src/app/destinations/[slug]/page.tsx | 20 ++++ frontend/src/app/globals.css | 55 +++++++++++ frontend/src/app/login/page.tsx | 59 +++++++++--- frontend/src/app/plan/page.tsx | 11 +++ frontend/src/app/sitemap.ts | 4 +- frontend/src/app/tools/page.tsx | 24 +++++ frontend/src/components/Footer.tsx | 7 +- frontend/src/components/Hero.tsx | 4 +- frontend/src/components/HomeClient.tsx | 21 +---- frontend/src/components/MovePlanClient.tsx | 2 +- frontend/src/components/Navbar.tsx | 4 +- frontend/src/components/OnboardingTour.tsx | 29 +++--- frontend/src/components/SectionNav.tsx | 57 +++++++----- frontend/src/components/ToolsStrip.tsx | 20 ++-- frontend/src/components/TripPlanner.tsx | 91 ++++++------------- scripts/deploy_direct_sync.py | 10 ++ 18 files changed, 293 insertions(+), 146 deletions(-) diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index 9e92a96..3ec8802 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -8,6 +8,16 @@ export const metadata: Metadata = { }; const LOGS = [ + { + date: "2026-08-29", + tag: "产品打磨", + items: [ + "Hero / 导航 / 底栏 / 快捷条 / 新手引导统一导向计划与对比", + "登录支持 ?next= 回流,默认进入计划中心并强调云端同步", + "Sitemap 与 OG 补齐 /plan /compare;目的地页补 generateMetadata", + "首页对比直达对比台;行程区收束为计划中心入口", + ], + }, { date: "2026-08-29", tag: "性能优化", diff --git a/frontend/src/app/compare/page.tsx b/frontend/src/app/compare/page.tsx index 81529c5..3fe7a42 100644 --- a/frontend/src/app/compare/page.tsx +++ b/frontend/src/app/compare/page.tsx @@ -8,6 +8,17 @@ import SiteShell from "@/components/SiteShell"; export const metadata: Metadata = { title: "城市对比 · nomadro", description: "并排对比旅居城市费用、网速、气候与评分,决定后写入旅居计划", + openGraph: { + title: "城市对比台 · nomadro", + description: "最多 4 城并排对比,推荐城一键写入旅居计划", + url: "/compare", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "城市对比台 · nomadro", + description: "最多 4 城并排对比,推荐城一键写入旅居计划", + }, }; export const revalidate = 60; diff --git a/frontend/src/app/destinations/[slug]/page.tsx b/frontend/src/app/destinations/[slug]/page.tsx index ac05857..0441fda 100644 --- a/frontend/src/app/destinations/[slug]/page.tsx +++ b/frontend/src/app/destinations/[slug]/page.tsx @@ -1,10 +1,30 @@ import Link from "next/link"; +import type { Metadata } from "next"; import { api } from "@/lib/api"; import { notFound } from "next/navigation"; import FavoriteButton from "@/components/FavoriteButton"; import DestinationDetailClient from "@/components/DestinationDetailClient"; import SiteShell from "@/components/SiteShell"; +export async function generateMetadata( + { params }: { params: Promise<{ slug: string }> } +): Promise { + const { slug } = await params; + try { + const dest = await api.getDestination(slug); + const title = `${dest.emoji} ${dest.name}, ${dest.country} · nomadro`; + const description = `${dest.description.slice(0, 120)}… 月费约 ¥${dest.cost.toLocaleString()} · 网速 ${dest.speed}Mbps`; + return { + title, + description, + openGraph: { title, description, url: `/destinations/${slug}` }, + twitter: { card: "summary_large_image", title, description }, + }; + } catch { + return { title: "目的地 · nomadro" }; + } +} + export default async function DestinationPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; let dest; diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a08b91e..04d27d4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -9237,3 +9237,58 @@ img { max-width: 100%; display: block; } color: var(--text-secondary); line-height: 1.5; } + +/* ===== Product discovery polish ===== */ +.trip-hero-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + margin-top: 14px; +} +.auth-sync-note { + margin: 0 0 16px; + padding: 10px 14px; + border-radius: var(--radius-md); + border: var(--border-glass); + background: color-mix(in srgb, var(--accent-2) 10%, transparent); + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.5; +} +.auth-sync-note a { + color: var(--accent-2); + margin-left: 4px; +} +.tools-core-banner { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin: 0 0 28px; +} +.tools-core-card { + display: flex; + gap: 12px; + align-items: center; + padding: 16px 18px; + border-radius: var(--radius-xl); + border: var(--border-glass); + background: var(--bg-card); + text-decoration: none; + color: inherit; + transition: border-color 0.2s, transform 0.2s; +} +.tools-core-card:hover { + border-color: var(--accent-2); + transform: translateY(-2px); +} +.tools-core-card span { font-size: 1.6rem; } +.tools-core-card strong { display: block; font-size: 0.95rem; } +.tools-core-card p { + margin: 2px 0 0; + font-size: 0.78rem; + color: var(--text-secondary); +} +@media (max-width: 800px) { + .tools-core-banner { grid-template-columns: 1fr; } +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 05143b6..fc70899 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,14 +1,25 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense } 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() { +function safeNext(raw: string | null): string { + if (!raw || !raw.startsWith("/") || raw.startsWith("//")) return "/plan"; + return raw; +} + +function LoginForm() { const { login, register, demoLogin } = useAuth(); const { toast } = useToast(); + const router = useRouter(); + const searchParams = useSearchParams(); + const next = safeNext(searchParams.get("next")); + const [mode, setMode] = useState<"login" | "register">("login"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -17,6 +28,11 @@ export default function LoginPage() { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const goAfterAuth = (msg: string) => { + toast(msg); + setTimeout(() => { router.replace(next); }, 400); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); @@ -26,8 +42,11 @@ export default function LoginPage() { : await register(email, password, name); setLoading(false); if (ok) { - toast(mode === "login" ? "欢迎回来!🌍" : "注册成功,开启旅居之旅 ✨"); - setTimeout(() => { window.location.href = "/"; }, 600); + goAfterAuth( + mode === "login" + ? "欢迎回来!计划已开始同步 🗓️" + : "注册成功,旅居计划可跨设备同步 ✨" + ); } else { setError(mode === "login" ? "邮箱或密码错误" : "注册失败,邮箱可能已存在"); } @@ -37,14 +56,10 @@ export default function LoginPage() { setLoading(true); const ok = await demoLogin(); setLoading(false); - if (ok) { - toast("演示账号已登录 🎮"); - setTimeout(() => { window.location.href = "/"; }, 600); - } + if (ok) goAfterAuth("演示账号已登录 · 正在同步旅居计划 🎮"); }; return ( -
+ ); +} + +export default function LoginPage() { + return ( + +

加载中…

}> + +
); } diff --git a/frontend/src/app/plan/page.tsx b/frontend/src/app/plan/page.tsx index c9d5bb0..0cc15a3 100644 --- a/frontend/src/app/plan/page.tsx +++ b/frontend/src/app/plan/page.tsx @@ -7,6 +7,17 @@ import SiteShell from "@/components/SiteShell"; export const metadata: Metadata = { title: "旅居计划中心 · nomadro", description: "多城时间轴、预算与月均、签证停留提醒、出发就绪清单、日历导出——一页做完旅居规划", + openGraph: { + title: "旅居计划中心 · nomadro", + description: "时间轴、签证、预算与就绪清单——真正能执行的旅居计划", + url: "/plan", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "旅居计划中心 · nomadro", + description: "时间轴、签证、预算与就绪清单——真正能执行的旅居计划", + }, }; export const revalidate = 60; diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts index c736919..d9fce82 100644 --- a/frontend/src/app/sitemap.ts +++ b/frontend/src/app/sitemap.ts @@ -9,10 +9,12 @@ export default function sitemap(): MetadataRoute.Sitemap { return [ { url: SITE, lastModified: now, changeFrequency: "weekly", priority: 1 }, + { url: `${SITE}/plan`, lastModified: now, changeFrequency: "weekly", priority: 0.95 }, + { url: `${SITE}/compare`, lastModified: now, changeFrequency: "weekly", priority: 0.9 }, + { url: `${SITE}/tools`, lastModified: now, changeFrequency: "weekly", priority: 0.8 }, { url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, { url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 }, { url: `${SITE}/about`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, - { url: `${SITE}/tools`, lastModified: now, changeFrequency: "weekly", priority: 0.8 }, { url: `${SITE}/changelog`, lastModified: now, changeFrequency: "weekly", priority: 0.4 }, { url: `${SITE}/profile`, lastModified: now, changeFrequency: "monthly", priority: 0.4 }, ...DESTINATIONS.map((slug) => ({ diff --git a/frontend/src/app/tools/page.tsx b/frontend/src/app/tools/page.tsx index fa59491..aa50f9e 100644 --- a/frontend/src/app/tools/page.tsx +++ b/frontend/src/app/tools/page.tsx @@ -38,6 +38,30 @@ export default function ToolsHubPage() {

规划、金钱、工作、生活与安全——一站直达所有旅居工具

+
+ + 🗓️ +
+ 旅居计划中心 +

时间轴 · 签证 · 预算 · 日历导出

+
+ + + ⚖️ +
+ 城市对比台 +

并排指标 · 一键写入计划

+
+ + + ☁️ +
+ 登录同步 +

跨设备保存计划与收藏

+
+ +
+
✨ 本周推荐 diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 72a3e23..adabe68 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -40,17 +40,18 @@ export default function Footer() {

探索

目的地 + 旅居计划 + 城市对比 签证指南 - 活动日历 博客 关于 nomadro

工具

全部工具箱 - 行程规划 + 计划中心 + 对比台 费用计算器 - 取现避坑 更新日志 API 文档
diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx index 54bcc44..ec8e086 100644 --- a/frontend/src/components/Hero.tsx +++ b/frontend/src/components/Hero.tsx @@ -119,8 +119,8 @@ export default function Hero({ stats }: Props) { 探索目的地 - 🧮 费用计算 - 🛠️ 工具箱 + 🗓️ 旅居计划 + ⚖️ 城市对比 diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx index 95c7910..f71abeb 100644 --- a/frontend/src/components/HomeClient.tsx +++ b/frontend/src/components/HomeClient.tsx @@ -3,7 +3,6 @@ import { useEffect, useState, type ComponentType } from "react"; import Link from "next/link"; import dynamic from "next/dynamic"; -import { api } from "@/lib/api"; import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types"; import Loader from "./Loader"; import ParticleCanvas from "./ParticleCanvas"; @@ -84,7 +83,6 @@ const BlogSection = lazy(() => import("./BlogSection")); const FAQSection = lazy(() => import("./FAQSection")); const Community = lazy(() => import("./Community")); const DestinationMatcher = lazy(() => import("./DestinationMatcher")); -const CompareModal = lazy(() => import("./CompareModal")); const CookieConsent = lazy(() => import("./CookieConsent")); const NomadTips = lazy(() => import("./NomadTips")); const FeedbackWidget = lazy(() => import("./FeedbackWidget")); @@ -105,8 +103,6 @@ interface Props { export default function HomeClient(props: Props) { const [compareList, setCompareList] = useState([]); - const [compareResults, setCompareResults] = useState(null); - const [compareOpen, setCompareOpen] = useState(false); const [matcherOpen, setMatcherOpen] = useState(false); const [chromeReady, setChromeReady] = useState(false); @@ -163,16 +159,11 @@ export default function HomeClient(props: Props) { if (prev.length >= 4) return prev; return [...prev, slug]; }); - setCompareResults(null); }; - const runCompare = async () => { + const runCompare = () => { if (compareList.length < 2) return; - try { - const data = await api.compareDestinations(compareList); - setCompareResults(data); - setCompareOpen(true); - } catch { /* ignore */ } + window.location.href = `/compare?cities=${compareList.join(",")}`; }; return ( @@ -266,7 +257,7 @@ export default function HomeClient(props: Props) { {compareList.length > 0 && (
⚖️ 已选 {compareList.length}/4 - + = 2 ? `/compare?cities=${compareList.join(",")}` : "/compare"} className={`btn btn-ghost${compareList.length < 2 ? " disabled" : ""}`} @@ -275,13 +266,9 @@ export default function HomeClient(props: Props) { > 完整对比页 - +
)} - - {compareOpen && compareResults && ( - setCompareOpen(false)} /> - )} ); } diff --git a/frontend/src/components/MovePlanClient.tsx b/frontend/src/components/MovePlanClient.tsx index df4ca9b..5a1dad2 100644 --- a/frontend/src/components/MovePlanClient.tsx +++ b/frontend/src/components/MovePlanClient.tsx @@ -301,7 +301,7 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) { ) : ( - 登录后跨设备同步计划 → + 登录后跨设备同步计划 → )}
diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index c572a32..e00a43c 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -9,7 +9,7 @@ const LINKS = [ { href: "/#map", label: "🗺️ 地图", section: "map" }, { href: "/#destinations", label: "🌍 目的地", section: "destinations" }, { href: "/plan", label: "🗓️ 计划", section: "plan" }, - { href: "/#calculator", label: "🧮 计算器", section: "calculator" }, + { href: "/compare", label: "⚖️ 对比", section: "compare" }, { href: "/tools", label: "🛠️ 工具箱", section: "tools" }, { href: "/#visa", label: "📋 签证", section: "visa" }, { href: "/#blog", label: "📝 博客", section: "blog" }, @@ -86,7 +86,7 @@ export default function Navbar() { {LINKS.map((l) => ( { if (localStorage.getItem("nomadro-onboarded")) return; @@ -25,16 +27,21 @@ export default function OnboardingTour() { }; const next = () => { + const s = STEPS[step]; + if (s.action === "matcher") { + window.dispatchEvent(new CustomEvent("open-matcher")); + } else if (s.action === "route" && s.href) { + finish(); + router.push(s.href); + return; + } else { + document.getElementById(s.id)?.scrollIntoView({ behavior: "smooth" }); + } + if (step >= STEPS.length - 1) { finish(); return; } - const s = STEPS[step]; - if (s.id === "matcher") { - window.dispatchEvent(new CustomEvent("open-matcher")); - } else { - document.getElementById(s.id)?.scrollIntoView({ behavior: "smooth" }); - } setStep((n) => n + 1); }; @@ -55,7 +62,7 @@ export default function OnboardingTour() { ))}
diff --git a/frontend/src/components/SectionNav.tsx b/frontend/src/components/SectionNav.tsx index 59c70ed..6b16ef3 100644 --- a/frontend/src/components/SectionNav.tsx +++ b/frontend/src/components/SectionNav.tsx @@ -4,13 +4,12 @@ import { useEffect, useState } from "react"; import Link from "next/link"; const PRIMARY = [ - { id: "map", emoji: "🗺️", label: "地图" }, - { id: "destinations", emoji: "🌍", label: "目的地" }, - { id: "trip", emoji: "🗓️", label: "行程" }, - { id: "calculator", emoji: "🧮", label: "费用" }, - { id: "visa", emoji: "📋", label: "签证" }, - { id: "events", emoji: "📅", label: "活动" }, - { id: "blog", emoji: "📝", label: "博客" }, + { id: "map", emoji: "🗺️", label: "地图", type: "scroll" as const }, + { id: "destinations", emoji: "🌍", label: "目的地", type: "scroll" as const }, + { id: "plan", emoji: "🗓️", label: "计划", type: "link" as const, href: "/plan" }, + { id: "compare", emoji: "⚖️", label: "对比", type: "link" as const, href: "/compare" }, + { id: "visa", emoji: "📋", label: "签证", type: "scroll" as const }, + { id: "blog", emoji: "📝", label: "博客", type: "scroll" as const }, ]; export default function SectionNav() { @@ -25,7 +24,8 @@ export default function SectionNav() { }, []); useEffect(() => { - const sections = PRIMARY.map((s) => document.getElementById(s.id)).filter(Boolean); + const scrollIds = PRIMARY.filter((s) => s.type === "scroll").map((s) => s.id); + const sections = scrollIds.map((id) => document.getElementById(id)).filter(Boolean); const observer = new IntersectionObserver( (entries) => entries.forEach((e) => { if (e.isIntersecting) setActive(e.target.id); }), { threshold: 0.25, rootMargin: "-80px 0px -55% 0px" } @@ -34,24 +34,33 @@ export default function SectionNav() { return () => observer.disconnect(); }, []); - const scrollTo = (id: string) => { - document.getElementById(id)?.scrollIntoView({ behavior: "smooth" }); - }; - return (