216 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|