Leaderboard added

This commit is contained in:
2026-09-21 14:55:28 +03:30
parent 74e048cc2a
commit 15d110d824

View File

@@ -6,7 +6,7 @@ import {
Lock, Sparkles, MessageCircle, Check, X, Mic, Send, Lock, Sparkles, MessageCircle, Check, X, Mic, Send,
ChevronLeft, ChevronRight, Zap, ChevronDown, AlertCircle, ChevronLeft, ChevronRight, Zap, ChevronDown, AlertCircle,
Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat,
Flame, Star, Trophy, Heart, Flame, Star, Trophy, Heart, Users, FileText,
} from "lucide-react"; } from "lucide-react";
import { createClient, ApiError } from "../api"; import { createClient, ApiError } from "../api";
import type { import type {
@@ -300,7 +300,7 @@ function SeyarMascot({ char, mood = "idle", size = 100, bob = false }: { char: S
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
type AppFlow = "splash" | "onboarding" | "login" | "profile" | "main"; type AppFlow = "splash" | "onboarding" | "login" | "profile" | "main";
type MainScreen = "home" | "topics" | "skillmap" | "lessons" | "lesson" | "question" | "askai" | "subscription"; type MainScreen = "home" | "topics" | "skillmap" | "lessons" | "lesson" | "question" | "askai" | "subscription" | "notifications" | "leaderboard";
interface Profile { interface Profile {
name: string; name: string;
age: number; age: number;
@@ -349,6 +349,19 @@ function mapMascotMood(server: string | undefined | null): SeyarMood {
return "idle"; return "idle";
} }
// ─── اعلان‌ها ─────────────────────────────────────────────────────────────────
interface NotificationItem { id: number; type: string; title: string; isRead: boolean }
const NOTIF_STYLE: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
xp_earned: { icon: Star, color: "#E8A400", bg: "#FFF8E1" },
lesson_completed: { icon: Check, color: "#5FBF8F", bg: "#EAF8F0" },
order_paid: { icon: Crown, color: "#9B7BFF", bg: "#F5F1FF" },
ticket_replied: { icon: MessageCircle, color: "#4C5FD5", bg: "#EEF1FF" },
family_link_accepted: { icon: Users, color: "#FF7A3D", bg: "#FFF1E6" },
weekly_report_ready: { icon: FileText, color: "#91ADC6", bg: "#EAF2F6" },
};
const notifStyle = (type: string) => NOTIF_STYLE[type] ?? { icon: Bell, color: "#9e9ec0", bg: "#f0eeff" };
// ─── Bottom Nav ─────────────────────────────────────────────────────────────── // ─── Bottom Nav ───────────────────────────────────────────────────────────────
function BottomNav({ active, onChange }: { active: MainScreen; onChange: (s: MainScreen) => void }) { function BottomNav({ active, onChange }: { active: MainScreen; onChange: (s: MainScreen) => void }) {
@@ -1115,12 +1128,14 @@ function HomeScreen({
profile, profile,
home, home,
summary, summary,
unreadCount,
onNavigate, onNavigate,
onContinue, onContinue,
}: { }: {
profile: Profile; profile: Profile;
home: HomeResponse | null; home: HomeResponse | null;
summary: { xp: number; coins: number; streak: { current: number; longest: number } } | null; summary: { xp: number; coins: number; streak: { current: number; longest: number } } | null;
unreadCount: number;
onNavigate: (s: MainScreen) => void; onNavigate: (s: MainScreen) => void;
onContinue: (unitId: string) => void; onContinue: (unitId: string) => void;
}) { }) {
@@ -1153,9 +1168,21 @@ function HomeScreen({
</span> </span>
)} )}
</div> </div>
<button className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<Bell size={17}/> <button onClick={() => onNavigate("leaderboard")}
className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center">
<Trophy size={17}/>
</button> </button>
<button onClick={() => onNavigate("notifications")}
className="relative w-10 h-10 rounded-full bg-white/15 flex items-center justify-center">
<Bell size={17}/>
{unreadCount > 0 && (
<span className="absolute -top-0.5 -left-0.5 min-w-[16px] h-4 px-1 rounded-full bg-[#FFD23F] text-[#1a1a2e] text-[9px] font-black flex items-center justify-center">
{unreadCount > 9 ? "۹+" : String(unreadCount).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)])}
</span>
)}
</button>
</div>
</div> </div>
</div> </div>
@@ -2409,6 +2436,212 @@ function AskAIScreen({ profile }: { profile: Profile }) {
); );
} }
// ─── Notifications Screen ──────────────────────────────────────────────────────
function NotificationsScreen({
items,
loading,
loadErr,
onBack,
onMarkRead,
onMarkAllRead,
}: {
items: NotificationItem[] | null;
loading: boolean;
loadErr: boolean;
onBack: () => void;
onMarkRead: (id: number) => void;
onMarkAllRead: () => void;
}) {
const unreadCount = items?.filter((n) => !n.isRead).length ?? 0;
return (
<div className="flex flex-col h-full bg-[#f0f4ff]" dir="rtl">
<div className="flex-shrink-0 bg-white px-5 pt-12 pb-4 border-b border-[#ede9ff]">
<div className="flex items-center gap-3">
<button onClick={onBack}
className="w-8 h-8 rounded-full bg-[#f0eeff] flex items-center justify-center flex-shrink-0">
<ChevronRight size={17} className="text-[#ED6956]" strokeWidth={2.5}/>
</button>
<h1 className="flex-1 font-black text-[#1a1a2e] text-lg">اعلان‌ها</h1>
{unreadCount > 0 && (
<button onClick={onMarkAllRead} className="text-xs text-[#4C5FD5] font-bold">
همه خوانده شد
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 py-20">
<SeyarMascot char="sigma" mood="idle" size={64}/>
<p className="text-sm text-[#9e9ec0]">در حال آوردنِ اعلان‌ها...</p>
</div>
) : loadErr ? (
<div className="flex flex-col items-center justify-center gap-3 py-20">
<SeyarMascot char="pi" mood="sad" size={64}/>
<p className="text-sm text-[#9e9ec0]">اعلان‌ها الان نیومدن، بعداً دوباره امتحان کن.</p>
</div>
) : !items || items.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-20">
<SeyarMascot char="pi" mood="idle" size={64}/>
<p className="text-sm text-[#9e9ec0]">فعلاً خبری نیست</p>
</div>
) : (
<div className="flex flex-col gap-2">
{items.map((n) => {
const style = notifStyle(n.type);
const Icon = style.icon;
return (
<button key={n.id} onClick={() => !n.isRead && onMarkRead(n.id)}
className={`flex items-center gap-3 p-3.5 rounded-2xl text-right transition-colors ${
n.isRead ? "bg-white border border-[#ede9ff]" : "bg-[#EEF1FF] border border-[#4C5FD5]/20"
}`}>
<div className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0" style={{ backgroundColor: style.bg }}>
<Icon size={18} style={{ color: style.color }}/>
</div>
<p className={`flex-1 text-sm ${n.isRead ? "text-[#9e9ec0]" : "text-[#1a1a2e] font-bold"}`}>{n.title}</p>
{!n.isRead && <span className="w-2 h-2 rounded-full bg-[#ED6956] flex-shrink-0"/>}
</button>
);
})}
</div>
)}
</div>
</div>
);
}
// ─── Leaderboard Screen ─────────────────────────────────────────────────────────
// توجه: کلاینتِ api.ts پارامترِ period رو "weekly" | "monthly" تایپ کرده، ولی
// بک‌اندِ واقعی فقط "weekly" | "all_time" رو قبول می‌کنه (سریالایزرِ خودشون رو
// دیدم). این‌جا از مقادیرِ واقعیِ بک‌اند استفاده می‌کنیم.
interface LeaderboardEntry { rank: number; userId: string; name: string; xpInPeriod: number }
type LeaderboardPeriod = "weekly" | "all_time";
const RANK_MEDAL: Record<number, { bg: string; fg: string }> = {
1: { bg: "#FFD23F", fg: "#8a6d00" },
2: { bg: "#D9DEE8", fg: "#5a6270" },
3: { bg: "#E8B27A", fg: "#7a4a1a" },
};
function LeaderboardScreen({
entries,
period,
loading,
loadErr,
myUserId,
onChangePeriod,
onBack,
}: {
entries: LeaderboardEntry[] | null;
period: LeaderboardPeriod;
loading: boolean;
loadErr: boolean;
myUserId?: string;
onChangePeriod: (p: LeaderboardPeriod) => void;
onBack: () => void;
}) {
const toFa = (n: number) => String(n).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)]);
const top3 = entries?.slice(0, 3) ?? [];
const rest = entries?.slice(3) ?? [];
return (
<div className="flex flex-col h-full bg-[#f0f4ff]" dir="rtl">
{/* Header */}
<div className="flex-shrink-0 bg-[#FF7A3D] px-5 pt-12 pb-6 text-white rounded-b-[32px] relative overflow-hidden">
<div className="pointer-events-none absolute -left-10 -top-10 w-40 h-40 rounded-full bg-white/10"/>
<div className="relative z-10">
<div className="flex items-center gap-3 mb-3">
<button onClick={onBack} className="w-8 h-8 rounded-full bg-white/15 flex items-center justify-center flex-shrink-0">
<ChevronRight size={17} strokeWidth={2.5}/>
</button>
<h1 className="flex-1 font-black text-lg">جدولِ امتیازها</h1>
<SeyarMascot char="delta" mood={loading ? "idle" : "excited"} size={54}/>
</div>
{/* Period toggle */}
<div className="flex bg-white/15 rounded-2xl p-1 gap-1">
{([["weekly", "این هفته"], ["all_time", "کل زمان"]] as [LeaderboardPeriod, string][]).map(([p, label]) => (
<button key={p} onClick={() => onChangePeriod(p)}
className={`flex-1 py-2 rounded-xl text-xs font-bold transition-colors ${
period === p ? "bg-white text-[#FF7A3D]" : "text-white/80"
}`}>
{label}
</button>
))}
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 py-20">
<SeyarMascot char="delta" mood="idle" size={64}/>
<p className="text-sm text-[#9e9ec0]">در حال آوردنِ جدول...</p>
</div>
) : loadErr ? (
<div className="flex flex-col items-center justify-center gap-3 py-20">
<SeyarMascot char="pi" mood="sad" size={64}/>
<p className="text-sm text-[#9e9ec0]">جدول الان نیومد، بعداً دوباره امتحان کن.</p>
</div>
) : !entries || entries.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-20">
<SeyarMascot char="pi" mood="idle" size={64}/>
<p className="text-sm text-[#9e9ec0]">هنوز کسی تو این جدول نیست</p>
</div>
) : (
<>
{/* سکوی سه نفرِ برتر */}
{top3.length > 0 && (
<div className="flex items-end justify-center gap-2.5 mb-6 pt-4">
{[top3[1], top3[0], top3[2]].filter(Boolean).map((e) => {
const isMe = e.userId === myUserId;
const medal = RANK_MEDAL[e.rank];
const podiumH = e.rank === 1 ? 92 : e.rank === 2 ? 72 : 56;
return (
<div key={e.userId} className="flex flex-col items-center gap-1.5" style={{ width: 96 }}>
<div className={`w-14 h-14 rounded-full flex items-center justify-center font-black text-lg border-2 ${isMe ? "border-[#ED6956]" : "border-white"}`}
style={{ backgroundColor: medal.bg, color: medal.fg }}>
{toFa(e.rank)}
</div>
<p className={`text-xs font-bold text-center truncate w-full ${isMe ? "text-[#ED6956]" : "text-[#1a1a2e]"}`}>{isMe ? "تو" : e.name}</p>
<p className="text-[10px] text-[#9e9ec0]">{toFa(e.xpInPeriod)} XP</p>
<div className="w-full rounded-t-xl mt-1" style={{ height: podiumH, backgroundColor: medal.bg, opacity: 0.35 }}/>
</div>
);
})}
</div>
)}
{/* بقیه‌ی رتبه‌ها */}
<div className="flex flex-col gap-2">
{rest.map((e) => {
const isMe = e.userId === myUserId;
return (
<div key={e.userId}
className={`flex items-center gap-3 p-3 rounded-2xl ${isMe ? "bg-[#FFF1E6] border-2 border-[#FF7A3D]/40" : "bg-white border border-[#ede9ff]"}`}>
<span className="w-7 text-center font-black text-[#9e9ec0] text-sm flex-shrink-0">{toFa(e.rank)}</span>
<div className="w-9 h-9 rounded-full bg-[#f0eeff] flex items-center justify-center flex-shrink-0 font-bold text-[#9e9ec0] text-xs">
{e.name ? e.name[0] : "؟"}
</div>
<p className={`flex-1 text-sm truncate ${isMe ? "text-[#B34E1C] font-bold" : "text-[#1a1a2e]"}`}>{isMe ? "تو" : e.name}</p>
<span className="text-xs text-[#9e9ec0] font-bold flex-shrink-0">{toFa(e.xpInPeriod)} XP</span>
</div>
);
})}
</div>
</>
)}
</div>
</div>
);
}
// ─── Subscription ───────────────────────────────────────────────────────────── // ─── Subscription ─────────────────────────────────────────────────────────────
const PLAN_STYLES = [ const PLAN_STYLES = [
@@ -2523,6 +2756,10 @@ export default function App() {
const [lessonId, setLessonId] = useState<number>(1); const [lessonId, setLessonId] = useState<number>(1);
const [home, setHome] = useState<HomeResponse | null>(null); const [home, setHome] = useState<HomeResponse | null>(null);
const [summary, setSummary] = useState<{ xp: number; coins: number; streak: { current: number; longest: number } } | null>(null); const [summary, setSummary] = useState<{ xp: number; coins: number; streak: { current: number; longest: number } } | null>(null);
const [notifications, setNotifications] = useState<NotificationItem[] | null>(null);
const [notifLoading, setNotifLoading] = useState(false);
const [notifErr, setNotifErr] = useState(false);
const unreadCount = notifications?.filter((n) => !n.isRead).length ?? 0;
const refreshHome = () => { const refreshHome = () => {
api.home().then((h) => { api.home().then((h) => {
@@ -2532,10 +2769,47 @@ export default function App() {
api.summary().then((s: any) => setSummary(s)).catch(() => {}); api.summary().then((s: any) => setSummary(s)).catch(() => {});
}; };
const refreshNotifications = () => {
setNotifLoading(true);
setNotifErr(false);
api.notifications.list()
.then((r) => setNotifications(r.items as unknown as NotificationItem[]))
.catch(() => setNotifErr(true))
.finally(() => setNotifLoading(false));
};
const markNotifRead = (id: number) => {
setNotifications((list) => list?.map((n) => (n.id === id ? { ...n, isRead: true } : n)) ?? list);
api.notifications.markRead(id).catch(() => {});
};
const markAllNotifRead = () => {
const unread = notifications?.filter((n) => !n.isRead) ?? [];
setNotifications((list) => list?.map((n) => ({ ...n, isRead: true })) ?? list);
unread.forEach((n) => api.notifications.markRead(n.id).catch(() => {}));
};
const [lbEntries, setLbEntries] = useState<LeaderboardEntry[] | null>(null);
const [lbPeriod, setLbPeriod] = useState<LeaderboardPeriod>("weekly");
const [lbLoading, setLbLoading] = useState(false);
const [lbErr, setLbErr] = useState(false);
const refreshLeaderboard = (period: LeaderboardPeriod) => {
setLbLoading(true);
setLbErr(false);
api.leaderboard(period as any)
.then((r) => setLbEntries(r.entries as unknown as LeaderboardEntry[]))
.catch(() => setLbErr(true))
.finally(() => setLbLoading(false));
};
useEffect(() => { useEffect(() => {
if (flow === "main") refreshHome(); if (flow === "main") { refreshHome(); refreshNotifications(); }
}, [flow]); }, [flow]);
useEffect(() => {
if (flow === "main" && screen === "leaderboard" && !lbEntries) refreshLeaderboard(lbPeriod);
}, [flow, screen]);
// ─── تاریخچه‌ی صفحات، برای دکمه‌ی «بازگشت» ──────────────────────────────── // ─── تاریخچه‌ی صفحات، برای دکمه‌ی «بازگشت» ────────────────────────────────
// navigate: رفتن به جلو (صفحه‌ی فعلی رو تو تاریخچه نگه می‌داره) // navigate: رفتن به جلو (صفحه‌ی فعلی رو تو تاریخچه نگه می‌داره)
// goBack: برگشت به صفحه‌ی قبلی (یا خانه، اگه چیزی تو تاریخچه نبود) // goBack: برگشت به صفحه‌ی قبلی (یا خانه، اگه چیزی تو تاریخچه نبود)
@@ -2638,7 +2912,9 @@ export default function App() {
{flow === "main" && screen === "askai" && <AskAIScreen profile={profile!}/>} {flow === "main" && screen === "askai" && <AskAIScreen profile={profile!}/>}
{flow === "main" && screen === "subscription" && <SubscriptionScreen/>} {flow === "main" && screen === "subscription" && <SubscriptionScreen/>}
{flow === "login" && <LoginScreen onDone={handleLoginDone}/>} {flow === "login" && <LoginScreen onDone={handleLoginDone}/>}
{flow === "main" && screen === "home" && <HomeScreen profile={profile!} home={home} summary={summary} onNavigate={navigate} onContinue={(id) => { setUnitId(id); navigate("skillmap"); }}/>} {flow === "main" && screen === "home" && <HomeScreen profile={profile!} home={home} summary={summary} unreadCount={unreadCount} onNavigate={navigate} onContinue={(id) => { setUnitId(id); navigate("skillmap"); }}/>}
{flow === "main" && screen === "notifications" && <NotificationsScreen items={notifications} loading={notifLoading} loadErr={notifErr} onBack={goBack} onMarkRead={markNotifRead} onMarkAllRead={markAllNotifRead}/>}
{flow === "main" && screen === "leaderboard" && <LeaderboardScreen entries={lbEntries} period={lbPeriod} loading={lbLoading} loadErr={lbErr} myUserId={auth?.userId} onChangePeriod={(p) => { setLbPeriod(p); refreshLeaderboard(p); }} onBack={goBack}/>}
{flow === "main" && screen === "topics" && <TopicSelectScreen home={home} onSelect={(id) => { setUnitId(id); navigate("skillmap"); }}/>} {flow === "main" && screen === "topics" && <TopicSelectScreen home={home} onSelect={(id) => { setUnitId(id); navigate("skillmap"); }}/>}
{flow === "main" && screen === "lessons" && <LessonsHubScreen home={home} onOpenUnit={(id) => { setUnitId(id); navigate("skillmap"); }} onNavigate={navigate}/>} {flow === "main" && screen === "lessons" && <LessonsHubScreen home={home} onOpenUnit={(id) => { setUnitId(id); navigate("skillmap"); }} onNavigate={navigate}/>}
{flow === "main" && screen === "skillmap" && <SkillMapScreen unitId={unitId} home={home} onOpenLesson={(id) => { setLessonId(id); navigate("lesson"); }} onBack={goBack}/>} {flow === "main" && screen === "skillmap" && <SkillMapScreen unitId={unitId} home={home} onOpenLesson={(id) => { setLessonId(id); navigate("lesson"); }} onBack={goBack}/>}