diff --git a/src/api/index.ts b/src/api/index.ts index feb51a2..1125131 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -81,8 +81,11 @@ export const api = { /** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */ weeklyReport: () => http.get('/user/weekly-report'), - /** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */ - home: () => http.get('/home'), + /** + * بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. بدونِ subject اولین کتابِ پایه؛ + * کتابِ ناموجود در پایه ۴۰۰ می‌دهد (برای کلیدِ ذخیره‌شده‌ی قدیمی از `fetchHome` استفاده کنید). + */ + home: (subject?: string) => http.get(`/home${q({ subject })}`), nodeLessons: (nodeId: string) => http.get(`/nodes/${seg(nodeId)}/lessons`), /** @@ -136,7 +139,11 @@ export const api = { * `questionId` به دستیار می‌گوید دانش‌آموز کدام سوال را غلط زده. */ async ask( - input: { userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string }, + input: { + userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string; + /** نامِ کتاب («هندسه»)؛ برای پرسشِ بی‌درس تا دستیار از کتابِ همان انتخابِ کاربر جواب بدهد. */ + subject?: string; + }, onDelta: (text: string) => void, signal?: AbortSignal, ): Promise { diff --git a/src/api/types.ts b/src/api/types.ts index bf3cacd..3927e4a 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -82,7 +82,22 @@ export interface HomeStats { coins: number; } +/** یک کتابِ ریاضیِ پایه (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته). */ +export interface Subject { + /** همان که به `api.home(subject)` داده می‌شود. */ + key: string; + title: string; + /** نامِ آیکنِ lucide. */ + icon: string; + color: string; + order: number; +} + export interface HomeResponse { + /** کتابی که `units` از آن است؛ null فقط وقتی پایه فصلی ندارد. */ + subject: Subject | null; + /** همه‌ی کتاب‌های این پایه؛ نوارِ کتاب‌ها فقط با بیش از یکی نشان داده می‌شود. */ + subjects: Subject[]; units: HomeUnit[]; stats: HomeStats; } diff --git a/src/app/App.tsx b/src/app/App.tsx index 31cf398..844268f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,6 +7,7 @@ import { ToastProvider } from '@/components/Toast'; import { ErrorState } from '@/components/ui'; import { KEYS, store } from '@/lib/storage'; import { askStore } from '@/state/askStore'; +import { fetchHome, subjectStore } from '@/state/subject'; import { clearAsyncCache } from '@/lib/useAsync'; import { LoginScreen } from '@/screens/LoginScreen'; import { OnboardingScreen } from '@/screens/OnboardingScreen'; @@ -34,7 +35,8 @@ export function App() { const enter = useCallback(async () => { // نقشه و آمار هم‌زمان با پروفایل گرفته می‌شوند تا خانه کامل بیاید، نه اول «دارم مسیرت رو میارم» و آمارِ صفر. // شکستشان ورود را نمی‌خواباند: AppData همان‌ها را دوباره می‌گیرد و خطای نقشه را روی خانه نشان می‌دهد. - const [profile, grades, prefetch] = await Promise.all([api.profile.get(), api.grades(), prefetchMain()]); + const profileReq = api.profile.get(); + const [profile, grades, prefetch] = await Promise.all([profileReq, api.grades(), prefetchMain(profileReq)]); await trackReferral(); if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades, prefetch }); else setFlow({ kind: 'setup', profile, grades }); @@ -147,8 +149,11 @@ export function App() { } /** داده‌ی صفحه‌ی اول؛ هر کدام که نرسید null می‌ماند و AppData خودش دوباره می‌گیرد. */ -async function prefetchMain(): Promise { - const [home, summary] = await Promise.allSettled([api.home(), api.summary()]); +async function prefetchMain(profile: Promise): Promise { + // کتابِ انتخاب‌شده به پایه بسته است؛ فقط وقتی چیزی ذخیره شده منتظرِ پروفایل می‌مانیم (پایه‌های ≤ دهم + // معمولاً هیچ انتخابی ندارند و نقشه هم‌زمان با پروفایل می‌آید). + const gradeId = Object.keys(subjectStore.snapshot()).length ? (await profile).gradeId : null; + const [home, summary] = await Promise.allSettled([fetchHome(gradeId), api.summary()]); return { home: home.status === 'fulfilled' ? home.value : null, summary: summary.status === 'fulfilled' ? summary.value : null, diff --git a/src/components/NodeIcon.tsx b/src/components/NodeIcon.tsx index 2e7744f..d11a669 100644 --- a/src/components/NodeIcon.tsx +++ b/src/components/NodeIcon.tsx @@ -1,5 +1,5 @@ import { - BookOpen, Brain, Compass, Grid3x3, LineChart, Play, Repeat, Sigma, Sparkles, TrendingUp, + BarChart3, BookOpen, Brain, Compass, Grid3x3, LineChart, Network, Play, Repeat, Sigma, Sparkles, TrendingUp, type LucideIcon, } from 'lucide-react'; @@ -8,7 +8,7 @@ import { * مجموعه‌ی کوچک در باندل است تا کلِ کتابخانه‌ی آیکن بار نشود. */ const ICONS: Record = { - Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain, Play, + Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain, Play, BarChart3, Network, }; const FALLBACK: LucideIcon[] = [Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain]; diff --git a/src/components/SubjectBar.tsx b/src/components/SubjectBar.tsx new file mode 100644 index 0000000..0ea267e --- /dev/null +++ b/src/components/SubjectBar.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef } from 'react'; +import { useAppData } from '@/state/AppData'; +import { onWhite } from '@/lib/color'; +import { iconFor } from '@/components/NodeIcon'; + +/** + * نوارِ نازکِ کتاب‌ها (ریاضی / هندسه / حسابان / آمار و احتمال / گسسته) زیرِ سربرگِ «آزمون‌ها» و «درس‌ها». + * انتخاب در AppData نگه داشته می‌شود و هر دو تب از همان `home` می‌خوانند، پس جابه‌جایی در یکی در دیگری + * هم دیده می‌شود. فقط وقتی پایه بیش از یک کتاب دارد نشان داده می‌شود؛ پایه‌های دیگر هیچ تغییری نمی‌بینند. + * + * دکمه‌ها عرضِ نوار را پر می‌کنند (پایه‌ی ۱۰ دو کتاب، ۱۱ و ۱۲ سه کتاب دارند)؛ اگر جا کم بود اسکرولِ افقی می‌شود. + * + * بیرونِ ناحیه‌ی اسکرول است تا با پایین‌رفتنِ نقشه گم نشود و با سربرگِ چسبانِ فصل‌ها هم‌پوشانی نکند. + */ +export function SubjectBar() { + const { home, subjectKey, selectSubject } = useAppData(); + const subjects = home?.subjects ?? []; + const activeRef = useRef(null); + + useEffect(() => { + // کتابِ فعال در دید بماند وقتی پنج دکمه از عرضِ گوشی بیشتر است. + activeRef.current?.scrollIntoView({ inline: 'center', block: 'nearest' }); + }, [subjectKey]); + + if (subjects.length < 2) return null; + + return ( + + ); +} diff --git a/src/lib/MathText.tsx b/src/lib/MathText.tsx index ae38505..022a534 100644 --- a/src/lib/MathText.tsx +++ b/src/lib/MathText.tsx @@ -18,8 +18,12 @@ import { Fragment } from 'react'; */ // حروف، اِعراب (کسره‌ی «خودِ»)، و نیم‌فاصله؛ رقم‌های ۰–۹ عمداً بیرون‌اند. const LETTER = '\\u0620-\\u065F\\u066E-\\u06D3\\u06FA-\\u06FF\\u200C'; -// تکه‌ای بدونِ حرفِ فارسی و بدونِ خطِ تازه که دستِ‌کم یک رقم دارد -const RUN = new RegExp(`[^${LETTER}\\n]*[0-9۰-۹][^${LETTER}\\n]*`, 'g'); +// «نویسه‌ی ریاضی»: رقم یا حرفِ لاتین (نامِ تابع و متغیر: f، x، A). بدونِ حرفِ لاتین «−f(x)» تکه حساب +// نمی‌شد و در متنِ راست‌به‌چپ به‌صورتِ «f(x)−» دیده می‌شد (گزارشِ ۲۰۲۶-۱۰-۱۲). +const MATH_CH = '0-9۰-۹A-Za-z'; +// تکه‌ای بدونِ حرفِ فارسی و بدونِ خطِ تازه که دستِ‌کم یک نویسه‌ی ریاضی دارد +const RUN = new RegExp(`[^${LETTER}\\n]*[${MATH_CH}][^${LETTER}\\n]*`, 'g'); +const HAS_MATH = new RegExp(`[${MATH_CH}]`); // نشانه‌هایی که مالِ جمله‌ی فارسی‌اند، نه ریاضی: از دو سرِ تکه بیرون می‌مانند const EDGE_START = /^[\s،؛:!؟«»"'.=]+/; const EDGE_END = /[\s،؛:!؟«»"'.=]+$/; @@ -48,7 +52,7 @@ export function MathText({ text }: { text: string }) { const edge = trimUnpaired(run); const head = lead + edge.head; run = edge.run; - if (!run || !/[0-9۰-۹]/.test(run)) continue; + if (!run || !HAS_MATH.test(run)) continue; out.push(text.slice(last, start + head.length)); out.push({run}); last = start + head.length + run.length; diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 97c3a6e..cd19416 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -29,4 +29,6 @@ export const KEYS = { sound: 'tadrisino.sound', referralCode: 'tadrisino.referralCode', companion: 'tadrisino.companion', + /** {gradeId: subjectKey}؛ کتابی که کاربر در هر پایه آخر انتخاب کرده. */ + subjects: 'tadrisino.subjects', } as const; diff --git a/src/screens/AskScreen.tsx b/src/screens/AskScreen.tsx index 4c84d34..23ded16 100644 --- a/src/screens/AskScreen.tsx +++ b/src/screens/AskScreen.tsx @@ -19,7 +19,8 @@ const STARTERS = ['این فصل درباره‌ی چیه؟', 'یه مثالِ /** «از {همراه} بپرس»: دستیارِ ریاضی که از روی کتابِ پایه‌ی کاربر جواب می‌دهد؛ چهره و نامش همراهِ انتخابیِ کاربر است. */ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) { - const { profile } = useAppData(); + const { profile, home } = useAppData(); + const subjectTitle = home?.subject?.title; const companion = useCompanion(); const chat = useAskState(); const [input, setInput] = useState(''); @@ -90,6 +91,9 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; userQuestion: question, conversationId: askStore.get().conversationId ?? undefined, lessonId: askStore.get().lessonId ?? undefined, + // بی‌درس، کتابِ انتخابیِ دانش‌آموز (هندسه، گسسته، …) را می‌فرستیم تا دستیار از همان کتاب جواب بدهد؛ + // با درس، سرور کتاب را از خودِ درس درمی‌آورد. + subject: askStore.get().lessonId ? undefined : subjectTitle, questionId: forQuestionId, }, (delta) => { diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 1fc1e3b..8691244 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -14,6 +14,7 @@ import { MathText } from '@/lib/MathText'; import { BrandFooter } from '@/components/BrandFooter'; import { BrandMark } from '@/components/BrandMark'; import { StreakFlame, flameState } from '@/components/StreakFlame'; +import { SubjectBar } from '@/components/SubjectBar'; /** * خانه = خودِ مسیر، مثلِ دولینگو: همه‌ی فصل‌های پایه پشتِ هم، و جزیره‌ی فعلی با @@ -108,6 +109,8 @@ export function HomeScreen() { + +
{!home ? ( homeError ? void refreshHome()} /> : diff --git a/src/screens/LessonsScreen.tsx b/src/screens/LessonsScreen.tsx index 0278400..35bf48c 100644 --- a/src/screens/LessonsScreen.tsx +++ b/src/screens/LessonsScreen.tsx @@ -12,6 +12,7 @@ import { iconFor } from '@/components/NodeIcon'; import { toFa } from '@/lib/format'; import { MathText } from '@/lib/MathText'; import { useFirstVisit } from '@/lib/firstVisit'; +import { SubjectBar } from '@/components/SubjectBar'; /** * تبِ «درس‌ها»: فصل ← جزیره ← درس. فصل‌ها اول بسته‌اند و بالای صفحه یک کارتِ «ادامه بده» هست. @@ -53,6 +54,8 @@ export function LessonsScreen() { )} + +
{!home ? ( homeError ? void refreshHome()} /> : diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index cacaf79..c22f260 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -256,7 +256,7 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) { await api.profile.setGrade(gradeId); setProfile({ ...profile, gradeId }); askStore.reset(null); - await Promise.all([refreshHome(), refreshSummary()]); + await Promise.all([refreshHome(gradeId), refreshSummary()]); toast('پایه‌ت عوض شد', 'success'); onClose(); } catch (err) { diff --git a/src/screens/QuizScreen.tsx b/src/screens/QuizScreen.tsx index 9c64724..ac69c40 100644 --- a/src/screens/QuizScreen.tsx +++ b/src/screens/QuizScreen.tsx @@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'motion/react'; import { Brain, Check, ChevronLeft, Coins, Compass, Flag, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react'; import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api'; import { useAppData } from '@/state/AppData'; +import { fetchHome } from '@/state/subject'; import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum'; import { useNav } from '@/nav/Navigator'; import { newIdempotencyKey } from '@/api/http'; @@ -449,7 +450,7 @@ function Result({ result, total, correct, lessonId, nodeId, color, streakUp, }: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string; streakUp: { from: number; to: number } | null }) { const nav = useNav(); - const { companion } = useAppData(); + const { companion, profile } = useAppData(); const [loadingNext, setLoadingNext] = useState(false); // بعد از نتیجه، اگر زنجیره امروز روشن شد، یک صفحه‌ی جشنِ جدا (مثلِ دولینگو) و بعد مقصدِ انتخاب‌شده. const [streakThen, setStreakThen] = useState<(() => void) | null>(null); @@ -468,7 +469,7 @@ function Result({ } setLoadingNext(true); try { - const fresh = await api.home(); + const fresh = await fetchHome(profile.gradeId); const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId!); const next = ref ? nextLessonOf(ref.node) : null; if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) { diff --git a/src/state/AppData.tsx b/src/state/AppData.tsx index 93862b7..95c486e 100644 --- a/src/state/AppData.tsx +++ b/src/state/AppData.tsx @@ -1,7 +1,8 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { api, type Character, type Grade, type HomeResponse, type UserProfile, type UserSummary } from '@/api'; import { seyarFromSlug, type SeyarChar } from '@/mascots/seyar'; import { companionStore, useCompanion } from '@/state/companion'; +import { fetchHome, subjectStore } from '@/state/subject'; /** * داده‌هایی که چند صفحه با هم لازم دارند: پروفایل، آمار، نقشه، همراهِ انتخابی @@ -18,7 +19,12 @@ interface AppData { /** همراهی که کاربر در صفحه‌ی کاراکترها انتخاب کرده؛ پیش‌فرض سیگما. */ companion: SeyarChar; unreadCount: number; - refreshHome: () => Promise; + /** `gradeId` را فقط وقتی بدهید که پایه همین الان عوض شده و state هنوز رندر نشده. */ + refreshHome: (gradeId?: number) => Promise; + /** عوضِ کتابِ نقشه (هندسه، حسابان، …)؛ آزمون‌ها و درس‌ها هر دو همین را می‌خوانند. */ + selectSubject: (key: string) => void; + /** کتابی که روی `home` است؛ بی‌نقشه (هنوز نرسیده) null. */ + subjectKey: string | null; refreshSummary: () => Promise; refreshUnread: () => Promise; setProfile: (profile: UserProfile) => void; @@ -54,14 +60,35 @@ export function AppDataProvider({ const setCompanionChar = companionStore.set; const [unreadCount, setUnreadCount] = useState(0); - const refreshHome = useCallback(async () => { + const gradeId = profile.gradeId; + // نقشه‌ی کتاب‌هایی که دیده شده؛ جابه‌جایی به کتابِ دیده‌شده فوری است و پشت‌صحنه تازه می‌شود. + const homeCache = useRef(new Map()); + + const refreshHome = useCallback(async (forGrade?: number) => { + const target = forGrade ?? gradeId; try { - setHome(await api.home()); + const fresh = await fetchHome(target); + if (fresh.subject) homeCache.current.set(`${target}:${fresh.subject.key}`, fresh); + setHome(fresh); setHomeError(null); } catch (err) { setHomeError(err); } - }, []); + }, [gradeId]); + + const selectSubject = useCallback( + (key: string) => { + if (gradeId == null) return; + subjectStore.set(gradeId, key); + const cached = homeCache.current.get(`${gradeId}:${key}`); + // کتابِ دیده‌شده فوری می‌آید؛ دیده‌نشده یک لحظه «در حال آوردن» است تا نقشه‌ی کتابِ قبلی زیرِ نامِ + // کتابِ تازه نماند. + setHome(cached ?? null); + setHomeError(null); + void refreshHome(); + }, + [gradeId, refreshHome], + ); const refreshSummary = useCallback(async () => { try { @@ -106,13 +133,15 @@ export function AppDataProvider({ companion, unreadCount, refreshHome, + selectSubject, + subjectKey: home?.subject?.key ?? null, refreshSummary, refreshUnread, setProfile, setCompanion: (character) => setCompanionChar(seyarFromSlug(character.slug) ?? 'sigma'), setUnreadCount, }), - [profile, grades, summary, home, homeError, companion, unreadCount, refreshHome, refreshSummary, refreshUnread], + [profile, grades, summary, home, homeError, companion, unreadCount, refreshHome, selectSubject, refreshSummary, refreshUnread], ); return {children}; diff --git a/src/state/subject.ts b/src/state/subject.ts new file mode 100644 index 0000000..1642b0f --- /dev/null +++ b/src/state/subject.ts @@ -0,0 +1,68 @@ +import { useSyncExternalStore } from 'react'; +import { api, ApiError, type HomeResponse } from '@/api'; +import { KEYS, store } from '@/lib/storage'; + +/** + * کتابی که دانش‌آموز در هر پایه انتخاب کرده («هندسه»، «حسابان»، …). یک منبعِ حقیقت برای هر دو تبِ «آزمون‌ها» و + * «درس‌ها»: هر دو از `home` در AppData می‌خوانند و `home` همیشه نقشه‌ی همین کتاب است، پس جابه‌جایی در یکی در + * دیگری هم دیده می‌شود. + * + * هر پایه جدا نگه داشته می‌شود (با عوض‌کردنِ پایه، انتخابِ پایه‌ی قبلی نباید به دوازدهم بپرد) و در + * localStorage می‌ماند تا بعد از بستنِ اپ همان کتاب باز شود. منبعِ حقیقتِ فهرستِ کتاب‌ها سرور است + * (`home.subjects`)؛ این فقط «کدام» را می‌گوید. + */ +type SubjectMap = Record; + +function read(): SubjectMap { + try { + const parsed: unknown = JSON.parse(store.get(KEYS.subjects) ?? '{}'); + return parsed && typeof parsed === 'object' ? (parsed as SubjectMap) : {}; + } catch { + return {}; + } +} + +let current: SubjectMap = read(); +const listeners = new Set<() => void>(); + +export const subjectStore = { + get: (gradeId: number): string | null => current[String(gradeId)] ?? null, + set(gradeId: number, key: string | null) { + const id = String(gradeId); + if ((current[id] ?? null) === key) return; + const next = { ...current }; + if (key) next[id] = key; + else delete next[id]; + current = next; + store.set(KEYS.subjects, JSON.stringify(next)); + listeners.forEach((l) => l()); + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + snapshot: () => current, +}; + +/** کتابِ انتخاب‌شده‌ی این پایه؛ null یعنی هنوز انتخابی نشده (سرور اولین کتاب را می‌دهد). */ +export function useSelectedSubject(gradeId: number | null | undefined): string | null { + const map = useSyncExternalStore(subjectStore.subscribe, subjectStore.snapshot, subjectStore.snapshot); + return gradeId == null ? null : map[String(gradeId)] ?? null; +} + +/** + * نقشه‌ی کتابِ انتخاب‌شده. کلیدِ ذخیره‌شده‌ای که سرور دیگر نمی‌شناسد (کتاب برداشته شده یا پایه عوض شده) + * ۴۰۰ می‌گیرد؛ آن را پاک می‌کنیم و نقشه‌ی پیش‌فرض را می‌گیریم تا کاربر صفحه‌ی خالی نبیند. + */ +export async function fetchHome(gradeId: number | null | undefined): Promise { + const wanted = gradeId == null ? null : subjectStore.get(gradeId); + try { + return await api.home(wanted ?? undefined); + } catch (err) { + if (wanted && gradeId != null && err instanceof ApiError && err.status === 400) { + subjectStore.set(gradeId, null); + return api.home(); + } + throw err; + } +}