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).
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Flame } from "lucide-react";
|
||||
|
||||
// نشان استریک روزانه؛ تریگر بیرونی کلاسیک برای برگشت روزانهی کاربر (Hook Model).
|
||||
// وقتی عدد عوض میشه یه بار کوچیک pop میزنه تا حس جایزه گرفتن بده.
|
||||
export function StreakBadge({ count }: { count: number }) {
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.div
|
||||
key={count}
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 15 }}
|
||||
className="flex items-center gap-1 rounded-(--radius-full) bg-amber-50 border border-amber-200 px-2.5 py-1 text-amber-600"
|
||||
>
|
||||
<Flame size={13} fill="currentColor" className="text-amber-500" />
|
||||
<span className="text-[12px] font-extrabold">{count.toLocaleString("fa-IR")}</span>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { RotateCcw, Trophy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
const GAME_DURATION = 20; // ثانیه
|
||||
const SPAWN_MS = 650;
|
||||
const HIGH_SCORE_KEY = "deldel_catch_hearts_high_score";
|
||||
|
||||
interface FallingHeart {
|
||||
id: number;
|
||||
x: number; // درصد از عرض
|
||||
fallSeconds: number;
|
||||
isBonus: boolean;
|
||||
}
|
||||
|
||||
// وقتی دیگه پیشنهادی برای نشون دادن نیست، بهجای یه پیام خالی، یه بازی کوچیک
|
||||
// میذاریم تا کاربر توی اپ بمونه («The Hunt» از مدل Hook — یه پاداش کوچیک و
|
||||
// غیرقابلپیشبینی حتی وقتی فید خالیه).
|
||||
export function CatchHeartsGame({ onExit }: { onExit: () => void }) {
|
||||
const [state, setState] = useState<"idle" | "playing" | "ended">("idle");
|
||||
const [hearts, setHearts] = useState<FallingHeart[]>([]);
|
||||
const [score, setScore] = useState(0);
|
||||
const [timeLeft, setTimeLeft] = useState(GAME_DURATION);
|
||||
const [highScore, setHighScore] = useState(0);
|
||||
const nextId = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = Number(localStorage.getItem(HIGH_SCORE_KEY) ?? 0);
|
||||
if (!Number.isNaN(stored)) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setHighScore(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "playing") return;
|
||||
|
||||
const spawnInterval = setInterval(() => {
|
||||
const id = nextId.current++;
|
||||
setHearts((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
x: 8 + Math.random() * 80,
|
||||
fallSeconds: 2.6 + Math.random() * 1.8,
|
||||
isBonus: Math.random() < 0.15,
|
||||
},
|
||||
]);
|
||||
}, SPAWN_MS);
|
||||
|
||||
const countdown = setInterval(() => {
|
||||
setTimeLeft((t) => Math.max(0, t - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(spawnInterval);
|
||||
clearInterval(countdown);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "playing" || timeLeft !== 0) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setState("ended");
|
||||
setHearts([]);
|
||||
setScore((s) => {
|
||||
if (s > highScore) {
|
||||
localStorage.setItem(HIGH_SCORE_KEY, String(s));
|
||||
setHighScore(s);
|
||||
}
|
||||
return s;
|
||||
});
|
||||
}, [timeLeft, state, highScore]);
|
||||
|
||||
function startGame() {
|
||||
setScore(0);
|
||||
setTimeLeft(GAME_DURATION);
|
||||
setHearts([]);
|
||||
setState("playing");
|
||||
}
|
||||
|
||||
function catchHeart(heart: FallingHeart) {
|
||||
setHearts((prev) => prev.filter((h) => h.id !== heart.id));
|
||||
setScore((s) => s + (heart.isBonus ? 3 : 1));
|
||||
}
|
||||
|
||||
function removeMissed(id: number) {
|
||||
setHearts((prev) => prev.filter((h) => h.id !== id));
|
||||
}
|
||||
|
||||
if (state === "idle") {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 text-center px-6">
|
||||
<div className="text-[56px] [animation:wiggle_3s_ease-in-out_infinite]">💘</div>
|
||||
<h3 className="font-[var(--font-display)] font-extrabold text-[19px] text-(--color-content-primary)">
|
||||
فعلاً کسی نمونده!
|
||||
</h3>
|
||||
<p className="text-[13px] text-(--color-content-tertiary) max-w-[240px]">
|
||||
تا پیشنهاد جدید بیاد، یه بازی کوچیک بزن: قلبها رو بگیر ببین چندتا امتیاز میگیری.
|
||||
</p>
|
||||
{highScore > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-amber-600 font-bold">
|
||||
<Trophy size={14} /> رکورد قبلیت: {highScore.toLocaleString("fa-IR")}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={startGame}>شروع بازی 🎮</Button>
|
||||
<button
|
||||
onClick={onExit}
|
||||
className="text-[12.5px] text-(--color-content-tertiary) underline mt-1"
|
||||
>
|
||||
بررسی مجدد پیشنهادها
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "playing") {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<div className="absolute top-0 inset-x-0 flex items-center justify-between px-2 z-10">
|
||||
<span className="text-[13px] font-extrabold text-(--color-brand-600)">
|
||||
امتیاز: {score.toLocaleString("fa-IR")}
|
||||
</span>
|
||||
<span className="text-[13px] font-bold text-(--color-content-tertiary)">
|
||||
{timeLeft.toLocaleString("fa-IR")} ثانیه
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute inset-0 pt-12 overflow-hidden">
|
||||
<AnimatePresence>
|
||||
{hearts.map((h) => (
|
||||
<motion.button
|
||||
key={h.id}
|
||||
initial={{ top: "-8%" }}
|
||||
animate={{ top: "100%" }}
|
||||
transition={{ duration: h.fallSeconds, ease: "linear" }}
|
||||
onAnimationComplete={() => removeMissed(h.id)}
|
||||
onClick={() => catchHeart(h)}
|
||||
whileTap={{ scale: 1.4 }}
|
||||
style={{ position: "absolute", left: `${h.x}%` }}
|
||||
className="text-[28px] leading-none cursor-pointer"
|
||||
aria-label="گرفتن قلب"
|
||||
>
|
||||
{h.isBonus ? "💎" : "❤️"}
|
||||
</motion.button>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 text-center px-6">
|
||||
<div className="text-[48px]">{score > 0 && score >= highScore ? "🏆" : "💖"}</div>
|
||||
<h3 className="font-[var(--font-display)] font-extrabold text-[19px]">
|
||||
امتیازت: {score.toLocaleString("fa-IR")}
|
||||
</h3>
|
||||
{score > 0 && score >= highScore && (
|
||||
<p className="text-[13px] text-amber-600 font-bold">رکورد جدید! 🎉</p>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button variant="tertiary" icon={<RotateCcw size={14} />} onClick={startGame}>
|
||||
دوباره بازی کن
|
||||
</Button>
|
||||
<Button onClick={onExit}>بررسی مجدد پیشنهادها</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { User } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { MatchView, photoUrl } from "@/lib/api";
|
||||
|
||||
const CONFETTI_COLORS = ["#ff4d6d", "#ffd166", "#06d6a0", "#4cc9f0", "#f472b6", "#ffffff"];
|
||||
|
||||
// بارونِ ریز کاغذرنگی که با باز شدن مودال مچ میباره؛ سلولوزیِ کلاسیک "پاداش
|
||||
// متغیر" (Variable Reward) لحظهی مچ رو حسمانیتر میکنه.
|
||||
function Confetti() {
|
||||
const [pieces] = useState(() =>
|
||||
Array.from({ length: 24 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
delay: Math.random() * 0.4,
|
||||
duration: 1.8 + Math.random() * 1.2,
|
||||
rotate: Math.random() * 360,
|
||||
color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
|
||||
size: 6 + Math.random() * 6,
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{pieces.map((p) => (
|
||||
<motion.span
|
||||
key={p.id}
|
||||
initial={{ y: "-10vh", x: `${p.x}vw`, opacity: 1, rotate: 0 }}
|
||||
animate={{ y: "110vh", rotate: p.rotate }}
|
||||
transition={{ duration: p.duration, delay: p.delay, ease: "easeIn" }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: p.size,
|
||||
height: p.size * 1.6,
|
||||
backgroundColor: p.color,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MatchModal({
|
||||
match,
|
||||
onClose,
|
||||
@@ -25,6 +64,7 @@ export function MatchModal({
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/70 px-8 text-center"
|
||||
>
|
||||
<Confetti />
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
|
||||
@@ -111,6 +111,11 @@ export function SwipeCard({
|
||||
</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>
|
||||
|
||||
@@ -2,15 +2,44 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X, Heart, Star, RotateCcw } from "lucide-react";
|
||||
import { X, Heart, Star } from "lucide-react";
|
||||
import { SwipeCard } from "./SwipeCard";
|
||||
import { MatchModal } from "./MatchModal";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { CatchHeartsGame } from "./CatchHeartsGame";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { DiscoverCandidate, MatchView, swipe } from "@/lib/api";
|
||||
|
||||
// وقتی کاربر لایک میزنه چندتا قلب کوچیک از وسط دِک به بالا شناور میشن و محو
|
||||
// میشن؛ یه تاییدِ حسی سریع برای اکشنی که همین الان انجام داد (نه فقط تغییر state).
|
||||
function HeartBurst({ onDone }: { onDone: () => void }) {
|
||||
const [hearts] = useState(() =>
|
||||
Array.from({ length: 6 }, (_, i) => ({
|
||||
id: i,
|
||||
x: (Math.random() - 0.5) * 90,
|
||||
delay: Math.random() * 0.15,
|
||||
size: 16 + Math.random() * 16,
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none flex items-center justify-center z-20">
|
||||
{hearts.map((h) => (
|
||||
<motion.span
|
||||
key={h.id}
|
||||
initial={{ opacity: 1, scale: 0.4, x: h.x, y: 0 }}
|
||||
animate={{ opacity: 0, scale: 1.15, y: -150 }}
|
||||
transition={{ duration: 0.9, delay: h.delay, ease: "easeOut" }}
|
||||
onAnimationComplete={h.id === hearts.length - 1 ? onDone : undefined}
|
||||
style={{ position: "absolute", fontSize: h.size }}
|
||||
>
|
||||
❤️
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwipeDeck({
|
||||
profiles,
|
||||
onReload,
|
||||
@@ -22,6 +51,7 @@ export function SwipeDeck({
|
||||
const [matched, setMatched] = useState<MatchView | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [burstId, setBurstId] = useState<number | null>(null);
|
||||
|
||||
async function decide(direction: "left" | "right", superlike = false) {
|
||||
if (deck.length === 0 || busy) return;
|
||||
@@ -29,6 +59,9 @@ export function SwipeDeck({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setDeck((prev) => prev.slice(1));
|
||||
if (direction === "right") {
|
||||
setBurstId(Date.now());
|
||||
}
|
||||
try {
|
||||
const action = direction === "left" ? "pass" : superlike ? "superlike" : "like";
|
||||
const result = await swipe(top.id, action);
|
||||
@@ -54,18 +87,7 @@ export function SwipeDeck({
|
||||
|
||||
<div className="relative flex-1">
|
||||
{visible.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<EmptyState
|
||||
icon={<RotateCcw size={32} />}
|
||||
title="فعلاً همه رو دیدی"
|
||||
description="پروفایل جدیدی برای نشون دادن نداریم. بعداً دوباره سر بزن."
|
||||
/>
|
||||
<Button size="sm" onClick={onReload}>
|
||||
بررسی مجدد
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CatchHeartsGame onExit={onReload} />
|
||||
) : (
|
||||
<AnimatePresence>
|
||||
{[...visible].reverse().map((profile, idxFromEnd) => {
|
||||
@@ -82,6 +104,7 @@ export function SwipeDeck({
|
||||
})}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
{burstId !== null && <HeartBurst key={burstId} onDone={() => setBurstId(null)} />}
|
||||
</div>
|
||||
|
||||
{visible.length > 0 && (
|
||||
|
||||
@@ -53,9 +53,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-(--radius-md) font-[var(--font-sans)] font-semibold",
|
||||
"transition-all duration-150 ease-out select-none cursor-pointer",
|
||||
"transition-all duration-150 ease-out select-none cursor-pointer active:scale-95",
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-(--color-brand-200)",
|
||||
"disabled:opacity-45 disabled:pointer-events-none disabled:translate-y-0 disabled:shadow-none",
|
||||
"disabled:opacity-45 disabled:pointer-events-none disabled:translate-y-0 disabled:scale-100 disabled:shadow-none",
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
isBlock && "w-full",
|
||||
|
||||
Reference in New Issue
Block a user