From d43005d04ab9164217851aca4181bb1f02807484 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 30 Aug 2026 20:16:31 -0500 Subject: [PATCH] Polish connect-ring UX: VIP, notifications, dating, gigs, chat. Co-authored-by: Cursor --- frontend/src/app/globals.css | 85 +++++++++++++++- frontend/src/app/profile/page.tsx | 10 +- frontend/src/components/ChatClient.tsx | 62 +++++++++--- .../src/components/CommunityHubClient.tsx | 7 +- frontend/src/components/DatingClient.tsx | 24 ++++- frontend/src/components/GigsClient.tsx | 66 ++++++++++--- frontend/src/components/JoinClient.tsx | 50 ++++++++-- frontend/src/components/JoinPaidClient.tsx | 51 +++++++--- frontend/src/components/Navbar.tsx | 19 +++- .../src/components/NotificationsClient.tsx | 97 ++++++++++++++++--- scripts/deploy_update.py | 11 ++- 11 files changed, 407 insertions(+), 75 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a508b06..8105752 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -72,9 +72,9 @@ body { } /* - * 任何祖先上的 overflow-x: hidden|clip 都会废掉 sticky 顶栏 - *(滚一下顶栏整块飞走,只剩顶部残影 —— 用户截图那个状态)。 - * ebook 路由 html/body 全部 visible。 + * ?????? overflow-x: hidden|clip ???? sticky ?? + *????????????????? ?? ?????????? + * ebook ?? html/body ?? visible? */ html:has([data-ebook-root]) { overflow-x: visible; @@ -1234,7 +1234,7 @@ img { max-width: 100%; display: block; } transform: translateY(0); } -/* Sub-pages use SiteShell, not HomeClient — ensure content is never stuck hidden */ +/* Sub-pages use SiteShell, not HomeClient ? ensure content is never stuck hidden */ :not(.home-flow) .reveal { opacity: 1; transform: translateY(0); @@ -2620,6 +2620,14 @@ img { max-width: 100%; display: block; } white-space: nowrap; } +.nav-user-avatar-img { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + .nav-logout { background: none; border: none; @@ -2730,6 +2738,28 @@ img { max-width: 100%; display: block; } font-weight: 600; } +.profile-badges-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + align-items: center; +} + +.profile-badges-row .profile-level { + margin-top: 0; +} + +.profile-vip-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 100px; + background: rgba(255, 193, 7, 0.18); + color: #e6b800; + font-size: 0.8rem; + font-weight: 600; +} + .profile-stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); @@ -9784,7 +9814,7 @@ img { max-width: 100%; display: block; } .compare-bar .btn { flex: 1; min-width: 0; } } -/* ===== Next Stop / Meetups / Community (NomadCNA → nomadro) ===== */ +/* ===== Next Stop / Meetups / Community (NomadCNA ? nomadro) ===== */ .next-stop-page, .meetups-page, @@ -10301,6 +10331,44 @@ img { max-width: 100%; display: block; } margin-bottom: 20px; } +.dating-quota a { + color: var(--accent-3); +} + +.dating-empty, +.gigs-empty, +.notif-empty { + text-align: center; + padding: 1.5rem 0 0.5rem; +} + +.dating-empty-actions, +.notif-empty-links, +.join-paid-actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + justify-content: center; + margin-top: 1rem; +} + +.join-vip-badge { + margin-top: 0.75rem; + display: inline-block; + padding: 0.35rem 0.9rem; + border-radius: 999px; + background: rgba(255, 193, 7, 0.15); + color: #e6b800; + font-size: 0.85rem; + font-weight: 600; +} + +.gigs-applied { + color: var(--accent-3); + font-weight: 600; + margin: 0.5rem 0 0; +} + .dating-card { max-width: 420px; margin: 0 auto; @@ -10569,6 +10637,13 @@ img { max-width: 100%; display: block; } .dating-like-photo { font-size: 2.5rem; margin-bottom: 8px; } .chat-avatar { font-size: 1.75rem; } +.chat-avatar-img { + width: 44px; + height: 44px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} .chat-unread { margin-left: auto; font-style: normal; diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 2cd919c..a1af31e 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -43,6 +43,7 @@ export default function ProfilePage() { const [meta, setMeta] = useState(null); const [loading, setLoading] = useState(true); const [uploadingAvatar, setUploadingAvatar] = useState(false); + const [vip, setVip] = useState(false); const onAvatarChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -79,9 +80,11 @@ export default function ProfilePage() { Promise.all([ api.getProfileStats(token), favorites.length > 0 ? api.getFavoriteDestinations(token) : Promise.resolve([]), - ]).then(([s, f]) => { + api.getVipStatus(token).catch(() => ({ vip: false, expires_at: 0 })), + ]).then(([s, f, v]) => { setStats(s); setFavoriteDests(f); + setVip(Boolean(v.vip)); }).catch(() => {}).finally(() => setLoading(false)); refreshPlan(); @@ -141,7 +144,10 @@ export default function ProfilePage() {

{user.name}

{user.email}

