Polish three rings: connect auth gates, explore shortcuts, grow nav and forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 21:37:57 -05:00
parent 2ee0f3bf77
commit ea030275ce
23 changed files with 274 additions and 26 deletions

View File

@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell";
import AiAssistantClient from "@/components/AiAssistantClient";
@ -7,10 +8,13 @@ export const metadata: Metadata = {
description: "智能推荐下一站游民城市",
};
export default function AiPage() {
export const revalidate = 60;
export default async function AiPage() {
const destinations = await api.getDestinations().catch(() => []);
return (
<SiteShell showFooter>
<AiAssistantClient />
<AiAssistantClient destinations={destinations} />
</SiteShell>
);
}

View File

@ -8,6 +8,15 @@ export const metadata: Metadata = {
};
const LOGS = [
{
date: "2026-08-30",
tag: "三环登录与导航",
items: [
"Connect:个人中心三环快捷入口、匹配加载态、发起活动/发赏金登录门、私信与登录提示",
"Explore:全站快捷键、首页 hash 锚点、对比空态、AI 推荐真正写入计划",
"Grow:导航补全服务/投稿、表单校验、学院空态与 VIP 课时直达开通",
],
},
{
date: "2026-08-30",
tag: "三环可用深化",

View File

@ -10903,6 +10903,14 @@ img { max-width: 100%; display: block; }
font-size: 0.95rem;
}
.digital-lesson-list li a.digital-lesson-locked {
opacity: 0.85;
}
.digital-lesson-list li a.digital-lesson-locked:hover .digital-lesson-meta {
color: var(--accent-3);
}
.digital-lesson-meta {
font-size: 0.8rem;
color: var(--text-muted);

View File

@ -73,7 +73,7 @@ export default function ProfilePage() {
useEffect(() => {
if (!token) {
router.push("/login");
router.push("/login?next=/profile");
return;
}
setLoading(true);
@ -291,8 +291,13 @@ export default function ProfilePage() {
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
<Link href="/plan" className="quick-card">🗓️ 旅居计划</Link>
<Link href="/compare" className="quick-card">⚖️ 城市对比</Link>
<Link href="/#visa" className="quick-card">📋 签证指南</Link>
<Link href="/#blog" className="quick-card">📝 阅读博客</Link>
<Link href="/next-stop" className="quick-card">🧭 智能下一站</Link>
<Link href="/meetups" className="quick-card">🎉 游民活动</Link>
<Link href="/dating" className="quick-card">💕 游民匹配</Link>
<Link href="/chat" className="quick-card">✉️ 私信</Link>
<Link href="/notifications" className="quick-card">🔔 通知</Link>
<Link href={vip ? "/join" : "/join"} className="quick-card">{vip ? "✨ VIP 会员" : "✨ 开通 VIP"}</Link>
<Link href="/digital" className="quick-card">🎓 游民学院</Link>
<Link href="/tools" className="quick-card">🛠️ 工具箱</Link>
</div>
</section>

View File

@ -2,9 +2,12 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import type { Destination } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
@ -14,9 +17,14 @@ const SUGGESTIONS = [
"清迈和巴厘岛怎么选?",
];
export default function AiAssistantClient() {
interface Props {
destinations: Destination[];
}
export default function AiAssistantClient({ destinations }: Props) {
const { t } = useI18n();
const { toast } = useToast();
const router = useRouter();
const rings = useRingSteps();
const [message, setMessage] = useState("");
const [reply, setReply] = useState("");
@ -43,6 +51,24 @@ export default function AiAssistantClient() {
}
};
const addToPlan = () => {
const dests = cities
.map((c) => destinations.find((d) => d.slug === c.slug))
.filter((d): d is Destination => Boolean(d));
if (dests.length === 0) {
toast("推荐城市暂无完整数据,请从详情页加入计划", "info");
router.push("/plan");
return;
}
const { added, skipped } = mergeDestinationsIntoTrip(dests, 1);
if (added === 0) {
toast(skipped ? t.compare.alreadyInPlan : "未能写入计划", "info");
} else {
toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}`);
}
router.push("/plan");
};
return (
<div className="community-page">
<div className="container">
@ -93,9 +119,11 @@ export default function AiAssistantClient() {
<Link href="/compare" className="btn btn-sm">
城市对比
</Link>
<Link href="/plan" className="btn btn-sm">
{cities.length > 0 && (
<button type="button" className="btn btn-sm btn-primary" onClick={addToPlan}>
写入计划
</Link>
</button>
)}
</div>
</div>
)}

View File

@ -45,6 +45,7 @@ export default function ChatClient() {
<div className="container">
<div className="dating-gate reveal">
<h2>{t.chat.loginTitle}</h2>
<p>{t.chat.loginDesc}</p>
<Link href="/login?next=/chat" className="btn btn-primary">{t.nav.login}</Link>
</div>
</div>

View File

@ -62,6 +62,7 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
<div className="container">
<div className="dating-gate reveal">
<h2>{t.chat.loginTitle}</h2>
<p>{t.chat.loginDesc}</p>
<Link href={`/login?next=/chat/${convId}`} className="btn btn-primary">
{t.nav.login}
</Link>

View File

@ -125,10 +125,24 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
{filtered.length === 0 && (
<div className="notif-empty reveal">
<p className="dest-empty">{t.community.empty}</p>
<div className="dating-empty-actions">
{(category !== "全部" || q.trim()) && (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
setCategory("全部");
setQ("");
}}
>
{t.meetups.all}
</button>
)}
<Link href="/community/new" className="btn btn-primary btn-sm">
{t.community.newTopic}
</Link>
</div>
</div>
)}
<RingNext steps={rings.afterCommunity} />

View File

@ -145,7 +145,11 @@ export default function CompareClient({ destinations }: Props) {
<div className="plan-empty compare-page-empty">
<span className="plan-empty-icon">⚖️</span>
<p>{t.compare.needMore} {Math.max(0, 2 - selected.length)} {t.compare.needMoreSuffix}</p>
<div className="dating-empty-actions">
<Link href="/#destinations" className="btn btn-ghost">{t.compare.backDest}</Link>
<Link href="/next-stop" className="btn">{t.nav.nextStop}</Link>
<Link href="/ai" className="btn btn-ghost">{t.nav.ai}</Link>
</div>
</div>
) : (
<>

View File

@ -8,11 +8,15 @@ import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function ContentSubmitClient() {
const { token } = useAuth();
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
const rings = useRingSteps();
const [title, setTitle] = useState("");
const [url, setUrl] = useState("");
const [notes, setNotes] = useState("");
@ -29,6 +33,10 @@ export default function ContentSubmitClient() {
toast("请填写更完整的标题", "info");
return;
}
if ((contentType === "article" || contentType === "video") && !/^https?:\/\/.+/.test(url.trim())) {
toast("文章/视频请填写有效链接(http/https)", "info");
return;
}
setBusy(true);
try {
await api.submitContent(
@ -113,6 +121,7 @@ export default function ContentSubmitClient() {
)}
</div>
)}
<RingNext steps={rings.afterGigs} />
</div>
</div>
);

View File

@ -160,7 +160,9 @@ export default function DatingClient() {
</div>
)}
{loading && joined && <p className="dest-empty reveal">加载候选人…</p>}
{loading && (
<p className="dest-empty reveal">{joined ? "加载候选人…" : "加载匹配资料…"}</p>
)}
{top && (
<div className="dating-card reveal">

View File

@ -39,7 +39,14 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
)}
</div>
{course.modules.length === 0 ? (
<div className="notif-empty reveal">
<p className="dest-empty">课程内容准备中</p>
<div className="dating-empty-actions">
<Link href="/digital" className="btn">{t.digital.back}</Link>
<Link href="/gigs" className="btn btn-ghost">{t.nav.gigs}</Link>
<Link href="/join" className="btn btn-primary">{t.nav.join}</Link>
</div>
</div>
) : (
<div className="digital-modules reveal">
{course.modules.map((mod, mi) => (
@ -50,16 +57,22 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
const locked = !lesson.free && !vip;
return (
<li key={lesson.title}>
<Link href={`/digital/course/${mi}/${li}`}>
<span>
{locked ? "🔒 " : ""}
{lesson.title}
{locked ? (
<Link href="/join" className="digital-lesson-locked">
<span>🔒 {lesson.title}</span>
<span className="digital-lesson-meta">
{lesson.duration} · VIP · 开通解锁
</span>
</Link>
) : (
<Link href={`/digital/course/${mi}/${li}`}>
<span>{lesson.title}</span>
<span className="digital-lesson-meta">
{lesson.duration}
{lesson.free ? ` · ${t.digital.free}` : " · VIP"}
</span>
</Link>
)}
</li>
);
})}

View File

@ -6,11 +6,14 @@ import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function FeedbackClient() {
const { token } = useAuth();
const { toast } = useToast();
const { t } = useI18n();
const rings = useRingSteps();
const [content, setContent] = useState("");
const [category, setCategory] = useState("general");
const [loading, setLoading] = useState(false);
@ -79,6 +82,7 @@ export default function FeedbackClient() {
{!token && <p className="section-desc">未登录也可提交;登录后便于我们回复你</p>}
</div>
)}
<RingNext steps={rings.afterGigs} />
</div>
</div>
);

View File

@ -61,6 +61,9 @@ export default function Footer() {
<h4>{t.footer.grow}</h4>
<Link href="/digital">{t.footer.digital}</Link>
<Link href="/gigs">{t.footer.gigs}</Link>
<Link href="/services">{t.nav.services}</Link>
<Link href="/submit">{t.submit.tag}</Link>
<Link href="/feedback">{t.nav.feedback}</Link>
<Link href="/tools">{t.footer.toolkit}</Link>
<Link href="/join">{t.footer.join}</Link>
</div>

View File

@ -19,6 +19,21 @@ export default function GigPostClient() {
const [deadline, setDeadline] = useState("");
const [busy, setBusy] = useState(false);
if (!token) {
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/gigs">{t.gigs.back}</Link></nav>
<div className="dating-gate reveal">
<h2>{t.gigs.postTitle}</h2>
<p>{t.gigs.loginFirst}</p>
<Link href="/login?next=/gigs/post" className="btn btn-primary">{t.nav.login}</Link>
</div>
</div>
</div>
);
}
const submit = async () => {
if (!token) {
router.push("/login?next=/gigs/post");

View File

@ -53,6 +53,17 @@ export default function HomeClient(props: Props) {
const [chromeReady, setChromeReady] = useState(false);
const rings = useRingSteps();
useEffect(() => {
const hash = window.location.hash.slice(1);
if (!hash) return;
const scrollToHash = () => {
const el = document.getElementById(hash);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
};
const t = window.setTimeout(scrollToHash, 400);
return () => window.clearTimeout(t);
}, []);
useEffect(() => {
const onMatcher = () => setMatcherOpen(true);
window.addEventListener("open-matcher", onMatcher);

View File

@ -122,7 +122,17 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
<iframe title="chat" src={session.chatUrl} className="live-iframe live-chat" />
)}
{!session.videoUrl && !session.chatUrl && (
<div className="notif-empty">
<p className="dest-empty">直播链接尚未配置</p>
<div className="dating-empty-actions">
<Link href="/meetups" className="btn btn-primary">
{t.live.back}
</Link>
<Link href="/feedback" className="btn btn-ghost">
{t.nav.feedback}
</Link>
</div>
</div>
)}
</div>
</div>

View File

@ -29,6 +29,21 @@ export default function MeetupsHostClient() {
const set = (k: string, v: string | number) => setForm((f) => ({ ...f, [k]: v }));
if (!token) {
return (
<div className="meetups-page">
<div className="container">
<nav className="detail-nav"><Link href="/meetups">← {t.meetups.title}</Link></nav>
<div className="dating-gate reveal">
<h2>{t.meetups.hostTitle}</h2>
<p>{t.meetups.loginDesc}</p>
<Link href="/login?next=/meetups/host" className="btn btn-primary">{t.nav.login}</Link>
</div>
</div>
</div>
);
}
const submit = async () => {
if (!token) {
router.push("/login?next=/meetups/host");

View File

@ -58,11 +58,15 @@ export default function Navbar() {
{ href: "/community", label: t.nav.community, section: "community" },
{ href: "/dating", label: t.nav.dating, section: "dating" },
{ href: "/chat", label: t.nav.chat, section: "chat" },
{ href: "/notifications", label: t.nav.notifications, section: "notifications" },
{ href: "/join", label: t.nav.join, section: "join" },
];
const grow: NavItem[] = [
{ href: "/digital", label: t.nav.digital, section: "digital" },
{ href: "/gigs", label: t.nav.gigs, section: "gigs" },
{ href: "/services", label: t.nav.services, section: "services" },
{ href: "/submit", label: t.submit.tag, section: "submit" },
{ href: "/tools", label: t.nav.tools, section: "tools" },
{ href: "/book", label: t.nav.ebook, section: "ebook" },
];

View File

@ -30,6 +30,10 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
toast("请填写姓名、邮箱和需求说明", "info");
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(f.email.trim())) {
toast("请填写有效邮箱地址", "info");
return;
}
setBusyId(serviceId);
try {
await api.submitServiceLead(serviceId, {

View File

@ -1,12 +1,19 @@
"use client";
import { useEffect } from "react";
import dynamic from "next/dynamic";
import { usePathname, useRouter } from "next/navigation";
import Navbar from "./Navbar";
import ParticleCanvas from "./ParticleCanvas";
import Footer from "./Footer";
import GlobalSearch from "./GlobalSearch";
const KeyboardShortcuts = dynamic(() => import("./KeyboardShortcuts"), { ssr: false });
export default function SiteShell({ children, showFooter = false }: { children: React.ReactNode; showFooter?: boolean }) {
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
const observer = new IntersectionObserver(
(entries) =>
@ -35,6 +42,15 @@ export default function SiteShell({ children, showFooter = false }: { children:
<ParticleCanvas />
<Navbar />
<GlobalSearch />
<KeyboardShortcuts
onOpenMatcher={() => {
if (pathname === "/") {
window.dispatchEvent(new Event("open-matcher"));
} else {
router.push("/next-stop");
}
}}
/>
{children}
{showFooter && <Footer />}
</>

View File

@ -7,6 +7,14 @@ type LoginDict = {
hintCompare: string;
hintTools: string;
hintDestination: string;
hintDating: string;
hintChat: string;
hintMeetups: string;
hintJoin: string;
hintNotifications: string;
hintGigs: string;
hintCommunity: string;
hintSubmit: string;
redirectTo: string;
planMergeNote: string;
destHome: string;
@ -15,6 +23,14 @@ type LoginDict = {
destCompare: string;
destTools: string;
destDestination: string;
destDating: string;
destChat: string;
destMeetups: string;
destJoin: string;
destNotifications: string;
destGigs: string;
destCommunity: string;
destSubmit: string;
submitLoginDefault: string;
submitLoginPlan: string;
demoHintDefault: string;
@ -55,6 +71,14 @@ function destLabel(path: string, t: LoginDict): string {
if (path === "/compare") return t.destCompare;
if (path === "/tools") return t.destTools;
if (path.startsWith("/destinations/")) return t.destDestination;
if (path.startsWith("/dating")) return t.destDating;
if (path.startsWith("/chat")) return t.destChat;
if (path.startsWith("/meetups")) return t.destMeetups;
if (path.startsWith("/join")) return t.destJoin;
if (path.startsWith("/notifications")) return t.destNotifications;
if (path.startsWith("/gigs")) return t.destGigs;
if (path.startsWith("/community")) return t.destCommunity;
if (path === "/submit") return t.destSubmit;
return t.destProfile;
}
@ -64,6 +88,14 @@ function hintFor(path: string, t: LoginDict): string {
if (path === "/tools") return t.hintTools;
if (path.startsWith("/destinations/")) return t.hintDestination;
if (path === "/profile") return t.hintProfile;
if (path.startsWith("/dating")) return t.hintDating;
if (path.startsWith("/chat")) return t.hintChat;
if (path.startsWith("/meetups")) return t.hintMeetups;
if (path.startsWith("/join")) return t.hintJoin;
if (path.startsWith("/notifications")) return t.hintNotifications;
if (path.startsWith("/gigs")) return t.hintGigs;
if (path.startsWith("/community")) return t.hintCommunity;
if (path === "/submit") return t.hintSubmit;
return t.hintExplore;
}

View File

@ -249,6 +249,14 @@ export const zh = {
hintCompare: "登录后继续你的城市对比",
hintTools: "登录后使用需要账号的工具与服务",
hintDestination: "登录后回到该城市页面,收藏与计划自动同步",
hintDating: "登录后开始滑动匹配,成功可私信聊天",
hintChat: "登录后查看与回复私信",
hintMeetups: "登录后报名活动或发起聚会",
hintJoin: "登录后开通或管理 VIP 会员",
hintNotifications: "登录后查看通知与提醒",
hintGigs: "登录后申请赏金或发布任务",
hintCommunity: "登录后发帖与参与讨论",
hintSubmit: "登录后提交内容投稿",
redirectTo: "登录后将前往",
planMergeNote: "检测到本机已有行程,登录后会自动与账号合并",
destHome: "首页",
@ -257,6 +265,14 @@ export const zh = {
destCompare: "城市对比",
destTools: "工具箱",
destDestination: "城市详情",
destDating: "游民匹配",
destChat: "私信",
destMeetups: "游民活动",
destJoin: "会员中心",
destNotifications: "通知中心",
destGigs: "赏金任务",
destCommunity: "社区讨论",
destSubmit: "内容投稿",
tabLogin: "登录",
tabRegister: "注册",
nickname: "昵称",
@ -341,6 +357,7 @@ export const zh = {
tag: "✉️ MESSAGES",
title: "私信",
loginTitle: "登录后查看私信",
loginDesc: "匹配成功或活动结识的游民,在这里继续对话",
empty: "还没有对话,去匹配页滑一张卡片吧",
noMessages: "暂无消息",
back: "返回列表",
@ -476,6 +493,7 @@ export const zh = {
hostSubmit: "发布活动",
hostOk: "活动已发布",
hostFail: "发布失败",
loginDesc: "登录后即可创建线上或线下游民活动",
empty: "暂无匹配的活动",
},
community: {
@ -889,6 +907,14 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
hintCompare: "Continue your city comparison after sign-in",
hintTools: "Access tools and services that need an account",
hintDestination: "Return to this city with favorites and plans in sync",
hintDating: "Sign in to swipe matches and chat on mutual like",
hintChat: "Sign in to read and reply to messages",
hintMeetups: "Sign in to RSVP or host nomad events",
hintJoin: "Sign in to manage or upgrade VIP membership",
hintNotifications: "Sign in to view alerts and reminders",
hintGigs: "Sign in to apply for gigs or post bounties",
hintCommunity: "Sign in to post and join discussions",
hintSubmit: "Sign in to submit content",
redirectTo: "After sign-in you'll go to",
planMergeNote: "Local trip data found — we'll merge it with your account",
destHome: "Home",
@ -897,6 +923,14 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
destCompare: "Compare",
destTools: "Toolkit",
destDestination: "City page",
destDating: "Nomad match",
destChat: "Messages",
destMeetups: "Events",
destJoin: "Membership",
destNotifications: "Notifications",
destGigs: "Bounty gigs",
destCommunity: "Community",
destSubmit: "Content submit",
tabLogin: "Log in",
tabRegister: "Sign up",
nickname: "Nickname",
@ -981,6 +1015,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
tag: "✉️ MESSAGES",
title: "Messages",
loginTitle: "Sign in to view messages",
loginDesc: "Continue conversations with nomads you matched or met at events",
empty: "No conversations yet — swipe on the match page",
noMessages: "No messages yet",
back: "Back to inbox",
@ -1116,6 +1151,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
hostSubmit: "Publish",
hostOk: "Event published",
hostFail: "Could not publish",
loginDesc: "Sign in to host online or in-person nomad events",
empty: "No events match this filter",
},
community: {