56 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
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
108 changed files with 4189 additions and 1448 deletions

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

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

138
README.md
View File

@@ -1,7 +1,20 @@
# تدریسینو — فرانتِ اپِ ریاضی # تدریسینو — اپِ ریاضی (فرانت‌اند)
اپِ وبِ موبایل‌محورِ تدریسینو (React + TypeScript + Vite + Tailwind). بک‌اندش ریپوی اپِ وبِ موبایل‌محورِ **تدریسینو**، آموزشِ ریاضی به سبکِ دولینگو برای پایه‌ی سوم تا دوازدهم: نقشه‌ای از جزیره‌ها برای هر
جداست (`Tadrisino/MathApp`)؛ قراردادِ API در `/api/docs/` همان بک‌اند است. کتاب، آزمون‌های کوتاه از مسئله‌های خودِ کتابِ درسی، دستیارِ هوشِ مصنوعی، همراه‌ها، زنجیره‌ی روزانه و اشتراک. همین
مخزن پوسته‌ی اندرویدِ اپ را هم دارد ([`android/`](android/README.md)).
بک‌اند مخزنِ جداگانه‌ای است ([`Tadrisino/MathApp`](https://gitea.tadrisino2.ir/Tadrisino/MathApp)) و قراردادِ API
اسکیمای OpenAPIِ همان‌جاست. این اپ تنها کلاینتِ آن API است.
| | |
|---|---|
| **سایتِ زنده** | `https://math.tadrisino2.ir` · API: `https://mathapi.tadrisino2.ir` |
| **فناوری** | React 18 · TypeScript (strict) · Vite 6 · Tailwind 4 · Motion |
| **وابستگی‌ها** | عمداً کم: بی‌کتابخانه‌ی router و state؛ ناوبری و داده‌ی مشترک در خودِ کد |
| **اندروید** | پوسته‌ی WebView روی همین سایت، با پرداختِ درون‌برنامه‌ایِ کافه‌بازار (Poolakey)؛ بسته‌ی `com.tadrisino.math.app` |
---
## راه‌اندازی ## راه‌اندازی
@@ -10,15 +23,14 @@ npm install
npm run dev # http://localhost:5173 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,65 +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 از کد، شش حالتِ چهره
diagram/ شکلِ جوابِ دستیار: توصیفِ JSON از بک‌اند → SVG؛ همه‌ی مختصات این‌جا حساب می‌شود diagram/ شکلِ درس و جوابِ دستیار: توصیفِ JSON از بک‌اند → SVG؛ همه‌ی مختصات این‌جا حساب می‌شود
(plane، shapes، expr، solid، charts، sets، misc، numberline). گالری: ?gallery فقط در dev lib/ اعدادِ فارسی، تاریخِ شمسی، MathText، پیامِ خطا، صدا، پلِ کافه‌بازار، روش‌های پرداخت
lib/ اعدادِ فارسی، تاریخِ شمسی، صدا، localStorage 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` عوض
شود، هشِ تازه‌اش باید در همان فایل بنشیند، وگرنه مرورگر آن را اجرا نمی‌کند.

1
android/.gitignore vendored
View File

@@ -6,4 +6,3 @@ local.properties
*.iml *.iml
*.jks *.jks
*.keystore *.keystore
build-out/

View File

@@ -5,61 +5,84 @@
محصولِ دیجیتال را فقط از راهِ خودش می‌پذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ محصولِ دیجیتال را فقط از راهِ خودش می‌پذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
فرانت به‌روز می‌شود، بی‌آن‌که نسخه‌ی تازه‌ی اپ لازم باشد. فرانت به‌روز می‌شود، بی‌آن‌که نسخه‌ی تازه‌ی اپ لازم باشد.
> ⚠️ APIِ Poolakey از README خودِ کتابخانه است و خرید روی گوشی هنوز آزموده نشده. > **ساخت و امضا با هم‌تیمی است (MH-W90، m.hassanabadi)** با روشِ امضای خودش (`my-release-key.keystore` +
> propertyهای Gradle)؛ تصمیمِ مالک این است که همان روش بماند. پیش از دست زدن به امضا از مالک بپرس و هرگز
> keystore را commit نکن. `applicationId` واقعی `com.tadrisino.math.app` است (از APKِ ساخته‌شده با aapt2).
> هیچ خریدِ واقعیِ بازار هنوز آزموده نشده.
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت) ## نسخه‌ی بعدیِ APK — چه چیزی در کد هست و در اپِ نصب‌شده نیست
| چه | کجا گذاشته می‌شود | وضعیت | این‌ها در `MainActivity.kt` نوشته شده‌اند (۱۴۰۵-۰۷، `468f5b2` و `737414f`) ولی هیچ‌کدام build نشده‌اند؛ اپی که الان روی
گوشی‌هاست بدونِ آن‌هاست. فرانت با هر دو نسخه کار می‌کند (اگر پل نبود، راهِ قدیم را می‌رود).
| تغییر | چرا | بعد از build روی گوشی بسنج |
|---|---|---| |---|---|---|
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.app` حدس است؛ تأیید نشده | | `TadrisinoAndroid.vibrate(patternJson)` با Vibratorِ گوشی | `navigator.vibrate` در WebView همیشه کار نمی‌کند؛ مالک گفت اپ لرزش ندارد | انتخابِ گزینه‌ی آزمون، جوابِ درست/غلط، پایانِ جزیره و صفحه‌ی «روزِ پیاپی» باید بلرزند؛ با خاموش کردنِ «صداها» در پروفایل نه |
| کلیدِ RSAِ عمومیِ اپ | `bazaarRsaKey` در فایلِ تنظیماتِ بیرون از ریپو (پایین) | ندارد؛ بدونِ آن build نوعِ release نمی‌شود | | `mediaPlaybackRequiresUserGesture = false` | صداهای رابط بعد از پاسخِ سرور پخش می‌شوند، نه در لحظه‌ی لمس | صدای جوابِ درست/غلط و پایانِ تمرین |
| SKUِ هر بسته (محصولِ **مصرف‌شدنی**) | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچ‌کدام تعریف نشده | | `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 دیده شود.
keystore و کلیدِ RSA هرگز داخلِ ریپو نمی‌آیند. یک فایل با دسترسیِ ۶۰۰ بسازید: `~/.tadrisino/android.properties` پیشنهادهایی که هنوز نوشته نشده‌اند:
(یا مسیرِ دلخواه در `TADRISINO_ANDROID_CONFIG`):
``` - **«برای خروج دوباره بزن»**: دکمه‌ی برگشتِ گوشی حالا قدم‌به‌قدم در خودِ وب عقب می‌رود (`src/nav/Navigator.tsx`) و
storeFile=/مسیر/کامل/tadrisino-release.jks از خانه یک‌راست بیرون می‌رود. اگر تأییدِ خروج خواستید، در `handleOnBackPressed` وقتی `!webView.canGoBack()` یک
storePassword=... Toast بدهید و فقط برگشتِ دوم در دو ثانیه `finish()` کند. در وب شدنی نیست (کروم ورودیِ history بی‌لمس را رد می‌کند).
keyAlias=... - `versionCode`/`versionName` را برای هر نسخه‌ای که منتشر می‌شود بالا ببرید؛ الان ۲ و 1.1.0 است (بازار فقط `versionCode`ِ بزرگ‌تر را می‌پذیرد).
keyPassword=...
bazaarRsaKey=<کلیدِ RSA از پیشخانِ بازار، یک خط>
```
| مقدار | از کجا | اگر نبود | قانونِ بازار: داخلِ اپِ بازار حالا پرداخت با درگاه هم پیشنهاد می‌شود (خواستِ مالک). بازار پرداختِ محتوای دیجیتال بیرون از
پرداختِ درون‌برنامه‌ای را محدود می‌کند؛ پیش از انتشار در بازار این را با بازار یا مالک روشن کنید.
ساخت روی مکِ مالک: SDK در `~/Library/Android/sdk`، JDK 17 (`/opt/homebrew/opt/openjdk@17`)، و چون Google Maven از
ایران بی‌VPN ۴۰۴ می‌دهد، `~/.gradle/init.d/google-mirror.gradle` آینه‌ی Aliyun را اضافه می‌کند؛ ساختِ اول ~۹ دقیقه.
## پیش از build — آنچه از پیشخانِ بازار لازم است
| چه | کجا گذاشته می‌شود | وضعیت (۲۰۲۶-۱۰-۱۲) |
|---|---|---| |---|---|---|
| `storeFile` تا `keyPassword` | keystoreِ شرکت. **همان فایلِ APKِ فعلی را امضا کرده؛ نسخه‌ی تازه با کلیدِ دیگر روی بازار آپدیت نمی‌شود.** دو نسخه‌ی پشتیبان در دو جای جدا | build نوعِ release با پیامِ روشن می‌ایستد | | اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app`؛ بازار محصول‌های ۱۰۳۴۲–۱۰۳۴۴ را زیرِ همین نام شناخت (API، توکنِ ساختگی) |
| `bazaarRsaKey` | پیشخان ← اپ ← پرداخت درون‌برنامه‌ای | build نوعِ release می‌ایستد (نه اپِ بی‌خرید) | | کلیدِ 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 ## build
نیازها (یک بار): JDK 17 (`brew install openjdk@17`) و Android SDK با `platforms;android-34` و `build-tools;34.0.0` در `~/Library/Android/sdk`. 1. Android Studio ← Open ← همین پوشه‌ی `android/`. Studio خودش Gradle wrapper را می‌سازد.
2. `Build ← Generate Signed App Bundle / APK`، با کلیدِ امضای شرکت. **کلیدِ امضا را گم نکنید**:
```bash بدونِ همان کلید هیچ نسخه‌ی بعدی روی بازار به‌روزرسانی نمی‌شود.
cd android && ./build-apk.sh 3. نصب روی گوشی‌ای که کافه‌بازار دارد و با حسابِ تست وارد شده.
```
خروجی `android/build-out/Tadrisino-<نسخه>-<versionCode>.apk` است. پیش از هر انتشار `versionCode` را در `app/build.gradle.kts`
بالا ببرید (بازار نسخه‌ی با شماره‌ی تکراری را نمی‌پذیرد). اسکریپت امضا را می‌سنجد و اثرانگشتِ گواهی را چاپ می‌کند؛ باید با APKِ قبلی یکی باشد.
APKِ آزمایشی بی‌نیاز از کلید: `./gradlew assembleDebug`.
## جریانِ خرید (برای عیب‌یابی) ## جریانِ خرید (برای عیب‌یابی)
``` ```
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
فرانت TadrisinoAndroid.bazaarPurchase(sku, orderId) فرانت (اختیاری) POST /api/discount/redeem {code, orderId}
اپ Poolakey.purchaseProduct(sku, payload=orderId) → purchaseToken فرانت POST /api/payment/bazaar/orders/{orderId}/start → sku، dynamicPriceToken (اگر تخفیف خورده)
فرانت TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
اپ Poolakey.subscribeProduct(sku, payload=orderId) → purchaseToken
اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken} اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken}
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار می‌پرسد فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار می‌پرسد و اشتراک را فعال می‌کند
فرانت TadrisinoAndroid.bazaarConsume(token) ← تا همان بسته دوباره خریدنی باشد
``` ```
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» می‌زند: فرانت همان سفارش را می‌فرستد اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» می‌زند: فرانت همان سفارش را می‌فرستد
و اپ توکنِ ماندهٔ همان سفارش را از `getPurchasedProducts` پیدا می‌کند. و اپ توکنِ همان سفارش را از `getSubscribedProducts` پیدا می‌کند.
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بک‌اند. قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بک‌اند.

View File

