2 Commits

Author SHA1 Message Date
267cf5fca0 fixed 2026-09-24 12:30:58 +03:30
15d110d824 Leaderboard added 2026-09-21 14:55:28 +03:30
3 changed files with 444 additions and 20 deletions

View File

@@ -1,11 +0,0 @@
# این فایل فقط نمونه است. اگر بک‌اندِ محلی دارید، آن را به .env کپی کنید:
# cp .env.example .env
#
# اگر هیچ کاری نکنید هم اپ کار می‌کند و به بک‌اندِ مشترک وصل می‌شود:
# https://api.math.tadrisino2.ir/api
#
# فقط وقتی بک‌اند را روی ماشینِ خودتان بالا آورده‌اید این را باز کنید:
# VITE_API_BASE_URL=http://127.0.0.1:8000/api
# هیچ کلید یا رمزی اینجا نگذارید: هر چیزی که با VITE_ شروع شود داخلِ باندلِ
# نهایی می‌رود و کاربر می‌تواند ببیندش.

View File

@@ -117,6 +117,16 @@ export interface Hearts {
isUnlimited: boolean; isUnlimited: boolean;
} }
export interface Character {
characterId: string;
slug: string;
name: string;
icon: string;
unlockXp: number;
unlocked: boolean;
selected: boolean;
}
export interface AskResponse { export interface AskResponse {
conversationId: string; conversationId: string;
messageId: string; messageId: string;
@@ -299,9 +309,16 @@ export function createClient(options: ClientOptions = {}) {
raw<{ currentStreak: number; longestStreak: number; activeDays: string[] }>( raw<{ currentStreak: number; longestStreak: number; activeDays: string[] }>(
'GET', '/user/streak/calendar', 'GET', '/user/streak/calendar',
), ),
leaderboard: (period: 'weekly' | 'monthly' = 'weekly') => leaderboard: (period: 'weekly' | 'all_time' = 'weekly') =>
raw<{ period: string; entries: unknown[] }>('GET', `/leaderboard?period=${period}`), raw<{ period: string; entries: unknown[] }>('GET', `/leaderboard?period=${period}`),
/** کاتالوگِ کاراکترهای گیمیفیکیشن — مثلِ ماسکات‌های Duolingo، بازشدن بر اساسِ XP. */
characters: {
list: () => raw<{ characters: Character[] }>('GET', '/characters').then((r) => r.characters),
select: (characterId: string) =>
raw<{ characterId: string; selected: boolean }>('POST', '/characters/select', { characterId }),
},
/* ── صفحه‌ی درس و سوال ─────────────────────────────────────────────── */ /* ── صفحه‌ی درس و سوال ─────────────────────────────────────────────── */
lesson: { lesson: {
intro: (lessonId: number) => raw<LessonIntro>('GET', `/lessons/${lessonId}/intro`), intro: (lessonId: number) => raw<LessonIntro>('GET', `/lessons/${lessonId}/intro`),

View File

@@ -6,17 +6,18 @@ 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 {
Grade as ApiGrade, HomeResponse, HomeUnit, HomeNode, NodeStatus, Grade as ApiGrade, HomeResponse, HomeUnit, HomeNode, NodeStatus,
LessonIntro, Question as ApiQuestion, AnswerResult, Plan as ApiPlan, LessonIntro, Question as ApiQuestion, AnswerResult, Plan as ApiPlan,
Character as ApiCharacter,
} from "../api"; } from "../api";
// ─── کلاینتِ API — تنها جایی که با بک‌اند حرف می‌زنیم ───────────────────────── // ─── کلاینتِ API — تنها جایی که با بک‌اند حرف می‌زنیم ─────────────────────────
const api = createClient({ const api = createClient({
baseUrl: (import.meta as { env?: Record<string, string> }).env?.VITE_API_URL ?? "/api", baseUrl: (import.meta as { env?: Record<string, string> }).env?.VITE_API_BASE_URL ?? "https://api.math.tadrisino2.ir/api",
}); });
function getOrCreateDeviceId(): string { function getOrCreateDeviceId(): string {
@@ -282,6 +283,16 @@ const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" }, pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
}; };
/** اگه slugِ کاراکترِ سرور با یکی از سه‌یار یکی باشه، از طرحِ خودمون (انیمیشن‌دار) استفاده می‌کنیم؛
* وگرنه یه کارتِ عمومی نشون می‌دیم (پیشنهاد: به بک‌اند بگید slugها رو sigma/delta/pi بذاره). */
function seyarSlugMatch(slug: string): SeyarChar | null {
const s = slug.toLowerCase();
if (s.includes("sigma") || s.includes("سیگما")) return "sigma";
if (s.includes("delta") || s.includes("دلتا")) return "delta";
if (s.includes("pi") || s.includes("پی")) return "pi";
return null;
}
function SeyarMascot({ char, mood = "idle", size = 100, bob = false }: { char: SeyarChar; mood?: SeyarMood; size?: number; bob?: boolean }) { function SeyarMascot({ char, mood = "idle", size = 100, bob = false }: { char: SeyarChar; mood?: SeyarMood; size?: number; bob?: boolean }) {
return ( return (
<motion.div <motion.div
@@ -300,7 +311,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" | "characters";
interface Profile { interface Profile {
name: string; name: string;
age: number; age: number;
@@ -349,6 +360,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 +1139,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 +1179,25 @@ 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("characters")}
</button> className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center">
<Sparkles size={17}/>
</button>
<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>
</div> </div>
@@ -2409,6 +2451,312 @@ 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>
);
}
// ─── Characters Screen (کاتالوگِ گیمیفیکیشنِ بک‌اند) ─────────────────────────
// کاراکترها رو خودِ بک‌اند مدیریت می‌کنه (مثلِ subscription.Plan)، پس تعدادشون
// می‌تونه هر چقدر باشه. اگه slug با سیگما/دلتا/پی جور دربیاد، طرحِ سه‌یارِ
// خودمون (انیمیشن‌دار) نشون داده می‌شه؛ وگرنه یه کارتِ عمومی.
function CharactersScreen({
characters,
loading,
loadErr,
selecting,
onSelect,
onBack,
}: {
characters: ApiCharacter[] | null;
loading: boolean;
loadErr: boolean;
selecting: string | null;
onSelect: (characterId: string) => void;
onBack: () => void;
}) {
const toFa = (n: number) => String(n).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)]);
return (
<div className="flex flex-col h-full bg-[#f0f4ff]" dir="rtl">
<div className="flex-shrink-0 bg-[#9B7BFF] 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 flex items-center gap-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>
<div className="flex-1">
<h1 className="font-black text-lg">همراهت رو انتخاب کن</h1>
<p className="text-white/70 text-xs mt-0.5">با کسبِ امتیاز، کاراکترهای بیشتری باز می‌شن</p>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-5">
{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>
) : !characters || characters.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="grid grid-cols-2 gap-3">
{characters.map((c) => {
const seyar = seyarSlugMatch(c.slug);
const isBusy = selecting === c.characterId;
return (
<button key={c.characterId}
disabled={!c.unlocked || isBusy}
onClick={() => onSelect(c.characterId)}
className={`relative rounded-3xl p-4 flex flex-col items-center gap-2 border-2 transition-all ${
c.selected ? "bg-[#F5F1FF] border-[#9B7BFF]" :
c.unlocked ? "bg-white border-[#ede9ff] active:scale-95" :
"bg-[#f4f3fa] border-[#ede9ff] opacity-60"
}`}>
{c.selected && (
<span className="absolute top-2 left-2 w-5 h-5 rounded-full bg-[#9B7BFF] flex items-center justify-center">
<Check size={11} className="text-white" strokeWidth={3}/>
</span>
)}
<div className="relative">
{seyar ? (
<SeyarMascot char={seyar} mood={c.unlocked ? "happy" : "idle"} size={80}/>
) : (
<div className="w-20 h-20 rounded-full bg-[#EEF1FF] flex items-center justify-center">
<Sparkles size={28} className="text-[#9B7BFF]"/>
</div>
)}
{!c.unlocked && (
<div className="absolute inset-0 flex items-center justify-center bg-white/40 rounded-full backdrop-blur-[1px]">
<Lock size={22} className="text-[#5a5580]"/>
</div>
)}
</div>
<p className="font-bold text-[#1a1a2e] text-sm">{c.name}</p>
<p className="text-[10px] text-[#9e9ec0]">
{c.unlocked ? (c.selected ? "انتخاب‌شده" : "بازشده") : `با ${toFa(c.unlockXp)} امتیاز باز می‌شه`}
</p>
</button>
);
})}
</div>
)}
</div>
</div>
);
}
// ─── Subscription ───────────────────────────────────────────────────────────── // ─── Subscription ─────────────────────────────────────────────────────────────
const PLAN_STYLES = [ const PLAN_STYLES = [
@@ -2523,6 +2871,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 +2884,73 @@ 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)
.then((r) => setLbEntries(r.entries as unknown as LeaderboardEntry[]))
.catch(() => setLbErr(true))
.finally(() => setLbLoading(false));
};
const [characters, setCharacters] = useState<ApiCharacter[] | null>(null);
const [charLoading, setCharLoading] = useState(false);
const [charErr, setCharErr] = useState(false);
const [charSelecting, setCharSelecting] = useState<string | null>(null);
const refreshCharacters = () => {
setCharLoading(true);
setCharErr(false);
api.characters.list()
.then(setCharacters)
.catch(() => setCharErr(true))
.finally(() => setCharLoading(false));
};
const selectCharacter = (characterId: string) => {
setCharSelecting(characterId);
api.characters.select(characterId)
.then(() => setCharacters((list) => list?.map((c) => ({ ...c, selected: c.characterId === characterId })) ?? list))
.catch(() => {})
.finally(() => setCharSelecting(null));
};
useEffect(() => { useEffect(() => {
if (flow === "main") refreshHome(); if (flow === "main") { refreshHome(); refreshNotifications(); }
}, [flow]); }, [flow]);
useEffect(() => {
if (flow === "main" && screen === "leaderboard" && !lbEntries) refreshLeaderboard(lbPeriod);
}, [flow, screen]);
useEffect(() => {
if (flow === "main" && screen === "characters" && !characters) refreshCharacters();
}, [flow, screen]);
// ─── تاریخچه‌ی صفحات، برای دکمه‌ی «بازگشت» ──────────────────────────────── // ─── تاریخچه‌ی صفحات، برای دکمه‌ی «بازگشت» ────────────────────────────────
// navigate: رفتن به جلو (صفحه‌ی فعلی رو تو تاریخچه نگه می‌داره) // navigate: رفتن به جلو (صفحه‌ی فعلی رو تو تاریخچه نگه می‌داره)
// goBack: برگشت به صفحه‌ی قبلی (یا خانه، اگه چیزی تو تاریخچه نبود) // goBack: برگشت به صفحه‌ی قبلی (یا خانه، اگه چیزی تو تاریخچه نبود)
@@ -2638,7 +3053,10 @@ 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 === "characters" && <CharactersScreen characters={characters} loading={charLoading} loadErr={charErr} selecting={charSelecting} onSelect={selectCharacter} 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}/>}