nomadweb/frontend/src/components/DatingClient.tsx
eric d43005d04a Polish connect-ring UX: VIP, notifications, dating, gigs, chat.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 20:16:31 -05:00

216 lines
7.7 KiB
TypeScript

"use client";
import { useCallback, useEffect, 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";
import { useI18n } from "@/lib/i18n";
import type { MatchIntent, MatchProfile, MatchQuota } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
const INTENTS: { key: MatchIntent; label: string; emoji: string }[] = [
{ key: "friends", label: "交朋友", emoji: "🤝" },
{ key: "dating", label: "约会", emoji: "💕" },
{ key: "partner", label: "伴侣", emoji: "💑" },
{ key: "roommate", label: "合租", emoji: "🏠" },
{ key: "cofounder", label: "联创", emoji: "🚀" },
{ key: "explore", label: "探索", emoji: "🌍" },
];
const PRIMARY_INTENTS = INTENTS.slice(0, 3);
export default function DatingClient() {
const { user, token } = useAuth();
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
const rings = useRingSteps();
const [intent, setIntent] = useState<MatchIntent>("friends");
const [moreIntents, setMoreIntents] = useState(false);
const [deck, setDeck] = useState<MatchProfile[]>([]);
const [quota, setQuota] = useState<MatchQuota | null>(null);
const [loading, setLoading] = useState(true);
const [joined, setJoined] = useState(false);
const [matchModal, setMatchModal] = useState<{ conversationId?: string; name: string } | null>(null);
const load = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const [candidates, q] = await Promise.all([
api.getMatchCandidates(token, { intent }),
api.getMatchQuota(token),
]);
setDeck(candidates);
setQuota(q);
setJoined(true);
} catch {
setJoined(false);
setDeck([]);
} finally {
setLoading(false);
}
}, [token, intent]);
useEffect(() => { void load(); }, [load]);
const quickJoin = async () => {
if (!token) {
router.push("/login?next=/dating");
return;
}
try {
await api.socialJoin(token, {
city: "全球",
lookingFor: [intent, "explore"],
bio: "nomadro 游民",
});
toast("资料已完善,开始匹配", "success");
await load();
} catch {
toast("加入失败", "error");
}
};
const swipe = async (action: "like" | "dislike" | "superlike") => {
if (!token || !deck[0]) return;
const current = deck[0];
try {
const res = await api.swipe(token, current.id, action, intent);
if (res.matched && res.match?.conversationId) {
setMatchModal({ conversationId: res.match.conversationId, name: current.name });
}
setDeck((d) => d.slice(1));
const q = await api.getMatchQuota(token);
setQuota(q);
} catch {
toast("滑动失败,请检查登录或配额", "error");
}
};
const top = deck[0];
if (!user) {
return (
<div className="dating-page">
<div className="container">
<div className="dating-gate reveal">
<h2>{t.dating.loginTitle}</h2>
<p>{t.dating.loginDesc}</p>
<Link href="/login?next=/dating" className="btn btn-primary">{t.nav.login}</Link>
</div>
</div>
</div>
);
}
return (
<div className="dating-page">
<div className="container">
<nav className="detail-nav">
<Link href="/">{t.common.backHome}</Link>
<span> · </span>
<Link href="/chat">{t.nav.chat}</Link>
<span> · </span>
<Link href="/dating/likes">{t.dating.likesTitle}</Link>
</nav>
<div className="section-header reveal">
<span className="section-tag">{t.dating.tag}</span>
<h1>{t.dating.title}</h1>
<p>{t.dating.subtitle}</p>
</div>
<div className="dating-intents reveal">
{(moreIntents ? INTENTS : PRIMARY_INTENTS).map((i) => (
<button
key={i.key}
type="button"
className={`filter-btn${intent === i.key ? " active" : ""}`}
onClick={() => setIntent(i.key)}
>
{i.emoji} {i.label}
</button>
))}
{!moreIntents && (
<button type="button" className="filter-btn" onClick={() => setMoreIntents(true)}>
更多意图…
</button>
)}
</div>
{quota && (
<p className="dating-quota reveal">
{quota.vip ? "✨ VIP 无限滑" : `今日剩余 ${quota.remaining}/${quota.limit} 次`}
{!quota.vip && quota.remaining <= 0 && (
<>
{" · "}
<Link href="/join">开通 VIP 无限匹配</Link>
</>
)}
</p>
)}
{!joined && !loading && (
<div className="dating-gate reveal">
<p>{t.dating.joinFirst}</p>
<button type="button" className="btn btn-primary" onClick={quickJoin}>{t.dating.joinBtn}</button>
</div>
)}
{loading && joined && <p className="dest-empty reveal">加载候选人…</p>}
{top && (
<div className="dating-card reveal">
<div className="dating-card-photo">{top.photo || "🧑‍💻"}</div>
<h2>{top.name}</h2>
<p className="dating-meta">{top.location} · {top.gender} {top.single ? `· ${top.single}` : ""}</p>
<p>{top.bio}</p>
<div className="dating-tags">
{(top.tags || []).map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)}
</div>
<div className="dating-actions">
<button type="button" className="dating-btn pass" onClick={() => swipe("dislike")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>✕</button>
<button type="button" className="dating-btn super" onClick={() => swipe("superlike")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>⭐</button>
<button type="button" className="dating-btn like" onClick={() => swipe("like")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>♥</button>
{token && (
<button type="button" className="btn btn-sm" title={t.dating.undo} onClick={() => api.undoSwipe(token).then(() => load())}>↩</button>
)}
</div>
</div>
)}
{!loading && joined && !top && (
<div className="dating-empty reveal">
<p className="dest-empty">{t.dating.empty}</p>
<div className="dating-empty-actions">
<button type="button" className="btn btn-sm" onClick={() => void load()}>刷新</button>
<Link href="/meetups" className="btn btn-sm">去活动认识人</Link>
<Link href="/chat" className="btn btn-sm">查看对话</Link>
{quota && !quota.vip && <Link href="/join" className="btn btn-primary btn-sm">开通 VIP</Link>}
</div>
</div>
)}
<RingNext steps={rings.afterDating} />
</div>
{matchModal && (
<div className="dating-match-modal" role="dialog">
<div className="dating-match-box">
<h3>🎉 {t.dating.matched} {matchModal.name}!</h3>
<div className="dating-match-actions">
<button type="button" className="btn" onClick={() => setMatchModal(null)}>{t.dating.keepSwiping}</button>
{matchModal.conversationId && (
<Link href={`/chat/${matchModal.conversationId}`} className="btn btn-primary">{t.dating.chatNow}</Link>
)}
</div>
</div>
</div>
)}
</div>
);
}