44 Commits

Author SHA1 Message Date
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
101 changed files with 3471 additions and 1405 deletions

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

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

134
README.md
View File

@@ -1,7 +1,20 @@
# تدریسینو — فرانتِ اپِ ریاضی
# تدریسینو — اپِ ریاضی (فرانت‌اند)
اپِ وبِ موبایل‌محورِ تدریسینو (React + TypeScript + Vite + Tailwind). بک‌اندش ریپوی
جداست (`Tadrisino/MathApp`)؛ قراردادِ API در `/api/docs/` همان بک‌اند است.
اپِ وبِ موبایل‌محورِ **تدریسینو**، آموزشِ ریاضی به سبکِ دولینگو برای پایه‌ی سوم تا دوازدهم: نقشه‌ای از جزیره‌ها برای هر
کتاب، آزمون‌های کوتاه از مسئله‌های خودِ کتابِ درسی، دستیارِ هوشِ مصنوعی، همراه‌ها، زنجیره‌ی روزانه و اشتراک. همین
مخزن پوسته‌ی اندرویدِ اپ را هم دارد ([`android/`](android/README.md)).
بک‌اند مخزنِ جداگانه‌ای است ([`Tadrisino/MathApp`](https://gitea.tadrisino2.ir/Tadrisino/MathApp)) و قراردادِ API
اسکیمای OpenAPIِ همان‌جاست. این اپ تنها کلاینتِ آن API است.
| | |
|---|---|
| **سایتِ زنده** | `https://math.tadrisino2.ir` · API: `https://mathapi.tadrisino2.ir` |
| **فناوری** | React 18 · TypeScript (strict) · Vite 6 · Tailwind 4 · Motion |
| **وابستگی‌ها** | عمداً کم: بی‌کتابخانه‌ی router و state؛ ناوبری و داده‌ی مشترک در خودِ کد |
| **اندروید** | پوسته‌ی WebView روی همین سایت، با پرداختِ درون‌برنامه‌ایِ کافه‌بازار (Poolakey)؛ بسته‌ی `com.tadrisino.math.app` |
---
## راه‌اندازی
@@ -10,15 +23,14 @@ npm install
npm run dev # http://localhost:5173
```
در حالتِ توسعه، درخواست‌های `/api` از خودِ Vite به بک‌اند پاس داده می‌شوند، پس CORS
مطرح نیست. پیش‌فرض بک‌اندِ محلی است (`python manage.py runserver` روی پورتِ ۸۰۰۰).
بدونِ بک‌اندِ محلی، یک فایلِ `.env.development.local` بسازید:
در حالتِ توسعه، Vite درخواست‌های `/api` را به بک‌اند پاس می‌دهد، پس CORS مطرح نیست. پیش‌فرض بک‌اندِ محلی است
(`python manage.py runserver` روی پورتِ ۸۰۰۰). بدونِ بک‌اندِ محلی، فایلِ `.env.development.local` بسازید:
```bash
VITE_DEV_PROXY_TARGET=https://mathapi.tadrisino2.ir
```
⚠️ آن‌وقت هر کاری در اپ بکنید روی دیتابیسِ واقعی نوشته می‌شود.
> ⚠️ در این حالت هر کاری در اپ بکنید روی دیتابیسِ واقعی نوشته می‌شود.
| دستور | کار |
|---|---|
@@ -27,65 +39,83 @@ VITE_DEV_PROXY_TARGET=https://mathapi.tadrisino2.ir
| `npm run build` | type-check و بعد build در `dist/` |
| `npm run preview` | اجرای همان build |
## آدرسِ بک‌اند و عوض کردنِ دامنه
دامنه هیچ‌جای کد نیامده. آدرسِ API به این ترتیب خوانده می‌شود
(`src/config.ts`):
1. **`dist/config.js`** (از `public/config.js`) — در زمانِ اجرا. بعد از build هم روی
سرور قابلِ ویرایش است و build دوباره نمی‌خواهد.
2. **`VITE_API_BASE_URL`** — در زمانِ build، از `.env.production`.
3. **`/api`** — هم‌دامنه، وقتی nginxِ دامنه‌ی فرانت مسیرِ `/api` را به بک‌اند پراکسی کند.
برای جابه‌جایی دامنه:
- **سریع، بدونِ build:** روی سرور در `dist/config.js` بنویسید
`apiBaseUrl: "https://api.new-domain.ir/api"` و کشِ CDN را برای همین فایل پاک کنید.
- **ماندگار:** خطِ `VITE_API_BASE_URL` در `.env.production` را عوض کنید و build بگیرید.
- **سمتِ بک‌اند** هم دامنه‌ی تازه‌ی فرانت باید مجاز باشد (`FRONTEND_URL` در `.env`
بک‌اند). جزئیات در `docs/operations.md` همان ریپو.
## دیپلوی
روی سرور (`~/mathapp_frontend_new`، که nginxِ `math.tadrisino2.ir` از `dist/`ش سرو می‌کند):
```bash
./deploy.sh # fetch، npm ci، type-check، build در dist.next و جابه‌جاییِ بی‌وقفه
```
buildِ قبلی در `dist.prev` می‌ماند (برگشت: `rm -rf dist && mv dist.prev dist`)، و
`dist/config.js`ِ ویرایش‌شده روی سرور به buildِ تازه منتقل می‌شود.
جای دیگر: `npm ci && npm run build` و سرو کردنِ `dist/`. اپ مسیرِ جدا (route) ندارد و همه‌چیز از `/` بار می‌شود؛
فقط `index.html` و `config.js` نباید طولانی کش شوند (باقیِ فایل‌ها نامِ hashدار دارند).
درگاهِ پرداخت کاربر را به `/?orderId=…&status=…` برمی‌گرداند و لینکِ دعوت
`/?ref=CODE` است؛ هر دو را خودِ اپ می‌خواند.
---
## ساختار
```
src/
config.ts آدرسِ API (بالا را ببینید)
config.ts آدرسِ API (بخشِ پایین)
api/ تنها درِ ارتباط با سرور — هیچ جای دیگر fetch نمی‌زند
http.ts timeout، تکرارِ GET، refreshِ تک‌نسخه‌ای، SSE
http.ts توکن، timeout، تکرارِ GET، refreshِ تک‌نسخه‌ای، SSE، Idempotency-Key
index.ts یک تابع برای هر اندپوینت
types.ts شکلِ پاسخ‌ها، از روی پاسخِ واقعیِ سرور
app/ App (جریانِ ورود) و MainApp (تب‌ها و صفحه‌ها)
nav/ ناوبریِ پشته‌ای، هماهنگ با دکمه‌ی back گوشی
state/ داده‌ی مشترک (پروفایل، نقشه، …) و گفتگوی دستیار
state/ داده‌ی مشترک (پروفایل، نقشه، کتابِ انتخاب‌شده، همراه) و گفتگوی دستیار
screens/ یک فایل برای هر صفحه
components/ اجزای مشترک (سربرگ، برگه‌ی پایین، …)
mascots/ همراه‌ها: سیگما، دلتا، پی، تدی
diagram/ شکلِ جوابِ دستیار: توصیفِ JSON از بک‌اند → SVG؛ همه‌ی مختصات این‌جا حساب می‌شود
(plane، shapes، expr، solid، charts، sets، misc، numberline). گالری: ?gallery فقط در dev
lib/ اعدادِ فارسی، تاریخِ شمسی، صدا، localStorage
components/ اجزای مشترک: مسیرِ جزیره‌ها، نوارِ کتاب‌ها، برگه‌ی پایین، بازخورد، …
mascots/ همراه‌ها (سیگما، دلتا، پی، امگا): SVG از کد، شش حالتِ چهره
diagram/ شکلِ درس و جوابِ دستیار: توصیفِ JSON از بک‌اند → SVG؛ همه‌ی مختصات این‌جا حساب می‌شود
lib/ اعدادِ فارسی، تاریخِ شمسی، MathText، پیامِ خطا، صدا، پلِ کافه‌بازار، روش‌های پرداخت
android/ پوسته‌ی اندروید (README خودش)
pay.html · src/pay.ts صفحه‌ی واسطِ پرداختِ زرین‌پال برای اپِ اندرویدِ فعلی
```
قاعده‌ها:
- **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ
`۰-۹` می‌فرستد و `\d` در JS آن‌ها را نمی‌شناسد).
- **کلاینت را از حافظه ننویسید.** قرارداد را از اسکیمای بک‌اند بخوانید
(`python manage.py spectacular --file schema.yaml --format openapi`) و هر تغییرِ `types.ts` را با پاسخِ واقعیِ سرور بسنجید.
- **صفحه‌ها هرگز مستقیم سرور را صدا نمی‌زنند:** همیشه `screens/* → api/index.ts → api/http.ts`.
- **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ `۰-۹` می‌فرستد و `\d` در JS آن‌ها را نمی‌شناسد).
- **رنگ‌ها** در `src/styles/index.css` (`@theme`) تعریف شده‌اند: `bg-coral`، `text-ink`، …
- **خطا:** پیامِ کاربر همیشه از `friendlyError` می‌آید، نه متنِ خامِ سرور.
- **کلاینت را از حافظه ننویسید.** قرارداد را از `/api/docs/` بک‌اند بخوانید و تغییرِ
`types.ts` را با پاسخِ واقعیِ سرور بسنجید.
- **متنِ فارسی** بی‌اعراب و بی «ی»ِ اضافه‌ی نوشته‌شده («پایه‌ی سوم») است (تصمیمِ شرکت)؛ تنوین («قبلاً») می‌ماند. متنِ تازه
را همین‌طور بنویسید. متنِ سرور را بک‌اند هنگامِ ساختنِ پاسخ پاک می‌کند و `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
*.jks
*.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` حدس است؛ تأیید نشده |
| کلیدِ RSAِ عمومیِ اپ | `bazaarRsaKey` در فایلِ تنظیماتِ بیرون از ریپو (پایین) | ندارد؛ بدونِ آن build نوعِ release نمی‌شود |
| SKUِ هر بسته (محصولِ **مصرف‌شدنی**) | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچ‌کدام تعریف نشده |
| `TadrisinoAndroid.vibrate(patternJson)` با Vibratorِ گوشی | `navigator.vibrate` در WebView همیشه کار نمی‌کند؛ مالک گفت اپ لرزش ندارد | انتخابِ گزینه‌ی آزمون، جوابِ درست/غلط، پایانِ جزیره و صفحه‌ی «روزِ پیاپی» باید بلرزند؛ با خاموش کردنِ «صداها» در پروفایل نه |
| `mediaPlaybackRequiresUserGesture = false` | صداهای رابط بعد از پاسخِ سرور پخش می‌شوند، نه در لحظه‌ی لمس | صدای جوابِ درست/غلط و پایانِ تمرین |
| `isPaymentPage` + `paysInApp()` | درگاهِ زرین‌پال داخلِ خودِ اپ باز می‌شود. اپِ فعلی آن را به مرورگرِ گوشی می‌دهد که Referer نمی‌فرستد و زرین‌پال بی‌Referer «دسترسی از این دامنه مجاز نمی‌باشد» می‌دهد؛ فعلاً فرانت از راهِ `pay.html` (با `http://`) دورش می‌زند | «پرداخت» با کارتِ بانکی: صفحه‌ی زرین‌پال و بانک داخلِ اپ، و بعد از پرداخت برگشت به اپ با پیامِ «پرداخت موفق بود» (PaymentResult). دکمه‌ی برگشت وسطِ صفحه‌ی بانک چه می‌کند را هم ببین |
| اسپلشِ یک‌مرحله‌ای: `installSplashScreen()` + `setKeepOnScreenCondition` + `TadrisinoAndroid.appReady()` (`446cf36`، `ab02bb4`) | اپِ فعلی سه تکه می‌شود: اسپلشِ اندروید ← WebViewِ سفیدِ خالی ← اسپلشِ وب (آیکون + نام) ← خانه. با APKِ تازه اسپلشِ بومی تا اولین صفحه‌ی واقعیِ وب می‌ماند و مستقیم می‌رود روی همان صفحه؛ اسپلشِ وب داخلِ اپ نامرئی است (در APKِ قدیمی فقط آیکونِ بی‌نام). فرانت `appReady` را بعد از اولین صفحه‌ی غیر‌boot صدا می‌زند و APKِ قدیمی آن را ندارد، پس بی‌اثر است | روی گوشی: باز کردنِ اپ با اینترنت باید آیکون ← خانه باشد، بی‌سفیدی و بی‌تصویرِ دوم؛ اندازه‌ی آیکون روی اندروید ۱۲+ (`drawable/splash_icon.xml`: ۲۸۸dp، حاشیه ۲۲٪) |
| خریدِ اشتراک از کافه‌بازار: کلیدِ RSA، `bazaarBuy` (`subscribeProduct`)، قیمتِ پیشخان (`bazaarPrices`)، «تخفیف پویا»؛ `versionCode` ۲ | بی‌این‌ها از بازار هیچ خریدی ممکن نیست (APKهای قبلی کلید نداشتند) | داخلِ اپ ← فروشگاه ← اشتراکِ سه‌ماهه: «کارتِ بانکی» پیش‌فرض، کاشیِ «کافه‌بازار» فعال با قیمتِ بازار زیرِ آن؛ خرید با حسابِ تست، برگشت با «اشتراکت فعاله»؛ در `/admin/payment/order/` سفارشِ بازار «پرداخت‌شده». یک بار هم «انصراف» در صفحه‌ی بازار |
| صفحه‌ی بی‌اتصال: `onReceivedError`/`onReceivedHttpError` ← همان تصویر + «اینترنت قطع است / کند است؛ در حال اتصال…»، تلاشِ دوباره هر ۵ ثانیه، و مجوزِ `ACCESS_NETWORK_STATE` | کاربر هرگز صفحه‌ی خطای WebView یا متنِ فنی نمی‌بیند. اتصالِ کند (بی‌خطا) عمداً هیچ متنِ اضافه نمی‌آورد و روی اسپلش می‌ماند | هواپیما روشن ← باز کردنِ اپ ← خاموش کردن: باید خودش وصل شود. سرورِ ۵۰۲ (مثلاً nginx خاموش) هم همین صفحه. اگر صفحه لود شد ولی ۱۵ ثانیه `appReady` نیامد، لاگِ ERROR و رهاشدنِ اسپلش (باگِ JS) |
قیمتِ هر محصول در پیشخانِ بازار باید با قیمتِ همان بسته در ادمین یکی باشد؛ سرور مبلغ را از
بازار نمی‌خواند.
**اسپلش و بی‌اینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصب‌شده امتحان شد).** چیزی که روی گوشی دیده می‌شود
از وب قابل رفع نیست، چون وب هنوز بار نشده یا اصلاً نمی‌رسد:
## تنظیماتِ بیرون از ریپو
- اپِ نصب‌شده: اسپلشِ اندروید ← صفحه‌ی سفید ← اسپلشِ وب ← خانه. با فیلترشکن (یا هر وقت `math.tadrisino2.ir` نرسد)
فقط **سفید** می‌ماند و هیچ پیامی نیست، چون HTML هرگز نرسیده و هیچ کدِ ما اجرا نمی‌شود. علتِ دقیقِ آن سفیدی (HTML نرسیده
یا API نرسیده) روی فیلترشکن بازتولید **نشد**؛ APKِ تازه هر دو را با همان صفحه‌ی بی‌اتصال می‌پوشاند.
- APKِ تازه (دو ردیفِ بالا) باید: ۱) با اینترنت، اسپلشِ بومی مستقیم به خانه برود و وسطش هیچ‌چیز نیفتد؛ ۲) بی‌اینترنت یا
کند، **زیرِ همان تصویرِ ثابت** بگوید «اینترنت قطع است / کند است»، نه سفید و نه متنِ خطا.
- آزمونِ پذیرش روی گوشی، پیش از انتشار: اینترنتِ عادی؛ هواپیما؛ فیلترشکنِ روشن (همان که سفید می‌داد)؛ اینترنتِ خیلی کند.
در هیچ‌کدام نباید سفیدِ خالی یا صفحه‌ی خطای WebView دیده شود.
keystore و کلیدِ RSA هرگز داخلِ ریپو نمی‌آیند. یک فایل با دسترسیِ ۶۰۰ بسازید: `~/.tadrisino/android.properties`
(یا مسیرِ دلخواه در `TADRISINO_ANDROID_CONFIG`):
پیشنهادهایی که هنوز نوشته نشده‌اند:
```
storeFile=/مسیر/کامل/tadrisino-release.jks
storePassword=...
keyAlias=...
keyPassword=...
bazaarRsaKey=<کلیدِ RSA از پیشخانِ بازار، یک خط>
```
- **«برای خروج دوباره بزن»**: دکمه‌ی برگشتِ گوشی حالا قدم‌به‌قدم در خودِ وب عقب می‌رود (`src/nav/Navigator.tsx`) و
از خانه یک‌راست بیرون می‌رود. اگر تأییدِ خروج خواستید، در `handleOnBackPressed` وقتی `!webView.canGoBack()` یک
Toast بدهید و فقط برگشتِ دوم در دو ثانیه `finish()` کند. در وب شدنی نیست (کروم ورودیِ history بی‌لمس را رد می‌کند).
- `versionCode`/`versionName` را برای هر نسخه‌ای که منتشر می‌شود بالا ببرید؛ الان ۲ و 1.1.0 است (بازار فقط `versionCode`ِ بزرگ‌تر را می‌پذیرد).
| مقدار | از کجا | اگر نبود |
قانونِ بازار: داخلِ اپِ بازار حالا پرداخت با درگاه هم پیشنهاد می‌شود (خواستِ مالک). بازار پرداختِ محتوای دیجیتال بیرون از
پرداختِ درون‌برنامه‌ای را محدود می‌کند؛ پیش از انتشار در بازار این را با بازار یا مالک روشن کنید.
ساخت روی مکِ مالک: SDK در `~/Library/Android/sdk`، JDK 17 (`/opt/homebrew/opt/openjdk@17`)، و چون Google Maven از
ایران بی‌VPN ۴۰۴ می‌دهد، `~/.gradle/init.d/google-mirror.gradle` آینه‌ی Aliyun را اضافه می‌کند؛ ساختِ اول ~۹ دقیقه.
## پیش از build — آنچه از پیشخانِ بازار لازم است
| چه | کجا گذاشته می‌شود | وضعیت (۲۰۲۶-۱۰-۱۲) |
|---|---|---|
| `storeFile` تا `keyPassword` | keystoreِ شرکت. **همان فایلِ APKِ فعلی را امضا کرده؛ نسخه‌ی تازه با کلیدِ دیگر روی بازار آپدیت نمی‌شود.** دو نسخه‌ی پشتیبان در دو جای جدا | build نوعِ release با پیامِ روشن می‌ایستد |
| `bazaarRsaKey` | پیشخان ← اپ ← پرداخت درون‌برنامه‌ای | build نوعِ release می‌ایستد (نه اپِ بی‌خرید) |
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app`؛ بازار محصول‌های ۱۰۳۴۲–۱۰۳۴۴ را زیرِ همین نام شناخت (API، توکنِ ساختگی) |
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | گذاشته شد |
| SKUِ هر پلن | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | یک‌ماهه `10342`، سه‌ماهه `10343`، شش‌ماهه `10344`؛ بسته‌ی سوال فروخته نمی‌شود |
| کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور |
فقط اشتراک فروخته می‌شود. قیمت: فرانت `TadrisinoAndroid.bazaarPrices(skusJson)` را صدا می‌زند و قیمتِ پیشخان را روی
کاشیِ بازار نشان می‌دهد. خرید: `TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)` با `subscribeProduct`
(مصرف نمی‌شود، بازار خودش تمدید می‌کند). قیمتِ پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف
خورده، سرور قیمتِ کم‌شده را امضا می‌کند.
`compileDebugKotlin` سبز است؛ هیچ خریدی روی گوشی آزموده نشده.
## build
نیازها (یک بار): JDK 17 (`brew install openjdk@17`) و Android SDK با `platforms;android-34` و `build-tools;34.0.0` در `~/Library/Android/sdk`.
```bash
cd android && ./build-apk.sh
```
خروجی `android/build-out/Tadrisino-<نسخه>-<versionCode>.apk` است. پیش از هر انتشار `versionCode` را در `app/build.gradle.kts`
بالا ببرید (بازار نسخه‌ی با شماره‌ی تکراری را نمی‌پذیرد). اسکریپت امضا را می‌سنجد و اثرانگشتِ گواهی را چاپ می‌کند؛ باید با APKِ قبلی یکی باشد.
APKِ آزمایشی بی‌نیاز از کلید: `./gradlew assembleDebug`.
1. Android Studio ← Open ← همین پوشه‌ی `android/`. Studio خودش Gradle wrapper را می‌سازد.
2. `Build ← Generate Signed App Bundle / APK`، با کلیدِ امضای شرکت. **کلیدِ امضا را گم نکنید**:
بدونِ همان کلید هیچ نسخه‌ی بعدی روی بازار به‌روزرسانی نمی‌شود.
3. نصب روی گوشی‌ای که کافه‌بازار دارد و با حسابِ تست وارد شده.
## جریانِ خرید (برای عیب‌یابی)
```
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
فرانت TadrisinoAndroid.bazaarPurchase(sku, orderId)
اپ Poolakey.purchaseProduct(sku, payload=orderId) → purchaseToken
فرانت (اختیاری) POST /api/discount/redeem {code, orderId}
فرانت POST /api/payment/bazaar/orders/{orderId}/start → sku، dynamicPriceToken (اگر تخفیف خورده)
فرانت TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
اپ Poolakey.subscribeProduct(sku, payload=orderId) → purchaseToken
اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken}
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار می‌پرسد
فرانت TadrisinoAndroid.bazaarConsume(token) ← تا همان بسته دوباره خریدنی باشد
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار می‌پرسد و اشتراک را فعال می‌کند
```
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» می‌زند: فرانت همان سفارش را می‌فرستد
و اپ توکنِ ماندهٔ همان سفارش را از `getPurchasedProducts` پیدا می‌کند.
و اپ توکنِ همان سفارش را از `getSubscribedProducts` پیدا می‌کند.
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بک‌اند.

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

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

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"?>
<resources>
<string name="app_name">تدریسینو</string>
<string name="app_name">تدریسینو ریاضی</string>
</resources>

View File

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

View File

@@ -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 name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ED6956" />
<meta name="description" content="تدریسینو — یادگیریِ ریاضیِ پایه‌ی سوم تا دوازدهم، قدم‌به‌قدم و با دستیارِ هوشمند." />
<meta name="description" content="تدریسینو — یادگیری ریاضی پایه سوم تا دوازدهم، قدم‌به‌قدم و با دستیار هوشمند." />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>تدریسینو</title>
<!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار می‌شود. -->
<script src="/config.js"></script>
<!-- اسپلشِ ثابت: همین‌جا در HTML است تا از اولین فریم دیده شود (نه صفحه‌ی سفید تا بار شدنِ JS)؛
SplashScreen در React عینِ همین را می‌کشد، پس هنگامِ تحویل چیزی نمی‌پرد. فونتِ سیستم، چون
Vazirmatn بعد از CSS می‌رسد و عوض‌شدنِ فونت خودش یک پرش است. -->
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preload" as="image" href="/app-icon.png" />
<script>
(function () {
var b = window.TadrisinoAndroid;
if (b) document.documentElement.className = typeof b.appReady === 'function' ? 'in-app' : 'in-old-app';
})();
</script>
<style>
.boot-splash { position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 20px; background: #fff; }
.boot-splash img { width: 144px; height: 144px; display: block; }
/* داخل اپ اندروید اسپلش بومی روی صفحه است و سایت زیرش بار می‌شود؛ اسپلش وب نباید یک بار دیگر
«عوض شدن» نشان دهد. APK تازه (appReady دارد): اسپلش وب نامرئی است. APK قدیمی: فقط آیکون،
بی‌نام، تا کمتر از اسپلش سیستم فرق کند. */
.in-app .boot-splash > * { visibility: hidden; }
.in-old-app .boot-splash span { display: none; }
.in-old-app .boot-splash img { width: 160px; height: 160px; }
.boot-splash span { font: 900 34px/1.2 system-ui, -apple-system, sans-serif; color: #1a1a2e; }
</style>
</head>
<body>
<div id="root"></div>
<div id="root">
<div class="boot-splash" dir="rtl"><img src="/app-icon.png" alt="" width="144" height="144" /><span>تدریسینو</span></div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
pay.html Normal file
View File

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

BIN
public/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

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

View File

@@ -21,6 +21,8 @@ export class ApiError extends Error {
readonly status: number,
readonly detail: string,
readonly body: Record<string, unknown> = {},
/** فقط برای status 0: درخواست به‌خاطرِ timeout قطع شد (اینترنت کند)، نه رسیدن‌نداشتن (اینترنت قطع). */
readonly timedOut = false,
) {
super(detail);
this.name = 'ApiError';
@@ -75,13 +77,16 @@ export interface HttpClientOptions {
baseUrl: string;
storage?: TokenStorage;
timeoutMs?: number;
readTimeoutMs?: number;
maxRetries?: number;
}
const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
function backoffMs(attempt: number, retryAfterMs?: number): number {
if (retryAfterMs != null) return Math.min(retryAfterMs, 30_000);
// Retry-After کمینه است، نه زمانِ دقیق: وقتی سرور زیرِ بار هزاران گوشی را هم‌زمان رد می‌کند، اگر همه
// دقیقاً بعد از همان چند ثانیه برگردند همان اوج دوباره ساخته می‌شود. پس بینِ ۱ تا ۲ برابرِ آن پخش می‌شوند.
if (retryAfterMs != null) return Math.min(retryAfterMs * (1 + Math.random()), 30_000);
return Math.random() * Math.min(1000 * 2 ** attempt, 8000);
}
@@ -159,6 +164,9 @@ export function createHttpClient(options: HttpClientOptions) {
const baseUrl = options.baseUrl.replace(/\/+$/, '');
const storage = options.storage ?? localTokenStorage;
const defaultTimeout = options.timeoutMs ?? 15_000;
// خواندن‌ها (GET) تکرارپذیرند و کاربر پشتِ اسپلش منتظر است: ۸ ثانیه کافی است، بعدش تلاشِ دوباره یا پیامِ «اینترنت کند است».
// نوشتن‌ها (POST/PATCH) همان ۱۵ می‌مانند: قطعِ زودهنگام یعنی کاربر دوباره می‌زند و ممکن است ثبتِ دوباره شود.
const readTimeout = options.readTimeoutMs ?? 8_000;
const maxRetries = options.maxRetries ?? 2;
const inFlight = new Map<string, Promise<unknown>>();
@@ -233,7 +241,7 @@ export function createHttpClient(options: HttpClientOptions) {
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
const timer = timeoutSignal(opts.timeoutMs ?? defaultTimeout);
const timer = timeoutSignal(opts.timeoutMs ?? (method === 'GET' ? readTimeout : defaultTimeout));
try {
return await fetch(`${baseUrl}${path}`, {
method,
@@ -246,7 +254,7 @@ export function createHttpClient(options: HttpClientOptions) {
} catch (err) {
if (opts.signal?.aborted) throw err;
const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError');
throw new ApiError(0, timedOut ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد');
throw new ApiError(0, timedOut ? 'زمان درخواست تمام شد' : 'ارتباط با سرور برقرار نشد', {}, timedOut);
}
}

View File

@@ -81,8 +81,11 @@ export const api = {
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
/** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */
home: () => http.get<T.HomeResponse>('/home'),
/**
* بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. بدونِ subject اولین کتابِ پایه؛
* کتابِ ناموجود در پایه ۴۰۰ می‌دهد (برای کلیدِ ذخیره‌شده‌ی قدیمی از `fetchHome` استفاده کنید).
*/
home: (subject?: string) => http.get<T.HomeResponse>(`/home${q({ subject })}`),
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
/**
@@ -118,6 +121,17 @@ export const api = {
),
},
/** لایک/دیسلایک و گزارشِ ایراد روی سوال و درس (۰ = پس‌گرفتنِ رأی). */
feedback: {
vote: (kind: 'question' | 'lesson', id: string, value: 1 | -1 | 0) =>
http.post<{ value: number }>(`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/vote`, { value }),
report: (kind: 'question' | 'lesson', id: string, reason: string, note?: string) =>
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/report`,
{ reason, ...(note ? { note } : {}) },
),
},
leaderboard: (period: T.LeaderboardPeriod) =>
http.get<{ period: string; entries: T.LeaderboardEntry[] }>(`/leaderboard${q({ period })}`).then((r) => r.entries),
@@ -136,7 +150,11 @@ export const api = {
* `questionId` به دستیار می‌گوید دانش‌آموز کدام سوال را غلط زده.
*/
async ask(
input: { userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string },
input: {
userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string;
/** نامِ کتاب («هندسه»)؛ برای پرسشِ بی‌درس تا دستیار از کتابِ همان انتخابِ کاربر جواب بدهد. */
subject?: string;
},
onDelta: (text: string) => void,
signal?: AbortSignal,
): Promise<T.AskDone> {
@@ -182,6 +200,8 @@ export const api = {
*/
purchaseBazaar: (planId: string, idempotencyKey: string) =>
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }),
/** SKU، نوعِ محصول و (اگر تخفیف خورده) قیمتِ امضاشده برای بازار؛ بعد از آن کدِ تخفیف نمی‌نشیند. */
startBazaar: (orderId: string) => http.post<T.BazaarStartResponse>(`/payment/bazaar/orders/${seg(orderId)}/start`),
/** توکنِ خریدِ Poolakey را سرور از خودِ بازار می‌سنجد؛ گفته‌ی اپ کافی نیست. */
verifyBazaar: (orderId: string, purchaseToken: string) =>
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),

View File

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

View File

@@ -1,11 +1,14 @@
import { useCallback, useEffect, useState } from 'react';
import { AnimatePresence, MotionConfig, motion } from 'motion/react';
import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
import type { Prefetch } from '@/state/AppData';
import { AppShell } from '@/components/AppShell';
import { ToastProvider } from '@/components/Toast';
import { ErrorState } from '@/components/ui';
import { KEYS, store } from '@/lib/storage';
import { askStore } from '@/state/askStore';
import { fetchHome, subjectStore } from '@/state/subject';
import { clearAsyncCache } from '@/lib/useAsync';
import { LoginScreen } from '@/screens/LoginScreen';
import { OnboardingScreen } from '@/screens/OnboardingScreen';
import { SetupScreen } from '@/screens/SetupScreen';
@@ -19,13 +22,10 @@ type Flow =
| { kind: 'onboarding' }
| { kind: 'login' }
| { kind: 'setup'; profile: UserProfile; grades: Grade[] }
| { kind: 'main'; profile: UserProfile; grades: Grade[] };
/** کمترین زمانِ نمایشِ اسپلش، تا صفحه چشمک نزند. */
const SPLASH_MIN_MS = 700;
| { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch };
/**
* جریانِ ورود: اسپلش → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ.
* جریانِ ورود: اسپلشِ ثابت (تا بررسیِ نشست) → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ.
* خودِ اپ (تب‌ها و صفحه‌ها) در MainApp است.
*/
export function App() {
@@ -33,15 +33,17 @@ export function App() {
/** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */
const enter = useCallback(async () => {
const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]);
// نقشه و آمار هم‌زمان با پروفایل گرفته می‌شوند تا خانه کامل بیاید، نه اول «دارم مسیرت رو میارم» و آمارِ صفر.
// شکستشان ورود را نمی‌خواباند: AppData همان‌ها را دوباره می‌گیرد و خطای نقشه را روی خانه نشان می‌دهد.
const profileReq = api.profile.get();
const [profile, grades, prefetch] = await Promise.all([profileReq, api.grades(), prefetchMain(profileReq)]);
await trackReferral();
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades });
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades, prefetch });
else setFlow({ kind: 'setup', profile, grades });
}, []);
const boot = useCallback(async () => {
setFlow({ kind: 'boot' });
const started = Date.now();
let next: Flow | null = null;
if (api.auth.isSignedIn()) {
try {
@@ -55,8 +57,6 @@ export function App() {
} else {
next = store.get(KEYS.onboarded) ? { kind: 'login' } : { kind: 'onboarding' };
}
const wait = SPLASH_MIN_MS - (Date.now() - started);
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
if (next) setFlow(next);
}, [enter]);
@@ -64,11 +64,20 @@ export function App() {
void boot();
}, [boot]);
// داخلِ اپِ اندروید اسپلشِ سیستم تا این لحظه روی صفحه می‌ماند؛ پس از اولین صفحه‌ی واقعی (نه boot) می‌رود
// و کاربر مستقیم همان صفحه را می‌بیند. ۳۰۰ms: تا محوشدنِ ورودِ صفحه تمام شود. APKِ قدیمی‌تر appReady ندارد.
useEffect(() => {
if (flow.kind === 'boot') return;
const t = setTimeout(() => window.TadrisinoAndroid?.appReady?.(), 300);
return () => clearTimeout(t);
}, [flow.kind]);
// هر جا نشست باطل شد (refresh هم شکست خورد)، برگرد به صفحه‌ی ورود.
useEffect(
() =>
http.onSignedOut(() => {
askStore.clear();
clearAsyncCache();
setFlow({ kind: 'login' });
}),
[],
@@ -87,14 +96,15 @@ export function App() {
<MotionConfig reducedMotion="user">
<AppShell>
<ToastProvider>
<AnimatePresence mode="wait">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={flow.kind}
className="flex h-full flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
// اسپلش بی‌محو: با عوض‌شدنِ صفحه فوراً می‌رود و صفحه‌ی بعد خودش محو می‌شود.
transition={{ duration: flow.kind === 'boot' ? 0 : 0.2 }}
>
{flow.kind === 'boot' && <SplashScreen />}
{flow.kind === 'bootError' && (
@@ -122,8 +132,12 @@ export function App() {
<MainApp
profile={flow.profile}
grades={flow.grades}
prefetch={flow.prefetch}
payment={launchPayment}
onSignedOut={() => setFlow({ kind: 'login' })}
onSignedOut={() => {
clearAsyncCache();
setFlow({ kind: 'login' });
}}
/>
)}
</motion.div>
@@ -134,6 +148,18 @@ export function App() {
);
}
/** داده‌ی صفحه‌ی اول؛ هر کدام که نرسید null می‌ماند و AppData خودش دوباره می‌گیرد. */
async function prefetchMain(profile: Promise<UserProfile>): Promise<Prefetch> {
// کتابِ انتخاب‌شده به پایه بسته است؛ فقط وقتی چیزی ذخیره شده منتظرِ پروفایل می‌مانیم (پایه‌های ≤ دهم
// معمولاً هیچ انتخابی ندارند و نقشه هم‌زمان با پروفایل می‌آید).
const gradeId = Object.keys(subjectStore.snapshot()).length ? (await profile).gradeId : null;
const [home, summary] = await Promise.allSettled([fetchHome(gradeId), api.summary()]);
return {
home: home.status === 'fulfilled' ? home.value : null,
summary: summary.status === 'fulfilled' ? summary.value : null,
};
}
/** اگر کاربر با لینکِ دعوت آمده بود و حالا وارد شده، معرفی را ثبت کن. */
async function trackReferral() {
const code = store.get(KEYS.referralCode);

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,12 @@
import { useEffect, type ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { AnimatePresence, motion, useDragControls } from 'motion/react';
import { X } from 'lucide-react';
import { useBackStep } from '@/nav/Navigator';
/** برگه‌ای که از پایین بالا می‌آید؛ با زدنِ پس‌زمینه یا Escape بسته می‌شود. */
/**
* برگه‌ای که از پایین بالا می‌آید؛ با زدنِ پس‌زمینه، Escape، یا کشیدنِ دستگیره/سرِ برگه به پایین بسته می‌شود.
* کشیدن فقط از بالای برگه شروع می‌شود تا با اسکرولِ محتوای بلندِ برگه قاطی نشود.
*/
export function Sheet({
open,
onClose,
@@ -14,6 +18,9 @@ export function Sheet({
title?: ReactNode;
children: ReactNode;
}) {
const drag = useDragControls();
// دکمه‌ی برگشتِ گوشی اول برگه را می‌بندد، نه صفحه‌ی زیرش را.
useBackStep(open, onClose);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
@@ -26,7 +33,7 @@ export function Sheet({
{open && (
<div className="absolute inset-0 z-50 flex flex-col justify-end">
<motion.div
className="absolute inset-0 bg-[#14142a]/45"
className="absolute inset-0 bg-[#14142a]/55"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -41,10 +48,20 @@ export function Sheet({
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
drag="y"
dragControls={drag}
dragListener={false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.9 }}
onDragEnd={(_, info) => {
if (info.offset.y > 90 || info.velocity.y > 600) onClose();
}}
>
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-soft" />
<div className="-mx-5 -mt-3 cursor-grab touch-none px-5 pb-3 pt-3" onPointerDown={(e) => drag.start(e)}>
<div className="mx-auto h-1.5 w-10 rounded-full bg-line" />
</div>
{title && (
<div className="mb-4 flex items-center justify-between gap-3">
<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>
<button type="button" aria-label="بستن" onClick={onClose} className="rounded-full bg-soft p-1.5 text-muted">
<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 { friendlyError } from '@/lib/errors';
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,
back = false,
mascot,
exact = false,
children,
}: {
title: ReactNode;
@@ -21,18 +23,27 @@ export function ScreenHeader({
color: string;
/** دکمه‌ی بازگشت؛ true یعنی صفحه‌ی قبل در پشته. */
back?: boolean | (() => void);
mascot?: { char: SeyarChar; mood?: SeyarMood; size?: number };
/** بی `char` همراهِ انتخابیِ کاربر است؛ همه‌ی صفحه‌ها همین را می‌خواهند. */
mascot?: { char?: SeyarChar; mood?: SeyarMood; size?: number };
/** رنگ را همان‌طور که هست بگذار (بی‌تیره‌کردن برای کنتراست)؛ انتخابِ مالک برای چند صفحه. */
exact?: boolean;
children?: ReactNode;
}) {
const nav = useNav();
const companion = useCompanion().char;
const onBack = typeof back === 'function' ? back : nav.back;
const bg = exact ? color : onWhite(color);
return (
<header
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 -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">
{back && (typeof back === 'function' || nav.canGoBack) && (
<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>
{subtitle && <div className="mt-1 text-xs text-white/75">{subtitle}</div>}
</div>
{mascot && <SeyarMascot char={mascot.char} mood={mascot.mood ?? 'happy'} size={mascot.size ?? 64} />}
{mascot && <SeyarMascot char={mascot.char ?? companion} mood={mascot.mood ?? 'happy'} size={mascot.size ?? 64} />}
</div>
{children}
</div>
@@ -111,7 +122,7 @@ export function Spinner({ size = 20, className = '' }: { size?: number; classNam
return (
<span
role="status"
aria-label="در حالِ بارگذاری"
aria-label="در حال بارگذاری"
className={`inline-block animate-spin rounded-full border-2 border-current border-t-transparent ${className}`}
style={{ width: size, height: size }}
/>
@@ -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 (
<div className="flex flex-col items-center justify-center gap-3 px-8 py-16 text-center">
<SeyarMascot char={char} mood={mood} size={72} />
<SeyarMascot char={char ?? companion} mood={mood} size={72} />
<p className="text-sm leading-7 text-muted">{text}</p>
{children}
</div>
@@ -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);
useEffect(() => {
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 }) {
return (
<StateBox char="pi" mood="sad" text={text ?? friendlyError(error)}>
<StateBox mood="sad" text={text ?? friendlyError(error)}>
{onRetry && (
<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} />;
}

View File

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

View File

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

View File

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

View File

@@ -74,7 +74,7 @@ function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts:
: <circle key={w} cx={cx(w)} cy={cy} r={r} fill={SHADE} stroke={INK} strokeWidth={2} />))}
<g>{parts === 1 ? <circle cx={cx(wholes)} cy={cy} r={r} fill={shaded ? SHADE : '#fff'} stroke={INK} strokeWidth={2} /> : Array.from({ length: parts }, (_, i) => slice(cx(wholes), i, false))}</g>
<text x={W / 2} y={112} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
{`${toFa(wholes)} واحدِ کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`}
{`${toFa(wholes)} واحد کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`}
</text>
</svg>
);
@@ -268,7 +268,7 @@ export function Pattern({ spec }: { spec: PatternSpec }) {
? <circle key={k} cx={cx + u / 2} cy={cy + u / 2} r={u * 0.32} fill={col} />
: <rect key={k} x={cx + 1} y={cy + 1} width={u - 2} height={u - 2} rx={2} fill={`${col}33`} stroke={col} strokeWidth={1.5} />;
})}
<text x={x0 + (s.w * u) / 2} y={base + 16} fontSize={11} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکلِ ${toFa(i + 1)}`}</text>
<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>}
</g>
);
@@ -281,7 +281,7 @@ export function Pattern({ spec }: { spec: PatternSpec }) {
<g>
<rect x={nx} y={base - 1.4 * u} width={1.4 * u} height={1.4 * u} rx={6} fill="#fff" stroke={COLORS.red} strokeWidth={2} strokeDasharray="4 3" />
<text x={nx + 0.7 * u} y={base - 0.45 * u} fontSize={u * 0.8} fontWeight={900} fill={COLORS.red} textAnchor="middle">?</text>
<text x={nx + 0.7 * u} y={base + 16} fontSize={11} fontWeight={800} fill={COLORS.red} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکلِ ${toFa(stages.length + 1)}`}</text>
<text x={nx + 0.7 * u} y={base + 16} fontSize={11} fontWeight={800} fill={COLORS.red} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکل ${toFa(stages.length + 1)}`}</text>
</g>
)}
</svg>

View File

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

View File

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

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} /></p>;
return (
<div className={className}>
{list.lead && <p className="mb-2 whitespace-pre-line"><MathText text={list.lead} /></p>}
<ol className="flex flex-col gap-2.5">
{list.items.map((item, i) => (
<li key={i} className="flex gap-2.5">
<span className="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
{toFa(i + 1)}
</span>
<span className="flex-1 whitespace-pre-line"><MathText text={item} /></span>
</li>
))}
</ol>
</div>
);
}

View File

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

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

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

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

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

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

@@ -0,0 +1,87 @@
/**
* تورِ ایمنیِ نمایش: 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: '⋯', 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(SYMBOL_RE, (_m, n: string) => SYMBOLS[n]);
s = s.replace(new RegExp(`\\^${G}`, 'g'), (_m, a: string) => script(a, SUP, '^'));
s = s.replace(new RegExp(`_${G}`, 'g'), (_m, a: string) => script(a, SUB, '_'));
s = s.replace(/\^\(((?:[^()]|\([^()]*\))*)\)/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توان (${a.trim()}) `);
s = s.replace(/\^([+\-−]?[0-9۰-۹]+)/g, (m, a: string) => tryMap(a, SUP) ?? m);
s = s.replace(/(?<=[A-Za-z0-9۰-۹)₀-₉])\^([A-Za-zα-ωΑ-Ω□])(?![A-Za-z0-9۰-۹])/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توان ${a} `);
s = s.replace(/((?<![A-Za-z0-9_])[A-Za-z]|log|ln|lim|[)∫Σ∏])_([0-9۰-۹]+[A-Za-z]?|[A-Za-z]{1,4}[0-9۰-۹]?)(?![A-Za-z0-9_])/g, (_m, p: string, a: string) => (p === 'log' && [...a].some((c) => !(c in SUB)) ? `log پایه ${a}` : p + [...a].map((c) => SUB[c] ?? c).join('')));
s = s.replace(/\^([+\-−])/g, (_m, a: string) => SUP[a]);
s = s.replace(/(?<=[A-Za-z)'])_([+\-−])/g, (_m, a: string) => SUB[a]);
s = s.replace(/\^(√[0-9۰-۹A-Za-z]+)/g, ' به توان $1 ').replace(/\^([A-Za-z]{2,3})(?![A-Za-z0-9])/g, ' به توان $1 ');
s = s.replace(/(?<=\S) {2,}(?=\S)/g, ' ');
s = s.replace(/\*\*([\s\S]+?)\*\*/g, '$1').replace(/(?<![_\w])__(.+?)__(?![_\w])/g, '$1').replace(/`/g, '');
return s.replace(/^\s{0,3}#{1,6}\s+/gm, '');
}
/**
* بی‌اعراب و بی «ی»ِ اضافه («پایه‌ی سوم» → «پایه سوم»)؛ تنوین («قبلاً») می‌ماند. همان قاعده‌ی بک‌اند
* (`content/plain_math.py::plain_persian`، روی هر پاسخِ API). این‌جا برای پاسخِ استریمیِ دستیار است که پیش از
* پاک‌سازیِ نهاییِ سرور تکه‌تکه می‌رسد.
*/
export function plainPersian(text: string): string {
if (!text) return text;
return text.replace(/[ٌ-ْٔ]/g, '').replace(/ه‌ی(?![؀-ۿ‌])/g, 'ه');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
} catch (err) {
if (mine !== attempt.current) return;
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن'));
setError(friendlyError(err, 'ارسال کد انجام نشد، دوباره امتحان کن'));
if (err instanceof ApiError && err.status === 400) {
setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند
} else if (err instanceof ApiError && err.isRateLimited && err.retryAfterMs) {
@@ -109,8 +109,8 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
<h1 className="text-xl font-black">ورود به تدریسینو</h1>
<p className="mt-1 text-sm text-white/75">
{step === 'phone'
? 'شماره‌ی موبایلت رو وارد کن'
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامک‌شده رو وارد کن`}
? 'شماره موبایلت رو وارد کن'
: `کد ${toFa(OTP_LENGTH)} رقمی پیامک‌شده رو وارد کن`}
</p>
</div>
<div className="rounded-2xl bg-white/15 p-2">
@@ -146,7 +146,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
) : (
<div>
<label className="block">
<span className="mb-2 block text-sm font-bold text-ink">کدِ تأیید</span>
<span className="mb-2 block text-sm font-bold text-ink">کد تأیید</span>
<input
ref={otpInput}
value={otp}
@@ -165,7 +165,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
)}
{devOtp && (
<p className="mt-2 text-xs text-muted">
کدِ آزمایشیِ حالتِ توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
کد آزمایشی حالت توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
</p>
)}
<div className="mt-3 flex items-center justify-between text-xs font-bold">
@@ -173,7 +173,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
<Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح
</button>
<button type="button" disabled={resendIn > 0 || sending || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted">
{resendIn > 0 ? `ارسالِ دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسالِ دوباره'}
{resendIn > 0 ? `ارسال دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسال دوباره'}
</button>
</div>
</div>
@@ -187,7 +187,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
loading={loading === 'login'}
disabled={step === 'phone' ? !phoneValid : !otpValid}
>
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'}
{step === 'phone' ? 'دریافت کد تأیید' : 'ورود'}
</PrimaryButton>
</div>
</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();
setUnreadCount(r.unreadCount);
return r.items;
}, []);
}, [], { key: 'notifications' });
const items = list.data ?? [];
const unread = items.filter((n) => !n.isRead).length;
@@ -51,7 +51,7 @@ export function NotificationsScreen() {
return (
<Screen>
<ScreenHeader title="اعلان‌ها" color="#4C5FD5" back mascot={{ char: 'sigma', mood: 'idle', size: 54 }}>
<ScreenHeader title="اعلان‌ها" color="#4C5FD5" back mascot={{ mood: 'idle', size: 54 }}>
{unread > 0 && (
<button type="button" onClick={() => void markAll()} className="mt-3 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold">
همه خوانده شد

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import confetti from 'canvas-confetti';
import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react';
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
import { Brain, Check, ChevronLeft, Coins, Compass, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react';
import { api, ApiError, type AnswerResult, type Hearts, type Question } from '@/api';
import { useAppData } from '@/state/AppData';
import { fetchHome } from '@/state/subject';
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { newIdempotencyKey } from '@/api/http';
@@ -16,7 +16,12 @@ import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton,
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
import { Diagram } from '@/diagram';
import { FeedbackBar } from '@/components/FeedbackBar';
import { sounds } from '@/lib/sound';
import { useGateHold } from '@/lib/useAsync';
import { StreakCelebration, StreakFlame } from '@/components/StreakFlame';
import { useCompanion } from '@/state/companion';
type Phase =
| { kind: 'loading' }
@@ -47,7 +52,11 @@ type PlayQuestion = Question & { lessonId?: string };
* اصلیِ بازی همین است.
*/
export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) {
const { home, companion, refreshHome, refreshSummary } = useAppData();
const { home, companion, summary, refreshHome, refreshSummary } = useAppData();
const look = useCompanion();
// زنجیره پیش از این تمرین؛ اگر بعد از ثبت امروز روشن شد، صفحه‌ی «روزِ پیاپی» نشان داده می‌شود.
const streakBefore = useRef(summary?.streak ?? null);
const [streakUp, setStreakUp] = useState<{ from: number; to: number } | null>(null);
const nav = useNav();
const toast = useToast();
@@ -61,8 +70,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
const [submitting, setSubmitting] = useState(false);
const [correctCount, setCorrectCount] = useState(0);
const [confirmExit, setConfirmExit] = useState(false);
const [reporting, setReporting] = useState(false);
const [reload, setReload] = useState(0);
// پشتِ هم درست؛ برای «سه تا پشتِ هم!» (حسِ پیشرفت وسطِ آزمون، نه فقط آخرش).
const [combo, setCombo] = useState(0);
useGateHold(phase.kind === 'loading');
// یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمی‌شود.
const answerKeys = useRef(new Map<string, string>());
@@ -91,20 +102,33 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
setSelected(null);
setAnswer(null);
setCorrectCount(0);
setCombo(0);
answerKeys.current.clear();
completeKey.current = newIdempotencyKey();
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
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 () => {
alive = false;
};
}, [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);
}
async function check() {
if (!question || selected === null || answer || submitting) return;
const optionId = selected;
setSubmitting(true);
let key = answerKeys.current.get(question.id);
if (!key) {
@@ -117,12 +141,15 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h));
if (result.correct) {
setCorrectCount((c) => c + 1);
sounds.correct();
setCombo(combo + 1);
if (look.char === 'delta' && combo + 1 >= 2) sounds.combo();
sounds.correct(look.pitch);
} else {
setCombo(0);
sounds.wrong();
}
} catch (err) {
setSelected(null);
// گزینه انتخاب‌شده می‌ماند تا کاربر دوباره «بررسی» بزند.
if (err instanceof ApiError && err.code === 'no_hearts') {
setPhase({
kind: 'noHearts',
@@ -165,9 +192,23 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
: await api.lesson.complete(lessonId, payload, completeKey.current);
setPhase({ kind: 'result', result });
sounds.finish();
if (result.stars >= 2) void confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } });
// کاغذرنگی به رنگِ همراه؛ امگا («جشن‌ساز») با هر نمره‌ای جشن می‌گیرد، بقیه از دو ستاره.
if (result.stars >= 2 || look.char === 'tedi') {
const colors = [look.color, look.light, '#FFD23F', '#ffffff'];
void import('canvas-confetti').then(({ default: confetti }) =>
confetti({ particleCount: look.char === 'tedi' ? 140 : 90, spread: look.char === 'tedi' ? 100 : 70, origin: { y: 0.35 }, colors }),
);
}
void refreshHome();
void refreshSummary();
const before = streakBefore.current;
if (before && !before.activeToday) {
api.summary()
.then((fresh) => {
if (fresh.streak.activeToday) setStreakUp({ from: before.current, to: fresh.streak.current });
})
.catch((err: unknown) => console.warn('streak check after finish failed', err));
}
} catch (error) {
// سرور پایانِ درس/جزیره را بی‌قلب و بی‌جواب نمی‌پذیرد (۱۴۰۵-۰۷)؛ همان صفحه‌ی انتظار.
if (error instanceof ApiError && error.code === 'no_hearts') {
@@ -206,7 +247,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
if (phase.kind === 'noHearts') {
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={() => setReload((n) => n + 1)} />;
}
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} />;
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} streakUp={streakUp} />;
if (phase.kind === 'finishing' || phase.kind === 'finishError') {
return (
<Screen>
@@ -216,7 +257,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
<p className="text-sm text-muted">دارم نتیجه‌ت رو ثبت می‌کنم…</p>
) : (
<>
<InlineError>نتیجه‌ی این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
<InlineError>نتیجه این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
<PrimaryButton color={color} onClick={() => void finish()}>دوباره ثبت کن</PrimaryButton>
<SecondaryButton onClick={nav.back}>بی‌خیال، برگرد</SecondaryButton>
</>
@@ -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 bubble = !answer
? 'جوابِ درست رو انتخاب کن!'
? selected === null ? 'جواب درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
: answer.correct
? 'آفرین! درست بود ⚡'
? combo >= 2 ? `${toFa(combo)} تا پشت هم درست! داری عالی پیش می‌ری` : PRAISE[index % PRAISE.length]
: outOfHearts
? 'قلب‌هات تموم شد؛ یه کم استراحت کنیم.'
: 'اشکالی نداره، جوابِ درست رو ببین.';
: NUDGE[index % NUDGE.length];
const shortOptions = question.options.every((o) => o.value.length <= 14);
const optionClass = (id: string) => {
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 === selected) return 'border-red-300 bg-[#fff0f0] text-red-500';
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-full rounded-full transition-all duration-500" style={{ width: `${((index + (answer ? 1 : 0)) / questions.length) * 100}%`, backgroundColor: color }} />
</div>
<span className="w-10 flex-shrink-0 text-left text-xs font-bold text-muted">
{toFa(index + 1)}/{toFa(questions.length)}
<TraitChip char={look.char} color={look.color} correct={correctCount} combo={combo} />
<span className="flex-shrink-0 text-left text-xs font-bold text-muted">
{/* «۱/۴» در اپِ ریاضی کسر خوانده می‌شد. */}
{toFa(index + 1)} از {toFa(questions.length)}
</span>
</div>
<HeartsRow hearts={hearts} />
@@ -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]'
}`}>
{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 className="rounded-3xl border border-line bg-white p-5 shadow-sm">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted">سوالِ {toFa(index + 1)}</span>
<button type="button" onClick={() => setReporting(true)} className="flex items-center gap-1 text-[11px] font-bold text-faint">
<Flag size={12} /> گزارشِ ایراد
</button>
<span className="text-xs font-medium text-muted">سوال {toFa(index + 1)}</span>
</div>
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} /></h2>
{question.figure ? <div className="mt-3"><Diagram spec={question.figure} /></div> : null}
</div>
<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
key={option.id}
type="button"
onClick={() => void choose(option.id)}
disabled={selected !== null && !answer}
whileTap={{ scale: selected === null ? 0.95 : 1 }}
onClick={() => choose(option.id)}
disabled={submitting || answer !== null}
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)}`}
>
<span><MathText text={option.value} /></span>
@@ -313,17 +367,35 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
))}
</div>
{answer && (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="mt-1 flex flex-col gap-2.5">
<FeedbackBar kind="question" id={question.id} />
</div>
{answer && !answer.correct && look.char === 'pi' && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="mx-4 mb-2 flex items-start gap-2 rounded-2xl bg-[#F5F1FF] px-3 py-2.5 text-xs font-bold leading-6 text-[#5B3FA0]"
>
<span className="text-base leading-5">π</span>
<span>{PI_TIPS[index % PI_TIPS.length]}</span>
</motion.div>
)}
<div className="flex flex-shrink-0 flex-col gap-2.5 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
{answer ? (
<>
{!answer.correct && (
<button type="button" onClick={askWhy} className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]">
<Brain size={16} /> از پی بپرس چرا غلط شد
<Brain size={16} /> از {look.name} بپرس چرا غلط شد
</button>
)}
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
{isLast ? 'پایانِ تمرین' : 'سوالِ بعدی'} <ChevronLeft size={18} />
{isLast ? 'پایان تمرین' : 'ادامه'} <ChevronLeft size={18} />
</PrimaryButton>
</motion.div>
</>
) : (
<PrimaryButton color={color} disabled={selected === null} loading={submitting} onClick={() => void check()}>
بررسی
</PrimaryButton>
)}
</div>
@@ -337,7 +409,6 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
</div>
</Sheet>
<ReportSheet open={reporting} questionId={question.id} onClose={() => setReporting(false)} />
</Screen>
);
}
@@ -346,7 +417,7 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
if (hearts?.isUnlimited) {
return (
<div className="mt-3 flex items-center justify-center gap-1.5 text-xs font-bold text-coral">
<Heart size={18} fill="#ED6956" /> <InfinityIcon size={18} /> قلبِ نامحدود
<Heart size={18} fill="#ED6956" /> <InfinityIcon size={18} /> قلب نامحدود
</div>
);
}
@@ -375,11 +446,15 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
}
function Result({
result, total, correct, lessonId, nodeId, color,
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string }) {
result, total, correct, lessonId, nodeId, color, streakUp,
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string; streakUp: { from: number; to: number } | null }) {
const nav = useNav();
const { companion } = useAppData();
const { companion, profile } = useAppData();
const [loadingNext, setLoadingNext] = useState(false);
// بعد از نتیجه، اگر زنجیره امروز روشن شد، یک صفحه‌ی جشنِ جدا (مثلِ دولینگو) و بعد مقصدِ انتخاب‌شده.
const [streakThen, setStreakThen] = useState<(() => void) | null>(null);
const then = (go: () => void) => (streakUp ? setStreakThen(() => go) : go());
if (streakThen && streakUp) return <StreakCelebration from={streakUp.from} to={streakUp.to} onDone={streakThen} />;
const nodeMode = lessonId === undefined;
const nextNode = result.nextNodeId ?? null;
@@ -393,7 +468,7 @@ function Result({
}
setLoadingNext(true);
try {
const fresh = await api.home();
const fresh = await fetchHome(profile.gradeId);
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId!);
const next = ref ? nextLessonOf(ref.node) : null;
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
@@ -419,10 +494,17 @@ function Result({
</div>
<div>
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی 🏝️</p>}
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی</p>}
<p className="mt-1 text-sm text-muted">{toFa(correct)} جواب درست از {toFa(total)} سوال</p>
{/* نمره‌ی کم: تلاش و راه را ستایش کن، نه «باهوشی» را (پژوهش‌های ذهنیتِ رشد)؛ و راهِ بعدی را نشان بده. */}
{result.stars <= 1 && (
<p className="mt-2 text-xs leading-6 text-body">
اشتباه‌ها همون جاهایی‌ان که مغزت داره یاد می‌گیره. هر وقت خواستی از «درس‌ها» دوباره مرورش کن.
</p>
)}
</div>
<div className="flex w-full gap-3">
{/* «+۰ امتیاز، +۰ سکه» بعد از یک دورِ بد فقط نمک روی زخم بود؛ پاداشِ صفر نشان داده نمی‌شود. */}
{(result.xpEarned > 0 || result.coinsEarned > 0) && <div className="flex w-full gap-3">
<div className="flex flex-1 flex-col items-center rounded-2xl border border-line bg-white py-3">
<Star size={18} className="text-gold" />
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.xpEarned)}</span>
@@ -433,13 +515,13 @@ function Result({
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.coinsEarned)}</span>
<span className="text-[10px] text-muted">سکه</span>
</div>
</div>
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
{nodeMode && nextNode ? 'بریم جزیره‌ی بعدی' : 'ادامه'} <ChevronLeft size={18} />
</div>}
<PrimaryButton color={color} loading={loadingNext} onClick={() => then(() => void goNext())}>
{nodeMode && nextNode ? 'بریم جزیره بعدی' : 'ادامه'} <ChevronLeft size={18} />
</PrimaryButton>
{nodeMode && nextNode && (
<button type="button" onClick={nav.back} className="-mt-2 py-2 text-sm font-bold text-muted">
فعلاً بسه، برگردیم به نقشه
<button type="button" onClick={() => then(nav.back)} className="-mt-2 py-2 text-sm font-bold text-muted">
فعلاً بسه، برگردیم
</button>
)}
</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: 'برای این درس خیلی سخته' },
{ id: 'other', label: 'یه چیزِ دیگه' },
/**
* پیامِ بعد از جواب. یک جمله‌ی ثابت بعد از چند بار دیگر خوانده نمی‌شود؛ چند تا به نوبت. برای جوابِ غلط
* تلاش و راه ستایش می‌شود و قدمِ بعدی گفته می‌شود، نه این‌که «اشکالی نداره» تنها بماند.
*/
const PRAISE = ['آفرین! درست بود', 'دقیقاً همینه!', 'عالی! خوب فکر کردی', 'درسته! داری راه میفتی'];
const NUDGE = ['نزدیک بودی! جواب درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد می‌گیریم.', 'این یکی سخت بود؛ جواب درست سبز شده.'];
/** «راهنمای مهربون»ِ پی: نکته‌ی عمومیِ کوتاه بعد از جوابِ غلط؛ نه جوابِ سوال، فقط عادتِ خوبِ حل کردن. */
const PI_TIPS = [
'صورت سوال رو یه بار دیگه آروم بخون؛ کلمه «نیست» یا «بیشترین» زیاد جا می‌مونه.',
'علامت منفی‌ها رو یه بار دیگه چک کن.',
'اول داخل پرانتز، بعد ضرب و تقسیم، آخرش جمع و تفریق.',
'جوابت رو بذار توی سوال؛ اگه درست درنیومد یه جا اشتباه شده.',
];
/** گزارشِ سوالِ ایراددار؛ مستقیم به صفِ بررسیِ محتوا می‌رود. */
function ReportSheet({ open, questionId, onClose }: { open: boolean; questionId: string; onClose: () => void }) {
const toast = useToast();
const [reason, setReason] = useState<ReportReason | null>(null);
const [note, setNote] = useState('');
const [sending, setSending] = useState(false);
useEffect(() => {
if (open) {
setReason(null);
setNote('');
}
}, [open]);
async function send() {
if (!reason) return;
setSending(true);
try {
const r = await api.lesson.report(questionId, reason, note.trim());
toast(r.alreadyReported ? 'قبلاً گزارشش رو داده بودی؛ ممنون!' : 'ممنون! گزارشت رسید و بررسی می‌شه.', 'success');
onClose();
} catch (err) {
toast(friendlyError(err, 'گزارش ارسال نشد'), 'error');
} finally {
setSending(false);
}
/**
* ویژگیِ همراه کنارِ نوارِ پیشرفتِ آزمون: سیگما جمعِ جوابای درست (Σ)، دلتا شعله‌ی جوابای پشتِ هم. پی و امگا
* ویژگی‌شان جای دیگری است (نکته بعد از غلط، جشنِ پایان)، پس این‌جا چیزی نشان نمی‌دهند.
*/
function TraitChip({ char, color, correct, combo }: { char: string; color: string; correct: number; combo: number }) {
if (char === 'sigma') {
return (
<motion.span key={correct} initial={{ scale: 1.4 }} animate={{ scale: 1 }} className="flex-shrink-0 rounded-full px-2 py-0.5 text-xs font-black text-white" style={{ backgroundColor: color }}>
Σ {toFa(correct)}
</motion.span>
);
}
if (char === 'delta' && combo >= 2) {
return (
<motion.span key={combo} initial={{ scale: 0.4, rotate: -12 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500, damping: 12 }} className="flex flex-shrink-0 items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-black text-white" style={{ backgroundColor: color }}>
<StreakFlame state="lit" size={11} className="[&_path]:fill-white" /> ×{toFa(combo)}
</motion.span>
);
}
return null;
}
/** آیکنِ کوچکِ بعد از جواب (همان خانواده‌ی آیکنِ اپ، نه ایموجیِ کیبورد)؛ هر سوال یکی از مجموعه، با یک تکانِ کوتاه. */
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 (
<Sheet open={open} onClose={onClose} title="این سوال چه ایرادی داره؟">
<div className="mb-4 flex flex-col gap-2">
{REASONS.map((r) => (
<button
key={r.id}
type="button"
onClick={() => setReason(r.id)}
className={`rounded-2xl border-2 px-4 py-3 text-right text-sm font-bold ${reason === r.id ? 'border-coral bg-[#fff4f2] text-coral' : 'border-line text-body'}`}
>
{r.label}
</button>
))}
</div>
<textarea
value={note}
onChange={(e) => setNote(e.target.value.slice(0, 500))}
placeholder="توضیحِ بیشتر (اختیاری)"
rows={2}
className="mb-4 w-full resize-none rounded-2xl border-2 border-line px-4 py-3 text-sm outline-none focus:border-coral"
/>
<PrimaryButton disabled={!reason} loading={sending} onClick={() => void send()}>ارسالِ گزارش</PrimaryButton>
</Sheet>
<motion.span
aria-hidden
className="mr-1.5 inline-block align-middle"
initial={{ scale: 0.4, rotate: 0 }}
animate={{ scale: 1, rotate: [0, -14, 14, -8, 8, 0] }}
transition={{ duration: 0.6, ease: 'easeOut' }}
>
<Icon size={18} color={color} strokeWidth={2.4} />
</motion.span>
);
}

View File

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

View File

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

View File

@@ -1,27 +1,13 @@
import { motion } from 'motion/react';
import { SeyarMascot } from '@/mascots/SeyarMascot';
/** تا وقتی نشستِ قبلی بررسی می‌شود نمایش داده می‌شود. */
/**
* اسپلشِ ثابت: آیکونِ اپ و نام، بی‌انیمیشن و بی‌همراه (همراهِ انتخابی هنوز معلوم نیست و تکرارش
* بد است). عینِ `.boot-splash` در index.html است تا تحویل از HTML به React دیده نشود؛ استایلش
* هم آن‌جا است چون باید پیش از رسیدنِ CSS و JS کار کند.
*/
export function SplashScreen() {
return (
<div className="flex h-full flex-col items-center justify-center bg-rose">
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
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 className="boot-splash" dir="rtl">
<img src="/app-icon.png" alt="" width={144} height={144} />
<span>تدریسینو</span>
</div>
);
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Sparkles } from 'lucide-react';
import { Crown, Sparkles } from 'lucide-react';
import type { HomeNode } from '@/api';
import { useAppData } from '@/state/AppData';
import { findUnit, unitColor, unitProgress } from '@/state/curriculum';
@@ -14,6 +14,7 @@ import { MathText } from '@/lib/MathText';
export function UnitScreen({ unitId }: { unitId: string }) {
const { home, homeError, refreshHome, companion } = useAppData();
const nav = useNav();
const paywall = () => nav.push({ name: 'subscription' });
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
const found = findUnit(home, unitId);
@@ -42,7 +43,7 @@ export function UnitScreen({ unitId }: { unitId: string }) {
>
<div className="mt-4">
<div className="mb-1.5 flex justify-between text-[10px] text-white/75">
<span>پیشرفتِ تو</span>
<span>پیشرفت تو</span>
<span>{toFa(progress.percent)}٪</span>
</div>
<ProgressBar percent={progress.percent} color="#fff" track="rgba(0,0,0,.1)" height={10} />
@@ -58,9 +59,14 @@ export function UnitScreen({ unitId }: { unitId: string }) {
nodes={unit.nodes}
color={color}
currentId={current?.id ?? null}
onOpen={setOpenNode}
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode(node))}
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">
<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` }}>

View File

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

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

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

View File

@@ -23,6 +23,7 @@ export interface NodeRef {
export function findContinue(home: HomeResponse | null): NodeRef | null {
if (!home) return null;
for (let i = 0; i < home.units.length; i++) {
if (home.units[i].requiresSubscription) continue;
const node = home.units[i].nodes.find((n) => n.status !== 'completed' && n.status !== 'locked');
if (node) return { unit: home.units[i], unitIndex: i, node };
}

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

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

View File

@@ -46,6 +46,8 @@
button {
cursor: pointer;
font: inherit;
/* بی‌تأخیرِ دابل‌تپ و بی‌زوم با دو ضربه روی دکمه‌ها */
touch-action: manipulation;
}
button:disabled {
cursor: not-allowed;
@@ -93,3 +95,45 @@
@utility pb-safe {
padding-bottom: max(env(safe-area-inset-bottom), 0px);
}
/* نمادهای شناورِ سربرگ (ui.tsx). فقط transform، پس روی GPU می‌ماند. */
@keyframes glyph-float {
0%, 100% { transform: translateY(0) rotate(-6deg); }
50% { transform: translateY(-7px) rotate(6deg); }
}
.glyph {
animation: glyph-float 6s ease-in-out infinite;
will-change: transform;
}
/* ناحیه‌ی لمسِ بزرگ‌تر بدونِ تغییرِ ظاهر: دکمه‌های کوچکِ سربرگ (۲۸–۳۲ پیکسل) برای انگشت کوچک‌اند. */
.hit { position: relative; }
.hit::after { content: ''; position: absolute; inset: -7px -3px; }
/* نوارِ اشتراک و نشانِ تدریسینو: فقط transform/opacity تا روی GPU بماند و روانی حفظ شود. */
@keyframes bar-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
.bar-fill { transform-origin: right; animation: bar-fill 0.9s cubic-bezier(.2,.8,.2,1) both; }
@keyframes shine { 0% { transform: translateX(120%); } 60%, 100% { transform: translateX(-220%); } }
.shine { animation: shine 3.2s ease-in-out infinite; will-change: transform; }
@keyframes spark { 0%, 100% { opacity: .25; transform: scale(.7); } 50% { opacity: 1; transform: scale(1); } }
.spark { animation: spark 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
/* نوارِ انتظارِ میانِ دو صفحه (MainApp → HoldBar). */
@keyframes hold-bar { 0% { transform: translateX(120%); } 100% { transform: translateX(-320%); } }
.hold-bar { animation: hold-bar 0.9s cubic-bezier(.4,0,.2,1) infinite; will-change: transform; }
/* شعله‌ی زنجیره (StreakFlame): دو لایه با ریتمِ متفاوت، از پایین تکان می‌خورند. */
@keyframes flame-a { 0%, 100% { transform: scale(1, 1) skewX(0deg); } 33% { transform: scale(.97, 1.04) skewX(-2deg); } 66% { transform: scale(1.02, .97) skewX(2deg); } }
@keyframes flame-b { 0%, 100% { transform: scale(1, 1); } 50% { transform: scale(.94, 1.08); } }
.flame-outer { transform-box: fill-box; transform-origin: 50% 100%; animation: flame-a 1.4s ease-in-out infinite; }
.flame-inner { transform-box: fill-box; transform-origin: 50% 100%; animation: flame-b .9s ease-in-out infinite; }
@keyframes ice-glint { 0%, 70%, 100% { opacity: 0; } 80% { opacity: 1; } }
.ice-glint { animation: ice-glint 2.8s ease-in-out infinite; }
@keyframes spark-rise { 0% { transform: translateY(0) scale(1); opacity: 0; } 15% { opacity: 1; } 100% { transform: translateY(-180px) scale(.3); opacity: 0; } }
.spark-rise { animation: spark-rise 1.8s ease-out infinite; }
/* همراه‌های کنارِ مسیرِ جزیره‌ها (IslandPath): فقط transform، تا روی گوشیِ ضعیف هم روی GPU بماند. */
@keyframes buddy-hop { 0%, 55%, 100% { transform: translateY(0) scale(1, 1); } 20% { transform: translateY(-12px) scale(.96, 1.05); } 40% { transform: translateY(0) scale(1.06, .94); } }
@keyframes buddy-sway { 0%, 100% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } }
.buddy-hop { animation: buddy-hop 1.6s ease-in-out infinite; transform-origin: 50% 100%; }
.buddy-sway { animation: buddy-sway 3.2s ease-in-out infinite; transform-origin: 50% 100%; }

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

@@ -5,6 +5,10 @@ interface ImportMetaEnv {
}
interface Window {
__TADRISINO_CONFIG__?: { apiBaseUrl?: string };
__TADRISINO_CONFIG__?: {
apiBaseUrl?: string;
/** روش‌های پرداختِ فعال در مرورگر (web) و داخلِ اپِ اندروید (app)؛ src/lib/paymentMethods.ts. */
paymentMethods?: { web?: string[]; app?: string[] };
};
webkitAudioContext?: typeof AudioContext;
}

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