From fe4f0f5983bb5d8f84cd84588f830f1a8f026634 Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 4 Sep 2026 06:43:48 -0500 Subject: [PATCH] Fix member profiles, chat peer header, and like-back CTAs. Co-authored-by: Cursor --- backend/app/routers/auth.py | 21 +++- backend/app/services/social_store.py | 96 ++++++++++++++++--- frontend/src/app/changelog/page.tsx | 8 ++ frontend/src/app/globals.css | 27 ++++++ frontend/src/app/members/[id]/page.tsx | 5 +- frontend/src/components/ChatThreadClient.tsx | 25 ++++- frontend/src/components/DatingLikesClient.tsx | 6 +- .../src/components/MemberProfileClient.tsx | 74 +++++++++++++- frontend/src/lib/api.ts | 9 ++ frontend/src/lib/i18n/dictionaries.ts | 6 ++ 10 files changed, 255 insertions(+), 22 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 483c22c..5d9eef7 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -14,16 +14,29 @@ from app.services.auth import ( update_profile, ) from app.services.pocketbase import pb +from app.services import social_store router = APIRouter(prefix="/auth", tags=["auth"]) +def _with_social_seed(result: dict | None) -> dict | None: + if not result: + return result + try: + uid = (result.get("user") or {}).get("id") + if uid: + social_store.seed_demo_social_activity(uid) + except Exception: + pass + return result + + @router.post("/register", response_model=AuthResponse) async def register(body: AuthRegister): result = register_user(body.email, body.password, body.name) if not result: raise HTTPException(400, "该邮箱已注册") - return AuthResponse(**result) + return AuthResponse(**_with_social_seed(result)) @router.post("/login", response_model=AuthResponse) @@ -31,7 +44,7 @@ async def login(body: AuthLogin): result = login_user(body.email, body.password) if not result: raise HTTPException(401, "邮箱或密码错误") - return AuthResponse(**result) + return AuthResponse(**_with_social_seed(result)) @router.get("/me", response_model=UserProfile) @@ -69,7 +82,7 @@ async def demo_login(): result = login_demo() if not result: raise HTTPException(500, "演示账号不可用") - return AuthResponse(**result) + return AuthResponse(**_with_social_seed(result)) @router.post("/google", response_model=AuthResponse) @@ -92,7 +105,7 @@ async def google_login(body: GoogleLoginRequest): result = login_google(email, name) if not result: raise HTTPException(500, "登录失败") - return AuthResponse(**result) + return AuthResponse(**_with_social_seed(result)) @router.get("/favorites", response_model=FavoriteResponse) diff --git a/backend/app/services/social_store.py b/backend/app/services/social_store.py index bac8f56..dafcde0 100644 --- a/backend/app/services/social_store.py +++ b/backend/app/services/social_store.py @@ -159,14 +159,84 @@ def join_member(user_id: str, name: str, payload: dict) -> dict: def get_public_profile(user_id: str) -> dict | None: + if not user_id: + return None + # Accept /members/user-{id} style links + lookup = user_id[5:] if user_id.startswith("user-") else user_id + if use_pb(): - row = safe_first("profiles", filter=f"userId={q(user_id)}") - if not row: - return None - return {**row, "vip": is_vip(user_id)} - _jload() - p = (_j.get("profiles") or {}).get(user_id) - return {**p, "vip": is_vip(user_id)} if p else None + row = safe_first("profiles", filter=f"userId={q(lookup)}") + if row: + return { + "id": row.get("id") or f"user-{lookup}", + "userId": row.get("userId") or lookup, + "name": row.get("name") or "游民", + "location": row.get("location") or "", + "citySlug": row.get("citySlug") or "", + "gender": row.get("gender") or "", + "single": row.get("single") or "", + "bio": row.get("bio") or "", + "photo": row.get("photo") or "🧑‍💻", + "tags": row.get("tags") or [], + "lookingFor": _profile_looking_for(row), + "vip": is_vip(lookup), + } + else: + _jload() + p = (_j.get("profiles") or {}).get(lookup) + if p: + return { + "id": f"user-{lookup}", + "userId": lookup, + "name": p.get("name") or "游民", + "location": p.get("location") or "", + "citySlug": p.get("citySlug") or "", + "gender": p.get("gender") or "", + "single": p.get("single") or "", + "bio": p.get("bio") or "", + "photo": p.get("photo") or "🧑‍💻", + "tags": p.get("tags") or [], + "lookingFor": _profile_looking_for(p), + "vip": is_vip(lookup), + } + + for cand in CANDIDATE_PROFILES: + if cand.get("userId") == lookup or cand.get("id") == lookup or cand.get("id") == user_id: + return { + "id": cand.get("id") or f"user-{cand.get('userId', lookup)}", + "userId": cand.get("userId") or lookup, + "name": cand.get("name") or "游民", + "location": cand.get("location") or "", + "citySlug": cand.get("citySlug") or "", + "gender": cand.get("gender") or "", + "single": cand.get("single") or "", + "bio": cand.get("bio") or "", + "photo": cand.get("photo") or "🧑‍💻", + "tags": cand.get("tags") or [], + "lookingFor": _profile_looking_for(cand), + "vip": False, + } + return None + + +def normalize_peer(peer: dict | None) -> dict | None: + if not peer: + return None + uid = peer.get("userId") or "" + return { + "id": peer.get("id") or (f"user-{uid}" if uid else "unknown"), + "userId": uid, + "name": peer.get("name") or "游民", + "location": peer.get("location") or "", + "citySlug": peer.get("citySlug") or "", + "gender": peer.get("gender") or "", + "single": peer.get("single") or "", + "bio": peer.get("bio") or "", + "photo": peer.get("photo") or "🧑‍💻", + "tags": peer.get("tags") or [], + "lookingFor": _profile_looking_for(peer), + "intent": peer.get("intent") or "", + } # ── Candidates / swipes ──────────────────────────────────────────────── @@ -511,19 +581,23 @@ def list_conversations(user_id: str) -> list[dict]: rows = safe_list("conversations", filter=f"userAId={q(user_id)} || userBId={q(user_id)}") rows.sort(key=lambda c: c.get("lastMessageAt") or c.get("created", ""), reverse=True) for c in rows: + if c.get("userAId") == c.get("userBId"): + continue peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] read_at = (c.get("readState") or {}).get(user_id, "") unread = 1 if (c.get("lastMessageAt") or "") > read_at else 0 - items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread}) + items.append({**c, "peer": normalize_peer(_peer_profile(peer_id)), "unreadCount": unread}) return items _jload() for c in _j.get("conversations") or []: if user_id not in (c.get("userAId"), c.get("userBId")): continue + if c.get("userAId") == c.get("userBId"): + continue peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] read_at = (c.get("readState") or {}).get(user_id, "") unread = 1 if c.get("lastMessageAt", "") > read_at else 0 - items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread}) + items.append({**c, "peer": normalize_peer(_peer_profile(peer_id)), "unreadCount": unread}) items.sort(key=lambda x: x.get("lastMessageAt", ""), reverse=True) return items @@ -534,12 +608,12 @@ def get_conversation(conv_id: str, user_id: str) -> dict | None: if not row or user_id not in (row.get("userAId"), row.get("userBId")): return None peer_id = row["userBId"] if row["userAId"] == user_id else row["userAId"] - return {**row, "peer": _peer_profile(peer_id)} + return {**row, "peer": normalize_peer(_peer_profile(peer_id))} _jload() for c in _j.get("conversations") or []: if c["id"] == conv_id and user_id in (c.get("userAId"), c.get("userBId")): peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] - return {**c, "peer": _peer_profile(peer_id)} + return {**c, "peer": normalize_peer(_peer_profile(peer_id))} return None diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index f94a1ad..4184f50 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -8,6 +8,14 @@ export const metadata: Metadata = { }; const LOGS = [ + { + date: "2026-09-04", + tag: "匹配资料 · 私信头", + items: [ + "会员页支持候选档案 /members/u1,可回赞并直达聊天;私信线程显示对方头像与资料入口", + "登录即注入匹配来赞与演示私信;会话 peer 字段规范化避免列表 500", + ], + }, { date: "2026-09-04", tag: "匹配滑动 · 喜欢我的", diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 01d9ac4..9b52b0c 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -10876,6 +10876,33 @@ a.weather-card:hover { min-height: calc(100vh - 120px); } +.chat-thread-peer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 0; + margin-bottom: 8px; + border-bottom: var(--border-glass); +} + +.chat-thread-peer-link { + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; + color: inherit; + min-width: 0; +} + +.chat-thread-peer-link strong { + display: block; +} + +.chat-thread-peer-link .dating-meta { + margin: 0; +} + .chat-thread-messages { flex: 1; display: flex; diff --git a/frontend/src/app/members/[id]/page.tsx b/frontend/src/app/members/[id]/page.tsx index 5cd606a..020e9a9 100644 --- a/frontend/src/app/members/[id]/page.tsx +++ b/frontend/src/app/members/[id]/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from "react"; import { notFound } from "next/navigation"; import { api } from "@/lib/api"; import SiteShell from "@/components/SiteShell"; @@ -9,7 +10,9 @@ export default async function MemberPage({ params }: { params: Promise<{ id: str if (!profile) notFound(); return ( - + …}> + + ); } diff --git a/frontend/src/components/ChatThreadClient.tsx b/frontend/src/components/ChatThreadClient.tsx index 00d0675..c0ad5bc 100644 --- a/frontend/src/components/ChatThreadClient.tsx +++ b/frontend/src/components/ChatThreadClient.tsx @@ -6,7 +6,7 @@ import { api } from "@/lib/api"; import { useAuth } from "@/lib/auth"; import { useI18n } from "@/lib/i18n"; import { useToast } from "@/lib/toast"; -import type { ChatMessage } from "@/lib/types"; +import type { ChatMessage, ConversationItem } from "@/lib/types"; import RingNext from "@/components/RingNext"; import { useRingSteps } from "@/lib/rings"; import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft"; @@ -17,6 +17,7 @@ export default function ChatThreadClient({ convId }: { convId: string }) { const { toast } = useToast(); const rings = useRingSteps(); const [messages, setMessages] = useState([]); + const [conv, setConv] = useState(null); const [text, setText] = useState(""); const [sending, setSending] = useState(false); const [loading, setLoading] = useState(Boolean(token)); @@ -38,6 +39,7 @@ export default function ChatThreadClient({ convId }: { convId: string }) { setLoading(false); return; } + api.getConversation(token, convId).then(setConv).catch(() => setConv(null)); load(); const id = setInterval(load, 4000); return () => clearInterval(id); @@ -85,6 +87,9 @@ export default function ChatThreadClient({ convId }: { convId: string }) { } }; + const peer = conv?.peer; + const peerHref = peer?.userId ? `/members/${peer.userId}` : peer?.id ? `/members/${peer.id}` : "/dating"; + if (!user || !token) { return (
@@ -115,8 +120,26 @@ export default function ChatThreadClient({ convId }: { convId: string }) { + + {peer && ( +
+ + {peer.photo || peer.name?.[0] || "💬"} +
+ {peer.name} +

{peer.location || t.dating.globalNomad}

+
+ + + {t.dating.viewProfile} + +
+ )} +
{loading && messages.length === 0 &&

{t.chat.loadingMessages}

} {!loading && messages.length === 0 && ( diff --git a/frontend/src/components/DatingLikesClient.tsx b/frontend/src/components/DatingLikesClient.tsx index 3db98b9..4320b01 100644 --- a/frontend/src/components/DatingLikesClient.tsx +++ b/frontend/src/components/DatingLikesClient.tsx @@ -108,7 +108,11 @@ export default function DatingLikesClient() { {!loading && list.map((p) => { const photo = p.photo || ""; - const href = p.userId ? `/members/${p.userId}` : "/dating"; + const href = p.userId + ? `/members/${p.userId}?from=likes` + : p.id + ? `/members/${p.id}?from=likes` + : "/dating"; return (
diff --git a/frontend/src/components/MemberProfileClient.tsx b/frontend/src/components/MemberProfileClient.tsx index aa2e972..6787b0f 100644 --- a/frontend/src/components/MemberProfileClient.tsx +++ b/frontend/src/components/MemberProfileClient.tsx @@ -1,7 +1,12 @@ "use client"; +import { useEffect, useState } from "react"; import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { api } from "@/lib/api"; +import { useAuth } from "@/lib/auth"; import { useI18n } from "@/lib/i18n"; +import { useToast } from "@/lib/toast"; import type { MemberProfile } from "@/lib/types"; import RingNext from "@/components/RingNext"; import { useRingSteps } from "@/lib/rings"; @@ -15,11 +20,59 @@ function cityFromLocation(location?: string) { export default function MemberProfileClient({ profile }: { profile: MemberProfile }) { const { t } = useI18n(); + const { token, user } = useAuth(); + const { toast } = useToast(); + const router = useRouter(); + const search = useSearchParams(); const rings = useRingSteps(); const photo = profile.photo || ""; const isUrl = photo.startsWith("http"); const city = cityFromLocation(profile.location); const meetupsHref = city ? meetupCityHref(city) : "/meetups"; + const [convId, setConvId] = useState(null); + const [liking, setLiking] = useState(false); + const fromLikes = search.get("from") === "likes"; + + const profileId = profile.id || (profile.userId ? `user-${profile.userId}` : ""); + const memberHref = profile.userId || profile.id || ""; + + useEffect(() => { + if (!token || !profile.userId) return; + api + .getMutualMatches(token) + .then((res) => { + const hit = (res.items || []).find( + (m) => m.peer?.userId === profile.userId || m.peer?.id === profile.id + ); + if (hit?.conversationId) setConvId(hit.conversationId); + }) + .catch(() => {}); + }, [token, profile.userId, profile.id]); + + const likeBack = async () => { + if (!token) { + router.push(`/login?next=/members/${memberHref}`); + return; + } + if (!profileId || liking) return; + setLiking(true); + try { + const res = await api.swipe(token, profileId, "like", "friends"); + if (res.matched && res.match?.conversationId) { + setConvId(res.match.conversationId); + toast(t.dating.matched + " " + profile.name, "success", { + href: `/chat/${res.match.conversationId}`, + label: t.dating.chatNow, + }); + } else { + toast(t.dating.likeSent, "success", { href: "/dating/likes", label: t.dating.likesTitle }); + } + } catch { + toast(t.dating.swipeFail, "error"); + } finally { + setLiking(false); + } + }; return (
@@ -27,6 +80,8 @@ export default function MemberProfileClient({ profile }: { profile: MemberProfil
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fedd719..c2df3c3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -250,6 +250,15 @@ export const api = { }), getConversations: (token: string) => fetchAPI("/social/conversations", { headers: { Authorization: `Bearer ${token}` } }), + getConversation: (token: string, convId: string) => + fetchAPI(`/social/conversations/${convId}`, { + headers: { Authorization: `Bearer ${token}` }, + }), + getMutualMatches: (token: string) => + fetchAPI<{ items: Array<{ conversationId?: string; peer?: MatchProfile; intent?: string }> }>( + "/social/matches/mutual", + { headers: { Authorization: `Bearer ${token}` } } + ), getMessages: (token: string, convId: string) => fetchAPI(`/social/conversations/${convId}/messages`, { headers: { Authorization: `Bearer ${token}` }, diff --git a/frontend/src/lib/i18n/dictionaries.ts b/frontend/src/lib/i18n/dictionaries.ts index 34b5542..923cbd9 100644 --- a/frontend/src/lib/i18n/dictionaries.ts +++ b/frontend/src/lib/i18n/dictionaries.ts @@ -457,6 +457,9 @@ export const zh = { likesReceivedSubtitle: "对你右滑的游民会出现在这里", likesReceivedEmpty: "还没有人喜欢你,先去完善资料并滑动匹配", likesYou: "喜欢了你", + likeBack: "回赞 / 匹配", + likeSent: "已发送喜欢", + viewProfile: "查看资料", }, chat: { tag: "✉️ MESSAGES", @@ -1722,6 +1725,9 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string likesReceivedSubtitle: "People who swiped right on you", likesReceivedEmpty: "No incoming likes yet — keep your profile fresh and swipe", likesYou: "Liked you", + likeBack: "Like back", + likeSent: "Like sent", + viewProfile: "View profile", }, chat: { tag: "✉️ MESSAGES",