forked from Tadrisino/MathApp_frontend
Leaderboard added
This commit is contained in:
292
src/app/App.tsx
292
src/app/App.tsx
@@ -6,7 +6,7 @@ import {
|
||||
Lock, Sparkles, MessageCircle, Check, X, Mic, Send,
|
||||
ChevronLeft, ChevronRight, Zap, ChevronDown, AlertCircle,
|
||||
Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat,
|
||||
Flame, Star, Trophy, Heart,
|
||||
Flame, Star, Trophy, Heart, Users, FileText,
|
||||
} from "lucide-react";
|
||||
import { createClient, ApiError } from "../api";
|
||||
import type {
|
||||
@@ -300,7 +300,7 @@ function SeyarMascot({ char, mood = "idle", size = 100, bob = false }: { char: S
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
name: string;
|
||||
age: number;
|
||||
@@ -349,6 +349,19 @@ function mapMascotMood(server: string | undefined | null): SeyarMood {
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function BottomNav({ active, onChange }: { active: MainScreen; onChange: (s: MainScreen) => void }) {
|
||||
@@ -1115,12 +1128,14 @@ function HomeScreen({
|
||||
profile,
|
||||
home,
|
||||
summary,
|
||||
unreadCount,
|
||||
onNavigate,
|
||||
onContinue,
|
||||
}: {
|
||||
profile: Profile;
|
||||
home: HomeResponse | null;
|
||||
summary: { xp: number; coins: number; streak: { current: number; longest: number } } | null;
|
||||
unreadCount: number;
|
||||
onNavigate: (s: MainScreen) => void;
|
||||
onContinue: (unitId: string) => void;
|
||||
}) {
|
||||
@@ -1153,9 +1168,21 @@ function HomeScreen({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center flex-shrink-0">
|
||||
<Bell size={17}/>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button onClick={() => onNavigate("leaderboard")}
|
||||
className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center">
|
||||
<Trophy size={17}/>
|
||||
</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>
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
const PLAN_STYLES = [
|
||||
@@ -2523,6 +2756,10 @@ export default function App() {
|
||||
const [lessonId, setLessonId] = useState<number>(1);
|
||||
const [home, setHome] = useState<HomeResponse | 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 = () => {
|
||||
api.home().then((h) => {
|
||||
@@ -2532,10 +2769,47 @@ export default function App() {
|
||||
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(() => {
|
||||
if (flow === "main") refreshHome();
|
||||
if (flow === "main") { refreshHome(); refreshNotifications(); }
|
||||
}, [flow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (flow === "main" && screen === "leaderboard" && !lbEntries) refreshLeaderboard(lbPeriod);
|
||||
}, [flow, screen]);
|
||||
|
||||
// ─── تاریخچهی صفحات، برای دکمهی «بازگشت» ────────────────────────────────
|
||||
// navigate: رفتن به جلو (صفحهی فعلی رو تو تاریخچه نگه میداره)
|
||||
// goBack: برگشت به صفحهی قبلی (یا خانه، اگه چیزی تو تاریخچه نبود)
|
||||
@@ -2638,7 +2912,9 @@ export default function App() {
|
||||
{flow === "main" && screen === "askai" && <AskAIScreen profile={profile!}/>}
|
||||
{flow === "main" && screen === "subscription" && <SubscriptionScreen/>}
|
||||
{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 === "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}/>}
|
||||
@@ -2650,4 +2926,4 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user