Polish discovery funnel: surface plan/compare, login next redirect, SEO metadata.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-29 04:17:50 -05:00
parent 11daabf065
commit 8ab9b9b9a1
18 changed files with 293 additions and 146 deletions

View File

@ -8,6 +8,16 @@ export const metadata: Metadata = {
}; };
const LOGS = [ const LOGS = [
{
date: "2026-08-29",
tag: "产品打磨",
items: [
"Hero / 导航 / 底栏 / 快捷条 / 新手引导统一导向计划与对比",
"登录支持 ?next= 回流,默认进入计划中心并强调云端同步",
"Sitemap 与 OG 补齐 /plan /compare;目的地页补 generateMetadata",
"首页对比直达对比台;行程区收束为计划中心入口",
],
},
{ {
date: "2026-08-29", date: "2026-08-29",
tag: "性能优化", tag: "性能优化",

View File

@ -8,6 +8,17 @@ import SiteShell from "@/components/SiteShell";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "城市对比 · nomadro", title: "城市对比 · nomadro",
description: "并排对比旅居城市费用、网速、气候与评分,决定后写入旅居计划", description: "并排对比旅居城市费用、网速、气候与评分,决定后写入旅居计划",
openGraph: {
title: "城市对比台 · nomadro",
description: "最多 4 城并排对比,推荐城一键写入旅居计划",
url: "/compare",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "城市对比台 · nomadro",
description: "最多 4 城并排对比,推荐城一键写入旅居计划",
},
}; };
export const revalidate = 60; export const revalidate = 60;

View File

@ -1,10 +1,30 @@
import Link from "next/link"; import Link from "next/link";
import type { Metadata } from "next";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import FavoriteButton from "@/components/FavoriteButton"; import FavoriteButton from "@/components/FavoriteButton";
import DestinationDetailClient from "@/components/DestinationDetailClient"; import DestinationDetailClient from "@/components/DestinationDetailClient";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
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 }> }) { export default async function DestinationPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; const { slug } = await params;
let dest; let dest;

View File

@ -9237,3 +9237,58 @@ img { max-width: 100%; display: block; }
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.5; 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; }
}

View File

