Add left/right/up drag swipes on dating match cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-09-04 06:23:57 -05:00
parent bdf0516197
commit 681879b28e
3 changed files with 236 additions and 32 deletions

View File

@ -10457,6 +10457,93 @@ img { max-width: 100%; display: block; }
text-align: center; text-align: center;
} }
.dating-deck {
position: relative;
max-width: 420px;
margin: 0 auto;
min-height: 360px;
touch-action: none;
user-select: none;
}
.dating-card-next {
position: absolute;
inset: 0;
transform: scale(0.96);
opacity: 0.55;
pointer-events: none;
z-index: 0;
}
.dating-card-top {
position: relative;
z-index: 1;
cursor: grab;
will-change: transform;
}
.dating-card-top.dragging {
cursor: grabbing;
}
.dating-card-top.fly-left {
animation: dating-fly-left 0.22s ease-in forwards;
}
.dating-card-top.fly-right {
animation: dating-fly-right 0.22s ease-in forwards;
}
.dating-card-top.fly-up {
animation: dating-fly-up 0.22s ease-in forwards;
}
@keyframes dating-fly-left {
to { transform: translate(-140%, 20px) rotate(-18deg); opacity: 0; }
}
@keyframes dating-fly-right {
to { transform: translate(140%, 20px) rotate(18deg); opacity: 0; }
}
@keyframes dating-fly-up {
to { transform: translate(0, -140%) rotate(-4deg); opacity: 0; }
}
.dating-stamp {
position: absolute;
top: 24px;
padding: 6px 12px;
border: 3px solid;
border-radius: 8px;
font-weight: 800;
letter-spacing: 0.08em;
pointer-events: none;
z-index: 2;
}
.dating-stamp.like {
left: 18px;
color: #3dd68c;
border-color: #3dd68c;
transform: rotate(-12deg);
}
.dating-stamp.nope {
right: 18px;
color: #ff6b6b;
border-color: #ff6b6b;
transform: rotate(12deg);
}
.dating-stamp.super {
left: 50%;
top: 18px;
transform: translateX(-50%) rotate(-6deg);
color: #ffe66d;
border-color: #ffe66d;
}
.dating-swipe-hint {
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
margin: 0.25rem 0 0.75rem;
}
.dating-card-photo { .dating-card-photo {
font-size: 4rem; font-size: 4rem;
margin-bottom: 12px; margin-bottom: 12px;

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@ -11,6 +11,9 @@ import type { MatchIntent, MatchProfile, MatchQuota } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
const SWIPE_THRESHOLD = 110;
const SUPER_THRESHOLD = -120;
export default function DatingClient() { export default function DatingClient() {
const { user, token } = useAuth(); const { user, token } = useAuth();
const router = useRouter(); const router = useRouter();
@ -25,6 +28,11 @@ export default function DatingClient() {
const [joined, setJoined] = useState(false); const [joined, setJoined] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [matchModal, setMatchModal] = useState<{ conversationId?: string; name: string } | null>(null); 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( const intents = useMemo(
() => () =>
@ -39,6 +47,7 @@ export default function DatingClient() {
[t] [t]
); );
const primary = intents.slice(0, 3); const primary = intents.slice(0, 3);
const quotaBlocked = Boolean(quota && !quota.vip && quota.remaining <= 0);
const ensureJoined = useCallback(async () => { const ensureJoined = useCallback(async () => {
if (!token) return; if (!token) return;
@ -49,7 +58,7 @@ export default function DatingClient() {
bio: t.dating.defaultBio, bio: t.dating.defaultBio,
}); });
} catch { } catch {
// Profile may already exist — continue to candidates. // Profile may already exist.
} }
}, [token, intent, t]); }, [token, intent, t]);
@ -69,6 +78,7 @@ export default function DatingClient() {
setDeck(Array.isArray(candidates) ? candidates : []); setDeck(Array.isArray(candidates) ? candidates : []);
setQuota(q); setQuota(q);
setJoined(true); setJoined(true);
setDrag({ x: 0, y: 0, active: false, flying: "" });
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
setError(msg); setError(msg);
@ -109,23 +119,84 @@ export default function DatingClient() {
} }
}; };
const swipe = async (action: "like" | "dislike" | "superlike") => { const swipe = useCallback(
if (!token || !deck[0]) return; async (action: "like" | "dislike" | "superlike") => {
if (!token || !deck[0] || swipingRef.current || quotaBlocked) return;
swipingRef.current = true;
const current = deck[0]; const current = deck[0];
const fly = action === "like" ? "right" : action === "dislike" ? "left" : "up";
setDrag({ x: 0, y: 0, active: false, flying: fly });
try { try {
const res = await api.swipe(token, current.id, action, intent); const res = await api.swipe(token, current.id, action, intent);
if (res.matched && res.match?.conversationId) { if (res.matched && res.match?.conversationId) {
setMatchModal({ conversationId: res.match.conversationId, name: current.name }); setMatchModal({ conversationId: res.match.conversationId, name: current.name });
} }
window.setTimeout(() => {
setDeck((d) => d.slice(1)); setDeck((d) => d.slice(1));
setDrag({ x: 0, y: 0, active: false, flying: "" });
swipingRef.current = false;
}, 220);
const q = await api.getMatchQuota(token); const q = await api.getMatchQuota(token);
setQuota(q); setQuota(q);
} catch { } catch {
setDrag({ x: 0, y: 0, active: false, flying: "" });
swipingRef.current = false;
toast(t.dating.swipeFail, "error"); 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 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) { if (!user) {
return ( return (
@ -219,7 +290,40 @@ export default function DatingClient() {
)} )}
{top && ( {top && (
<div className="dating-card reveal"> <>
<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> <div className="dating-card-photo">{top.photo || "🧑‍💻"}</div>
<h2>{top.name}</h2> <h2>{top.name}</h2>
<p className="dating-meta"> <p className="dating-meta">
@ -233,12 +337,15 @@ export default function DatingClient() {
</span> </span>
))} ))}
</div> </div>
<div className="dating-actions"> </div>
</div>
<div className="dating-actions reveal">
<button <button
type="button" type="button"
className="dating-btn pass" className="dating-btn pass"
onClick={() => void swipe("dislike")} onClick={() => void swipe("dislike")}
disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)} disabled={quotaBlocked}
aria-label={t.dating.swipeLeft}
> >
✕ ✕
</button> </button>
@ -246,7 +353,8 @@ export default function DatingClient() {
type="button" type="button"
className="dating-btn super" className="dating-btn super"
onClick={() => void swipe("superlike")} onClick={() => void swipe("superlike")}
disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)} disabled={quotaBlocked}
aria-label={t.dating.swipeUp}
> >
⭐ ⭐
</button> </button>
@ -254,7 +362,8 @@ export default function DatingClient() {
type="button" type="button"
className="dating-btn like" className="dating-btn like"
onClick={() => void swipe("like")} onClick={() => void swipe("like")}
disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)} disabled={quotaBlocked}
aria-label={t.dating.swipeRight}
> >
♥ ♥
</button> </button>
@ -269,7 +378,7 @@ export default function DatingClient() {
</button> </button>
)} )}
</div> </div>
</div> </>
)} )}
{!loading && joined && !top && ( {!loading && joined && !top && (

View File

@ -448,6 +448,10 @@ export const zh = {
openVip: "开通 VIP", openVip: "开通 VIP",
defaultCity: "全球", defaultCity: "全球",
defaultBio: "nomadro 游民", defaultBio: "nomadro 游民",
swipeHint: "← 左滑跳过 · 右滑喜欢 → · 上滑超级喜欢(也可点按钮 / 方向键)",
swipeLeft: "跳过",
swipeRight: "喜欢",
swipeUp: "超级喜欢",
}, },
chat: { chat: {
tag: "✉️ MESSAGES", tag: "✉️ MESSAGES",
@ -1704,6 +1708,10 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
openVip: "Get VIP", openVip: "Get VIP",
defaultCity: "Global", defaultCity: "Global",
defaultBio: "nomadro nomad", defaultBio: "nomadro nomad",
swipeHint: "← Swipe left to pass · right to like → · up for superlike (or use buttons / arrows)",
swipeLeft: "Pass",
swipeRight: "Like",
swipeUp: "Super like",
}, },
chat: { chat: {
tag: "✉️ MESSAGES", tag: "✉️ MESSAGES",