85 Commits

Author SHA1 Message Date
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
a8a5351862 Subscription store, paywall on locked units, profile subscription bar, Tadrisino branding
- Store opens with two choices (subscription / question packs); 3- and 6-month cards
  show per-month price and saving.
- Units that need a subscription show a crown badge and open the store; a 402 from
  the quiz goes there too. «ادامه بده» skips locked units.
- Profile: subscription card with an animated days-left bar (planDurationDays).
- BrandMark (SVG) on splash, home header and footer; copyright footer; floating math
  glyphs on splash. CSS transform/opacity only; honours reduced motion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:19:09 +03:30
8879b42b1c Fix: first screen's looping animations (mascot bob, island ring, start button) never ran until a tab switch; AnimatePresence initial={false} leaked into children 2026-10-08 18:12:35 +03:30
6930c83e07 Quiz: selecting an option only highlights it blue; the answer is submitted by a «بررسی» button and green/red is shown after, then «ادامه» 2026-10-08 18:04:02 +03:30
d0a2822658 Fine-tune: bigger invisible tap areas on header chips, press feedback on tabs and chips, touch-action manipulation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:59:12 +03:30
50f6ade9f8 Replace keyboard emojis with the app's own lucide icons (quiz reactions, +XP, welcome hand, party popper, streak flame)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:47:05 +03:30
22fc870176 Smoother tab switch (no dark flash), full welcome sentence, chapter banner sticky and chip centered, floating math glyphs in headers, quiz reaction emojis
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:38:28 +03:30
54b872026d Lessons and packages headers: back to the owner's preferred colors (green, coral), exactly as before
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:31:01 +03:30
54bd865835 Colors keep their role: headers/banners darken to readable contrast (same hue), lessons uses the teal token, packages use indigo; +XP pop, streak milestone card, lazy confetti
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:26:07 +03:30
dc1fb15a55 Lessons tab: chapters closed by default, no quiz-button flash while lessons load, state kept on back, continue card and progress
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:00:49 +03:30
09ca1a270a Lessons tab replaces the map tab: chapter > island > lesson list with the island quiz beside it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 15:46:38 +03:30
838049c469 Island sheet: quiz button stays primary, lessons button gets a neon-green outline and clearer wording
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 14:37:01 +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
2fbcf6e6e0 fixes 2026-10-07 19:04:45 +03:30
5b98c054ea Merge branch 'main' of https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend 2026-10-07 18:30:25 +03:30
c64b3939ba changes 2026-10-07 18:29:03 +03:30
b11016c6ea LoadingState appears only after 300ms: fast API answers no longer flash the loader (packs screen and every other data screen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 17:05:52 +03:30
ec1bfc721d Page switch: show the loading box only after 300ms, not for the 1-2 frames lazy() always takes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 17:02:18 +03:30
5b211c2786 Lesson screen: render fixed lesson figures under intro, example and steps
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 16:26:13 +03:30
0db6526933 Remove every guest remnant: profile badge, register flow, delete-by-word
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 18:20:10 +03:30
af2e1f57dd Login: no guest option; every user signs in with a phone number
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 17:38:18 +03:30
1025321b5f Android WebView: bridge only on our own https page, external schemes blocked, file access off; payment URL must be http(s)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 09:44:47 +03:30
e850f6dfbd Payment return decided by the server only; encoded path ids; no sign-out on a two-tab refresh race
- ?orderId must be numeric; every id in an API path is encodeURIComponent'd.
- The payment sheet shows success only from GET /payment/orders/{id}; a failed lookup says the
  status is unknown instead of "paid".
- A 401 from /auth/refresh is not a sign-out when another tab already stored a rotated token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:52:25 +03:30
647ccf2c88 copy: no companion count in splash/onboarding (said three, there are four)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:32:57 +03:30
f8cf86e4e8 UI readability pass and Bazaar in-app purchase bridge
- muted text #9e9ec0 -> #727299 (contrast 2.6 -> 4.5 on white); tab labels 11px
- map: chapter titles wrap to two lines instead of being cut off
- reduced-motion respected (MotionConfig + CSS), focus-visible ring,
  no pull-to-refresh, short vibration on correct/wrong answers
- src/lib/bazaar.ts: bridge to the Android WebView wrapper; checkout uses
  Bazaar IAP only inside that app, Zarinpal everywhere else (unchanged)
- android/: WebView wrapper with Poolakey; written but never built

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:13:12 +03:30
adecf25486 http: only a 401 from /auth/refresh signs the user out
A 429, 5xx or network failure during token refresh used to sign the user
out. Behind one school Wi-Fi or mobile CGNAT the backend's per-IP refresh
limit was hit, and phone users were sent back to SMS login. Now those cases
keep the session and surface as a retryable error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:05:59 +03:30
33f5f8d009 no-hearts screen: live countdown, filling heart, auto-continue; refunded/canceled invoices
- The wait screen counts down to the next heart second by second, fills
  the first heart as time passes, shows when all five are back, and asks
  the server when the time comes; 'continue' reloads the quiz. Packs and
  'ask Pi meanwhile' (asking costs no hearts) are offered as ways on.
- The server now also refuses to finish a lesson/island without hearts
  and answers (403 no_hearts); the quiz shows the same screen then.
- Invoices know the two admin-set statuses, canceled and refunded.
- Account deletion waits for all six digits of the SMS code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:42:14 +03:30
befaaeb7bb packs: no per-question price on the card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:27:38 +03:30
a2782da3bd tutor: free and purchased question counters in the header, larger
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:51:33 +03:30
b860c77b91 question packs page, free/purchased counters in the tutor, packs from the no-hearts page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:41:43 +03:30
44871894e0 User-style public ID, tutor quota on plan cards, switch and delete account
- profile shows "User482917" under the name, no copy button
- plan cards start with "N questions a day from Pi (free: M)" taken from the plan itself
- "ورود با حسابِ دیگر" signs out cleanly instead of keeping several accounts on one device
- "حذفِ حساب": SMS code for phone users, typing "حذف" for guests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:10:17 +03:30
de7cd0bfa8 parent report page with two charts, Jalali birth date picker, user ID and invoices in profile
- the parents section is gone: no parent login, invite codes or "I'm a parent" button;
  "گزارشِ والدین" in the profile shows the week against the previous one (praise line,
  six tiles, minutes-per-day and right/wrong-per-day charts, a table of the numbers)
- birth date as day / month / year selects (Jalali, leap Esfand handled) instead of age;
  adults (19+) are asked which grade's math they want to learn
- user ID under the name, copyable
- "پرداخت‌ها و فاکتورها": every order with invoice number, discount, amount and reference code
- tutor: when the free daily quota runs out, a button to the subscription page instead of "retry"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:38:43 +03:30
0e56d9c8aa math in Persian text: lists read right-to-left, every content text goes through MathText, chat input picks its direction per line
Measured by rendering every question, option and several tutor answers in
Chrome and comparing the on-screen x of each number (2026-09-29):
- A list inside a sentence ('از −۳ به −۲، −۱، ۰ می‌رسیم', 'الگویِ ۲، ۵، ۸')
  was locked as one left-to-right run, so all 482 read backwards. '،' and
  '؛' outside brackets now split the run; sets and coordinates in brackets
  stay one run. Locked runs are isolates (isolate-override), otherwise the
  comma between two of them was still pulled left-to-right.
- Without MathText 704 of 931 minus signs and 602 of 983 expressions were
  misplaced; lesson and island titles, the lesson intro, unit blurb,
  notification bodies and ticket messages now go through it.
- The chat box takes its direction from each line's first letter, so a
  line of pure math stays left-to-right while typing. A line that starts
  in Persian can still show its math reordered inside the box; a textarea
  cannot isolate part of its text. The sent message renders correctly.

Four expressions with Persian words inside them ('f(x) = (ریشه دوم x) + ۵۰')
remain ambiguous.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:46:18 +03:30
db6d694ab0 tutor wait: the thinking bubble moves through stages instead of one fixed line
The model provider holds the whole answer and sends it at once; a full
answer with a diagram took about 30 s on 2026-09-29. A fixed 'thinking'
line for that long looked stuck. The stage times are approximate and not
tied to what the server is actually doing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:05:33 +03:30
0957ef476b lighter first load, math reads left-to-right in quizzes, tickets from the server, readable separators
- Every screen but home is its own chunk and preloads when the browser is
  idle; React and motion get their own long-cached chunks. The app entry
  goes from 505 kB (160 kB gzip) to 85 kB (27 kB gzip).
- Quiz prompts, options and lesson examples go through MathText, so
  '10 + 3 - 7 - 2' reads from the left; an unpaired bracket at the edge of
  a math run stays with the Persian sentence.
- Support lists tickets from GET /support/tickets instead of this
  browser's localStorage; a ticket's messages load when it is opened.
- '·' next to a Persian zero read as another digit ('فصلِ ۱ · ۰' looked
  like ۱۰۰); a thin bar separates them now. Notification bodies show
  Persian digits.
- A root or vertex the model already marked with coordinates is no longer
  labelled a second time on top of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:18:49 +03:30
63f4ea8da0 README: the diagram module and the fourth companion
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 06:57:58 +03:30
2f8947ba94 diagram kit: triangle and quad from problem data (law of sines/cosines), anchoring and side-by-side layout, shape patterns, translate points
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 06:13:47 +03:30
dffa940f8a diagram kit: lines from their written equation, regular polygons, exterior angles; hide unused points
Found in a real chat with gpt-4.1-mini: the model writes a line's coefficients
as ax + by + c = 0, so eq now means that and its own earlier outputs render
right; it can also just copy the equation text (3x + 5y = 4, y = 12x - 16).
regularPolygon builds the vertices (the model's own pentagon was not regular),
exteriorAngle draws the extension of the side (the model always drew the
interior angle). Named points no item refers to are no longer drawn, and a
computed root or vertex skips its label when the model already put a point there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:37:50 +03:30
ec4c844531 draw every kind of textbook figure, lock math text left-to-right, student-first family screen
- src/diagram: plane (constructed points, graphs with computed roots, vertex,
  extrema, tangent, asymptotes, piecewise limits, regions, rotations and
  reflections, line from ax + by = c), number line with intervals and stacked
  fractions, solids with hidden edges, bar/line/pie charts, sign tables, Venn,
  arrow mappings, probability trees, fraction bars and mixed numbers, grids,
  groups, clock. Pure-math text is bidi-overridden: Persian digits used to turn
  3 × 4 = 12 into 12 = 4 × 3 even with dir=ltr.
- MathText locks math runs inside Persian sentences in chat bubbles and figure
  captions, so -1 no longer shows as 1-.
- Family screen: a student sees only the invite-code form; the parent form is
  behind a link.
- Dev-only diagram gallery at ?gallery (not in the production build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:24:50 +03:30
355bae7035 play whole islands, make home the path itself, draw tutor diagrams, add tedi
- Home is now the island path of every unit with one clear Start button on the
  current island; the six-card dashboard is gone.
- Start opens /nodes/{id}/quiz (4 questions) instead of one-question lessons, so
  an island takes one round instead of ~11. Lessons stay one tap away in the sheet.
- Tutor answers render the backend's validated diagram (plane, number line,
  fraction) as React SVG; function expressions go through a small parser, no eval.
- Tedi, the mobile-preview mascot, joins as the fourth companion with all six moods
  and an onboarding slide.
- Light polish: 3D buttons, header gradients, warmer page background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:45:57 +03:30
b5d97c0cda open the code step at once and let the SMS request finish in the background
Kavenegar sends the SMS early but answers our API up to 16 s later
(2026-09-27), and the login screen waited for that answer. The server
stores the code before calling Kavenegar, so the code works immediately
(checked against a local server with an 8 s send: login 200 at 1.5 s).
Real failures still show on the code step, and resend unlocks at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:31:06 +03:30
25127d471e give the login-code request 45 s, since the backend now waits up to 40 s for Kavenegar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:14:23 +03:30
2b4db90fe4 give parents their own way in instead of the student setup form
A parent account has no grade, so the app sent the demo mother to the
"which grade are you in?" form and she never reached the parents screen.
Now an account with a name, no grade and a parent-side family link opens
straight on the parents screen (with a logout button, since it has no tab
bar), and the setup form offers "I'm a parent, not a student" for new ones.
The header's back button only shows when there is somewhere to go back to.
Found on the live site with the demo accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:33:39 +03:30
33e539e562 parents screen: tell the child and the parent apart
A linked child (demo user Ali) was told "you are linked to Ali" and the
screen asked for the parent-only weekly report, which 404s. GET /family/link
does not say which side the caller is on, so the role now comes from the
weekly report: it answers only for the parent. The child sees a line saying
their parent follows their progress and no invite/accept forms. Found on the
live site with the demo accounts. deploy.sh now prints the full API URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:26:22 +03:30
116 changed files with 9274 additions and 1579 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ dist
dist.next dist.next
dist.prev dist.prev
.deploy-backups .deploy-backups
tsconfig.tsbuildinfo

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
}

136
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 npm run dev # http://localhost:5173
``` ```
در حالتِ توسعه، درخواست‌های `/api` از خودِ Vite به بک‌اند پاس داده می‌شوند، پس CORS در حالتِ توسعه، Vite درخواست‌های `/api` را به بک‌اند پاس می‌دهد، پس CORS مطرح نیست. پیش‌فرض بک‌اندِ محلی است
مطرح نیست. پیش‌فرض بک‌اندِ محلی است (`python manage.py runserver` روی پورتِ ۸۰۰۰). (`python manage.py runserver` روی پورتِ ۸۰۰۰). بدونِ بک‌اندِ محلی، فایلِ `.env.development.local` بسازید:
بدونِ بک‌اندِ محلی، یک فایلِ `.env.development.local` بسازید:
```bash ```bash
VITE_DEV_PROXY_TARGET=https://mathapi.tadrisino2.ir VITE_DEV_PROXY_TARGET=https://mathapi.tadrisino2.ir
``` ```
⚠️ آن‌وقت هر کاری در اپ بکنید روی دیتابیسِ واقعی نوشته می‌شود. > ⚠️ در این حالت هر کاری در اپ بکنید روی دیتابیسِ واقعی نوشته می‌شود.
| دستور | کار | | دستور | کار |
|---|---| |---|---|
@@ -27,63 +39,87 @@ VITE_DEV_PROXY_TARGET=https://mathapi.tadrisino2.ir
| `npm run build` | type-check و بعد build در `dist/` | | `npm run build` | type-check و بعد build در `dist/` |
| `npm run preview` | اجرای همان build | | `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/ src/
config.ts آدرسِ API (بالا را ببینید) config.ts آدرسِ API (بخشِ پایین)
api/ تنها درِ ارتباط با سرور — هیچ جای دیگر fetch نمی‌زند api/ تنها درِ ارتباط با سرور — هیچ جای دیگر fetch نمی‌زند
http.ts timeout، تکرارِ GET، refreshِ تک‌نسخه‌ای، SSE http.ts توکن، timeout، تکرارِ GET، refreshِ تک‌نسخه‌ای، SSE، Idempotency-Key
index.ts یک تابع برای هر اندپوینت index.ts یک تابع برای هر اندپوینت
types.ts شکلِ پاسخ‌ها، از روی پاسخِ واقعیِ سرور types.ts شکلِ پاسخ‌ها، از روی پاسخِ واقعیِ سرور
app/ App (جریانِ ورود) و MainApp (تب‌ها و صفحه‌ها) app/ App (جریانِ ورود) و MainApp (تب‌ها و صفحه‌ها)
nav/ ناوبریِ پشته‌ای، هماهنگ با دکمه‌ی back گوشی nav/ ناوبریِ پشته‌ای، هماهنگ با دکمه‌ی back گوشی
state/ داده‌ی مشترک (پروفایل، نقشه، …) و گفتگوی دستیار state/ داده‌ی مشترک (پروفایل، نقشه، کتابِ انتخاب‌شده، همراه) و گفتگوی دستیار
screens/ یک فایل برای هر صفحه screens/ یک فایل برای هر صفحه
components/ اجزای مشترک (سربرگ، برگه‌ی پایین، …) components/ اجزای مشترک: مسیرِ جزیره‌ها، نوارِ کتاب‌ها، برگه‌ی پایین، بازخورد، …
mascots/ سه‌یار: سیگما، دلتا، پی mascots/ همراه‌ها (سیگما، دلتا، پی، امگا): SVG از کد، شش حالتِ چهره
lib/ اعدادِ فارسی، تاریخِ شمسی، صدا، localStorage 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`، … - **رنگ‌ها** در `src/styles/index.css` (`@theme`) تعریف شده‌اند: `bg-coral`، `text-ink`، …
- **خطا:** پیامِ کاربر همیشه از `friendlyError` می‌آید، نه متنِ خامِ سرور. - **خطا:** پیامِ کاربر همیشه از `friendlyError` می‌آید، نه متنِ خامِ سرور.
- **کلاینت را از حافظه ننویسید.** قرارداد را از `/api/docs/` بک‌اند بخوانید و تغییرِ - **بازشدنِ فوری:** کاربرِ واردشده خانه را از عکسِ روی گوشی می‌بیند (`state/homeSnapshot.ts`: پروفایل، پایه‌ها، نقشه،
`types.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` عوض
شود، هشِ تازه‌اش باید در همان فایل بنشیند، وگرنه مرورگر آن را اجرا نمی‌کند.

8
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.gradle/
build/
app/build/
local.properties
.idea/
*.iml
*.jks
*.keystore

88
android/README.md Normal file
View File

@@ -0,0 +1,88 @@
# اپِ اندرویدِ تدریسینو (پوسته‌ی WebView + پرداختِ بازار)
همان سایتِ `math.tadrisino2.ir` داخلِ یک WebView. تنها کدِ بومی پرداختِ درون‌برنامه‌ایِ
کافه‌بازار است (کتابخانه‌ی Poolakey)، چون بازار برای اپی که در آن منتشر می‌شود پرداختِ
محصولِ دیجیتال را فقط از راهِ خودش می‌پذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
فرانت به‌روز می‌شود، بی‌آن‌که نسخه‌ی تازه‌ی اپ لازم باشد.
> **ساخت و امضا با هم‌تیمی است (MH-W90، m.hassanabadi)** با روشِ امضای خودش (`my-release-key.keystore` +
> propertyهای Gradle)؛ تصمیمِ مالک این است که همان روش بماند. پیش از دست زدن به امضا از مالک بپرس و هرگز
> keystore را commit نکن. `applicationId` واقعی `com.tadrisino.math.app` است (از APKِ ساخته‌شده با aapt2).
> هیچ خریدِ واقعیِ بازار هنوز آزموده نشده.
## نسخه‌ی بعدیِ APK — چه چیزی در کد هست و در اپِ نصب‌شده نیست
این‌ها در `MainActivity.kt` نوشته شده‌اند (۱۴۰۵-۰۷، `468f5b2` و `737414f`) ولی هیچ‌کدام build نشده‌اند؛ اپی که الان روی
گوشی‌هاست بدونِ آن‌هاست. فرانت با هر دو نسخه کار می‌کند (اگر پل نبود، راهِ قدیم را می‌رود).
| تغییر | چرا | بعد از build روی گوشی بسنج |
|---|---|---|
| `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
1. Android Studio ← Open ← همین پوشه‌ی `android/`. Studio خودش Gradle wrapper را می‌سازد.
2. `Build ← Generate Signed App Bundle / APK`، با کلیدِ امضای شرکت. **کلیدِ امضا را گم نکنید**:
بدونِ همان کلید هیچ نسخه‌ی بعدی روی بازار به‌روزرسانی نمی‌شود.
3. نصب روی گوشی‌ای که کافه‌بازار دارد و با حسابِ تست وارد شده.
## جریانِ خرید (برای عیب‌یابی)
```
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
فرانت (اختیاری) 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} ← سرور از بازار می‌پرسد و اشتراک را فعال می‌کند
```
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» می‌زند: فرانت همان سفارش را می‌فرستد
و اپ توکنِ همان سفارش را از `getSubscribedProducts` پیدا می‌کند.
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بک‌اند.

View File

@@ -0,0 +1,113 @@
// plugins {
// id("com.android.application")
// id("org.jetbrains.kotlin.android")
// }
// android {
// // ⚠️ اسمِ پکیج باید همانی باشد که در پیشخانِ بازار ثبت است. com.tadrisino.app را بازار
// // برای حسابِ شرکت «Product is not found» جواب داد (یعنی هست)، ولی سنیور تأیید نکرده
// // که همان اپِ ریاضی است (CLAUDE.mdِ بک‌اند، ۲۰۲۶-۰۹-۳۰).
// namespace = "com.tadrisino.app"
// compileSdk = 34
// defaultConfig {
// applicationId = "com.tadrisino.math.app"
// minSdk = 23
// targetSdk = 34
// versionCode = 1
// versionName = "1.0.0"
// // آدرسِ همان فرانتِ وب. دامنه عوض شود، فقط همین خط.
// buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
// // کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (بخشِ «پرداخت درون‌برنامه‌ای»). کلیدِ عمومی
// // است و در اپ می‌نشیند؛ رمز نیست. تا پر نشود، اپ خرید را شروع نمی‌کند.
// buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
// }
// buildFeatures {
// buildConfig = true
// }
// buildTypes {
// release {
// isMinifyEnabled = false
// }
// }
// compileOptions {
// sourceCompatibility = JavaVersion.VERSION_17
// targetCompatibility = JavaVersion.VERSION_17
// }
// kotlinOptions {
// jvmTarget = "17"
// }
// }
// dependencies {
// implementation("androidx.appcompat:appcompat:1.7.0")
// implementation("androidx.activity:activity-ktx:1.9.2")
// implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
// }
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
// Package name must match the one registered on Cafe Bazaar.
// Confirm the final applicationId before publishing.
namespace = "com.tadrisino.app"
compileSdk = 34
defaultConfig {
applicationId = "com.tadrisino.math.app"
minSdk = 23
targetSdk = 34
// بازار نسخه‌ی تازه را فقط با versionCodeِ بزرگ‌تر می‌پذیرد؛ هر build که منتشر می‌شود یکی بالا.
versionCode = 2
versionName = "1.1.0"
// URL of the web frontend.
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (پرداخت درون‌برنامه‌ای)؛ سینیور، ۲۰۲۶-۱۰-۱۲. عمومی است و در APK
// می‌رود؛ Poolakey با آن امضای خرید را روی گوشی می‌سنجد. سنجشِ اصلی سمتِ سرور است.
buildConfigField("String", "BAZAAR_RSA_KEY", "\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwC9EBw0RW6jeTdHkrJwfB/mX+klpjpVXVb2stpGDcixeqHiLE+/Iow+PiMbZEQfxZDuED6ELvZlzQX+ALOlGRPxaa1ctl6/Uy6wGaPXhGPDpZL6jPrXV88mjJzMcEoB4sf3yNb+6YNKrvKhRyXXR0JdMUmPMqMe8igmQZTv/UF+QEMNqv7lg7qw9Yl+fG802nqjasft7qUPQK+YHJYPS4PJ5Pj/YpTkGDdDmVi7AIMCAwEAAQ==\"")
}
buildFeatures {
buildConfig = true
}
signingConfigs {
create("release") {
storeFile = file("my-release-key.keystore")
storePassword = providers.gradleProperty("MYAPP_STORE_PASSWORD").orNull
keyAlias = "my-key-alias"
keyPassword = providers.gradleProperty("MYAPP_KEY_PASSWORD").orNull
}
}
buildTypes {
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
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

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-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" />
<!-- اندروید ۱۱+: بدونِ این، اپ نمی‌تواند سرویسِ پرداختِ بازار را پیدا کند. -->
<queries>
<package android:name="com.farsitel.bazaar" />
</queries>
<application
android:allowBackup="false"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Tadrisino">
<activity
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" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,400 @@
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.bazaarBuy(sku, orderId, dynamicPriceToken) (اشتراک)
* TadrisinoAndroid.bazaarPrices(skusJson) → رویدادِ «tadrisino:bazaar-prices»
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
* { orderId, error: "canceled" | "failed" | "unavailable", message }
*
* سمتِ سرور توکن را از خودِ بازار می‌سنجد (`POST /api/payment/bazaar/verify`)، پس این‌جا
* فقط واسطه است و به هیچ نتیجه‌ای اعتماد نمی‌کند.
*
* ⚠️ کامپایل می‌شود (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 ?: ""
/** فقط خودِ سایت و فقط با 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)
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") {
startActivity(Intent(Intent.ACTION_VIEW, request.url))
}
return true
}
}
webView.addJavascriptInterface(Bridge(), "TadrisinoAndroid")
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
// اپِ وب برای هر صفحه history.pushState می‌زند؛ دکمه‌ی back همان را برمی‌گرداند.
override fun handleOnBackPressed() {
if (webView.canGoBack()) webView.goBack() else finish()
}
})
connectToBazaar()
if (savedInstanceState == null) webView.loadUrl(BuildConfig.WEB_URL)
}
private fun connectToBazaar() {
if (BuildConfig.BAZAAR_RSA_KEY.isEmpty()) {
Log.e(TAG, "BAZAAR_RSA_KEY is empty; in-app purchase disabled")
return
}
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") }
connectionFailed { Log.e(TAG, "bazaar connection failed", it) }
disconnected { Log.w(TAG, "bazaar disconnected") }
}
}
}
/**
* خریدِ اشتراکِ بازار (subscribeProduct؛ فقط اشتراک فروخته می‌شود و مصرف نمی‌شود، بازار خودش تمدید می‌کند).
* dynamicPriceToken خالی یعنی قیمتِ پیشخان؛ پر یعنی قیمتی که سرور با کلیدِ «تخفیف پویا» امضا کرده
* (بازارِ ۱۳.۳ به بعد).
*/
private fun buy(sku: String, orderId: String, dynamicPriceToken: String) {
val p = payment
if (p == null || connection?.getState() !is ConnectionState.Connected) {
// بازار نصب نیست، هنوز وصل نشده، یا اتصال افتاده؛ برای تلاشِ بعدی دوباره وصل می‌شویم.
connectToBazaar()
send(orderId, error = "unavailable", message = "not_connected")
return
}
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 { failed(sku, orderId, it) }
failedToBeginFlow { failed(sku, orderId, it) }
}
}
/**
* بازارِ نصب‌نشده یا قدیمی (بی پشتیبانیِ اشتراک یا «تخفیف پویا») کاری است که کاربر باید بکند، نه خطای خرید؛
* فرانت برایش پیامِ جدا دارد. بقیه‌ی خطاها ممکن است خریدی باشند که پولش رفته، پس از بازار پرسیده می‌شوند.
*/
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.getSubscribedProducts {
querySucceed { owned ->
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
if (match != null) send(orderId, token = match.purchaseToken)
else send(orderId, error = "failed", message = cause.message)
}
queryFailed { send(orderId, error = "failed", message = cause.message) }
}
}
/**
* قیمتِ ثبت‌شده در پیشخانِ بازار برای هر 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,
)
}
}
private fun send(orderId: String, token: String? = null, error: String? = null, message: String? = null) {
val detail = JSONObject().put("orderId", orderId)
if (token != null) detail.put("purchaseToken", token)
if (error != null) detail.put("error", error)
if (message != null) detail.put("message", message)
runOnUiThread {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('tadrisino:bazaar', { detail: $detail }));", null,
)
}
}
/** متدهای این کلاس روی threadِ WebView صدا زده می‌شوند، نه UI. */
private inner class Bridge {
/** قراردادش در src/lib/bazaar.tsِ فرانت است. */
@JavascriptInterface
fun bazaarBuy(sku: String, orderId: String, dynamicPriceToken: String) {
runOnUiThread {
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
buy(sku, orderId, dynamicPriceToken)
}
}
/**
* لرزشِ کوتاهِ رابط (src/lib/sound.tsِ فرانت). `navigator.vibrate` در همه‌ی WebViewها اجرا نمی‌شود؛
* این مستقیم از Vibratorِ گوشی است. ورودی آرایه‌ی JSON از میلی‌ثانیه‌ها: [لرزش، مکث، لرزش، …].
*/
@JavascriptInterface
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
prices(skus)
}
}
/** فرانت با این می‌فهمد درگاه داخلِ اپ باز می‌شود (isPaymentPage)، نه در مرورگرِ بیرونی. */
@JavascriptInterface
fun paysInApp(): Boolean = true
}
override fun onDestroy() {
connection?.disconnect()
super.onDestroy()
}
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%" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">تدریسینو ریاضی</string>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Tadrisino" parent="Theme.AppCompat.Light.NoActionBar">
<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>

4
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}

View File

@@ -0,0 +1,2 @@
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2g

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
// Poolakey (کتابخانه‌ی پرداختِ کافه‌بازار) فقط روی jitpack منتشر می‌شود.
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "Tadrisino"
include(":app")

View File

@@ -45,6 +45,6 @@ step "جابه‌جایی"
rm -rf dist.prev rm -rf dist.prev
[ -d dist ] && mv dist dist.prev [ -d dist ] && mv dist dist.prev
mv dist.next dist mv dist.next dist
API="$(grep -o 'https\?://[a-zA-Z0-9.-]*/api' dist/assets/*.js | head -1 | cut -d: -f2-)" API="$(grep -oh 'https\?://[a-zA-Z0-9.-]*/api' dist/assets/*.js | head -1)"
printf ' منتشر شد. آدرسِ API در build: %s\n' "${API:-(هم‌دامنه /api یا config.js)}" printf ' منتشر شد. آدرسِ API در build: %s\n' "${API:-(هم‌دامنه /api یا config.js)}"
printf ' کشِ آروان را برای index.html و config.js پاک کنید اگر تغییری دیده نشد.\n' printf ' کشِ آروان را برای index.html و config.js پاک کنید اگر تغییری دیده نشد.\n'

View File

@@ -4,15 +4,39 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ED6956" /> <meta name="theme-color" content="#ED6956" />
<meta name="description" content="تدریسینو — یادگیریِ ریاضیِ پایه‌ی سوم تا دوازدهم، قدم‌به‌قدم و با دستیارِ هوشمند." /> <meta name="description" content="تدریسینو ریاضی — یادگیری ریاضی پایه سوم تا دوازدهم، قدم‌به‌قدم و با دستیار هوشمند." />
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <title>تدریسینو ریاضی</title>
<title>تدریسینو</title>
<!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار می‌شود. --> <!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار می‌شود. -->
<script src="/config.js"></script> <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> </head>
<body> <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> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </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 دوباره، آدرسِ کامل را اینجا بنویسید: // برای جابه‌جا کردنِ بک‌اند بدونِ build دوباره، آدرسِ کامل را اینجا بنویسید:
// apiBaseUrl: "https://api.new-domain.ir/api", // apiBaseUrl: "https://api.new-domain.ir/api",
// اگر CDN (آروان) جلوی سایت است، بعد از ویرایش کشِ این فایل را پاک کنید. // اگر CDN (آروان) جلوی سایت است، بعد از ویرایش کشِ این فایل را پاک کنید.
//
// paymentMethods: روش‌های پرداختی که برگه‌ی پرداخت نشان می‌دهد، به همین ترتیب؛ «zarinpal» و «bazaar».
// web = سایت در مرورگر، app = داخلِ اپِ اندروید. نسخه‌ی کافه‌بازار: app: ["bazaar"].
// نبودنِ این کلید یعنی پیش‌فرضِ src/lib/paymentMethods.ts.
window.__TADRISINO_CONFIG__ = { window.__TADRISINO_CONFIG__ = {
apiBaseUrl: "", apiBaseUrl: "",
}; };

View File

@@ -21,6 +21,8 @@ export class ApiError extends Error {
readonly status: number, readonly status: number,
readonly detail: string, readonly detail: string,
readonly body: Record<string, unknown> = {}, readonly body: Record<string, unknown> = {},
/** فقط برای status 0: درخواست به‌خاطرِ timeout قطع شد (اینترنت کند)، نه رسیدن‌نداشتن (اینترنت قطع). */
readonly timedOut = false,
) { ) {
super(detail); super(detail);
this.name = 'ApiError'; this.name = 'ApiError';
@@ -75,13 +77,16 @@ export interface HttpClientOptions {
baseUrl: string; baseUrl: string;
storage?: TokenStorage; storage?: TokenStorage;
timeoutMs?: number; timeoutMs?: number;
readTimeoutMs?: number;
maxRetries?: number; maxRetries?: number;
} }
const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
function backoffMs(attempt: number, retryAfterMs?: number): number { 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); return Math.random() * Math.min(1000 * 2 ** attempt, 8000);
} }
@@ -159,11 +164,14 @@ export function createHttpClient(options: HttpClientOptions) {
const baseUrl = options.baseUrl.replace(/\/+$/, ''); const baseUrl = options.baseUrl.replace(/\/+$/, '');
const storage = options.storage ?? localTokenStorage; const storage = options.storage ?? localTokenStorage;
const defaultTimeout = options.timeoutMs ?? 15_000; const defaultTimeout = options.timeoutMs ?? 15_000;
// خواندن‌ها (GET) تکرارپذیرند و کاربر پشتِ اسپلش منتظر است: ۸ ثانیه کافی است، بعدش تلاشِ دوباره یا پیامِ «اینترنت کند است».
// نوشتن‌ها (POST/PATCH) همان ۱۵ می‌مانند: قطعِ زودهنگام یعنی کاربر دوباره می‌زند و ممکن است ثبتِ دوباره شود.
const readTimeout = options.readTimeoutMs ?? 8_000;
const maxRetries = options.maxRetries ?? 2; const maxRetries = options.maxRetries ?? 2;
const inFlight = new Map<string, Promise<unknown>>(); const inFlight = new Map<string, Promise<unknown>>();
const signedOutListeners = new Set<() => void>(); const signedOutListeners = new Set<() => void>();
let refreshing: Promise<Tokens | null> | null = null; let refreshing: Promise<Tokens | 'invalid' | ApiError> | null = null;
function authHeaders(): Record<string, string> { function authHeaders(): Record<string, string> {
const token = storage.get()?.token; const token = storage.get()?.token;
@@ -175,27 +183,45 @@ export function createHttpClient(options: HttpClientOptions) {
signedOutListeners.forEach((listener) => listener()); signedOutListeners.forEach((listener) => listener());
} }
async function refreshTokens(): Promise<Tokens | null> { /**
* نتیجه‌ی تمدید سه حالت دارد و فقط یکی‌اش یعنی خروج:
* • Tokens — تمدید شد.
* • 'invalid' — سرور گفت refresh token معتبر نیست (۴۰۱)؛ نشست واقعاً تمام است.
* • ApiError — سرور در دسترس نبود یا سقف را زد (۴۲۹، ۵xx، قطعیِ شبکه)؛ نشست سالم است.
* تا ۲۰۲۶-۱۰-۰۳ هر شکستی خروج حساب می‌شد: پشتِ وای‌فایِ مدرسه که سقفِ تمدیدِ IP پر می‌شد،
* دانش‌آموزِ شماره‌دار به صفحه‌ی ورود پرت می‌شد و باید دوباره پیامک می‌گرفت.
*/
async function refreshTokens(): Promise<Tokens | 'invalid' | ApiError> {
const current = storage.get(); const current = storage.get();
if (!current?.refreshToken) return null; if (!current?.refreshToken) return 'invalid';
if (refreshing) return refreshing; if (refreshing) return refreshing;
refreshing = (async () => { refreshing = (async (): Promise<Tokens | 'invalid' | ApiError> => {
try { try {
const response = await fetch(`${baseUrl}/auth/refresh`, { let response: Response;
try {
response = await fetch(`${baseUrl}/auth/refresh`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: current.refreshToken }), body: JSON.stringify({ refreshToken: current.refreshToken }),
signal: timeoutSignal(defaultTimeout), signal: timeoutSignal(defaultTimeout),
credentials: 'omit', credentials: 'omit',
}); });
if (!response.ok) return null; } catch {
return new ApiError(0, 'ارتباط با سرور برقرار نشد');
}
if (response.status === 401) {
// تبِ دیگری همین توکن را زودتر تمدید کرده (سرور توکنِ قبلی را باطل می‌کند) و توکنِ تازه
// را در همان localStorageِ مشترک گذاشته؛ آن نشست سالم است و نباید هر دو تب خارج شوند.
const latest = storage.get();
if (latest?.refreshToken && latest.refreshToken !== current.refreshToken) return latest;
return 'invalid';
}
if (!response.ok) return errorFrom(response, await parseBody(response));
const data = (await response.json()) as Tokens; const data = (await response.json()) as Tokens;
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken }; const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
storage.set(next); storage.set(next);
return next; return next;
} catch {
return null;
} finally { } finally {
queueMicrotask(() => { queueMicrotask(() => {
refreshing = null; refreshing = null;
@@ -215,7 +241,7 @@ export function createHttpClient(options: HttpClientOptions) {
if (body !== undefined) headers['Content-Type'] = 'application/json'; if (body !== undefined) headers['Content-Type'] = 'application/json';
if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey; if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
const timer = timeoutSignal(opts.timeoutMs ?? defaultTimeout); const timer = timeoutSignal(opts.timeoutMs ?? (method === 'GET' ? readTimeout : defaultTimeout));
try { try {
return await fetch(`${baseUrl}${path}`, { return await fetch(`${baseUrl}${path}`, {
method, method,
@@ -228,7 +254,7 @@ export function createHttpClient(options: HttpClientOptions) {
} catch (err) { } catch (err) {
if (opts.signal?.aborted) throw err; if (opts.signal?.aborted) throw err;
const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError'); const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError');
throw new ApiError(0, timedOut ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد'); throw new ApiError(0, timedOut ? 'زمان درخواست تمام شد' : 'ارتباط با سرور برقرار نشد', {}, timedOut);
} }
} }
@@ -243,8 +269,9 @@ export function createHttpClient(options: HttpClientOptions) {
let response = await send(method, path, body, opts); let response = await send(method, path, body, opts);
if (response.status === 401 && hadToken) { if (response.status === 401 && hadToken) {
const refreshed = await refreshTokens(); const refreshed = await refreshTokens();
if (refreshed) response = await send(method, path, body, opts); if (refreshed === 'invalid') signOut();
else signOut(); else if (refreshed instanceof ApiError) throw refreshed; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه
else response = await send(method, path, body, opts);
} }
return response; return response;
} }

View File

@@ -4,13 +4,26 @@
* هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است. * هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است.
*/ */
import { resolveApiBaseUrl } from '@/config'; 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'; import type * as T from './types';
export { ApiError }; export { ApiError };
export type * from './types'; 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 q = (params: Record<string, string | number | undefined>) => {
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== ''); const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '');
@@ -18,29 +31,44 @@ const q = (params: Record<string, string | number | undefined>) => {
}; };
function saveTokens(data: T.AuthResponse) { function saveTokens(data: T.AuthResponse) {
// ورودِ تازه ممکن است کاربرِ دیگری باشد؛ عکسِ خانه فقط از پاسخِ سرورِ همین نشست ساخته می‌شود.
homeSnapshot.clear();
http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' }); http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' });
return data; return data;
} }
/** یک بخشِ مسیر؛ شناسه‌ای که از آدرسِ صفحه یا سرور می‌آید نباید با «/» یا «..» مسیرِ دیگری از API را صدا بزند. */
const seg = (value: string | number) => encodeURIComponent(String(value));
export const api = { export const api = {
auth: { auth: {
isSignedIn: () => Boolean(http.storage.get()?.token), isSignedIn: () => Boolean(http.storage.get()?.token),
/** مهمان: فقط با شناسه‌ی دستگاه. refreshToken هم می‌گیرد، پس بعد از یک ساعت نمی‌افتد. */ /**
guest: (deviceId: string) => * گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمی‌گردد.
http.post<T.AuthResponse>('/auth/session', { type: 'guest', deviceId }).then(saveTokens), * مهلتِ ۴۵ ثانیه، نه ۱۵ِ پیش‌فرض: بک‌اند تا ۱۰+۳۰ ثانیه منتظرِ کاوه‌نگار می‌ماند
* (۲۰۲۶-۰۹-۲۷، پیامک رسید ولی جوابِ کاوه‌نگار دیر آمد). اگر این زودتر قطع کند،
/** گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمی‌گردد. */ * کاربر «ارتباط برقرار نشد» می‌بیند در حالی که کد در گوشی‌اش است.
*/
requestOtp: (phone: string) => requestOtp: (phone: string) =>
http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }), http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }, { timeoutMs: 45_000 }),
/** /**
* گامِ ۲: تأییدِ کد. اگر الان مهمان باشد، سرور همان کاربر را نگه می‌دارد و * گامِ ۲: تأییدِ کد. شماره‌ی تازه حسابِ تازه می‌سازد.
* پیشرفتش از دست نمی‌رود (توکنِ مهمان همراهِ همین درخواست می‌رود).
*/ */
login: (phone: string, otp: string) => login: (phone: string, otp: string) =>
http.post<T.AuthResponse>('/auth/login', { phone, otp }).then(saveTokens), http.post<T.AuthResponse>('/auth/login', { phone, otp }).then(saveTokens),
/**
* حذفِ همیشگیِ حساب. کاربر کدِ تازه‌ی پیامکی می‌دهد (اول requestOtp با شماره‌ی خودش).
* بعد از موفقیت توکن‌ها پاک می‌شوند؛ دیگر کاربری پشتشان نیست.
*/
deleteAccount: (body: { otp: string }) =>
http.post<{ success: boolean }>('/user/delete', body).then((r) => {
http.storage.set(null);
return r;
}),
/** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */ /** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */
async logout() { async logout() {
const refreshToken = http.storage.get()?.refreshToken; const refreshToken = http.storage.get()?.refreshToken;
@@ -56,7 +84,7 @@ export const api = {
profile: { profile: {
get: () => http.get<T.UserProfile>('/user/profile'), get: () => http.get<T.UserProfile>('/user/profile'),
update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) => update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'birthDate' | 'bio'>>) =>
http.patch<T.UserProfile>('/user/profile', patch), http.patch<T.UserProfile>('/user/profile', patch),
setGrade: (gradeId: number) => setGrade: (gradeId: number) =>
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }), http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
@@ -65,31 +93,56 @@ export const api = {
summary: () => http.get<T.UserSummary>('/user/summary'), summary: () => http.get<T.UserSummary>('/user/summary'),
hearts: () => http.get<T.Hearts>('/user/hearts'), hearts: () => http.get<T.Hearts>('/user/hearts'),
streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'), streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'),
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
/** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */ /**
home: () => http.get<T.HomeResponse>('/home'), * بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. بدونِ subject اولین کتابِ پایه؛
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${nodeId}/lessons`), * کتابِ ناموجود در پایه ۴۰۰ می‌دهد (برای کلیدِ ذخیره‌شده‌ی قدیمی از `fetchHome` استفاده کنید).
*/
home: (subject?: string) => http.get<T.HomeResponse>(`/home${q({ subject })}`),
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
/**
* آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیش‌فرض ۴). ترتیب برای هر
* کاربر ثابت است، پس رفرش همان آزمون را می‌دهد.
*/
nodeQuiz: (nodeId: string) => http.get<T.NodeQuiz>(`/nodes/${seg(nodeId)}/quiz`),
/** پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی را باز. */
nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
http.create<T.NodeQuizComplete>(`/nodes/${seg(nodeId)}/quiz/complete`, result, { idempotencyKey }),
lesson: { lesson: {
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${lessonId}/intro`), intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${seg(lessonId)}/intro`),
questions: (lessonId: number) => questions: (lessonId: number) =>
http.get<{ questions: T.Question[] }>(`/lessons/${lessonId}/questions`).then((r) => r.questions), http.get<{ questions: T.Question[] }>(`/lessons/${seg(lessonId)}/questions`).then((r) => r.questions),
/** /**
* کلیدِ یکتا را یک‌بار برای هر جواب بسازید و در تکرار همان را بفرستید تا * کلیدِ یکتا را یک‌بار برای هر جواب بسازید و در تکرار همان را بفرستید تا
* امتیاز و قلب دوبار حساب نشود. * امتیاز و قلب دوبار حساب نشود.
*/ */
answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) => answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) =>
http.post<T.AnswerResult>( http.post<T.AnswerResult>(
`/questions/${questionId}/answer`, `/questions/${seg(questionId)}/answer`,
{ selectedOptionId: Number(selectedOptionId) }, { selectedOptionId: Number(selectedOptionId) },
{ idempotencyKey, retry: true }, { idempotencyKey, retry: true },
), ),
/** مجموعِ XP و سکه‌ی کاربر اینجا به‌روز می‌شود، نه سرِ هر جواب. */ /** مجموعِ XP و سکه‌ی کاربر اینجا به‌روز می‌شود، نه سرِ هر جواب. */
complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) => complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
http.create<T.LessonComplete>(`/lessons/${lessonId}/complete`, result, { idempotencyKey }), http.create<T.LessonComplete>(`/lessons/${seg(lessonId)}/complete`, result, { idempotencyKey }),
report: (questionId: string, reason: T.ReportReason, note?: string) => report: (questionId: string, reason: T.ReportReason, note?: string) =>
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>( http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
`/questions/${questionId}/report`, `/questions/${seg(questionId)}/report`,
{ reason, ...(note ? { note } : {}) },
),
},
/** لایک/دیسلایک و گزارشِ ایراد روی سوال و درس (۰ = پس‌گرفتنِ رأی). */
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 } : {}) }, { reason, ...(note ? { note } : {}) },
), ),
}, },
@@ -104,12 +157,19 @@ export const api = {
}, },
ai: { ai: {
/** «امروز X سوالِ رایگان + Y سوالِ خریده‌شده»، پیش از اولین سوال. */
quota: () => http.get<T.TutorQuota>('/ai/quota'),
/** /**
* پرسش با پاسخِ تکه‌تکه. `lessonId` پاسخ را به کتابِ همان درس مستند می‌کند؛ * پرسش با پاسخِ تکه‌تکه. `lessonId` پاسخ را به کتابِ همان درس مستند می‌کند؛
* `questionId` به دستیار می‌گوید دانش‌آموز کدام سوال را غلط زده. * `questionId` به دستیار می‌گوید دانش‌آموز کدام سوال را غلط زده.
*/ */
async ask( 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, onDelta: (text: string) => void,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<T.AskDone> { ): Promise<T.AskDone> {
@@ -128,57 +188,61 @@ export const api = {
return done; return done;
}, },
feedback: (messageId: string, isHelpful: boolean, reason?: string) => feedback: (messageId: string, isHelpful: boolean, reason?: string) =>
http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, { http.post<{ success: boolean }>(`/ai/messages/${seg(messageId)}/feedback`, {
isHelpful, isHelpful,
...(reason ? { reason } : {}), ...(reason ? { reason } : {}),
}), }),
history: (conversationId: string) => history: (conversationId: string) =>
http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>( http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>(
`/ai/conversations/${conversationId}/messages`, `/ai/conversations/${seg(conversationId)}/messages`,
), ),
}, },
notifications: { notifications: {
list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'), list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'),
markRead: (id: number) => http.patch(`/notifications/${id}/read`), markRead: (id: number) => http.patch(`/notifications/${seg(id)}/read`),
markAllRead: () => http.patch('/notifications/read-all'), markAllRead: () => http.patch('/notifications/read-all'),
}, },
subscription: { subscription: {
plans: () => http.get<{ plans: T.Plan[] }>('/subscription/plans').then((r) => r.plans), plans: () => http.get<T.PlansResponse>('/subscription/plans'),
status: () => http.get<T.SubscriptionStatus>('/subscription/status'), status: () => http.get<T.SubscriptionStatus>('/subscription/status'),
/** سفارش می‌سازد؛ paymentUrl خودش سراغِ درگاه می‌رود. */ /** سفارش می‌سازد؛ paymentUrl خودش سراغِ درگاه می‌رود. */
purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }), purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }),
/**
* خرید داخلِ اپِ اندرویدِ بازار. idempotencyKey ثابت است تا تلاشِ دوباره بعد از قطعِ اینترنت
* همان سفارش را برگرداند (src/lib/bazaar.ts).
*/
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 }),
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */ /** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
redeem: (code: string, orderId: string) => redeem: (code: string, orderId: string) =>
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }), http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${orderId}`), order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${seg(orderId)}`),
/** پرداخت‌ها و فاکتورهای همین کاربر، تازه‌ترین اول. */
invoices: () => http.get<{ invoices: T.Invoice[] }>('/payment/invoices').then((r) => r.invoices),
}, },
support: { support: {
createTicket: (subject: string, category: string) => createTicket: (subject: string, category: string) =>
http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }), http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }),
ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${ticketId}`), /** تیکت‌های همین کاربر، تازه‌ترین اول (۲۰ تای آخر). */
tickets: () => http.get<{ tickets: T.TicketSummary[] }>('/support/tickets').then((r) => r.tickets),
ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${seg(ticketId)}`),
reply: (ticketId: string, text: string) => reply: (ticketId: string, text: string) =>
http.create<{ messageId: string; success: boolean }>(`/support/tickets/${ticketId}/messages`, { text }), http.create<{ messageId: string; success: boolean }>(`/support/tickets/${seg(ticketId)}/messages`, { text }),
bugReport: (title: string, description: string) => bugReport: (title: string, description: string) =>
http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }), http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }),
}, },
family: {
link: () => http.get<T.FamilyLink>('/family/link'),
invite: (childPhone: string) =>
http.post<{ familyLinkId: string; inviteCode: string; status: string }>('/family/link/invite', { childPhone }),
accept: (inviteCode: string) =>
http.post<{ familyLinkId: string; status: string }>('/family/link/accept', { inviteCode }),
weeklyReport: () => http.get<T.FamilyWeeklyReport>('/family/weekly-report'),
settings: () => http.get<T.FamilySettings>('/family/settings'),
updateSettings: (patch: Partial<T.FamilySettings>) => http.patch('/family/settings', patch),
},
affiliate: { affiliate: {
me: () => http.get<T.AffiliateMe>('/affiliate/me'), me: () => http.get<T.AffiliateMe>('/affiliate/me'),
/** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل می‌کند. */ /** کاربرِ تازه را به صاحبِ کدِ معرف وصل می‌کند. */
track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }), track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }),
}, },
}; };

View File

@@ -14,7 +14,6 @@ export interface AuthResponse {
token: string; token: string;
refreshToken: string; refreshToken: string;
isNewUser?: boolean; isNewUser?: boolean;
isGuest?: boolean;
} }
export interface Grade { export interface Grade {
@@ -28,13 +27,15 @@ export interface Grade {
export interface UserProfile { export interface UserProfile {
userId: string; userId: string;
/** شناسه‌ی نمایشی «User482917»؛ همان که پشتیبانی در ادمین جستجو می‌کند. */
publicId: string;
phone: string | null; phone: string | null;
name: string; name: string;
avatar: string; avatar: string;
isGuest: boolean;
gradeId: number | null; gradeId: number | null;
birthYear: number | null; birthYear: number | null;
parentPhone: string; /** تاریخِ تولدِ شمسی «1385-04-12»؛ null وقتی فقط سال داده شده یا هیچ. */
birthDate: string | null;
bio: string; bio: string;
} }
@@ -45,7 +46,10 @@ export interface UserSummary {
gradeId: number | null; gradeId: number | null;
xp: number; xp: number;
coins: number; coins: number;
streak: { current: number; longest: number }; /**
* activeToday: امروز تمرین شده (آتش روشن). lost: طولِ زنجیره‌ای که دیروز شکست (> ۰ یعنی «یخ‌زده»).
*/
streak: { current: number; longest: number; activeToday: boolean; lost: number };
subscription: string | null; subscription: string | null;
} }
@@ -67,6 +71,8 @@ export interface HomeUnit {
id: string; id: string;
title: string; title: string;
blurb: string; blurb: string;
/** این کاربر برای بازیِ این فصل اشتراک لازم دارد (فقط فصلِ اولِ هر پایه رایگان است). */
requiresSubscription: boolean;
nodes: HomeNode[]; nodes: HomeNode[];
} }
@@ -76,7 +82,22 @@ export interface HomeStats {
coins: number; coins: number;
} }
/** یک کتابِ ریاضیِ پایه (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته). */
export interface Subject {
/** همان که به `api.home(subject)` داده می‌شود. */
key: string;
title: string;
/** نامِ آیکنِ lucide. */
icon: string;
color: string;
order: number;
}
export interface HomeResponse { export interface HomeResponse {
/** کتابی که `units` از آن است؛ null فقط وقتی پایه فصلی ندارد. */
subject: Subject | null;
/** همه‌ی کتاب‌های این پایه؛ نوارِ کتاب‌ها فقط با بیش از یکی نشان داده می‌شود. */
subjects: Subject[];
units: HomeUnit[]; units: HomeUnit[];
stats: HomeStats; stats: HomeStats;
} }
@@ -105,10 +126,19 @@ export interface LessonIntro {
kind: 'quiz' | 'video'; kind: 'quiz' | 'video';
example: string; example: string;
steps: string[]; steps: string[];
/** شکل‌های ثابتِ درس (یک بار ساخته و تأییدشده؛ هیچ تولیدِ زنده‌ای نیست). */
figures: LessonFigure[];
videoUrl: string; videoUrl: string;
durationMinutes: number | null; durationMinutes: number | null;
} }
export interface LessonFigure {
where: 'intro' | 'example' | 'step';
/** فقط برای where='step'، از صفر. */
index?: number;
diagram: DiagramSpec;
}
export interface QuestionOption { export interface QuestionOption {
id: string; id: string;
value: string; value: string;
@@ -119,6 +149,33 @@ export interface Question {
type: string; type: string;
prompt: string; prompt: string;
options: QuestionOption[]; options: QuestionOption[];
/** شکلِ سوال (همان قالبِ شکلِ درس‌ها)؛ null یعنی شکل ندارد. */
figure?: DiagramSpec | null;
}
export interface NodeQuizQuestion extends Question {
lessonId: string;
lessonTitle: string;
}
export interface NodeQuiz {
nodeId: string;
nodeTitle: string;
unitTitle: string;
/** ممکن است از requested کمتر باشد (جزیره‌ی کم‌سوال). */
questionCount: number;
requested: number;
questions: NodeQuizQuestion[];
}
export interface NodeQuizComplete {
nodeId: string;
stars: number;
xpEarned: number;
coinsEarned: number;
/** جزیره‌ی بعدی که حالا باز است؛ null یعنی آخرینِ پایه بود. */
nextNodeId: string | null;
stats: HomeStats;
} }
export interface AnswerResult { export interface AnswerResult {
@@ -182,16 +239,24 @@ export interface AskDone {
conversationId: string; conversationId: string;
messageId: string; messageId: string;
suggestedFollowUps: string[]; suggestedFollowUps: string[];
/** سوال‌های رایگانِ باقیِ امروز. */
remainingQuota: number; remainingQuota: number;
usedChunks: UsedChunk[]; usedChunks: UsedChunk[];
diagram?: Diagram | null;
} }
export interface ConversationMessage { export interface ConversationMessage {
messageId: string; messageId: string;
role: 'user' | 'assistant' | string; role: 'user' | 'assistant' | string;
text: string; text: string;
diagram?: Diagram | null;
} }
/* ─── شکلِ پاسخِ دستیار: شِمایش در src/diagram/types.ts ─────────────────── */
import type { DiagramSpec } from '@/diagram/types';
export type Diagram = DiagramSpec;
export interface NotificationItem { export interface NotificationItem {
id: number; id: number;
type: string; type: string;
@@ -212,17 +277,59 @@ export interface Plan {
durationDays: number; durationDays: number;
features: string[]; features: string[];
badge: string; badge: string;
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
aiDailyQuota: number;
/** SKUِ همین اشتراک در کافه‌بازار؛ خالی یعنی از بازار خریدنی نیست. */
bazaarSku: string;
}
export interface PlansResponse {
plans: Plan[];
/** سقفِ روزانه‌ی کاربرِ بی‌اشتراک، برای مقایسه روی کارت. */
freeAiDailyQuota: number;
} }
export interface SubscriptionStatus { export interface SubscriptionStatus {
/** اشتراکِ زمانیِ فعال (یک/سه/شش‌ماهه). */
active: boolean; active: boolean;
/** نامِ پلنِ فعال؛ null بی‌اشتراک. */
planTitle: string | null;
/** طولِ دوره‌ی پلنِ فعال به روز؛ مخرجِ نوارِ «چقدر مانده». */
planDurationDays: number | null;
/** سقفِ سوالِ روزانه‌ی همین کاربر از دستیار. */
aiDailyQuota: number;
planId: string | null; planId: string | null;
expiresAt: string | null; expiresAt: string | null;
} }
/** GET /ai/quota */
export interface TutorQuota {
freeDaily: number;
remainingQuota: number;
}
export interface PurchaseResponse { export interface PurchaseResponse {
orderId: string; orderId: string;
paymentUrl: string; /** زرین‌پال: آدرسِ درگاه. بازار: null. */
paymentUrl: string | null;
/** فقط بازار: SKUی که اپِ اندروید می‌خرد. */
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;
valid: boolean;
} }
export interface RedeemResponse { export interface RedeemResponse {
@@ -239,6 +346,16 @@ export interface OrderStatus {
amount: number; amount: number;
} }
/** یک ردیف از فهرستِ تیکت‌های کاربر (GET /support/tickets). */
export interface TicketSummary {
ticketId: string;
subject: string;
category: string;
status: string;
createdAt: string;
supportReplies: number;
}
export interface Ticket { export interface Ticket {
ticketId: string; ticketId: string;
subject: string; subject: string;
@@ -246,24 +363,58 @@ export interface Ticket {
messages: { id: string; sender: string; text: string }[]; messages: { id: string; sender: string; text: string }[];
} }
export interface FamilyLink { /** یک سفارش به شکلِ فاکتور (GET /payment/invoices). مبلغ‌ها تومان‌اند. */
familyLinkId: string; export interface Invoice {
status: string; orderId: string;
childName: string; invoiceNumber: string;
/** «canceled» و «refunded» را فقط ادمین می‌گذارد (لغو و بازپرداخت، از ۱۴۰۵-۰۷). */
status: 'pending' | 'paid' | 'failed' | 'canceled' | 'refunded';
provider: 'gateway' | 'bazaar' | string;
planTitle: string;
/** قیمتِ پایه پیش از تخفیف؛ null برای سفارش‌های قدیمی‌تر از ثبتِ فاکتور. */
listPrice: number | null;
discountAmount: number | null;
discountCode: string;
amount: number;
currency: string;
createdAt: string;
paidAt: string | null;
/** کدِ پیگیریِ بانکی/بازار؛ خالی برای پرداخت‌نشده یا کدِ تخفیفِ ۱۰۰٪. */
refId: string;
} }
export interface FamilyWeeklyReport { /** یک روز از گزارشِ هفتگی. `minutes` تخمینِ سرور است (از فاصله‌ی جواب‌ها)، نه زمانِ باز بودنِ اپ. */
weekStart: string; export interface ReportDay {
xpEarned: number; date: string;
lessonsCompleted: number; minutes: number;
islandsCompleted: number; questions: number;
currentStreak: number; correct: number;
xp: number;
lessons: number;
islands: number;
aiQuestions: number;
} }
export interface FamilySettings { export interface ReportTotals {
dailyLimitMinutes: number; minutes: number;
quietHoursStart: string; activeDays: number;
quietHoursEnd: string; questions: number;
correct: number;
xp: number;
/** یکتا: درس یا جزیره‌ی تکراری یک بار شمرده می‌شود. */
lessons: number;
islands: number;
aiQuestions: number;
}
/** GET /user/weekly-report: هفت روزِ اخیر و هفت روزِ قبلش، هر دو قدیمی به جدید. */
export interface WeeklyReport {
today: string;
days: ReportDay[];
previousDays: ReportDay[];
thisWeek: ReportTotals;
lastWeek: ReportTotals;
streak: { current: number; longest: number };
} }
export interface AffiliateMe { export interface AffiliateMe {

View File

@@ -1,11 +1,15 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, MotionConfig, motion } from 'motion/react';
import { api, ApiError, http, type Grade, type UserProfile } from '@/api'; import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
import type { Prefetch } from '@/state/AppData';
import { AppShell } from '@/components/AppShell'; import { AppShell } from '@/components/AppShell';
import { ToastProvider } from '@/components/Toast'; import { ToastProvider } from '@/components/Toast';
import { ErrorState } from '@/components/ui'; import { ErrorState } from '@/components/ui';
import { KEYS, store } from '@/lib/storage'; import { KEYS, store } from '@/lib/storage';
import { askStore } from '@/state/askStore'; 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 { LoginScreen } from '@/screens/LoginScreen';
import { OnboardingScreen } from '@/screens/OnboardingScreen'; import { OnboardingScreen } from '@/screens/OnboardingScreen';
import { SetupScreen } from '@/screens/SetupScreen'; import { SetupScreen } from '@/screens/SetupScreen';
@@ -19,30 +23,46 @@ type Flow =
| { kind: 'onboarding' } | { kind: 'onboarding' }
| { kind: 'login' } | { kind: 'login' }
| { kind: 'setup'; profile: UserProfile; grades: Grade[] } | { kind: 'setup'; profile: UserProfile; grades: Grade[] }
| { kind: 'main'; profile: UserProfile; grades: Grade[] }; | { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch; stale?: boolean };
/** کمترین زمانِ نمایشِ اسپلش، تا صفحه چشمک نزند. */
const SPLASH_MIN_MS = 700;
/** /**
* جریانِ ورود: اسپلش → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ. * جریانِ ورود: اسپلشِ ثابت (تا بررسیِ نشست) → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ.
* خودِ اپ (تب‌ها و صفحه‌ها) در MainApp است. * کاربرِ واردشده‌ای که عکسِ خانه روی گوشی دارد (state/homeSnapshot.ts) بی‌انتظار به خانه می‌رود و
* داده‌ها پشتِ صحنه تازه می‌شوند. خودِ اپ (تب‌ها و صفحه‌ها) در MainApp است.
*/ */
export function App() { export function App() {
const [flow, setFlow] = useState<Flow>({ kind: 'boot' }); const [flow, setFlow] = useState<Flow>({ kind: 'boot' });
// با هر rebootFresh یکی بالا می‌رود و جزوِ کلیدِ صفحه است. بی‌آن «main → boot → main» برای AnimatePresence
// همان کلید است و MainAppِ در حالِ خروج (با داده‌ی عکسِ غلط) به‌جای MainAppِ تازه سرِ جایش می‌ماند.
const [generation, setGeneration] = useState(0);
/** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */ /** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */
const enter = useCallback(async () => { const enter = useCallback(async () => {
const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]); // نقشه و آمار هم‌زمان با پروفایل گرفته می‌شوند تا خانه کامل بیاید، نه اول «دارم مسیرت رو میارم» و آمارِ صفر.
await trackReferral(profile); // شکستشان ورود را نمی‌خواباند: AppData همان‌ها را دوباره می‌گیرد و خطای نقشه را روی خانه نشان می‌دهد.
setFlow(profile.name && profile.gradeId ? { kind: 'main', profile, grades } : { kind: 'setup', profile, grades }); const profileReq = api.profile.get();
const [profile, grades, prefetch] = await Promise.all([profileReq, api.grades(), prefetchMain(profileReq)]);
await trackReferral();
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades, prefetch });
else setFlow({ kind: 'setup', profile, grades });
}, []); }, []);
const boot = useCallback(async () => { const boot = useCallback(async () => {
setFlow({ kind: 'boot' }); setFlow({ kind: 'boot' });
const started = Date.now();
let next: Flow | null = null; let next: Flow | null = null;
if (api.auth.isSignedIn()) { 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 { try {
await enter(); await enter();
} catch (error) { } catch (error) {
@@ -54,8 +74,6 @@ export function App() {
} else { } else {
next = store.get(KEYS.onboarded) ? { kind: 'login' } : { kind: 'onboarding' }; 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); if (next) setFlow(next);
}, [enter]); }, [enter]);
@@ -63,16 +81,34 @@ export function App() {
void boot(); void boot();
}, [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 هم شکست خورد)، برگرد به صفحه‌ی ورود. // هر جا نشست باطل شد (refresh هم شکست خورد)، برگرد به صفحه‌ی ورود.
useEffect( useEffect(
() => () =>
http.onSignedOut(() => { http.onSignedOut(() => {
askStore.clear(); askStore.clear();
clearAsyncCache();
setFlow({ kind: 'login' }); setFlow({ kind: 'login' });
}), }),
[], [],
); );
// عکسِ خانه مالِ کسِ دیگری بود یا پروفایل حالا ناقص است: عکس کنار می‌رود و بوتِ معمولی تصمیم می‌گیرد.
const rebootFresh = useCallback(() => {
homeSnapshot.clear();
askStore.clear();
clearAsyncCache();
setGeneration((g) => g + 1);
void boot();
}, [boot]);
const afterLogin = async () => { const afterLogin = async () => {
try { try {
await enter(); await enter();
@@ -82,16 +118,19 @@ export function App() {
}; };
return ( return (
// reducedMotion="user": اگر «کاهشِ حرکت» در گوشی روشن باشد، motion جابه‌جایی‌ها را حذف می‌کند.
<MotionConfig reducedMotion="user">
<AppShell> <AppShell>
<ToastProvider> <ToastProvider>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait" initial={false}>
<motion.div <motion.div
key={flow.kind} key={`${flow.kind}:${generation}`}
className="flex h-full flex-col" className="flex h-full flex-col"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} // اسپلش بی‌محو: با عوض‌شدنِ صفحه فوراً می‌رود و صفحه‌ی بعد خودش محو می‌شود.
transition={{ duration: flow.kind === 'boot' ? 0 : 0.2 }}
> >
{flow.kind === 'boot' && <SplashScreen />} {flow.kind === 'boot' && <SplashScreen />}
{flow.kind === 'bootError' && ( {flow.kind === 'bootError' && (
@@ -119,25 +158,40 @@ export function App() {
<MainApp <MainApp
profile={flow.profile} profile={flow.profile}
grades={flow.grades} grades={flow.grades}
prefetch={flow.prefetch}
stale={flow.stale}
onIdentityChanged={rebootFresh}
payment={launchPayment} payment={launchPayment}
onSignedOut={() => setFlow({ kind: 'login' })} onSignedOut={() => {
onRegistered={trackReferral} clearAsyncCache();
setFlow({ kind: 'login' });
}}
/> />
)} )}
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</ToastProvider> </ToastProvider>
</AppShell> </AppShell>
</MotionConfig>
); );
} }
/** /** داده‌ی صفحه‌ی اول؛ هر کدام که نرسید null می‌ماند و AppData خودش دوباره می‌گیرد. */
* اگر کاربر با لینکِ دعوت آمده بود و حالا با شماره وارد شده، معرفی را ثبت کن. async function prefetchMain(profile: Promise<UserProfile>): Promise<Prefetch> {
* مهمان را سرور نمی‌پذیرد، پس کد تا ثبتِ شماره نگه داشته می‌شود. // کتابِ انتخاب‌شده به پایه بسته است؛ فقط وقتی چیزی ذخیره شده منتظرِ پروفایل می‌مانیم (پایه‌های ≤ دهم
*/ // معمولاً هیچ انتخابی ندارند و نقشه هم‌زمان با پروفایل می‌آید).
async function trackReferral(profile: UserProfile) { 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); const code = store.get(KEYS.referralCode);
if (!code || profile.isGuest) return; if (!code) return;
try { try {
await api.affiliate.track(code); await api.affiliate.track(code);
store.remove(KEYS.referralCode); store.remove(KEYS.referralCode);

View File

@@ -1,82 +1,296 @@
import { useEffect, useState } from 'react'; import { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
import { AnimatePresence, motion } from 'motion/react'; import { motion } from 'motion/react';
import { CheckCircle2, XCircle } from 'lucide-react'; import { CheckCircle2, XCircle } from 'lucide-react';
import { api, type Grade, type UserProfile } from '@/api'; import { api, type Grade, type UserProfile } from '@/api';
import { AppDataProvider, useAppData } from '@/state/AppData'; import { AppDataProvider, useAppData, type Prefetch } from '@/state/AppData';
import { NavigatorProvider, useNav } from '@/nav/Navigator'; import { NavigatorProvider, useNav, type NavDirection } from '@/nav/Navigator';
import { ScreenGateContext, type ScreenGate } from '@/lib/useAsync';
import { isTab, type Route } from '@/nav/routes'; import { isTab, type Route } from '@/nav/routes';
import { BottomNav } from '@/components/BottomNav'; import { BottomNav } from '@/components/BottomNav';
import { Sheet } from '@/components/Sheet'; import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast'; import { LoadingState, PrimaryButton } from '@/components/ui';
import { PrimaryButton } from '@/components/ui';
import { AskScreen } from '@/screens/AskScreen';
import { CharactersScreen } from '@/screens/CharactersScreen';
import { FamilyScreen } from '@/screens/FamilyScreen';
import { HomeScreen } from '@/screens/HomeScreen'; import { HomeScreen } from '@/screens/HomeScreen';
import { InviteScreen } from '@/screens/InviteScreen';
import { LeaderboardScreen } from '@/screens/LeaderboardScreen';
import { LessonScreen } from '@/screens/LessonScreen';
import { LoginScreen } from '@/screens/LoginScreen';
import { MapScreen } from '@/screens/MapScreen';
import { NotificationsScreen } from '@/screens/NotificationsScreen';
import { ProfileScreen } from '@/screens/ProfileScreen';
import { QuizScreen } from '@/screens/QuizScreen';
import { StreakScreen } from '@/screens/StreakScreen';
import { SubscriptionScreen } from '@/screens/SubscriptionScreen';
import { SupportScreen } from '@/screens/SupportScreen';
import { UnitScreen } from '@/screens/UnitScreen';
import type { PaymentReturn } from './launchParams'; import type { PaymentReturn } from './launchParams';
/**
* خانه همراهِ خودِ اپ بار می‌شود؛ بقیه‌ی صفحه‌ها هر کدام فایلِ جدا دارند تا بارِ اول
* سبک باشد (قبلاً کلِ اپ یک فایلِ ۵۰۵ کیلوبایتی بود، و دستیار با ماژولِ شکل‌ها
* بزرگ‌ترین تکه‌اش). بعد از بالا آمدنِ خانه همه در پس‌زمینه گرفته می‌شوند
* (preloadScreens)، پس رفتن به صفحه‌ی بعدی منتظرِ شبکه نمی‌ماند.
*/
const loaders: (() => Promise<unknown>)[] = [];
function screen<M extends Record<K, ComponentType<any>>, K extends keyof M>(load: () => Promise<M>, name: K) {
loaders.push(load);
return lazy(async () => ({ default: (await load())[name] }));
}
const AskScreen = screen(() => import('@/screens/AskScreen'), 'AskScreen');
const CharactersScreen = screen(() => import('@/screens/CharactersScreen'), 'CharactersScreen');
const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScreen');
const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen');
const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen');
const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen');
const LessonsScreen = screen(() => import('@/screens/LessonsScreen'), 'LessonsScreen');
const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen');
const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen');
const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen');
const QuizScreen = screen(() => import('@/screens/QuizScreen'), 'QuizScreen');
const StreakScreen = screen(() => import('@/screens/StreakScreen'), 'StreakScreen');
const SubscriptionScreen = screen(() => import('@/screens/SubscriptionScreen'), 'SubscriptionScreen');
const SupportScreen = screen(() => import('@/screens/SupportScreen'), 'SupportScreen');
const UnitScreen = screen(() => import('@/screens/UnitScreen'), 'UnitScreen');
let preloaded = false;
function preloadScreens() {
if (preloaded) return;
preloaded = true;
const run = () => loaders.forEach((load) => void load().catch(() => {}));
if ('requestIdleCallback' in window) window.requestIdleCallback(run, { timeout: 2500 });
else setTimeout(run, 1200);
}
interface Props { interface Props {
profile: UserProfile; profile: UserProfile;
grades: Grade[]; grades: Grade[];
prefetch?: Prefetch;
/** داده‌ها از عکسِ خانه روی گوشی آمده‌اند و پشتِ صحنه تازه می‌شوند (AppData). */
stale?: boolean;
onIdentityChanged?: () => void;
payment: PaymentReturn | null; payment: PaymentReturn | null;
onSignedOut: () => void; onSignedOut: () => void;
onRegistered: (profile: UserProfile) => Promise<void>;
} }
/** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */ /** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */
export function MainApp({ profile, grades, payment, onSignedOut, onRegistered }: Props) { export function MainApp({ profile, grades, prefetch, stale, onIdentityChanged, payment, onSignedOut }: Props) {
useEffect(preloadScreens, []);
return ( return (
<AppDataProvider initialProfile={profile} grades={grades}> <AppDataProvider
initialProfile={profile}
grades={grades}
prefetch={prefetch}
stale={stale}
onIdentityChanged={onIdentityChanged}
>
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}> <NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
<Screens onSignedOut={onSignedOut} onRegistered={onRegistered} /> <Screens onSignedOut={onSignedOut} />
{payment && <PaymentResult payment={payment} />} {payment && <PaymentResult payment={payment} />}
</NavigatorProvider> </NavigatorProvider>
</AppDataProvider> </AppDataProvider>
); );
} }
function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onRegistered'>) { /**
* صحنه‌ی صفحه‌ها. صفحه‌ی تازه اول پنهان زیرِ صفحه‌ی فعلی ساخته می‌شود و فقط وقتی داده‌ی اولش رسید نشان داده
* می‌شود (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 nav = useNav();
const { route } = nav; const { route, direction, canGoBack } = nav;
const showNav = isTab(route.name) && nav.tab === route.name; const nextId = useRef(1);
// صفحه‌ی اولِ بعد از ورود بی‌انتظار و بی‌حرکت می‌آید (داده‌ی خانه پیش از آن در اسپلش گرفته شده).
const [layers, setLayers] = useState<Layer[]>(() => [{ id: 0, route, direction, root: true, shown: true }]);
useEffect(() => {
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 ( return (
<> <>
<div className="relative flex-1 overflow-hidden"> <div ref={stage} className="relative flex-1 overflow-hidden bg-page">
<AnimatePresence mode="popLayout" initial={false}> {layers.map((layer) => (
<motion.div <ScreenLayer key={layer.id} layer={layer} onReady={reveal} onSettled={settle} onSignedOut={onSignedOut} />
key={JSON.stringify(route)} ))}
className="absolute inset-0 flex flex-col" <HoldBar active={waiting} />
initial={{ opacity: 0, x: -18 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 18 }}
transition={{ duration: 0.18 }}
>
<RouteView route={route} onSignedOut={onSignedOut} onRegistered={onRegistered} />
</motion.div>
</AnimatePresence>
</div> </div>
{showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />} {showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />}
</> </>
); );
} }
function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick<Props, 'onSignedOut' | 'onRegistered'>) { 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) { switch (route.name) {
case 'home': return <HomeScreen />; case 'home': return <HomeScreen />;
case 'map': return <MapScreen />; case 'lessons': return <LessonsScreen />;
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />; case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
case 'subscription': return <SubscriptionScreen />; case 'subscription': return <SubscriptionScreen />;
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />; case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
@@ -88,59 +302,53 @@ function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick
case 'characters': return <CharactersScreen />; case 'characters': return <CharactersScreen />;
case 'streak': return <StreakScreen />; case 'streak': return <StreakScreen />;
case 'support': return <SupportScreen />; case 'support': return <SupportScreen />;
case 'family': return <FamilyScreen />; case 'report': return <ReportScreen />;
case 'invoices': return <InvoicesScreen />;
case 'invite': return <InviteScreen />; case 'invite': return <InviteScreen />;
case 'register': return <RegisterView onRegistered={onRegistered} />;
} }
} }
/** مهمانی که شماره ثبت می‌کند؛ همان کاربر می‌ماند و فقط پروفایل تازه می‌شود. */ /**
function RegisterView({ onRegistered }: Pick<Props, 'onRegistered'>) { * نتیجه‌ی برگشت از درگاه. فقط پاسخِ سرور تعیین می‌کند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر
const nav = useNav(); * لینکِ ساختگی می‌تواند «paid» بگذارد.
const { setProfile } = useAppData(); */
const toast = useToast();
return (
<LoginScreen
upgrade
onCancel={nav.back}
onDone={async () => {
try {
const profile = await api.profile.get();
setProfile(profile);
await onRegistered(profile);
} catch {
/* ورود انجام شده؛ پروفایل با بازگشت به اپ تازه می‌شود */
}
toast('شماره‌ت ثبت شد! پیشرفتت امنه.', 'success');
nav.back();
}}
/>
);
}
/** نتیجه‌ی برگشت از درگاه؛ وضعیتِ سفارش از خودِ سرور هم دوباره خوانده می‌شود. */
function PaymentResult({ payment }: { payment: PaymentReturn }) { function PaymentResult({ payment }: { payment: PaymentReturn }) {
const { refreshSummary } = useAppData(); const { refreshSummary } = useAppData();
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [paid, setPaid] = useState(payment.status === 'paid'); const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking');
useEffect(() => { useEffect(() => {
api.subscription api.subscription
.order(payment.orderId) .order(payment.orderId)
.then((order) => setPaid(order.status === 'paid')) .then((order) => setState(order.status === 'paid' ? 'paid' : 'failed'))
.catch(() => {}); .catch((err: unknown) => {
console.warn('payment result lookup failed', err);
setState('unknown');
});
void refreshSummary(); void refreshSummary();
}, [payment.orderId, refreshSummary]); }, [payment.orderId, refreshSummary]);
if (state === 'checking') {
return (
<Sheet open={open} onClose={() => setOpen(false)}>
<LoadingState text="دارم پرداخت رو بررسی می‌کنم…" />
</Sheet>
);
}
const paid = state === 'paid';
return ( return (
<Sheet open={open} onClose={() => setOpen(false)}> <Sheet open={open} onClose={() => setOpen(false)}>
<div className="flex flex-col items-center gap-3 pb-2 text-center"> <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" />} {paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />}
<h2 className="text-lg font-black text-ink">{paid ? 'پرداخت موفق بود! 🎉' : 'پرداخت انجام نشد'}</h2> <h2 className="text-lg font-black text-ink">
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیت پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
</h2>
<p className="text-sm leading-7 text-muted"> <p className="text-sm leading-7 text-muted">
{paid {paid
? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.' ? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.'
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'} : state === 'unknown'
? 'چند دقیقه دیگه از «پرداخت‌ها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال می‌شه یا پول خودکار برمی‌گرده.'
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طی چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'}
</p> </p>
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton> <PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
</div> </div>

View File

@@ -22,7 +22,8 @@ function readLaunchParams(): PaymentReturn | null {
if (ref || orderId) { if (ref || orderId) {
window.history.replaceState(null, '', window.location.pathname); window.history.replaceState(null, '', window.location.pathname);
} }
return orderId ? { orderId, status: status ?? '' } : null; // فقط شماره‌ی سفارش: هر چیزِ دیگری (مثلاً «../../user/delete») از یک لینکِ ساختگی آمده است.
return orderId && /^\d{1,12}$/.test(orderId) ? { orderId, status: status ?? '' } : null;
} }
/** یک‌بار، هنگامِ بار شدنِ اپ. */ /** یک‌بار، هنگامِ بار شدنِ اپ. */

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

@@ -1,66 +0,0 @@
import { Minus, Plus } from 'lucide-react';
import { currentJalaliYear, toFa } from '@/lib/format';
import { sounds } from '@/lib/sound';
const MIN_AGE = 7;
const MAX_AGE = 19;
const DEFAULT_AGE = 13;
/** سرور سالِ تولدِ شمسی می‌خواهد، نه سن. */
export const birthYearFromAge = (age: number) => currentJalaliYear() - age;
export function ageFromBirthYear(year: number | null | undefined): number | null {
if (!year) return null;
const age = currentJalaliYear() - year;
return age >= MIN_AGE && age <= MAX_AGE ? age : null;
}
/**
* انتخابِ سن با دو دکمه‌ی − و +. جای «چرخِ» قبلی نشست که عددِ وسطش با مقدارِ
* واقعی نمی‌خواند و کلیک روی عددهایش کار نمی‌کرد.
*/
export function AgePicker({ value, onChange }: { value: number | null; onChange: (age: number) => void }) {
const current = value ?? DEFAULT_AGE;
const step = (delta: number) => {
const next = Math.max(MIN_AGE, Math.min(MAX_AGE, current + delta));
if (next !== current || value === null) {
sounds.tick();
onChange(next);
}
};
return (
<div className="flex items-center justify-between rounded-2xl border-2 border-line bg-white p-2">
<button
type="button"
aria-label="کمتر"
onClick={() => step(-1)}
disabled={value !== null && current <= MIN_AGE}
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
>
<Minus size={20} strokeWidth={3} />
</button>
<div className="text-center" aria-live="polite">
{value === null ? (
<button type="button" onClick={() => onChange(DEFAULT_AGE)} className="text-sm font-bold text-muted">
بزن تا انتخاب کنی
</button>
) : (
<>
<span className="text-3xl font-black text-coral">{toFa(value)}</span>
<span className="mr-1 text-sm font-bold text-muted">سال</span>
</>
)}
</div>
<button
type="button"
aria-label="بیشتر"
onClick={() => step(1)}
disabled={value !== null && current >= MAX_AGE}
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
>
<Plus size={20} strokeWidth={3} />
</button>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react';
import { toEnDigits, toFa } from '@/lib/format';
/**
* تاریخِ تولدِ شمسی با سه فهرستِ روز/ماه/سال (از ۱۴۰۵-۰۷ به جای «چند سالته؟»).
* <select> عمداً بومی است: روی گوشی همان چرخِ آشنای خودِ گوشی باز می‌شود و تقویمِ
* ماهانه‌ای که برای تاریخِ سی سال پیش باید ده‌ها بار ورق بخورد لازم نیست.
*
* سرور تاریخ را شمسی می‌گیرد («1385-04-12») و خودش به میلادی تبدیل می‌کند
* (accounts/jalali.py)؛ این‌جا هیچ تبدیلی انجام نمی‌شود، فقط تعدادِ روزهای ماه.
*/
export const JALALI_MONTHS = [
'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور',
'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند',
];
/** همان بازه‌ی سرور (accounts/serializers.py: MIN_AGE / MAX_AGE). */
const MIN_AGE = 5;
const MAX_AGE = 99;
/** از این سن به بعد فرمِ آشنایی به‌جای «کدوم پایه‌ای؟» می‌پرسد «ریاضیِ کدوم پایه؟». */
export const ADULT_AGE = 19;
type Parts = { y: number; m: number; d: number };
function jalaliParts(date: Date): Parts {
const parts = new Intl.DateTimeFormat('en-US-u-ca-persian', { year: 'numeric', month: 'numeric', day: 'numeric' }).formatToParts(date);
const get = (type: string) => parseInt(toEnDigits(parts.find((p) => p.type === type)?.value ?? '0'), 10);
return { y: get('year'), m: get('month'), d: get('day') };
}
export const todayJalali = () => jalaliParts(new Date());
/** کبیسه یعنی سی‌امِ اسفند وجود دارد؛ آن روز همیشه یکی از ۱۹ تا ۲۱ مارسِ سالِ بعدِ میلادی است. */
function isLeap(y: number): boolean {
return [19, 20, 21].some((day) => {
const p = jalaliParts(new Date(y + 622, 2, day, 12));
return p.y === y && p.m === 12 && p.d === 30;
});
}
function monthLength(y: number, m: number): number {
if (m <= 6) return 31;
if (m <= 11) return 30;
return isLeap(y) ? 30 : 29;
}
function parse(value: string | null | undefined): Parts | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
return match ? { y: +match[1], m: +match[2], d: +match[3] } : null;
}
const pad = (n: number) => String(n).padStart(2, '0');
/** «1385-04-12» → «۱۲ تیر ۱۳۸۵». */
export function formatBirthDate(value: string | null): string {
const p = parse(value);
return p ? `${toFa(p.d)} ${JALALI_MONTHS[p.m - 1]} ${toFa(p.y)}` : '';
}
/** سنِ کامل از تاریخِ تولد؛ برای کاربرِ قدیمی که فقط سال داده، تقریبی از سال. */
export function ageOf(birthDate: string | null, birthYear: number | null): number | null {
const today = todayJalali();
const born = parse(birthDate);
if (born) return today.y - born.y - (today.m < born.m || (today.m === born.m && today.d < born.d) ? 1 : 0);
return birthYear ? today.y - birthYear : null;
}
export function BirthDatePicker({
value,
fallbackYear = null,
onChange,
}: {
value: string | null;
/** سالِ تولدِ کاربرِ قدیمی (بی‌تاریخ) تا فهرستِ سال خالی باز نشود. */
fallbackYear?: number | null;
/** فقط وقتی هر سه انتخاب شده‌اند تاریخ می‌دهد؛ تا آن موقع null. */
onChange: (value: string | null) => void;
}) {
const initial = parse(value);
const [y, setY] = useState<number | ''>(initial?.y ?? fallbackYear ?? '');
const [m, setM] = useState<number | ''>(initial?.m ?? '');
const [d, setD] = useState<number | ''>(initial?.d ?? '');
// والد هر بار تابعِ تازه می‌سازد؛ با ref، تغییرِ تابع خودش onChange را دوباره صدا نمی‌زند.
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const thisYear = todayJalali().y;
const years = Array.from({ length: MAX_AGE - MIN_AGE + 1 }, (_, i) => thisYear - MIN_AGE - i);
const days = monthLength(typeof y === 'number' ? y : thisYear, typeof m === 'number' ? m : 1);
// سی‌ویکِ مهر یا سی‌امِ اسفندِ غیرکبیسه با عوض شدنِ ماه/سال معتبر نمی‌ماند.
useEffect(() => {
if (typeof d === 'number' && d > days) setD(days);
}, [d, days]);
useEffect(() => {
const complete = y !== '' && m !== '' && d !== '' && d <= days;
onChangeRef.current(complete ? `${y}-${pad(m)}-${pad(d)}` : null);
}, [y, m, d, days]);
const select = 'w-full appearance-none rounded-2xl border-2 bg-white px-3 py-3.5 text-center text-sm font-bold outline-none focus:border-coral';
const tone = (v: number | '') => (v === '' ? 'border-line text-muted' : 'border-coral text-ink');
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)}`}>
<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)}`}>
<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)}`}>
<option value="">سال</option>
{years.map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
</select>
</div>
);
}

View File

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

View File

@@ -0,0 +1,13 @@
import { toFa } from '@/lib/format';
import { BrandMark } from '@/components/BrandMark';
/** امضای تدریسینو پایینِ صفحه‌ها: نشان، نام و حقِ نشر، کوچک و کم‌رنگ. */
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>
</footer>
);
}

View File

@@ -0,0 +1,31 @@
import { useId } from 'react';
import { useCompanion } from '@/state/companion';
/**
* نشانِ تدریسینو: یک کاشیِ گرد با نمادِ همراهِ انتخابیِ کاربر (Σ، Δ، π، ω) به رنگِ همان همراه، و جرقه‌ی کوچکِ
* چشمک‌زن، کنارِ نامِ تدریسینو. پیش‌تر همیشه «π»ِ نارنجی بود و با همراهِ کاربر جور نبود (خواستِ مالک، ۱۴۰۵-۰۷).
* خودِ 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="تدریسینو ریاضی">
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true">
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
<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(#${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>}
</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

@@ -0,0 +1,189 @@
import { forwardRef, memo, useMemo } from 'react';
import { motion } from 'motion/react';
import { Check, Lock, Play, Star } from 'lucide-react';
import type { HomeNode } from '@/api';
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;
const TOP = 70;
/**
* مسیرِ جزیره‌های یک فصل، مثلِ دولینگو. جزیره‌ی فعلی یک دکمه‌ی «شروع» بالای
* سرش دارد که مستقیم آزمون را باز می‌کند؛ زدنِ خودِ جزیره برگه‌ی جزئیات را.
*
* `currentRef` به جزیره‌ی فعلی وصل می‌شود تا خانه بتواند تا آنجا اسکرول کند.
*/
export const IslandPath = forwardRef<HTMLDivElement, {
nodes: HomeNode[];
color: string;
/** جزیره‌ای که باید «شروع» بخورد؛ null یعنی در این فصل نیست. */
currentId: string | null;
onOpen: (node: HomeNode) => void;
onStart: (node: HomeNode) => void;
}>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) {
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';
const current = node.id === currentId;
const Icon = iconFor(node.icon, i);
return (
<motion.div
key={node.id}
ref={current ? currentRef : undefined}
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"
style={{ left: `${points[i].x}%`, top: points[i].y }}
>
{current && (
<>
<motion.button
type="button"
onClick={() => onStart(node)}
animate={{ y: [0, -5, 0] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
className="absolute -top-[62px] left-1/2 z-20 flex -translate-x-1/2 items-center gap-1.5 whitespace-nowrap rounded-2xl px-4 py-2 text-sm font-black text-white"
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.25)}` }}
>
<Play size={14} fill="white" /> {node.status === 'in_progress' ? 'ادامه' : 'شروع'}
<span className="absolute -bottom-1.5 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45" style={{ backgroundColor: color }} />
</motion.button>
<motion.div
animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.06, 0.3] }}
transition={{ duration: 1.8, repeat: Infinity }}
className="absolute inset-[-12px] rounded-full"
style={{ backgroundColor: color }}
/>
</>
)}
<button
type="button"
aria-label={`${node.title}${locked ? ' (قفل)' : done ? ' (تمام‌شده)' : ''}`}
onClick={() => onOpen(node)}
className="relative z-10 flex items-center justify-center rounded-full transition-transform active:translate-y-1"
style={{
width: NODE_SIZE,
height: NODE_SIZE,
backgroundColor: locked ? '#e7e6ef' : done ? color : '#fff',
border: current ? `5px solid ${color}` : 'none',
boxShadow: locked ? '0 6px 0 #cfcedb' : done ? `0 7px 0 ${shade(color, -0.25)}` : `0 7px 0 ${shade(color, -0.25)}`,
}}
>
{locked ? (
<Lock size={24} strokeWidth={2.5} className="text-[#a9a8b8]" />
) : done ? (
<Check size={32} color="white" strokeWidth={3.5} />
) : (
<Icon size={30} style={{ color }} strokeWidth={2.5} />
)}
{done && (
<Star size={16} className="absolute -right-1 -top-1 text-[#FFC53D]" fill="#FFC53D" />
)}
</button>
{/* اسم کنارِ جزیره، نه زیرش: فضای بینِ دو جزیره مالِ دکمه‌ی «شروع» است
و اسمِ زیرِ جزیره‌ی قبلی زیرِ آن دکمه پنهان می‌شد. سمتی که جای بیشتری دارد. */}
<div
className={`absolute top-1/2 w-28 -translate-y-1/2 text-[11px] font-bold leading-4 ${
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-3"><MathText text={node.title} /></span>
</div>
</motion.div>
);
})}
</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

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { BookOpen, CheckCircle2, ChevronDown, Circle, Lock, Play, RotateCw } from 'lucide-react';
import { api, type HomeNode } from '@/api';
import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet';
import { ErrorState, PrimaryButton } from '@/components/ui';
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
/**
* برگه‌ی جزیره. دو دکمه: «اول درس‌ها رو بخون» (رنگی و بالا، چون تست‌کننده‌ها
* دکمه‌ی کم‌رنگِ قبلی را پیدا نمی‌کردند) و «شروعِ آزمونِ جزیره». فهرستِ درس‌ها زیرِ دکمه‌ی اول باز می‌شود.
*/
export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
const nav = useNav();
const [showLessons, setShowLessons] = useState(false);
const lessons = useAsync(
() => (node && showLessons ? api.nodeLessons(node.id) : Promise.resolve(null)),
[node?.id, showLessons],
);
const locked = node?.status === 'locked';
const done = node?.status === 'completed';
const close = () => {
setShowLessons(false);
onClose();
};
const start = () => {
if (!node) return;
close();
nav.push({ name: 'quiz', nodeId: node.id });
};
const openLesson = (lessonId: number) => {
if (!node) return;
close();
nav.push({ name: 'lesson', lessonId, nodeId: node.id });
};
// فقط درس‌هایی که سوال دارند در lessonIds هستند.
const playable = new Set(node?.lessonIds ?? []);
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
return (
<Sheet open={node !== null} onClose={close} title={node ? <MathText text={node.title} /> : undefined}>
{locked ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<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>
</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" /> شروع آزمون جزیره</>}
</PrimaryButton>
<button
type="button"
onClick={() => setShowLessons((s) => !s)}
className="mt-3 mb-1 flex w-full items-center justify-center gap-2 rounded-2xl border-2 py-3 text-sm font-black transition-colors active:translate-y-[2px]"
style={{ borderColor: '#39FF14', color: '#15803d', backgroundColor: showLessons ? '#39FF1422' : 'white' }}
aria-expanded={showLessons}
>
<BookOpen size={18} /> {done ? 'مرور درس‌ها' : 'اول درس‌ها رو بخون'}
<ChevronDown size={16} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} />
</button>
{showLessons && (
lessons.loading ? (
<div className="py-4 text-center text-sm text-muted">دارم درس‌ها رو میارم…</div>
) : lessons.error ? (
<ErrorState error={lessons.error} onRetry={lessons.reload} />
) : (
<>
<p className="mb-2 mt-1 text-xs text-muted">
{toFa(list.filter((l) => l.done).length)} از {toFa(list.length)} درس خونده شده
</p>
<ul className="flex max-h-60 flex-col gap-1.5 overflow-y-auto">
{list.map((lesson) => (
<li key={lesson.id}>
<button
type="button"
onClick={() => openLesson(Number(lesson.id))}
className="flex w-full items-center gap-3 rounded-2xl border border-line bg-white px-3 py-2.5 text-right text-sm"
>
{lesson.done ? (
<CheckCircle2 size={18} className="flex-shrink-0 text-mint" />
) : (
<Circle size={18} className="flex-shrink-0 text-faint" />
)}
<span className={`flex-1 ${lesson.done ? 'text-muted' : 'text-ink'}`}><MathText text={lesson.title} /></span>
</button>
</li>
))}
</ul>
</>
)
)}
</>
)}
</Sheet>
);
}

View File

@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react';
import { motion } from 'motion/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) و پایانِ درس یا
* جزیره را هم بی‌جواب نمی‌پذیرد. این صفحه شمارشِ معکوسِ قلبِ بعدی را زنده نشان می‌دهد و وقتی رسید،
* خودش از سرور می‌پرسد و دکمه‌ی «ادامه بده» را می‌آورد.
*/
/** همان HEART_REFILL_INTERVALِ سرور (progress/models.py)؛ فقط برای پرشدنِ نوار و قلبِ اول. */
const REFILL_MS = 5 * 60 * 60 * 1000;
const MAX_HEARTS = 5;
function useNow(): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, []);
return now;
}
/** «۴:۰۷:۳۲» */
function clock(ms: number): string {
const s = Math.max(0, Math.ceil(ms / 1000));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const pad = (n: number) => String(n).padStart(2, '0');
return toFa(`${h}:${pad(m)}:${pad(s % 60)}`);
}
/** «ساعتِ ۱۸:۳۰ امروز» / «… فردا» / «… شنبه» */
function whenLabel(iso: string, now: number): string {
const at = new Date(iso);
const time = new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit', hour12: false }).format(at);
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}`;
}
export function NoHeartsScreen({
nextRefillAt,
fullRefillAt,
onRefilled,
}: {
nextRefillAt: string | null;
fullRefillAt: string | null;
/** قلب برگشت و کاربر «ادامه بده» زد. */
onRefilled: () => void;
}) {
const companionName = useCompanion().name;
const nav = useNav();
const { companion } = useAppData();
const now = useNow();
const [next, setNext] = useState(nextRefillAt);
const [full, setFull] = useState(fullRefillAt);
const [back, setBack] = useState(false);
const left = next ? Date.parse(next) - now : null;
const progress = left === null ? 0 : Math.min(1, Math.max(0, 1 - left / REFILL_MS));
const due = left !== null && left <= 0;
// زمانِ قلبِ بعدی رسید: از سرور می‌پرسیم (ساعتِ گوشی ممکن است جلو باشد).
useEffect(() => {
if (!due || back) return;
let alive = true;
const check = () =>
api.hearts().then((h) => {
if (!alive) return;
if (h.isUnlimited || h.currentHearts > 0) setBack(true);
else {
setNext(h.nextRefillAt);
setFull(h.fullRefillAt);
}
}).catch(() => undefined);
void check();
return () => {
alive = false;
};
}, [due, back]);
return (
<Screen className="bg-gradient-to-b from-[#FFF1EE] via-page to-page">
<div className="flex justify-start px-5 pt-safe">
<button type="button" aria-label="برگشت" onClick={nav.back} className="mt-5 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow-sm">
<X size={16} className="text-coral" />
</button>
</div>
<div className="flex flex-1 flex-col items-center gap-5 overflow-y-auto px-6 pb-8 pt-2 text-center [&>*]:shrink-0">
<motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="relative">
<div className="rounded-full bg-white p-4 shadow-[0_8px_30px_rgba(237,105,86,0.18)]">
<SeyarMascot char={companion} mood={back ? 'excited' : 'depressed'} size={104} />
</div>
<motion.span
className="absolute -left-2 -top-1 flex h-11 w-11 items-center justify-center rounded-full bg-white shadow"
animate={back ? { scale: [1, 1.25, 1] } : { rotate: [0, -8, 8, 0] }}
transition={{ duration: back ? 0.6 : 2.4, repeat: back ? 0 : Infinity }}
>
{back ? <Heart size={24} className="text-coral" fill="#ED6956" /> : <HeartCrack size={24} className="text-coral" />}
</motion.span>
</motion.div>
<div>
<h1 className="text-2xl font-black text-ink">{back ? 'یه قلب برگشت!' : 'قلب‌هات تموم شد'}</h1>
<p className="mt-1.5 text-sm leading-7 text-muted">
{back ? 'بریم ادامه بدیم؛ این بار با دقت بیشتر' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'}
</p>
</div>
<HeartSlots progress={back ? 1 : progress} />
{back ? (
<div className="w-full">
<PrimaryButton onClick={onRefilled}>ادامه بده</PrimaryButton>
</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>
{left !== null ? (
<>
<div dir="ltr" className="mt-1 text-4xl font-black tracking-wide text-coral tabular-nums">{clock(left)}</div>
<div className="mt-4 h-2.5 overflow-hidden rounded-full bg-soft">
<motion.div
className="h-full rounded-full bg-gradient-to-l from-coral to-[#FF9A7A]"
animate={{ width: `${Math.max(progress * 100, 3)}%` }}
transition={{ ease: 'linear', duration: 1 }}
/>
</div>
{full && (
<p className="mt-3 text-xs leading-6 text-muted">
همه {toFa(MAX_HEARTS)} قلب: <b className="text-body">{whenLabel(full, now)}</b>
</p>
)}
</>
) : (
<p className="mt-2 text-sm text-body">کمی صبر کن تا قلب‌هات دوباره پر بشن.</p>
)}
</div>
)}
{!back && (
<>
<button
type="button"
onClick={() => nav.switchTab('subscription')}
className="relative w-full overflow-hidden rounded-3xl bg-gradient-to-l from-coral to-orange p-5 text-right text-white shadow-lg shadow-coral/30 active:scale-[0.99]"
>
<span className="absolute -left-6 -top-6 h-24 w-24 rounded-full bg-white/10" />
<span className="flex items-center gap-2 text-base font-black">
<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">
<Crown size={15} /> دیدن اشتراک‌ها
</span>
</button>
<button
type="button"
onClick={() => nav.switchTab('ask')}
className="flex w-full items-center gap-3 rounded-3xl border border-line bg-white p-4 text-right active:scale-[0.99]"
>
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl bg-[#F5F1FF] text-violet">
<Brain size={20} />
</span>
<span className="flex-1">
<span className="block text-sm font-black text-ink">تا اون موقع از «{companionName}» بپرس</span>
<span className="block text-xs leading-6 text-muted">جایی که گیر کردی رو با هم مرور کنیم؛ قلب خرج نمی‌کنه.</span>
</span>
</button>
<button type="button" onClick={nav.back} className="py-1 text-sm font-bold text-muted">
باشه، بعداً
</button>
</>
)}
</div>
</Screen>
);
}
/** پنج جای قلب؛ اولی همراهِ زمان از پایین پر می‌شود. */
function HeartSlots({ progress }: { progress: number }) {
return (
<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" />
{i === 0 && (
<motion.span
className="absolute inset-0 overflow-hidden"
style={{ clipPath: `inset(${(1 - progress) * 100}% 0 0 0)` }}
animate={progress < 1 ? { opacity: [0.75, 1, 0.75] } : { scale: [1, 1.3, 1] }}
transition={{ duration: progress < 1 ? 1.6 : 0.5, repeat: progress < 1 ? Infinity : 0 }}
>
<Heart size={32} className="text-coral" fill="#ED6956" />
</motion.span>
)}
</span>
))}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { 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, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
@@ -8,7 +8,7 @@ import {
* مجموعه‌ی کوچک در باندل است تا کلِ کتابخانه‌ی آیکن بار نشود. * مجموعه‌ی کوچک در باندل است تا کلِ کتابخانه‌ی آیکن بار نشود.
*/ */
const ICONS: Record<string, LucideIcon> = { 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]; 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 { useEffect, type ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion, useDragControls } from 'motion/react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useBackStep } from '@/nav/Navigator';
/** برگه‌ای که از پایین بالا می‌آید؛ با زدنِ پس‌زمینه یا Escape بسته می‌شود. */ /**
* برگه‌ای که از پایین بالا می‌آید؛ با زدنِ پس‌زمینه، Escape، یا کشیدنِ دستگیره/سرِ برگه به پایین بسته می‌شود.
* کشیدن فقط از بالای برگه شروع می‌شود تا با اسکرولِ محتوای بلندِ برگه قاطی نشود.
*/
export function Sheet({ export function Sheet({
open, open,
onClose, onClose,
@@ -14,6 +18,9 @@ export function Sheet({
title?: ReactNode; title?: ReactNode;
children: ReactNode; children: ReactNode;
}) { }) {
const drag = useDragControls();
// دکمه‌ی برگشتِ گوشی اول برگه را می‌بندد، نه صفحه‌ی زیرش را.
useBackStep(open, onClose);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
@@ -26,7 +33,7 @@ export function Sheet({
{open && ( {open && (
<div className="absolute inset-0 z-50 flex flex-col justify-end"> <div className="absolute inset-0 z-50 flex flex-col justify-end">
<motion.div <motion.div
className="absolute inset-0 bg-[#14142a]/45" className="absolute inset-0 bg-[#14142a]/55"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
@@ -41,10 +48,20 @@ export function Sheet({
animate={{ y: 0 }} animate={{ y: 0 }}
exit={{ y: '100%' }} exit={{ y: '100%' }}
transition={{ type: 'spring', stiffness: 320, damping: 32 }} 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 && ( {title && (
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4 flex touch-none items-center justify-between gap-3" onPointerDown={(e) => drag.start(e)}>
<h2 className="text-base font-black text-ink">{title}</h2> <h2 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"> <button type="button" aria-label="بستن" onClick={onClose} className="rounded-full bg-soft p-1.5 text-muted">
<X size={16} /> <X size={16} />

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

@@ -1,9 +1,11 @@
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react'; import { useEffect, useState, type ButtonHTMLAttributes, type CSSProperties, type ReactNode } from 'react';
import { ChevronRight, RotateCw } from 'lucide-react'; import { ChevronRight, RotateCw } from 'lucide-react';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarChar, SeyarMood } from '@/mascots/seyar'; import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
import { onWhite, shade } from '@/lib/color';
import { useCompanion } from '@/state/companion';
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */ /* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
@@ -13,6 +15,7 @@ export function ScreenHeader({
color, color,
back = false, back = false,
mascot, mascot,
exact = false,
children, children,
}: { }: {
title: ReactNode; title: ReactNode;
@@ -20,20 +23,29 @@ export function ScreenHeader({
color: string; color: string;
/** دکمه‌ی بازگشت؛ true یعنی صفحه‌ی قبل در پشته. */ /** دکمه‌ی بازگشت؛ true یعنی صفحه‌ی قبل در پشته. */
back?: boolean | (() => void); back?: boolean | (() => void);
mascot?: { char: SeyarChar; mood?: SeyarMood; size?: number }; /** بی `char` همراهِ انتخابیِ کاربر است؛ همه‌ی صفحه‌ها همین را می‌خواهند. */
mascot?: { char?: SeyarChar; mood?: SeyarMood; size?: number };
/** رنگ را همان‌طور که هست بگذار (بی‌تیره‌کردن برای کنتراست)؛ انتخابِ مالک برای چند صفحه. */
exact?: boolean;
children?: ReactNode; children?: ReactNode;
}) { }) {
const nav = useNav(); const nav = useNav();
const companion = useCompanion().char;
const onBack = typeof back === 'function' ? back : nav.back; const onBack = typeof back === 'function' ? back : nav.back;
const bg = exact ? color : onWhite(color);
return ( return (
<header <header
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white" className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
style={{ backgroundColor: color }} style={{ backgroundColor: bg, backgroundImage: `linear-gradient(160deg, ${shade(bg, 0.12)} 0%, ${bg} 55%, ${shade(bg, -0.06)} 100%)` }}
> >
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" /> <div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" /> <div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
{/* نمادهای ریاضی که آرام شناورند؛ فقط transform/opacity و بی‌تصویر، پس ارزان‌اند. */}
<span aria-hidden className="glyph pointer-events-none absolute left-[42%] top-5 text-3xl font-black text-white/15" style={{ animationDelay: '0s' }}>π</span>
<span aria-hidden className="glyph pointer-events-none absolute right-[34%] bottom-3 text-2xl font-black text-white/15" style={{ animationDelay: '-2s' }}>∑</span>
<span aria-hidden className="glyph pointer-events-none absolute left-[26%] bottom-2 text-2xl font-black text-white/15" style={{ animationDelay: '-4s' }}>√</span>
<div className="relative z-10 pt-6"> <div className="relative z-10 pt-6">
{back && ( {back && (typeof back === 'function' || nav.canGoBack) && (
<IconButton label="بازگشت" onClick={onBack} className="mb-3 bg-white/15"> <IconButton label="بازگشت" onClick={onBack} className="mb-3 bg-white/15">
<ChevronRight size={18} strokeWidth={2.5} /> <ChevronRight size={18} strokeWidth={2.5} />
</IconButton> </IconButton>
@@ -43,7 +55,7 @@ export function ScreenHeader({
<h1 className="text-xl font-black leading-snug">{title}</h1> <h1 className="text-xl font-black leading-snug">{title}</h1>
{subtitle && <div className="mt-1 text-xs text-white/75">{subtitle}</div>} {subtitle && <div className="mt-1 text-xs text-white/75">{subtitle}</div>}
</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> </div>
{children} {children}
</div> </div>
@@ -85,8 +97,8 @@ export function PrimaryButton({
<button <button
type="button" type="button"
disabled={disabled || loading} disabled={disabled || loading}
className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-transform active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100 ${className}`} className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-[transform,box-shadow] active:translate-y-[3px] active:shadow-none disabled:opacity-50 disabled:active:translate-y-0 ${className}`}
style={{ backgroundColor: color, boxShadow: `0 10px 24px -8px ${color}88`, ...style }} style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.22)}`, ...style }}
{...rest} {...rest}
> >
{loading ? <Spinner /> : children} {loading ? <Spinner /> : children}
@@ -98,7 +110,7 @@ export function SecondaryButton({ className = '', children, ...rest }: ButtonHTM
return ( return (
<button <button
type="button" type="button"
className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:scale-[0.98] disabled:opacity-50 ${className}`} className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-b-4 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:translate-y-[2px] active:border-b-2 disabled:opacity-50 ${className}`}
{...rest} {...rest}
> >
{children} {children}
@@ -110,7 +122,7 @@ export function Spinner({ size = 20, className = '' }: { size?: number; classNam
return ( return (
<span <span
role="status" role="status"
aria-label="در حالِ بارگذاری" aria-label="در حال بارگذاری"
className={`inline-block animate-spin rounded-full border-2 border-current border-t-transparent ${className}`} className={`inline-block animate-spin rounded-full border-2 border-current border-t-transparent ${className}`}
style={{ width: size, height: size }} style={{ width: size, height: size }}
/> />
@@ -119,23 +131,35 @@ 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 ( return (
<div className="flex flex-col items-center justify-center gap-3 px-8 py-16 text-center"> <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> <p className="text-sm leading-7 text-muted">{text}</p>
{children} {children}
</div> </div>
); );
} }
export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'sigma' as SeyarChar }) { /**
return <StateBox char={char} mood="idle" text={text} />; * لودر فقط بعد از `delayMs` دیده می‌شود. پاسخِ API معمولاً زیرِ ۳۰۰ms می‌رسد و نشان دادنِ لودر برای
* همان چند فریم، پرشِ ریزِ «لودر ← محتوا» می‌ساخت؛ حالا تا آن لحظه جای خالی می‌ماند. اگر پاسخ
* کند باشد کاربر باز هم لودر را می‌بیند. در مرورگر با شبکه‌ی کند آزموده نشده.
*/
export function LoadingState({ text = 'یه لحظه صبر کن…', char, delayMs = 300 }: { text?: string; char?: SeyarChar; delayMs?: number }) {
const [show, setShow] = useState(delayMs <= 0);
useEffect(() => {
if (delayMs <= 0) return;
const t = setTimeout(() => setShow(true), delayMs);
return () => clearTimeout(t);
}, [delayMs]);
return show ? <StateBox char={char} mood="idle" text={text} /> : null;
} }
export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?: () => void; text?: string }) { export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?: () => void; text?: string }) {
return ( return (
<StateBox char="pi" mood="sad" text={text ?? friendlyError(error)}> <StateBox mood="sad" text={text ?? friendlyError(error)}>
{onRetry && ( {onRetry && (
<button <button
type="button" type="button"
@@ -149,10 +173,18 @@ 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} />; return <StateBox char={char} mood="idle" text={text} />;
} }
/**
* جداکننده‌ی میانِ دو تکه‌ی متن. «·» کنارِ صفرِ فارسی (که خودش یک نقطه است)
* خوانده نمی‌شد: «فصلِ ۱ · ۰ از ۴» شبیهِ «۱۰۰» دیده می‌شد.
*/
export function Sep() {
return <span aria-hidden className="mx-1.5 inline-block h-2.5 w-px bg-current align-middle opacity-40" />;
}
export function InlineError({ children }: { children: ReactNode }) { export function InlineError({ children }: { children: ReactNode }) {
return ( return (
<div role="alert" className="rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-xs leading-6 text-red-600"> <div role="alert" className="rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-xs leading-6 text-red-600">

View File

@@ -0,0 +1,41 @@
import { Diagram } from '@/diagram';
import type { DiagramSpec } from '@/diagram/types';
import { SAMPLES } from './diagramSamples';
import { MathText } from '@/lib/MathText';
const TEXTS = [
'۵) پس وقتی از ۲ سه واحد به چپ برویم، به عدد -۱ می‌رسیم.',
'✅ جواب: رأس سهمی در (1, -1) است.',
'۴) ۲ - ۳ = -۱.',
'مجموع زاویه‌های داخلی = (5 - 2) × 180 = 3 × 180 = 540 درجه',
'بازه [-2, 3) یعنی از −۲ تا ۳، خود ۳ نه.',
'sin 30° = 0.5 و cos 30° ≈ 0.87 است.',
'در کتاب ۲/۵ یعنی دو و نیم (اختیاری).',
];
/**
* گالریِ شکل‌ها، فقط در حالتِ توسعه: http://localhost:5173/?gallery
* برای وارسیِ چشمی بعد از هر تغییر در src/diagram. اگر window.__GALLERY__ ست شده
* باشد (تستِ خودکار با خروجیِ واقعیِ مدل)، همان‌ها را نشان می‌دهد.
*/
export function DiagramGallery() {
const injected = (window as unknown as { __GALLERY__?: { title: string; spec: DiagramSpec }[] }).__GALLERY__;
const items = injected ?? SAMPLES;
return (
<div dir="rtl" className="min-h-screen bg-white p-3">
{!injected && (
<div className="mx-auto mb-3 max-w-[420px] space-y-1.5 rounded-2xl border border-line p-3 text-sm leading-7" data-texts>
{TEXTS.map((t) => <p key={t} className="rounded-xl bg-soft px-3 py-1.5"><MathText text={t} /></p>)}
</div>
)}
<div className="mx-auto grid max-w-[1200px] grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((it, i) => (
<section key={i} data-sample={i} className="rounded-2xl border border-line p-2">
<h2 className="mb-1 text-xs font-black text-body">{i + 1}. {it.title}</h2>
<Diagram spec={it.spec} />
</section>
))}
</div>
</div>
);
}

247
src/dev/diagramSamples.ts Normal file
View File

@@ -0,0 +1,247 @@
import type { DiagramSpec } from '@/diagram/types';
/** نمونه‌های دست‌نویس از هر نوع شکل، از روی مسئله‌های واقعیِ کتاب‌ها. فقط برای گالری. */
export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
{
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: [
{ 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' },
{ type: 'segment', from: 'O', to: 'H', color: 'orange', label: 'cos 30°' },
{ type: 'angle', vertex: 'O', from: 'A', to: 'P', label: '30°' },
{ type: 'rightAngle', vertex: 'H', from: 'O', to: 'P' },
] },
},
{
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' },
{ type: 'segment', from: 'A', to: 'B', label: '4' },
{ type: 'segment', from: 'A', to: 'C', label: '3' },
{ type: 'segment', from: 'B', to: 'C', label: '?', color: 'red' },
] },
},
{
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' },
{ type: 'line', through: ['_d1', '_d2'], color: 'purple', label: 'd' },
{ type: 'angle', vertex: 'P', from: '_a2', to: '_d2', label: 'Â1', arcs: 1 },
{ type: 'angle', vertex: 'Q', from: '_b2', to: '_d2', label: 'B̂1', arcs: 1 },
] },
},
{
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' },
{ type: 'segment', from: 'O', to: 'A', color: 'blue' }, { type: 'segment', from: 'O', to: 'B', color: 'blue' },
{ type: 'angle', vertex: 'C', from: 'A', to: 'B', label: '70°' },
{ type: 'angle', vertex: 'O', from: 'A', to: 'B', label: '140°', color: 'blue' },
] },
},
{
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 },
{ type: 'segment', from: 'M', to: 'N', dashed: true, color: 'purple' },
] },
},
{
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: 'هشتم — بردار و جمع بردارها روی شبکه',
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' },
{ type: 'vector', from: 'A', to: 'C', label: 'a + b', color: 'green' },
] },
},
{
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' },
{ type: 'point', point: 'P', label: 'P (2, 3)' },
] },
},
{
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: [
{ 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: 'دوازدهم — خط مماس بر نمودار',
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' },
] },
},
{
title: 'دوازدهم — مجانب‌ها: y = 1/(x − 2) + 1',
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: 'دوازدهم — اکسترمم‌های نسبی تابع درجه سوم',
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'] },
] },
},
{
title: 'یازدهم — تابع و وارونش، قرینه نسبت به y = x',
spec: { kind: 'plane', title: 'f و f⁻¹', axes: true, xRange: [-1, 8], yRange: [-1, 8], points: {}, items: [
{ type: 'function', expr: '2^x', from: -1, to: 3, label: 'y = 2ˣ' },
{ type: 'function', expr: 'log(x)/log(2)', from: 0.3, to: 8, label: 'y = log₂x' },
{ type: 'function', expr: 'x', from: -1, to: 8, dashed: true, color: 'gray', label: 'y = x' },
] },
},
{
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: 'دهم — ناحیه جواب نامعادله',
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: 'هشتم — محور اعداد: از ۲ سه واحد به چپ',
spec: { kind: 'numberline', title: '۲ − ۳ = −۱', min: -4, max: 4, points: [{ at: 2, label: 'شروع' }, { at: -1, label: 'پایان' }], jumps: [{ from: 2, to: -1, label: '۳ واحد به چپ' }] },
},
{
title: 'دهم — بازه‌ها روی محور',
spec: { kind: 'numberline', title: '[−۲، ۳) و (۱، +∞)', min: -4, max: 5, points: [], jumps: [], intervals: [
{ from: -2, to: 3, closedFrom: true, closedTo: false, label: '[−2, 3)' },
{ from: 1, to: null, closedFrom: false, label: '(1, +∞)' },
] },
},
{
title: 'ششم — کسرها روی محور',
spec: { kind: 'numberline', title: 'سه‌چهارم', min: 0, max: 2, step: 0.25, fractions: true, points: [{ at: 0.75, label: 'سه‌چهارم' }], jumps: [] },
},
{
title: 'ششم — کسر روی دایره',
spec: { kind: 'fraction', title: 'سه‌هشتم', shape: 'circle', parts: 8, shaded: 3 },
},
{
title: 'ششم — ۲۰٪ از ۲۵ خانه',
spec: { kind: 'grid', title: '۲۰ درصد', rows: 5, cols: 5, shaded: 5 },
},
{
title: 'سوم — مدل ضرب: ۳ گروه ۴تایی',
spec: { kind: 'groups', title: '۳ × ۴', groups: 3, each: 4, item: 'circle' },
},
{
title: 'سوم — ساعت ۳ و ۲۵ دقیقه',
spec: { kind: 'clock', title: 'ساعت ۳:۲۵', hour: 3, minute: 25 },
},
{
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: 'linechart', title: 'دما (°C)', labels: ['1', '2', '3', '4', '5', '6'], values: [55, 90, 125, 160, 195, 230], unit: '°C' },
},
{
title: 'دهم — جدول تعیین علامت',
spec: { kind: 'table', title: 'y = (x + 1)(x − 2)', headers: ['x', '−∞', '−1', '', '2', '+∞'], rows: [['y', '+', '0', '−', '0', '+']] },
},
{
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'] },
},
{
title: 'دهم — ون با سه مجموعه',
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: 'دهم — نمودار پیکانی تابع',
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: 'نهم — نمودار درختی دو بار پرتاب سکه',
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' }] },
] } },
},
{ 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: '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: 'fraction', title: '۲٫۷', shape: 'circle', parts: 10, shaded: 7, wholes: 2 } },
{ 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: [
{ 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: [
{ 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: [
{ 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: [
{ type: 'triangle', names: ['A', 'B', 'C'], angles: { A: 40, B: 75 }, labels: { C: '?' } },
] } },
{ 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: '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: '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: '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] } },
];

101
src/diagram/charts.tsx Normal file
View File

@@ -0,0 +1,101 @@
import type { ChartSpec } from './types';
import { COLORS, GRID, INK, Label, SOFT, bidi, fa, mathText, niceStep, seriesColors, ticksBetween } from './svg';
const W = 330;
/** نمودارِ ستونی، خط‌شکسته و دایره‌ای. درصدِ دایره‌ای را کد از روی مقدارها حساب می‌کند. */
export function Chart({ spec }: { spec: ChartSpec }) {
const n = Math.min(spec.labels.length, spec.values.length);
if (!n) return null;
const labels = spec.labels.slice(0, n), values = spec.values.slice(0, n);
return spec.kind === 'pie' ? <Pie labels={labels} values={values} /> : <Bars kind={spec.kind} labels={labels} values={values} unit={spec.unit} />;
}
function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; labels: string[]; values: number[]; unit?: string }) {
const H = 230, left = 38, right = 12, top = 22, bottom = 44;
const lo = Math.min(0, ...values), hi = Math.max(0, ...values);
const step = niceStep(hi - lo || 1, 5);
const y0 = Math.floor(lo / step) * step, y1 = Math.ceil(hi / step) * step || step;
const plotW = W - left - right, plotH = H - top - bottom;
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);
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img">
{ticksBetween(y0, y1, step).map((t) => (
<g key={t}>
<line x1={left} x2={W - right} y1={Y(t)} y2={Y(t)} stroke={Math.abs(t) < 1e-9 ? SOFT : GRID} strokeWidth={Math.abs(t) < 1e-9 ? 1.5 : 1} />
<text x={left - 6} y={Y(t) + 3.5} fontSize={9.5} fill={SOFT} textAnchor="end">{fa(t)}</text>
</g>
))}
{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 = 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} />
<Label x={X(i)} y={v >= 0 ? Y(v) - 5 : Y(v) + 13} color={INK} size={10.5}>{fa(v)}</Label>
</g>
);
})
: (
<>
<path d={values.map((v, i) => `${i ? 'L' : 'M'}${X(i)},${Y(v)}`).join(' ')} fill="none" stroke={COLORS.blue} strokeWidth={2.6} strokeLinejoin="round" />
{values.map((v, i) => (
<g key={i}>
<circle cx={X(i)} cy={Y(v)} r={4.2} fill={COLORS.blue} stroke="#fff" strokeWidth={1.5} />
<Label x={X(i)} y={Y(v) - 8} color={INK} size={10}>{fa(v)}</Label>
</g>
))}
</>
)}
{labels.map((l, i) => (
<text key={i} x={X(i)} y={H - bottom + 16} fontSize={long ? 9 : 10.5} fontWeight={700} fill={INK} textAnchor={long ? 'end' : 'middle'}
transform={long ? `rotate(-30 ${X(i)} ${H - bottom + 16})` : undefined} {...bidi(mathText(l))}>{mathText(l)}</text>
))}
</svg>
);
}
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: sliceColors[i] };
});
const H = Math.max(215, 24 + labels.length * 22);
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img">
{slices.map((s) => {
if (s.frac >= 0.9999) return <circle key={s.i} cx={cx} cy={cy} r={r} fill={s.c} stroke="#fff" strokeWidth={2} />;
const large = s.a1 - s.a0 > Math.PI ? 1 : 0;
const p0 = [cx + r * Math.cos(s.a0), cy + r * Math.sin(s.a0)], p1 = [cx + r * Math.cos(s.a1), cy + r * Math.sin(s.a1)];
return <path key={s.i} d={`M${cx},${cy} L${p0.join(',')} A${r},${r} 0 ${large} 1 ${p1.join(',')} Z`} fill={s.c} stroke="#fff" strokeWidth={2} />;
})}
{slices.filter((s) => s.frac >= 0.06).map((s) => {
const m = (s.a0 + s.a1) / 2;
return (
<text key={`t${s.i}`} x={cx + r * 0.62 * Math.cos(m)} y={cy + r * 0.62 * Math.sin(m) + 4} fontSize={11} fontWeight={800} fill="#fff" textAnchor="middle">
{fa(s.frac * 100, 1)}٪
</text>
);
})}
{labels.map((l, i) => (
<g key={`l${i}`}>
<rect x={214} y={18 + i * 22} width={12} height={12} rx={3} fill={slices[i].c} />
<text x={232} y={28 + i * 22} fontSize={10.5} fontWeight={700} fill={INK} style={{ unicodeBidi: 'plaintext' }}>{`${l} (${fa(values[i])})`}</text>
</g>
))}
</svg>
);
}

179
src/diagram/expr.ts Normal file
View File

@@ -0,0 +1,179 @@
/**
* پارسرِ کوچکِ عبارتِ ریاضی برای نمودارِ دستیار (`x^2-2*x`، `sin(x)+1`).
*
* عمداً eval یا new Function نیست: عبارت از مدل می‌آید و مدل متنِ کاربر را
* خوانده است. فقط عدد، x، pi، e، + − * / ^، پرانتز و چند تابعِ شناخته‌شده
* پذیرفته می‌شود؛ هر چیزِ دیگر خطا است و نمودار کشیده نمی‌شود. ضربِ ضمنی
* (2x، 3(x+1)، x sin(x)) هم پشتیبانی می‌شود چون مدل گاهی می‌نویسدش.
*/
type Node =
| { k: 'num'; v: number }
| { k: 'x' }
| { k: 'neg'; a: Node }
| { k: 'bin'; op: '+' | '-' | '*' | '/' | '^'; a: Node; b: Node }
| { k: 'fn'; f: string; a: Node };
const FUNCS: Record<string, (v: number) => number> = {
sin: (v) => Math.sin(v), cos: (v) => Math.cos(v), tan: (v) => Math.tan(v),
sqrt: (v) => Math.sqrt(v), abs: (v) => Math.abs(v), log: (v) => Math.log10(v), ln: (v) => Math.log(v),
};
function tokenize(src: string): string[] {
const s = src.replace(/\s+/g, '').replace(/\*\*/g, '^').replace(/[−–]/g, '-').replace(/[×·]/g, '*').replace(/÷/g, '/').replace(/²/g, '^2').replace(/³/g, '^3').replace(/π/g, 'pi');
const out: string[] = [];
let i = 0;
while (i < s.length) {
const rest = s.slice(i);
const num = /^(\d+\.?\d*|\.\d+)/.exec(rest);
if (num) { out.push(num[0]); i += num[0].length; continue; }
const word = /^[a-z]+/.exec(rest);
if (word) {
const w = word[0];
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}`);
out.push(known); i += known.length; continue;
}
if ('+-*/^()'.includes(s[i])) { out.push(s[i]); i++; continue; }
throw new Error(`نویسه ناشناخته: ${s[i]}`);
}
return out;
}
export function parseExpr(src: string): (x: number) => number {
if (src.length > 120) throw new Error('عبارت بلند است');
const t = tokenize(src);
let p = 0;
const peek = () => t[p];
const eat = (tok?: string) => {
const v = t[p++];
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);
function expr(): Node {
let a = term();
while (peek() === '+' || peek() === '-') { const op = eat() as '+' | '-'; a = { k: 'bin', op, a, b: term() }; }
return a;
}
function term(): Node {
let a = unary();
for (;;) {
if (peek() === '*' || peek() === '/') { const op = eat() as '*' | '/'; a = { k: 'bin', op, a, b: unary() }; }
else if (startsAtom(peek())) a = { k: 'bin', op: '*', a, b: power() }; // ضربِ ضمنی
else return a;
}
}
function unary(): Node {
if (peek() === '-') { eat(); return { k: 'neg', a: unary() }; }
if (peek() === '+') { eat(); return unary(); }
return power();
}
function power(): Node {
const base = atom();
if (peek() === '^') { eat(); return { k: 'bin', op: '^', a: base, b: unary() }; }
return base;
}
function atom(): Node {
const tok = eat();
if (tok === undefined) throw new Error('عبارت ناتمام است');
if (/^[\d.]/.test(tok)) return { k: 'num', v: Number(tok) };
if (tok === 'x') return { k: 'x' };
if (tok === 'pi') return { k: 'num', v: Math.PI };
if (tok === 'e') return { k: 'num', v: Math.E };
if (tok in FUNCS) {
if (peek() === '(') { eat('('); const a = expr(); eat(')'); return { k: 'fn', f: tok, a }; }
return { k: 'fn', f: tok, a: power() };
}
if (tok === '(') { const a = expr(); eat(')'); return a; }
throw new Error(`غیرمنتظره: ${tok}`);
}
const tree = expr();
if (p !== t.length) throw new Error('عبارت اضافه دارد');
const ev = (n: Node, x: number): number => {
switch (n.k) {
case 'num': return n.v;
case 'x': return x;
case 'neg': return -ev(n.a, x);
case 'fn': return FUNCS[n.f](ev(n.a, x));
case 'bin': {
const a = ev(n.a, x), b = ev(n.b, x);
return n.op === '+' ? a + b : n.op === '-' ? a - b : n.op === '*' ? a * b : n.op === '/' ? a / b : Math.pow(a, b);
}
}
};
return (x) => ev(tree, x);
}
/* ─── تحلیلِ عددی: ریشه، اکسترمم، شیب ───────────────────────────────────────
* همه با دو برابر کردنِ دقت (bisection) تا ۱e-10؛ برای نمایش به دو رقمِ اعشار گرد
* می‌شوند. ریشه‌ی مضاعف (مثلِ x² در صفر) علامت عوض نمی‌کند، پس جدا از روی
* اکسترممی که مقدارش صفر است پیدا می‌شود.
*/
type F = (x: number) => number;
function bisect(g: F, a: number, b: number): number {
let ga = g(a);
for (let i = 0; i < 80; i++) {
const m = (a + b) / 2, gm = g(m);
if (!Number.isFinite(gm)) return m;
if (Math.sign(gm) === Math.sign(ga)) { a = m; ga = gm; } else b = m;
if (b - a < 1e-11) break;
}
return (a + b) / 2;
}
/** مشتقِ عددی (تفاضلِ مرکزی). */
export function derivative(f: F, x: number): number {
const h = 1e-5 * Math.max(1, Math.abs(x));
return (f(x + h) - f(x - h)) / (2 * h);
}
const snap = (x: number) => {
const r = Math.round(x);
if (Math.abs(x - r) < 1e-7) return r;
const r2 = Math.round(x * 1e6) / 1e6;
return Math.abs(x - r2) < 1e-9 ? r2 : x;
};
export function extrema(f: F, from: number, to: number): { x: number; y: number; kind: 'max' | 'min' }[] {
const n = 600, out: { x: number; y: number; kind: 'max' | 'min' }[] = [];
const d = (x: number) => derivative(f, x);
let px = from, pd = d(px);
for (let i = 1; i <= n; i++) {
const x = from + ((to - from) * i) / n, dx = d(x);
if (Number.isFinite(pd) && Number.isFinite(dx) && pd !== 0 && Math.sign(dx) !== Math.sign(pd)) {
const xe = snap(bisect(d, px, x));
const y = f(xe);
// لبه‌ی مجانب (1/x) علامتِ مشتق را بی‌اکسترمم عوض نمی‌کند: تابع باید پیوسته باشد.
if (Number.isFinite(y) && Math.abs(f(px) - f(x)) < 1e3 * (Math.abs(y) + 1)) out.push({ x: xe, y: snap(y), kind: pd > 0 ? 'max' : 'min' });
}
px = x; pd = dx;
}
return out;
}
export function roots(f: F, from: number, to: number): number[] {
const n = 800, out: number[] = [];
let px = from, py = f(px);
if (Math.abs(py) < 1e-12) out.push(from);
for (let i = 1; i <= n; i++) {
const x = from + ((to - from) * i) / n, y = f(x);
if (Math.abs(y) < 1e-12) out.push(x);
else if (Number.isFinite(py) && Number.isFinite(y) && py !== 0 && Math.sign(y) !== Math.sign(py)) {
const r = bisect(f, px, x);
// پرشِ علامت بدونِ گذر از صفر (مجانب) ریشه نیست.
if (Math.abs(f(r)) < 1e-6) out.push(r);
}
px = x; py = y;
}
for (const e of extrema(f, from, to)) if (Math.abs(e.y) < 1e-9) out.push(e.x);
const uniq: number[] = [];
for (const r of out.map(snap).sort((a, b) => a - b)) if (!uniq.length || Math.abs(r - uniq[uniq.length - 1]) > 1e-6) uniq.push(r);
return uniq;
}

61
src/diagram/index.tsx Normal file
View File

@@ -0,0 +1,61 @@
import { Component, memo, type ReactNode } from 'react';
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 { HideCounts, PAPER } from './svg';
import { MathText } from '@/lib/MathText';
export type { DiagramSpec } from './types';
/**
* شکلِ جوابِ دستیار. توصیف از بک‌اند می‌آید (rag/services/diagram.py، اعتبارسنجی‌شده)
* و اینجا فقط به عنصرِ React/SVG تبدیل می‌شود؛ هیچ HTMLی از مدل وارد صفحه نمی‌شود.
*
* هر خطایی در رسم (شکلی که کد پیش‌بینی نکرده) فقط همین شکل را پنهان می‌کند؛
* متنِ جواب سرِ جایش می‌ماند.
*/
function Body({ spec }: { spec: DiagramSpec }): ReactNode {
switch (spec.kind) {
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} />;
case 'tree': return <Tree spec={spec} />;
case 'fraction': return <Fraction spec={spec} />;
case 'grid': return <Grid spec={spec} />;
case 'groups': return <Groups spec={spec} />;
case 'clock': return <Clock spec={spec} />;
case 'table': return <Table spec={spec} />;
case 'pattern': return <Pattern spec={spec} />;
default: return null;
}
}
class Guard extends Component<{ children: ReactNode }, { broken: boolean }> {
state = { broken: false };
static getDerivedStateFromError() { return { broken: true }; }
render() { return this.state.broken ? null : this.props.children; }
}
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 }}>
<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>
);
}
export const Diagram = memo(DiagramBase);

294
src/diagram/misc.tsx Normal file
View File

@@ -0,0 +1,294 @@
import type { ClockSpec, FractionSpec, GridSpec, GroupsSpec, PatternSpec, TableSpec } from './types';
import { useContext } from 'react';
import { COLORS, HideCounts, INK, bidi, color, mathText } from './svg';
import { toFa } from '@/lib/format';
const W = 320;
const SHADE = COLORS.yellow;
/* ─── کسر: دایره یا نوارِ تکه‌تکه ─────────────────────────────────────────── */
export function Fraction({ spec }: { spec: FractionSpec }) {
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 = 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 (
<svg viewBox={`0 0 ${W} 96`} className="mx-auto block w-full max-w-[320px]" role="img">
{/* از راست به چپ رنگ می‌شود، مثلِ خواندنِ فارسی */}
{Array.from({ length: parts }, (_, i) => <rect key={i} x={W - 20 - (i + 1) * w} y={18} width={w} height={48} fill={fill(i)} stroke={INK} strokeWidth={2} />)}
{caption}
</svg>
);
}
const cx = W / 2, cy = 80, r = 66;
return (
<svg viewBox={`0 0 ${W} 176`} className="mx-auto block w-full max-w-[260px]" role="img">
{parts === 1 ? <circle cx={cx} cy={cy} r={r} fill={fill(0)} stroke={INK} strokeWidth={2} /> : Array.from({ length: parts }, (_, i) => {
const a0 = -Math.PI / 2 + (2 * Math.PI * i) / parts, a1 = -Math.PI / 2 + (2 * Math.PI * (i + 1)) / parts;
return <path key={i} d={`M${cx},${cy} L${cx + r * Math.cos(a0)},${cy + r * Math.sin(a0)} A${r},${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${cx + r * Math.cos(a1)},${cy + r * Math.sin(a1)} Z`} fill={fill(i)} stroke={INK} strokeWidth={2} strokeLinejoin="round" />;
})}
{caption}
</svg>
);
}
/** چند کسر روی نوارهای هم‌اندازه، برای مقایسه (یک‌پنجم و نُه‌دهم). */
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];
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
{items.map((f, r) => {
const y = 8 + r * (h + gap), w = barW / f.parts;
return (
<g key={r}>
{Array.from({ length: f.parts }, (_, i) => (
<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 ?? (hide ? '' : `${toFa(f.shaded)} از ${toFa(f.parts)}`)}
</text>
</g>
);
})}
</svg>
);
}
/** عددِ مخلوط: چند واحدِ کامل و یک کسر (دو و هفت‌دهم). */
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) => {
const a0 = -Math.PI / 2 + (2 * Math.PI * i) / parts, a1 = -Math.PI / 2 + (2 * Math.PI * (i + 1)) / parts;
return <path key={i} d={`M${x},${cy} L${x + r * Math.cos(a0)},${cy + r * Math.sin(a0)} A${r},${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x + r * Math.cos(a1)},${cy + r * Math.sin(a1)} Z`} fill={full || i < shaded ? SHADE : '#fff'} stroke={INK} strokeWidth={1.6} />;
};
return (
<svg viewBox={`0 0 ${W} 120`} className="mx-auto block w-full max-w-[340px]" role="img">
{Array.from({ length: wholes }, (_, w) => (shape === 'rect'
? <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>
{!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>
);
}
/* ─── شبکه‌ی خانه‌ها: درصد، مساحت با شمردن، کسر روی جدول ─────────────────── */
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;
const x0 = (W - gw) / 2, y0 = 12;
const on = new Set<string>();
if (typeof spec.shaded === 'number') {
// سطر به سطر، از بالا و از راست (جهتِ خواندنِ فارسی)
for (let i = 0; i < Math.min(spec.shaded, rows * cols); i++) on.add(`${Math.floor(i / cols)},${cols - 1 - (i % cols)}`);
} else spec.shaded.forEach(([r, c]) => on.add(`${r},${c}`));
const col = color(spec.color, SHADE);
const count = on.size;
return (
<svg viewBox={`0 0 ${W} ${gh + 44}`} className="mx-auto block w-full max-w-[320px]" role="img">
{Array.from({ length: rows }, (_, r) => Array.from({ length: cols }, (_, c) => (
<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} />
{!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>}
</svg>
);
}
/* ─── گروه‌ها: مدلِ ضرب و تقسیم برای دبستان ───────────────────────────────── */
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);
const inner = Math.ceil(Math.sqrt(each));
const dot = Math.min(14, 64 / inner);
const boxW = inner * dot + 16, boxH = Math.ceil(each / inner) * dot + 16;
const gap = 12;
const totalW = perRow * boxW + (perRow - 1) * gap;
const scale = Math.min(1, (W - 20) / totalW);
const H = (rowsG * (boxH + gap) + 30) * scale + 10;
const shape = spec.item ?? 'circle';
const cols = [COLORS.red, COLORS.blue, COLORS.green, COLORS.orange, COLORS.purple];
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[320px]" role="img">
<g transform={`translate(${(W - totalW * scale) / 2} 8) scale(${scale})`}>
{Array.from({ length: g }, (_, gi) => {
const bx = (gi % perRow) * (boxW + gap), by = Math.floor(gi / perRow) * (boxH + gap);
const c = cols[gi % cols.length];
return (
<g key={gi} transform={`translate(${bx} ${by})`}>
<rect width={boxW} height={boxH} rx={10} fill={`${c}12`} stroke={c} strokeWidth={2} strokeDasharray="5 3" />
{Array.from({ length: each }, (_, i) => {
const x = 8 + (i % inner) * dot + dot / 2, y = 8 + Math.floor(i / inner) * dot + dot / 2, s = dot * 0.36;
if (shape === 'square') return <rect key={i} x={x - s} y={y - s} width={s * 2} height={s * 2} rx={2} fill={c} />;
if (shape === 'star') return <path key={i} d={star(x, y, s * 1.2)} fill={c} />;
return <circle key={i} cx={x} cy={y} r={s} fill={c} />;
})}
</g>
);
})}
</g>
{(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>}
</svg>
);
}
function star(cx: number, cy: number, r: number): string {
const pts = Array.from({ length: 10 }, (_, i) => {
const t = -Math.PI / 2 + (i * Math.PI) / 5, rr = i % 2 ? r * 0.45 : r;
return `${cx + rr * Math.cos(t)},${cy + rr * Math.sin(t)}`;
});
return `M${pts.join(' L')} Z`;
}
/* ─── ساعت ────────────────────────────────────────────────────────────────── */
export function Clock({ spec }: { spec: ClockSpec }) {
const cx = W / 2, cy = 100, r = 84;
const h = ((spec.hour % 12) + spec.minute / 60) * 30, m = spec.minute * 6;
const hand = (deg: number, len: number, w: number, c: string) => {
const t = ((deg - 90) * Math.PI) / 180;
return <line x1={cx} y1={cy} x2={cx + len * Math.cos(t)} y2={cy + len * Math.sin(t)} stroke={c} strokeWidth={w} strokeLinecap="round" />;
};
return (
<svg viewBox={`0 0 ${W} 200`} className="mx-auto block w-full max-w-[240px]" role="img">
<circle cx={cx} cy={cy} r={r} fill="#fff" stroke={INK} strokeWidth={3} />
{Array.from({ length: 60 }, (_, i) => {
const t = (i * 6 - 90) * (Math.PI / 180), big = i % 5 === 0;
return <line key={i} x1={cx + (r - (big ? 10 : 5)) * Math.cos(t)} y1={cy + (r - (big ? 10 : 5)) * Math.sin(t)} x2={cx + (r - 2) * Math.cos(t)} y2={cy + (r - 2) * Math.sin(t)} stroke={INK} strokeWidth={big ? 2.2 : 1} />;
})}
{Array.from({ length: 12 }, (_, i) => {
const n = i + 1, t = (n * 30 - 90) * (Math.PI / 180);
return <text key={n} x={cx + (r - 22) * Math.cos(t)} y={cy + (r - 22) * Math.sin(t) + 5} fontSize={14} fontWeight={800} fill={INK} textAnchor="middle">{toFa(n)}</text>;
})}
{hand(h, r * 0.5, 5, COLORS.blue)}
{hand(m, r * 0.78, 3, COLORS.red)}
<circle cx={cx} cy={cy} r={4.5} fill={INK} />
</svg>
);
}
/* ─── جدول (مقدارها، تعیینِ علامت) ───────────────────────────────────────── */
export function Table({ spec }: { spec: TableSpec }) {
const cellStyle = (v: string) => {
const t = v.trim();
if (t === '+') return { color: COLORS.green, fontWeight: 900 };
if (t === '-' || t === '−') return { color: COLORS.red, fontWeight: 900 };
if (t === '0' || t === '۰') return { color: INK, fontWeight: 900 };
return undefined;
};
return (
// جدولِ ریاضی (تعیینِ علامت، مقدارها) چپ‌به‌راست است، مثلِ محورِ x.
<div className="overflow-x-auto" dir="ltr">
<table className="mx-auto border-collapse text-center text-[12px]">
{spec.headers.length > 0 && (
<thead>
<tr>{spec.headers.map((h, i) => <th key={i} className="border border-[#d8dae6] bg-[#eef0fb] px-2.5 py-1.5 font-black text-ink" style={bidi(mathText(h)).style}>{mathText(h)}</th>)}</tr>
</thead>
)}
<tbody>
{spec.rows.map((row, r) => (
<tr key={r}>
{row.map((c, i) => (
<td key={i} className={`border border-[#d8dae6] px-2.5 py-1.5 ${i === 0 ? 'bg-[#f6f7fc] font-bold' : 'bg-white'}`} style={{ ...cellStyle(c), ...bidi(mathText(c)).style }}>
{mathText(c)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/* ─── الگوی شکلی: شکلِ ۱، ۲، ۳، … و «شکلِ بعدی؟» (فصلِ الگو و دنباله) ────────── */
/** خانه‌های هر مرحله در واحدِ شبکه؛ اولین مرحله راست است، مثلِ کتاب. */
function patternCells(layout: PatternSpec['layout'], n: number): [number, number][] {
const out: [number, number][] = [];
if (layout === 'square') for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) out.push([c, r]);
else if (layout === 'rect') for (let r = 0; r < n; r++) for (let c = 0; c <= n; c++) out.push([c, r]);
else if (layout === 'triangle') for (let r = 0; r < n; r++) for (let c = 0; c <= r; c++) out.push([c + (n - 1 - r) / 2, r]);
else if (layout === 'stair') for (let c = 0; c < n; c++) for (let r = 0; r <= c; r++) out.push([c, n - 1 - r]);
else if (layout === 'L') { for (let r = 0; r < n; r++) out.push([0, r]); for (let c = 1; c < n; c++) out.push([c, n - 1]); }
else for (let c = 0; c < n; c++) out.push([c, 0]);
return out;
}
export function Pattern({ spec }: { spec: PatternSpec }) {
const hide = useContext(HideCounts);
const stages = spec.stages;
const sticks = spec.unit === 'stick';
const sizes = stages.map((n) => {
const cells = sticks ? [] : patternCells(spec.layout, n);
const w = sticks ? n : Math.max(1, ...cells.map(([c]) => c + 1));
const h = sticks ? 1 : Math.max(1, ...cells.map(([, r]) => r + 1));
return { n, cells, w, h };
});
const gapU = 1.2;
const totalU = sizes.reduce((t, s) => t + s.w, 0) + gapU * (sizes.length - 1 + (spec.next ? 1 : 0)) + (spec.next ? 1.4 : 0);
const maxH = Math.max(...sizes.map((s) => s.h));
const u = Math.min(22, (W - 24) / totalU, 150 / maxH);
const H = maxH * u + 46;
const base = 10 + maxH * u;
const col = color(spec.color, COLORS.blue);
let x = W - 12;
const groups = sizes.map((s, i) => {
x -= s.w * u;
const x0 = x;
x -= gapU * u;
const count = sticks ? 3 * s.n + 1 : s.cells.length;
return (
<g key={i}>
{sticks
? Array.from({ length: s.n + 1 }, (_, k) => <line key={`v${k}`} x1={x0 + k * u} y1={base - u + 2} x2={x0 + k * u} y2={base - 2} stroke={COLORS.orange} strokeWidth={3} strokeLinecap="round" />)
.concat(Array.from({ length: s.n }, (_, k) => [
<line key={`t${k}`} x1={x0 + k * u + 2} y1={base - u} x2={x0 + (k + 1) * u - 2} y2={base - u} stroke={COLORS.orange} strokeWidth={3} strokeLinecap="round" />,
<line key={`b${k}`} x1={x0 + k * u + 2} y1={base} x2={x0 + (k + 1) * u - 2} y2={base} stroke={COLORS.orange} strokeWidth={3} strokeLinecap="round" />,
]).flat())
: s.cells.map(([c, r], k) => {
const cx = x0 + (s.w - 1 - c) * u, cy = base - (s.h - r) * u;
return spec.unit === 'dot'
? <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 && !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>
);
});
const nx = x - 1.4 * u + gapU * u;
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
{groups}
{spec.next && (
<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>
</g>
)}
</svg>
);
}

106
src/diagram/numberline.tsx Normal file
View File

@@ -0,0 +1,106 @@
import type { NumberLineSpec } from './types';
import { Arrowheads, COLORS, INK, Label, SERIES, SOFT, StackedFraction, arrowId, color, fa, gcd, mathText, niceStep, ticksBetween } from './svg';
import { toFa } from '@/lib/format';
const W = 330;
const PAD = 26;
/**
* محورِ اعداد: نقطه (توپُر یا توخالی)، پرش (فلشِ خمیده)، و بازه (پاره‌خطِ رنگی با
* سرِ باز/بسته، یا تا بی‌نهایت). با `fractions` تیک‌ها کسرِ دوطبقه‌اند (۱/۴، ۲/۴)،
* چون در کتابِ دبستان «/» ممیز است.
*/
export function NumberLine({ spec }: { spec: NumberLineSpec }) {
const { min, max } = spec;
const intervals = spec.intervals ?? [];
const lanes = intervals.length;
const jumpH = spec.jumps.length ? 52 : 0;
const LANE = intervals.some((iv) => iv.label) ? 24 : 14;
const base = 22 + jumpH + lanes * LANE + 10;
const H = base + (spec.fractions ? 58 : 44);
const X = (v: number) => PAD + ((v - min) / (max - min)) * (W - 2 * PAD);
// محورِ عددهای صحیح گامِ صحیحِ «گرد» می‌گیرد (۱، ۲، ۵، ۱۰، …)، نه ۲٫۵
const intStep = (span: number) => [1, 2, 5, 10, 20, 25, 50, 100, 200, 500, 1000].find((c) => span / c <= 12) ?? niceStep(span, 12);
const integers = Number.isInteger(min) && Number.isInteger(max);
const step = spec.step && spec.step > 0 && (max - min) / spec.step <= 40 ? spec.step : integers ? intStep(max - min) : niceStep(max - min, 12);
const ticks = ticksBetween(min, max, step);
const labelled = new Set(spec.points.map((p) => p.at));
// بیش از ۱۴ تیک: فقط بعضی عدد می‌گیرند (صفر، مضرب‌های ۵ یا ۱۰، نقطه‌های خواسته‌شده)،
// وگرنه عددها روی هم می‌افتند.
const every = ticks.length > 14 ? Math.ceil(ticks.length / 10) : 1;
const showLabel = (t: number, i: number) => every === 1 || labelled.has(t) || Math.abs(t) < 1e-9 || i % every === 0;
const arrows = new Set<string>([INK, COLORS.red]);
const tickLabel = (t: number) => {
if (spec.fractions && step < 1) {
const den = Math.round(1 / step);
const num = Math.round(t * den);
if (num % den === 0) return null; // عددِ صحیح با رقمِ معمولی
const g = gcd(num, den);
return { num: toFa(Math.abs(num / g)), den: toFa(den / g), neg: num < 0 };
}
return null;
};
return (
<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)})`} />
{ticks.map((t, ti) => {
const fr = showLabel(t, ti) ? tickLabel(t) : null;
const hideNumber = !showLabel(t, ti);
const on = labelled.has(t);
const whole = Math.abs(t - Math.round(t)) < 1e-9;
return (
<g key={t}>
<line x1={X(t)} x2={X(t)} y1={base - (whole ? 6 : 4)} y2={base + (whole ? 6 : 4)} stroke={INK} strokeWidth={Math.abs(t) < 1e-9 ? 2.2 : 1.3} />
{fr ? (
<StackedFraction x={X(t)} y={base + 16} num={`${fr.neg ? '−' : ''}${fr.num}`} den={fr.den} color={on ? INK : SOFT} size={9} />
) : hideNumber ? null : (
<text x={X(t)} y={base + 20} fontSize={on ? 11.5 : 10} fontWeight={on ? 800 : 500} fill={on ? INK : SOFT} textAnchor="middle">{fa(t)}</text>
)}
</g>
);
})}
{intervals.map((iv, i) => {
const col = color(iv.color, COLORS[SERIES[i % SERIES.length]]);
const y = base - 10 - i * LANE;
const a = iv.from === null ? PAD - 12 : X(iv.from), b = iv.to === null ? W - PAD + 12 : X(iv.to);
return (
<g key={`iv${i}`}>
<line x1={a} x2={b} y1={y} y2={y} stroke={col} strokeWidth={4} strokeLinecap="round"
markerStart={iv.from === null ? `url(#${arrowId(col)})` : undefined} markerEnd={iv.to === null ? `url(#${arrowId(col)})` : undefined} />
{iv.from !== null && <circle cx={a} cy={y} r={4.6} fill={iv.closedFrom ? col : '#fff'} stroke={col} strokeWidth={2.2} />}
{iv.to !== null && <circle cx={b} cy={y} r={4.6} fill={iv.closedTo ? col : '#fff'} stroke={col} strokeWidth={2.2} />}
{iv.label && <Label x={(Math.max(a, PAD) + Math.min(b, W - PAD)) / 2} y={y - 7} color={col} size={10.5}>{mathText(iv.label)}</Label>}
</g>
);
})}
{spec.jumps.map((j, i) => {
const a = X(j.from), b = X(j.to);
const lift = Math.min(42, Math.abs(b - a) * 0.45 + 12);
const y = base - 6 - lanes * LANE;
return (
<g key={`j${i}`}>
<path d={`M${a},${y} Q${(a + b) / 2},${y - lift * 1.5} ${b},${y}`} fill="none" stroke={COLORS.red} strokeWidth={2.3} markerEnd={`url(#${arrowId(COLORS.red)})`} />
{j.label && <Label x={(a + b) / 2} y={y - lift * 0.8 - 4} color={COLORS.red} size={10.5}>{mathText(j.label)}</Label>}
</g>
);
})}
{spec.points.map((p, i) => {
const col = color(p.color, i === spec.points.length - 1 && spec.points.length > 1 ? COLORS.red : COLORS.blue);
const numeric = /^[-−+]?[\d۰-۹.,٫/]+$/.test(p.label.trim());
return (
<g key={`p${i}`}>
<circle cx={X(p.at)} cy={base} r={5.6} fill={p.open ? '#fff' : col} stroke={p.open ? col : '#fff'} strokeWidth={p.open ? 2.4 : 1.6} />
{p.label && !numeric && <Label x={X(p.at)} y={base + (spec.fractions ? 50 : 36)} color={col} size={10.5}>{p.label}</Label>}
</g>
);
})}
</svg>
);
}

796
src/diagram/plane.tsx Normal file
View File

@@ -0,0 +1,796 @@
import { useId, type ReactNode } from 'react';
import type { ColorName, FunctionMark, PlaneItem, PlaneSpec, PointDef, Ref, Transform } from './types';
import { derivative, extrema, parseExpr, roots } from './expr';
import { expandQuad, expandTriangle, type Layout } from './shapes';
import { Arrowheads, COLORS, GRID, INK, Label, PAPER, PERSIAN_LETTER, bidi, SERIES, SOFT, arrowId, color, fa, ltr, mathText, niceStep, piLabel, ticksBetween } from './svg';
/**
* صفحه‌ی مختصات و هندسه. همه‌ی مختصات اینجا حساب می‌شوند:
* نقطه‌ها polar، وسطِ پاره‌خط، پای عمود، محلِ برخوردِ دو خط، نقطه‌ی روی نمودار
* تابع‌ها ریشه، رأس/اکسترمم، عرض از مبدأ، محورِ تقارن، شیبِ مماس
* تبدیل‌ها دوران، قرینه، انتقال، تجانس
* مدل فقط می‌گوید کدام را می‌خواهد. هر تکه‌ای که حل نشود (نقطه‌ی ناشناخته، فرمولِ
* نامعتبر) بی‌صدا حذف می‌شود و بقیه‌ی شکل کشیده می‌شود.
*/
type XY = [number, number];
const W = 320;
const PAD = 24;
const GEOMETRY = new Set(['circle', 'arc', 'sector', 'angle', 'rightAngle', 'polygon', 'image']);
/* ─── هندسه‌ی نقطه‌ها ─────────────────────────────────────────────────────── */
function footOf(p: XY, a: XY, b: XY): XY {
const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy || 1);
return [a[0] + t * dx, a[1] + t * dy];
}
function intersect(a: XY, b: XY, c: XY, d: XY): XY | null {
const den = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
if (Math.abs(den) < 1e-12) return null;
const t = ((a[0] - c[0]) * (c[1] - d[1]) - (a[1] - c[1]) * (c[0] - d[0])) / den;
return [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])];
}
function transformPoint(p: XY, tr: Transform, ref: (r: Ref) => XY | null): XY | null {
if ('rotate' in tr) {
const c = ref(tr.rotate.center);
if (!c) return null;
// دوران مثبت = پادساعتگرد، مثلِ کتاب؛ «در جهتِ عقربه‌ها» یعنی زاویه‌ی منفی.
const t = (tr.rotate.deg * Math.PI) / 180, [x, y] = [p[0] - c[0], p[1] - c[1]];
return [c[0] + x * Math.cos(t) - y * Math.sin(t), c[1] + x * Math.sin(t) + y * Math.cos(t)];
}
if ('translate' in tr) return [p[0] + tr.translate[0], p[1] + tr.translate[1]];
if ('scale' in tr) {
const c = ref(tr.scale.center);
return c ? [c[0] + tr.scale.k * (p[0] - c[0]), c[1] + tr.scale.k * (p[1] - c[1])] : null;
}
const r = tr.reflect;
if (r === 'x') return [p[0], -p[1]];
if (r === 'y') return [-p[0], p[1]];
if (r === 'y=x') return [p[1], p[0]];
const a = ref(r[0]), b = ref(r[1]);
if (!a || !b) return null;
const f = footOf(p, a, b);
return [2 * f[0] - p[0], 2 * f[1] - p[1]];
}
/** نقطه‌های نام‌دار، با حلِ ارجاعِ زنجیره‌ای و بدونِ حلقه‌ی بی‌پایان. */
function resolvePoints(spec: PlaneSpec) {
const named = new Map<string, XY>();
const resolving = new Set<string>();
const ref = (r: Ref): XY | null => (typeof r === 'string' ? byName(r) : [r[0], r[1]]);
function byName(name: string): XY | null {
if (named.has(name)) return named.get(name)!;
const def: PointDef | undefined = spec.points[name];
if (def === undefined || resolving.has(name)) return null;
resolving.add(name);
const xy = build(def);
resolving.delete(name);
if (xy && Number.isFinite(xy[0]) && Number.isFinite(xy[1])) named.set(name, xy);
return named.get(name) ?? null;
}
function build(def: PointDef): XY | null {
if (Array.isArray(def)) return [def[0], def[1]];
if ('polar' in def) {
const o = def.from !== undefined ? ref(def.from) : [0, 0];
if (!o) return null;
const t = (def.polar[1] * Math.PI) / 180;
return [o[0] + def.polar[0] * Math.cos(t), o[1] + def.polar[0] * Math.sin(t)];
}
if ('mid' in def) {
const a = ref(def.mid[0]), b = ref(def.mid[1]);
return a && b ? [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] : null;
}
if ('foot' in def) {
const p = ref(def.foot[0]), a = ref(def.foot[1]), b = ref(def.foot[2]);
return p && a && b ? footOf(p, a, b) : null;
}
if ('intersect' in def) {
const [[a, b], [c, d]] = def.intersect;
const pa = ref(a), pb = ref(b), pc = ref(c), pd = ref(d);
return pa && pb && pc && pd ? intersect(pa, pb, pc, pd) : null;
}
if ('along' in def) {
const a = ref(def.along[0]), b = ref(def.along[1]), t = def.along[2];
return a && b ? [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])] : null;
}
if ('translate' in def) {
const o = ref(def.from);
return o ? [o[0] + def.translate[0], o[1] + def.translate[1]] : null;
}
if ('fx' in def) {
try {
const x = def.fx[1];
return [x, parseExpr(def.fx[0])(x)];
} catch {
return null;
}
}
return null;
}
Object.keys(spec.points).forEach(byName);
return { named, ref };
}
/* ─── نمونه‌برداری از نمودار، با شکستن در مجانب ──────────────────────────── */
function sample(f: (x: number) => number, from: number, to: number, n = 360): XY[][] {
const runs: XY[][] = [];
let run: XY[] = [];
let prev: number | null = null;
for (let i = 0; i <= n; i++) {
const x = from + ((to - from) * i) / n, y = f(x);
const jump = prev !== null && Number.isFinite(y) && Math.abs(y - prev) > 50 * (Math.abs(prev) + Math.abs(y) + 1) / Math.max(1, n / 60);
if (!Number.isFinite(y) || jump) {
if (run.length > 1) runs.push(run);
run = Number.isFinite(y) ? [[x, y]] : [];
} else run.push([x, y]);
prev = Number.isFinite(y) ? y : null;
}
if (run.length > 1) runs.push(run);
return runs;
}
interface Curve { f: (x: number) => number; from: number; to: number; runs: XY[][] }
function compile(expr: string, from: number, to: number): Curve | null {
try {
const f = parseExpr(expr);
const runs = sample(f, from, to);
return runs.length ? { f, from, to, runs } : null;
} catch {
return null;
}
}
/* ─── برچسبِ ترکیبی: «رأس (۱، −۱)» دو سطر می‌شود تا جهتِ متن به هم نریزد ── */
export function splitLabel(text: string): [string, string] | null {
if (!PERSIAN_LETTER.test(text)) return null;
const m = /^([^()\d۰-۹=]*?[؀-ۿ][^()\d۰-۹=]*?)\s*([(\d۰-۹−\-+].*)$/.exec(text);
return m && /[\d۰-۹]/.test(m[2]) ? [m[1].trim(), m[2].trim()] : null;
}
/** رقمِ لاتین به فارسی و منفیِ درست، در متنِ ریاضیِ برچسب‌ها. */
export { mathText };
function RichLabel({ x, y, text, fill, size = 11, anchor = 'middle' }: { x: number; y: number; text: string; fill: string; size?: number; anchor?: 'start' | 'middle' | 'end' }) {
const parts = splitLabel(text);
if (!parts) return <Label x={x} y={y} color={fill} size={size} anchor={anchor}>{mathText(text)}</Label>;
return (
<g>
<Label x={x} y={y - size * 0.55} color={fill} size={size} anchor={anchor}>{parts[0]}</Label>
<text x={x} y={y + size * 0.75} fontSize={size - 0.5} fontWeight={700} fill={fill} textAnchor={anchor} {...bidi(mathText(parts[1]))}
stroke={PAPER} strokeWidth={3} paintOrder="stroke">{mathText(parts[1])}</text>
</g>
);
}
/* ─── خودِ شکل ───────────────────────────────────────────────────────────── */
/**
* قطعه‌هایی که به قطعه‌های ساده‌تر باز می‌شوند، پیش از هر حسابی:
* regularPolygon → نقطه‌های polar با زاویه‌های مساوی + polygon (مدل مختصاتِ رأسِ پنج‌ضلعیِ
* منتظم را خودش حدس زده بود و شکل منتظم نشد)
* exteriorAngle → نقطه‌ی پنهان روی امتدادِ ضلع + پاره‌خطِ خط‌چین + angle (مدل با angle همیشه
* زاویه‌ی داخلی را می‌کشید)
* triangle، quad → رأس‌ها از روی ضلع و زاویه و ارتفاعِ مسئله (shapes.ts)
*/
const COMPOUND = new Set(['regularPolygon', 'exteriorAngle', 'triangle', 'quad']);
function expand(spec: PlaneSpec): PlaneSpec {
if (!spec.items.some((i) => COMPOUND.has(i.type))) return spec;
const points: Record<string, PointDef> = { ...spec.points };
const items: PlaneItem[] = [];
const layout: Layout = { placed: [] };
const taken = (n: string) => n in points || spec.items.some((i) => (i.type === 'triangle' || i.type === 'quad') && (i.names as string[]).includes(n));
spec.items.forEach((item, idx) => {
if (item.type === 'triangle') {
// داده‌ی متناقض (۳، ۴، ۱۰) مثلثی نمی‌سازد: فقط همین item حذف می‌شود
items.push(...(expandTriangle(item, idx, points, taken, layout) ?? []));
} else if (item.type === 'quad') {
items.push(...expandQuad(item, idx, points, layout));
} else if (item.type === 'regularPolygon') {
const center = item.center ?? '_rc' + idx;
if (!item.center) points['_rc' + idx] = [0, 0];
// پیش‌فرض: ضلعِ پایین افقی، مثلِ کتاب
const start = item.start ?? -90 - 180 / item.n;
const names = Array.from({ length: item.n }, (_, i) => item.names?.[i] ?? `_r${idx}_${i}`);
names.forEach((name, i) => { points[name] = { polar: [item.r, start + (360 * i) / item.n], from: center }; });
items.push({ type: 'polygon', points: names, fill: item.fill, color: item.color, dashed: item.dashed, label: item.label });
} else if (item.type === 'exteriorAngle') {
const ext = `_ext${idx}`;
points[ext] = { along: [item.from, item.vertex, 1.7] };
items.push({ type: 'segment', from: item.vertex, to: ext, dashed: true, color: 'gray' });
items.push({ type: 'angle', vertex: item.vertex, from: ext, to: item.to, label: item.label, color: item.color });
} else items.push(item);
});
return { ...spec, points, items };
}
/** نام‌هایی که یک item یا تعریفِ نقطه‌ی دیگری به آن‌ها ارجاع داده. */
function referencedNames(spec: PlaneSpec): Set<string> {
const out = new Set<string>();
const walk = (v: unknown) => {
if (typeof v === 'string') { if (v in spec.points) out.add(v); }
else if (Array.isArray(v)) v.forEach(walk);
else if (v && typeof v === 'object') Object.values(v).forEach(walk);
};
spec.items.forEach(walk);
Object.values(spec.points).forEach(walk);
return out;
}
export function Plane({ spec: raw }: { spec: PlaneSpec }) {
const spec = expand(raw);
// شناسه‌ی یکتا: چند شکل در یک گفتگو نباید clipPathِ همدیگر را بگیرند.
const clipId = `dg-plot-${useId().replace(/:/g, '')}`;
const { named, ref } = resolvePoints(spec);
// تصویرِ تبدیل‌ها پیش از همه، تا A′ مثلِ هر نقطه‌ی دیگر قابلِ ارجاع باشد.
const images: { item: Extract<PlaneItem, { type: 'image' }>; pts: XY[]; names: string[] }[] = [];
for (const item of spec.items) {
if (item.type !== 'image') continue;
const pts: XY[] = [], names: string[] = [];
item.of.forEach((r, i) => {
const p = ref(r);
const q = p && transformPoint(p, item.transform, ref);
if (!q) return;
const name = item.names?.[i] ?? (typeof r === 'string' ? `${r}′` : '');
pts.push(q); names.push(name);
if (name && !named.has(name)) named.set(name, q);
});
if (pts.length) images.push({ item, pts, names });
}
// نمودارها، برای کادر و برای رسم.
let series = 0;
const nextColor = (c?: ColorName) => COLORS[c ?? SERIES[series++ % SERIES.length]];
const curves = new Map<PlaneItem, Curve[]>();
for (const item of spec.items) {
if (item.type === 'function' || item.type === 'area' || item.type === 'region') {
const range = item.type === 'region' ? [item.from ?? -50, item.to ?? 50] : [item.from, item.to];
const c = compile(item.expr, range[0], range[1]);
if (c) curves.set(item, [c]);
if (item.type === 'area' && item.expr2) {
const c2 = compile(item.expr2, item.from, item.to);
if (c && c2) curves.set(item, [c, c2]);
}
} else if (item.type === 'piecewise') {
const cs = item.pieces.map((p) => compile(p.expr, p.from, p.to)).filter((c): c is Curve => c !== null);
if (cs.length) curves.set(item, cs);
} else if (item.type === 'tangent') {
try {
const f = parseExpr(item.expr);
if (Number.isFinite(f(item.at))) curves.set(item, [{ f, from: item.at, to: item.at, runs: [] }]);
} catch { /* فرمولِ نامعتبر */ }
}
}
// نقطه‌های ویژه‌ی نمودار (ریشه، رأس، …) که کد حساب می‌کند.
const markPts: { xy: XY; kind: FunctionMark | 'max' | 'min'; color: string }[] = [];
const axisLines: number[] = [];
for (const item of spec.items) {
if (item.type !== 'function' || !item.marks?.length) continue;
const c = curves.get(item)?.[0];
if (!c) continue;
const want = new Set(item.marks);
if (want.has('roots')) for (const r of roots(c.f, c.from, c.to).slice(0, 6)) markPts.push({ xy: [r, 0], kind: 'roots', color: COLORS.green });
if (want.has('vertex') || want.has('extrema') || want.has('axis')) {
const ex = extrema(c.f, c.from, c.to).slice(0, 6);
if (want.has('vertex') || want.has('extrema')) for (const e of ex) markPts.push({ xy: [e.x, e.y], kind: want.has('vertex') ? 'vertex' : e.kind, color: COLORS.red });
if (want.has('axis') && ex.length === 1) axisLines.push(ex[0].x);
}
if (want.has('yIntercept') && c.from <= 0 && c.to >= 0) {
const y = c.f(0);
if (Number.isFinite(y)) markPts.push({ xy: [0, y], kind: 'yIntercept', color: COLORS.orange });
}
}
/* ── کادر ── */
const xs: number[] = [], ys: number[] = [];
const add = (p: XY | null) => { if (p) { xs.push(p[0]); ys.push(p[1]); } };
named.forEach((p) => add(p));
images.forEach((im) => im.pts.forEach(add));
markPts.forEach((m) => add(m.xy));
for (const item of spec.items) {
switch (item.type) {
case 'circle': case 'arc': case 'sector': {
const c = ref(item.center);
const through = item.type === 'circle' && item.through ? ref(item.through) : null;
const r = item.type === 'circle' ? (through && c ? Math.hypot(through[0] - c[0], through[1] - c[1]) : item.r ?? 0) : item.r;
if (c) { add([c[0] - r, c[1] - r]); add([c[0] + r, c[1] + r]); }
break;
}
case 'segment': case 'arrow': case 'vector': add(ref(item.from)); add(ref(item.to)); break;
case 'ray': add(ref(item.from)); add(ref(item.through)); break;
case 'line': { const [a, b] = lineEnds(item, ref); if (!item.eq && !item.equation) { add(a); add(b); } else if (a) add(a); break; }
case 'polygon': item.points.forEach((p) => add(ref(p))); break;
case 'point': add(ref(item.point)); break;
case 'text': add(item.at); break;
case 'vline': xs.push(item.x); break;
case 'hline': ys.push(item.y); break;
case 'tangent': { const c = curves.get(item)?.[0]; if (c) add([item.at, c.f(item.at)]); break; }
default: break;
}
}
for (const [item, cs] of curves) {
if (item.type === 'region' || item.type === 'tangent') continue;
for (const c of cs) {
xs.push(c.from, c.to);
const all = c.runs.flat().map((p) => p[1]).sort((a, b) => a - b);
if (all.length) ys.push(all[Math.floor(all.length * 0.04)], all[Math.ceil(all.length * 0.96) - 1]);
}
}
if (spec.axes) { xs.push(0); ys.push(0); }
if (!xs.length || !ys.length) return null;
let [x0, x1] = spec.xRange ?? [Math.min(...xs), Math.max(...xs)];
let [y0, y1] = spec.yRange ?? [Math.min(...ys), Math.max(...ys)];
if (x1 - x0 < 1e-9) { x0 -= 1; x1 += 1; }
if (y1 - y0 < 1e-9) { y0 -= 1; y1 += 1; }
if (!spec.xRange) { const m = (x1 - x0) * 0.1 + 0.15; x0 -= m; x1 += m; }
if (!spec.yRange) { const m = (y1 - y0) * 0.1 + 0.15; y0 -= m; y1 += m; }
// مقیاسِ یکسان برای هندسه (دایره نباید بیضی شود)؛ برای نمودارِ تنها، ارتفاع سقف دارد.
const geometry = spec.items.some((i) => GEOMETRY.has(i.type));
const plotW = W - 2 * PAD;
let sx = plotW / (x1 - x0), sy = sx;
let H = (y1 - y0) * sy + 2 * PAD;
const [minH, maxH] = [170, 380];
if (H > maxH) {
if (geometry) { sy = sx = (maxH - 2 * PAD) / (y1 - y0); } else sy = (maxH - 2 * PAD) / (y1 - y0);
H = maxH;
} else if (H < minH && !geometry) {
sy = (minH - 2 * PAD) / (y1 - y0);
H = minH;
} else H = Math.max(H, 120);
const ox = (W - (x1 - x0) * sx) / 2, oy = (H - (y1 - y0) * sy) / 2;
const X = (x: number) => ox + (x - x0) * sx;
const Y = (y: number) => oy + (y1 - y) * sy;
const P = (p: XY): XY => [X(p[0]), Y(p[1])];
const path = (pts: XY[]) => pts.map((p, i) => `${i ? 'L' : 'M'}${X(p[0]).toFixed(2)},${Y(p[1]).toFixed(2)}`).join(' ');
const layers = { back: [] as ReactNode[], fill: [] as ReactNode[], mid: [] as ReactNode[], curves: [] as ReactNode[], marks: [] as ReactNode[], points: [] as ReactNode[], labels: [] as ReactNode[] };
const arrowColors = new Set<string>([SOFT]);
let key = 0;
const k = () => key++;
const clip = `url(#${clipId})`;
const centroid: XY = (() => {
const all = [...named.values()];
return all.length ? [all.reduce((a, p) => a + p[0], 0) / all.length, all.reduce((a, p) => a + p[1], 0) / all.length] : [(x0 + x1) / 2, (y0 + y1) / 2];
})();
const away = (p: XY, dist = 13): XY => {
const [px, py] = P(p), [cx, cy] = P(centroid);
let [dx, dy] = [px - cx, py - cy];
const n = Math.hypot(dx, dy);
if (n < 1e-6) { dx = 0.6; dy = -0.8; } else { dx /= n; dy /= n; }
return [px + dx * dist, py + dy * dist + 4];
};
/* ── شبکه و محور ── */
const trig = spec.trig ?? false;
const xstep = trig ? (x1 - x0 > 5 * Math.PI ? Math.PI : Math.PI / 2) : spec.grid ? Math.max(1, niceStep(x1 - x0, 12)) : niceStep(x1 - x0, 8);
const ystep = spec.grid ? Math.max(1, niceStep(y1 - y0, 12)) : niceStep(y1 - y0, 7);
const xticks = ticksBetween(x0, x1, xstep), yticks = ticksBetween(y0, y1, ystep);
if (spec.axes || spec.grid) {
layers.back.push(
<g key={k()} stroke={GRID} strokeWidth={1}>
{xticks.map((t) => <line key={`gx${t}`} x1={X(t)} x2={X(t)} y1={Y(y0)} y2={Y(y1)} />)}
{yticks.map((t) => <line key={`gy${t}`} y1={Y(t)} y2={Y(t)} x1={X(x0)} x2={X(x1)} />)}
</g>,
);
}
if (spec.axes) {
const ax = x0 <= 0 && x1 >= 0 ? X(0) : X(x0), ay = y0 <= 0 && y1 >= 0 ? Y(0) : Y(y0);
layers.back.push(
<g key={k()} stroke={SOFT} strokeWidth={1.4}>
<line x1={X(x0)} y1={ay} x2={X(x1) + 6} y2={ay} markerEnd={`url(#${arrowId(SOFT)})`} />
<line x1={ax} y1={Y(y0)} x2={ax} y2={Y(y1) - 6} markerEnd={`url(#${arrowId(SOFT)})`} />
</g>,
<g key={k()} fill={SOFT} fontSize={9.5} fontWeight={600}>
{xticks.filter((t) => Math.abs(t) > 1e-9).map((t) => (
<g key={`tx${t}`}>
<line x1={X(t)} x2={X(t)} y1={ay - 3} y2={ay + 3} stroke={SOFT} />
<text x={X(t)} y={ay + 13} textAnchor="middle">{trig ? piLabel(t) : fa(t)}</text>
</g>
))}
{yticks.filter((t) => Math.abs(t) > 1e-9).map((t) => (
<g key={`ty${t}`}>
<line x1={ax - 3} x2={ax + 3} y1={Y(t)} y2={Y(t)} stroke={SOFT} />
<text x={ax - 6} y={Y(t) + 3.5} textAnchor="end">{fa(t)}</text>
</g>
))}
{x0 <= 0 && x1 >= 0 && y0 <= 0 && y1 >= 0 && <text x={ax - 6} y={ay + 13} textAnchor="end">۰</text>}
<text x={X(x1) + 2} y={ay - 7} textAnchor="end" fontStyle="italic">x</text>
<text x={ax + 7} y={Y(y1) + 4} fontStyle="italic">y</text>
</g>,
);
}
/* ── تکه‌ها ── */
for (const item of spec.items) {
const itemColor = (fallback: string) => color(item.color, fallback);
switch (item.type) {
case 'circle': {
const c = ref(item.center);
if (!c) break;
const through = item.through ? ref(item.through) : null;
const r = through ? Math.hypot(through[0] - c[0], through[1] - c[1]) : item.r ?? 0;
if (r <= 0) break;
const col = itemColor(COLORS.blue);
layers.mid.push(<ellipse key={k()} cx={X(c[0])} cy={Y(c[1])} rx={r * sx} ry={r * sy} fill={item.fill ? `${col}1f` : 'none'} stroke={col} strokeWidth={2.2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(c[0] + r * 0.72)} y={Y(c[1] + r * 0.72) - 4} text={item.label} fill={col} />);
break;
}
case 'arc': case 'sector': {
const c = ref(item.center);
if (!c) break;
const col = itemColor(item.type === 'sector' ? COLORS.orange : COLORS.red);
const a0 = (item.start * Math.PI) / 180, a1 = (item.end * Math.PI) / 180;
const pts: XY[] = [];
for (let i = 0; i <= 64; i++) { const t = a0 + ((a1 - a0) * i) / 64; pts.push([c[0] + item.r * Math.cos(t), c[1] + item.r * Math.sin(t)]); }
const d = item.type === 'sector' ? `${path([c, ...pts])} Z` : path(pts);
layers.mid.push(<path key={k()} d={d} fill={item.type === 'sector' ? `${col}33` : 'none'} stroke={col} strokeWidth={item.type === 'arc' ? 3 : 2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
if (item.label) {
const m = (a0 + a1) / 2, rr = item.type === 'arc' ? item.r * 1.16 : item.r * 0.6;
layers.labels.push(<RichLabel key={k()} x={X(c[0] + rr * Math.cos(m))} y={Y(c[1] + rr * Math.sin(m)) + 4} text={item.label} fill={col} />);
}
break;
}
case 'polygon': {
const pts = item.points.map(ref).filter((p): p is XY => p !== null);
if (pts.length < 3) break;
const col = itemColor(COLORS.blue);
layers.fill.push(<path key={k()} d={`${path(pts)} Z`} fill={item.fill === false ? 'none' : `${col}1a`} stroke={item.color ? col : INK} strokeWidth={2.2} strokeLinejoin="round" strokeDasharray={item.dashed ? '6 5' : undefined} />);
if (item.label) {
const cx = pts.reduce((a, p) => a + p[0], 0) / pts.length, cy = pts.reduce((a, p) => a + p[1], 0) / pts.length;
layers.labels.push(<RichLabel key={k()} x={X(cx)} y={Y(cy) + 4} text={item.label} fill={col} />);
}
break;
}
case 'segment': case 'arrow': case 'vector': {
const a = ref(item.from), b = ref(item.to);
if (!a || !b) break;
const isArrow = item.type !== 'segment';
const col = itemColor(isArrow ? (item.type === 'vector' ? COLORS.purple : COLORS.red) : INK);
if (isArrow) arrowColors.add(col);
const [ax, ay] = P(a), [bx, by] = P(b);
layers.mid.push(<line key={k()} x1={ax} y1={ay} x2={bx} y2={by} stroke={col} strokeWidth={isArrow ? 2.4 : 2.2} strokeLinecap="round"
strokeDasharray={item.dashed ? '6 5' : undefined} markerEnd={isArrow ? `url(#${arrowId(col)})` : undefined} />);
if (item.type === 'segment' && item.ticks) layers.mid.push(<Ticks key={k()} a={[ax, ay]} b={[bx, by]} n={item.ticks} col={col} />);
if (item.label) {
const [lx, ly, anchor] = sideOffset([ax, ay], [bx, by], P(centroid));
layers.labels.push(<RichLabel key={k()} x={lx} y={ly} anchor={anchor} text={item.label} fill={item.color ? col : COLORS.blue} />);
}
break;
}
case 'line': case 'ray': {
const [a, b] = item.type === 'line' ? lineEnds(item, ref) : [ref(item.from), ref(item.through)];
if (!a || !b || (a[0] === b[0] && a[1] === b[1])) break;
const col = itemColor(COLORS.blue);
const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
const t = (Math.abs(x1 - x0) + Math.abs(y1 - y0)) * 3 / Math.hypot(dx, dy);
const s: XY = item.type === 'line' ? [a[0] - dx * t, a[1] - dy * t] : a;
const e: XY = [a[0] + dx * t, a[1] + dy * t];
layers.mid.push(<line key={k()} clipPath={clip} x1={X(s[0])} y1={Y(s[1])} x2={X(e[0])} y2={Y(e[1])} stroke={col} strokeWidth={2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
if (item.label) {
const lp = edgePoint(a, b, x0, x1, y0, y1);
layers.labels.push(<RichLabel key={k()} x={X(lp[0])} y={Y(lp[1]) - 6} text={item.label} fill={col} />);
}
break;
}
case 'vline': case 'hline': {
const col = itemColor(COLORS.gray);
const dash = item.dashed === false ? undefined : '5 4';
if (item.type === 'vline') {
layers.mid.push(<line key={k()} x1={X(item.x)} x2={X(item.x)} y1={Y(y0)} y2={Y(y1)} stroke={col} strokeWidth={1.8} strokeDasharray={dash} />);
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(item.x) + 4} y={Y(y1) + 12} text={item.label} fill={col} />);
} else {
layers.mid.push(<line key={k()} y1={Y(item.y)} y2={Y(item.y)} x1={X(x0)} x2={X(x1)} stroke={col} strokeWidth={1.8} strokeDasharray={dash} />);
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(x0) + 30} y={Y(item.y) - 7} text={item.label} fill={col} />);
}
break;
}
case 'function': case 'piecewise': {
const cs = curves.get(item);
if (!cs) break;
const col = nextColor(item.color);
cs.forEach((c) => c.runs.forEach((run) => layers.curves.push(
<path key={k()} clipPath={clip} d={path(run)} fill="none" stroke={col} strokeWidth={2.6} strokeLinejoin="round" strokeLinecap="round" strokeDasharray={item.dashed ? '7 5' : undefined} />,
)));
if (item.type === 'piecewise') {
item.pieces.forEach((p) => {
let f: (x: number) => number;
try { f = parseExpr(p.expr); } catch { return; }
([[p.from, p.closedFrom ?? true], [p.to, p.closedTo ?? true]] as [number, boolean][]).forEach(([x, closed]) => {
const y = f(x);
if (Number.isFinite(y) && Math.abs(x) < 1e3) layers.points.push(<circle key={k()} cx={X(x)} cy={Y(y)} r={4} fill={closed ? col : '#fff'} stroke={col} strokeWidth={2} />);
});
});
}
if (item.label) {
// سرِ راستِ بخشِ دیده‌شده‌ی نمودار، کمی دورتر از خودِ خط.
const vis = cs.flatMap((c) => c.runs.flat()).filter(([x, y]) => x >= x0 && x <= x1 && y >= y0 + (y1 - y0) * 0.04 && y <= y1 - (y1 - y0) * 0.04);
const at = vis[vis.length - 1];
if (at) {
const slope = vis.length > 2 ? (Y(at[1]) - Y(vis[vis.length - 3][1])) : 0;
layers.labels.push(<RichLabel key={k()} x={Math.min(W - 44, Math.max(44, X(at[0]) - 30))} y={Math.max(14, Math.min(H - 8, Y(at[1]) + (slope < 0 ? 16 : -10)))} text={item.label} fill={col} />);
}
}
break;
}
case 'tangent': {
const c = curves.get(item)?.[0];
if (!c) break;
const col = itemColor(COLORS.red);
const y = c.f(item.at), m = derivative(c.f, item.at);
const span = (x1 - x0) * 2;
layers.curves.push(<line key={k()} clipPath={clip} x1={X(item.at - span)} y1={Y(y - m * span)} x2={X(item.at + span)} y2={Y(y + m * span)} stroke={col} strokeWidth={2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
layers.points.push(<circle key={k()} cx={X(item.at)} cy={Y(y)} r={4.2} fill={col} stroke="#fff" strokeWidth={1.5} />);
const text = item.label ?? `شیب = ${fa(m)}`;
layers.labels.push(<RichLabel key={k()} x={Math.min(W - 44, X(item.at) + 36)} y={Y(y) + (m > 0 ? 18 : -10)} text={text} fill={col} />);
break;
}
case 'area': {
const cs = curves.get(item);
if (!cs) break;
const col = nextColor(item.color);
const top = cs[0].runs.flat();
const bottom: XY[] = cs[1] ? cs[1].runs.flat().slice().reverse() : [[item.to, 0], [item.from, 0]];
layers.fill.push(<path key={k()} clipPath={clip} d={`${path([...top, ...bottom])} Z`} fill={`${col}38`} stroke="none" />);
if (item.label) {
const mid = top[Math.floor(top.length / 2)];
layers.labels.push(<RichLabel key={k()} x={X(mid[0])} y={(Y(mid[1]) + Y(cs[1] ? cs[1].f(mid[0]) : 0)) / 2 + 4} text={item.label} fill={col} />);
}
break;
}
case 'region': {
const c = curves.get(item)?.[0];
if (!c) break;
const col = itemColor(COLORS.blue);
const edge = item.side === 'above' ? y1 + (y1 - y0) : y0 - (y1 - y0);
c.runs.forEach((run) => {
const poly: XY[] = [...run, [run[run.length - 1][0], edge], [run[0][0], edge]];
layers.fill.push(<path key={k()} clipPath={clip} d={`${path(poly)} Z`} fill={`${col}26`} stroke="none" />);
layers.curves.push(<path key={k()} clipPath={clip} d={path(run)} fill="none" stroke={col} strokeWidth={2.2} strokeDasharray={item.strict ? '6 5' : undefined} />);
});
if (item.label) {
// جایی داخلِ ناحیه: بینِ نمودار و لبه‌ی کادر، در یک‌چهارمِ عرض از سمتی که ناحیه پهن‌تر است.
const candidates = [0.2, 0.5, 0.8].map((t) => x0 + (x1 - x0) * t);
let best = candidates[0], room = -Infinity;
for (const x of candidates) {
const y = c.f(x);
const r = item.side === 'above' ? y1 - y : y - y0;
if (Number.isFinite(r) && r > room) { room = r; best = x; }
}
const yc = c.f(best);
const yl = item.side === 'above' ? (Math.min(yc, y1) + y1) / 2 : (Math.max(yc, y0) + y0) / 2;
layers.labels.push(<RichLabel key={k()} x={X(best)} y={Y(yl)} text={item.label} fill={col} />);
}
break;
}
case 'angle': case 'rightAngle': {
const v = ref(item.vertex), a = ref(item.from), b = ref(item.to);
if (!v || !a || !b) break;
const col = itemColor(COLORS.red);
const [vx, vy] = P(v), [ax, ay] = P(a), [bx, by] = P(b);
const ta = Math.atan2(ay - vy, ax - vx), tb = Math.atan2(by - vy, bx - vx);
let d = tb - ta;
while (d <= -Math.PI) d += 2 * Math.PI;
while (d > Math.PI) d -= 2 * Math.PI;
const r = Math.max(12, Math.min(26, Math.hypot(ax - vx, ay - vy) * 0.35, Math.hypot(bx - vx, by - vy) * 0.35));
if (item.type === 'rightAngle') {
const q = Math.min(r, 12);
const ua: XY = [Math.cos(ta), Math.sin(ta)], ub: XY = [Math.cos(tb), Math.sin(tb)];
const p1: XY = [vx + ua[0] * q, vy + ua[1] * q], p3: XY = [vx + ub[0] * q, vy + ub[1] * q];
const p2: XY = [p1[0] + ub[0] * q, p1[1] + ub[1] * q];
layers.marks.push(<path key={k()} d={`M${p1.join(',')} L${p2.join(',')} L${p3.join(',')}`} fill={`${col}22`} stroke={col} strokeWidth={1.8} />);
} else {
const arcs = Math.max(1, Math.min(3, item.arcs ?? 1));
for (let i = 0; i < arcs; i++) {
const rr = r + i * 4;
const s: XY = [vx + rr * Math.cos(ta), vy + rr * Math.sin(ta)], e: XY = [vx + rr * Math.cos(ta + d), vy + rr * Math.sin(ta + d)];
layers.marks.push(
<path key={k()} d={i === 0 ? `M${vx},${vy} L${s.join(',')} A${rr},${rr} 0 0 ${d > 0 ? 1 : 0} ${e.join(',')} Z` : `M${s.join(',')} A${rr},${rr} 0 0 ${d > 0 ? 1 : 0} ${e.join(',')}`}
fill={i === 0 ? `${col}2a` : 'none'} stroke={col} strokeWidth={1.7} />,
);
}
}
if (item.label) {
const m = ta + d / 2, lr = r + 15 + ((item.type === 'angle' ? item.arcs ?? 1 : 1) - 1) * 4;
layers.labels.push(<RichLabel key={k()} x={vx + lr * Math.cos(m)} y={vy + lr * Math.sin(m) + 4} text={item.label} fill={col} />);
}
break;
}
case 'text': {
layers.labels.push(<RichLabel key={k()} x={X(item.at[0])} y={Y(item.at[1])} text={item.text} fill={color(item.color, INK)} />);
break;
}
default: break;
}
}
/* ── تصویرِ تبدیل‌ها ── */
for (const im of images) {
const col = color(im.item.color, COLORS.purple);
if (im.pts.length >= 3) layers.fill.push(<path key={k()} d={`${path(im.pts)} Z`} fill={`${col}14`} stroke={col} strokeWidth={2.2} strokeDasharray={im.item.dashed === false ? undefined : '6 4'} strokeLinejoin="round" />);
else if (im.pts.length === 2) layers.mid.push(<line key={k()} x1={X(im.pts[0][0])} y1={Y(im.pts[0][1])} x2={X(im.pts[1][0])} y2={Y(im.pts[1][1])} stroke={col} strokeWidth={2.2} strokeDasharray="6 4" />);
im.pts.forEach((p, i) => {
layers.points.push(<circle key={k()} cx={X(p[0])} cy={Y(p[1])} r={3.4} fill={col} />);
if (im.names[i]) { const [lx, ly] = away(p); layers.labels.push(<Label key={k()} x={lx} y={ly} color={col} weight={800}>{im.names[i]}</Label>); }
});
}
/* ── نقطه‌های ویژه‌ی نمودار ── */
for (const x of axisLines) {
layers.mid.push(<line key={k()} x1={X(x)} x2={X(x)} y1={Y(y0)} y2={Y(y1)} stroke={COLORS.gray} strokeWidth={1.6} strokeDasharray="5 4" />);
layers.labels.push(<Label key={k()} x={X(x)} y={Y(y1) + 12} color={COLORS.gray} size={10}>{`x = ${fa(x)}`}</Label>);
}
const markName: Record<string, string> = { vertex: 'رأس', max: 'ماکزیمم', min: 'مینیمم', yIntercept: '', roots: '' };
// عددِ نزدیک به مضربِ π/2 در نمودارِ مثلثاتی به صورتِ π نوشته می‌شود (۳٫۱۴ → π).
const fx = (x: number) => (trig && Math.abs(x / (Math.PI / 2) - Math.round(x / (Math.PI / 2))) < 1e-6 ? piLabel(x) : fa(x));
// بیش از چهار نقطه از یک نوع (ریشه‌های سینوس) فقط نقطه می‌گیرند، نه برچسب؛ وگرنه روی هم می‌افتند.
const kindCount = new Map<string, number>();
markPts.forEach((m) => kindCount.set(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind, (kindCount.get(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind) ?? 0) + 1));
// اگر مدل خودش روی همان ریشه یا رأس نقطه گذاشته (نام‌دار یا با مختصات)، برچسبِ ما
// تکرار نشود؛ وگرنه «(۳، ۰)» دو بار و روی هم نوشته می‌شد.
const namedXY = [
...named.values(),
...spec.items.flatMap((it) => (it.type === 'point' && typeof it.point !== 'string' ? [it.point as XY] : [])),
];
for (const m of markPts) {
if (namedXY.some((q) => Math.abs(q[0] - m.xy[0]) < 1e-3 && Math.abs(q[1] - m.xy[1]) < 1e-3)) continue;
const [px, py] = P(m.xy);
if (px < 0 || px > W || py < 0 || py > H) continue;
layers.points.push(<circle key={k()} cx={px} cy={py} r={4.3} fill={m.color} stroke="#fff" strokeWidth={1.6} />);
const crowded = (kindCount.get(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind) ?? 0) > 4;
if (crowded) continue;
const coord = ltr(`(${fx(m.xy[0])}, ${fa(m.xy[1])})`);
const below = m.kind === 'min' || m.kind === 'vertex' ? m.xy[1] <= (y0 + y1) / 2 : m.kind === 'roots';
const ly = below ? py + 16 : py - 9;
const name = markName[m.kind];
layers.labels.push(
<g key={k()}>
{name && <Label x={px} y={below ? ly + 12 : ly - 12} color={m.color} size={10}>{name}</Label>}
<text x={px} y={ly} fontSize={10} fontWeight={800} fill={m.color} textAnchor="middle" {...bidi(coord)} stroke={PAPER} strokeWidth={3} paintOrder="stroke">{coord}</text>
</g>,
);
}
/* ── نقطه‌ها ── */
const explicit = new Map<string, Extract<PlaneItem, { type: 'point' }>>();
const loosePoints: Extract<PlaneItem, { type: 'point' }>[] = [];
for (const item of spec.items) {
if (item.type !== 'point') continue;
if (typeof item.point === 'string') explicit.set(item.point, item); else loosePoints.push(item);
}
const drawPoint = (xy: XY, label: string, col: string, open = false) => {
const [px, py] = P(xy);
layers.points.push(<circle key={k()} cx={px} cy={py} r={open ? 4 : 3.8} fill={open ? '#fff' : col} stroke={open ? col : '#fff'} strokeWidth={open ? 2 : 1.4} />);
if (label) { const [lx, ly] = away(xy, 14); layers.labels.push(<RichLabel key={k()} x={lx} y={ly} text={label} fill={col} />); }
};
const imageNames = new Set(images.flatMap((im) => im.names));
// نقطه‌ای که هیچ تکه‌ای به آن ارجاع نداده رسم نمی‌شود (مدل هفت نقطه‌ی امتداد تعریف کرده
// بود که هیچ‌جا به کار نرفتند و شکل را شلوغ کردند) — مگر شکل فقط از نقطه ساخته شده باشد.
const used = referencedNames(spec);
const onlyPoints = spec.items.length === 0;
named.forEach((xy, name) => {
if (name.startsWith('_') || imageNames.has(name)) return;
if (!onlyPoints && !used.has(name)) return;
const it = explicit.get(name);
// مبدأ روی محور خودش «۰» دارد؛ فقط اگر مدل صریحاً نقطه‌اش را خواسته باشد برچسب می‌گیرد.
if (spec.axes && !it && Math.abs(xy[0]) < 1e-9 && Math.abs(xy[1]) < 1e-9) return;
drawPoint(xy, it?.label ?? name, color(it?.color, INK), it?.open);
});
loosePoints.forEach((it) => drawPoint(it.point as XY, it.label ?? '', color(it.color, INK), it.open));
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img" aria-label={spec.title || 'شکل'}>
<defs>
<clipPath id={clipId}><rect x={X(x0)} y={Y(y1)} width={(x1 - x0) * sx} height={(y1 - y0) * sy} /></clipPath>
</defs>
<Arrowheads ids={[...arrowColors]} />
{layers.back}{layers.fill}{layers.mid}{layers.curves}{layers.marks}{layers.points}{layers.labels}
</svg>
);
}
/**
* دو نقطه از خط: یا through، یا از معادله‌ی ax + by = c (ضریب‌ها از صورتِ مسئله؛
* شیب را کد حساب می‌کند، چون مدل در بازنویسیِ 3x + 5y = 4 به y = … علامت را گم کرده بود).
*/
function lineEnds(item: Extract<PlaneItem, { type: 'line' }>, ref: (r: Ref) => XY | null): [XY | null, XY | null] {
const coef = item.equation ? parseLinear(item.equation) : item.eq ?? null;
if (coef) {
// ax + by + c = 0
const [a, b, c] = coef;
if (Math.abs(b) > 1e-12) return [[0, -c / b], [1, (-c - a) / b]];
if (Math.abs(a) > 1e-12) return [[-c / a, 0], [-c / a, 1]];
return [null, null];
}
return item.through ? [ref(item.through[0]), ref(item.through[1])] : [null, null];
}
/**
* «3x + 5y = 4»، «y = 12x − 16»، «2y = x + 1»، «x = 2.5» → [a, b, c] با ax + by + c = 0.
* هر چیزِ غیرخطی (x²، sin) null است و خط کشیده نمی‌شود.
*/
export function parseLinear(src: string): [number, number, number] | null {
const text = toLatin(src).replace(/\s+/g, '').replace(/[−–]/g, '-').replace(/\*/g, '');
const sides = text.split('=');
if (sides.length !== 2) return null;
const side = (t: string): [number, number, number] | null => {
if (!t) return null;
const terms = t.replace(/-/g, '+-').split('+').filter(Boolean);
const acc: [number, number, number] = [0, 0, 0];
for (const term of terms) {
const m = /^(-?)(\d*\.?\d*)(?:\/(\d+\.?\d*))?([xy]?)$/.exec(term);
if (!m || (!m[2] && !m[4])) return null;
let v = m[2] ? Number(m[2]) : 1;
if (m[3]) v /= Number(m[3]);
if (m[1]) v = -v;
acc[m[4] === 'x' ? 0 : m[4] === 'y' ? 1 : 2] += v;
}
return acc;
};
const l = side(sides[0]), r = side(sides[1]);
if (!l || !r) return null;
const out: [number, number, number] = [l[0] - r[0], l[1] - r[1], l[2] - r[2]];
return Math.abs(out[0]) + Math.abs(out[1]) > 1e-12 ? out : null;
}
const toLatin = (s: string) => s.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
/** علامتِ تساویِ ضلع: یک، دو یا سه خطِ کوچک عمود بر وسطِ پاره‌خط. */
function Ticks({ a, b, n, col }: { a: XY; b: XY; n: number; col: string }) {
const [mx, my] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
const [ux, uy] = [(b[0] - a[0]) / len, (b[1] - a[1]) / len];
const [nx, ny] = [-uy, ux];
const count = Math.max(1, Math.min(3, n));
return (
<g stroke={col} strokeWidth={2}>
{Array.from({ length: count }, (_, i) => {
const o = (i - (count - 1) / 2) * 4.5;
const cx = mx + ux * o, cy = my + uy * o;
return <line key={i} x1={cx - nx * 6} y1={cy - ny * 6} x2={cx + nx * 6} y2={cy + ny * 6} />;
})}
</g>
);
}
/**
* جای برچسبِ ضلع: وسطِ پاره‌خط، به سمتِ بیرونِ شکل. برای خطِ تقریباً عمودی برچسب
* از کنارِ خط شروع می‌شود (نه وسط‌چین)، وگرنه متنِ بلند روی خودِ خط می‌افتد.
*/
function sideOffset(a: XY, b: XY, center: XY): [number, number, 'start' | 'middle' | 'end'] {
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
let nx = -(b[1] - a[1]), ny = b[0] - a[0];
const n = Math.hypot(nx, ny) || 1;
nx /= n; ny /= n;
if ((mx - center[0]) * nx + (my - center[1]) * ny < 0) { nx = -nx; ny = -ny; }
const anchor = nx > 0.6 ? 'start' : nx < -0.6 ? 'end' : 'middle';
const d = anchor === 'middle' ? 14 : 8;
return [mx + nx * d, my + ny * d + 4, anchor];
}
/** نقطه‌ای از خط که داخلِ کادر و نزدیکِ لبه‌ی راستِ آن است، برای برچسبِ خط. */
function edgePoint(a: XY, b: XY, x0: number, x1: number, y0: number, y1: number): XY {
let [dx, dy] = [b[0] - a[0], b[1] - a[1]];
const n = Math.hypot(dx, dy) || 1;
[dx, dy] = [dx / n, dy / n];
if (dx < 0 || (dx === 0 && dy < 0)) [dx, dy] = [-dx, -dy];
const inside = (p: XY) => p[0] > x0 + (x1 - x0) * 0.1 && p[0] < x1 - (x1 - x0) * 0.1 && p[1] > y0 + (y1 - y0) * 0.1 && p[1] < y1 - (y1 - y0) * 0.1;
const span = Math.hypot(x1 - x0, y1 - y0);
let best: XY | null = null;
for (let t = -span * 2; t <= span * 2; t += span / 200) {
const p: XY = [a[0] + dx * t, a[1] + dy * t];
if (inside(p)) best = p; // آخرین نقطه‌ی داخلِ کادر در جهتِ راست
}
return best ?? [(x0 + x1) / 2, (y0 + y1) / 2];
}

166
src/diagram/sets.tsx Normal file
View File

@@ -0,0 +1,166 @@
import { useId } from 'react';
import type { MappingSpec, TreeNode, TreeSpec, VennSpec } from './types';
import { Arrowheads, COLORS, INK, Label, SERIES, arrowId, mathText } from './svg';
const W = 320;
/* ─── نمودارِ ون ──────────────────────────────────────────────────────────
* ناحیه‌ها با نامِ مجموعه‌هایی که در آن‌اند: «A» فقط A، «AB» اشتراکِ A و B (و نه
* C)، «ABC»، و «out» بیرونِ همه. سایه با برشِ تو در تو (clipPath) و ماسکِ
* «نه-این-مجموعه» ساخته می‌شود، پس ناحیه‌ی سایه‌خورده دقیقاً همان ناحیه است. */
export function Venn({ spec }: { spec: VennSpec }) {
const uid = useId().replace(/:/g, '');
const sets = spec.sets.slice(0, 3);
if (sets.length < 1) return null;
const H = sets.length === 3 ? 250 : 190;
const R = sets.length === 3 ? 62 : 70;
const centers: [number, number][] = sets.length === 1 ? [[W / 2, 95]]
: sets.length === 2 ? [[W / 2 - 45, 95], [W / 2 + 45, 95]]
: [[W / 2 - 40, 95], [W / 2 + 40, 95], [W / 2, 160]];
const ids = sets.map((_, i) => `v${uid}-${i}`);
const keyOf = (inside: number[]) => inside.map((i) => sets[i]).join('');
// مرکزِ تقریبیِ هر ناحیه برای نوشتنِ اعضا
const regionCenter = (code: string): [number, number] => {
if (code === 'out') return [22, H - 16];
const inside = sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0);
const all = centers.map((_, i) => i);
const cxIn = inside.reduce((a, i) => a + centers[i][0], 0) / inside.length, cyIn = inside.reduce((a, i) => a + centers[i][1], 0) / inside.length;
const outside = all.filter((i) => !inside.includes(i));
if (!outside.length || inside.length > 1) {
if (inside.length === sets.length) return [cxIn, cyIn];
const o = outside.length ? centers[outside[0]] : [cxIn, cyIn];
return [cxIn + (cxIn - o[0]) * 0.18, cyIn + (cyIn - o[1]) * 0.18];
}
const ox = outside.reduce((a, i) => a + centers[i][0], 0) / outside.length, oy = outside.reduce((a, i) => a + centers[i][1], 0) / outside.length;
const dx = cxIn - ox, dy = cyIn - oy, n = Math.hypot(dx, dy) || 1;
return [cxIn + (dx / n) * R * 0.42, cyIn + (dy / n) * R * 0.42];
};
const shaded = (spec.shade ?? []).filter((c) => c === 'out' || [...c].every((ch) => sets.includes(ch)));
const shadeRegion = (code: string, idx: number) => {
const fill = `${COLORS.orange}66`;
if (code === 'out') {
return (
<g key={`s${idx}`}>
<mask id={`${ids[0]}-out`}><rect x={0} y={0} width={W} height={H} fill="#fff" />{centers.map((c, i) => <circle key={i} cx={c[0]} cy={c[1]} r={R} fill="#000" />)}</mask>
<rect x={4} y={4} width={W - 8} height={H - 8} fill={fill} mask={`url(#${ids[0]}-out)`} />
</g>
);
}
const inside = sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0);
const outside = sets.map((_, i) => i).filter((i) => !inside.includes(i));
const mid = `${ids[0]}-m${idx}`;
let node = (
<g>
{outside.length > 0 && <mask id={mid}><rect x={0} y={0} width={W} height={H} fill="#fff" />{outside.map((i) => <circle key={i} cx={centers[i][0]} cy={centers[i][1]} r={R} fill="#000" />)}</mask>}
<rect x={0} y={0} width={W} height={H} fill={fill} mask={outside.length ? `url(#${mid})` : undefined} />
</g>
);
for (const i of inside) node = <g clipPath={`url(#${ids[i]})`}>{node}</g>;
return <g key={`s${idx}`}>{node}</g>;
};
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
<defs>{centers.map((c, i) => <clipPath key={i} id={ids[i]}><circle cx={c[0]} cy={c[1]} r={R} /></clipPath>)}</defs>
<rect x={4} y={4} width={W - 8} height={H - 8} rx={10} fill="none" stroke={INK} strokeWidth={1.5} />
<text x={W - 12} y={20} fontSize={12} fontWeight={800} fill={INK} textAnchor="end" fontStyle="italic">U</text>
{shaded.map(shadeRegion)}
{centers.map((c, i) => {
const col = COLORS[SERIES[i]];
return (
<g key={i}>
<circle cx={c[0]} cy={c[1]} r={R} fill={`${col}14`} stroke={col} strokeWidth={2.4} />
<Label x={c[0] + (i === 1 ? R * 0.72 : i === 0 ? -R * 0.72 : 0)} y={i === 2 ? c[1] + R + 14 : c[1] - R * 0.78} color={col} size={spec.names?.[i] ? 11.5 : 13} weight={900}>{spec.names?.[i] ?? sets[i]}</Label>
</g>
);
})}
{Object.entries(spec.regions).map(([code, members]) => {
const valid = code === 'out' || [...code].every((ch) => sets.includes(ch));
if (!valid || !members.length) return null;
const [x, y] = regionCenter(code === 'out' ? 'out' : keyOf(sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0)));
const lines = chunk(members.slice(0, 12), 3);
return lines.map((line, li) => (
<Label key={`${code}${li}`} x={x} y={y + (li - (lines.length - 1) / 2) * 14 + 4} color={INK} size={11} anchor={code === 'out' ? 'start' : 'middle'}>
{mathText(line.join('، '))}
</Label>
));
})}
</svg>
);
}
const chunk = <T,>(xs: T[], n: number): T[][] => Array.from({ length: Math.ceil(xs.length / n) }, (_, i) => xs.slice(i * n, i * n + n));
/* ─── نمودارِ پیکانی (تابع از A به B) ─────────────────────────────────────── */
export function Mapping({ spec }: { spec: MappingSpec }) {
const L = spec.left.slice(0, 10), Rt = spec.right.slice(0, 10);
const rows = Math.max(L.length, Rt.length, 1);
const H = 56 + rows * 28;
const yOf = (i: number, n: number) => 40 + ((i + 0.5) * (rows * 28)) / n;
// جهتِ نگاشت چپ به راست است، مثلِ کتاب (دامنه سمتِ چپ)
const lx = 78, rx = W - 78;
const col = COLORS.red;
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
<Arrowheads ids={[col]} />
{[[lx, L, spec.leftLabel, COLORS.blue], [rx, Rt, spec.rightLabel, COLORS.green]].map(([x, items, label, c]) => (
<g key={String(x)}>
<ellipse cx={x as number} cy={40 + (rows * 28) / 2} rx={42} ry={(rows * 28) / 2 + 14} fill={`${c}14`} stroke={c as string} strokeWidth={2.2} />
{label && <Label x={x as number} y={18} color={c as string} size={12} weight={900}>{label as string}</Label>}
{(items as string[]).map((it, i) => (
<Label key={i} x={x as number} y={yOf(i, (items as string[]).length) + 4} color={INK} size={12}>{mathText(it)}</Label>
))}
</g>
))}
{spec.arrows.filter(([a, b]) => a < L.length && b < Rt.length).map(([a, b], i) => (
<line key={i} x1={lx + 18} y1={yOf(a, L.length)} x2={rx - 18} y2={yOf(b, Rt.length)} stroke={col} strokeWidth={1.8} markerEnd={`url(#${arrowId(col)})`} />
))}
</svg>
);
}
/* ─── نمودارِ درختی (احتمال و شمارش) ──────────────────────────────────────── */
export function Tree({ spec }: { spec: TreeSpec }) {
interface Laid { node: TreeNode; x: number; y: number; depth: number; parent?: Laid }
const laid: Laid[] = [];
let leaf = 0;
const depthOf = (n: TreeNode): number => 1 + Math.max(0, ...(n.children ?? []).map(depthOf));
const depth = Math.min(4, depthOf(spec.root));
const levelH = 62;
const place = (n: TreeNode, d: number, parent?: Laid): Laid => {
const kids = d < 3 ? (n.children ?? []).slice(0, 6) : [];
const me: Laid = { node: n, x: 0, y: 24 + d * levelH, depth: d, parent };
if (!kids.length) { me.x = leaf++; laid.push(me); return me; }
const placed = kids.map((c) => place(c, d + 1, me));
me.x = (placed[0].x + placed[placed.length - 1].x) / 2;
laid.push(me);
return me;
};
place(spec.root, 0);
const leaves = Math.max(1, leaf);
const H = 24 + (depth - 1) * levelH + 34;
const X = (x: number) => 24 + ((x + 0.5) / leaves) * (W - 48);
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
{laid.filter((n) => n.parent).map((n, i) => {
const p = n.parent!;
return (
<g key={`e${i}`}>
<line x1={X(p.x)} y1={p.y + 9} x2={X(n.x)} y2={n.y - 11} stroke={COLORS.gray} strokeWidth={1.8} />
{n.node.p && <Label x={(X(p.x) + X(n.x)) / 2 + (n.x < p.x ? -10 : n.x > p.x ? 10 : 12)} y={(p.y + n.y) / 2 + 3} color={COLORS.red} size={10}>{mathText(n.node.p)}</Label>}
</g>
);
})}
{laid.map((n, i) => (
<g key={`n${i}`}>
<rect x={X(n.x) - 26} y={n.y - 11} width={52} height={21} rx={10} fill={n.depth === 0 ? `${COLORS.blue}22` : '#fff'} stroke={COLORS.blue} strokeWidth={1.6} />
<text x={X(n.x)} y={n.y + 4} fontSize={10.5} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{mathText(n.node.label).slice(0, 10)}</text>
</g>
))}
</svg>
);
}

300
src/diagram/shapes.ts Normal file
View File

@@ -0,0 +1,300 @@
import type { ColorName, PlaneItem, PointDef, Ref } from './types';
/**
* مثلث و چهارضلعی از روی داده‌های مسئله، نه از روی مختصاتِ حدسیِ مدل.
*
* مدل می‌گوید «مثلثی با AB = 6، AC = 8 و زاویه‌ی A = 90°» یا «ذوزنقه با قاعده‌های ۸ و ۵ و
* ارتفاعِ ۳»؛ این‌جا با قانونِ کسینوس‌ها و سینوس‌ها (مثلث) یا فرمولِ هر چهارضلعی رأس‌ها
* حساب می‌شوند و به نقطه + polygon + برچسب باز می‌شوند. سنجشِ ۲۰۲۶-۰۹-۲۸: مربعی که مدل
* مختصاتش را خودش نوشت کج درآمد؛ این ابزار آن خطا را از دستِ مدل بیرون می‌آورد.
* داده‌ی ناقص با پیش‌فرضِ ساده پر می‌شود (ضلعِ نامعلوم = میانگینِ ضلع‌های معلوم)، پس
* شکل همیشه کشیده می‌شود؛ داده‌ی متناقض (۳، ۴، ۱۰) مثلثی نمی‌سازد و item حذف می‌شود.
*/
type XY = [number, number];
const RAD = Math.PI / 180;
export interface TriangleItem {
type: 'triangle';
names: [string, string, string];
/** کلید: دو نامِ رأس («AB» یا «BA») */
sides?: Record<string, number>;
/** کلید: نامِ رأس، درجه */
angles?: Record<string, number>;
/** برچسبِ دلخواه برای ضلع («BC»: «x»)، رأس («A»: «?») یا ارتفاع («h») */
labels?: Record<string, string>;
/** رأسی که ارتفاع از آن بر ضلعِ روبه‌رو کشیده شود */
altitude?: string;
/** جای رأسِ اول */
at?: XY;
color?: ColorName;
fill?: boolean;
}
export interface QuadItem {
type: 'quad';
shape: 'rectangle' | 'square' | 'parallelogram' | 'rhombus' | 'trapezoid';
names: [string, string, string, string];
w?: number; h?: number; side?: number; angle?: number; top?: number; d1?: number; d2?: number;
right?: boolean;
at?: XY;
labels?: Record<string, string>;
color?: ColorName;
fill?: boolean;
}
interface Solved { a: number; b: number; c: number; A: number; B: number; C: number }
/** a روبه‌روی A و ...؛ زاویه‌ها به درجه. null یعنی داده‌ها مثلث نمی‌سازند. */
export function solveTriangle(sides: Partial<Record<'a' | 'b' | 'c', number>>, angles: Partial<Record<'A' | 'B' | 'C', number>>): Solved | null {
const s: Record<string, number | undefined> = { ...sides };
const g: Record<string, number | undefined> = { ...angles };
const opp = { a: 'A', b: 'B', c: 'C' } as const;
const known = (o: Record<string, number | undefined>, k: string) => o[k] !== undefined && Number.isFinite(o[k]!);
const step = () => {
const ks = ['A', 'B', 'C'].filter((k) => known(g, k));
if (ks.length === 2) {
const rest = 180 - ks.reduce((t, k) => t + g[k]!, 0);
if (rest <= 0) return false;
g[['A', 'B', 'C'].find((k) => !known(g, k))!] = rest;
}
const ss = (['a', 'b', 'c'] as const).filter((k) => known(s, k));
if (ss.length === 3 && ['A', 'B', 'C'].some((k) => !known(g, k))) {
const { a, b, c } = s as Record<string, number>;
if (a + b <= c || a + c <= b || b + c <= a) return false;
g.A = Math.acos((b * b + c * c - a * a) / (2 * b * c)) / RAD;
g.B = Math.acos((a * a + c * c - b * b) / (2 * a * c)) / RAD;
g.C = 180 - g.A - g.B;
}
// دو ضلع و زاویه‌ی بینشان
for (const [x, y, z] of [['b', 'c', 'a'], ['a', 'c', 'b'], ['a', 'b', 'c']] as const) {
if (known(s, x) && known(s, y) && !known(s, z) && known(g, opp[z])) {
s[z] = Math.sqrt(s[x]! ** 2 + s[y]! ** 2 - 2 * s[x]! * s[y]! * Math.cos(g[opp[z]]! * RAD));
}
}
// قانونِ سینوس‌ها
const pair = (['a', 'b', 'c'] as const).find((k) => known(s, k) && known(g, opp[k]));
if (pair) {
const k = s[pair]! / Math.sin(g[opp[pair]]! * RAD);
for (const side of ['a', 'b', 'c'] as const) {
const ang = opp[side];
if (!known(s, side) && known(g, ang)) s[side] = k * Math.sin(g[ang]! * RAD);
else if (known(s, side) && !known(g, ang)) {
const r = s[side]! / k;
if (r > 1 + 1e-9) return false;
g[ang] = Math.asin(Math.min(1, r)) / RAD;
}
}
}
return true;
};
const done = () => ['a', 'b', 'c'].every((k) => known(s, k)) && ['A', 'B', 'C'].every((k) => known(g, k));
const count = () => ['a', 'b', 'c'].filter((k) => known(s, k)).length + ['A', 'B', 'C'].filter((k) => known(g, k)).length;
for (let round = 0; round < 12 && !done(); round++) {
const before = count();
if (!step()) return null;
if (done()) break;
if (count() > before) continue;
// گیر کرد: داده‌ی ناقص. ضلعِ نامعلومِ اول = میانگینِ ضلع‌های معلوم (متساوی‌الساقین/متساوی‌الاضلاع)
const ss = (['a', 'b', 'c'] as const).filter((k) => known(s, k));
if (!ss.length) { s.c = 4; continue; }
const missing = (['a', 'b', 'c'] as const).find((k) => !known(s, k));
if (missing) s[missing] = ss.reduce((t, k) => t + s[k]!, 0) / ss.length;
}
if (!done()) return null;
const out = { a: s.a!, b: s.b!, c: s.c!, A: g.A!, B: g.B!, C: g.C! };
if (Object.values(out).some((v) => !Number.isFinite(v) || v <= 0) || Math.abs(out.A + out.B + out.C - 180) > 0.5) return null;
return out;
}
const num = (v: number) => String(Math.round(v * 100) / 100);
/** رأس‌های شکل‌های مرکبی که تا این‌جا جا گرفته‌اند (برای چیدنِ چند شکل کنارِ هم). */
export interface Layout { placed: XY[] }
/**
* جای شکل: رأس‌ها در دستگاهِ محلی (رأسِ اول در مبدأ، ضلعِ اول روی محورِ x) ساخته شده‌اند.
* دو رأسِ اول از قبل مختصات دارند → شکل روی همان ضلع ساخته می‌شود (ذوزنقه روی ضلعِ مستطیل)
* فقط رأسِ اول → همان‌جا؛ "at" → همان‌جا
* هیچ‌کدام، ولی شکلِ دیگری قبلاً کشیده شده → کنارِ آن، نه روی آن (دو مربعِ کنارِ هم)
*/
function placement(names: string[], local: XY[], points: Record<string, PointDef>, layout: Layout, at?: XY): (p: XY) => XY {
const plain = (n: string) => (Array.isArray(points[n]) ? (points[n] as XY) : null);
const p0 = plain(names[0]), p1 = plain(names[1]);
let T: (p: XY) => XY = (p) => p;
if (p0 && p1 && Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) > 1e-9) {
const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
const u: XY = [(p1[0] - p0[0]) / len, (p1[1] - p0[1]) / len];
T = (p) => [p0[0] + p[0] * u[0] - p[1] * u[1], p0[1] + p[0] * u[1] + p[1] * u[0]];
} else if (p0) T = (p) => [p0[0] + p[0], p0[1] + p[1]];
else if (at) T = (p) => [at[0] + p[0], at[1] + p[1]];
else if (layout.placed.length) {
const right = Math.max(...layout.placed.map((q) => q[0]));
const bottom = Math.min(...layout.placed.map((q) => q[1]));
const minX = Math.min(...local.map((q) => q[0])), minY = Math.min(...local.map((q) => q[1]));
const gap = Math.max(1, 0.2 * (Math.max(...local.map((q) => q[0])) - minX));
T = (p) => [p[0] - minX + right + gap, p[1] - minY + bottom];
}
layout.placed.push(...local.map(T));
return T;
}
function sideKey(key: string, names: string[]): [number, number] | null {
for (let i = 0; i < names.length; i++) for (let j = 0; j < names.length; j++) {
if (i !== j && key === names[i] + names[j]) return [i, j];
}
return null;
}
/** item‌های ساده (نقطه، polygon، پاره‌خطِ برچسب‌دار، زاویه) به‌جای triangle. */
export function expandTriangle(item: TriangleItem, idx: number, points: Record<string, PointDef>, taken: (n: string) => boolean, layout: Layout): PlaneItem[] | null {
const [P, Q, R] = item.names;
const sides: Partial<Record<'a' | 'b' | 'c', number>> = {};
const oppOf = (i: number, j: number) => (['a', 'b', 'c'] as const)[3 - i - j];
Object.entries(item.sides ?? {}).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sides[oppOf(p[0], p[1])] = v; });
const angles: Partial<Record<'A' | 'B' | 'C', number>> = {};
Object.entries(item.angles ?? {}).forEach(([k, v]) => { const i = item.names.indexOf(k); if (i >= 0) angles[(['A', 'B', 'C'] as const)[i]] = v; });
// مثلثی روی نقطه‌هایی که مدل خودش تعریف کرده (مختصات، polar، تبدیل) و بی‌داده‌ی ضلع و زاویه:
// همان نقطه‌ها، دست‌نخورده (سنجشِ ۲۰۲۶-۰۹-۲۹: جابه‌جا کردنشان تصویرِ تقارن را خراب کرد)
const hasData = Object.keys(sides).length + Object.keys(angles).length > 0;
if (!(hasData || !item.names.every((n) => n in points))) {
layout.placed.push(...item.names.map((n) => points[n]).filter((v): v is XY => Array.isArray(v)));
} else {
const t = solveTriangle(sides, angles);
if (!t) return null;
const local: XY[] = [[0, 0], [t.c, 0], [t.b * Math.cos(t.A * RAD), t.b * Math.sin(t.A * RAD)]];
const T = placement(item.names, local, points, layout, item.at);
item.names.forEach((n, i) => { points[n] = T(local[i]); });
}
const style = { color: item.color };
const out: PlaneItem[] = [{ type: 'polygon', points: [P, Q, R], fill: item.fill ?? true, ...style }];
const labels = item.labels ?? {};
const sideLabel = new Map<string, string>();
Object.entries(item.sides ?? {}).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sideLabel.set([p[0], p[1]].sort().join(), num(v)); });
Object.entries(labels).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sideLabel.set([p[0], p[1]].sort().join(), v); });
// همه‌ی ضلع‌ها پاره‌خط‌اند (نه فقط برچسب‌دارها) تا ضخامتِ ضلع‌ها یکی باشد
[[0, 1], [1, 2], [0, 2]].forEach(([i, j]) => {
out.push({ type: 'segment', from: item.names[i], to: item.names[j], label: sideLabel.get(`${i},${j}`), ...style });
});
item.names.forEach((n, i) => {
const given = item.angles?.[n];
const label = labels[n] ?? (given !== undefined ? `${num(given)}°` : undefined);
if (label === undefined) return;
const [from, to] = [item.names[(i + 1) % 3], item.names[(i + 2) % 3]];
if (given !== undefined && Math.abs(given - 90) < 1e-6 && labels[n] === undefined) out.push({ type: 'rightAngle', vertex: n, from, to });
else out.push({ type: 'angle', vertex: n, from, to, label, color: 'red' });
});
const alt = item.altitude;
if (alt && item.names.includes(alt)) {
const [u, v] = item.names.filter((n) => n !== alt);
const foot = taken('H') ? `_h${idx}` : 'H';
points[foot] = { foot: [alt, u, v] };
// امتدادِ ضلع فقط وقتی پای ارتفاع بیرونِ مثلث می‌افتد (مثلثِ منفرجه)
const [pu, pv, pa] = [u, v, alt].map((n) => (Array.isArray(points[n]) ? (points[n] as XY) : ([0, 0] as XY)));
const d: XY = [pv[0] - pu[0], pv[1] - pu[1]];
const tt = ((pa[0] - pu[0]) * d[0] + (pa[1] - pu[1]) * d[1]) / (d[0] * d[0] + d[1] * d[1] || 1);
if (tt < 0) out.push({ type: 'segment', from: foot, to: u, dashed: true, color: 'gray' });
if (tt > 1) out.push({ type: 'segment', from: v, to: foot, dashed: true, color: 'gray' });
out.push({ type: 'segment', from: alt, to: foot, dashed: true, color: 'green', label: labels.h });
out.push({ type: 'rightAngle', vertex: foot, from: alt, to: v });
}
return out;
}
export function expandQuad(item: QuadItem, idx: number, points: Record<string, PointDef>, layout: Layout): PlaneItem[] {
const [A, B, C, D] = item.names;
const labels = item.labels ?? {};
const style = { color: item.color };
const lab = (key: string, value?: number) => labels[key] ?? (value !== undefined ? num(value) : undefined);
const out: PlaneItem[] = [];
const seg = (from: Ref, to: Ref, label?: string, extra: Partial<Extract<PlaneItem, { type: 'segment' }>> = {}) => {
out.push({ type: 'segment', from, to, label, ...style, ...extra });
};
// ضلع‌هایی که برچسب یا علامت نگرفتند، بعداً با همان ضخامت کشیده می‌شوند
const edges = () => {
const drawn = new Set(out.filter((i) => i.type === 'segment' && !i.dashed).map((i) => { const s = i as { from: Ref; to: Ref }; return [s.from, s.to].sort().join(); }));
[[A, B], [B, C], [C, D], [D, A]].forEach(([p, q]) => { if (!drawn.has([p, q].sort().join())) seg(p, q); });
};
// بی‌هیچ اندازه و روی چهار نقطه‌ای که مدل خودش تعریف کرده: همان نقطه‌ها
const keep = ['w', 'h', 'side', 'top', 'd1', 'd2', 'angle'].every((k) => item[k as keyof QuadItem] === undefined)
&& item.names.every((n) => n in points);
let T: (p: XY) => XY = (p) => p;
const put = (xy: XY[]) => {
if (keep) { layout.placed.push(...item.names.map((n) => points[n]).filter((v): v is XY => Array.isArray(v))); return; }
T = placement(item.names, xy, points, layout, item.at);
[A, B, C, D].forEach((n, i) => { points[n] = T(xy[i]); });
};
const height = (label?: string) => {
const foot = `_q${idx}`;
points[foot] = { foot: [D, A, B] };
out.push({ type: 'segment', from: D, to: foot, dashed: true, color: 'green', label });
out.push({ type: 'rightAngle', vertex: foot, from: D, to: B });
};
const θ = (item.angle ?? 60) * RAD;
switch (item.shape) {
case 'rectangle': {
const w = item.w ?? 6, h = item.h ?? 4;
put([[0, 0], [w, 0], [w, h], [0, h]]);
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style }, { type: 'rightAngle', vertex: A, from: B, to: D });
seg(A, B, lab(A + B, item.w)); seg(B, C, lab(B + C, item.h));
break;
}
case 'square': {
const s = item.side ?? item.w ?? 4;
put([[0, 0], [s, 0], [s, s], [0, s]]);
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style }, { type: 'rightAngle', vertex: A, from: B, to: D });
seg(A, B, lab(A + B, item.side ?? item.w), { ticks: 1 }); seg(B, C, lab(B + C), { ticks: 1 });
seg(C, D, lab(C + D), { ticks: 1 }); seg(D, A, lab(D + A), { ticks: 1 });
break;
}
case 'parallelogram': {
const w = item.w ?? 6;
const s = item.side ?? (item.h ? item.h / Math.sin(θ) : 3.5);
const d: XY = [s * Math.cos(θ), s * Math.sin(θ)];
put([[0, 0], [w, 0], [w + d[0], d[1]], d]);
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
seg(A, B, lab(A + B, item.w)); seg(A, D, lab(A + D, item.side));
if (item.h !== undefined || labels.h) height(lab('h', item.h));
if (item.angle !== undefined) out.push({ type: 'angle', vertex: A, from: B, to: D, label: `${num(item.angle)}°`, color: 'red' });
break;
}
case 'rhombus': {
if (item.d1 !== undefined || item.d2 !== undefined) {
const d1 = item.d1 ?? 6, d2 = item.d2 ?? 4, o = `_o${idx}`;
put([[-d1 / 2, 0], [0, -d2 / 2], [d1 / 2, 0], [0, d2 / 2]]);
points[o] = T([0, 0]);
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
// برچسبِ قطرها کنارِ هم نمی‌نشیند (هر دو وسطشان در O است): روی یک‌چهارمِ هر قطر
seg(A, C, undefined, { dashed: true }); seg(B, D, undefined, { dashed: true });
const l1 = lab('d1', item.d1), l2 = lab('d2', item.d2);
if (l1) out.push({ type: 'text', at: T([d1 / 4, d2 * 0.07]), text: l1, color: 'blue' });
if (l2) out.push({ type: 'text', at: T([d1 * 0.07, d2 / 4]), text: l2, color: 'blue' });
out.push({ type: 'rightAngle', vertex: o, from: C, to: D });
[[A, B], [B, C], [C, D], [D, A]].forEach(([p, q]) => seg(p, q, lab(p + q), { ticks: 1 }));
} else {
const s = item.side ?? item.w ?? 4;
put([[0, 0], [s, 0], [s + s * Math.cos(θ), s * Math.sin(θ)], [s * Math.cos(θ), s * Math.sin(θ)]]);
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
seg(A, B, lab(A + B, item.side ?? item.w), { ticks: 1 });
[[B, C], [C, D], [D, A]].forEach(([p, q]) => seg(p, q, lab(p + q), { ticks: 1 }));
if (item.h !== undefined || labels.h) height(lab('h', item.h));
if (item.angle !== undefined) out.push({ type: 'angle', vertex: A, from: B, to: D, label: `${num(item.angle)}°`, color: 'red' });
}
break;
}
case 'trapezoid': {
const w = item.w ?? 6, t = item.top ?? w * 0.6, h = item.h ?? 3;
const x0 = item.right ? 0 : (w - t) / 2;
put([[0, 0], [w, 0], [x0 + t, h], [x0, h]]);
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
seg(A, B, lab(A + B, item.w)); seg(D, C, lab(D + C, item.top));
if (item.right) {
out.push({ type: 'rightAngle', vertex: A, from: B, to: D });
seg(A, D, lab(A + D, item.h) ?? lab('h'));
} else if (item.h !== undefined || labels.h) height(lab('h', item.h));
break;
}
}
edges();
return out;
}

156
src/diagram/solid.tsx Normal file
View File

@@ -0,0 +1,156 @@
import type { ReactNode } from 'react';
import type { SolidSpec } from './types';
import { COLORS, INK, Label, mathText } from './svg';
/**
* حجم‌های هندسی، با تصویرِ مایلِ کتابِ درسی: وجهِ جلو واقعی، عمق با زاویه‌ی ۳۵°
* و نصفِ اندازه؛ یال‌های پنهان خط‌چین. اندازه‌ها (a، b، c، r، h) نسبتِ واقعی را
* نگه می‌دارند؛ برچسب‌ها همان متنی‌اند که مدل داده («۵ cm»).
*/
type XY = [number, number];
const W = 300;
const H = 250;
const FILL = `${COLORS.blue}1c`;
const TOP = `${COLORS.blue}33`;
const SIDE = `${COLORS.blue}10`;
const HID = '5 4';
const DEPTH = 0.5;
const ANG = (35 * Math.PI) / 180;
const SQUASH = 0.32; // دایره‌ی قاعده از نمای مایل
export function Solid({ spec }: { spec: SolidSpec }) {
const d = spec.dims ?? {};
const l = spec.labels ?? {};
const els: ReactNode[] = [];
let key = 0;
const k = () => key++;
const edge = (a: XY, b: XY, hidden = false, col = INK) =>
els.push(<line key={k()} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]} stroke={hidden ? '#8A8FA3' : col} strokeWidth={hidden ? 1.5 : 2.2} strokeDasharray={hidden ? HID : undefined} strokeLinecap="round" />);
const face = (pts: XY[], fill: string) => els.push(<path key={k()} d={`M${pts.map((p) => p.join(',')).join(' L')} Z`} fill={fill} stroke="none" />);
const label = (x: number, y: number, text: string | undefined, col = COLORS.red) => {
if (text) els.push(<Label key={k()} x={x} y={y} color={col} size={12}>{mathText(text)}</Label>);
};
const dim = (a: XY, b: XY, text: string | undefined) => {
// خطِ اندازه: خط‌چینِ قرمز با برچسب وسطش
els.push(<line key={k()} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]} stroke={COLORS.red} strokeWidth={1.8} strokeDasharray="4 3" />);
if (text) label((a[0] + b[0]) / 2 + 12, (a[1] + b[1]) / 2 + 4, text);
};
const shape = spec.shape;
if (shape === 'cube' || shape === 'cuboid') {
let a = d.a ?? 1, b = d.b ?? (shape === 'cube' ? a : 1), c = d.c ?? (shape === 'cube' ? a : 1);
if (shape === 'cube') b = c = a;
const ox = Math.cos(ANG) * DEPTH * b, oy = Math.sin(ANG) * DEPTH * b;
const s = Math.min(170 / (a + ox), 170 / (c + oy));
const x0 = (W - (a + ox) * s) / 2, y0 = H - 30;
const f = (x: number, y: number, z: number): XY => [x0 + (x + z * Math.cos(ANG) * DEPTH) * s, y0 - (y + z * Math.sin(ANG) * DEPTH) * s];
const [A, B, C, D] = [f(0, 0, 0), f(a, 0, 0), f(a, c, 0), f(0, c, 0)];
const [E, F, G, Hh] = [f(0, 0, b), f(a, 0, b), f(a, c, b), f(0, c, b)];
face([D, C, G, Hh], TOP); face([B, F, G, C], SIDE); face([A, B, C, D], FILL);
edge(A, E, true); edge(E, F, true); edge(E, Hh, true);
[[A, B], [B, C], [C, D], [D, A], [B, F], [F, G], [G, C], [G, Hh], [Hh, D]].forEach(([p, q]) => edge(p, q));
label((A[0] + B[0]) / 2, A[1] + 17, l.a);
label(A[0] - 18, (A[1] + D[1]) / 2 + 4, l.c ?? (shape === 'cube' ? undefined : undefined));
label((B[0] + F[0]) / 2 + 16, (B[1] + F[1]) / 2 + 10, l.b);
} else if (shape === 'prism' || shape === 'pyramid') {
const n = Math.max(3, Math.min(8, Math.round(d.n ?? (shape === 'pyramid' ? 4 : 3))));
const r = d.r ?? 1, h = d.h ?? 1.6;
const s = Math.min(95 / r, 165 / h);
const cx = W / 2, by = H - 44;
const rot = n === 4 ? Math.PI / 4 : Math.PI / 2 + Math.PI / n;
const ang = Array.from({ length: n }, (_, i) => rot + (2 * Math.PI * i) / n);
// sin(t) > 0 یعنی پایینِ بیضی در تصویر = جلوی حجم، نزدیک به بیننده.
const base: XY[] = ang.map((t) => [cx + r * s * Math.cos(t), by + r * s * SQUASH * Math.sin(t)]);
const midAng = (i: number) => ang[i] + Math.PI / n;
const inr = r * Math.cos(Math.PI / n);
// دیده شدنِ وجهِ جانبیِ i (بینِ رأسِ i و i+1): عمودِ بیرونی رو به بیننده.
// نما کمی از بالاست (tan ≈ SQUASH)، پس وجهِ مایلِ هرم کمی زودتر دیده می‌شود.
const faceVisible = (i: number) => shape === 'prism'
? Math.sin(midAng(i)) > 1e-9
: h * Math.sin(midAng(i)) + inr * SQUASH > 1e-9;
const vertexVisible = (i: number) => faceVisible(i) || faceVisible((i - 1 + n) % n);
if (shape === 'prism') {
const top: XY[] = base.map((p) => [p[0], p[1] - h * s]);
face(top, TOP);
for (let i = 0; i < n; i++) if (faceVisible(i)) face([base[i], base[(i + 1) % n], top[(i + 1) % n], top[i]], FILL);
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
edge(base[i], base[j], !faceVisible(i));
edge(top[i], top[j]);
edge(base[i], top[i], !vertexVisible(i));
}
const right = base.reduce((m, p, i) => (p[0] > base[m][0] ? i : m), 0);
if (l.h) dim([top[right][0] + 16, top[right][1]], [base[right][0] + 16, base[right][1]], l.h);
} else {
const apex: XY = [cx, by - h * s];
for (let i = 0; i < n; i++) if (faceVisible(i)) face([base[i], base[(i + 1) % n], apex], i % 2 ? FILL : SIDE);
for (let i = 0; i < n; i++) {
edge(base[i], base[(i + 1) % n], !faceVisible(i));
edge(base[i], apex, !vertexVisible(i));
}
if (l.h) {
edge(apex, [cx, by], true, COLORS.red);
els.push(<path key={k()} d={`M${cx + 8},${by} L${cx + 8},${by - 8} L${cx},${by - 8}`} fill="none" stroke={COLORS.red} strokeWidth={1.4} />);
label(cx + 16, (apex[1] + by) / 2, l.h);
}
}
// ضلعِ قاعده: پایین‌ترین یالِ جلو
if (l.a) {
let best = 0;
for (let i = 0; i < n; i++) if (base[i][1] + base[(i + 1) % n][1] > base[best][1] + base[(best + 1) % n][1]) best = i;
label((base[best][0] + base[(best + 1) % n][0]) / 2, (base[best][1] + base[(best + 1) % n][1]) / 2 + 17, l.a);
}
} else if (shape === 'cylinder' || shape === 'cone') {
const r = d.r ?? 1, h = d.h ?? 2;
const s = Math.min(110 / r, 170 / (h + r * SQUASH * 2));
const cx = W / 2, by = H - 36, rx = r * s, ry = r * s * SQUASH;
const topY = by - h * s;
const halfEllipse = (cy: number, front: boolean) => `M${cx - rx},${cy} A${rx},${ry} 0 0 ${front ? 0 : 1} ${cx + rx},${cy}`;
if (shape === 'cylinder') {
els.push(<path key={k()} d={`M${cx - rx},${topY} L${cx - rx},${by} A${rx},${ry} 0 0 0 ${cx + rx},${by} L${cx + rx},${topY} Z`} fill={FILL} />);
els.push(<ellipse key={k()} cx={cx} cy={topY} rx={rx} ry={ry} fill={TOP} stroke={INK} strokeWidth={2.2} />);
edge([cx - rx, topY], [cx - rx, by]); edge([cx + rx, topY], [cx + rx, by]);
els.push(<path key={k()} d={halfEllipse(by, true)} fill="none" stroke={INK} strokeWidth={2.2} />);
els.push(<path key={k()} d={halfEllipse(by, false)} fill="none" stroke="#8A8FA3" strokeWidth={1.5} strokeDasharray={HID} />);
if (l.r) { dim([cx, topY], [cx + rx, topY], undefined); els.push(<circle key={k()} cx={cx} cy={topY} r={2.6} fill={INK} />); label(cx + rx / 2, topY - 8, l.r); }
if (l.h) dim([cx + rx + 16, topY], [cx + rx + 16, by], l.h);
} else {
const apex: XY = [cx, topY];
// مماس از رأس بر بیضیِ قاعده: نقطه‌ی تماس جایی است که sin t = −ry / ارتفاعِ تصویر
// (از صفر شدنِ ضربِ خارجیِ «رأس→نقطه» در «مشتقِ بیضی» درمی‌آید).
const st = ry / Math.max(1e-6, by - topY), ct = Math.sqrt(Math.max(0, 1 - st * st));
const tl: XY = [cx - rx * ct, by - ry * st], tr: XY = [cx + rx * ct, by - ry * st];
els.push(<path key={k()} d={`M${apex.join(',')} L${tl.join(',')} A${rx},${ry} 0 1 0 ${tr.join(',')} Z`} fill={FILL} />);
els.push(<path key={k()} d={`M${tl.join(',')} A${rx},${ry} 0 1 0 ${tr.join(',')}`} fill="none" stroke={INK} strokeWidth={2.2} />);
els.push(<path key={k()} d={`M${tl.join(',')} A${rx},${ry} 0 0 1 ${tr.join(',')}`} fill="none" stroke="#8A8FA3" strokeWidth={1.5} strokeDasharray={HID} />);
edge(apex, tl); edge(apex, tr);
if (l.h) {
edge(apex, [cx, by], true, COLORS.red);
els.push(<path key={k()} d={`M${cx + 8},${by} L${cx + 8},${by - 8} L${cx},${by - 8}`} fill="none" stroke={COLORS.red} strokeWidth={1.4} />);
label(cx + 22, (topY + by) / 2 - 10, l.h);
}
if (l.r) { dim([cx, by], [cx + rx, by], undefined); label(cx + rx / 2, by + ry + 16, l.r); }
}
} else if (shape === 'sphere' || shape === 'hemisphere') {
const r = d.r ?? 1;
const R = 92, cx = W / 2, cy = shape === 'sphere' ? H / 2 + 4 : H - 70, ry = R * SQUASH;
if (shape === 'sphere') {
els.push(<circle key={k()} cx={cx} cy={cy} r={R} fill={FILL} stroke={INK} strokeWidth={2.2} />);
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${ry} 0 0 0 ${cx + R},${cy}`} fill="none" stroke={INK} strokeWidth={1.6} />);
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${ry} 0 0 1 ${cx + R},${cy}`} fill="none" stroke="#8A8FA3" strokeWidth={1.4} strokeDasharray={HID} />);
} else {
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${R} 0 0 1 ${cx + R},${cy} A${R},${ry} 0 0 1 ${cx - R},${cy} Z`} fill={FILL} />);
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${R} 0 0 1 ${cx + R},${cy}`} fill="none" stroke={INK} strokeWidth={2.2} />);
els.push(<ellipse key={k()} cx={cx} cy={cy} rx={R} ry={ry} fill={TOP} stroke={INK} strokeWidth={2} />);
}
els.push(<circle key={k()} cx={cx} cy={cy} r={2.8} fill={INK} />);
if (l.r) { dim([cx, cy], [cx + R, cy], undefined); label(cx + R / 2, cy - 8, l.r); }
void r;
} else return null;
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[300px]" role="img" aria-label={spec.title || 'حجم'}>
{els}
</svg>
);
}

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

165
src/diagram/svg.tsx Normal file
View File

@@ -0,0 +1,165 @@
import { createContext, type ReactNode } from 'react';
import type { ColorName } from './types';
import { toFa } from '@/lib/format';
/**
* رنگ‌ها و قطعه‌های مشترکِ همه‌ی شکل‌ها.
*
* رنگ‌ها معنا دارند و در همه‌ی شکل‌ها یکی‌اند: آبی شکلِ اصلی، قرمز زاویه و چیزی که
* باید دیده شود، سبز جواب، نارنجی/بنفش سری‌های دوم و سوم. مدل فقط نامِ رنگ را
* می‌دهد، نه کدِ hex، تا شکل‌ها همیشه با رنگ‌های اپ بخوانند.
*/
export const COLORS: Record<ColorName, string> = {
blue: '#3F5BD8',
red: '#E5484D',
green: '#1F9D55',
orange: '#EE8A12',
purple: '#8B5CF6',
teal: '#0E9F9F',
pink: '#D9468F',
gray: '#8A8FA3',
black: '#26263D',
yellow: '#F5B400',
};
/** ترتیبِ رنگِ سری‌ها وقتی مدل رنگ نداده (نمودارِ دوم، ستونِ سوم، …). */
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';
export const PAPER = '#FBFBFF';
export const color = (name: ColorName | undefined, fallback: string) => (name ? COLORS[name] : fallback);
/** عدد با رقمِ فارسی، منفیِ درست (−) و بدونِ صفرهای بی‌معنای اعشار. */
export function fa(n: number, digits = 2): string {
if (!Number.isFinite(n)) return '';
const r = Math.round(n * 10 ** digits) / 10 ** digits;
const s = (Object.is(r, -0) ? 0 : r).toString();
return toFa(s.replace('-', '−').replace('.', '٫'));
}
/** حرفِ فارسی/عربی — نه رقم: ۰–۹ِ فارسی (U+06F0…) عمداً بیرون است. */
export const PERSIAN_LETTER = /[\u0620-\u064A\u066E-\u06D3\u06FA-\u06FF]/;
/**
* متنِ ریاضی را چپ‌به‌راست قفل می‌کند. رقمِ فارسی در الگوریتمِ جهتِ یونیکد «عددِ
* عربی» است و علامت‌های بینِ دو رقم را راست‌به‌چپ می‌کند: «۳ × ۴ = ۱۲» به شکلِ
* «۱۲ = ۴ × ۳» دیده می‌شد، حتی با dir=ltr. نشانه‌ی نامرئیِ LRM بینِ نویسه‌ها
* همه‌ی علامت‌ها را چپ‌به‌راست نگه می‌دارد. فقط برای متنی که حرفِ فارسی ندارد.
*/
export function ltr(s: string): string {
if (!s || PERSIAN_LETTER.test(s)) return s;
return '\u200E' + [...s].join('\u200E') + '\u200E';
}
/** رقمِ لاتین به فارسی، منفیِ درست (−)، و قفلِ چپ‌به‌راست برای متنِ ریاضیِ خالص. */
export function mathText(s: string): string {
const t = toFa(s.replace(/-/g, '−'));
return ltr(t);
}
/**
* جهتِ متنِ SVG: متنِ ریاضیِ خالص با bidi-override چپ‌به‌راست (در Chrome نه LRM و نه
* direction=ltr رقمِ فارسی را از برعکس شدن نجات نداد: «۳ × ۴ = ۱۲» → «۱۲ = ۴ × ۳»)،
* و متنِ فارسی جهتش را از خودش می‌گیرد.
*/
export function bidi(children: ReactNode): { direction?: 'ltr'; style: { unicodeBidi: 'plaintext' | 'bidi-override'; direction?: 'ltr' } } {
const math = typeof children === 'string' && !PERSIAN_LETTER.test(children);
// bidi-override: نویسه‌ها دقیقاً به ترتیبِ نوشتن، چپ به راست. برای ریاضیِ خالص درست
// همان چیزی است که می‌خواهیم؛ برای متنِ فارسی هرگز، چون حروف را برعکس می‌کند.
return math ? { direction: 'ltr', style: { unicodeBidi: 'bidi-override', direction: 'ltr' } } : { style: { unicodeBidi: 'plaintext' } };
}
/** برچسب با هاله‌ی سفید تا روی خط‌ها خوانا بماند؛ جهتش را از متنِ خودش می‌گیرد. */
export function Label({
x, y, color: c = INK, size = 11, weight = 700, anchor = 'middle', children,
}: { x: number; y: number; color?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; children: ReactNode }) {
return (
<text x={x} y={y} fontSize={size} fontWeight={weight} fill={c} textAnchor={anchor}
stroke={PAPER} strokeWidth={3.2} strokeLinejoin="round" paintOrder="stroke" {...bidi(children)}>
{children}
</text>
);
}
/** سرِ فلش؛ هر رنگ یک marker (SVG رنگِ marker را از خط به ارث نمی‌برد). */
export function Arrowheads({ ids }: { ids: string[] }) {
return (
<defs>
{[...new Set(ids)].map((c) => (
<marker key={c} id={arrowId(c)} viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill={c} />
</marker>
))}
</defs>
);
}
export const arrowId = (hex: string) => `dg-ah-${hex.replace('#', '')}`;
/** کسرِ دوطبقه (صورت روی مخرج)؛ در کتابِ دبستان «/» ممیز است، پس کسر همیشه طبقه‌ای کشیده می‌شود. */
export function StackedFraction({ x, y, num, den, color: c = INK, size = 10 }: { x: number; y: number; num: string; den: string; color?: string; size?: number }) {
const w = Math.max(num.length, den.length) * size * 0.62 + 4;
return (
<g>
<text x={x} y={y - 2} fontSize={size} fontWeight={700} fill={c} textAnchor="middle">{num}</text>
<line x1={x - w / 2} x2={x + w / 2} y1={y + 1} y2={y + 1} stroke={c} strokeWidth={1} />
<text x={x} y={y + size + 2} fontSize={size} fontWeight={700} fill={c} textAnchor="middle">{den}</text>
</g>
);
}
export function niceStep(span: number, maxTicks: number): number {
const raw = span / Math.max(1, maxTicks);
const pow = 10 ** Math.floor(Math.log10(raw || 1));
const m = raw / pow;
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * pow;
}
export function ticksBetween(lo: number, hi: number, step: number): number[] {
const out: number[] = [];
for (let t = Math.ceil(lo / step - 1e-9) * step; t <= hi + 1e-9; t += step) out.push(Math.round(t * 1e9) / 1e9);
return out;
}
/** بزرگ‌ترین مقسوم‌علیهِ مشترک، برای ساده کردنِ کسرِ برچسب‌ها. */
export function gcd(a: number, b: number): number {
a = Math.abs(a); b = Math.abs(b);
while (b) [a, b] = [b, a % b];
return a || 1;
}
/** برچسبِ مضربِ π برای محورِ مثلثاتی: π/2، π، 3π/2، −π، … */
export function piLabel(t: number): string {
const k = Math.round(t / (Math.PI / 2));
if (k === 0) return '۰';
const g = gcd(k, 2);
const num = k / g, den = 2 / g;
const sign = num < 0 ? '−' : '';
const n = Math.abs(num);
const head = n === 1 ? 'π' : `${toFa(n)}π`;
return den === 1 ? `${sign}${head}` : `${sign}${head}/${toFa(den)}`;
}
/**
* زیرِ شکلِ سوالِ آزمون، شمارشِ خودکار («۲ تکه از ۵ تکه»، «۶ خانه از ۱۲ خانه»، «۶ × ۴ = ۲۴»، «۹ تا»)
* جوابِ سوال را لو می‌داد؛ آزمون این را روشن می‌کند و شکل فقط خودِ داده را نشان می‌دهد.
* در درس و جوابِ دستیار شمارش می‌ماند چون آنجا توضیح است، نه سوال.
*/
export const HideCounts = createContext(false);

130
src/diagram/types.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* شِمای شکلِ دستیار. همان قراردادی که بک‌اند (rag/services/diagram.py) اعتبارسنجی
* می‌کند و مدل طبقِ قاعده‌ی پرامپت می‌نویسد. هر تغییر اینجا، آنجا هم لازم است.
*
* اصل: مدل «چه» را می‌گوید (نقطه‌ی وسط، پای عمود، ریشه‌های تابع)، کدِ اپ «کجا» را
* حساب می‌کند. هیچ مختصاتی که بشود حساب کرد از مدل گرفته نمی‌شود.
*/
import type { QuadItem, TriangleItem } from './shapes';
export type ColorName = 'blue' | 'red' | 'green' | 'orange' | 'purple' | 'teal' | 'pink' | 'gray' | 'black' | 'yellow';
export type Ref = string | [number, number];
/** تعریفِ نقطه: مختصات، یا ساختنی از نقطه‌های دیگر. */
export type PointDef =
| [number, number]
| { polar: [number, number]; from?: Ref }
| { mid: [Ref, Ref] }
| { foot: [Ref, Ref, Ref] }
| { intersect: [[Ref, Ref], [Ref, Ref]] }
| { along: [Ref, Ref, number] }
| { fx: [string, number] }
/** نقطه‌ی انتقال‌یافته با بردارِ [dx, dy] */
| { translate: [number, number]; from: Ref };
interface Style {
color?: ColorName;
dashed?: boolean;
label?: string;
}
export type Transform =
| { rotate: { center: Ref; deg: number } }
| { reflect: 'x' | 'y' | 'y=x' | [Ref, Ref] }
| { translate: [number, number] }
| { scale: { center: Ref; k: number } };
export type FunctionMark = 'roots' | 'vertex' | 'extrema' | 'yIntercept' | 'axis';
export type PlaneItem =
| ({ type: 'point'; point: Ref; open?: boolean } & Style)
| ({ type: 'segment'; from: Ref; to: Ref; ticks?: number } & Style)
| ({ type: 'arrow' | 'vector'; from: Ref; to: Ref } & Style)
| ({ type: 'line'; through?: [Ref, Ref]; /** «3x + 5y = 4»، همان‌طور که در مسئله نوشته شده */ equation?: string; /** ax + by + c = 0 */ eq?: [number, number, number] } & Style)
| ({ type: 'ray'; from: Ref; through: Ref } & Style)
| ({ type: 'vline'; x: number } & Style)
| ({ type: 'hline'; y: number } & Style)
| ({ type: 'circle'; center: Ref; r?: number; through?: Ref; fill?: boolean } & Style)
| ({ type: 'arc' | 'sector'; center: Ref; r: number; start: number; end: number } & Style)
| ({ type: 'angle'; vertex: Ref; from: Ref; to: Ref; arcs?: number } & Style)
| ({ type: 'rightAngle'; vertex: Ref; from: Ref; to: Ref } & Style)
| ({ type: 'polygon'; points: Ref[]; fill?: boolean } & Style)
| ({ type: 'regularPolygon'; n: number; r: number; center?: Ref; start?: number; names?: string[]; fill?: boolean } & Style)
| ({ type: 'exteriorAngle'; vertex: Ref; from: Ref; to: Ref } & Style)
| TriangleItem
| QuadItem
| ({ type: 'function'; expr: string; from: number; to: number; marks?: FunctionMark[] } & Style)
| ({ type: 'piecewise'; pieces: { expr: string; from: number; to: number; closedFrom?: boolean; closedTo?: boolean }[] } & Style)
| ({ type: 'tangent'; expr: string; at: number } & Style)
| ({ type: 'area'; expr: string; expr2?: string; from: number; to: number } & Style)
| ({ type: 'region'; expr: string; side: 'above' | 'below'; from?: number; to?: number; strict?: boolean } & Style)
| ({ type: 'image'; of: Ref[]; transform: Transform; names?: string[] } & Style)
| { type: 'text'; at: [number, number]; text: string; color?: ColorName };
export interface PlaneSpec {
kind: 'plane';
title: string;
axes: boolean;
grid?: boolean;
/** محورِ x با مضرب‌های π/2 */
trig?: boolean;
xRange?: [number, number];
yRange?: [number, number];
points: Record<string, PointDef>;
items: PlaneItem[];
}
export interface NumberLineSpec {
kind: 'numberline';
title: string;
min: number;
max: number;
step?: number;
/** برچسبِ تیک‌ها به‌صورتِ کسر (۱/۴، ۲/۴، …) */
fractions?: boolean;
points: { at: number; label: string; open?: boolean; color?: ColorName }[];
jumps: { from: number; to: number; label: string }[];
intervals?: { from: number | null; to: number | null; closedFrom?: boolean; closedTo?: boolean; color?: ColorName; label?: string }[];
}
export interface FractionSpec {
kind: 'fraction'; title: string; shape: 'circle' | 'rect'; parts: number; shaded: number;
/** واحدهای کاملِ عددِ مخلوط (دو و سه‌چهارم → ۲) */
wholes?: number;
/** مقایسه‌ی چند کسر با نوارهای هم‌اندازه */
compare?: { parts: number; shaded: number; label?: string }[];
}
export interface GridSpec { kind: 'grid'; title: string; rows: number; cols: number; shaded: number | [number, number][]; color?: ColorName }
export interface GroupsSpec { kind: 'groups'; title: string; groups: number; each: number; item?: 'circle' | 'square' | 'star'; label?: string }
/** الگوی شکلی: stages تعدادِ پایه‌ی هر مرحله (row: n در یک ردیف، square: n×n، rect: n×(n+1)،
* triangle: مثلثی، stair: پلکانی، L)؛ stick: n مربعِ چوب‌کبریتی در یک ردیف. */
export interface PatternSpec { kind: 'pattern'; title: string; layout: 'row' | 'square' | 'rect' | 'triangle' | 'stair' | 'L'; unit: 'square' | 'dot' | 'stick'; stages: number[]; next?: boolean; counts?: boolean; color?: ColorName }
export interface ClockSpec { kind: 'clock'; title: string; hour: number; minute: number }
export interface ChartSpec { kind: 'bar' | 'linechart' | 'pie'; title: string; labels: string[]; values: number[]; unit?: string }
export interface TableSpec { kind: 'table'; title: string; headers: string[]; rows: string[][] }
export interface VennSpec { kind: 'venn'; title: string; sets: string[]; regions: Record<string, string[]>; shade?: string[]; /** نامِ نمایشیِ مجموعه‌ها («والیبال»)؛ بی‌آن همان A، B، C */ names?: string[] }
export interface MappingSpec { kind: 'mapping'; title: string; left: string[]; right: string[]; arrows: [number, number][]; leftLabel?: string; rightLabel?: string }
export interface TreeNode { label: string; p?: string; children?: TreeNode[] }
export interface TreeSpec { kind: 'tree'; title: string; root: TreeNode }
export interface SolidSpec {
kind: 'solid';
title: string;
shape: 'cube' | 'cuboid' | 'prism' | 'pyramid' | 'cylinder' | 'cone' | 'sphere' | 'hemisphere';
/** a پهنا، b عمق، c ارتفاع (مکعب‌مستطیل)؛ n تعدادِ ضلعِ قاعده؛ r شعاع؛ h ارتفاع */
dims?: { a?: number; b?: number; c?: number; n?: number; r?: number; h?: number };
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 | SpaceSpec;

248
src/lib/MathText.tsx Normal file
View File

@@ -0,0 +1,248 @@
import { Fragment } from 'react';
import { plainMath, plainPersian } from './plainMath';
/**
* متنِ فارسی که ریاضی لایش است («عدد −۱ می‌شود»، «رأس (۱، −۴) است»).
*
* رقمِ فارسی در الگوریتمِ جهتِ یونیکد «عددِ عربی» است و در متنِ راست‌به‌چپ علامت‌های
* کنارش را جابه‌جا می‌کند: «−۱» به‌صورتِ «۱−» و «(۱، −۴)» به‌صورتِ «(−۴ ،۱)» دیده
* می‌شد. هر تکه‌ای که حرفِ فارسی ندارد ولی رقم دارد، اینجا با bidi-override چپ‌به‌راست
* قفل می‌شود؛ جمله‌ی فارسی دورش دست نمی‌خورد.
*
* فهرستِ داخلِ جمله («از −۳ به −۲، −۱، ۰ می‌رسیم»، «الگویِ ۲، ۵، ۸، …») مالِ جمله است و
* از راست خوانده می‌شود؛ پس «،» و «؛» بیرونِ پرانتز و آکولاد تکه را می‌شکنند و هر عضو
* جدا قفل می‌شود. داخلِ `{۱، ۲، ۳}` و `(۱، −۴)` ریاضی است و یک‌جا قفل می‌ماند. سنجیده
* با مختصاتِ رسم‌شده در Chrome روی همه‌ی سوال‌ها و گزینه‌های بانک و چند جوابِ دستیار
* (۲۰۲۶-۰۹-۲۹)؛ پیش از آن هر ۴۸۲ فهرستِ داخلِ جمله برعکس دیده می‌شد.
*
* آزموده روی Chrome (دسکتاپ و موبایلِ شبیه‌سازی‌شده)؛ روی Safariِ واقعیِ آیفون دیده نشده.
*/
// حروف، اِعراب (کسره‌ی «خودِ»)، و نیم‌فاصله؛ رقم‌های ۰–۹ عمداً بیرون‌اند.
const LETTER = '\\u0620-\\u065F\\u066E-\\u06D3\\u06FA-\\u06FF\\u200C';
// «نویسه‌ی ریاضی»: رقم یا حرفِ لاتین (نامِ تابع و متغیر: 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،؛:!؟«»"'.=]+$/;
// isolate، نه embedding: تکه‌ی قفل‌شده از بیرون یک واحدِ خنثی است. با bidi-overrideِ ساده
// تکه‌ها از بیرون «حرفِ چپ‌به‌راست» حساب می‌شدند و «،»ِ بینِ دو عضوِ فهرست را هم به
// سمتِ خودشان می‌کشیدند، پس فهرست باز برعکس می‌شد.
const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const;
// فرمولِ «واقعی» (نه عددِ تنها): نمادِ رابطه یا عمل دارد و دستِ‌کم چهار نویسه است.
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;
for (const m of text.matchAll(RUN)) {
for (const [start, piece] of splitList(m[0], m.index ?? 0, depth)) {
let run = piece;
// شماره‌ی مرحله در سرِ خط («۴) …») مالِ جمله‌ی فارسی است و باید سمتِ راست بماند.
const atLineStart = start === 0 || text[start - 1] === '\n';
const step = atLineStart ? /^\s*[0-9۰-۹]{1,2}\)\s*/.exec(run)?.[0] ?? '' : '';
const lead = step + (EDGE_START.exec(run.slice(step.length))?.[0] ?? '');
run = run.slice(lead.length);
const tail = EDGE_END.exec(run)?.[0] ?? '';
run = run.slice(0, run.length - tail.length);
// «(۱، −۴)» باید کامل قفل شود، ولی پرانتزِ بی‌جفت در لبه‌ی تکه مالِ جمله است:
// در «۱۰ − ۲ (با محاسبه…)» پرانتزِ باز جزوِ جمله‌ی فارسیِ بعدی است و اگر قفل شود
// به سمتِ غلط می‌رود.
const edge = trimUnpaired(run);
const head = lead + edge.head;
run = edge.run;
if (!run || !HAS_MATH.test(run)) continue;
out.push(text.slice(last, start + head.length));
const before = text.slice(0, start + head.length), after = text.slice(start + head.length + run.length);
const inSentence = new RegExp(`[${LETTER}]`).test(before) || new RegExp(`[${LETTER}]`).test(after);
if (display && inSentence && run.trim().length >= 4 && FORMULA.test(run) && depth[start + head.length] === 0) {
blocks++;
out.push(<span key={i++} style={{ ...LTR, display: 'block', textAlign: 'center', margin: '4px 0', fontWeight: 800 }}>{run}</span>);
} else {
out.push(<span key={i++} style={LTR}>{run}</span>);
}
last = start + head.length + run.length;
}
}
out.push(text.slice(last));
return { nodes: out.map((n, j) => (typeof n === 'string' ? <Fragment key={`t${j}`}>{n}</Fragment> : n)), blocks };
}
/** عمقِ پرانتز/کروشه/آکولاد پیش از هر نویسه‌ی متن. */
function bracketDepth(text: string): number[] {
const depth: number[] = [];
let d = 0;
for (const ch of text) {
depth.push(d);
if ('([{'.includes(ch)) d++;
else if (')]}'.includes(ch)) d = Math.max(0, d - 1);
}
return depth;
}
/** تکه را روی «،» و «؛»ِ بیرونِ پرانتز می‌شکند؛ جداکننده خودش بیرون می‌ماند. */
function splitList(run: string, offset: number, depth: number[]): [number, string][] {
const pieces: [number, string][] = [];
let from = 0;
for (let k = 0; k < run.length; k++) {
if ((run[k] === '،' || run[k] === '؛') && !depth[offset + k]) {
pieces.push([offset + from, run.slice(from, k)]);
from = k + 1;
}
}
pieces.push([offset + from, run.slice(from)]);
return pieces;
}
const count = (s: string, ch: string) => s.split(ch).length - 1;
/**
* پرانتزِ بازِ بی‌جفت در تهِ تکه و بسته‌ی بی‌جفت در سرش را (با فاصله‌ی کنارش) کنار
* می‌گذارد؛ `head` یعنی چه مقدار از سرِ تکه به جمله‌ی فارسی برگشت.
*/
function trimUnpaired(run: string): { head: string; run: string } {
let head = '';
const opens = (r: string) => count(r, '(') + count(r, '[') + count(r, '{');
const closes = (r: string) => count(r, ')') + count(r, ']') + count(r, '}');
while (/[(\[{]\s*$/.test(run) && opens(run) > closes(run)) {
run = run.slice(0, run.length - /\s*[(\[{]\s*$/.exec(run)![0].length);
}
while (/^\s*[)\]}]/.test(run) && closes(run) > opens(run)) {
const m = /^\s*[)\]}]\s*/.exec(run)![0];
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>
);
}

141
src/lib/bazaar.ts Normal file
View File

@@ -0,0 +1,141 @@
import { store } from './storage';
/**
* پلِ پرداختِ کافه‌بازار، وقتی همین سایت داخلِ اپِ اندرویدِ تدریسینو (WebView) باز است.
*
* خریدِ درون‌برنامه‌ایِ بازار فقط از کدِ بومیِ اندروید (کتابخانه‌ی Poolakey) شدنی است، پس
* اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه می‌گذارد و این فایل تنها
* جایی است که با آن حرف می‌زند. در مرورگرِ معمولی آن شیء نیست و خرید با زرین‌پال است.
*
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
* 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' | '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;
bazaarPrices?: (skusJson: string) => void;
/** اولین صفحه‌ی واقعی کشیده شد؛ اسپلشِ سیستم می‌تواند برود (APKهای قدیمی‌تر این را ندارند). */
appReady?: () => void;
}
declare global {
interface Window {
TadrisinoAndroid?: AndroidBridge;
}
}
type BridgeResult =
| { orderId: string; purchaseToken: string }
| { orderId: string; error: 'canceled' | 'failed' | 'unavailable'; message?: string };
export class BazaarPurchaseError extends Error {
constructor(readonly kind: 'canceled' | 'failed' | 'unavailable' | 'timeout', message = '') {
super(message || kind);
}
}
/** داخلِ اپِ اندرویدِ تدریسینوییم (هر نسخه‌ای). */
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 bazaarBuy(sku: string, orderId: string, dynamicPriceToken: string | null): Promise<string> {
const bridge = window.TadrisinoAndroid;
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) {
window.clearTimeout(timer);
window.removeEventListener('tadrisino:bazaar', onResult);
settle();
}
function onResult(event: Event) {
const detail = (event as CustomEvent<BridgeResult>).detail;
if (!detail || detail.orderId !== orderId) return;
if ('purchaseToken' in detail && detail.purchaseToken) finish(() => resolve(detail.purchaseToken));
else if ('error' in detail) finish(() => reject(new BazaarPurchaseError(detail.error, detail.message)));
}
window.addEventListener('tadrisino:bazaar', onResult);
try {
bridge.bazaarBuy!(sku, orderId, dynamicPriceToken ?? '');
} catch (err) {
finish(() => reject(new BazaarPurchaseError('failed', String(err))));
}
});
}
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 {
return await askPrices(skus);
} catch {
await new Promise((r) => setTimeout(r, 1500));
try {
return await askPrices(skus);
} catch (err) {
console.error('bazaarPrices failed', err);
return {};
}
}
}
/**
* Idempotency-Keyِ خریدِ نیمه‌تمامِ هر SKU. اگر بازار پول را گرفت ولی تأییدِ سرور نرسید (قطعِ
* اینترنت)، تلاشِ بعدی باید همان سفارش را بگیرد؛ سفارشِ تازه با توکنِ قبلی تأیید نمی‌شد چون هر
* توکن فقط به یک سفارش می‌خورد.
*/
const pendingKey = (sku: string) => `tadrisino.bazaarPending.${sku}`;
export const bazaarPending = {
get: (sku: string) => store.get(pendingKey(sku)),
set: (sku: string, key: string) => store.set(pendingKey(sku), key),
clear: (sku: string) => store.remove(pendingKey(sku)),
};

31
src/lib/color.ts Normal file
View File

@@ -0,0 +1,31 @@
/** رنگِ hex را روشن (amount > 0) یا تیره (amount < 0) می‌کند؛ برای سایه‌ی دکمه‌های سه‌بعدی. */
export function shade(hex: string, amount: number): string {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return hex;
const n = parseInt(m[1], 16);
const channel = (c: number) => {
const v = amount < 0 ? c * (1 + amount) : c + (255 - c) * amount;
return Math.max(0, Math.min(255, Math.round(v)));
};
const r = channel((n >> 16) & 255), g = channel((n >> 8) & 255), b = channel(n & 255);
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
function luminance(hex: string): number {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return 1;
const n = parseInt(m[1], 16);
const lin = (c: number) => { const v = c / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
return 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
}
/**
* همان رنگ، فقط تا جایی تیره می‌شود که متنِ سفید رویش کنتراستِ دست‌کم `min` بگیرد
* (WCAG: ۳ برای متنِ درشت). رنگِ روشن برای آیکن و نوار می‌ماند و فقط پس‌زمینه‌ی متنِ سفید از این رد می‌شود،
* پس هیچ رنگِ تازه‌ای وارد اپ نمی‌شود، فقط سایه‌ی تیره‌ترِ همان.
*/
export function onWhite(hex: string, min = 3.2): string {
let c = hex;
for (let i = 0; i < 12 && 1.05 / (luminance(c) + 0.05) < min; i++) c = shade(c, -0.07);
return c;
}

View File

@@ -1,11 +1,20 @@
import { ApiError } from '@/api'; import { ApiError } from '@/api';
/** پیامِ قابلِ فهم برای دانش‌آموز، از هر خطایی. */ const PERSIAN = /[\u0600-\u06FF]/;
/**
* پیامِ قابلِ فهم برای دانش‌آموز، از هر خطایی. کاربر هرگز متنِ فنیِ خطا را نمی‌بیند: «خطای ۴۰۴»،
* انگلیسیِ پیش‌فرضِ DRF یا پیامِ مرورگر نشان داده نمی‌شود. فقط جمله‌ی فارسیِ خودِ سرور (که برای
* همین نوشته شده، مثل «کد درست نبود») یا fallback.
*/
export function friendlyError(err: unknown, fallback = 'یه مشکلی پیش اومد، دوباره امتحان کن'): string { export function friendlyError(err: unknown, fallback = 'یه مشکلی پیش اومد، دوباره امتحان کن'): string {
if (!(err instanceof ApiError)) return fallback; if (!(err instanceof ApiError)) return fallback;
if (err.isNetwork) return 'ارتباط با سرور برقرار نشد؛ اینترنتت رو چک کن'; if (err.isNetwork) {
if (err.isRateLimited) return err.detail && !err.detail.startsWith('خطای') ? err.detail : 'زیادی تلاش کردی؛ چند لحظه صبر کن'; 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.isProviderDown) return 'این سرویس الان در دسترس نیست؛ کمی بعد دوباره امتحان کن';
if (err.status >= 500) 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'; import { KEYS, store } from './storage';
/** /**
* صداهای کوتاهِ رابط. یک AudioContext برای کلِ اپ؛ ساختنِ یکی برای هر صدا * صدا و لرزشِ کوتاهِ رابط، به‌جا و کم: انتخابِ گزینه، جوابِ درست/غلط، پایانِ درس/جزیره، روشن‌ماندنِ زنجیره.
* در Safari بعد از چند بار شکست می‌خورد. * همه با کلیدِ «صداها» در پروفایل خاموش می‌شوند.
*
* چرا روی اپِ نصب‌شده صدا نبود (از روی کد؛ روی گوشی دیده نشد): AudioContext اولین بار بعد از پاسخِ سرور
* ساخته می‌شد، یعنی بیرون از لمسِ کاربر، و WebViewِ اندروید و Safari چنین صدایی را «پخشِ خودکار» می‌دانند و
* بی‌صدا نگهش می‌دارند. حالا با اولین لمسِ صفحه ساخته و باز می‌شود (unlockAudioOnFirstTouch).
*
* صداها با Web Audio ساخته می‌شوند، بی‌فایل: بارِ اپ اضافه نمی‌شود.
*/ */
let ctx: AudioContext | null = null; let ctx: AudioContext | null = null;
@@ -12,12 +18,33 @@ function context(): AudioContext | null {
if (!Ctor) return null; if (!Ctor) return null;
try { try {
ctx = new Ctor(); ctx = new Ctor();
} catch { } catch (err) {
console.warn('AudioContext unavailable', err);
return null; return null;
} }
return ctx; 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 { export function soundEnabled(): boolean {
return store.get(KEYS.sound) !== 'off'; return store.get(KEYS.sound) !== 'off';
} }
@@ -26,29 +53,113 @@ export function setSoundEnabled(on: boolean) {
store.set(KEYS.sound, on ? 'on' : 'off'); 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(); const audio = context();
if (!audio || !soundEnabled()) return; if (!audio || !soundEnabled()) return;
if (audio.state === 'suspended') void audio.resume(); if (audio.state === 'suspended') void audio.resume();
const t = audio.currentTime + start; const t = audio.currentTime + start;
const osc = audio.createOscillator();
const gain = audio.createGain(); const gain = audio.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(frequency, t);
gain.gain.setValueAtTime(0.0001, 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); 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.start(t);
osc.stop(t + duration + 0.02); 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 = { export const sounds = {
tick: () => tone(720, 0, 0.045, 0.04), /** لمسِ گزینه‌ی آزمون: یک «تیک»ِ خیلی ریز. */
correct: () => { select: () => {
tone(660, 0, 0.12); note(880, 0, 0.05, 0.025, 'triangle');
tone(990, 0.1, 0.18); buzz(8);
}, },
wrong: () => tone(220, 0, 0.25, 0.06), /** `pitch`: نیم‌پرده بالاتر برای همراهِ انتخابی (state/companion)؛ هر همراه صدای خودش را دارد. */
finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)), 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: () => {
note(311, 0, 0.16, 0.045, 'triangle');
note(233, 0.12, 0.24, 0.045, 'triangle');
buzz([35, 50, 35]);
},
/** پایانِ درس/جزیره: آرپژِ بالارونده و یک آکوردِ کوتاه. */
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

@@ -24,22 +24,13 @@ export const store = {
}; };
export const KEYS = { export const KEYS = {
deviceId: 'deviceId',
onboarded: 'tadrisino.onboarded', onboarded: 'tadrisino.onboarded',
conversationId: 'tadrisino.conversationId', conversationId: 'tadrisino.conversationId',
ticketIds: 'tadrisino.ticketIds',
sound: 'tadrisino.sound', sound: 'tadrisino.sound',
referralCode: 'tadrisino.referralCode', referralCode: 'tadrisino.referralCode',
companion: 'tadrisino.companion',
/** {gradeId: subjectKey}؛ کتابی که کاربر در هر پایه آخر انتخاب کرده. */
subjects: 'tadrisino.subjects',
/** عکسِ صفحه‌ی خانه برای بازشدنِ فوری (state/homeSnapshot.ts). */
homeSnapshot: 'tadrisino.homeSnapshot',
} as const; } as const;
/** شناسه‌ی ثابتِ این مرورگر برای ورودِ مهمان. */
export function getDeviceId(): string {
let id = store.get(KEYS.deviceId);
if (!id) {
id = globalThis.crypto && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
store.set(KEYS.deviceId, id);
}
return id;
}

5
src/lib/subscription.ts Normal file
View File

@@ -0,0 +1,5 @@
/** چند روز تا پایانِ اشتراک مانده (رو به بالا)؛ null برای اشتراکِ بی‌تاریخ (هدیه‌ی نامحدود). */
export function daysLeft(expiresAt: string | null): number | null {
if (!expiresAt) return null;
return Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000));
}

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> { export interface AsyncState<T> {
data: T | undefined; data: T | undefined;
@@ -8,39 +8,104 @@ export interface AsyncState<T> {
setData: (update: T | ((prev: T | undefined) => T | undefined)) => void; 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) نادیده گرفته می‌شود. * پاسخِ درخواستِ کهنه (بعد از عوض شدنِ deps) نادیده گرفته می‌شود.
* با `key`، پاسخ در کشِ نشست می‌ماند و بارِ بعد فوراً نشان داده می‌شود (بالای همین فایل).
*/ */
export function useAsync<T>(load: () => Promise<T>, deps: DependencyList): AsyncState<T> { export function useAsync<T>(load: () => Promise<T>, deps: DependencyList, opts: { key?: string } = {}): AsyncState<T> {
const [data, setData] = useState<T | undefined>(undefined); 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 [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 [nonce, setNonce] = useState(0);
const loadRef = useRef(load); const loadRef = useRef(load);
loadRef.current = load; loadRef.current = load;
const gate = useContext(ScreenGateContext);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const hit = key !== undefined && cache.has(key);
if (hit) {
// کلید عوض شده (مثلاً «هفتگی» ← «همیشه») و برای کلیدِ تازه کش هست: همان را نشان بده و بی‌لودر تازه کن.
setDataState(cache.get(key) as T);
setLoading(false);
} else {
setLoading(true); setLoading(true);
}
setError(null); setError(null);
// دروازه‌ای که یک بار باز شده hold را نادیده می‌گیرد؛ پس «دوباره» و تازه‌سازی صفحه را پنهان نمی‌کنند.
const release = !hit && gate ? gate.hold() : null;
loadRef.current().then( loadRef.current().then(
(value) => { (value) => {
if (!alive) return; if (!alive) return;
setData(value); if (key !== undefined) cache.set(key, value);
setDataState(value);
setLoading(false); setLoading(false);
release?.();
}, },
(err: unknown) => { (err: unknown) => {
if (!alive) return; if (!alive) return;
if (hit) {
// داده‌ی کش‌شده روی صفحه است؛ شکستِ تازه‌سازی آن را با صفحه‌ی خطا عوض نمی‌کند، ولی دیده می‌شود.
console.warn(`refresh of ${key} failed; showing cached data`, err);
} else {
setError(err); setError(err);
}
setLoading(false); setLoading(false);
release?.();
}, },
); );
return () => { return () => {
alive = false; alive = false;
release?.();
}; };
}, [...deps, nonce]); }, [...deps, key, nonce]);
const reload = useCallback(() => setNonce((n) => n + 1), []); 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 }; return { data, error, loading, reload, setData };
} }

View File

@@ -1,10 +1,25 @@
import { StrictMode } from 'react'; import { StrictMode, lazy, Suspense } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { App } from './app/App'; import { App } from './app/App';
import './styles/index.css'; import './styles/index.css';
import { unlockAudioOnFirstTouch } from './lib/sound';
import { startLineFit } from './lib/lineFit';
unlockAudioOnFirstTouch();
startLineFit();
// گالریِ شکل‌ها فقط در حالتِ توسعه؛ در build اصلاً وارد نمی‌شود (import.meta.env.DEV ثابت است).
const DiagramGallery = import.meta.env.DEV
? lazy(() => import('./dev/DiagramGallery').then((m) => ({ default: m.DiagramGallery })))
: null;
const showGallery = DiagramGallery && new URLSearchParams(window.location.search).has('gallery');
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
{showGallery && DiagramGallery ? (
<Suspense fallback={null}><DiagramGallery /></Suspense>
) : (
<App /> <App />
)}
</StrictMode>, </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 { motion } from 'motion/react';
import { SEYAR_INFO, SEYAR_SVG, type SeyarChar, type SeyarMood } from './seyar'; 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) { function SeyarMascotBase({ char, mood = 'idle', size = 100, bob = false, className }: Props) {
const svg = useMemo(() => SEYAR_SVG[char](mood), [char, mood]); const svg = useMemo(() => SEYAR_SVG[char](mood), [char, mood]);
// ظاهر شدن با فنر فقط وقتی حالت یا شخصیت عوض می‌شود؛ روی هر صفحه‌ی تازه، ماسکوت دیر از صفر بزرگ
// می‌شد و بعد از رسیدنِ صفحه یک «پرش»ِ جدا می‌ساخت.
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
}, []);
return ( return (
<motion.div <motion.div
key={char + mood} key={char + mood}
role="img" role="img"
aria-label={SEYAR_INFO[char].name} aria-label={SEYAR_INFO[char].name}
className={className} 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 }} animate={{ scale: 1, opacity: 1, y: bob ? [0, -6, 0] : 0 }}
transition={ transition={
bob bob

View File

@@ -1,5 +1,6 @@
/** /**
* سه‌یار: سیگما (Σ)، دلتا (Δ) و پی (π) — ماسکوت‌های اپ. * همراه‌های اپ: سیگما (Σ)، دلتا (Δ)، پی (π) و امگا (ω). «سه‌یار» نامِ قدیمیِ سه‌تای اول است؛ در متنِ
* کاربر تعدادِ همراه‌ها نوشته نمی‌شود، چون کاتالوگ از ادمین می‌آید و بزرگ می‌شود.
* *
* هر سه از دلِ یک دفترچه‌ی بی‌نهایت آمده‌اند و هر کدام از جنسِ یک نمادِ ریاضی‌اند: * هر سه از دلِ یک دفترچه‌ی بی‌نهایت آمده‌اند و هر کدام از جنسِ یک نمادِ ریاضی‌اند:
* سیگما → راهنمای درس و آزمون دلتا → هم‌تیمیِ تمرینِ سریع پی → دستیار و همراهِ دلگرمی * سیگما → راهنمای درس و آزمون دلتا → هم‌تیمیِ تمرینِ سریع پی → دستیار و همراهِ دلگرمی
@@ -8,13 +9,14 @@
* ورودی‌ها همه از همین فایل‌اند و هیچ متنِ کاربر واردِ SVG نمی‌شود. * ورودی‌ها همه از همین فایل‌اند و هیچ متنِ کاربر واردِ SVG نمی‌شود.
*/ */
export type SeyarChar = 'sigma' | 'delta' | 'pi'; export type SeyarChar = 'sigma' | 'delta' | 'pi' | 'tedi';
export type SeyarMood = 'idle' | 'happy' | 'excited' | 'sad' | 'angry' | 'depressed'; export type SeyarMood = 'idle' | 'happy' | 'excited' | 'sad' | 'angry' | 'depressed';
const PALETTE: Record<SeyarChar, Record<string, string>> = { const PALETTE: Record<SeyarChar, Record<string, string>> = {
sigma: { body: "#4C5FD5", inner: "#7A8CFF", belly: "#EAF0FF", ink: "#232355", cheek: "#FFB2C0", mouth: "#FF6B6B", tear: "#8EC9FF", badge: "#FFD23F" }, sigma: { body: "#4C5FD5", inner: "#7A8CFF", belly: "#EAF0FF", ink: "#232355", cheek: "#FFB2C0", mouth: "#FF6B6B", tear: "#8EC9FF", badge: "#FFD23F" },
delta: { body: "#FF7A3D", inner: "#FFB25E", belly: "#FFE7C7", ink: "#6E2A12", cheek: "#FF8FA3", mouth: "#D6402A", tear: "#8EC9FF", spike: "#FFEB3B" }, delta: { body: "#FF7A3D", inner: "#FFB25E", belly: "#FFE7C7", ink: "#6E2A12", cheek: "#FF8FA3", mouth: "#D6402A", tear: "#8EC9FF", spike: "#FFEB3B" },
pi: { body: "#9B7BFF", inner: "#C6B3FF", belly: "#F1ECFF", ink: "#3B2B66", cheek: "#FFC2D1", mouth: "#FF6F91", tear: "#5EC8E8" }, pi: { body: "#9B7BFF", inner: "#C6B3FF", belly: "#F1ECFF", ink: "#3B2B66", cheek: "#FFC2D1", mouth: "#FF6F91", tear: "#5EC8E8" },
tedi: { body: "#FFC63D", inner: "#FF9E42", belly: "#FFE9B0", ink: "#4A3B32", cheek: "#FFAEB0", mouth: "#E7573F", tear: "#7EC8E3" },
}; };
const Y_SHIFT: Record<SeyarMood, number> = { idle: 0, happy: 0, excited: -4, sad: 3, angry: 0, depressed: 9 }; const Y_SHIFT: Record<SeyarMood, number> = { idle: 0, happy: 0, excited: -4, sad: 3, angry: 0, depressed: 9 };
const ARM_DELTA: Record<SeyarMood, number> = { idle: 0, happy: 2, excited: 46, sad: -4, angry: -8, depressed: -10 }; const ARM_DELTA: Record<SeyarMood, number> = { idle: 0, happy: 2, excited: 46, sad: -4, angry: -8, depressed: -10 };
@@ -87,6 +89,12 @@ function seyarSigmaSVG(mood: SeyarMood): string {
</g></svg>`; </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 { function seyarDeltaSVG(mood: SeyarMood): string {
const C = PALETTE.delta; const C = PALETTE.delta;
const f = face(mood, 84, 108, 100, 128, C.ink, C.mouth, C.tear, C.cheek, 32); const f = face(mood, 84, 108, 100, 128, C.ink, C.mouth, C.tear, C.cheek, 32);
@@ -101,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 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}"/> <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} ${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
${letterBadge(100, 160, '&#916;', '#FFD23F', C.ink)}
</g></svg>`; </g></svg>`;
} }
@@ -119,22 +128,86 @@ 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="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}"/> <ellipse cx="100" cy="134" rx="46" ry="46" fill="${C.belly}"/>
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth} ${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
${letterBadge(100, 170, '&#960;', '#FFD23F', C.ink)}
</g></svg>`;
}
/**
* امگا (ω؛ تا ۱۴۰۵-۰۷ «تدی»؛ کلیدِ داخلی‌اش هنوز `tedi` است چون slugِ سرور همین است) — شکلکِ زردِ پیش‌نمایشِ موبایل (mascot-pack، ریپوی بک‌اند، پوشه‌ی
* mobile-preview). بدن، گوش‌ها، پاها و دست‌ها عیناً از همان پک است؛ چهره‌ی
* «عصبانی» و «ناامید» را پک نداشت و با همان زبانِ طراحیِ سه‌یار اضافه شد.
*/
function tediSVG(mood: SeyarMood): string {
const C = PALETTE.tedi;
const S = `stroke="${C.ink}" stroke-width="5"`;
const dy = Y_SHIFT[mood];
const restArmL = `<rect x="20" y="112" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(14 32 135)"/>`;
const restArmR = `<rect x="156" y="112" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(-14 168 135)"/>`;
const upArmL = `<rect x="14" y="70" width="24" height="50" rx="12" fill="${C.body}" ${S} transform="rotate(28 26 95)"/>`;
const upArmR = `<rect x="162" y="70" width="24" height="50" rx="12" fill="${C.body}" ${S} transform="rotate(-28 174 95)"/>`;
const droopArmL = `<rect x="24" y="118" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(4 36 141)"/>`;
const droopArmR = `<rect x="152" y="118" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(-4 164 141)"/>`;
let arms = restArmL + restArmR, hands = '', eyes = '', mouth = '', cheeks = '', extra = '';
if (mood === 'happy' || mood === 'excited') {
eyes = `<path d="M66 100 q10 -13 20 0" fill="none" ${S} stroke-linecap="round"/><path d="M114 100 q10 -13 20 0" fill="none" ${S} stroke-linecap="round"/>`;
mouth = `<path d="M78 122 q22 34 44 0 q-22 12 -44 0z" fill="${C.mouth}" ${S} stroke-linejoin="round"/>`;
cheeks = `<ellipse cx="64" cy="122" rx="12" ry="8" fill="${C.cheek}"/><ellipse cx="136" cy="122" rx="12" ry="8" fill="${C.cheek}"/>`;
if (mood === 'excited') {
arms = upArmL + upArmR;
hands = `<circle cx="18" cy="66" r="12" fill="${C.body}" ${S}/><circle cx="182" cy="66" r="12" fill="${C.body}" ${S}/>`;
extra = `<g stroke="${C.inner}" stroke-width="5" stroke-linecap="round"><path d="M40 30 l-8 -12"/><path d="M160 30 l8 -12"/><path d="M100 8 v-12"/></g>`;
}
} else if (mood === 'sad') {
eyes = `<circle cx="78" cy="104" r="8" fill="${C.ink}"/><circle cx="122" cy="104" r="8" fill="${C.ink}"/>`
+ `<path d="M68 94 q10 -6 20 -2" fill="none" ${S} stroke-linecap="round"/><path d="M112 92 q10 -4 20 2" fill="none" ${S} stroke-linecap="round"/>`;
mouth = `<path d="M82 142 q18 -18 36 0" fill="none" ${S} stroke-linecap="round"/>`;
extra = `<path d="M78 112 q-4 12 0 18 q4 -6 0 -18z" fill="${C.tear}"/>`;
} else if (mood === 'angry') {
eyes = `<path d="M68 98 l16 5 l-5 9 z" fill="${C.ink}"/><path d="M132 98 l-16 5 l5 9 z" fill="${C.ink}"/>`
+ `<path d="M64 88 l22 7" ${S} stroke-linecap="round"/><path d="M136 88 l-22 7" ${S} stroke-linecap="round"/>`;
mouth = `<path d="M82 136 l6 -6 l6 6 l6 -6 l6 6 l6 -6 l6 6" fill="none" ${S} stroke-linecap="round" stroke-linejoin="round"/>`;
cheeks = `<ellipse cx="62" cy="124" rx="10" ry="6" fill="${C.mouth}" opacity=".5"/><ellipse cx="138" cy="124" rx="10" ry="6" fill="${C.mouth}" opacity=".5"/>`;
extra = `<g stroke="${C.ink}" stroke-width="4" stroke-linecap="round" opacity=".75"><path d="M30 20 q4 -8 -2 -14"/><path d="M170 20 q-4 -8 2 -14"/></g>`;
} else if (mood === 'depressed') {
arms = droopArmL + droopArmR;
eyes = `<path d="M68 104 q10 5 20 0" fill="none" stroke="${C.ink}" stroke-width="6" stroke-linecap="round"/><path d="M112 104 q10 5 20 0" fill="none" stroke="${C.ink}" stroke-width="6" stroke-linecap="round"/>`;
mouth = `<path d="M88 136 q12 6 24 1" fill="none" ${S} stroke-linecap="round"/>`;
extra = `<g><ellipse cx="100" cy="-6" rx="26" ry="10" fill="#B9C2CC" opacity=".9"/><ellipse cx="84" cy="0" rx="16" ry="8" fill="#B9C2CC" opacity=".9"/><ellipse cx="118" cy="0" rx="15" ry="8" fill="#B9C2CC" opacity=".9"/>`
+ `<g stroke="#8FA0B3" stroke-width="3.5" stroke-linecap="round" opacity=".8"><line x1="86" y1="10" x2="82" y2="20"/><line x1="100" y1="12" x2="96" y2="24"/><line x1="114" y1="10" x2="110" y2="22"/></g></g>`;
} else {
eyes = `<circle cx="78" cy="102" r="9" fill="${C.ink}"/><circle cx="122" cy="102" r="9" fill="${C.ink}"/>`
+ `<circle cx="81" cy="99" r="3" fill="#fff"/><circle cx="125" cy="99" r="3" fill="#fff"/>`;
mouth = `<path d="M84 128 q16 18 32 0" fill="none" ${S} stroke-linecap="round"/>`;
}
return `<svg viewBox="-14 -34 228 250" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 ${dy})">
${extra}
<ellipse cx="74" cy="185" rx="20" ry="13" fill="${C.inner}" ${S}/><ellipse cx="126" cy="185" rx="20" ry="13" fill="${C.inner}" ${S}/>
${arms}
<circle cx="56" cy="46" r="24" fill="${C.body}" ${S}/><circle cx="144" cy="46" r="24" fill="${C.body}" ${S}/>
<circle cx="56" cy="46" r="12" fill="${C.inner}"/><circle cx="144" cy="46" r="12" fill="${C.inner}"/>
<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>`; </g></svg>`;
} }
export const SEYAR_SVG: Record<SeyarChar, (mood: SeyarMood) => string> = { export const SEYAR_SVG: Record<SeyarChar, (mood: SeyarMood) => string> = {
sigma: seyarSigmaSVG, delta: seyarDeltaSVG, pi: seyarPiSVG, sigma: seyarSigmaSVG, delta: seyarDeltaSVG, pi: seyarPiSVG, tedi: tediSVG,
}; };
export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = { export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = {
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" }, sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
delta: { name: "دلتا", symbol: "Δ", role: "هم‌تیمیِ تمرین‌های سریع" }, delta: { name: "دلتا", symbol: "Δ", role: "هم‌تیمی تمرین‌های سریع" },
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" }, pi: { name: "پی", symbol: "π", role: "همراه دلگرمی" },
tedi: { name: "امگا", symbol: "ω", role: "رفیق همیشگی تدریسینو ریاضی" },
}; };
/** slugِ کاراکترِ سرور (sigma/delta/pi) را به یکی از سه‌یار نگاشت می‌کند. */ /** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراه‌ها نگاشت می‌کند. */
export function seyarFromSlug(slug: string | null | undefined): SeyarChar | null { export function seyarFromSlug(slug: string | null | undefined): SeyarChar | null {
const s = (slug ?? '').toLowerCase(); const s = (slug ?? '').toLowerCase();
if (s === 'sigma' || s === 'delta' || s === 'pi') return s; if (s === 'sigma' || s === 'delta' || s === 'pi' || s === 'tedi') return s;
return null; return null;
} }

View File

@@ -1,68 +1,165 @@
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'; 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 { interface Nav {
route: Route; route: Route;
direction: NavDirection;
/** تبی که ریشه‌ی پشته‌ی فعلی است؛ برای روشن کردنِ نوارِ پایین. */ /** تبی که ریشه‌ی پشته‌ی فعلی است؛ برای روشن کردنِ نوارِ پایین. */
tab: TabName | null; tab: TabName | null;
canGoBack: boolean; canGoBack: boolean;
/** هر بار که کاربر تبِ همین‌حالا باز را دوباره می‌زند یکی زیاد می‌شود (برای «برو بالا»). */
reselect: number;
push: (route: Route) => void; push: (route: Route) => void;
/** صفحه‌ی فعلی را عوض می‌کند بدونِ اضافه کردن به تاریخچه. */ /** صفحه‌ی فعلی را عوض می‌کند بدونِ اضافه کردن به تاریخچه. */
replace: (route: Route) => void; replace: (route: Route) => void;
back: () => void; back: () => void;
/** از صفحه‌ی فعلی بیرون می‌رود حتی اگر برگه‌ای باز باشد (برگه هم بسته می‌شود)؛ `back` فقط خودِ برگه را می‌بندد. */
leave: () => void;
switchTab: (tab: TabName) => void; switchTab: (tab: TabName) => void;
} }
const NavContext = createContext<Nav | null>(null); const NavContext = createContext<Nav | null>(null);
export function NavigatorProvider({ initial, children }: { initial: Route; children: ReactNode }) { interface BackSteps {
const [stack, setStack] = useState<Route[]>([initial]); register: (onBack: () => void) => () => void;
}
const BackContext = createContext<BackSteps | null>(null);
useEffect(() => { /**
const onPop = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s)); * یک قدمِ برگشت‌پذیرِ داخلِ صفحه (برگه‌ی باز، فهرستی که از «انتخاب» باز شده): تا `active` است دکمه‌ی برگشتِ
window.addEventListener('popstate', onPop); * گوشی اول `onBack` را صدا می‌زند. بیرون از اپِ اصلی (صفحه‌ی ورود) کاری نمی‌کند.
return () => window.removeEventListener('popstate', onPop); */
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]);
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) => { const push = useCallback((route: Route) => {
setStack((s) => [...s, route]); setState((prev) => ({ stack: [...prev.stack, route], direction: 'push' }));
window.history.pushState({ tadrisino: true }, '');
}, []); }, []);
const replace = useCallback((route: Route) => { 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(() => { const back = useCallback(() => {
// popstate خودش صفحه را برمی‌دارد؛ پس دو منبعِ حقیقت نداریم. // دکمه‌ی برگشتِ داخلِ اپ همان راهِ دکمه‌ی گوشی را می‌رود تا history و پشته از هم جدا نشوند.
window.history.back(); 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) => { 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 value = useMemo<Nav>(() => {
const root = stack[0].name; const root = stack[0].name;
return { return {
route: stack[stack.length - 1], route: stack[stack.length - 1],
tab: ['home', 'map', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null, direction,
tab: ['home', 'lessons', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null,
canGoBack: stack.length > 1, canGoBack: stack.length > 1,
reselect,
push, push,
replace, replace,
back, back,
leave,
switchTab, 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 { export function useNav(): Nav {

View File

@@ -1,25 +1,29 @@
/** همه‌ی صفحه‌های اپ و پارامترهایشان. */ /** همه‌ی صفحه‌های اپ و پارامترهایشان. */
export type Route = export type Route =
| { name: 'home' } | { name: 'home' }
| { name: 'map' } | { name: 'lessons' }
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string } | { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
/** view: مستقیم به فهرستِ اشتراک‌ها یا بسته‌ها (مثلاً از فصلِ قفل)؛ بی‌آن انتخابِ دوگزینه‌ای. */
| { name: 'subscription' } | { name: 'subscription' }
| { name: 'profile' } | { name: 'profile' }
| { name: 'unit'; unitId: string } | { name: 'unit'; unitId: string }
| { name: 'lesson'; lessonId: number; nodeId?: string } | { name: 'lesson'; lessonId: number; nodeId?: string }
| { name: 'quiz'; lessonId: number; nodeId?: string } /** بدونِ lessonId یعنی آزمونِ کلِ جزیره (nodeId لازم است). */
| { name: 'quiz'; lessonId?: number; nodeId?: string }
| { name: 'notifications' } | { name: 'notifications' }
| { name: 'leaderboard' } | { name: 'leaderboard' }
| { name: 'characters' } | { name: 'characters' }
| { name: 'streak' } | { name: 'streak' }
| { name: 'support' } | { name: 'support' }
| { name: 'family' } /** «گزارشِ والدین»: والد پیشرفتِ هفته را در حسابِ خودِ دانش‌آموز می‌بیند. */
| { name: 'invite' } | { name: 'report' }
| { name: 'register' }; /** پرداخت‌ها و فاکتورهای خودِ کاربر. */
| { name: 'invoices' }
| { name: 'invite' };
export type TabName = 'home' | 'map' | 'ask' | 'subscription' | 'profile'; export type TabName = 'home' | 'lessons' | 'ask' | 'subscription' | 'profile';
export const TABS: TabName[] = ['home', 'map', 'ask', 'subscription', 'profile']; export const TABS: TabName[] = ['home', 'lessons', 'ask', 'subscription', 'profile'];
export function isTab(name: Route['name']): name is TabName { export function isTab(name: Route['name']): name is TabName {
return (TABS as string[]).includes(name); return (TABS as string[]).includes(name);

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

@@ -1,24 +1,42 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { BookOpen, MessageSquarePlus, RotateCw, Send, ThumbsDown, ThumbsUp } from 'lucide-react'; import { BookOpen, MessageSquarePlus, RotateCw, Send, ThumbsDown, ThumbsUp } from 'lucide-react';
import { api } from '@/api'; import { api, ApiError } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { askStore, useAskState, type ChatMessage } from '@/state/askStore'; import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
import { Screen, ScreenHeader } from '@/components/ui'; import { Screen, ScreenHeader } from '@/components/ui';
import { Diagram } from '@/diagram';
import { MathText } from '@/lib/MathText';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format'; 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 }) { 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 chat = useAskState();
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// «X سوالِ امروز»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
const [quota, setQuota] = useState<number | null>(null); const [quota, setQuota] = useState<number | null>(null);
const [quotaDone, setQuotaDone] = useState(false);
useEffect(() => {
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 listEnd = useRef<HTMLDivElement>(null);
const autoSent = useRef(false); const autoSent = useRef(false);
@@ -39,7 +57,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
askStore.setHistory( askStore.setHistory(
r.messages r.messages
.filter((m) => m.role === 'user' || m.role === 'assistant') .filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId })), .map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId, diagram: m.diagram ?? null })),
), ),
) )
.catch(() => askStore.reset(null)); .catch(() => askStore.reset(null));
@@ -73,6 +91,9 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
userQuestion: question, userQuestion: question,
conversationId: askStore.get().conversationId ?? undefined, conversationId: askStore.get().conversationId ?? undefined,
lessonId: askStore.get().lessonId ?? undefined, lessonId: askStore.get().lessonId ?? undefined,
// بی‌درس، کتابِ انتخابیِ دانش‌آموز (هندسه، گسسته، …) را می‌فرستیم تا دستیار از همان کتاب جواب بدهد؛
// با درس، سرور کتاب را از خودِ درس درمی‌آورد.
subject: askStore.get().lessonId ? undefined : subjectTitle,
questionId: forQuestionId, questionId: forQuestionId,
}, },
(delta) => { (delta) => {
@@ -85,14 +106,18 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
messageId: done.messageId, messageId: done.messageId,
usedChunks: done.usedChunks, usedChunks: done.usedChunks,
followUps: done.suggestedFollowUps, followUps: done.suggestedFollowUps,
diagram: done.diagram ?? null,
}); });
askStore.setConversation(done.conversationId); askStore.setConversation(done.conversationId);
setQuota(done.remainingQuota); setQuota(done.remainingQuota);
} catch (err) { } catch (err) {
// سقفِ روزانه (نه throttleِ چندثانیه‌ای): «دوباره» تا فردا بی‌فایده است.
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
askStore.update(replyId, { askStore.update(replyId, {
text: friendlyError(err, 'جوابی نرسید'), text: friendlyError(err, 'جوابی نرسید'),
failedQuestion: question, ...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
}); });
if (quotaReached) setQuota(0);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -113,27 +138,30 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
return ( return (
<Screen> <Screen>
<ScreenHeader <ScreenHeader
title="از پی بپرس" title={`از ${companion.name} بپرس`}
subtitle={chat.lessonId ? 'از روی کتابِ همین درس جواب می‌دم' : 'دستیارِ ریاضی، از روی کتابِ خودت'} subtitle={chat.lessonId ? 'از روی کتاب همین درس جواب می‌دم' : 'دستیار ریاضی، از روی کتاب خودت'}
color="#9B7BFF" color={companion.color}
back={lessonId !== undefined || prompt !== undefined} 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} />}
{!empty && ( {!empty && (
<button <button
type="button" type="button"
onClick={() => askStore.reset(null)} onClick={() => askStore.reset(null)}
disabled={busy} disabled={busy}
className="mt-3 flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold disabled:opacity-50" className="flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold disabled:opacity-50"
> >
<MessageSquarePlus size={14} /> گفتگوی تازه <MessageSquarePlus size={14} /> گفتگوی تازه
</button> </button>
)} )}
</div>
</ScreenHeader> </ScreenHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4"> <div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
<Bubble role="assistant"> <Bubble role="assistant">
سلام {profile.name || 'دوستِ من'}! من پی‌ام 🙂 هر سوالی از ریاضیِ کتابت داری بپرس؛ قدم‌به‌قدم با هم حلش می‌کنیم. سلام {profile.name || 'دوست من'}! {`من ${iAm(companion.name)}`} 🙂 هر سوالی از ریاضی کتابت داری بپرس؛ قدم‌به‌قدم با هم حلش می‌کنیم.
</Bubble> </Bubble>
{empty && chat.historyLoaded && ( {empty && chat.historyLoaded && (
@@ -170,6 +198,11 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
} }
}} }}
rows={1} rows={1}
// جهتِ هر خط از اولین حرفش: «-3 + 5» هنگامِ تایپ چپ‌به‌راست می‌ماند. خطی که با
// فارسی شروع شود راست‌به‌چپ است و ریاضیِ وسطش در کادرِ تایپ ممکن است جابه‌جا دیده شود؛
// در textarea نمی‌شود تکه‌ای را قفل کرد. پیامِ فرستاده‌شده از MathText رد می‌شود.
dir={input ? 'auto' : 'rtl'}
style={{ unicodeBidi: 'plaintext', textAlign: 'right' }}
placeholder="سوالت رو بنویس…" placeholder="سوالت رو بنویس…"
className="max-h-28 min-h-10 flex-1 resize-none rounded-3xl bg-soft px-4 py-2.5 text-sm leading-6 text-ink outline-none placeholder:text-faint" className="max-h-28 min-h-10 flex-1 resize-none rounded-3xl bg-soft px-4 py-2.5 text-sm leading-6 text-ink outline-none placeholder:text-faint"
/> />
@@ -182,19 +215,19 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
<Send size={16} className="-scale-x-100" /> <Send size={16} className="-scale-x-100" />
</button> </button>
</div> </div>
{quota !== null && <p className="mt-1.5 text-center text-[10px] text-muted">امروز {toFa(quota)} سوالِ دیگه می‌تونی بپرسی</p>}
</form> </form>
</Screen> </Screen>
); );
} }
function Bubble({ role, children }: { role: 'user' | 'assistant'; children: React.ReactNode }) { function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
const mine = role === 'user'; const mine = role === 'user';
const companion = useCompanion().char;
return ( return (
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}> <div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine && ( {!mine && (
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]"> <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> </span>
)} )}
<div <div
@@ -203,6 +236,44 @@ function Bubble({ role, children }: { role: 'user' | 'assistant'; children: Reac
}`} }`}
> >
{children} {children}
{extra}
</div>
</div>
);
}
/**
* منتظرِ جوابِ دستیار. سرویسِ مدل کلِ جواب را نگه می‌دارد و یک‌جا می‌فرستد؛ جوابِ
* کامل با شکل در ۲۰۲۶-۰۹-۲۹ حدودِ ۳۰ ثانیه طول کشید. پیامِ ثابت در آن مدت شبیهِ
* گیر کردن بود، پس متن با زمان جلو می‌رود. زمان‌ها تقریبی‌اند و به مرحله‌ی واقعیِ
* سرور وصل نیستند.
*/
const THINKING_STEPS: [number, string][] = [
[0, '{name} داره تو کتابت می‌گرده'],
[4, '{name} داره حلش می‌کنه'],
[15, 'داره قدم‌ها رو مرتب می‌نویسه'],
[28, 'جواب کامل یه کم طول می‌کشه، الان می‌رسه'],
];
function Thinking() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const started = Date.now();
const timer = setInterval(() => setSeconds((Date.now() - started) / 1000), 1000);
return () => clearInterval(timer);
}, []);
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={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}
{[0, 1, 2].map((i) => (
<motion.span key={i} className="h-1.5 w-1.5 rounded-full bg-violet/60" animate={{ y: [0, -4, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }} />
))}
</div> </div>
</div> </div>
); );
@@ -218,24 +289,11 @@ function Message({
onFollowUp: (q: string) => void; onFollowUp: (q: string) => void;
}) { }) {
const toast = useToast(); const toast = useToast();
const nav = useNav();
if (message.role === 'user') return <Bubble role="user">{message.text}</Bubble>; if (message.role === 'user') return <Bubble role="user"><MathText text={message.text} /></Bubble>;
if (!message.text) { if (!message.text) return <Thinking />;
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} />
</span>
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted">
پی داره فکر می‌کنه
{[0, 1, 2].map((i) => (
<motion.span key={i} className="h-1.5 w-1.5 rounded-full bg-violet/60" animate={{ y: [0, -4, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }} />
))}
</div>
</div>
);
}
async function rate(helpful: boolean) { async function rate(helpful: boolean) {
if (!message.messageId || message.feedback) return; if (!message.messageId || message.feedback) return;
@@ -251,9 +309,15 @@ function Message({
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Bubble role="assistant">{message.text}</Bubble> <Bubble role="assistant" extra={message.diagram ? <div className="whitespace-normal"><Diagram spec={message.diagram} /></div> : undefined}>
<MathText text={message.text} />
</Bubble>
<div className="flex flex-wrap items-center gap-2 pr-10"> <div className="flex flex-wrap items-center gap-2 pr-10">
{message.failedQuestion ? ( {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"> <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">
<RotateCw size={12} /> دوباره امتحان کن <RotateCw size={12} /> دوباره امتحان کن
</button> </button>
@@ -289,3 +353,23 @@ function Message({
</div> </div>
); );
} }
/**
* شمارنده در سربرگِ دستیار: سوال‌های باقیِ امروز. وقتی تمام شد، دکمه‌ی دوم به فروشگاهِ اشتراک می‌برد
* (سوال جدا فروخته نمی‌شود؛ مشترک فردا دوباره سهم دارد).
*/
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)} سوال امروز</> : 'سوال‌های امروز تموم شد'}
</span>
{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,9 +4,10 @@ import { api, type Character } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync'; import { useAsync } from '@/lib/useAsync';
import { SeyarMascot } from '@/mascots/SeyarMascot'; 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 { useToast } from '@/components/Toast';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui'; import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader, Sep } from '@/components/ui';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
@@ -17,7 +18,7 @@ import { toFa } from '@/lib/format';
export function CharactersScreen() { export function CharactersScreen() {
const { summary, setCompanion } = useAppData(); const { summary, setCompanion } = useAppData();
const toast = useToast(); const toast = useToast();
const list = useAsync(() => api.characters.list(), []); const list = useAsync(() => api.characters.list(), [], { key: 'characters' });
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
async function select(c: Character) { async function select(c: Character) {
@@ -39,7 +40,7 @@ export function CharactersScreen() {
<Screen> <Screen>
<ScreenHeader <ScreenHeader
title="همراهت رو انتخاب کن" title="همراهت رو انتخاب کن"
subtitle={`امتیازِ تو: ${toFa(summary?.xp ?? 0)} · با امتیازِ بیشتر همراه‌های بیشتری باز می‌شن`} subtitle={<>امتیاز تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیاز بیشتر همراه‌های بیشتری باز می‌شن</>}
color="#9B7BFF" color="#9B7BFF"
back back
/> />
@@ -54,6 +55,7 @@ export function CharactersScreen() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{list.data.map((c) => { {list.data.map((c) => {
const seyar = seyarFromSlug(c.slug); const seyar = seyarFromSlug(c.slug);
const look = seyar ? COMPANIONS[seyar] : null;
return ( return (
<button <button
key={c.characterId} key={c.characterId}
@@ -62,11 +64,12 @@ export function CharactersScreen() {
onClick={() => void select(c)} onClick={() => void select(c)}
aria-pressed={c.selected} aria-pressed={c.selected}
className={`relative flex flex-col items-center gap-2 rounded-3xl border-2 p-4 transition-transform ${ 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 && ( {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} /> <Check size={11} className="text-white" strokeWidth={3} />
</span> </span>
)} )}
@@ -84,15 +87,14 @@ export function CharactersScreen() {
<Lock size={16} className="text-[#5a5580]" /> <Lock size={16} className="text-[#5a5580]" />
</span> </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"> <span className="text-center text-[10px] leading-5 text-muted">
{!c.unlocked {!c.unlocked ? `با ${toFa(c.unlockXp)} امتیاز باز می‌شه` : c.selected ? 'همراه فعلی' : 'بزن تا همراهت بشه'}
? `با ${toFa(c.unlockXp)} امتیاز باز می‌شه`
: c.selected
? 'همراهِ فعلی'
: seyar
? SEYAR_INFO[seyar].role
: 'بازشده'}
</span> </span>
</button> </button>
); );

View File

@@ -1,168 +0,0 @@
import { useState } from 'react';
import { BookOpen, Flame, Link2, Star, Trophy, UserPlus } from 'lucide-react';
import { api, ApiError, type FamilyLink } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { InlineError, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
/**
* بخشِ والدین. والد با شماره‌ی فرزند دعوت می‌سازد و یک کد می‌گیرد؛ فرزند
* (با همان شماره وارد شده) کد را اینجا وارد می‌کند. بعد از اتصال، والد
* گزارشِ هفتگی را می‌بیند.
*/
export function FamilyScreen() {
const link = useAsync(
() => api.family.link().catch((err) => {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}),
[],
);
return (
<Screen>
<ScreenHeader title="بخشِ والدین" subtitle="پیشرفتِ فرزندت رو هر هفته ببین" color="#FF7A3D" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
{link.loading ? (
<LoadingState />
) : (
<>
{link.data && <LinkStatus link={link.data} />}
{link.data?.status === 'accepted' && <WeeklyReport />}
<InviteChild onDone={link.reload} />
<AcceptInvite onDone={link.reload} />
</>
)}
</ScreenBody>
</Screen>
);
}
function LinkStatus({ link }: { link: FamilyLink }) {
const accepted = link.status === 'accepted';
return (
<div className={`flex items-center gap-3 rounded-3xl p-4 ${accepted ? 'bg-[#EAF8F0]' : 'bg-[#FFF8E1]'}`}>
<Link2 size={20} className={accepted ? 'text-mint' : 'text-gold'} />
<p className="text-sm leading-7 text-ink">
{accepted ? <>به <b>{link.childName || 'فرزندت'}</b> وصلی.</> : 'دعوت فرستاده شده و منتظرِ تأییدِ فرزندته.'}
</p>
</div>
);
}
function WeeklyReport() {
const report = useAsync(() => api.family.weeklyReport(), []);
if (report.loading) return <div className="h-32 animate-pulse rounded-3xl bg-soft" />;
if (!report.data) return null;
const r = report.data;
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-3 text-sm font-black text-ink">گزارشِ این هفته <span className="text-xs font-medium text-muted">(از {formatJalaliDate(r.weekStart)})</span></h2>
<div className="grid grid-cols-2 gap-2">
{[
{ icon: Star, label: 'امتیاز', value: r.xpEarned },
{ icon: BookOpen, label: 'درسِ تمام‌شده', value: r.lessonsCompleted },
{ icon: Trophy, label: 'جزیره‌ی تمام‌شده', value: r.islandsCompleted },
{ icon: Flame, label: 'زنجیره (روز)', value: r.currentStreak },
].map(({ icon: Icon, label, value }) => (
<div key={label} className="flex items-center gap-2 rounded-2xl bg-soft px-3 py-2.5">
<Icon size={16} className="text-orange" />
<span className="flex-1 text-xs text-body">{label}</span>
<span className="text-sm font-black text-ink">{toFa(value)}</span>
</div>
))}
</div>
</section>
);
}
function InviteChild({ onDone }: { onDone: () => void }) {
const [phone, setPhone] = useState('');
const [code, setCode] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function invite() {
setBusy(true);
setError('');
try {
const r = await api.family.invite(phone);
setCode(r.inviteCode);
onDone();
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
}
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-1 flex items-center gap-2 text-sm font-black text-ink"><UserPlus size={16} className="text-orange" /> من والدم</h2>
<p className="mb-3 text-xs leading-6 text-muted">شماره‌ای که فرزندت باهاش وارد تدریسینو شده رو بنویس.</p>
{code ? (
<div className="rounded-2xl bg-soft p-4 text-center">
<p className="text-xs text-muted">این کد رو به فرزندت بده تا تو بخشِ والدینِ حسابِ خودش وارد کنه:</p>
<p dir="ltr" className="mt-2 text-2xl font-black tracking-widest text-ink">{code}</p>
</div>
) : (
<>
<input
value={phone}
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))}
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
dir="ltr"
inputMode="numeric"
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-left outline-none focus:border-orange"
/>
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<PrimaryButton color="#FF7A3D" loading={busy} disabled={!/^09\d{9}$/.test(phone)} onClick={() => void invite()}>
ساختِ کدِ دعوت
</PrimaryButton>
</>
)}
</section>
);
}
function AcceptInvite({ onDone }: { onDone: () => void }) {
const toast = useToast();
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function accept() {
setBusy(true);
setError('');
try {
await api.family.accept(code.trim());
toast('به والدت وصل شدی', 'success');
setCode('');
onDone();
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
}
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-1 text-sm font-black text-ink">من دانش‌آموزم</h2>
<p className="mb-3 text-xs leading-6 text-muted">اگه پدر یا مادرت کدِ دعوت بهت داده، اینجا واردش کن.</p>
<input
value={code}
onChange={(e) => setCode(e.target.value.slice(0, 32))}
placeholder="کدِ دعوت"
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-orange"
/>
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<PrimaryButton color="#FF7A3D" loading={busy} disabled={!code.trim()} onClick={() => void accept()}>
وصل شو
</PrimaryButton>
</section>
);
}

View File

@@ -1,180 +1,154 @@
import { Bell, Brain, ChevronLeft, Coins, Flame, Map, Play, Star, Trophy } from 'lucide-react'; import { useEffect, useRef, useState } from 'react';
import { Bell, ChevronLeft, Coins, Crown, Flame, RotateCw, Star } from 'lucide-react';
import { ApiError, type HomeNode } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { findContinue, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum'; import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { IslandPath } from '@/components/IslandPath';
import { Card, ErrorState, ProgressBar, Screen } from '@/components/ui'; import { IslandSheet } from '@/components/IslandSheet';
import { iconFor } from '@/components/NodeIcon'; import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
import { onWhite, shade } from '@/lib/color';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
import { BrandFooter } from '@/components/BrandFooter';
import { BrandMark } from '@/components/BrandMark';
import { StreakFlame, flameState } from '@/components/StreakFlame';
import { SubjectBar } from '@/components/SubjectBar';
/**
* خانه = خودِ مسیر، مثلِ دولینگو: همه‌ی فصل‌های پایه پشتِ هم، و جزیره‌ی فعلی با
* یک دکمه‌ی «شروع» روی سرش. قبلاً خانه داشبوردی از شش کارت بود (ادامه، نقشه،
* دستیار، فصل‌ها، چالش) و بچه نمی‌دانست کدام را بزند؛ حالا یک کارِ واضح دارد.
*/
export function HomeScreen() { 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 nav = useNav();
// فصلِ غیرِ اول بی‌اشتراک: زدنِ جزیره یک‌راست فهرستِ اشتراک‌ها را باز می‌کند.
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);
const cont = findContinue(home); const cont = findContinue(home);
const contLesson = cont ? nextLessonOf(cont.node) : null;
const contColor = cont ? unitColor(cont.unitIndex) : '#ED6956';
const contProgress = cont ? unitProgress(cont.unit) : null;
const openLesson = (quiz = false) => { // بارِ اول تا جزیره‌ی فعلی اسکرول کن، نه هر بار که نقشه تازه می‌شود.
if (!cont || contLesson == null) return; useEffect(() => {
nav.push({ name: quiz ? 'quiz' : 'lesson', lessonId: contLesson, nodeId: cont.node.id }); if (!home || scrolled.current) return;
}; scrolled.current = true;
const frame = requestAnimationFrame(() => currentRef.current?.scrollIntoView({ block: 'center' }));
return () => cancelAnimationFrame(frame);
}, [home]);
const flame = flameState(summary?.streak);
const streak = summary?.streak;
const stats = [ const stats = [
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', fg: '#FF7A3D', bg: '#FFF1E6', 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: 'امتیاز', fg: '#E8A400', bg: '#FFF8E1', fill: false, to: 'leaderboard' as const }, { icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', color: '#E8A400', fill: true, to: 'leaderboard' as const },
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', fg: '#9B7BFF', bg: '#F5F1FF', fill: false, to: 'characters' as const }, { icon: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const },
]; ];
return ( return (
<Screen> <Screen>
<div className="flex-1 overflow-y-auto"> <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">
<header className="relative overflow-hidden rounded-b-[32px] bg-coral px-5 pb-11 pt-safe text-white"> <div className="flex items-center gap-2 pt-4">
<div className="pointer-events-none absolute -left-10 -top-10 h-48 w-48 rounded-full bg-white/5" /> <BrandMark size={26} withName={false} />
<div className="relative flex items-center justify-between pt-8">
<div className="min-w-0">
<p className="text-sm text-white/75">سلام {profile.name || 'دوستِ من'}!</p>
<h1 className="mt-0.5 text-xl font-black leading-snug">آماده‌ای ریاضی رو ادامه بدیم؟</h1>
{gradeTitle && ( {gradeTitle && (
<span className="mt-1.5 inline-block rounded-full bg-white/15 px-2.5 py-0.5 text-xs text-white/85">{gradeTitle}</span> <button type="button" onClick={() => nav.switchTab('profile')} className="hit rounded-full bg-white/20 px-3 py-1.5 text-xs font-black active:scale-95">
)} {gradeTitle}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<button type="button" aria-label="جدولِ امتیازها" onClick={() => nav.push({ name: 'leaderboard' })} className="flex h-10 w-10 items-center justify-center rounded-full bg-white/15">
<Trophy size={18} />
</button> </button>
<button type="button" aria-label="اعلان‌ها" onClick={() => nav.push({ name: 'notifications' })} className="relative flex h-10 w-10 items-center justify-center rounded-full bg-white/15"> )}
<Bell size={18} /> <div className="mr-auto flex items-center gap-1.5">
{stats.map(({ icon: Icon, value, label, color, fill, to }) => (
<button
key={label}
type="button"
aria-label={`${label}: ${toFa(value)}`}
onClick={() => nav.push({ name: to })}
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'}`}
>
{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">
<Bell size={16} />
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="absolute -left-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#FFD23F] px-1 text-[9px] font-black text-ink"> <span className="absolute -left-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#FFD23F] px-1 text-[9px] font-black text-ink">
{unreadCount > 9 ? '۹+' : toFa(unreadCount)} {unreadCount > 9 ? '۹+' : toFa(unreadCount)}
</span> </span>
)} )}
</button> </button>
</div> </div>
</div> </div>
</header> </header>
<div className="relative z-10 -mt-7 px-4"> <SubjectBar />
<div className="flex rounded-3xl border border-line bg-white shadow-lg shadow-black/10">
{stats.map(({ icon: Icon, value, label, fg, bg, fill, to }) => (
<button key={label} type="button" onClick={() => nav.push({ name: to })} className="flex flex-1 flex-col items-center py-3.5">
<span className="mb-1.5 flex h-10 w-10 items-center justify-center rounded-full" style={{ backgroundColor: bg }}>
<Icon size={18} style={{ color: fg }} strokeWidth={2.4} fill={fill ? fg : 'none'} />
</span>
<span className="text-lg font-black leading-none text-ink">{toFa(value)}</span>
<span className="mt-1 text-[10px] text-muted">{label}</span>
</button>
))}
</div>
</div>
<div className="flex flex-col gap-4 px-4 pb-6 pt-4"> <div className="flex-1 overflow-y-auto">
{homeError && !home ? ( {!home ? (
<ErrorState error={homeError} onRetry={() => void refreshHome()} /> homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />
) : cont && contProgress ? ( ) : home.units.length === 0 ? (
<Card className="shadow-md shadow-coral/10"> <EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
<div className="mb-3 flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted">ادامه از جایی که موندی</p>
<h3 className="mt-0.5 text-base font-black text-ink">{cont.node.title}</h3>
<p className="mt-0.5 truncate text-xs text-muted">
{cont.unit.title} · {toFa(contProgress.done)} از {toFa(contProgress.total)} جزیره
</p>
</div>
<SeyarMascot char={companion} mood="happy" size={62} className="mr-3" />
</div>
<div className="mb-3">
<ProgressBar percent={contProgress.percent} color={contColor} />
</div>
<button
type="button"
onClick={() => openLesson()}
className="flex w-full items-center justify-center gap-2 rounded-2xl py-3 text-sm font-bold text-white active:scale-[0.98]"
style={{ backgroundColor: contColor }}
>
<Play size={15} fill="white" /> ادامه بده
</button>
</Card>
) : ( ) : (
<Card className="flex items-center gap-3 p-5"> <div className="flex flex-col gap-2 px-4 pb-10 pt-4">
<SeyarMascot char={companion} mood={home ? 'excited' : 'idle'} size={56} /> {homeError != null && (
<p className="text-sm font-medium text-muted"> // نقشه هست ولی تازه نشد (معمولاً عکسِ ذخیره‌شده روی گوشی و اینترنتِ قطع): بچه باید بداند
{!home ? 'دارم مسیرت رو میارم…' : home.units.length ? 'همه‌ی جزیره‌های این پایه رو تموم کردی! 🎉' : 'هنوز درسی برای این پایه ثبت نشده'} // عددها و قفل‌ها ممکن است مالِ دفعه‌ی قبل باشند.
</p> <div role="status" className="flex items-center gap-2 rounded-xl bg-soft px-3 py-2 text-xs leading-6 text-body">
</Card> <span className="flex-1">
)} {homeError instanceof ApiError && homeError.isNetwork ? 'اینترنت وصل نیست یا کنده' : 'اطلاعات تازه نشد'}؛
آخرین اطلاعات ذخیره‌شده رو می‌بینی.
<div className="grid grid-cols-2 gap-3">
<Card onClick={() => nav.switchTab('map')} className="p-4">
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-[#EAF2F6]">
<Map size={20} className="text-steel" />
</span> </span>
<div className="text-sm font-bold text-ink">نقشه‌ی فصل‌ها</div>
<div className="mt-0.5 text-[11px] text-muted">ببین کجای مسیری</div>
</Card>
<button <button
type="button" type="button"
onClick={() => nav.switchTab('ask')} onClick={() => void Promise.all([refreshHome(), refreshSummary()])}
className="rounded-3xl bg-violet p-4 text-right shadow-md shadow-violet/30 active:scale-[0.98]" className="flex shrink-0 items-center gap-1 rounded-full bg-white px-3 py-1 font-bold text-coral ring-1 ring-line"
> >
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-white/15"> <RotateCw size={12} /> دوباره
<Brain size={20} className="text-white" />
</span>
<div className="text-sm font-bold text-white">از پی بپرس</div>
<div className="mt-0.5 text-[11px] text-white/75">هر جا گیر کردی</div>
</button> </button>
</div> </div>
)}
{home && home.units.length > 0 && (
<section>
<div className="mb-3 flex items-baseline justify-between">
<h2 className="text-sm font-black text-ink">فصل‌های {gradeTitle || 'این پایه'}</h2>
<span className="text-[11px] text-muted">{toFa(home.units.length)} فصل</span>
</div>
<div className="no-scrollbar -mx-4 flex gap-3 overflow-x-auto px-4 pb-1">
{home.units.map((unit, i) => { {home.units.map((unit, i) => {
const p = unitProgress(unit);
const color = unitColor(i); const color = unitColor(i);
const Icon = iconFor(unit.nodes[0]?.icon, i); const p = unitProgress(unit);
const isCurrentUnit = cont?.unit.id === unit.id;
return ( return (
<section key={unit.id} aria-label={unit.title}>
<button <button
key={unit.id}
type="button" type="button"
onClick={() => nav.push({ name: 'unit', unitId: unit.id })} onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
className="w-[140px] flex-shrink-0 rounded-2xl border border-line bg-white p-3.5 text-right shadow-sm active:scale-95" className="sticky top-2 z-10 flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-right text-white active:translate-y-0.5"
style={{ backgroundColor: onWhite(color), boxShadow: `0 4px 0 ${shade(onWhite(color), -0.25)}` }}
> >
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl" style={{ backgroundColor: `${color}1f` }}>
<Icon size={19} style={{ color }} />
</span>
<div className="line-clamp-2 min-h-[2.5em] text-[12.5px] font-bold leading-snug text-ink">{unit.title}</div>
<div className="mt-1 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
<div className="mt-2">
<ProgressBar percent={p.percent} color={color} track="#f0eeff" height={6} />
</div>
</button>
);
})}
</div>
</section>
)}
{cont && contLesson != null && (
<button
type="button"
onClick={() => openLesson(true)}
className="flex items-center gap-3 rounded-3xl bg-orange p-4 text-right shadow-md shadow-orange/25 active:scale-[0.98]"
>
<SeyarMascot char="delta" mood="excited" size={64} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-black text-white">یه سؤال دیگه… فقط یکی!</div> <div className="text-[11px] font-bold text-white/80">
<div className="mt-0.5 truncate text-[11px] text-white/80">چالشِ سریع با دلتا · {cont.node.title}</div> فصل {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>
<div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div>
</div> </div>
<ChevronLeft size={18} className="flex-shrink-0 text-white/80" /> <ChevronLeft size={18} className="flex-shrink-0 text-white/80" />
</button> </button>
<div className="pt-12">
<IslandPath
ref={isCurrentUnit ? currentRef : undefined}
nodes={unit.nodes}
color={color}
currentId={isCurrentUnit ? cont!.node.id : null}
onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode({ node, color }))}
onStart={(node) => (unit.requiresSubscription ? paywall() : nav.push({ name: 'quiz', nodeId: node.id }))}
/>
</div>
</section>
);
})}
<BrandFooter />
</div>
)} )}
</div> </div>
</div>
<IslandSheet node={openNode?.node ?? null} color={openNode?.color ?? '#ED6956'} onClose={() => setOpenNode(null)} />
</Screen> </Screen>
); );
} }

View File

@@ -12,14 +12,14 @@ export function inviteLink(code: string): string {
export function InviteScreen() { export function InviteScreen() {
const toast = useToast(); const toast = useToast();
const me = useAsync(() => api.affiliate.me(), []); const me = useAsync(() => api.affiliate.me(), [], { key: 'affiliate' });
async function share(code: string) { async function share(code: string) {
const url = inviteLink(code); const url = inviteLink(code);
const text = 'با تدریسینو ریاضی رو قدم‌به‌قدم یاد بگیر:'; const text = 'با تدریسینو ریاضی رو قدم‌به‌قدم یاد بگیر:';
if (navigator.share) { if (navigator.share) {
try { try {
await navigator.share({ title: 'تدریسینو', text, url }); await navigator.share({ title: 'تدریسینو ریاضی', text, url });
return; return;
} catch { } catch {
/* کاربر لغو کرد؛ می‌رویم سراغِ کپی */ /* کاربر لغو کرد؛ می‌رویم سراغِ کپی */
@@ -35,7 +35,7 @@ export function InviteScreen() {
return ( return (
<Screen> <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"> <ScreenBody className="flex flex-col gap-4">
{me.loading ? ( {me.loading ? (
<LoadingState /> <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"> <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" /> <Gift size={28} className="text-mint" />
<span className="text-xs text-muted">کدِ معرفیِ تو</span> <span className="text-xs text-muted">کد معرفی تو</span>
<button <button
type="button" type="button"
onClick={() => void navigator.clipboard?.writeText(me.data!.referralCode).then(() => toast('کد کپی شد', 'success'))} 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"> <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" /> <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> </p>
<PrimaryButton color="#5FBF8F" onClick={() => void share(me.data!.referralCode)}> <PrimaryButton color="#5FBF8F" onClick={() => void share(me.data!.referralCode)}>
<Share2 size={17} /> فرستادنِ لینکِ دعوت <Share2 size={17} /> فرستادن لینک دعوت
</PrimaryButton> </PrimaryButton>
</> </>
)} )}

View File

@@ -0,0 +1,142 @@
import { useState } from 'react';
import { ChevronLeft, Copy, Receipt } from 'lucide-react';
import { api, type Invoice } from '@/api';
import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { Sheet } from '@/components/Sheet';
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader, Sep } from '@/components/ui';
import { formatJalaliDate, formatToman } from '@/lib/format';
/**
* «پرداخت‌ها و فاکتورها» (۱۴۰۵-۰۷): همه‌ی سفارش‌های کاربر، با فاکتورِ هر خرید.
* سفارشِ «در انتظار» و «ناموفق» هم نشان داده می‌شود تا کسی که پولش کم شده ولی
* اشتراکش فعال نشده، شماره‌ی فاکتور را به پشتیبانی بدهد. عددها از GET /payment/invoices.
*/
const STATUS: Record<Invoice['status'], { label: string; className: string }> = {
paid: { label: 'پرداخت‌شده', className: 'bg-[#EAF8F0] text-[#2E7D5B]' },
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 dateOf = (invoice: Invoice) => formatJalaliDate(invoice.paidAt ?? invoice.createdAt);
export function InvoicesScreen() {
const nav = useNav();
const list = useAsync(() => api.subscription.invoices(), [], { key: 'invoices' });
const [open, setOpen] = useState<Invoice | null>(null);
return (
<Screen>
<ScreenHeader title="پرداخت‌ها و فاکتورها" subtitle="همه خریدهات، با شماره فاکتور و کد پیگیری" color="#4C5FD5" back />
<ScreenBody className="flex flex-col gap-3">
{list.loading ? (
<LoadingState />
) : list.error || !list.data ? (
<ErrorState error={list.error} onRetry={list.reload} />
) : list.data.length === 0 ? (
<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>
</div>
) : (
list.data.map((invoice) => (
<button
key={invoice.orderId}
type="button"
onClick={() => setOpen(invoice)}
className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4 text-right active:scale-[0.99]"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-black text-ink">{invoice.planTitle || 'اشتراک'}</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold ${STATUS[invoice.status].className}`}>
{STATUS[invoice.status].label}
</span>
</div>
<div className="mt-1 text-xs text-muted">
{dateOf(invoice)}<Sep /><span dir="ltr">{invoice.invoiceNumber}</span>
</div>
</div>
<span className="text-sm font-black text-ink">{formatToman(invoice.amount)}</span>
<ChevronLeft size={16} className="text-faint" />
</button>
))
)}
</ScreenBody>
<InvoiceSheet invoice={open} onClose={() => setOpen(null)} />
</Screen>
);
}
function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose: () => void }) {
const toast = useToast();
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text);
toast('کپی شد', 'success');
} catch {
toast(text, 'success');
}
}
const rows: [string, React.ReactNode][] = invoice
? [
['شماره فاکتور', <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})` : 'تخفیف',
formatToman(invoice.discountAmount),
] as [string, React.ReactNode]] : []),
['مبلغ پرداختی', <b>{formatToman(invoice.amount)}</b>],
['روش پرداخت', PROVIDER[invoice.provider] ?? invoice.provider],
]
: [];
return (
<Sheet open={invoice !== null} onClose={onClose} title="فاکتور">
{invoice && (
<div className="flex flex-col gap-4">
<table className="w-full text-sm">
<tbody>
{rows.map(([label, value]) => (
<tr key={label} className="border-b border-line last:border-0">
<td className="py-2.5 text-muted">{label}</td>
<td className="py-2.5 text-left text-ink">{value}</td>
</tr>
))}
</tbody>
</table>
{invoice.refId ? (
<button
type="button"
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="flex items-center gap-2 font-bold text-ink" dir="ltr">
{/* کد همان‌طور که کپی می‌شود: با رقمِ فارسی صفرها نقطه دیده می‌شدند. */}
{invoice.refId} <Copy size={14} />
</span>
</button>
) : (
(invoice.status === 'pending' || invoice.status === 'failed') && (
<p className="text-xs leading-6 text-muted">
اگه پولی از حسابت کم شده ولی این خرید «پرداخت‌شده» نیست، شماره فاکتور رو به پشتیبانی بده.
</p>
)
)}
</div>
)}
</Sheet>
);
}

View File

@@ -15,16 +15,16 @@ const MEDAL: Record<number, { bg: string; fg: string; h: number }> = {
export function LeaderboardScreen() { export function LeaderboardScreen() {
const { profile } = useAppData(); const { profile } = useAppData();
const [period, setPeriod] = useState<LeaderboardPeriod>('weekly'); 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 entries = board.data ?? [];
const podium = [entries[1], entries[0], entries[2]].filter(Boolean); const podium = [entries[1], entries[0], entries[2]].filter(Boolean);
const isMe = (userId: string) => userId === profile.userId; const isMe = (userId: string) => userId === profile.userId;
return ( return (
<Screen> <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"> <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 <button
key={p} key={p}
type="button" type="button"
@@ -41,7 +41,7 @@ export function LeaderboardScreen() {
<div className="flex-1 overflow-y-auto px-4 py-4"> <div className="flex-1 overflow-y-auto px-4 py-4">
{board.loading ? ( {board.loading ? (
<LoadingState char="delta" text="دارم جدول رو میارم…" /> <LoadingState text="دارم جدول رو میارم…" />
) : board.error ? ( ) : board.error ? (
<ErrorState error={board.error} onRetry={board.reload} /> <ErrorState error={board.error} onRetry={board.reload} />
) : entries.length === 0 ? ( ) : entries.length === 0 ? (

View File

@@ -1,3 +1,4 @@
import { useCompanion } from '@/state/companion';
import { Brain, Play, Zap } from 'lucide-react'; import { Brain, Play, Zap } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
@@ -6,22 +7,31 @@ import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync'; import { useAsync } from '@/lib/useAsync';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import { moodFromServer } from '@/mascots/seyar'; import { moodFromServer } from '@/mascots/seyar';
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui'; import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader, Sep } from '@/components/ui';
import { toFa } from '@/lib/format'; 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 }) { export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
const { home, companion } = useAppData(); const { home, companion } = useAppData();
const companionName = useCompanion().name;
const nav = useNav(); 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 ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
const color = ref ? unitColor(ref.unitIndex) : '#ED6956'; const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
const data = intro.data; const data = intro.data;
const figs = (where: 'intro' | 'example' | 'step', index?: number) =>
(data?.figures ?? [])
.filter((f) => f.where === where && (where !== 'step' || f.index === index))
.map((f, k) => <Diagram key={k} spec={f.diagram} />);
return ( return (
<Screen> <Screen>
<ScreenHeader <ScreenHeader
title={data?.title ?? '…'} title={data ? <MathText text={data.title} /> : '…'}
color={color} color={color}
back back
mascot={{ char: companion, mood: moodFromServer(data?.mascotAnimation), size: 72 }} mascot={{ char: companion, mood: moodFromServer(data?.mascotAnimation), size: 72 }}
@@ -29,7 +39,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
ref && ( ref && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1"> <span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1">
{/* ترتیبِ lessonIds «درسِ بعدی اول» است، نه ترتیبِ کتاب؛ پس شماره‌ی درس از آن درنمی‌آید. */} {/* ترتیبِ lessonIds «درسِ بعدی اول» است، نه ترتیبِ کتاب؛ پس شماره‌ی درس از آن درنمی‌آید. */}
<Zap size={11} fill="white" /> {ref.node.title} · {toFa(ref.node.lessonIds.length)} درس <Zap size={11} fill="white" /> <MathText text={ref.node.title} /><Sep />{toFa(ref.node.lessonIds.length)} درس
</span> </span>
) )
} }
@@ -43,9 +53,10 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
) : ( ) : (
<> <>
<div className="flex items-start gap-3"> <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"> <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">{data.introText}</p> <StepText text={data.introText} color={color} className="text-[15px] leading-[1.95] text-body" />
{figs('intro')}
</div> </div>
</div> </div>
@@ -56,7 +67,8 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
{data.example && ( {data.example && (
<section className="rounded-3xl border border-line bg-white p-4"> <section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-2 text-xs font-black text-muted">مثال</h2> <h2 className="mb-2 text-xs font-black text-muted">مثال</h2>
<p className="whitespace-pre-line text-sm leading-[1.95] text-ink">{data.example}</p> <StepText text={data.example} color={color} className="text-[15px] leading-[1.95] text-ink" />
{figs('example')}
</section> </section>
)} )}
@@ -69,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 }}> <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)} {toFa(i + 1)}
</span> </span>
<span className="flex-1 whitespace-pre-line">{step}</span> <span className="flex-1 whitespace-pre-line"><MathText text={step} display />{figs('step', i)}</span>
</li> </li>
))} ))}
</ol> </ol>
@@ -77,13 +89,14 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
)} )}
<div className="mt-auto flex flex-col gap-3 pt-2"> <div className="mt-auto flex flex-col gap-3 pt-2">
<FeedbackBar kind="lesson" id={String(lessonId)} />
{data.questionCount === 0 ? ( {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 className="rounded-2xl border-2 border-dashed border-[#dedced] bg-white py-4 text-center text-sm font-bold text-muted">
تمرینِ این درس به‌زودی اضافه می‌شه تمرین این درس به‌زودی اضافه می‌شه
</div> </div>
) : ( ) : (
<PrimaryButton color={color} onClick={() => nav.replace({ name: 'quiz', lessonId, nodeId: ref?.node.id ?? nodeId })}> <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> </PrimaryButton>
)} )}
<button <button
@@ -91,7 +104,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
onClick={() => nav.push({ name: 'ask', lessonId })} 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]" 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> </button>
</div> </div>
</> </>

View File

@@ -0,0 +1,256 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import { Brain, Check, ChevronDown, Crown, Lock, PartyPopper, Play, Sparkles } from 'lucide-react';
import { api, type HomeNode, type HomeUnit } from '@/api';
import { useAppData } from '@/state/AppData';
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
import { onWhite } from '@/lib/color';
import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync';
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui';
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';
/**
* تبِ «درس‌ها»: فصل ← جزیره ← درس. فصل‌ها اول بسته‌اند و بالای صفحه یک کارتِ «ادامه بده» هست.
* بازبودنِ فصل/جزیره در متغیرِ ماژول نگه داشته می‌شود، چون با رفتن به درس و برگشتن صفحه از نو ساخته
* می‌شود (کلیدِ مسیر در MainApp) و کاربر جایش را گم می‌کرد. با بستنِ اپ پاک می‌شود، عمداً.
*/
const remembered: { unit: string | null; node: string | null } = { unit: null, node: null };
export function LessonsScreen() {
const { home, homeError, refreshHome, gradeTitle } = useAppData();
const nav = useNav();
const units = home?.units ?? [];
const current = findContinue(home);
const [openUnit, setOpenUnitState] = useState<string | null>(remembered.unit);
const [openNode, setOpenNodeState] = useState<string | null>(remembered.node);
const setOpenUnit = (id: string | null) => { remembered.unit = id; setOpenUnitState(id); };
const setOpenNode = (id: string | null) => { remembered.node = id; setOpenNodeState(id); };
const totalNodes = units.reduce((n, u) => n + u.nodes.length, 0);
const doneNodes = units.reduce((n, u) => n + unitProgress(u).done, 0);
return (
<Screen className="bg-page">
<ScreenHeader
title="درس‌ها"
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمون جزیره رو بده</>}
color="#2FB36B"
exact
mascot={{ mood: 'happy' }}
>
{home && totalNodes > 0 && (
<div className="mt-4">
<div className="mb-1.5 flex justify-between text-[11px] text-white/85">
<span>جزیره‌هایی که تموم کردی</span>
<span>{toFa(doneNodes)} از {toFa(totalNodes)}</span>
</div>
<ProgressBar percent={Math.round((doneNodes / totalNodes) * 100)} color="#fff" track="rgba(255,255,255,.25)" height={10} />
</div>
)}
</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="دارم درس‌ها رو میارم…" />
) : units.length === 0 ? (
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
) : (
<div className="flex flex-col gap-3">
{current && current.node.nextLessonId != null && (
<button
type="button"
onClick={() => nav.push({ name: 'lesson', lessonId: current.node.nextLessonId as number, nodeId: current.node.id })}
className="flex items-center gap-3 rounded-2xl px-4 py-3.5 text-right text-white"
style={{ backgroundColor: onWhite(unitColor(current.unitIndex)), boxShadow: `0 4px 0 ${onWhite(unitColor(current.unitIndex))}99` }}
>
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-white/25">
<Play size={20} fill="white" />
</span>
<span className="flex-1">
<span className="block text-[11px] font-bold text-white/85">ادامه بده از همین‌جا</span>
<span className="block text-sm font-black leading-6"><MathText text={current.node.title} /></span>
</span>
</button>
)}
{units.map((unit, i) => (
<UnitCard
key={unit.id}
index={i}
unit={unit}
color={unitColor(i)}
currentNodeId={current?.node.id ?? null}
open={openUnit === unit.id}
onToggle={() => setOpenUnit(openUnit === unit.id ? null : unit.id)}
openNode={openNode}
onToggleNode={(id) => setOpenNode(openNode === id ? null : id)}
/>
))}
<button
type="button"
onClick={() => nav.switchTab('ask')}
className="mt-1 flex items-center gap-3 rounded-2xl border border-line bg-white px-4 py-3 text-right"
>
<Brain size={22} className="flex-shrink-0 text-violet" />
<span className="text-sm leading-6 text-body">یه جایی از درس رو نفهمیدی؟ <b className="text-ink">از دستیار بپرس</b></span>
</button>
</div>
)}
</div>
</Screen>
);
}
function UnitCard({
index, unit, color, currentNodeId, open, onToggle, openNode, onToggleNode,
}: {
index: number; unit: HomeUnit; color: string; currentNodeId: string | null; open: boolean; onToggle: () => void;
openNode: string | null; onToggleNode: (id: string) => void;
}) {
const p = unitProgress(unit);
const complete = p.total > 0 && p.done === p.total;
const untouched = unit.nodes.every((n) => n.status === 'locked');
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={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' }) : 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 }}
>
{complete ? <Check size={26} strokeWidth={3.5} /> : untouched ? <Lock size={22} /> : <Icon size={26} strokeWidth={2.2} />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[11px] font-bold" style={{ color }}>
{complete ? <><PartyPopper size={12} className="mb-0.5 ml-1 inline" />تموم شد</> : `${toFa(p.total)} جزیره`}
{premium && <span className="mr-1.5 inline-flex items-center gap-0.5 rounded-full bg-[#FFF4D6] px-1.5 text-[10px] text-[#A86A00]"><Crown size={10} /> با اشتراک</span>}
</span>
<span className="block text-sm font-black leading-6 text-ink"><MathText text={unit.title} /></span>
<span className="mt-1 flex items-center gap-2">
<span className="flex-1"><ProgressBar percent={p.percent} color={color} height={6} /></span>
<span className="text-[11px] text-muted">{toFa(p.done)}/{toFa(p.total)}</span>
</span>
</span>
{premium ? <Crown size={18} className="flex-shrink-0 text-[#E0A200]" /> : <ChevronDown size={18} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />}
</button>
{open && !premium && (
<ul className="flex flex-col gap-1.5 border-t border-line bg-[#fbfaff] px-3 py-3">
{unit.nodes.map((node, ni) => (
<NodeRow
key={node.id}
num={ni + 1}
node={node}
color={color}
isCurrent={node.id === currentNodeId}
open={openNode === node.id}
onToggle={() => onToggleNode(node.id)}
/>
))}
</ul>
)}
</motion.div>
);
}
function NodeRow({
num, node, color, isCurrent, open, onToggle,
}: { num: number; node: HomeNode; color: string; isCurrent: boolean; open: boolean; onToggle: () => void }) {
const nav = useNav();
const locked = node.status === 'locked';
const done = node.status === 'completed';
const lessons = useAsync(() => (open && !locked ? api.nodeLessons(node.id) : Promise.resolve(null)), [node.id, open, locked]);
const playable = new Set(node.lessonIds);
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
const loaded = !lessons.loading && !lessons.error && lessons.data !== null && lessons.data !== undefined;
return (
<li className="rounded-xl border bg-white" style={{ borderColor: isCurrent ? color : '#e9e6f7' }}>
<button
type="button"
disabled={locked}
onClick={onToggle}
aria-expanded={open}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-right disabled:opacity-60"
>
<span
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-xs font-black"
style={done ? { backgroundColor: '#3E9E73', color: '#fff' } : { backgroundColor: locked ? '#f0eeff' : `${color}22`, color: locked ? '#727299' : color }}
>
{done ? <Check size={15} strokeWidth={3.5} /> : locked ? <Lock size={13} /> : toFa(num)}
</span>
<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)} درس`}
</span>
</span>
{isCurrent && !open && (
<span className="flex-shrink-0 rounded-full px-2 py-0.5 text-[10px] font-black text-white" style={{ backgroundColor: color }}>
بعدی
</span>
)}
{!locked && <ChevronDown size={16} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />}
</button>
{open && !locked && (
<div className="px-3 pb-3">
{lessons.error ? (
<ErrorState error={lessons.error} onRetry={lessons.reload} />
) : !loaded ? (
<div className="flex flex-col gap-1.5" aria-busy="true" aria-label="دارم درس‌ها رو میارم">
{[0, 1, 2].map((k) => <div key={k} className="h-10 animate-pulse rounded-xl bg-soft" />)}
</div>
) : (
<>
<ul className="flex flex-col gap-1.5">
{list.map((l, li) => (
<li key={l.id}>
<button
type="button"
onClick={() => nav.push({ name: 'lesson', lessonId: Number(l.id), nodeId: node.id })}
className="flex w-full items-center gap-2.5 rounded-xl bg-[#f7f8ff] px-3 py-2 text-right text-sm active:scale-[.99]"
>
<span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-black"
style={l.done ? { backgroundColor: '#3E9E73', color: '#fff' } : { backgroundColor: '#fff', color: '#727299', border: '1px solid #e9e6f7' }}
>
{l.done ? <Check size={13} strokeWidth={3.5} /> : toFa(li + 1)}
</span>
<span className={`flex-1 ${l.done ? 'text-muted' : 'text-ink'}`}><MathText text={l.title} /></span>
</button>
</li>
))}
</ul>
<button
type="button"
onClick={() => nav.push({ name: 'quiz', nodeId: node.id })}
className="mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-black text-white active:translate-y-[2px]"
style={{ backgroundColor: onWhite(color), boxShadow: `0 3px 0 ${onWhite(color)}99` }}
>
{done ? <Sparkles size={15} /> : <Play size={15} fill="white" />}
{done ? 'دوباره تمرین کن' : 'درس‌ها رو خوندی؟ برو آزمون'}
</button>
</>
)}
</div>
)}
</li>
);
}

View File

@@ -1,38 +1,30 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { ChevronRight, Phone } from 'lucide-react'; import { ChevronRight, Phone } from 'lucide-react';
import { api } from '@/api'; import { api, ApiError } from '@/api';
import { InlineError, PrimaryButton } from '@/components/ui'; import { InlineError, PrimaryButton } from '@/components/ui';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { digitsOnly, toFa } from '@/lib/format'; import { digitsOnly, toFa } from '@/lib/format';
import { getDeviceId } from '@/lib/storage';
const OTP_LENGTH = 6; const OTP_LENGTH = 6;
/** همان cooldownِ سرور (OTP_REQUEST_PHONE_COOLDOWN_SECONDS). */ /** همان cooldownِ سرور (OTP_REQUEST_PHONE_COOLDOWN_SECONDS). */
const RESEND_SECONDS = 120; const RESEND_SECONDS = 120;
/** /**
* ورود با شماره (OTP) یا به‌عنوانِ مهمان. * ورود با شماره (OTP): تنها راهِ ورود است.
*
* `upgrade` برای مهمانی است که از صفحه‌ی «من» می‌خواهد شماره ثبت کند؛ آنجا
* گزینه‌ی مهمان نیست و سرور همان کاربر را نگه می‌دارد.
*/ */
export function LoginScreen({ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel?: () => void }) {
onDone,
onCancel,
upgrade = false,
}: {
onDone: () => void;
onCancel?: () => void;
upgrade?: boolean;
}) {
const [step, setStep] = useState<'phone' | 'otp'>('phone'); const [step, setStep] = useState<'phone' | 'otp'>('phone');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [otp, setOtp] = useState(''); const [otp, setOtp] = useState('');
const [devOtp, setDevOtp] = useState<string | null>(null); const [devOtp, setDevOtp] = useState<string | null>(null);
const [loading, setLoading] = useState<'otp' | 'login' | 'guest' | null>(null); const [loading, setLoading] = useState<'login' | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [resendIn, setResendIn] = useState(0); const [resendIn, setResendIn] = useState(0);
/** درخواستِ پیامک هنوز جواب نداده؛ جدا از `loading` تا واردکردنِ کد قفل نشود. */
const [sending, setSending] = useState(false);
/** شماره‌ی آخرین درخواست؛ جوابِ دیرِ درخواستِ قدیمی‌تر نادیده گرفته می‌شود. */
const attempt = useRef(0);
const otpInput = useRef<HTMLInputElement>(null); const otpInput = useRef<HTMLInputElement>(null);
const phoneValid = /^09\d{9}$/.test(phone); const phoneValid = /^09\d{9}$/.test(phone);
@@ -48,20 +40,37 @@ export function LoginScreen({
if (step === 'otp') otpInput.current?.focus(); if (step === 'otp') otpInput.current?.focus();
}, [step]); }, [step]);
/**
* صفحه‌ی کد فوراً باز می‌شود و درخواست در پس‌زمینه جلو می‌رود. سرور کد را پیش از
* تماس با کاوه‌نگار ذخیره می‌کند، پس کدِ رسیده همان لحظه قابلِ تأیید است؛ ولی
* جوابِ کاوه‌نگار گاهی بعد از رسیدنِ پیامک می‌آید (۱۶ ثانیه در ۲۰۲۶-۰۹-۲۷) و
* منتظر ماندن برایش کاربر را بی‌دلیل معطل می‌کرد.
*/
async function requestOtp() { async function requestOtp() {
if (!phoneValid || loading) return; if (!phoneValid || sending || loading) return;
const mine = ++attempt.current;
setError(''); setError('');
setLoading('otp'); setDevOtp(null);
try {
const data = await api.auth.requestOtp(phone);
setDevOtp(data.devOtp ?? null);
setOtp(''); setOtp('');
setStep('otp'); setStep('otp');
setResendIn(RESEND_SECONDS); setResendIn(RESEND_SECONDS);
setSending(true);
try {
const data = await api.auth.requestOtp(phone);
if (mine !== attempt.current) return;
setDevOtp(data.devOtp ?? null);
} catch (err) { } catch (err) {
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن')); if (mine !== attempt.current) return;
setError(friendlyError(err, 'ارسال کد انجام نشد، دوباره امتحان کن'));
if (err instanceof ApiError && err.status === 400) {
setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند
} else if (err instanceof ApiError && err.isRateLimited && err.retryAfterMs) {
setResendIn(Math.ceil(err.retryAfterMs / 1000));
} else {
setResendIn(0); // پیامکی نرفت و سرور cooldown را برداشته؛ همین حالا دوباره بفرستد
}
} finally { } finally {
setLoading(null); if (mine === attempt.current) setSending(false);
} }
} }
@@ -80,21 +89,6 @@ export function LoginScreen({
} }
} }
async function continueAsGuest() {
setError('');
setLoading('guest');
try {
await api.auth.guest(getDeviceId());
onDone();
} catch (err) {
// قبلاً اینجا یک «مهمانِ محلی» بدونِ توکن ساخته می‌شد و بعد همه‌ی
// درخواست‌ها بی‌صدا شکست می‌خوردند. حالا همین‌جا می‌گوییم چه شده.
setError(friendlyError(err, 'ورودِ مهمان انجام نشد، دوباره امتحان کن'));
} finally {
setLoading(null);
}
}
function onOtpChange(value: string) { function onOtpChange(value: string) {
const code = digitsOnly(value, OTP_LENGTH); const code = digitsOnly(value, OTP_LENGTH);
setOtp(code); setOtp(code);
@@ -112,11 +106,11 @@ export function LoginScreen({
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-xl font-black">{upgrade ? 'ثبتِ شماره‌ی موبایل' : 'ورود به تدریسینو'}</h1> <h1 className="text-balance text-xl font-black">ورود به تدریسینو ریاضی</h1>
<p className="mt-1 text-sm text-white/75"> <p className="mt-1 text-sm text-white/75">
{step === 'phone' {step === 'phone'
? upgrade ? 'امتیازها و پیشرفتت سرِ جاش می‌مونه' : 'شماره‌ی موبایلت رو وارد کن' ? 'شماره موبایلت رو وارد کن'
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامک‌شده رو وارد کن`} : `کد ${toFa(OTP_LENGTH)} رقمی پیامک‌شده رو وارد کن`}
</p> </p>
</div> </div>
<div className="rounded-2xl bg-white/15 p-2"> <div className="rounded-2xl bg-white/15 p-2">
@@ -152,7 +146,7 @@ export function LoginScreen({
) : ( ) : (
<div> <div>
<label className="block"> <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 <input
ref={otpInput} ref={otpInput}
value={otp} value={otp}
@@ -166,17 +160,20 @@ export function LoginScreen({
}`} }`}
/> />
</label> </label>
{sending && !error && (
<p className="mt-2 text-xs text-muted">پیامک در راهه؛ همین که رسید کد رو بزن</p>
)}
{devOtp && ( {devOtp && (
<p className="mt-2 text-xs text-muted"> <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> </p>
)} )}
<div className="mt-3 flex items-center justify-between text-xs font-bold"> <div className="mt-3 flex items-center justify-between text-xs font-bold">
<button type="button" className="flex items-center gap-1 text-coral" onClick={() => { setStep('phone'); setError(''); setDevOtp(null); }}> <button type="button" className="flex items-center gap-1 text-coral" onClick={() => { attempt.current++; setSending(false); setStep('phone'); setError(''); setDevOtp(null); }}>
<Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح <Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح
</button> </button>
<button type="button" disabled={resendIn > 0 || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted"> <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> </button>
</div> </div>
</div> </div>
@@ -187,21 +184,11 @@ export function LoginScreen({
<div className="mt-auto flex flex-col gap-3"> <div className="mt-auto flex flex-col gap-3">
<PrimaryButton <PrimaryButton
type="submit" type="submit"
loading={loading === 'otp' || loading === 'login'} loading={loading === 'login'}
disabled={step === 'phone' ? !phoneValid : !otpValid} disabled={step === 'phone' ? !phoneValid : !otpValid}
> >
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'} {step === 'phone' ? 'دریافت کد تأیید' : 'ورود'}
</PrimaryButton> </PrimaryButton>
{!upgrade && (
<button
type="button"
onClick={() => void continueAsGuest()}
disabled={loading !== null}
className="py-2 text-center text-sm font-bold text-muted disabled:opacity-40"
>
{loading === 'guest' ? 'در حالِ ورود…' : 'فعلاً مهمان باشم'}
</button>
)}
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,96 +0,0 @@
import { motion } from 'motion/react';
import { Check } from 'lucide-react';
import { useAppData } from '@/state/AppData';
import { unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
import { iconFor } from '@/components/NodeIcon';
import { toFa } from '@/lib/format';
/** نقشه‌ی فصل‌های پایه: هر خانه یک فصل است. */
export function MapScreen() {
const { home, homeError, refreshHome, gradeTitle } = useAppData();
const nav = useNav();
const units = home?.units ?? [];
const points = zigzagPositions(units.length, 150);
const height = points.length ? points[points.length - 1].y + 200 : 400;
const overall = Math.round(home?.stats.progressPercent ?? 0);
return (
<Screen className="bg-[#f7f8ff]">
<ScreenHeader
title="نقشه‌ی فصل‌ها"
subtitle={`${gradeTitle ? `${gradeTitle} · ` : ''}${toFa(overall)}٪ از کلِ مسیر`}
color="#91ADC6"
mascot={{ char: 'sigma', mood: overall > 0 ? 'happy' : 'idle' }}
>
<div className="mt-4">
<ProgressBar percent={overall} color="#fff" track="rgba(255,255,255,.25)" />
</div>
</ScreenHeader>
<div className="flex-1 overflow-y-auto">
{!home ? (
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم فصل‌ها رو میارم…" />
) : units.length === 0 ? (
<EmptyState text="هنوز فصلی برای این پایه ثبت نشده" />
) : (
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
<ZigzagPath
points={points}
height={height}
colorOf={(i) => (units[i].nodes.some((n) => n.status !== 'locked') ? unitColor(i) : null)}
/>
{units.map((unit, i) => {
const p = unitProgress(unit);
const color = unitColor(i);
const Icon = iconFor(unit.nodes[0]?.icon, i);
const complete = p.total > 0 && p.done === p.total;
return (
<motion.div
key={unit.id}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.05, type: 'spring', stiffness: 220, damping: 16 }}
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1.5"
style={{ left: `${points[i].x}%`, top: points[i].y }}
>
<button
type="button"
aria-label={unit.title}
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
className="relative flex h-[84px] w-[84px] items-center justify-center rounded-[26px] transition-transform active:translate-y-1"
style={{
backgroundColor: complete ? color : '#fff',
border: complete ? 'none' : `4px solid ${color}`,
boxShadow: complete ? `0 6px 0 ${color}99` : `0 6px 0 ${color}40, 0 0 0 5px white`,
}}
>
{complete ? <Check size={30} color="white" strokeWidth={3.5} /> : <Icon size={30} style={{ color }} strokeWidth={2.2} />}
{!complete && p.done > 0 && (
<span className="absolute -left-2 -top-2 rounded-full border bg-white px-1.5 py-0.5 text-[10px] font-black shadow" style={{ color, borderColor: color }}>
{toFa(p.percent)}٪
</span>
)}
</button>
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
<div className="max-w-[130px] truncate text-xs font-black text-ink">{unit.title}</div>
<div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
</div>
</motion.div>
);
})}
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 150 }}>
<SeyarMascot char="pi" mood={overall >= 100 ? 'excited' : 'happy'} size={72} bob />
<div className="mt-1 whitespace-nowrap rounded-full border border-line bg-white px-3 py-1 text-[10px] font-bold text-[#7857E0] shadow-sm">
{overall >= 100 ? 'همه رو تموم کردی! 🎉' : 'همین‌جا منتظرتم'}
</div>
</div>
</div>
)}
</div>
</Screen>
);
}

View File

@@ -1,10 +1,11 @@
import { Bell, Check, Crown, FileText, MessageCircle, Star, Trophy, Users, type LucideIcon } from 'lucide-react'; import { Bell, Check, Crown, MessageCircle, Star, Trophy, type LucideIcon } from 'lucide-react';
import { api, type NotificationItem } from '@/api'; import { api, type NotificationItem } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync'; import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui'; import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
import { relativeTime } from '@/lib/format'; import { relativeTime, toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = { const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = {
xp_earned: { icon: Star, color: '#E8A400', bg: '#FFF8E1' }, xp_earned: { icon: Star, color: '#E8A400', bg: '#FFF8E1' },
@@ -12,8 +13,6 @@ const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = {
node_completed: { icon: Trophy, color: '#FF7A3D', bg: '#FFF1E6' }, node_completed: { icon: Trophy, color: '#FF7A3D', bg: '#FFF1E6' },
order_paid: { icon: Crown, color: '#9B7BFF', bg: '#F5F1FF' }, order_paid: { icon: Crown, color: '#9B7BFF', bg: '#F5F1FF' },
ticket_replied: { icon: MessageCircle, color: '#4C5FD5', bg: '#EEF1FF' }, ticket_replied: { icon: MessageCircle, color: '#4C5FD5', bg: '#EEF1FF' },
family_link_accepted: { icon: Users, color: '#FF7A3D', bg: '#FFF1E6' },
weekly_report_ready: { icon: FileText, color: '#91ADC6', bg: '#EAF2F6' },
}; };
const styleOf = (type: string) => STYLE[type] ?? { icon: Bell, color: '#9e9ec0', bg: '#f0eeff' }; const styleOf = (type: string) => STYLE[type] ?? { icon: Bell, color: '#9e9ec0', bg: '#f0eeff' };
@@ -24,7 +23,7 @@ export function NotificationsScreen() {
const r = await api.notifications.list(); const r = await api.notifications.list();
setUnreadCount(r.unreadCount); setUnreadCount(r.unreadCount);
return r.items; return r.items;
}, []); }, [], { key: 'notifications' });
const items = list.data ?? []; const items = list.data ?? [];
const unread = items.filter((n) => !n.isRead).length; const unread = items.filter((n) => !n.isRead).length;
@@ -52,7 +51,7 @@ export function NotificationsScreen() {
return ( return (
<Screen> <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 && ( {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"> <button type="button" onClick={() => void markAll()} className="mt-3 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold">
همه خوانده شد همه خوانده شد
@@ -83,7 +82,7 @@ export function NotificationsScreen() {
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className={`block text-sm ${n.isRead ? 'text-muted' : 'font-bold text-ink'}`}>{n.title}</span> <span className={`block text-sm ${n.isRead ? 'text-muted' : 'font-bold text-ink'}`}>{n.title}</span>
{n.body && <span className="mt-0.5 block whitespace-pre-line text-xs leading-6 text-body">{n.body}</span>} {n.body && <span className="mt-0.5 block whitespace-pre-line text-xs leading-6 text-body"><MathText text={toFa(n.body)} /></span>}
<span className="mt-1 block text-[10px] text-faint">{relativeTime(n.createdAt)}</span> <span className="mt-1 block text-[10px] text-faint">{relativeTime(n.createdAt)}</span>
</span> </span>
{!n.isRead && <span className="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-coral" />} {!n.isRead && <span className="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-coral" />}

View File

@@ -7,21 +7,27 @@ import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title: string; desc: string }[] = [ const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title: string; desc: string }[] = [
{ {
char: 'sigma', mood: 'happy', bg: '#4C5FD5', char: 'sigma', mood: 'happy', bg: '#4C5FD5',
sub: 'از پایه‌ی سوم تا دوازدهم', sub: 'از پایه سوم تا دوازدهم',
title: 'به تدریسینو خوش اومدی!', title: 'به تدریسینو ریاضی خوش اومدی!',
desc: 'من سیگمام. ریاضیِ کتابِ خودت رو جزیره به جزیره با هم جلو می‌بریم؛ هر جزیره چند درسِ کوتاه و یه عالمه تمرین.', desc: 'من سیگمام. با هم ریاضی کتاب درسیت رو جزیره به جزیره جلو می‌بریم؛ هر جزیره چند درس کوتاه داره و کلی تمرین.',
}, },
{ {
char: 'delta', mood: 'excited', bg: '#FF7A3D', char: 'delta', mood: 'excited', bg: '#FF7A3D',
sub: 'هر روز یه کم', sub: 'هر روز یه کم',
title: 'امتیاز بگیر، زنجیره بساز', title: 'امتیاز بگیر، زنجیره بساز',
desc: 'منم دلتا! با هر جوابِ درست امتیاز و سکه می‌گیری، روزهای پیاپی‌ت شمرده می‌شه و تو جدولِ امتیازها بالا می‌ری.', desc: 'منم دلتا! با هر جواب درست امتیاز و سکه می‌گیری، روزهایی که پشت هم تمرین می‌کنی شمرده می‌شه و تو جدول امتیازها بالا می‌ری.',
}, },
{ {
char: 'pi', mood: 'happy', bg: '#9B7BFF', char: 'pi', mood: 'happy', bg: '#9B7BFF',
sub: 'هر جا گیر کردی', sub: 'هر جا گیر کردی',
title: 'از پی بپرس', title: 'از پی بپرس',
desc: 'من پی‌ام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدم‌به‌قدم توضیح می‌دم.', desc: 'من پی‌ام. هر جا سوالی داشتی یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتاب درسیت قدم‌به‌قدم توضیح می‌دم.',
},
{
char: 'tedi', mood: 'excited', bg: '#D9820B',
sub: 'یکی از ما مال توئه',
title: 'همراهت رو انتخاب کن',
desc: 'منم امگا! هر چی امتیازت بیشتر بشه، همراه‌های تازه‌تری باز می‌شن. از صفحه «من» هر کدوممون رو بخوای انتخاب می‌کنی.',
}, },
]; ];
@@ -58,7 +64,7 @@ export function OnboardingScreen({ onDone }: { onDone: () => void }) {
</div> </div>
<div> <div>
<p className="mb-1 text-sm font-medium text-white/70">{slide.sub}</p> <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> <p className="text-sm leading-[1.9] text-white/85">{slide.desc}</p>
</div> </div>
</motion.div> </motion.div>

View File

@@ -1,7 +1,7 @@
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { import {
Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut, BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Crown, Heart, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
Pencil, Smartphone, Sparkles, Star, Trophy, Users, Volume2, VolumeX, FileText, Pencil, Sparkles, Star, Trash2, Trophy, UserRound, Volume2, VolumeX,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
@@ -11,13 +11,16 @@ import type { Route } from '@/nav/routes';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import { Sheet } from '@/components/Sheet'; import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
import { AgePicker, ageFromBirthYear, birthYearFromAge } from '@/components/AgePicker'; import { BirthDatePicker, ageOf, formatBirthDate } from '@/components/BirthDatePicker';
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui'; import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { setSoundEnabled, soundEnabled } from '@/lib/sound'; import { setSoundEnabled, soundEnabled } from '@/lib/sound';
import { toFa } from '@/lib/format'; import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
import { useAsync } from '@/lib/useAsync';
import { daysLeft } from '@/lib/subscription';
import { BrandFooter } from '@/components/BrandFooter';
type Editing = 'name' | 'grade' | 'age' | 'logout' | null; type Editing = 'name' | 'grade' | 'birth' | 'account' | 'logout' | 'delete' | null;
/** «من»: پروفایل، تنظیمات و میان‌بُر به بخش‌های فرعی. */ /** «من»: پروفایل، تنظیمات و میان‌بُر به بخش‌های فرعی. */
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) { export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
@@ -25,7 +28,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
const nav = useNav(); const nav = useNav();
const [editing, setEditing] = useState<Editing>(null); const [editing, setEditing] = useState<Editing>(null);
const [sound, setSound] = useState(soundEnabled()); const [sound, setSound] = useState(soundEnabled());
const age = ageFromBirthYear(profile.birthYear); const age = ageOf(profile.birthDate, profile.birthYear);
const go = (route: Route) => () => nav.push(route); const go = (route: Route) => () => nav.push(route);
@@ -42,14 +45,13 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<button type="button" onClick={() => setEditing('name')} className="flex items-center gap-2 text-xl font-black"> <button type="button" onClick={() => setEditing('name')} className="flex items-center gap-2 text-xl font-black">
{profile.name || 'بی‌نام'} <Pencil size={14} className="text-white/70" /> {profile.name || 'بی‌نام'} <Pencil size={14} className="text-white/70" />
</button> </button>
<div className="mt-0.5 text-xs text-white/75">
شناسه کاربری: <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"> <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>} {gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
{age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>} {age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>}
{profile.isGuest ? ( {profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>}
<span className="rounded-full bg-[#FFD23F] px-2.5 py-0.5 font-bold text-ink">مهمان</span>
) : (
profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -69,37 +71,30 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
</header> </header>
<div className="flex flex-col gap-4 px-4 py-5"> <div className="flex flex-col gap-4 px-4 py-5">
{profile.isGuest && ( <SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
<button
type="button"
onClick={go({ name: 'register' })}
className="flex items-center gap-3 rounded-3xl bg-[#FFF8E1] p-4 text-right ring-2 ring-[#FFD23F]"
>
<Smartphone size={22} className="flex-shrink-0 text-gold" />
<div className="flex-1">
<div className="text-sm font-black text-ink">شماره‌ت رو ثبت کن</div>
<div className="mt-0.5 text-xs leading-6 text-body">تا اگه گوشیت عوض شد، امتیازها و پیشرفتت گم نشه.</div>
</div>
<ChevronLeft size={18} className="text-muted" />
</button>
)}
<Menu> <Menu>
<Row icon={GraduationCap} label="پایه‌ی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} /> <Row icon={GraduationCap} label="پایه تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
<Row icon={CalendarDays} label="سن" value={age ? `${toFa(age)} سال` : 'وارد نشده'} onClick={() => setEditing('age')} /> <Row
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} /> icon={CalendarDays}
label="تاریخ تولد"
value={formatBirthDate(profile.birthDate) || (age ? `${toFa(age)} سال` : 'وارد نشده')}
onClick={() => setEditing('birth')}
/>
<Row icon={Sparkles} label="همراه من" onClick={go({ name: 'characters' })} />
</Menu> </Menu>
<Menu> <Menu>
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} /> <Row icon={BarChart3} label="گزارش والدین" onClick={go({ name: 'report' })} />
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} /> <Row icon={Flame} label="تقویم زنجیره" onClick={go({ name: 'streak' })} />
<Row icon={Trophy} label="جدول امتیازها" onClick={go({ name: 'leaderboard' })} />
<Row icon={Bell} label="اعلان‌ها" onClick={go({ name: 'notifications' })} /> <Row icon={Bell} label="اعلان‌ها" onClick={go({ name: 'notifications' })} />
</Menu> </Menu>
<Menu> <Menu>
{!profile.isGuest && <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />} <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />
{!profile.isGuest && <Row icon={Users} label="بخشِ والدین" onClick={go({ name: 'family' })} />} <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 <Row
icon={sound ? Volume2 : VolumeX} icon={sound ? Volume2 : VolumeX}
label="صداها" label="صداها"
@@ -111,29 +106,98 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
/> />
</Menu> </Menu>
<button type="button" onClick={() => setEditing('logout')} className="flex items-center justify-center gap-2 py-3 text-sm font-bold text-red-500"> {/* حذفِ حساب یک لایه پایین‌تر، در «حساب کاربری»، مثلِ اپ‌های بزرگ (دیجی‌کالا، اسنپ، دولینگو): پیدا می‌شود
<LogOut size={16} /> خروج از حساب (Google Play و App Store «در دسترس و پیداشدنی» می‌خواهند) ولی با یک لمسِ اشتباه زیرِ انگشت نیست. */}
</button> <Menu>
<Row icon={UserRound} label="حساب کاربری" value={profile.phone ? toFa(profile.phone) : undefined} onClick={() => setEditing('account')} />
<Row icon={LogOut} label="خروج از حساب" danger onClick={() => setEditing('logout')} />
</Menu>
<BrandFooter />
</div> </div>
</div> </div>
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} /> <NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} /> <GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
<AgeSheet open={editing === 'age'} onClose={() => setEditing(null)} /> <BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
<AccountSheet open={editing === 'account'} onClose={() => setEditing(null)} onDelete={() => setEditing('delete')} />
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} /> <LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
<DeleteAccountSheet open={editing === 'delete'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
</Screen> </Screen>
); );
} }
/**
* اشتراک به‌صورتِ نوار: چند روز از کلِ دوره مانده. مخرج طولِ پلن است (planDurationDays)؛ تمدیدِ زودتر از
* موعد روزها را روی هم می‌گذارد، پس نوار در ۱۰۰٪ بسته می‌شود. زدنِ کارت فروشگاه را باز می‌کند.
*/
function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
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;
const total = s.planDurationDays ?? 30;
// اشتراکِ بی‌تاریخ (هدیه) پر نشان داده می‌شود.
const percent = s.active ? (days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)))) : 0;
const low = s.active && days !== null && days <= 5;
return (
<button
type="button"
onClick={onOpen}
className={`relative overflow-hidden rounded-3xl p-4 text-right active:scale-[0.99] ${s.active ? 'bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] text-white shadow-lg shadow-orange/25' : 'border-2 border-dashed border-orange/50 bg-white text-ink'}`}
>
<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-base font-black">{s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}</span>
</span>
<span className="text-left">
{s.active && days !== null ? (
<><b className="block text-2xl font-black leading-none">{toFa(days)}</b><span className="text-[10px] text-white/80">روز مونده</span></>
) : (
<ChevronLeft size={18} className={s.active ? 'text-white/80' : 'text-faint'} />
)}
</span>
</div>
<div
className={`relative mt-3 h-3 overflow-hidden rounded-full ${s.active ? 'bg-black/15' : 'bg-soft'}`}
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={days ?? total}
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' }}>
<span className="shine absolute inset-y-0 w-1/3 bg-gradient-to-l from-transparent via-white/70 to-transparent" style={{ mixBlendMode: 'overlay' }} />
</div>
)}
</div>
<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>
</>
) : (
<span>فصل اول هر پایه رایگانه؛ با اشتراک همه فصل‌ها و قلب نامحدود باز می‌شه.</span>
)}
</div>
</button>
);
}
function Menu({ children }: { children: ReactNode }) { function Menu({ children }: { children: ReactNode }) {
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>; 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 ( return (
<button type="button" onClick={onClick} className="flex w-full items-center gap-3 px-4 py-3.5 text-right active:bg-soft"> <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" /> <Icon size={18} className={`flex-shrink-0 ${danger ? 'text-red-500' : 'text-indigo'}`} />
<span className="flex-1 text-sm font-bold text-ink">{label}</span> <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>} {value && <span className="text-xs text-muted">{value}</span>}
<ChevronLeft size={16} className="text-faint" /> <ChevronLeft size={16} className="text-faint" />
</button> </button>
@@ -187,7 +251,7 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
await api.profile.setGrade(gradeId); await api.profile.setGrade(gradeId);
setProfile({ ...profile, gradeId }); setProfile({ ...profile, gradeId });
askStore.reset(null); askStore.reset(null);
await Promise.all([refreshHome(), refreshSummary()]); await Promise.all([refreshHome(gradeId), refreshSummary()]);
toast('پایه‌ت عوض شد', 'success'); toast('پایه‌ت عوض شد', 'success');
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -198,8 +262,8 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
} }
return ( return (
<Sheet open={open} onClose={onClose} title="پایه‌ی تحصیلی"> <Sheet open={open} onClose={onClose} title="پایه تحصیلی">
<p className="mb-3 text-xs leading-6 text-muted">پیشرفتِ هر پایه جدا نگه داشته می‌شه؛ با برگشتن، سرِ جاشه.</p> <p className="mb-3 text-xs leading-6 text-muted">پیشرفت هر پایه جدا نگه داشته می‌شه؛ با برگشتن، سر جاشه.</p>
<div className="grid grid-cols-2 gap-2.5"> <div className="grid grid-cols-2 gap-2.5">
{grades.map((g) => { {grades.map((g) => {
const on = g.id === profile.gradeId; const on = g.id === profile.gradeId;
@@ -222,17 +286,17 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
); );
} }
function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) { function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const { profile, setProfile } = useAppData(); const { profile, setProfile } = useAppData();
const toast = useToast(); const toast = useToast();
const [age, setAge] = useState<number | null>(ageFromBirthYear(profile.birthYear)); const [birthDate, setBirthDate] = useState<string | null>(profile.birthDate);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
async function save() { async function save() {
if (!age) return; if (!birthDate) return;
setSaving(true); setSaving(true);
try { try {
setProfile(await api.profile.update({ birthYear: birthYearFromAge(age) })); setProfile(await api.profile.update({ birthDate }));
toast('ذخیره شد', 'success'); toast('ذخیره شد', 'success');
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -243,17 +307,22 @@ function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
} }
return ( return (
<Sheet open={open} onClose={onClose} title="چند سالته؟"> <Sheet open={open} onClose={onClose} title="تاریخ تولد">
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سال تولدت به شمسی.</p>
<div className="mb-5"> <div className="mb-5">
<AgePicker value={age} onChange={setAge} /> <BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
</div> </div>
<PrimaryButton loading={saving} disabled={!age} onClick={() => void save()}>ذخیره</PrimaryButton> <PrimaryButton loading={saving} disabled={!birthDate} onClick={() => void save()}>ذخیره</PrimaryButton>
</Sheet> </Sheet>
); );
} }
/**
* خروج. «ورود با حسابِ دیگر» (۲۰۲۶-۱۰-۱۴) برداشته شد: همان خروج بود با متنِ دیگر. عمداً چند حسابِ واردشده روی یک
* گوشی نگه داشته نمی‌شود: گوشیِ خانوادگی دست‌به‌دست می‌شود و هر کس بی‌کد واردِ حسابِ دیگری می‌شد، و چند refresh
* tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر می‌کرد. خروج و ورودِ دوباره با پیامک همان کار را می‌کند.
*/
function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) { function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
const { profile } = useAppData();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
async function logout() { async function logout() {
@@ -269,11 +338,7 @@ function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: (
return ( return (
<Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟"> <Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟">
<p className="mb-5 text-sm leading-7 text-muted"> <p className="mb-5 text-sm leading-7 text-muted">هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سر جاشه.</p>
{profile.isGuest
? 'تو مهمانی: با همین گوشی و مرورگر، «فعلاً مهمان باشم» دوباره تو رو به همین پیشرفت برمی‌گردونه، ولی از گوشیِ دیگه نه. برای امن‌تر شدن، اول شماره‌ت رو ثبت کن.'
: 'هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.'}
</p>
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، می‌مونم</SecondaryButton> <SecondaryButton onClick={onClose}>نه، می‌مونم</SecondaryButton>
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton> <PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
@@ -281,3 +346,103 @@ function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: (
</Sheet> </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). شماره‌دار با کدِ تازه‌ی پیامکی تأیید می‌کند تا گوشیِ
* بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند.
*/
function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
const { profile } = useAppData();
const [sent, setSent] = useState(false);
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function sendCode() {
if (!profile.phone) return;
setBusy(true);
setError('');
try {
await api.auth.requestOtp(profile.phone);
setSent(true);
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
}
async function remove() {
setBusy(true);
setError('');
try {
await api.auth.deleteAccount({ otp: digitsOnly(value, 6) });
askStore.clear();
onSignedOut();
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
}
return (
<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="کد پیامک‌شده"
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>
)}
{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}
disabled={value.length < 6}
onClick={() => void remove()}
>
حذف همیشگی
</PrimaryButton>
)}
</div>
</Sheet>
);
}

View File

@@ -1,38 +1,68 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import confetti from 'canvas-confetti'; import { Brain, Check, ChevronLeft, Coins, Compass, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react';
import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react'; import { api, ApiError, type AnswerResult, type Hearts, type Question } from '@/api';
import { api, ApiError, type AnswerResult, type Hearts, type LessonComplete, type Question, type ReportReason } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { fetchHome } from '@/state/subject';
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum'; import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
import { newIdempotencyKey } from '@/api/http'; import { newIdempotencyKey } from '@/api/http';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarMood } from '@/mascots/seyar'; import type { SeyarMood } from '@/mascots/seyar';
import { NoHeartsScreen } from '@/components/NoHeartsScreen';
import { Sheet } from '@/components/Sheet'; import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton, Screen } from '@/components/ui'; import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton, Screen } from '@/components/ui';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { timeUntil, toFa } from '@/lib/format'; 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 { sounds } from '@/lib/sound';
import { useGateHold } from '@/lib/useAsync';
import { StreakCelebration, StreakFlame } from '@/components/StreakFlame';
import { useCompanion } from '@/state/companion';
type Phase = type Phase =
| { kind: 'loading' } | { kind: 'loading' }
| { kind: 'error'; error: unknown } | { kind: 'error'; error: unknown }
| { kind: 'playing' } | { kind: 'playing' }
| { kind: 'noHearts'; nextRefillAt: string | null } | { kind: 'noHearts'; nextRefillAt: string | null; fullRefillAt: string | null }
| { kind: 'finishing' } | { kind: 'finishing' }
| { kind: 'finishError'; error: unknown } | { kind: 'finishError'; error: unknown }
| { kind: 'result'; result: LessonComplete }; | { kind: 'result'; result: QuizResult };
/** تمرینِ یک درس: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه. */ /** نتیجه‌ی مشترکِ پایانِ درس و پایانِ جزیره. */
export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) { interface QuizResult {
const { home, companion, refreshHome, refreshSummary } = useAppData(); stars: number;
xpEarned: number;
coinsEarned: number;
/** فقط در آزمونِ جزیره. */
nextNodeId?: string | null;
}
type PlayQuestion = Question & { lessonId?: string };
/**
* تمرین: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه.
*
* دو حالت دارد. با `lessonId` تمرینِ همان یک درس است (هر درس معمولاً فقط یک
* سوال دارد). بدونِ آن آزمونِ کلِ جزیره است (`/nodes/{id}/quiz`، ۴ سوال از
* درس‌های مختلف) که با تمام شدنش جزیره تمام و جزیره‌ی بعدی باز می‌شود؛ مسیرِ
* اصلیِ بازی همین است.
*/
export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) {
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 nav = useNav();
const toast = useToast(); const toast = useToast();
const [phase, setPhase] = useState<Phase>({ kind: 'loading' }); const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
const [questions, setQuestions] = useState<Question[]>([]); const [questions, setQuestions] = useState<PlayQuestion[]>([]);
const nodeMode = lessonId === undefined;
const [hearts, setHearts] = useState<Hearts | null>(null); const [hearts, setHearts] = useState<Hearts | null>(null);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
@@ -40,14 +70,16 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [correctCount, setCorrectCount] = useState(0); const [correctCount, setCorrectCount] = useState(0);
const [confirmExit, setConfirmExit] = useState(false); const [confirmExit, setConfirmExit] = useState(false);
const [reporting, setReporting] = useState(false);
const [reload, setReload] = useState(0); const [reload, setReload] = useState(0);
// پشتِ هم درست؛ برای «سه تا پشتِ هم!» (حسِ پیشرفت وسطِ آزمون، نه فقط آخرش).
const [combo, setCombo] = useState(0);
useGateHold(phase.kind === 'loading');
// یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمی‌شود. // یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمی‌شود.
const answerKeys = useRef(new Map<string, string>()); const answerKeys = useRef(new Map<string, string>());
const completeKey = useRef(newIdempotencyKey()); const completeKey = useRef(newIdempotencyKey());
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId); const ref = findNode(home, nodeId) ?? (lessonId !== undefined ? findNodeOfLesson(home, lessonId) : null);
const color = ref ? unitColor(ref.unitIndex) : '#ED6956'; const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
const question = questions[index]; const question = questions[index];
const isLast = index === questions.length - 1; const isLast = index === questions.length - 1;
@@ -58,7 +90,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setPhase({ kind: 'loading' }); setPhase({ kind: 'loading' });
Promise.all([api.lesson.questions(lessonId), api.hearts().catch(() => null)]) const load: Promise<PlayQuestion[]> = nodeMode
? api.nodeQuiz(nodeId ?? '').then((r) => r.questions)
: api.lesson.questions(lessonId);
Promise.all([load, api.hearts().catch(() => null)])
.then(([qs, h]) => { .then(([qs, h]) => {
if (!alive) return; if (!alive) return;
setQuestions(qs); setQuestions(qs);
@@ -67,20 +102,33 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
setSelected(null); setSelected(null);
setAnswer(null); setAnswer(null);
setCorrectCount(0); setCorrectCount(0);
setCombo(0);
answerKeys.current.clear(); answerKeys.current.clear();
completeKey.current = newIdempotencyKey(); completeKey.current = newIdempotencyKey();
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt }); if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
else setPhase({ kind: 'playing' }); else setPhase({ kind: 'playing' });
}) })
.catch((error: unknown) => alive && setPhase({ kind: 'error', error })); .catch((error: unknown) => {
if (!alive) return;
// فصلِ غیرِ اول بی‌اشتراک (۴۰۲)؛ صفحه‌ی خطا معنا ندارد، کاربر یک‌راست به اشتراک‌ها می‌رود.
if (error instanceof ApiError && error.code === 'subscription_required') nav.replace({ name: 'subscription' });
else setPhase({ kind: 'error', error });
});
return () => { return () => {
alive = false; alive = false;
}; };
}, [lessonId, reload]); }, [lessonId, nodeId, nodeMode, reload]);
async function choose(optionId: string) { /** انتخاب فقط گزینه را آبی می‌کند؛ تا «بررسی» نزده، چیزی ثبت یا رو نمی‌شود و می‌شود عوضش کرد. */
if (!question || selected !== null || submitting) return; function choose(optionId: string) {
if (!question || answer || submitting) return;
if (optionId !== selected) sounds.select();
setSelected(optionId); setSelected(optionId);
}
async function check() {
if (!question || selected === null || answer || submitting) return;
const optionId = selected;
setSubmitting(true); setSubmitting(true);
let key = answerKeys.current.get(question.id); let key = answerKeys.current.get(question.id);
if (!key) { if (!key) {
@@ -93,14 +141,21 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h)); setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h));
if (result.correct) { if (result.correct) {
setCorrectCount((c) => c + 1); setCorrectCount((c) => c + 1);
sounds.correct(); setCombo(combo + 1);
if (look.char === 'delta' && combo + 1 >= 2) sounds.combo();
sounds.correct(look.pitch);
} else { } else {
setCombo(0);
sounds.wrong(); sounds.wrong();
} }
} catch (err) { } catch (err) {
setSelected(null); // گزینه انتخاب‌شده می‌ماند تا کاربر دوباره «بررسی» بزند.
if (err instanceof ApiError && err.code === 'no_hearts') { if (err instanceof ApiError && err.code === 'no_hearts') {
setPhase({ kind: 'noHearts', nextRefillAt: (err.body.nextRefillAt as string | null) ?? null }); setPhase({
kind: 'noHearts',
nextRefillAt: (err.body.nextRefillAt as string | null) ?? null,
fullRefillAt: (err.body.fullRefillAt as string | null) ?? null,
});
} else { } else {
toast(friendlyError(err, 'جوابت ثبت نشد، دوباره بزن'), 'error'); toast(friendlyError(err, 'جوابت ثبت نشد، دوباره بزن'), 'error');
} }
@@ -112,7 +167,11 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
async function next() { async function next() {
if (outOfHearts && !isLast) { if (outOfHearts && !isLast) {
const h = await api.hearts().catch(() => null); const h = await api.hearts().catch(() => null);
setPhase({ kind: 'noHearts', nextRefillAt: h?.nextRefillAt ?? hearts?.nextRefillAt ?? null }); setPhase({
kind: 'noHearts',
nextRefillAt: h?.nextRefillAt ?? hearts?.nextRefillAt ?? null,
fullRefillAt: h?.fullRefillAt ?? hearts?.fullRefillAt ?? null,
});
return; return;
} }
if (!isLast) { if (!isLast) {
@@ -127,23 +186,50 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
async function finish() { async function finish() {
setPhase({ kind: 'finishing' }); setPhase({ kind: 'finishing' });
try { try {
const result = await api.lesson.complete( const payload = { correctCount, totalQuestions: questions.length };
lessonId, const result: QuizResult = nodeMode
{ correctCount, totalQuestions: questions.length }, ? await api.nodeQuizComplete(nodeId ?? '', payload, completeKey.current)
completeKey.current, : await api.lesson.complete(lessonId, payload, completeKey.current);
);
setPhase({ kind: 'result', result }); setPhase({ kind: 'result', result });
sounds.finish(); sounds.finish();
if (result.stars >= 2) void 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 refreshHome();
void refreshSummary(); 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) { } catch (error) {
// سرور پایانِ درس/جزیره را بی‌قلب و بی‌جواب نمی‌پذیرد (۱۴۰۵-۰۷)؛ همان صفحه‌ی انتظار.
if (error instanceof ApiError && error.code === 'no_hearts') {
setPhase({
kind: 'noHearts',
nextRefillAt: (error.body.nextRefillAt as string | null) ?? null,
fullRefillAt: (error.body.fullRefillAt as string | null) ?? null,
});
return;
}
setPhase({ kind: 'finishError', error }); setPhase({ kind: 'finishError', error });
} }
} }
const askWhy = () => const askWhy = () =>
nav.push({ name: 'ask', lessonId, questionId: question?.id, prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟' }); nav.push({
name: 'ask',
lessonId: lessonId ?? (question?.lessonId ? Number(question.lessonId) : undefined),
questionId: question?.id,
prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟',
});
/* ─── حالت‌های غیرِ بازی ─────────────────────────────────────────────── */ /* ─── حالت‌های غیرِ بازی ─────────────────────────────────────────────── */
@@ -158,8 +244,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
</Screen> </Screen>
); );
} }
if (phase.kind === 'noHearts') return <NoHearts nextRefillAt={phase.nextRefillAt} />; if (phase.kind === 'noHearts') {
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id} color={color} />; return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={() => setReload((n) => n + 1)} />;
}
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') { if (phase.kind === 'finishing' || phase.kind === 'finishError') {
return ( return (
<Screen> <Screen>
@@ -169,7 +257,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
<p className="text-sm text-muted">دارم نتیجه‌ت رو ثبت می‌کنم…</p> <p className="text-sm text-muted">دارم نتیجه‌ت رو ثبت می‌کنم…</p>
) : ( ) : (
<> <>
<InlineError>نتیجه‌ی این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError> <InlineError>نتیجه این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
<PrimaryButton color={color} onClick={() => void finish()}>دوباره ثبت کن</PrimaryButton> <PrimaryButton color={color} onClick={() => void finish()}>دوباره ثبت کن</PrimaryButton>
<SecondaryButton onClick={nav.back}>بی‌خیال، برگرد</SecondaryButton> <SecondaryButton onClick={nav.back}>بی‌خیال، برگرد</SecondaryButton>
</> </>
@@ -182,7 +270,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
return ( return (
<Screen> <Screen>
<div className="flex flex-1 flex-col justify-center gap-4 px-8"> <div className="flex flex-1 flex-col justify-center gap-4 px-8">
<ErrorState text="این درس هنوز سوالی نداره" /> <ErrorState text={nodeMode ? 'این جزیره هنوز سوالی نداره' : 'این درس هنوز سوالی نداره'} />
<SecondaryButton onClick={nav.back}>برگشت</SecondaryButton> <SecondaryButton onClick={nav.back}>برگشت</SecondaryButton>
</div> </div>
</Screen> </Screen>
@@ -193,16 +281,16 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad'; const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad';
const bubble = !answer const bubble = !answer
? 'جوابِ درست رو انتخاب کن!' ? selected === null ? 'جواب درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
: answer.correct : answer.correct
? 'آفرین! درست بود ⚡' ? combo >= 2 ? `${toFa(combo)} تا پشت هم درست! داری عالی پیش می‌ری` : PRAISE[index % PRAISE.length]
: outOfHearts : outOfHearts
? 'قلب‌هات تموم شد؛ یه کم استراحت کنیم.' ? 'قلب‌هات تموم شد؛ یه کم استراحت کنیم.'
: 'اشکالی نداره، جوابِ درست رو ببین.'; : NUDGE[index % NUDGE.length];
const shortOptions = question.options.every((o) => o.value.length <= 14); const shortOptions = question.options.every((o) => o.value.length <= 14);
const optionClass = (id: string) => { const optionClass = (id: string) => {
if (!answer) return selected === id ? 'border-coral bg-[#fff4f2] text-ink' : 'border-line bg-white text-ink'; if (!answer) return selected === id ? 'border-[#84D8FF] bg-[#DDF4FF] text-[#1581BA]' : 'border-line bg-white text-ink';
if (id === answer.correctOptionId) return 'border-green-400 bg-[#e5fdf0] text-green-700'; if (id === answer.correctOptionId) return 'border-green-400 bg-[#e5fdf0] text-green-700';
if (id === selected) return 'border-red-300 bg-[#fff0f0] text-red-500'; if (id === selected) return 'border-red-300 bg-[#fff0f0] text-red-500';
return 'border-line bg-white text-faint'; return 'border-line bg-white text-faint';
@@ -218,8 +306,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-soft"> <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 className="h-full rounded-full transition-all duration-500" style={{ width: `${((index + (answer ? 1 : 0)) / questions.length) * 100}%`, backgroundColor: color }} />
</div> </div>
<span className="w-10 flex-shrink-0 text-left text-xs font-bold text-muted"> <TraitChip char={look.char} color={look.color} correct={correctCount} combo={combo} />
{toFa(index + 1)}/{toFa(questions.length)} <span className="flex-shrink-0 text-left text-xs font-bold text-muted">
{/* «۱/۴» در اپِ ریاضی کسر خوانده می‌شد. */}
{toFa(index + 1)} از {toFa(questions.length)}
</span> </span>
</div> </div>
<HeartsRow hearts={hearts} /> <HeartsRow hearts={hearts} />
@@ -236,17 +326,27 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
!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]' !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} {bubble}
{answer && <Reaction key={`r${question.id}`} correct={answer.correct} seed={index} />}
{answer?.correct && answer.xpEarned > 0 && (
<motion.span
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 }}
className="mr-2 inline-block rounded-full bg-[#FFF3C4] px-2 py-0.5 text-[11px] font-black text-[#8A5A00]"
>
+{toFa(answer.xpEarned)} <Star size={11} className="mb-0.5 inline" fill="#E8A400" color="#E8A400" />
</motion.span>
)}
</div> </div>
</div> </div>
<div className="rounded-3xl border border-line bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-line bg-white p-5 shadow-sm">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted">سوالِ {toFa(index + 1)}</span> <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>
</div> </div>
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink">{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>
<div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}> <div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
@@ -254,41 +354,61 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
<motion.button <motion.button
key={option.id} key={option.id}
type="button" type="button"
onClick={() => void choose(option.id)} onClick={() => choose(option.id)}
disabled={selected !== null && !answer} disabled={submitting || answer !== null}
whileTap={{ scale: selected === null ? 0.95 : 1 }} aria-pressed={selected === option.id}
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`} whileTap={{ scale: answer ? 1 : 0.95 }}
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 border-b-4 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`}
> >
<span dir="auto">{option.value}</span> <span><MathText text={option.value} /></span>
{answer && option.id === answer.correctOptionId && <Check size={16} className="text-green-500" strokeWidth={3} />} {answer && option.id === answer.correctOptionId && <Check size={16} className="text-green-500" strokeWidth={3} />}
{answer && option.id === selected && !answer.correct && <X size={16} className="text-red-400" strokeWidth={3} />} {answer && option.id === selected && !answer.correct && <X size={16} className="text-red-400" strokeWidth={3} />}
</motion.button> </motion.button>
))} ))}
</div> </div>
{answer && ( <FeedbackBar kind="question" id={question.id} />
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="mt-1 flex flex-col gap-2.5"> </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 && ( {!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]"> <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> </button>
)} )}
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}> <PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
{isLast ? 'پایانِ تمرین' : 'سوالِ بعدی'} <ChevronLeft size={18} /> {isLast ? 'پایان تمرین' : 'ادامه'} <ChevronLeft size={18} />
</PrimaryButton>
</>
) : (
<PrimaryButton color={color} disabled={selected === null} loading={submitting} onClick={() => void check()}>
بررسی
</PrimaryButton> </PrimaryButton>
</motion.div>
)} )}
</div> </div>
<Sheet open={confirmExit} onClose={() => setConfirmExit(false)} title="از تمرین بیرون بری؟"> <Sheet open={confirmExit} onClose={() => setConfirmExit(false)} title="از تمرین بیرون بری؟">
<p className="mb-5 text-sm leading-7 text-muted">جواب‌هایی که دادی ثبت شده، ولی این درس تا آخرش رو نزنی تموم‌شده حساب نمی‌شه.</p> <p className="mb-5 text-sm leading-7 text-muted">
جواب‌هایی که دادی ثبت شده، ولی {nodeMode ? 'این جزیره' : 'این درس'} تا آخرش رو نزنی تموم‌شده حساب نمی‌شه.
</p>
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه می‌دم</PrimaryButton> <PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه می‌دم</PrimaryButton>
<SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton> <SecondaryButton onClick={() => nav.leave()}>خروج</SecondaryButton>
</div> </div>
</Sheet> </Sheet>
<ReportSheet open={reporting} questionId={question.id} onClose={() => setReporting(false)} />
</Screen> </Screen>
); );
} }
@@ -297,7 +417,7 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
if (hearts?.isUnlimited) { if (hearts?.isUnlimited) {
return ( return (
<div className="mt-3 flex items-center justify-center gap-1.5 text-xs font-bold text-coral"> <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> </div>
); );
} }
@@ -325,38 +445,31 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
); );
} }
function NoHearts({ nextRefillAt }: { nextRefillAt: string | null }) {
const nav = useNav();
return (
<Screen>
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
<SeyarMascot char="pi" mood="depressed" size={110} />
<h2 className="text-xl font-black text-ink">قلب‌هات تموم شد</h2>
<p className="text-sm leading-7 text-muted">
{nextRefillAt ? <>قلبِ بعدی تا <b className="text-coral">{timeUntil(nextRefillAt)}</b> دیگه پر می‌شه.</> : 'کمی صبر کن تا قلب‌هات دوباره پر بشن.'}
<br />
با اشتراکِ ویژه قلب‌ها نامحدودن.
</p>
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ اشتراک</PrimaryButton>
<SecondaryButton onClick={nav.back}>باشه، بعداً</SecondaryButton>
</div>
</Screen>
);
}
function Result({ function Result({
result, total, correct, lessonId, nodeId, color, result, total, correct, lessonId, nodeId, color, streakUp,
}: { result: LessonComplete; total: number; correct: number; lessonId: number; nodeId?: string; color: string }) { }: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string; streakUp: { from: number; to: number } | null }) {
const nav = useNav(); const nav = useNav();
const { companion } = useAppData(); const { companion, profile } = useAppData();
const [loadingNext, setLoadingNext] = useState(false); 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;
// بعد از ثبت، نقشه عوض شده؛ درسِ بعدی را از نسخه‌ی تازه‌ی نقشه می‌خوانیم. // بعد از ثبت، نقشه عوض شده؛ درسِ بعدی را از نسخه‌ی تازه‌ی نقشه می‌خوانیم.
async function goNext() { async function goNext() {
if (nodeMode) {
if (nextNode) nav.replace({ name: 'quiz', nodeId: nextNode });
else nav.back();
return;
}
setLoadingNext(true); setLoadingNext(true);
try { try {
const fresh = await api.home(); const fresh = await fetchHome(profile.gradeId);
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId); const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId!);
const next = ref ? nextLessonOf(ref.node) : null; const next = ref ? nextLessonOf(ref.node) : null;
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) { if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
nav.replace({ name: 'lesson', lessonId: next, nodeId: ref.node.id }); nav.replace({ name: 'lesson', lessonId: next, nodeId: ref.node.id });
@@ -381,9 +494,17 @@ function Result({
</div> </div>
<div> <div>
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2> <h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p> {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>
{/* نمره‌ی کم: تلاش و راه را ستایش کن، نه «باهوشی» را (پژوهش‌های ذهنیتِ رشد)؛ و راهِ بعدی را نشان بده. */}
{result.stars <= 1 && (
<p className="mt-2 text-xs leading-6 text-body">
اشتباه‌ها همون جاهایی‌ان که مغزت داره یاد می‌گیره. هر وقت خواستی از «درس‌ها» دوباره مرورش کن.
</p>
)}
</div> </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"> <div className="flex flex-1 flex-col items-center rounded-2xl border border-line bg-white py-3">
<Star size={18} className="text-gold" /> <Star size={18} className="text-gold" />
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.xpEarned)}</span> <span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.xpEarned)}</span>
@@ -394,73 +515,72 @@ function Result({
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.coinsEarned)}</span> <span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.coinsEarned)}</span>
<span className="text-[10px] text-muted">سکه</span> <span className="text-[10px] text-muted">سکه</span>
</div> </div>
</div> </div>}
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}> <PrimaryButton color={color} loading={loadingNext} onClick={() => then(() => void goNext())}>
ادامه <ChevronLeft size={18} /> {nodeMode && nextNode ? 'بریم جزیره بعدی' : 'ادامه'} <ChevronLeft size={18} />
</PrimaryButton> </PrimaryButton>
{nodeMode && nextNode && (
<button type="button" onClick={() => then(nav.back)} className="-mt-2 py-2 text-sm font-bold text-muted">
فعلاً بسه، برگردیم
</button>
)}
</div> </div>
</Screen> </Screen>
); );
} }
const REASONS: { id: ReportReason; label: string }[] = [ /**
{ id: 'wrong_answer', label: 'جوابِ درست اشتباهه' }, * پیامِ بعد از جواب. یک جمله‌ی ثابت بعد از چند بار دیگر خوانده نمی‌شود؛ چند تا به نوبت. برای جوابِ غلط
{ id: 'unclear', label: 'صورتِ سوال گنگه' }, * تلاش و راه ستایش می‌شود و قدمِ بعدی گفته می‌شود، نه این‌که «اشکالی نداره» تنها بماند.
{ id: 'typo', label: 'غلطِ تایپی داره' }, */
{ id: 'too_hard', label: 'برای این درس خیلی سخته' }, const PRAISE = ['آفرین! درست بود', 'دقیقاً همینه!', 'عالی! خوب فکر کردی', 'درسته! داری راه میفتی'];
{ id: 'other', label: 'یه چیزِ دیگه' }, 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(''); function TraitChip({ char, color, correct, combo }: { char: string; color: string; correct: number; combo: number }) {
const [sending, setSending] = useState(false); if (char === 'sigma') {
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);
}
}
return ( return (
<Sheet open={open} onClose={onClose} title="این سوال چه ایرادی داره؟"> <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 }}>
<div className="mb-4 flex flex-col gap-2"> Σ {toFa(correct)}
{REASONS.map((r) => ( </motion.span>
<button );
key={r.id} }
type="button" if (char === 'delta' && combo >= 2) {
onClick={() => setReason(r.id)} return (
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'}`} <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;
}
/** آیکنِ کوچکِ بعد از جواب (همان خانواده‌ی آیکنِ اپ، نه ایموجیِ کیبورد)؛ هر سوال یکی از مجموعه، با یک تکانِ کوتاه. */
const WIN: [LucideIcon, string][] = [[Brain, '#9B7BFF'], [Zap, '#E8A400'], [Target, '#ED6956'], [ThumbsUp, '#3E9E73'], [Sparkles, '#4C5FD5']];
const MISS: [LucideIcon, string][] = [[Lightbulb, '#E8A400'], [Ruler, '#4C5FD5'], [Compass, '#9B7BFF']];
function Reaction({ correct, seed }: { correct: boolean; seed: number }) {
const set = correct ? WIN : MISS;
const [Icon, color] = set[seed % set.length];
return (
<motion.span
aria-hidden
className="mr-1.5 inline-block align-middle"
initial={{ scale: 0.4, rotate: 0 }}
animate={{ scale: 1, rotate: [0, -14, 14, -8, 8, 0] }}
transition={{ duration: 0.6, ease: 'easeOut' }}
> >
{r.label} <Icon size={18} color={color} strokeWidth={2.4} />
</button> </motion.span>
))}
</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>
); );
} }

Some files were not shown because too many files have changed in this diff Show More