16 Commits

Author SHA1 Message Date
06f80d181e Quiz: phone Back asks before leaving; unknown hearts are not shown as five full
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:27:44 +03:30
3a03c5088a deploy.sh: cp --update=none (coreutils 9 warns on -n)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:31:02 +03:30
0a91ffc059 deploy.sh: DEPLOY_BUNDLE for when the server cannot fetch from Gitea
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:26:41 +03:30
b75127c237 Session, navigation, payment and resilience fixes found in a behaviour review
- http: a request whose 401 arrived after another request's refresh had finished refreshed again;
  it now resends with the new token. A refresh finishing after logout no longer restores tokens.
- logout sends the refresh token current at send time (after a 401 -> refresh it had sent the
  rotated-out one and left the new session alive on the server for 30 days).
- Sign-out/sign-in in another browser tab is followed (storage event).
- Navigator removes its history entries when the main app unmounts (Back did nothing after logout).
- Login: +98 / 0098 / 9xxxxxxxxx phone input (autofill, typing, paste) normalised; incomplete OTP
  is not sent (each wrong guess burns one of five).
- Quiz resumes at the same question after hearts refill instead of restarting; the no-hearts
  screen re-checks while due (phone clock ahead of the server left it stuck at zero).
- Payment return: a pending order is re-checked for about a minute instead of shown as failed;
  the pay button is reset when the page comes back from bfcache.
- Tutor: retry keeps the quiz question id; a network error no longer forgets the conversation;
  daily quota detected by code ai_daily_quota.
- Root error boundary and vite:preloadError reload instead of a white screen; deploy.sh keeps
  hashed assets of earlier builds for 30 days so open apps can still load their chunks.
- Sheet: labelled dialog, focus moves in and back, Escape closes only the top sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:25:40 +03:30
f7009b11e8 Instant home on reopen: snapshot of profile/grades/map/stats on the device, refreshed behind the scenes
A signed-in user with a snapshot goes straight to home; the same boot requests run in the
background and replace it. Snapshot is cleared whenever tokens are cleared and on a new login,
ignored from another build or when older than 7 days, and dropped when the fresh profile is a
different user (reboot remounts MainApp via a generation key; AnimatePresence otherwise kept the
exiting one). Notifications/characters wait for the profile answer so an expired access token
costs the same requests as before. refreshHome applies only the latest answer. Home shows a
"saved data" strip when the map could not be refreshed.

Measured locally (old vs new build, same backend, Chrome throttling, median of 3, time to map):
Fast 3G 2.46s -> 1.80s (expired token 3.63s -> 1.80s); Slow 3G 8.52s -> 6.14s (expired 12.6s ->
6.13s); API requests per open unchanged (6 valid, 11 expired). 12/12 behaviour checks passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:57:42 +03:30
f0b7a5e28f Diagrams: hide answer-giving counts in quizzes (HideCounts); bar/pie slices take the colour named by their label
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:27:22 +03:30
24a6f7c570 Quiz: 'خروج' leaves the quiz even though the confirm sheet holds a back step (nav.leave)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 02:18:10 +03:30
2e7e5823cc lineFit runs before first paint (no 150ms debounce): no flash of unfitted text
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:59:03 +03:30
09f6b78198 Text rendering: unpaired closing paren returns to the sentence, formulas in one sentence stay inline, matrix rows align as a grid, line fit also for short items and drops justify when gaps stay ugly
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:53:27 +03:30
22bdb4d39f Line fitting for justified text: per-paragraph word-spacing chosen by measured gap badness (Knuth-Plass-style shrink)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:31:52 +03:30
f214abff3c plainMath: vdots/ddots (with or without backslash) and sqrt( no longer reach the student raw
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:24:09 +03:30
9a6ef69480 Justify multi-line prose app-wide (p, li, pre-line text); centered sections keep centering
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:09:26 +03:30
12c3721d92 diagram: kind=space (fixed oblique 3D view)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 01:04:08 +03:30
782998e325 MathText: matrix [[..],[..]] drawn as a real matrix block instead of wrapping mid-row
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 22:13:34 +03:30
2ac783d222 Brand name shown as «تدریسینو ریاضی» everywhere users see it (splash, login, onboarding, footer, subscription, share); splash nowrap + balanced titles for narrow phones
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 21:55:20 +03:30
941e8245f1 MathText display: formula in the middle of a Persian sentence gets its own centered line (quiz and lessons)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 20:24:55 +03:30
42 changed files with 1020 additions and 119 deletions

View File

@@ -70,6 +70,10 @@ pay.html · src/pay.ts صفحه‌ی واسطِ پرداختِ زرین‌پا
- **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ `۰-۹` می‌فرستد و `\d` در JS آن‌ها را نمی‌شناسد).
- **رنگ‌ها** در `src/styles/index.css` (`@theme`) تعریف شده‌اند: `bg-coral`، `text-ink`، …
- **خطا:** پیامِ کاربر همیشه از `friendlyError` می‌آید، نه متنِ خامِ سرور.
- **بازشدنِ فوری:** کاربرِ واردشده خانه را از عکسِ روی گوشی می‌بیند (`state/homeSnapshot.ts`: پروفایل، پایه‌ها، نقشه،
آمار) و همان درخواست‌های بوت پشتِ صحنه تازه‌اش می‌کنند. عکس با پاک شدنِ توکن و ورودِ تازه پاک می‌شود، با بیلدِ دیگر
یا کهنه‌تر از ۷ روز خوانده نمی‌شود، و درستی (قفل، اشتراک، قلب) همیشه با سرور است. داده‌ی تازه‌ای که در خانه دیده
می‌شود باید در `AppData` بماند تا در عکس هم برود.
- **متنِ فارسی** بی‌اعراب و بی «ی»ِ اضافه‌ی نوشته‌شده («پایه‌ی سوم») است (تصمیمِ شرکت)؛ تنوین («قبلاً») می‌ماند. متنِ تازه
را همین‌طور بنویسید. متنِ سرور را بک‌اند هنگامِ ساختنِ پاسخ پاک می‌کند و `plainPersian` در `MathText` همان کار را برای
پاسخِ زنده‌ی دستیار. `lib/plainMath.ts` هر LaTeX یا markdownی را که برسد ساده می‌کند و باید با `content/plain_math.py`ِ

View File

@@ -18,7 +18,16 @@ step() { printf '\n\033[0;32m▸ %s\033[0m\n' "$1"; }
fail() { printf '\033[0;31m✖ %s\033[0m\n' "$1" >&2; exit 1; }
step "گرفتنِ کدِ تازه ($BRANCH)"
git fetch origin "$BRANCH" || fail "به gitea وصل نشد."
# DEPLOY_BUNDLE=/مسیر/فایل.bundle: کد از یک git bundle، نه از gitea (مثلِ deploy.shِ بک‌اند): برای وقتی که سرور
# به gitea نمی‌رسد یا اعتبارنامه ندارد. فقط وقتی صریحاً داده شود؛ بی‌آن نرسیدن به gitea دیپلو را می‌ایستاند.
if [ -n "${DEPLOY_BUNDLE:-}" ]; then
[ -f "$DEPLOY_BUNDLE" ] || fail "DEPLOY_BUNDLE=$DEPLOY_BUNDLE وجود ندارد."
git bundle verify "$DEPLOY_BUNDLE" >/dev/null 2>&1 || fail "bundle معتبر نیست یا کامیتِ پایه‌اش این‌جا نیست."
git fetch "$DEPLOY_BUNDLE" "$BRANCH:refs/remotes/origin/$BRANCH" || fail "گرفتن از bundle نشد."
else
git fetch origin "$BRANCH" || fail "به gitea وصل نشد (اگر فقط از این سرور: DEPLOY_BUNDLE، بالای همین خط)."
fi
printf ' origin/%s = %s\n' "$BRANCH" "$(git rev-parse --short "origin/$BRANCH")"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
mkdir -p .deploy-backups
PATCH=".deploy-backups/local-changes-$(date +%Y%m%d-%H%M%S).patch"
@@ -41,6 +50,13 @@ if [ -f dist/config.js ] && ! cmp -s dist/config.js public/config.js; then
printf ' config.jsِ ویرایش‌شده‌ی سرور نگه داشته شد.\n'
fi
# فایل‌های buildهای قبلی (نامشان hash دارد و با هم برخورد نمی‌کنند) تا ۳۰ روز می‌مانند: گوشی‌ای که اپ از قبل
# در آن باز است هنوز index.htmlِ قبلی را دارد و با باز کردنِ صفحه‌ای که بار نشده همان نام‌ها را می‌خواهد؛ بی‌این
# ۴۰۴ و صفحه‌ی سفید می‌گرفت. cp -p زمانِ فایل را نگه می‌دارد، پس هر فایل ۳۰ روز بعد از buildِ خودش کنار می‌رود.
if [ -d dist/assets ]; then
find dist/assets -type f -mtime -30 -exec cp -p --update=none {} dist.next/assets/ \;
fi
step "جابه‌جایی"
rm -rf dist.prev
[ -d dist ] && mv dist dist.prev

View File