@ -1,14 +1,25 @@
"use client"; "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 { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import Link from "next/link";
import SiteShell from "@/components/SiteShell"; 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 { login, register, demoLogin } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter();
const searchParams = useSearchParams();
const next = safeNext(searchParams.get("next"));
const [mode, setMode] = useState<"login" | "register">("login"); const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@ -17,6 +28,11 @@ export default function LoginPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const goAfterAuth = (msg: string) => {
toast(msg);
setTimeout(() => { router.replace(next); }, 400);
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
@ -26,8 +42,11 @@ export default function LoginPage() {
: await register(email, password, name); : await register(email, password, name);
setLoading(false); setLoading(false);
if (ok) { if (ok) {
toast(mode === "login" ? "欢迎回来!🌍" : "注册成功,开启旅居之旅 ✨"); goAfterAuth(
setTimeout(() => { window.location.href = "/"; }, 600); mode === "login"
? "欢迎回来!计划已开始同步 🗓️"
: "注册成功,旅居计划可跨设备同步 ✨"
);
} else { } else {
setError(mode === "login" ? "邮箱或密码错误" : "注册失败,邮箱可能已存在"); setError(mode === "login" ? "邮箱或密码错误" : "注册失败,邮箱可能已存在");
} }
@ -37,14 +56,10 @@ export default function LoginPage() {
setLoading(true); setLoading(true);
const ok = await demoLogin(); const ok = await demoLogin();
setLoading(false); setLoading(false);
if (ok) { if (ok) goAfterAuth("演示账号已登录 · 正在同步旅居计划 🎮");
toast("演示账号已登录 🎮");
setTimeout(() => { window.location.href = "/"; }, 600);
}
}; };
return ( return (
<SiteShell>
<div className="auth-page"> <div className="auth-page">
<div className="auth-bg-shapes" aria-hidden="true"> <div className="auth-bg-shapes" aria-hidden="true">
<span className="auth-shape auth-shape-1">🌍</span> <span className="auth-shape auth-shape-1">🌍</span>
@ -56,7 +71,16 @@ export default function LoginPage() {
<div className="auth-header"> <div className="auth-header">
<span className="auth-logo-emoji">🌍</span> <span className="auth-logo-emoji">🌍</span>
<h1>{mode === "login" ? "欢迎回来" : "加入 nomadro"}</h1> <h1>{mode === "login" ? "欢迎回来" : "加入 nomadro"}</h1>
<p>{mode === "login" ? "登录你的游民账户" : "开始你的旅居之旅"}</p> <p>
{mode === "login"
? "登录后同步旅居计划、收藏与就绪清单"
: "注册即可跨设备保存你的旅居计划"}
</p>
</div>
<div className="auth-sync-note">
🗓️ 登录后自动把本机计划与账号合并 · 完成后将前往
<Link href={next}>{next === "/plan" ? "旅居计划中心" : next}</Link>
</div> </div>
<div className="auth-tabs"> <div className="auth-tabs">
@ -93,7 +117,7 @@ export default function LoginPage() {
</div> </div>
{error && <p className="auth-error">{error}</p>} {error && <p className="auth-error">{error}</p>}
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}> <button type="submit" className="btn btn-primary auth-submit" disabled={loading}>
{loading ? <span className="auth-loading">处理中...</span> : (mode === "login" ? "🚀 登录" : "✨ 注册")} {loading ? <span className="auth-loading">处理中...</span> : (mode === "login" ? "🚀 登录并同步" : "✨ 注册并开始")}
</button> </button>
</form> </form>
@ -101,9 +125,18 @@ export default function LoginPage() {
<button className="btn btn-ghost" style={{ width: "100%" }} onClick={handleDemo} disabled={loading}> <button className="btn btn-ghost" style={{ width: "100%" }} onClick={handleDemo} disabled={loading}>
🎮 一键体验演示账号 🎮 一键体验演示账号
</button> </button>
<p className="auth-hint">演示账号:demo@nomadro.com / demo123</p> <p className="auth-hint">演示账号:demo@nomadro.com / demo123 · 登录后进入计划中心</p>
</div> </div>
</div> </div>
);
}
export default function LoginPage() {
return (
<SiteShell>
<Suspense fallback={<div className="auth-page"><p className="plan-loading">加载中…</p></div>}>
<LoginForm />
</Suspense>
</SiteShell> </SiteShell>
); );
} }

View File

@ -7,6 +7,17 @@ import SiteShell from "@/components/SiteShell";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "旅居计划中心 · nomadro", title: "旅居计划中心 · nomadro",
description: "多城时间轴、预算与月均、签证停留提醒、出发就绪清单、日历导出——一页做完旅居规划", description: "多城时间轴、预算与月均、签证停留提醒、出发就绪清单、日历导出——一页做完旅居规划",
openGraph: {
title: "旅居计划中心 · nomadro",
description: "时间轴、签证、预算与就绪清单——真正能执行的旅居计划",
url: "/plan",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "旅居计划中心 · nomadro",
description: "时间轴、签证、预算与就绪清单——真正能执行的旅居计划",
},
}; };
export const revalidate = 60; export const revalidate = 60;

View File

