Polish discovery loop: ToolsStrip, map CTAs, meetup city filter, gig login gate.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
31ce4107f4
commit
8ea3f622ed
@ -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">
|
||||
|
||||
@ -8,6 +8,15 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
const LOGS = [
|
||||
{
|
||||
date: "2026-08-30",
|
||||
tag: "决策与发现闭环",
|
||||
items: [
|
||||
"首页挂载快捷工具条;地图选城可对比/加计划;下一站活动按推荐城市筛选",
|
||||
"赏金未登录直接引导登录;活动页支持 ?city= 筛选;视频详情 RingNext",
|
||||
"电子书感谢页无下载链时引导在线阅读;社区发帖/发赏金/通知设置登录门",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-08-30",
|
||||
tag: "三环登录与导航",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user