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;
}
.member-photo-img {
width: 96px;
height: 96px;
border-radius: 50%;
object-fit: cover;
display: block;
margin: 0 auto;
}
.dating-meta {
color: var(--text-secondary);
font-size: 0.9rem;
@ -10564,6 +10573,23 @@ img { max-width: 100%; display: block; }
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; }
.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 { api } from "@/lib/api";
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() {
const { t } = useI18n();
const { toast } = useToast();
const rings = useRingSteps();
const [message, setMessage] = useState("");
const [reply, setReply] = useState("");
const [cities, setCities] = useState<{ slug: string; name: string; emoji: string }[]>([]);
const [loading, setLoading] = useState(false);
const ask = async () => {
if (!message.trim()) return;
const ask = async (text?: string) => {
const q = (text ?? message).trim();
if (!q) {
toast("先写一个问题", "info");
return;
}
setMessage(q);
setLoading(true);
try {
const res = await api.askAssistant(message.trim());
const res = await api.askAssistant(q);
setReply(res.reply);
setCities(res.cities || []);
} catch {
setReply(t.ai.fail);
setCities([]);
} finally {
setLoading(false);
}
@ -29,27 +46,60 @@ export default function AiAssistantClient() {
return (
<div className="community-page">
<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">
<span className="section-tag">{t.ai.tag}</span>
<h1>{t.ai.title}</h1>
<p>{t.ai.subtitle}</p>
</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">
<textarea rows={4} placeholder={t.ai.placeholder} value={message} onChange={(e) => setMessage(e.target.value)} />
<button type="button" className="btn btn-primary" disabled={loading} onClick={ask}>{t.ai.ask}</button>
<textarea
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>
{reply && (
<div className="ai-reply reveal">
<p>{reply}</p>
<div className="ai-cities">
{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>
)}
<RingNext steps={rings.afterDiscover} />
</div>
</div>
);

View File

@ -118,7 +118,10 @@ export default function CompareClient({ destinations }: Props) {
</div>
</div>
<div className="compare-pick-grid">
{destinations.map((d) => {
{destinations.length === 0 ? (
<p className="dest-empty">目的地数据加载中或暂不可用</p>
) : (
destinations.map((d) => {
const on = picked.includes(d.slug);
return (
<button
@ -133,7 +136,8 @@ export default function CompareClient({ destinations }: Props) {
<small>¥{d.cost.toLocaleString()}</small>
</button>
);
})}
})
)}
</div>
</section>

View File

@ -14,14 +14,19 @@ export default function FeedbackClient() {
const [content, setContent] = useState("");
const [category, setCategory] = useState("general");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const submit = async () => {
if (!content.trim()) return;
if (!content.trim() || content.trim().length < 5) {
toast("请至少写 5 个字", "info");
return;
}
setLoading(true);
try {
await api.submitFeedback(content.trim(), token || undefined, category);
toast(t.feedback.ok, "success");
setContent("");
setDone(true);
} catch {
toast(t.feedback.fail, "error");
} finally {
@ -32,12 +37,28 @@ export default function FeedbackClient() {
return (
<div className="community-page">
<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">
<span className="section-tag">{t.feedback.tag}</span>
<h1>{t.feedback.title}</h1>
<p>{t.feedback.subtitle}</p>
</div>
{done ? (
<div className="dating-gate reveal">
<h2>已收到,谢谢</h2>
<p>我们会认真阅读每一条反馈</p>
<div className="dating-empty-actions">
<button type="button" className="btn btn-primary" onClick={() => setDone(false)}>
再写一条
</button>
<Link href="/community" className="btn">
去社区
</Link>
</div>
</div>
) : (
<div className="community-compose reveal">
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="general">{t.feedback.catGeneral}</option>
@ -45,9 +66,19 @@ export default function FeedbackClient() {
<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)} />
<button type="button" className="btn btn-primary" disabled={loading} onClick={submit}>{t.feedback.submit}</button>
<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>
);

View File

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

View File

@ -11,26 +11,80 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
const { token } = useAuth();
const { t } = useI18n();
const [session, setSession] = useState<MeetupSession | null>(null);
const [error, setError] = useState(false);
const [layout, setLayout] = useState<"split" | "video" | "chat">("split");
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]);
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) {
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) {
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 (
<div className="live-page">
<div className="container">
<div className="live-gate reveal">
<h2>{title}</h2>
<p>{session.lockReason === "login_required" ? t.live.loginRequired : t.live.vipRequired}</p>
<Link href={session.lockReason === "login_required" ? `/login?next=/meetups/${meetupId}/live` : "/join"} className="btn btn-primary">
{session.lockReason === "login_required" ? t.nav.login : t.join.payBtn}
<p>{copy}</p>
<div className="dating-empty-actions">
<Link href={href} className="btn btn-primary">
{label}
</Link>
<Link href="/meetups" className="btn">
{t.live.back}
</Link>
</div>
</div>
</div>
</div>
@ -44,19 +98,32 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
<strong>{title}</strong>
<div className="live-layout-btns">
{(["split", "video", "chat"] as const).map((m) => (
<button key={m} type="button" className={`filter-btn${layout === m ? " active" : ""}`} onClick={() => setLayout(m)}>
{m}
<button
key={m}
type="button"
className={`filter-btn${layout === m ? " active" : ""}`}
onClick={() => setLayout(m)}
>
{m === "split" ? "分屏" : m === "video" ? "视频" : "聊天"}
</button>
))}
</div>
</div>
<div className={`live-stage layout-${layout}`}>
{(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 && (
<iframe title="chat" src={session.chatUrl} className="live-iframe live-chat" />
)}
{!session.videoUrl && !session.chatUrl && (
<p className="dest-empty">直播链接尚未配置</p>
)}
</div>
</div>
);

View File

@ -164,7 +164,24 @@ export default function MeetupsClient({ meetups }: Props) {
})}
</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" />
<RingNext steps={rings.afterMeetups} />

View File

@ -6,17 +6,46 @@ import type { MemberProfile } from "@/lib/types";
export default function MemberProfileClient({ profile }: { profile: MemberProfile }) {
const { t } = useI18n();
const photo = profile.photo || "";
const isUrl = photo.startsWith("http");
return (
<div className="dating-page">
<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-photo">{profile.photo || "🧑‍💻"}</div>
<h2>{profile.name}{profile.vip ? " ✨" : ""}</h2>
<p className="dating-meta">{profile.location}</p>
<p>{profile.bio}</p>
<div className="dating-card-photo">
{isUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<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">
{(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>

View File

@ -401,7 +401,17 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
<div className="plan-empty">
<span className="plan-empty-icon">🗺️</span>
<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>
) : (
<ol className="plan-timeline">

View File

@ -9,17 +9,38 @@ export default function VideoDetailClient({ video }: { video: VideoItem }) {
return (
<div className="community-detail-page">
<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">
<span className="community-detail-cat">{video.city} · {video.guest}</span>
<h1>{video.emoji} {video.title}</h1>
<span className="community-detail-cat">
{video.city} · {video.guest}
</span>
<h1>
{video.emoji} {video.title}
</h1>
<p>{video.excerpt}</p>
{video.video_url && (
{video.video_url ? (
<div className="video-embed">
<iframe src={video.video_url} title={video.title} allowFullScreen />
</div>
) : (
<div className="notif-empty">
<p className="dest-empty">播放链接暂未配置</p>
<Link href="/book/read" className="btn btn-primary btn-sm">
去读电子书
</Link>
</div>
)}
</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>
);

View File

@ -3,30 +3,52 @@
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 VideosClient({ videos }: { videos: VideoItem[] }) {
const { t } = useI18n();
const rings = useRingSteps();
return (
<div className="community-page">
<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">
<span className="section-tag">{t.videos.tag}</span>
<h1>{t.videos.title}</h1>
<p>{t.videos.subtitle}</p>
</div>
{videos.length === 0 ? (
<div className="notif-empty reveal">
<p className="dest-empty">视频内容准备中</p>
<div className="dating-empty-actions">
<Link href="/book" className="btn btn-primary btn-sm">
先读电子书
</Link>
<Link href="/digital" className="btn btn-sm">
去学院
</Link>
</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>
<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>
);

View File

@ -29,18 +29,32 @@ export const STATIC_PAGES: Record<string, StaticPageDef> = {
title: "帮助中心",
sections: [
{ heading: "如何开始", body: "注册账号 → 探索目的地 → 创建旅居计划 → 参与社区与活动。" },
{
heading: "三条产品环",
list: [
"发现:目的地 / 下一站 / 计划 / 对比",
"连接:活动 RSVP、社区讨论、匹配与私信",
"成长:学院课程、赏金任务、电子书与会员",
],
},
{
heading: "常见问题",
list: [
"匹配功能需要先完善游民资料(/join)",
"VIP 会员解锁无限滑动、直播活动与学院课程",
"VIP 会员解锁无限滑动、直播活动与学院付费课时",
"旅居计划支持云端同步与 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: {
tag: "🛡️ SAFETY",