Polish explore hubs: feedback API, live gates, AI tips, empty states.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 20:45:58 -05:00
parent d96666c329
commit 0ce48023ae
12 changed files with 398 additions and 84 deletions

View File

@ -10384,6 +10384,15 @@ img { max-width: 100%; display: block; }
margin-bottom: 12px; margin-bottom: 12px;
} }
.member-photo-img {
width: 96px;
height: 96px;
border-radius: 50%;
object-fit: cover;
display: block;
margin: 0 auto;
}
.dating-meta { .dating-meta {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
@ -10564,6 +10573,23 @@ img { max-width: 100%; display: block; }
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }
.ai-suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.feedback-more-link {
margin-top: 12px;
text-align: center;
font-size: 0.85rem;
}
.feedback-more-link a {
color: var(--text-muted);
}
.ai-cities { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } .ai-cities { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.notif-toolbar { display: flex; gap: 8px; flex-wrap: wrap; } .notif-toolbar { display: flex; gap: 8px; flex-wrap: wrap; }

View File

@ -4,23 +4,40 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
const SUGGESTIONS = [
"预算 6000,想找网速好的东南亚城市",
"适合第一次远程办公的城市?",
"清迈和巴厘岛怎么选?",
];
export default function AiAssistantClient() { export default function AiAssistantClient() {
const { t } = useI18n(); const { t } = useI18n();
const { toast } = useToast();
const rings = useRingSteps();
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [reply, setReply] = useState(""); const [reply, setReply] = useState("");
const [cities, setCities] = useState<{ slug: string; name: string; emoji: string }[]>([]); const [cities, setCities] = useState<{ slug: string; name: string; emoji: string }[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const ask = async () => { const ask = async (text?: string) => {
if (!message.trim()) return; const q = (text ?? message).trim();
if (!q) {
toast("先写一个问题", "info");
return;
}
setMessage(q);
setLoading(true); setLoading(true);
try { try {
const res = await api.askAssistant(message.trim()); const res = await api.askAssistant(q);
setReply(res.reply); setReply(res.reply);
setCities(res.cities || []); setCities(res.cities || []);
} catch { } catch {
setReply(t.ai.fail); setReply(t.ai.fail);
setCities([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -29,27 +46,60 @@ export default function AiAssistantClient() {
return ( return (
<div className="community-page"> <div className="community-page">
<div className="container"> <div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav> <nav className="detail-nav">
<Link href="/">{t.common.backHome}</Link>
<span> · </span>
<Link href="/next-stop">{t.ai.nextStop}</Link>
</nav>
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.ai.tag}</span> <span className="section-tag">{t.ai.tag}</span>
<h1>{t.ai.title}</h1> <h1>{t.ai.title}</h1>
<p>{t.ai.subtitle}</p> <p>{t.ai.subtitle}</p>
</div> </div>
<div className="ai-suggestions reveal">
{SUGGESTIONS.map((s) => (
<button key={s} type="button" className="filter-btn" disabled={loading} onClick={() => void ask(s)}>
{s}
</button>
))}
</div>
<div className="community-compose reveal"> <div className="community-compose reveal">
<textarea rows={4} placeholder={t.ai.placeholder} value={message} onChange={(e) => setMessage(e.target.value)} /> <textarea
<button type="button" className="btn btn-primary" disabled={loading} onClick={ask}>{t.ai.ask}</button> rows={4}
placeholder={t.ai.placeholder}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void ask();
}}
/>
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void ask()}>
{loading ? "思考中…" : t.ai.ask}
</button>
<p className="section-desc">Ctrl/⌘ + Enter 发送</p>
</div> </div>
{reply && ( {reply && (
<div className="ai-reply reveal"> <div className="ai-reply reveal">
<p>{reply}</p> <p>{reply}</p>
<div className="ai-cities"> <div className="ai-cities">
{cities.map((c) => ( {cities.map((c) => (
<Link key={c.slug} href={`/destinations/${c.slug}`} className="btn btn-sm">{c.emoji} {c.name}</Link> <Link key={c.slug} href={`/destinations/${c.slug}`} className="btn btn-sm">
{c.emoji} {c.name}
</Link>
))} ))}
<Link href="/next-stop" className="btn btn-sm">{t.ai.nextStop}</Link> <Link href="/next-stop" className="btn btn-sm">
{t.ai.nextStop}
</Link>
<Link href="/compare" className="btn btn-sm">
城市对比
</Link>
<Link href="/plan" className="btn btn-sm">
写入计划
</Link>
</div> </div>
</div> </div>
)} )}
<RingNext steps={rings.afterDiscover} />
</div> </div>
</div> </div>
); );

View File

@ -118,22 +118,26 @@ export default function CompareClient({ destinations }: Props) {
</div> </div>
</div> </div>
<div className="compare-pick-grid"> <div className="compare-pick-grid">
{destinations.map((d) => { {destinations.length === 0 ? (
const on = picked.includes(d.slug); <p className="dest-empty">目的地数据加载中或暂不可用</p>
return ( ) : (
<button destinations.map((d) => {
key={d.slug} const on = picked.includes(d.slug);
type="button" return (
className={`compare-pick-chip${on ? " on" : ""}`} <button
onClick={() => toggle(d.slug)} key={d.slug}
aria-pressed={on} type="button"
> className={`compare-pick-chip${on ? " on" : ""}`}
<span>{d.emoji}</span> onClick={() => toggle(d.slug)}
<strong>{d.name}</strong> aria-pressed={on}
<small>¥{d.cost.toLocaleString()}</small> >
</button> <span>{d.emoji}</span>
); <strong>{d.name}</strong>
})} <small>¥{d.cost.toLocaleString()}</small>
</button>
);
})
)}
</div> </div>
</section> </section>

View File

@ -14,14 +14,19 @@ export default function FeedbackClient() {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [category, setCategory] = useState("general"); const [category, setCategory] = useState("general");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const submit = async () => { const submit = async () => {
if (!content.trim()) return; if (!content.trim() || content.trim().length < 5) {
toast("请至少写 5 个字", "info");
return;
}
setLoading(true); setLoading(true);
try { try {
await api.submitFeedback(content.trim(), token || undefined, category); await api.submitFeedback(content.trim(), token || undefined, category);
toast(t.feedback.ok, "success"); toast(t.feedback.ok, "success");
setContent(""); setContent("");
setDone(true);
} catch { } catch {
toast(t.feedback.fail, "error"); toast(t.feedback.fail, "error");
} finally { } finally {
@ -32,22 +37,48 @@ export default function FeedbackClient() {
return ( return (
<div className="community-page"> <div className="community-page">
<div className="container"> <div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav> <nav className="detail-nav">
<Link href="/">{t.common.backHome}</Link>
</nav>
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.feedback.tag}</span> <span className="section-tag">{t.feedback.tag}</span>
<h1>{t.feedback.title}</h1> <h1>{t.feedback.title}</h1>
<p>{t.feedback.subtitle}</p> <p>{t.feedback.subtitle}</p>
</div> </div>
<div className="community-compose reveal"> {done ? (
<select value={category} onChange={(e) => setCategory(e.target.value)}> <div className="dating-gate reveal">
<option value="general">{t.feedback.catGeneral}</option> <h2>已收到,谢谢</h2>
<option value="bug">{t.feedback.catBug}</option> <p>我们会认真阅读每一条反馈</p>
<option value="feature">{t.feedback.catFeature}</option> <div className="dating-empty-actions">
<option value="safety">{t.feedback.catSafety}</option> <button type="button" className="btn btn-primary" onClick={() => setDone(false)}>
</select> 再写一条
<textarea rows={6} placeholder={t.feedback.placeholder} value={content} onChange={(e) => setContent(e.target.value)} /> </button>
<button type="button" className="btn btn-primary" disabled={loading} onClick={submit}>{t.feedback.submit}</button> <Link href="/community" className="btn">
</div> 去社区
</Link>
</div>
</div>
) : (
<div className="community-compose reveal">
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="general">{t.feedback.catGeneral}</option>
<option value="bug">{t.feedback.catBug}</option>
<option value="feature">{t.feedback.catFeature}</option>
<option value="safety">{t.feedback.catSafety}</option>
</select>
<textarea
rows={6}
placeholder={t.feedback.placeholder}
value={content}
onChange={(e) => setContent(e.target.value)}
maxLength={2000}
/>
<button type="button" className="btn btn-primary" disabled={loading} onClick={submit}>
{loading ? "提交中…" : t.feedback.submit}
</button>
{!token && <p className="section-desc">未登录也可提交;登录后便于我们回复你</p>}
</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -1,18 +1,22 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
const TYPES = [ const TYPES = [
{ key: "idea", label: "💡 想法" }, { key: "feature", label: "💡 想法" },
{ key: "bug", label: "🐛 问题" }, { key: "bug", label: "🐛 问题" },
{ key: "love", label: "❤️ 喜欢" }, { key: "general", label: "❤️ 喜欢" },
]; ];
export default function FeedbackWidget() { export default function FeedbackWidget() {
const { token } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [type, setType] = useState("idea"); const [type, setType] = useState("feature");
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
@ -20,15 +24,22 @@ export default function FeedbackWidget() {
e.preventDefault(); e.preventDefault();
if (!msg.trim()) return; if (!msg.trim()) return;
setSending(true); setSending(true);
// Persist locally for demo; can wire to API later
try { try {
const key = "nomadro-feedback"; await api.submitFeedback(msg.trim(), token || undefined, type);
const prev = JSON.parse(localStorage.getItem(key) || "[]"); // Keep a local copy for the user as well
prev.push({ type, msg: msg.trim(), at: new Date().toISOString() }); try {
localStorage.setItem(key, JSON.stringify(prev.slice(-50))); const key = "nomadro-feedback";
const prev = JSON.parse(localStorage.getItem(key) || "[]");
prev.push({ type, msg: msg.trim(), at: new Date().toISOString() });
localStorage.setItem(key, JSON.stringify(prev.slice(-50)));
} catch {
/* ignore */
}
toast("感谢反馈!我们会认真看的 🙏"); toast("感谢反馈!我们会认真看的 🙏");
setMsg(""); setMsg("");
setOpen(false); setOpen(false);
} catch {
toast("发送失败,请稍后重试或去 /feedback", "error");
} finally { } finally {
setSending(false); setSending(false);
} }
@ -48,7 +59,9 @@ export default function FeedbackWidget() {
{open && ( {open && (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && setOpen(false)}> <div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && setOpen(false)}>
<div className="modal feedback-modal"> <div className="modal feedback-modal">
<button className="modal-close" onClick={() => setOpen(false)} aria-label="关闭">✕</button> <button className="modal-close" onClick={() => setOpen(false)} aria-label="关闭">
✕
</button>
<div className="feedback-body"> <div className="feedback-body">
<span className="section-tag">💬 FEEDBACK</span> <span className="section-tag">💬 FEEDBACK</span>
<h2>给 nomadro 提建议</h2> <h2>给 nomadro 提建议</h2>
@ -75,9 +88,19 @@ export default function FeedbackWidget() {
required required
maxLength={500} maxLength={500}
/> />
<button type="submit" className="btn btn-primary" disabled={sending || !msg.trim()} style={{ width: "100%" }}> <button
type="submit"
className="btn btn-primary"
disabled={sending || !msg.trim()}
style={{ width: "100%" }}
>
{sending ? "发送中…" : "发送反馈 🚀"} {sending ? "发送中…" : "发送反馈 🚀"}
</button> </button>
<p className="feedback-more-link">
<Link href="/feedback" onClick={() => setOpen(false)}>
打开完整反馈页
</Link>
</p>
</form> </form>
</div> </div>
</div> </div>

View File

@ -11,26 +11,80 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
const { token } = useAuth(); const { token } = useAuth();
const { t } = useI18n(); const { t } = useI18n();
const [session, setSession] = useState<MeetupSession | null>(null); const [session, setSession] = useState<MeetupSession | null>(null);
const [error, setError] = useState(false);
const [layout, setLayout] = useState<"split" | "video" | "chat">("split"); const [layout, setLayout] = useState<"split" | "video" | "chat">("split");
useEffect(() => { useEffect(() => {
api.getMeetupSession(meetupId, token || undefined).then(setSession).catch(() => setSession(null)); setError(false);
setSession(null);
api
.getMeetupSession(meetupId, token || undefined)
.then(setSession)
.catch(() => {
setSession(null);
setError(true);
});
}, [meetupId, token]); }, [meetupId, token]);
if (error) {
return (
<div className="live-page">
<div className="container">
<div className="live-gate reveal">
<h2>无法打开直播间</h2>
<p>活动可能不存在,或暂时不可用</p>
<Link href="/meetups" className="btn btn-primary">
{t.live.back}
</Link>
</div>
</div>
</div>
);
}
if (!session) { if (!session) {
return <div className="live-page"><div className="container"><p className="dest-empty">{t.live.loading}</p></div></div>; return (
<div className="live-page">
<div className="container">
<p className="dest-empty">{t.live.loading}</p>
</div>
</div>
);
} }
if (!session.canJoin) { if (!session.canJoin) {
const reason = session.lockReason || (session.mode === "offline" ? "offline" : "login_required");
const copy =
reason === "vip_required"
? t.live.vipRequired
: reason === "host_required"
? "仅主办方可进入此房间"
: reason === "offline"
? "此活动为线下场次,没有在线直播间"
: t.live.loginRequired;
const href =
reason === "vip_required"
? "/join"
: reason === "offline"
? "/meetups"
: `/login?next=/meetups/${meetupId}/live`;
const label =
reason === "vip_required" ? t.join.payBtn : reason === "offline" ? t.live.back : t.nav.login;
return ( return (
<div className="live-page"> <div className="live-page">
<div className="container"> <div className="container">
<div className="live-gate reveal"> <div className="live-gate reveal">
<h2>{title}</h2> <h2>{title}</h2>
<p>{session.lockReason === "login_required" ? t.live.loginRequired : t.live.vipRequired}</p> <p>{copy}</p>
<Link href={session.lockReason === "login_required" ? `/login?next=/meetups/${meetupId}/live` : "/join"} className="btn btn-primary"> <div className="dating-empty-actions">
{session.lockReason === "login_required" ? t.nav.login : t.join.payBtn} <Link href={href} className="btn btn-primary">
</Link> {label}
</Link>
<Link href="/meetups" className="btn">
{t.live.back}
</Link>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -44,19 +98,32 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
<strong>{title}</strong> <strong>{title}</strong>
<div className="live-layout-btns"> <div className="live-layout-btns">
{(["split", "video", "chat"] as const).map((m) => ( {(["split", "video", "chat"] as const).map((m) => (
<button key={m} type="button" className={`filter-btn${layout === m ? " active" : ""}`} onClick={() => setLayout(m)}> <button
{m} key={m}
type="button"
className={`filter-btn${layout === m ? " active" : ""}`}
onClick={() => setLayout(m)}
>
{m === "split" ? "分屏" : m === "video" ? "视频" : "聊天"}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<div className={`live-stage layout-${layout}`}> <div className={`live-stage layout-${layout}`}>
{(layout === "split" || layout === "video") && session.videoUrl && ( {(layout === "split" || layout === "video") && session.videoUrl && (
<iframe title="video" src={session.videoUrl} className="live-iframe live-video" allow="camera; microphone; display-capture" /> <iframe
title="video"
src={session.videoUrl}
className="live-iframe live-video"
allow="camera; microphone; display-capture"
/>
)} )}
{(layout === "split" || layout === "chat") && session.chatUrl && ( {(layout === "split" || layout === "chat") && session.chatUrl && (
<iframe title="chat" src={session.chatUrl} className="live-iframe live-chat" /> <iframe title="chat" src={session.chatUrl} className="live-iframe live-chat" />
)} )}
{!session.videoUrl && !session.chatUrl && (
<p className="dest-empty">直播链接尚未配置</p>
)}
</div> </div>
</div> </div>
); );

View File

@ -164,7 +164,24 @@ export default function MeetupsClient({ meetups }: Props) {
})} })}
</div> </div>
{filtered.length === 0 && <p className="dest-empty">{t.meetups.empty}</p>} {filtered.length === 0 && (
<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")}>
查看全部
</button>
)}
<Link href="/meetups/host" className="btn btn-primary btn-sm">
{t.meetups.hostBtn}
</Link>
<Link href="/community" className="btn btn-sm">
{t.nav.community}
</Link>
</div>
</div>
)}
<NewsletterSubscribe source="meetups" /> <NewsletterSubscribe source="meetups" />
<RingNext steps={rings.afterMeetups} /> <RingNext steps={rings.afterMeetups} />

View File

@ -6,17 +6,46 @@ import type { MemberProfile } from "@/lib/types";
export default function MemberProfileClient({ profile }: { profile: MemberProfile }) { export default function MemberProfileClient({ profile }: { profile: MemberProfile }) {
const { t } = useI18n(); const { t } = useI18n();
const photo = profile.photo || "";
const isUrl = photo.startsWith("http");
return ( return (
<div className="dating-page"> <div className="dating-page">
<div className="container"> <div className="container">
<nav className="detail-nav"><Link href="/dating">← {t.nav.dating}</Link></nav> <nav className="detail-nav">
<Link href="/dating">← {t.nav.dating}</Link>
<span> · </span>
<Link href="/chat">{t.nav.chat}</Link>
</nav>
<div className="dating-card reveal" style={{ maxWidth: 480 }}> <div className="dating-card reveal" style={{ maxWidth: 480 }}>
<div className="dating-card-photo">{profile.photo || "🧑‍💻"}</div> <div className="dating-card-photo">
<h2>{profile.name}{profile.vip ? " ✨" : ""}</h2> {isUrl ? (
<p className="dating-meta">{profile.location}</p> // eslint-disable-next-line @next/next/no-img-element
<p>{profile.bio}</p> <img src={photo} alt="" className="member-photo-img" />
) : (
photo || "🧑‍💻"
)}
</div>
<h2>
{profile.name}
{profile.vip ? " ✨" : ""}
</h2>
<p className="dating-meta">{profile.location || "全球游民"}</p>
<p>{profile.bio || "这位游民还没写自我介绍"}</p>
<div className="dating-tags"> <div className="dating-tags">
{(profile.tags || []).map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)} {(profile.tags || []).map((tag) => (
<span key={tag} className="meetup-tag">
{tag}
</span>
))}
</div>
<div className="dating-empty-actions" style={{ marginTop: "1.25rem" }}>
<Link href="/dating" className="btn btn-primary btn-sm">
继续匹配
</Link>
<Link href="/meetups" className="btn btn-sm">
去活动认识人
</Link>
</div> </div>
</div> </div>
</div> </div>

View File

@ -401,7 +401,17 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
<div className="plan-empty"> <div className="plan-empty">
<span className="plan-empty-icon">🗺️</span> <span className="plan-empty-icon">🗺️</span>
<p>{t.plan.empty}</p> <p>{t.plan.empty}</p>
<Link href="/#destinations" className="btn btn-primary">{t.plan.goDest}</Link> <div className="dating-empty-actions">
<Link href="/next-stop" className="btn btn-primary">
智能下一站
</Link>
<Link href="/compare" className="btn">
城市对比
</Link>
<Link href="/#destinations" className="btn btn-ghost">
{t.plan.goDest}
</Link>
</div>
</div> </div>
) : ( ) : (
<ol className="plan-timeline"> <ol className="plan-timeline">

View File

@ -9,17 +9,38 @@ export default function VideoDetailClient({ video }: { video: VideoItem }) {
return ( return (
<div className="community-detail-page"> <div className="community-detail-page">
<div className="container"> <div className="container">
<nav className="detail-nav"><Link href="/videos">{t.videos.back}</Link></nav> <nav className="detail-nav">
<Link href="/videos">{t.videos.back}</Link>
</nav>
<article className="community-detail reveal"> <article className="community-detail reveal">
<span className="community-detail-cat">{video.city} · {video.guest}</span> <span className="community-detail-cat">
<h1>{video.emoji} {video.title}</h1> {video.city} · {video.guest}
</span>
<h1>
{video.emoji} {video.title}
</h1>
<p>{video.excerpt}</p> <p>{video.excerpt}</p>
{video.video_url && ( {video.video_url ? (
<div className="video-embed"> <div className="video-embed">
<iframe src={video.video_url} title={video.title} allowFullScreen /> <iframe src={video.video_url} title={video.title} allowFullScreen />
</div> </div>
) : (
<div className="notif-empty">
<p className="dest-empty">播放链接暂未配置</p>
<Link href="/book/read" className="btn btn-primary btn-sm">
去读电子书
</Link>
</div>
)} )}
</article> </article>
<div className="dating-empty-actions" style={{ marginTop: "1.5rem" }}>
<Link href="/meetups" className="btn btn-sm">
相关活动
</Link>
<Link href="/digital" className="btn btn-sm">
学院
</Link>
</div>
</div> </div>
</div> </div>
); );

View File

@ -3,30 +3,52 @@
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { VideoItem } from "@/lib/types"; import type { VideoItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function VideosClient({ videos }: { videos: VideoItem[] }) { export default function VideosClient({ videos }: { videos: VideoItem[] }) {
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
return ( return (
<div className="community-page"> <div className="community-page">
<div className="container"> <div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav> <nav className="detail-nav">
<Link href="/">{t.common.backHome}</Link>
</nav>
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.videos.tag}</span> <span className="section-tag">{t.videos.tag}</span>
<h1>{t.videos.title}</h1> <h1>{t.videos.title}</h1>
<p>{t.videos.subtitle}</p> <p>{t.videos.subtitle}</p>
</div> </div>
<div className="community-list"> {videos.length === 0 ? (
{videos.map((v) => ( <div className="notif-empty reveal">
<Link key={v.id} href={`/videos/${v.slug}`} className="community-card reveal"> <p className="dest-empty">视频内容准备中</p>
<div className="community-card-head"> <div className="dating-empty-actions">
<span>{v.emoji}</span> <Link href="/book" className="btn btn-primary btn-sm">
<span className="community-meta">{v.city} · {v.duration}</span> 先读电子书
</div> </Link>
<h3>{v.title}</h3> <Link href="/digital" className="btn btn-sm">
<p className="community-excerpt">{v.excerpt}</p> 去学院
</Link> </Link>
))} </div>
</div> </div>
) : (
<div className="community-list">
{videos.map((v) => (
<Link key={v.id} href={`/videos/${v.slug}`} className="community-card reveal">
<div className="community-card-head">
<span>{v.emoji}</span>
<span className="community-meta">
{v.city} · {v.duration}
</span>
</div>
<h3>{v.title}</h3>
<p className="community-excerpt">{v.excerpt}</p>
</Link>
))}
</div>
)}
<RingNext steps={rings.afterDiscover} />
</div> </div>
</div> </div>
); );

View File

@ -29,18 +29,32 @@ export const STATIC_PAGES: Record<string, StaticPageDef> = {
title: "帮助中心", title: "帮助中心",
sections: [ sections: [
{ heading: "如何开始", body: "注册账号 → 探索目的地 → 创建旅居计划 → 参与社区与活动。" }, { heading: "如何开始", body: "注册账号 → 探索目的地 → 创建旅居计划 → 参与社区与活动。" },
{
heading: "三条产品环",
list: [
"发现:目的地 / 下一站 / 计划 / 对比",
"连接:活动 RSVP、社区讨论、匹配与私信",
"成长:学院课程、赏金任务、电子书与会员",
],
},
{ {
heading: "常见问题", heading: "常见问题",
list: [ list: [
"匹配功能需要先完善游民资料(/join)", "匹配功能需要先完善游民资料(/join)",
"VIP 会员解锁无限滑动、直播活动与学院课程", "VIP 会员解锁无限滑动、直播活动与学院付费课时",
"旅居计划支持云端同步与 ICS 导出", "旅居计划支持云端同步与 ICS 导出",
"活动 RSVP 后可在活动页进入直播房间", "线上/混合活动 RSVP 后可从活动卡片进入直播",
"头像可在资料页上传;通知铃铛显示未读数",
], ],
}, },
{ heading: "更多", body: "查看 FAQ 区块或社区讨论搜索已有话题。" }, { heading: "更多", body: "查看首页 FAQ,或在社区发帖提问。" },
],
cta: [
{ label: "打开 FAQ", href: "/#faq" },
{ label: "社区讨论", href: "/community" },
{ label: "提交反馈", href: "/feedback" },
{ label: "开通会员", href: "/join" },
], ],
cta: [{ label: "打开 FAQ", href: "/#faq" }, { label: "社区讨论", href: "/community" }],
}, },
safety: { safety: {
tag: "🛡️ SAFETY", tag: "🛡️ SAFETY",