@@ -1,63 +1,93 @@
import java.util.Properties // 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 { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
} }
// تنظیماتِ حساس (keystore و کلیدِ RSAِ بازار) در ریپو نیستند. از فایلی بیرون از ریپو خوانده می‌شوند:
// $TADRISINO_ANDROID_CONFIG یا ~/.tadrisino/android.properties
// کلیدها: storeFile, storePassword, keyAlias, keyPassword, bazaarRsaKey. شرحِ کامل: README.md.
val configFile = File(System.getenv("TADRISINO_ANDROID_CONFIG") ?: "${System.getProperty("user.home")}/.tadrisino/android.properties")
val secrets = Properties().apply { if (configFile.exists()) configFile.inputStream().use { load(it) } }
// فقط وقتی build از نوعِ release است الزامی است؛ debug بدونِ آن هم ساخته می‌شود.
val wantsRelease = gradle.startParameter.taskNames.any { it.contains("release", ignoreCase = true) }
fun secret(name: String): String {
val v = secrets.getProperty(name)?.trim().orEmpty()
if (wantsRelease && v.isEmpty()) {
throw GradleException("build نوعِ release «$name» را لازم دارد. در ${configFile.path} نیست یا خالی است (README.md ← «تنظیماتِ بیرون از ریپو»).")
}
return v
}
android { android {
// ⚠️ اسمِ پکیج باید همانی باشد که در پیشخانِ بازار ثبت است. com.tadrisino.app را بازار // Package name must match the one registered on Cafe Bazaar.
// برای حسابِ شرکت «Product is not found» جواب داد (یعنی هست)، ولی سنیور تأیید نکرده // Confirm the final applicationId before publishing.
// که همان اپِ ریاضی است (CLAUDE.mdِ بک‌اند، ۲۰۲۶-۰۹-۳۰).
namespace = "com.tadrisino.app" namespace = "com.tadrisino.app"
compileSdk = 34 compileSdk = 34
defaultConfig { defaultConfig {
applicationId = "com.tadrisino.app" applicationId = "com.tadrisino.math.app"
minSdk = 23 minSdk = 23
targetSdk = 34 targetSdk = 34
versionCode = 1 // بازار نسخه‌ی تازه را فقط با versionCodeِ بزرگ‌تر می‌پذیرد؛ هر build که منتشر می‌شود یکی بالا.
versionName = "1.0.0" versionCode = 2
versionName = "1.1.0"
// آدرسِ همان فرانتِ وب. دامنه عوض شود، فقط همین خط. // URL of the web frontend.
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"") buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (بخشِ «پرداخت درون‌برنامه‌ای»). کلیدِ عمومی // کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (پرداخت درون‌برنامه‌ای)؛ سینیور، ۲۰۲۶-۱۰-۱۲. عمومی است و در APK
// است و در اپ می‌نشیند؛ رمز نیست. تا پر نشود، اپ خرید را شروع نمی‌کند. // می‌رود؛ Poolakey با آن امضای خرید را روی گوشی می‌سنجد. سنجشِ اصلی سمتِ سرور است.
// از فایلِ بیرون از ریپو؛ برای build نوعِ release خالی بودنش خطا می‌دهد (نه اپِ بی‌خرید). buildConfigField("String", "BAZAAR_RSA_KEY", "\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwC9EBw0RW6jeTdHkrJwfB/mX+klpjpVXVb2stpGDcixeqHiLE+/Iow+PiMbZEQfxZDuED6ELvZlzQX+ALOlGRPxaa1ctl6/Uy6wGaPXhGPDpZL6jPrXV88mjJzMcEoB4sf3yNb+6YNKrvKhRyXXR0JdMUmPMqMe8igmQZTv/UF+QEMNqv7lg7qw9Yl+fG802nqjasft7qUPQK+YHJYPS4PJ5Pj/YpTkGDdDmVi7AIMCAwEAAQ==\"")
buildConfigField("String", "BAZAAR_RSA_KEY", "\"${secret("bazaarRsaKey")}\"")
}
signingConfigs {
create("release") {
// release بدونِ keystore ساخته نمی‌شود؛ هرگز بی‌صدا با کلیدِ debug امضا نمی‌شود.
if (wantsRelease) {
storeFile = file(secret("storeFile"))
storePassword = secret("storePassword")
keyAlias = secret("keyAlias")
keyPassword = secret("keyPassword")
}
}
} }
buildFeatures { buildFeatures {
buildConfig = true 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 { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
@@ -77,5 +107,7 @@ android {
dependencies { dependencies {
implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-ktx:1.9.2") 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") implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
} }

View File

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

View File

@@ -2,40 +2,131 @@ package com.tadrisino.app
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent 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.net.Uri
import android.os.Build
import android.os.Bundle 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.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.JavascriptInterface
import android.webkit.WebResourceResponse
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import ir.cafebazaar.poolakey.Connection import ir.cafebazaar.poolakey.Connection
import ir.cafebazaar.poolakey.ConnectionState
import ir.cafebazaar.poolakey.Payment import ir.cafebazaar.poolakey.Payment
import ir.cafebazaar.poolakey.config.PaymentConfiguration import ir.cafebazaar.poolakey.config.PaymentConfiguration
import ir.cafebazaar.poolakey.config.SecurityCheck 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 ir.cafebazaar.poolakey.request.PurchaseRequest
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
/** /**
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درون‌برنامه‌ایِ بازار است. * همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درون‌برنامه‌ایِ بازار است.
* *
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت: * قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
* JS → اینجا TadrisinoAndroid.bazaarPurchase(sku, orderId) * JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken) (اشتراک)
* TadrisinoAndroid.bazaarConsume(purchaseToken) * TadrisinoAndroid.bazaarPrices(skusJson) → رویدادِ «tadrisino:bazaar-prices»
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا * اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
* { orderId, error: "canceled" | "failed", message } * { orderId, error: "canceled" | "failed" | "unavailable", message }
* *
* سمتِ سرور توکن را از خودِ بازار می‌سنجد (`POST /api/payment/bazaar/verify`)، پس این‌جا * سمتِ سرور توکن را از خودِ بازار می‌سنجد (`POST /api/payment/bazaar/verify`)، پس این‌جا
* فقط واسطه است و به هیچ نتیجه‌ای اعتماد نمی‌کند. * فقط واسطه است و به هیچ نتیجه‌ای اعتماد نمی‌کند.
* *
* ⚠️ نوشته شده بی‌آن‌که build شود (ماشینِ نویسنده Android SDK نداشت، ۲۰۲۶-۱۰-۰۳)؛ APIِ * ⚠️ کامپایل می‌شود (compileDebugKotlin، ۲۰۲۶-۱۰-۱۲) و امضای توابعِ Poolakey 2.2.0 از خودِ کتابخانه خوانده شد،
* Poolakey از README خودش است. پیش از انتشار با Android Studio بسازید و یک خریدِ واقعی بزنید. * ولی روی گوشی اجرا نشده؛ پیش از انتشار یک خریدِ واقعی بزنید.
*/ */
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView 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 payment: Payment? = null
private var connection: Connection? = null private var connection: Connection? = null
private val webHost: String = Uri.parse(BuildConfig.WEB_URL).host ?: "" private val webHost: String = Uri.parse(BuildConfig.WEB_URL).host ?: ""
@@ -43,22 +134,58 @@ class MainActivity : AppCompatActivity() {
/** فقط خودِ سایت و فقط با https؛ «http://دامنه‌ی ما» قابلِ دست‌کاریِ شبکه است و نباید پل داشته باشد. */ /** فقط خودِ سایت و فقط با https؛ «http://دامنه‌ی ما» قابلِ دست‌کاریِ شبکه است و نباید پل داشته باشد. */
private fun isOurPage(uri: Uri?): Boolean = uri != null && uri.scheme == "https" && uri.host == webHost 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") @SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().setKeepOnScreenCondition { !webReady }
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
webView = WebView(this) webView = WebView(this)
setContentView(webView) offlineView = buildOfflineView()
setContentView(FrameLayout(this).apply {
addView(webView)
addView(offlineView)
})
webView.settings.javaScriptEnabled = true webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true // توکنِ ورود در localStorage است webView.settings.domStorageEnabled = true // توکنِ ورود در localStorage است
// WebView فقط آدرسِ وب را می‌خواند؛ دسترسی به فایل و content:// لازم نیست. // WebView فقط آدرسِ وب را می‌خواند؛ دسترسی به فایل و content:// لازم نیست.
webView.settings.allowFileAccess = false webView.settings.allowFileAccess = false
webView.settings.allowContentAccess = false webView.settings.allowContentAccess = false
// صداهای کوتاهِ رابط (Web Audio) بعد از پاسخِ سرور پخش می‌شوند، نه درست در لحظه‌ی لمس.
webView.settings.mediaPlaybackRequiresUserGesture = false
webView.webViewClient = object : WebViewClient() { 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 { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
if (isOurPage(request.url)) return false if (isOurPage(request.url)) return false
// پرداخت با درگاه داخلِ خودِ اپ می‌ماند: زرین‌پال بی‌Refererِ دامنه‌ی ما صفحه را باز نمی‌کند و مرورگرِ
// بیرونی Referer نمی‌فرستد. بعد از پرداخت، زرین‌پال به سایتِ ما برمی‌گرداند و نتیجه همین‌جا دیده می‌شود.
if (isPaymentPage(request.url)) return false
// فقط http(s) به مرورگرِ گوشی می‌رود؛ intent:، file: و schemeهای سفارشی بی‌صدا بسته می‌شوند // فقط http(s) به مرورگرِ گوشی می‌رود؛ intent:، file: و schemeهای سفارشی بی‌صدا بسته می‌شوند
// (پیش‌تر هر آدرسی بیرونِ دامنه به ACTION_VIEW می‌رفت و صفحه می‌توانست اپِ دلخواه باز کند). // (پیش‌تر هر آدرسی بیرونِ دامنه به ACTION_VIEW می‌رفت و صفحه می‌توانست اپِ دلخواه باز کند).
if (request.url.scheme == "https" || request.url.scheme == "http") { if (request.url.scheme == "https" || request.url.scheme == "http") {
@@ -88,6 +215,7 @@ class MainActivity : AppCompatActivity() {
val config = PaymentConfiguration( val config = PaymentConfiguration(
localSecurityCheck = SecurityCheck.Enable(rsaPublicKey = BuildConfig.BAZAAR_RSA_KEY), localSecurityCheck = SecurityCheck.Enable(rsaPublicKey = BuildConfig.BAZAAR_RSA_KEY),
) )
connection?.disconnect()
payment = Payment(context = this, config = config).also { p -> payment = Payment(context = this, config = config).also { p ->
connection = p.connect { connection = p.connect {
connectionSucceed { Log.i(TAG, "bazaar connected") } connectionSucceed { Log.i(TAG, "bazaar connected") }
@@ -97,31 +225,53 @@ class MainActivity : AppCompatActivity() {
} }
} }
private fun purchase(sku: String, orderId: String) { /**
* خریدِ اشتراکِ بازار (subscribeProduct؛ فقط اشتراک فروخته می‌شود و مصرف نمی‌شود، بازار خودش تمدید می‌کند).
* dynamicPriceToken خالی یعنی قیمتِ پیشخان؛ پر یعنی قیمتی که سرور با کلیدِ «تخفیف پویا» امضا کرده
* (بازارِ ۱۳.۳ به بعد).
*/
private fun buy(sku: String, orderId: String, dynamicPriceToken: String) {
val p = payment val p = payment
if (p == null) { if (p == null || connection?.getState() !is ConnectionState.Connected) {
send(orderId, error = "failed", message = "bazaar not connected") // بازار نصب نیست، هنوز وصل نشده، یا اتصال افتاده؛ برای تلاشِ بعدی دوباره وصل می‌شویم.
connectToBazaar()
send(orderId, error = "unavailable", message = "not_connected")
return return
} }
p.purchaseProduct( val request = PurchaseRequest(
registry = activityResultRegistry, productId = sku, payload = orderId, dynamicPriceToken = dynamicPriceToken.ifEmpty { null },
request = PurchaseRequest(productId = sku, payload = orderId), )
) { p.subscribeProduct(activityResultRegistry, request) {
purchaseSucceed { send(orderId, token = it.purchaseToken) } purchaseSucceed { send(orderId, token = it.purchaseToken) }
purchaseCanceled { send(orderId, error = "canceled") } purchaseCanceled { send(orderId, error = "canceled") }
purchaseFailed { recoverOwned(sku, orderId, it) } purchaseFailed { failed(sku, orderId, it) }
failedToBeginFlow { recoverOwned(sku, orderId, it) } failedToBeginFlow { failed(sku, orderId, it) }
} }
} }
/** /**
* خریدِ قبلیِ همین محصول که مصرف نشده (پول رفته ولی تأییدِ سرور نرسیده بود) جلوی خریدِ * بازارِ نصب‌نشده یا قدیمی (بی پشتیبانیِ اشتراک یا «تخفیف پویا») کاری است که کاربر باید بکند، نه خطای خرید؛
* دوباره را می‌گیرد. فرانت در تلاشِ بعدی همان سفارش را می‌فرستد؛ اگر توکنِ ماندهٔ همان * فرانت برایش پیامِ جدا دارد. بقیه‌ی خطاها ممکن است خریدی باشند که پولش رفته، پس از بازار پرسیده می‌شوند.
* سفارش (payload) پیدا شد، همان برمی‌گردد تا سرور ثبتش کند. */
private fun failed(sku: String, orderId: String, cause: Throwable) {
val reason = when (cause) {
is BazaarNotFoundException -> "not_installed"
is BazaarNotSupportedException, is SubsNotSupportedException, is IAPNotSupportedException,
is DynamicPriceNotSupportedException -> "old_bazaar"
else -> null
}
Log.e(TAG, "bazaar purchase failed: ${reason ?: cause.javaClass.simpleName}", cause)
if (reason != null) send(orderId, error = "unavailable", message = reason)
else recoverOwned(sku, orderId, cause)
}
/**
* اشتراکِ همین محصول که پولش رفته ولی تأییدِ سرور نرسیده بود جلوی خریدِ دوباره را می‌گیرد. فرانت در تلاشِ
* بعدی همان سفارش را می‌فرستد؛ اگر اشتراکِ همان سفارش (payload) پیدا شد، توکنش برمی‌گردد تا سرور ثبتش کند.
*/ */
private fun recoverOwned(sku: String, orderId: String, cause: Throwable) { private fun recoverOwned(sku: String, orderId: String, cause: Throwable) {
val p = payment ?: return send(orderId, error = "failed", message = cause.message) val p = payment ?: return send(orderId, error = "failed", message = cause.message)
p.getPurchasedProducts { p.getSubscribedProducts {
querySucceed { owned -> querySucceed { owned ->
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId } val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
if (match != null) send(orderId, token = match.purchaseToken) if (match != null) send(orderId, token = match.purchaseToken)
@@ -131,10 +281,31 @@ class MainActivity : AppCompatActivity() {
} }
} }
private fun consume(purchaseToken: String) { /**
payment?.consumeProduct(purchaseToken) { * قیمتِ ثبت‌شده در پیشخانِ بازار برای هر SKU (متنِ آماده‌ی نمایش، مثلِ «۸۹۸,۰۰۰ تومان»)، تا فرانت روی کاشیِ
consumeSucceed { Log.i(TAG, "consumed") } * بازار همان را نشان دهد. API سرور-به-سرورِ بازار قیمت نمی‌دهد؛ فقط Poolakey روی گوشی.
consumeFailed { Log.e(TAG, "consume failed", it) } * نتیجه: رویدادِ «tadrisino:bazaar-prices» با { prices: { sku: price } } یا { error }.
*/
private fun prices(skus: List<String>) {
val p = payment ?: return sendPrices(error = "bazaar not connected")
p.getSubscriptionSkuDetails(skus) {
getSkuDetailsSucceed { list ->
val map = JSONObject()
list.forEach { map.put(it.sku, it.price) }
sendPrices(prices = map)
}
getSkuDetailsFailed { sendPrices(error = it.message ?: "failed") }
}
}
private fun sendPrices(prices: JSONObject? = null, error: String? = null) {
val detail = JSONObject()
if (prices != null) detail.put("prices", prices)
if (error != null) detail.put("error", error)
runOnUiThread {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('tadrisino:bazaar-prices', { detail: $detail }));", null,
)
} }
} }
@@ -152,21 +323,68 @@ class MainActivity : AppCompatActivity() {
/** متدهای این کلاس روی threadِ WebView صدا زده می‌شوند، نه UI. */ /** متدهای این کلاس روی threadِ WebView صدا زده می‌شوند، نه UI. */
private inner class Bridge { private inner class Bridge {
/** قراردادش در src/lib/bazaar.tsِ فرانت است. */
@JavascriptInterface @JavascriptInterface
fun bazaarPurchase(sku: String, orderId: String) { fun bazaarBuy(sku: String, orderId: String, dynamicPriceToken: String) {
runOnUiThread { runOnUiThread {
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
purchase(sku, orderId) buy(sku, orderId, dynamicPriceToken)
} }
} }
/**
* لرزشِ کوتاهِ رابط (src/lib/sound.tsِ فرانت). `navigator.vibrate` در همه‌ی WebViewها اجرا نمی‌شود؛
* این مستقیم از Vibratorِ گوشی است. ورودی آرایه‌ی JSON از میلی‌ثانیه‌ها: [لرزش، مکث، لرزش، …].
*/
@JavascriptInterface @JavascriptInterface
fun bazaarConsume(purchaseToken: String) { fun vibrate(patternJson: String) {
val pattern = try {
val arr = JSONArray(patternJson)
LongArray(arr.length()) { i -> arr.getLong(i).coerceIn(0, 1000) }
} catch (e: Exception) {
Log.w(TAG, "bad vibrate pattern", e)
return
}
if (pattern.isEmpty()) return
val vibrator = if (Build.VERSION.SDK_INT >= 31) {
(getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(VIBRATOR_SERVICE) as Vibrator
}
// [لرزش، مکث، …] → قالبِ اندروید که با مکث شروع می‌شود.
val timings = longArrayOf(0) + pattern
if (Build.VERSION.SDK_INT >= 26) vibrator.vibrate(VibrationEffect.createWaveform(timings, -1))
else @Suppress("DEPRECATION") vibrator.vibrate(timings, -1)
}
/** فرانت اولین صفحه‌ی واقعی را کشید؛ اسپلشِ سیستم می‌تواند برود. */
@JavascriptInterface
fun appReady() {
webReady = true
runOnUiThread { hideOffline() }
}
/** skusJson آرایه‌ی JSON از SKUهای اشتراک. */
@JavascriptInterface
fun bazaarPrices(skusJson: String) {
val skus = try {
val arr = JSONArray(skusJson)
List(arr.length()) { i -> arr.getString(i) }
} catch (e: Exception) {
Log.w(TAG, "bad skus", e)
return
}
runOnUiThread { runOnUiThread {
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
consume(purchaseToken) prices(skus)
} }
} }
/** فرانت با این می‌فهمد درگاه داخلِ اپ باز می‌شود (isPaymentPage)، نه در مرورگرِ بیرونی. */
@JavascriptInterface
fun paysInApp(): Boolean = true
} }
override fun onDestroy() { override fun onDestroy() {
@@ -176,5 +394,7 @@ class MainActivity : AppCompatActivity() {
companion object { companion object {
private const val TAG = "Tadrisino" 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

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

View File

@@ -4,4 +4,15 @@
<item name="android:statusBarColor">#ED6956</item> <item name="android:statusBarColor">#ED6956</item>
<item name="android:windowBackground">@android:color/white</item> <item name="android:windowBackground">@android:color/white</item>
</style> </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> </resources>

View File

@@ -1,33 +0,0 @@
#!/usr/bin/env bash
#
# یک دستور برای ساختِ APKِ امضاشده: ./build-apk.sh
#
# چیزی را حدس نمی‌زند: JDK، Android SDK، keystore یا کلیدِ RSA نبود، با پیامِ روشن می‌ایستد.
# تنظیمات از ~/.tadrisino/android.properties (یا $TADRISINO_ANDROID_CONFIG) می‌آید، نه از ریپو.
# بعد از build، امضا را با apksigner می‌سنجد و اثرانگشتِ گواهی را چاپ می‌کند تا با نسخه‌ی قبلی بخوانید:
# گواهیِ متفاوت یعنی بازار آپدیت را نمی‌پذیرد.
set -euo pipefail
cd "$(dirname "$0")"
CONFIG="${TADRISINO_ANDROID_CONFIG:-$HOME/.tadrisino/android.properties}"
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
if [ -z "${JAVA_HOME:-}" ] && [ -d /opt/homebrew/opt/openjdk@17 ]; then export JAVA_HOME=/opt/homebrew/opt/openjdk@17; fi
fail() { printf '✖ %s\n' "$1" >&2; exit 1; }
[ -n "${JAVA_HOME:-}" ] && [ -x "$JAVA_HOME/bin/java" ] || fail "JDK 17 پیدا نشد (JAVA_HOME). نصب: brew install openjdk@17"
[ -d "$ANDROID_HOME/platforms/android-34" ] || fail "Android SDK (platforms;android-34) در $ANDROID_HOME نیست."
[ -f "$CONFIG" ] || fail "فایلِ تنظیمات نیست: $CONFIG (README.md ← «تنظیماتِ بیرون از ریپو»)."
[ "$(stat -f %Lp "$CONFIG" 2>/dev/null || stat -c %a "$CONFIG")" = "600" ] || fail "دسترسیِ $CONFIG باید ۶۰۰ باشد: chmod 600 \"$CONFIG\""
[ -x ./gradlew ] || fail "gradlew نیست (gradle wrapper در ریپو کامیت می‌شود)."
./gradlew --no-daemon assembleRelease
APK="app/build/outputs/apk/release/app-release.apk"
[ -f "$APK" ] || fail "APK ساخته نشد: $APK"
APKSIGNER="$(ls -d "$ANDROID_HOME"/build-tools/*/apksigner | sort -V | tail -1)"
"$APKSIGNER" verify --print-certs "$APK" | grep -E "certificate (DN|SHA-256)"
CODE="$(grep -E '^\s*versionCode' app/build.gradle.kts | grep -oE '[0-9]+')"
NAME="$(grep -E '^\s*versionName' app/build.gradle.kts | grep -oE '"[^"]+"' | tr -d '"')"
OUT="build-out/Tadrisino-$NAME-$CODE.apk"
mkdir -p build-out && cp "$APK" "$OUT"
printf '✔ %s (versionCode %s)\n اثرانگشتِ گواهی بالا باید با APKِ قبلیِ منتشرشده یکی باشد.\n' "$OUT" "$CODE"

Binary file not shown.

View File

@@ -1,9 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
android/gradlew vendored
View File

@@ -1,248 +0,0 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

112
android/gradlew.bat vendored
View File

@@ -1,112 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%

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,6 +164,9 @@ 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>>();
@@ -233,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,
@@ -246,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);
} }
} }

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,6 +31,8 @@ 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;
} }
@@ -81,8 +96,11 @@ export const api = {
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */ /** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'), weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
/** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */ /**
home: () => http.get<T.HomeResponse>('/home'), * بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. بدونِ subject اولین کتابِ پایه؛
* کتابِ ناموجود در پایه ۴۰۰ می‌دهد (برای کلیدِ ذخیره‌شده‌ی قدیمی از `fetchHome` استفاده کنید).
*/
home: (subject?: string) => http.get<T.HomeResponse>(`/home${q({ subject })}`),
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`), nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
/** /**
@@ -118,6 +136,17 @@ export const api = {
), ),
}, },
/** لایک/دیسلایک و گزارشِ ایراد روی سوال و درس (۰ = پس‌گرفتنِ رأی). */
feedback: {
vote: (kind: 'question' | 'lesson', id: string, value: 1 | -1 | 0) =>
http.post<{ value: number }>(`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/vote`, { value }),
report: (kind: 'question' | 'lesson', id: string, reason: string, note?: string) =>
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/report`,
{ reason, ...(note ? { note } : {}) },
),
},
leaderboard: (period: T.LeaderboardPeriod) => leaderboard: (period: T.LeaderboardPeriod) =>
http.get<{ period: string; entries: T.LeaderboardEntry[] }>(`/leaderboard${q({ period })}`).then((r) => r.entries), http.get<{ period: string; entries: T.LeaderboardEntry[] }>(`/leaderboard${q({ period })}`).then((r) => r.entries),
@@ -136,7 +165,11 @@ export const api = {
* `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> {
@@ -182,6 +215,8 @@ export const api = {
*/ */
purchaseBazaar: (planId: string, idempotencyKey: string) => purchaseBazaar: (planId: string, idempotencyKey: string) =>
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }), 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 را سرور از خودِ بازار می‌سنجد؛ گفته‌ی اپ کافی نیست. */ /** توکنِ خریدِ Poolakey را سرور از خودِ بازار می‌سنجد؛ گفته‌ی اپ کافی نیست. */
verifyBazaar: (orderId: string, purchaseToken: string) => verifyBazaar: (orderId: string, purchaseToken: string) =>
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }), http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),

View File

@@ -46,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;
} }
@@ -68,6 +71,8 @@ export interface HomeUnit {
id: string; id: string;
title: string; title: string;
blurb: string; blurb: string;
/** این کاربر برای بازیِ این فصل اشتراک لازم دارد (فقط فصلِ اولِ هر پایه رایگان است). */
requiresSubscription: boolean;
nodes: HomeNode[]; nodes: HomeNode[];
} }
@@ -77,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;
} }
@@ -129,6 +149,8 @@ export interface Question {
type: string; type: string;
prompt: string; prompt: string;
options: QuestionOption[]; options: QuestionOption[];
/** شکلِ سوال (همان قالبِ شکلِ درس‌ها)؛ null یعنی شکل ندارد. */
figure?: DiagramSpec | null;
} }
export interface NodeQuizQuestion extends Question { export interface NodeQuizQuestion extends Question {
@@ -219,8 +241,6 @@ export interface AskDone {
suggestedFollowUps: string[]; suggestedFollowUps: string[];
/** سوال‌های رایگانِ باقیِ امروز. */ /** سوال‌های رایگانِ باقیِ امروز. */
remainingQuota: number; remainingQuota: number;
/** اعتبارِ بسته‌های سوال؛ منقضی نمی‌شود. */
credits?: number;
usedChunks: UsedChunk[]; usedChunks: UsedChunk[];
diagram?: Diagram | null; diagram?: Diagram | null;
} }
@@ -259,13 +279,7 @@ export interface Plan {
badge: string; badge: string;
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */ /** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
aiDailyQuota: number; aiDailyQuota: number;
/** «pack» بسته‌ی سوال است (از ۱۴۰۵-۰۷)؛ «subscription» اشتراکِ زمانیِ قدیمی. */ /** SKUِ همین اشتراک در کافه‌بازار؛ خالی یعنی از بازار خریدنی نیست. */
kind: 'pack' | 'subscription';
/** بسته: سوال‌هایی که به اعتبار اضافه می‌شود. */
questionCredits: number;
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
heartsDays: number;
/** SKUِ همین بسته در کافه‌بازار؛ خالی یعنی داخلِ اپِ بازار خریدنی نیست. */
bazaarSku: string; bazaarSku: string;
} }
@@ -276,21 +290,22 @@ export interface PlansResponse {
} }
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;
/** سوال‌های خریده‌شده‌ی باقی‌مانده. */
questionCredits: number;
/** پایانِ قلبِ نامحدودِ هدیه‌ی بسته‌ها؛ null یعنی ندارد. */
unlimitedHeartsUntil: string | null;
} }
/** GET /ai/quota */ /** GET /ai/quota */
export interface TutorQuota { export interface TutorQuota {
freeDaily: number; freeDaily: number;
remainingQuota: number; remainingQuota: number;
credits: number;
} }
export interface PurchaseResponse { export interface PurchaseResponse {
@@ -301,6 +316,16 @@ export interface PurchaseResponse {
bazaarSku?: string; 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 { export interface BazaarVerifyResponse {
orderId: string; orderId: string;
status: string; status: string;

View File

@@ -1,11 +1,15 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { AnimatePresence, MotionConfig, 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,31 +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()]); // نقشه و آمار هم‌زمان با پروفایل گرفته می‌شوند تا خانه کامل بیاید، نه اول «دارم مسیرت رو میارم» و آمارِ صفر.
// شکستشان ورود را نمی‌خواباند: AppData همان‌ها را دوباره می‌گیرد و خطای نقشه را روی خانه نشان می‌دهد.
const profileReq = api.profile.get();
const [profile, grades, prefetch] = await Promise.all([profileReq, api.grades(), prefetchMain(profileReq)]);
await trackReferral(); await trackReferral();
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades }); if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades, prefetch });
else setFlow({ kind: 'setup', profile, grades }); 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) {
@@ -55,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]);
@@ -64,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();
@@ -87,14 +122,15 @@ export function App() {
<MotionConfig reducedMotion="user"> <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' && (
@@ -122,8 +158,14 @@ 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={() => {
clearAsyncCache();
setFlow({ kind: 'login' });
}}
/> />
)} )}
</motion.div> </motion.div>
@@ -134,6 +176,18 @@ export function App() {
); );
} }
/** داده‌ی صفحه‌ی اول؛ هر کدام که نرسید null می‌ماند و AppData خودش دوباره می‌گیرد. */
async function prefetchMain(profile: Promise<UserProfile>): Promise<Prefetch> {
// کتابِ انتخاب‌شده به پایه بسته است؛ فقط وقتی چیزی ذخیره شده منتظرِ پروفایل می‌مانیم (پایه‌های ≤ دهم
// معمولاً هیچ انتخابی ندارند و نقشه هم‌زمان با پروفایل می‌آید).
const gradeId = Object.keys(subjectStore.snapshot()).length ? (await profile).gradeId : null;
const [home, summary] = await Promise.allSettled([fetchHome(gradeId), api.summary()]);
return {
home: home.status === 'fulfilled' ? home.value : null,
summary: summary.status === 'fulfilled' ? summary.value : null,
};
}
/** اگر کاربر با لینکِ دعوت آمده بود و حالا وارد شده، معرفی را ثبت کن. */ /** اگر کاربر با لینکِ دعوت آمده بود و حالا وارد شده، معرفی را ثبت کن. */
async function trackReferral() { async function trackReferral() {
const code = store.get(KEYS.referralCode); const code = store.get(KEYS.referralCode);

View File

@@ -1,9 +1,10 @@
import { lazy, Suspense, useEffect, useState, type ComponentType } 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';
@@ -28,7 +29,7 @@ const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScree
const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen'); const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen');
const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen'); const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen');
const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen'); const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen');
const MapScreen = screen(() => import('@/screens/MapScreen'), 'MapScreen'); const LessonsScreen = screen(() => import('@/screens/LessonsScreen'), 'LessonsScreen');
const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen'); const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen');
const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen'); const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen');
const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen'); const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen');
@@ -50,15 +51,25 @@ function preloadScreens() {
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;
} }
/** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */ /** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */
export function MainApp({ profile, grades, payment, onSignedOut }: Props) { export function MainApp({ profile, grades, prefetch, stale, onIdentityChanged, payment, onSignedOut }: Props) {
useEffect(preloadScreens, []); 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} /> <Screens onSignedOut={onSignedOut} />
{payment && <PaymentResult payment={payment} />} {payment && <PaymentResult payment={payment} />}
@@ -67,38 +78,219 @@ export function MainApp({ profile, grades, payment, onSignedOut }: Props) {
); );
} }
/**
* صحنه‌ی صفحه‌ها. صفحه‌ی تازه اول پنهان زیرِ صفحه‌ی فعلی ساخته می‌شود و فقط وقتی داده‌ی اولش رسید نشان داده
* می‌شود (ScreenGate در useAsync)، با یک حرکتِ کوتاه. قبلاً صفحه همان لحظه نیمه‌کاره می‌آمد (کارتِ خالی، «نداری»،
* عددِ صفر) و چند صد میلی‌ثانیه بعد داده‌ی واقعی جایش می‌پرید؛ همان «چشمک»ی که روی گوشی دیده می‌شد.
*
* اگر جواب کند باشد، بعد از HOLD_MAX_MS صفحه با لودرِ خودش می‌آید تا لمسِ کاربر بی‌جواب نماند؛ در این فاصله
* نوارِ باریکِ بالا نشان می‌دهد که کاری در جریان است. عددها از راهنمای رایجِ پاسخ‌گویی آمده‌اند (زیرِ ~۱۰۰ms
* «آنی»، تا ~۴۰۰ms هنوز پیوسته)؛ روی گوشیِ واقعی با شبکه‌ی ضعیف سنجیده نشده‌اند.
*/
const HOLD_MAX_MS = 450;
const HOLD_BAR_AFTER_MS = 120;
interface Layer {
id: number;
route: Route;
direction: NavDirection;
/** ریشه‌ی پشته است (تب)، نه صفحه‌ای که روی آن باز شده؛ نوارِ پایین فقط آن‌جا هست. */
root: boolean;
shown: boolean;
}
function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) { 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 }}
>
<Suspense fallback={<LoadingState />}>
<RouteView route={route} onSignedOut={onSignedOut} />
</Suspense>
</motion.div>
</AnimatePresence>
</div> </div>
{showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />} {showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />}
</> </>
); );
} }
const ENTER: Record<NavDirection, { opacity: number; x: number }> = {
// در راست‌به‌چپ صفحه‌ی بعدی از چپ می‌آید و برگشت از راست؛ تعویضِ تب فقط محو است، مثلِ اپ‌های بومی.
push: { opacity: 0, x: -28 },
replace: { opacity: 0, x: -28 },
back: { opacity: 0, x: 28 },
tab: { opacity: 0, x: 0 },
};
function ScreenLayer({ layer, onReady, onSettled, onSignedOut }: {
layer: Layer;
onReady: (id: number) => void;
onSettled: (id: number) => void;
} & Pick<Props, 'onSignedOut'>) {
const { id, shown } = layer;
const initiallyShown = useRef(shown).current;
const gate = useGate(initiallyShown ? null : () => onReady(id));
// اگر onAnimationComplete نرسید (مثلاً «کاهشِ حرکت» یا قطعِ انیمیشن)، صفحه‌های زیرین باز هم برداشته شوند.
useEffect(() => {
if (!shown || initiallyShown) return;
const t = setTimeout(() => onSettled(id), 700);
return () => clearTimeout(t);
}, [shown, initiallyShown, id, onSettled]);
return (
<ScreenGateContext.Provider value={gate}>
<motion.div
className="absolute inset-0 flex flex-col bg-page"
initial={initiallyShown ? false : ENTER[layer.direction]}
animate={shown ? { opacity: 1, x: 0 } : ENTER[layer.direction]}
transition={{ duration: layer.direction === 'tab' ? 0.14 : 0.2, ease: [0.2, 0.8, 0.2, 1] }}
onAnimationComplete={() => shown && onSettled(id)}
data-layer={id}
style={{ pointerEvents: shown ? 'auto' : 'none', zIndex: id }}
aria-hidden={!shown}
// صفحه‌ی پنهان نباید با Tab صفحه‌کلید فوکوس بگیرد. (React 18 «inert» را فقط به‌صورتِ رشته رد می‌کند.)
{...((!shown ? { inert: '' } : {}) as object)}
>
<Suspense fallback={<SuspenseHold />}>
<RouteView route={layer.route} onSignedOut={onSignedOut} />
</Suspense>
</motion.div>
</ScreenGateContext.Provider>
);
}
/**
* شمارنده‌ی holdهای صفحه. وقتی بعد از mount هیچ holdی باز نماند، یا HOLD_MAX_MS گذشت، onReady یک بار صدا
* زده می‌شود. React افکت‌های فرزند را پیش از پدر اجرا می‌کند، پس تا افکتِ این‌جا همه‌ی useAsyncهای صفحه
* hold گرفته‌اند.
*/
function useGate(onReady: (() => void) | null): ScreenGate | null {
const readyRef = useRef(onReady);
readyRef.current = onReady;
const state = useRef({ count: 0, mounted: false, done: onReady === null });
const fire = useCallback(() => {
const s = state.current;
if (s.done) return;
s.done = true;
readyRef.current?.();
}, []);
const gate = useMemo<ScreenGate | null>(
() =>
onReady === null
? null
: {
hold() {
const s = state.current;
if (s.done) return () => {};
s.count++;
let released = false;
return () => {
if (released) return;
released = true;
s.count--;
// یک microtask صبر: در StrictMode (و جابه‌جاییِ Suspense) افکت‌ها پاک و دوباره اجرا می‌شوند و
// hold تازه بلافاصله گرفته می‌شود؛ بی‌این صبر دروازه پیش از رسیدنِ داده باز می‌شد.
queueMicrotask(() => {
if (s.mounted && s.count === 0) fire();
});
};
},
},
// فقط بارِ اول؛ لایه‌ای که یک بار نشان داده شد دیگر دروازه ندارد.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
useEffect(() => {
const s = state.current;
if (s.done) return;
s.mounted = true;
if (s.count === 0) {
fire();
return;
}
const t = setTimeout(fire, HOLD_MAX_MS);
return () => {
s.mounted = false;
clearTimeout(t);
};
}, [fire]);
return gate;
}
/** تا فایلِ صفحه از شبکه نرسیده، دروازه بسته می‌ماند (معمولاً از قبل در پس‌زمینه گرفته شده). */
function SuspenseHold() {
const gate = useContext(ScreenGateContext);
useEffect(() => {
const release = gate?.hold();
// آزاد کردن خودش یک microtask صبر می‌کند (useGate)، پس useAsyncهای صفحه‌ی تازه پیش از آن hold گرفته‌اند.
return () => release?.();
}, [gate]);
return <LoadingState />;
}
/** نوارِ باریکِ بالای صفحه وقتی صفحه‌ی بعدی بیش از HOLD_BAR_AFTER_MS منتظرِ داده است. */
function HoldBar({ active }: { active: boolean }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!active) {
setVisible(false);
return;
}
const t = setTimeout(() => setVisible(true), HOLD_BAR_AFTER_MS);
return () => clearTimeout(t);
}, [active]);
return (
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 z-[9999] h-[3px] overflow-hidden">
{visible && <div className="hold-bar h-full w-1/3 rounded-full bg-white/90 shadow-[0_0_6px_rgba(237,105,86,.8)]" />}
</div>
);
}
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) { 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} />;
@@ -149,14 +341,14 @@ function PaymentResult({ payment }: { payment: PaymentReturn }) {
<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"> <h2 className="text-lg font-black text-ink">
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیتِ پرداخت معلوم نشد' : 'پرداخت انجام نشد'} {paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیت پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
</h2> </h2>
<p className="text-sm leading-7 text-muted"> <p className="text-sm leading-7 text-muted">
{paid {paid
? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.' ? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.'
: state === 'unknown' : state === 'unknown'
? 'چند دقیقه‌ی دیگه از «پرداخت‌ها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال می‌شه یا پول خودکار برمی‌گرده.' ? 'چند دقیقه دیگه از «پرداخت‌ها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال می‌شه یا پول خودکار برمی‌گرده.'
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'} : 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طی چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'}
</p> </p>
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton> <PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
</div> </div>

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

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

View File

@@ -1,15 +1,20 @@
import { Brain, ShoppingBag, 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: ShoppingBag, 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,11 +23,15 @@ 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 min-w-14 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-[11px] font-bold">{label}</span> <span className="text-[11px] font-bold">{label}</span>

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

@@ -1,4 +1,4 @@
import { forwardRef } from 'react'; import { forwardRef, memo, useMemo } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Check, Lock, Play, Star } from 'lucide-react'; import { Check, Lock, Play, Star } from 'lucide-react';
import type { HomeNode } from '@/api'; import type { HomeNode } from '@/api';
@@ -6,6 +6,9 @@ import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
import { iconFor } from '@/components/NodeIcon'; import { iconFor } from '@/components/NodeIcon';
import { shade } from '@/lib/color'; import { shade } from '@/lib/color';
import { MathText } from '@/lib/MathText'; 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 NODE_SIZE = 76;
const SPACING = 128; const SPACING = 128;
@@ -25,12 +28,18 @@ export const IslandPath = forwardRef<HTMLDivElement, {
onOpen: (node: HomeNode) => void; onOpen: (node: HomeNode) => void;
onStart: (node: HomeNode) => void; onStart: (node: HomeNode) => void;
}>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) { }>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) {
const points = zigzagPositions(nodes.length, SPACING, TOP); const points = useMemo(() => zigzagPositions(nodes.length, SPACING, TOP), [nodes.length]);
const intro = useFirstVisit(`islands:${nodes[0]?.id ?? ''}`);
const height = points.length ? points[points.length - 1].y + 80 : 120; const height = points.length ? points[points.length - 1].y + 80 : 120;
const { char } = useCompanion();
const buddies = useMemo(() => buddySpots(nodes, points, currentId), [nodes, points, currentId]);
return ( return (
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}> <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)} /> <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) => { {nodes.map((node, i) => {
const locked = node.status === 'locked'; const locked = node.status === 'locked';
const done = node.status === 'completed'; const done = node.status === 'completed';
@@ -40,7 +49,7 @@ export const IslandPath = forwardRef<HTMLDivElement, {
<motion.div <motion.div
key={node.id} key={node.id}
ref={current ? currentRef : undefined} ref={current ? currentRef : undefined}
initial={{ opacity: 0, scale: 0.7 }} initial={intro ? { opacity: 0, scale: 0.7 } : { opacity: 1, scale: 1 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ delay: Math.min(i, 8) * 0.04, type: 'spring', stiffness: 220, damping: 16 }} transition={{ delay: Math.min(i, 8) * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
className="absolute -translate-x-1/2 -translate-y-1/2" className="absolute -translate-x-1/2 -translate-y-1/2"
@@ -98,7 +107,7 @@ export const IslandPath = forwardRef<HTMLDivElement, {
points[i].x >= 50 ? 'right-full mr-3 text-right' : 'left-full ml-3 text-left' 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'}`} } ${current ? 'text-[12px] text-ink' : locked ? 'text-faint' : 'text-muted'}`}
> >
<span className="line-clamp-2"><MathText text={node.title} /></span> <span className="line-clamp-3"><MathText text={node.title} /></span>
</div> </div>
</motion.div> </motion.div>
); );
@@ -106,3 +115,75 @@ export const IslandPath = forwardRef<HTMLDivElement, {
</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

@@ -9,8 +9,8 @@ import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText'; import { MathText } from '@/lib/MathText';
/** /**
* برگه‌ی جزیره. کارِ اصلی یک دکمه است: «شروعِ آزمونِ جزیره». فهرستِ درس‌ها * برگه‌ی جزیره. دو دکمه: «اول درس‌ها رو بخون» (رنگی و بالا، چون تست‌کننده‌ها
* (معرفیِ هر نوع مسئله) زیرِ آن و بسته است، برای بچه‌ای که اول می‌خواهد بخواند. * دکمه‌ی کم‌رنگِ قبلی را پیدا نمی‌کردند) و «شروعِ آزمونِ جزیره». فهرستِ درس‌ها زیرِ دکمه‌ی اول باز می‌شود.
*/ */
export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) { export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
const nav = useNav(); const nav = useNav();
@@ -48,27 +48,28 @@ export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; c
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft"> <span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
<Lock size={24} className="text-muted" /> <Lock size={24} className="text-muted" />
</span> </span>
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره‌ی قبلی رو تموم کن تا باز بشه.</p> <p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره قبلی رو تموم کن تا باز بشه.</p>
</div> </div>
) : ( ) : (
<> <>
<p className="mb-4 text-sm leading-7 text-body"> <p className="mb-4 text-sm leading-7 text-body">
{done {done
? 'این جزیره رو تموم کردی! می‌تونی دوباره تمرینش کنی (امتیازِ تازه نداره).' ? 'این جزیره رو تموم کردی! می‌تونی دوباره تمرینش کنی (امتیاز تازه نداره).'
: 'چند سوالِ کوتاه از همین جزیره. هر جوابی بدی، آخرش جزیره‌ی بعدی باز می‌شه.'} : 'اگه مطمئن نیستی، اول درس‌ها رو بخون؛ بعد آزمون کوتاه جزیره رو بده. هر جوابی بدی، آخرش جزیره بعدی باز می‌شه.'}
</p> </p>
<PrimaryButton color={color} onClick={start}>
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروعِ آزمونِ جزیره</>}
</PrimaryButton>
<PrimaryButton color={color} onClick={start}>
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروع آزمون جزیره</>}
</PrimaryButton>
<button <button
type="button" type="button"
onClick={() => setShowLessons((s) => !s)} onClick={() => setShowLessons((s) => !s)}
className="mt-3 flex w-full items-center justify-center gap-1.5 py-2 text-sm font-bold text-muted" 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} aria-expanded={showLessons}
> >
<BookOpen size={15} /> اول درس‌هاش رو بخونم <BookOpen size={18} /> {done ? 'مرور درس‌ها' : 'اول درس‌ها رو بخون'}
<ChevronDown size={15} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} /> <ChevronDown size={16} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} />
</button> </button>
{showLessons && ( {showLessons && (

View File

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

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

@@ -4,7 +4,8 @@ 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 { shade } from '@/lib/color'; import { onWhite, shade } from '@/lib/color';
import { useCompanion } from '@/state/companion';
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */ /* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
@@ -14,6 +15,7 @@ export function ScreenHeader({
color, color,
back = false, back = false,
mascot, mascot,
exact = false,
children, children,
}: { }: {
title: ReactNode; title: ReactNode;
@@ -21,18 +23,27 @@ 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, backgroundImage: `linear-gradient(160deg, ${shade(color, 0.12)} 0%, ${color} 55%, ${shade(color, -0.06)} 100%)` }} 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 && (typeof back === 'function' || nav.canGoBack) && ( {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">
@@ -44,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>
@@ -111,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 }}
/> />
@@ -120,10 +131,11 @@ export function Spinner({ size = 20, className = '' }: { size?: number; classNam
/* ─── حالت‌های بارگذاری / خطا / خالی ───────────────────────────────────────── */ /* ─── حالت‌های بارگذاری / خطا / خالی ───────────────────────────────────────── */
function StateBox({ char, mood, text, children }: { char: SeyarChar; mood: SeyarMood; text: ReactNode; children?: ReactNode }) { function StateBox({ char, mood, text, children }: { char?: SeyarChar; mood: SeyarMood; text: ReactNode; children?: ReactNode }) {
const companion = useCompanion().char;
return ( 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>
@@ -135,7 +147,7 @@ function StateBox({ char, mood, text, children }: { char: SeyarChar; mood: Seyar
* همان چند فریم، پرشِ ریزِ «لودر ← محتوا» می‌ساخت؛ حالا تا آن لحظه جای خالی می‌ماند. اگر پاسخ * همان چند فریم، پرشِ ریزِ «لودر ← محتوا» می‌ساخت؛ حالا تا آن لحظه جای خالی می‌ماند. اگر پاسخ
* کند باشد کاربر باز هم لودر را می‌بیند. در مرورگر با شبکه‌ی کند آزموده نشده. * کند باشد کاربر باز هم لودر را می‌بیند. در مرورگر با شبکه‌ی کند آزموده نشده.
*/ */
export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'sigma' as SeyarChar, delayMs = 300 }) { export function LoadingState({ text = 'یه لحظه صبر کن…', char, delayMs = 300 }: { text?: string; char?: SeyarChar; delayMs?: number }) {
const [show, setShow] = useState(delayMs <= 0); const [show, setShow] = useState(delayMs <= 0);
useEffect(() => { useEffect(() => {
if (delayMs <= 0) return; if (delayMs <= 0) return;
@@ -147,7 +159,7 @@ export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'si
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"
@@ -161,7 +173,7 @@ export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?
); );
} }
export function EmptyState({ text, char = 'pi' as SeyarChar }: { text: string; char?: SeyarChar }) { export function EmptyState({ text, char }: { text: string; char?: SeyarChar }) {
return <StateBox char={char} mood="idle" text={text} />; return <StateBox char={char} mood="idle" text={text} />;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'; import { createContext, type ReactNode } from 'react';
import type { ColorName } from './types'; import type { ColorName } from './types';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
@@ -25,6 +25,21 @@ export const COLORS: Record<ColorName, string> = {
/** ترتیبِ رنگِ سری‌ها وقتی مدل رنگ نداده (نمودارِ دوم، ستونِ سوم، …). */ /** ترتیبِ رنگِ سری‌ها وقتی مدل رنگ نداده (نمودارِ دوم، ستونِ سوم، …). */
export const SERIES: ColorName[] = ['blue', 'red', 'green', 'orange', 'purple', 'teal', 'pink', 'yellow']; 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 INK = COLORS.black;
export const SOFT = '#9EA3B8'; export const SOFT = '#9EA3B8';
export const GRID = '#E4E6F0'; export const GRID = '#E4E6F0';
@@ -141,3 +156,10 @@ export function piLabel(t: number): string {
const head = n === 1 ? 'π' : `${toFa(n)}π`; const head = n === 1 ? 'π' : `${toFa(n)}π`;
return den === 1 ? `${sign}${head}` : `${sign}${head}/${toFa(den)}`; return den === 1 ? `${sign}${head}` : `${sign}${head}/${toFa(den)}`;
} }
/**
* زیرِ شکلِ سوالِ آزمون، شمارشِ خودکار («۲ تکه از ۵ تکه»، «۶ خانه از ۱۲ خانه»، «۶ × ۴ = ۲۴»، «۹ تا»)
* جوابِ سوال را لو می‌داد؛ آزمون این را روشن می‌کند و شکل فقط خودِ داده را نشان می‌دهد.
* در درس و جوابِ دستیار شمارش می‌ماند چون آنجا توضیح است، نه سوال.
*/
export const HideCounts = createContext(false);

View File

@@ -117,6 +117,14 @@ export interface SolidSpec {
labels?: { a?: string; b?: string; c?: string; r?: string; h?: string }; 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 = export type DiagramSpec =
| PlaneSpec | NumberLineSpec | FractionSpec | GridSpec | GroupsSpec | ClockSpec | PlaneSpec | NumberLineSpec | FractionSpec | GridSpec | GroupsSpec | ClockSpec
| ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec | PatternSpec; | ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec | PatternSpec | SpaceSpec;

View File

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

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

@@ -0,0 +1,47 @@
import { MathText } from './MathText';
import { toFa } from './format';
/**
* متنِ درس که قدم‌هایش در یک پاراگراف پشتِ هم آمده‌اند («۱. مجموعه را بنویسید. ۲. بررسی کنید… ۳. …»):
* ۱۰۴۱ مقدمه‌ی درس در دیتابیس همین شکل‌اند (۱۴۰۵-۰۷) و روی صفحه یک تکه‌ی درهم دیده می‌شدند. این‌جا به
* فهرستِ شماره‌دار تبدیل می‌شوند؛ متنِ بی‌فهرست همان پاراگرافِ قبلی می‌ماند.
*
* فقط دنباله‌ی ۱، ۲، ۳، … با «.» یا «)» و فاصله‌ی بعدش شمرده می‌شود، پس «۳.۵» (اعشار) و «شکلِ ۲. سپس» که
* پیش از آن ۱ ندارد فهرست نمی‌شوند.
*/
const MARKER = /(^|\s)([0-9۰-۹]{1,2})[.)](?=\s)/g;
const num = (d: string) => Number(d.replace(/[۰-۹]/g, (c) => String(c.charCodeAt(0) - 0x06f0)));
export function parseNumbered(text: string): { lead: string; items: string[] } | null {
const marks: { start: number; end: number }[] = [];
for (const m of text.matchAll(MARKER)) {
if (num(m[2]) !== marks.length + 1) continue;
const start = (m.index ?? 0) + m[1].length;
marks.push({ start, end: start + m[2].length + 1 });
}
if (marks.length < 2) return null;
return {
lead: text.slice(0, marks[0].start).trim(),
items: marks.map((mk, i) => text.slice(mk.end, marks[i + 1]?.start ?? text.length).trim()),
};
}
export function StepText({ text, color, className = '' }: { text: string; color: string; className?: string }) {
const list = parseNumbered(text);
if (!list) return <p className={`whitespace-pre-line ${className}`}><MathText text={text} display /></p>;
return (
<div className={className}>
{list.lead && <p className="mb-2 whitespace-pre-line"><MathText text={list.lead} display /></p>}
<ol className="flex flex-col gap-2.5">
{list.items.map((item, i) => (
<li key={i} className="flex gap-2.5">
<span className="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
{toFa(i + 1)}
</span>
<span className="flex-1 whitespace-pre-line"><MathText text={item} display /></span>
</li>
))}
</ol>
</div>
);
}

View File

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

View File

@@ -10,3 +10,22 @@ export function shade(hex: string, amount: number): string {
const r = channel((n >> 16) & 255), g = channel((n >> 8) & 255), b = channel(n & 255); 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')}`; 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,46 +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);
osc.start(t); for (const [mult, level] of [[1, 1], [2, 0.18], [3, 0.06]] as const) {
osc.stop(t + duration + 0.02); const osc = audio.createOscillator();
} const g = audio.createGain();
osc.type = type;
/** osc.frequency.setValueAtTime(frequency * mult, t);
* لرزشِ کوتاه کنارِ صدا، با همان کلیدِ «صدا» خاموش می‌شود. فقط اندروید/کروم دارد؛ g.gain.value = level;
* Safariِ آیفون `vibrate` ندارد و این‌جا هیچ کاری نمی‌کند. osc.connect(g).connect(gain);
*/ osc.start(t);
function buzz(pattern: number | number[]) { osc.stop(t + duration + 0.03);
if (!soundEnabled() || typeof navigator.vibrate !== 'function') return;
try {
navigator.vibrate(pattern);
} catch {
/* بعضی WebViewها بی‌اجازه throw می‌کنند؛ لرزش فقط تزئینی است */
} }
} }
/** «فوش»ِ آتش برای زنجیره: نویز از یک فیلترِ بالارونده. */
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);
buzz(30); },
/** `pitch`: نیم‌پرده بالاتر برای همراهِ انتخابی (state/companion)؛ هر همراه صدای خودش را دارد. */
correct: (pitch = 0) => {
const k = 2 ** (pitch / 12);
note(E5 * k, 0, 0.12, 0.05);
note(C6 * k, 0.09, 0.22, 0.05);
buzz(25);
},
/** دلتا: جوابِ درستِ پشتِ هم یک لرزشِ دوتایی و یک نتِ بالاتر اضافه دارد. */
combo: () => {
note(G6, 0.2, 0.15, 0.03);
buzz([20, 40, 30]);
}, },
wrong: () => { wrong: () => {
tone(220, 0, 0.25, 0.06); note(311, 0, 0.16, 0.045, 'triangle');
buzz([40, 60, 40]); note(233, 0.12, 0.24, 0.045, 'triangle');
buzz([35, 50, 35]);
}, },
finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)), /** پایانِ درس/جزیره: آرپژِ بالارونده و یک آکوردِ کوتاه. */
finish: () => {
[C5, E5, G5, C6].forEach((f, i) => note(f, i * 0.09, 0.22, 0.045));
[C6, E6, G6].forEach((f) => note(f, 0.4, 0.5, 0.025));
buzz([30, 60, 30, 60, 90]);
},
/** زنجیره روشن ماند: «فوش»ِ آتش و دو نتِ بالارونده. */
streak: () => {
whoosh(0, 0.55, 0.06);
note(G5, 0.45, 0.18, 0.045);
note(C6, 0.58, 0.4, 0.05);
buzz([20, 40, 20, 40, 160]);
},
/** لرزشِ بسیار کوتاه برای تعویضِ تب؛ بی‌صدا. */
tap: () => buzz(6),
}; };

