47 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
3a0192518c README rewritten as a formal entry page for the current app; Android version note corrected
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:47:55 +03:30
ca96a91dee No diacritics or written ezafe ی in the UI; map without the speech banner, companions cheering along the path; familiar payment sheet with gateway logos and per-channel methods; account deletion moved under «حساب کاربری»
- Text: kasra/fatha/damma/shadda/sukun/hamza-above and «ه‌ی» ezafe removed from every user-visible string (comments untouched); tanwin kept. Stale copy fixed (question packs are no longer sold), awkward onboarding sentences rewritten, سؤال → سوال. plainPersian() in MathText cleans the live tutor stream.
- Home: the mascot + speech bubble header is gone; the selected companion hops beside the current island and sits at every third bend (CSS transform animations only).
- Checkout: order summary, payment-method rows with Zarinpal/Bazaar icons and radio, amount on the pay button, lock trust line, darker overlay. paymentMethods in config.js per channel (web: zarinpal, app: zarinpal+bazaar by default).
- Profile: «ورود با حساب دیگر» removed (it was logout); new «حساب کاربری» sheet shows phone/ID and holds «حذف حساب کاربری»; logout is a red row. Delete sheet tells Bazaar subscribers to cancel renewal in Bazaar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:55:17 +03:30
4a661f504f Offline wording back to "اینترنت قطع است / کند است" (owner's choice)
This reverts commit beeada6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:07:18 +03:30
beeada6814 Offline wording: "اینترنت متصل نیست" under the app's splash image (Android) and in web error messages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:05:53 +03:30
872e272ee1 Bazaar: clear message when Bazaar is missing, old or not connected; reconnect before the next try
Android: buy() checks the Poolakey connection state and reconnects; BazaarNotFound / not supported / dynamic price
not supported become error "unavailable" (not_installed | old_bazaar | not_connected) instead of a generic failure.
assembleDebug green: com.tadrisino.math.app, versionCode 2, queries com.farsitel.bazaar. Not run on a phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:56:54 +03:30
854149bb5d Subscriptions only: no question credits anywhere; Bazaar bridge is subscribeProduct only (bazaarBuy(sku, orderId, token), bazaarPrices(skusJson)), no consume
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:33:47 +03:30
e0a9605b8e Store sells subscriptions only: each card lists what you get (features + assistant questions/day), tab renamed, assistant points to subscription
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:57:18 +03:30
d440986b88 Checkout: Zarinpal is the default; Bazaar tile shows the price Bazaar itself has (Poolakey getSkuDetails bridge)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:21:48 +03:30
fffdeac3ed Bazaar: subscriptions (subscribeProduct), dynamic price token, RSA key; Zarinpal and Bazaar side by side at checkout
- Checkout sheet always shows both methods as two tiles; Bazaar tile disabled with the reason
  (not in Bazaar / Android app only / update the app) instead of hidden; default is Bazaar inside an app that can buy.
- Bridge v2: TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken); inAndroidApp() split
  from canBuyFromBazaar() so the pay.html workaround still applies to the installed APK.
- Bazaar flow: order -> optional discount code -> POST /payment/bazaar/orders/{id}/start -> Poolakey -> verify;
  consume only packs.
- Android: RSA key, subscribeProduct/getSubscribedProducts for subscriptions, dynamicPriceToken, versionCode 2.
  compileDebugKotlin green; no purchase tested on a phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:51:03 +03:30
3a2a685ed3 FeedbackBar: like/dislike/report under every quiz question and lesson 2026-10-12 12:56:52 +03:30
41f1c993ee plainMath: identifiers like OPENAI_API_KEY untouched, log base text 2026-10-12 12:18:48 +03:30
f2263e6dff MathText: last-line guard that turns stray LaTeX/markdown into plain Unicode 2026-10-12 12:15:31 +03:30
cf2a3251dd Quiz: draw the question figure under the prompt (Question.figure) 2026-10-12 11:58:42 +03:30
18eeb54327 Island label shows up to 3 lines so the part number of split islands stays visible
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 10:54:34 +03:30
f569ece55b Subject bar under the header in Quizzes and Lessons, synced between tabs; bidi fix for Latin math in RTL text
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 05:35:30 +03:30
56dcc93202 android/README: splash/offline are the APK's job; acceptance checklist incl. VPN-on white page 2026-10-11 22:57:57 +03:30
186dc208be GET requests time out after 8s (was 15s): the 'internet is slow' message and retry come sooner
Writes keep 15s so an early cut does not make the user submit twice. Worst-case boot
wait with an unreachable API goes from ~50s to ~27s (3 attempts + backoff). Not tested
on a real slow network.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 22:54:21 +03:30
b635b15c7d android/README: splash and offline screen join the next-APK list 2026-10-11 22:52:17 +03:30
0d6396e0de Users never see error text: offline/slow wording in app and web; technical details are never shown
friendlyError now shows a server detail only when it is Persian prose (no 'خطای 404', no
DRF English defaults); network errors say 'اینترنت قطع است' (navigator offline) or
'اینترنت کند است' (timeout). The native connecting screen picks the same two wordings from
ConnectivityManager (ACCESS_NETWORK_STATE). Debug build compiles; not run on a device.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 22:50:58 +03:30
ab02bb43b9 Native splash held while connecting; offline shows the same picture with a connecting line; web splash hidden inside the app
Inside the app the web splash is invisible with the new APK (the system splash is still on
top) and icon-only for old APKs, so there is no second picture to switch to. Offline or a
5xx on the main frame: system splash released onto the same icon (288dp drawable) with
'در حال اتصال به اینترنت…', retried every 5s. A slow-but-online load shows nothing extra.
Debug build compiles; not run on a device.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 22:48:06 +03:30
446cf36103 Keep the system splash until the first real screen: no blank WebView gap, no second splash
Installed APK sequence was system splash -> white WebView -> web splash -> home. The
activity now holds the system splash (core-splashscreen) until the page calls
TadrisinoAndroid.appReady(), after its first non-boot screen; released after 10s or on a
main-frame load error so a dead site shows the WebView error, not an endless splash.
Older APKs have no appReady and are unaffected. Debug build compiles; not run on a device.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 22:41:07 +03:30
3520279143 One static splash: app icon + name in index.html from the first frame, no companion, no timers
The first second was a blank page, then a splash with the selected companion, then the
home screen. Now index.html carries the splash (icon from the Android launcher, name,
white to match the native window background) and SplashScreen draws the same markup, so
the hand-over from HTML to React shows no change. Not run on a device.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 22:33:02 +03:30
16cf5c91f7 Spread retries over 1-2x Retry-After so a crowd turned away together does not return together
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:55:29 +03:30
c7407f32b9 Splash only appears when the server is slow: no flash of the welcome screen on a fast start
The splash used to stay a fixed 700ms plus a fade, so a fast response still showed it
briefly and jumped away. Now only the matching background shows for the first 350ms;
the splash content appears only if the response has not arrived by then, and once shown
it stays at least 700ms. Not run on a device.
2026-10-11 01:19:34 +03:30
8a61cb25b8 The selected companion everywhere, each with a small trait; next-APK notes
- state/companion.ts: one store for the chosen companion (also in localStorage so splash/login show it).
  BrandMark (the orange «π» tile) now shows the companion's symbol (Σ Δ π ω) in its colour; screen-header
  mascots, loaders and empty/error states default to it; the assistant is «از {name} بپرس» with its face,
  colour and wording; the shop, lesson and quiz «ask» buttons name it; the active tab takes its tint.
- Traits, all client-side and light: Σ sigma keeps a running tally of correct answers in the quiz header;
  Δ delta shows a flame «×N» chip and an extra buzz for answers in a row; π pi gives a short solving tip
  after a wrong answer; ω omega throws gold confetti at the end of every island whatever the score.
  Each companion's «correct» sound is pitched differently; confetti uses its colours. Characters screen
  lists each trait.
- android/README.md: what MainActivity.kt has that the installed APK lacks (vibrate bridge, media gesture,
  in-app gateway + paysInApp), what to check on a phone after building, signing owner, Bazaar policy note.

Checked in headless Chrome with Sigma, Delta and Omega selected (home, assistant, shop, characters, quiz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:51:19 +03:30
737414f246 Payment from the Android app: reach Zarinpal through pay.html so it carries our Referer
The app hands every outside URL to the phone browser, which sends no Referer, and Zarinpal's StartPay
refuses a request without our domain as Referer («دسترسی از این دامنه مجاز نمی‌باشد»). Checked against
production on a pending order: no Referer → refused; Referer https://math.tadrisino2.ir/ → forwards to
the bank (sep.shaparak.ir).

- pay.html + src/pay.ts: opened in the phone browser, forwards only to our own
  /api/payment/orders/<id>/pay link (anything else is refused), so the Referer is math.tadrisino2.ir.
  The app opens it as http:// (the WebView keeps only https on its own host); ArvanCloud moves it to
  https, and the link rides in the fragment, which never reaches a server.
- MainActivity.kt (next APK): payment pages (our subdomains, zarinpal, shaparak) stay inside the WebView
  and `paysInApp()` tells the frontend to navigate directly. Not built.

Not checked: a full payment on a real phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:40:10 +03:30
468f5b2d29 Calmer shop with payment choice, step-by-step phone back, sound/haptics that reach the phone, live streak flame
- Shop: subscription perks shown once (each card repeated them, «۳ سوال در روز» twice), plans as selectable
  rows with one big buy button, prices as «۷۹۸ هزار تومان», a single clear status card for subscribers.
  Checkout inside the Bazaar app offers «کارتِ بانکی» (default) or «کافه‌بازار» for packs with a SKU;
  «به‌زودی در بازار» is gone. The gateway opens in the phone browser; status refreshes on return.
- Back button: every back-able level (open sheet, pushed screen, non-home tab, shop list) owns exactly one
  history entry created right after the tap; back closes the sheet, then the screen, then returns to Home,
  then leaves the app. (A re-pushed trap is skipped by Chrome's history intervention.)
- Sound: AudioContext unlocked on the first touch (it was created after a network wait, which WebView/Safari
  keep muted); softer notes, option tick, finish fanfare, streak whoosh. Haptics through the new
  TadrisinoAndroid.vibrate bridge when the APK has it, navigator.vibrate otherwise.
- Streak: animated flame with lit / at-risk (grey) / frozen (ice) states on Home and the streak screen,
  a companion reminder when the streak is at risk or frozen, and a full-screen «روزِ پیاپی» celebration
  after the first lesson of the day (needs backend 9024c6b: streak.activeToday / lost).
- Lessons: intro/example text with inline «۱. … ۲. …» renders as a numbered list (2,250 of 4,469 texts).
- Quiz: duplicate React key on the answer bubble fixed.
- android/MainActivity.kt: vibrate bridge and mediaPlaybackRequiresUserGesture=false. Not built; needs the
  next APK from the signing owner.

Checked in headless Chrome against the local backend (shop as a subscriber with the Bazaar bridge stubbed,
back sequences, quiz → streak celebration, frozen state). Not checked on a real phone or in the APK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:27:24 +03:30
eafee76a93 Smooth screen transitions: new screen waits hidden until its data arrives, no half-built flash
- Navigator stage: the next screen mounts hidden under the current one and is revealed only when its
  first data has arrived (ScreenGate held by useAsync/useGateHold), at most 450 ms; a thin top bar shows
  after 120 ms of waiting. Push slides from the left, back from the right, tabs only fade.
- Session cache (stale-while-revalidate) for screen GETs: revisited screens draw instantly.
- Home map and stats are fetched during the splash, so the first screen arrives complete.
- Entrance pop-ins (mascot, islands, lesson cards) run on first view only, not on every revisit.
- Shop status card shows placeholders instead of a wrong «نداری» while loading.
- Re-tapping the open tab scrolls to top instead of reloading; sheets close with a swipe down.
- Quiz: «۱ از ۴» instead of «۱/۴» (read as a fraction), varied praise, «N تا پشتِ هم درست»,
  growth-mindset copy after a weak round, zero-reward tiles hidden.
- Streak: a concrete next step when the streak is 0 or today is still empty.

Checked in headless Chrome against the local backend (frame captures at 150 ms and 700 ms latency,
rapid tab tapping, full quiz run). Not checked on a real phone or iOS Safari.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:55:47 +03:30
19ca3d7de1 Merge personal-fork Android build commit; keep MH-W90's signing flow from upstream (owner of Android signing)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:41:20 +03:30
f74b6ad279 Companions: each wears its own Greek letter badge (Δ, π, ω); Tedi renamed Omega (ω)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:40:52 +03:30
5327fed1dd Android: reproducible signed build (keystore and Bazaar RSA key read from outside the repo), Gradle wrapper, build-apk.sh
First time the project has been built: debug and release both produce an APK.
Release refuses to build without keystore or RSA key.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 14:05:48 +03:30
88 changed files with 3865 additions and 1029 deletions

138
README.md
View File

@@ -1,7 +1,20 @@
# تدریسینو — فرانتِ اپِ ریاضی
# تدریسینو — اپِ ریاضی (فرانت‌اند)
اپِ وبِ موبایل‌محورِ تدریسینو (React + TypeScript + Vite + Tailwind). بک‌اندش ریپوی
جداست (`Tadrisino/MathApp`)؛ قراردادِ API در `/api/docs/` همان بک‌اند است.
اپِ وبِ موبایل‌محورِ **تدریسینو**، آموزشِ ریاضی به سبکِ دولینگو برای پایه‌ی سوم تا دوازدهم: نقشه‌ای از جزیره‌ها برای هر
کتاب، آزمون‌های کوتاه از مسئله‌های خودِ کتابِ درسی، دستیارِ هوشِ مصنوعی، همراه‌ها، زنجیره‌ی روزانه و اشتراک. همین
مخزن پوسته‌ی اندرویدِ اپ را هم دارد ([`android/`](android/README.md)).
بک‌اند مخزنِ جداگانه‌ای است ([`Tadrisino/MathApp`](https://gitea.tadrisino2.ir/Tadrisino/MathApp)) و قراردادِ API
اسکیمای OpenAPIِ همان‌جاست. این اپ تنها کلاینتِ آن API است.
| | |
|---|---|
| **سایتِ زنده** | `https://math.tadrisino2.ir` · API: `https://mathapi.tadrisino2.ir` |
| **فناوری** | React 18 · TypeScript (strict) · Vite 6 · Tailwind 4 · Motion |
| **وابستگی‌ها** | عمداً کم: بی‌کتابخانه‌ی router و state؛ ناوبری و داده‌ی مشترک در خودِ کد |
| **اندروید** | پوسته‌ی WebView روی همین سایت، با پرداختِ درون‌برنامه‌ایِ کافه‌بازار (Poolakey)؛ بسته‌ی `com.tadrisino.math.app` |
---
## راه‌اندازی
@@ -10,15 +23,14 @@ npm install
npm run dev # http://localhost:5173
```
در حالتِ توسعه، درخواست‌های `/api` از خودِ Vite به بک‌اند پاس داده می‌شوند، پس CORS
مطرح نیست. پیش‌فرض بک‌اندِ محلی است (`python manage.py runserver` روی پورتِ ۸۰۰۰).
بدونِ بک‌اندِ محلی، یک فایلِ `.env.development.local` بسازید:
در حالتِ توسعه، Vite درخواست‌های `/api` را به بک‌اند پاس می‌دهد، پس CORS مطرح نیست. پیش‌فرض بک‌اندِ محلی است
(`python manage.py runserver` روی پورتِ ۸۰۰۰). بدونِ بک‌اندِ محلی، فایلِ `.env.development.local` بسازید:
```bash
VITE_DEV_PROXY_TARGET=https://mathapi.tadrisino2.ir
```
⚠️ آن‌وقت هر کاری در اپ بکنید روی دیتابیسِ واقعی نوشته می‌شود.
> ⚠️ در این حالت هر کاری در اپ بکنید روی دیتابیسِ واقعی نوشته می‌شود.
| دستور | کار |
|---|---|
@@ -27,65 +39,87 @@ VITE_DEV_PROXY_TARGET=https://mathapi.tadrisino2.ir
| `npm run build` | type-check و بعد build در `dist/` |
| `npm run preview` | اجرای همان build |
## آدرسِ بک‌اند و عوض کردنِ دامنه
دامنه هیچ‌جای کد نیامده. آدرسِ API به این ترتیب خوانده می‌شود
(`src/config.ts`):
1. **`dist/config.js`** (از `public/config.js`) — در زمانِ اجرا. بعد از build هم روی
سرور قابلِ ویرایش است و build دوباره نمی‌خواهد.
2. **`VITE_API_BASE_URL`** — در زمانِ build، از `.env.production`.
3. **`/api`** — هم‌دامنه، وقتی nginxِ دامنه‌ی فرانت مسیرِ `/api` را به بک‌اند پراکسی کند.
برای جابه‌جایی دامنه:
- **سریع، بدونِ build:** روی سرور در `dist/config.js` بنویسید
`apiBaseUrl: "https://api.new-domain.ir/api"` و کشِ CDN را برای همین فایل پاک کنید.
- **ماندگار:** خطِ `VITE_API_BASE_URL` در `.env.production` را عوض کنید و build بگیرید.
- **سمتِ بک‌اند** هم دامنه‌ی تازه‌ی فرانت باید مجاز باشد (`FRONTEND_URL` در `.env`
بک‌اند). جزئیات در `docs/operations.md` همان ریپو.
## دیپلوی
روی سرور (`~/mathapp_frontend_new`، که nginxِ `math.tadrisino2.ir` از `dist/`ش سرو می‌کند):
```bash
./deploy.sh # fetch، npm ci، type-check، build در dist.next و جابه‌جاییِ بی‌وقفه
```
buildِ قبلی در `dist.prev` می‌ماند (برگشت: `rm -rf dist && mv dist.prev dist`)، و
`dist/config.js`ِ ویرایش‌شده روی سرور به buildِ تازه منتقل می‌شود.
جای دیگر: `npm ci && npm run build` و سرو کردنِ `dist/`. اپ مسیرِ جدا (route) ندارد و همه‌چیز از `/` بار می‌شود؛
فقط `index.html` و `config.js` نباید طولانی کش شوند (باقیِ فایل‌ها نامِ hashدار دارند).
درگاهِ پرداخت کاربر را به `/?orderId=…&status=…` برمی‌گرداند و لینکِ دعوت
`/?ref=CODE` است؛ هر دو را خودِ اپ می‌خواند.
---
## ساختار
```
src/
config.ts آدرسِ API (بالا را ببینید)
config.ts آدرسِ API (بخشِ پایین)
api/ تنها درِ ارتباط با سرور — هیچ جای دیگر fetch نمی‌زند
http.ts timeout، تکرارِ GET، refreshِ تک‌نسخه‌ای، SSE
http.ts توکن، timeout، تکرارِ GET، refreshِ تک‌نسخه‌ای، SSE، Idempotency-Key
index.ts یک تابع برای هر اندپوینت
types.ts شکلِ پاسخ‌ها، از روی پاسخِ واقعیِ سرور
app/ App (جریانِ ورود) و MainApp (تب‌ها و صفحه‌ها)
nav/ ناوبریِ پشته‌ای، هماهنگ با دکمه‌ی back گوشی
state/ داده‌ی مشترک (پروفایل، نقشه، …) و گفتگوی دستیار
state/ داده‌ی مشترک (پروفایل، نقشه، کتابِ انتخاب‌شده، همراه) و گفتگوی دستیار
screens/ یک فایل برای هر صفحه
components/ اجزای مشترک (سربرگ، برگه‌ی پایین، …)
mascots/ همراه‌ها: سیگما، دلتا، پی، تدی
diagram/ شکلِ جوابِ دستیار: توصیفِ JSON از بک‌اند → SVG؛ همه‌ی مختصات این‌جا حساب می‌شود
(plane، shapes، expr، solid، charts، sets، misc، numberline). گالری: ?gallery فقط در dev
lib/ اعدادِ فارسی، تاریخِ شمسی، صدا، localStorage
components/ اجزای مشترک: مسیرِ جزیره‌ها، نوارِ کتاب‌ها، برگه‌ی پایین، بازخورد، …
mascots/ همراه‌ها (سیگما، دلتا، پی، امگا): SVG از کد، شش حالتِ چهره
diagram/ شکلِ درس و جوابِ دستیار: توصیفِ JSON از بک‌اند → SVG؛ همه‌ی مختصات این‌جا حساب می‌شود
lib/ اعدادِ فارسی، تاریخِ شمسی، MathText، پیامِ خطا، صدا، پلِ کافه‌بازار، روش‌های پرداخت
android/ پوسته‌ی اندروید (README خودش)
pay.html · src/pay.ts صفحه‌ی واسطِ پرداختِ زرین‌پال برای اپِ اندرویدِ فعلی
```
قاعده‌ها:
- **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ
`۰-۹` می‌فرستد و `\d` در JS آن‌ها را نمی‌شناسد).
- **کلاینت را از حافظه ننویسید.** قرارداد را از اسکیمای بک‌اند بخوانید
(`python manage.py spectacular --file schema.yaml --format openapi`) و هر تغییرِ `types.ts` را با پاسخِ واقعیِ سرور بسنجید.
- **صفحه‌ها هرگز مستقیم سرور را صدا نمی‌زنند:** همیشه `screens/* → api/index.ts → api/http.ts`.
- **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ `۰-۹` می‌فرستد و `\d` در JS آن‌ها را نمی‌شناسد).
- **رنگ‌ها** در `src/styles/index.css` (`@theme`) تعریف شده‌اند: `bg-coral`، `text-ink`، …
- **خطا:** پیامِ کاربر همیشه از `friendlyError` می‌آید، نه متنِ خامِ سرور.
- **کلاینت را از حافظه ننویسید.** قرارداد را از `/api/docs/` بک‌اند بخوانید و تغییرِ
`types.ts` را با پاسخِ واقعیِ سرور بسنجید.
- **بازشدنِ فوری:** کاربرِ واردشده خانه را از عکسِ روی گوشی می‌بیند (`state/homeSnapshot.ts`: پروفایل، پایه‌ها، نقشه،
آمار) و همان درخواست‌های بوت پشتِ صحنه تازه‌اش می‌کنند. عکس با پاک شدنِ توکن و ورودِ تازه پاک می‌شود، با بیلدِ دیگر
یا کهنه‌تر از ۷ روز خوانده نمی‌شود، و درستی (قفل، اشتراک، قلب) همیشه با سرور است. داده‌ی تازه‌ای که در خانه دیده
می‌شود باید در `AppData` بماند تا در عکس هم برود.
- **متنِ فارسی** بی‌اعراب و بی «ی»ِ اضافه‌ی نوشته‌شده («پایه‌ی سوم») است (تصمیمِ شرکت)؛ تنوین («قبلاً») می‌ماند. متنِ تازه
را همین‌طور بنویسید. متنِ سرور را بک‌اند هنگامِ ساختنِ پاسخ پاک می‌کند و `plainPersian` در `MathText` همان کار را برای
پاسخِ زنده‌ی دستیار. `lib/plainMath.ts` هر LaTeX یا markdownی را که برسد ساده می‌کند و باید با `content/plain_math.py`ِ
بک‌اند هم‌رفتار بماند.
---
## آدرسِ بک‌اند و عوض کردنِ دامنه
دامنه هیچ‌جای کد نیامده. آدرسِ API به این ترتیب خوانده می‌شود (`src/config.ts`):
1. **`dist/config.js`** (از `public/config.js`) — در زمانِ اجرا؛ بعد از build هم روی سرور ویرایش‌شدنی است.
2. **`VITE_API_BASE_URL`** — در زمانِ build، از `.env.production`.
3. **`/api`** — هم‌دامنه، وقتی nginxِ دامنه‌ی فرانت مسیرِ `/api` را به بک‌اند پراکسی کند.
برای جابه‌جاییِ دامنه:
- **سریع، بدونِ build:** روی سرور در `dist/config.js` بنویسید `apiBaseUrl: "https://<دامنه‌ی تازه>/api"` و کشِ آروان را
برای همین فایل پاک کنید.
- **ماندگار:** خطِ `VITE_API_BASE_URL` در `.env.production` را عوض کنید و build بگیرید.
- **سمتِ بک‌اند** دامنه‌ی تازه‌ی فرانت باید مجاز باشد (`FRONTEND_URL`)، و در CSPِ nginx (`connect-src`) دامنه‌ی تازه‌ی API
بیاید. رویه‌ی کامل: `docs/operations.md`ِ بک‌اند، بخشِ «عوض کردنِ دامنه».
## روش‌های پرداخت
برگه‌ی پرداخت روش‌ها را از `paymentMethods` در `dist/config.js` می‌خواند (`src/lib/paymentMethods.ts`)، جدا برای مرورگر
(`web`) و داخلِ اپِ اندروید (`app`)؛ مقدارها `"zarinpal"` و `"bazaar"`. پیش‌فرض: مرورگر فقط زرین‌پال، اپ هر دو. نسخه‌ای که
فقط در کافه‌بازار است: `paymentMethods: { app: ["bazaar"] }`؛ build لازم نیست، فقط کشِ آروانِ این فایل را پاک کنید.
---
## انتشار
روی سرور (`~/mathapp_frontend_new`، که nginxِ `math.tadrisino2.ir` از `dist/`ش سرو می‌کند):
```bash
cd ~/mathapp_frontend_new && ./deploy.sh # fetch، npm ci، type-check، build در dist.next و جابه‌جاییِ بی‌وقفه
```
buildِ قبلی در `dist.prev` می‌ماند (برگشت: `rm -rf dist && mv dist.prev dist`) و `dist/config.js`ِ ویرایش‌شده روی سرور به
buildِ تازه منتقل می‌شود. سرور مشترکِ شرکت است: پیش از هر کار آن‌جا قواعدِ `CLAUDE.md`ِ بک‌اند (بخشِ «Working on the
servers») را بخوانید.
جای دیگر: `npm ci && npm run build` و سرو کردنِ `dist/`. اپ مسیرِ جدا (route) ندارد و همه‌چیز از `/` بار می‌شود؛ فقط
`index.html` و `config.js` نباید طولانی کش شوند (باقیِ فایل‌ها نامِ hashدار دارند). درگاهِ پرداخت کاربر را به
`/?orderId=…&status=…` برمی‌گرداند و لینکِ دعوت `/?ref=CODE` است؛ هر دو را خودِ اپ می‌خواند.
CSPِ سایت در nginx است (`ops/nginx/zareian-front-headers.conf` در مخزنِ بک‌اند). اگر اسکریپتِ درون‌خطیِ `index.html` عوض
شود، هشِ تازه‌اش باید در همان فایل بنشیند، وگرنه مرورگر آن را اجرا نمی‌کند.

View File

@@ -5,20 +5,63 @@
محصولِ دیجیتال را فقط از راهِ خودش می‌پذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
فرانت به‌روز می‌شود، بی‌آن‌که نسخه‌ی تازه‌ی اپ لازم باشد.
> ⚠️ **این پروژه هنوز یک بار هم build نشده.** روی ماشینی نوشته شد که Android SDK نداشت
> (۲۰۲۶-۱۰-۰۳). APIِ Poolakey از README خودِ کتابخانه است. اولین کار: باز کردن در Android
> Studio، build، و یک خریدِ واقعی روی گوشی.
> **ساخت و امضا با هم‌تیمی است (MH-W90، m.hassanabadi)** با روشِ امضای خودش (`my-release-key.keystore` +
> propertyهای Gradle)؛ تصمیمِ مالک این است که همان روش بماند. پیش از دست زدن به امضا از مالک بپرس و هرگز
> keystore را commit نکن. `applicationId` واقعی `com.tadrisino.math.app` است (از APKِ ساخته‌شده با aapt2).
> هیچ خریدِ واقعیِ بازار هنوز آزموده نشده.
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
## نسخه‌ی بعدیِ APK — چه چیزی در کد هست و در اپِ نصب‌شده نیست
| چه | کجا گذاشته می‌شود | وضعیت |
این‌ها در `MainActivity.kt` نوشته شده‌اند (۱۴۰۵-۰۷، `468f5b2` و `737414f`) ولی هیچ‌کدام build نشده‌اند؛ اپی که الان روی
گوشی‌هاست بدونِ آن‌هاست. فرانت با هر دو نسخه کار می‌کند (اگر پل نبود، راهِ قدیم را می‌رود).
| تغییر | چرا | بعد از build روی گوشی بسنج |
|---|---|---|
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.app` حدس است؛ تأیید نشده |
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | ندارد؛ بدونِ آن خرید شروع نمی‌شود |
| SKUِ هر بسته (محصولِ **مصرف‌شدنی**) | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچ‌کدام تعریف نشده |
| `TadrisinoAndroid.vibrate(patternJson)` با Vibratorِ گوشی | `navigator.vibrate` در WebView همیشه کار نمی‌کند؛ مالک گفت اپ لرزش ندارد | انتخابِ گزینه‌ی آزمون، جوابِ درست/غلط، پایانِ جزیره و صفحه‌ی «روزِ پیاپی» باید بلرزند؛ با خاموش کردنِ «صداها» در پروفایل نه |
| `mediaPlaybackRequiresUserGesture = false` | صداهای رابط بعد از پاسخِ سرور پخش می‌شوند، نه در لحظه‌ی لمس | صدای جوابِ درست/غلط و پایانِ تمرین |
| `isPaymentPage` + `paysInApp()` | درگاهِ زرین‌پال داخلِ خودِ اپ باز می‌شود. اپِ فعلی آن را به مرورگرِ گوشی می‌دهد که Referer نمی‌فرستد و زرین‌پال بی‌Referer «دسترسی از این دامنه مجاز نمی‌باشد» می‌دهد؛ فعلاً فرانت از راهِ `pay.html` (با `http://`) دورش می‌زند | «پرداخت» با کارتِ بانکی: صفحه‌ی زرین‌پال و بانک داخلِ اپ، و بعد از پرداخت برگشت به اپ با پیامِ «پرداخت موفق بود» (PaymentResult). دکمه‌ی برگشت وسطِ صفحه‌ی بانک چه می‌کند را هم ببین |
| اسپلشِ یک‌مرحله‌ای: `installSplashScreen()` + `setKeepOnScreenCondition` + `TadrisinoAndroid.appReady()` (`446cf36`، `ab02bb4`) | اپِ فعلی سه تکه می‌شود: اسپلشِ اندروید ← WebViewِ سفیدِ خالی ← اسپلشِ وب (آیکون + نام) ← خانه. با APKِ تازه اسپلشِ بومی تا اولین صفحه‌ی واقعیِ وب می‌ماند و مستقیم می‌رود روی همان صفحه؛ اسپلشِ وب داخلِ اپ نامرئی است (در APKِ قدیمی فقط آیکونِ بی‌نام). فرانت `appReady` را بعد از اولین صفحه‌ی غیر‌boot صدا می‌زند و APKِ قدیمی آن را ندارد، پس بی‌اثر است | روی گوشی: باز کردنِ اپ با اینترنت باید آیکون ← خانه باشد، بی‌سفیدی و بی‌تصویرِ دوم؛ اندازه‌ی آیکون روی اندروید ۱۲+ (`drawable/splash_icon.xml`: ۲۸۸dp، حاشیه ۲۲٪) |
| خریدِ اشتراک از کافه‌بازار: کلیدِ RSA، `bazaarBuy` (`subscribeProduct`)، قیمتِ پیشخان (`bazaarPrices`)، «تخفیف پویا»؛ `versionCode` ۲ | بی‌این‌ها از بازار هیچ خریدی ممکن نیست (APKهای قبلی کلید نداشتند) | داخلِ اپ ← فروشگاه ← اشتراکِ سه‌ماهه: «کارتِ بانکی» پیش‌فرض، کاشیِ «کافه‌بازار» فعال با قیمتِ بازار زیرِ آن؛ خرید با حسابِ تست، برگشت با «اشتراکت فعاله»؛ در `/admin/payment/order/` سفارشِ بازار «پرداخت‌شده». یک بار هم «انصراف» در صفحه‌ی بازار |
| صفحه‌ی بی‌اتصال: `onReceivedError`/`onReceivedHttpError` ← همان تصویر + «اینترنت قطع است / کند است؛ در حال اتصال…»، تلاشِ دوباره هر ۵ ثانیه، و مجوزِ `ACCESS_NETWORK_STATE` | کاربر هرگز صفحه‌ی خطای WebView یا متنِ فنی نمی‌بیند. اتصالِ کند (بی‌خطا) عمداً هیچ متنِ اضافه نمی‌آورد و روی اسپلش می‌ماند | هواپیما روشن ← باز کردنِ اپ ← خاموش کردن: باید خودش وصل شود. سرورِ ۵۰۲ (مثلاً nginx خاموش) هم همین صفحه. اگر صفحه لود شد ولی ۱۵ ثانیه `appReady` نیامد، لاگِ ERROR و رهاشدنِ اسپلش (باگِ JS) |
قیمتِ هر محصول در پیشخانِ بازار باید با قیمتِ همان بسته در ادمین یکی باشد؛ سرور مبلغ را از
بازار نمی‌خواند.
**اسپلش و بی‌اینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصب‌شده امتحان شد).** چیزی که روی گوشی دیده می‌شود
از وب قابل رفع نیست، چون وب هنوز بار نشده یا اصلاً نمی‌رسد:
- اپِ نصب‌شده: اسپلشِ اندروید ← صفحه‌ی سفید ← اسپلشِ وب ← خانه. با فیلترشکن (یا هر وقت `math.tadrisino2.ir` نرسد)
فقط **سفید** می‌ماند و هیچ پیامی نیست، چون HTML هرگز نرسیده و هیچ کدِ ما اجرا نمی‌شود. علتِ دقیقِ آن سفیدی (HTML نرسیده
یا API نرسیده) روی فیلترشکن بازتولید **نشد**؛ APKِ تازه هر دو را با همان صفحه‌ی بی‌اتصال می‌پوشاند.
- APKِ تازه (دو ردیفِ بالا) باید: ۱) با اینترنت، اسپلشِ بومی مستقیم به خانه برود و وسطش هیچ‌چیز نیفتد؛ ۲) بی‌اینترنت یا
کند، **زیرِ همان تصویرِ ثابت** بگوید «اینترنت قطع است / کند است»، نه سفید و نه متنِ خطا.
- آزمونِ پذیرش روی گوشی، پیش از انتشار: اینترنتِ عادی؛ هواپیما؛ فیلترشکنِ روشن (همان که سفید می‌داد)؛ اینترنتِ خیلی کند.
در هیچ‌کدام نباید سفیدِ خالی یا صفحه‌ی خطای WebView دیده شود.
پیشنهادهایی که هنوز نوشته نشده‌اند:
- **«برای خروج دوباره بزن»**: دکمه‌ی برگشتِ گوشی حالا قدم‌به‌قدم در خودِ وب عقب می‌رود (`src/nav/Navigator.tsx`) و
از خانه یک‌راست بیرون می‌رود. اگر تأییدِ خروج خواستید، در `handleOnBackPressed` وقتی `!webView.canGoBack()` یک
Toast بدهید و فقط برگشتِ دوم در دو ثانیه `finish()` کند. در وب شدنی نیست (کروم ورودیِ history بی‌لمس را رد می‌کند).
- `versionCode`/`versionName` را برای هر نسخه‌ای که منتشر می‌شود بالا ببرید؛ الان ۲ و 1.1.0 است (بازار فقط `versionCode`ِ بزرگ‌تر را می‌پذیرد).
قانونِ بازار: داخلِ اپِ بازار حالا پرداخت با درگاه هم پیشنهاد می‌شود (خواستِ مالک). بازار پرداختِ محتوای دیجیتال بیرون از
پرداختِ درون‌برنامه‌ای را محدود می‌کند؛ پیش از انتشار در بازار این را با بازار یا مالک روشن کنید.
ساخت روی مکِ مالک: SDK در `~/Library/Android/sdk`، JDK 17 (`/opt/homebrew/opt/openjdk@17`)، و چون Google Maven از
ایران بی‌VPN ۴۰۴ می‌دهد، `~/.gradle/init.d/google-mirror.gradle` آینه‌ی Aliyun را اضافه می‌کند؛ ساختِ اول ~۹ دقیقه.
## پیش از build — آنچه از پیشخانِ بازار لازم است
| چه | کجا گذاشته می‌شود | وضعیت (۲۰۲۶-۱۰-۱۲) |
|---|---|---|
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app`؛ بازار محصول‌های ۱۰۳۴۲–۱۰۳۴۴ را زیرِ همین نام شناخت (API، توکنِ ساختگی) |
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | گذاشته شد |
| SKUِ هر پلن | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | یک‌ماهه `10342`، سه‌ماهه `10343`، شش‌ماهه `10344`؛ بسته‌ی سوال فروخته نمی‌شود |
| کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور |
فقط اشتراک فروخته می‌شود. قیمت: فرانت `TadrisinoAndroid.bazaarPrices(skusJson)` را صدا می‌زند و قیمتِ پیشخان را روی
کاشیِ بازار نشان می‌دهد. خرید: `TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)` با `subscribeProduct`
(مصرف نمی‌شود، بازار خودش تمدید می‌کند). قیمتِ پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف
خورده، سرور قیمتِ کم‌شده را امضا می‌کند.
`compileDebugKotlin` سبز است؛ هیچ خریدی روی گوشی آزموده نشده.
## build
@@ -31,14 +74,15 @@
```
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
فرانت TadrisinoAndroid.bazaarPurchase(sku, orderId)
اپ Poolakey.purchaseProduct(sku, payload=orderId) → purchaseToken
فرانت (اختیاری) POST /api/discount/redeem {code, orderId}
فرانت POST /api/payment/bazaar/orders/{orderId}/start → sku، dynamicPriceToken (اگر تخفیف خورده)
فرانت TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
اپ Poolakey.subscribeProduct(sku, payload=orderId) → purchaseToken
اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken}
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار می‌پرسد
فرانت TadrisinoAndroid.bazaarConsume(token) ← تا همان بسته دوباره خریدنی باشد
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار می‌پرسد و اشتراک را فعال می‌کند
```
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» می‌زند: فرانت همان سفارش را می‌فرستد
و اپ توکنِ ماندهٔ همان سفارش را از `getPurchasedProducts` پیدا می‌کند.
و اپ توکنِ همان سفارش را از `getSubscribedProducts` پیدا می‌کند.
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بک‌اند.

View File

@@ -64,13 +64,15 @@ android {
applicationId = "com.tadrisino.math.app"
minSdk = 23
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
// بازار نسخه‌ی تازه را فقط با versionCodeِ بزرگ‌تر می‌پذیرد؛ هر build که منتشر می‌شود یکی بالا.
versionCode = 2
versionName = "1.1.0"
// URL of the web frontend.
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
// Public RSA key from the Cafe Bazaar panel. Purchases won't start until it is filled in.
buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (پرداخت درون‌برنامه‌ای)؛ سینیور، ۲۰۲۶-۱۰-۱۲. عمومی است و در APK
// می‌رود؛ Poolakey با آن امضای خرید را روی گوشی می‌سنجد. سنجشِ اصلی سمتِ سرور است.
buildConfigField("String", "BAZAAR_RSA_KEY", "\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwC9EBw0RW6jeTdHkrJwfB/mX+klpjpVXVb2stpGDcixeqHiLE+/Iow+PiMbZEQfxZDuED6ELvZlzQX+ALOlGRPxaa1ctl6/Uy6wGaPXhGPDpZL6jPrXV88mjJzMcEoB4sf3yNb+6YNKrvKhRyXXR0JdMUmPMqMe8igmQZTv/UF+QEMNqv7lg7qw9Yl+fG802nqjasft7qUPQK+YHJYPS4PJ5Pj/YpTkGDdDmVi7AIMCAwEAAQ==\"")
}
buildFeatures {
@@ -105,5 +107,7 @@ android {
dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-ktx:1.9.2")
// اسپلشِ سیستم تا آماده‌شدنِ اولین صفحه‌ی وب می‌ماند (MainActivity.keepSplash).
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
}

View File

@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- فقط برای فرقِ «اینترنت قطع است» و «اینترنت کند است» در اسپلشِ بی‌اتصال؛ مجوزِ عادی، بی‌پرسش. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- برای لرزشِ کوتاهِ جوابِ درست/غلط (navigator.vibrate در WebView). -->
<uses-permission android:name="android.permission.VIBRATE" />
@@ -21,6 +23,7 @@
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="true"
android:theme="@style/Theme.Tadrisino.Starting"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -2,40 +2,131 @@ package com.tadrisino.app
import android.annotation.SuppressLint
import android.content.Intent
import android.content.Context
import android.graphics.Color
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import android.webkit.JavascriptInterface
import android.webkit.WebResourceResponse
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import ir.cafebazaar.poolakey.Connection
import ir.cafebazaar.poolakey.ConnectionState
import ir.cafebazaar.poolakey.Payment
import ir.cafebazaar.poolakey.config.PaymentConfiguration
import ir.cafebazaar.poolakey.config.SecurityCheck
import ir.cafebazaar.poolakey.exception.BazaarNotFoundException
import ir.cafebazaar.poolakey.exception.BazaarNotSupportedException
import ir.cafebazaar.poolakey.exception.DynamicPriceNotSupportedException
import ir.cafebazaar.poolakey.exception.IAPNotSupportedException
import ir.cafebazaar.poolakey.exception.SubsNotSupportedException
import ir.cafebazaar.poolakey.request.PurchaseRequest
import org.json.JSONArray
import org.json.JSONObject
/**
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درون‌برنامه‌ایِ بازار است.
*
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
* JS → اینجا TadrisinoAndroid.bazaarPurchase(sku, orderId)
* TadrisinoAndroid.bazaarConsume(purchaseToken)
* JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken) (اشتراک)
* TadrisinoAndroid.bazaarPrices(skusJson) → رویدادِ «tadrisino:bazaar-prices»
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
* { orderId, error: "canceled" | "failed", message }
* { orderId, error: "canceled" | "failed" | "unavailable", message }
*
* سمتِ سرور توکن را از خودِ بازار می‌سنجد (`POST /api/payment/bazaar/verify`)، پس این‌جا
* فقط واسطه است و به هیچ نتیجه‌ای اعتماد نمی‌کند.
*
* ⚠️ نوشته شده بی‌آن‌که build شود (ماشینِ نویسنده Android SDK نداشت، ۲۰۲۶-۱۰-۰۳)؛ APIِ
* Poolakey از README خودش است. پیش از انتشار با Android Studio بسازید و یک خریدِ واقعی بزنید.
* ⚠️ کامپایل می‌شود (compileDebugKotlin، ۲۰۲۶-۱۰-۱۲) و امضای توابعِ Poolakey 2.2.0 از خودِ کتابخانه خوانده شد،
* ولی روی گوشی اجرا نشده؛ پیش از انتشار یک خریدِ واقعی بزنید.
*/
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
/**
* اسپلشِ سیستم تا وقتی true نشده روی صفحه می‌ماند، هرچقدر اتصال کند باشد (خواستِ مالک): فرانت وقتی
* اولین صفحه‌ی واقعی (ورود/خانه) کشیده شد `TadrisinoAndroid.appReady()` را صدا می‌زند.
* فقط دو حالت زودتر رهایش می‌کند: صفحه‌ی اصلی بالا نیامد (آفلاین/خطای سرور → [showOffline]، با همان
* تصویر و یک جمله)، یا صفحه لود شد ولی تا [READY_WATCHDOG_MS] appReady نیامد (خطای JS؛ لاگ می‌شود).
*/
@Volatile private var webReady = false
private val handler = Handler(Looper.getMainLooper())
private lateinit var offlineView: View
private lateinit var offlineText: TextView
private var offlineShown = false
private val retryLoad = object : Runnable {
override fun run() {
if (!offlineShown) return
webView.loadUrl(BuildConfig.WEB_URL)
handler.postDelayed(this, RETRY_MS)
}
}
private fun dp(v: Int): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics).toInt()
/** همان تصویرِ اسپلشِ سیستم (۲۸۸dp، همان drawable) با یک جمله زیرش؛ جای آیکون با اسپلشِ سیستم یکی است. */
private fun buildOfflineView(): View = FrameLayout(this).apply {
setBackgroundColor(Color.WHITE)
visibility = View.GONE
isClickable = true
addView(ImageView(context).apply { setImageResource(R.drawable.splash_icon) },
FrameLayout.LayoutParams(dp(288), dp(288), Gravity.CENTER))
addView(TextView(context).apply {
offlineText = this
text = "اینترنت قطع است"
setTextColor(Color.parseColor("#1a1a2e"))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
layoutDirection = View.LAYOUT_DIRECTION_RTL
gravity = Gravity.CENTER
}, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER)
.apply { topMargin = dp(150) })
}
/** هیچ متنِ فنیِ خطا نشان داده نمی‌شود؛ فقط یکی از دو جمله: «قطع است» (شبکه‌ای نیست) یا «کند است». */
private fun hasInternet(): Boolean {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
private fun showOffline() {
offlineText.text = if (hasInternet()) "اینترنت کند است؛ در حال اتصال…" else "اینترنت قطع است؛ در حال اتصال…"
if (!offlineShown) {
offlineShown = true
webView.visibility = View.INVISIBLE // صفحه‌ی خطای پیش‌فرضِ WebView دیده نشود
offlineView.visibility = View.VISIBLE
handler.postDelayed(retryLoad, RETRY_MS)
}
webReady = true
}
private fun hideOffline() {
if (!offlineShown) return
offlineShown = false
handler.removeCallbacks(retryLoad)
offlineView.visibility = View.GONE
webView.visibility = View.VISIBLE
}
private var payment: Payment? = null
private var connection: Connection? = null
private val webHost: String = Uri.parse(BuildConfig.WEB_URL).host ?: ""
@@ -43,22 +134,58 @@ class MainActivity : AppCompatActivity() {
/** فقط خودِ سایت و فقط با https؛ «http://دامنه‌ی ما» قابلِ دست‌کاریِ شبکه است و نباید پل داشته باشد. */
private fun isOurPage(uri: Uri?): Boolean = uri != null && uri.scheme == "https" && uri.host == webHost
/** API و درگاه: زیردامنه‌های دامنه‌ی خودمان (mathapi…)، زرین‌پال و صفحه‌های بانکیِ شاپرک؛ فقط https. پل این‌جا کار نمی‌کند. */
private fun isPaymentPage(uri: Uri?): Boolean {
val host = uri?.host ?: return false
if (uri.scheme != "https") return false
val ours = webHost.substringAfter('.')
return host == ours || host.endsWith(".$ours") ||
host == "zarinpal.com" || host.endsWith(".zarinpal.com") || host.endsWith(".shaparak.ir")
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().setKeepOnScreenCondition { !webReady }
super.onCreate(savedInstanceState)
webView = WebView(this)
setContentView(webView)
offlineView = buildOfflineView()
setContentView(FrameLayout(this).apply {
addView(webView)
addView(offlineView)
})
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true // توکنِ ورود در localStorage است
// WebView فقط آدرسِ وب را می‌خواند؛ دسترسی به فایل و content:// لازم نیست.
webView.settings.allowFileAccess = false
webView.settings.allowContentAccess = false
// صداهای کوتاهِ رابط (Web Audio) بعد از پاسخِ سرور پخش می‌شوند، نه درست در لحظه‌ی لمس.
webView.settings.mediaPlaybackRequiresUserGesture = false
webView.webViewClient = object : WebViewClient() {
// صفحه‌ی اصلی بالا نیامد (بی‌اینترنت یا خطای سرور): همان تصویر با «در حال اتصال»، و هر چند ثانیه دوباره.
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: android.webkit.WebResourceError) {
if (request.isForMainFrame) showOffline()
}
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) {
if (request.isForMainFrame && errorResponse.statusCode >= 500) showOffline()
}
override fun onPageFinished(view: WebView, url: String?) {
if (offlineShown) return // صفحه‌ی خطا هم onPageFinished می‌زند؛ تا بارِ موفق همان تصویر بماند
handler.postDelayed({
if (!webReady) Log.e(TAG, "page loaded but appReady never came in ${READY_WATCHDOG_MS}ms; releasing the splash")
webReady = true
}, READY_WATCHDOG_MS)
}
// هر آدرسی بیرونِ دامنه‌ی خودمان در مرورگرِ گوشی باز می‌شود، نه داخلِ اپ؛ پس پل فقط
// در صفحه‌های خودمان در دسترس است.
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
if (isOurPage(request.url)) return false
// پرداخت با درگاه داخلِ خودِ اپ می‌ماند: زرین‌پال بی‌Refererِ دامنه‌ی ما صفحه را باز نمی‌کند و مرورگرِ
// بیرونی Referer نمی‌فرستد. بعد از پرداخت، زرین‌پال به سایتِ ما برمی‌گرداند و نتیجه همین‌جا دیده می‌شود.
if (isPaymentPage(request.url)) return false
// فقط http(s) به مرورگرِ گوشی می‌رود؛ intent:، file: و schemeهای سفارشی بی‌صدا بسته می‌شوند
// (پیش‌تر هر آدرسی بیرونِ دامنه به ACTION_VIEW می‌رفت و صفحه می‌توانست اپِ دلخواه باز کند).
if (request.url.scheme == "https" || request.url.scheme == "http") {
@@ -88,6 +215,7 @@ class MainActivity : AppCompatActivity() {
val config = PaymentConfiguration(
localSecurityCheck = SecurityCheck.Enable(rsaPublicKey = BuildConfig.BAZAAR_RSA_KEY),
)
connection?.disconnect()
payment = Payment(context = this, config = config).also { p ->
connection = p.connect {
connectionSucceed { Log.i(TAG, "bazaar connected") }
@@ -97,31 +225,53 @@ class MainActivity : AppCompatActivity() {
}
}
private fun purchase(sku: String, orderId: String) {
/**
* خریدِ اشتراکِ بازار (subscribeProduct؛ فقط اشتراک فروخته می‌شود و مصرف نمی‌شود، بازار خودش تمدید می‌کند).
* dynamicPriceToken خالی یعنی قیمتِ پیشخان؛ پر یعنی قیمتی که سرور با کلیدِ «تخفیف پویا» امضا کرده
* (بازارِ ۱۳.۳ به بعد).
*/
private fun buy(sku: String, orderId: String, dynamicPriceToken: String) {
val p = payment
if (p == null) {
send(orderId, error = "failed", message = "bazaar not connected")
if (p == null || connection?.getState() !is ConnectionState.Connected) {
// بازار نصب نیست، هنوز وصل نشده، یا اتصال افتاده؛ برای تلاشِ بعدی دوباره وصل می‌شویم.
connectToBazaar()
send(orderId, error = "unavailable", message = "not_connected")
return
}
p.purchaseProduct(
registry = activityResultRegistry,
request = PurchaseRequest(productId = sku, payload = orderId),
) {
val request = PurchaseRequest(
productId = sku, payload = orderId, dynamicPriceToken = dynamicPriceToken.ifEmpty { null },
)
p.subscribeProduct(activityResultRegistry, request) {
purchaseSucceed { send(orderId, token = it.purchaseToken) }
purchaseCanceled { send(orderId, error = "canceled") }
purchaseFailed { recoverOwned(sku, orderId, it) }
failedToBeginFlow { recoverOwned(sku, orderId, it) }
purchaseFailed { failed(sku, orderId, it) }
failedToBeginFlow { failed(sku, orderId, it) }
}
}
/**
* خریدِ قبلیِ همین محصول که مصرف نشده (پول رفته ولی تأییدِ سرور نرسیده بود) جلوی خریدِ
* دوباره را می‌گیرد. فرانت در تلاشِ بعدی همان سفارش را می‌فرستد؛ اگر توکنِ ماندهٔ همان
* سفارش (payload) پیدا شد، همان برمی‌گردد تا سرور ثبتش کند.
* بازارِ نصب‌نشده یا قدیمی (بی پشتیبانیِ اشتراک یا «تخفیف پویا») کاری است که کاربر باید بکند، نه خطای خرید؛
* فرانت برایش پیامِ جدا دارد. بقیه‌ی خطاها ممکن است خریدی باشند که پولش رفته، پس از بازار پرسیده می‌شوند.
*/
private fun failed(sku: String, orderId: String, cause: Throwable) {
val reason = when (cause) {
is BazaarNotFoundException -> "not_installed"
is BazaarNotSupportedException, is SubsNotSupportedException, is IAPNotSupportedException,
is DynamicPriceNotSupportedException -> "old_bazaar"
else -> null
}
Log.e(TAG, "bazaar purchase failed: ${reason ?: cause.javaClass.simpleName}", cause)
if (reason != null) send(orderId, error = "unavailable", message = reason)
else recoverOwned(sku, orderId, cause)
}
/**
* اشتراکِ همین محصول که پولش رفته ولی تأییدِ سرور نرسیده بود جلوی خریدِ دوباره را می‌گیرد. فرانت در تلاشِ
* بعدی همان سفارش را می‌فرستد؛ اگر اشتراکِ همان سفارش (payload) پیدا شد، توکنش برمی‌گردد تا سرور ثبتش کند.
*/
private fun recoverOwned(sku: String, orderId: String, cause: Throwable) {
val p = payment ?: return send(orderId, error = "failed", message = cause.message)
p.getPurchasedProducts {
p.getSubscribedProducts {
querySucceed { owned ->
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
if (match != null) send(orderId, token = match.purchaseToken)
@@ -131,10 +281,31 @@ class MainActivity : AppCompatActivity() {
}
}
private fun consume(purchaseToken: String) {
payment?.consumeProduct(purchaseToken) {
consumeSucceed { Log.i(TAG, "consumed") }
consumeFailed { Log.e(TAG, "consume failed", it) }
/**
* قیمتِ ثبت‌شده در پیشخانِ بازار برای هر SKU (متنِ آماده‌ی نمایش، مثلِ «۸۹۸,۰۰۰ تومان»)، تا فرانت روی کاشیِ
* بازار همان را نشان دهد. API سرور-به-سرورِ بازار قیمت نمی‌دهد؛ فقط Poolakey روی گوشی.
* نتیجه: رویدادِ «tadrisino:bazaar-prices» با { prices: { sku: price } } یا { error }.
*/
private fun prices(skus: List<String>) {
val p = payment ?: return sendPrices(error = "bazaar not connected")
p.getSubscriptionSkuDetails(skus) {
getSkuDetailsSucceed { list ->
val map = JSONObject()
list.forEach { map.put(it.sku, it.price) }
sendPrices(prices = map)
}
getSkuDetailsFailed { sendPrices(error = it.message ?: "failed") }
}
}
private fun sendPrices(prices: JSONObject? = null, error: String? = null) {
val detail = JSONObject()
if (prices != null) detail.put("prices", prices)
if (error != null) detail.put("error", error)
runOnUiThread {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('tadrisino:bazaar-prices', { detail: $detail }));", null,
)
}
}
@@ -152,21 +323,68 @@ class MainActivity : AppCompatActivity() {
/** متدهای این کلاس روی threadِ WebView صدا زده می‌شوند، نه UI. */
private inner class Bridge {
/** قراردادش در src/lib/bazaar.tsِ فرانت است. */
@JavascriptInterface
fun bazaarPurchase(sku: String, orderId: String) {
fun bazaarBuy(sku: String, orderId: String, dynamicPriceToken: String) {
runOnUiThread {
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
purchase(sku, orderId)
buy(sku, orderId, dynamicPriceToken)
}
}
/**
* لرزشِ کوتاهِ رابط (src/lib/sound.tsِ فرانت). `navigator.vibrate` در همه‌ی WebViewها اجرا نمی‌شود؛
* این مستقیم از Vibratorِ گوشی است. ورودی آرایه‌ی JSON از میلی‌ثانیه‌ها: [لرزش، مکث، لرزش، …].
*/
@JavascriptInterface
fun bazaarConsume(purchaseToken: String) {
fun vibrate(patternJson: String) {
val pattern = try {
val arr = JSONArray(patternJson)
LongArray(arr.length()) { i -> arr.getLong(i).coerceIn(0, 1000) }
} catch (e: Exception) {
Log.w(TAG, "bad vibrate pattern", e)
return
}
if (pattern.isEmpty()) return
val vibrator = if (Build.VERSION.SDK_INT >= 31) {
(getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(VIBRATOR_SERVICE) as Vibrator
}
// [لرزش، مکث، …] → قالبِ اندروید که با مکث شروع می‌شود.
val timings = longArrayOf(0) + pattern
if (Build.VERSION.SDK_INT >= 26) vibrator.vibrate(VibrationEffect.createWaveform(timings, -1))
else @Suppress("DEPRECATION") vibrator.vibrate(timings, -1)
}
/** فرانت اولین صفحه‌ی واقعی را کشید؛ اسپلشِ سیستم می‌تواند برود. */
@JavascriptInterface
fun appReady() {
webReady = true
runOnUiThread { hideOffline() }
}
/** skusJson آرایه‌ی JSON از SKUهای اشتراک. */
@JavascriptInterface
fun bazaarPrices(skusJson: String) {
val skus = try {
val arr = JSONArray(skusJson)
List(arr.length()) { i -> arr.getString(i) }
} catch (e: Exception) {
Log.w(TAG, "bad skus", e)
return
}
runOnUiThread {
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
consume(purchaseToken)
prices(skus)
}
}
/** فرانت با این می‌فهمد درگاه داخلِ اپ باز می‌شود (isPaymentPage)، نه در مرورگرِ بیرونی. */
@JavascriptInterface
fun paysInApp(): Boolean = true
}
override fun onDestroy() {
@@ -176,5 +394,7 @@ class MainActivity : AppCompatActivity() {
companion object {
private const val TAG = "Tadrisino"
private const val RETRY_MS = 5_000L
private const val READY_WATCHDOG_MS = 15_000L
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- همان تصویرِ آیکونِ اپ؛ حاشیه برای این‌که ماسکِ گردِ اندروید ۱۲ گوشه‌هایش را نبُرد. -->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/splash_icon_art"
android:insetLeft="22%"
android:insetTop="22%"
android:insetRight="22%"
android:insetBottom="22%" />

View File

@@ -4,4 +4,15 @@
<item name="android:statusBarColor">#ED6956</item>
<item name="android:windowBackground">@android:color/white</item>
</style>
<!--
اسپلشِ سیستم: آیکونِ اپ روی سفید، تا وقتی صفحه‌ی وب خودش بگوید آماده است (appReady) روی صفحه می‌ماند.
پیش از این، اسپلش می‌آمد، بعد WebView سفیدِ خالی تا رسیدنِ HTML، بعد اسپلشِ وب، بعد خانه.
سفید است چون windowBackgroundِ پایین هم سفید است و اسپلشِ وب (index.html) هم سفید است.
-->
<style name="Theme.Tadrisino.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@android:color/white</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="postSplashScreenTheme">@style/Theme.Tadrisino</item>
</style>
</resources>

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,15 +4,39 @@
<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" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>تدریسینو</title>
<title>تدریسینو ریاضی</title>
<!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار می‌شود. -->
<script src="/config.js"></script>
<!-- اسپلشِ ثابت: همین‌جا در HTML است تا از اولین فریم دیده شود (نه صفحه‌ی سفید تا بار شدنِ JS)؛
SplashScreen در React عینِ همین را می‌کشد، پس هنگامِ تحویل چیزی نمی‌پرد. فونتِ سیستم، چون
Vazirmatn بعد از CSS می‌رسد و عوض‌شدنِ فونت خودش یک پرش است. -->
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preload" as="image" href="/app-icon.png" />
<script>
(function () {
var b = window.TadrisinoAndroid;
if (b) document.documentElement.className = typeof b.appReady === 'function' ? 'in-app' : 'in-old-app';
})();
</script>
<style>
.boot-splash { position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 20px; background: #fff; }
.boot-splash img { width: 144px; height: 144px; display: block; }
/* داخل اپ اندروید اسپلش بومی روی صفحه است و سایت زیرش بار می‌شود؛ اسپلش وب نباید یک بار دیگر
«عوض شدن» نشان دهد. APK تازه (appReady دارد): اسپلش وب نامرئی است. APK قدیمی: فقط آیکون،
بی‌نام، تا کمتر از اسپلش سیستم فرق کند. */
.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 min(34px, 8.5vw)/1.2 system-ui, -apple-system, sans-serif; color: #1a1a2e; white-space: nowrap; }
</style>
</head>
<body>
<div id="root"></div>
<div id="root">
<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>
</html>

28
pay.html Normal file
View File

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<!-- Referer باید از دامنه‌ی خودمان برود؛ زرین‌پال بی‌آن صفحه را باز نمی‌کند (src/pay.ts). -->
<meta name="referrer" content="strict-origin-when-cross-origin" />
<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; }
main { text-align: center; padding: 24px; max-width: 340px; }
.spin { width: 36px; height: 36px; margin: 0 auto 16px; border-radius: 50%; border: 4px solid #f3c9c2; border-top-color: #ED6956; animation: s 0.8s linear infinite; }
@keyframes s { to { transform: rotate(360deg); } }
a { display: none; margin-top: 16px; padding: 14px 24px; border-radius: 16px; background: #ED6956; color: #fff; font-weight: 800; text-decoration: none; }
p { line-height: 1.9; }
</style>
</head>
<body>
<main>
<div class="spin" id="spin"></div>
<p id="msg">داریم می‌ریم به درگاه پرداخت…</p>
<a id="go" href="#">رفتن به درگاه</a>
</main>
<script type="module" src="/src/pay.ts"></script>
</body>
</html>

BIN
public/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -4,6 +4,10 @@
// برای جابه‌جا کردنِ بک‌اند بدونِ build دوباره، آدرسِ کامل را اینجا بنویسید:
// apiBaseUrl: "https://api.new-domain.ir/api",
// اگر CDN (آروان) جلوی سایت است، بعد از ویرایش کشِ این فایل را پاک کنید.
//
// paymentMethods: روش‌های پرداختی که برگه‌ی پرداخت نشان می‌دهد، به همین ترتیب؛ «zarinpal» و «bazaar».
// web = سایت در مرورگر، app = داخلِ اپِ اندروید. نسخه‌ی کافه‌بازار: app: ["bazaar"].
// نبودنِ این کلید یعنی پیش‌فرضِ src/lib/paymentMethods.ts.
window.__TADRISINO_CONFIG__ = {
apiBaseUrl: "",
};

View File

@@ -21,6 +21,8 @@ export class ApiError extends Error {
readonly status: number,
readonly detail: string,
readonly body: Record<string, unknown> = {},
/** فقط برای status 0: درخواست به‌خاطرِ timeout قطع شد (اینترنت کند)، نه رسیدن‌نداشتن (اینترنت قطع). */
readonly timedOut = false,
) {
super(detail);
this.name = 'ApiError';
@@ -41,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;
@@ -55,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 {
/* ذخیره نشد؛ نشست فقط تا بستنِ صفحه می‌ماند */
}
@@ -75,13 +77,16 @@ export interface HttpClientOptions {
baseUrl: string;
storage?: TokenStorage;
timeoutMs?: number;
readTimeoutMs?: number;
maxRetries?: number;
}
const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
function backoffMs(attempt: number, retryAfterMs?: number): number {
if (retryAfterMs != null) return Math.min(retryAfterMs, 30_000);
// Retry-After کمینه است، نه زمانِ دقیق: وقتی سرور زیرِ بار هزاران گوشی را هم‌زمان رد می‌کند، اگر همه
// دقیقاً بعد از همان چند ثانیه برگردند همان اوج دوباره ساخته می‌شود. پس بینِ ۱ تا ۲ برابرِ آن پخش می‌شوند.
if (retryAfterMs != null) return Math.min(retryAfterMs * (1 + Math.random()), 30_000);
return Math.random() * Math.min(1000 * 2 ** attempt, 8000);
}
@@ -159,6 +164,9 @@ export function createHttpClient(options: HttpClientOptions) {
const baseUrl = options.baseUrl.replace(/\/+$/, '');
const storage = options.storage ?? localTokenStorage;
const defaultTimeout = options.timeoutMs ?? 15_000;
// خواندن‌ها (GET) تکرارپذیرند و کاربر پشتِ اسپلش منتظر است: ۸ ثانیه کافی است، بعدش تلاشِ دوباره یا پیامِ «اینترنت کند است».
// نوشتن‌ها (POST/PATCH) همان ۱۵ می‌مانند: قطعِ زودهنگام یعنی کاربر دوباره می‌زند و ممکن است ثبتِ دوباره شود.
const readTimeout = options.readTimeoutMs ?? 8_000;
const maxRetries = options.maxRetries ?? 2;
const inFlight = new Map<string, Promise<unknown>>();
@@ -212,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 {
@@ -229,16 +239,18 @@ 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 ?? defaultTimeout);
const timer = timeoutSignal(opts.timeoutMs ?? (method === 'GET' ? readTimeout : defaultTimeout));
try {
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',
@@ -246,7 +258,7 @@ export function createHttpClient(options: HttpClientOptions) {
} catch (err) {
if (opts.signal?.aborted) throw err;
const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError');
throw new ApiError(0, timedOut ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد');
throw new ApiError(0, timedOut ? 'زمان درخواست تمام شد' : 'ارتباط با سرور برقرار نشد', {}, timedOut);
}
}
@@ -257,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);
}
@@ -81,8 +101,11 @@ export const api = {
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
/** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */
home: () => http.get<T.HomeResponse>('/home'),
/**
* بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. بدونِ subject اولین کتابِ پایه؛
* کتابِ ناموجود در پایه ۴۰۰ می‌دهد (برای کلیدِ ذخیره‌شده‌ی قدیمی از `fetchHome` استفاده کنید).
*/
home: (subject?: string) => http.get<T.HomeResponse>(`/home${q({ subject })}`),
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
/**
@@ -118,6 +141,17 @@ export const api = {
),
},
/** لایک/دیسلایک و گزارشِ ایراد روی سوال و درس (۰ = پس‌گرفتنِ رأی). */
feedback: {
vote: (kind: 'question' | 'lesson', id: string, value: 1 | -1 | 0) =>
http.post<{ value: number }>(`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/vote`, { value }),
report: (kind: 'question' | 'lesson', id: string, reason: string, note?: string) =>
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/report`,
{ reason, ...(note ? { note } : {}) },
),
},
leaderboard: (period: T.LeaderboardPeriod) =>
http.get<{ period: string; entries: T.LeaderboardEntry[] }>(`/leaderboard${q({ period })}`).then((r) => r.entries),
@@ -136,7 +170,11 @@ export const api = {
* `questionId` به دستیار می‌گوید دانش‌آموز کدام سوال را غلط زده.
*/
async ask(
input: { userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string },
input: {
userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string;
/** نامِ کتاب («هندسه»)؛ برای پرسشِ بی‌درس تا دستیار از کتابِ همان انتخابِ کاربر جواب بدهد. */
subject?: string;
},
onDelta: (text: string) => void,
signal?: AbortSignal,
): Promise<T.AskDone> {
@@ -182,6 +220,8 @@ export const api = {
*/
purchaseBazaar: (planId: string, idempotencyKey: string) =>
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }),
/** SKU، نوعِ محصول و (اگر تخفیف خورده) قیمتِ امضاشده برای بازار؛ بعد از آن کدِ تخفیف نمی‌نشیند. */
startBazaar: (orderId: string) => http.post<T.BazaarStartResponse>(`/payment/bazaar/orders/${seg(orderId)}/start`),
/** توکنِ خریدِ Poolakey را سرور از خودِ بازار می‌سنجد؛ گفته‌ی اپ کافی نیست. */
verifyBazaar: (orderId: string, purchaseToken: string) =>
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),

View File

@@ -46,7 +46,10 @@ export interface UserSummary {
gradeId: number | null;
xp: number;
coins: number;
streak: { current: number; longest: number };
/**
* activeToday: امروز تمرین شده (آتش روشن). lost: طولِ زنجیره‌ای که دیروز شکست (> ۰ یعنی «یخ‌زده»).
*/
streak: { current: number; longest: number; activeToday: boolean; lost: number };
subscription: string | null;
}
@@ -79,7 +82,22 @@ export interface HomeStats {
coins: number;
}
/** یک کتابِ ریاضیِ پایه (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته). */
export interface Subject {
/** همان که به `api.home(subject)` داده می‌شود. */
key: string;
title: string;
/** نامِ آیکنِ lucide. */
icon: string;
color: string;
order: number;
}
export interface HomeResponse {
/** کتابی که `units` از آن است؛ null فقط وقتی پایه فصلی ندارد. */
subject: Subject | null;
/** همه‌ی کتاب‌های این پایه؛ نوارِ کتاب‌ها فقط با بیش از یکی نشان داده می‌شود. */
subjects: Subject[];
units: HomeUnit[];
stats: HomeStats;
}
@@ -131,6 +149,8 @@ export interface Question {
type: string;
prompt: string;
options: QuestionOption[];
/** شکلِ سوال (همان قالبِ شکلِ درس‌ها)؛ null یعنی شکل ندارد. */
figure?: DiagramSpec | null;
}
export interface NodeQuizQuestion extends Question {
@@ -221,8 +241,6 @@ export interface AskDone {
suggestedFollowUps: string[];
/** سوال‌های رایگانِ باقیِ امروز. */
remainingQuota: number;
/** اعتبارِ بسته‌های سوال؛ منقضی نمی‌شود. */
credits?: number;
usedChunks: UsedChunk[];
diagram?: Diagram | null;
}
@@ -261,13 +279,7 @@ export interface Plan {
badge: string;
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
aiDailyQuota: number;
/** «pack» بسته‌ی سوال؛ «subscription» اشتراکِ زمانی که همه‌ی فصل‌ها را باز می‌کند. */
kind: 'pack' | 'subscription';
/** بسته: سوال‌هایی که به اعتبار اضافه می‌شود. */
questionCredits: number;
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
heartsDays: number;
/** SKUِ همین بسته در کافه‌بازار؛ خالی یعنی داخلِ اپِ بازار خریدنی نیست. */
/** SKUِ همین اشتراک در کافه‌بازار؛ خالی یعنی از بازار خریدنی نیست. */
bazaarSku: string;
}
@@ -278,7 +290,7 @@ export interface PlansResponse {
}
export interface SubscriptionStatus {
/** اشتراکِ زمانیِ فعال (یک/سه/شش‌ماهه)، نه بسته. */
/** اشتراکِ زمانیِ فعال (یک/سه/شش‌ماهه). */
active: boolean;
/** نامِ پلنِ فعال؛ null بی‌اشتراک. */
planTitle: string | null;
@@ -288,17 +300,12 @@ export interface SubscriptionStatus {
aiDailyQuota: number;
planId: string | null;
expiresAt: string | null;
/** سوال‌های خریده‌شده‌ی باقی‌مانده. */
questionCredits: number;
/** پایانِ قلبِ نامحدودِ هدیه‌ی بسته‌ها؛ null یعنی ندارد. */
unlimitedHeartsUntil: string | null;
}
/** GET /ai/quota */
export interface TutorQuota {
freeDaily: number;
remainingQuota: number;
credits: number;
}
export interface PurchaseResponse {
@@ -309,6 +316,16 @@ export interface PurchaseResponse {
bazaarSku?: string;
}
/** POST /payment/bazaar/orders/{id}/start — بعد از کدِ تخفیف، درست پیش از صفحه‌ی خریدِ بازار. */
export interface BazaarStartResponse {
orderId: string;
sku: string;
/** مبلغِ سفارش (تومان)، پس از کدِ تخفیف. */
amount: number;
/** JWTِ «تخفیف پویا»؛ null یعنی بازار قیمتِ پیشخان را می‌گیرد. */
dynamicPriceToken: string | null;
}
export interface BazaarVerifyResponse {
orderId: string;
status: string;

View File

@@ -1,11 +1,16 @@
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';
import { OnboardingScreen } from '@/screens/OnboardingScreen';
import { SetupScreen } from '@/screens/SetupScreen';
@@ -19,31 +24,46 @@ type Flow =
| { kind: 'onboarding' }
| { kind: 'login' }
| { kind: 'setup'; profile: UserProfile; grades: Grade[] }
| { kind: 'main'; profile: UserProfile; grades: Grade[] };
/** کمترین زمانِ نمایشِ اسپلش، تا صفحه چشمک نزند. */
const SPLASH_MIN_MS = 700;
| { 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 () => {
const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]);
// نقشه و آمار هم‌زمان با پروفایل گرفته می‌شوند تا خانه کامل بیاید، نه اول «دارم مسیرت رو میارم» و آمارِ صفر.
// شکستشان ورود را نمی‌خواباند: AppData همان‌ها را دوباره می‌گیرد و خطای نقشه را روی خانه نشان می‌دهد.
const profileReq = api.profile.get();
const [profile, grades, prefetch] = await Promise.all([profileReq, api.grades(), prefetchMain(profileReq)]);
await trackReferral();
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades });
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades, prefetch });
else setFlow({ kind: 'setup', profile, grades });
}, []);
const boot = useCallback(async () => {
setFlow({ kind: 'boot' });
const started = Date.now();
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) {
@@ -55,8 +75,6 @@ export function App() {
} else {
next = store.get(KEYS.onboarded) ? { kind: 'login' } : { kind: 'onboarding' };
}
const wait = SPLASH_MIN_MS - (Date.now() - started);
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
if (next) setFlow(next);
}, [enter]);
@@ -64,16 +82,53 @@ export function App() {
void boot();
}, [boot]);
// داخلِ اپِ اندروید اسپلشِ سیستم تا این لحظه روی صفحه می‌ماند؛ پس از اولین صفحه‌ی واقعی (نه boot) می‌رود
// و کاربر مستقیم همان صفحه را می‌بیند. ۳۰۰ms: تا محوشدنِ ورودِ صفحه تمام شود. APKِ قدیمی‌تر appReady ندارد.
useEffect(() => {
if (flow.kind === 'boot') return;
const t = setTimeout(() => window.TadrisinoAndroid?.appReady?.(), 300);
return () => clearTimeout(t);
}, [flow.kind]);
// هر جا نشست باطل شد (refresh هم شکست خورد)، برگرد به صفحه‌ی ورود.
useEffect(
() =>
http.onSignedOut(() => {
askStore.clear();
clearAsyncCache();
setFlow({ kind: 'login' });
}),
[],
);
// عکسِ خانه مالِ کسِ دیگری بود یا پروفایل حالا ناقص است: عکس کنار می‌رود و بوتِ معمولی تصمیم می‌گیرد.
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();
@@ -87,14 +142,15 @@ export function App() {
<MotionConfig reducedMotion="user">
<AppShell>
<ToastProvider>
<AnimatePresence mode="wait">
<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 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
// اسپلش بی‌محو: با عوض‌شدنِ صفحه فوراً می‌رود و صفحه‌ی بعد خودش محو می‌شود.
transition={{ duration: flow.kind === 'boot' ? 0 : 0.2 }}
>
{flow.kind === 'boot' && <SplashScreen />}
{flow.kind === 'bootError' && (
@@ -122,8 +178,14 @@ export function App() {
<MainApp
profile={flow.profile}
grades={flow.grades}
prefetch={flow.prefetch}
stale={flow.stale}
onIdentityChanged={rebootFresh}
payment={launchPayment}
onSignedOut={() => setFlow({ kind: 'login' })}
onSignedOut={() => {
clearAsyncCache();
setFlow({ kind: 'login' });
}}
/>
)}
</motion.div>
@@ -134,6 +196,18 @@ export function App() {
);
}
/** داده‌ی صفحه‌ی اول؛ هر کدام که نرسید null می‌ماند و AppData خودش دوباره می‌گیرد. */
async function prefetchMain(profile: Promise<UserProfile>): Promise<Prefetch> {
// کتابِ انتخاب‌شده به پایه بسته است؛ فقط وقتی چیزی ذخیره شده منتظرِ پروفایل می‌مانیم (پایه‌های ≤ دهم
// معمولاً هیچ انتخابی ندارند و نقشه هم‌زمان با پروفایل می‌آید).
const gradeId = Object.keys(subjectStore.snapshot()).length ? (await profile).gradeId : null;
const [home, summary] = await Promise.allSettled([fetchHome(gradeId), api.summary()]);
return {
home: home.status === 'fulfilled' ? home.value : null,
summary: summary.status === 'fulfilled' ? summary.value : null,
};
}
/** اگر کاربر با لینکِ دعوت آمده بود و حالا وارد شده، معرفی را ثبت کن. */
async function trackReferral() {
const code = store.get(KEYS.referralCode);

View File

@@ -1,9 +1,10 @@
import { lazy, Suspense, useEffect, useRef, useState, type ComponentType } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
import { motion } from 'motion/react';
import { CheckCircle2, XCircle } from 'lucide-react';
import { api, type Grade, type UserProfile } from '@/api';
import { AppDataProvider, useAppData } from '@/state/AppData';
import { NavigatorProvider, useNav } from '@/nav/Navigator';
import { AppDataProvider, useAppData, type Prefetch } from '@/state/AppData';
import { NavigatorProvider, useNav, type NavDirection } from '@/nav/Navigator';
import { ScreenGateContext, type ScreenGate } from '@/lib/useAsync';
import { isTab, type Route } from '@/nav/routes';
import { BottomNav } from '@/components/BottomNav';
import { Sheet } from '@/components/Sheet';
@@ -50,15 +51,25 @@ function preloadScreens() {
interface Props {
profile: UserProfile;
grades: Grade[];
prefetch?: Prefetch;
/** داده‌ها از عکسِ خانه روی گوشی آمده‌اند و پشتِ صحنه تازه می‌شوند (AppData). */
stale?: boolean;
onIdentityChanged?: () => void;
payment: PaymentReturn | null;
onSignedOut: () => void;
}
/** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */
export function MainApp({ profile, grades, payment, onSignedOut }: Props) {
export function MainApp({ profile, grades, prefetch, stale, onIdentityChanged, payment, onSignedOut }: Props) {
useEffect(preloadScreens, []);
return (
<AppDataProvider initialProfile={profile} grades={grades}>
<AppDataProvider
initialProfile={profile}
grades={grades}
prefetch={prefetch}
stale={stale}
onIdentityChanged={onIdentityChanged}
>
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
<Screens onSignedOut={onSignedOut} />
{payment && <PaymentResult payment={payment} />}
@@ -67,50 +78,221 @@ export function MainApp({ profile, grades, payment, onSignedOut }: Props) {
);
}
/**
* صحنه‌ی صفحه‌ها. صفحه‌ی تازه اول پنهان زیرِ صفحه‌ی فعلی ساخته می‌شود و فقط وقتی داده‌ی اولش رسید نشان داده
* می‌شود (ScreenGate در useAsync)، با یک حرکتِ کوتاه. قبلاً صفحه همان لحظه نیمه‌کاره می‌آمد (کارتِ خالی، «نداری»،
* عددِ صفر) و چند صد میلی‌ثانیه بعد داده‌ی واقعی جایش می‌پرید؛ همان «چشمک»ی که روی گوشی دیده می‌شد.
*
* اگر جواب کند باشد، بعد از HOLD_MAX_MS صفحه با لودرِ خودش می‌آید تا لمسِ کاربر بی‌جواب نماند؛ در این فاصله
* نوارِ باریکِ بالا نشان می‌دهد که کاری در جریان است. عددها از راهنمای رایجِ پاسخ‌گویی آمده‌اند (زیرِ ~۱۰۰ms
* «آنی»، تا ~۴۰۰ms هنوز پیوسته)؛ روی گوشیِ واقعی با شبکه‌ی ضعیف سنجیده نشده‌اند.
*/
const HOLD_MAX_MS = 450;
const HOLD_BAR_AFTER_MS = 120;
interface Layer {
id: number;
route: Route;
direction: NavDirection;
/** ریشه‌ی پشته است (تب)، نه صفحه‌ای که روی آن باز شده؛ نوارِ پایین فقط آن‌جا هست. */
root: boolean;
shown: boolean;
}
function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
const nav = useNav();
const { route } = nav;
const showNav = isTab(route.name) && nav.tab === route.name;
// صفحه‌ی اولِ بعد از ورود بی‌انیمیشنِ ورود می‌آید، ولی این را روی خودِ motion.div می‌گذاریم نه
// `<AnimatePresence initial={false}>`: آن یکی به همه‌ی فرزندهای motionِ صفحه‌ی اول هم سرایت می‌کند و
// انیمیشن‌های تکرارشونده‌شان (ماسکوتِ بالا‌پایین، حلقه‌ی جزیره، دکمه‌ی «شروع») تا اولین تعویضِ تب شروع نمی‌شدند.
const firstRender = useRef(true);
const { route, direction, canGoBack } = nav;
const nextId = useRef(1);
// صفحه‌ی اولِ بعد از ورود بی‌انتظار و بی‌حرکت می‌آید (داده‌ی خانه پیش از آن در اسپلش گرفته شده).
const [layers, setLayers] = useState<Layer[]>(() => [{ id: 0, route, direction, root: true, shown: true }]);
useEffect(() => {
firstRender.current = false;
setLayers((prev) => {
if (prev[prev.length - 1].route === route) return prev;
// صفحه‌ای که هنوز نیامده و کاربر از آن گذشته، همان‌جا کنار می‌رود.
const visible = prev.filter((l) => l.shown);
return [...visible, { id: nextId.current++, route, direction, root: !canGoBack, shown: false }];
});
}, [route, direction, canGoBack]);
const reveal = useCallback((id: number) => {
setLayers((prev) => prev.map((l) => (l.id === id ? { ...l, shown: true } : l)));
}, []);
const settle = useCallback((id: number) => {
// صفحه‌ی تازه کامل آمد؛ هرچه زیرش است برداشته شود.
setLayers((prev) => {
const i = prev.findIndex((l) => l.id === id);
return i > 0 ? prev.slice(i) : prev;
});
}, []);
const top = [...layers].reverse().find((l) => l.shown) ?? layers[0];
// زدنِ دوباره‌ی تبِ باز: بخشِ اسکرول‌شونده‌ی صفحه‌ی دیده‌شده نرم به بالا می‌رود.
const stage = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!nav.reselect) return;
const layer = stage.current?.querySelector<HTMLElement>(`[data-layer="${top.id}"]`);
layer?.querySelectorAll<HTMLElement>('.overflow-y-auto').forEach((el) => el.scrollTo({ top: 0, behavior: 'smooth' }));
// فقط با زدنِ دوباره، نه با عوض شدنِ لایه.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nav.reselect]);
const waiting = !layers[layers.length - 1].shown;
// نوارِ پایین از صفحه‌ای که دیده می‌شود پیروی می‌کند، نه از صفحه‌ای که هنوز پنهان است؛ وگرنه ارتفاعِ صفحه‌ی
// فعلی پیش از رفتن عوض می‌شد و می‌پرید. تبِ روشن ولی همان لحظه‌ی لمس عوض می‌شود (بازخوردِ فوری).
const showNav = isTab(top.route.name) && top.root;
return (
<>
<div className="relative flex-1 overflow-hidden bg-page">
<AnimatePresence mode="popLayout">
<motion.div
key={JSON.stringify(route)}
className="absolute inset-0 flex flex-col bg-page"
initial={firstRender.current ? false : { opacity: 0, x: -14 }}
animate={{ opacity: 1, x: 0 }}
// صفحه‌ی قبلی نمی‌پرد و محو نمی‌شود، زیرِ صفحه‌ی تازه می‌ماند تا کامل بیاید. با محوشدنِ هم‌زمانِ
// هر دو، پس‌زمینه‌ی تیره‌ی body از وسط دیده می‌شد و تعویضِ تب «گلیچ» می‌زد (از روی کد؛ با
// فیلمِ دستگاهِ واقعی سنجیده نشده).
exit={{ opacity: 1, x: 0, transition: { duration: 0.16 } }}
transition={{ duration: 0.16, ease: 'easeOut' }}
>
<Suspense fallback={<LoadingState />}>
<RouteView route={route} onSignedOut={onSignedOut} />
</Suspense>
</motion.div>
</AnimatePresence>
<div ref={stage} className="relative flex-1 overflow-hidden bg-page">
{layers.map((layer) => (
<ScreenLayer key={layer.id} layer={layer} onReady={reveal} onSettled={settle} onSignedOut={onSignedOut} />
))}
<HoldBar active={waiting} />
</div>
{showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />}
</>
);
}
const ENTER: Record<NavDirection, { opacity: number; x: number }> = {
// در راست‌به‌چپ صفحه‌ی بعدی از چپ می‌آید و برگشت از راست؛ تعویضِ تب فقط محو است، مثلِ اپ‌های بومی.
push: { opacity: 0, x: -28 },
replace: { opacity: 0, x: -28 },
back: { opacity: 0, x: 28 },
tab: { opacity: 0, x: 0 },
};
function ScreenLayer({ layer, onReady, onSettled, onSignedOut }: {
layer: Layer;
onReady: (id: number) => void;
onSettled: (id: number) => void;
} & Pick<Props, 'onSignedOut'>) {
const { id, shown } = layer;
const initiallyShown = useRef(shown).current;
const gate = useGate(initiallyShown ? null : () => onReady(id));
// اگر onAnimationComplete نرسید (مثلاً «کاهشِ حرکت» یا قطعِ انیمیشن)، صفحه‌های زیرین باز هم برداشته شوند.
useEffect(() => {
if (!shown || initiallyShown) return;
const t = setTimeout(() => onSettled(id), 700);
return () => clearTimeout(t);
}, [shown, initiallyShown, id, onSettled]);
return (
<ScreenGateContext.Provider value={gate}>
<motion.div
className="absolute inset-0 flex flex-col bg-page"
initial={initiallyShown ? false : ENTER[layer.direction]}
animate={shown ? { opacity: 1, x: 0 } : ENTER[layer.direction]}
transition={{ duration: layer.direction === 'tab' ? 0.14 : 0.2, ease: [0.2, 0.8, 0.2, 1] }}
onAnimationComplete={() => shown && onSettled(id)}
data-layer={id}
style={{ pointerEvents: shown ? 'auto' : 'none', zIndex: id }}
aria-hidden={!shown}
// صفحه‌ی پنهان نباید با Tab صفحه‌کلید فوکوس بگیرد. (React 18 «inert» را فقط به‌صورتِ رشته رد می‌کند.)
{...((!shown ? { inert: '' } : {}) as object)}
>
<Suspense fallback={<SuspenseHold />}>
<RouteView route={layer.route} onSignedOut={onSignedOut} />
</Suspense>
</motion.div>
</ScreenGateContext.Provider>
);
}
/**
* شمارنده‌ی holdهای صفحه. وقتی بعد از mount هیچ holdی باز نماند، یا HOLD_MAX_MS گذشت، onReady یک بار صدا
* زده می‌شود. React افکت‌های فرزند را پیش از پدر اجرا می‌کند، پس تا افکتِ این‌جا همه‌ی useAsyncهای صفحه
* hold گرفته‌اند.
*/
function useGate(onReady: (() => void) | null): ScreenGate | null {
const readyRef = useRef(onReady);
readyRef.current = onReady;
const state = useRef({ count: 0, mounted: false, done: onReady === null });
const fire = useCallback(() => {
const s = state.current;
if (s.done) return;
s.done = true;
readyRef.current?.();
}, []);
const gate = useMemo<ScreenGate | null>(
() =>
onReady === null
? null
: {
hold() {
const s = state.current;
if (s.done) return () => {};
s.count++;
let released = false;
return () => {
if (released) return;
released = true;
s.count--;
// یک microtask صبر: در StrictMode (و جابه‌جاییِ Suspense) افکت‌ها پاک و دوباره اجرا می‌شوند و
// hold تازه بلافاصله گرفته می‌شود؛ بی‌این صبر دروازه پیش از رسیدنِ داده باز می‌شد.
queueMicrotask(() => {
if (s.mounted && s.count === 0) fire();
});
};
},
},
// فقط بارِ اول؛ لایه‌ای که یک بار نشان داده شد دیگر دروازه ندارد.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
useEffect(() => {
const s = state.current;
if (s.done) return;
s.mounted = true;
if (s.count === 0) {
fire();
return;
}
const t = setTimeout(fire, HOLD_MAX_MS);
return () => {
s.mounted = false;
clearTimeout(t);
};
}, [fire]);
return gate;
}
/** تا فایلِ صفحه از شبکه نرسیده، دروازه بسته می‌ماند (معمولاً از قبل در پس‌زمینه گرفته شده). */
function SuspenseHold() {
const gate = useContext(ScreenGateContext);
useEffect(() => {
const release = gate?.hold();
// آزاد کردن خودش یک microtask صبر می‌کند (useGate)، پس useAsyncهای صفحه‌ی تازه پیش از آن hold گرفته‌اند.
return () => release?.();
}, [gate]);
return <LoadingState />;
}
/** نوارِ باریکِ بالای صفحه وقتی صفحه‌ی بعدی بیش از HOLD_BAR_AFTER_MS منتظرِ داده است. */
function HoldBar({ active }: { active: boolean }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!active) {
setVisible(false);
return;
}
const t = setTimeout(() => setVisible(true), HOLD_BAR_AFTER_MS);
return () => clearTimeout(t);
}, [active]);
return (
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 z-[9999] h-[3px] overflow-hidden">
{visible && <div className="hold-bar h-full w-1/3 rounded-full bg-white/90 shadow-[0_0_6px_rgba(237,105,86,.8)]" />}
</div>
);
}
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
switch (route.name) {
case 'home': return <HomeScreen />;
case 'lessons': return <LessonsScreen />;
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
case 'subscription': return <SubscriptionScreen key={route.view ?? ''} initialView={route.view} />;
case 'subscription': return <SubscriptionScreen />;
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
case 'unit': return <UnitScreen unitId={route.unitId} />;
case 'lesson': return <LessonScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
@@ -130,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') {
@@ -159,14 +366,14 @@ function PaymentResult({ payment }: { payment: PaymentReturn }) {
<div className="flex flex-col items-center gap-3 pb-2 text-center">
{paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />}
<h2 className="text-lg font-black text-ink">
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیتِ پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیت پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
</h2>
<p className="text-sm leading-7 text-muted">
{paid
? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.'
: state === 'unknown'
? 'چند دقیقه‌ی دیگه از «پرداخت‌ها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال می‌شه یا پول خودکار برمی‌گرده.'
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'}
? 'چند دقیقه دیگه از «پرداخت‌ها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال می‌شه یا پول خودکار برمی‌گرده.'
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طی چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'}
</p>
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
</div>

BIN
src/assets/pay/bazaar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src/assets/pay/zarinpal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -102,15 +102,15 @@ export function BirthDatePicker({
return (
<div className="grid grid-cols-[1fr_1.5fr_1.3fr] gap-2">
<select aria-label="روزِ تولد" value={d} onChange={(e) => setD(e.target.value ? +e.target.value : '')} className={`${select} ${tone(d)}`}>
<select aria-label="روز تولد" value={d} onChange={(e) => setD(e.target.value ? +e.target.value : '')} className={`${select} ${tone(d)}`}>
<option value="">روز</option>
{Array.from({ length: days }, (_, i) => i + 1).map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
</select>
<select aria-label="ماهِ تولد" value={m} onChange={(e) => setM(e.target.value ? +e.target.value : '')} className={`${select} ${tone(m)}`}>
<select aria-label="ماه تولد" value={m} onChange={(e) => setM(e.target.value ? +e.target.value : '')} className={`${select} ${tone(m)}`}>
<option value="">ماه</option>
{JALALI_MONTHS.map((name, i) => <option key={name} value={i + 1}>{name}</option>)}
</select>
<select aria-label="سالِ تولد" value={y} onChange={(e) => setY(e.target.value ? +e.target.value : '')} className={`${select} ${tone(y)}`}>
<select aria-label="سال تولد" value={y} onChange={(e) => setY(e.target.value ? +e.target.value : '')} className={`${select} ${tone(y)}`}>
<option value="">سال</option>
{years.map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
</select>

View File

@@ -1,3 +1,6 @@
import { sounds } from '@/lib/sound';
import { useCompanion } from '@/state/companion';
import { onWhite } from '@/lib/color';
import { Brain, ShoppingBag, Home, BookOpen, UserRound, type LucideIcon } from 'lucide-react';
import type { TabName } from '@/nav/routes';
@@ -5,11 +8,13 @@ const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
{ id: 'home', icon: Home, label: 'خانه' },
{ id: 'lessons', icon: BookOpen, label: 'درس‌ها' },
{ id: 'ask', icon: Brain, label: 'دستیار' },
{ id: 'subscription', icon: ShoppingBag, label: 'بسته‌ها' },
{ id: 'subscription', icon: ShoppingBag, label: 'اشتراک' },
{ id: 'profile', icon: UserRound, label: 'من' },
];
export function BottomNav({ active, onChange }: { active: TabName | null; onChange: (tab: TabName) => void }) {
// تبِ روشن به رنگِ همراهِ انتخابی (تیره‌شده تا روی سفید خوانا بماند؛ امگای طلایی بی‌آن کم‌رنگ بود).
const tint = onWhite(useCompanion().color);
return (
<nav className="flex flex-shrink-0 items-center justify-around border-t border-line bg-white px-2 pb-safe pt-1.5">
{ITEMS.map(({ id, icon: Icon, label }) => {
@@ -18,11 +23,15 @@ export function BottomNav({ active, onChange }: { active: TabName | null; onChan
<button
key={id}
type="button"
onClick={() => onChange(id)}
onClick={() => {
sounds.tap();
onChange(id);
}}
aria-current={on ? 'page' : undefined}
className={`flex min-w-14 flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-[color,transform] active:scale-90 ${on ? 'text-coral' : 'text-muted'}`}
className={`flex min-w-14 flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-[color,transform] active:scale-90 ${on ? '' : 'text-muted'}`}
style={on ? { color: tint } : undefined}
>
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
<span className="rounded-xl p-1.5 transition-colors" style={on ? { backgroundColor: `${tint}1F` } : undefined}>
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
</span>
<span className="text-[11px] font-bold">{label}</span>

View File

@@ -6,8 +6,8 @@ export function BrandFooter() {
return (
<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-muted">ریاضی، قدم‌به‌قدم، از روی کتاب درسی</span>
<span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو ریاضی · همه حقوق محفوظ است</span>
</footer>
);
}

View File

@@ -1,23 +1,31 @@
import { useId } from 'react';
import { useCompanion } from '@/state/companion';
/**
* نشانِ تدریسینو: یک کاشیِ گرد با «π» و جرقه‌ی کوچکِ چشمک‌زن، کنارِ نامِ تدریسینو.
* خودِ SVG است (بی‌فایلِ تصویر)، پس در هر اندازه تیز است و چیزی دانلود نمی‌شود.
* نشانِ تدریسینو: یک کاشیِ گرد با نمادِ همراهِ انتخابیِ کاربر (Σ، Δ، π، ω) به رنگِ همان همراه، و جرقه‌ی کوچکِ
* چشمک‌زن، کنارِ نامِ تدریسینو. پیش‌تر همیشه «π»ِ نارنجی بود و با همراهِ کاربر جور نبود (خواستِ مالک، ۱۴۰۵-۰۷).
* خودِ SVG است (بی‌فایلِ تصویر).
*/
export function BrandMark({ size = 28, tone = 'light', withName = true }: { size?: number; tone?: 'light' | 'dark'; withName?: boolean }) {
const ink = tone === 'light' ? '#fff' : '#3C3F8F';
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="bm-g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#FF9A5C" />
<stop offset="1" stopColor="#ED6956" />
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor={c.light} />
<stop offset="1" stopColor={c.color} />
</linearGradient>
</defs>
<rect x="1" y="1" width="30" height="30" rx="9" fill="url(#bm-g)" stroke={tone === 'light' ? 'rgba(255,255,255,.55)' : 'none'} />
<path d="M9 11.5h14M12.5 11.5v10M19 11.5v8.2c0 1.2.7 1.8 1.8 1.8" stroke="#fff" strokeWidth="2.6" strokeLinecap="round" fill="none" />
<rect x="1" y="1" width="30" height="30" rx="9" fill={`url(#${gid})`} stroke={tone === 'light' ? 'rgba(255,255,255,.55)' : 'none'} />
<text x="16" y="17" textAnchor="middle" dominantBaseline="central" fill="#fff" fontSize="19" fontWeight="900" fontFamily="Vazirmatn, system-ui, sans-serif">
{c.symbol}
</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

@@ -0,0 +1,131 @@
import { useEffect, useState } from 'react';
import { Flag, ThumbsDown, ThumbsUp } from 'lucide-react';
import { api } from '@/api';
import { friendlyError } from '@/lib/errors';
import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast';
import { PrimaryButton } from '@/components/ui';
type Kind = 'question' | 'lesson';
const REASONS: Record<Kind, { id: string; label: string }[]> = {
question: [
{ id: 'wrong_answer', label: 'جواب درست اشتباهه' },
{ id: 'unclear', label: 'صورت سوال گنگه' },
{ id: 'typo', label: 'غلط تایپی داره' },
{ id: 'too_hard', label: 'برای این درس خیلی سخته' },
{ id: 'other', label: 'یه چیز دیگه' },
],
lesson: [
{ id: 'wrong_content', label: 'مطلب درس اشتباهه' },
{ id: 'unclear', label: 'توضیح گنگه' },
{ id: 'typo', label: 'غلط تایپی داره' },
{ id: 'bad_figure', label: 'شکل ایراد داره' },
{ id: 'other', label: 'یه چیز دیگه' },
],
};
const memoKey = (kind: Kind, id: string) => `tadrisino.vote.${kind}.${id}`;
function readVote(kind: Kind, id: string): 1 | -1 | 0 {
try {
const v = Number(sessionStorage.getItem(memoKey(kind, id)));
return v === 1 || v === -1 ? v : 0;
} catch {
return 0;
}
}
/**
* زیرِ هر سوال و هر درس: لایک، دیسلایک و گزارشِ ایراد. به ادمین می‌رسد
* (ستونِ 👍/👎 و صفِ گزارش‌ها) تا ایرادِ محتوا را خودِ دانش‌آموزها خبر بدهند.
*/
export function FeedbackBar({ kind, id }: { kind: Kind; id: string }) {
const toast = useToast();
const [vote, setVote] = useState<1 | -1 | 0>(0);
const [open, setOpen] = useState(false);
useEffect(() => setVote(readVote(kind, id)), [kind, id]);
async function cast(next: 1 | -1) {
const value = vote === next ? 0 : next;
const before = vote;
setVote(value);
try {
await api.feedback.vote(kind, id, value);
try { sessionStorage.setItem(memoKey(kind, id), String(value)); } catch { /* حافظه‌ی مرورگر بسته است؛ فقط یادآوریِ همین نشست */ }
if (value !== 0) toast('ممنون از نظرت!', 'success');
} catch (err) {
setVote(before);
toast(friendlyError(err, 'نظرت ثبت نشد'), 'error');
}
}
const base = 'flex min-h-11 flex-1 items-center justify-center gap-1.5 rounded-2xl border-2 px-2 text-xs font-black transition-colors';
const idle = 'border-line bg-white text-muted';
return (
<>
<div className="flex gap-2" role="group" aria-label="نظر و گزارش">
<button type="button" aria-pressed={vote === 1} onClick={() => void cast(1)} className={`${base} ${vote === 1 ? 'border-[#5FBF8F] bg-[#EEF9F3] text-[#2f8a5f]' : idle}`}>
<ThumbsUp size={16} strokeWidth={2.4} /> خوب بود
</button>
<button type="button" aria-pressed={vote === -1} onClick={() => void cast(-1)} className={`${base} ${vote === -1 ? 'border-[#ED6956] bg-[#fff4f2] text-coral' : idle}`}>
<ThumbsDown size={16} strokeWidth={2.4} /> خوب نبود
</button>
<button type="button" onClick={() => setOpen(true)} className={`${base} ${idle}`}>
<Flag size={16} strokeWidth={2.4} /> گزارش ایراد
</button>
</div>
<ReportSheet open={open} kind={kind} id={id} onClose={() => setOpen(false)} />
</>
);
}
function ReportSheet({ open, kind, id, onClose }: { open: boolean; kind: Kind; id: string; onClose: () => void }) {
const toast = useToast();
const [reason, setReason] = useState<string | null>(null);
const [note, setNote] = useState('');
const [sending, setSending] = useState(false);
useEffect(() => {
if (open) { setReason(null); setNote(''); }
}, [open]);
async function send() {
if (!reason) return;
setSending(true);
try {
const r = await api.feedback.report(kind, id, reason, note.trim());
toast(r.alreadyReported ? 'قبلاً گزارشش رو داده بودی؛ ممنون!' : 'ممنون! گزارشت رسید و بررسی می‌شه.', 'success');
onClose();
} catch (err) {
toast(friendlyError(err, 'گزارش ارسال نشد'), 'error');
} finally {
setSending(false);
}
}
return (
<Sheet open={open} onClose={onClose} title={kind === 'question' ? 'این سوال چه ایرادی داره؟' : 'این درس چه ایرادی داره؟'}>
<div className="mb-4 flex flex-col gap-2">
{REASONS[kind].map((r) => (
<button
key={r.id}
type="button"
onClick={() => setReason(r.id)}
className={`rounded-2xl border-2 px-4 py-3 text-right text-sm font-bold ${reason === r.id ? 'border-coral bg-[#fff4f2] text-coral' : 'border-line text-body'}`}
>
{r.label}
</button>
))}
</div>
<textarea
value={note}
onChange={(e) => setNote(e.target.value.slice(0, 500))}
placeholder="توضیح بیشتر (اختیاری)"
rows={2}
className="mb-4 w-full resize-none rounded-2xl border-2 border-line px-4 py-3 text-sm outline-none focus:border-coral"
/>
<PrimaryButton disabled={!reason} loading={sending} onClick={() => void send()}>ارسال گزارش</PrimaryButton>
</Sheet>
);
}

View File

@@ -1,4 +1,4 @@
import { forwardRef } from 'react';
import { forwardRef, memo, useMemo } from 'react';
import { motion } from 'motion/react';
import { Check, Lock, Play, Star } from 'lucide-react';
import type { HomeNode } from '@/api';
@@ -6,6 +6,9 @@ import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
import { iconFor } from '@/components/NodeIcon';
import { shade } from '@/lib/color';
import { MathText } from '@/lib/MathText';
import { useFirstVisit } from '@/lib/firstVisit';
import { SEYAR_SVG, type SeyarChar, type SeyarMood } from '@/mascots/seyar';
import { useCompanion } from '@/state/companion';
const NODE_SIZE = 76;
const SPACING = 128;
@@ -25,12 +28,18 @@ export const IslandPath = forwardRef<HTMLDivElement, {
onOpen: (node: HomeNode) => void;
onStart: (node: HomeNode) => void;
}>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) {
const points = zigzagPositions(nodes.length, SPACING, TOP);
const points = useMemo(() => zigzagPositions(nodes.length, SPACING, TOP), [nodes.length]);
const intro = useFirstVisit(`islands:${nodes[0]?.id ?? ''}`);
const height = points.length ? points[points.length - 1].y + 80 : 120;
const { char } = useCompanion();
const buddies = useMemo(() => buddySpots(nodes, points, currentId), [nodes, points, currentId]);
return (
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
<ZigzagPath points={points} height={height} colorOf={(i) => (nodes[i].status === 'completed' ? color : null)} />
{buddies.map((b) => (
<PathBuddy key={b.segment} char={char} {...b} />
))}
{nodes.map((node, i) => {
const locked = node.status === 'locked';
const done = node.status === 'completed';
@@ -40,7 +49,7 @@ export const IslandPath = forwardRef<HTMLDivElement, {
<motion.div
key={node.id}
ref={current ? currentRef : undefined}
initial={{ opacity: 0, scale: 0.7 }}
initial={intro ? { opacity: 0, scale: 0.7 } : { opacity: 1, scale: 1 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: Math.min(i, 8) * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
className="absolute -translate-x-1/2 -translate-y-1/2"
@@ -98,7 +107,7 @@ export const IslandPath = forwardRef<HTMLDivElement, {
points[i].x >= 50 ? 'right-full mr-3 text-right' : 'left-full ml-3 text-left'
} ${current ? 'text-[12px] text-ink' : locked ? 'text-faint' : 'text-muted'}`}
>
<span className="line-clamp-2"><MathText text={node.title} /></span>
<span className="line-clamp-3"><MathText text={node.title} /></span>
</div>
</motion.div>
);
@@ -106,3 +115,75 @@ export const IslandPath = forwardRef<HTMLDivElement, {
</div>
);
});
interface BuddySpot {
segment: number;
x: number;
y: number;
mood: SeyarMood;
current: boolean;
dim: boolean;
}
/**
* جای همراه‌های کنارِ مسیر: وسطِ یک پیچ (بینِ دو جزیره)، در سمتی که مسیر از آن دور است. در آن ارتفاع نه اسمِ جزیره‌ای
* هست (اسم‌ها هم‌ترازِ خودِ جزیره‌اند، ±۲۴ پیکسل) نه دکمه‌ی «شروع» (بالای جزیره‌ی فعلی). همراهِ اصلی زیرِ جزیره‌ی فعلی
* تشویق می‌کند؛ بقیه هر سه پیچ یکی، تا مسیر شلوغ نشود. فاصله‌ها با SPACING = ۱۲۸ و اندازه‌ی ≤ ۶۴ سنجیده شده‌اند، نه با
* عرضِ صفحه‌ی کمتر از ۳۲۰ پیکسل.
*/
function buddySpots(nodes: HomeNode[], points: { x: number; y: number }[], currentId: string | null): BuddySpot[] {
if (points.length < 2) return [];
const currentIndex = nodes.findIndex((n) => n.id === currentId);
const mainSegment = currentIndex < 0 ? -1 : Math.min(currentIndex, points.length - 2);
const spots: BuddySpot[] = [];
for (let i = 0; i < points.length - 1; i++) {
const main = i === mainSegment;
if (!main && (i % 3 !== 1 || Math.abs(i - mainSegment) <= 1)) continue;
const midX = (points[i].x + points[i + 1].x) / 2;
const x = Math.min(84, Math.max(16, midX >= 50 ? midX - 34 : midX + 34));
const locked = nodes[i + 1].status === 'locked' && !main;
spots.push({
segment: i,
x,
y: (points[i].y + points[i + 1].y) / 2,
mood: main ? 'excited' : locked ? 'idle' : 'happy',
current: main,
dim: locked,
});
}
return spots;
}
/** همراهِ انتخابیِ کاربر کنارِ مسیر؛ فقط تزئین است و لمس را به جزیره‌ها رد می‌کند. */
const PathBuddy = memo(function PathBuddy({ char, segment, x, y, mood, current, dim }: BuddySpot & { char: SeyarChar }) {
const size = current ? 62 : 46;
const svg = useMemo(() => SEYAR_SVG[char](mood), [char, mood]);
return (
<div
aria-hidden
className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: `${x}%`, top: y, opacity: dim ? 0.5 : 1 }}
>
{current && (
<>
<Sparkle className="spark absolute -right-2 top-0" />
<Sparkle className="spark absolute -left-3 top-5" style={{ animationDelay: '0.8s' }} />
</>
)}
<div
className={current ? 'buddy-hop' : 'buddy-sway'}
style={{ width: size, height: size, lineHeight: 0, animationDelay: `${(segment % 4) * 0.35}s` }}
// SVG از seyar.ts ساخته می‌شود و هیچ ورودیِ کاربر در آن نیست.
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
);
});
function Sparkle({ className, style }: { className: string; style?: React.CSSProperties }) {
return (
<svg width="14" height="14" viewBox="0 0 24 24" className={className} style={style}>
<path d="M12 0 L14.5 9.5 L24 12 L14.5 14.5 L12 24 L9.5 14.5 L0 12 L9.5 9.5 Z" fill="#FFC53D" />
</svg>
);
}

View File

@@ -48,18 +48,18 @@ export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; c
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
<Lock size={24} className="text-muted" />
</span>
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره‌ی قبلی رو تموم کن تا باز بشه.</p>
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره قبلی رو تموم کن تا باز بشه.</p>
</div>
) : (
<>
<p className="mb-4 text-sm leading-7 text-body">
{done
? 'این جزیره رو تموم کردی! می‌تونی دوباره تمرینش کنی (امتیازِ تازه نداره).'
: 'اگه مطمئن نیستی، اول درس‌ها رو بخون؛ بعد آزمونِ کوتاهِ جزیره رو بده. هر جوابی بدی، آخرش جزیره‌ی بعدی باز می‌شه.'}
? 'این جزیره رو تموم کردی! می‌تونی دوباره تمرینش کنی (امتیاز تازه نداره).'
: 'اگه مطمئن نیستی، اول درس‌ها رو بخون؛ بعد آزمون کوتاه جزیره رو بده. هر جوابی بدی، آخرش جزیره بعدی باز می‌شه.'}
</p>
<PrimaryButton color={color} onClick={start}>
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروعِ آزمونِ جزیره</>}
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروع آزمون جزیره</>}
</PrimaryButton>
<button
type="button"
@@ -68,7 +68,7 @@ export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; c
style={{ borderColor: '#39FF14', color: '#15803d', backgroundColor: showLessons ? '#39FF1422' : 'white' }}
aria-expanded={showLessons}
>
<BookOpen size={18} /> {done ? 'مرورِ درس‌ها' : 'اول درس‌ها رو بخون'}
<BookOpen size={18} /> {done ? 'مرور درس‌ها' : 'اول درس‌ها رو بخون'}
<ChevronDown size={16} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} />
</button>

View File

@@ -1,12 +1,13 @@
import { useEffect, useState } from 'react';
import { motion } from 'motion/react';
import { Brain, Heart, HeartCrack, Infinity as InfinityIcon, ShoppingBag, X } from 'lucide-react';
import { Brain, Heart, HeartCrack, Infinity as InfinityIcon, Crown, X } from 'lucide-react';
import { api } from '@/api';
import { useAppData } from '@/state/AppData';
import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { PrimaryButton, Screen } from '@/components/ui';
import { toFa } from '@/lib/format';
import { useCompanion } from '@/state/companion';
/**
* «قلب‌هات تموم شد»: با قلبِ صفر سرور هیچ جوابی نمی‌پذیرد (۴۰۳ با code=no_hearts) و پایانِ درس یا
@@ -43,9 +44,12 @@ function whenLabel(iso: string, now: number): string {
const dayOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const days = Math.round((dayOf(at) - dayOf(new Date(now))) / 86_400_000);
const day = days <= 0 ? 'امروز' : days === 1 ? 'فردا' : new Intl.DateTimeFormat('fa-IR', { weekday: 'long' }).format(at);
return `ساعتِ ${time} ${day}`;
return `ساعت ${time} ${day}`;
}
/** سرور هر بار regen را خودش حساب می‌کند؛ پرسیدنِ هر ۱۵ ثانیه برای یک صفحه‌ی منتظر بار نیست. */
const DUE_RECHECK_MS = 15_000;
export function NoHeartsScreen({
nextRefillAt,
fullRefillAt,
@@ -56,6 +60,7 @@ export function NoHeartsScreen({
/** قلب برگشت و کاربر «ادامه بده» زد. */
onRefilled: () => void;
}) {
const companionName = useCompanion().name;
const nav = useNav();
const { companion } = useAppData();
const now = useNow();
@@ -79,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]);
@@ -111,7 +120,7 @@ export function NoHeartsScreen({
<div>
<h1 className="text-2xl font-black text-ink">{back ? 'یه قلب برگشت!' : 'قلب‌هات تموم شد'}</h1>
<p className="mt-1.5 text-sm leading-7 text-muted">
{back ? 'بریم ادامه بدیم؛ این بار با دقتِ بیشتر' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'}
{back ? 'بریم ادامه بدیم؛ این بار با دقت بیشتر' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'}
</p>
</div>
@@ -123,7 +132,7 @@ export function NoHeartsScreen({
</div>
) : (
<div className="w-full rounded-3xl border border-line bg-white p-5 shadow-sm">
<div className="text-xs font-bold text-muted">قلبِ بعدی</div>
<div className="text-xs font-bold text-muted">قلب بعدی</div>
{left !== null ? (
<>
<div dir="ltr" className="mt-1 text-4xl font-black tracking-wide text-coral tabular-nums">{clock(left)}</div>
@@ -136,7 +145,7 @@ export function NoHeartsScreen({
</div>
{full && (
<p className="mt-3 text-xs leading-6 text-muted">
همه‌ی {toFa(MAX_HEARTS)} قلب: <b className="text-body">{whenLabel(full, now)}</b>
همه {toFa(MAX_HEARTS)} قلب: <b className="text-body">{whenLabel(full, now)}</b>
</p>
)}
</>
@@ -158,10 +167,10 @@ export function NoHeartsScreen({
<Heart size={18} fill="#fff" /> <InfinityIcon size={18} /> نمی‌خوای منتظر بمونی؟
</span>
<span className="mt-1 block text-xs leading-6 text-white/90">
هر بسته‌ی سوال چند روز قلبِ نامحدود هدیه داره؛ هر چقدر بخوای تمرین کن.
با اشتراک، قلب‌هات نامحدود می‌شه؛ هر چقدر بخوای تمرین کن.
</span>
<span className="mt-3 inline-flex items-center gap-1.5 rounded-2xl bg-white px-4 py-2 text-sm font-black text-coral">
<ShoppingBag size={15} /> دیدنِ بسته‌ها
<Crown size={15} /> دیدن اشتراک‌ها
</span>
</button>
@@ -174,7 +183,7 @@ export function NoHeartsScreen({
<Brain size={20} />
</span>
<span className="flex-1">
<span className="block text-sm font-black text-ink">تا اون موقع از «پی» بپرس</span>
<span className="block text-sm font-black text-ink">تا اون موقع از «{companionName}» بپرس</span>
<span className="block text-xs leading-6 text-muted">جایی که گیر کردی رو با هم مرور کنیم؛ قلب خرج نمی‌کنه.</span>
</span>
</button>
@@ -192,7 +201,7 @@ export function NoHeartsScreen({
/** پنج جای قلب؛ اولی همراهِ زمان از پایین پر می‌شود. */
function HeartSlots({ progress }: { progress: number }) {
return (
<div className="flex items-center gap-2.5" aria-label={`قلبِ بعدی ${toFa(Math.round(progress * 100))} درصد پر شده`}>
<div className="flex items-center gap-2.5" aria-label={`قلب بعدی ${toFa(Math.round(progress * 100))} درصد پر شده`}>
{Array.from({ length: MAX_HEARTS }, (_, i) => (
<span key={i} className="relative h-8 w-8">
<Heart size={32} className="absolute inset-0 text-[#F2DCD8]" fill="#FBEDEA" />

View File

@@ -1,5 +1,5 @@
import {
BookOpen, Brain, Compass, Grid3x3, LineChart, Play, Repeat, Sigma, Sparkles, TrendingUp,
BarChart3, BookOpen, Brain, Compass, Grid3x3, LineChart, Network, Play, Repeat, Sigma, Sparkles, TrendingUp,
type LucideIcon,
} from 'lucide-react';
@@ -8,7 +8,7 @@ import {
* مجموعه‌ی کوچک در باندل است تا کلِ کتابخانه‌ی آیکن بار نشود.
*/
const ICONS: Record<string, LucideIcon> = {
Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain, Play,
Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain, Play, BarChart3, Network,
};
const FALLBACK: LucideIcon[] = [Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain];

View File

@@ -1,8 +1,12 @@
import { useEffect, type ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/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';
/** برگه‌ای که از پایین بالا می‌آید؛ با زدنِ پس‌زمینه یا Escape بسته می‌شود. */
/**
* برگه‌ای که از پایین بالا می‌آید؛ با زدنِ پس‌زمینه، Escape، یا کشیدنِ دستگیره/سرِ برگه به پایین بسته می‌شود.
* کشیدن فقط از بالای برگه شروع می‌شود تا با اسکرولِ محتوای بلندِ برگه قاطی نشود.
*/
export function Sheet({
open,
onClose,
@@ -14,38 +18,71 @@ export function Sheet({
title?: ReactNode;
children: ReactNode;
}) {
const drag = useDragControls();
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>
{open && (
<div className="absolute inset-0 z-50 flex flex-col justify-end">
<motion.div
className="absolute inset-0 bg-[#14142a]/45"
className="absolute inset-0 bg-[#14142a]/55"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
drag="y"
dragControls={drag}
dragListener={false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.9 }}
onDragEnd={(_, info) => {
if (info.offset.y > 90 || info.velocity.y > 600) onClose();
}}
>
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-soft" />
<div className="-mx-5 -mt-3 cursor-grab touch-none px-5 pb-3 pt-3" onPointerDown={(e) => drag.start(e)}>
<div className="mx-auto h-1.5 w-10 rounded-full bg-line" />
</div>
{title && (
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-base font-black text-ink">{title}</h2>
<div className="mb-4 flex touch-none items-center justify-between gap-3" onPointerDown={(e) => drag.start(e)}>
<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

@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react';
import { motion } from 'motion/react';
import { PrimaryButton } from '@/components/ui';
import { toFa } from '@/lib/format';
import { sounds } from '@/lib/sound';
/**
* سه حالتِ زنجیره، مثلِ دولینگو:
* lit امروز تمرین شده؛ آتشِ نارنجیِ زنده (لرزشِ آرامِ شعله‌ها).
* risk زنجیره هست ولی امروز هنوز نه؛ همان شعله خاکستری، تا کاربر ببیند چیزی منتظرش است.
* frozen زنجیره دیروز شکست؛ شعله‌ی یخ‌زده‌ی آبی، با برق.
* none هیچ‌وقت زنجیره نداشته؛ مثلِ risk.
* فقط SVG و CSS (transform/opacity)، بی‌تصویر.
*/
export type FlameState = 'lit' | 'risk' | 'frozen' | 'none';
export function flameState(streak: { current: number; activeToday?: boolean; lost?: number } | undefined | null): FlameState {
if (!streak) return 'none';
if (streak.activeToday) return 'lit';
if (streak.current > 0) return 'risk';
if ((streak.lost ?? 0) > 0) return 'frozen';
return 'none';
}
const PALETTE: Record<FlameState, [string, string, string]> = {
lit: ['#FF7A3D', '#FFB23F', '#FFE58A'],
risk: ['#C9C6DA', '#DCDAE8', '#EEEDF5'],
none: ['#C9C6DA', '#DCDAE8', '#EEEDF5'],
frozen: ['#5BB8F0', '#9ED8FA', '#E6F6FF'],
};
export function StreakFlame({ state, size = 20, className = '' }: { state: FlameState; size?: number; className?: string }) {
const [outer, middle, core] = PALETTE[state];
const alive = state === 'lit';
return (
<svg viewBox="0 0 64 80" width={size} height={size * 1.25} className={className} aria-hidden>
<g className={alive ? 'flame-outer' : undefined}>
<path d="M32 2C36 18 54 26 54 50c0 15-10 28-22 28S10 65 10 50c0-11 6-18 10-22 1 7 4 11 8 12-2-14 0-28 4-38z" fill={outer} />
</g>
<g className={alive ? 'flame-inner' : undefined}>
<path d="M33 30c3 9 13 13 13 26 0 9-6 16-14 16s-14-7-14-16c0-6 3-10 6-12 0 4 2 7 5 7-1-8 1-15 4-21z" fill={middle} />
</g>
<path d="M32 52c2 5 7 7 7 13 0 4-3 7-7 7s-7-3-7-7c0-3 2-5 3-6 0 2 1 3 2 3 0-4 1-7 2-10z" fill={core} />
{state === 'frozen' && (
<>
{/* برقِ یخ و ترک‌های ریز */}
<path d="M22 40l6 4M40 34l-5 6M30 60l4-5" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" opacity=".85" />
<circle cx="44" cy="22" r="2.4" fill="#fff" className="ice-glint" />
</>
)}
</svg>
);
}
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
/**
* صفحه‌ی «زنجیره‌ت روشن ماند!» بعد از اولین درس/جزیره‌ی امروز. عدد از مقدارِ قبلی به تازه می‌رود و روزهای این
* هفته که در زنجیره‌اند روشن می‌شوند (از طولِ زنجیره حساب می‌شود، بی‌درخواستِ دیگر).
*/
export function StreakCelebration({ from, to, onDone }: { from: number; to: number; onDone: () => void }) {
const [shown, setShown] = useState(from);
useEffect(() => {
sounds.streak();
const t = setTimeout(() => setShown(to), 650);
return () => clearTimeout(t);
}, [to]);
const today = (new Date().getDay() + 1) % 7; // شنبه = ۰
const message = to === 1
? 'اولین روز زنجیره‌ت! فردا هم بیا تا آتیشش بزرگ‌تر بشه.'
: `${toFa(to)} روزه که هر روز ریاضی تمرین می‌کنی. فردا هم بیا تا خاموش نشه!`;
return (
<div className="relative flex h-full flex-col items-center justify-center gap-6 overflow-hidden bg-gradient-to-b from-[#FFF4EC] to-white px-8 text-center">
<div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-1/3 h-48">
{[14, 30, 48, 66, 82].map((x, i) => (
<span key={x} className="spark-rise absolute bottom-0 h-2 w-2 rounded-full bg-[#FFB23F]" style={{ left: `${x}%`, animationDelay: `${i * 0.35}s` }} />
))}
</div>
<motion.div
initial={{ scale: 0.3, y: 40, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
transition={{ type: 'spring', stiffness: 180, damping: 12 }}
className="relative"
>
<div className="absolute inset-0 -z-10 scale-150 rounded-full bg-[#FFB23F]/30 blur-2xl" />
<StreakFlame state="lit" size={130} />
</motion.div>
<div>
<motion.div
key={shown}
initial={{ scale: 1.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 14 }}
className="text-6xl font-black text-orange"
>
{toFa(shown)}
</motion.div>
<h2 className="mt-1 text-2xl font-black text-ink">روز پیاپی!</h2>
<p className="mt-2 text-sm leading-7 text-body">{message}</p>
</div>
<div className="flex gap-2" aria-label="روزهای این هفته">
{WEEKDAYS.map((d, i) => {
const inStreak = i <= today && today - i < to;
return (
<motion.div
key={d}
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5 + i * 0.06 }}
className="flex flex-col items-center gap-1"
>
<span className={`flex h-9 w-9 items-center justify-center rounded-full ${inStreak ? 'bg-orange' : 'bg-soft'} ${i === today ? 'ring-2 ring-orange ring-offset-2' : ''}`}>
{inStreak ? <StreakFlame state="lit" size={16} className="[&_path]:fill-white" /> : null}
</span>
<span className={`text-xs font-bold ${i === today ? 'text-orange' : 'text-muted'}`}>{d}</span>
</motion.div>
);
})}
</div>
<div className="w-full">
<PrimaryButton color="#FF7A3D" onClick={onDone}>ادامه</PrimaryButton>
</div>
</div>
);
}

View File

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

View File

@@ -5,6 +5,7 @@ import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
import { friendlyError } from '@/lib/errors';
import { useNav } from '@/nav/Navigator';
import { onWhite, shade } from '@/lib/color';
import { useCompanion } from '@/state/companion';
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
@@ -22,12 +23,14 @@ export function ScreenHeader({
color: string;
/** دکمه‌ی بازگشت؛ true یعنی صفحه‌ی قبل در پشته. */
back?: boolean | (() => void);
mascot?: { char: SeyarChar; mood?: SeyarMood; size?: number };
/** بی `char` همراهِ انتخابیِ کاربر است؛ همه‌ی صفحه‌ها همین را می‌خواهند. */
mascot?: { char?: SeyarChar; mood?: SeyarMood; size?: number };
/** رنگ را همان‌طور که هست بگذار (بی‌تیره‌کردن برای کنتراست)؛ انتخابِ مالک برای چند صفحه. */
exact?: boolean;
children?: ReactNode;
}) {
const nav = useNav();
const companion = useCompanion().char;
const onBack = typeof back === 'function' ? back : nav.back;
const bg = exact ? color : onWhite(color);
return (
@@ -52,7 +55,7 @@ export function ScreenHeader({
<h1 className="text-xl font-black leading-snug">{title}</h1>
{subtitle && <div className="mt-1 text-xs text-white/75">{subtitle}</div>}
</div>
{mascot && <SeyarMascot char={mascot.char} mood={mascot.mood ?? 'happy'} size={mascot.size ?? 64} />}
{mascot && <SeyarMascot char={mascot.char ?? companion} mood={mascot.mood ?? 'happy'} size={mascot.size ?? 64} />}
</div>
{children}
</div>
@@ -119,7 +122,7 @@ export function Spinner({ size = 20, className = '' }: { size?: number; classNam
return (
<span
role="status"
aria-label="در حالِ بارگذاری"
aria-label="در حال بارگذاری"
className={`inline-block animate-spin rounded-full border-2 border-current border-t-transparent ${className}`}
style={{ width: size, height: size }}
/>
@@ -128,10 +131,11 @@ export function Spinner({ size = 20, className = '' }: { size?: number; classNam
/* ─── حالت‌های بارگذاری / خطا / خالی ───────────────────────────────────────── */
function StateBox({ char, mood, text, children }: { char: SeyarChar; mood: SeyarMood; text: ReactNode; children?: ReactNode }) {
function StateBox({ char, mood, text, children }: { char?: SeyarChar; mood: SeyarMood; text: ReactNode; children?: ReactNode }) {
const companion = useCompanion().char;
return (
<div className="flex flex-col items-center justify-center gap-3 px-8 py-16 text-center">
<SeyarMascot char={char} mood={mood} size={72} />
<SeyarMascot char={char ?? companion} mood={mood} size={72} />
<p className="text-sm leading-7 text-muted">{text}</p>
{children}
</div>
@@ -143,7 +147,7 @@ function StateBox({ char, mood, text, children }: { char: SeyarChar; mood: Seyar
* همان چند فریم، پرشِ ریزِ «لودر ← محتوا» می‌ساخت؛ حالا تا آن لحظه جای خالی می‌ماند. اگر پاسخ
* کند باشد کاربر باز هم لودر را می‌بیند. در مرورگر با شبکه‌ی کند آزموده نشده.
*/
export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'sigma' as SeyarChar, delayMs = 300 }) {
export function LoadingState({ text = 'یه لحظه صبر کن…', char, delayMs = 300 }: { text?: string; char?: SeyarChar; delayMs?: number }) {
const [show, setShow] = useState(delayMs <= 0);
useEffect(() => {
if (delayMs <= 0) return;
@@ -155,7 +159,7 @@ export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'si
export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?: () => void; text?: string }) {
return (
<StateBox char="pi" mood="sad" text={text ?? friendlyError(error)}>
<StateBox mood="sad" text={text ?? friendlyError(error)}>
{onRetry && (
<button
type="button"
@@ -169,7 +173,7 @@ export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?
);
}
export function EmptyState({ text, char = 'pi' as SeyarChar }: { text: string; char?: SeyarChar }) {
export function EmptyState({ text, char }: { text: string; char?: SeyarChar }) {
return <StateBox char={char} mood="idle" text={text} />;
}

View File

@@ -8,7 +8,7 @@ const TEXTS = [
'✅ جواب: رأس سهمی در (1, -1) است.',
'۴) ۲ - ۳ = -۱.',
'مجموع زاویه‌های داخلی = (5 - 2) × 180 = 3 × 180 = 540 درجه',
'بازه‌ی [-2, 3) یعنی از −۲ تا ۳، خودِ ۳ نه.',
'بازه [-2, 3) یعنی از −۲ تا ۳، خود ۳ نه.',
'sin 30° = 0.5 و cos 30° ≈ 0.87 است.',
'در کتاب ۲/۵ یعنی دو و نیم (اختیاری).',
];

View File

@@ -3,14 +3,14 @@ import type { DiagramSpec } from '@/diagram/types';
/** نمونه‌های دست‌نویس از هر نوع شکل، از روی مسئله‌های واقعیِ کتاب‌ها. فقط برای گالری. */
export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
{
title: 'یازدهم — سهمی با ریشه، رأس، محورِ تقارن و عرض از مبدأ',
title: 'یازدهم — سهمی با ریشه، رأس، محور تقارن و عرض از مبدأ',
spec: { kind: 'plane', title: 'y = x² − 2x − 3', axes: true, points: {}, items: [
{ type: 'function', expr: 'x^2-2x-3', from: -2.5, to: 4.5, label: 'y = x² − 2x − 3', marks: ['roots', 'vertex', 'axis', 'yIntercept'] },
] },
},
{
title: 'دهم — دایره‌ی مثلثاتی، سینوس و کسینوسِ ۳۰°',
spec: { kind: 'plane', title: 'زاویه‌ی ۳۰ درجه', axes: true, points: { O: [0, 0], A: [1, 0], P: { polar: [1, 30] }, H: { foot: ['P', 'O', 'A'] } }, items: [
title: 'دهم — دایره مثلثاتی، سینوس و کسینوس ۳۰°',
spec: { kind: 'plane', title: 'زاویه ۳۰ درجه', axes: true, points: { O: [0, 0], A: [1, 0], P: { polar: [1, 30] }, H: { foot: ['P', 'O', 'A'] } }, items: [
{ type: 'circle', center: 'O', r: 1 },
{ type: 'segment', from: 'O', to: 'P' },
{ type: 'segment', from: 'P', to: 'H', dashed: true, color: 'green', label: 'sin 30° = 0.5' },
@@ -20,7 +20,7 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] },
},
{
title: 'نهم — مثلثِ قائم‌الزاویه، فیثاغورس',
title: 'نهم — مثلث قائم‌الزاویه، فیثاغورس',
spec: { kind: 'plane', title: 'وتر = ۵', axes: false, points: { A: [0, 0], B: [4, 0], C: [0, 3] }, items: [
{ type: 'polygon', points: ['A', 'B', 'C'] },
{ type: 'rightAngle', vertex: 'A', from: 'B', to: 'C' },
@@ -30,7 +30,7 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] },
},
{
title: 'هشتم — خطوطِ موازی و مورب، زاویه‌های مساوی',
title: 'هشتم — خطوط موازی و مورب، زاویه‌های مساوی',
spec: { kind: 'plane', title: 'a ∥ b و d مورب', axes: false, points: { _a1: [0, 2], _a2: [6, 2], _b1: [0, 0], _b2: [6, 0], _d1: [1.2, -1], _d2: [4.8, 3], P: { intersect: [['_a1', '_a2'], ['_d1', '_d2']] }, Q: { intersect: [['_b1', '_b2'], ['_d1', '_d2']] } }, items: [
{ type: 'line', through: ['_a1', '_a2'], label: 'a' },
{ type: 'line', through: ['_b1', '_b2'], label: 'b' },
@@ -40,8 +40,8 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] },
},
{
title: 'هشتم — زاویه‌ی محاطی و مرکزیِ روبه‌روی یک کمان',
spec: { kind: 'plane', title: 'زاویه‌ی محاطی نصفِ مرکزی است', axes: false, points: { O: [0, 0], A: { polar: [2, 200] }, B: { polar: [2, -20] }, C: { polar: [2, 100] } }, items: [
title: 'هشتم — زاویه محاطی و مرکزی روبه‌روی یک کمان',
spec: { kind: 'plane', title: 'زاویه محاطی نصف مرکزی است', axes: false, points: { O: [0, 0], A: { polar: [2, 200] }, B: { polar: [2, -20] }, C: { polar: [2, 100] } }, items: [
{ type: 'circle', center: 'O', r: 2 },
{ type: 'arc', center: 'O', r: 2, start: 200, end: 340, label: 'AB' },
{ type: 'segment', from: 'C', to: 'A' }, { type: 'segment', from: 'C', to: 'B' },
@@ -51,8 +51,8 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] },
},
{
title: 'نهم — متوازی‌الاضلاع و وسطِ ضلع‌ها، ضلع‌های مساوی',
spec: { kind: 'plane', title: 'M و N وسطِ ضلع‌ها', axes: false, points: { A: [0, 0], B: [4, 0], C: [5, 2.5], D: [1, 2.5], M: { mid: ['A', 'B'] }, N: { mid: ['C', 'D'] } }, items: [
title: 'نهم — متوازی‌الاضلاع و وسط ضلع‌ها، ضلع‌های مساوی',
spec: { kind: 'plane', title: 'M و N وسط ضلع‌ها', axes: false, points: { A: [0, 0], B: [4, 0], C: [5, 2.5], D: [1, 2.5], M: { mid: ['A', 'B'] }, N: { mid: ['C', 'D'] } }, items: [
{ type: 'polygon', points: ['A', 'B', 'C', 'D'] },
{ type: 'segment', from: 'A', to: 'M', ticks: 1 }, { type: 'segment', from: 'M', to: 'B', ticks: 1 },
{ type: 'segment', from: 'D', to: 'N', ticks: 2 }, { type: 'segment', from: 'N', to: 'C', ticks: 2 },
@@ -60,14 +60,14 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] },
},
{
title: 'هفتم — دورانِ ۹۰° مثلث حولِ C در جهتِ عقربه‌ها، روی شبکه',
spec: { kind: 'plane', title: 'دورانِ ۹۰°', axes: false, grid: true, points: { A: [1, 1], B: [4, 1], C: [1, 3] }, items: [
title: 'هفتم — دوران ۹۰° مثلث حول C در جهت عقربه‌ها، روی شبکه',
spec: { kind: 'plane', title: 'دوران ۹۰°', axes: false, grid: true, points: { A: [1, 1], B: [4, 1], C: [1, 3] }, items: [
{ type: 'polygon', points: ['A', 'B', 'C'] },
{ type: 'image', of: ['A', 'B', 'C'], transform: { rotate: { center: 'C', deg: -90 } } },
] },
},
{
title: 'هشتم — بردار و جمعِ بردارها روی شبکه',
title: 'هشتم — بردار و جمع بردارها روی شبکه',
spec: { kind: 'plane', title: 'AB + BC = AC', axes: false, grid: true, points: { A: [0, 0], B: [3, 1], C: [4, 4] }, items: [
{ type: 'vector', from: 'A', to: 'B', label: 'a' },
{ type: 'vector', from: 'B', to: 'C', label: 'b', color: 'blue' },
@@ -75,7 +75,7 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] },
},
{
title: 'نهم — دو خط و محلِ برخورد',
title: 'نهم — دو خط و محل برخورد',
spec: { kind: 'plane', title: 'y = 2x − 1 و y = −x + 5', axes: true, points: { P: { intersect: [[[0, -1], [1, 1]], [[0, 5], [1, 4]]] } }, items: [
{ type: 'function', expr: '2x-1', from: -1, to: 4, label: 'y = 2x − 1' },
{ type: 'function', expr: '-x+5', from: -1, to: 6, label: 'y = −x + 5' },
@@ -83,21 +83,21 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] },
},
{
title: 'یازدهم — نمودارِ سینوس با مضرب‌های π',
title: 'یازدهم — نمودار سینوس با مضرب‌های π',
spec: { kind: 'plane', title: 'y = sin x', axes: true, trig: true, points: {}, items: [
{ type: 'function', expr: 'sin(x)', from: -6.2832, to: 6.2832, label: 'y = sin x', marks: ['roots', 'extrema'] },
{ type: 'hline', y: 0.5, color: 'orange', label: 'y = ½' },
] },
},
{
title: 'یازدهم — تابعِ چندضابطه‌ای و حد (نقطه‌ی توخالی)',
spec: { kind: 'plane', title: 'حدِ چپ و راست در x = 1', axes: true, points: {}, items: [
title: 'یازدهم — تابع چندضابطه‌ای و حد (نقطه توخالی)',
spec: { kind: 'plane', title: 'حد چپ و راست در x = 1', axes: true, points: {}, items: [
{ type: 'piecewise', pieces: [{ expr: 'x+1', from: -2, to: 1, closedFrom: true, closedTo: false }, { expr: '-x+4', from: 1, to: 4, closedFrom: true, closedTo: true }] },
{ type: 'vline', x: 1, label: 'x = 1' },
] },
},
{
title: 'دوازدهم — خطِ مماس بر نمودار',
title: 'دوازدهم — خط مماس بر نمودار',
spec: { kind: 'plane', title: 'مماس بر y = x² در x = 1', axes: true, points: {}, items: [
{ type: 'function', expr: 'x^2', from: -2, to: 2.5, label: 'y = x²' },
{ type: 'tangent', expr: 'x^2', at: 1, label: 'y = 2x − 1' },
@@ -105,14 +105,14 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
},
{
title: 'دوازدهم — مجانب‌ها: y = 1/(x − 2) + 1',
spec: { kind: 'plane', title: 'مجانبِ قائم و افقی', axes: true, xRange: [-2, 6], yRange: [-4, 6], points: {}, items: [
spec: { kind: 'plane', title: 'مجانب قائم و افقی', axes: true, xRange: [-2, 6], yRange: [-4, 6], points: {}, items: [
{ type: 'function', expr: '1/(x-2)+1', from: -2, to: 6, label: 'y = 1/(x−2) + 1' },
{ type: 'vline', x: 2, label: 'x = 2', color: 'red' },
{ type: 'hline', y: 1, label: 'y = 1', color: 'red' },
] },
},
{
title: 'دوازدهم — اکسترمم‌های نسبیِ تابعِ درجه‌ی سوم',
title: 'دوازدهم — اکسترمم‌های نسبی تابع درجه سوم',
spec: { kind: 'plane', title: 'y = x³ − 3x', axes: true, points: {}, items: [
{ type: 'function', expr: 'x^3-3x', from: -2.3, to: 2.3, label: 'y = x³ − 3x', marks: ['extrema', 'roots'] },
] },
@@ -126,20 +126,20 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] },
},
{
title: 'دوازدهم — مساحتِ زیرِ نمودار',
spec: { kind: 'plane', title: 'ناحیه‌ی بینِ y = √x و محورِ x', axes: true, points: {}, items: [
title: 'دوازدهم — مساحت زیر نمودار',
spec: { kind: 'plane', title: 'ناحیه بین y = √x و محور x', axes: true, points: {}, items: [
{ type: 'area', expr: 'sqrt(x)', from: 0, to: 4, color: 'green', label: 'S' },
{ type: 'function', expr: 'sqrt(x)', from: 0, to: 5, label: 'y = √x', color: 'green' },
] },
},
{
title: 'دهم — ناحیه‌ی جوابِ نامعادله',
title: 'دهم — ناحیه جواب نامعادله',
spec: { kind: 'plane', title: 'y > 2x − 1', axes: true, xRange: [-3, 3], yRange: [-4, 4], points: {}, items: [
{ type: 'region', expr: '2x-1', side: 'above', strict: true, label: 'y > 2x − 1' },
] },
},
{
title: 'هشتم — محورِ اعداد: از ۲ سه واحد به چپ',
title: 'هشتم — محور اعداد: از ۲ سه واحد به چپ',
spec: { kind: 'numberline', title: '۲ − ۳ = −۱', min: -4, max: 4, points: [{ at: 2, label: 'شروع' }, { at: -1, label: 'پایان' }], jumps: [{ from: 2, to: -1, label: '۳ واحد به چپ' }] },
},
{
@@ -162,7 +162,7 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
spec: { kind: 'grid', title: '۲۰ درصد', rows: 5, cols: 5, shaded: 5 },
},
{
title: 'سوم — مدلِ ضرب: ۳ گروهِ ۴تایی',
title: 'سوم — مدل ضرب: ۳ گروه ۴تایی',
spec: { kind: 'groups', title: '۳ × ۴', groups: 3, each: 4, item: 'circle' },
},
{
@@ -170,23 +170,23 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
spec: { kind: 'clock', title: 'ساعت ۳:۲۵', hour: 3, minute: 25 },
},
{
title: 'سوم — نمودارِ ستونیِ آب‌میوه‌ها',
spec: { kind: 'bar', title: 'آب‌میوه‌ی موردِ علاقه', labels: ['پرتقال', 'سیب', 'انگور', 'هلو'], values: [8, 5, 3, 4], unit: 'نفر' },
title: 'سوم — نمودار ستونی آب‌میوه‌ها',
spec: { kind: 'bar', title: 'آب‌میوه مورد علاقه', labels: ['پرتقال', 'سیب', 'انگور', 'هلو'], values: [8, 5, 3, 4], unit: 'نفر' },
},
{
title: 'هشتم — نمودارِ دایره‌ای',
spec: { kind: 'pie', title: 'ورزشِ موردِ علاقه', labels: ['فوتبال', 'والیبال', 'شنا', 'بقیه'], values: [12, 6, 4, 2] },
title: 'هشتم — نمودار دایره‌ای',
spec: { kind: 'pie', title: 'ورزش مورد علاقه', labels: ['فوتبال', 'والیبال', 'شنا', 'بقیه'], values: [12, 6, 4, 2] },
},
{
title: 'دهم — دمای زمین بر حسبِ عمق (خط‌شکسته)',
title: 'دهم — دمای زمین بر حسب عمق (خط‌شکسته)',
spec: { kind: 'linechart', title: 'دما (°C)', labels: ['1', '2', '3', '4', '5', '6'], values: [55, 90, 125, 160, 195, 230], unit: '°C' },
},
{
title: 'دهم — جدولِ تعیینِ علامت',
title: 'دهم — جدول تعیین علامت',
spec: { kind: 'table', title: 'y = (x + 1)(x − 2)', headers: ['x', '−∞', '−1', '', '2', '+∞'], rows: [['y', '+', '0', '−', '0', '+']] },
},
{
title: 'دهم — نمودارِ ون: A ∩ B',
title: 'دهم — نمودار ون: A ∩ B',
spec: { kind: 'venn', title: 'A = {1,2,3,4} و B = {3,4,5}', sets: ['A', 'B'], regions: { A: ['1', '2'], AB: ['3', '4'], B: ['5'], out: ['6'] }, shade: ['AB'] },
},
{
@@ -194,11 +194,11 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
spec: { kind: 'venn', title: 'A − (B ∪ C)', sets: ['A', 'B', 'C'], regions: { A: ['1'], B: ['2'], C: ['3'], AB: ['4'], ABC: ['5'] }, shade: ['A'] },
},
{
title: 'دهم — نمودارِ پیکانیِ تابع',
title: 'دهم — نمودار پیکانی تابع',
spec: { kind: 'mapping', title: 'f(x) = 3(x − 1)²', left: ['0', '0.5', '1'], right: ['3', '0.75', '0'], arrows: [[0, 0], [1, 1], [2, 2]], leftLabel: 'دامنه', rightLabel: 'برد' },
},
{
title: 'نهم — نمودارِ درختیِ دو بار پرتابِ سکه',
title: 'نهم — نمودار درختی دو بار پرتاب سکه',
spec: { kind: 'tree', title: '۴ حالت', root: { label: 'شروع', children: [
{ label: 'رو', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] },
{ label: 'پشت', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] },
@@ -206,42 +206,42 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
},
{ title: 'هفتم — مکعب‌مستطیل', spec: { kind: 'solid', title: 'حجم = ۵ × ۳ × ۴', shape: 'cuboid', dims: { a: 5, b: 3, c: 4 }, labels: { a: '5 cm', b: '3 cm', c: '4 cm' } } },
{ title: 'هفتم — مکعب', spec: { kind: 'solid', title: 'مکعب', shape: 'cube', dims: { a: 3 }, labels: { a: 'a' } } },
{ title: 'هفتم — منشورِ سه‌پهلو', spec: { kind: 'solid', title: 'منشورِ مثلثی', shape: 'prism', dims: { n: 3, r: 1.2, h: 2 }, labels: { h: 'h' } } },
{ title: 'نهم — منشورِ شش‌پهلو', spec: { kind: 'solid', title: 'منشورِ شش‌ضلعی', shape: 'prism', dims: { n: 6, r: 1.2, h: 1.6 }, labels: { h: '8 cm' } } },
{ title: 'نهم — هرمِ مربع‌القاعده با ارتفاع', spec: { kind: 'solid', title: 'هرم', shape: 'pyramid', dims: { n: 4, r: 1.3, h: 2 }, labels: { h: 'h', a: 'a' } } },
{ title: 'نهم — هرمِ پنج‌پهلو', spec: { kind: 'solid', title: 'قاعده‌ی ABCDE', shape: 'pyramid', dims: { n: 5, r: 1.3, h: 2 }, labels: { h: 'OH' } } },
{ title: 'نهم — استوانه', spec: { kind: 'solid', title: 'لیوانِ استوانه‌ای', shape: 'cylinder', dims: { r: 6, h: 10 }, labels: { r: '6 cm', h: 'h' } } },
{ title: 'نهم — مخروط', spec: { kind: 'solid', title: 'ظرفِ مخروطی', shape: 'cone', dims: { r: 4, h: 12 }, labels: { r: '4 cm', h: '12 cm' } } },
{ title: 'هفتم — منشور سه‌پهلو', spec: { kind: 'solid', title: 'منشور مثلثی', shape: 'prism', dims: { n: 3, r: 1.2, h: 2 }, labels: { h: 'h' } } },
{ title: 'نهم — منشور شش‌پهلو', spec: { kind: 'solid', title: 'منشور شش‌ضلعی', shape: 'prism', dims: { n: 6, r: 1.2, h: 1.6 }, labels: { h: '8 cm' } } },
{ title: 'نهم — هرم مربع‌القاعده با ارتفاع', spec: { kind: 'solid', title: 'هرم', shape: 'pyramid', dims: { n: 4, r: 1.3, h: 2 }, labels: { h: 'h', a: 'a' } } },
{ title: 'نهم — هرم پنج‌پهلو', spec: { kind: 'solid', title: 'قاعده ABCDE', shape: 'pyramid', dims: { n: 5, r: 1.3, h: 2 }, labels: { h: 'OH' } } },
{ title: 'نهم — استوانه', spec: { kind: 'solid', title: 'لیوان استوانه‌ای', shape: 'cylinder', dims: { r: 6, h: 10 }, labels: { r: '6 cm', h: 'h' } } },
{ title: 'نهم — مخروط', spec: { kind: 'solid', title: 'ظرف مخروطی', shape: 'cone', dims: { r: 4, h: 12 }, labels: { r: '4 cm', h: '12 cm' } } },
{ title: 'نهم — کره', spec: { kind: 'solid', title: 'کره', shape: 'sphere', dims: { r: 1 }, labels: { r: 'r' } } },
{ title: 'نهم — نیم‌کره', spec: { kind: 'solid', title: 'پیمانه‌ی نیم‌کره', shape: 'hemisphere', dims: { r: 12 }, labels: { r: '12 cm' } } },
{ title: 'ششم — مقایسه‌ی یک‌پنجم و نُه‌دهم', spec: { kind: 'fraction', title: 'کدام بزرگ‌تر است؟', shape: 'rect', parts: 5, shaded: 1, compare: [{ parts: 5, shaded: 1, label: 'یک‌پنجم' }, { parts: 10, shaded: 9, label: 'نُه‌دهم' }] } },
{ title: 'نهم — نیم‌کره', spec: { kind: 'solid', title: 'پیمانه نیم‌کره', shape: 'hemisphere', dims: { r: 12 }, labels: { r: '12 cm' } } },
{ title: 'ششم — مقایسه یک‌پنجم و نه‌دهم', spec: { kind: 'fraction', title: 'کدام بزرگ‌تر است؟', shape: 'rect', parts: 5, shaded: 1, compare: [{ parts: 5, shaded: 1, label: 'یک‌پنجم' }, { parts: 10, shaded: 9, label: 'نه‌دهم' }] } },
{ title: 'ششم — دو و هفت‌دهم', spec: { kind: 'fraction', title: '۲٫۷', shape: 'circle', parts: 10, shaded: 7, wholes: 2 } },
{ title: 'دهم — دایره و خطِ مماس از معادله', spec: { kind: 'plane', title: 'فاصله‌ی مرکز تا خط', axes: true, points: { O: [-1, 2] }, items: [
{ title: 'دهم — دایره و خط مماس از معادله', spec: { kind: 'plane', title: 'فاصله مرکز تا خط', axes: true, points: { O: [-1, 2] }, items: [
{ type: 'line', eq: [3, 5, 4], label: '3x + 5y = 4' }, { type: 'circle', center: 'O', r: 0.5145, color: 'green' },
] } },
{ title: 'هشتم — زاویه‌ی خارجیِ پنج‌ضلعی با امتدادِ ضلع', spec: { kind: 'plane', title: 'زاویه‌ی خارجی = ۷۲°', axes: false, points: { A: { polar: [2, 234] }, B: { polar: [2, 306] }, C: { polar: [2, 18] }, D: { polar: [2, 90] }, E: { polar: [2, 162] }, X: { along: ['A', 'B', 1.6] } }, items: [
{ title: 'هشتم — زاویه خارجی پنج‌ضلعی با امتداد ضلع', spec: { kind: 'plane', title: 'زاویه خارجی = ۷۲°', axes: false, points: { A: { polar: [2, 234] }, B: { polar: [2, 306] }, C: { polar: [2, 18] }, D: { polar: [2, 90] }, E: { polar: [2, 162] }, X: { along: ['A', 'B', 1.6] } }, items: [
{ type: 'polygon', points: ['A', 'B', 'C', 'D', 'E'] }, { type: 'segment', from: 'B', to: 'X', dashed: true }, { type: 'angle', vertex: 'B', from: 'X', to: 'C', label: '72°' },
] } },
{ title: 'سوم — ضرب روی محورِ اعداد (تیک‌های زیاد)', spec: { kind: 'numberline', title: '۲ × ۱۱', min: 0, max: 25, points: [{ at: 11, label: '' }, { at: 22, label: '' }], jumps: [{ from: 0, to: 11, label: '۱۱' }, { from: 11, to: 22, label: '۱۱' }] } },
{ title: 'نهم — مثلث از روی داده: AB = 6، AC = 8، زاویه‌ی A قائمه', spec: { kind: 'plane', title: 'فیثاغورس', axes: false, points: {}, items: [
{ title: 'سوم — ضرب روی محور اعداد (تیک‌های زیاد)', spec: { kind: 'numberline', title: '۲ × ۱۱', min: 0, max: 25, points: [{ at: 11, label: '' }, { at: 22, label: '' }], jumps: [{ from: 0, to: 11, label: '۱۱' }, { from: 11, to: 22, label: '۱۱' }] } },
{ title: 'نهم — مثلث از روی داده: AB = 6، AC = 8، زاویه A قائمه', spec: { kind: 'plane', title: 'فیثاغورس', axes: false, points: {}, items: [
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 6, AC: 8 }, angles: { A: 90 }, labels: { BC: 'x' } },
] } },
{ title: 'هشتم — مثلث با سه ضلع ۵، ۷، ۹ و ارتفاع از C', spec: { kind: 'plane', title: 'ارتفاعِ وارد بر AB', axes: false, points: {}, items: [
{ title: 'هشتم — مثلث با سه ضلع ۵، ۷، ۹ و ارتفاع از C', spec: { kind: 'plane', title: 'ارتفاع وارد بر AB', axes: false, points: {}, items: [
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 9, BC: 7, CA: 5 }, altitude: 'C', labels: { h: 'h' } },
] } },
{ title: 'هفتم — مثلث با دو زاویه (۴۰° و ۷۵°)', spec: { kind: 'plane', title: 'زاویه‌ی سوم؟', axes: false, points: {}, items: [
{ title: 'هفتم — مثلث با دو زاویه (۴۰° و ۷۵°)', spec: { kind: 'plane', title: 'زاویه سوم؟', axes: false, points: {}, items: [
{ type: 'triangle', names: ['A', 'B', 'C'], angles: { A: 40, B: 75 }, labels: { C: '?' } },
] } },
{ title: 'منفرجه — ارتفاع بیرونِ مثلث', spec: { kind: 'plane', title: 'زاویه‌ی B = ۱۲۰°', axes: false, points: {}, items: [
{ title: 'منفرجه — ارتفاع بیرون مثلث', spec: { kind: 'plane', title: 'زاویه B = ۱۲۰°', axes: false, points: {}, items: [
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 4, BC: 3 }, angles: { B: 120 }, altitude: 'C' },
] } },
{ title: 'ششم — مستطیلِ باغچه ۸ × ۵', spec: { kind: 'plane', title: 'باغچه', axes: false, points: {}, items: [{ type: 'quad', shape: 'rectangle', names: ['A', 'B', 'C', 'D'], w: 8, h: 5 }] } },
{ title: 'مربع با ضلعِ ۴', spec: { kind: 'plane', title: 'مربع', axes: false, points: {}, items: [{ type: 'quad', shape: 'square', names: ['A', 'B', 'C', 'D'], side: 4 }] } },
{ title: 'ششم — مستطیل باغچه ۸ × ۵', spec: { kind: 'plane', title: 'باغچه', axes: false, points: {}, items: [{ type: 'quad', shape: 'rectangle', names: ['A', 'B', 'C', 'D'], w: 8, h: 5 }] } },
{ title: 'مربع با ضلع ۴', spec: { kind: 'plane', title: 'مربع', axes: false, points: {}, items: [{ type: 'quad', shape: 'square', names: ['A', 'B', 'C', 'D'], side: 4 }] } },
{ title: 'ششم — متوازی‌الاضلاع: قاعده ۷، ارتفاع ۴', spec: { kind: 'plane', title: 'مساحت = قاعده × ارتفاع', axes: false, points: {}, items: [{ type: 'quad', shape: 'parallelogram', names: ['A', 'B', 'C', 'D'], w: 7, h: 4 }] } },
{ title: 'هفتم — لوزی با قطرهای ۸ و ۶', spec: { kind: 'plane', title: 'مساحت = نصفِ حاصل‌ضربِ قطرها', axes: false, points: {}, items: [{ type: 'quad', shape: 'rhombus', names: ['A', 'B', 'C', 'D'], d1: 8, d2: 6 }] } },
{ title: 'هفتم — لوزی با قطرهای ۸ و ۶', spec: { kind: 'plane', title: 'مساحت = نصف حاصل‌ضرب قطرها', axes: false, points: {}, items: [{ type: 'quad', shape: 'rhombus', names: ['A', 'B', 'C', 'D'], d1: 8, d2: 6 }] } },
{ title: 'هشتم — ذوزنقه: قاعده‌های ۱۰ و ۶، ارتفاع ۴', spec: { kind: 'plane', title: 'ذوزنقه', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 10, top: 6, h: 4 }] } },
{ title: 'ذوزنقه‌ی قائم‌الزاویه', spec: { kind: 'plane', title: 'ذوزنقه‌ی قائم', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 8, top: 5, h: 4, right: true }] } },
{ title: 'هفتم — الگوی مربعی ۱، ۴، ۹ و شکلِ بعدی', spec: { kind: 'pattern', title: 'الگوی n²', layout: 'square', unit: 'square', stages: [1, 2, 3], next: true } },
{ title: 'ذوزنقه قائم‌الزاویه', spec: { kind: 'plane', title: 'ذوزنقه قائم', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 8, top: 5, h: 4, right: true }] } },
{ title: 'هفتم — الگوی مربعی ۱، ۴، ۹ و شکل بعدی', spec: { kind: 'pattern', title: 'الگوی n²', layout: 'square', unit: 'square', stages: [1, 2, 3], next: true } },
{ title: 'هفتم — الگوی چوب‌کبریت', spec: { kind: 'pattern', title: 'چوب‌کبریت: 3n + 1', layout: 'row', unit: 'stick', stages: [1, 2, 3, 4], next: true } },
{ title: 'دهم — عددهای مثلثی با نقطه', spec: { kind: 'pattern', title: '۱، ۳، ۶، ۱۰', layout: 'triangle', unit: 'dot', stages: [1, 2, 3, 4] } },
];

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

@@ -32,11 +32,11 @@ function tokenize(src: string): string[] {
if (w === 'x' || w === 'pi' || w === 'e' || w in FUNCS) { out.push(w); i += w.length; continue; }
// «xsin» یا «pix»: نامِ چسبیده را تکه‌تکه کن
const known = ['sqrt', 'sin', 'cos', 'tan', 'abs', 'log', 'ln', 'pi', 'x', 'e'].find((k) => w.startsWith(k));
if (!known) throw new Error(`نامِ ناشناخته: ${w}`);
if (!known) throw new Error(`نام ناشناخته: ${w}`);
out.push(known); i += known.length; continue;
}
if ('+-*/^()'.includes(s[i])) { out.push(s[i]); i++; continue; }
throw new Error(`نویسه‌ی ناشناخته: ${s[i]}`);
throw new Error(`نویسه ناشناخته: ${s[i]}`);
}
return out;
}
@@ -48,7 +48,7 @@ export function parseExpr(src: string): (x: number) => number {
const peek = () => t[p];
const eat = (tok?: string) => {
const v = t[p++];
if (tok !== undefined && v !== tok) throw new Error(`انتظارِ ${tok}`);
if (tok !== undefined && v !== tok) throw new Error(`انتظار ${tok}`);
return v;
};
const startsAtom = (tok: string | undefined) => tok !== undefined && (/^[\d.]/.test(tok) || tok === 'x' || tok === 'pi' || tok === 'e' || tok === '(' || tok in FUNCS);

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' }}>
{`${toFa(wholes)} واحدِ کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`}
</text>
{!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>}
</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) => {
@@ -268,8 +273,8 @@ export function Pattern({ spec }: { spec: PatternSpec }) {
? <circle key={k} cx={cx + u / 2} cy={cy + u / 2} r={u * 0.32} fill={col} />
: <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>}
<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 && !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>
);
});
@@ -281,7 +286,7 @@ export function Pattern({ spec }: { spec: PatternSpec }) {
<g>
<rect x={nx} y={base - 1.4 * u} width={1.4 * u} height={1.4 * u} rx={6} fill="#fff" stroke={COLORS.red} strokeWidth={2} strokeDasharray="4 3" />
<text x={nx + 0.7 * u} y={base - 0.45 * u} fontSize={u * 0.8} fontWeight={900} fill={COLORS.red} textAnchor="middle">?</text>
<text x={nx + 0.7 * u} y={base + 16} fontSize={11} fontWeight={800} fill={COLORS.red} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکلِ ${toFa(stages.length + 1)}`}</text>
<text x={nx + 0.7 * u} y={base + 16} fontSize={11} fontWeight={800} fill={COLORS.red} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکل ${toFa(stages.length + 1)}`}</text>
</g>
)}
</svg>

View File

@@ -43,7 +43,7 @@ export function NumberLine({ spec }: { spec: NumberLineSpec }) {
};
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img" aria-label={spec.title || 'محورِ اعداد'}>
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img" aria-label={spec.title || 'محور اعداد'}>
<Arrowheads ids={[...arrows, ...intervals.map((iv, i) => color(iv.color, COLORS[SERIES[i % SERIES.length]]))]} />
<line x1={PAD - 16} y1={base} x2={W - PAD + 16} y2={base} stroke={INK} strokeWidth={2}
markerEnd={`url(#${arrowId(INK)})`} markerStart={`url(#${arrowId(INK)})`} />

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

@@ -1,4 +1,5 @@
import { Fragment } from 'react';
import { plainMath, plainPersian } from './plainMath';
/**
* متنِ فارسی که ریاضی لایش است («عدد −۱ می‌شود»، «رأس (۱، −۴) است»).
@@ -11,15 +12,19 @@ import { Fragment } from 'react';
* فهرستِ داخلِ جمله («از −۳ به −۲، −۱، ۰ می‌رسیم»، «الگویِ ۲، ۵، ۸، …») مالِ جمله است و
* از راست خوانده می‌شود؛ پس «،» و «؛» بیرونِ پرانتز و آکولاد تکه را می‌شکنند و هر عضو
* جدا قفل می‌شود. داخلِ `{۱، ۲، ۳}` و `(۱، −۴)` ریاضی است و یک‌جا قفل می‌ماند. سنجیده
* با مختصاتِ رسم‌شده در Chrome روی همه‌ی سؤال‌ها و گزینه‌های بانک و چند جوابِ دستیار
* با مختصاتِ رسم‌شده در Chrome روی همه‌ی سوال‌ها و گزینه‌های بانک و چند جوابِ دستیار
* (۲۰۲۶-۰۹-۲۹)؛ پیش از آن هر ۴۸۲ فهرستِ داخلِ جمله برعکس دیده می‌شد.
*
* آزموده روی Chrome (دسکتاپ و موبایلِ شبیه‌سازی‌شده)؛ روی Safariِ واقعیِ آیفون دیده نشده.
*/
// حروف، اِعراب (کسره‌ی «خودِ»)، و نیم‌فاصله؛ رقم‌های ۰–۹ عمداً بیرون‌اند.
const LETTER = '\\u0620-\\u065F\\u066E-\\u06D3\\u06FA-\\u06FF\\u200C';
// تکه‌ای بدونِ حرفِ فارسی و بدونِ خطِ تازه که دستِ‌کم یک رقم دارد
const RUN = new RegExp(`[^${LETTER}\\n]*[0-9۰-۹][^${LETTER}\\n]*`, 'g');
// «نویسه‌ی ریاضی»: رقم یا حرفِ لاتین (نامِ تابع و متغیر: f، x، A). بدونِ حرفِ لاتین «−f(x)» تکه حساب
// نمی‌شد و در متنِ راست‌به‌چپ به‌صورتِ «f(x)−» دیده می‌شد (گزارشِ ۲۰۲۶-۱۰-۱۲).
const MATH_CH = '0-9۰-۹A-Za-z';
// تکه‌ای بدونِ حرفِ فارسی و بدونِ خطِ تازه که دستِ‌کم یک نویسه‌ی ریاضی دارد
const RUN = new RegExp(`[^${LETTER}\\n]*[${MATH_CH}][^${LETTER}\\n]*`, 'g');
const HAS_MATH = new RegExp(`[${MATH_CH}]`);
// نشانه‌هایی که مالِ جمله‌ی فارسی‌اند، نه ریاضی: از دو سرِ تکه بیرون می‌مانند
const EDGE_START = /^[\s،؛:!؟«»"'.=]+/;
const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
@@ -28,7 +33,126 @@ const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
// سمتِ خودشان می‌کشیدند، پس فهرست باز برعکس می‌شد.
const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const;
export function MathText({ text }: { 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;
@@ -48,14 +172,21 @@ export function MathText({ text }: { text: string }) {
const edge = trimUnpaired(run);
const head = lead + edge.head;
run = edge.run;
if (!run || !/[0-9۰-۹]/.test(run)) continue;
if (!run || !HAS_MATH.test(run)) continue;
out.push(text.slice(last, start + head.length));
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 };
}
/** عمقِ پرانتز/کروشه/آکولاد پیش از هر نویسه‌ی متن. */
@@ -102,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 };
}

47
src/lib/StepText.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { MathText } from './MathText';
import { toFa } from './format';
/**
* متنِ درس که قدم‌هایش در یک پاراگراف پشتِ هم آمده‌اند («۱. مجموعه را بنویسید. ۲. بررسی کنید… ۳. …»):
* ۱۰۴۱ مقدمه‌ی درس در دیتابیس همین شکل‌اند (۱۴۰۵-۰۷) و روی صفحه یک تکه‌ی درهم دیده می‌شدند. این‌جا به
* فهرستِ شماره‌دار تبدیل می‌شوند؛ متنِ بی‌فهرست همان پاراگرافِ قبلی می‌ماند.
*
* فقط دنباله‌ی ۱، ۲، ۳، … با «.» یا «)» و فاصله‌ی بعدش شمرده می‌شود، پس «۳.۵» (اعشار) و «شکلِ ۲. سپس» که
* پیش از آن ۱ ندارد فهرست نمی‌شوند.
*/
const MARKER = /(^|\s)([0-9۰-۹]{1,2})[.)](?=\s)/g;
const num = (d: string) => Number(d.replace(/[۰-۹]/g, (c) => String(c.charCodeAt(0) - 0x06f0)));
export function parseNumbered(text: string): { lead: string; items: string[] } | null {
const marks: { start: number; end: number }[] = [];
for (const m of text.matchAll(MARKER)) {
if (num(m[2]) !== marks.length + 1) continue;
const start = (m.index ?? 0) + m[1].length;
marks.push({ start, end: start + m[2].length + 1 });
}
if (marks.length < 2) return null;
return {
lead: text.slice(0, marks[0].start).trim(),
items: marks.map((mk, i) => text.slice(mk.end, marks[i + 1]?.start ?? text.length).trim()),
};
}
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} display /></p>;
return (
<div className={className}>
{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} display /></span>
</li>
))}
</ol>
</div>
);
}

View File

@@ -5,24 +5,31 @@ import { store } from './storage';
*
* خریدِ درون‌برنامه‌ایِ بازار فقط از کدِ بومیِ اندروید (کتابخانه‌ی Poolakey) شدنی است، پس
* اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه می‌گذارد و این فایل تنها
* جایی است که با آن حرف می‌زند. در مرورگرِ معمولی آن شیء نیست، `inBazaarApp()` false است و
* خرید مثلِ قبل به زرین‌پال می‌رود.
* جایی است که با آن حرف می‌زند. در مرورگرِ معمولی آن شیء نیست و خرید با زرین‌پال است.
*
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
* JS → اندروید TadrisinoAndroid.bazaarPurchase(sku, orderId) خرید را شروع کن؛ orderId همان
* developerPayloadِ خرید است.
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) بعد از تأییدِ سرور؛ بدونِ آن
* همان بسته دوباره خریدنی نیست.
* JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
* خریدِ اشتراک با subscribeProduct (فقط اشتراک فروخته می‌شود)؛ orderId همان payloadِ خرید است؛
* dynamicPriceToken رشته‌ی خالی یعنی قیمتِ پیشخان.
* JS → اندروید TadrisinoAndroid.bazaarPrices(skusJson) قیمتِ پیشخان؛ جواب با رویدادِ «tadrisino:bazaar-prices»
* و detail‌ِ { prices: { sku: «۸۹۸,۰۰۰ تومان» } } یا { error }
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
* { orderId, purchaseToken } خرید انجام شد
* { orderId, error: 'canceled' | 'failed', message? }
* { orderId, error: 'canceled' | 'failed' | 'unavailable', message? }
* («unavailable»: بازار نصب نیست/وصل نشد یا قدیمی است؛ message: not_installed | old_bazaar | not_connected)
*
* هیچ‌کدام از این‌ها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۰۳): اپِ اندروید ساخته نشده.
* `bazaarBuy` از APKهای پس از ۲۰۲۶-۱۰-۱۲ است. APKهای قبلی کلیدِ RSA نداشتند و Poolakey در آن‌ها خاموش بود، پس از
* بازار هیچ خریدی نکرده‌اند؛ در آن‌ها کاشیِ بازار غیرفعال است و «اپ را به‌روز کن» می‌گوید.
* هیچ‌کدام از این‌ها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۱۲).
*/
interface AndroidBridge {
bazaarBuy?: (sku: string, orderId: string, dynamicPriceToken: string) => void;
/** APKِ قدیمی؛ فقط برای این‌که بدانیم داخلِ اپیم ولی خرید از بازار در آن ممکن نیست. */
bazaarPurchase?: (sku: string, orderId: string) => void;
bazaarConsume?: (purchaseToken: string) => void;
bazaarPrices?: (skusJson: string) => void;
/** اولین صفحه‌ی واقعی کشیده شد؛ اسپلشِ سیستم می‌تواند برود (APKهای قدیمی‌تر این را ندارند). */
appReady?: () => void;
}
declare global {
@@ -33,25 +40,31 @@ declare global {
type BridgeResult =
| { orderId: string; purchaseToken: string }
| { orderId: string; error: 'canceled' | 'failed'; message?: string };
| { orderId: string; error: 'canceled' | 'failed' | 'unavailable'; message?: string };
export class BazaarPurchaseError extends Error {
constructor(readonly kind: 'canceled' | 'failed' | 'timeout', message = '') {
constructor(readonly kind: 'canceled' | 'failed' | 'unavailable' | 'timeout', message = '') {
super(message || kind);
}
}
export function inBazaarApp(): boolean {
return typeof window.TadrisinoAndroid?.bazaarPurchase === 'function';
/** داخلِ اپِ اندرویدِ تدریسینوییم (هر نسخه‌ای). */
export function inAndroidApp(): boolean {
return typeof window.TadrisinoAndroid === 'object' && window.TadrisinoAndroid !== null;
}
/** اپی که می‌تواند از بازار اشتراک بخرد. */
export function canBuyFromBazaar(): boolean {
return typeof window.TadrisinoAndroid?.bazaarBuy === 'function';
}
/** صفحه‌ی خریدِ بازار ممکن است طول بکشد (ورود به بازار، کارتِ بانکی)؛ این سقف فقط جلوی گیر کردنِ همیشگی را می‌گیرد. */
const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000;
/** خرید را به اپِ اندروید می‌سپارد و purchaseToken را برمی‌گرداند. */
export function bazaarPurchase(sku: string, orderId: string): Promise<string> {
export function bazaarBuy(sku: string, orderId: string, dynamicPriceToken: string | null): Promise<string> {
const bridge = window.TadrisinoAndroid;
if (!bridge?.bazaarPurchase) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app'));
if (!bridge?.bazaarBuy) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app'));
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => finish(() => reject(new BazaarPurchaseError('timeout'))), PURCHASE_TIMEOUT_MS);
function finish(settle: () => void) {
@@ -67,19 +80,51 @@ export function bazaarPurchase(sku: string, orderId: string): Promise<string> {
}
window.addEventListener('tadrisino:bazaar', onResult);
try {
bridge.bazaarPurchase!(sku, orderId);
bridge.bazaarBuy!(sku, orderId, dynamicPriceToken ?? '');
} catch (err) {
finish(() => reject(new BazaarPurchaseError('failed', String(err))));
}
});
}
export function bazaarConsume(purchaseToken: string) {
type PricesResult = { prices?: Record<string, string>; error?: string };
function askPrices(skus: string[]): Promise<Record<string, string>> {
const bridge = window.TadrisinoAndroid;
if (!bridge?.bazaarPrices || !skus.length) return Promise.resolve({});
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => done(() => reject(new Error('timeout'))), 8000);
function done(settle: () => void) {
window.clearTimeout(timer);
window.removeEventListener('tadrisino:bazaar-prices', onResult);
settle();
}
function onResult(event: Event) {
const detail = (event as CustomEvent<PricesResult>).detail;
if (!detail) return;
if (detail.prices) done(() => resolve(detail.prices!));
else done(() => reject(new Error(detail.error || 'failed')));
}
window.addEventListener('tadrisino:bazaar-prices', onResult);
bridge.bazaarPrices!(JSON.stringify(skus));
});
}
/**
* قیمتِ ثبت‌شده در پیشخانِ بازار برای هر SKU، از خودِ بازار روی گوشی (API سرور قیمت نمی‌دهد). اتصالِ Poolakey
* چند لحظه بعد از باز شدنِ اپ برقرار می‌شود، پس یک بار دوباره امتحان می‌شود. خطا یعنی قیمتی نشان داده نمی‌شود.
*/
export async function bazaarPrices(skus: string[]): Promise<Record<string, string>> {
try {
window.TadrisinoAndroid?.bazaarConsume?.(purchaseToken);
return await askPrices(skus);
} catch {
await new Promise((r) => setTimeout(r, 1500));
try {
return await askPrices(skus);
} catch (err) {
// مصرف نشدن یعنی خریدِ دوباره‌ی همین بسته تا مصرفِ بعدی گیر می‌کند؛ اعتبارِ کاربر سالم است.
console.error('bazaarConsume failed', err);
console.error('bazaarPrices failed', err);
return {};
}
}
}

View File

@@ -1,11 +1,20 @@
import { ApiError } from '@/api';
/** پیامِ قابلِ فهم برای دانش‌آموز، از هر خطایی. */
const PERSIAN = /[\u0600-\u06FF]/;
/**
* پیامِ قابلِ فهم برای دانش‌آموز، از هر خطایی. کاربر هرگز متنِ فنیِ خطا را نمی‌بیند: «خطای ۴۰۴»،
* انگلیسیِ پیش‌فرضِ DRF یا پیامِ مرورگر نشان داده نمی‌شود. فقط جمله‌ی فارسیِ خودِ سرور (که برای
* همین نوشته شده، مثل «کد درست نبود») یا fallback.
*/
export function friendlyError(err: unknown, fallback = 'یه مشکلی پیش اومد، دوباره امتحان کن'): string {
if (!(err instanceof ApiError)) return fallback;
if (err.isNetwork) return 'ارتباط با سرور برقرار نشد؛ اینترنتت رو چک کن';
if (err.isRateLimited) return err.detail && !err.detail.startsWith('خطای') ? err.detail : 'زیادی تلاش کردی؛ چند لحظه صبر کن';
if (err.isNetwork) {
if (typeof navigator !== 'undefined' && navigator.onLine === false) return 'اینترنت قطع است؛ اتصالت رو چک کن و دوباره امتحان کن';
return err.timedOut ? 'اینترنت کند است؛ کمی صبر کن و دوباره امتحان کن' : 'اینترنت قطع یا ناپایدار است؛ دوباره امتحان کن';
}
if (err.isRateLimited) return err.detail && PERSIAN.test(err.detail) && !err.detail.startsWith('خطا') ? err.detail : 'زیادی تلاش کردی؛ چند لحظه صبر کن';
if (err.isProviderDown) return 'این سرویس الان در دسترس نیست؛ کمی بعد دوباره امتحان کن';
if (err.status >= 500) return 'سرور موقتاً مشکل داره؛ کمی بعد دوباره امتحان کن';
return err.detail || fallback;
return err.detail && PERSIAN.test(err.detail) && !err.detail.startsWith('خطای ') ? err.detail : fallback;
}

16
src/lib/firstVisit.ts Normal file
View File

@@ -0,0 +1,16 @@
import { useState } from 'react';
const seen = new Set<string>();
/**
* true فقط بارِ اولی که این بخش در این نشست دیده می‌شود. انیمیشنِ ورودِ کارت‌ها و جزیره‌ها (یکی‌یکی ظاهر
* شدن) بارِ اول جذاب است، ولی با هر برگشت به همان تب تکرار می‌شد و صفحه چشمک می‌زد.
*/
export function useFirstVisit(tag: string): boolean {
const [first] = useState(() => {
if (seen.has(tag)) return false;
seen.add(tag);
return true;
});
return first;
}

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

30
src/lib/paymentMethods.ts Normal file
View File

@@ -0,0 +1,30 @@
import { inAndroidApp } from './bazaar';
export type PaymentMethod = 'zarinpal' | 'bazaar';
const KNOWN: PaymentMethod[] = ['zarinpal', 'bazaar'];
/**
* پیش‌فرض: در مرورگر فقط زرین‌پال (خریدِ بازار بیرون از اپِ اندروید ممکن نیست)، داخلِ اپ هر دو، زرین‌پال اول.
* برای نسخه‌ای که فقط در کافه‌بازار منتشر می‌شود، `app: ["bazaar"]` در dist/config.js؛ build لازم نیست.
*/
const DEFAULTS: Record<'web' | 'app', PaymentMethod[]> = {
web: ['zarinpal'],
app: ['zarinpal', 'bazaar'],
};
/**
* روش‌های پرداختِ این‌جا (مرورگر یا اپ)، به ترتیبِ نمایش. نامِ ناشناخته در config.js نادیده گرفته نمی‌شود که
* بی‌صدا یک روش گم شود: در کنسول خطا می‌دهد و فهرستِ پیش‌فرض به کار می‌رود.
*/
export function enabledPaymentMethods(): PaymentMethod[] {
const channel = inAndroidApp() ? 'app' : 'web';
const configured = window.__TADRISINO_CONFIG__?.paymentMethods?.[channel];
if (!configured) return DEFAULTS[channel];
const unknown = configured.filter((m) => !KNOWN.includes(m as PaymentMethod));
if (unknown.length || !configured.length) {
console.error(`config.js: paymentMethods.${channel} نامعتبر است`, configured);
return DEFAULTS[channel];
}
return configured as PaymentMethod[];
}

90
src/lib/plainMath.ts Normal file
View File

@@ -0,0 +1,90 @@
/**
* تورِ ایمنیِ نمایش: LaTeX و نشانه‌گذاریِ خامی که به هر دلیل به صفحه رسیده به یونیکدِ ساده برمی‌گردد.
* بک‌اند هم همین تبدیل را در seed، دیتابیس و پاسخِ دستیار انجام می‌دهد
* (`content/plain_math.py`، `rag/services/mathtext.py`)؛ این‌جا فقط آخرین لایه است تا متنِ کهنه یا
* پاسخِ ناخواسته‌ی مدل هرگز با `\frac` و `^{2}` دیده نشود. هر دو باید هم‌رفتار بمانند.
*/
const SUP: Record<string, string> = {
...Object.fromEntries([...'۰۱۲۳۴۵۶۷۸۹'].map((c, i) => [c, '⁰¹²³⁴⁵⁶⁷⁸⁹'[i]])),
...Object.fromEntries([...'0123456789'].map((c, i) => [c, '⁰¹²³⁴⁵⁶⁷⁸⁹'[i]])),
'+': '⁺', '-': '⁻', '−': '⁻', '=': '⁼', '(': '⁽', ')': '⁾',
...Object.fromEntries([...'abcdefghijklmnoprstuvwxyzαβγδεθφχ'].map((c, i) => [c, [...'ᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏˡᵐⁿᵒᵖʳˢᵗᵘᵛʷˣʸᶻᵅᵝᵞᵟᵋᶿᵠᵡ'][i]])),
};
const SUB: Record<string, string> = {
...Object.fromEntries([...'۰۱۲۳۴۵۶۷۸۹'].map((c, i) => [c, '₀₁₂₃₄₅₆₇₈₉'[i]])),
...Object.fromEntries([...'0123456789'].map((c, i) => [c, '₀₁₂₃₄₅₆₇₈₉'[i]])),
'+': '₊', '-': '₋', '−': '₋', '=': '₌', '(': '₍', ')': '₎',
...Object.fromEntries([...'aehijklmnoprstuvx'].map((c, i) => [c, [...'ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ'][i]])),
};
const SYMBOLS: Record<string, string> = {
times: '×', cdot: '·', div: '÷', pm: '±', mp: '∓', theta: 'θ', pi: 'π', alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ',
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: '⋯', 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: '′',
lim: 'lim', sin: 'sin', cos: 'cos', tan: 'tan', cot: 'cot', sec: 'sec', csc: 'csc', log: 'log', ln: 'ln', exp: 'exp',
max: 'max', min: 'min', det: 'det', gcd: 'gcd', arcsin: 'arcsin', arccos: 'arccos', arctan: 'arctan',
};
const BLACKBOARD: Record<string, string> = { R: 'ℝ', Q: 'ℚ', N: 'ℕ', Z: 'ℤ', C: 'ℂ' };
const SYMBOL_RE = new RegExp('\\\\(' + Object.keys(SYMBOLS).sort((a, b) => b.length - a.length).join('|') + ')', 'g');
const G = '\\{([^{}]*)\\}';
const NEEDS = /[\\^$]|[A-Za-z)]_|\*\*|__|`|^#{1,6}\s/m;
const atomic = (s: string) => /^[\w۰-۹.]+$/.test(s.trim());
const wrap = (s: string) => (atomic(s) ? s.trim() : `(${s.trim()})`);
const tryMap = (body: string, table: Record<string, string>) => {
const b = body.trim();
return b && [...b].every((c) => c in table) ? [...b].map((c) => table[c]).join('') : null;
};
const script = (body: string, table: Record<string, string>, marker: string) => {
const mapped = tryMap(body, table);
if (mapped) return mapped;
const b = body.trim();
if (marker === '^') return ` به توان (${b}) `;
return /^[\w۰-۹]{1,4}$/.test(b) ? b : `_(${b})`;
};
export function plainMath(text: string): string {
if (!text || !NEEDS.test(text)) return text;
let s = text;
s = s.replace(/\$\$([\s\S]+?)\$\$/g, '$1').replace(/(?<![\w$])\$([^$\n]+?)\$(?![\w$])/g, '$1');
s = s.replace(/\\[(\[]\s*|\s*\\[)\]]/g, '');
for (let k = 0; k < 4; k++) {
s = s.replace(new RegExp(`\\\\(?:vec|overrightarrow|overline|bar|hat|widehat)${G}`, 'g'), '$1');
s = s.replace(new RegExp(`\\\\mathbb${G}`, 'g'), (_m, a: string) => BLACKBOARD[a] ?? a);
s = s.replace(new RegExp(`\\\\(?:text|mathrm|mathbf|operatorname|textbf)${G}`, 'g'), '$1');
s = s.replace(new RegExp(`\\\\[dt]?frac${G}${G}`, 'g'), (_m, a: string, b: string) => `${wrap(a)}/${wrap(b)}`);
s = s.replace(new RegExp(`\\\\sqrt${G}`, 'g'), (_m, a: string) => `√${wrap(a)}`);
}
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()}) `);
s = s.replace(/\^([+\-−]?[0-9۰-۹]+)/g, (m, a: string) => tryMap(a, SUP) ?? m);
s = s.replace(/(?<=[A-Za-z0-9۰-۹)₀-₉])\^([A-Za-zα-ωΑ-Ω□])(?![A-Za-z0-9۰-۹])/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توان ${a} `);
s = s.replace(/((?<![A-Za-z0-9_])[A-Za-z]|log|ln|lim|[)∫Σ∏])_([0-9۰-۹]+[A-Za-z]?|[A-Za-z]{1,4}[0-9۰-۹]?)(?![A-Za-z0-9_])/g, (_m, p: string, a: string) => (p === 'log' && [...a].some((c) => !(c in SUB)) ? `log پایه ${a}` : p + [...a].map((c) => SUB[c] ?? c).join('')));
s = s.replace(/\^([+\-−])/g, (_m, a: string) => SUP[a]);
s = s.replace(/(?<=[A-Za-z)'])_([+\-−])/g, (_m, a: string) => SUB[a]);
s = s.replace(/\^(√[0-9۰-۹A-Za-z]+)/g, ' به توان $1 ').replace(/\^([A-Za-z]{2,3})(?![A-Za-z0-9])/g, ' به توان $1 ');
s = s.replace(/(?<=\S) {2,}(?=\S)/g, ' ');
s = s.replace(/\*\*([\s\S]+?)\*\*/g, '$1').replace(/(?<![_\w])__(.+?)__(?![_\w])/g, '$1').replace(/`/g, '');
return s.replace(/^\s{0,3}#{1,6}\s+/gm, '');
}
/**
* بی‌اعراب و بی «ی»ِ اضافه («پایه‌ی سوم» → «پایه سوم»)؛ تنوین («قبلاً») می‌ماند. همان قاعده‌ی بک‌اند
* (`content/plain_math.py::plain_persian`، روی هر پاسخِ API). این‌جا برای پاسخِ استریمیِ دستیار است که پیش از
* پاک‌سازیِ نهاییِ سرور تکه‌تکه می‌رسد.
*/
export function plainPersian(text: string): string {
if (!text) return text;
return text.replace(/[ٌ-ْٔ]/g, '').replace(/ه‌ی(?![؀-ۿ‌])/g, 'ه');
}

View File

@@ -1,8 +1,14 @@
import { KEYS, store } from './storage';
/**
* صداهای کوتاهِ رابط. یک AudioContext برای کلِ اپ؛ ساختنِ یکی برای هر صدا
* در Safari بعد از چند بار شکست می‌خورد.
* صدا و لرزشِ کوتاهِ رابط، به‌جا و کم: انتخابِ گزینه، جوابِ درست/غلط، پایانِ درس/جزیره، روشن‌ماندنِ زنجیره.
* همه با کلیدِ «صداها» در پروفایل خاموش می‌شوند.
*
* چرا روی اپِ نصب‌شده صدا نبود (از روی کد؛ روی گوشی دیده نشد): AudioContext اولین بار بعد از پاسخِ سرور
* ساخته می‌شد، یعنی بیرون از لمسِ کاربر، و WebViewِ اندروید و Safari چنین صدایی را «پخشِ خودکار» می‌دانند و
* بی‌صدا نگهش می‌دارند. حالا با اولین لمسِ صفحه ساخته و باز می‌شود (unlockAudioOnFirstTouch).
*
* صداها با Web Audio ساخته می‌شوند، بی‌فایل: بارِ اپ اضافه نمی‌شود.
*/
let ctx: AudioContext | null = null;
@@ -12,12 +18,33 @@ function context(): AudioContext | null {
if (!Ctor) return null;
try {
ctx = new Ctor();
} catch {
} catch (err) {
console.warn('AudioContext unavailable', err);
return null;
}
return ctx;
}
/** با اولین لمسِ صفحه صدا را باز می‌کند؛ یک بار در main صدا زده می‌شود. */
export function unlockAudioOnFirstTouch() {
const unlock = () => {
const audio = context();
if (!audio) return;
if (audio.state === 'suspended') void audio.resume();
// یک بافرِ ساکت؛ iOS بدونِ پخشِ واقعی در همان لمس، context را باز نمی‌کند.
const src = audio.createBufferSource();
src.buffer = audio.createBuffer(1, 1, 22050);
src.connect(audio.destination);
src.start(0);
if (audio.state === 'running') {
window.removeEventListener('pointerdown', unlock, true);
window.removeEventListener('touchend', unlock, true);
}
};
window.addEventListener('pointerdown', unlock, true);
window.addEventListener('touchend', unlock, true);
}
export function soundEnabled(): boolean {
return store.get(KEYS.sound) !== 'off';
}
@@ -26,46 +53,113 @@ export function setSoundEnabled(on: boolean) {
store.set(KEYS.sound, on ? 'on' : 'off');
}
function tone(frequency: number, start: number, duration: number, volume = 0.05) {
/** یک نتِ نرم: حمله‌ی کوتاه و افتِ نمایی، با کمی هارمونیک تا شبیهِ بوقِ سینوسیِ خالی نباشد. */
function note(frequency: number, start: number, duration: number, volume = 0.05, type: OscillatorType = 'sine') {
const audio = context();
if (!audio || !soundEnabled()) return;
if (audio.state === 'suspended') void audio.resume();
const t = audio.currentTime + start;
const osc = audio.createOscillator();
const gain = audio.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(frequency, t);
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(volume, t + 0.01);
gain.gain.exponentialRampToValueAtTime(volume, t + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
osc.connect(gain).connect(audio.destination);
gain.connect(audio.destination);
for (const [mult, level] of [[1, 1], [2, 0.18], [3, 0.06]] as const) {
const osc = audio.createOscillator();
const g = audio.createGain();
osc.type = type;
osc.frequency.setValueAtTime(frequency * mult, t);
g.gain.value = level;
osc.connect(g).connect(gain);
osc.start(t);
osc.stop(t + duration + 0.02);
}
/**
* لرزشِ کوتاه کنارِ صدا، با همان کلیدِ «صدا» خاموش می‌شود. فقط اندروید/کروم دارد؛
* Safariِ آیفون `vibrate` ندارد و این‌جا هیچ کاری نمی‌کند.
*/
function buzz(pattern: number | number[]) {
if (!soundEnabled() || typeof navigator.vibrate !== 'function') return;
try {
navigator.vibrate(pattern);
} catch {
/* بعضی WebViewها بی‌اجازه throw می‌کنند؛ لرزش فقط تزئینی است */
osc.stop(t + duration + 0.03);
}
}
/** «فوش»ِ آتش برای زنجیره: نویز از یک فیلترِ بالارونده. */
function whoosh(start: number, duration: number, volume = 0.05) {
const audio = context();
if (!audio || !soundEnabled()) return;
if (audio.state === 'suspended') void audio.resume();
const t = audio.currentTime + start;
const length = Math.floor(audio.sampleRate * duration);
const buffer = audio.createBuffer(1, length, audio.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1;
const src = audio.createBufferSource();
src.buffer = buffer;
const filter = audio.createBiquadFilter();
filter.type = 'bandpass';
filter.Q.value = 0.8;
filter.frequency.setValueAtTime(300, t);
filter.frequency.exponentialRampToValueAtTime(2400, t + duration);
const gain = audio.createGain();
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(volume, t + duration * 0.35);
gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
src.connect(filter).connect(gain).connect(audio.destination);
src.start(t);
}
interface AndroidHaptics {
vibrate?: (patternJson: string) => void;
}
/**
* لرزش. اپِ اندروید (android/MainActivity.kt) از نسخه‌ی بعدی `TadrisinoAndroid.vibrate` دارد که مستقیم از
* Vibratorِ گوشی می‌لرزاند؛ تا آن نسخه ساخته نشده `navigator.vibrate` امتحان می‌شود، که همه‌ی WebViewها
* اجرایش نمی‌کنند. Safariِ آیفون لرزش ندارد.
*/
function buzz(pattern: number | number[]) {
if (!soundEnabled()) return;
const bridge = (window as unknown as { TadrisinoAndroid?: AndroidHaptics }).TadrisinoAndroid;
try {
if (bridge?.vibrate) bridge.vibrate(JSON.stringify(Array.isArray(pattern) ? pattern : [pattern]));
else if (typeof navigator.vibrate === 'function') navigator.vibrate(pattern);
} catch (err) {
console.warn('vibrate failed', err);
}
}
// نت‌ها (هرتز)
const C5 = 523.25, E5 = 659.25, G5 = 783.99, C6 = 1046.5, E6 = 1318.5, G6 = 1568;
export const sounds = {
tick: () => tone(720, 0, 0.045, 0.04),
correct: () => {
tone(660, 0, 0.12);
tone(990, 0.1, 0.18);
buzz(30);
/** لمسِ گزینه‌ی آزمون: یک «تیک»ِ خیلی ریز. */
select: () => {
note(880, 0, 0.05, 0.025, 'triangle');
buzz(8);
},
/** `pitch`: نیم‌پرده بالاتر برای همراهِ انتخابی (state/companion)؛ هر همراه صدای خودش را دارد. */
correct: (pitch = 0) => {
const k = 2 ** (pitch / 12);
note(E5 * k, 0, 0.12, 0.05);
note(C6 * k, 0.09, 0.22, 0.05);
buzz(25);
},
/** دلتا: جوابِ درستِ پشتِ هم یک لرزشِ دوتایی و یک نتِ بالاتر اضافه دارد. */
combo: () => {
note(G6, 0.2, 0.15, 0.03);
buzz([20, 40, 30]);
},
wrong: () => {
tone(220, 0, 0.25, 0.06);
buzz([40, 60, 40]);
note(311, 0, 0.16, 0.045, 'triangle');
note(233, 0.12, 0.24, 0.045, 'triangle');
buzz([35, 50, 35]);
},
finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)),
/** پایانِ درس/جزیره: آرپژِ بالارونده و یک آکوردِ کوتاه. */
finish: () => {
[C5, E5, G5, C6].forEach((f, i) => note(f, i * 0.09, 0.22, 0.045));
[C6, E6, G6].forEach((f) => note(f, 0.4, 0.5, 0.025));
buzz([30, 60, 30, 60, 90]);
},
/** زنجیره روشن ماند: «فوش»ِ آتش و دو نتِ بالارونده. */
streak: () => {
whoosh(0, 0.55, 0.06);
note(G5, 0.45, 0.18, 0.045);
note(C6, 0.58, 0.4, 0.05);
buzz([20, 40, 20, 40, 160]);
},
/** لرزشِ بسیار کوتاه برای تعویضِ تب؛ بی‌صدا. */
tap: () => buzz(6),
};

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type DependencyList } from 'react';
import { createContext, useCallback, useContext, useEffect, useRef, useState, type DependencyList } from 'react';
export interface AsyncState<T> {
data: T | undefined;
@@ -8,39 +8,104 @@ export interface AsyncState<T> {
setData: (update: T | ((prev: T | undefined) => T | undefined)) => void;
}
/**
* کشِ همین نشست برای پاسخ‌های GET، با کلیدی که صفحه می‌دهد. صفحه‌ای که دوباره باز می‌شود (برگشت، تعویضِ تب)
* فوراً با داده‌ی قبلی رسم می‌شود و همان لحظه بی‌صدا از سرور تازه می‌شود (stale-while-revalidate). قبلاً هر بار
* اول کارتِ خالی یا «نداری» دیده می‌شد و چند صد میلی‌ثانیه بعد داده‌ی واقعی جایش می‌پرید.
* با خروج از حساب پاک می‌شود (`clearAsyncCache`)، تا داده‌ی یک کاربر به کاربرِ بعدی نرسد.
*/
const cache = new Map<string, unknown>();
export function clearAsyncCache() {
cache.clear();
}
/**
* «دروازه»ی صفحه‌ی تازه: Navigator صفحه‌ی بعدی را پنهان زیرِ صفحه‌ی فعلی می‌سازد و تا وقتی داده‌ی اولش
* نرسیده (یا سقفِ انتظار نگذشته) نشانش نمی‌دهد. هر useAsyncی که بارِ اولش بی‌کش است یک hold می‌گیرد.
* بیرون از صفحه‌ها (مثلاً جریانِ ورود) این context خالی است و کاری نمی‌کند.
*/
export interface ScreenGate {
hold: () => () => void;
}
export const ScreenGateContext = createContext<ScreenGate | null>(null);
/**
* برای صفحه‌ای که داده‌اش را بی useAsync می‌گیرد (آزمون، دستیار): تا `loading` درست است، صفحه‌ی تازه پنهان
* می‌ماند. همان قاعده‌ی useAsync: دروازه‌ی باز‌شده hold را نادیده می‌گیرد.
*/
export function useGateHold(loading: boolean) {
const gate = useContext(ScreenGateContext);
useEffect(() => (loading && gate ? gate.hold() : undefined), [loading, gate]);
}
/**
* داده‌ای که از سرور می‌آید، با حالت‌های بارگذاری/خطا و «دوباره».
* پاسخِ درخواستِ کهنه (بعد از عوض شدنِ deps) نادیده گرفته می‌شود.
* با `key`، پاسخ در کشِ نشست می‌ماند و بارِ بعد فوراً نشان داده می‌شود (بالای همین فایل).
*/
export function useAsync<T>(load: () => Promise<T>, deps: DependencyList): AsyncState<T> {
const [data, setData] = useState<T | undefined>(undefined);
export function useAsync<T>(load: () => Promise<T>, deps: DependencyList, opts: { key?: string } = {}): AsyncState<T> {
const { key } = opts;
const [data, setDataState] = useState<T | undefined>(() => (key !== undefined ? (cache.get(key) as T | undefined) : undefined));
const [error, setError] = useState<unknown>(null);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(() => key === undefined || !cache.has(key));
const [nonce, setNonce] = useState(0);
const loadRef = useRef(load);
loadRef.current = load;
const gate = useContext(ScreenGateContext);
useEffect(() => {
let alive = true;
const hit = key !== undefined && cache.has(key);
if (hit) {
// کلید عوض شده (مثلاً «هفتگی» ← «همیشه») و برای کلیدِ تازه کش هست: همان را نشان بده و بی‌لودر تازه کن.
setDataState(cache.get(key) as T);
setLoading(false);
} else {
setLoading(true);
}
setError(null);
// دروازه‌ای که یک بار باز شده hold را نادیده می‌گیرد؛ پس «دوباره» و تازه‌سازی صفحه را پنهان نمی‌کنند.
const release = !hit && gate ? gate.hold() : null;
loadRef.current().then(
(value) => {
if (!alive) return;
setData(value);
if (key !== undefined) cache.set(key, value);
setDataState(value);
setLoading(false);
release?.();
},
(err: unknown) => {
if (!alive) return;
if (hit) {
// داده‌ی کش‌شده روی صفحه است؛ شکستِ تازه‌سازی آن را با صفحه‌ی خطا عوض نمی‌کند، ولی دیده می‌شود.
console.warn(`refresh of ${key} failed; showing cached data`, err);
} else {
setError(err);
}
setLoading(false);
release?.();
},
);
return () => {
alive = false;
release?.();
};
}, [...deps, nonce]);
}, [...deps, key, nonce]);
const reload = useCallback(() => setNonce((n) => n + 1), []);
const setData = useCallback(
(update: T | ((prev: T | undefined) => T | undefined)) => {
setDataState((prev) => {
const next = typeof update === 'function' ? (update as (p: T | undefined) => T | undefined)(prev) : update;
if (key !== undefined) {
if (next === undefined) cache.delete(key);
else cache.set(key, next);
}
return next;
});
},
[key],
);
return { data, error, loading, reload, setData };
}

View File

@@ -1,7 +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
@@ -11,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

@@ -1,4 +1,4 @@
import { memo, useMemo } from 'react';
import { memo, useEffect, useMemo, useRef } from 'react';
import { motion } from 'motion/react';
import { SEYAR_INFO, SEYAR_SVG, type SeyarChar, type SeyarMood } from './seyar';
@@ -13,13 +13,20 @@ interface Props {
function SeyarMascotBase({ char, mood = 'idle', size = 100, bob = false, className }: Props) {
const svg = useMemo(() => SEYAR_SVG[char](mood), [char, mood]);
// ظاهر شدن با فنر فقط وقتی حالت یا شخصیت عوض می‌شود؛ روی هر صفحه‌ی تازه، ماسکوت دیر از صفر بزرگ
// می‌شد و بعد از رسیدنِ صفحه یک «پرش»ِ جدا می‌ساخت.
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
}, []);
return (
<motion.div
key={char + mood}
role="img"
aria-label={SEYAR_INFO[char].name}
className={className}
initial={{ scale: 0.8, opacity: 0 }}
// نه `initial={false}`: آن انیمیشنِ تکرارشونده‌ی bob را هم از کار می‌انداخت (همان باگِ ۸۸۷۹b42).
initial={mounted.current ? { scale: 0.8, opacity: 0 } : { scale: 1, opacity: 1 }}
animate={{ scale: 1, opacity: 1, y: bob ? [0, -6, 0] : 0 }}
transition={
bob

View File

@@ -1,5 +1,5 @@
/**
* همراه‌های اپ: سیگما (Σ)، دلتا (Δ)، پی (π) و تدی. «سه‌یار» نامِ قدیمیِ سه‌تای اول است؛ در متنِ
* همراه‌های اپ: سیگما (Σ)، دلتا (Δ)، پی (π) و امگا (ω). «سه‌یار» نامِ قدیمیِ سه‌تای اول است؛ در متنِ
* کاربر تعدادِ همراه‌ها نوشته نمی‌شود، چون کاتالوگ از ادمین می‌آید و بزرگ می‌شود.
*
* هر سه از دلِ یک دفترچه‌ی بی‌نهایت آمده‌اند و هر کدام از جنسِ یک نمادِ ریاضی‌اند:
@@ -89,6 +89,12 @@ function seyarSigmaSVG(mood: SeyarMood): string {
</g></svg>`;
}
/** نشانِ حرفِ یونانیِ هر همراه (مثلِ مدالِ Σِ سیگما): دایره‌ی کوچک روی شکم با نمادِ خودش. */
function letterBadge(cx: number, cy: number, entity: string, fill: string, ink: string): string {
return `<circle cx="${cx}" cy="${cy}" r="13" fill="${fill}" stroke="${ink}" stroke-width="4"/>`
+ `<text x="${cx}" y="${cy + 6.5}" font-size="18" text-anchor="middle" font-family="Georgia,serif" font-weight="bold" fill="${ink}">${entity}</text>`;
}
function seyarDeltaSVG(mood: SeyarMood): string {
const C = PALETTE.delta;
const f = face(mood, 84, 108, 100, 128, C.ink, C.mouth, C.tear, C.cheek, 32);
@@ -103,6 +109,7 @@ function seyarDeltaSVG(mood: SeyarMood): string {
<path d="M100 30 Q112 30 118 42 L160 156 Q166 172 150 174 L50 174 Q34 172 40 156 L82 42 Q88 30 100 30 Z" fill="${C.body}" stroke="${C.ink}" stroke-width="6" stroke-linejoin="round"/>
<path d="M100 74 Q108 74 112 84 L136 148 Q140 158 128 158 L72 158 Q60 158 64 148 L88 84 Q92 74 100 74 Z" fill="${C.belly}"/>
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
${letterBadge(100, 160, '&#916;', '#FFD23F', C.ink)}
</g></svg>`;
}
@@ -121,11 +128,12 @@ function seyarPiSVG(mood: SeyarMood): string {
<ellipse cx="100" cy="118" rx="74" ry="72" fill="${C.body}" stroke="${C.ink}" stroke-width="6"/>
<ellipse cx="100" cy="134" rx="46" ry="46" fill="${C.belly}"/>
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
${letterBadge(100, 170, '&#960;', '#FFD23F', C.ink)}
</g></svg>`;
}
/**
* تدی — شکلکِ زردِ پیش‌نمایشِ موبایل (mascot-pack، ریپوی بک‌اند، پوشه‌ی
* امگا (ω؛ تا ۱۴۰۵-۰۷ «تدی»؛ کلیدِ داخلی‌اش هنوز `tedi` است چون slugِ سرور همین است) — شکلکِ زردِ پیش‌نمایشِ موبایل (mascot-pack، ریپوی بک‌اند، پوشه‌ی
* mobile-preview). بدن، گوش‌ها، پاها و دست‌ها عیناً از همان پک است؛ چهره‌ی
* «عصبانی» و «ناامید» را پک نداشت و با همان زبانِ طراحیِ سه‌یار اضافه شد.
*/
@@ -182,6 +190,7 @@ function tediSVG(mood: SeyarMood): string {
<ellipse cx="100" cy="116" rx="72" ry="70" fill="${C.body}" ${S}/>
<ellipse cx="100" cy="133" rx="44" ry="45" fill="${C.belly}"/>
${hands}${cheeks}<g>${eyes}</g>${mouth}
${letterBadge(100, 166, '&#969;', '#fff', C.ink)}
</g></svg>`;
}
@@ -190,9 +199,9 @@ export const SEYAR_SVG: Record<SeyarChar, (mood: SeyarMood) => string> = {
};
export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = {
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
delta: { name: "دلتا", symbol: "Δ", role: "هم‌تیمیِ تمرین‌های سریع" },
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
tedi: { name: "تدی", symbol: "★", role: "رفیقِ همیشگیِ تدریسینو" },
delta: { name: "دلتا", symbol: "Δ", role: "هم‌تیمی تمرین‌های سریع" },
pi: { name: "پی", symbol: "π", role: "همراه دلگرمی" },
tedi: { name: "امگا", symbol: "ω", role: "رفیق همیشگی تدریسینو ریاضی" },
};
/** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراه‌ها نگاشت می‌کند. */

View File

@@ -1,68 +1,181 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import type { Route, TabName } from './routes';
/**
* ناوبریِ پشته‌ای، هماهنگ با تاریخچه‌ی مرورگر.
* ناوبریِ پشته‌ای، هماهنگ با تاریخچه‌ی مرورگر، تا دکمه‌ی «برگشت»ِ گوشی مثلِ اپ‌های بومی قدم‌به‌قدم عقب برود:
* برگه‌ی باز ← صفحه‌ی قبلی ← تبِ خانه ← بیرون از اپ.
*
* هر push یک ورودی به history اضافه می‌کند، پس دکمه‌ی back گوشی یا مرورگر
* یک صفحه عقب می‌رود به‌جای بیرون انداختنِ کاربر از اپ. تعویضِ تب پشته را
* از نو می‌سازد (مثلِ اپ‌های بومی).
* هر «قدمِ برگشت‌پذیر» دقیقاً یک ورودیِ history دارد: هر صفحه‌ی push‌شده، تبی غیر از خانه، و هر برگه/حالتی که
* با useBackStep ثبت شده. تعدادِ لازم (desired) از وضعیت حساب می‌شود و یک افکت ورودی‌ها را با آن یکی می‌کند.
* دکمه‌ی برگشت یک ورودی برمی‌دارد (popstate) و این‌جا یک قدم در اپ عقب می‌رویم؛ بستنِ برگه یا عوض کردنِ تب از
* داخلِ اپ ورودیِ اضافه را با history.go پاک می‌کند (و آن popstate نادیده گرفته می‌شود).
*
* چرا «تله»ی همیشگی (pushState دوباره داخلِ popstate) نه: کروم ورودی‌ای را که بی‌لمسِ کاربر ساخته شده در دکمه‌ی
* برگشت رد می‌کند، پس آن تله گاهی کاربر را یک‌راست بیرون می‌انداخت. این‌جا ورودی‌ها در افکتِ بلافاصله بعد از لمس
* ساخته می‌شوند (فعال‌سازیِ گذرای کاربر هنوز برقرار است). در کرومِ دسکتاپ و موبایلِ شبیه‌سازی‌شده آزموده شد؛
* روی WebViewِ اپِ اندروید و Safariِ آیفون با دستگاهِ واقعی نه.
*/
/** نوعِ آخرین جابه‌جایی؛ انیمیشنِ ورودِ صفحه از آن می‌آید (جلو از چپ، عقب از راست، تب فقط محو). */
export type NavDirection = 'push' | 'back' | 'tab' | 'replace';
interface Nav {
route: Route;
direction: NavDirection;
/** تبی که ریشه‌ی پشته‌ی فعلی است؛ برای روشن کردنِ نوارِ پایین. */
tab: TabName | null;
canGoBack: boolean;
/** هر بار که کاربر تبِ همین‌حالا باز را دوباره می‌زند یکی زیاد می‌شود (برای «برو بالا»). */
reselect: number;
push: (route: Route) => void;
/** صفحه‌ی فعلی را عوض می‌کند بدونِ اضافه کردن به تاریخچه. */
replace: (route: Route) => void;
back: () => void;
/** از صفحه‌ی فعلی بیرون می‌رود حتی اگر برگه‌ای باز باشد (برگه هم بسته می‌شود)؛ `back` فقط خودِ برگه را می‌بندد. */
leave: () => void;
switchTab: (tab: TabName) => void;
}
const NavContext = createContext<Nav | null>(null);
export function NavigatorProvider({ initial, children }: { initial: Route; children: ReactNode }) {
const [stack, setStack] = useState<Route[]>([initial]);
interface BackSteps {
register: (onBack: () => void) => () => void;
}
const BackContext = createContext<BackSteps | null>(null);
useEffect(() => {
const onPop = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
/**
* یک قدمِ برگشت‌پذیرِ داخلِ صفحه (برگه‌ی باز، فهرستی که از «انتخاب» باز شده): تا `active` است دکمه‌ی برگشتِ
* گوشی اول `onBack` را صدا می‌زند. بیرون از اپِ اصلی (صفحه‌ی ورود) کاری نمی‌کند.
*/
export function useBackStep(active: boolean, onBack: () => void) {
const steps = useContext(BackContext);
const ref = useRef(onBack);
ref.current = onBack;
useEffect(() => (active && steps ? steps.register(() => ref.current()) : undefined), [active, steps]);
}
export function NavigatorProvider({ initial, children }: { initial: Route; children: ReactNode }) {
const [{ stack, direction }, setState] = useState<{ stack: Route[]; direction: NavDirection }>({ stack: [initial], direction: 'tab' });
const [reselect, setReselect] = useState(0);
const [steps, setSteps] = useState<(() => void)[]>([]);
const stackRef = useRef(stack);
stackRef.current = stack;
const stepsRef = useRef(steps);
stepsRef.current = steps;
/** ورودی‌هایی که خودمان بالای ورودیِ اولِ اپ گذاشته‌ایم. */
const entries = useRef(0);
/** popstateهایی که از history.goِ خودمان است، نه از دکمه‌ی کاربر. */
const ignorePops = useRef(0);
/** یک قدم عقب در خودِ اپ (بی history). */
const stepBack = useCallback(() => {
const open = stepsRef.current;
if (open.length) {
open[open.length - 1]();
return;
}
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' });
}, []);
useEffect(() => {
const onPop = () => {
if (ignorePops.current > 0) {
ignorePops.current--;
return;
}
entries.current = Math.max(0, entries.current - 1);
stepBack();
};
window.addEventListener('popstate', onPop);
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;
if (diff > 0) {
for (let i = 0; i < diff; i++) window.history.pushState({ tadrisino: true }, '');
} else if (diff < 0) {
ignorePops.current++;
window.history.go(diff);
}
entries.current = desired;
}, [desired]);
const register = useCallback((onBack: () => void) => {
setSteps((list) => [...list, onBack]);
return () => setSteps((list) => list.filter((f) => f !== onBack));
}, []);
const backSteps = useMemo<BackSteps>(() => ({ register }), [register]);
const push = useCallback((route: Route) => {
setStack((s) => [...s, route]);
window.history.pushState({ tadrisino: true }, '');
setState((prev) => ({ stack: [...prev.stack, route], direction: 'push' }));
}, []);
const replace = useCallback((route: Route) => {
setStack((s) => [...s.slice(0, -1), route]);
setState((prev) => ({ stack: [...prev.stack.slice(0, -1), route], direction: 'replace' }));
}, []);
const back = useCallback(() => {
// popstate خودش صفحه را برمی‌دارد؛ پس دو منبعِ حقیقت نداریم.
window.history.back();
// دکمه‌ی برگشتِ داخلِ اپ همان راهِ دکمه‌ی گوشی را می‌رود تا history و پشته از هم جدا نشوند.
if (entries.current > 0) window.history.back();
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) => {
setStack([{ name: tab } as Route]);
// زدنِ همان تبی که باز و ریشه است صفحه را از نو نمی‌سازد (قبلاً دوباره بار می‌شد)؛ مثلِ اپ‌های بومی به
// بالای همان صفحه برمی‌گردد.
const current = stackRef.current;
if (current.length === 1 && current[0].name === tab) setReselect((n) => n + 1);
else setState({ stack: [{ name: tab } as Route], direction: 'tab' });
}, []);
const value = useMemo<Nav>(() => {
const root = stack[0].name;
return {
route: stack[stack.length - 1],
direction,
tab: ['home', 'lessons', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null,
canGoBack: stack.length > 1,
reselect,
push,
replace,
back,
leave,
switchTab,
};
}, [stack, push, replace, back, switchTab]);
}, [stack, direction, reselect, push, replace, back, leave, switchTab]);
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
return (
<NavContext.Provider value={value}>
<BackContext.Provider value={backSteps}>{children}</BackContext.Provider>
</NavContext.Provider>
);
}
export function useNav(): Nav {

View File

@@ -4,7 +4,7 @@ export type Route =
| { name: 'lessons' }
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
/** view: مستقیم به فهرستِ اشتراک‌ها یا بسته‌ها (مثلاً از فصلِ قفل)؛ بی‌آن انتخابِ دوگزینه‌ای. */
| { name: 'subscription'; view?: 'subscription' | 'pack' }
| { name: 'subscription' }
| { name: 'profile' }
| { name: 'unit'; unitId: string }
| { name: 'lesson'; lessonId: number; nodeId?: string }

50
src/pay.ts Normal file
View File

@@ -0,0 +1,50 @@
import { resolveApiBaseUrl } from '@/config';
/**
* صفحه‌ی واسطه‌ی پرداخت (pay.html)، برای اپِ اندرویدی که درگاه را در مرورگرِ گوشی باز می‌کند.
*
* زرین‌پال صفحه‌ی StartPay را فقط وقتی باز می‌کند که Referer از دامنه‌ی ثبت‌شده باشد؛ بی‌آن می‌گوید «دسترسی از این
* دامنه مجاز نمی‌باشد» (روی سرور با درخواستِ بی‌Referer دیده شد، ۱۴۰۵-۰۷). مرورگری که اپِ دیگری بازش کرده Referer
* نمی‌فرستد. پس اپ اول همین صفحه را در مرورگر باز می‌کند و این صفحه کاربر را به لینکِ پرداخت می‌فرستد؛ حالا Referer
* از math.tadrisino2.ir است، درست مثلِ پرداخت از سایت.
*
* فقط لینکِ پرداختِ API خودمان پذیرفته می‌شود (پیشوندِ آدرسِ API + /payment/orders/<id>/pay)، تا این صفحه
* ریدایرکتِ باز به هر جایی نشود.
*/
const msg = document.getElementById('msg')!;
const spin = document.getElementById('spin')!;
const go = document.getElementById('go') as HTMLAnchorElement;
function fail(text: string) {
spin.style.display = 'none';
msg.textContent = text;
}
// در fragment می‌آید، نه query: اپ این صفحه را با http:// باز می‌کند (پایینِ SubscriptionScreen) و fragment هرگز
// به سرور فرستاده نمی‌شود، پس امضای لینکِ پرداخت روی آن یک گامِ بی‌رمز دیده نمی‌شود.
const raw = new URLSearchParams(location.hash.slice(1)).get('u') ?? '';
let target: URL | null = null;
try {
target = new URL(raw);
} catch {
target = null;
}
const api = new URL(resolveApiBaseUrl() + '/', location.origin);
const ok =
target !== null &&
target.protocol === 'https:' &&
target.origin === api.origin &&
new RegExp(`^${api.pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}payment/orders/\\d+/pay$`).test(target.pathname);
if (!ok || !target) {
fail('این لینک پرداخت معتبر نیست. به اپ برگرد و دوباره «پرداخت» رو بزن.');
} else {
go.href = target.href;
location.replace(target.href);
// اگر مرورگر جابه‌جا نشد (مثلاً جلوی ریدایرکت را گرفت)، دکمه‌ی دستی.
setTimeout(() => {
spin.style.display = 'none';
msg.textContent = 'اگه صفحه پرداخت باز نشد، این دکمه رو بزن:';
go.style.display = 'inline-block';
}, 2500);
}

View File

@@ -12,20 +12,31 @@ import { Diagram } from '@/diagram';
import { MathText } from '@/lib/MathText';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
import { useGateHold } from '@/lib/useAsync';
import { iAm, useCompanion } from '@/state/companion';
const STARTERS = ['این فصل درباره‌ی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
const STARTERS = ['این فصل درباره چیه؟', 'یه مثال ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
/** «از پی بپرس»: دستیارِ ریاضی که از روی کتابِ پایه‌ی کاربر جواب می‌دهد. */
/** «از {همراه} بپرس»: دستیارِ ریاضی که از روی کتابِ پایه‌ی کاربر جواب می‌دهد؛ چهره و نامش همراهِ انتخابیِ کاربر است. */
export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) {
const { profile } = useAppData();
const { profile, home } = useAppData();
const subjectTitle = home?.subject?.title;
const companion = useCompanion();
const chat = useAskState();
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
// «X سوالِ رایگانِ امروز + Y سوالِ خریده‌شده»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
const [quota, setQuota] = useState<{ free: number; credits: number } | null>(null);
// «X سوالِ امروز»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
const [quota, setQuota] = useState<number | null>(null);
const [quotaDone, setQuotaDone] = useState(false);
useEffect(() => {
api.ai.quota().then((q) => setQuota({ free: q.remainingQuota, credits: q.credits })).catch(() => {});
api.ai
.quota()
.then((q) => setQuota(q.remainingQuota))
.catch((err: unknown) => console.warn('ai quota lookup failed', err))
.finally(() => setQuotaDone(true));
}, []);
// صفحه وقتی می‌آید که تاریخچه و سهمیه رسیده باشند؛ وگرنه اول خوش‌آمد و بعد پیام‌های قبلی رویش می‌پرید.
useGateHold(!chat.historyLoaded || !quotaDone);
const listEnd = useRef<HTMLDivElement>(null);
const autoSent = useRef(false);
@@ -49,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 وابسته نیست.
}, []);
@@ -80,6 +96,9 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
userQuestion: question,
conversationId: askStore.get().conversationId ?? undefined,
lessonId: askStore.get().lessonId ?? undefined,
// بی‌درس، کتابِ انتخابیِ دانش‌آموز (هندسه، گسسته، …) را می‌فرستیم تا دستیار از همان کتاب جواب بدهد؛
// با درس، سرور کتاب را از خودِ درس درمی‌آورد.
subject: askStore.get().lessonId ? undefined : subjectTitle,
questionId: forQuestionId,
},
(delta) => {
@@ -95,15 +114,15 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
diagram: done.diagram ?? null,
});
askStore.setConversation(done.conversationId);
setQuota((q) => ({ free: done.remainingQuota, credits: done.credits ?? q?.credits ?? 0 }));
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({ free: 0, credits: 0 });
if (quotaReached) setQuota(0);
} finally {
setBusy(false);
}
@@ -116,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;
@@ -124,14 +143,14 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
return (
<Screen>
<ScreenHeader
title="از پی بپرس"
subtitle={chat.lessonId ? 'از روی کتابِ همین درس جواب می‌دم' : 'دستیارِ ریاضی، از روی کتابِ خودت'}
color="#9B7BFF"
title={`از ${companion.name} بپرس`}
subtitle={chat.lessonId ? 'از روی کتاب همین درس جواب می‌دم' : 'دستیار ریاضی، از روی کتاب خودت'}
color={companion.color}
back={lessonId !== undefined || prompt !== undefined}
mascot={{ char: 'pi', mood: busy ? 'excited' : 'idle', size: 60 }}
mascot={{ mood: busy ? 'excited' : 'idle', size: 60 }}
>
<div className="mt-3 flex flex-wrap items-center gap-2">
{quota !== null && <QuotaPills free={quota.free} credits={quota.credits} />}
{quota !== null && <QuotaPills free={quota} />}
{!empty && (
<button
type="button"
@@ -147,7 +166,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
<Bubble role="assistant">
سلام {profile.name || 'دوستِ من'}! من پی‌ام 🙂 هر سوالی از ریاضیِ کتابت داری بپرس؛ قدم‌به‌قدم با هم حلش می‌کنیم.
سلام {profile.name || 'دوست من'}! {`من ${iAm(companion.name)}`} 🙂 هر سوالی از ریاضی کتابت داری بپرس؛ قدم‌به‌قدم با هم حلش می‌کنیم.
</Bubble>
{empty && chat.historyLoaded && (
@@ -208,11 +227,12 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
const mine = role === 'user';
const companion = useCompanion().char;
return (
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine && (
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
<SeyarMascot char="pi" mood="happy" size={32} />
<SeyarMascot char={companion} mood="happy" size={32} />
</span>
)}
<div
@@ -234,10 +254,10 @@ function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; childre
* سرور وصل نیستند.
*/
const THINKING_STEPS: [number, string][] = [
[0, 'پی داره تو کتابت می‌گرده'],
[4, 'پی داره حلش می‌کنه'],
[0, '{name} داره تو کتابت می‌گرده'],
[4, '{name} داره حلش می‌کنه'],
[15, 'داره قدم‌ها رو مرتب می‌نویسه'],
[28, 'جوابِ کامل یه کم طول می‌کشه، الان می‌رسه'],
[28, 'جواب کامل یه کم طول می‌کشه، الان می‌رسه'],
];
function Thinking() {
@@ -247,11 +267,12 @@ function Thinking() {
const timer = setInterval(() => setSeconds((Date.now() - started) / 1000), 1000);
return () => clearInterval(timer);
}, []);
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1];
const companion = useCompanion();
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1].replace('{name}', companion.name);
return (
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
<SeyarMascot char="pi" mood="idle" size={32} />
<SeyarMascot char={companion.char} mood="idle" size={32} />
</span>
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted" aria-live="polite">
{text}
@@ -299,7 +320,7 @@ function Message({
<div className="flex flex-wrap items-center gap-2 pr-10">
{message.quotaReached ? (
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-violet px-3 py-1 text-xs font-bold text-white">
خریدِ بسته‌ی سوال
اشتراک
</button>
) : message.failedQuestion ? (
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">
@@ -339,23 +360,21 @@ function Message({
}
/**
* شمارنده در سربرگِ دستیار: سوال‌های رایگانِ باقیِ امروز و سوال‌های خریده‌شده، هر کدام جدا و
* درشت، تا بچه پیش از پرسیدن بداند چقدر دارد. زدن روی دومی صفحه‌ی بسته‌ها را باز می‌کند.
* شمارنده در سربرگِ دستیار: سوال‌های باقیِ امروز. وقتی تمام شد، دکمه‌ی دوم به فروشگاهِ اشتراک می‌برد
* (سوال جدا فروخته نمی‌شود؛ مشترک فردا دوباره سهم دارد).
*/
function QuotaPills({ free, credits }: { free: number; credits: number }) {
function QuotaPills({ free }: { free: number }) {
const nav = useNav();
return (
<>
<span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}>
{free > 0 ? <>{toFa(free)} سوالِ رایگانِ امروز</> : 'رایگانِ امروز تموم شد'}
{free > 0 ? <>{toFa(free)} سوال امروز</> : 'سوال‌های امروز تموم شد'}
</span>
<button
type="button"
onClick={() => nav.switchTab('subscription')}
className={`rounded-full px-3 py-1.5 text-xs font-black ${credits > 0 ? 'bg-white text-violet' : 'bg-white/15 text-white'}`}
>
{credits > 0 ? <>{toFa(credits)} سوالِ خریده‌شده</> : '+ خریدِ سوال'}
{free === 0 && (
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-white/15 px-3 py-1.5 text-xs font-black text-white">
سوال بیشتر با اشتراک
</button>
)}
</>
);
}

View File

@@ -4,7 +4,8 @@ import { api, type Character } from '@/api';
import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { SEYAR_INFO, seyarFromSlug } from '@/mascots/seyar';
import { seyarFromSlug } from '@/mascots/seyar';
import { COMPANIONS } from '@/state/companion';
import { useToast } from '@/components/Toast';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader, Sep } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
@@ -17,7 +18,7 @@ import { toFa } from '@/lib/format';
export function CharactersScreen() {
const { summary, setCompanion } = useAppData();
const toast = useToast();
const list = useAsync(() => api.characters.list(), []);
const list = useAsync(() => api.characters.list(), [], { key: 'characters' });
const [busy, setBusy] = useState<string | null>(null);
async function select(c: Character) {
@@ -39,7 +40,7 @@ export function CharactersScreen() {
<Screen>
<ScreenHeader
title="همراهت رو انتخاب کن"
subtitle={<>امتیازِ تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیازِ بیشتر همراه‌های بیشتری باز می‌شن</>}
subtitle={<>امتیاز تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیاز بیشتر همراه‌های بیشتری باز می‌شن</>}
color="#9B7BFF"
back
/>
@@ -54,6 +55,7 @@ export function CharactersScreen() {
<div className="grid grid-cols-2 gap-3">
{list.data.map((c) => {
const seyar = seyarFromSlug(c.slug);
const look = seyar ? COMPANIONS[seyar] : null;
return (
<button
key={c.characterId}
@@ -62,11 +64,12 @@ export function CharactersScreen() {
onClick={() => void select(c)}
aria-pressed={c.selected}
className={`relative flex flex-col items-center gap-2 rounded-3xl border-2 p-4 transition-transform ${
c.selected ? 'border-violet bg-[#F5F1FF]' : c.unlocked ? 'border-line bg-white active:scale-95' : 'border-line bg-[#f4f3fa]'
c.selected ? '' : c.unlocked ? 'border-line bg-white active:scale-95' : 'border-line bg-[#f4f3fa]'
}`}
style={c.selected && look ? { borderColor: look.color, backgroundColor: `${look.color}12` } : undefined}
>
{c.selected && (
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-violet">
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-violet" style={look ? { backgroundColor: look.color } : undefined}>
<Check size={11} className="text-white" strokeWidth={3} />
</span>
)}
@@ -84,15 +87,14 @@ export function CharactersScreen() {
<Lock size={16} className="text-[#5a5580]" />
</span>
)}
<span className="text-sm font-bold text-ink">{c.name}</span>
<span className="text-sm font-bold text-ink">{look ? `${look.symbol} ${c.name}` : c.name}</span>
{look && (
<span className="text-center text-[11px] leading-5 text-body">
<b style={{ color: look.color }}>{look.trait.title}:</b> {look.trait.text}
</span>
)}
<span className="text-center text-[10px] leading-5 text-muted">
{!c.unlocked
? `با ${toFa(c.unlockXp)} امتیاز باز می‌شه`
: c.selected
? 'همراهِ فعلی'
: seyar
? SEYAR_INFO[seyar].role
: 'بازشده'}
{!c.unlocked ? `با ${toFa(c.unlockXp)} امتیاز باز می‌شه` : c.selected ? 'همراه فعلی' : 'بزن تا همراهت بشه'}
</span>
</button>
);

View File

@@ -1,18 +1,18 @@
import { useEffect, useRef, useState } from 'react';
import { Bell, ChevronLeft, Coins, Crown, Flame, Hand, PartyPopper, 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';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { IslandPath } from '@/components/IslandPath';
import { IslandSheet } from '@/components/IslandSheet';
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
import { onWhite, shade } from '@/lib/color';
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
import { BrandFooter } from '@/components/BrandFooter';
import { BrandMark } from '@/components/BrandMark';
import { StreakFlame, flameState } from '@/components/StreakFlame';
import { SubjectBar } from '@/components/SubjectBar';
/**
* خانه = خودِ مسیر، مثلِ دولینگو: همه‌ی فصل‌های پایه پشتِ هم، و جزیره‌ی فعلی با
@@ -20,10 +20,10 @@ import { BrandMark } from '@/components/BrandMark';
* دستیار، فصل‌ها، چالش) و بچه نمی‌دانست کدام را بزند؛ حالا یک کارِ واضح دارد.
*/
export function HomeScreen() {
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
const { gradeTitle, summary, home, homeError, unreadCount, refreshHome, refreshSummary } = useAppData();
const nav = useNav();
// فصلِ غیرِ اول بی‌اشتراک: زدنِ جزیره یک‌راست فهرستِ اشتراک‌ها را باز می‌کند.
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
const paywall = () => nav.push({ name: 'subscription' });
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
const currentRef = useRef<HTMLDivElement>(null);
const scrolled = useRef(false);
@@ -37,24 +37,18 @@ export function HomeScreen() {
return () => cancelAnimationFrame(frame);
}, [home]);
const name = profile.name || 'دوستِ من';
const bubble = !home
? 'دارم مسیرت رو میارم…'
: !home.units.length
? 'هنوز درسی برای این پایه ثبت نشده.'
: cont
? <>سلام {name}! <Hand size={15} className="mb-0.5 inline text-[#E8A400]" /> وقتشه جزیره‌ی <b>«<MathText text={cont.node.title} />»</b> رو شروع کنی؛ دکمه‌ی <b>شروع</b> رو بزن.</>
: <>آفرین {name}! همه‌ی جزیره‌های این پایه رو تموم کردی <PartyPopper size={15} className="mb-0.5 inline text-[#E8A400]" /></>;
const flame = flameState(summary?.streak);
const streak = summary?.streak;
const stats = [
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روز پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', color: '#E8A400', fill: true, to: 'leaderboard' as const },
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const },
];
return (
<Screen>
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-4 pt-safe text-white shadow-md shadow-coral/20">
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-3 pt-safe text-white shadow-md shadow-coral/20">
<div className="flex items-center gap-2 pt-4">
<BrandMark size={26} withName={false} />
{gradeTitle && (
@@ -69,10 +63,10 @@ export function HomeScreen() {
type="button"
aria-label={`${label}: ${toFa(value)}`}
onClick={() => nav.push({ name: to })}
className="hit flex items-center gap-1 rounded-full bg-white px-2.5 py-1.5 text-xs font-black text-ink shadow-sm active:scale-95"
className={`hit flex items-center gap-1 rounded-full px-2.5 py-1.5 text-xs font-black shadow-sm active:scale-95 ${to === 'streak' && flame === 'frozen' ? 'bg-[#E6F6FF] text-[#2B7FB8]' : 'bg-white text-ink'}`}
>
<Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />
{toFa(value)}
{to === 'streak' ? <StreakFlame state={flame} size={13} /> : <Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />}
{toFa(to === 'streak' && flame === 'frozen' ? streak?.lost ?? 0 : value)}
</button>
))}
<button type="button" aria-label="اعلان‌ها" onClick={() => nav.push({ name: 'notifications' })} className="hit relative flex h-8 w-8 items-center justify-center rounded-full bg-white/20 active:scale-95">
@@ -86,14 +80,10 @@ export function HomeScreen() {
</div>
</div>
<div className="mt-3 flex items-end gap-2">
<SeyarMascot char={companion} mood={cont ? 'happy' : home ? 'excited' : 'idle'} size={64} bob />
<div className="relative mb-2 flex-1 rounded-2xl rounded-br-sm bg-white px-3.5 py-2.5 text-[13px] leading-6 text-ink shadow-sm">
{bubble}
</div>
</div>
</header>
<SubjectBar />
<div className="flex-1 overflow-y-auto">
{!home ? (
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />
@@ -101,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);
@@ -115,7 +122,7 @@ export function HomeScreen() {
>
<div className="min-w-0 flex-1">
<div className="text-[11px] font-bold text-white/80">
فصلِ {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
فصل {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
{unit.requiresSubscription && <span className="mr-1.5 inline-flex h-5 items-center gap-1 rounded-full bg-white/25 px-2 align-middle leading-none"><Crown size={11} /> با اشتراک</span>}
{isCurrentUnit && <span className="mr-1.5 inline-flex h-5 items-center rounded-full bg-white/25 px-2 align-middle leading-none">الان اینجایی</span>}
</div>

View File

@@ -12,14 +12,14 @@ export function inviteLink(code: string): string {
export function InviteScreen() {
const toast = useToast();
const me = useAsync(() => api.affiliate.me(), []);
const me = useAsync(() => api.affiliate.me(), [], { key: 'affiliate' });
async function share(code: string) {
const url = inviteLink(code);
const text = 'با تدریسینو ریاضی رو قدم‌به‌قدم یاد بگیر:';
if (navigator.share) {
try {
await navigator.share({ title: 'تدریسینو', text, url });
await navigator.share({ title: 'تدریسینو ریاضی', text, url });
return;
} catch {
/* کاربر لغو کرد؛ می‌رویم سراغِ کپی */
@@ -35,7 +35,7 @@ export function InviteScreen() {
return (
<Screen>
<ScreenHeader title="معرفی به دوستان" subtitle="دوستات رو بیار، با هم ریاضی بخونید" color="#5FBF8F" back mascot={{ char: 'delta', mood: 'happy', size: 56 }} />
<ScreenHeader title="معرفی به دوستان" subtitle="دوستات رو بیار، با هم ریاضی بخونید" color="#5FBF8F" back mascot={{ mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
{me.loading ? (
<LoadingState />
@@ -45,7 +45,7 @@ export function InviteScreen() {
<>
<div className="flex flex-col items-center gap-2 rounded-3xl border border-line bg-white p-5 text-center">
<Gift size={28} className="text-mint" />
<span className="text-xs text-muted">کدِ معرفیِ تو</span>
<span className="text-xs text-muted">کد معرفی تو</span>
<button
type="button"
onClick={() => void navigator.clipboard?.writeText(me.data!.referralCode).then(() => toast('کد کپی شد', 'success'))}
@@ -71,11 +71,11 @@ export function InviteScreen() {
<p className="flex items-start gap-2 rounded-2xl bg-[#EAF8F0] p-3.5 text-xs leading-6 text-body">
<Users size={16} className="mt-0.5 flex-shrink-0 text-mint" />
دوستت باید با لینکِ تو وارد بشه و شماره‌ش رو ثبت کنه. اگه اشتراک خرید، {toFa(Math.round(me.data.commissionRate * 100))}٪ مبلغ پورسانتِ توئه.
دوستت باید با لینک تو وارد بشه و شماره‌ش رو ثبت کنه. اگه اشتراک خرید، {toFa(Math.round(me.data.commissionRate * 100))}٪ مبلغ پورسانت توئه.
</p>
<PrimaryButton color="#5FBF8F" onClick={() => void share(me.data!.referralCode)}>
<Share2 size={17} /> فرستادنِ لینکِ دعوت
<Share2 size={17} /> فرستادن لینک دعوت
</PrimaryButton>
</>
)}

View File

@@ -15,24 +15,24 @@ import { formatJalaliDate, formatToman } from '@/lib/format';
*/
const STATUS: Record<Invoice['status'], { label: string; className: string }> = {
paid: { label: 'پرداخت‌شده', className: 'bg-[#EAF8F0] text-[#2E7D5B]' },
pending: { label: 'در انتظارِ پرداخت', className: 'bg-[#FFF8E1] text-[#8A6100]' },
pending: { label: 'در انتظار پرداخت', className: 'bg-[#FFF8E1] text-[#8A6100]' },
failed: { label: 'ناموفق', className: 'bg-soft text-muted' },
canceled: { label: 'لغوشده', className: 'bg-soft text-muted' },
refunded: { label: 'بازپرداخت‌شده', className: 'bg-[#EEF1FF] text-indigo' },
};
const PROVIDER: Record<string, string> = { gateway: 'درگاهِ زرین‌پال', bazaar: 'کافه‌بازار' };
const PROVIDER: Record<string, string> = { gateway: 'درگاه زرین‌پال', bazaar: 'کافه‌بازار' };
const dateOf = (invoice: Invoice) => formatJalaliDate(invoice.paidAt ?? invoice.createdAt);
export function InvoicesScreen() {
const nav = useNav();
const list = useAsync(() => api.subscription.invoices(), []);
const list = useAsync(() => api.subscription.invoices(), [], { key: 'invoices' });
const [open, setOpen] = useState<Invoice | null>(null);
return (
<Screen>
<ScreenHeader title="پرداخت‌ها و فاکتورها" subtitle="همه‌ی خریدهات، با شماره‌ی فاکتور و کدِ پیگیری" color="#4C5FD5" back />
<ScreenHeader title="پرداخت‌ها و فاکتورها" subtitle="همه خریدهات، با شماره فاکتور و کد پیگیری" color="#4C5FD5" back />
<ScreenBody className="flex flex-col gap-3">
{list.loading ? (
<LoadingState />
@@ -42,7 +42,7 @@ export function InvoicesScreen() {
<div className="flex flex-col items-center gap-4 rounded-3xl border border-line bg-white px-5 py-8 text-center">
<Receipt size={32} className="text-faint" />
<p className="text-sm text-body">هنوز خریدی نکردی.</p>
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ اشتراک‌ها</PrimaryButton>
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدن اشتراک‌ها</PrimaryButton>
</div>
) : (
list.data.map((invoice) => (
@@ -88,17 +88,17 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
const rows: [string, React.ReactNode][] = invoice
? [
['شماره‌ی فاکتور', <span dir="ltr">{invoice.invoiceNumber}</span>],
['شماره فاکتور', <span dir="ltr">{invoice.invoiceNumber}</span>],
['تاریخ', dateOf(invoice)],
['وضعیت', STATUS[invoice.status].label],
['اشتراک', invoice.planTitle || '—'],
...(invoice.listPrice !== null ? [['قیمت', formatToman(invoice.listPrice)] as [string, React.ReactNode]] : []),
...(invoice.discountAmount ? [[
invoice.discountCode ? `تخفیف (کدِ ${invoice.discountCode})` : 'تخفیف',
invoice.discountCode ? `تخفیف (کد ${invoice.discountCode})` : 'تخفیف',
formatToman(invoice.discountAmount),
] as [string, React.ReactNode]] : []),
['مبلغِ پرداختی', <b>{formatToman(invoice.amount)}</b>],
['روشِ پرداخت', PROVIDER[invoice.provider] ?? invoice.provider],
['مبلغ پرداختی', <b>{formatToman(invoice.amount)}</b>],
['روش پرداخت', PROVIDER[invoice.provider] ?? invoice.provider],
]
: [];
@@ -122,7 +122,7 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
onClick={() => void copy(invoice.refId)}
className="flex items-center justify-between rounded-2xl bg-soft px-4 py-3 text-sm"
>
<span className="text-muted">کدِ پیگیری</span>
<span className="text-muted">کد پیگیری</span>
<span className="flex items-center gap-2 font-bold text-ink" dir="ltr">
{/* کد همان‌طور که کپی می‌شود: با رقمِ فارسی صفرها نقطه دیده می‌شدند. */}
{invoice.refId} <Copy size={14} />
@@ -131,7 +131,7 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
) : (
(invoice.status === 'pending' || invoice.status === 'failed') && (
<p className="text-xs leading-6 text-muted">
اگه پولی از حسابت کم شده ولی این خرید «پرداخت‌شده» نیست، شماره‌ی فاکتور رو به پشتیبانی بده.
اگه پولی از حسابت کم شده ولی این خرید «پرداخت‌شده» نیست، شماره فاکتور رو به پشتیبانی بده.
</p>
)
)}

View File

@@ -15,16 +15,16 @@ const MEDAL: Record<number, { bg: string; fg: string; h: number }> = {
export function LeaderboardScreen() {
const { profile } = useAppData();
const [period, setPeriod] = useState<LeaderboardPeriod>('weekly');
const board = useAsync(() => api.leaderboard(period), [period]);
const board = useAsync(() => api.leaderboard(period), [period], { key: `leaderboard:${period}` });
const entries = board.data ?? [];
const podium = [entries[1], entries[0], entries[2]].filter(Boolean);
const isMe = (userId: string) => userId === profile.userId;
return (
<Screen>
<ScreenHeader title="جدولِ امتیازها" color="#FF7A3D" back mascot={{ char: 'delta', mood: 'excited', size: 56 }}>
<ScreenHeader title="جدول امتیازها" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }}>
<div className="mt-4 flex gap-1 rounded-2xl bg-white/15 p-1" role="tablist">
{([['weekly', 'این هفته'], ['all_time', 'همه‌ی زمان‌ها']] as const).map(([p, label]) => (
{([['weekly', 'این هفته'], ['all_time', 'همه زمان‌ها']] as const).map(([p, label]) => (
<button
key={p}
type="button"
@@ -41,7 +41,7 @@ export function LeaderboardScreen() {
<div className="flex-1 overflow-y-auto px-4 py-4">
{board.loading ? (
<LoadingState char="delta" text="دارم جدول رو میارم…" />
<LoadingState text="دارم جدول رو میارم…" />
) : board.error ? (
<ErrorState error={board.error} onRetry={board.reload} />
) : entries.length === 0 ? (

View File

@@ -1,3 +1,4 @@
import { useCompanion } from '@/state/companion';
import { Brain, Play, Zap } from 'lucide-react';
import { api } from '@/api';
import { useAppData } from '@/state/AppData';
@@ -10,12 +11,15 @@ import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHead
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
import { Diagram } from '@/diagram';
import { StepText } from '@/lib/StepText';
import { FeedbackBar } from '@/components/FeedbackBar';
/** معرفیِ درس، پیش از تمرین. */
export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
const { home, companion } = useAppData();
const companionName = useCompanion().name;
const nav = useNav();
const intro = useAsync(() => api.lesson.intro(lessonId), [lessonId]);
const intro = useAsync(() => api.lesson.intro(lessonId), [lessonId], { key: `lesson-intro:${lessonId}` });
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
const data = intro.data;
@@ -49,9 +53,9 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
) : (
<>
<div className="flex items-start gap-3">
<SeyarMascot char="sigma" mood="idle" size={56} />
<SeyarMascot char={companion} mood="idle" size={56} />
<div className="flex-1 rounded-3xl rounded-tr-sm border border-line bg-white p-4 shadow-sm">
<p className="whitespace-pre-line text-sm leading-[1.95] text-body"><MathText text={data.introText} /></p>
<StepText text={data.introText} color={color} className="text-[15px] leading-[1.95] text-body" />
{figs('intro')}
</div>
</div>
@@ -63,7 +67,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
{data.example && (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-2 text-xs font-black text-muted">مثال</h2>
<p className="whitespace-pre-line text-sm leading-[1.95] text-ink"><MathText text={data.example} /></p>
<StepText text={data.example} color={color} className="text-[15px] leading-[1.95] text-ink" />
{figs('example')}
</section>
)}
@@ -77,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>
@@ -85,13 +89,14 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
)}
<div className="mt-auto flex flex-col gap-3 pt-2">
<FeedbackBar kind="lesson" id={String(lessonId)} />
{data.questionCount === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-[#dedced] bg-white py-4 text-center text-sm font-bold text-muted">
تمرینِ این درس به‌زودی اضافه می‌شه
تمرین این درس به‌زودی اضافه می‌شه
</div>
) : (
<PrimaryButton color={color} onClick={() => nav.replace({ name: 'quiz', lessonId, nodeId: ref?.node.id ?? nodeId })}>
<Play size={18} fill="white" /> شروعِ تمرین ({toFa(data.questionCount)} سوال)
<Play size={18} fill="white" /> شروع تمرین ({toFa(data.questionCount)} سوال)
</PrimaryButton>
)}
<button
@@ -99,7 +104,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
onClick={() => nav.push({ name: 'ask', lessonId })}
className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]"
>
<Brain size={16} /> درباره‌ی این درس از پی بپرس
<Brain size={16} /> درباره این درس از {companionName} بپرس
</button>
</div>
</>

View File

@@ -11,6 +11,8 @@ import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader
import { iconFor } from '@/components/NodeIcon';
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
import { useFirstVisit } from '@/lib/firstVisit';
import { SubjectBar } from '@/components/SubjectBar';
/**
* تبِ «درس‌ها»: فصل ← جزیره ← درس. فصل‌ها اول بسته‌اند و بالای صفحه یک کارتِ «ادامه بده» هست.
@@ -36,10 +38,10 @@ export function LessonsScreen() {
<Screen className="bg-page">
<ScreenHeader
title="درس‌ها"
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>}
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمون جزیره رو بده</>}
color="#2FB36B"
exact
mascot={{ char: 'sigma', mood: 'happy' }}
mascot={{ mood: 'happy' }}
>
{home && totalNodes > 0 && (
<div className="mt-4">
@@ -52,6 +54,8 @@ export function LessonsScreen() {
)}
</ScreenHeader>
<SubjectBar />
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-4">
{!home ? (
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم درس‌ها رو میارم…" />
@@ -117,15 +121,16 @@ function UnitCard({
const Icon = iconFor(unit.nodes[0]?.icon, index);
const nav = useNav();
const premium = unit.requiresSubscription;
const intro = useFirstVisit(`unit-card:${unit.id}`);
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
initial={intro ? { opacity: 0, y: 12 } : { opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index, 8) * 0.04 }}
className="overflow-hidden rounded-2xl bg-white"
style={{ border: `2px solid ${open ? color : '#e9e6f7'}`, boxShadow: open ? `0 4px 0 ${color}40` : '0 2px 0 #e9e6f7' }}
>
<button type="button" onClick={premium ? () => nav.push({ name: 'subscription', view: 'subscription' }) : onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
<button type="button" onClick={premium ? () => nav.push({ name: 'subscription' }) : onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
<span
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-2xl"
style={{ backgroundColor: complete ? color : `${color}22`, color: complete ? '#fff' : color, opacity: untouched ? 0.55 : 1 }}
@@ -193,7 +198,7 @@ function NodeRow({
<span className="min-w-0 flex-1">
<span className="block text-sm font-bold leading-6 text-ink"><MathText text={node.title} /></span>
<span className="block text-[11px] text-muted">
{locked ? 'جزیره‌ی قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`}
{locked ? 'جزیره قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`}
</span>
</span>
{isCurrent && !open && (

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;
@@ -61,7 +63,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
setDevOtp(data.devOtp ?? null);
} catch (err) {
if (mine !== attempt.current) return;
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن'));
setError(friendlyError(err, 'ارسال کد انجام نشد، دوباره امتحان کن'));
if (err instanceof ApiError && err.status === 400) {
setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند
} else if (err instanceof ApiError && err.isRateLimited && err.retryAfterMs) {
@@ -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,11 +108,11 @@ 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'
? 'شماره‌ی موبایلت رو وارد کن'
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامک‌شده رو وارد کن`}
? 'شماره موبایلت رو وارد کن'
: `کد ${toFa(OTP_LENGTH)} رقمی پیامک‌شده رو وارد کن`}
</p>
</div>
<div className="rounded-2xl bg-white/15 p-2">
@@ -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"
@@ -146,7 +148,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
) : (
<div>
<label className="block">
<span className="mb-2 block text-sm font-bold text-ink">کدِ تأیید</span>
<span className="mb-2 block text-sm font-bold text-ink">کد تأیید</span>
<input
ref={otpInput}
value={otp}
@@ -165,7 +167,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
)}
{devOtp && (
<p className="mt-2 text-xs text-muted">
کدِ آزمایشیِ حالتِ توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
کد آزمایشی حالت توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
</p>
)}
<div className="mt-3 flex items-center justify-between text-xs font-bold">
@@ -173,7 +175,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
<Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح
</button>
<button type="button" disabled={resendIn > 0 || sending || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted">
{resendIn > 0 ? `ارسالِ دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسالِ دوباره'}
{resendIn > 0 ? `ارسال دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسال دوباره'}
</button>
</div>
</div>
@@ -187,10 +189,23 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
loading={loading === 'login'}
disabled={step === 'phone' ? !phoneValid : !otpValid}
>
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'}
{step === 'phone' ? 'دریافت کد تأیید' : 'ورود'}
</PrimaryButton>
</div>
</form>
</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

@@ -23,7 +23,7 @@ export function NotificationsScreen() {
const r = await api.notifications.list();
setUnreadCount(r.unreadCount);
return r.items;
}, []);
}, [], { key: 'notifications' });
const items = list.data ?? [];
const unread = items.filter((n) => !n.isRead).length;
@@ -51,7 +51,7 @@ export function NotificationsScreen() {
return (
<Screen>
<ScreenHeader title="اعلان‌ها" color="#4C5FD5" back mascot={{ char: 'sigma', mood: 'idle', size: 54 }}>
<ScreenHeader title="اعلان‌ها" color="#4C5FD5" back mascot={{ mood: 'idle', size: 54 }}>
{unread > 0 && (
<button type="button" onClick={() => void markAll()} className="mt-3 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold">
همه خوانده شد

View File

@@ -7,27 +7,27 @@ import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title: string; desc: string }[] = [
{
char: 'sigma', mood: 'happy', bg: '#4C5FD5',
sub: 'از پایه‌ی سوم تا دوازدهم',
title: 'به تدریسینو خوش اومدی!',
desc: 'من سیگمام. ریاضیِ کتابِ خودت رو جزیره به جزیره با هم جلو می‌بریم؛ هر جزیره چند درسِ کوتاه و یه عالمه تمرین.',
sub: 'از پایه سوم تا دوازدهم',
title: 'به تدریسینو ریاضی خوش اومدی!',
desc: 'من سیگمام. با هم ریاضی کتاب درسیت رو جزیره به جزیره جلو می‌بریم؛ هر جزیره چند درس کوتاه داره و کلی تمرین.',
},
{
char: 'delta', mood: 'excited', bg: '#FF7A3D',
sub: 'هر روز یه کم',
title: 'امتیاز بگیر، زنجیره بساز',
desc: 'منم دلتا! با هر جوابِ درست امتیاز و سکه می‌گیری، روزهای پیاپی‌ت شمرده می‌شه و تو جدولِ امتیازها بالا می‌ری.',
desc: 'منم دلتا! با هر جواب درست امتیاز و سکه می‌گیری، روزهایی که پشت هم تمرین می‌کنی شمرده می‌شه و تو جدول امتیازها بالا می‌ری.',
},
{
char: 'pi', mood: 'happy', bg: '#9B7BFF',
sub: 'هر جا گیر کردی',
title: 'از پی بپرس',
desc: 'من پی‌ام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدم‌به‌قدم توضیح می‌دم.',
desc: 'من پی‌ام. هر جا سوالی داشتی یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتاب درسیت قدم‌به‌قدم توضیح می‌دم.',
},
{
char: 'tedi', mood: 'excited', bg: '#D9820B',
sub: 'یکی از ما مالِ توئه',
sub: 'یکی از ما مال توئه',
title: 'همراهت رو انتخاب کن',
desc: 'منم تدی! هر چی امتیازت بیشتر بشه، همراه‌های تازه باز می‌شن. از صفحه‌ی «من» هر کدوممون رو خواستی کنارت می‌ذاری.',
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

@@ -1,7 +1,7 @@
import { useState, type ReactNode } from 'react';
import {
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Crown, Heart, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
FileText, Pencil, Repeat, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX,
FileText, Pencil, Sparkles, Star, Trash2, Trophy, UserRound, Volume2, VolumeX,
} from 'lucide-react';
import { api } from '@/api';
import { useAppData } from '@/state/AppData';
@@ -20,7 +20,7 @@ import { useAsync } from '@/lib/useAsync';
import { daysLeft } from '@/lib/subscription';
import { BrandFooter } from '@/components/BrandFooter';
type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null;
type Editing = 'name' | 'grade' | 'birth' | 'account' | 'logout' | 'delete' | null;
/** «من»: پروفایل، تنظیمات و میان‌بُر به بخش‌های فرعی. */
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
@@ -46,7 +46,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
{profile.name || 'بی‌نام'} <Pencil size={14} className="text-white/70" />
</button>
<div className="mt-0.5 text-xs text-white/75">
شناسه‌ی کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span>
شناسه کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80">
{gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
@@ -74,27 +74,27 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
<Menu>
<Row icon={GraduationCap} label="پایه‌ی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
<Row icon={GraduationCap} label="پایه تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
<Row
icon={CalendarDays}
label="تاریخِ تولد"
label="تاریخ تولد"
value={formatBirthDate(profile.birthDate) || (age ? `${toFa(age)} سال` : 'وارد نشده')}
onClick={() => setEditing('birth')}
/>
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} />
<Row icon={Sparkles} label="همراه من" onClick={go({ name: 'characters' })} />
</Menu>
<Menu>
<Row icon={BarChart3} label="گزارشِ والدین" onClick={go({ name: 'report' })} />
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} />
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} />
<Row icon={BarChart3} label="گزارش والدین" onClick={go({ name: 'report' })} />
<Row icon={Flame} label="تقویم زنجیره" onClick={go({ name: 'streak' })} />
<Row icon={Trophy} label="جدول امتیازها" onClick={go({ name: 'leaderboard' })} />
<Row icon={Bell} label="اعلان‌ها" onClick={go({ name: 'notifications' })} />
</Menu>
<Menu>
<Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />
<Row icon={FileText} label="پرداخت‌ها و فاکتورها" onClick={go({ name: 'invoices' })} />
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
<Row icon={LifeBuoy} label="پشتیبانی و گزارش مشکل" onClick={go({ name: 'support' })} />
<Row
icon={sound ? Volume2 : VolumeX}
label="صداها"
@@ -106,13 +106,12 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
/>
</Menu>
{/* حذفِ حساب یک لایه پایین‌تر، در «حساب کاربری»، مثلِ اپ‌های بزرگ (دیجی‌کالا، اسنپ، دولینگو): پیدا می‌شود
(Google Play و App Store «در دسترس و پیداشدنی» می‌خواهند) ولی با یک لمسِ اشتباه زیرِ انگشت نیست. */}
<Menu>
<Row icon={Repeat} label="ورود با حسابِ دیگر" onClick={() => setEditing('switch')} />
<Row icon={LogOut} label="خروج از حساب" onClick={() => setEditing('logout')} />
<Row icon={UserRound} label="حساب کاربری" value={profile.phone ? toFa(profile.phone) : undefined} onClick={() => setEditing('account')} />
<Row icon={LogOut} label="خروج از حساب" danger onClick={() => setEditing('logout')} />
</Menu>
<button type="button" onClick={() => setEditing('delete')} className="flex items-center justify-center gap-2 py-2 text-xs font-bold text-red-500">
<Trash2 size={14} /> حذفِ حساب
</button>
<BrandFooter />
</div>
</div>
@@ -120,7 +119,8 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
<BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
<LogoutSheet open={editing === 'logout' || editing === 'switch'} switching={editing === 'switch'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
<AccountSheet open={editing === 'account'} onClose={() => setEditing(null)} onDelete={() => setEditing('delete')} />
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
<DeleteAccountSheet open={editing === 'delete'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
</Screen>
);
@@ -131,7 +131,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
* موعد روزها را روی هم می‌گذارد، پس نوار در ۱۰۰٪ بسته می‌شود. زدنِ کارت فروشگاه را باز می‌کند.
*/
function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
const status = useAsync(() => api.subscription.status(), []);
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
const s = status.data;
if (!s) return <div className="h-[132px] animate-pulse rounded-3xl bg-soft" />;
const days = s.active ? daysLeft(s.expiresAt) : 0;
@@ -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">
@@ -166,7 +166,7 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={days ?? total}
aria-label="روزهای ماندهٔ اشتراک"
aria-label="روزهای مانده اشتراک"
>
{percent > 0 && (
<div className="bar-fill absolute inset-y-0 right-0 overflow-hidden rounded-full" style={{ width: `${percent}%`, backgroundColor: low ? '#FFD23F' : '#fff' }}>
@@ -178,18 +178,13 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
<div className={`mt-2 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[11px] ${s.active ? 'text-white/85' : 'text-muted'}`}>
{s.active ? (
<>
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقتِ تمدیده'}</span>}
<span className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلبِ نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقت تمدیده'}</span>}
<span className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلب نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
</>
) : (
<span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همه‌ی فصل‌ها و قلبِ نامحدود باز می‌شه.</span>
<span>فصل اول هر پایه رایگانه؛ با اشتراک همه فصل‌ها و قلب نامحدود باز می‌شه.</span>
)}
</div>
{s.questionCredits > 0 && (
<div className={`mt-1.5 flex items-center gap-1 text-[11px] font-bold ${s.active ? 'text-white/90' : 'text-violet'}`}>
<Sparkles size={11} /> {toFa(s.questionCredits)} سوالِ خریده‌شده در اعتبارت
</div>
)}
</button>
);
}
@@ -198,11 +193,11 @@ function Menu({ children }: { children: ReactNode }) {
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
}
function Row({ icon: Icon, label, value, onClick }: { icon: typeof Bell; label: string; value?: string; onClick: () => void }) {
function Row({ icon: Icon, label, value, danger = false, onClick }: { icon: typeof Bell; label: string; value?: string; danger?: boolean; onClick: () => void }) {
return (
<button type="button" onClick={onClick} className="flex w-full items-center gap-3 px-4 py-3.5 text-right active:bg-soft">
<Icon size={18} className="flex-shrink-0 text-indigo" />
<span className="flex-1 text-sm font-bold text-ink">{label}</span>
<Icon size={18} className={`flex-shrink-0 ${danger ? 'text-red-500' : 'text-indigo'}`} />
<span className={`flex-1 text-sm font-bold ${danger ? 'text-red-500' : 'text-ink'}`}>{label}</span>
{value && <span className="text-xs text-muted">{value}</span>}
<ChevronLeft size={16} className="text-faint" />
</button>
@@ -256,7 +251,7 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
await api.profile.setGrade(gradeId);
setProfile({ ...profile, gradeId });
askStore.reset(null);
await Promise.all([refreshHome(), refreshSummary()]);
await Promise.all([refreshHome(gradeId), refreshSummary()]);
toast('پایه‌ت عوض شد', 'success');
onClose();
} catch (err) {
@@ -267,8 +262,8 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
}
return (
<Sheet open={open} onClose={onClose} title="پایه‌ی تحصیلی">
<p className="mb-3 text-xs leading-6 text-muted">پیشرفتِ هر پایه جدا نگه داشته می‌شه؛ با برگشتن، سرِ جاشه.</p>
<Sheet open={open} onClose={onClose} title="پایه تحصیلی">
<p className="mb-3 text-xs leading-6 text-muted">پیشرفت هر پایه جدا نگه داشته می‌شه؛ با برگشتن، سر جاشه.</p>
<div className="grid grid-cols-2 gap-2.5">
{grades.map((g) => {
const on = g.id === profile.gradeId;
@@ -312,8 +307,8 @@ function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void
}
return (
<Sheet open={open} onClose={onClose} title="تاریخِ تولد">
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سالِ تولدت به شمسی.</p>
<Sheet open={open} onClose={onClose} title="تاریخ تولد">
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سال تولدت به شمسی.</p>
<div className="mb-5">
<BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
</div>
@@ -323,11 +318,11 @@ function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void
}
/**
* خروج، و «ورود با حسابِ دیگر» که همان خروج است با متنِ دیگر. عمداً چند حسابِ واردشده روی
* یک گوشی نگه داشته نمی‌شود: گوشیِ خانوادگی دست‌به‌دست می‌شود و هر کس بی‌کد واردِ حسابِ
* دیگری می‌شد، و چند refresh tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر می‌کرد.
* خروج. «ورود با حسابِ دیگر» (۲۰۲۶-۱۰-۱۴) برداشته شد: همان خروج بود با متنِ دیگر. عمداً چند حسابِ واردشده روی یک
* گوشی نگه داشته نمی‌شود: گوشیِ خانوادگی دست‌به‌دست می‌شود و هر کس بی‌کد واردِ حسابِ دیگری می‌شد، و چند refresh
* tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر می‌کرد. خروج و ورودِ دوباره با پیامک همان کار را می‌کند.
*/
function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open: boolean; switching?: boolean; onClose: () => void; onSignedOut: () => void }) {
function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
const [busy, setBusy] = useState(false);
async function logout() {
@@ -342,22 +337,39 @@ function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open:
}
return (
<Sheet open={open} onClose={onClose} title={switching ? 'ورود با حسابِ دیگر' : 'از حسابت خارج بشی؟'}>
<p className="mb-5 text-sm leading-7 text-muted">
{switching && 'اول از این حساب خارج می‌شی، بعد با شماره‌ی دیگه وارد شو. '}
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
</p>
<p className="mb-5 -mt-3 text-xs text-muted">ممنون که با تدریسینو یاد می‌گیری 💛</p>
<Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟">
<p className="mb-5 text-sm leading-7 text-muted">هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سر جاشه.</p>
<div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، می‌مونم</SecondaryButton>
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>
{switching ? 'خروج و ورود با حسابِ دیگر' : 'خروج'}
</PrimaryButton>
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
</div>
</Sheet>
);
}
/** «حساب کاربری»: شماره و شناسه، و پایینش حذفِ حساب (جای استانداردش؛ نه دمِ دستِ صفحه‌ی اصلی). */
function AccountSheet({ open, onClose, onDelete }: { open: boolean; onClose: () => void; onDelete: () => void }) {
const { profile } = useAppData();
return (
<Sheet open={open} onClose={onClose} title="حساب کاربری">
<div className="mb-6 divide-y divide-line overflow-hidden rounded-2xl border border-line">
<div className="flex items-center justify-between px-4 py-3.5 text-sm">
<span className="font-bold text-body">شماره موبایل</span>
<span dir="ltr" className="font-bold text-ink">{profile.phone ? toFa(profile.phone) : '—'}</span>
</div>
<div className="flex items-center justify-between px-4 py-3.5 text-sm">
<span className="font-bold text-body">شناسه کاربری</span>
<span dir="ltr" className="font-bold tracking-wide text-ink">{profile.publicId}</span>
</div>
</div>
<p className="mb-2 text-xs leading-6 text-muted">با حذف حساب، همه پیشرفت و اطلاعاتت برای همیشه پاک می‌شه.</p>
<button type="button" onClick={onDelete} className="flex w-full items-center justify-center gap-2 rounded-2xl border border-red-200 py-3 text-sm font-bold text-red-500 active:bg-red-50">
<Trash2 size={16} /> حذف حساب کاربری
</button>
</Sheet>
);
}
/**
* حذفِ همیشگیِ حساب (POST /user/delete). شماره‌دار با کدِ تازه‌ی پیامکی تأیید می‌کند تا گوشیِ
* بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند.
@@ -398,28 +410,28 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
}
return (
<Sheet open={open} onClose={onClose} title="حذفِ همیشگیِ حساب">
<Sheet open={open} onClose={onClose} title="حذف همیشگی حساب">
<p className="mb-3 text-sm leading-7 text-body">
امتیاز، زنجیره، پیشرفتِ جزیره‌ها، گفتگوها با دستیار، سوال‌های خریده‌شده و قلبِ نامحدودت پاک می‌شن و برنمی‌گردن.
سوابقِ پرداخت فقط برای حسابداری می‌مونه.
امتیاز، زنجیره، پیشرفت جزیره‌ها، گفتگوها با دستیار و اشتراکت پاک می‌شن و برنمی‌گردن.
سوابق پرداخت فقط برای حسابداری می‌مونه. اگه اشتراکت رو از کافه‌بازار خریدی، تمدید خودکارش رو از بخش «اشتراک‌ها»ی بازار لغو کن.
</p>
{sent ? (
<input
value={value}
onChange={(e) => setValue(digitsOnly(e.target.value, 6))}
placeholder="کدِ پیامک‌شده"
placeholder="کد پیامک‌شده"
inputMode="numeric"
dir="ltr"
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-red-400"
/>
) : (
<p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شماره‌ی خودت پیامک می‌شه.</p>
<p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شماره خودت پیامک می‌شه.</p>
)}
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton>
{!sent ? (
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادنِ کد</PrimaryButton>
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادن کد</PrimaryButton>
) : (
<PrimaryButton
color="#ef4444"
@@ -427,7 +439,7 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
disabled={value.length < 6}
onClick={() => void remove()}
>
حذفِ همیشگی
حذف همیشگی
</PrimaryButton>
)}
</div>

View File

@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { Brain, Check, ChevronLeft, Coins, Compass, Flag, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react';
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
import { Brain, Check, ChevronLeft, Coins, Compass, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react';
import { api, ApiError, type AnswerResult, type Hearts, type Question } from '@/api';
import { useAppData } from '@/state/AppData';
import { fetchHome } from '@/state/subject';
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { useBackStep, useNav } from '@/nav/Navigator';
import { newIdempotencyKey } from '@/api/http';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarMood } from '@/mascots/seyar';
@@ -15,7 +16,12 @@ import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton,
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
import { Diagram } from '@/diagram';
import { FeedbackBar } from '@/components/FeedbackBar';
import { sounds } from '@/lib/sound';
import { useGateHold } from '@/lib/useAsync';
import { StreakCelebration, StreakFlame } from '@/components/StreakFlame';
import { useCompanion } from '@/state/companion';
type Phase =
| { kind: 'loading' }
@@ -46,7 +52,11 @@ type PlayQuestion = Question & { lessonId?: string };
* اصلیِ بازی همین است.
*/
export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) {
const { home, companion, refreshHome, refreshSummary } = useAppData();
const { home, companion, summary, refreshHome, refreshSummary } = useAppData();
const look = useCompanion();
// زنجیره پیش از این تمرین؛ اگر بعد از ثبت امروز روشن شد، صفحه‌ی «روزِ پیاپی» نشان داده می‌شود.
const streakBefore = useRef(summary?.streak ?? null);
const [streakUp, setStreakUp] = useState<{ from: number; to: number } | null>(null);
const nav = useNav();
const toast = useToast();
@@ -60,8 +70,13 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
const [submitting, setSubmitting] = useState(false);
const [correctCount, setCorrectCount] = useState(0);
const [confirmExit, setConfirmExit] = useState(false);
const [reporting, setReporting] = useState(false);
const [reload, setReload] = useState(0);
// پشتِ هم درست؛ برای «سه تا پشتِ هم!» (حسِ پیشرفت وسطِ آزمون، نه فقط آخرش).
const [combo, setCombo] = useState(0);
useGateHold(phase.kind === 'loading');
// دکمه‌ی برگشتِ گوشی وسطِ تمرین همان «X» است: اول می‌پرسد، بی‌صدا بیرون نمی‌اندازد. برگه‌ی باز خودش قدمِ
// برگشتِ جداگانه دارد، پس برگشتِ دوم فقط برگه را می‌بندد.
useBackStep(phase.kind === 'playing' && !confirmExit, () => setConfirmExit(true));
// یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمی‌شود.
const answerKeys = useRef(new Map<string, string>());
@@ -90,6 +105,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
setSelected(null);
setAnswer(null);
setCorrectCount(0);
setCombo(0);
answerKeys.current.clear();
completeKey.current = newIdempotencyKey();
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
@@ -98,7 +114,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
.catch((error: unknown) => {
if (!alive) return;
// فصلِ غیرِ اول بی‌اشتراک (۴۰۲)؛ صفحه‌ی خطا معنا ندارد، کاربر یک‌راست به اشتراک‌ها می‌رود.
if (error instanceof ApiError && error.code === 'subscription_required') nav.replace({ name: 'subscription', view: 'subscription' });
if (error instanceof ApiError && error.code === 'subscription_required') nav.replace({ name: 'subscription' });
else setPhase({ kind: 'error', error });
});
return () => {
@@ -109,6 +125,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
/** انتخاب فقط گزینه را آبی می‌کند؛ تا «بررسی» نزده، چیزی ثبت یا رو نمی‌شود و می‌شود عوضش کرد. */
function choose(optionId: string) {
if (!question || answer || submitting) return;
if (optionId !== selected) sounds.select();
setSelected(optionId);
}
@@ -127,8 +144,11 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h));
if (result.correct) {
setCorrectCount((c) => c + 1);
sounds.correct();
setCombo(combo + 1);
if (look.char === 'delta' && combo + 1 >= 2) sounds.combo();
sounds.correct(look.pitch);
} else {
setCombo(0);
sounds.wrong();
}
} catch (err) {
@@ -175,9 +195,23 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
: await api.lesson.complete(lessonId, payload, completeKey.current);
setPhase({ kind: 'result', result });
sounds.finish();
if (result.stars >= 2) void import('canvas-confetti').then(({ default: confetti }) => confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } }));
// کاغذرنگی به رنگِ همراه؛ امگا («جشن‌ساز») با هر نمره‌ای جشن می‌گیرد، بقیه از دو ستاره.
if (result.stars >= 2 || look.char === 'tedi') {
const colors = [look.color, look.light, '#FFD23F', '#ffffff'];
void import('canvas-confetti').then(({ default: confetti }) =>
confetti({ particleCount: look.char === 'tedi' ? 140 : 90, spread: look.char === 'tedi' ? 100 : 70, origin: { y: 0.35 }, colors }),
);
}
void refreshHome();
void refreshSummary();
const before = streakBefore.current;
if (before && !before.activeToday) {
api.summary()
.then((fresh) => {
if (fresh.streak.activeToday) setStreakUp({ from: before.current, to: fresh.streak.current });
})
.catch((err: unknown) => console.warn('streak check after finish failed', err));
}
} catch (error) {
// سرور پایانِ درس/جزیره را بی‌قلب و بی‌جواب نمی‌پذیرد (۱۴۰۵-۰۷)؛ همان صفحه‌ی انتظار.
if (error instanceof ApiError && error.code === 'no_hearts') {
@@ -192,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',
@@ -214,9 +267,9 @@ 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} />;
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') {
return (
<Screen>
@@ -226,7 +279,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
<p className="text-sm text-muted">دارم نتیجه‌ت رو ثبت می‌کنم…</p>
) : (
<>
<InlineError>نتیجه‌ی این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
<InlineError>نتیجه این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
<PrimaryButton color={color} onClick={() => void finish()}>دوباره ثبت کن</PrimaryButton>
<SecondaryButton onClick={nav.back}>بی‌خیال، برگرد</SecondaryButton>
</>
@@ -250,12 +303,12 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad';
const bubble = !answer
? selected === null ? 'جوابِ درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
? selected === null ? 'جواب درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
: answer.correct
? 'آفرین! درست بود'
? combo >= 2 ? `${toFa(combo)} تا پشت هم درست! داری عالی پیش می‌ری` : PRAISE[index % PRAISE.length]
: outOfHearts
? 'قلب‌هات تموم شد؛ یه کم استراحت کنیم.'
: 'اشکالی نداره، جوابِ درست رو ببین.';
: NUDGE[index % NUDGE.length];
const shortOptions = question.options.every((o) => o.value.length <= 14);
const optionClass = (id: string) => {
@@ -275,8 +328,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-soft">
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${((index + (answer ? 1 : 0)) / questions.length) * 100}%`, backgroundColor: color }} />
</div>
<span className="w-10 flex-shrink-0 text-left text-xs font-bold text-muted">
{toFa(index + 1)}/{toFa(questions.length)}
<TraitChip char={look.char} color={look.color} correct={correctCount} combo={combo} />
<span className="flex-shrink-0 text-left text-xs font-bold text-muted">
{/* «۱/۴» در اپِ ریاضی کسر خوانده می‌شد. */}
{toFa(index + 1)} از {toFa(questions.length)}
</span>
</div>
<HeartsRow hearts={hearts} />
@@ -293,10 +348,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
}`}>
{bubble}
{answer && <Reaction key={question.id} correct={answer.correct} seed={index} />}
{answer && <Reaction key={`r${question.id}`} correct={answer.correct} seed={index} />}
{answer?.correct && answer.xpEarned > 0 && (
<motion.span
key={question.id}
key={`xp${question.id}`}
initial={{ opacity: 0, y: 8, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 400, damping: 14 }}
@@ -310,12 +365,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
<div className="rounded-3xl border border-line bg-white p-5 shadow-sm">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted">سوالِ {toFa(index + 1)}</span>
<button type="button" onClick={() => setReporting(true)} className="flex items-center gap-1 text-[11px] font-bold text-faint">
<Flag size={12} /> گزارشِ ایراد
</button>
<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>
<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'}>
@@ -336,18 +389,29 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
))}
</div>
<FeedbackBar kind="question" id={question.id} />
</div>
{answer && !answer.correct && look.char === 'pi' && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="mx-4 mb-2 flex items-start gap-2 rounded-2xl bg-[#F5F1FF] px-3 py-2.5 text-xs font-bold leading-6 text-[#5B3FA0]"
>
<span className="text-base leading-5">π</span>
<span>{PI_TIPS[index % PI_TIPS.length]}</span>
</motion.div>
)}
<div className="flex flex-shrink-0 flex-col gap-2.5 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
{answer ? (
<>
{!answer.correct && (
<button type="button" onClick={askWhy} className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]">
<Brain size={16} /> از پی بپرس چرا غلط شد
<Brain size={16} /> از {look.name} بپرس چرا غلط شد
</button>
)}
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
{isLast ? 'پایانِ تمرین' : 'ادامه'} <ChevronLeft size={18} />
{isLast ? 'پایان تمرین' : 'ادامه'} <ChevronLeft size={18} />
</PrimaryButton>
</>
) : (
@@ -363,11 +427,10 @@ 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>
<ReportSheet open={reporting} questionId={question.id} onClose={() => setReporting(false)} />
</Screen>
);
}
@@ -376,12 +439,14 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
if (hearts?.isUnlimited) {
return (
<div className="mt-3 flex items-center justify-center gap-1.5 text-xs font-bold text-coral">
<Heart size={18} fill="#ED6956" /> <InfinityIcon size={18} /> قلبِ نامحدود
<Heart size={18} fill="#ED6956" /> <InfinityIcon size={18} /> قلب نامحدود
</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) => (
@@ -405,11 +470,15 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
}
function Result({
result, total, correct, lessonId, nodeId, color,
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string }) {
result, total, correct, lessonId, nodeId, color, streakUp,
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string; streakUp: { from: number; to: number } | null }) {
const nav = useNav();
const { companion } = useAppData();
const { companion, profile } = useAppData();
const [loadingNext, setLoadingNext] = useState(false);
// بعد از نتیجه، اگر زنجیره امروز روشن شد، یک صفحه‌ی جشنِ جدا (مثلِ دولینگو) و بعد مقصدِ انتخاب‌شده.
const [streakThen, setStreakThen] = useState<(() => void) | null>(null);
const then = (go: () => void) => (streakUp ? setStreakThen(() => go) : go());
if (streakThen && streakUp) return <StreakCelebration from={streakUp.from} to={streakUp.to} onDone={streakThen} />;
const nodeMode = lessonId === undefined;
const nextNode = result.nextNodeId ?? null;
@@ -423,7 +492,7 @@ function Result({
}
setLoadingNext(true);
try {
const fresh = await api.home();
const fresh = await fetchHome(profile.gradeId);
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId!);
const next = ref ? nextLessonOf(ref.node) : null;
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
@@ -450,9 +519,16 @@ function Result({
<div>
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی</p>}
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
<p className="mt-1 text-sm text-muted">{toFa(correct)} جواب درست از {toFa(total)} سوال</p>
{/* نمره‌ی کم: تلاش و راه را ستایش کن، نه «باهوشی» را (پژوهش‌های ذهنیتِ رشد)؛ و راهِ بعدی را نشان بده. */}
{result.stars <= 1 && (
<p className="mt-2 text-xs leading-6 text-body">
اشتباه‌ها همون جاهایی‌ان که مغزت داره یاد می‌گیره. هر وقت خواستی از «درس‌ها» دوباره مرورش کن.
</p>
)}
</div>
<div className="flex w-full gap-3">
{/* «+۰ امتیاز، +۰ سکه» بعد از یک دورِ بد فقط نمک روی زخم بود؛ پاداشِ صفر نشان داده نمی‌شود. */}
{(result.xpEarned > 0 || result.coinsEarned > 0) && <div className="flex w-full gap-3">
<div className="flex flex-1 flex-col items-center rounded-2xl border border-line bg-white py-3">
<Star size={18} className="text-gold" />
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.xpEarned)}</span>
@@ -463,12 +539,12 @@ function Result({
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.coinsEarned)}</span>
<span className="text-[10px] text-muted">سکه</span>
</div>
</div>
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
{nodeMode && nextNode ? 'بریم جزیره‌ی بعدی' : 'ادامه'} <ChevronLeft size={18} />
</div>}
<PrimaryButton color={color} loading={loadingNext} onClick={() => then(() => void goNext())}>
{nodeMode && nextNode ? 'بریم جزیره بعدی' : 'ادامه'} <ChevronLeft size={18} />
</PrimaryButton>
{nodeMode && nextNode && (
<button type="button" onClick={nav.back} className="-mt-2 py-2 text-sm font-bold text-muted">
<button type="button" onClick={() => then(nav.back)} className="-mt-2 py-2 text-sm font-bold text-muted">
فعلاً بسه، برگردیم
</button>
)}
@@ -477,66 +553,41 @@ function Result({
);
}
const REASONS: { id: ReportReason; label: string }[] = [
{ id: 'wrong_answer', label: 'جوابِ درست اشتباهه' },
{ id: 'unclear', label: 'صورتِ سوال گنگه' },
{ id: 'typo', label: 'غلطِ تایپی داره' },
{ id: 'too_hard', label: 'برای این درس خیلی سخته' },
{ id: 'other', label: 'یه چیزِ دیگه' },
/**
* پیامِ بعد از جواب. یک جمله‌ی ثابت بعد از چند بار دیگر خوانده نمی‌شود؛ چند تا به نوبت. برای جوابِ غلط
* تلاش و راه ستایش می‌شود و قدمِ بعدی گفته می‌شود، نه این‌که «اشکالی نداره» تنها بماند.
*/
const PRAISE = ['آفرین! درست بود', 'دقیقاً همینه!', 'عالی! خوب فکر کردی', 'درسته! داری راه میفتی'];
const NUDGE = ['نزدیک بودی! جواب درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد می‌گیریم.', 'این یکی سخت بود؛ جواب درست سبز شده.'];
/** «راهنمای مهربون»ِ پی: نکته‌ی عمومیِ کوتاه بعد از جوابِ غلط؛ نه جوابِ سوال، فقط عادتِ خوبِ حل کردن. */
const PI_TIPS = [
'صورت سوال رو یه بار دیگه آروم بخون؛ کلمه «نیست» یا «بیشترین» زیاد جا می‌مونه.',
'علامت منفی‌ها رو یه بار دیگه چک کن.',
'اول داخل پرانتز، بعد ضرب و تقسیم، آخرش جمع و تفریق.',
'جوابت رو بذار توی سوال؛ اگه درست درنیومد یه جا اشتباه شده.',
];
/** گزارشِ سوالِ ایراددار؛ مستقیم به صفِ بررسیِ محتوا می‌رود. */
function ReportSheet({ open, questionId, onClose }: { open: boolean; questionId: string; onClose: () => void }) {
const toast = useToast();
const [reason, setReason] = useState<ReportReason | null>(null);
const [note, setNote] = useState('');
const [sending, setSending] = useState(false);
useEffect(() => {
if (open) {
setReason(null);
setNote('');
}
}, [open]);
async function send() {
if (!reason) return;
setSending(true);
try {
const r = await api.lesson.report(questionId, reason, note.trim());
toast(r.alreadyReported ? 'قبلاً گزارشش رو داده بودی؛ ممنون!' : 'ممنون! گزارشت رسید و بررسی می‌شه.', 'success');
onClose();
} catch (err) {
toast(friendlyError(err, 'گزارش ارسال نشد'), 'error');
} finally {
setSending(false);
}
}
/**
* ویژگیِ همراه کنارِ نوارِ پیشرفتِ آزمون: سیگما جمعِ جوابای درست (Σ)، دلتا شعله‌ی جوابای پشتِ هم. پی و امگا
* ویژگی‌شان جای دیگری است (نکته بعد از غلط، جشنِ پایان)، پس این‌جا چیزی نشان نمی‌دهند.
*/
function TraitChip({ char, color, correct, combo }: { char: string; color: string; correct: number; combo: number }) {
if (char === 'sigma') {
return (
<Sheet open={open} onClose={onClose} title="این سوال چه ایرادی داره؟">
<div className="mb-4 flex flex-col gap-2">
{REASONS.map((r) => (
<button
key={r.id}
type="button"
onClick={() => setReason(r.id)}
className={`rounded-2xl border-2 px-4 py-3 text-right text-sm font-bold ${reason === r.id ? 'border-coral bg-[#fff4f2] text-coral' : 'border-line text-body'}`}
>
{r.label}
</button>
))}
</div>
<textarea
value={note}
onChange={(e) => setNote(e.target.value.slice(0, 500))}
placeholder="توضیحِ بیشتر (اختیاری)"
rows={2}
className="mb-4 w-full resize-none rounded-2xl border-2 border-line px-4 py-3 text-sm outline-none focus:border-coral"
/>
<PrimaryButton disabled={!reason} loading={sending} onClick={() => void send()}>ارسالِ گزارش</PrimaryButton>
</Sheet>
<motion.span key={correct} initial={{ scale: 1.4 }} animate={{ scale: 1 }} className="flex-shrink-0 rounded-full px-2 py-0.5 text-xs font-black text-white" style={{ backgroundColor: color }}>
Σ {toFa(correct)}
</motion.span>
);
}
if (char === 'delta' && combo >= 2) {
return (
<motion.span key={combo} initial={{ scale: 0.4, rotate: -12 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500, damping: 12 }} className="flex flex-shrink-0 items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-black text-white" style={{ backgroundColor: color }}>
<StreakFlame state="lit" size={11} className="[&_path]:fill-white" /> ×{toFa(combo)}
</motion.span>
);
}
return null;
}
/** آیکنِ کوچکِ بعد از جواب (همان خانواده‌ی آیکنِ اپ، نه ایموجیِ کیبورد)؛ هر سوال یکی از مجموعه، با یک تکانِ کوتاه. */

View File

@@ -7,6 +7,7 @@ import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarMood } from '@/mascots/seyar';
import { ErrorState, LoadingState, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { toFa } from '@/lib/format';
import { useCompanion } from '@/state/companion';
/*
* «گزارشِ والدین». تا ۱۴۰۵-۰۷ والد حسابِ جدا و کدِ دعوت داشت؛ به خواستِ سینیور
@@ -37,16 +38,16 @@ const percent = (part: number, whole: number) => (whole ? Math.round((part / who
export function ReportScreen() {
const { profile } = useAppData();
const report = useAsync(() => api.weeklyReport(), []);
const report = useAsync(() => api.weeklyReport(), [], { key: 'weekly-report' });
return (
<Screen>
<ScreenHeader
title="گزارشِ والدین"
subtitle={`پیشرفتِ ${profile.name || 'دانش‌آموز'} در ۷ روزِ اخیر`}
title="گزارش والدین"
subtitle={`پیشرفت ${profile.name || 'دانش‌آموز'} در ۷ روز اخیر`}
color={THIS_WEEK}
back
mascot={{ char: 'sigma', mood: 'happy', size: 56 }}
mascot={{ mood: 'happy', size: 56 }}
/>
<ScreenBody className="flex flex-col gap-4">
{report.loading ? (
@@ -68,43 +69,43 @@ function Report({ data, name }: { data: WeeklyReport; name: string }) {
<Verdict now={now} before={before} name={name} />
<div className="grid grid-cols-2 gap-2.5">
<Stat icon={Clock} label="زمانِ تمرین" value={`حدود ${toFa(now.minutes)} دقیقه`} delta={change(now.minutes, before.minutes, 'دقیقه')} />
<Stat icon={Clock} label="زمان تمرین" value={`حدود ${toFa(now.minutes)} دقیقه`} delta={change(now.minutes, before.minutes, 'دقیقه')} />
<Stat icon={CalendarCheck} label="روزهای فعال" value={`${toFa(now.activeDays)} از ۷ روز`} delta={change(now.activeDays, before.activeDays, 'روز')} />
<Stat icon={Target} label="سوالِ حل‌شده" value={toFa(now.questions)} delta={change(now.questions, before.questions, '')} />
<Stat icon={Target} label="سوال حل‌شده" value={toFa(now.questions)} delta={change(now.questions, before.questions, '')} />
<Stat
icon={CheckCircle2}
label="درصدِ جوابِ درست"
label="درصد جواب درست"
value={now.questions ? `${toFa(percent(now.correct, now.questions))}٪` : '—'}
delta={now.questions && before.questions ? change(percent(now.correct, now.questions), percent(before.correct, before.questions), '') : null}
/>
{/* آزمونِ جزیره و آزمونِ تک‌درس هر دو «تمام کردن» اند؛ جدا شمردنشان برای والد فرقی نمی‌کند. */}
<Stat icon={BookOpen} label="جزیره و درسِ تمام‌شده" value={toFa(now.islands + now.lessons)} delta={change(now.islands + now.lessons, before.islands + before.lessons, '')} />
<Stat icon={BookOpen} label="جزیره و درس تمام‌شده" value={toFa(now.islands + now.lessons)} delta={change(now.islands + now.lessons, before.islands + before.lessons, '')} />
<Stat icon={MessageCircle} label="سوال از دستیار" value={toFa(now.aiQuestions)} delta={change(now.aiQuestions, before.aiQuestions, '')} />
</div>
{data.streak.longest > 0 && (
<div className="flex items-center gap-2 rounded-2xl bg-[#FFF1E6] px-4 py-3 text-xs text-body">
<Flame size={16} className="text-orange" fill="#FF7A3D" />
زنجیره‌ی فعلی <b className="text-ink">{toFa(data.streak.current)}</b> روز؛ بیشترین <b className="text-ink">{toFa(data.streak.longest)}</b> روز
زنجیره فعلی <b className="text-ink">{toFa(data.streak.current)}</b> روز؛ بیشترین <b className="text-ink">{toFa(data.streak.longest)}</b> روز
</div>
)}
<BarChart
title="زمانِ تمرین در هر روز"
description="ستونِ آبی هر روزِ این هفته است و ستونِ نارنجیِ کنارش همان روز در هفته‌ی پیش؛ آبیِ بلندتر یعنی بیشتر از هفته‌ی پیش."
title="زمان تمرین در هر روز"
description="ستون آبی هر روز این هفته است و ستون نارنجی کنارش همان روز در هفته پیش؛ آبی بلندتر یعنی بیشتر از هفته پیش."
unit="دقیقه"
days={data.days}
mode="grouped"
series={[
{ label: 'این هفته', color: THIS_WEEK, values: data.days.map((d) => d.minutes) },
{ label: 'هفته‌ی پیش', color: LAST_WEEK, values: data.previousDays.map((d) => d.minutes) },
{ label: 'هفته پیش', color: LAST_WEEK, values: data.previousDays.map((d) => d.minutes) },
]}
note="زمان تقریبی است: از فاصله‌ی جواب‌ها و پرسش‌ها حساب می‌شود، پس خواندنِ درس بدونِ جواب دادن در آن نیست."
note="زمان تقریبی است: از فاصله جواب‌ها و پرسش‌ها حساب می‌شود، پس خواندن درس بدون جواب دادن در آن نیست."
/>
<BarChart
title="سوال‌های حل‌شده در هر روز"
description="کلِ ستون تعدادِ سوال‌هایی است که آن روز جواب داده؛ بخشِ سبز جواب‌های درست است و بخشِ طلایی نادرست."
description="کل ستون تعداد سوال‌هایی است که آن روز جواب داده؛ بخش سبز جواب‌های درست است و بخش طلایی نادرست."
unit="سوال"
days={data.days}
mode="stacked"
@@ -127,25 +128,26 @@ function verdict(now: ReportTotals, before: ReportTotals, name: string): { mood:
const diff = now.minutes - before.minutes;
const meaningful = Math.abs(diff) >= 3 && Math.abs(diff) >= before.minutes * 0.1;
if (!now.minutes && !before.minutes) {
return { mood: 'idle', text: 'این هفته هنوز تمرینی ثبت نشده. یه جزیره‌ی کوتاه شروعِ خوبیه!' };
return { mood: 'idle', text: 'این هفته هنوز تمرینی ثبت نشده. یه جزیره کوتاه شروع خوبیه!' };
}
if (!before.minutes) {
return { mood: 'excited', text: `آفرین! ${who}این هفته شروع کرده و ${toFa(now.activeDays)} روز تمرین کرده.` };
}
if (meaningful && diff > 0) {
return { mood: 'excited', text: `آفرین! ${who}این هفته حدودِ ${toFa(diff)} دقیقه بیشتر از هفته‌ی پیش تمرین کرده.` };
return { mood: 'excited', text: `آفرین! ${who}این هفته حدود ${toFa(diff)} دقیقه بیشتر از هفته پیش تمرین کرده.` };
}
if (meaningful && diff < 0) {
return { mood: 'idle', text: `این هفته حدودِ ${toFa(-diff)} دقیقه کمتر از هفته‌ی پیش تمرین شده. یه تشویقِ کوچیک کمک می‌کنه!` };
return { mood: 'idle', text: `این هفته حدود ${toFa(-diff)} دقیقه کمتر از هفته پیش تمرین شده. یه تشویق کوچیک کمک می‌کنه!` };
}
return { mood: 'happy', text: `${who}مثلِ هفته‌ی پیش منظم تمرین کرده؛ همین‌طور ادامه بده!` };
return { mood: 'happy', text: `${who}مثل هفته پیش منظم تمرین کرده؛ همین‌طور ادامه بده!` };
}
function Verdict({ now, before, name }: { now: ReportTotals; before: ReportTotals; name: string }) {
const { mood, text } = verdict(now, before, name);
const companion = useCompanion().char;
return (
<div className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4">
<SeyarMascot char="sigma" mood={mood} size={52} />
<SeyarMascot char={companion} mood={mood} size={52} />
<p className="flex-1 text-sm font-bold leading-7 text-ink">{text}</p>
</div>
);
@@ -160,8 +162,8 @@ function change(now: number, before: number, unit: string): Delta {
const u = unit ? ` ${unit}` : '';
// صفر در برابرِ صفر خبری ندارد؛ زیرِ همه‌ی کاشی‌های حسابِ تازه «مثلِ هفته‌ی پیش» می‌نشست.
if (!now && !before) return null;
if (diff === 0) return { text: 'مثلِ هفته‌ی پیش', up: null };
return { text: `${toFa(Math.abs(diff))}${u} ${diff > 0 ? 'بیشتر' : 'کمتر'} از هفته‌ی پیش`, up: diff > 0 };
if (diff === 0) return { text: 'مثل هفته پیش', up: null };
return { text: `${toFa(Math.abs(diff))}${u} ${diff > 0 ? 'بیشتر' : 'کمتر'} از هفته پیش`, up: diff > 0 };
}
function Stat({ icon: Icon, label, value, delta }: { icon: LucideIcon; label: string; value: string; delta: Delta }) {
@@ -324,7 +326,7 @@ function DayTable({ days, previous }: { days: ReportDay[]; previous: ReportDay[]
<tr>
<th className="py-1 text-right font-bold">روز</th>
<th className="font-bold">دقیقه</th>
<th className="font-bold">هفته‌ی پیش</th>
<th className="font-bold">هفته پیش</th>
<th className="font-bold">سوال</th>
<th className="font-bold">درست</th>
</tr>

View File

@@ -58,7 +58,7 @@ export function SetupScreen({
<div className="absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
<div className="relative flex items-center justify-between pt-8">
<div>
<div className="mb-1 text-xs font-bold text-white/75">فقط چندتا سؤالِ کوچیک</div>
<div className="mb-1 text-xs font-bold text-white/75">فقط چندتا سوال کوچیک</div>
<h1 className="text-2xl font-black">بیا با هم آشنا بشیم!</h1>
</div>
<div className="rounded-3xl bg-white/15 p-2">
@@ -84,14 +84,14 @@ export function SetupScreen({
<div>
<span className="mb-2 block text-sm font-black text-ink">
تاریخِ تولدت <span className="text-xs font-medium text-muted">(به شمسی، اختیاری)</span>
تاریخ تولدت <span className="text-xs font-medium text-muted">(به شمسی، اختیاری)</span>
</span>
<BirthDatePicker value={birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
</div>
<div>
<span className="mb-3 block text-sm font-black text-ink">
{adult ? 'ریاضیِ کدوم پایه رو می‌خوای یاد بگیری؟' : 'کدوم پایه‌ای؟'}
{adult ? 'ریاضی کدوم پایه رو می‌خوای یاد بگیری؟' : 'کدوم پایه‌ای؟'}
{adult && <span className="mt-1 block text-xs font-medium text-muted">هر وقت خواستی از پروفایل عوضش کن.</span>}
</span>
<div className="grid grid-cols-2 gap-3">
@@ -129,7 +129,7 @@ export function SetupScreen({
{error && <InlineError>{error}</InlineError>}
<PrimaryButton onClick={() => void submit()} loading={saving} className="mt-auto">
شروعِ یادگیری <ChevronLeft size={19} strokeWidth={3} />
شروع یادگیری <ChevronLeft size={19} strokeWidth={3} />
</PrimaryButton>
</div>
</div>

View File

@@ -1,41 +1,13 @@
import { motion } from 'motion/react';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { BrandMark } from '@/components/BrandMark';
/** نمادهای ریاضی که آرام از پایینِ صفحه بالا می‌روند؛ فقط CSS (transform/opacity)، بی‌بارِ اضافه. */
const GLYPHS = [
{ c: 'π', x: 12, d: 0 }, { c: '+', x: 28, d: 2.6 }, { c: '√', x: 46, d: 1.2 },
{ c: '÷', x: 64, d: 3.8 }, { c: '∑', x: 80, d: 0.6 }, { c: '×', x: 90, d: 4.6 },
];
/** تا وقتی نشستِ قبلی بررسی می‌شود نمایش داده می‌شود؛ عمداً تأخیرِ ساختگی ندارد. */
/**
* اسپلشِ ثابت: آیکونِ اپ و نام، بی‌انیمیشن و بی‌همراه (همراهِ انتخابی هنوز معلوم نیست و تکرارش
* بد است). عینِ `.boot-splash` در index.html است تا تحویل از HTML به React دیده نشود؛ استایلش
* هم آن‌جا است چون باید پیش از رسیدنِ CSS و JS کار کند.
*/
export function SplashScreen() {
return (
<div className="relative flex h-full flex-col items-center justify-center overflow-hidden bg-rose">
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2">
{GLYPHS.map((g) => (
<span key={g.c} className="rise absolute bottom-0 text-3xl font-black text-white/60" style={{ left: `${g.x}%`, animationDelay: `${g.d}s` }}>
{g.c}
</span>
))}
</div>
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
className="relative flex flex-col items-center gap-4"
>
<div className="rounded-[32px] bg-white/15 p-6 backdrop-blur-sm">
<SeyarMascot char="sigma" mood="happy" size={130} />
</div>
<BrandMark size={44} />
<p className="text-sm text-white/80">ریاضی، قدم‌به‌قدم، از روی کتابِ درسی</p>
</motion.div>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1.8, repeat: Infinity, ease: 'linear' }}
className="absolute bottom-20 h-6 w-6 rounded-full border-2 border-white/25 border-t-white"
/>
<div className="boot-splash" dir="rtl">
<img src="/app-icon.png" alt="" width={144} height={144} />
<span>تدریسینو ریاضی</span>
</div>
);
}

View File

@@ -3,6 +3,8 @@ import { api } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { ErrorState, LoadingState, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { toFa } from '@/lib/format';
import { useAppData } from '@/state/AppData';
import { StreakFlame, flameState } from '@/components/StreakFlame';
const WEEKS = 6;
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
@@ -29,35 +31,37 @@ function recentWeeks(today = new Date()): Date[][] {
const dayOfMonth = (d: Date) => new Intl.DateTimeFormat('fa-IR-u-ca-persian', { day: 'numeric' }).format(d);
export function StreakScreen() {
const calendar = useAsync(() => api.streakCalendar(), []);
const calendar = useAsync(() => api.streakCalendar(), [], { key: 'streak-calendar' });
const { summary } = useAppData();
const flame = flameState(summary?.streak);
const active = new Set(calendar.data?.activeDays ?? []);
const today = isoDay(new Date());
const weeks = recentWeeks();
return (
<Screen>
<ScreenHeader title="زنجیره‌ی روزها" subtitle="هر روز حداقل یه درس، زنجیره‌ت رو زنده نگه می‌داره" color="#FF7A3D" back mascot={{ char: 'delta', mood: 'excited', size: 56 }} />
<ScreenHeader title="زنجیره روزها" subtitle="هر روز حداقل یه درس، زنجیره‌ت رو زنده نگه می‌داره" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
{calendar.loading ? (
<LoadingState char="delta" />
<LoadingState />
) : calendar.error || !calendar.data ? (
<ErrorState error={calendar.error} onRetry={calendar.reload} />
) : (
<>
<div className="grid grid-cols-2 gap-3">
{[
{ label: 'زنجیره‌ی فعلی', value: calendar.data.currentStreak },
{ label: 'زنجیره فعلی', value: calendar.data.currentStreak },
{ label: 'بیشترین زنجیره', value: calendar.data.longestStreak },
].map((s) => (
].map((s, i) => (
<div key={s.label} className="flex flex-col items-center rounded-3xl border border-line bg-white py-4">
<Flame size={24} className="text-orange" fill="#FF7A3D" />
{i === 0 ? <StreakFlame state={flame} size={26} /> : <Flame size={24} className="text-orange" fill="#FF7A3D" />}
<span className="mt-1 text-2xl font-black text-ink">{toFa(s.value)}</span>
<span className="text-xs text-muted">{s.label} (روز)</span>
</div>
))}
</div>
<StreakGoal current={calendar.data.currentStreak} />
<StreakGoal current={calendar.data.currentStreak} doneToday={active.has(today)} lost={summary?.streak.lost ?? 0} />
<div className="rounded-3xl border border-line bg-white p-4">
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
@@ -96,14 +100,24 @@ export function StreakScreen() {
/** نقطه‌عطف‌های زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعی‌ای پشتش نیست (پاداش تصمیمِ محصولی است). */
const MILESTONES = [3, 7, 14, 30, 60, 100];
function StreakGoal({ current }: { current: number }) {
function StreakGoal({ current, doneToday, lost }: { current: number; doneToday: boolean; lost: number }) {
const next = MILESTONES.find((m) => m > current);
const reached = MILESTONES.filter((m) => m <= current).pop();
// زنجیره‌ی صفر: «۳ روز دیگه می‌شه زنجیره‌ی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته می‌شود.
// زنجیره‌ی روشن و امروزِ خالی: یادآوریِ این‌که امروز چیزی مانده تا زنجیره نسوزد.
const today = current === 0 && lost > 0
? `زنجیره ${toFa(lost)} روزه‌ت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.`
: current === 0
? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیره‌ت روشن بشه.'
: !doneToday
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیره ${toFa(current)} روزه‌ت نسوزه.`
: null;
return (
<div className="rounded-3xl border border-line bg-[#FFF3E8] px-4 py-3">
{today && <p className="mb-1 text-xs font-bold leading-6 text-[#B4501C]">{today}</p>}
<p className="text-sm font-black leading-7 text-ink">
{reached ? <><Flame size={16} fill="#FF7A3D" className="mb-0.5 ml-1 inline text-orange" />{toFa(current)} روزه‌ای! </> : null}
{next ? `${toFa(next - current)} روز دیگه می‌شه زنجیره‌ی ${toFa(next)} روزه.` : 'از همه‌ی نقطه‌عطف‌ها رد شدی، افسانه‌ای!'}
{next ? `${toFa(next - current)} روز دیگه می‌شه زنجیره ${toFa(next)} روزه.` : 'از همه نقطه‌عطف‌ها رد شدی، افسانه‌ای!'}
</p>
{next && (
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white">

View File

@@ -1,14 +1,18 @@
import { useState, type ReactNode } from 'react';
import { Check, ChevronLeft, ChevronRight, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
import { api, type Plan, type SubscriptionStatus, type TutorQuota } from '@/api';
import { useEffect, useMemo, useState } from 'react';
import { Check, ChevronDown, Crown, Lock, Ticket } from 'lucide-react';
import { api, type Plan, type SubscriptionStatus } from '@/api';
import { daysLeft } from '@/lib/subscription';
import { newIdempotencyKey } from '@/api/http';
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
import { BazaarPurchaseError, bazaarBuy, bazaarPending, bazaarPrices, canBuyFromBazaar, inAndroidApp } from '@/lib/bazaar';
import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet';
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { formatJalaliDate, formatToman, toFa } from '@/lib/format';
import { formatNumber, formatToman, toFa } from '@/lib/format';
import { useCompanion } from '@/state/companion';
import { enabledPaymentMethods, type PaymentMethod } from '@/lib/paymentMethods';
import zarinpalLogo from '@/assets/pay/zarinpal.png';
import bazaarLogo from '@/assets/pay/bazaar.png';
function durationLabel(days: number): string {
if (days === 30 || days === 31) return 'یک‌ماهه';
@@ -18,224 +22,216 @@ function durationLabel(days: number): string {
return days ? `${toFa(days)} روزه` : '';
}
type View = 'subscription' | 'pack';
/** «۷۹۸ هزار تومان» به‌جای «۷۹۸٬۰۰۰ تومان»؛ صفرهای پشتِ هم برای بچه خواندنِ قیمت را سخت می‌کرد. */
function price(value: number): string {
return value >= 1000 && value % 1000 === 0 ? `${formatNumber(value / 1000)} هزار تومان` : formatToman(value);
}
/**
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/شش‌ماهه؛ همه‌ی فصل‌ها، قلبِ نامحدود، سقفِ روزانه‌ی دستیار)
* و «بسته‌ی سوال» (سوالِ بی‌انقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
* یک‌جا کاربر را گیج نکند. از فصلِ قفل یک‌راست به فهرستِ اشتراک می‌آید (`initialView`).
* فروشگاه فقط اشتراک دارد (یک/سه/شش‌ماهه؛ تصمیمِ سینیور ۲۰۲۶-۱۰-۱۲: بسته‌ی سوال دیگر فروخته نمی‌شود). هر کارت
* خودش می‌گوید چه می‌گیری (خواستِ سینیور): مزیت‌های پلن (`features`، از ادمین) و سقفِ روزانه‌ی دستیار از `aiDailyQuota`،
* تا عدد با سقفِ واقعی جدا نیفتد. یک دکمه‌ی بزرگ پایینِ صفحه؛ روشِ پرداخت (زرین‌پال یا کافه‌بازار) در برگه‌ی پرداخت.
*/
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
const plans = useAsync(() => api.subscription.plans(), []);
const status = useAsync(() => api.subscription.status(), []);
const quota = useAsync(() => api.ai.quota(), []);
const [view, setView] = useState<View | null>(initialView ?? null);
export function SubscriptionScreen() {
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
const [picked, setPicked] = useState<string | null>(null);
const [buying, setBuying] = useState<Plan | null>(null);
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درون‌برنامه‌ایِ خودش است؛ محصولی که
// SKU ندارد آن‌جا خریدنی نیست (به زرین‌پال فرستاده نمی‌شود).
const bazaar = inBazaarApp();
// داخلِ اپی که از بازار می‌خرد: قیمتِ پیشخانِ هر SKU، تا کاشیِ بازار همان را بگوید که بازار می‌گیرد.
const [storePrices, setStorePrices] = useState<Record<string, string>>({});
const look = useCompanion();
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
const reload = () => { status.reload(); quota.reload(); };
const list = plans.data?.plans.filter((p) => p.kind === view) ?? [];
const monthly = plans.data?.plans.find((p) => p.kind === 'subscription' && p.durationDays <= 31);
// پرداخت با درگاه داخلِ اپ در مرورگرِ گوشی باز می‌شود؛ برگشت به اپ یعنی وضعیت را دوباره بپرس.
useEffect(() => {
const onVisible = () => document.visibilityState === 'visible' && status.reload();
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [status.reload]);
const list = plans.data?.plans ?? [];
useEffect(() => {
const skus = list.filter((p) => p.bazaarSku).map((p) => p.bazaarSku);
if (!canBuyFromBazaar() || !skus.length) return;
let alive = true;
void bazaarPrices(skus).then((prices) => alive && setStorePrices(prices));
return () => { alive = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plans.data]);
const monthly = list.find((p) => p.durationDays <= 31);
const selected = list.find((p) => p.id === picked) ?? list.find((p) => p.popular) ?? list[0];
const active = status.data?.active ?? false;
return (
<Screen>
<div className="flex-1 overflow-y-auto">
<ScreenHeader
title={view === 'pack' ? 'بسته‌ی سوال' : view === 'subscription' ? 'اشتراکِ تدریسینو' : 'فروشگاه'}
subtitle={view === 'pack' ? 'سوالِ بیشتر از دستیار، بدونِ انقضا' : 'همه‌ی فصل‌ها، قلبِ نامحدود'}
title={active ? 'تمدید اشتراک' : 'اشتراک تدریسینو ریاضی'}
subtitle="همه فصل‌ها، قلب نامحدود"
color="#ED6956"
exact
mascot={{ char: 'pi', mood: 'excited', size: 60 }}
back
mascot={{ mood: 'excited', size: 60 }}
/>
<div className="flex flex-col gap-4 px-4 py-5">
<StatusCard status={status.data ?? null} credits={credits} heartsUntil={heartsUntil} quota={quota.data ?? null} />
{view === null ? (
<div className="flex flex-col gap-3">
<ChoiceCard
icon={<Crown size={24} />}
tone="#FF7A3D"
title="اشتراکِ ماهانه"
text="همه‌ی فصل‌ها و جزیره‌ها باز، با قلبِ نامحدود"
hint={monthly ? `از ${formatToman(monthly.price)}` : undefined}
onClick={() => setView('subscription')}
/>
<ChoiceCard
icon={<Sparkles size={24} />}
tone="#9B7BFF"
title="بسته‌ی سوال"
text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچ‌وقت منقضی نمی‌شه"
onClick={() => setView('pack')}
/>
</div>
) : (
<>
{!initialView && (
<button type="button" onClick={() => setView(null)} className="flex items-center gap-1 self-start text-xs font-bold text-coral">
<ChevronRight size={14} /> برگشت به انتخاب
</button>
)}
{plans.loading ? (
<StatusCard status={status.data ?? null} loading={status.loading && !status.data} />
{plans.loading && !plans.data ? (
<LoadingState text="دارم فهرست رو میارم…" />
) : plans.error ? (
) : plans.error && !plans.data ? (
<ErrorState error={plans.error} onRetry={plans.reload} />
) : !list.length ? (
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
) : (
list.map((plan) => (
<PackCard
key={plan.id}
plan={plan}
monthlyPrice={monthly?.price}
// اشتراک در بازار هنوز SKU و تمدیدِ آزموده‌شده ندارد؛ آن‌جا فقط بسته خریدنی است.
available={!bazaar || (plan.kind === 'pack' && !!plan.bazaarSku)}
onBuy={() => setBuying(plan)}
/>
))
<div role="radiogroup" aria-label="مدت اشتراک" className="flex flex-col gap-3">
{list.map((plan) => (
<PlanRow key={plan.id} plan={plan} assistant={look.name} monthlyPrice={monthly?.price} selected={plan.id === selected?.id} onSelect={() => setPicked(plan.id)} />
))}
</div>
)}
{view === 'pack' && (
<p className="text-center text-xs leading-6 text-muted">
هر روز {toFa(status.data?.aiDailyQuota ?? plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول از سقفِ روزانه‌ته؛ بعدش از اعتبارِ بسته کم می‌شه.
</p>
)}
</>
)}
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
{bazaar ? 'پرداخت از طریقِ کافه‌بازار انجام می‌شه.' : 'پرداخت از طریقِ درگاهِ امنِ زرین‌پال انجام می‌شه.'}
</p>
</div>
</div>
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); reload(); }} />
{selected && (
<div className="flex-shrink-0 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
<PrimaryButton color="#FF7A3D" onClick={() => setBuying(selected)}>
{`خرید اشتراک ${durationLabel(selected.durationDays) || selected.title}`}
</PrimaryButton>
</div>
)}
<CheckoutSheet plan={buying} storePrices={storePrices} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
</Screen>
);
}
function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) {
/**
* وضعیتِ کاربر بالای فروشگاه: مشترک یک کارتِ واضح با روزهای مانده می‌بیند؛ بی‌اشتراک هیچ کارتی (کارت‌های
* اشتراکِ زیر خودشان حرف را می‌زنند). تا وضعیت نرسیده جای خالیِ هم‌اندازه می‌آید، نه «نداری» که برای مشترک غلط بود.
*/
function StatusCard({ status, loading }: { status: SubscriptionStatus | null; loading: boolean }) {
if (loading) return <div className="h-[104px] animate-pulse rounded-3xl bg-soft" />;
if (!status?.active) return null;
const days = daysLeft(status.expiresAt);
const total = status.planDurationDays ?? 30;
const percent = days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)));
return (
<button type="button" onClick={onClick} className="flex items-center gap-3 rounded-3xl border-2 border-line bg-white p-4 text-right active:scale-[0.99]">
<span className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl text-white" style={{ backgroundColor: tone }}>{icon}</span>
<span className="min-w-0 flex-1">
<span className="block text-base font-black text-ink">{title}</span>
<span className="mt-0.5 block text-xs leading-6 text-muted">{text}</span>
{hint && <span className="mt-1 block text-xs font-black" style={{ color: tone }}>{hint}</span>}
</span>
<ChevronLeft size={18} className="flex-shrink-0 text-faint" />
</button>
);
}
/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */
function StatusCard({ status, credits, heartsUntil, quota }: {
status: SubscriptionStatus | null; credits: number; heartsUntil: string | null; quota: TutorQuota | null;
}) {
const days = status?.active ? daysLeft(status.expiresAt) : null;
return (
<div className="rounded-3xl border border-line bg-white p-4">
<div className="rounded-3xl bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] p-4 text-white shadow-lg shadow-orange/25">
<div className="flex items-center gap-3">
<span className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl ${status?.active ? 'bg-[#FFF4D6] text-[#E0A200]' : 'bg-soft text-muted'}`}><Crown size={22} /></span>
<div className="flex-1">
<div className="text-xs font-bold text-muted">اشتراکِ تو</div>
<div className="text-base font-black text-ink">
{status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</span>}</> : 'نداری'}
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-white/20"><Crown size={24} /></span>
<div className="min-w-0 flex-1">
<div className="text-base font-black">اشتراکت فعاله</div>
<div className="text-sm text-white/85">{status.planTitle ?? 'اشتراک'}</div>
</div>
</div>
{quota && (
<div className="text-left text-xs leading-6 text-muted">
سوالِ امروز<br />
<b className="text-base text-ink">{toFa(quota.remainingQuota)}</b> از {toFa(status?.aiDailyQuota ?? quota.freeDaily)}
{days !== null && (
<div className="text-center">
<div className="text-3xl font-black leading-none">{toFa(days)}</div>
<div className="mt-1 text-xs text-white/85">روز مونده</div>
</div>
)}
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-xs font-bold">
<span className="flex items-center gap-1.5 text-violet"><Sparkles size={13} /> اعتبار: {toFa(credits)} سوال</span>
{(status?.active || heartsUntil) && (
<span className="flex items-center gap-1.5 text-coral">
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}</>}
</span>
)}
{status?.active && status.expiresAt && <span className="text-muted">تا {formatJalaliDate(status.expiresAt)}</span>}
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-black/15">
<div className="h-full rounded-full bg-white" style={{ width: `${percent}%` }} />
</div>
</div>
);
}
function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthlyPrice?: number; available: boolean; onBuy: () => void }) {
/** یک کارتِ انتخابی: نام و قیمت درشت، و زیرش آنچه این اشتراک می‌دهد (خواستِ سینیور: روی هر کارت). */
function PlanRow({ plan, assistant, monthlyPrice, selected, onSelect }: { plan: Plan; assistant: string; monthlyPrice?: number; selected: boolean; onSelect: () => void }) {
const months = Math.round(plan.durationDays / 30);
const saving = plan.kind === 'subscription' && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
const featured = plan.popular;
const isPack = plan.kind === 'pack';
const tone = featured ? 'text-white/80' : 'text-muted';
const saving = monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
const tone = '#FF7A3D';
const perks = [...plan.features, `${toFa(plan.aiDailyQuota)} سوال در روز از ${assistant}`];
return (
<div className="relative pt-3">
<button
type="button"
role="radio"
aria-checked={selected}
onClick={onSelect}
className="relative flex flex-col gap-3 rounded-3xl border-2 border-b-4 bg-white px-4 py-4 text-right transition-colors active:translate-y-0.5"
style={{ borderColor: selected ? tone : '#e9e6f7', backgroundColor: selected ? `${tone}0F` : '#fff' }}
>
{plan.badge && (
<span className="absolute right-5 top-0 z-10 rounded-full bg-[#FFD23F] px-3 py-1 text-[11px] font-black text-ink shadow">{plan.badge}</span>
<span className="absolute -top-3 left-4 rounded-full px-3 py-0.5 text-xs font-black text-white shadow-sm" style={{ backgroundColor: tone }}>
{plan.badge}
</span>
)}
<div className={`rounded-3xl p-5 ${featured ? 'bg-orange text-white shadow-lg shadow-orange/30' : 'border border-line bg-white text-ink'}`}>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h3 className="text-2xl font-black">{isPack ? <>{toFa(plan.questionCredits)} <span className="text-base">سوال</span></> : plan.title}</h3>
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ هوشمند، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
</div>
<div className="text-left">
<p className="text-base font-black">{formatToman(plan.price)}</p>
{months > 1 && !isPack && <p className={`text-[11px] ${tone}`}>ماهی {formatToman(Math.round(plan.price / months / 1000) * 1000)}</p>}
{saving > 0 && <p className={`mt-0.5 text-[11px] font-black ${featured ? 'text-white' : 'text-mint'}`}>{toFa(saving)}٪ ارزان‌تر</p>}
</div>
</div>
<ul className="mb-4 flex flex-col gap-2 text-sm">
{isPack && plan.heartsDays > 0 && (
<li className="flex items-center gap-2 font-bold">
<Heart size={14} fill={featured ? '#fff' : '#ED6956'} className={featured ? 'text-white' : 'text-coral'} />
هدیه: {toFa(plan.heartsDays)} روز قلبِ نامحدود
</li>
)}
{!isPack && plan.aiDailyQuota > 0 && (
<li className="flex items-center gap-2 font-bold"><Sparkles size={14} /> {toFa(plan.aiDailyQuota)} سوال در روز از دستیار</li>
)}
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2">
<Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
<span className={featured ? 'text-white/90' : 'text-body'}>{f}</span>
<span className="flex items-center gap-3">
<span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2"
style={{ borderColor: selected ? tone : '#d6d3ea', backgroundColor: selected ? tone : '#fff' }}
>
{selected && <Check size={14} strokeWidth={3.5} className="text-white" />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-lg font-black text-ink">{durationLabel(plan.durationDays) || plan.title}</span>
{saving > 0 && <span className="mt-0.5 block text-sm font-bold text-[#2E9E6A]">{toFa(saving)}٪ ارزان‌تر از ماهانه</span>}
</span>
<span className="text-left text-base font-black text-ink">{price(plan.price)}</span>
</span>
<ul className="flex flex-col gap-1.5 border-t border-line pt-3">
{perks.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm font-bold text-body">
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-orange text-white"><Check size={12} strokeWidth={3.5} /></span>
{f}
</li>
))}
</ul>
<button
type="button"
onClick={onBuy}
disabled={!available}
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-50 ${featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'}`}
>
{available ? 'خرید' : 'به‌زودی در بازار'}
</button>
</div>
</div>
);
}
/** چرا بازار برای این پلن و این‌جا ممکن نیست؛ null یعنی ممکن است. */
function bazaarBlocker(plan: Plan | null): string | null {
if (!plan?.bazaarSku) return 'این مورد در بازار نیست';
if (!inAndroidApp()) return 'فقط در اپ اندروید';
if (!canBuyFromBazaar()) return 'اپ رو از بازار به‌روز کن';
return null;
}
/**
* خرید: سفارش ساخته می‌شود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت می‌شود،
* و بعد کاربر در همین برگه به درگاه می‌رود. window.open بعد از await را
* Safariِ آیفون مسدود می‌کند؛ برای همین جابه‌جاییِ همین صفحه است.
* خرید: سفارش ساخته می‌شود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت می‌شود، و بعد کاربر به درگاه یا به
* صفحه‌ی خریدِ بازار می‌رود. window.open بعد از await را Safariِ آیفون مسدود می‌کند؛ برای همین درگاه
* جابه‌جاییِ همین صفحه است.
*
* روش‌های پرداخت به شکلِ آشنای برگه‌ی پرداختِ اپ‌ها می‌آیند: هر روش یک ردیف با نشانِ خودِ درگاه، نام و یک
* دایره‌ی انتخاب، و دکمه‌ی پایین مبلغ را می‌گوید (خواستِ مالک، ۲۰۲۶-۱۰-۱۴: «حسِ اطمینان»). کدام روش‌ها در مرورگر و
* کدام داخلِ اپ دیده شوند از config.js می‌آید (src/lib/paymentMethods.ts)، تا نسخه‌ی کافه‌بازار فقط بازار را نشان دهد
* و سایت فقط زرین‌پال را، بی build. روشی که فهرست شده ولی این‌جا ممکن نیست (APKِ قدیمی) غیرفعال با دلیل می‌آید.
* ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ درون‌برنامه‌ای را محدود می‌کند؛ زرین‌پال داخلِ اپ
* تصمیمِ مالک است و با بازار هماهنگ نشده.
*/
function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) {
function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | null; storePrices: Record<string, string>; onClose: () => void; onPaid: () => void }) {
const blocker = bazaarBlocker(plan);
const methods = useMemo(enabledPaymentMethods, []);
const firstUsable = methods.find((m) => m !== 'bazaar' || !blocker) ?? methods[0];
const [method, setMethod] = useState<PaymentMethod>(firstUsable);
const [code, setCode] = useState('');
const [showCode, setShowCode] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState<'code' | 'bazaar' | null>(null);
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
// هر بار که برگه برای پلنی باز می‌شود، اولین روشِ ممکن انتخاب است (ترتیب از config.js؛ پیش‌فرض زرین‌پال اول).
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;
/**
* داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید.
* کدِ تخفیف این‌جا نیست: بازار قیمتِ ثابتِ محصول را می‌گیرد و سرور تخفیف را رد می‌کند.
* بازار: سفارش (با کلیدِ ثابتِ همین SKU) → کدِ تخفیف → شروع (SKU، نوع، قیمتِ امضاشده) → خرید در بازار
* → سنجشِ سرور. اشتراکِ بازار مصرف (consume) نمی‌شود؛ بازار خودش آخرِ هر دوره تمدیدش می‌کند.
*/
async function payWithBazaar(plan: Plan) {
async function payWithBazaar(plan: Plan): Promise<'bazaar' | 'code' | null> {
const sku = plan.bazaarSku;
let key = bazaarPending.get(sku);
if (key) {
@@ -250,30 +246,49 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
bazaarPending.set(sku, key);
}
const order = await api.subscription.purchaseBazaar(plan.id, key);
if (code.trim()) {
const current = await api.subscription.order(order.orderId);
// سفارشِ مانده از تلاشِ قبلی ممکن است همین کد را از قبل گرفته باشد.
if (current.amount === plan.price) {
const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
if (redeemed.orderStatus === 'paid') {
bazaarPending.clear(sku);
return 'code';
}
}
}
const start = await api.subscription.startBazaar(order.orderId);
let token: string;
try {
token = await bazaarPurchase(order.bazaarSku || sku, order.orderId);
token = await bazaarBuy(start.sku, order.orderId, start.dynamicPriceToken);
} catch (err) {
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return false;
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return null;
throw err;
}
const result = await api.subscription.verifyBazaar(order.orderId, token);
if (result.status !== 'paid') throw new Error('bazaar purchase not confirmed');
bazaarPending.clear(sku);
bazaarConsume(token);
return true;
return 'bazaar';
}
async function pay() {
if (!plan || busy) return;
setBusy(true);
setError('');
if (bazaar) {
if (method === 'bazaar') {
try {
if (await payWithBazaar(plan)) setDone('bazaar');
const outcome = await payWithBazaar(plan);
if (outcome) setDone(outcome);
} catch (err) {
if (err instanceof BazaarPurchaseError && err.kind === 'unavailable') {
// کارِ کاربر است، نه خطای خرید: پولی جابه‌جا نشده.
setError(err.message === 'old_bazaar'
? 'کافه‌بازار گوشیت قدیمیه؛ از خود بازار به‌روزش کن و دوباره بزن، یا با کارت بانکی پرداخت کن.'
: 'کافه‌بازار روی گوشیت پیدا نشد یا وصل نشد. مطمئن شو نصبه و وارد حسابت شدی، بعد دوباره بزن؛ یا با کارت بانکی پرداخت کن.');
} else {
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
}
}
setBusy(false);
return;
}
@@ -290,7 +305,18 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
if (!order.paymentUrl) throw new Error('order without paymentUrl');
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
const shell = (window as unknown as { TadrisinoAndroid?: { paysInApp?: () => boolean } }).TadrisinoAndroid;
if (inAndroidApp() && !shell?.paysInApp?.()) {
// اپِ اندرویدِ قدیمی هر آدرسِ بیرونی را در مرورگرِ گوشی باز می‌کند و آن مرورگر Referer نمی‌فرستد؛ زرین‌پال
// بی‌Referer می‌گوید «دسترسی از این دامنه مجاز نمی‌باشد». پس اول pay.htmlِ خودمان در مرورگر باز می‌شود
// (src/pay.ts) و آن به درگاه می‌رود. «http://» عمداً است: WebView فقط https‌ِ همین دامنه را در خودش نگه
// می‌دارد؛ آروان بلافاصله به https می‌برد و لینک در fragment است، پس به سرور نمی‌رسد.
window.location.assign(`http://${window.location.host}/pay.html#u=${encodeURIComponent(order.paymentUrl)}`);
setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500);
} else {
// مرورگر، یا اپی که پرداخت را در خودش باز می‌کند (paysInApp): همین صفحه به درگاه می‌رود و Referer دارد.
window.location.assign(order.paymentUrl);
}
} catch (err) {
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
setBusy(false);
@@ -306,24 +332,43 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
setDone(null);
};
const doneText =
done === 'gateway' ? 'صفحه پرداخت در مرورگر گوشی باز شد. بعد از پرداخت برگرد همین‌جا؛ خریدت خودش فعال می‌شه.'
: done === 'code' ? 'کد تخفیفت کل مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
: 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخر هر دوره خودش تمدیدش می‌کنه؛ لغو تمدید از بخش «اشتراک‌ها»ی خود بازاره.';
return (
<Sheet open={plan !== null} onClose={close} title={done ? 'خریدت انجام شد! 🎉' : plan?.title}>
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? 'پرداخت' : ''}>
{done ? (
<>
<p className="mb-5 text-sm leading-7 text-muted">
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
</p>
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
<p className="mb-5 text-sm leading-7 text-muted">{doneText}</p>
<PrimaryButton onClick={close}>{done === 'gateway' ? 'باشه' : 'عالیه!'}</PrimaryButton>
</>
) : plan && (
<>
<div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3">
<span className="text-sm text-body">مبلغ</span>
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
<div className="mb-5 flex items-center gap-3 rounded-2xl bg-soft px-4 py-3.5">
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-[#FFF4D6] text-[#E0A200]"><Crown size={20} /></span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-black text-ink">اشتراک {durationLabel(plan.durationDays) || plan.title}</span>
<span className="block text-xs text-muted">همه فصل‌ها، قلب نامحدود</span>
</span>
<span className="text-left text-base font-black text-ink">{method === 'bazaar' && storePrice ? storePrice : price(plan.price)}</span>
</div>
{bazaar ? null : showCode ? (
<div role="radiogroup" aria-label="روش پرداخت" className="mb-4">
<span className="mb-2 block text-sm font-black text-ink">روش پرداخت</span>
<div className="flex flex-col gap-2">
{methods.map((m) =>
m === 'zarinpal' ? (
<MethodRow key={m} selected={method === m} onSelect={() => setMethod(m)} logo={zarinpalLogo} title="زرین‌پال" note="کارت بانکی، همه بانک‌های عضو شتاب" />
) : (
<MethodRow key={m} selected={method === m} onSelect={() => setMethod(m)} logo={bazaarLogo} title="کافه‌بازار" note={blocker ?? (storePrice ? `${storePrice} · با حساب بازارت` : 'با حساب بازارت')} disabled={!!blocker} />
),
)}
</div>
</div>
{showCode ? (
<label className="mb-4 block">
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
<span className="mb-2 block text-sm font-bold text-body">کد تخفیف</span>
<input
value={code}
onChange={(e) => setCode(e.target.value.trim().slice(0, 40))}
@@ -332,17 +377,56 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
placeholder="CODE"
className="w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-coral"
/>
<span className="mt-1.5 block text-[11px] text-muted">مبلغِ نهایی بعد از اعمالِ کد در درگاه نشون داده می‌شه.</span>
</label>
) : (
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex items-center gap-1.5 text-sm font-bold text-coral">
<Ticket size={15} /> کدِ تخفیف دارم
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex w-full items-center gap-2 rounded-2xl border border-dashed border-line px-4 py-3 text-sm font-bold text-body active:bg-soft">
<Ticket size={16} className="text-coral" /> <span className="flex-1 text-right">کد تخفیف دارم</span> <ChevronDown size={16} className="text-faint" />
</button>
)}
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
<PrimaryButton loading={busy} onClick={() => void pay()}>پرداخت</PrimaryButton>
<PrimaryButton loading={busy} disabled={method === 'bazaar' && !!blocker} onClick={() => void pay()}>
{`پرداخت ${method === 'bazaar' && storePrice ? storePrice : price(plan.price)}`}
</PrimaryButton>
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
<Lock size={13} />
{method === 'bazaar'
? 'پرداخت امن در خود کافه‌بازار؛ آخر هر دوره خودکار تمدید می‌شه.'
: 'پرداخت امن در درگاه زرین‌پال'}
</p>
</>
)}
</Sheet>
);
}
/** یک روشِ پرداخت به شکلِ ردیفِ آشنای برگه‌های پرداخت: نشانِ درگاه، نام و توضیح، و دایره‌ی انتخاب. */
function MethodRow({ selected, onSelect, logo, title, note, disabled = false }: { selected: boolean; onSelect: () => void; logo: string; title: string; note: string; disabled?: boolean }) {
const on = selected && !disabled;
return (
<button
type="button"
role="radio"
aria-checked={on}
aria-disabled={disabled}
disabled={disabled}
onClick={onSelect}
className={`flex w-full items-center gap-3 rounded-2xl border-2 px-3 py-3 text-right transition-colors ${
disabled ? 'cursor-not-allowed border-line bg-soft opacity-60' : on ? 'border-coral bg-[#fff6f4]' : 'border-line bg-white active:bg-soft'
}`}
>
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl border border-line bg-white">
<img src={logo} alt="" width={30} height={30} className={disabled ? 'grayscale' : ''} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[15px] font-black text-ink">{title}</span>
<span className="block truncate text-xs text-muted">{note}</span>
</span>
<span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2"
style={{ borderColor: on ? '#ED6956' : '#d6d3ea', backgroundColor: on ? '#ED6956' : '#fff' }}
>
{on && <Check size={14} strokeWidth={3.5} className="text-white" />}
</span>
</button>
);
}

View File

@@ -12,12 +12,12 @@ const CATEGORIES = [
{ id: 'learning', label: 'درس و سوال‌ها' },
{ id: 'payment', label: 'پرداخت و اشتراک' },
{ id: 'account', label: 'حساب و ورود' },
{ id: 'other', label: 'چیزِ دیگه' },
{ id: 'other', label: 'چیز دیگه' },
];
const STATUS_LABEL: Record<string, string> = {
open: 'باز',
in_progress: 'در حالِ بررسی',
in_progress: 'در حال بررسی',
answered: 'پاسخ داده شد',
closed: 'بسته',
};
@@ -31,7 +31,7 @@ export function SupportScreen() {
const [sending, setSending] = useState(false);
const [error, setError] = useState('');
// فهرست از سرور می‌آید، پس تیکت و پاسخش با عوض شدنِ گوشی هم دیده می‌شود.
const tickets = useAsync(() => api.support.tickets(), []);
const tickets = useAsync(() => api.support.tickets(), [], { key: 'support-tickets' });
async function submit() {
if (!subject.trim() || !text.trim()) return;
@@ -58,10 +58,10 @@ export function SupportScreen() {
return (
<Screen>
<ScreenHeader title="پشتیبانی" subtitle="سوال، مشکل یا پیشنهاد داری؟ بنویس." color="#4C5FD5" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} />
<ScreenHeader title="پشتیبانی" subtitle="سوال، مشکل یا پیشنهاد داری؟ بنویس." color="#4C5FD5" back mascot={{ mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
<div className="flex gap-1 rounded-2xl bg-soft p-1">
{([['ticket', 'پیام به پشتیبانی', MessageCircle], ['bug', 'گزارشِ خطای اپ', Bug]] as const).map(([id, label, Icon]) => (
{([['ticket', 'پیام به پشتیبانی', MessageCircle], ['bug', 'گزارش خطای اپ', Bug]] as const).map(([id, label, Icon]) => (
<button
key={id}
type="button"
@@ -108,7 +108,7 @@ export function SupportScreen() {
{!!tickets.data?.length && (
<section>
<h2 className="mb-2 mt-2 text-sm font-black text-ink">پیام‌های قبلیِ تو</h2>
<h2 className="mb-2 mt-2 text-sm font-black text-ink">پیام‌های قبلی تو</h2>
<div className="flex flex-col gap-2">
{tickets.data.map((t) => <TicketCard key={t.ticketId} summary={t} />)}
</div>

View File

@@ -14,7 +14,7 @@ import { MathText } from '@/lib/MathText';
export function UnitScreen({ unitId }: { unitId: string }) {
const { home, homeError, refreshHome, companion } = useAppData();
const nav = useNav();
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
const paywall = () => nav.push({ name: 'subscription' });
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
const found = findUnit(home, unitId);
@@ -43,7 +43,7 @@ export function UnitScreen({ unitId }: { unitId: string }) {
>
<div className="mt-4">
<div className="mb-1.5 flex justify-between text-[10px] text-white/75">
<span>پیشرفتِ تو</span>
<span>پیشرفت تو</span>
<span>{toFa(progress.percent)}٪</span>
</div>
<ProgressBar percent={progress.percent} color="#fff" track="rgba(0,0,0,.1)" height={10} />

View File

@@ -1,6 +1,9 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { api, type Character, type Grade, type HomeResponse, type UserProfile, type UserSummary } from '@/api';
import { seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
import { companionStore, useCompanion } from '@/state/companion';
import { homeSnapshot } from '@/state/homeSnapshot';
import { fetchHome, subjectStore } from '@/state/subject';
/**
* داده‌هایی که چند صفحه با هم لازم دارند: پروفایل، آمار، نقشه، همراهِ انتخابی
@@ -17,7 +20,12 @@ interface AppData {
/** همراهی که کاربر در صفحه‌ی کاراکترها انتخاب کرده؛ پیش‌فرض سیگما. */
companion: SeyarChar;
unreadCount: number;
refreshHome: () => Promise<void>;
/** `gradeId` را فقط وقتی بدهید که پایه همین الان عوض شده و state هنوز رندر نشده. */
refreshHome: (gradeId?: number) => Promise<void>;
/** عوضِ کتابِ نقشه (هندسه، حسابان، …)؛ آزمون‌ها و درس‌ها هر دو همین را می‌خوانند. */
selectSubject: (key: string) => void;
/** کتابی که روی `home` است؛ بی‌نقشه (هنوز نرسیده) null. */
subjectKey: string | null;
refreshSummary: () => Promise<void>;
refreshUnread: () => Promise<void>;
setProfile: (profile: UserProfile) => void;
@@ -27,30 +35,76 @@ interface AppData {
const AppDataContext = createContext<AppData | null>(null);
/** آنچه در اسپلش پیش از ورود به اپ گرفته شد (App.prefetchMain). */
export interface Prefetch {
home: HomeResponse | null;
summary: UserSummary | null;
}
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 [summary, setSummary] = useState<UserSummary | null>(null);
const [home, setHome] = useState<HomeResponse | null>(null);
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);
const [companion, setCompanionChar] = useState<SeyarChar>('sigma');
// همراه در companionStore است تا بیرون از AppData (اسپلش، نشان، لودرها) هم خوانده شود.
const companion = useCompanion().char;
const setCompanionChar = companionStore.set;
const [unreadCount, setUnreadCount] = useState(0);
const refreshHome = useCallback(async () => {
const gradeId = profile.gradeId;
// نقشه‌ی کتاب‌هایی که دیده شده؛ جابه‌جایی به کتابِ دیده‌شده فوری است و پشت‌صحنه تازه می‌شود.
const homeCache = useRef(new Map<string, HomeResponse>());
// فقط پاسخِ آخرین درخواست روی صفحه می‌نشیند: وقتی پایه یا کتاب وسطِ راه عوض شود (مثلاً پروفایلِ تازه‌ی
// پشتِ صحنه پایه‌ی دیگری گفت)، پاسخِ کندترِ درخواستِ قبلی نباید نقشه‌ی تازه را بپوشاند.
const homeSeq = useRef(0);
const refreshHome = useCallback(async (forGrade?: number) => {
const target = forGrade ?? gradeId;
const seq = ++homeSeq.current;
try {
setHome(await api.home());
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]);
const selectSubject = useCallback(
(key: string) => {
if (gradeId == null) return;
subjectStore.set(gradeId, key);
const cached = homeCache.current.get(`${gradeId}:${key}`);
// کتابِ دیده‌شده فوری می‌آید؛ دیده‌نشده یک لحظه «در حال آوردن» است تا نقشه‌ی کتابِ قبلی زیرِ نامِ
// کتابِ تازه نماند.
setHome(cached ?? null);
setHomeError(null);
void refreshHome();
},
[gradeId, refreshHome],
);
const refreshSummary = useCallback(async () => {
try {
@@ -68,19 +122,76 @@ export function AppDataProvider({
}
}, []);
// بارِ اول فقط یک بار؛ عوض شدنِ پایه refreshHome را تازه می‌سازد و نباید همه‌ی این‌ها دوباره گرفته شوند.
const started = useRef(false);
// عکس مالِ این کاربر نبود: این AppData تا برداشته شدن دیگر عکس نمی‌نویسد (پاسخِ دیرِ نقشه با پروفایلِ غلط).
const identityLost = useRef(false);
useEffect(() => {
void refreshHome();
void refreshSummary();
if (started.current) return;
started.current = true;
// اعلان‌ها و همراه برای صفحه‌ی اول لازم نیستند.
const loadExtras = () => {
if (!api.auth.isSignedIn()) return;
void refreshUnread();
api.characters
.list()
.then((list) => {
const selected = seyarFromSlug(list.find((c) => c.selected)?.slug);
if (selected) setCompanionChar(selected);
// بی‌انتخاب یعنی پیش‌فرضِ سرور (سیگما)؛ همراهِ کاربرِ قبلی روی این دستگاه نباید بماند.
setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma');
})
.catch(() => {});
.catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err));
};
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,
@@ -92,13 +203,15 @@ export function AppDataProvider({
companion,
unreadCount,
refreshHome,
selectSubject,
subjectKey: home?.subject?.key ?? null,
refreshSummary,
refreshUnread,
setProfile,
setCompanion: (character) => setCompanionChar(seyarFromSlug(character.slug) ?? 'sigma'),
setUnreadCount,
}),
[profile, grades, summary, home, homeError, companion, unreadCount, refreshHome, refreshSummary, refreshUnread],
[profile, grades, summary, home, homeError, companion, unreadCount, refreshHome, selectSubject, refreshSummary, refreshUnread],
);
return <AppDataContext.Provider value={value}>{children}</AppDataContext.Provider>;

View File

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

70
src/state/companion.ts Normal file
View File

@@ -0,0 +1,70 @@
import { useSyncExternalStore } from 'react';
import { KEYS, store } from '@/lib/storage';
import { SEYAR_INFO, seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
/**
* همراهِ انتخابیِ کاربر، یک جا برای کلِ اپ: نشانِ سرِ خانه، ماسکوتِ سربرگ‌ها و لودرها، نام و چهره‌ی دستیار، و ویژگیِ
* کوچکِ هر همراه. پیش‌تر هر صفحه همراهِ خودش را ثابت نوشته بود (فروشگاه و دستیار «پی»، درس‌ها «سیگما»، …) و انتخابِ
* کاربر فقط در خانه و درس دیده می‌شد.
*
* در localStorage هم می‌ماند تا اسپلش و صفحه‌ی ورود پیش از رسیدنِ پاسخِ سرور همان همراه را نشان دهند؛ منبعِ حقیقت
* همچنان سرور است (AppData بعد از ورود از /characters می‌خواند و این را به‌روز می‌کند).
*/
export interface CompanionLook {
char: SeyarChar;
name: string;
symbol: string;
/** رنگِ اصلی (بدنه‌ی ماسکوت) و رنگِ روشن‌ترِ کنارش، برای نشان و ته‌رنگ. */
color: string;
light: string;
/** ویژگیِ این همراه؛ در صفحه‌ی همراه‌ها نوشته شده و در آزمون دیده می‌شود (QuizScreen). */
trait: { title: string; text: string };
/** گامِ صدای «درست» نسبت به سیگما (نیم‌پرده)؛ هر همراه صدای خودش را دارد. */
pitch: number;
}
export const COMPANIONS: Record<SeyarChar, CompanionLook> = {
sigma: {
char: 'sigma', name: SEYAR_INFO.sigma.name, symbol: 'Σ', color: '#4C5FD5', light: '#7A8CFF', pitch: 0,
trait: { title: 'جمع‌زن', text: 'تو آزمون جوابای درستت رو یکی‌یکی برات جمع می‌زنه.' },
},
delta: {
char: 'delta', name: SEYAR_INFO.delta.name, symbol: 'Δ', color: '#FF7A3D', light: '#FFB25E', pitch: 2,
trait: { title: 'شعله سرعت', text: 'جوابای درست پشت هم رو آتیشی می‌کنه و تشویقت می‌کنه.' },
},
pi: {
char: 'pi', name: SEYAR_INFO.pi.name, symbol: 'π', color: '#9B7BFF', light: '#C6B3FF', pitch: 4,
trait: { title: 'راهنمای مهربون', text: 'وقتی غلط می‌زنی یه نکته کوچیک می‌گه که دفعه بعد گیر نکنی.' },
},
tedi: {
char: 'tedi', name: SEYAR_INFO.tedi.name, symbol: 'ω', color: '#E8A400', light: '#FFC63D', pitch: 7,
trait: { title: 'جشن‌ساز', text: 'آخر هر جزیره، هر نمره‌ای بگیری، برات جشن طلایی می‌گیره.' },
},
};
let current: SeyarChar = seyarFromSlug(store.get(KEYS.companion)) ?? 'sigma';
const listeners = new Set<() => void>();
export const companionStore = {
get: () => current,
set(char: SeyarChar) {
if (char === current) return;
current = char;
store.set(KEYS.companion, char);
listeners.forEach((l) => l());
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
export function useCompanion(): CompanionLook {
const char = useSyncExternalStore(companionStore.subscribe, companionStore.get, companionStore.get);
return COMPANIONS[char];
}
/** «من سیگمام»، «من پی‌ام». */
export function iAm(name: string): string {
return name.endsWith('ی') ? `${name}‌ام` : `${name}م`;
}

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);
},
};

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

@@ -0,0 +1,68 @@
import { useSyncExternalStore } from 'react';
import { api, ApiError, type HomeResponse } from '@/api';
import { KEYS, store } from '@/lib/storage';
/**
* کتابی که دانش‌آموز در هر پایه انتخاب کرده («هندسه»، «حسابان»، …). یک منبعِ حقیقت برای هر دو تبِ «آزمون‌ها» و
* «درس‌ها»: هر دو از `home` در AppData می‌خوانند و `home` همیشه نقشه‌ی همین کتاب است، پس جابه‌جایی در یکی در
* دیگری هم دیده می‌شود.
*
* هر پایه جدا نگه داشته می‌شود (با عوض‌کردنِ پایه، انتخابِ پایه‌ی قبلی نباید به دوازدهم بپرد) و در
* localStorage می‌ماند تا بعد از بستنِ اپ همان کتاب باز شود. منبعِ حقیقتِ فهرستِ کتاب‌ها سرور است
* (`home.subjects`)؛ این فقط «کدام» را می‌گوید.
*/
type SubjectMap = Record<string, string>;
function read(): SubjectMap {
try {
const parsed: unknown = JSON.parse(store.get(KEYS.subjects) ?? '{}');
return parsed && typeof parsed === 'object' ? (parsed as SubjectMap) : {};
} catch {
return {};
}
}
let current: SubjectMap = read();
const listeners = new Set<() => void>();
export const subjectStore = {
get: (gradeId: number): string | null => current[String(gradeId)] ?? null,
set(gradeId: number, key: string | null) {
const id = String(gradeId);
if ((current[id] ?? null) === key) return;
const next = { ...current };
if (key) next[id] = key;
else delete next[id];
current = next;
store.set(KEYS.subjects, JSON.stringify(next));
listeners.forEach((l) => l());
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
snapshot: () => current,
};
/** کتابِ انتخاب‌شده‌ی این پایه؛ null یعنی هنوز انتخابی نشده (سرور اولین کتاب را می‌دهد). */
export function useSelectedSubject(gradeId: number | null | undefined): string | null {
const map = useSyncExternalStore(subjectStore.subscribe, subjectStore.snapshot, subjectStore.snapshot);
return gradeId == null ? null : map[String(gradeId)] ?? null;
}
/**
* نقشه‌ی کتابِ انتخاب‌شده. کلیدِ ذخیره‌شده‌ای که سرور دیگر نمی‌شناسد (کتاب برداشته شده یا پایه عوض شده)
* ۴۰۰ می‌گیرد؛ آن را پاک می‌کنیم و نقشه‌ی پیش‌فرض را می‌گیریم تا کاربر صفحه‌ی خالی نبیند.
*/
export async function fetchHome(gradeId: number | null | undefined): Promise<HomeResponse> {
const wanted = gradeId == null ? null : subjectStore.get(gradeId);
try {
return await api.home(wanted ?? undefined);
} catch (err) {
if (wanted && gradeId != null && err instanceof ApiError && err.status === 400) {
subjectStore.set(gradeId, null);
return api.home();
}
throw err;
}
}

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 {
@@ -117,5 +135,23 @@
.shine { animation: shine 3.2s ease-in-out infinite; will-change: transform; }
@keyframes spark { 0%, 100% { opacity: .25; transform: scale(.7); } 50% { opacity: 1; transform: scale(1); } }
.spark { animation: spark 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
@keyframes rise { 0% { transform: translateY(16px); opacity: 0; } 15%, 85% { opacity: .55; } 100% { transform: translateY(-110px); opacity: 0; } }
.rise { animation: rise 7s linear infinite; will-change: transform, opacity; }
/* نوارِ انتظارِ میانِ دو صفحه (MainApp → HoldBar). */
@keyframes hold-bar { 0% { transform: translateX(120%); } 100% { transform: translateX(-320%); } }
.hold-bar { animation: hold-bar 0.9s cubic-bezier(.4,0,.2,1) infinite; will-change: transform; }
/* شعله‌ی زنجیره (StreakFlame): دو لایه با ریتمِ متفاوت، از پایین تکان می‌خورند. */
@keyframes flame-a { 0%, 100% { transform: scale(1, 1) skewX(0deg); } 33% { transform: scale(.97, 1.04) skewX(-2deg); } 66% { transform: scale(1.02, .97) skewX(2deg); } }
@keyframes flame-b { 0%, 100% { transform: scale(1, 1); } 50% { transform: scale(.94, 1.08); } }
.flame-outer { transform-box: fill-box; transform-origin: 50% 100%; animation: flame-a 1.4s ease-in-out infinite; }
.flame-inner { transform-box: fill-box; transform-origin: 50% 100%; animation: flame-b .9s ease-in-out infinite; }
@keyframes ice-glint { 0%, 70%, 100% { opacity: 0; } 80% { opacity: 1; } }
.ice-glint { animation: ice-glint 2.8s ease-in-out infinite; }
@keyframes spark-rise { 0% { transform: translateY(0) scale(1); opacity: 0; } 15% { opacity: 1; } 100% { transform: translateY(-180px) scale(.3); opacity: 0; } }
.spark-rise { animation: spark-rise 1.8s ease-out infinite; }
/* همراه‌های کنارِ مسیرِ جزیره‌ها (IslandPath): فقط transform، تا روی گوشیِ ضعیف هم روی GPU بماند. */
@keyframes buddy-hop { 0%, 55%, 100% { transform: translateY(0) scale(1, 1); } 20% { transform: translateY(-12px) scale(.96, 1.05); } 40% { transform: translateY(0) scale(1.06, .94); } }
@keyframes buddy-sway { 0%, 100% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } }
.buddy-hop { animation: buddy-hop 1.6s ease-in-out infinite; transform-origin: 50% 100%; }
.buddy-sway { animation: buddy-sway 3.2s ease-in-out infinite; transform-origin: 50% 100%; }

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

@@ -1,10 +1,17 @@
/// <reference types="vite/client" />
/** شناسه‌ی هر بیلد (vite.config.ts)؛ عکسِ خانه‌ی بیلدِ دیگر خوانده نمی‌شود. */
declare const __APP_BUILD__: string;
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface Window {
__TADRISINO_CONFIG__?: { apiBaseUrl?: string };
__TADRISINO_CONFIG__?: {
apiBaseUrl?: string;
/** روش‌های پرداختِ فعال در مرورگر (web) و داخلِ اپِ اندروید (app)؛ src/lib/paymentMethods.ts. */
paymentMethods?: { web?: string[]; app?: string[] };
};
webkitAudioContext?: typeof AudioContext;
}

View File

@@ -9,11 +9,16 @@ 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') },
},
build: {
rollupOptions: {
// pay.html: صفحه‌ی واسطه‌ی درگاه برای اپِ اندروید (src/pay.ts).
input: { main: path.resolve(__dirname, 'index.html'), pay: path.resolve(__dirname, 'pay.html') },
output: {
// کتابخانه‌ها با هر انتشار عوض نمی‌شوند؛ فایلِ جدا یعنی بعد از هر دیپلوی
// از کشِ مرورگر می‌آیند و فقط کدِ خودِ اپ دوباره دانلود می‌شود.