diff --git a/src/app/App.tsx b/src/app/App.tsx index d6f8953..cc21679 100644 --- a/src/app/App.tsx +++ b/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 = { + 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({ )} - +
+ + +
@@ -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 ( +
+
+
+ +

اعلان‌ها

+ {unreadCount > 0 && ( + + )} +
+
+ +
+ {loading ? ( +
+ +

در حال آوردنِ اعلان‌ها...

+
+ ) : loadErr ? ( +
+ +

اعلان‌ها الان نیومدن، بعداً دوباره امتحان کن.

+
+ ) : !items || items.length === 0 ? ( +
+ +

فعلاً خبری نیست

+
+ ) : ( +
+ {items.map((n) => { + const style = notifStyle(n.type); + const Icon = style.icon; + return ( + + ); + })} +
+ )} +
+
+ ); +} + +// ─── 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 = { + 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 ( +
+ {/* Header */} +
+
+
+
+ +

جدولِ امتیازها

+ +
+ + {/* Period toggle */} +
+ {([["weekly", "این هفته"], ["all_time", "کل زمان"]] as [LeaderboardPeriod, string][]).map(([p, label]) => ( + + ))} +
+
+
+ +
+ {loading ? ( +
+ +

در حال آوردنِ جدول...

+
+ ) : loadErr ? ( +
+ +

جدول الان نیومد، بعداً دوباره امتحان کن.

+
+ ) : !entries || entries.length === 0 ? ( +
+ +

هنوز کسی تو این جدول نیست

+
+ ) : ( + <> + {/* سکوی سه نفرِ برتر */} + {top3.length > 0 && ( +
+ {[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 ( +
+
+ {toFa(e.rank)} +
+

{isMe ? "تو" : e.name}

+

{toFa(e.xpInPeriod)} XP

+
+
+ ); + })} +
+ )} + + {/* بقیه‌ی رتبه‌ها */} +
+ {rest.map((e) => { + const isMe = e.userId === myUserId; + return ( +
+ {toFa(e.rank)} +
+ {e.name ? e.name[0] : "؟"} +
+

{isMe ? "تو" : e.name}

+ {toFa(e.xpInPeriod)} XP +
+ ); + })} +
+ + )} +
+
+ ); +} + + + // ─── Subscription ───────────────────────────────────────────────────────────── const PLAN_STYLES = [ @@ -2523,6 +2756,10 @@ export default function App() { const [lessonId, setLessonId] = useState(1); const [home, setHome] = useState(null); const [summary, setSummary] = useState<{ xp: number; coins: number; streak: { current: number; longest: number } } | null>(null); + const [notifications, setNotifications] = useState(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(null); + const [lbPeriod, setLbPeriod] = useState("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" && } {flow === "main" && screen === "subscription" && } {flow === "login" && } - {flow === "main" && screen === "home" && { setUnitId(id); navigate("skillmap"); }}/>} + {flow === "main" && screen === "home" && { setUnitId(id); navigate("skillmap"); }}/>} + {flow === "main" && screen === "notifications" && } + {flow === "main" && screen === "leaderboard" && { setLbPeriod(p); refreshLeaderboard(p); }} onBack={goBack}/>} {flow === "main" && screen === "topics" && { setUnitId(id); navigate("skillmap"); }}/>} {flow === "main" && screen === "lessons" && { setUnitId(id); navigate("skillmap"); }} onNavigate={navigate}/>} {flow === "main" && screen === "skillmap" && { setLessonId(id); navigate("lesson"); }} onBack={goBack}/>} @@ -2650,4 +2926,4 @@ export default function App() {
); -} +} \ No newline at end of file