View File

@@ -28,4 +28,9 @@ export const KEYS = {
conversationId: 'tadrisino.conversationId', conversationId: 'tadrisino.conversationId',
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;

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;
setLoading(true); const hit = key !== undefined && cache.has(key);
if (hit) {
// کلید عوض شده (مثلاً «هفتگی» ← «همیشه») و برای کلیدِ تازه کش هست: همان را نشان بده و بی‌لودر تازه کن.
setDataState(cache.get(key) as T);
setLoading(false);
} else {
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;
setError(err); if (hit) {
// داده‌ی کش‌شده روی صفحه است؛ شکستِ تازه‌سازی آن را با صفحه‌ی خطا عوض نمی‌کند، ولی دیده می‌شود.
console.warn(`refresh of ${key} failed; showing cached data`, err);
} else {
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

@@ -2,6 +2,11 @@ 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 ثابت است). // گالریِ شکل‌ها فقط در حالتِ توسعه؛ در build اصلاً وارد نمی‌شود (import.meta.env.DEV ثابت است).
const DiagramGallery = import.meta.env.DEV const DiagramGallery = import.meta.env.DEV

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,5 @@
/** /**
* همراه‌های اپ: سیگما (Σ)، دلتا (Δ)، پی (π) و تدی. «سه‌یار» نامِ قدیمیِ سه‌تای اول است؛ در متنِ * همراه‌های اپ: سیگما (Σ)، دلتا (Δ)، پی (π) و امگا (ω). «سه‌یار» نامِ قدیمیِ سه‌تای اول است؛ در متنِ
* کاربر تعدادِ همراه‌ها نوشته نمی‌شود، چون کاتالوگ از ادمین می‌آید و بزرگ می‌شود. * کاربر تعدادِ همراه‌ها نوشته نمی‌شود، چون کاتالوگ از ادمین می‌آید و بزرگ می‌شود.
* *
* هر سه از دلِ یک دفترچه‌ی بی‌نهایت آمده‌اند و هر کدام از جنسِ یک نمادِ ریاضی‌اند: * هر سه از دلِ یک دفترچه‌ی بی‌نهایت آمده‌اند و هر کدام از جنسِ یک نمادِ ریاضی‌اند:
@@ -89,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);
@@ -103,6 +109,7 @@ function seyarDeltaSVG(mood: SeyarMood): string {
<path d="M100 30 Q112 30 118 42 L160 156 Q166 172 150 174 L50 174 Q34 172 40 156 L82 42 Q88 30 100 30 Z" fill="${C.body}" stroke="${C.ink}" stroke-width="6" stroke-linejoin="round"/> <path d="M100 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>`;
} }
@@ -121,11 +128,12 @@ function seyarPiSVG(mood: SeyarMood): string {
<ellipse cx="100" cy="118" rx="74" ry="72" fill="${C.body}" stroke="${C.ink}" stroke-width="6"/> <ellipse cx="100" cy="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>`; </g></svg>`;
} }
/** /**
* تدی — شکلکِ زردِ پیش‌نمایشِ موبایل (mascot-pack، ریپوی بک‌اند، پوشه‌ی * امگا (ω؛ تا ۱۴۰۵-۰۷ «تدی»؛ کلیدِ داخلی‌اش هنوز `tedi` است چون slugِ سرور همین است) — شکلکِ زردِ پیش‌نمایشِ موبایل (mascot-pack، ریپوی بک‌اند، پوشه‌ی
* mobile-preview). بدن، گوش‌ها، پاها و دست‌ها عیناً از همان پک است؛ چهره‌ی * mobile-preview). بدن، گوش‌ها، پاها و دست‌ها عیناً از همان پک است؛ چهره‌ی
* «عصبانی» و «ناامید» را پک نداشت و با همان زبانِ طراحیِ سه‌یار اضافه شد. * «عصبانی» و «ناامید» را پک نداشت و با همان زبانِ طراحیِ سه‌یار اضافه شد.
*/ */
@@ -182,6 +190,7 @@ function tediSVG(mood: SeyarMood): string {
<ellipse cx="100" cy="116" rx="72" ry="70" fill="${C.body}" ${S}/> <ellipse cx="100" cy="116" rx="72" ry="70" fill="${C.body}" ${S}/>
<ellipse cx="100" cy="133" rx="44" ry="45" fill="${C.belly}"/> <ellipse cx="100" cy="133" rx="44" ry="45" fill="${C.belly}"/>
${hands}${cheeks}<g>${eyes}</g>${mouth} ${hands}${cheeks}<g>${eyes}</g>${mouth}
${letterBadge(100, 166, '&#969;', '#fff', C.ink)}
</g></svg>`; </g></svg>`;
} }
@@ -190,9 +199,9 @@ export const SEYAR_SVG: Record<SeyarChar, (mood: SeyarMood) => string> = {
}; };
export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = { 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: "رفیقِ همیشگیِ تدریسینو" }, tedi: { name: "امگا", symbol: "ω", role: "رفیق همیشگی تدریسینو ریاضی" },
}; };
/** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراه‌ها نگاشت می‌کند. */ /** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراه‌ها نگاشت می‌کند. */

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,8 +1,9 @@
/** همه‌ی صفحه‌های اپ و پارامترهایشان. */ /** همه‌ی صفحه‌های اپ و پارامترهایشان. */
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 }
@@ -20,9 +21,9 @@ export type Route =
| { name: 'invoices' } | { name: 'invoices' }
| { name: 'invite' }; | { 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

@@ -12,20 +12,31 @@ import { Diagram } from '@/diagram';
import { MathText } from '@/lib/MathText'; 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 سوالِ رایگانِ امروز + Y سوالِ خریده‌شده»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب. // «X سوالِ امروز»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
const [quota, setQuota] = useState<{ free: number; credits: number } | null>(null); const [quota, setQuota] = useState<number | null>(null);
const [quotaDone, setQuotaDone] = useState(false);
useEffect(() => { useEffect(() => {
api.ai.quota().then((q) => setQuota({ free: q.remainingQuota, credits: q.credits })).catch(() => {}); api.ai
.quota()
.then((q) => setQuota(q.remainingQuota))
.catch((err: unknown) => console.warn('ai quota lookup failed', err))
.finally(() => setQuotaDone(true));
}, []); }, []);
// صفحه وقتی می‌آید که تاریخچه و سهمیه رسیده باشند؛ وگرنه اول خوش‌آمد و بعد پیام‌های قبلی رویش می‌پرید.
useGateHold(!chat.historyLoaded || !quotaDone);
const listEnd = useRef<HTMLDivElement>(null); const listEnd = useRef<HTMLDivElement>(null);
const autoSent = useRef(false); const autoSent = useRef(false);
@@ -80,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) => {
@@ -95,7 +109,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
diagram: done.diagram ?? null, diagram: done.diagram ?? null,
}); });
askStore.setConversation(done.conversationId); askStore.setConversation(done.conversationId);
setQuota((q) => ({ free: done.remainingQuota, credits: done.credits ?? q?.credits ?? 0 })); setQuota(done.remainingQuota);
} catch (err) { } catch (err) {
// سقفِ روزانه (نه throttleِ چندثانیه‌ای): «دوباره» تا فردا بی‌فایده است. // سقفِ روزانه (نه throttleِ چندثانیه‌ای): «دوباره» تا فردا بی‌فایده است.
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال'); const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
@@ -103,7 +117,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
text: friendlyError(err, 'جوابی نرسید'), text: friendlyError(err, 'جوابی نرسید'),
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }), ...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
}); });
if (quotaReached) setQuota({ free: 0, credits: 0 }); if (quotaReached) setQuota(0);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -124,14 +138,14 @@ 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"> <div className="mt-3 flex flex-wrap items-center gap-2">
{quota !== null && <QuotaPills free={quota.free} credits={quota.credits} />} {quota !== null && <QuotaPills free={quota} />}
{!empty && ( {!empty && (
<button <button
type="button" type="button"
@@ -147,7 +161,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4"> <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 && (
@@ -208,11 +222,12 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: 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
@@ -234,10 +249,10 @@ function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; childre
* سرور وصل نیستند. * سرور وصل نیستند.
*/ */
const THINKING_STEPS: [number, string][] = [ const THINKING_STEPS: [number, string][] = [
[0, 'پی داره تو کتابت می‌گرده'], [0, '{name} داره تو کتابت می‌گرده'],
[4, 'پی داره حلش می‌کنه'], [4, '{name} داره حلش می‌کنه'],
[15, 'داره قدم‌ها رو مرتب می‌نویسه'], [15, 'داره قدم‌ها رو مرتب می‌نویسه'],
[28, 'جوابِ کامل یه کم طول می‌کشه، الان می‌رسه'], [28, 'جواب کامل یه کم طول می‌کشه، الان می‌رسه'],
]; ];
function Thinking() { function Thinking() {
@@ -247,11 +262,12 @@ function Thinking() {
const timer = setInterval(() => setSeconds((Date.now() - started) / 1000), 1000); const timer = setInterval(() => setSeconds((Date.now() - started) / 1000), 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1]; const companion = useCompanion();
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1].replace('{name}', companion.name);
return ( return (
<div className="flex items-center gap-2"> <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]"> <span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
<SeyarMascot char="pi" mood="idle" size={32} /> <SeyarMascot char={companion.char} mood="idle" size={32} />
</span> </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"> <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} {text}
@@ -299,7 +315,7 @@ function Message({
<div className="flex flex-wrap items-center gap-2 pr-10"> <div className="flex flex-wrap items-center gap-2 pr-10">
{message.quotaReached ? ( {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 type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-violet px-3 py-1 text-xs font-bold text-white">
خریدِ بسته‌ی سوال اشتراک
</button> </button>
) : message.failedQuestion ? ( ) : 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">
@@ -339,23 +355,21 @@ function Message({
} }
/** /**
* شمارنده در سربرگِ دستیار: سوال‌های رایگانِ باقیِ امروز و سوال‌های خریده‌شده، هر کدام جدا و * شمارنده در سربرگِ دستیار: سوال‌های باقیِ امروز. وقتی تمام شد، دکمه‌ی دوم به فروشگاهِ اشتراک می‌برد
* درشت، تا بچه پیش از پرسیدن بداند چقدر دارد. زدن روی دومی صفحه‌ی بسته‌ها را باز می‌کند. * (سوال جدا فروخته نمی‌شود؛ مشترک فردا دوباره سهم دارد).
*/ */
function QuotaPills({ free, credits }: { free: number; credits: number }) { function QuotaPills({ free }: { free: number }) {
const nav = useNav(); const nav = useNav();
return ( 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'}`}> <span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}>
{free > 0 ? <>{toFa(free)} سوالِ رایگانِ امروز</> : 'رایگانِ امروز تموم شد'} {free > 0 ? <>{toFa(free)} سوال امروز</> : 'سوال‌های امروز تموم شد'}
</span> </span>
<button {free === 0 && (
type="button" <button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-white/15 px-3 py-1.5 text-xs font-black text-white">
onClick={() => nav.switchTab('subscription')} سوال بیشتر با اشتراک
className={`rounded-full px-3 py-1.5 text-xs font-black ${credits > 0 ? 'bg-white text-violet' : 'bg-white/15 text-white'}`} </button>
> )}
{credits > 0 ? <>{toFa(credits)} سوالِ خریده‌شده</> : '+ خریدِ سوال'}
</button>
</> </>
); );
} }

View File

@@ -4,7 +4,8 @@ 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, Sep } from '@/components/ui'; import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader, Sep } from '@/components/ui';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
@@ -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)}<Sep />با امتیازِ بیشتر همراه‌های بیشتری باز می‌شن</>} 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,16 +1,18 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Bell, ChevronLeft, Coins, Flame, Star } from 'lucide-react'; import { Bell, ChevronLeft, Coins, Crown, Flame, RotateCw, Star } from 'lucide-react';
import type { HomeNode } from '@/api'; import { ApiError, type HomeNode } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { findContinue, 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 { IslandPath } from '@/components/IslandPath';
import { IslandSheet } from '@/components/IslandSheet'; import { IslandSheet } from '@/components/IslandSheet';
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui'; import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
import { shade } from '@/lib/color'; import { onWhite, shade } from '@/lib/color';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText'; import { BrandFooter } from '@/components/BrandFooter';
import { BrandMark } from '@/components/BrandMark';
import { StreakFlame, flameState } from '@/components/StreakFlame';
import { SubjectBar } from '@/components/SubjectBar';
/** /**
* خانه = خودِ مسیر، مثلِ دولینگو: همه‌ی فصل‌های پایه پشتِ هم، و جزیره‌ی فعلی با * خانه = خودِ مسیر، مثلِ دولینگو: همه‌ی فصل‌های پایه پشتِ هم، و جزیره‌ی فعلی با
@@ -18,8 +20,10 @@ import { MathText } from '@/lib/MathText';
* دستیار، فصل‌ها، چالش) و بچه نمی‌دانست کدام را بزند؛ حالا یک کارِ واضح دارد. * دستیار، فصل‌ها، چالش) و بچه نمی‌دانست کدام را بزند؛ حالا یک کارِ واضح دارد.
*/ */
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 [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
const currentRef = useRef<HTMLDivElement>(null); const currentRef = useRef<HTMLDivElement>(null);
const scrolled = useRef(false); const scrolled = useRef(false);
@@ -33,27 +37,22 @@ export function HomeScreen() {
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [home]); }, [home]);
const name = profile.name || 'دوستِ من'; const flame = flameState(summary?.streak);
const bubble = !home const streak = summary?.streak;
? 'دارم مسیرت رو میارم…'
: !home.units.length
? 'هنوز درسی برای این پایه ثبت نشده.'
: cont
? <>سلام {name}! 👋 نوبتِ جزیره‌ی <b>«<MathText text={cont.node.title} />»</b>ـه. دکمه‌ی <b>شروع</b> رو بزن.</>
: <>آفرین {name}! همه‌ی جزیره‌های این پایه رو تموم کردی 🎉</>;
const stats = [ const stats = [
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const }, { icon: Flame, value: summary?.streak.current ?? 0, label: 'روز پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', color: '#E8A400', fill: true, to: 'leaderboard' as const }, { icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', color: '#E8A400', fill: true, to: 'leaderboard' as const },
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const }, { icon: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const },
]; ];
return ( return (
<Screen> <Screen>
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-4 pt-safe text-white shadow-md shadow-coral/20"> <header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-3 pt-safe text-white shadow-md shadow-coral/20">
<div className="flex items-center gap-2 pt-4"> <div className="flex items-center gap-2 pt-4">
<BrandMark size={26} withName={false} />
{gradeTitle && ( {gradeTitle && (
<button type="button" onClick={() => nav.switchTab('profile')} className="rounded-full bg-white/20 px-3 py-1.5 text-xs font-black"> <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} {gradeTitle}
</button> </button>
)} )}
@@ -64,13 +63,13 @@ export function HomeScreen() {
type="button" type="button"
aria-label={`${label}: ${toFa(value)}`} aria-label={`${label}: ${toFa(value)}`}
onClick={() => nav.push({ name: to })} onClick={() => nav.push({ name: to })}
className="flex items-center gap-1 rounded-full bg-white px-2.5 py-1.5 text-xs font-black text-ink shadow-sm" className={`hit flex items-center gap-1 rounded-full px-2.5 py-1.5 text-xs font-black shadow-sm active:scale-95 ${to === 'streak' && flame === 'frozen' ? 'bg-[#E6F6FF] text-[#2B7FB8]' : 'bg-white text-ink'}`}
> >
<Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} /> {to === 'streak' ? <StreakFlame state={flame} size={13} /> : <Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />}
{toFa(value)} {toFa(to === 'streak' && flame === 'frozen' ? streak?.lost ?? 0 : value)}
</button> </button>
))} ))}
<button type="button" aria-label="اعلان‌ها" onClick={() => nav.push({ name: 'notifications' })} className="relative flex h-8 w-8 items-center justify-center rounded-full bg-white/20"> <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} /> <Bell size={16} />
{unreadCount > 0 && ( {unreadCount > 0 && (
<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"> <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">
@@ -81,14 +80,10 @@ export function HomeScreen() {
</div> </div>
</div> </div>
<div className="mt-3 flex items-end gap-2">
<SeyarMascot char={companion} mood={cont ? 'happy' : home ? 'excited' : 'idle'} size={64} bob />
<div className="relative mb-2 flex-1 rounded-2xl rounded-br-sm bg-white px-3.5 py-2.5 text-[13px] leading-6 text-ink shadow-sm">
{bubble}
</div>
</div>
</header> </header>
<SubjectBar />
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{!home ? ( {!home ? (
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" /> homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />
@@ -96,6 +91,23 @@ export function HomeScreen() {
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" /> <EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
) : ( ) : (
<div className="flex flex-col gap-2 px-4 pb-10 pt-4"> <div className="flex flex-col gap-2 px-4 pb-10 pt-4">
{homeError != null && (
// نقشه هست ولی تازه نشد (معمولاً عکسِ ذخیره‌شده روی گوشی و اینترنتِ قطع): بچه باید بداند
// عددها و قفل‌ها ممکن است مالِ دفعه‌ی قبل باشند.
<div role="status" className="flex items-center gap-2 rounded-xl bg-soft px-3 py-2 text-xs leading-6 text-body">
<span className="flex-1">
{homeError instanceof ApiError && homeError.isNetwork ? 'اینترنت وصل نیست یا کنده' : 'اطلاعات تازه نشد'}؛
آخرین اطلاعات ذخیره‌شده رو می‌بینی.
</span>
<button
type="button"
onClick={() => void Promise.all([refreshHome(), refreshSummary()])}
className="flex shrink-0 items-center gap-1 rounded-full bg-white px-3 py-1 font-bold text-coral ring-1 ring-line"
>
<RotateCw size={12} /> دوباره
</button>
</div>
)}
{home.units.map((unit, i) => { {home.units.map((unit, i) => {
const color = unitColor(i); const color = unitColor(i);
const p = unitProgress(unit); const p = unitProgress(unit);
@@ -105,13 +117,14 @@ export function HomeScreen() {
<button <button
type="button" type="button"
onClick={() => nav.push({ name: 'unit', unitId: unit.id })} onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
className="flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-right text-white active:translate-y-0.5" 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: color, boxShadow: `0 4px 0 ${shade(color, -0.25)}` }} style={{ backgroundColor: onWhite(color), boxShadow: `0 4px 0 ${shade(onWhite(color), -0.25)}` }}
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[11px] font-bold text-white/80"> <div className="text-[11px] font-bold text-white/80">
فصلِ {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره فصل {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
{isCurrentUnit && <span className="mr-1.5 rounded-full bg-white/25 px-2 py-0.5">الان اینجایی</span>} {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>
<div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div> <div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div>
</div> </div>
@@ -123,13 +136,14 @@ export function HomeScreen() {
nodes={unit.nodes} nodes={unit.nodes}
color={color} color={color}
currentId={isCurrentUnit ? cont!.node.id : null} currentId={isCurrentUnit ? cont!.node.id : null}
onOpen={(node) => setOpenNode({ node, color })} onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode({ node, color }))}
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })} onStart={(node) => (unit.requiresSubscription ? paywall() : nav.push({ name: 'quiz', nodeId: node.id }))}
/> />
</div> </div>
</section> </section>
); );
})} })}
<BrandFooter />
</div> </div>
)} )}
</div> </div>

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

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

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';
@@ -10,12 +11,15 @@ import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHead
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText'; import { MathText } from '@/lib/MathText';
import { Diagram } from '@/diagram'; 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;
@@ -49,9 +53,9 @@ 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"><MathText text={data.introText} /></p> <StepText text={data.introText} color={color} className="text-[15px] leading-[1.95] text-body" />
{figs('intro')} {figs('intro')}
</div> </div>
</div> </div>
@@ -63,7 +67,7 @@ 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"><MathText text={data.example} /></p> <StepText text={data.example} color={color} className="text-[15px] leading-[1.95] text-ink" />
{figs('example')} {figs('example')}
</section> </section>
)} )}
@@ -77,7 +81,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}> <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"><MathText text={step} />{figs('step', i)}</span> <span className="flex-1 whitespace-pre-line"><MathText text={step} display />{figs('step', i)}</span>
</li> </li>
))} ))}
</ol> </ol>
@@ -85,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
@@ -99,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

