445 lines
15 KiB
TypeScript
445 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, 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 SWIPE_THRESHOLD = 110;
|
|
const SUPER_THRESHOLD = -120;
|
|
|
|
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 [error, setError] = useState("");
|
|
const [matchModal, setMatchModal] = useState<{ conversationId?: string; name: string } | null>(null);
|
|
const [drag, setDrag] = useState({ x: 0, y: 0, active: false, flying: "" });
|
|
const startRef = useRef<{ x: number; y: number } | null>(null);
|
|
const dragRef = useRef({ x: 0, y: 0 });
|
|
const swipingRef = useRef(false);
|
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const intents = useMemo(
|
|
() =>
|
|
[
|
|
{ key: "friends" as const, label: t.dating.intentFriends, emoji: "🤝" },
|
|
{ key: "dating" as const, label: t.dating.intentDating, emoji: "💕" },
|
|
{ key: "partner" as const, label: t.dating.intentPartner, emoji: "💑" },
|
|
{ key: "roommate" as const, label: t.dating.intentRoommate, emoji: "🏠" },
|
|
{ key: "cofounder" as const, label: t.dating.intentCofounder, emoji: "🚀" },
|
|
{ key: "explore" as const, label: t.dating.intentExplore, emoji: "🌍" },
|
|
],
|
|
[t]
|
|
);
|
|
const primary = intents.slice(0, 3);
|
|
const quotaBlocked = Boolean(quota && !quota.vip && quota.remaining <= 0);
|
|
|
|
const ensureJoined = useCallback(async () => {
|
|
if (!token) return;
|
|
try {
|
|
await api.socialJoin(token, {
|
|
city: t.dating.defaultCity,
|
|
lookingFor: [intent, "explore"],
|
|
bio: t.dating.defaultBio,
|
|
});
|
|
} catch {
|
|
// Profile may already exist.
|
|
}
|
|
}, [token, intent, t]);
|
|
|
|
const load = useCallback(async () => {
|
|
if (!token) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
await ensureJoined();
|
|
const [candidates, q] = await Promise.all([
|
|
api.getMatchCandidates(token, { intent }),
|
|
api.getMatchQuota(token),
|
|
]);
|
|
setDeck(Array.isArray(candidates) ? candidates : []);
|
|
setQuota(q);
|
|
setJoined(true);
|
|
setDrag({ x: 0, y: 0, active: false, flying: "" });
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
setError(msg);
|
|
setJoined(false);
|
|
setDeck([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [token, intent, ensureJoined]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const quickJoin = async () => {
|
|
if (!token) {
|
|
router.push("/login?next=/dating");
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
await api.socialJoin(token, {
|
|
city: t.dating.defaultCity,
|
|
lookingFor: [intent, "explore"],
|
|
bio: t.dating.defaultBio,
|
|
});
|
|
toast(t.dating.joinOk, "success", {
|
|
href: "/meetups",
|
|
label: t.dating.meetAtEvents,
|
|
});
|
|
await load();
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : t.dating.joinFail;
|
|
setError(msg);
|
|
toast(t.dating.joinFail, "error");
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const swipe = useCallback(
|
|
async (action: "like" | "dislike" | "superlike") => {
|
|
if (!token || !deck[0] || swipingRef.current || quotaBlocked) return;
|
|
swipingRef.current = true;
|
|
const current = deck[0];
|
|
const fly = action === "like" ? "right" : action === "dislike" ? "left" : "up";
|
|
setDrag({ x: 0, y: 0, active: false, flying: fly });
|
|
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 });
|
|
}
|
|
window.setTimeout(() => {
|
|
setDeck((d) => d.slice(1));
|
|
setDrag({ x: 0, y: 0, active: false, flying: "" });
|
|
swipingRef.current = false;
|
|
}, 220);
|
|
const q = await api.getMatchQuota(token);
|
|
setQuota(q);
|
|
} catch {
|
|
setDrag({ x: 0, y: 0, active: false, flying: "" });
|
|
swipingRef.current = false;
|
|
toast(t.dating.swipeFail, "error");
|
|
}
|
|
},
|
|
[token, deck, intent, quotaBlocked, toast, t]
|
|
);
|
|
|
|
const onPointerDown = (e: React.PointerEvent) => {
|
|
if (quotaBlocked || swipingRef.current) return;
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest("button,a,input")) return;
|
|
startRef.current = { x: e.clientX, y: e.clientY };
|
|
dragRef.current = { x: 0, y: 0 };
|
|
setDrag({ x: 0, y: 0, active: true, flying: "" });
|
|
cardRef.current?.setPointerCapture(e.pointerId);
|
|
};
|
|
|
|
const onPointerMove = (e: React.PointerEvent) => {
|
|
if (!startRef.current || !drag.active) return;
|
|
const x = e.clientX - startRef.current.x;
|
|
const y = e.clientY - startRef.current.y;
|
|
dragRef.current = { x, y };
|
|
setDrag({ x, y, active: true, flying: "" });
|
|
};
|
|
|
|
const endDrag = () => {
|
|
if (!startRef.current) return;
|
|
const { x, y } = dragRef.current;
|
|
startRef.current = null;
|
|
if (Math.abs(x) > SWIPE_THRESHOLD) {
|
|
void swipe(x > 0 ? "like" : "dislike");
|
|
return;
|
|
}
|
|
if (y < SUPER_THRESHOLD) {
|
|
void swipe("superlike");
|
|
return;
|
|
}
|
|
setDrag({ x: 0, y: 0, active: false, flying: "" });
|
|
};
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (!deck[0] || matchModal || quotaBlocked) return;
|
|
if (e.key === "ArrowLeft") void swipe("dislike");
|
|
if (e.key === "ArrowRight") void swipe("like");
|
|
if (e.key === "ArrowUp") void swipe("superlike");
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [deck, matchModal, quotaBlocked, swipe]);
|
|
|
|
const top = deck[0];
|
|
const next = deck[1];
|
|
const rotate = drag.x * 0.06;
|
|
const likeOpacity = Math.min(1, Math.max(0, drag.x / SWIPE_THRESHOLD));
|
|
const nopeOpacity = Math.min(1, Math.max(0, -drag.x / SWIPE_THRESHOLD));
|
|
const superOpacity = Math.min(1, Math.max(0, -drag.y / Math.abs(SUPER_THRESHOLD)));
|
|
|
|
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>
|
|
<div className="dating-empty-actions">
|
|
<Link href="/login?next=/dating" className="btn btn-primary">{t.nav.login}</Link>
|
|
<Link href="/meetups" className="btn btn-ghost">{t.dating.meetAtEvents}</Link>
|
|
<Link href="/community" className="btn btn-ghost">{t.nav.community}</Link>
|
|
</div>
|
|
</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>
|
|
<span> · </span>
|
|
<Link href="/meetups">{t.nav.meetups}</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).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)}>
|
|
{t.dating.moreIntents}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{quota && (
|
|
<p className="dating-quota reveal">
|
|
{quota.vip
|
|
? t.dating.vipUnlimited
|
|
: t.dating.quotaLeft
|
|
.replace("{remaining}", String(quota.remaining))
|
|
.replace("{limit}", String(quota.limit))}
|
|
{!quota.vip && quota.remaining <= 0 && (
|
|
<>
|
|
{" · "}
|
|
<Link href="/join">{t.dating.vipUnlock}</Link>
|
|
</>
|
|
)}
|
|
</p>
|
|
)}
|
|
|
|
{loading && (
|
|
<p className="dest-empty reveal">
|
|
{joined ? t.dating.loadingCandidates : t.dating.loadingProfile}
|
|
</p>
|
|
)}
|
|
|
|
{!joined && !loading && (
|
|
<div className="dating-gate reveal">
|
|
<p>{t.dating.joinFirst}</p>
|
|
{error && <p className="dest-empty" style={{ marginTop: "0.5rem" }}>{error}</p>}
|
|
<div className="dating-empty-actions">
|
|
<button type="button" className="btn btn-primary" onClick={() => void quickJoin()}>
|
|
{t.dating.joinBtn}
|
|
</button>
|
|
<button type="button" className="btn btn-ghost" onClick={() => void load()}>
|
|
{t.dating.refresh}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{top && (
|
|
<>
|
|
<p className="dating-swipe-hint reveal">{t.dating.swipeHint}</p>
|
|
<div className="dating-deck reveal">
|
|
{next && (
|
|
<div className="dating-card dating-card-next" aria-hidden>
|
|
<div className="dating-card-photo">{next.photo || "🧑💻"}</div>
|
|
<h2>{next.name}</h2>
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={cardRef}
|
|
className={`dating-card dating-card-top${drag.active ? " dragging" : ""}${drag.flying ? ` fly-${drag.flying}` : ""}`}
|
|
style={
|
|
drag.flying
|
|
? undefined
|
|
: {
|
|
transform: `translate(${drag.x}px, ${drag.y}px) rotate(${rotate}deg)`,
|
|
transition: drag.active ? "none" : "transform 0.25s ease",
|
|
}
|
|
}
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={endDrag}
|
|
onPointerCancel={endDrag}
|
|
>
|
|
<span className="dating-stamp like" style={{ opacity: likeOpacity }}>
|
|
LIKE
|
|
</span>
|
|
<span className="dating-stamp nope" style={{ opacity: nopeOpacity }}>
|
|
NOPE
|
|
</span>
|
|
<span className="dating-stamp super" style={{ opacity: superOpacity }}>
|
|
SUPER
|
|
</span>
|
|
<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>
|
|
<Link
|
|
href={`/members/${top.userId || top.id}`}
|
|
className="dating-view-profile"
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{t.dating.viewProfile} →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
<div className="dating-actions reveal">
|
|
<button
|
|
type="button"
|
|
className="dating-btn pass"
|
|
onClick={() => void swipe("dislike")}
|
|
disabled={quotaBlocked}
|
|
aria-label={t.dating.swipeLeft}
|
|
>
|
|
✕
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="dating-btn super"
|
|
onClick={() => void swipe("superlike")}
|
|
disabled={quotaBlocked}
|
|
aria-label={t.dating.swipeUp}
|
|
>
|
|
⭐
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="dating-btn like"
|
|
onClick={() => void swipe("like")}
|
|
disabled={quotaBlocked}
|
|
aria-label={t.dating.swipeRight}
|
|
>
|
|
♥
|
|
</button>
|
|
{token && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
title={t.dating.undo}
|
|
onClick={() => void api.undoSwipe(token).then(() => load())}
|
|
>
|
|
↩
|
|
</button>
|
|
)}
|
|
</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()}>
|
|
{t.dating.refresh}
|
|
</button>
|
|
<Link href="/meetups" className="btn btn-sm">
|
|
{t.dating.meetAtEvents}
|
|
</Link>
|
|
<Link href="/community" className="btn btn-sm">
|
|
{t.nav.community}
|
|
</Link>
|
|
<Link href="/chat" className="btn btn-sm">
|
|
{t.dating.viewChats}
|
|
</Link>
|
|
{quota && !quota.vip && (
|
|
<Link href="/join" className="btn btn-primary btn-sm">
|
|
{t.dating.openVip}
|
|
</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>
|
|
)}
|
|
<Link href="/meetups" className="btn btn-ghost" onClick={() => setMatchModal(null)}>
|
|
{t.dating.meetAtEvents}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|