diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 8105752..bf0403b 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -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; }
diff --git a/frontend/src/components/AiAssistantClient.tsx b/frontend/src/components/AiAssistantClient.tsx
index f4062c3..6a74e77 100644
--- a/frontend/src/components/AiAssistantClient.tsx
+++ b/frontend/src/components/AiAssistantClient.tsx
@@ -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 (
-
+
{t.ai.tag}
{t.ai.title}
{t.ai.subtitle}
+
+ {SUGGESTIONS.map((s) => (
+
+ ))}
+
{reply && (
{reply}
{cities.map((c) => (
- {c.emoji} {c.name}
+
+ {c.emoji} {c.name}
+
))}
- {t.ai.nextStop}
+
+ {t.ai.nextStop}
+
+
+ 城市对比
+
+
+ 写入计划
+
)}
+
);
diff --git a/frontend/src/components/CompareClient.tsx b/frontend/src/components/CompareClient.tsx
index 5628414..65dcce7 100644
--- a/frontend/src/components/CompareClient.tsx
+++ b/frontend/src/components/CompareClient.tsx
@@ -118,22 +118,26 @@ export default function CompareClient({ destinations }: Props) {
- {destinations.map((d) => {
- const on = picked.includes(d.slug);
- return (
-
- );
- })}
+ {destinations.length === 0 ? (
+
目的地数据加载中或暂不可用
+ ) : (
+ destinations.map((d) => {
+ const on = picked.includes(d.slug);
+ return (
+
+ );
+ })
+ )}
diff --git a/frontend/src/components/FeedbackClient.tsx b/frontend/src/components/FeedbackClient.tsx
index 671318b..9deff62 100644
--- a/frontend/src/components/FeedbackClient.tsx
+++ b/frontend/src/components/FeedbackClient.tsx
@@ -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,22 +37,48 @@ export default function FeedbackClient() {
return (
-
+
{t.feedback.tag}
{t.feedback.title}
{t.feedback.subtitle}
-
-
-
+ {done ? (
+
+
已收到,谢谢
+
我们会认真阅读每一条反馈
+
+
+
+ 去社区
+
+
+
+ ) : (
+
+
+
+ )}
);
diff --git a/frontend/src/components/FeedbackWidget.tsx b/frontend/src/components/FeedbackWidget.tsx
index 4a54a62..ce45f25 100644
--- a/frontend/src/components/FeedbackWidget.tsx
+++ b/frontend/src/components/FeedbackWidget.tsx
@@ -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 {
- 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)));
+ 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 && (
e.target === e.currentTarget && setOpen(false)}>
-
+
💬 FEEDBACK
给 nomadro 提建议
@@ -75,9 +88,19 @@ export default function FeedbackWidget() {
required
maxLength={500}
/>
-
diff --git a/frontend/src/components/MeetupLiveClient.tsx b/frontend/src/components/MeetupLiveClient.tsx
index a4ddddc..9ecc17b 100644
--- a/frontend/src/components/MeetupLiveClient.tsx
+++ b/frontend/src/components/MeetupLiveClient.tsx
@@ -11,26 +11,80 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
const { token } = useAuth();
const { t } = useI18n();
const [session, setSession] = useState
(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 (
+
+
+
+
无法打开直播间
+
活动可能不存在,或暂时不可用
+
+ {t.live.back}
+
+
+
+
+ );
+ }
+
if (!session) {
- return ;
+ return (
+
+ );
}
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 (
{title}
-
{session.lockReason === "login_required" ? t.live.loginRequired : t.live.vipRequired}
-
- {session.lockReason === "login_required" ? t.nav.login : t.join.payBtn}
-
+
{copy}
+
+
+ {label}
+
+
+ {t.live.back}
+
+
@@ -44,19 +98,32 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
{title}
{(["split", "video", "chat"] as const).map((m) => (
- setLayout(m)}>
- {m}
+ setLayout(m)}
+ >
+ {m === "split" ? "分屏" : m === "video" ? "视频" : "聊天"}
))}
{(layout === "split" || layout === "video") && session.videoUrl && (
-
+
)}
{(layout === "split" || layout === "chat") && session.chatUrl && (
)}
+ {!session.videoUrl && !session.chatUrl && (
+
直播链接尚未配置
+ )}
);
diff --git a/frontend/src/components/MeetupsClient.tsx b/frontend/src/components/MeetupsClient.tsx
index 8328057..b1ed735 100644
--- a/frontend/src/components/MeetupsClient.tsx
+++ b/frontend/src/components/MeetupsClient.tsx
@@ -164,7 +164,24 @@ export default function MeetupsClient({ meetups }: Props) {
})}
- {filtered.length === 0 && {t.meetups.empty}
}
+ {filtered.length === 0 && (
+
+
{t.meetups.empty}
+
+ {mode !== "all" && (
+ setMode("all")}>
+ 查看全部
+
+ )}
+
+ {t.meetups.hostBtn}
+
+
+ {t.nav.community}
+
+
+
+ )}
diff --git a/frontend/src/components/MemberProfileClient.tsx b/frontend/src/components/MemberProfileClient.tsx
index daf6620..50b1bb3 100644
--- a/frontend/src/components/MemberProfileClient.tsx
+++ b/frontend/src/components/MemberProfileClient.tsx
@@ -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 (
-
+
-
{profile.photo || "🧑💻"}
-
{profile.name}{profile.vip ? " ✨" : ""}
-
{profile.location}
-
{profile.bio}
+
+ {isUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+ photo || "🧑💻"
+ )}
+
+
+ {profile.name}
+ {profile.vip ? " ✨" : ""}
+
+
{profile.location || "全球游民"}
+
{profile.bio || "这位游民还没写自我介绍"}
- {(profile.tags || []).map((tag) => {tag})}
+ {(profile.tags || []).map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+ 继续匹配
+
+
+ 去活动认识人
+
diff --git a/frontend/src/components/MovePlanClient.tsx b/frontend/src/components/MovePlanClient.tsx
index fe44c3b..1229a0d 100644
--- a/frontend/src/components/MovePlanClient.tsx
+++ b/frontend/src/components/MovePlanClient.tsx
@@ -401,7 +401,17 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
🗺️
{t.plan.empty}
-
{t.plan.goDest}
+
+
+ 智能下一站
+
+
+ 城市对比
+
+
+ {t.plan.goDest}
+
+
) : (
diff --git a/frontend/src/components/VideoDetailClient.tsx b/frontend/src/components/VideoDetailClient.tsx
index 7f4b465..50f798e 100644
--- a/frontend/src/components/VideoDetailClient.tsx
+++ b/frontend/src/components/VideoDetailClient.tsx
@@ -9,17 +9,38 @@ export default function VideoDetailClient({ video }: { video: VideoItem }) {
return (
-
+
- {video.city} · {video.guest}
- {video.emoji} {video.title}
+
+ {video.city} · {video.guest}
+
+
+ {video.emoji} {video.title}
+
{video.excerpt}
- {video.video_url && (
+ {video.video_url ? (
+ ) : (
+
)}
+
+
+ 相关活动
+
+
+ 学院
+
+
);
diff --git a/frontend/src/components/VideosClient.tsx b/frontend/src/components/VideosClient.tsx
index c60d515..6c22d0c 100644
--- a/frontend/src/components/VideosClient.tsx
+++ b/frontend/src/components/VideosClient.tsx
@@ -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 (
-
+
{t.videos.tag}
{t.videos.title}
{t.videos.subtitle}
-
- {videos.map((v) => (
-
-
- {v.emoji}
- {v.city} · {v.duration}
-
-
{v.title}
-
{v.excerpt}
-
- ))}
-
+ {videos.length === 0 ? (
+
+
视频内容准备中
+
+
+ 先读电子书
+
+
+ 去学院
+
+
+
+ ) : (
+
+ {videos.map((v) => (
+
+
+ {v.emoji}
+
+ {v.city} · {v.duration}
+
+
+
{v.title}
+
{v.excerpt}
+
+ ))}
+
+ )}
+
);
diff --git a/frontend/src/lib/staticPages.ts b/frontend/src/lib/staticPages.ts
index 6acab5e..1d76d37 100644
--- a/frontend/src/lib/staticPages.ts
+++ b/frontend/src/lib/staticPages.ts
@@ -29,18 +29,32 @@ export const STATIC_PAGES: Record = {
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",