cbbfb145dc
Applies a few Hook Model (trigger/action/reward/investment) patterns on top of the existing swipe/match loop: - Daily streak: a flame badge in the home header, backed by the new checkin endpoint — an external trigger to return daily. - Profile completeness meter + badge collection on the profile page (client-computed from existing data) — the investment loop nudging users to fill out bio/photos/answers. - Bio editor on the profile page, shown on swipe cards and candidate detail so profiles read richer. - Catch the Hearts: a small tap mini-game (falling hearts, bonus items, 20s timer, persisted high score) shown instead of a dead end when the discover feed runs out, so there's still a reason to stay in the app. - Micro-animations: heart-burst on like, confetti on match, tap feedback on all buttons (active:scale-95). Fixed a React "purity" lint violation along the way (Math.random() can't be called inside useMemo under this project's rules — moved to useState lazy initializers, which run once on mount as intended).
124 lines
4.4 KiB
TypeScript
124 lines
4.4 KiB
TypeScript
"use client";
|
||
|
||
import { useRouter } from "next/navigation";
|
||
import { motion, useMotionValue, useTransform, PanInfo } from "framer-motion";
|
||
import { MapPin, User } from "lucide-react";
|
||
import { DiscoverCandidate, photoUrl } from "@/lib/api";
|
||
import { formatDistance } from "@/lib/utils";
|
||
|
||
const SWIPE_THRESHOLD = 120;
|
||
|
||
export function SwipeCard({
|
||
profile,
|
||
isTop,
|
||
stackIndex,
|
||
onDecide,
|
||
}: {
|
||
profile: DiscoverCandidate;
|
||
isTop: boolean;
|
||
stackIndex: number;
|
||
onDecide: (direction: "left" | "right") => void;
|
||
}) {
|
||
const router = useRouter();
|
||
const x = useMotionValue(0);
|
||
const rotate = useTransform(x, [-260, 260], [-16, 16]);
|
||
const likeOpacity = useTransform(x, [20, 120], [0, 1]);
|
||
const nopeOpacity = useTransform(x, [-120, -20], [1, 0]);
|
||
|
||
function handleDragEnd(_: unknown, info: PanInfo) {
|
||
if (info.offset.x > SWIPE_THRESHOLD) {
|
||
onDecide("right");
|
||
} else if (info.offset.x < -SWIPE_THRESHOLD) {
|
||
onDecide("left");
|
||
}
|
||
}
|
||
|
||
const photo = profile.photos?.[0];
|
||
|
||
return (
|
||
<motion.div
|
||
className="absolute inset-0"
|
||
style={
|
||
isTop
|
||
? { x, rotate }
|
||
: {
|
||
scale: 1 - stackIndex * 0.04,
|
||
y: stackIndex * 10,
|
||
}
|
||
}
|
||
drag={isTop ? "x" : false}
|
||
dragElastic={0.9}
|
||
onDragEnd={isTop ? handleDragEnd : undefined}
|
||
initial={{ scale: 1 - stackIndex * 0.04, y: stackIndex * 10, opacity: 0 }}
|
||
animate={{ scale: 1 - stackIndex * 0.04, y: stackIndex * 10, opacity: 1 }}
|
||
exit={{
|
||
x: x.get() > 0 ? 400 : x.get() < 0 ? -400 : 0,
|
||
opacity: 0,
|
||
transition: { duration: 0.3 },
|
||
}}
|
||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||
>
|
||
<div
|
||
onClick={() => isTop && router.push(`/home/${profile.id}`)}
|
||
className="relative w-full h-full rounded-(--radius-xl) overflow-hidden shadow-(--shadow-cute-lg) bg-(--color-bg-tertiary) cursor-pointer select-none"
|
||
>
|
||
{photo ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={photoUrl(photo)}
|
||
alt={profile.first_name}
|
||
draggable={false}
|
||
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
|
||
/>
|
||
) : (
|
||
<div className="absolute inset-0 flex items-center justify-center bg-(--color-bg-tertiary) text-(--color-content-disabled) pointer-events-none">
|
||
<User size={64} strokeWidth={1.2} />
|
||
</div>
|
||
)}
|
||
<div className="absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-black/85 via-black/30 to-transparent pointer-events-none" />
|
||
|
||
{isTop && (
|
||
<>
|
||
<motion.span
|
||
style={{ opacity: likeOpacity }}
|
||
className="absolute top-8 right-6 rotate-[-12deg] border-4 border-(--color-status-success) text-(--color-status-success) font-[var(--font-display)] font-extrabold text-[26px] px-3 py-1 rounded-(--radius-sm) bg-white/70"
|
||
>
|
||
لایک
|
||
</motion.span>
|
||
<motion.span
|
||
style={{ opacity: nopeOpacity }}
|
||
className="absolute top-8 left-6 rotate-[12deg] border-4 border-(--color-status-error) text-(--color-status-error) font-[var(--font-display)] font-extrabold text-[26px] px-3 py-1 rounded-(--radius-sm) bg-white/70"
|
||
>
|
||
ردّ
|
||
</motion.span>
|
||
</>
|
||
)}
|
||
|
||
<div className="absolute inset-x-0 bottom-0 p-5 pointer-events-none">
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="font-[var(--font-display)] font-extrabold text-[24px] text-white">
|
||
{profile.first_name || "بدون نام"}
|
||
</h2>
|
||
<span className="font-[var(--font-display)] font-bold text-[20px] text-white/90">
|
||
{profile.age}
|
||
</span>
|
||
</div>
|
||
{profile.distance_km != null && (
|
||
<div className="flex items-center gap-1.5 mt-1.5">
|
||
<MapPin size={14} className="text-white/80" />
|
||
<span className="text-[13px] text-white/80">
|
||
{formatDistance(profile.distance_km)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{profile.bio && (
|
||
<p className="text-[12.5px] text-white/85 leading-5 mt-1.5 line-clamp-2">
|
||
{profile.bio}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
}
|