@@ -4,9 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ED6956" />
<meta name="description" content="تدریسینو — یادگیری ریاضی پایه سوم تا دوازدهم، قدم‌به‌قدم و با دستیار هوشمند." />
<meta name="description" content="تدریسینو ریاضی — یادگیری ریاضی پایه سوم تا دوازدهم، قدم‌به‌قدم و با دستیار هوشمند." />
<meta name="robots" content="noindex, nofollow" />
<title>تدریسینو</title>
<title>تدریسینو ریاضی</title>
<!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار می‌شود. -->
<script src="/config.js"></script>
<!-- اسپلشِ ثابت: همین‌جا در HTML است تا از اولین فریم دیده شود (نه صفحه‌ی سفید تا بار شدنِ JS)؛
@@ -30,12 +30,12 @@
.in-app .boot-splash > * { visibility: hidden; }
.in-old-app .boot-splash span { display: none; }
.in-old-app .boot-splash img { width: 160px; height: 160px; }
.boot-splash span { font: 900 34px/1.2 system-ui, -apple-system, sans-serif; color: #1a1a2e; }
.boot-splash span { font: 900 min(34px, 8.5vw)/1.2 system-ui, -apple-system, sans-serif; color: #1a1a2e; white-space: nowrap; }
</style>
</head>
<body>
<div id="root">
<div class="boot-splash" dir="rtl"><img src="/app-icon.png" alt="" width="144" height="144" /><span>تدریسینو</span></div>
<div class="boot-splash" dir="rtl"><img src="/app-icon.png" alt="" width="144" height="144" /><span>تدریسینو ریاضی</span></div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -6,7 +6,7 @@
<meta name="robots" content="noindex, nofollow" />
<!-- Referer باید از دامنه‌ی خودمان برود؛ زرین‌پال بی‌آن صفحه را باز نمی‌کند (src/pay.ts). -->
<meta name="referrer" content="strict-origin-when-cross-origin" />
<title>رفتن به درگاه — تدریسینو</title>
<title>رفتن به درگاه — تدریسینو ریاضی</title>
<script src="/config.js"></script>
<style>
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f6f5fb; color: #1a1a2e; font-family: Vazirmatn, system-ui, sans-serif; }

View File

@@ -43,13 +43,13 @@ export interface TokenStorage {
set(tokens: Tokens | null): void;
}
const STORAGE_KEY = 'tadrisino.tokens';
export const TOKENS_STORAGE_KEY = 'tadrisino.tokens';
/** localStorage در حالتِ خصوصیِ بعضی مرورگرها throw می‌کند؛ اپ نباید بخوابد. */
export const localTokenStorage: TokenStorage = {
get() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(TOKENS_STORAGE_KEY);
return raw ? (JSON.parse(raw) as Tokens) : null;
} catch {
return null;
@@ -57,8 +57,8 @@ export const localTokenStorage: TokenStorage = {
},
set(tokens) {
try {
if (tokens) localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
else localStorage.removeItem(STORAGE_KEY);
if (tokens) localStorage.setItem(TOKENS_STORAGE_KEY, JSON.stringify(tokens));
else localStorage.removeItem(TOKENS_STORAGE_KEY);
} catch {
/* ذخیره نشد؛ نشست فقط تا بستنِ صفحه می‌ماند */
}
@@ -220,6 +220,8 @@ export function createHttpClient(options: HttpClientOptions) {
if (!response.ok) return errorFrom(response, await parseBody(response));
const data = (await response.json()) as Tokens;
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
// کاربر در همین فاصله خارج شد (یا حسابش را حذف کرد): توکنِ تازه نباید نشستِ پاک‌شده را روی گوشی زنده کند.
if (!storage.get()) return 'invalid';
storage.set(next);
return next;
} finally {
@@ -237,8 +239,10 @@ export function createHttpClient(options: HttpClientOptions) {
body: unknown,
opts: RequestOptions,
): Promise<Response> {
// بدنه‌ی تابعی سرِ هر ارسال از نو ساخته می‌شود (خروج: refreshTokenِ همان لحظه، نه نسخه‌ی پیش از refresh).
const payload = typeof body === 'function' ? (body as () => unknown)() : body;
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (payload !== undefined) headers['Content-Type'] = 'application/json';
if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
const timer = timeoutSignal(opts.timeoutMs ?? (method === 'GET' ? readTimeout : defaultTimeout));
@@ -246,7 +250,7 @@ export function createHttpClient(options: HttpClientOptions) {
return await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
body: payload === undefined ? undefined : JSON.stringify(payload),
signal: opts.signal ? anySignal([timer, opts.signal]) : timer,
// احرازِ هویت هدرِ Bearer است، نه کوکی؛ سرور هم CORS را بدونِ credentials دارد.
credentials: 'omit',
@@ -265,9 +269,13 @@ export function createHttpClient(options: HttpClientOptions) {
body: unknown,
opts: RequestOptions,
): Promise<Response> {
const hadToken = Boolean(storage.get()?.token);
const sentToken = storage.get()?.token;
let response = await send(method, path, body, opts);
if (response.status === 401 && hadToken) {
if (response.status === 401 && sentToken) {
// درخواستی که با توکنِ کهنه رفته و ۴۰۱اش بعد از تمامِ refreshِ درخواستِ دیگری رسیده، نباید دوباره
// refresh کند (refreshing آن موقع دیگر null است): توکنِ تازه در storage هست، فقط با آن دوباره بفرست.
const latest = storage.get()?.token;
if (latest && latest !== sentToken) return send(method, path, body, opts);
const refreshed = await refreshTokens();
if (refreshed === 'invalid') signOut();
else if (refreshed instanceof ApiError) throw refreshed; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه

View File

@@ -4,13 +4,26 @@
* هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است.
*/
import { resolveApiBaseUrl } from '@/config';
import { ApiError, createHttpClient, newIdempotencyKey } from './http';
import { homeSnapshot } from '@/state/homeSnapshot';
import { ApiError, createHttpClient, localTokenStorage, newIdempotencyKey, type TokenStorage } from './http';
import type * as T from './types';
export { ApiError };
export type * from './types';
export const http = createHttpClient({ baseUrl: resolveApiBaseUrl() });
/**
* پاک شدنِ توکن (خروج، حذفِ حساب، نشستِ باطل در http.signOut) عکسِ خانه را هم پاک می‌کند؛ هر سه از همین
* `set(null)` می‌گذرند، پس راهی برای ماندنِ خانه‌ی کاربرِ قبلی روی گوشیِ مشترک نمی‌ماند.
*/
const tokenStorage: TokenStorage = {
get: () => localTokenStorage.get(),
set(tokens) {
if (!tokens) homeSnapshot.clear();
localTokenStorage.set(tokens);
},
};
export const http = createHttpClient({ baseUrl: resolveApiBaseUrl(), storage: tokenStorage });
const q = (params: Record<string, string | number | undefined>) => {
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '');
@@ -18,6 +31,8 @@ const q = (params: Record<string, string | number | undefined>) => {
};
function saveTokens(data: T.AuthResponse) {
// ورودِ تازه ممکن است کاربرِ دیگری باشد؛ عکسِ خانه فقط از پاسخِ سرورِ همین نشست ساخته می‌شود.
homeSnapshot.clear();
http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' });
return data;
}
@@ -56,9 +71,14 @@ export const api = {
/** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */
async logout() {
// بدنه تابع است تا سرِ هر ارسال خوانده شود: اگر access token منقضی بوده، ۴۰۱ ← refresh ← تکرار، و
// refresh token عوض شده است؛ فرستادنِ نسخه‌ی قبلی نشستِ تازه را ۳۰ روز روی سرور باطل‌نشده می‌گذاشت.
const body = () => {
const refreshToken = http.storage.get()?.refreshToken;
return refreshToken ? { refreshToken } : {};
};
try {
await http.post('/auth/logout', refreshToken ? { refreshToken } : {});
await http.post('/auth/logout', body);
} finally {
http.storage.set(null);
}

View File

@@ -1,12 +1,14 @@
import { useCallback, useEffect, useState } from 'react';
import { AnimatePresence, MotionConfig, motion } from 'motion/react';
import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
import { TOKENS_STORAGE_KEY } from '@/api/http';
import type { Prefetch } from '@/state/AppData';
import { AppShell } from '@/components/AppShell';
import { ToastProvider } from '@/components/Toast';
import { ErrorState } from '@/components/ui';
import { KEYS, store } from '@/lib/storage';
import { askStore } from '@/state/askStore';
import { homeSnapshot } from '@/state/homeSnapshot';
import { fetchHome, subjectStore } from '@/state/subject';
import { clearAsyncCache } from '@/lib/useAsync';
import { LoginScreen } from '@/screens/LoginScreen';
@@ -22,14 +24,18 @@ type Flow =
| { kind: 'onboarding' }
| { kind: 'login' }
| { kind: 'setup'; profile: UserProfile; grades: Grade[] }
| { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch };
| { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch; stale?: boolean };
/**
* جریانِ ورود: اسپلشِ ثابت (تا بررسیِ نشست) → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ.
* خودِ اپ (تب‌ها و صفحه‌ها) در MainApp است.
* کاربرِ واردشده‌ای که عکسِ خانه روی گوشی دارد (state/homeSnapshot.ts) بی‌انتظار به خانه می‌رود و
* داده‌ها پشتِ صحنه تازه می‌شوند. خودِ اپ (تب‌ها و صفحه‌ها) در MainApp است.
*/
export function App() {
const [flow, setFlow] = useState<Flow>({ kind: 'boot' });
// با هر rebootFresh یکی بالا می‌رود و جزوِ کلیدِ صفحه است. بی‌آن «main → boot → main» برای AnimatePresence
// همان کلید است و MainAppِ در حالِ خروج (با داده‌ی عکسِ غلط) به‌جای MainAppِ تازه سرِ جایش می‌ماند.
const [generation, setGeneration] = useState(0);
/** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */
const enter = useCallback(async () => {
@@ -46,6 +52,18 @@ export function App() {
setFlow({ kind: 'boot' });
let next: Flow | null = null;
if (api.auth.isSignedIn()) {
const snap = homeSnapshot.read();
if (snap) {
setFlow({
kind: 'main',
profile: snap.profile,
grades: snap.grades,
prefetch: { home: snap.home, summary: snap.summary },
stale: true,
});
void trackReferral();
return;
}
try {
await enter();
} catch (error) {
@@ -83,6 +101,34 @@ export function App() {
[],
);
// عکسِ خانه مالِ کسِ دیگری بود یا پروفایل حالا ناقص است: عکس کنار می‌رود و بوتِ معمولی تصمیم می‌گیرد.
const rebootFresh = useCallback(() => {
homeSnapshot.clear();
askStore.clear();
clearAsyncCache();
setGeneration((g) => g + 1);
void boot();
}, [boot]);
// تبِ دیگری از همین مرورگر خارج شد یا وارد شد (localStorage مشترک است). بی‌این، تبِ خارج‌شده داخلِ اپ می‌ماند و
// هر درخواستش بی‌توکن خطا می‌گرفت؛ تبِ بیرون‌مانده هم تا رفرش در صفحه‌ی ورود می‌ماند. تمدیدِ توکن (هر دو مقدار
// پُر) کاری ندارد.
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key !== TOKENS_STORAGE_KEY && e.key !== null) return;
const signedIn = api.auth.isSignedIn();
if (!signedIn && (e.key === null || e.oldValue)) {
askStore.clear();
clearAsyncCache();
setFlow({ kind: 'login' });
} else if (signedIn && e.key !== null && !e.oldValue) {
rebootFresh();
}
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [rebootFresh]);
const afterLogin = async () => {
try {
await enter();
@@ -98,7 +144,7 @@ export function App() {
<ToastProvider>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={flow.kind}
key={`${flow.kind}:${generation}`}
className="flex h-full flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -133,6 +179,8 @@ export function App() {
profile={flow.profile}
grades={flow.grades}
prefetch={flow.prefetch}
stale={flow.stale}
onIdentityChanged={rebootFresh}
payment={launchPayment}
onSignedOut={() => {
clearAsyncCache();

View File

@@ -52,15 +52,24 @@ interface Props {
profile: UserProfile;
grades: Grade[];
prefetch?: Prefetch;
/** داده‌ها از عکسِ خانه روی گوشی آمده‌اند و پشتِ صحنه تازه می‌شوند (AppData). */
stale?: boolean;
onIdentityChanged?: () => void;
payment: PaymentReturn | null;
onSignedOut: () => void;
}
/** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */
export function MainApp({ profile, grades, prefetch, payment, onSignedOut }: Props) {
export function MainApp({ profile, grades, prefetch, stale, onIdentityChanged, payment, onSignedOut }: Props) {
useEffect(preloadScreens, []);
return (
<AppDataProvider initialProfile={profile} grades={grades} prefetch={prefetch}>
<AppDataProvider
initialProfile={profile}
grades={grades}
prefetch={prefetch}
stale={stale}
onIdentityChanged={onIdentityChanged}
>
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
<Screens onSignedOut={onSignedOut} />
{payment && <PaymentResult payment={payment} />}
@@ -303,20 +312,45 @@ function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSig
* نتیجه‌ی برگشت از درگاه. فقط پاسخِ سرور تعیین می‌کند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر
* لینکِ ساختگی می‌تواند «paid» بگذارد.
*/
/** سرور سفارشِ pending را دقیقه‌ای یک بار از درگاه می‌پرسد؛ ۴ بار با فاصله‌ی ۲۰ ثانیه حدودِ یک دقیقه است. */
const PENDING_CHECKS = 4;
const PENDING_CHECK_MS = 20_000;
function PaymentResult({ payment }: { payment: PaymentReturn }) {
const { refreshSummary } = useAppData();
const [open, setOpen] = useState(true);
const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking');
useEffect(() => {
// pending یعنی تأییدِ درگاه هنوز نرسیده (مثلاً verify هنگامِ برگشت جواب نداد)، نه «پرداخت نشد»: سرور با هر
// خواندنِ وضعیت دوباره از زرین‌پال می‌پرسد (حداکثر دقیقه‌ای یک بار)، پس چند بار با فاصله می‌پرسیم.
let alive = true;
let timer: ReturnType<typeof setTimeout> | undefined;
const check = (attempt: number) => {
api.subscription
.order(payment.orderId)
.then((order) => setState(order.status === 'paid' ? 'paid' : 'failed'))
.then((order) => {
if (!alive) return;
if (order.status === 'paid') {
setState('paid');
void refreshSummary();
} else if (order.status === 'pending' && attempt < PENDING_CHECKS) {
timer = setTimeout(() => check(attempt + 1), PENDING_CHECK_MS);
} else {
setState(order.status === 'pending' ? 'unknown' : 'failed');
}
})
.catch((err: unknown) => {
console.warn('payment result lookup failed', err);
setState('unknown');
if (alive) setState('unknown');
});
};
check(1);
void refreshSummary();
return () => {
alive = false;
clearTimeout(timer);
};
}, [payment.orderId, refreshSummary]);
if (state === 'checking') {

View File

@@ -7,7 +7,7 @@ export function BrandFooter() {
<footer className="flex flex-col items-center gap-1 pb-4 pt-3 text-center">
<BrandMark size={24} tone="dark" />
<span className="text-[10px] text-muted">ریاضی، قدم‌به‌قدم، از روی کتاب درسی</span>
<span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو · همه حقوق محفوظ است</span>
<span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو ریاضی · همه حقوق محفوظ است</span>
</footer>
);
}

View File

@@ -11,7 +11,7 @@ export function BrandMark({ size = 28, tone = 'light', withName = true }: { size
const c = useCompanion();
const gid = useId();
return (
<span className="inline-flex items-center gap-1.5" aria-label="تدریسینو">
<span className="inline-flex items-center gap-1.5" aria-label="تدریسینو ریاضی">
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true">
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
@@ -25,7 +25,7 @@ export function BrandMark({ size = 28, tone = 'light', withName = true }: { size
</text>
<path className="spark" d="M25 4.5l.9 1.9 1.9.9-1.9.9-.9 1.9-.9-1.9-1.9-.9 1.9-.9z" fill="#FFD23F" />
</svg>
{withName && <span className="font-black tracking-tight" style={{ color: ink, fontSize: size * 0.62 }}>تدریسینو</span>}
{withName && <span className="font-black tracking-tight" style={{ color: ink, fontSize: size * 0.62 }}>تدریسینو ریاضی</span>}
</span>
);
}

View File

@@ -47,6 +47,9 @@ function whenLabel(iso: string, now: number): string {
return `ساعت ${time} ${day}`;
}
/** سرور هر بار regen را خودش حساب می‌کند؛ پرسیدنِ هر ۱۵ ثانیه برای یک صفحه‌ی منتظر بار نیست. */
const DUE_RECHECK_MS = 15_000;
export function NoHeartsScreen({
nextRefillAt,
fullRefillAt,
@@ -81,10 +84,14 @@ export function NoHeartsScreen({
setNext(h.nextRefillAt);
setFull(h.fullRefillAt);
}
}).catch(() => undefined);
}).catch((err: unknown) => console.warn('hearts check failed', err));
void check();
// ساعتِ گوشیِ جلوتر از سرور: سرور هنوز «صفر» و همان nextRefillAt را می‌دهد و `due` عوض نمی‌شود؛ بی‌تکرار،
// صفحه روی شمارشِ صفر می‌ماند تا کاربر بیرون برود. پس تا برگشتنِ قلب با فاصله دوباره می‌پرسیم.
const timer = setInterval(() => void check(), DUE_RECHECK_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [due, back]);

View File

@@ -1,4 +1,4 @@
import { useEffect, type ReactNode } from 'react';
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { AnimatePresence, motion, useDragControls } from 'motion/react';
import { X } from 'lucide-react';
import { useBackStep } from '@/nav/Navigator';
@@ -19,14 +19,23 @@ export function Sheet({
children: ReactNode;
}) {
const drag = useDragControls();
// دکمه‌ی برگشتِ گوشی اول برگه را می‌بندد، نه صفحه‌ی زیرش را.
useBackStep(open, onClose);
const titleId = useId();
const panel = useRef<HTMLDivElement>(null);
// فوکوس با باز شدن به برگه می‌رود (صفحه‌خوان و صفحه‌کلید از همان‌جا ادامه می‌دهند) و با بسته شدن به دکمه‌ای
// که بازش کرد برمی‌گردد. ورودی‌ای که خودش autoFocus دارد فوکوسش را نگه می‌دارد.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const t = setTimeout(() => {
if (!panel.current?.contains(document.activeElement)) panel.current?.focus({ preventScroll: true });
}, 0);
return () => {
clearTimeout(t);
if (opener?.isConnected) opener.focus({ preventScroll: true });
};
}, [open]);
// دکمه‌ی برگشتِ گوشی اول برگه را می‌بندد، نه صفحه‌ی زیرش را.
useBackStep(open, onClose);
return (
<AnimatePresence>
@@ -40,9 +49,20 @@ export function Sheet({
onClick={onClose}
/>
<motion.div
ref={panel}
role="dialog"
aria-modal="true"
className="relative max-h-[85%] overflow-y-auto rounded-t-[28px] bg-white px-5 pt-3"
aria-labelledby={title ? titleId : undefined}
// Escape فقط برگه‌ی رویی را می‌بندد (فوکوس داخلِ همان است)؛ قبلاً هر برگه‌ی باز روی window گوش می‌داد
// و یک Escape برگه‌ی زیرین را هم می‌بست.
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
}}
className="relative max-h-[85%] overflow-y-auto rounded-t-[28px] bg-white px-5 pt-3 outline-none"
style={{ paddingBottom: 'calc(2rem + env(safe-area-inset-bottom))' }}
initial={{ y: '100%' }}
animate={{ y: 0 }}
@@ -62,7 +82,7 @@ export function Sheet({
</div>
{title && (
<div className="mb-4 flex touch-none items-center justify-between gap-3" onPointerDown={(e) => drag.start(e)}>
<h2 className="text-base font-black text-ink">{title}</h2>
<h2 id={titleId} className="text-base font-black text-ink">{title}</h2>
<button type="button" aria-label="بستن" onClick={onClose} className="rounded-full bg-soft p-1.5 text-muted">
<X size={16} />
</button>

View File

@@ -1,5 +1,5 @@
import type { ChartSpec } from './types';
import { COLORS, GRID, INK, Label, SERIES, SOFT, bidi, fa, mathText, niceStep, ticksBetween } from './svg';
import { COLORS, GRID, INK, Label, SOFT, bidi, fa, mathText, niceStep, seriesColors, ticksBetween } from './svg';
const W = 330;
@@ -20,6 +20,7 @@ function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; label
const Y = (v: number) => top + ((y1 - v) / (y1 - y0)) * plotH;
const slot = plotW / labels.length;
const X = (i: number) => left + slot * (i + 0.5);
const barColors = seriesColors(labels, values.length);
const barW = Math.min(38, slot * 0.62);
const long = labels.some((l) => l.length > 6);
@@ -34,7 +35,7 @@ function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; label
{unit && <text x={left - 4} y={12} fontSize={9.5} fill={SOFT} textAnchor="end" {...bidi(mathText(unit))}>{mathText(unit)}</text>}
{kind === 'bar'
? values.map((v, i) => {
const c = COLORS[SERIES[i % SERIES.length]];
const c = barColors[i];
return (
<g key={i}>
<rect x={X(i) - barW / 2} y={Math.min(Y(v), Y(0))} width={barW} height={Math.abs(Y(v) - Y(0))} rx={4} fill={c} />
@@ -64,12 +65,13 @@ function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; label
function Pie({ labels, values }: { labels: string[]; values: number[] }) {
const total = values.reduce((a, v) => a + Math.max(0, v), 0);
if (total <= 0) return null;
const sliceColors = seriesColors(labels, values.length);
const cx = 105, cy = 105, r = 88;
let a = -Math.PI / 2;
const slices = values.map((v, i) => {
const frac = Math.max(0, v) / total, a0 = a, a1 = a + frac * 2 * Math.PI;
a = a1;
return { i, frac, a0, a1, c: COLORS[SERIES[i % SERIES.length]] };
return { i, frac, a0, a1, c: sliceColors[i] };
});
const H = Math.max(215, 24 + labels.length * 22);
return (

View File

@@ -3,10 +3,11 @@ import type { DiagramSpec } from './types';
import { Plane } from './plane';
import { NumberLine } from './numberline';
import { Solid } from './solid';
import { Space } from './space';
import { Chart } from './charts';
import { Mapping, Tree, Venn } from './sets';
import { Clock, Fraction, Grid, Groups, Pattern, Table } from './misc';
import { PAPER } from './svg';
import { HideCounts, PAPER } from './svg';
import { MathText } from '@/lib/MathText';
export type { DiagramSpec } from './types';
@@ -23,6 +24,7 @@ function Body({ spec }: { spec: DiagramSpec }): ReactNode {
case 'plane': return <Plane spec={spec} />;
case 'numberline': return <NumberLine spec={spec} />;
case 'solid': return <Solid spec={spec} />;
case 'space': return <Space spec={spec} />;
case 'bar': case 'linechart': case 'pie': return <Chart spec={spec} />;
case 'venn': return <Venn spec={spec} />;
case 'mapping': return <Mapping spec={spec} />;
@@ -43,13 +45,13 @@ class Guard extends Component<{ children: ReactNode }, { broken: boolean }> {
render() { return this.state.broken ? null : this.props.children; }
}
function DiagramBase({ spec }: { spec: DiagramSpec }) {
function DiagramBase({ spec, hideCounts = false }: { spec: DiagramSpec; hideCounts?: boolean }) {
return (
<Guard>
{/* dir=ltr: محور و مختصات چپ‌به‌راست‌اند؛ در زمینه‌ی rtl «−۱» به «۱−» برمی‌گشت.
برچسب‌های فارسی با unicode-bidi: plaintext جهتِ خودشان را می‌گیرند. */}
<figure dir="ltr" className="mt-2 overflow-hidden rounded-2xl border border-line p-2" style={{ backgroundColor: PAPER }}>
<Body spec={spec} />
<HideCounts.Provider value={hideCounts}><Body spec={spec} /></HideCounts.Provider>
{spec.title && <figcaption dir="rtl" className="mt-1 text-center text-[11px] font-bold text-muted"><MathText text={spec.title} /></figcaption>}
</figure>
</Guard>

View File

@@ -1,5 +1,6 @@
import type { ClockSpec, FractionSpec, GridSpec, GroupsSpec, PatternSpec, TableSpec } from './types';
import { COLORS, INK, bidi, color, mathText } from './svg';
import { useContext } from 'react';
import { COLORS, HideCounts, INK, bidi, color, mathText } from './svg';
import { toFa } from '@/lib/format';
const W = 320;
@@ -8,11 +9,12 @@ const SHADE = COLORS.yellow;
/* ─── کسر: دایره یا نوارِ تکه‌تکه ─────────────────────────────────────────── */
export function Fraction({ spec }: { spec: FractionSpec }) {
if (spec.compare?.length) return <FractionCompare items={spec.compare} />;
if (spec.wholes) return <MixedNumber wholes={spec.wholes} parts={spec.parts} shaded={spec.shaded} shape={spec.shape} />;
const hide = useContext(HideCounts);
if (spec.compare?.length) return <FractionCompare items={spec.compare} hide={hide} />;
if (spec.wholes) return <MixedNumber wholes={spec.wholes} parts={spec.parts} shaded={spec.shaded} shape={spec.shape} hide={hide} />;
const { parts, shaded } = spec;
const fill = (i: number) => (i < shaded ? SHADE : '#fff');
const caption = <text x={W / 2} y={spec.shape === 'rect' ? 88 : 166} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(shaded)} تکه از ${toFa(parts)} تکه`}</text>;
const caption = hide ? null : <text x={W / 2} y={spec.shape === 'rect' ? 88 : 166} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(shaded)} تکه از ${toFa(parts)} تکه`}</text>;
if (spec.shape === 'rect') {
const w = (W - 40) / parts;
return (
@@ -36,7 +38,7 @@ export function Fraction({ spec }: { spec: FractionSpec }) {
}
/** چند کسر روی نوارهای هم‌اندازه، برای مقایسه (یک‌پنجم و نُه‌دهم). */
function FractionCompare({ items }: { items: { parts: number; shaded: number; label?: string }[] }) {
function FractionCompare({ items, hide }: { items: { parts: number; shaded: number; label?: string }[]; hide: boolean }) {
const barW = W - 110, h = 30, gap = 16;
const H = items.length * (h + gap) + 8;
const cols = [COLORS.yellow, COLORS.teal, COLORS.orange, COLORS.purple];
@@ -50,7 +52,7 @@ function FractionCompare({ items }: { items: { parts: number; shaded: number; la
<rect key={i} x={W - 12 - (i + 1) * w} y={y} width={w} height={h} fill={i < f.shaded ? cols[r % cols.length] : '#fff'} stroke={INK} strokeWidth={1.6} />
))}
<text x={W - 20 - barW} y={y + h / 2 + 4} fontSize={11.5} fontWeight={800} fill={INK} textAnchor="end" style={{ unicodeBidi: 'plaintext' }}>
{f.label ?? `${toFa(f.shaded)} از ${toFa(f.parts)}`}
{f.label ?? (hide ? '' : `${toFa(f.shaded)} از ${toFa(f.parts)}`)}
</text>
</g>
);
@@ -60,7 +62,7 @@ function FractionCompare({ items }: { items: { parts: number; shaded: number; la
}
/** عددِ مخلوط: چند واحدِ کامل و یک کسر (دو و هفت‌دهم). */
function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts: number; shaded: number; shape: 'circle' | 'rect' }) {
function MixedNumber({ wholes, parts, shaded, shape, hide }: { wholes: number; parts: number; shaded: number; shape: 'circle' | 'rect'; hide: boolean }) {
const n = wholes + 1, r = Math.min(40, (W - 20) / n / 2 - 6), cy = 52;
const cx = (i: number) => W - 10 - r - 6 - i * (2 * r + 12);
const slice = (x: number, i: number, full: boolean) => {
@@ -73,9 +75,9 @@ function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts:
? <rect key={w} x={cx(w) - r} y={cy - r} width={2 * r} height={2 * r} fill={SHADE} stroke={INK} strokeWidth={2} />
: <circle key={w} cx={cx(w)} cy={cy} r={r} fill={SHADE} stroke={INK} strokeWidth={2} />))}
<g>{parts === 1 ? <circle cx={cx(wholes)} cy={cy} r={r} fill={shaded ? SHADE : '#fff'} stroke={INK} strokeWidth={2} /> : Array.from({ length: parts }, (_, i) => slice(cx(wholes), i, false))}</g>
<text x={W / 2} y={112} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
{!hide && <text x={W / 2} y={112} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
{`${toFa(wholes)} واحد کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`}
</text>
</text>}
</svg>
);
}
@@ -83,6 +85,7 @@ function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts:
/* ─── شبکه‌ی خانه‌ها: درصد، مساحت با شمردن، کسر روی جدول ─────────────────── */
export function Grid({ spec }: { spec: GridSpec }) {
const hide = useContext(HideCounts);
const rows = spec.rows, cols = spec.cols;
const cell = Math.min(26, (W - 30) / cols, 220 / rows);
const gw = cols * cell, gh = rows * cell;
@@ -100,9 +103,9 @@ export function Grid({ spec }: { spec: GridSpec }) {
<rect key={`${r}-${c}`} x={x0 + c * cell} y={y0 + r * cell} width={cell} height={cell} fill={on.has(`${r},${c}`) ? col : '#fff'} stroke="#B8BCCD" strokeWidth={1} />
)))}
<rect x={x0} y={y0} width={gw} height={gh} fill="none" stroke={INK} strokeWidth={2} />
<text x={W / 2} y={gh + 34} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
{!hide && <text x={W / 2} y={gh + 34} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
{`${toFa(count)} خانه از ${toFa(rows * cols)} خانه${100 % (rows * cols) === 0 || (rows * cols) % 10 === 0 ? `، یعنی ${toFa(Math.round((count / (rows * cols)) * 1000) / 10)}٪` : ''}`}
</text>
</text>}
</svg>
);
}
@@ -110,6 +113,7 @@ export function Grid({ spec }: { spec: GridSpec }) {
/* ─── گروه‌ها: مدلِ ضرب و تقسیم برای دبستان ───────────────────────────────── */
export function Groups({ spec }: { spec: GroupsSpec }) {
const hide = useContext(HideCounts);
const g = spec.groups, each = spec.each;
const perRow = Math.min(g, g <= 4 ? g : Math.ceil(g / 2));
const rowsG = Math.ceil(g / perRow);
@@ -141,9 +145,9 @@ export function Groups({ spec }: { spec: GroupsSpec }) {
);
})}
</g>
<text x={W / 2} y={H - 8} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" {...bidi(mathText(spec.label ?? `${g} × ${each} = ${g * each}`))}>
{(spec.label != null || !hide) && <text x={W / 2} y={H - 8} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" {...bidi(mathText(spec.label ?? `${g} × ${each} = ${g * each}`))}>
{mathText(spec.label ?? `${g} × ${each} = ${g * each}`)}
</text>
</text>}
</svg>
);
}
@@ -233,6 +237,7 @@ function patternCells(layout: PatternSpec['layout'], n: number): [number, number
}
export function Pattern({ spec }: { spec: PatternSpec }) {
const hide = useContext(HideCounts);
const stages = spec.stages;
const sticks = spec.unit === 'stick';
const sizes = stages.map((n) => {
@@ -269,7 +274,7 @@ export function Pattern({ spec }: { spec: PatternSpec }) {
: <rect key={k} x={cx + 1} y={cy + 1} width={u - 2} height={u - 2} rx={2} fill={`${col}33`} stroke={col} strokeWidth={1.5} />;
})}
<text x={x0 + (s.w * u) / 2} y={base + 16} fontSize={11} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکل ${toFa(i + 1)}`}</text>
{spec.counts !== false && <text x={x0 + (s.w * u) / 2} y={base + 30} fontSize={10.5} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(count)} ${sticks ? 'چوب' : 'تا'}`}</text>}
{spec.counts !== false && !hide && <text x={x0 + (s.w * u) / 2} y={base + 30} fontSize={10.5} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(count)} ${sticks ? 'چوب' : 'تا'}`}</text>}
</g>
);
});

125
src/diagram/space.tsx Normal file
View File

@@ -0,0 +1,125 @@
import type { ReactNode } from 'react';
import type { SpaceSpec } from './types';
import { COLORS, INK, Label, SOFT, color, mathText } from './svg';
/**
* فضای سه‌بعدی با تصویرِ مایلِ ثابت: x رو به بیننده (پایین‌چپ)، y راست، z بالا. چرخش و تعامل ندارد.
* هر نقطه (x, y, z) به صفحه می‌رود با sx = y − 0.46·x ، sy = z − 0.36·x و کلِ شکل برای جا شدن در
* کادر مقیاس می‌شود. جهتِ x کوتاه‌شده است (نسبتِ ۰٫۵۸) تا طولِ ظاهری بزرگ‌تر از واقعی دیده نشود.
*/
type XY = [number, number];
type P3 = [number, number, number];
const W = 320;
const H = 270;
const PAD = 34;
const proj = ([x, y, z]: P3): XY => [y - 0.46 * x, z - 0.36 * x];
export function Space({ spec }: { spec: SpaceSpec }) {
const pts: Record<string, P3> = { O: [0, 0, 0], ...spec.points };
const items = spec.items ?? [];
const get = (n: string): P3 | undefined => pts[n];
// کرانِ شکل: همه‌ی نقطه‌ها، جعبه‌ها و محورها (محور تا کمی بیشتر از بزرگ‌ترین مختصات)
const all: P3[] = Object.values(pts);
for (const it of items) if (it.type === 'box') all.push(it.to);
const ext = (k: 0 | 1 | 2) => Math.max(1, ...all.map((p) => Math.abs(p[k])));
const ax: P3 = [ext(0) * 1.15, ext(1) * 1.15, ext(2) * 1.15];
const corners: P3[] = [...all];
if (spec.axes !== false) corners.push([ax[0], 0, 0], [0, ax[1], 0], [0, 0, ax[2]]);
const xs = corners.map((c) => proj(c)[0]), ys = corners.map((c) => proj(c)[1]);
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
const s = Math.min((W - 2 * PAD) / Math.max(x1 - x0, 1e-6), (H - 2 * PAD) / Math.max(y1 - y0, 1e-6));
const ox = (W - (x1 - x0) * s) / 2 - x0 * s;
const oy = (H - (y1 - y0) * s) / 2 + y1 * s;
const T = (p: P3): XY => {
const [u, v] = proj(p);
return [ox + u * s, oy - v * s];
};
const els: ReactNode[] = [];
let key = 0;
const k = () => key++;
const line = (a: P3, b: P3, col: string, w = 1.8, dash?: string, arrow = false) => {
const A = T(a), B = T(b);
els.push(<line key={k()} x1={A[0]} y1={A[1]} x2={B[0]} y2={B[1]} stroke={col} strokeWidth={w} strokeDasharray={dash} strokeLinecap="round" />);
if (arrow) {
const ang = Math.atan2(B[1] - A[1], B[0] - A[0]);
const h = 9, a1 = ang + Math.PI - 0.38, a2 = ang + Math.PI + 0.38;
els.push(<path key={k()} d={`M${B[0]},${B[1]} L${B[0] + h * Math.cos(a1)},${B[1] + h * Math.sin(a1)} L${B[0] + h * Math.cos(a2)},${B[1] + h * Math.sin(a2)} Z`} fill={col} />);
}
};
const text = (p: XY, t: string | undefined, col = INK, dx = 8, dy = -7) => {
if (t) els.push(<Label key={k()} x={p[0] + dx} y={p[1] + dy} color={col} size={12}>{mathText(t)}</Label>);
};
// صفحه‌های مختصات به‌صورت شبکه‌ی کم‌رنگ پشت همه چیز
if (spec.axes !== false) {
const mk = (a: P3, b: P3, c: P3, d: P3) => {
const q = [a, b, c, d].map(T);
els.push(<path key={k()} d={`M${q.map((p) => p.join(',')).join(' L')} Z`} fill={`${COLORS.blue}0c`} stroke="none" />);
};
mk([0, 0, 0], [0, ax[1], 0], [ax[0], ax[1], 0], [ax[0], 0, 0]);
line([0, 0, 0], [ax[0], 0, 0], SOFT, 1.6, undefined, true);
line([0, 0, 0], [0, ax[1], 0], SOFT, 1.6, undefined, true);
line([0, 0, 0], [0, 0, ax[2]], SOFT, 1.6, undefined, true);
text(T([ax[0], 0, 0]), 'x', SOFT, -10, 12);
text(T([0, ax[1], 0]), 'y', SOFT, 8, 4);
text(T([0, 0, ax[2]]), 'z', SOFT, 8, 6);
}
// چندضلعی‌ها اول (نیمه‌شفاف)، بعد جعبه‌ها، پاره‌خط‌ها، نقطه‌ها
for (const it of items) {
if (it.type !== 'polygon') continue;
const ps = it.points.map(get).filter((p): p is P3 => !!p);
if (ps.length < 3) continue;
const q = ps.map(T);
const c = color(it.color, COLORS.blue);
els.push(<path key={k()} d={`M${q.map((p) => p.join(',')).join(' L')} Z`} fill={`${c}26`} stroke={c} strokeWidth={1.8} strokeLinejoin="round" />);
text([q.reduce((a, p) => a + p[0], 0) / q.length, q.reduce((a, p) => a + p[1], 0) / q.length], it.label, c, 0, 4);
}
for (const it of items) {
if (it.type !== 'box') continue;
const [a, b, c] = it.to;
const col = color(it.color, COLORS.teal);
const v = (i: number, j: number, l: number): P3 => [i * a, j * b, l * c];
const hidden: [P3, P3][] = [[v(0, 0, 0), v(1, 0, 0)], [v(0, 0, 0), v(0, 1, 0)], [v(0, 0, 0), v(0, 0, 1)]];
const shown: [P3, P3][] = [
[v(1, 0, 0), v(1, 1, 0)], [v(0, 1, 0), v(1, 1, 0)],
[v(1, 0, 0), v(1, 0, 1)], [v(0, 1, 0), v(0, 1, 1)], [v(1, 1, 0), v(1, 1, 1)],
[v(0, 0, 1), v(1, 0, 1)], [v(0, 0, 1), v(0, 1, 1)], [v(1, 0, 1), v(1, 1, 1)], [v(0, 1, 1), v(1, 1, 1)],
];
hidden.forEach(([p, q]) => line(p, q, col, 1.3, '4 3'));
shown.forEach(([p, q]) => line(p, q, col, 1.8));
}
for (const it of items) {
if (it.type === 'segment' || it.type === 'vector') {
const a = get(it.from), b = get(it.to);
if (!a || !b) continue;
const col = color(it.color, it.type === 'vector' ? COLORS.blue : INK);
line(a, b, col, 2.2, it.dashed ? '5 4' : undefined, it.type === 'vector');
const A = T(a), B = T(b);
text([(A[0] + B[0]) / 2, (A[1] + B[1]) / 2], it.label, col, 8, -6);
} else if (it.type === 'drop') {
const p = get(it.point);
if (!p) continue;
const col = color(it.color, SOFT);
line(p, [p[0], p[1], 0], col, 1.2, '3 3');
line([p[0], p[1], 0], [p[0], 0, 0], col, 1.2, '3 3');
line([p[0], p[1], 0], [0, p[1], 0], col, 1.2, '3 3');
}
}
for (const it of items) {
if (it.type !== 'point') continue;
const p = get(it.point);
if (!p) continue;
const q = T(p), col = color(it.color, INK);
els.push(<circle key={k()} cx={q[0]} cy={q[1]} r={3.6} fill={col} />);
text(q, it.label ?? it.point, col, 8, -6);
}
// مبدأ
const o = T([0, 0, 0]);
els.push(<circle key={k()} cx={o[0]} cy={o[1]} r={2.6} fill={SOFT} />);
if (!items.some((i) => i.type === 'point' && i.point === 'O')) text(o, 'O', SOFT, -9, 13);
return <svg viewBox={`0 0 ${W} ${H}`} width="100%" role="img" aria-label="شکلِ سه‌بعدی">{els}</svg>;
}

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import { createContext, type ReactNode } from 'react';
import type { ColorName } from './types';
import { toFa } from '@/lib/format';
@@ -25,6 +25,21 @@ export const COLORS: Record<ColorName, string> = {
/** ترتیبِ رنگِ سری‌ها وقتی مدل رنگ نداده (نمودارِ دوم، ستونِ سوم، …). */
export const SERIES: ColorName[] = ['blue', 'red', 'green', 'orange', 'purple', 'teal', 'pink', 'yellow'];
/** رنگِ هر ستون یا برشِ نمودار: اگر برچسب نامِ یک رنگ باشد («قرمز»، «مهره‌ی آبی (۳)») همان رنگ، وگرنه رنگ‌های
* پیش‌فرض به ترتیب، بدون رنگ‌هایی که برچسبی به نامشان هست؛ تا «قرمز» در چرخنده یا جدولِ فراوانی آبی کشیده نشود. */
const COLOR_WORDS: [string, ColorName][] = [
['قرمز', 'red'], ['سرخ', 'red'], ['آبی', 'blue'], ['سبز', 'green'], ['نارنجی', 'orange'], ['بنفش', 'purple'],
['صورتی', 'pink'], ['زرد', 'yellow'], ['خاکستری', 'gray'], ['مشکی', 'black'], ['سیاه', 'black'],
];
const namedColor = (label: string): ColorName | undefined =>
COLOR_WORDS.find(([w]) => new RegExp(`(^|[\\s‌(«])${w}($|[\\s‌)»])`).test(label))?.[1];
export function seriesColors(labels: string[], n: number): string[] {
const named = Array.from({ length: n }, (_, i) => namedColor(labels[i] ?? ''));
const free = SERIES.filter((c) => !named.includes(c));
let k = 0;
return named.map((c) => COLORS[c ?? free[k++ % free.length]]);
}
export const INK = COLORS.black;
export const SOFT = '#9EA3B8';
export const GRID = '#E4E6F0';
@@ -141,3 +156,10 @@ export function piLabel(t: number): string {
const head = n === 1 ? 'π' : `${toFa(n)}π`;
return den === 1 ? `${sign}${head}` : `${sign}${head}/${toFa(den)}`;
}
/**
* زیرِ شکلِ سوالِ آزمون، شمارشِ خودکار («۲ تکه از ۵ تکه»، «۶ خانه از ۱۲ خانه»، «۶ × ۴ = ۲۴»، «۹ تا»)
* جوابِ سوال را لو می‌داد؛ آزمون این را روشن می‌کند و شکل فقط خودِ داده را نشان می‌دهد.
* در درس و جوابِ دستیار شمارش می‌ماند چون آنجا توضیح است، نه سوال.
*/
export const HideCounts = createContext(false);

View File

@@ -117,6 +117,14 @@ export interface SolidSpec {
labels?: { a?: string; b?: string; c?: string; r?: string; h?: string };
}
export type SpaceItem =
| { type: 'point' | 'drop'; point: string; label?: string; color?: ColorName }
| { type: 'vector' | 'segment'; from: string; to: string; label?: string; dashed?: boolean; color?: ColorName }
| { type: 'polygon'; points: string[]; label?: string; color?: ColorName }
| { type: 'box'; to: [number, number, number]; label?: string; color?: ColorName };
/** فضای سه‌بعدی با تصویرِ مایلِ ثابت (x رو به بیننده، y راست، z بالا). */
export interface SpaceSpec { kind: 'space'; title: string; axes?: boolean; points: Record<string, [number, number, number]>; items: SpaceItem[] }
export type DiagramSpec =
| PlaneSpec | NumberLineSpec | FractionSpec | GridSpec | GroupsSpec | ClockSpec
| ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec | PatternSpec;
| ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec | PatternSpec | SpaceSpec;

View File

@@ -33,8 +33,126 @@ const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
// سمتِ خودشان می‌کشیدند، پس فهرست باز برعکس می‌شد.
const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const;
export function MathText({ text: raw }: { text: string }) {
// فرمولِ «واقعی» (نه عددِ تنها): نمادِ رابطه یا عمل دارد و دستِ‌کم چهار نویسه است.
const FORMULA = /[=<>≤≥≠∈∉⊂⊆∪∩+×÷^√∑∫]|[0-9۰-۹A-Za-z)][\s]*[−\-/][\s]*[0-9۰-۹A-Za-z(]/;
/**
* `display`: فرمولِ وسطِ جمله‌ی فارسی در خطِ جدا و وسط‌چین می‌نشیند (سوال‌ها و درس‌ها)، تا
* چشم وسطِ فرمول به خطِ بعد نیفتد. عددِ تنها و عبارتِ کوتاهِ بدونِ نمادِ عمل داخلِ جمله می‌ماند.
* آزموده نشده: صفحه‌ی واقعیِ اپ در Chrome و Safari؛ فقط منطقِ تشخیص با نمونه‌های بانک سنجیده شد.
*/
// ماتریس «[[۲، ۷]، [۴، ۱]]»: ردیف‌ها داخلِ کروشه‌ی تو در تو. اگر مثلِ متنِ عادی بگذاریم، در خطِ باریک
// وسطِ ردیف شکسته می‌شود و راست‌به‌چپ بودنِ جمله عددها را جابه‌جا نشان می‌دهد (گزارشِ مالک ۲۰۲۶-۱۰-۱۴).
const MATRIX = /\[\s*\[[^\[\]]*\](?:\s*[،,]\s*\[[^\[\]]*\])*\s*\]/g;
function Matrix({ src }: { src: string }) {
const rows = [...src.matchAll(/\[([^\[\]]*)\]/g)].map((m) => m[1].split(/[،,]/).map((c) => plainPersian(plainMath(c.trim()))));
const border = '2px solid currentColor';
return (
<span
dir="ltr"
style={{ display: 'inline-block', verticalAlign: 'middle', margin: '4px 6px', padding: '2px 8px', borderLeft: border, borderRight: border, borderRadius: 6, unicodeBidi: 'isolate' }}
>
{rows.map((row, i) => (
<span key={i} style={{ display: 'flex', justifyContent: 'center', gap: 14, lineHeight: 1.7, whiteSpace: 'nowrap' }}>
{row.map((c, j) => <span key={j} style={{ minWidth: 18, textAlign: 'center' }}>{c}</span>)}
</span>
))}
</span>
);
}
/**
* سطرهای ماتریس که خطِ‌به‌خط نوشته شده‌اند («b₁₁ b₁₂ ⋯ b(1n)» زیرِ «b₂₁ b₂₂ ⋯ b(2n)» و «⋮ ⋮ ⋱ ⋮»):
* بدونِ ستون‌بندی، نقطه‌های عمودی زیرِ درایه‌ها نمی‌افتند و فاصله‌ی دوتایی هم در HTML فرو می‌ریزد.
* دو خطِ پشتِ هم یا بیشتر که بدونِ حرفِ فارسی‌اند و تعدادِ توکنشان برابر (≥۳) است، یک جدول می‌شوند.
* سطرِ اول می‌تواند برچسب داشته باشد («B(m×n) =»).
*/
const PERSIAN = /[؀-ۿ]/;
function parseRow(line: string): { label: string; cells: string[] } | null {
const t = line.trim();
if (!t || PERSIAN.test(t)) return null;
const m = /^(.*?\s=\s+)(\S.*)$/.exec(t);
const label = m ? m[1].trim() : '';
const cells = (m ? m[2] : t).split(/\s+/);
// معادله («f(1) = 1² + 1 = 2») سطرِ ماتریس نیست: توکنِ عملگر یعنی ماتریس نیست.
// نمادِ تکی (+ = ⊆ → …) جز نقطه‌چین‌ها و شماره‌ی فهرست («۲)») هم یعنی ماتریس نیست.
if (cells.some((c) => (/^[^\p{L}\p{N}]$/u.test(c) && !'⋯⋮⋱…'.includes(c)) || /^[0-9۰-۹]+[.)]$/.test(c))) return null;
return cells.length >= 3 ? { label, cells } : null;
}
function splitGrids(raw: string): (string | { rows: { label: string; cells: string[] }[] })[] | null {
const lines = raw.split('\n');
const parts: (string | { rows: { label: string; cells: string[] }[] })[] = [];
let buf: string[] = [], found = false, i = 0;
while (i < lines.length) {
const first = parseRow(lines[i]);
let j = i;
const rows: { label: string; cells: string[] }[] = [];
while (first && j < lines.length) {
const r = parseRow(lines[j]);
if (!r || r.cells.length !== first.cells.length) break;
rows.push(r);
j++;
}
if (rows.length >= 2) {
if (buf.length) parts.push(buf.join('\n'));
buf = [];
parts.push({ rows });
found = true;
i = j;
} else {
buf.push(lines[i]);
i++;
}
}
if (buf.length) parts.push(buf.join('\n'));
return found ? parts : null;
}
function Grid({ rows }: { rows: { label: string; cells: string[] }[] }) {
const border = '2px solid currentColor';
const label = rows.find((r) => r.label)?.label ?? '';
const clean = (c: string) => plainPersian(plainMath(c));
return (
<span dir="ltr" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', gap: '2px 8px', margin: '6px 0', maxWidth: '100%', unicodeBidi: 'isolate', fontWeight: 700 }}>
{label && <span style={{ whiteSpace: 'nowrap' }}>{clean(label)}</span>}
<span style={{ display: 'inline-grid', gridTemplateColumns: `repeat(${rows[0].cells.length}, auto)`, columnGap: 10, rowGap: 4, padding: '2px 8px', maxWidth: '100%', overflowX: 'auto', borderLeft: border, borderRight: border, borderRadius: 6 }}>
{rows.flatMap((r, i) => r.cells.map((c, j) => <span key={`${i}-${j}`} style={{ textAlign: 'center', whiteSpace: 'nowrap', lineHeight: 1.7 }}>{clean(c)}</span>))}
</span>
</span>
);
}
export function MathText({ text: raw, display = false }: { text: string; display?: boolean }) {
const grids = splitGrids(raw);
if (grids) {
return <>{grids.map((p, k) => (typeof p === 'string' ? <MathText key={k} text={p} display={display} /> : <Grid key={k} rows={p.rows} />))}</>;
}
if (!MATRIX.test(raw)) { MATRIX.lastIndex = 0; return <Inline raw={raw} display={display} />; }
MATRIX.lastIndex = 0;
const out: React.ReactNode[] = [];
let last = 0, k = 0;
for (const m of raw.matchAll(MATRIX)) {
const at = m.index ?? 0;
if (at > last) out.push(<Inline key={`t${k++}`} raw={raw.slice(last, at)} display={display} />);
out.push(<Matrix key={`m${k++}`} src={m[0]} />);
last = at + m[0].length;
}
if (last < raw.length) out.push(<Inline key={`t${k++}`} raw={raw.slice(last)} display={display} />);
return <>{out}</>;
}
function Inline({ raw, display }: { raw: string; display: boolean }) {
const first = build(raw, display);
// دو فرمولِ بلوکی در یک جمله («a₁ = … و a₂ = …») هر کدام خطِ جدا می‌گرفتند و «و» و «.» تنها می‌ماندند.
const { nodes } = first.blocks > 1 ? build(raw, false) : first;
return <>{nodes}</>;
}
function build(raw: string, display: boolean): { nodes: React.ReactNode[]; blocks: number } {
const text = plainPersian(plainMath(raw));
let blocks = 0;
const out: React.ReactNode[] = [];
const depth = bracketDepth(text);
let last = 0, i = 0;
@@ -56,12 +174,19 @@ export function MathText({ text: raw }: { text: string }) {
run = edge.run;
if (!run || !HAS_MATH.test(run)) continue;
out.push(text.slice(last, start + head.length));
const before = text.slice(0, start + head.length), after = text.slice(start + head.length + run.length);
const inSentence = new RegExp(`[${LETTER}]`).test(before) || new RegExp(`[${LETTER}]`).test(after);
if (display && inSentence && run.trim().length >= 4 && FORMULA.test(run) && depth[start + head.length] === 0) {
blocks++;
out.push(<span key={i++} style={{ ...LTR, display: 'block', textAlign: 'center', margin: '4px 0', fontWeight: 800 }}>{run}</span>);
} else {
out.push(<span key={i++} style={LTR}>{run}</span>);
}
last = start + head.length + run.length;
}
}
out.push(text.slice(last));
return <>{out.map((n, j) => (typeof n === 'string' ? <Fragment key={`t${j}`}>{n}</Fragment> : n))}</>;
return { nodes: out.map((n, j) => (typeof n === 'string' ? <Fragment key={`t${j}`}>{n}</Fragment> : n)), blocks };
}
/** عمقِ پرانتز/کروشه/آکولاد پیش از هر نویسه‌ی متن. */
@@ -108,5 +233,16 @@ function trimUnpaired(run: string): { head: string; run: string } {
head += m;
run = run.slice(m.length);
}
// «…اگر i<j).»: پرانتزِ بسته‌ی بی‌جفتِ تهِ تکه مالِ جمله‌ی فارسی است. اگر داخلِ قفلِ چپ‌به‌راست بماند،
// سمتِ راستِ j با شکلِ «)» دیده می‌شود، نه سمتِ چپ و آینه‌شده (گزارشِ مالک ۲۰۲۶-۱۰-۱۵).
while (/[)\]}]\s*$/.test(run) && closes(run) > opens(run)) {
run = run.slice(0, run.length - /\s*[)\]}]\s*$/.exec(run)![0].length);
}
// و پرانتزِ بازِ بی‌جفتِ سرِ تکه («(مثلاً a=7 …» که بسته‌اش جای دیگری است) هم مالِ جمله است.
while (/^\s*[(\[{]/.test(run) && opens(run) > closes(run)) {
const m = /^\s*[(\[{]\s*/.exec(run)![0];
head += m;
run = run.slice(m.length);
}
return { head, run };
}

View File

@@ -28,17 +28,17 @@ export function parseNumbered(text: string): { lead: string; items: string[] } |
export function StepText({ text, color, className = '' }: { text: string; color: string; className?: string }) {
const list = parseNumbered(text);
if (!list) return <p className={`whitespace-pre-line ${className}`}><MathText text={text} /></p>;
if (!list) return <p className={`whitespace-pre-line ${className}`}><MathText text={text} display /></p>;
return (
<div className={className}>
{list.lead && <p className="mb-2 whitespace-pre-line"><MathText text={list.lead} /></p>}
{list.lead && <p className="mb-2 whitespace-pre-line"><MathText text={list.lead} display /></p>}
<ol className="flex flex-col gap-2.5">
{list.items.map((item, i) => (
<li key={i} className="flex gap-2.5">
<span className="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
{toFa(i + 1)}
</span>
<span className="flex-1 whitespace-pre-line"><MathText text={item} /></span>
<span className="flex-1 whitespace-pre-line"><MathText text={item} display /></span>
</li>
))}
</ol>

128
src/lib/lineFit.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* شکستنِ بهترِ خط برای متنِ دوطرف‌چین.
*
* مرورگر خط را حریصانه می‌شکند: کلمه‌ای که یک ذره جا نشود به خطِ بعد می‌رود و خطِ بالا برای
* پر شدن فاصله‌ی بینِ کلمه‌ها را بیش‌ازحد باز می‌کند. چاپِ حرفه‌ای (الگوریتمِ Knuth–Plass) برای
* این‌که کلمه‌ای از خطِ پایین به بالا بیاید، فاصله‌ها را کمی فشرده می‌کند. این‌جا همان را با
* `word-spacing`ِ منفیِ کوچک می‌کنیم: برای هر پاراگراف چند مقدار امتحان می‌شود، هر بار خط‌ها
* اندازه‌گیری می‌شوند و مقداری می‌ماند که فاصله‌ی نهاییِ خط‌ها به فاصله‌ی معمولی نزدیک‌تر است.
*
* فقط `style.wordSpacing` عوض می‌شود، نه DOM؛ پس با React و قفل‌های جهتِ `MathText` درگیر نمی‌شود.
* آزموده: Chrome (سرِ بی‌سر، فونتِ واقعیِ Vazirmatn)؛ روی Safari و WebViewِ اندروید دیده نشده.
*/
// برحسبِ em. فاصله‌ی معمولیِ Vazirmatn حدودِ ۰٫۲۶em است؛ تا ۰٫۰۸em فشردن (~۳۰٪) حدِ چاپِ حرفه‌ای است.
const CANDIDATES = [0, -0.02, -0.04, -0.06, -0.08];
const SPACE_EM = 0.26;
const SELECTOR = 'p, li, .whitespace-pre-line';
const MIN_CHARS = 14;
// بیشینه‌ی فاصله‌ی مجازِ بینِ کلمه‌ها نسبت به فاصله‌ی معمول، پیش از آن‌که دوطرف‌چین کنار گذاشته شود
const MAX_GAP = 2.2;
const observed = new WeakSet<HTMLElement>();
/** پهنای طبیعیِ هر خط (با ترازِ start) از مستطیل‌های Range. */
function lineWidths(el: HTMLElement, fontSize: number): number[] {
const range = document.createRange();
range.selectNodeContents(el);
const rects = [...range.getClientRects()].filter((r) => r.width > 0.5).sort((a, b) => a.top + a.height / 2 - (b.top + b.height / 2));
const lines: { y: number; left: number; right: number }[] = [];
for (const r of rects) {
const y = r.top + r.height / 2;
const last = lines[lines.length - 1];
if (last && Math.abs(y - last.y) < fontSize * 0.7) {
last.left = Math.min(last.left, r.left);
last.right = Math.max(last.right, r.right);
} else {
lines.push({ y, left: r.left, right: r.right });
}
}
return lines.map((l) => l.right - l.left);
}
export function fit(el: HTMLElement) {
if (!el.isConnected) return;
el.style.wordSpacing = '';
el.style.textAlign = '';
const cs = getComputedStyle(el);
if (cs.textAlign !== 'justify' || cs.display === 'flex' || cs.display === 'inline-flex' || cs.display === 'none') return;
const text = el.textContent ?? '';
if (text.length < MIN_CHARS) return;
const fontSize = parseFloat(cs.fontSize);
const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
if (!(width > 0)) return;
const spaces = (text.match(/\s+/g) ?? []).length;
if (spaces < 2) return;
const s0 = SPACE_EM * fontSize;
el.style.textAlign = 'start';
let best = 0, bestScore = Infinity, bestWorst = 0;
for (const c of CANDIDATES) {
el.style.wordSpacing = `${c}em`;
const w = lineWidths(el, fontSize);
if (w.length < 2) { best = 0; break; }
const total = w.reduce((a, b) => a + b, 0);
let score = 0, worst = 0;
for (let i = 0; i < w.length - 1; i++) {
const k = Math.max(1, (spaces * w[i]) / total); // فاصله‌های این خط (تخمین)
const gap = s0 + c * fontSize + Math.max(0, width - w[i]) / k;
// فشردن گران‌تر از کشیدن است (چاپِ حرفه‌ای هم همین‌طور): فشرده‌شدنِ فاصله زودتر زشت دیده می‌شود
const dev = (gap - s0) / s0;
worst = Math.max(worst, gap / s0);
score += (dev < 0 ? 2.2 * dev : dev) ** 2;
}
if (score < bestScore - 1e-6) { bestScore = score; best = c; bestWorst = worst; }
}
// حتی بهترین شکستن فاصله‌ای بیش از ۲٫۲ برابرِ معمول می‌سازد (کلمه‌ی بلند که جا نمی‌شود): چاپِ
// حرفه‌ای هم در این حالت دوطرف‌چین نمی‌کند، چون لبه‌ی نامرتب از فاصله‌ی گشاد بهتر است.
if (bestWorst > MAX_GAP) {
el.style.wordSpacing = '';
el.style.textAlign = 'start';
return;
}
el.style.textAlign = '';
el.style.wordSpacing = best ? `${best}em` : '';
}
// همه‌ی اجراها همزمان و پیش از اولین نقاشیِ صفحه‌اند (MutationObserver/ResizeObserver پیش از paint
// صدا زده می‌شوند)، پس متنِ خام و اصلاح‌نشده هرگز دیده نمی‌شود؛ تأخیرِ قبلی (۱۵۰ms) یک چشمک می‌ساخت.
const lastWidth = new WeakMap<HTMLElement, number>();
function watch(el: HTMLElement) {
if (observed.has(el)) return;
observed.add(el);
resizer.observe(el);
fit(el);
}
const resizer = new ResizeObserver((entries) => {
for (const e of entries) {
const el = e.target as HTMLElement;
const width = e.contentRect.width;
// تغییرِ ارتفاعِ خودِ fit نباید دوباره fit را راه بیندازد
if (lastWidth.get(el) === width) continue;
lastWidth.set(el, width);
fit(el);
}
});
function scan(node: ParentNode) {
if (node instanceof HTMLElement && node.matches(SELECTOR)) watch(node);
node.querySelectorAll<HTMLElement>(SELECTOR).forEach(watch);
}
export function startLineFit() {
const root = document.getElementById('root')!;
scan(root);
new MutationObserver((records) => {
const changed = new Set<HTMLElement>();
for (const r of records) {
r.addedNodes.forEach((n) => { if (n instanceof HTMLElement) scan(n); });
// متنِ داخلِ پاراگراف عوض شده (سوالِ بعدی، پاسخِ در حالِ پخش)
const host = (r.target instanceof HTMLElement ? r.target : r.target.parentElement)?.closest<HTMLElement>(SELECTOR);
if (host) changed.add(host);
}
changed.forEach(fit);
}).observe(root, { childList: true, subtree: true, characterData: true });
void document.fonts.ready.then(() => document.querySelectorAll<HTMLElement>(SELECTOR).forEach(fit));
}

View File

@@ -21,7 +21,7 @@ const SYMBOLS: Record<string, string> = {
Delta: 'Δ', lambda: 'λ', mu: 'μ', sigma: 'σ', Sigma: 'Σ', phi: 'φ', varphi: 'φ', omega: 'ω', epsilon: 'ε', rho: 'ρ', tau: 'τ',
infty: '∞', neq: '≠', ne: '≠', leq: '≤', le: '≤', geq: '≥', ge: '≥', to: '→', rightarrow: '→', leftarrow: '←',
Rightarrow: '⇒', implies: '⇒', Leftrightarrow: '⇔', iff: '⇔', perp: '⊥', parallel: '∥', circ: '°', degree: '°', ldots: '…',
dots: '…', cdots: '⋯', int: '∫', sum: 'Σ', prod: '∏', partial: '∂', in: '∈', notin: '∉', subset: '⊂', subseteq: '⊆',
dots: '…', cdots: '⋯', vdots: '⋮', ddots: '⋱', int: '∫', sum: 'Σ', prod: '∏', partial: '∂', in: '∈', notin: '∉', subset: '⊂', subseteq: '⊆',
supset: '⊃', cup: '∪', cap: '∩', emptyset: '∅', varnothing: '∅', forall: '∀', exists: '∃', angle: '∠', triangle: '△',
approx: '≈', equiv: '≡', sim: '∼', cong: '≅', mid: '|', vert: '|', neg: '¬', therefore: '∴', setminus: '−',
quad: ' ', qquad: ' ', prime: '′',
@@ -61,7 +61,10 @@ export function plainMath(text: string): string {
}
s = s.replace(new RegExp(`\\\\lim\\s*_${G}`, 'g'), 'lim($1)');
s = s.replace(/\\(left|right)\s*/g, '').replace(/\\\|/g, '|').replace(/\\\{/g, '{').replace(/\\\}/g, '}').replace(/\\[, ]/g, ' ');
s = s.replace(/(?<![A-Za-z\\])sqrt\(/g, '√(');
s = s.replace(SYMBOL_RE, (_m, n: string) => SYMBOLS[n]);
// «vdots/ddots» بدونِ بک‌اسلش (در درسِ ماتریس‌ها همین‌طور ذخیره شده بود)؛ سطرِ ماتریس نقطه‌چینِ عمودی/قطری است.
s = s.replace(/(?<![A-Za-z\\])(vdots|ddots)(?![A-Za-z])/g, (_m, n: string) => SYMBOLS[n]);
s = s.replace(new RegExp(`\\^${G}`, 'g'), (_m, a: string) => script(a, SUP, '^'));
s = s.replace(new RegExp(`_${G}`, 'g'), (_m, a: string) => script(a, SUB, '_'));
s = s.replace(/\^\(((?:[^()]|\([^()]*\))*)\)/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توان (${a.trim()}) `);

View File

@@ -31,4 +31,6 @@ export const KEYS = {
companion: 'tadrisino.companion',
/** {gradeId: subjectKey}؛ کتابی که کاربر در هر پایه آخر انتخاب کرده. */
subjects: 'tadrisino.subjects',
/** عکسِ صفحه‌ی خانه برای بازشدنِ فوری (state/homeSnapshot.ts). */
homeSnapshot: 'tadrisino.homeSnapshot',
} as const;

View File

@@ -1,10 +1,64 @@
import { StrictMode, lazy, Suspense } from 'react';
import { Component, StrictMode, lazy, Suspense, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import './styles/index.css';
import { unlockAudioOnFirstTouch } from './lib/sound';
import { startLineFit } from './lib/lineFit';
unlockAudioOnFirstTouch();
startLineFit();
/**
* فایلِ یک صفحه بعد از انتشارِ تازه پیدا نشد (اپ از قبل باز بود و نامِ فایل‌ها با هر build عوض می‌شود): راهِ پیشنهادیِ
* Vite یک بار بارگذاریِ دوباره است تا index.htmlِ تازه بیاید. تا ۳۰ ثانیه فقط یک بار، تا خرابیِ واقعیِ شبکه حلقه نشود؛
* بعد از آن خطا به RootBoundary می‌رسد.
*/
const RELOAD_KEY = 'tadrisino.chunkReloadAt';
window.addEventListener('vite:preloadError', (event) => {
let last = 0;
try {
last = Number(sessionStorage.getItem(RELOAD_KEY)) || 0;
} catch {
/* sessionStorage بسته؛ بی‌نگهبانِ حلقه بارگذاری نمی‌کنیم */
return;
}
if (Date.now() - last < 30_000) return;
event.preventDefault();
try {
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
} catch {
return;
}
window.location.reload();
});
/**
* آخرین تور: هر خطای رندر که جای دیگری گرفته نشد، به‌جای صفحه‌ی سفید (React بی‌boundary کلِ درخت را برمی‌دارد)
* پیامی با «دوباره» نشان می‌دهد. سبک‌ها درون‌خطی‌اند چون ممکن است خودِ CSS یا فونت هم نرسیده باشد.
*/
class RootBoundary extends Component<{ children: ReactNode }, { error: unknown }> {
state = { error: null as unknown };
static getDerivedStateFromError(error: unknown) {
return { error };
}
componentDidCatch(error: unknown) {
console.error('render crashed', error);
// داخلِ اپِ اندروید اسپلشِ بومی تا appReady می‌ماند و این پیام را می‌پوشاند.
window.TadrisinoAndroid?.appReady?.();
}
render() {
if (!this.state.error) return this.props.children;
return (
<div dir="rtl" role="alert" style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16, padding: 24, background: '#fff', fontFamily: 'system-ui, sans-serif', textAlign: 'center' }}>
<p style={{ fontSize: 16, fontWeight: 800, color: '#1a1a2e' }}>یه مشکلی پیش اومد</p>
<p style={{ fontSize: 13, color: '#6b6b80', lineHeight: 1.8 }}>پیشرفتت سر جاشه؛ یه بار دیگه بازش کن.</p>
<button type="button" onClick={() => window.location.reload()} style={{ border: 0, borderRadius: 16, padding: '12px 32px', background: '#ED6956', color: '#fff', fontSize: 15, fontWeight: 800 }}>
دوباره
</button>
</div>
);
}
}
// گالریِ شکل‌ها فقط در حالتِ توسعه؛ در build اصلاً وارد نمی‌شود (import.meta.env.DEV ثابت است).
const DiagramGallery = import.meta.env.DEV
@@ -14,10 +68,12 @@ const showGallery = DiagramGallery && new URLSearchParams(window.location.search
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RootBoundary>
{showGallery && DiagramGallery ? (
<Suspense fallback={null}><DiagramGallery /></Suspense>
) : (
<App />
)}
</RootBoundary>
</StrictMode>,
);

View File

@@ -201,7 +201,7 @@ export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role:
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
delta: { name: "دلتا", symbol: "Δ", role: "هم‌تیمی تمرین‌های سریع" },
pi: { name: "پی", symbol: "π", role: "همراه دلگرمی" },
tedi: { name: "امگا", symbol: "ω", role: "رفیق همیشگی تدریسینو" },
tedi: { name: "امگا", symbol: "ω", role: "رفیق همیشگی تدریسینو ریاضی" },
};
/** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراه‌ها نگاشت می‌کند. */

View File

@@ -30,6 +30,8 @@ interface Nav {
/** صفحه‌ی فعلی را عوض می‌کند بدونِ اضافه کردن به تاریخچه. */
replace: (route: Route) => void;
back: () => void;
/** از صفحه‌ی فعلی بیرون می‌رود حتی اگر برگه‌ای باز باشد (برگه هم بسته می‌شود)؛ `back` فقط خودِ برگه را می‌بندد. */
leave: () => void;
switchTab: (tab: TabName) => void;
}
@@ -90,6 +92,22 @@ export function NavigatorProvider({ initial, children }: { initial: Route; child
return () => window.removeEventListener('popstate', onPop);
}, [stepBack]);
// اپِ اصلی بسته شد (خروج، نشستِ باطل): ورودی‌هایی که گذاشته بودیم برداشته می‌شوند. بی‌این، در صفحه‌ی ورود
// (و بعد از ورودِ دوباره) هر کدام یک «برگشت»ِ بی‌اثر بود و کاربر باید چند بار می‌زد تا از اپ بیرون برود.
// یک tick صبر: StrictMode افکت را پاک و فوراً دوباره اجرا می‌کند و آن بسته‌شدنِ واقعی نیست.
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
setTimeout(() => {
if (mounted.current || entries.current === 0) return;
window.history.go(-entries.current);
entries.current = 0;
}, 0);
};
}, []);
const desired = steps.length + (stack.length - 1) + (stack[0].name !== 'home' ? 1 : 0);
useEffect(() => {
const diff = desired - entries.current;
@@ -122,6 +140,13 @@ export function NavigatorProvider({ initial, children }: { initial: Route; child
else stepBack();
}, [stepBack]);
const leave = useCallback(() => {
setSteps([]);
const s = stackRef.current;
if (s.length > 1) setState({ stack: s.slice(0, -1), direction: 'back' });
else if (s[0].name !== 'home') setState({ stack: [{ name: 'home' }], direction: 'back' });
}, []);
const switchTab = useCallback((tab: TabName) => {
// زدنِ همان تبی که باز و ریشه است صفحه را از نو نمی‌سازد (قبلاً دوباره بار می‌شد)؛ مثلِ اپ‌های بومی به
// بالای همان صفحه برمی‌گردد.
@@ -141,9 +166,10 @@ export function NavigatorProvider({ initial, children }: { initial: Route; child
push,
replace,
back,
leave,
switchTab,
};
}, [stack, direction, reselect, push, replace, back, switchTab]);
}, [stack, direction, reselect, push, replace, back, leave, switchTab]);
return (
<NavContext.Provider value={value}>

View File

@@ -60,7 +60,12 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId, diagram: m.diagram ?? null })),
),
)
.catch(() => askStore.reset(null));
.catch((err: unknown) => {
// فقط گفتگوی ناموجود (۴۰۴: حذف شده یا مالِ حسابِ دیگر) کنار می‌رود. قبلاً اینترنتِ قطع هم شناسه را پاک
// می‌کرد و گفتگو برای همیشه گم می‌شد؛ حالا historyLoaded نمی‌خورد و بارِ بعدِ باز شدنِ صفحه دوباره می‌پرسد.
if (err instanceof ApiError && err.status === 404) askStore.reset(null);
else console.warn('ai history lookup failed; keeping the conversation', err);
});
// فقط بارِ اولِ باز شدنِ صفحه؛ عمداً به chat وابسته نیست.
}, []);
@@ -112,10 +117,10 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
setQuota(done.remainingQuota);
} catch (err) {
// سقفِ روزانه (نه throttleِ چندثانیه‌ای): «دوباره» تا فردا بی‌فایده است.
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
const quotaReached = err instanceof ApiError && err.code === 'ai_daily_quota';
askStore.update(replyId, {
text: friendlyError(err, 'جوابی نرسید'),
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
...(quotaReached ? { quotaReached: true } : { failedQuestion: question, failedQuestionId: forQuestionId }),
});
if (quotaReached) setQuota(0);
} finally {
@@ -130,7 +135,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
const question = all[index - 1];
askStore.remove(message.localId);
if (question?.role === 'user') askStore.remove(question.localId);
void send(message.failedQuestion);
void send(message.failedQuestion, message.failedQuestionId);
}
const empty = chat.messages.length === 0;

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Bell, ChevronLeft, Coins, Crown, Flame, Star } from 'lucide-react';
import type { HomeNode } from '@/api';
import { Bell, ChevronLeft, Coins, Crown, Flame, RotateCw, Star } from 'lucide-react';
import { ApiError, type HomeNode } from '@/api';
import { useAppData } from '@/state/AppData';
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
@@ -20,7 +20,7 @@ import { SubjectBar } from '@/components/SubjectBar';
* دستیار، فصل‌ها، چالش) و بچه نمی‌دانست کدام را بزند؛ حالا یک کارِ واضح دارد.
*/
export function HomeScreen() {
const { gradeTitle, summary, home, homeError, unreadCount, refreshHome } = useAppData();
const { gradeTitle, summary, home, homeError, unreadCount, refreshHome, refreshSummary } = useAppData();
const nav = useNav();
// فصلِ غیرِ اول بی‌اشتراک: زدنِ جزیره یک‌راست فهرستِ اشتراک‌ها را باز می‌کند.
const paywall = () => nav.push({ name: 'subscription' });
@@ -91,6 +91,23 @@ export function HomeScreen() {
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
) : (
<div className="flex flex-col gap-2 px-4 pb-10 pt-4">
{homeError != null && (
// نقشه هست ولی تازه نشد (معمولاً عکسِ ذخیره‌شده روی گوشی و اینترنتِ قطع): بچه باید بداند
// عددها و قفل‌ها ممکن است مالِ دفعه‌ی قبل باشند.
<div role="status" className="flex items-center gap-2 rounded-xl bg-soft px-3 py-2 text-xs leading-6 text-body">
<span className="flex-1">
{homeError instanceof ApiError && homeError.isNetwork ? 'اینترنت وصل نیست یا کنده' : 'اطلاعات تازه نشد'}؛
آخرین اطلاعات ذخیره‌شده رو می‌بینی.
</span>
<button
type="button"
onClick={() => void Promise.all([refreshHome(), refreshSummary()])}
className="flex shrink-0 items-center gap-1 rounded-full bg-white px-3 py-1 font-bold text-coral ring-1 ring-line"
>
<RotateCw size={12} /> دوباره
</button>
</div>
)}
{home.units.map((unit, i) => {
const color = unitColor(i);
const p = unitProgress(unit);

View File

@@ -19,7 +19,7 @@ export function InviteScreen() {
const text = 'با تدریسینو ریاضی رو قدم‌به‌قدم یاد بگیر:';
if (navigator.share) {
try {
await navigator.share({ title: 'تدریسینو', text, url });
await navigator.share({ title: 'تدریسینو ریاضی', text, url });
return;
} catch {
/* کاربر لغو کرد؛ می‌رویم سراغِ کپی */

View File

@@ -81,7 +81,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
{toFa(i + 1)}
</span>
<span className="flex-1 whitespace-pre-line"><MathText text={step} />{figs('step', i)}</span>
<span className="flex-1 whitespace-pre-line"><MathText text={step} display />{figs('step', i)}</span>
</li>
))}
</ol>

View File

@@ -28,7 +28,9 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
const otpInput = useRef<HTMLInputElement>(null);
const phoneValid = /^09\d{9}$/.test(phone);
const otpValid = otp.length >= 4;
// کدِ سرور ۶ رقمی است و هر حدسِ غلط یکی از ۵ فرصت را می‌سوزاند؛ کدِ ناقص نباید فرستاده شود.
// فقط در حالتِ توسعه کدِ ثابتِ ۴ رقمیِ DEV_MASTER_OTP هم هست.
const otpValid = otp.length === OTP_LENGTH || (import.meta.env.DEV && otp.length === 4);
useEffect(() => {
if (resendIn <= 0) return;
@@ -75,7 +77,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
}
async function verify(code = otp) {
if (code.length < 4 || loading) return;
if (!(code.length === OTP_LENGTH || (import.meta.env.DEV && code.length === 4)) || loading) return;
setError('');
setLoading('login');
try {
@@ -106,7 +108,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-black">ورود به تدریسینو</h1>
<h1 className="text-balance text-xl font-black">ورود به تدریسینو ریاضی</h1>
<p className="mt-1 text-sm text-white/75">
{step === 'phone'
? 'شماره موبایلت رو وارد کن'
@@ -132,7 +134,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
<span className="mb-2 block text-sm font-bold text-ink">شماره موبایل</span>
<input
value={phone}
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))}
onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
dir="ltr"
inputMode="numeric"
@@ -194,3 +196,16 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
</div>
);
}
/**
* پرکردنِ خودکارِ گوشی و چسباندن معمولاً «+98 912 …» یا «0098…» می‌دهد؛ قبلاً فقط ۱۱ رقمِ اول نگه داشته می‌شد
* («98912…») و دکمه بی‌توضیح خاموش می‌ماند. سرور هم همین شکل‌ها را می‌پذیرد (accounts/phone.py).
*/
function normalizePhoneInput(value: string): string {
let digits = digitsOnly(value);
if (digits.startsWith('0098')) digits = `0${digits.slice(4)}`;
else if (digits.startsWith('98') && digits.length >= 12) digits = `0${digits.slice(2)}`;
// «98…» هنوز در حالِ تایپ است (۱۰ و ۱۱ رقمِ وسطِ «+98912…» هم با ۹ شروع می‌شوند)؛ پیش‌شماره‌ی ۰۹۸ نداریم.
else if (digits.startsWith('9') && !digits.startsWith('98') && digits.length === 10) digits = `0${digits}`;
return digits.slice(0, 11);
}

View File

@@ -8,7 +8,7 @@ const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title
{
char: 'sigma', mood: 'happy', bg: '#4C5FD5',
sub: 'از پایه سوم تا دوازدهم',
title: 'به تدریسینو خوش اومدی!',
title: 'به تدریسینو ریاضی خوش اومدی!',
desc: 'من سیگمام. با هم ریاضی کتاب درسیت رو جزیره به جزیره جلو می‌بریم؛ هر جزیره چند درس کوتاه داره و کلی تمرین.',
},
{
@@ -64,7 +64,7 @@ export function OnboardingScreen({ onDone }: { onDone: () => void }) {
</div>
<div>
<p className="mb-1 text-sm font-medium text-white/70">{slide.sub}</p>
<h1 className="mb-3 text-2xl font-black leading-snug">{slide.title}</h1>
<h1 className="mb-3 text-balance text-2xl font-black leading-snug">{slide.title}</h1>
<p className="text-sm leading-[1.9] text-white/85">{slide.desc}</p>
</div>
</motion.div>

View File

@@ -148,7 +148,7 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
<div className="flex items-center gap-3">
<span className={`glyph flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl ${s.active ? 'bg-white/20' : 'bg-[#FFF4D6] text-[#E0A200]'}`}><Crown size={20} /></span>
<span className="min-w-0 flex-1">
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراک تدریسینو</span>
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراک تدریسینو ریاضی</span>
<span className="block text-base font-black">{s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}</span>
</span>
<span className="text-left">

View File

@@ -5,7 +5,7 @@ import { api, ApiError, type AnswerResult, type Hearts, type Question } from '@/
import { useAppData } from '@/state/AppData';
import { fetchHome } from '@/state/subject';
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { useBackStep, useNav } from '@/nav/Navigator';
import { newIdempotencyKey } from '@/api/http';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarMood } from '@/mascots/seyar';
@@ -74,6 +74,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
// پشتِ هم درست؛ برای «سه تا پشتِ هم!» (حسِ پیشرفت وسطِ آزمون، نه فقط آخرش).
const [combo, setCombo] = useState(0);
useGateHold(phase.kind === 'loading');
// دکمه‌ی برگشتِ گوشی وسطِ تمرین همان «X» است: اول می‌پرسد، بی‌صدا بیرون نمی‌اندازد. برگه‌ی باز خودش قدمِ
// برگشتِ جداگانه دارد، پس برگشتِ دوم فقط برگه را می‌بندد.
useBackStep(phase.kind === 'playing' && !confirmExit, () => setConfirmExit(true));
// یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمی‌شود.
const answerKeys = useRef(new Map<string, string>());
@@ -223,6 +226,25 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
}
}
/**
* قلب برگشت: آزمون از همان سوال ادامه می‌یابد. قبلاً کلِ آزمون از نو بار می‌شد و سوال و «درست‌ها» صفر می‌شدند؛
* دانش‌آموزی که وسطِ جزیره قلبش تمام شده بود بعد از ساعت‌ها انتظار باید سوال‌های جواب‌داده را دوباره می‌زد.
*/
function resume() {
if (!questions.length) {
setReload((n) => n + 1);
return;
}
api.hearts().then(
(h) => {
setHearts(h);
if (!h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
else setPhase({ kind: 'playing' });
},
(err: unknown) => toast(friendlyError(err, 'وضعیت قلب‌ها معلوم نشد، دوباره بزن'), 'error'),
);
}
const askWhy = () =>
nav.push({
name: 'ask',
@@ -245,7 +267,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
);
}
if (phase.kind === 'noHearts') {
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={() => setReload((n) => n + 1)} />;
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={resume} />;
}
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} streakUp={streakUp} />;
if (phase.kind === 'finishing' || phase.kind === 'finishError') {
@@ -345,8 +367,8 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted">سوال {toFa(index + 1)}</span>
</div>
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} /></h2>
{question.figure ? <div className="mt-3"><Diagram spec={question.figure} /></div> : null}
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} display /></h2>
{question.figure ? <div className="mt-3"><Diagram spec={question.figure} hideCounts /></div> : null}
</div>
<div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
@@ -405,7 +427,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
</p>
<div className="flex flex-col gap-2.5">
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه می‌دم</PrimaryButton>
<SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton>
<SecondaryButton onClick={() => nav.leave()}>خروج</SecondaryButton>
</div>
</Sheet>
@@ -421,8 +443,10 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
</div>
);
}
const max = hearts?.maxHearts ?? 5;
const current = hearts?.currentHearts ?? max;
// وضعیتِ قلب نرسید: پنج قلبِ پُرِ ساختگی نشان نمی‌دهیم؛ سرور با هر جواب عددِ واقعی را برمی‌گرداند.
if (!hearts) return null;
const max = hearts.maxHearts;
const current = hearts.currentHearts;
return (
<div className="mt-3 flex items-center justify-center gap-2" aria-label={`${toFa(current)} قلب از ${toFa(max)}`}>
{Array.from({ length: max }, (_, i) => (

View File

@@ -7,7 +7,7 @@ export function SplashScreen() {
return (
<div className="boot-splash" dir="rtl">
<img src="/app-icon.png" alt="" width={144} height={144} />
<span>تدریسینو</span>
<span>تدریسینو ریاضی</span>
</div>
);
}

View File

@@ -65,7 +65,7 @@ export function SubscriptionScreen() {
<Screen>
<div className="flex-1 overflow-y-auto">
<ScreenHeader
title={active ? 'تمدید اشتراک' : 'اشتراک تدریسینو'}
title={active ? 'تمدید اشتراک' : 'اشتراک تدریسینو ریاضی'}
subtitle="همه فصل‌ها، قلب نامحدود"
color="#ED6956"
exact
@@ -216,6 +216,15 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
useEffect(() => {
if (plan) setMethod(firstUsable);
}, [plan, firstUsable]);
// برگشت از صفحه‌ی درگاه با دکمه‌ی «برگشت»: مرورگر صفحه را از bfcache با همان busy=true برمی‌گرداند و دکمه‌ی
// «پرداخت» تا ابد می‌چرخید.
useEffect(() => {
const onShow = (e: PageTransitionEvent) => {
if (e.persisted) setBusy(false);
};
window.addEventListener('pageshow', onShow);
return () => window.removeEventListener('pageshow', onShow);
}, []);
const storePrice = plan?.bazaarSku ? storePrices[plan.bazaarSku] : undefined;
/**

View File

@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
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 { homeSnapshot } from '@/state/homeSnapshot';
import { fetchHome, subjectStore } from '@/state/subject';
/**
@@ -42,16 +43,26 @@ export interface Prefetch {
export function AppDataProvider({
initialProfile,
grades,
grades: initialGrades,
prefetch,
stale = false,
onIdentityChanged,
children,
}: {
initialProfile: UserProfile;
grades: Grade[];
prefetch?: Prefetch;
/**
* داده‌ی اولیه عکسِ ذخیره‌شده روی گوشی است (state/homeSnapshot.ts)، نه پاسخِ همین حالا: همه‌چیز پشتِ صحنه
* دوباره گرفته می‌شود. همان چهار درخواستِ بوت، پس بارِ سرور بیشتر نمی‌شود؛ فقط کاربر منتظرشان نمی‌ماند.
*/
stale?: boolean;
/** پروفایلِ تازه با عکس نمی‌خواند (کاربرِ دیگر، یا بی‌نام/بی‌پایه که باید به تکمیلِ پروفایل برود). */
onIdentityChanged?: () => void;
children: ReactNode;
}) {
const [profile, setProfile] = useState(initialProfile);
const [grades, setGrades] = useState(initialGrades);
const [summary, setSummary] = useState<UserSummary | null>(prefetch?.summary ?? null);
const [home, setHome] = useState<HomeResponse | null>(prefetch?.home ?? null);
const [homeError, setHomeError] = useState<unknown>(null);
@@ -63,16 +74,21 @@ export function AppDataProvider({
const gradeId = profile.gradeId;
// نقشه‌ی کتاب‌هایی که دیده شده؛ جابه‌جایی به کتابِ دیده‌شده فوری است و پشت‌صحنه تازه می‌شود.
const homeCache = useRef(new Map<string, HomeResponse>());
// فقط پاسخِ آخرین درخواست روی صفحه می‌نشیند: وقتی پایه یا کتاب وسطِ راه عوض شود (مثلاً پروفایلِ تازه‌ی
// پشتِ صحنه پایه‌ی دیگری گفت)، پاسخِ کندترِ درخواستِ قبلی نباید نقشه‌ی تازه را بپوشاند.
const homeSeq = useRef(0);
const refreshHome = useCallback(async (forGrade?: number) => {
const target = forGrade ?? gradeId;
const seq = ++homeSeq.current;
try {
const fresh = await fetchHome(target);
if (fresh.subject) homeCache.current.set(`${target}:${fresh.subject.key}`, fresh);
if (seq !== homeSeq.current) return;
setHome(fresh);
setHomeError(null);
} catch (err) {
setHomeError(err);
if (seq === homeSeq.current) setHomeError(err);
}
}, [gradeId]);
@@ -106,10 +122,16 @@ export function AppDataProvider({
}
}, []);
// بارِ اول فقط یک بار؛ عوض شدنِ پایه refreshHome را تازه می‌سازد و نباید همه‌ی این‌ها دوباره گرفته شوند.
const started = useRef(false);
// عکس مالِ این کاربر نبود: این AppData تا برداشته شدن دیگر عکس نمی‌نویسد (پاسخِ دیرِ نقشه با پروفایلِ غلط).
const identityLost = useRef(false);
useEffect(() => {
// آنچه در اسپلش رسیده دوباره گرفته نمی‌شود؛ فقط کم‌ها.
if (!prefetch?.home) void refreshHome();
if (!prefetch?.summary) void refreshSummary();
if (started.current) return;
started.current = true;
// اعلان‌ها و همراه برای صفحه‌ی اول لازم نیستند.
const loadExtras = () => {
if (!api.auth.isSignedIn()) return;
void refreshUnread();
api.characters
.list()
@@ -118,10 +140,58 @@ export function AppDataProvider({
setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma');
})
.catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err));
// فقط بارِ اول؛ prefetch بعد از mount عوض نمی‌شود.
};
if (stale) {
api.profile
.get()
.then((fresh) => {
if (fresh.userId !== initialProfile.userId || !fresh.name || fresh.gradeId == null) {
identityLost.current = true;
onIdentityChanged?.();
return false;
}
setProfile(fresh);
// پایه در دستگاهِ دیگری عوض شده؛ نقشه‌ای که هم‌زمان با پایه‌ی عکس گرفته شد به کار نمی‌آید.
if (fresh.gradeId !== initialProfile.gradeId) void refreshHome(fresh.gradeId);
return true;
})
// ۴۰۱ را http خودش به خروج می‌برد. بقیه (اینترنتِ قطع، ۵xx): پروفایلِ عکس می‌ماند و نوارِ «اطلاعاتِ
// ذخیره‌شده» در خانه (homeError) خبر می‌دهد؛ نقشه هم‌زمان همین درخواست‌ها را می‌زند و همان‌جا شکست می‌خورد.
.catch((err: unknown) => {
console.warn('profile refresh failed; showing the stored one', err);
return true;
})
// بعد از پروفایل، نه هم‌زمان: با توکنِ یک‌ساعته‌ی منقضی هر درخواستِ هم‌زمان یک ۴۰۱ و یک تکرار می‌شود؛
// این‌طور تعدادِ درخواست‌ها همان بوتِ قبلی است (۴ + refresh + ۴ تکرار + ۲).
.then((stays) => stays && loadExtras());
api
.grades()
.then(setGrades)
.catch((err: unknown) => console.warn('grades refresh failed; keeping the stored list', err));
void refreshHome();
void refreshSummary();
} else {
// آنچه در اسپلش رسیده دوباره گرفته نمی‌شود؛ فقط کم‌ها.
if (!prefetch?.home) void refreshHome();
if (!prefetch?.summary) void refreshSummary();
loadExtras();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshHome, refreshSummary, refreshUnread]);
// هر داده‌ی تازه عکسِ خانه را به‌روز می‌کند. بارِ اولِ حالتِ stale همان عکسِ خوانده‌شده است و نوشته نمی‌شود،
// تا savedAt فقط با پاسخِ سرور جلو برود.
const skipSnapshotWrite = useRef(stale);
useEffect(() => {
if (skipSnapshotWrite.current) {
skipSnapshotWrite.current = false;
return;
}
// پاسخی که بعد از خروج رسید (توکن پاک شده) نباید عکسِ تازه بسازد.
if (!home || !summary || identityLost.current || !api.auth.isSignedIn()) return;
homeSnapshot.save({ profile, grades, home, summary });
}, [profile, grades, home, summary]);
const value = useMemo<AppData>(
() => ({
profile,

View File

@@ -18,6 +18,8 @@ export interface ChatMessage {
feedback?: 'up' | 'down';
/** پیامِ خطا به‌جای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده می‌شود. */
failedQuestion?: string;
/** سوالِ آزمونی که پرسش درباره‌اش بود («چرا غلط بود؟»)؛ «دوباره» باید همان را دوباره بفرستد. */
failedQuestionId?: string;
/** سهمیه‌ی رایگانِ امروز تمام شده؛ به‌جای «دوباره»، راهِ اشتراک نشان داده می‌شود. */
quotaReached?: boolean;
}

63
src/state/homeSnapshot.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { Grade, HomeResponse, UserProfile, UserSummary } from '@/api';
import { KEYS, store } from '@/lib/storage';
/**
* عکسِ صفحه‌ی خانه روی گوشی: آخرین پروفایل، پایه‌ها، نقشه و آمار. اپ با آن فوراً خانه را نشان می‌دهد و
* همان درخواست‌های همیشگی را پشتِ صحنه می‌زند (AppData، `stale`)؛ بدونِ آن اسپلش تا رسیدنِ هر چهار پاسخ
* می‌ماند، که روی اینترنتِ ضعیف و با توکنِ یک‌ساعته‌ی منقضی (۴۰۱ ← refresh ← تکرار) چند رفت‌وبرگشتِ پشتِ هم است.
*
* درستی را سرور نگه می‌دارد، نه این عکس: جزیره‌ی قفل (۴۰۳)، اشتراکِ تمام‌شده (۴۰۲) و قلبِ صفر (۴۰۳) همان‌جا
* رد می‌شوند؛ عکسِ قدیمی فقط ممکن است یکی دو ثانیه عددِ کهنه نشان دهد.
*
* با پاک شدنِ توکن (خروج، حذفِ حساب، نشستِ باطل) و با ورودِ تازه پاک می‌شود (api/index.ts)، تا خانه‌ی یک
* کاربر روی گوشیِ مشترک به کاربرِ بعدی نرسد.
*/
export interface HomeSnapshot {
build: string;
savedAt: number;
profile: UserProfile;
grades: Grade[];
home: HomeResponse;
summary: UserSummary;
}
/**
* عکسِ کهنه‌تر از این کنار گذاشته می‌شود و اپ مثلِ قبل منتظرِ سرور می‌ماند. انتخابِ محصولی: کسی که یک هفته
* نیامده، آمدنِ یک‌باره‌ی نقشه‌ی تازه را به دیدنِ نقشه‌ی یک هفته پیش ترجیح می‌دهد.
*/
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
export const homeSnapshot = {
/** عکسِ قابلِ استفاده، یا null: نبود، مالِ بیلدِ دیگری بود (شکلِ داده ممکن است عوض شده باشد)، کهنه یا ناقص بود. */
read(): HomeSnapshot | null {
const raw = store.get(KEYS.homeSnapshot);
if (!raw) return null;
let snap: Partial<HomeSnapshot>;
try {
snap = JSON.parse(raw) as Partial<HomeSnapshot>;
} catch (err) {
console.warn('home snapshot unreadable; dropped', err);
store.remove(KEYS.homeSnapshot);
return null;
}
const usable =
snap.build === __APP_BUILD__ &&
typeof snap.savedAt === 'number' &&
Date.now() - snap.savedAt < MAX_AGE_MS &&
// بی‌نام یا بی‌پایه یعنی کاربر باید به «تکمیلِ پروفایل» برود؛ آن تصمیم با پاسخِ سرور است.
Boolean(snap.profile?.name) &&
snap.profile?.gradeId != null &&
Array.isArray(snap.grades) &&
Array.isArray(snap.home?.units) &&
snap.summary != null;
return usable ? (snap as HomeSnapshot) : null;
},
save(data: Omit<HomeSnapshot, 'build' | 'savedAt'>) {
store.set(KEYS.homeSnapshot, JSON.stringify({ ...data, build: __APP_BUILD__, savedAt: Date.now() }));
},
clear() {
store.remove(KEYS.homeSnapshot);
},
};

View File

@@ -79,6 +79,24 @@
input, textarea {
font: inherit;
}
/* متنِ چندخطی (پاراگراف، گام‌های درس، متنِ سوال، حبابِ پی) دو‌طرف‌چین است تا لبه‌ی چپ
مرتب باشد. این‌ها در لایه‌ی base‌اند، پس هر جا کلاسِ text-center / text-left داده شده
برنده است و تیترها و برچسب‌های کوتاه سرِ جایشان می‌مانند. خطِ آخر طبقِ معمول راست‌چین
می‌ماند. text-justify فقط فاصله‌ی بینِ کلمه‌ها را باز می‌کند (کشیده‌نویسی در CSS نیست). */
p, li, .whitespace-pre-line {
text-align: justify;
text-justify: inter-word;
/* از تک‌کلمه‌ی یتیم در خطِ آخر جلوگیری می‌کند */
text-wrap: pretty;
}
/* پاراگرافِ داخلِ بخشِ وسط‌چین (حالتِ خالی، آنبوردینگ، اسپلش) وسط‌چین می‌ماند؛
بدونِ این، قاعده‌ی بالا ارثِ text-center را می‌شکست. */
.text-center :is(p, li, .whitespace-pre-line) {
text-align: center;
}
.text-left :is(p, li, .whitespace-pre-line) {
text-align: left;
}
}
@utility no-scrollbar {

3
src/vite-env.d.ts vendored
View File

@@ -1,5 +1,8 @@
/// <reference types="vite/client" />
/** شناسه‌ی هر بیلد (vite.config.ts)؛ عکسِ خانه‌ی بیلدِ دیگر خوانده نمی‌شود. */
declare const __APP_BUILD__: string;
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}

View File

@@ -9,6 +9,9 @@ export default defineConfig(({ mode }) => {
return {
plugins: [react(), tailwindcss()],
// عکسِ خانه (src/state/homeSnapshot.ts) فقط با همان بیلدی خوانده می‌شود که نوشته‌اش؛ بعد از هر دیپلوی
// بارِ اول مثلِ قبل از سرور می‌آید، تا داده‌ای با شکلِ قدیمی به کدِ تازه نرسد.
define: { __APP_BUILD__: JSON.stringify(Date.now().toString(36)) },
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},