- {stats && {stats.nomad_level}} +
+ {stats && {stats.nomad_level}} + {vip && ✨ VIP} +
{planSyncStatus === "syncing" && "计划同步中…"} {planSyncStatus === "synced" && "✓ 计划已云端同步"} diff --git a/frontend/src/components/ChatClient.tsx b/frontend/src/components/ChatClient.tsx index 6b47b3f..fecc697 100644 --- a/frontend/src/components/ChatClient.tsx +++ b/frontend/src/components/ChatClient.tsx @@ -6,15 +6,37 @@ import { api } from "@/lib/api"; import { useAuth } from "@/lib/auth"; import { useI18n } from "@/lib/i18n"; import type { ConversationItem } from "@/lib/types"; +import RingNext from "@/components/RingNext"; +import { useRingSteps } from "@/lib/rings"; + +function Avatar({ photo, name }: { photo?: string; name?: string }) { + if (photo?.startsWith("http")) { + return ( + // eslint-disable-next-line @next/next/no-img-element + + ); + } + return {photo || name?.[0] || "💬"}; +} export default function ChatClient() { const { user, token } = useAuth(); const { t } = useI18n(); + const rings = useRingSteps(); const [items, setItems] = useState([]); + const [loading, setLoading] = useState(Boolean(token)); useEffect(() => { - if (!token) return; - api.getConversations(token).then(setItems).catch(() => setItems([])); + if (!token) { + setLoading(false); + return; + } + setLoading(true); + api + .getConversations(token) + .then(setItems) + .catch(() => setItems([])) + .finally(() => setLoading(false)); }, [token]); if (!user) { @@ -41,18 +63,32 @@ export default function ChatClient() {

{t.chat.title}

- {items.map((c) => ( - 0 ? " unread" : ""}`}> - {c.peer?.photo || "💬"} -
- {c.peer?.name || "游民"} -

{c.lastMessagePreview || t.chat.noMessages}

-
- {c.unreadCount > 0 && {c.unreadCount}} - - ))} - {items.length === 0 &&

{t.chat.empty}

} + {loading &&

加载对话…

} + {!loading && + items.map((c) => ( + 0 ? " unread" : ""}`} + > + +
+ {c.peer?.name || "游民"} +

{c.lastMessagePreview || t.chat.noMessages}

+
+ {c.unreadCount > 0 && {c.unreadCount}} + + ))} + {!loading && items.length === 0 && ( +
+

{t.chat.empty}

+ + 去匹配 + +
+ )}
+ ); diff --git a/frontend/src/components/CommunityHubClient.tsx b/frontend/src/components/CommunityHubClient.tsx index 656c924..3025977 100644 --- a/frontend/src/components/CommunityHubClient.tsx +++ b/frontend/src/components/CommunityHubClient.tsx @@ -123,7 +123,12 @@ export default function CommunityHubClient({ discussions, featured }: Props) { {filtered.length === 0 && ( -

{t.community.empty}

+
+

{t.community.empty}

+ + {t.community.newTopic} + +
)} diff --git a/frontend/src/components/DatingClient.tsx b/frontend/src/components/DatingClient.tsx index f5332f6..4b8bf57 100644 --- a/frontend/src/components/DatingClient.tsx +++ b/frontend/src/components/DatingClient.tsx @@ -144,6 +144,12 @@ export default function DatingClient() { {quota && (

{quota.vip ? "✨ VIP 无限滑" : `今日剩余 ${quota.remaining}/${quota.limit} 次`} + {!quota.vip && quota.remaining <= 0 && ( + <> + {" · "} + 开通 VIP 无限匹配 + + )}

)} @@ -154,6 +160,8 @@ export default function DatingClient() { )} + {loading && joined &&

加载候选人…

} + {top && (
{top.photo || "🧑‍💻"}
@@ -164,9 +172,9 @@ export default function DatingClient() { {(top.tags || []).map((tag) => {tag})}
- - - + + + {token && ( )} @@ -175,7 +183,15 @@ export default function DatingClient() { )} {!loading && joined && !top && ( -

{t.dating.empty}

+
+

{t.dating.empty}

+
+ + 去活动认识人 + 查看对话 + {quota && !quota.vip && 开通 VIP} +
+
)} diff --git a/frontend/src/components/GigsClient.tsx b/frontend/src/components/GigsClient.tsx index e0e63e1..54a4aac 100644 --- a/frontend/src/components/GigsClient.tsx +++ b/frontend/src/components/GigsClient.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { api } from "@/lib/api"; import { useAuth } from "@/lib/auth"; import { useToast } from "@/lib/toast"; @@ -12,23 +13,33 @@ import { useRingSteps } from "@/lib/rings"; export default function GigsClient({ gigs }: { gigs: GigItem[] }) { const { token } = useAuth(); + const router = useRouter(); const { toast } = useToast(); const { t } = useI18n(); const rings = useRingSteps(); const [msg, setMsg] = useState>({}); + const [applied, setApplied] = useState>({}); + const [busyId, setBusyId] = useState(null); const apply = async (id: string) => { if (!token) { - toast(t.gigs.loginFirst, "info"); + router.push("/login?next=/gigs"); return; } const message = msg[id]?.trim(); - if (!message) return; + if (!message) { + toast("请先填写申请说明", "info"); + return; + } + setBusyId(id); try { await api.applyGig(token, id, message); + setApplied((a) => ({ ...a, [id]: true })); toast(t.gigs.applyOk, "success"); } catch { toast(t.gigs.applyFail, "error"); + } finally { + setBusyId(null); } }; @@ -42,17 +53,46 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {

{t.gigs.subtitle}

{t.gigs.postTitle}
-
- {gigs.map((g) => ( -
-

{g.title}

-

{g.description}

-

{g.poster} · {g.budget} · {g.deadline}

-