@ -9,10 +9,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
return [ return [
{ url: SITE, lastModified: now, changeFrequency: "weekly", priority: 1 }, { 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}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 }, { url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
{ url: `${SITE}/about`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, { 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}/changelog`, lastModified: now, changeFrequency: "weekly", priority: 0.4 },
{ url: `${SITE}/profile`, lastModified: now, changeFrequency: "monthly", priority: 0.4 }, { url: `${SITE}/profile`, lastModified: now, changeFrequency: "monthly", priority: 0.4 },
...DESTINATIONS.map((slug) => ({ ...DESTINATIONS.map((slug) => ({

View File

@ -38,6 +38,30 @@ export default function ToolsHubPage() {
<p>规划、金钱、工作、生活与安全——一站直达所有旅居工具</p> <p>规划、金钱、工作、生活与安全——一站直达所有旅居工具</p>
</div> </div>
<div className="tools-core-banner">
<Link href="/plan" className="tools-core-card">
<span>🗓️</span>
<div>
<strong>旅居计划中心</strong>
<p>时间轴 · 签证 · 预算 · 日历导出</p>
</div>
</Link>
<Link href="/compare" className="tools-core-card">
<span>⚖️</span>
<div>
<strong>城市对比台</strong>
<p>并排指标 · 一键写入计划</p>
</div>
</Link>
<Link href="/login?next=/plan" className="tools-core-card">
<span>☁️</span>
<div>
<strong>登录同步</strong>
<p>跨设备保存计划与收藏</p>
</div>
</Link>
</div>
<div className="tools-featured"> <div className="tools-featured">
<div className="tools-featured-head"> <div className="tools-featured-head">
<strong>✨ 本周推荐</strong> <strong>✨ 本周推荐</strong>

View File

@ -40,17 +40,18 @@ export default function Footer() {
<div className="footer-links"> <div className="footer-links">
<h4>探索</h4> <h4>探索</h4>
<Link href="/#destinations">目的地</Link> <Link href="/#destinations">目的地</Link>
<Link href="/plan">旅居计划</Link>
<Link href="/compare">城市对比</Link>
<Link href="/#visa">签证指南</Link> <Link href="/#visa">签证指南</Link>
<Link href="/#events">活动日历</Link>
<Link href="/#blog">博客</Link> <Link href="/#blog">博客</Link>
<Link href="/about">关于 nomadro</Link> <Link href="/about">关于 nomadro</Link>
</div> </div>
<div className="footer-links"> <div className="footer-links">
<h4>工具</h4> <h4>工具</h4>
<Link href="/tools">全部工具箱</Link> <Link href="/tools">全部工具箱</Link>
<Link href="/#trip">行程规划</Link> <Link href="/plan">计划中心</Link>
<Link href="/compare">对比台</Link>
<Link href="/#calculator">费用计算器</Link> <Link href="/#calculator">费用计算器</Link>
<Link href="/#atm">取现避坑</Link>
<Link href="/changelog">更新日志</Link> <Link href="/changelog">更新日志</Link>
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a> <a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
</div> </div>

View File

@ -119,8 +119,8 @@ export default function Hero({ stats }: Props) {
<span>探索目的地</span> <span>探索目的地</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M12 5l7 7-7 7" /></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
</a> </a>
<a href="#calculator" className="btn btn-ghost"><span>🧮 费用计算</span></a> <a href="/plan" className="btn btn-ghost"><span>🗓️ 旅居计划</span></a>
<a href="/tools" className="btn btn-ghost"><span>🛠️ 工具箱</span></a> <a href="/compare" className="btn btn-ghost"><span>⚖️ 城市对比</span></a>
<button className="btn btn-ghost hero-matcher-btn" onClick={() => { <button className="btn btn-ghost hero-matcher-btn" onClick={() => {
window.dispatchEvent(new CustomEvent("open-matcher")); window.dispatchEvent(new CustomEvent("open-matcher"));
}}><span>🎯 智能匹配</span></button> }}><span>🎯 智能匹配</span></button>

View File

@ -3,7 +3,6 @@
import { useEffect, useState, type ComponentType } from "react"; import { useEffect, useState, type ComponentType } from "react";
import Link from "next/link"; import Link from "next/link";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { api } from "@/lib/api";
import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types"; import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types";
import Loader from "./Loader"; import Loader from "./Loader";
import ParticleCanvas from "./ParticleCanvas"; import ParticleCanvas from "./ParticleCanvas";
@ -84,7 +83,6 @@ const BlogSection = lazy(() => import("./BlogSection"));
const FAQSection = lazy(() => import("./FAQSection")); const FAQSection = lazy(() => import("./FAQSection"));
const Community = lazy(() => import("./Community")); const Community = lazy(() => import("./Community"));
const DestinationMatcher = lazy(() => import("./DestinationMatcher")); const DestinationMatcher = lazy(() => import("./DestinationMatcher"));
const CompareModal = lazy(() => import("./CompareModal"));
const CookieConsent = lazy(() => import("./CookieConsent")); const CookieConsent = lazy(() => import("./CookieConsent"));
const NomadTips = lazy(() => import("./NomadTips")); const NomadTips = lazy(() => import("./NomadTips"));
const FeedbackWidget = lazy(() => import("./FeedbackWidget")); const FeedbackWidget = lazy(() => import("./FeedbackWidget"));
@ -105,8 +103,6 @@ interface Props {
export default function HomeClient(props: Props) { export default function HomeClient(props: Props) {
const [compareList, setCompareList] = useState<string[]>([]); const [compareList, setCompareList] = useState<string[]>([]);
const [compareResults, setCompareResults] = useState<Destination[] | null>(null);
const [compareOpen, setCompareOpen] = useState(false);
const [matcherOpen, setMatcherOpen] = useState(false); const [matcherOpen, setMatcherOpen] = useState(false);
const [chromeReady, setChromeReady] = useState(false); const [chromeReady, setChromeReady] = useState(false);
@ -163,16 +159,11 @@ export default function HomeClient(props: Props) {
if (prev.length >= 4) return prev; if (prev.length >= 4) return prev;
return [...prev, slug]; return [...prev, slug];
}); });
setCompareResults(null);
}; };
const runCompare = async () => { const runCompare = () => {
if (compareList.length < 2) return; if (compareList.length < 2) return;
try { window.location.href = `/compare?cities=${compareList.join(",")}`;
const data = await api.compareDestinations(compareList);
setCompareResults(data);
setCompareOpen(true);
} catch { /* ignore */ }
}; };
return ( return (
@ -266,7 +257,7 @@ export default function HomeClient(props: Props) {
{compareList.length > 0 && ( {compareList.length > 0 && (
<div className="compare-bar"> <div className="compare-bar">
<span>⚖️ 已选 {compareList.length}/4</span> <span>⚖️ 已选 {compareList.length}/4</span>
<button className="btn btn-primary" onClick={runCompare} disabled={compareList.length < 2}>快速对比</button> <button className="btn btn-primary" onClick={runCompare} disabled={compareList.length < 2}>打开对比台</button>
<Link <Link
href={compareList.length >= 2 ? `/compare?cities=${compareList.join(",")}` : "/compare"} href={compareList.length >= 2 ? `/compare?cities=${compareList.join(",")}` : "/compare"}
className={`btn btn-ghost${compareList.length < 2 ? " disabled" : ""}`} className={`btn btn-ghost${compareList.length < 2 ? " disabled" : ""}`}
@ -275,13 +266,9 @@ export default function HomeClient(props: Props) {
> >
完整对比页 完整对比页
</Link> </Link>
<button className="btn btn-ghost" onClick={() => { setCompareList([]); setCompareResults(null); }}>清空</button> <button className="btn btn-ghost" onClick={() => setCompareList([])}>清空</button>
</div> </div>
)} )}
{compareOpen && compareResults && (
<CompareModal destinations={compareResults} onClose={() => setCompareOpen(false)} />
)}
</> </>
); );
} }

View File

@ -301,7 +301,7 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
</button> </button>
</> </>
) : ( ) : (
<Link href="/login" className="plan-sync-pill idle">登录后跨设备同步计划 →</Link> <Link href="/login?next=/plan" className="plan-sync-pill idle">登录后跨设备同步计划 →</Link>
)} )}
</div> </div>
<div className="plan-meta-row"> <div className="plan-meta-row">

View File

@ -9,7 +9,7 @@ const LINKS = [
{ href: "/#map", label: "🗺️ 地图", section: "map" }, { href: "/#map", label: "🗺️ 地图", section: "map" },
{ href: "/#destinations", label: "🌍 目的地", section: "destinations" }, { href: "/#destinations", label: "🌍 目的地", section: "destinations" },
{ href: "/plan", label: "🗓️ 计划", section: "plan" }, { href: "/plan", label: "🗓️ 计划", section: "plan" },
{ href: "/#calculator", label: "🧮 计算器", section: "calculator" }, { href: "/compare", label: "⚖️ 对比", section: "compare" },
{ href: "/tools", label: "🛠️ 工具箱", section: "tools" }, { href: "/tools", label: "🛠️ 工具箱", section: "tools" },
{ href: "/#visa", label: "📋 签证", section: "visa" }, { href: "/#visa", label: "📋 签证", section: "visa" },
{ href: "/#blog", label: "📝 博客", section: "blog" }, { href: "/#blog", label: "📝 博客", section: "blog" },
@ -86,7 +86,7 @@ export default function Navbar() {
{LINKS.map((l) => ( {LINKS.map((l) => (
<Link key={l.section} href={l.href} data-section={l.section} <Link key={l.section} href={l.href} data-section={l.section}
className={ className={
l.href === "/tools" || l.href === "/plan" l.href === "/tools" || l.href === "/plan" || l.href === "/compare"
? (pathname === l.href ? "active" : "") ? (pathname === l.href ? "active" : "")
: (isHome && active === l.section ? "active" : "") : (isHome && active === l.section ? "active" : "")
} }

View File

@ -1,17 +1,19 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
const STEPS = [ const STEPS = [
{ id: "destinations", emoji: "🌍", title: "探索目的地", desc: "筛选城市、收藏与对比" }, { id: "destinations", emoji: "🌍", title: "探索目的地", desc: "筛选城市、收藏与加入计划", action: "scroll" as const },
{ id: "matcher", emoji: "🎯", title: "智能匹配", desc: "问卷推荐最适合你的城市" }, { id: "matcher", emoji: "🎯", title: "智能匹配", desc: "问卷推荐城市,一键写入计划", action: "matcher" as const },
{ id: "trip", emoji: "🗓️", title: "规划行程", desc: "多城串联并估算预算" }, { id: "plan", emoji: "🗓️", title: "旅居计划", desc: "时间轴、签证、预算与日历导出", action: "route" as const, href: "/plan" },
{ id: "tools", emoji: "🛠️", title: "用好工具", desc: "签证、保险、网速与账本" }, { id: "compare", emoji: "⚖️", title: "城市对比", desc: "并排指标,决定后写入计划", action: "route" as const, href: "/compare" },
]; ];
export default function OnboardingTour() { export default function OnboardingTour() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const router = useRouter();
useEffect(() => { useEffect(() => {
if (localStorage.getItem("nomadro-onboarded")) return; if (localStorage.getItem("nomadro-onboarded")) return;
@ -25,16 +27,21 @@ export default function OnboardingTour() {
}; };
const next = () => { 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) { if (step >= STEPS.length - 1) {
finish(); finish();
return; 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); setStep((n) => n + 1);
}; };
@ -55,7 +62,7 @@ export default function OnboardingTour() {
))} ))}
</div> </div>
<button className="btn btn-primary btn-sm" onClick={next}> <button className="btn btn-primary btn-sm" onClick={next}>
{step >= STEPS.length - 1 ? "开始探索 🚀" : "下一步 →"} {step >= STEPS.length - 1 ? "打开对比台 →" : "下一步 →"}
</button> </button>
</div> </div>
</div> </div>

View File

@ -4,13 +4,12 @@ import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
const PRIMARY = [ const PRIMARY = [
{ id: "map", emoji: "🗺️", label: "地图" }, { id: "map", emoji: "🗺️", label: "地图", type: "scroll" as const },
{ id: "destinations", emoji: "🌍", label: "目的地" }, { id: "destinations", emoji: "🌍", label: "目的地", type: "scroll" as const },
{ id: "trip", emoji: "🗓️", label: "行程" }, { id: "plan", emoji: "🗓️", label: "计划", type: "link" as const, href: "/plan" },
{ id: "calculator", emoji: "🧮", label: "费用" }, { id: "compare", emoji: "⚖️", label: "对比", type: "link" as const, href: "/compare" },
{ id: "visa", emoji: "📋", label: "签证" }, { id: "visa", emoji: "📋", label: "签证", type: "scroll" as const },
{ id: "events", emoji: "📅", label: "活动" }, { id: "blog", emoji: "📝", label: "博客", type: "scroll" as const },
{ id: "blog", emoji: "📝", label: "博客" },
]; ];
export default function SectionNav() { export default function SectionNav() {
@ -25,7 +24,8 @@ export default function SectionNav() {
}, []); }, []);
useEffect(() => { 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( const observer = new IntersectionObserver(
(entries) => entries.forEach((e) => { if (e.isIntersecting) setActive(e.target.id); }), (entries) => entries.forEach((e) => { if (e.isIntersecting) setActive(e.target.id); }),
{ threshold: 0.25, rootMargin: "-80px 0px -55% 0px" } { threshold: 0.25, rootMargin: "-80px 0px -55% 0px" }
@ -34,24 +34,33 @@ export default function SectionNav() {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
const scrollTo = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
};
return ( return (
<nav className={`section-nav${visible ? " visible" : ""}`} aria-label="章节导航"> <nav className={`section-nav${visible ? " visible" : ""}`} aria-label="章节导航">
{PRIMARY.map((s) => ( {PRIMARY.map((s) =>
s.type === "link" ? (
<Link
key={s.id}
href={s.href!}
className="section-nav-dot"
title={s.label}
aria-label={s.label}
>
<span className="section-nav-emoji">{s.emoji}</span>
<span className="section-nav-label">{s.label}</span>
</Link>
) : (
<button <button
key={s.id} key={s.id}
className={`section-nav-dot${active === s.id ? " active" : ""}`} className={`section-nav-dot${active === s.id ? " active" : ""}`}
onClick={() => scrollTo(s.id)} onClick={() => document.getElementById(s.id)?.scrollIntoView({ behavior: "smooth" })}
aria-label={s.label} aria-label={s.label}
title={s.label} title={s.label}
> >
<span className="section-nav-emoji">{s.emoji}</span> <span className="section-nav-emoji">{s.emoji}</span>
<span className="section-nav-label">{s.label}</span> <span className="section-nav-label">{s.label}</span>
</button> </button>
))} )
)}
<Link href="/tools" className="section-nav-dot section-nav-tools" title="全部工具" aria-label="全部工具"> <Link href="/tools" className="section-nav-dot section-nav-tools" title="全部工具" aria-label="全部工具">
<span className="section-nav-emoji">🛠️</span> <span className="section-nav-emoji">🛠️</span>
<span className="section-nav-label">工具</span> <span className="section-nav-label">工具</span>

View File

@ -3,24 +3,24 @@
import Link from "next/link"; import Link from "next/link";
const QUICK = [ const QUICK = [
{ href: "/plan", emoji: "🗓️", label: "计划" },
{ href: "/compare", emoji: "⚖️", label: "对比" },
{ href: "/#destinations", emoji: "🌍", label: "目的地" },
{ href: "/#visa", emoji: "📋", label: "签证" },
{ href: "/tools", emoji: "🛠️", label: "工具箱" },
{ href: "/#weekend", emoji: "🎉", label: "周末" }, { href: "/#weekend", emoji: "🎉", label: "周末" },
{ href: "/#day-plan", emoji: "🧩", label: "日程" }, { href: "/#calculator", emoji: "🧮", label: "费用" },
{ href: "/#bill-split", emoji: "➗", label: "分账" }, { href: "/changelog", emoji: "📜", label: "更新" },
{ href: "/#mood", emoji: "🪞", label: "心情" },
{ href: "/#scam-alerts", emoji: "🚨", label: "防坑" },
{ href: "/#deposit-return", emoji: "🔐", label: "押金" },
{ href: "/#airport-transfer", emoji: "🚕", label: "接驳" },
{ href: "/tools", emoji: "🛠️", label: "全部" },
]; ];
export default function ToolsStrip() { export default function ToolsStrip() {
return ( return (
<section className="tools-strip" id="tools-strip" aria-label="快捷工具"> <section className="tools-strip" id="tools-strip" aria-label="核心入口">
<div className="container"> <div className="container">
<div className="tools-strip-inner reveal"> <div className="tools-strip-inner reveal">
<div className="tools-strip-head"> <div className="tools-strip-head">
<strong>⚡ 常用工具</strong> <strong>⚡ 核心功能</strong>
<Link href="/tools">打开工具箱 →</Link> <Link href="/plan">打开旅居计划 →</Link>
</div> </div>
<div className="tools-strip-list"> <div className="tools-strip-list">
{QUICK.map((t) => ( {QUICK.map((t) => (

View File

@ -2,14 +2,17 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { loadTrip, saveTrip } from "@/lib/tripStorage"; import { loadTrip, saveTrip } from "@/lib/tripStorage";
import type { Destination, TripItem } from "@/lib/types"; import type { Destination, TripItem } from "@/lib/types";
interface Props { destinations: Destination[] } interface Props { destinations: Destination[] }
/** Homepage teaser — full editing lives on /plan */
export default function TripPlanner({ destinations }: Props) { export default function TripPlanner({ destinations }: Props) {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter();
const [trip, setTrip] = useState<TripItem[]>([]); const [trip, setTrip] = useState<TripItem[]>([]);
const [selected, setSelected] = useState(""); const [selected, setSelected] = useState("");
const [months, setMonths] = useState(1); const [months, setMonths] = useState(1);
@ -21,16 +24,15 @@ export default function TripPlanner({ destinations }: Props) {
try { try {
const decoded = JSON.parse(atob(shared)) as TripItem[]; const decoded = JSON.parse(atob(shared)) as TripItem[];
if (Array.isArray(decoded) && decoded.length > 0) { if (Array.isArray(decoded) && decoded.length > 0) {
setTrip(decoded);
saveTrip(decoded); saveTrip(decoded);
toast("已导入分享的行程 🗺️"); toast("已导入行程,正在打开计划中心");
window.history.replaceState({}, "", window.location.pathname + "#trip"); router.replace(`/plan?trip=${shared}`);
}
} catch { /* ignore */ }
return; return;
} }
} catch { /* ignore */ }
}
setTrip(loadTrip<TripItem>()); setTrip(loadTrip<TripItem>());
}, [toast]); }, [toast, router]);
const save = (items: TripItem[]) => { const save = (items: TripItem[]) => {
setTrip(items); setTrip(items);
@ -46,66 +48,28 @@ export default function TripPlanner({ destinations }: Props) {
}]); }]);
setSelected(""); setSelected("");
setMonths(1); setMonths(1);
toast(`${dest.emoji} 已加入 · 完整功能请打开计划中心`);
}; };
const removeCity = (slug: string) => save(trip.filter((t) => t.slug !== slug)); const removeCity = (slug: string) => save(trip.filter((t) => t.slug !== slug));
const updateMonths = (slug: string, m: number) =>
save(trip.map((t) => t.slug === slug ? { ...t, months: m } : t));
const totalCost = trip.reduce((sum, t) => sum + t.cost * t.months, 0); const totalCost = trip.reduce((sum, t) => sum + t.cost * t.months, 0);
const totalMonths = trip.reduce((sum, t) => sum + t.months, 0); const totalMonths = trip.reduce((sum, t) => sum + t.months, 0);
const shareTrip = async () => {
const encoded = btoa(JSON.stringify(trip));
const url = `${window.location.origin}/plan?trip=${encoded}`;
await navigator.clipboard.writeText(url);
toast("分享链接已复制!发送给好友即可导入行程 🔗");
};
const exportTrip = () => {
const text = trip.map((t, i) =>
`${i + 1}. ${t.emoji} ${t.name}, ${t.country} — ${t.months}个月 (¥${(t.cost * t.months).toLocaleString()})`
).join("\n") + `\n\n总计: ${totalMonths}个月 · ¥${totalCost.toLocaleString()}`;
navigator.clipboard.writeText(text);
toast("行程已复制到剪贴板 📋");
};
const downloadTrip = () => {
const lines = [
"# nomadro 旅居行程",
"",
...trip.map((t, i) =>
`${i + 1}. ${t.emoji} **${t.name}, ${t.country}** — ${t.months} 个月 · ¥${(t.cost * t.months).toLocaleString()}`
),
"",
`> 总计 **${totalMonths} 个月** · **¥${totalCost.toLocaleString()}**`,
"",
`_由 nomadro 生成 · ${new Date().toLocaleDateString("zh-CN")}_`,
];
const blob = new Blob([lines.join("\n")], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `nomadro-trip-${Date.now()}.md`;
a.click();
URL.revokeObjectURL(url);
toast("已下载 Markdown 行程文件 📄");
};
return ( return (
<section className="section trip-section" id="trip"> <section className="section trip-section" id="trip">
<div className="container"> <div className="container">
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">🗓️ TRIP PLANNER</span> <span className="section-tag">🗓️ MOVE PLAN</span>
<h2>行程规划器</h2> <h2>旅居计划</h2>
<p>快速搭路线看预算;完整时间轴、签证提醒与出发清单请用计划中心</p> <p>在首页快速加城;签证提醒、就绪清单、日历导出与云端同步请用计划中心</p>
<Link href="/plan" className="btn btn-primary" style={{ marginTop: 12 }}> <div className="trip-hero-actions">
打开旅居计划中心 → <Link href="/plan" className="btn btn-primary">打开旅居计划中心 →</Link>
</Link> <Link href="/compare" className="btn btn-ghost">城市对比台</Link>
</div>
</div> </div>
<div className="trip-wrapper reveal"> <div className="trip-wrapper reveal">
<div className="trip-form"> <div className="trip-form">
<h3>➕ 添加城市</h3> <h3>➕ 快速添加</h3>
<div className="calc-field"> <div className="calc-field">
<label>🏙️ 选择目的地</label> <label>🏙️ 选择目的地</label>
<select value={selected} onChange={(e) => setSelected(e.target.value)}> <select value={selected} onChange={(e) => setSelected(e.target.value)}>
@ -120,7 +84,7 @@ export default function TripPlanner({ destinations }: Props) {
<input type="range" min={1} max={12} value={months} onChange={(e) => setMonths(+e.target.value)} /> <input type="range" min={1} max={12} value={months} onChange={(e) => setMonths(+e.target.value)} />
</div> </div>
<button className="btn btn-primary" onClick={addCity} disabled={!selected}> <button className="btn btn-primary" onClick={addCity} disabled={!selected}>
添加到行程 🚀 添加到计划
</button> </button>
</div> </div>
@ -128,7 +92,8 @@ export default function TripPlanner({ destinations }: Props) {
{trip.length === 0 ? ( {trip.length === 0 ? (
<div className="trip-empty"> <div className="trip-empty">
<span style={{ fontSize: "3rem" }}>🗺️</span> <span style={{ fontSize: "3rem" }}>🗺️</span>
<p>还没有添加城市,开始规划你的旅程吧</p> <p>还没有城市。去匹配推荐,或打开计划中心开始。</p>
<Link href="/plan" className="btn btn-ghost" style={{ marginTop: 12 }}>去计划中心 →</Link>
</div> </div>
) : ( ) : (
<> <>
@ -143,10 +108,7 @@ export default function TripPlanner({ destinations }: Props) {
<button onClick={() => removeCity(t.slug)} className="trip-remove">✕</button> <button onClick={() => removeCity(t.slug)} className="trip-remove">✕</button>
</div> </div>
<div className="trip-stop-controls"> <div className="trip-stop-controls">
<label>停留</label> <span>{t.months} 个月</span>
<input type="number" min={1} max={24} value={t.months}
onChange={(e) => updateMonths(t.slug, +e.target.value)} />
<span>个月</span>
<span className="trip-stop-cost">¥{(t.cost * t.months).toLocaleString()}</span> <span className="trip-stop-cost">¥{(t.cost * t.months).toLocaleString()}</span>
</div> </div>
</div> </div>
@ -166,10 +128,15 @@ export default function TripPlanner({ destinations }: Props) {
<span>🏙️ 城市数</span> <span>🏙️ 城市数</span>
<strong>{trip.length} 个</strong> <strong>{trip.length} 个</strong>
</div> </div>
<button className="btn btn-ghost" onClick={exportTrip}>📋 复制行程</button> <Link href="/plan" className="btn btn-primary">继续完善计划 →</Link>
<button className="btn btn-ghost" onClick={downloadTrip}>📄 下载 MD</button> {trip.length >= 2 && (
<button className="btn btn-ghost" onClick={shareTrip}>🔗 分享行程</button> <Link
<button className="btn btn-ghost" onClick={() => save([])}>🗑️ 清空</button> href={`/compare?cities=${trip.map((t) => t.slug).slice(0, 4).join(",")}`}
className="btn btn-ghost"
>
对比这些城市
</Link>
)}
</div> </div>
</> </>
)} )}

View File

@ -43,6 +43,16 @@ FILES = [
"frontend/src/components/WhenVisible.tsx", "frontend/src/components/WhenVisible.tsx",
"frontend/src/app/layout.tsx", "frontend/src/app/layout.tsx",
"frontend/next.config.ts", "frontend/next.config.ts",
"frontend/src/components/Hero.tsx",
"frontend/src/components/ToolsStrip.tsx",
"frontend/src/components/SectionNav.tsx",
"frontend/src/components/Footer.tsx",
"frontend/src/components/OnboardingTour.tsx",
"frontend/src/components/TripPlanner.tsx",
"frontend/src/app/login/page.tsx",
"frontend/src/app/sitemap.ts",
"frontend/src/app/tools/page.tsx",
"frontend/src/app/destinations/[slug]/page.tsx",
"frontend/src/lib/types.ts", "frontend/src/lib/types.ts",
"frontend/src/lib/planMeta.ts", "frontend/src/lib/planMeta.ts",
"frontend/src/lib/compareScore.ts", "frontend/src/lib/compareScore.ts",