Polish explore hubs: feedback API, live gates, AI tips, empty states.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d96666c329
commit
0ce48023ae
@ -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; }
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user