Subject bar under the header in Quizzes and Lessons, synced between tabs; bidi fix for Latin math in RTL text

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 05:35:30 +03:30
parent 56dcc93202
commit f569ece55b
14 changed files with 217 additions and 21 deletions

View File

@@ -81,8 +81,11 @@ export const api = {
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
/** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */
home: () => http.get<T.HomeResponse>('/home'),
/**
* بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. بدونِ subject اولین کتابِ پایه؛
* کتابِ ناموجود در پایه ۴۰۰ می‌دهد (برای کلیدِ ذخیره‌شده‌ی قدیمی از `fetchHome` استفاده کنید).
*/
home: (subject?: string) => http.get<T.HomeResponse>(`/home${q({ subject })}`),
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/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<T.AskDone> {

View File

@@ -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;
}

View File

@@ -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<Prefetch> {
const [home, summary] = await Promise.allSettled([api.home(), api.summary()]);
async function prefetchMain(profile: Promise<UserProfile>): Promise<Prefetch> {
// کتابِ انتخاب‌شده به پایه بسته است؛ فقط وقتی چیزی ذخیره شده منتظرِ پروفایل می‌مانیم (پایه‌های ≤ دهم
// معمولاً هیچ انتخابی ندارند و نقشه هم‌زمان با پروفایل می‌آید).
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,

View File

@@ -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<string, LucideIcon> = {
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];

View File

@@ -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<HTMLButtonElement>(null);
useEffect(() => {
// کتابِ فعال در دید بماند وقتی پنج دکمه از عرضِ گوشی بیشتر است.
activeRef.current?.scrollIntoView({ inline: 'center', block: 'nearest' });
}, [subjectKey]);
if (subjects.length < 2) return null;
return (
<nav aria-label="کتاب‌ها" className="flex-shrink-0 border-b border-line bg-white">
<div role="tablist" className="no-scrollbar flex gap-2 overflow-x-auto px-4 py-2">
{subjects.map((subject, index) => {
const active = subject.key === subjectKey;
const Icon = iconFor(subject.icon, index);
const color = onWhite(subject.color || '#4C5FD5');
return (
<button
key={subject.key}
ref={active ? activeRef : undefined}
type="button"
role="tab"
aria-selected={active}
onClick={() => !active && selectSubject(subject.key)}
className={`flex h-10 min-w-fit flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-full border px-3.5 text-[13px] font-black transition active:scale-95 ${
active ? 'border-transparent text-white shadow-sm' : 'border-line bg-white text-body'
}`}
style={active ? { backgroundColor: color, boxShadow: `0 3px 0 ${color}66` } : undefined}
>
<Icon size={16} strokeWidth={2.4} style={active ? undefined : { color }} />
{subject.title}
</button>
);
})}
</div>
</nav>
);
}

View File

@@ -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(<span key={i++} style={LTR}>{run}</span>);
last = start + head.length + run.length;

View File

@@ -29,4 +29,6 @@ export const KEYS = {
sound: 'tadrisino.sound',
referralCode: 'tadrisino.referralCode',
companion: 'tadrisino.companion',
/** {gradeId: subjectKey}؛ کتابی که کاربر در هر پایه آخر انتخاب کرده. */
subjects: 'tadrisino.subjects',
} as const;

View File

@@ -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) => {

View File

@@ -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() {
</div>
</header>
<SubjectBar />
<div className="flex-1 overflow-y-auto">
{!home ? (
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />

View File

@@ -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() {
)}
</ScreenHeader>
<SubjectBar />
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-4">
{!home ? (
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم درس‌ها رو میارم…" />

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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<void>;
/** `gradeId` را فقط وقتی بدهید که پایه همین الان عوض شده و state هنوز رندر نشده. */
refreshHome: (gradeId?: number) => Promise<void>;
/** عوضِ کتابِ نقشه (هندسه، حسابان، …)؛ آزمون‌ها و درس‌ها هر دو همین را می‌خوانند. */
selectSubject: (key: string) => void;
/** کتابی که روی `home` است؛ بی‌نقشه (هنوز نرسیده) null. */
subjectKey: string | null;
refreshSummary: () => Promise<void>;
refreshUnread: () => Promise<void>;
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<string, HomeResponse>());
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 <AppDataContext.Provider value={value}>{children}</AppDataContext.Provider>;

68
src/state/subject.ts Normal file
View File

@@ -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<string, string>;
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<HomeResponse> {
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;
}
}