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:
2026-09-20 12:26:41 +03:30
parent 4bd7b93b4a
commit cbbfb145dc
11 changed files with 524 additions and 21 deletions
+6
View File
@@ -180,6 +180,12 @@ export default function ProfileDetailPage() {
</div>
)}
{profile.bio && (
<p className="text-[14.5px] text-(--color-content-primary) leading-7 -mt-1">
{profile.bio}
</p>
)}
{(profile.height_cm || profile.weight_kg) && (
<SectionCard icon={<Ruler size={15} />} title="قد و وزن">
<div className="flex gap-4 text-[14px]">
+8 -3
View File
@@ -12,6 +12,7 @@ import { useAuth } from "@/lib/auth-context";
import { formatApiError } from "@/lib/hooks";
import { SwipeDeck } from "@/components/discover/SwipeDeck";
import { FiltersSheet, DiscoverFilters } from "@/components/discover/FiltersSheet";
import { StreakBadge } from "@/components/StreakBadge";
import { DiscoverCandidate, getDiscoverFeed, updateLocation } from "@/lib/api";
const POLL_MS = 20_000;
@@ -110,6 +111,7 @@ function Rejected({ reason }: { reason?: string }) {
function DiscoverScreen() {
const router = useRouter();
const { streak } = useAuth();
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<DiscoverFilters>(DEFAULT_FILTERS);
const [profiles, setProfiles] = useState<DiscoverCandidate[] | null>(null);
@@ -165,9 +167,12 @@ function DiscoverScreen() {
return (
<div className="flex flex-col min-h-full">
<header className="flex items-center justify-between px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-3">
<span className="font-[var(--font-display)] font-extrabold text-[19px] text-(--color-brand-600)">
دلدل 💌
</span>
<div className="flex items-center gap-2">
<span className="font-[var(--font-display)] font-extrabold text-[19px] text-(--color-brand-600)">
دلدل 💌
</span>
<StreakBadge count={streak} />
</div>
<div className="flex items-center gap-1">
<button
onClick={() => router.push("/home/likes")}
+192 -1
View File
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import {
Pencil,
Ruler,
@@ -18,9 +19,11 @@ import {
import { Card } from "@/components/ui/Card";
import { Tag } from "@/components/ui/Badge";
import { Avatar } from "@/components/ui/Avatar";
import { Button } from "@/components/ui/Button";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { getProfileMe, uploadPhoto, deletePhoto, photoUrl, FullProfile, Photo } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import { getProfileMe, uploadPhoto, deletePhoto, updateBio, photoUrl, FullProfile, Photo } from "@/lib/api";
import { formatApiError } from "@/lib/hooks";
import { calcAge } from "@/lib/utils";
@@ -28,6 +31,185 @@ function selectionTitles(profile: FullProfile, key: string): string[] {
return profile.selections.find((s) => s.category_key === key)?.titles ?? [];
}
interface CompletenessItem {
label: string;
done: boolean;
}
function computeCompleteness(profile: FullProfile): { pct: number; items: CompletenessItem[] } {
const items: CompletenessItem[] = [
{ label: "بیوگرافی", done: !!profile.user.bio?.trim() },
{ label: "حداقل ۳ عکس", done: profile.photos.length >= 3 },
{ label: "قد و وزن", done: !!profile.user.height_cm && !!profile.user.weight_kg },
{ label: "تحصیلات", done: selectionTitles(profile, "education_level").length > 0 },
{ label: "فعالیت‌ها", done: selectionTitles(profile, "occupation").length > 0 },
{ label: "علاقه‌مندی‌ها", done: selectionTitles(profile, "interest").length >= 5 },
{ label: "دنبال چی می‌گردی", done: selectionTitles(profile, "looking_for").length > 0 },
{ label: "معرفی خودت", done: profile.profile_answers.length >= 3 },
];
const doneCount = items.filter((i) => i.done).length;
return { pct: Math.round((doneCount / items.length) * 100), items };
}
// نوار تکمیل پروفایل؛ حلقه‌ی «سرمایه‌گذاری» مدل Hook: هرچی کاربر بیشتر روی
// پروفایلش وقت بذاره، حس ازدست‌رفتنِ پیشرفت بیشتر می‌شه و برمی‌گرده تکمیلش کنه.
function ProfileCompleteness({ profile }: { profile: FullProfile }) {
const { pct, items } = computeCompleteness(profile);
const missing = items.filter((i) => !i.done);
return (
<Card className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[13px] font-bold text-(--color-content-secondary)">تکمیل پروفایل</span>
<span className="text-[13px] font-extrabold text-(--color-brand-600)">{pct}%</span>
</div>
<div className="h-2.5 rounded-(--radius-full) bg-(--color-bg-tertiary) overflow-hidden">
<motion.div
className="h-full rounded-(--radius-full) bg-(--color-brand-500)"
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.7, ease: "easeOut" }}
/>
</div>
{pct < 100 ? (
<p className="text-[12.5px] text-(--color-content-tertiary)">
{missing.length} مورد مونده: {missing.map((m) => m.label).join("، ")}
</p>
) : (
<p className="text-[12.5px] text-emerald-600 font-semibold">پروفایلت کامله؛ آفرین! 🎉</p>
)}
</Card>
);
}
interface BadgeDef {
emoji: string;
label: string;
achieved: boolean;
hint: string;
}
function computeBadges(profile: FullProfile, streak: number): BadgeDef[] {
const { pct } = computeCompleteness(profile);
return [
{
emoji: "🔥",
label: "استریک",
achieved: streak >= 3,
hint: streak >= 3 ? `${streak} روز متوالی اومدی` : "۳ روز پشت‌سرهم بیا",
},
{ emoji: "📸", label: "عکاس", achieved: profile.photos.length >= 6, hint: "۶ عکس آپلود کن" },
{ emoji: "💯", label: "کامل", achieved: pct === 100, hint: "پروفایلت رو ۱۰۰٪ کن" },
{ emoji: "📝", label: "خودشناس", achieved: !!profile.user.bio?.trim(), hint: "بیو بنویس" },
{ emoji: "🎯", label: "رک‌وراست", achieved: profile.profile_answers.length >= 3, hint: "به ۳ سوال جواب بده" },
];
}
// شبکه‌ی نشان‌ها؛ نشان‌های نگرفته خاکستری و کم‌رنگ می‌مونن (curiosity gap) تا
// انگیزه‌ی جمع کردن کاملشون رو بده.
function BadgesGrid({ badges }: { badges: BadgeDef[] }) {
return (
<Card className="flex flex-col gap-3">
<span className="text-[13px] font-bold text-(--color-content-secondary)">نشان‌ها</span>
<div className="grid grid-cols-5 gap-2">
{badges.map((b) => (
<div
key={b.label}
className="flex flex-col items-center gap-1"
title={b.achieved ? b.label : b.hint}
>
<motion.div
initial={false}
animate={b.achieved ? { scale: [0.85, 1.08, 1] } : { scale: 1 }}
transition={{ duration: 0.4 }}
className={`w-12 h-12 rounded-(--radius-md) flex items-center justify-center text-[22px] ${
b.achieved
? "bg-(--color-brand-50)"
: "bg-(--color-bg-tertiary) grayscale opacity-40"
}`}
>
{b.emoji}
</motion.div>
<span className="text-[10px] text-(--color-content-tertiary) text-center leading-tight">
{b.label}
</span>
</div>
))}
</div>
</Card>
);
}
function BioSection({ bio, onChange }: { bio: string; onChange: (bio: string) => void }) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(bio);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSave() {
setSaving(true);
setError(null);
try {
const trimmed = value.trim();
await updateBio(trimmed);
onChange(trimmed);
setEditing(false);
} catch (err) {
setError(formatApiError(err));
} finally {
setSaving(false);
}
}
return (
<Card className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[13px] font-bold text-(--color-content-secondary)">بیوگرافی</span>
{!editing && (
<button
onClick={() => {
setValue(bio);
setEditing(true);
}}
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
>
<Pencil size={13} />
</button>
)}
</div>
<ErrorBanner message={error} />
{editing ? (
<div className="flex flex-col gap-2">
<textarea
value={value}
onChange={(e) => setValue(e.target.value.slice(0, 300))}
rows={3}
placeholder="چند خط راجع به خودت بنویس..."
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
/>
<div className="flex items-center justify-between">
<span className="text-[11px] text-(--color-content-tertiary)">{value.length}/300</span>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>
انصراف
</Button>
<Button size="sm" isLoading={saving} onClick={handleSave}>
ذخیره
</Button>
</div>
</div>
</div>
) : bio ? (
<p className="text-[14px] text-(--color-content-primary) leading-6">{bio}</p>
) : (
<p className="text-[13px] text-(--color-content-tertiary)">
هنوز بیو ننوشتی؛ یه چیزی بنویس تا بقیه بهتر بشناسنت.
</p>
)}
</Card>
);
}
function SectionCard({
icon,
title,
@@ -138,6 +320,7 @@ function PhotosSection({ photos, onChange }: { photos: Photo[]; onChange: (photo
}
export default function ProfilePage() {
const { streak } = useAuth();
const [profile, setProfile] = useState<FullProfile | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -188,6 +371,9 @@ export default function ProfilePage() {
</div>
</div>
<ProfileCompleteness profile={profile} />
<BadgesGrid badges={computeBadges(profile, streak)} />
{profile.photos.length === 0 && (
<div className="flex items-start gap-2 rounded-(--radius-sm) bg-amber-50 border-2 border-amber-100 text-amber-800 px-4 py-3 text-[12px] font-medium">
<UserIcon size={15} className="shrink-0 mt-0.5" />
@@ -200,6 +386,11 @@ export default function ProfilePage() {
onChange={(photos) => setProfile((p) => (p ? { ...p, photos } : p))}
/>
<BioSection
bio={profile.user.bio ?? ""}
onChange={(bio) => setProfile((p) => (p ? { ...p, user: { ...p.user, bio } } : p))}
/>
<SectionCard icon={<Ruler size={15} />} title="قد و وزن" editHref="/onboarding/body-info">
<div className="flex gap-4 text-[14px]">
<span className="flex items-center gap-1.5">