Polish discovery funnel: surface plan/compare, login next redirect, SEO metadata.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
11daabf065
commit
8ab9b9b9a1
@ -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: "性能优化",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<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 }> }) {
|
||||
const { slug } = await params;
|
||||
let dest;
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<SiteShell>
|
||||
<div className="auth-page">
|
||||
<div className="auth-bg-shapes" aria-hidden="true">
|
||||
<span className="auth-shape auth-shape-1">🌍</span>
|
||||
@ -56,7 +71,16 @@ export default function LoginPage() {
|
||||
<div className="auth-header">
|
||||
<span className="auth-logo-emoji">🌍</span>
|
||||
<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 className="auth-tabs">
|
||||
@ -93,7 +117,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
<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>
|
||||
</form>
|
||||
|
||||
@ -101,9 +125,18 @@ export default function LoginPage() {
|
||||
<button className="btn btn-ghost" style={{ width: "100%" }} onClick={handleDemo} disabled={loading}>
|
||||
🎮 一键体验演示账号
|
||||
</button>
|
||||
<p className="auth-hint">演示账号:demo@nomadro.com / demo123</p>
|
||||
<p className="auth-hint">演示账号:demo@nomadro.com / demo123 · 登录后进入计划中心</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<SiteShell>
|
||||
<Suspense fallback={<div className="auth-page"><p className="plan-loading">加载中…</p></div>}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -38,6 +38,30 @@ export default function ToolsHubPage() {
|
||||
<p>规划、金钱、工作、生活与安全——一站直达所有旅居工具</p>
|
||||
</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-head">
|
||||
<strong>✨ 本周推荐</strong>
|
||||
|
||||
@ -40,17 +40,18 @@ export default function Footer() {
|
||||
<div className="footer-links">
|
||||
<h4>探索</h4>
|
||||
<Link href="/#destinations">目的地</Link>
|
||||
<Link href="/plan">旅居计划</Link>
|
||||
<Link href="/compare">城市对比</Link>
|
||||
<Link href="/#visa">签证指南</Link>
|
||||
<Link href="/#events">活动日历</Link>
|
||||
<Link href="/#blog">博客</Link>
|
||||
<Link href="/about">关于 nomadro</Link>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<h4>工具</h4>
|
||||
<Link href="/tools">全部工具箱</Link>
|
||||
<Link href="/#trip">行程规划</Link>
|
||||
<Link href="/plan">计划中心</Link>
|
||||
<Link href="/compare">对比台</Link>
|
||||
<Link href="/#calculator">费用计算器</Link>
|
||||
<Link href="/#atm">取现避坑</Link>
|
||||
<Link href="/changelog">更新日志</Link>
|
||||
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
|
||||
</div>
|
||||
|
||||
@ -119,8 +119,8 @@ export default function Hero({ stats }: Props) {
|
||||
<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>
|
||||
</a>
|
||||
<a href="#calculator" className="btn btn-ghost"><span>🧮 费用计算</span></a>
|
||||
<a href="/tools" className="btn btn-ghost"><span>🛠️ 工具箱</span></a>
|
||||
<a href="/plan" 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={() => {
|
||||
window.dispatchEvent(new CustomEvent("open-matcher"));
|
||||
}}><span>🎯 智能匹配</span></button>
|
||||
|
||||
@ -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<string[]>([]);
|
||||
const [compareResults, setCompareResults] = useState<Destination[] | null>(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 && (
|
||||
<div className="compare-bar">
|
||||
<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
|
||||
href={compareList.length >= 2 ? `/compare?cities=${compareList.join(",")}` : "/compare"}
|
||||
className={`btn btn-ghost${compareList.length < 2 ? " disabled" : ""}`}
|
||||
@ -275,13 +266,9 @@ export default function HomeClient(props: Props) {
|
||||
>
|
||||
完整对比页
|
||||
</Link>
|
||||
<button className="btn btn-ghost" onClick={() => { setCompareList([]); setCompareResults(null); }}>清空</button>
|
||||
<button className="btn btn-ghost" onClick={() => setCompareList([])}>清空</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{compareOpen && compareResults && (
|
||||
<CompareModal destinations={compareResults} onClose={() => setCompareOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -301,7 +301,7 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link href="/login" className="plan-sync-pill idle">登录后跨设备同步计划 →</Link>
|
||||
<Link href="/login?next=/plan" className="plan-sync-pill idle">登录后跨设备同步计划 →</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="plan-meta-row">
|
||||
|
||||
@ -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) => (
|
||||
<Link key={l.section} href={l.href} data-section={l.section}
|
||||
className={
|
||||
l.href === "/tools" || l.href === "/plan"
|
||||
l.href === "/tools" || l.href === "/plan" || l.href === "/compare"
|
||||
? (pathname === l.href ? "active" : "")
|
||||
: (isHome && active === l.section ? "active" : "")
|
||||
}
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const STEPS = [
|
||||
{ id: "destinations", emoji: "🌍", title: "探索目的地", desc: "筛选城市、收藏与对比" },
|
||||
{ id: "matcher", emoji: "🎯", title: "智能匹配", desc: "问卷推荐最适合你的城市" },
|
||||
{ id: "trip", emoji: "🗓️", title: "规划行程", desc: "多城串联并估算预算" },
|
||||
{ id: "tools", emoji: "🛠️", title: "用好工具", desc: "签证、保险、网速与账本" },
|
||||
{ id: "destinations", emoji: "🌍", title: "探索目的地", desc: "筛选城市、收藏与加入计划", action: "scroll" as const },
|
||||
{ id: "matcher", emoji: "🎯", title: "智能匹配", desc: "问卷推荐城市,一键写入计划", action: "matcher" as const },
|
||||
{ id: "plan", emoji: "🗓️", title: "旅居计划", desc: "时间轴、签证、预算与日历导出", action: "route" as const, href: "/plan" },
|
||||
{ id: "compare", emoji: "⚖️", title: "城市对比", desc: "并排指标,决定后写入计划", action: "route" as const, href: "/compare" },
|
||||
];
|
||||
|
||||
export default function OnboardingTour() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState(0);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
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() {
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={next}>
|
||||
{step >= STEPS.length - 1 ? "开始探索 🚀" : "下一步 →"}
|
||||
{step >= STEPS.length - 1 ? "打开对比台 →" : "下一步 →"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 (
|
||||
<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
|
||||
key={s.id}
|
||||
className={`section-nav-dot${active === s.id ? " active" : ""}`}
|
||||
onClick={() => scrollTo(s.id)}
|
||||
onClick={() => document.getElementById(s.id)?.scrollIntoView({ behavior: "smooth" })}
|
||||
aria-label={s.label}
|
||||
title={s.label}
|
||||
>
|
||||
<span className="section-nav-emoji">{s.emoji}</span>
|
||||
<span className="section-nav-label">{s.label}</span>
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
<Link href="/tools" className="section-nav-dot section-nav-tools" title="全部工具" aria-label="全部工具">
|
||||
<span className="section-nav-emoji">🛠️</span>
|
||||
<span className="section-nav-label">工具</span>
|
||||
|
||||
@ -3,24 +3,24 @@
|
||||
import Link from "next/link";
|
||||
|
||||
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: "/#day-plan", emoji: "🧩", label: "日程" },
|
||||
{ href: "/#bill-split", 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: "全部" },
|
||||
{ href: "/#calculator", emoji: "🧮", label: "费用" },
|
||||
{ href: "/changelog", emoji: "📜", label: "更新" },
|
||||
];
|
||||
|
||||
export default function ToolsStrip() {
|
||||
return (
|
||||
<section className="tools-strip" id="tools-strip" aria-label="快捷工具">
|
||||
<section className="tools-strip" id="tools-strip" aria-label="核心入口">
|
||||
<div className="container">
|
||||
<div className="tools-strip-inner reveal">
|
||||
<div className="tools-strip-head">
|
||||
<strong>⚡ 常用工具</strong>
|
||||
<Link href="/tools">打开工具箱 →</Link>
|
||||
<strong>⚡ 核心功能</strong>
|
||||
<Link href="/plan">打开旅居计划 →</Link>
|
||||
</div>
|
||||
<div className="tools-strip-list">
|
||||
{QUICK.map((t) => (
|
||||
|
||||
@ -2,14 +2,17 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { loadTrip, saveTrip } from "@/lib/tripStorage";
|
||||
import type { Destination, TripItem } from "@/lib/types";
|
||||
|
||||
interface Props { destinations: Destination[] }
|
||||
|
||||
/** Homepage teaser — full editing lives on /plan */
|
||||
export default function TripPlanner({ destinations }: Props) {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const [trip, setTrip] = useState<TripItem[]>([]);
|
||||
const [selected, setSelected] = useState("");
|
||||
const [months, setMonths] = useState(1);
|
||||
@ -21,16 +24,15 @@ export default function TripPlanner({ destinations }: Props) {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(shared)) as TripItem[];
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
setTrip(decoded);
|
||||
saveTrip(decoded);
|
||||
toast("已导入分享的行程 🗺️");
|
||||
window.history.replaceState({}, "", window.location.pathname + "#trip");
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
toast("已导入行程,正在打开计划中心");
|
||||
router.replace(`/plan?trip=${shared}`);
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
setTrip(loadTrip<TripItem>());
|
||||
}, [toast]);
|
||||
}, [toast, router]);
|
||||
|
||||
const save = (items: TripItem[]) => {
|
||||
setTrip(items);
|
||||
@ -46,66 +48,28 @@ export default function TripPlanner({ destinations }: Props) {
|
||||
}]);
|
||||
setSelected("");
|
||||
setMonths(1);
|
||||
toast(`${dest.emoji} 已加入 · 完整功能请打开计划中心`);
|
||||
};
|
||||
|
||||
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 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 (
|
||||
<section className="section trip-section" id="trip">
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">🗓️ TRIP PLANNER</span>
|
||||
<h2>行程规划器</h2>
|
||||
<p>快速搭路线看预算;完整时间轴、签证提醒与出发清单请用计划中心</p>
|
||||
<Link href="/plan" className="btn btn-primary" style={{ marginTop: 12 }}>
|
||||
打开旅居计划中心 →
|
||||
</Link>
|
||||
<span className="section-tag">🗓️ MOVE PLAN</span>
|
||||
<h2>旅居计划</h2>
|
||||
<p>在首页快速加城;签证提醒、就绪清单、日历导出与云端同步请用计划中心</p>
|
||||
<div className="trip-hero-actions">
|
||||
<Link href="/plan" className="btn btn-primary">打开旅居计划中心 →</Link>
|
||||
<Link href="/compare" className="btn btn-ghost">城市对比台</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="trip-wrapper reveal">
|
||||
<div className="trip-form">
|
||||
<h3>➕ 添加城市</h3>
|
||||
<h3>➕ 快速添加</h3>
|
||||
<div className="calc-field">
|
||||
<label>🏙️ 选择目的地</label>
|
||||
<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)} />
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={addCity} disabled={!selected}>
|
||||
添加到行程 🚀
|
||||
添加到计划
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -128,7 +92,8 @@ export default function TripPlanner({ destinations }: Props) {
|
||||
{trip.length === 0 ? (
|
||||
<div className="trip-empty">
|
||||
<span style={{ fontSize: "3rem" }}>🗺️</span>
|
||||
<p>还没有添加城市,开始规划你的旅程吧</p>
|
||||
<p>还没有城市。去匹配推荐,或打开计划中心开始。</p>
|
||||
<Link href="/plan" className="btn btn-ghost" style={{ marginTop: 12 }}>去计划中心 →</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@ -143,10 +108,7 @@ export default function TripPlanner({ destinations }: Props) {
|
||||
<button onClick={() => removeCity(t.slug)} className="trip-remove">✕</button>
|
||||
</div>
|
||||
<div className="trip-stop-controls">
|
||||
<label>停留</label>
|
||||
<input type="number" min={1} max={24} value={t.months}
|
||||
onChange={(e) => updateMonths(t.slug, +e.target.value)} />
|
||||
<span>个月</span>
|
||||
<span>{t.months} 个月</span>
|
||||
<span className="trip-stop-cost">¥{(t.cost * t.months).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -166,10 +128,15 @@ export default function TripPlanner({ destinations }: Props) {
|
||||
<span>🏙️ 城市数</span>
|
||||
<strong>{trip.length} 个</strong>
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={exportTrip}>📋 复制行程</button>
|
||||
<button className="btn btn-ghost" onClick={downloadTrip}>📄 下载 MD</button>
|
||||
<button className="btn btn-ghost" onClick={shareTrip}>🔗 分享行程</button>
|
||||
<button className="btn btn-ghost" onClick={() => save([])}>🗑️ 清空</button>
|
||||
<Link href="/plan" className="btn btn-primary">继续完善计划 →</Link>
|
||||
{trip.length >= 2 && (
|
||||
<Link
|
||||
href={`/compare?cities=${trip.map((t) => t.slug).slice(0, 4).join(",")}`}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
对比这些城市
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -43,6 +43,16 @@ FILES = [
|
||||
"frontend/src/components/WhenVisible.tsx",
|
||||
"frontend/src/app/layout.tsx",
|
||||
"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/planMeta.ts",
|
||||
"frontend/src/lib/compareScore.ts",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user