Add left/right/up drag swipes on dating match cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bdf0516197
commit
681879b28e
@ -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;
|
||||||
|
|||||||
@ -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") => {
|
||||||
const current = deck[0];
|
if (!token || !deck[0] || swipingRef.current || quotaBlocked) return;
|
||||||
try {
|
swipingRef.current = true;
|
||||||
const res = await api.swipe(token, current.id, action, intent);
|
const current = deck[0];
|
||||||
if (res.matched && res.match?.conversationId) {
|
const fly = action === "like" ? "right" : action === "dislike" ? "left" : "up";
|
||||||
setMatchModal({ conversationId: res.match.conversationId, name: current.name });
|
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");
|
||||||
}
|
}
|
||||||
setDeck((d) => d.slice(1));
|
},
|
||||||
const q = await api.getMatchQuota(token);
|
[token, deck, intent, quotaBlocked, toast, t]
|
||||||
setQuota(q);
|
);
|
||||||
} catch {
|
|
||||||
toast(t.dating.swipeFail, "error");
|
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,26 +290,62 @@ export default function DatingClient() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{top && (
|
{top && (
|
||||||
<div className="dating-card reveal">
|
<>
|
||||||
<div className="dating-card-photo">{top.photo || "🧑💻"}</div>
|
<p className="dating-swipe-hint reveal">{t.dating.swipeHint}</p>
|
||||||
<h2>{top.name}</h2>
|
<div className="dating-deck reveal">
|
||||||
<p className="dating-meta">
|
{next && (
|
||||||
{top.location} · {top.gender} {top.single ? `· ${top.single}` : ""}
|
<div className="dating-card dating-card-next" aria-hidden>
|
||||||
</p>
|
<div className="dating-card-photo">{next.photo || "🧑💻"}</div>
|
||||||
<p>{top.bio}</p>
|
<h2>{next.name}</h2>
|
||||||
<div className="dating-tags">
|
</div>
|
||||||
{(top.tags || []).map((tag) => (
|
)}
|
||||||
<span key={tag} className="meetup-tag">
|
<div
|
||||||
{tag}
|
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>
|
||||||
))}
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="dating-actions">
|
<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 && (
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user