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:
@@ -180,6 +180,12 @@ export default function ProfileDetailPage() {
|
|||||||
</div>
|
</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) && (
|
{(profile.height_cm || profile.weight_kg) && (
|
||||||
<SectionCard icon={<Ruler size={15} />} title="قد و وزن">
|
<SectionCard icon={<Ruler size={15} />} title="قد و وزن">
|
||||||
<div className="flex gap-4 text-[14px]">
|
<div className="flex gap-4 text-[14px]">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useAuth } from "@/lib/auth-context";
|
|||||||
import { formatApiError } from "@/lib/hooks";
|
import { formatApiError } from "@/lib/hooks";
|
||||||
import { SwipeDeck } from "@/components/discover/SwipeDeck";
|
import { SwipeDeck } from "@/components/discover/SwipeDeck";
|
||||||
import { FiltersSheet, DiscoverFilters } from "@/components/discover/FiltersSheet";
|
import { FiltersSheet, DiscoverFilters } from "@/components/discover/FiltersSheet";
|
||||||
|
import { StreakBadge } from "@/components/StreakBadge";
|
||||||
import { DiscoverCandidate, getDiscoverFeed, updateLocation } from "@/lib/api";
|
import { DiscoverCandidate, getDiscoverFeed, updateLocation } from "@/lib/api";
|
||||||
|
|
||||||
const POLL_MS = 20_000;
|
const POLL_MS = 20_000;
|
||||||
@@ -110,6 +111,7 @@ function Rejected({ reason }: { reason?: string }) {
|
|||||||
|
|
||||||
function DiscoverScreen() {
|
function DiscoverScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { streak } = useAuth();
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
const [filters, setFilters] = useState<DiscoverFilters>(DEFAULT_FILTERS);
|
const [filters, setFilters] = useState<DiscoverFilters>(DEFAULT_FILTERS);
|
||||||
const [profiles, setProfiles] = useState<DiscoverCandidate[] | null>(null);
|
const [profiles, setProfiles] = useState<DiscoverCandidate[] | null>(null);
|
||||||
@@ -165,9 +167,12 @@ function DiscoverScreen() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-full">
|
<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">
|
<header className="flex items-center justify-between px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-[var(--font-display)] font-extrabold text-[19px] text-(--color-brand-600)">
|
<span className="font-[var(--font-display)] font-extrabold text-[19px] text-(--color-brand-600)">
|
||||||
دلدل 💌
|
دلدل 💌
|
||||||
</span>
|
</span>
|
||||||
|
<StreakBadge count={streak} />
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/home/likes")}
|
onClick={() => router.push("/home/likes")}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Ruler,
|
Ruler,
|
||||||
@@ -18,9 +19,11 @@ import {
|
|||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { Tag } from "@/components/ui/Badge";
|
import { Tag } from "@/components/ui/Badge";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
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 { formatApiError } from "@/lib/hooks";
|
||||||
import { calcAge } from "@/lib/utils";
|
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 ?? [];
|
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({
|
function SectionCard({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
@@ -138,6 +320,7 @@ function PhotosSection({ photos, onChange }: { photos: Photo[]; onChange: (photo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
|
const { streak } = useAuth();
|
||||||
const [profile, setProfile] = useState<FullProfile | null>(null);
|
const [profile, setProfile] = useState<FullProfile | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -188,6 +371,9 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ProfileCompleteness profile={profile} />
|
||||||
|
<BadgesGrid badges={computeBadges(profile, streak)} />
|
||||||
|
|
||||||
{profile.photos.length === 0 && (
|
{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">
|
<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" />
|
<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))}
|
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">
|
<SectionCard icon={<Ruler size={15} />} title="قد و وزن" editHref="/onboarding/body-info">
|
||||||
<div className="flex gap-4 text-[14px]">
|
<div className="flex gap-4 text-[14px]">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { User } from "lucide-react";
|
import { User } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { MatchView, photoUrl } from "@/lib/api";
|
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({
|
export function MatchModal({
|
||||||
match,
|
match,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -25,6 +64,7 @@ export function MatchModal({
|
|||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/70 px-8 text-center"
|
className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/70 px-8 text-center"
|
||||||
>
|
>
|
||||||
|
<Confetti />
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.7, opacity: 0 }}
|
initial={{ scale: 0.7, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ export function SwipeCard({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{profile.bio && (
|
||||||
|
<p className="text-[12.5px] text-white/85 leading-5 mt-1.5 line-clamp-2">
|
||||||
|
{profile.bio}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -2,15 +2,44 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
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 { SwipeCard } from "./SwipeCard";
|
||||||
import { MatchModal } from "./MatchModal";
|
import { MatchModal } from "./MatchModal";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { CatchHeartsGame } from "./CatchHeartsGame";
|
||||||
import { Button } from "@/components/ui/Button";
|
|
||||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
import { formatApiError } from "@/lib/hooks";
|
import { formatApiError } from "@/lib/hooks";
|
||||||
import { DiscoverCandidate, MatchView, swipe } from "@/lib/api";
|
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({
|
export function SwipeDeck({
|
||||||
profiles,
|
profiles,
|
||||||
onReload,
|
onReload,
|
||||||
@@ -22,6 +51,7 @@ export function SwipeDeck({
|
|||||||
const [matched, setMatched] = useState<MatchView | null>(null);
|
const [matched, setMatched] = useState<MatchView | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [burstId, setBurstId] = useState<number | null>(null);
|
||||||
|
|
||||||
async function decide(direction: "left" | "right", superlike = false) {
|
async function decide(direction: "left" | "right", superlike = false) {
|
||||||
if (deck.length === 0 || busy) return;
|
if (deck.length === 0 || busy) return;
|
||||||
@@ -29,6 +59,9 @@ export function SwipeDeck({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setDeck((prev) => prev.slice(1));
|
setDeck((prev) => prev.slice(1));
|
||||||
|
if (direction === "right") {
|
||||||
|
setBurstId(Date.now());
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const action = direction === "left" ? "pass" : superlike ? "superlike" : "like";
|
const action = direction === "left" ? "pass" : superlike ? "superlike" : "like";
|
||||||
const result = await swipe(top.id, action);
|
const result = await swipe(top.id, action);
|
||||||
@@ -54,18 +87,7 @@ export function SwipeDeck({
|
|||||||
|
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<div className="h-full flex items-center justify-center">
|
<CatchHeartsGame onExit={onReload} />
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<EmptyState
|
|
||||||
icon={<RotateCcw size={32} />}
|
|
||||||
title="فعلاً همه رو دیدی"
|
|
||||||
description="پروفایل جدیدی برای نشون دادن نداریم. بعداً دوباره سر بزن."
|
|
||||||
/>
|
|
||||||
<Button size="sm" onClick={onReload}>
|
|
||||||
بررسی مجدد
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{[...visible].reverse().map((profile, idxFromEnd) => {
|
{[...visible].reverse().map((profile, idxFromEnd) => {
|
||||||
@@ -82,6 +104,7 @@ export function SwipeDeck({
|
|||||||
})}
|
})}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)}
|
)}
|
||||||
|
{burstId !== null && <HeartBurst key={burstId} onDone={() => setBurstId(null)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{visible.length > 0 && (
|
{visible.length > 0 && (
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
disabled={disabled || isLoading}
|
disabled={disabled || isLoading}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center rounded-(--radius-md) font-[var(--font-sans)] font-semibold",
|
"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)",
|
"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],
|
sizeClasses[size],
|
||||||
variantClasses[variant],
|
variantClasses[variant],
|
||||||
isBlock && "w-full",
|
isBlock && "w-full",
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export interface UserProfile {
|
|||||||
approval_status: ApprovalStatus;
|
approval_status: ApprovalStatus;
|
||||||
rejection_reason?: string;
|
rejection_reason?: string;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
|
bio?: string;
|
||||||
|
streak_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenPair {
|
export interface TokenPair {
|
||||||
@@ -125,6 +127,7 @@ export interface DiscoverCandidate {
|
|||||||
id: number;
|
id: number;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
age: number;
|
age: number;
|
||||||
|
bio?: string;
|
||||||
photos: string[];
|
photos: string[];
|
||||||
distance_km?: number;
|
distance_km?: number;
|
||||||
interests?: string[];
|
interests?: string[];
|
||||||
@@ -139,6 +142,7 @@ export interface CandidateDetail {
|
|||||||
id: number;
|
id: number;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
age: number;
|
age: number;
|
||||||
|
bio?: string;
|
||||||
photos: string[];
|
photos: string[];
|
||||||
distance_km?: number;
|
distance_km?: number;
|
||||||
height_cm?: number;
|
height_cm?: number;
|
||||||
@@ -420,6 +424,22 @@ export function updateLocation(latitude: number, longitude: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateBio(bio: string) {
|
||||||
|
return authRequest<null>("/profile/bio", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ bio }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckInResult {
|
||||||
|
streak_count: number;
|
||||||
|
is_new_today: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkIn() {
|
||||||
|
return authRequest<CheckInResult>("/profile/checkin", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- دوستیابی ----------------
|
// ---------------- دوستیابی ----------------
|
||||||
|
|
||||||
export function getDiscoverFeed(params?: {
|
export function getDiscoverFeed(params?: {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
verifyOtp as apiVerifyOtp,
|
verifyOtp as apiVerifyOtp,
|
||||||
getOnboardingStatus,
|
getOnboardingStatus,
|
||||||
getProfileMe,
|
getProfileMe,
|
||||||
|
checkIn as apiCheckIn,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
type AuthStatus = "loading" | "guest" | "authenticated";
|
type AuthStatus = "loading" | "guest" | "authenticated";
|
||||||
@@ -23,6 +24,7 @@ interface AuthContextValue {
|
|||||||
status: AuthStatus;
|
status: AuthStatus;
|
||||||
user: UserProfile | null;
|
user: UserProfile | null;
|
||||||
onboardingStatus: OnboardingStatus | null;
|
onboardingStatus: OnboardingStatus | null;
|
||||||
|
streak: number;
|
||||||
login: (phone: string, otp: string) => Promise<UserProfile>;
|
login: (phone: string, otp: string) => Promise<UserProfile>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
refreshOnboardingStatus: () => Promise<OnboardingStatus | null>;
|
refreshOnboardingStatus: () => Promise<OnboardingStatus | null>;
|
||||||
@@ -37,6 +39,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [onboardingStatus, setOnboardingStatus] = useState<OnboardingStatus | null>(
|
const [onboardingStatus, setOnboardingStatus] = useState<OnboardingStatus | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
const [streak, setStreak] = useState(0);
|
||||||
|
|
||||||
const bootstrap = useCallback(async () => {
|
const bootstrap = useCallback(async () => {
|
||||||
const access = tokenStore.getAccess();
|
const access = tokenStore.getAccess();
|
||||||
@@ -48,7 +51,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const profile = await getProfileMe();
|
const profile = await getProfileMe();
|
||||||
setUser(profile.user);
|
setUser(profile.user);
|
||||||
setOnboardingStatus(profile.onboarding_status);
|
setOnboardingStatus(profile.onboarding_status);
|
||||||
|
setStreak(profile.user.streak_count);
|
||||||
setStatus("authenticated");
|
setStatus("authenticated");
|
||||||
|
// یک بار در روز برای هر کاربر: استریک روزانه (گیمیفیکیشن) رو ثبت میکنه.
|
||||||
|
// fire-and-forget؛ اگه شکست بخوره چیزی توی تجربهی کاربر خراب نمیشه.
|
||||||
|
apiCheckIn()
|
||||||
|
.then((r) => setStreak(r.streak_count))
|
||||||
|
.catch(() => {});
|
||||||
} catch {
|
} catch {
|
||||||
tokenStore.clear();
|
tokenStore.clear();
|
||||||
setStatus("guest");
|
setStatus("guest");
|
||||||
@@ -64,10 +73,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const data = await apiVerifyOtp(phone, otp);
|
const data = await apiVerifyOtp(phone, otp);
|
||||||
tokenStore.set(data.tokens.access_token, data.tokens.refresh_token);
|
tokenStore.set(data.tokens.access_token, data.tokens.refresh_token);
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
|
setStreak(data.user.streak_count);
|
||||||
setStatus("authenticated");
|
setStatus("authenticated");
|
||||||
// وضعیت کامل مراحل رو جدا میگیریم چون verify-otp فقط current_step/is_onboarded میده
|
// وضعیت کامل مراحل رو جدا میگیریم چون verify-otp فقط current_step/is_onboarded میده
|
||||||
const fullStatus = await getOnboardingStatus();
|
const fullStatus = await getOnboardingStatus();
|
||||||
setOnboardingStatus(fullStatus);
|
setOnboardingStatus(fullStatus);
|
||||||
|
apiCheckIn()
|
||||||
|
.then((r) => setStreak(r.streak_count))
|
||||||
|
.catch(() => {});
|
||||||
return data.user;
|
return data.user;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -75,6 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
tokenStore.clear();
|
tokenStore.clear();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setOnboardingStatus(null);
|
setOnboardingStatus(null);
|
||||||
|
setStreak(0);
|
||||||
setStatus("guest");
|
setStatus("guest");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -92,6 +106,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const profile = await getProfileMe();
|
const profile = await getProfileMe();
|
||||||
setUser(profile.user);
|
setUser(profile.user);
|
||||||
setOnboardingStatus(profile.onboarding_status);
|
setOnboardingStatus(profile.onboarding_status);
|
||||||
|
setStreak(profile.user.streak_count);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,6 +115,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
status,
|
status,
|
||||||
user,
|
user,
|
||||||
onboardingStatus,
|
onboardingStatus,
|
||||||
|
streak,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
refreshOnboardingStatus,
|
refreshOnboardingStatus,
|
||||||
|
|||||||
Reference in New Issue
Block a user