Polish discovery loop: ToolsStrip, map CTAs, meetup city filter, gig login gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 22:16:46 -05:00
parent 31ce4107f4
commit 8ea3f622ed
15 changed files with 160 additions and 15 deletions

View File

@ -76,7 +76,21 @@ function BookThanksInner() {
</button>
</div>
)}
{status === "ok" && (
{status === "ok" && files.length === 0 && (
<div>
<p>购买已确认,下载文件暂未配置。</p>
<p className="section-desc">可先在线阅读,或联系支持获取 PDF/EPUB。</p>
<div className="dating-empty-actions">
<Link href="/book/read" className="btn btn-primary">
继续在线阅读
</Link>
<Link href="/feedback" className="btn btn-ghost">
联系支持
</Link>
</div>
</div>
)}
{status === "ok" && files.length > 0 && (
<div>
<p>购买成功,可下载:</p>
<ul className="ebook-download-list">

View File

@ -8,6 +8,15 @@ export const metadata: Metadata = {
};
const LOGS = [
{
date: "2026-08-30",
tag: "决策与发现闭环",
items: [
"首页挂载快捷工具条;地图选城可对比/加计划;下一站活动按推荐城市筛选",
"赏金未登录直接引导登录;活动页支持 ?city= 筛选;视频详情 RingNext",
"电子书感谢页无下载链时引导在线阅读;社区发帖/发赏金/通知设置登录门",
],
},
{
date: "2026-08-30",
tag: "三环登录与导航",

View File

@ -1,3 +1,4 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { api } from "@/lib/api";
import SiteShell from "@/components/SiteShell";
@ -15,7 +16,9 @@ export default async function MeetupsPage() {
return (
<SiteShell showFooter>
<Suspense fallback={<div className="container"><p className="dest-empty">加载活动…</p></div>}>
<MeetupsClient meetups={meetups} />
</Suspense>
</SiteShell>
);
}

View File

@ -7,17 +7,35 @@ 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 CommunityNewClient() {
const { token } = useAuth();
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
const rings = useRingSteps();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [category, setCategory] = useState("社区");
const [busy, setBusy] = useState(false);
if (!token) {
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/community">← {t.community.back}</Link></nav>
<div className="dating-gate reveal">
<h2>{t.community.newTitle}</h2>
<p>{t.community.loginDesc}</p>
<Link href="/login?next=/community/new" className="btn btn-primary">{t.nav.login}</Link>
</div>
</div>
</div>
);
}
const submit = async () => {
if (!token) {
router.push("/login?next=/community/new");
@ -70,6 +88,7 @@ export default function CommunityNewClient() {
{busy ? "发布中…" : t.community.newSubmit}
</button>
</div>
<RingNext steps={rings.afterCommunity} />
</div>
</div>
);

View File

@ -22,7 +22,10 @@ export default function FeedbackWidget() {
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!msg.trim()) return;
if (!msg.trim() || msg.trim().length < 5) {
toast("请至少写 5 个字", "info");
return;
}
setSending(true);
try {
await api.submitFeedback(msg.trim(), token || undefined, type);

View File

@ -48,6 +48,7 @@ export default function Footer() {
<Link href="/#destinations">{t.footer.destinations}</Link>
<Link href="/next-stop">{t.footer.nextStop}</Link>
{!isHome && <Link href="/plan">{t.footer.plan}</Link>}
{!isHome && <Link href="/compare">{t.footer.compare}</Link>}
<Link href="/#visa">{t.footer.visa}</Link>
</div>
<div className="footer-links">

View File

@ -7,12 +7,15 @@ 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 GigPostClient() {
const { token } = useAuth();
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
const rings = useRingSteps();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [budget, setBudget] = useState("");
@ -77,6 +80,7 @@ export default function GigPostClient() {
{busy ? "发布中…" : t.gigs.postSubmit}
</button>
</div>
<RingNext steps={rings.afterGigs} />
</div>
</div>
);

View File

@ -64,6 +64,11 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
</div>
) : (
<div className="digital-jobs-grid reveal">
{!token && (
<p className="section-desc reveal">
<Link href="/login?next=/gigs">{t.nav.login}</Link> · {t.gigs.loginFirst}
</p>
)}
{gigs.map((g) => (
<article key={g.id} className="digital-job-card">
<h3>{g.title}</h3>
@ -71,6 +76,10 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
<p className="digital-job-meta">{g.poster} · {g.budget} · {g.deadline}</p>
{applied[g.id] ? (
<p className="gigs-applied">✓ 已申请</p>
) : !token ? (
<Link href="/login?next=/gigs" className="btn btn-primary btn-sm">
{t.gigs.apply}
</Link>
) : (
<>
<textarea

View File

@ -32,6 +32,7 @@ const CookieConsent = lazy(() => import("./CookieConsent"));
const NomadTips = lazy(() => import("./NomadTips"));
const FeedbackWidget = lazy(() => import("./FeedbackWidget"));
const OnboardingTour = lazy(() => import("./OnboardingTour"));
const ToolsStrip = lazy(() => import("./ToolsStrip"));
const KeyboardShortcuts = lazy(() => import("./KeyboardShortcuts"));
const PwaInstallHint = lazy(() => import("./PwaInstallHint"));
@ -121,6 +122,10 @@ export default function HomeClient(props: Props) {
<WorldMap destinations={props.destinations} />
<Destinations destinations={props.destinations} onOpenMatcher={openMatcher} />
<WhenVisible minHeight={120}>
<ToolsStrip />
</WhenVisible>
<div className="container home-ring-wrap">
<RingNext title={t.ring.continue} steps={rings.afterDiscover} />
</div>

View File

@ -2,7 +2,7 @@
import { useMemo, useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { useToast } from "@/lib/toast";
import { useAuth } from "@/lib/auth";
@ -28,6 +28,8 @@ export default function MeetupsClient({ meetups }: Props) {
const { toast } = useToast();
const { token } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const cityFilter = searchParams.get("city")?.trim() || "";
const [mode, setMode] = useState<"all" | Meetup["mode"]>("all");
const [rsvpIds, setRsvpIds] = useState<Set<string>>(new Set());
const [busyId, setBusyId] = useState<string | null>(null);
@ -41,9 +43,18 @@ export default function MeetupsClient({ meetups }: Props) {
}, [token]);
const filtered = useMemo(() => {
if (mode === "all") return meetups;
return meetups.filter((m) => m.mode === mode);
}, [meetups, mode]);
let list = meetups;
if (mode !== "all") list = list.filter((m) => m.mode === mode);
if (cityFilter) {
const q = cityFilter.toLowerCase();
list = list.filter(
(m) =>
m.city.toLowerCase().includes(q) ||
m.destination_slug.toLowerCase().includes(q)
);
}
return list;
}, [meetups, mode, cityFilter]);
const handleRsvp = async (meetup: Meetup) => {
if (!token) {
@ -100,6 +111,15 @@ export default function MeetupsClient({ meetups }: Props) {
<div className="meetups-toolbar reveal">
<Link href="/meetups/host" className="btn btn-primary btn-sm">{t.meetups.hostBtn}</Link>
{cityFilter && (
<button
type="button"
className="filter-btn active"
onClick={() => router.replace("/meetups")}
>
{cityFilter} ✕
</button>
)}
{(["all", "online", "offline", "hybrid"] as const).map((m) => (
<button
key={m}
@ -168,9 +188,16 @@ export default function MeetupsClient({ meetups }: Props) {
<div className="notif-empty reveal">
<p className="dest-empty">{t.meetups.empty}</p>
<div className="dating-empty-actions">
{mode !== "all" && (
<button type="button" className="btn btn-sm" onClick={() => setMode("all")}>
查看全部
{(mode !== "all" || cityFilter) && (
<button
type="button"
className="btn btn-sm"
onClick={() => {
setMode("all");
if (cityFilter) router.replace("/meetups");
}}
>
{t.meetups.all}
</button>
)}
<Link href="/meetups/host" className="btn btn-primary btn-sm">

View File

@ -87,6 +87,17 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
const display = results.length ? results : localFallback;
const best = display[0];
const relevantMeetups = useMemo(() => {
if (!best) return meetups.slice(0, 3);
const bySlug = meetups.filter((m) => m.destination_slug === best.slug);
if (bySlug.length) return bySlug.slice(0, 3);
const byCity = meetups.filter(
(m) => m.city === best.name || m.city.includes(best.name) || best.name.includes(m.city)
);
if (byCity.length) return byCity.slice(0, 3);
return meetups.slice(0, 3);
}, [meetups, best]);
const toggleTag = (tag: string) => {
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
@ -279,15 +290,21 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
</section>
)}
{meetups.length > 0 && (
{relevantMeetups.length > 0 && (
<section className="next-stop-meetups reveal">
<div className="next-stop-meetups-head">
<h3>{t.nextStop.meetups}</h3>
<Link href="/meetups">{t.nextStop.allMeetups} →</Link>
<h3>{t.nextStop.meetups}{best ? ` · ${best.name}` : ""}</h3>
<Link href={best ? `/meetups?city=${encodeURIComponent(best.name)}` : "/meetups"}>
{t.nextStop.allMeetups} →
</Link>
</div>
<div className="next-stop-meetup-row">
{meetups.slice(0, 3).map((m) => (
<Link key={m.id} href="/meetups" className="next-stop-meetup-chip">
{relevantMeetups.map((m) => (
<Link
key={m.id}
href={`/meetups?city=${encodeURIComponent(m.city)}`}
className="next-stop-meetup-chip"
>
<span>{m.emoji}</span>
<div>
<strong>{m.title}</strong>

View File

@ -59,6 +59,7 @@ export default function NotificationSettingsClient() {
<div className="container">
<div className="dating-gate reveal">
<h2>{t.notifSettings.title}</h2>
<p>{t.notifSettings.loginDesc}</p>
<Link href="/login?next=/settings/notifications" className="btn btn-primary">
{t.nav.login}
</Link>

View File

@ -3,9 +3,12 @@
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { VideoItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function VideoDetailClient({ video }: { video: VideoItem }) {
const { t } = useI18n();
const rings = useRingSteps();
return (
<div className="community-detail-page">
<div className="container">
@ -41,6 +44,7 @@ export default function VideoDetailClient({ video }: { video: VideoItem }) {
学院
</Link>
</div>
<RingNext steps={rings.afterDiscover} />
</div>
</div>
);

View File

@ -2,6 +2,10 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { compareUrl } from "@/lib/compareScore";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import type { Destination } from "@/lib/types";
@ -9,8 +13,20 @@ interface Props { destinations: Destination[] }
export default function WorldMap({ destinations }: Props) {
const { t } = useI18n();
const { toast } = useToast();
const router = useRouter();
const [selected, setSelected] = useState<Destination | null>(null);
const addToPlan = () => {
if (!selected) return;
const { added } = mergeDestinationsIntoTrip([selected], 1);
toast(
added ? `${selected.emoji} ${selected.name} 已加入计划` : "该城已在计划中",
added ? "success" : "info"
);
if (added) router.push("/plan");
};
return (
<section className="section map-section section-compact" id="map">
<div className="container">
@ -76,6 +92,15 @@ export default function WorldMap({ destinations }: Props) {
<Link href={`/destinations/${selected.slug}`} className="btn btn-primary btn-sm">
{t.common.detail}
</Link>
<button type="button" className="btn btn-sm" onClick={addToPlan}>
🗓️ {t.nav.plan}
</button>
<Link href={compareUrl([selected.slug])} className="btn btn-ghost btn-sm">
⚖️ {t.nav.compare}
</Link>
<Link href="/next-stop" className="btn btn-ghost btn-sm">
🧭 {t.nav.nextStop}
</Link>
</div>
</div>
) : (

View File

@ -509,6 +509,7 @@ export const zh = {
hot: "热门讨论",
search: "搜索话题…",
empty: "没有匹配的讨论",
loginDesc: "登录后即可发布话题并参与讨论",
back: "返回社区",
readMore: "阅读全文",
replies: "回复",
@ -631,6 +632,7 @@ export const zh = {
},
notifSettings: {
title: "通知设置",
loginDesc: "登录后可管理匹配、活动与社区通知偏好",
back: "返回通知",
save: "保存设置",
saved: "设置已保存",
@ -1170,6 +1172,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
hot: "Hot thread",
search: "Search topics…",
empty: "No discussions match",
loginDesc: "Sign in to post topics and join discussions",
back: "Back to community",
readMore: "Read more",
replies: "replies",
@ -1292,6 +1295,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
},
notifSettings: {
title: "Notification settings",
loginDesc: "Sign in to manage match, event and community alert preferences",
back: "Back to inbox",
save: "Save",
saved: "Settings saved",