Files
chat_room/src/components/discover/SwipeCard.tsx
T
mobiniqow cbbfb145dc Add gamification, micro-animations, and a mini-game for empty states
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).
2026-09-20 12:26:41 +03:30

124 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}