Fix member profiles, chat peer header, and like-back CTAs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-09-04 06:43:48 -05:00
parent 5525d04744
commit fe4f0f5983
10 changed files with 255 additions and 22 deletions

View File

@ -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)

View File

@ -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 use_pb():
row = safe_first("profiles", filter=f"userId={q(user_id)}")
if not row:
if not user_id:
return None
return {**row, "vip": is_vip(user_id)}
# 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(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(user_id)
return {**p, "vip": is_vip(user_id)} if p else None
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

View File

@ -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: "匹配滑动 · 喜欢我的",

View File

@ -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;

View File

@ -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 (
<SiteShell showFooter>
<Suspense fallback={<div className="container dest-empty">…</div>}>
<MemberProfileClient profile={profile} />
</Suspense>
</SiteShell>
);
}

View File

@ -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<ChatMessage[]>([]);
const [conv, setConv] = useState<ConversationItem | null>(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 (
<div className="chat-thread-page">
@ -115,8 +120,26 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
<nav className="detail-nav">
<Link href="/chat">← {t.chat.back}</Link>
<span> · </span>
<Link href="/dating">{t.nav.dating}</Link>
<span> · </span>
<Link href="/meetups">{t.nav.meetups}</Link>
</nav>
{peer && (
<div className="chat-thread-peer reveal">
<Link href={peerHref} className="chat-thread-peer-link">
<span className="chat-avatar">{peer.photo || peer.name?.[0] || "💬"}</span>
<div>
<strong>{peer.name}</strong>
<p className="dating-meta">{peer.location || t.dating.globalNomad}</p>
</div>
</Link>
<Link href={peerHref} className="btn btn-ghost btn-sm">
{t.dating.viewProfile}
</Link>
</div>
)}
<div className="chat-thread-messages">
{loading && messages.length === 0 && <p className="dest-empty">{t.chat.loadingMessages}</p>}
{!loading && messages.length === 0 && (

View File

@ -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 (
<Link key={p.id || p.userId || p.name} href={href} className="dating-like-card reveal">
<div className="dating-like-photo">

View File

@ -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<string | null>(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 (
<div className="dating-page">
@ -27,6 +80,8 @@ export default function MemberProfileClient({ profile }: { profile: MemberProfil
<nav className="detail-nav">
<Link href="/dating">← {t.nav.dating}</Link>
<span> · </span>
<Link href="/dating/likes">{t.dating.likesTitle}</Link>
<span> · </span>
<Link href="/chat">{t.nav.chat}</Link>
<span> · </span>
<Link href={meetupsHref}>{t.nav.meetups}</Link>
@ -53,6 +108,7 @@ export default function MemberProfileClient({ profile }: { profile: MemberProfil
profile.location || t.dating.globalNomad
)}
</p>
{fromLikes && <p className="meetup-tag">{t.dating.likesYou}</p>}
<p>{profile.bio || t.dating.noBio}</p>
<div className="dating-tags">
{(profile.tags || []).map((tag) => (
@ -62,15 +118,25 @@ export default function MemberProfileClient({ profile }: { profile: MemberProfil
))}
</div>
<div className="dating-empty-actions" style={{ marginTop: "1.25rem" }}>
<Link href="/dating" className="btn btn-primary btn-sm">
{convId ? (
<Link href={`/chat/${convId}`} className="btn btn-primary btn-sm">
{t.dating.chatNow}
</Link>
) : user ? (
<button type="button" className="btn btn-primary btn-sm" disabled={liking} onClick={() => void likeBack()}>
{liking ? "…" : t.dating.likeBack}
</button>
) : (
<Link href={`/login?next=/members/${memberHref}`} className="btn btn-primary btn-sm">
{t.nav.login}
</Link>
)}
<Link href="/dating" className="btn btn-sm">
{t.dating.keepSwiping}
</Link>
<Link href={meetupsHref} className="btn btn-sm">
{city ? t.common.cityMeetups : t.dating.meetAtEvents}
</Link>
<Link href="/community" className="btn btn-sm">
{t.nav.community}
</Link>
</div>
</div>
<RingNext steps={rings.afterDating} />

View File

@ -250,6 +250,15 @@ export const api = {
}),
getConversations: (token: string) =>
fetchAPI<ConversationItem[]>("/social/conversations", { headers: { Authorization: `Bearer ${token}` } }),
getConversation: (token: string, convId: string) =>
fetchAPI<ConversationItem>(`/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<ChatMessage[]>(`/social/conversations/${convId}/messages`, {
headers: { Authorization: `Bearer ${token}` },

View File

@ -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",