@@ -61,7 +61,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
setDevOtp(data.devOtp ?? null); setDevOtp(data.devOtp ?? null);
} catch (err) { } catch (err) {
if (mine !== attempt.current) return; if (mine !== attempt.current) return;
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن')); setError(friendlyError(err, 'ارسال کد انجام نشد، دوباره امتحان کن'));
if (err instanceof ApiError && err.status === 400) { if (err instanceof ApiError && err.status === 400) {
setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند
} else if (err instanceof ApiError && err.isRateLimited && err.retryAfterMs) { } else if (err instanceof ApiError && err.isRateLimited && err.retryAfterMs) {
@@ -106,11 +106,11 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-xl font-black">ورود به تدریسینو</h1> <h1 className="text-balance text-xl font-black">ورود به تدریسینو ریاضی</h1>
<p className="mt-1 text-sm text-white/75"> <p className="mt-1 text-sm text-white/75">
{step === 'phone' {step === 'phone'
? 'شماره‌ی موبایلت رو وارد کن' ? 'شماره موبایلت رو وارد کن'
: `کدِ ${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">
@@ -146,7 +146,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
) : ( ) : (
<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}
@@ -165,7 +165,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
)} )}
{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">
@@ -173,7 +173,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
<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 || sending || 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,7 +187,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
loading={loading === 'login'} loading={loading === 'login'}
disabled={step === 'phone' ? !phoneValid : !otpValid} disabled={step === 'phone' ? !phoneValid : !otpValid}
> >
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'} {step === 'phone' ? 'دریافت کد تأیید' : 'ورود'}
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>

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, Sep } 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}<Sep /></>}{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="line-clamp-2 max-w-[170px] text-xs font-black leading-5 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

@@ -23,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;
@@ -51,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">
همه خوانده شد همه خوانده شد

View File

@@ -7,27 +7,27 @@ import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title: string; desc: string }[] = [ 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', char: 'tedi', mood: 'excited', bg: '#D9820B',
sub: 'یکی از ما مالِ توئه', sub: 'یکی از ما مال توئه',
title: 'همراهت رو انتخاب کن', title: 'همراهت رو انتخاب کن',
desc: 'منم تدی! هر چی امتیازت بیشتر بشه، همراه‌های تازه باز می‌شن. از صفحه‌ی «من» هر کدوممون رو خواستی کنارت می‌ذاری.', desc: 'منم امگا! هر چی امتیازت بیشتر بشه، همراه‌های تازه‌تری باز می‌شن. از صفحه «من» هر کدوممون رو بخوای انتخاب می‌کنی.',
}, },
]; ];
@@ -64,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 {
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut, BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Crown, Heart, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
FileText, Pencil, Repeat, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX, FileText, Pencil, Sparkles, Star, Trash2, Trophy, UserRound, Volume2, VolumeX,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
@@ -15,9 +15,12 @@ import { BirthDatePicker, ageOf, formatBirthDate } from '@/components/BirthDateP
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 { digitsOnly, 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' | 'birth' | 'logout' | 'switch' | 'delete' | null; type Editing = 'name' | 'grade' | 'birth' | 'account' | 'logout' | 'delete' | null;
/** «من»: پروفایل، تنظیمات و میان‌بُر به بخش‌های فرعی. */ /** «من»: پروفایل، تنظیمات و میان‌بُر به بخش‌های فرعی. */
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) { export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
@@ -43,7 +46,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
{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"> <div className="mt-0.5 text-xs text-white/75">
شناسه‌ی کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span> شناسه کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span>
</div> </div>
<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>}
@@ -68,28 +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">
<SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
<Menu> <Menu>
<Row icon={GraduationCap} label="پایه‌ی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} /> <Row icon={GraduationCap} label="پایه تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
<Row <Row
icon={CalendarDays} icon={CalendarDays}
label="تاریخِ تولد" label="تاریخ تولد"
value={formatBirthDate(profile.birthDate) || (age ? `${toFa(age)} سال` : 'وارد نشده')} value={formatBirthDate(profile.birthDate) || (age ? `${toFa(age)} سال` : 'وارد نشده')}
onClick={() => setEditing('birth')} onClick={() => setEditing('birth')}
/> />
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} /> <Row icon={Sparkles} label="همراه من" onClick={go({ name: 'characters' })} />
</Menu> </Menu>
<Menu> <Menu>
<Row icon={BarChart3} label="گزارشِ والدین" onClick={go({ name: 'report' })} /> <Row icon={BarChart3} label="گزارش والدین" onClick={go({ name: 'report' })} />
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} /> <Row icon={Flame} label="تقویم زنجیره" onClick={go({ name: 'streak' })} />
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} /> <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>
<Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} /> <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />
<Row icon={FileText} label="پرداخت‌ها و فاکتورها" onClick={go({ name: 'invoices' })} /> <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="صداها"
@@ -101,34 +106,98 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
/> />
</Menu> </Menu>
{/* حذفِ حساب یک لایه پایین‌تر، در «حساب کاربری»، مثلِ اپ‌های بزرگ (دیجی‌کالا، اسنپ، دولینگو): پیدا می‌شود
(Google Play و App Store «در دسترس و پیداشدنی» می‌خواهند) ولی با یک لمسِ اشتباه زیرِ انگشت نیست. */}
<Menu> <Menu>
<Row icon={Repeat} label="ورود با حسابِ دیگر" onClick={() => setEditing('switch')} /> <Row icon={UserRound} label="حساب کاربری" value={profile.phone ? toFa(profile.phone) : undefined} onClick={() => setEditing('account')} />
<Row icon={LogOut} label="خروج از حساب" onClick={() => setEditing('logout')} /> <Row icon={LogOut} label="خروج از حساب" danger onClick={() => setEditing('logout')} />
</Menu> </Menu>
<button type="button" onClick={() => setEditing('delete')} className="flex items-center justify-center gap-2 py-2 text-xs font-bold text-red-500"> <BrandFooter />
<Trash2 size={14} /> حذفِ حساب
</button>
</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)} />
<BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} /> <BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
<LogoutSheet open={editing === 'logout' || editing === 'switch'} switching={editing === 'switch'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} /> <AccountSheet open={editing === 'account'} onClose={() => setEditing(null)} onDelete={() => setEditing('delete')} />
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
<DeleteAccountSheet open={editing === 'delete'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} /> <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>
@@ -182,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) {
@@ -193,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;
@@ -238,8 +307,8 @@ function BirthDateSheet({ 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="mb-5"> <div className="mb-5">
<BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} /> <BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
</div> </div>
@@ -249,11 +318,11 @@ function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void
} }
/** /**
* خروج، و «ورود با حسابِ دیگر» که همان خروج است با متنِ دیگر. عمداً چند حسابِ واردشده روی * خروج. «ورود با حسابِ دیگر» (۲۰۲۶-۱۰-۱۴) برداشته شد: همان خروج بود با متنِ دیگر. عمداً چند حسابِ واردشده روی یک
* یک گوشی نگه داشته نمی‌شود: گوشیِ خانوادگی دست‌به‌دست می‌شود و هر کس بی‌کد واردِ حسابِ * گوشی نگه داشته نمی‌شود: گوشیِ خانوادگی دست‌به‌دست می‌شود و هر کس بی‌کد واردِ حسابِ دیگری می‌شد، و چند refresh
* دیگری می‌شد، و چند refresh tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر می‌کرد. * tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر می‌کرد. خروج و ورودِ دوباره با پیامک همان کار را می‌کند.
*/ */
function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open: boolean; switching?: boolean; onClose: () => void; onSignedOut: () => void }) { function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
async function logout() { async function logout() {
@@ -268,21 +337,39 @@ function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open:
} }
return ( return (
<Sheet open={open} onClose={onClose} title={switching ? 'ورود با حسابِ دیگر' : 'از حسابت خارج بشی؟'}> <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>
{switching && 'اول از این حساب خارج می‌شی، بعد با شماره‌ی دیگه وارد شو. '}
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
</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={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}> <PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
{switching ? 'خروج و ورود با حسابِ دیگر' : 'خروج'}
</PrimaryButton>
</div> </div>
</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). شماره‌دار با کدِ تازه‌ی پیامکی تأیید می‌کند تا گوشیِ * حذفِ همیشگیِ حساب (POST /user/delete). شماره‌دار با کدِ تازه‌ی پیامکی تأیید می‌کند تا گوشیِ
* بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند. * بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند.
@@ -323,28 +410,28 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
} }
return ( return (
<Sheet open={open} onClose={onClose} title="حذفِ همیشگیِ حساب"> <Sheet open={open} onClose={onClose} title="حذف همیشگی حساب">
<p className="mb-3 text-sm leading-7 text-body"> <p className="mb-3 text-sm leading-7 text-body">
امتیاز، زنجیره، پیشرفتِ جزیره‌ها، گفتگوها با دستیار، سوال‌های خریده‌شده و قلبِ نامحدودت پاک می‌شن و برنمی‌گردن. امتیاز، زنجیره، پیشرفت جزیره‌ها، گفتگوها با دستیار و اشتراکت پاک می‌شن و برنمی‌گردن.
سوابقِ پرداخت فقط برای حسابداری می‌مونه. سوابق پرداخت فقط برای حسابداری می‌مونه. اگه اشتراکت رو از کافه‌بازار خریدی، تمدید خودکارش رو از بخش «اشتراک‌ها»ی بازار لغو کن.
</p> </p>
{sent ? ( {sent ? (
<input <input
value={value} value={value}
onChange={(e) => setValue(digitsOnly(e.target.value, 6))} onChange={(e) => setValue(digitsOnly(e.target.value, 6))}
placeholder="کدِ پیامک‌شده" placeholder="کد پیامک‌شده"
inputMode="numeric" inputMode="numeric"
dir="ltr" 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" className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-red-400"
/> />
) : ( ) : (
<p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شماره‌ی خودت پیامک می‌شه.</p> <p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شماره خودت پیامک می‌شه.</p>
)} )}
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>} {error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton> <SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton>
{!sent ? ( {!sent ? (
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادنِ کد</PrimaryButton> <PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادن کد</PrimaryButton>
) : ( ) : (
<PrimaryButton <PrimaryButton
color="#ef4444" color="#ef4444"
@@ -352,7 +439,7 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
disabled={value.length < 6} disabled={value.length < 6}
onClick={() => void remove()} onClick={() => void remove()}
> >
حذفِ همیشگی حذف همیشگی
</PrimaryButton> </PrimaryButton>
)} )}
</div> </div>

View File

@@ -1,9 +1,9 @@
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 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';
@@ -16,7 +16,12 @@ import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton,
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText'; 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' }
@@ -47,7 +52,11 @@ type PlayQuestion = Question & { lessonId?: string };
* اصلیِ بازی همین است. * اصلیِ بازی همین است.
*/ */
export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) { export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) {
const { home, companion, refreshHome, refreshSummary } = useAppData(); const { home, companion, summary, refreshHome, refreshSummary } = useAppData();
const look = useCompanion();
// زنجیره پیش از این تمرین؛ اگر بعد از ثبت امروز روشن شد، صفحه‌ی «روزِ پیاپی» نشان داده می‌شود.
const streakBefore = useRef(summary?.streak ?? null);
const [streakUp, setStreakUp] = useState<{ from: number; to: number } | null>(null);
const nav = useNav(); const nav = useNav();
const toast = useToast(); const toast = useToast();
@@ -61,8 +70,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
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>());
@@ -91,20 +102,33 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
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, fullRefillAt: h.fullRefillAt }); 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, nodeId, nodeMode, 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) {
@@ -117,12 +141,15 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
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({ setPhase({
kind: 'noHearts', kind: 'noHearts',
@@ -165,9 +192,23 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
: await api.lesson.complete(lessonId, payload, 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') { if (error instanceof ApiError && error.code === 'no_hearts') {
@@ -206,7 +247,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
if (phase.kind === 'noHearts') { if (phase.kind === 'noHearts') {
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={() => setReload((n) => n + 1)} />; return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={() => 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} />; 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>
@@ -216,7 +257,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
<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>
</> </>
@@ -240,16 +281,16 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
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';
@@ -265,8 +306,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-soft"> <div className="h-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} />
@@ -283,17 +326,27 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]' !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"><MathText text={question.prompt} /></h2> <h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} display /></h2>
{question.figure ? <div className="mt-3"><Diagram spec={question.figure} hideCounts /></div> : null}
</div> </div>
<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'}>
@@ -301,9 +354,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
<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}
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)}`} 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><MathText text={option.value} /></span> <span><MathText text={option.value} /></span>
@@ -313,17 +367,35 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
))} ))}
</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>
</motion.div> </>
) : (
<PrimaryButton color={color} disabled={selected === null} loading={submitting} onClick={() => void check()}>
بررسی
</PrimaryButton>
)} )}
</div> </div>
@@ -333,11 +405,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
</p> </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>
); );
} }
@@ -346,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>
); );
} }
@@ -375,11 +446,15 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
} }
function Result({ function Result({
result, total, correct, lessonId, nodeId, color, result, total, correct, lessonId, nodeId, color, streakUp,
}: { result: QuizResult; 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 nodeMode = lessonId === undefined;
const nextNode = result.nextNodeId ?? null; const nextNode = result.nextNodeId ?? null;
@@ -393,7 +468,7 @@ function Result({
} }
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) {
@@ -419,10 +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>
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی 🏝️</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> <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>
@@ -433,13 +515,13 @@ 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())}>
{nodeMode && nextNode ? 'بریم جزیره‌ی بعدی' : 'ادامه'} <ChevronLeft size={18} /> {nodeMode && nextNode ? 'بریم جزیره بعدی' : 'ادامه'} <ChevronLeft size={18} />
</PrimaryButton> </PrimaryButton>
{nodeMode && nextNode && ( {nodeMode && nextNode && (
<button type="button" onClick={nav.back} className="-mt-2 py-2 text-sm font-bold text-muted"> <button type="button" onClick={() => then(nav.back)} className="-mt-2 py-2 text-sm font-bold text-muted">
فعلاً بسه، برگردیم به نقشه فعلاً بسه، برگردیم
</button> </button>
)} )}
</div> </div>
@@ -447,64 +529,58 @@ function Result({
); );
} }
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') {
return (
useEffect(() => { <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 }}>
if (open) { Σ {toFa(correct)}
setReason(null); </motion.span>
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);
}
} }
if (char === 'delta' && combo >= 2) {
return (
<motion.span key={combo} initial={{ scale: 0.4, rotate: -12 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500, damping: 12 }} className="flex flex-shrink-0 items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-black text-white" style={{ backgroundColor: color }}>
<StreakFlame state="lit" size={11} className="[&_path]:fill-white" /> ×{toFa(combo)}
</motion.span>
);
}
return null;
}
/** آیکنِ کوچکِ بعد از جواب (همان خانواده‌ی آیکنِ اپ، نه ایموجیِ کیبورد)؛ هر سوال یکی از مجموعه، با یک تکانِ کوتاه. */
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 ( return (
<Sheet open={open} onClose={onClose} title="این سوال چه ایرادی داره؟"> <motion.span
<div className="mb-4 flex flex-col gap-2"> aria-hidden
{REASONS.map((r) => ( className="mr-1.5 inline-block align-middle"
<button initial={{ scale: 0.4, rotate: 0 }}
key={r.id} animate={{ scale: 1, rotate: [0, -14, 14, -8, 8, 0] }}
type="button" transition={{ duration: 0.6, ease: 'easeOut' }}
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'}`} <Icon size={18} color={color} strokeWidth={2.4} />
> </motion.span>
{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

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

View File

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

View File

@@ -1,27 +1,13 @@
import { motion } from 'motion/react'; /**
import { SeyarMascot } from '@/mascots/SeyarMascot'; * اسپلشِ ثابت: آیکونِ اپ و نام، بی‌انیمیشن و بی‌همراه (همراهِ انتخابی هنوز معلوم نیست و تکرارش
* بد است). عینِ `.boot-splash` در index.html است تا تحویل از HTML به React دیده نشود؛ استایلش
/** تا وقتی نشستِ قبلی بررسی می‌شود نمایش داده می‌شود. */ * هم آن‌جا است چون باید پیش از رسیدنِ CSS و JS کار کند.
*/
export function SplashScreen() { export function SplashScreen() {
return ( return (
<div className="flex h-full flex-col items-center justify-center bg-rose"> <div className="boot-splash" dir="rtl">
<motion.div <img src="/app-icon.png" alt="" width={144} height={144} />
initial={{ scale: 0.5, opacity: 0 }} <span>تدریسینو ریاضی</span>
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
className="flex flex-col items-center gap-4"
>
<div className="rounded-[32px] bg-white/15 p-6 backdrop-blur-sm">
<SeyarMascot char="sigma" mood="happy" size={130} />
</div>
<h1 className="text-3xl font-black text-white">تدریسینو</h1>
<p className="text-sm text-white/75">ریاضی، قدم‌به‌قدم، با همراه‌هات</p>
</motion.div>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1.8, repeat: Infinity, ease: 'linear' }}
className="absolute bottom-20 h-6 w-6 rounded-full border-2 border-white/25 border-t-white"
/>
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Sparkles } from 'lucide-react'; import { Crown, Sparkles } from 'lucide-react';
import type { HomeNode } from '@/api'; import type { HomeNode } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { findUnit, unitColor, unitProgress } from '@/state/curriculum'; import { findUnit, unitColor, unitProgress } from '@/state/curriculum';
@@ -14,6 +14,7 @@ import { MathText } from '@/lib/MathText';
export function UnitScreen({ unitId }: { unitId: string }) { export function UnitScreen({ unitId }: { unitId: string }) {
const { home, homeError, refreshHome, companion } = useAppData(); const { home, homeError, refreshHome, companion } = useAppData();
const nav = useNav(); const nav = useNav();
const paywall = () => nav.push({ name: 'subscription' });
const [openNode, setOpenNode] = useState<HomeNode | null>(null); const [openNode, setOpenNode] = useState<HomeNode | null>(null);
const found = findUnit(home, unitId); const found = findUnit(home, unitId);
@@ -42,7 +43,7 @@ export function UnitScreen({ unitId }: { unitId: string }) {
> >
<div className="mt-4"> <div className="mt-4">
<div className="mb-1.5 flex justify-between text-[10px] text-white/75"> <div className="mb-1.5 flex justify-between text-[10px] text-white/75">
<span>پیشرفتِ تو</span> <span>پیشرفت تو</span>
<span>{toFa(progress.percent)}٪</span> <span>{toFa(progress.percent)}٪</span>
</div> </div>
<ProgressBar percent={progress.percent} color="#fff" track="rgba(0,0,0,.1)" height={10} /> <ProgressBar percent={progress.percent} color="#fff" track="rgba(0,0,0,.1)" height={10} />
@@ -58,9 +59,14 @@ export function UnitScreen({ unitId }: { unitId: string }) {
nodes={unit.nodes} nodes={unit.nodes}
color={color} color={color}
currentId={current?.id ?? null} currentId={current?.id ?? null}
onOpen={setOpenNode} onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode(node))}
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })} onStart={(node) => (unit.requiresSubscription ? paywall() : nav.push({ name: 'quiz', nodeId: node.id }))}
/> />
{unit.requiresSubscription && (
<button type="button" onClick={paywall} className="mx-auto mt-6 flex items-center gap-2 rounded-2xl bg-orange px-5 py-3 text-sm font-black text-white shadow-lg shadow-orange/30">
<Crown size={16} /> این فصل با اشتراک باز می‌شه
</button>
)}
<div className="mt-6 flex flex-col items-center"> <div className="mt-6 flex flex-col items-center">
<span className="mb-1 text-[11px] font-bold text-muted">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span> <span className="mb-1 text-[11px] font-bold text-muted">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}> <span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>

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