Fix member profiles, chat peer header, and like-back CTAs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5525d04744
commit
fe4f0f5983
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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: "匹配滑动 · 喜欢我的",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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}` },
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user