diff --git a/src/app/(main)/home/[id]/page.tsx b/src/app/(main)/home/[id]/page.tsx
index bb5a542..7ac756c 100644
--- a/src/app/(main)/home/[id]/page.tsx
+++ b/src/app/(main)/home/[id]/page.tsx
@@ -180,6 +180,12 @@ export default function ProfileDetailPage() {
)}
+ {profile.bio && (
+
+ {profile.bio}
+
+ )}
+
{(profile.height_cm || profile.weight_kg) && (
} title="قد و وزن">
diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx
index 52dab2b..c1295a1 100644
--- a/src/app/(main)/home/page.tsx
+++ b/src/app/(main)/home/page.tsx
@@ -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
(DEFAULT_FILTERS);
const [profiles, setProfiles] = useState(null);
@@ -165,9 +167,12 @@ function DiscoverScreen() {
return (
-
- دلدل 💌
-
+
+
+ دلدل 💌
+
+
+
+
+
+
{profile.photos.length === 0 && (
@@ -200,6 +386,11 @@ export default function ProfilePage() {
onChange={(photos) => setProfile((p) => (p ? { ...p, photos } : p))}
/>
+
setProfile((p) => (p ? { ...p, user: { ...p.user, bio } } : p))}
+ />
+
} title="قد و وزن" editHref="/onboarding/body-info">
diff --git a/src/components/StreakBadge.tsx b/src/components/StreakBadge.tsx
new file mode 100644
index 0000000..bb32868
--- /dev/null
+++ b/src/components/StreakBadge.tsx
@@ -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 (
+
+
+
+ {count.toLocaleString("fa-IR")}
+
+
+ );
+}
diff --git a/src/components/discover/CatchHeartsGame.tsx b/src/components/discover/CatchHeartsGame.tsx
new file mode 100644
index 0000000..f686d4f
--- /dev/null
+++ b/src/components/discover/CatchHeartsGame.tsx
@@ -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([]);
+ 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 (
+
+
💘
+
+ فعلاً کسی نمونده!
+
+
+ تا پیشنهاد جدید بیاد، یه بازی کوچیک بزن: قلبها رو بگیر ببین چندتا امتیاز میگیری.
+
+ {highScore > 0 && (
+
+ رکورد قبلیت: {highScore.toLocaleString("fa-IR")}
+
+ )}
+
شروع بازی 🎮
+
+ بررسی مجدد پیشنهادها
+
+
+ );
+ }
+
+ if (state === "playing") {
+ return (
+
+
+
+ امتیاز: {score.toLocaleString("fa-IR")}
+
+
+ {timeLeft.toLocaleString("fa-IR")} ثانیه
+
+
+
+
+ {hearts.map((h) => (
+ 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 ? "💎" : "❤️"}
+
+ ))}
+
+
+
+ );
+ }
+
+ return (
+
+
{score > 0 && score >= highScore ? "🏆" : "💖"}
+
+ امتیازت: {score.toLocaleString("fa-IR")}
+
+ {score > 0 && score >= highScore && (
+
رکورد جدید! 🎉
+ )}
+
+ } onClick={startGame}>
+ دوباره بازی کن
+
+ بررسی مجدد پیشنهادها
+
+
+ );
+}
diff --git a/src/components/discover/MatchModal.tsx b/src/components/discover/MatchModal.tsx
index baa5252..554bac9 100644
--- a/src/components/discover/MatchModal.tsx
+++ b/src/components/discover/MatchModal.tsx
@@ -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 (
+
+ {pieces.map((p) => (
+
+ ))}
+
+ );
+}
+
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"
>
+
)}
+ {profile.bio && (
+
+ {profile.bio}
+
+ )}
diff --git a/src/components/discover/SwipeDeck.tsx b/src/components/discover/SwipeDeck.tsx
index 53940a7..f623fcf 100644
--- a/src/components/discover/SwipeDeck.tsx
+++ b/src/components/discover/SwipeDeck.tsx
@@ -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 (
+
+ {hearts.map((h) => (
+
+ ❤️
+
+ ))}
+
+ );
+}
+
export function SwipeDeck({
profiles,
onReload,
@@ -22,6 +51,7 @@ export function SwipeDeck({
const [matched, setMatched] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
+ const [burstId, setBurstId] = useState(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({
{visible.length === 0 ? (
-
-
- }
- title="فعلاً همه رو دیدی"
- description="پروفایل جدیدی برای نشون دادن نداریم. بعداً دوباره سر بزن."
- />
-
- بررسی مجدد
-
-
-
+
) : (
{[...visible].reverse().map((profile, idxFromEnd) => {
@@ -82,6 +104,7 @@ export function SwipeDeck({
})}
)}
+ {burstId !== null &&
setBurstId(null)} />}
{visible.length > 0 && (
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
index 0428af9..44e2e01 100644
--- a/src/components/ui/Button.tsx
+++ b/src/components/ui/Button.tsx
@@ -53,9 +53,9 @@ export const Button = forwardRef(
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",
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 39a57a7..8f50c34 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -46,6 +46,8 @@ export interface UserProfile {
approval_status: ApprovalStatus;
rejection_reason?: string;
is_admin: boolean;
+ bio?: string;
+ streak_count: number;
}
export interface TokenPair {
@@ -125,6 +127,7 @@ export interface DiscoverCandidate {
id: number;
first_name: string;
age: number;
+ bio?: string;
photos: string[];
distance_km?: number;
interests?: string[];
@@ -139,6 +142,7 @@ export interface CandidateDetail {
id: number;
first_name: string;
age: number;
+ bio?: string;
photos: string[];
distance_km?: number;
height_cm?: number;
@@ -420,6 +424,22 @@ export function updateLocation(latitude: number, longitude: number) {
});
}
+export function updateBio(bio: string) {
+ return authRequest("/profile/bio", {
+ method: "PATCH",
+ body: JSON.stringify({ bio }),
+ });
+}
+
+export interface CheckInResult {
+ streak_count: number;
+ is_new_today: boolean;
+}
+
+export function checkIn() {
+ return authRequest("/profile/checkin", { method: "POST" });
+}
+
// ---------------- دوستیابی ----------------
export function getDiscoverFeed(params?: {
diff --git a/src/lib/auth-context.tsx b/src/lib/auth-context.tsx
index a02d87b..b600dbb 100644
--- a/src/lib/auth-context.tsx
+++ b/src/lib/auth-context.tsx
@@ -15,6 +15,7 @@ import {
verifyOtp as apiVerifyOtp,
getOnboardingStatus,
getProfileMe,
+ checkIn as apiCheckIn,
} from "./api";
type AuthStatus = "loading" | "guest" | "authenticated";
@@ -23,6 +24,7 @@ interface AuthContextValue {
status: AuthStatus;
user: UserProfile | null;
onboardingStatus: OnboardingStatus | null;
+ streak: number;
login: (phone: string, otp: string) => Promise;
logout: () => void;
refreshOnboardingStatus: () => Promise;
@@ -37,6 +39,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [onboardingStatus, setOnboardingStatus] = useState(
null
);
+ const [streak, setStreak] = useState(0);
const bootstrap = useCallback(async () => {
const access = tokenStore.getAccess();
@@ -48,7 +51,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const profile = await getProfileMe();
setUser(profile.user);
setOnboardingStatus(profile.onboarding_status);
+ setStreak(profile.user.streak_count);
setStatus("authenticated");
+ // یک بار در روز برای هر کاربر: استریک روزانه (گیمیفیکیشن) رو ثبت میکنه.
+ // fire-and-forget؛ اگه شکست بخوره چیزی توی تجربهی کاربر خراب نمیشه.
+ apiCheckIn()
+ .then((r) => setStreak(r.streak_count))
+ .catch(() => {});
} catch {
tokenStore.clear();
setStatus("guest");
@@ -64,10 +73,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await apiVerifyOtp(phone, otp);
tokenStore.set(data.tokens.access_token, data.tokens.refresh_token);
setUser(data.user);
+ setStreak(data.user.streak_count);
setStatus("authenticated");
// وضعیت کامل مراحل رو جدا میگیریم چون verify-otp فقط current_step/is_onboarded میده
const fullStatus = await getOnboardingStatus();
setOnboardingStatus(fullStatus);
+ apiCheckIn()
+ .then((r) => setStreak(r.streak_count))
+ .catch(() => {});
return data.user;
}, []);
@@ -75,6 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
tokenStore.clear();
setUser(null);
setOnboardingStatus(null);
+ setStreak(0);
setStatus("guest");
}, []);
@@ -92,6 +106,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const profile = await getProfileMe();
setUser(profile.user);
setOnboardingStatus(profile.onboarding_status);
+ setStreak(profile.user.streak_count);
}, []);
return (
@@ -100,6 +115,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
status,
user,
onboardingStatus,
+ streak,
login,
logout,
refreshOnboardingStatus,