Compare commits
88 Commits
rebuild/v2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a03c5088a | |||
| 0a91ffc059 | |||
| b75127c237 | |||
| f7009b11e8 | |||
| f0b7a5e28f | |||
| 24a6f7c570 | |||
| 2e7e5823cc | |||
| 09f6b78198 | |||
| 22bdb4d39f | |||
| f214abff3c | |||
| 9a6ef69480 | |||
| 12c3721d92 | |||
| 782998e325 | |||
| 2ac783d222 | |||
| 941e8245f1 | |||
| 3a0192518c | |||
| ca96a91dee | |||
| 4a661f504f | |||
| beeada6814 | |||
| 872e272ee1 | |||
| 854149bb5d | |||
| e0a9605b8e | |||
| d440986b88 | |||
| fffdeac3ed | |||
| 3a2a685ed3 | |||
| 41f1c993ee | |||
| f2263e6dff | |||
| cf2a3251dd | |||
| 18eeb54327 | |||
| f569ece55b | |||
| 56dcc93202 | |||
| 186dc208be | |||
| b635b15c7d | |||
| 0d6396e0de | |||
| ab02bb43b9 | |||
| 446cf36103 | |||
| 3520279143 | |||
| 16cf5c91f7 | |||
| c7407f32b9 | |||
| 8a61cb25b8 | |||
| 737414f246 | |||
| 468f5b2d29 | |||
| eafee76a93 | |||
| 19ca3d7de1 | |||
| f74b6ad279 | |||
| a8a5351862 | |||
| 8879b42b1c | |||
| 6930c83e07 | |||
| d0a2822658 | |||
| 50f6ade9f8 | |||
| 22fc870176 | |||
| 54b872026d | |||
| 54bd865835 | |||
| dc1fb15a55 | |||
| 09ca1a270a | |||
| 838049c469 | |||
| 5327fed1dd | |||
| 2fbcf6e6e0 | |||
| 5b98c054ea | |||
| c64b3939ba | |||
| b11016c6ea | |||
| ec1bfc721d | |||
| 5b211c2786 | |||
| 0db6526933 | |||
| af2e1f57dd | |||
| 1025321b5f | |||
| e850f6dfbd | |||
| 647ccf2c88 | |||
| f8cf86e4e8 | |||
| adecf25486 | |||
| 33f5f8d009 | |||
| befaaeb7bb | |||
| a2782da3bd | |||
| b860c77b91 | |||
| 44871894e0 | |||
| de7cd0bfa8 | |||
| 0e56d9c8aa | |||
| db6d694ab0 | |||
| 0957ef476b | |||
| 63f4ea8da0 | |||
| 2f8947ba94 | |||
| dffa940f8a | |||
| ec4c844531 | |||
| 355bae7035 | |||
| b5d97c0cda | |||
| 25127d471e | |||
| 2b4db90fe4 | |||
| 33e539e562 |
1
.gitignore
vendored
@@ -8,3 +8,4 @@ dist
|
||||
dist.next
|
||||
dist.prev
|
||||
.deploy-backups
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "interactive"
|
||||
}
|
||||
136
README.md
@@ -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,63 +39,87 @@ VITE_DEV_PROXY_TARGET=https://mathapi.tadrisino2.ir
|
||||
| `npm run build` | type-check و بعد build در `dist/` |
|
||||
| `npm run preview` | اجرای همان build |
|
||||
|
||||
## آدرسِ بکاند و عوض کردنِ دامنه
|
||||
|
||||
دامنه هیچجای کد نیامده. آدرسِ API به این ترتیب خوانده میشود
|
||||
(`src/config.ts`):
|
||||
|
||||
1. **`dist/config.js`** (از `public/config.js`) — در زمانِ اجرا. بعد از build هم روی
|
||||
سرور قابلِ ویرایش است و build دوباره نمیخواهد.
|
||||
2. **`VITE_API_BASE_URL`** — در زمانِ build، از `.env.production`.
|
||||
3. **`/api`** — همدامنه، وقتی nginxِ دامنهی فرانت مسیرِ `/api` را به بکاند پراکسی کند.
|
||||
|
||||
برای جابهجایی دامنه:
|
||||
|
||||
- **سریع، بدونِ build:** روی سرور در `dist/config.js` بنویسید
|
||||
`apiBaseUrl: "https://api.new-domain.ir/api"` و کشِ CDN را برای همین فایل پاک کنید.
|
||||
- **ماندگار:** خطِ `VITE_API_BASE_URL` در `.env.production` را عوض کنید و build بگیرید.
|
||||
- **سمتِ بکاند** هم دامنهی تازهی فرانت باید مجاز باشد (`FRONTEND_URL` در `.env`
|
||||
بکاند). جزئیات در `docs/operations.md` همان ریپو.
|
||||
|
||||
## دیپلوی
|
||||
|
||||
روی سرور (`~/mathapp_frontend_new`، که nginxِ `math.tadrisino2.ir` از `dist/`ش سرو میکند):
|
||||
|
||||
```bash
|
||||
./deploy.sh # fetch، npm ci، type-check، build در dist.next و جابهجاییِ بیوقفه
|
||||
```
|
||||
|
||||
buildِ قبلی در `dist.prev` میماند (برگشت: `rm -rf dist && mv dist.prev dist`)، و
|
||||
`dist/config.js`ِ ویرایششده روی سرور به buildِ تازه منتقل میشود.
|
||||
|
||||
جای دیگر: `npm ci && npm run build` و سرو کردنِ `dist/`. اپ مسیرِ جدا (route) ندارد و همهچیز از `/` بار میشود؛
|
||||
فقط `index.html` و `config.js` نباید طولانی کش شوند (باقیِ فایلها نامِ hashدار دارند).
|
||||
درگاهِ پرداخت کاربر را به `/?orderId=…&status=…` برمیگرداند و لینکِ دعوت
|
||||
`/?ref=CODE` است؛ هر دو را خودِ اپ میخواند.
|
||||
---
|
||||
|
||||
## ساختار
|
||||
|
||||
```
|
||||
src/
|
||||
config.ts آدرسِ API (بالا را ببینید)
|
||||
config.ts آدرسِ API (بخشِ پایین)
|
||||
api/ تنها درِ ارتباط با سرور — هیچ جای دیگر fetch نمیزند
|
||||
http.ts timeout، تکرارِ GET، refreshِ تکنسخهای، SSE
|
||||
http.ts توکن، timeout، تکرارِ GET، refreshِ تکنسخهای، SSE، Idempotency-Key
|
||||
index.ts یک تابع برای هر اندپوینت
|
||||
types.ts شکلِ پاسخها، از روی پاسخِ واقعیِ سرور
|
||||
app/ App (جریانِ ورود) و MainApp (تبها و صفحهها)
|
||||
nav/ ناوبریِ پشتهای، هماهنگ با دکمهی back گوشی
|
||||
state/ دادهی مشترک (پروفایل، نقشه، …) و گفتگوی دستیار
|
||||
state/ دادهی مشترک (پروفایل، نقشه، کتابِ انتخابشده، همراه) و گفتگوی دستیار
|
||||
screens/ یک فایل برای هر صفحه
|
||||
components/ اجزای مشترک (سربرگ، برگهی پایین، …)
|
||||
mascots/ سهیار: سیگما، دلتا، پی
|
||||
lib/ اعدادِ فارسی، تاریخِ شمسی، صدا، localStorage
|
||||
components/ اجزای مشترک: مسیرِ جزیرهها، نوارِ کتابها، برگهی پایین، بازخورد، …
|
||||
mascots/ همراهها (سیگما، دلتا، پی، امگا): SVG از کد، شش حالتِ چهره
|
||||
diagram/ شکلِ درس و جوابِ دستیار: توصیفِ JSON از بکاند → SVG؛ همهی مختصات اینجا حساب میشود
|
||||
lib/ اعدادِ فارسی، تاریخِ شمسی، MathText، پیامِ خطا، صدا، پلِ کافهبازار، روشهای پرداخت
|
||||
android/ پوستهی اندروید (README خودش)
|
||||
pay.html · src/pay.ts صفحهی واسطِ پرداختِ زرینپال برای اپِ اندرویدِ فعلی
|
||||
```
|
||||
|
||||
قاعدهها:
|
||||
|
||||
- **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ
|
||||
`۰-۹` میفرستد و `\d` در JS آنها را نمیشناسد).
|
||||
- **کلاینت را از حافظه ننویسید.** قرارداد را از اسکیمای بکاند بخوانید
|
||||
(`python manage.py spectacular --file schema.yaml --format openapi`) و هر تغییرِ `types.ts` را با پاسخِ واقعیِ سرور بسنجید.
|
||||
- **صفحهها هرگز مستقیم سرور را صدا نمیزنند:** همیشه `screens/* → api/index.ts → api/http.ts`.
|
||||
- **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ `۰-۹` میفرستد و `\d` در JS آنها را نمیشناسد).
|
||||
- **رنگها** در `src/styles/index.css` (`@theme`) تعریف شدهاند: `bg-coral`، `text-ink`، …
|
||||
- **خطا:** پیامِ کاربر همیشه از `friendlyError` میآید، نه متنِ خامِ سرور.
|
||||
- **کلاینت را از حافظه ننویسید.** قرارداد را از `/api/docs/` بکاند بخوانید و تغییرِ
|
||||
`types.ts` را با پاسخِ واقعیِ سرور بسنجید.
|
||||
- **بازشدنِ فوری:** کاربرِ واردشده خانه را از عکسِ روی گوشی میبیند (`state/homeSnapshot.ts`: پروفایل، پایهها، نقشه،
|
||||
آمار) و همان درخواستهای بوت پشتِ صحنه تازهاش میکنند. عکس با پاک شدنِ توکن و ورودِ تازه پاک میشود، با بیلدِ دیگر
|
||||
یا کهنهتر از ۷ روز خوانده نمیشود، و درستی (قفل، اشتراک، قلب) همیشه با سرور است. دادهی تازهای که در خانه دیده
|
||||
میشود باید در `AppData` بماند تا در عکس هم برود.
|
||||
- **متنِ فارسی** بیاعراب و بی «ی»ِ اضافهی نوشتهشده («پایهی سوم») است (تصمیمِ شرکت)؛ تنوین («قبلاً») میماند. متنِ تازه
|
||||
را همینطور بنویسید. متنِ سرور را بکاند هنگامِ ساختنِ پاسخ پاک میکند و `plainPersian` در `MathText` همان کار را برای
|
||||
پاسخِ زندهی دستیار. `lib/plainMath.ts` هر LaTeX یا markdownی را که برسد ساده میکند و باید با `content/plain_math.py`ِ
|
||||
بکاند همرفتار بماند.
|
||||
|
||||
---
|
||||
|
||||
## آدرسِ بکاند و عوض کردنِ دامنه
|
||||
|
||||
دامنه هیچجای کد نیامده. آدرسِ API به این ترتیب خوانده میشود (`src/config.ts`):
|
||||
|
||||
1. **`dist/config.js`** (از `public/config.js`) — در زمانِ اجرا؛ بعد از build هم روی سرور ویرایششدنی است.
|
||||
2. **`VITE_API_BASE_URL`** — در زمانِ build، از `.env.production`.
|
||||
3. **`/api`** — همدامنه، وقتی nginxِ دامنهی فرانت مسیرِ `/api` را به بکاند پراکسی کند.
|
||||
|
||||
برای جابهجاییِ دامنه:
|
||||
|
||||
- **سریع، بدونِ build:** روی سرور در `dist/config.js` بنویسید `apiBaseUrl: "https://<دامنهی تازه>/api"` و کشِ آروان را
|
||||
برای همین فایل پاک کنید.
|
||||
- **ماندگار:** خطِ `VITE_API_BASE_URL` در `.env.production` را عوض کنید و build بگیرید.
|
||||
- **سمتِ بکاند** دامنهی تازهی فرانت باید مجاز باشد (`FRONTEND_URL`)، و در CSPِ nginx (`connect-src`) دامنهی تازهی API
|
||||
بیاید. رویهی کامل: `docs/operations.md`ِ بکاند، بخشِ «عوض کردنِ دامنه».
|
||||
|
||||
## روشهای پرداخت
|
||||
|
||||
برگهی پرداخت روشها را از `paymentMethods` در `dist/config.js` میخواند (`src/lib/paymentMethods.ts`)، جدا برای مرورگر
|
||||
(`web`) و داخلِ اپِ اندروید (`app`)؛ مقدارها `"zarinpal"` و `"bazaar"`. پیشفرض: مرورگر فقط زرینپال، اپ هر دو. نسخهای که
|
||||
فقط در کافهبازار است: `paymentMethods: { app: ["bazaar"] }`؛ build لازم نیست، فقط کشِ آروانِ این فایل را پاک کنید.
|
||||
|
||||
---
|
||||
|
||||
## انتشار
|
||||
|
||||
روی سرور (`~/mathapp_frontend_new`، که nginxِ `math.tadrisino2.ir` از `dist/`ش سرو میکند):
|
||||
|
||||
```bash
|
||||
cd ~/mathapp_frontend_new && ./deploy.sh # fetch، npm ci، type-check، build در dist.next و جابهجاییِ بیوقفه
|
||||
```
|
||||
|
||||
buildِ قبلی در `dist.prev` میماند (برگشت: `rm -rf dist && mv dist.prev dist`) و `dist/config.js`ِ ویرایششده روی سرور به
|
||||
buildِ تازه منتقل میشود. سرور مشترکِ شرکت است: پیش از هر کار آنجا قواعدِ `CLAUDE.md`ِ بکاند (بخشِ «Working on the
|
||||
servers») را بخوانید.
|
||||
|
||||
جای دیگر: `npm ci && npm run build` و سرو کردنِ `dist/`. اپ مسیرِ جدا (route) ندارد و همهچیز از `/` بار میشود؛ فقط
|
||||
`index.html` و `config.js` نباید طولانی کش شوند (باقیِ فایلها نامِ hashدار دارند). درگاهِ پرداخت کاربر را به
|
||||
`/?orderId=…&status=…` برمیگرداند و لینکِ دعوت `/?ref=CODE` است؛ هر دو را خودِ اپ میخواند.
|
||||
|
||||
CSPِ سایت در nginx است (`ops/nginx/zareian-front-headers.conf` در مخزنِ بکاند). اگر اسکریپتِ درونخطیِ `index.html` عوض
|
||||
شود، هشِ تازهاش باید در همان فایل بنشیند، وگرنه مرورگر آن را اجرا نمیکند.
|
||||
|
||||
8
android/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
*.jks
|
||||
*.keystore
|
||||
88
android/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# اپِ اندرویدِ تدریسینو (پوستهی WebView + پرداختِ بازار)
|
||||
|
||||
همان سایتِ `math.tadrisino2.ir` داخلِ یک WebView. تنها کدِ بومی پرداختِ درونبرنامهایِ
|
||||
کافهبازار است (کتابخانهی Poolakey)، چون بازار برای اپی که در آن منتشر میشود پرداختِ
|
||||
محصولِ دیجیتال را فقط از راهِ خودش میپذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
|
||||
فرانت بهروز میشود، بیآنکه نسخهی تازهی اپ لازم باشد.
|
||||
|
||||
> **ساخت و امضا با همتیمی است (MH-W90، m.hassanabadi)** با روشِ امضای خودش (`my-release-key.keystore` +
|
||||
> propertyهای Gradle)؛ تصمیمِ مالک این است که همان روش بماند. پیش از دست زدن به امضا از مالک بپرس و هرگز
|
||||
> keystore را commit نکن. `applicationId` واقعی `com.tadrisino.math.app` است (از APKِ ساختهشده با aapt2).
|
||||
> هیچ خریدِ واقعیِ بازار هنوز آزموده نشده.
|
||||
|
||||
## نسخهی بعدیِ APK — چه چیزی در کد هست و در اپِ نصبشده نیست
|
||||
|
||||
اینها در `MainActivity.kt` نوشته شدهاند (۱۴۰۵-۰۷، `468f5b2` و `737414f`) ولی هیچکدام build نشدهاند؛ اپی که الان روی
|
||||
گوشیهاست بدونِ آنهاست. فرانت با هر دو نسخه کار میکند (اگر پل نبود، راهِ قدیم را میرود).
|
||||
|
||||
| تغییر | چرا | بعد از build روی گوشی بسنج |
|
||||
|---|---|---|
|
||||
| `TadrisinoAndroid.vibrate(patternJson)` با Vibratorِ گوشی | `navigator.vibrate` در WebView همیشه کار نمیکند؛ مالک گفت اپ لرزش ندارد | انتخابِ گزینهی آزمون، جوابِ درست/غلط، پایانِ جزیره و صفحهی «روزِ پیاپی» باید بلرزند؛ با خاموش کردنِ «صداها» در پروفایل نه |
|
||||
| `mediaPlaybackRequiresUserGesture = false` | صداهای رابط بعد از پاسخِ سرور پخش میشوند، نه در لحظهی لمس | صدای جوابِ درست/غلط و پایانِ تمرین |
|
||||
| `isPaymentPage` + `paysInApp()` | درگاهِ زرینپال داخلِ خودِ اپ باز میشود. اپِ فعلی آن را به مرورگرِ گوشی میدهد که Referer نمیفرستد و زرینپال بیReferer «دسترسی از این دامنه مجاز نمیباشد» میدهد؛ فعلاً فرانت از راهِ `pay.html` (با `http://`) دورش میزند | «پرداخت» با کارتِ بانکی: صفحهی زرینپال و بانک داخلِ اپ، و بعد از پرداخت برگشت به اپ با پیامِ «پرداخت موفق بود» (PaymentResult). دکمهی برگشت وسطِ صفحهی بانک چه میکند را هم ببین |
|
||||
| اسپلشِ یکمرحلهای: `installSplashScreen()` + `setKeepOnScreenCondition` + `TadrisinoAndroid.appReady()` (`446cf36`، `ab02bb4`) | اپِ فعلی سه تکه میشود: اسپلشِ اندروید ← WebViewِ سفیدِ خالی ← اسپلشِ وب (آیکون + نام) ← خانه. با APKِ تازه اسپلشِ بومی تا اولین صفحهی واقعیِ وب میماند و مستقیم میرود روی همان صفحه؛ اسپلشِ وب داخلِ اپ نامرئی است (در APKِ قدیمی فقط آیکونِ بینام). فرانت `appReady` را بعد از اولین صفحهی غیرboot صدا میزند و APKِ قدیمی آن را ندارد، پس بیاثر است | روی گوشی: باز کردنِ اپ با اینترنت باید آیکون ← خانه باشد، بیسفیدی و بیتصویرِ دوم؛ اندازهی آیکون روی اندروید ۱۲+ (`drawable/splash_icon.xml`: ۲۸۸dp، حاشیه ۲۲٪) |
|
||||
| خریدِ اشتراک از کافهبازار: کلیدِ RSA، `bazaarBuy` (`subscribeProduct`)، قیمتِ پیشخان (`bazaarPrices`)، «تخفیف پویا»؛ `versionCode` ۲ | بیاینها از بازار هیچ خریدی ممکن نیست (APKهای قبلی کلید نداشتند) | داخلِ اپ ← فروشگاه ← اشتراکِ سهماهه: «کارتِ بانکی» پیشفرض، کاشیِ «کافهبازار» فعال با قیمتِ بازار زیرِ آن؛ خرید با حسابِ تست، برگشت با «اشتراکت فعاله»؛ در `/admin/payment/order/` سفارشِ بازار «پرداختشده». یک بار هم «انصراف» در صفحهی بازار |
|
||||
| صفحهی بیاتصال: `onReceivedError`/`onReceivedHttpError` ← همان تصویر + «اینترنت قطع است / کند است؛ در حال اتصال…»، تلاشِ دوباره هر ۵ ثانیه، و مجوزِ `ACCESS_NETWORK_STATE` | کاربر هرگز صفحهی خطای WebView یا متنِ فنی نمیبیند. اتصالِ کند (بیخطا) عمداً هیچ متنِ اضافه نمیآورد و روی اسپلش میماند | هواپیما روشن ← باز کردنِ اپ ← خاموش کردن: باید خودش وصل شود. سرورِ ۵۰۲ (مثلاً nginx خاموش) هم همین صفحه. اگر صفحه لود شد ولی ۱۵ ثانیه `appReady` نیامد، لاگِ ERROR و رهاشدنِ اسپلش (باگِ JS) |
|
||||
|
||||
**اسپلش و بیاینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصبشده امتحان شد).** چیزی که روی گوشی دیده میشود
|
||||
از وب قابل رفع نیست، چون وب هنوز بار نشده یا اصلاً نمیرسد:
|
||||
|
||||
- اپِ نصبشده: اسپلشِ اندروید ← صفحهی سفید ← اسپلشِ وب ← خانه. با فیلترشکن (یا هر وقت `math.tadrisino2.ir` نرسد)
|
||||
فقط **سفید** میماند و هیچ پیامی نیست، چون HTML هرگز نرسیده و هیچ کدِ ما اجرا نمیشود. علتِ دقیقِ آن سفیدی (HTML نرسیده
|
||||
یا API نرسیده) روی فیلترشکن بازتولید **نشد**؛ APKِ تازه هر دو را با همان صفحهی بیاتصال میپوشاند.
|
||||
- APKِ تازه (دو ردیفِ بالا) باید: ۱) با اینترنت، اسپلشِ بومی مستقیم به خانه برود و وسطش هیچچیز نیفتد؛ ۲) بیاینترنت یا
|
||||
کند، **زیرِ همان تصویرِ ثابت** بگوید «اینترنت قطع است / کند است»، نه سفید و نه متنِ خطا.
|
||||
- آزمونِ پذیرش روی گوشی، پیش از انتشار: اینترنتِ عادی؛ هواپیما؛ فیلترشکنِ روشن (همان که سفید میداد)؛ اینترنتِ خیلی کند.
|
||||
در هیچکدام نباید سفیدِ خالی یا صفحهی خطای WebView دیده شود.
|
||||
|
||||
پیشنهادهایی که هنوز نوشته نشدهاند:
|
||||
|
||||
- **«برای خروج دوباره بزن»**: دکمهی برگشتِ گوشی حالا قدمبهقدم در خودِ وب عقب میرود (`src/nav/Navigator.tsx`) و
|
||||
از خانه یکراست بیرون میرود. اگر تأییدِ خروج خواستید، در `handleOnBackPressed` وقتی `!webView.canGoBack()` یک
|
||||
Toast بدهید و فقط برگشتِ دوم در دو ثانیه `finish()` کند. در وب شدنی نیست (کروم ورودیِ history بیلمس را رد میکند).
|
||||
- `versionCode`/`versionName` را برای هر نسخهای که منتشر میشود بالا ببرید؛ الان ۲ و 1.1.0 است (بازار فقط `versionCode`ِ بزرگتر را میپذیرد).
|
||||
|
||||
قانونِ بازار: داخلِ اپِ بازار حالا پرداخت با درگاه هم پیشنهاد میشود (خواستِ مالک). بازار پرداختِ محتوای دیجیتال بیرون از
|
||||
پرداختِ درونبرنامهای را محدود میکند؛ پیش از انتشار در بازار این را با بازار یا مالک روشن کنید.
|
||||
|
||||
ساخت روی مکِ مالک: SDK در `~/Library/Android/sdk`، JDK 17 (`/opt/homebrew/opt/openjdk@17`)، و چون Google Maven از
|
||||
ایران بیVPN ۴۰۴ میدهد، `~/.gradle/init.d/google-mirror.gradle` آینهی Aliyun را اضافه میکند؛ ساختِ اول ~۹ دقیقه.
|
||||
|
||||
## پیش از build — آنچه از پیشخانِ بازار لازم است
|
||||
|
||||
| چه | کجا گذاشته میشود | وضعیت (۲۰۲۶-۱۰-۱۲) |
|
||||
|---|---|---|
|
||||
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app`؛ بازار محصولهای ۱۰۳۴۲–۱۰۳۴۴ را زیرِ همین نام شناخت (API، توکنِ ساختگی) |
|
||||
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | گذاشته شد |
|
||||
| SKUِ هر پلن | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | یکماهه `10342`، سهماهه `10343`، ششماهه `10344`؛ بستهی سوال فروخته نمیشود |
|
||||
| کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور |
|
||||
|
||||
فقط اشتراک فروخته میشود. قیمت: فرانت `TadrisinoAndroid.bazaarPrices(skusJson)` را صدا میزند و قیمتِ پیشخان را روی
|
||||
کاشیِ بازار نشان میدهد. خرید: `TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)` با `subscribeProduct`
|
||||
(مصرف نمیشود، بازار خودش تمدید میکند). قیمتِ پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف
|
||||
خورده، سرور قیمتِ کمشده را امضا میکند.
|
||||
`compileDebugKotlin` سبز است؛ هیچ خریدی روی گوشی آزموده نشده.
|
||||
|
||||
## build
|
||||
|
||||
1. Android Studio ← Open ← همین پوشهی `android/`. Studio خودش Gradle wrapper را میسازد.
|
||||
2. `Build ← Generate Signed App Bundle / APK`، با کلیدِ امضای شرکت. **کلیدِ امضا را گم نکنید**:
|
||||
بدونِ همان کلید هیچ نسخهی بعدی روی بازار بهروزرسانی نمیشود.
|
||||
3. نصب روی گوشیای که کافهبازار دارد و با حسابِ تست وارد شده.
|
||||
|
||||
## جریانِ خرید (برای عیبیابی)
|
||||
|
||||
```
|
||||
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
|
||||
فرانت (اختیاری) POST /api/discount/redeem {code, orderId}
|
||||
فرانت POST /api/payment/bazaar/orders/{orderId}/start → sku، dynamicPriceToken (اگر تخفیف خورده)
|
||||
فرانت TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
|
||||
اپ Poolakey.subscribeProduct(sku, payload=orderId) → purchaseToken
|
||||
اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken}
|
||||
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار میپرسد و اشتراک را فعال میکند
|
||||
```
|
||||
|
||||
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» میزند: فرانت همان سفارش را میفرستد
|
||||
و اپ توکنِ همان سفارش را از `getSubscribedProducts` پیدا میکند.
|
||||
|
||||
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بکاند.
|
||||
113
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,113 @@
|
||||
// plugins {
|
||||
// id("com.android.application")
|
||||
// id("org.jetbrains.kotlin.android")
|
||||
// }
|
||||
|
||||
// android {
|
||||
// // ⚠️ اسمِ پکیج باید همانی باشد که در پیشخانِ بازار ثبت است. com.tadrisino.app را بازار
|
||||
// // برای حسابِ شرکت «Product is not found» جواب داد (یعنی هست)، ولی سنیور تأیید نکرده
|
||||
// // که همان اپِ ریاضی است (CLAUDE.mdِ بکاند، ۲۰۲۶-۰۹-۳۰).
|
||||
// namespace = "com.tadrisino.app"
|
||||
// compileSdk = 34
|
||||
|
||||
// defaultConfig {
|
||||
// applicationId = "com.tadrisino.math.app"
|
||||
// minSdk = 23
|
||||
// targetSdk = 34
|
||||
// versionCode = 1
|
||||
// versionName = "1.0.0"
|
||||
|
||||
// // آدرسِ همان فرانتِ وب. دامنه عوض شود، فقط همین خط.
|
||||
// buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
|
||||
// // کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (بخشِ «پرداخت درونبرنامهای»). کلیدِ عمومی
|
||||
// // است و در اپ مینشیند؛ رمز نیست. تا پر نشود، اپ خرید را شروع نمیکند.
|
||||
// buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
|
||||
// }
|
||||
|
||||
// buildFeatures {
|
||||
// buildConfig = true
|
||||
// }
|
||||
|
||||
// buildTypes {
|
||||
// release {
|
||||
// isMinifyEnabled = false
|
||||
// }
|
||||
// }
|
||||
|
||||
// compileOptions {
|
||||
// sourceCompatibility = JavaVersion.VERSION_17
|
||||
// targetCompatibility = JavaVersion.VERSION_17
|
||||
// }
|
||||
// kotlinOptions {
|
||||
// jvmTarget = "17"
|
||||
// }
|
||||
// }
|
||||
|
||||
// dependencies {
|
||||
// implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
// implementation("androidx.activity:activity-ktx:1.9.2")
|
||||
// implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
|
||||
// }
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
// Package name must match the one registered on Cafe Bazaar.
|
||||
// Confirm the final applicationId before publishing.
|
||||
namespace = "com.tadrisino.app"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.tadrisino.math.app"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
// بازار نسخهی تازه را فقط با versionCodeِ بزرگتر میپذیرد؛ هر build که منتشر میشود یکی بالا.
|
||||
versionCode = 2
|
||||
versionName = "1.1.0"
|
||||
|
||||
// URL of the web frontend.
|
||||
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
|
||||
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (پرداخت درونبرنامهای)؛ سینیور، ۲۰۲۶-۱۰-۱۲. عمومی است و در APK
|
||||
// میرود؛ Poolakey با آن امضای خرید را روی گوشی میسنجد. سنجشِ اصلی سمتِ سرور است.
|
||||
buildConfigField("String", "BAZAAR_RSA_KEY", "\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwC9EBw0RW6jeTdHkrJwfB/mX+klpjpVXVb2stpGDcixeqHiLE+/Iow+PiMbZEQfxZDuED6ELvZlzQX+ALOlGRPxaa1ctl6/Uy6wGaPXhGPDpZL6jPrXV88mjJzMcEoB4sf3yNb+6YNKrvKhRyXXR0JdMUmPMqMe8igmQZTv/UF+QEMNqv7lg7qw9Yl+fG802nqjasft7qUPQK+YHJYPS4PJ5Pj/YpTkGDdDmVi7AIMCAwEAAQ==\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file("my-release-key.keystore")
|
||||
storePassword = providers.gradleProperty("MYAPP_STORE_PASSWORD").orNull
|
||||
keyAlias = "my-key-alias"
|
||||
keyPassword = providers.gradleProperty("MYAPP_KEY_PASSWORD").orNull
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.activity:activity-ktx:1.9.2")
|
||||
// اسپلشِ سیستم تا آمادهشدنِ اولین صفحهی وب میماند (MainActivity.keepSplash).
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
|
||||
}
|
||||
34
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- فقط برای فرقِ «اینترنت قطع است» و «اینترنت کند است» در اسپلشِ بیاتصال؛ مجوزِ عادی، بیپرسش. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- برای لرزشِ کوتاهِ جوابِ درست/غلط (navigator.vibrate در WebView). -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- اندروید ۱۱+: بدونِ این، اپ نمیتواند سرویسِ پرداختِ بازار را پیدا کند. -->
|
||||
<queries>
|
||||
<package android:name="com.farsitel.bazaar" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Tadrisino">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Tadrisino.Starting"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
400
android/app/src/main/java/com/tadrisino/app/MainActivity.kt
Normal file
@@ -0,0 +1,400 @@
|
||||
package com.tadrisino.app
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import ir.cafebazaar.poolakey.Connection
|
||||
import ir.cafebazaar.poolakey.ConnectionState
|
||||
import ir.cafebazaar.poolakey.Payment
|
||||
import ir.cafebazaar.poolakey.config.PaymentConfiguration
|
||||
import ir.cafebazaar.poolakey.config.SecurityCheck
|
||||
import ir.cafebazaar.poolakey.exception.BazaarNotFoundException
|
||||
import ir.cafebazaar.poolakey.exception.BazaarNotSupportedException
|
||||
import ir.cafebazaar.poolakey.exception.DynamicPriceNotSupportedException
|
||||
import ir.cafebazaar.poolakey.exception.IAPNotSupportedException
|
||||
import ir.cafebazaar.poolakey.exception.SubsNotSupportedException
|
||||
import ir.cafebazaar.poolakey.request.PurchaseRequest
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درونبرنامهایِ بازار است.
|
||||
*
|
||||
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
|
||||
* JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken) (اشتراک)
|
||||
* TadrisinoAndroid.bazaarPrices(skusJson) → رویدادِ «tadrisino:bazaar-prices»
|
||||
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
|
||||
* { orderId, error: "canceled" | "failed" | "unavailable", message }
|
||||
*
|
||||
* سمتِ سرور توکن را از خودِ بازار میسنجد (`POST /api/payment/bazaar/verify`)، پس اینجا
|
||||
* فقط واسطه است و به هیچ نتیجهای اعتماد نمیکند.
|
||||
*
|
||||
* ⚠️ کامپایل میشود (compileDebugKotlin، ۲۰۲۶-۱۰-۱۲) و امضای توابعِ Poolakey 2.2.0 از خودِ کتابخانه خوانده شد،
|
||||
* ولی روی گوشی اجرا نشده؛ پیش از انتشار یک خریدِ واقعی بزنید.
|
||||
*/
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var webView: WebView
|
||||
|
||||
/**
|
||||
* اسپلشِ سیستم تا وقتی true نشده روی صفحه میماند، هرچقدر اتصال کند باشد (خواستِ مالک): فرانت وقتی
|
||||
* اولین صفحهی واقعی (ورود/خانه) کشیده شد `TadrisinoAndroid.appReady()` را صدا میزند.
|
||||
* فقط دو حالت زودتر رهایش میکند: صفحهی اصلی بالا نیامد (آفلاین/خطای سرور → [showOffline]، با همان
|
||||
* تصویر و یک جمله)، یا صفحه لود شد ولی تا [READY_WATCHDOG_MS] appReady نیامد (خطای JS؛ لاگ میشود).
|
||||
*/
|
||||
@Volatile private var webReady = false
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private lateinit var offlineView: View
|
||||
private lateinit var offlineText: TextView
|
||||
private var offlineShown = false
|
||||
private val retryLoad = object : Runnable {
|
||||
override fun run() {
|
||||
if (!offlineShown) return
|
||||
webView.loadUrl(BuildConfig.WEB_URL)
|
||||
handler.postDelayed(this, RETRY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dp(v: Int): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics).toInt()
|
||||
|
||||
/** همان تصویرِ اسپلشِ سیستم (۲۸۸dp، همان drawable) با یک جمله زیرش؛ جای آیکون با اسپلشِ سیستم یکی است. */
|
||||
private fun buildOfflineView(): View = FrameLayout(this).apply {
|
||||
setBackgroundColor(Color.WHITE)
|
||||
visibility = View.GONE
|
||||
isClickable = true
|
||||
addView(ImageView(context).apply { setImageResource(R.drawable.splash_icon) },
|
||||
FrameLayout.LayoutParams(dp(288), dp(288), Gravity.CENTER))
|
||||
addView(TextView(context).apply {
|
||||
offlineText = this
|
||||
text = "اینترنت قطع است"
|
||||
setTextColor(Color.parseColor("#1a1a2e"))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
||||
layoutDirection = View.LAYOUT_DIRECTION_RTL
|
||||
gravity = Gravity.CENTER
|
||||
}, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER)
|
||||
.apply { topMargin = dp(150) })
|
||||
}
|
||||
|
||||
/** هیچ متنِ فنیِ خطا نشان داده نمیشود؛ فقط یکی از دو جمله: «قطع است» (شبکهای نیست) یا «کند است». */
|
||||
private fun hasInternet(): Boolean {
|
||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
private fun showOffline() {
|
||||
offlineText.text = if (hasInternet()) "اینترنت کند است؛ در حال اتصال…" else "اینترنت قطع است؛ در حال اتصال…"
|
||||
if (!offlineShown) {
|
||||
offlineShown = true
|
||||
webView.visibility = View.INVISIBLE // صفحهی خطای پیشفرضِ WebView دیده نشود
|
||||
offlineView.visibility = View.VISIBLE
|
||||
handler.postDelayed(retryLoad, RETRY_MS)
|
||||
}
|
||||
webReady = true
|
||||
}
|
||||
|
||||
private fun hideOffline() {
|
||||
if (!offlineShown) return
|
||||
offlineShown = false
|
||||
handler.removeCallbacks(retryLoad)
|
||||
offlineView.visibility = View.GONE
|
||||
webView.visibility = View.VISIBLE
|
||||
}
|
||||
private var payment: Payment? = null
|
||||
private var connection: Connection? = null
|
||||
private val webHost: String = Uri.parse(BuildConfig.WEB_URL).host ?: ""
|
||||
|
||||
/** فقط خودِ سایت و فقط با https؛ «http://دامنهی ما» قابلِ دستکاریِ شبکه است و نباید پل داشته باشد. */
|
||||
private fun isOurPage(uri: Uri?): Boolean = uri != null && uri.scheme == "https" && uri.host == webHost
|
||||
|
||||
/** API و درگاه: زیردامنههای دامنهی خودمان (mathapi…)، زرینپال و صفحههای بانکیِ شاپرک؛ فقط https. پل اینجا کار نمیکند. */
|
||||
private fun isPaymentPage(uri: Uri?): Boolean {
|
||||
val host = uri?.host ?: return false
|
||||
if (uri.scheme != "https") return false
|
||||
val ours = webHost.substringAfter('.')
|
||||
return host == ours || host.endsWith(".$ours") ||
|
||||
host == "zarinpal.com" || host.endsWith(".zarinpal.com") || host.endsWith(".shaparak.ir")
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen().setKeepOnScreenCondition { !webReady }
|
||||
super.onCreate(savedInstanceState)
|
||||
webView = WebView(this)
|
||||
offlineView = buildOfflineView()
|
||||
setContentView(FrameLayout(this).apply {
|
||||
addView(webView)
|
||||
addView(offlineView)
|
||||
})
|
||||
|
||||
webView.settings.javaScriptEnabled = true
|
||||
webView.settings.domStorageEnabled = true // توکنِ ورود در localStorage است
|
||||
// WebView فقط آدرسِ وب را میخواند؛ دسترسی به فایل و content:// لازم نیست.
|
||||
webView.settings.allowFileAccess = false
|
||||
webView.settings.allowContentAccess = false
|
||||
// صداهای کوتاهِ رابط (Web Audio) بعد از پاسخِ سرور پخش میشوند، نه درست در لحظهی لمس.
|
||||
webView.settings.mediaPlaybackRequiresUserGesture = false
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
// صفحهی اصلی بالا نیامد (بیاینترنت یا خطای سرور): همان تصویر با «در حال اتصال»، و هر چند ثانیه دوباره.
|
||||
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: android.webkit.WebResourceError) {
|
||||
if (request.isForMainFrame) showOffline()
|
||||
}
|
||||
|
||||
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) {
|
||||
if (request.isForMainFrame && errorResponse.statusCode >= 500) showOffline()
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
if (offlineShown) return // صفحهی خطا هم onPageFinished میزند؛ تا بارِ موفق همان تصویر بماند
|
||||
handler.postDelayed({
|
||||
if (!webReady) Log.e(TAG, "page loaded but appReady never came in ${READY_WATCHDOG_MS}ms; releasing the splash")
|
||||
webReady = true
|
||||
}, READY_WATCHDOG_MS)
|
||||
}
|
||||
|
||||
// هر آدرسی بیرونِ دامنهی خودمان در مرورگرِ گوشی باز میشود، نه داخلِ اپ؛ پس پل فقط
|
||||
// در صفحههای خودمان در دسترس است.
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||
if (isOurPage(request.url)) return false
|
||||
// پرداخت با درگاه داخلِ خودِ اپ میماند: زرینپال بیRefererِ دامنهی ما صفحه را باز نمیکند و مرورگرِ
|
||||
// بیرونی Referer نمیفرستد. بعد از پرداخت، زرینپال به سایتِ ما برمیگرداند و نتیجه همینجا دیده میشود.
|
||||
if (isPaymentPage(request.url)) return false
|
||||
// فقط http(s) به مرورگرِ گوشی میرود؛ intent:، file: و schemeهای سفارشی بیصدا بسته میشوند
|
||||
// (پیشتر هر آدرسی بیرونِ دامنه به ACTION_VIEW میرفت و صفحه میتوانست اپِ دلخواه باز کند).
|
||||
if (request.url.scheme == "https" || request.url.scheme == "http") {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, request.url))
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
webView.addJavascriptInterface(Bridge(), "TadrisinoAndroid")
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
// اپِ وب برای هر صفحه history.pushState میزند؛ دکمهی back همان را برمیگرداند.
|
||||
override fun handleOnBackPressed() {
|
||||
if (webView.canGoBack()) webView.goBack() else finish()
|
||||
}
|
||||
})
|
||||
|
||||
connectToBazaar()
|
||||
if (savedInstanceState == null) webView.loadUrl(BuildConfig.WEB_URL)
|
||||
}
|
||||
|
||||
private fun connectToBazaar() {
|
||||
if (BuildConfig.BAZAAR_RSA_KEY.isEmpty()) {
|
||||
Log.e(TAG, "BAZAAR_RSA_KEY is empty; in-app purchase disabled")
|
||||
return
|
||||
}
|
||||
val config = PaymentConfiguration(
|
||||
localSecurityCheck = SecurityCheck.Enable(rsaPublicKey = BuildConfig.BAZAAR_RSA_KEY),
|
||||
)
|
||||
connection?.disconnect()
|
||||
payment = Payment(context = this, config = config).also { p ->
|
||||
connection = p.connect {
|
||||
connectionSucceed { Log.i(TAG, "bazaar connected") }
|
||||
connectionFailed { Log.e(TAG, "bazaar connection failed", it) }
|
||||
disconnected { Log.w(TAG, "bazaar disconnected") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* خریدِ اشتراکِ بازار (subscribeProduct؛ فقط اشتراک فروخته میشود و مصرف نمیشود، بازار خودش تمدید میکند).
|
||||
* dynamicPriceToken خالی یعنی قیمتِ پیشخان؛ پر یعنی قیمتی که سرور با کلیدِ «تخفیف پویا» امضا کرده
|
||||
* (بازارِ ۱۳.۳ به بعد).
|
||||
*/
|
||||
private fun buy(sku: String, orderId: String, dynamicPriceToken: String) {
|
||||
val p = payment
|
||||
if (p == null || connection?.getState() !is ConnectionState.Connected) {
|
||||
// بازار نصب نیست، هنوز وصل نشده، یا اتصال افتاده؛ برای تلاشِ بعدی دوباره وصل میشویم.
|
||||
connectToBazaar()
|
||||
send(orderId, error = "unavailable", message = "not_connected")
|
||||
return
|
||||
}
|
||||
val request = PurchaseRequest(
|
||||
productId = sku, payload = orderId, dynamicPriceToken = dynamicPriceToken.ifEmpty { null },
|
||||
)
|
||||
p.subscribeProduct(activityResultRegistry, request) {
|
||||
purchaseSucceed { send(orderId, token = it.purchaseToken) }
|
||||
purchaseCanceled { send(orderId, error = "canceled") }
|
||||
purchaseFailed { failed(sku, orderId, it) }
|
||||
failedToBeginFlow { failed(sku, orderId, it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* بازارِ نصبنشده یا قدیمی (بی پشتیبانیِ اشتراک یا «تخفیف پویا») کاری است که کاربر باید بکند، نه خطای خرید؛
|
||||
* فرانت برایش پیامِ جدا دارد. بقیهی خطاها ممکن است خریدی باشند که پولش رفته، پس از بازار پرسیده میشوند.
|
||||
*/
|
||||
private fun failed(sku: String, orderId: String, cause: Throwable) {
|
||||
val reason = when (cause) {
|
||||
is BazaarNotFoundException -> "not_installed"
|
||||
is BazaarNotSupportedException, is SubsNotSupportedException, is IAPNotSupportedException,
|
||||
is DynamicPriceNotSupportedException -> "old_bazaar"
|
||||
else -> null
|
||||
}
|
||||
Log.e(TAG, "bazaar purchase failed: ${reason ?: cause.javaClass.simpleName}", cause)
|
||||
if (reason != null) send(orderId, error = "unavailable", message = reason)
|
||||
else recoverOwned(sku, orderId, cause)
|
||||
}
|
||||
|
||||
/**
|
||||
* اشتراکِ همین محصول که پولش رفته ولی تأییدِ سرور نرسیده بود جلوی خریدِ دوباره را میگیرد. فرانت در تلاشِ
|
||||
* بعدی همان سفارش را میفرستد؛ اگر اشتراکِ همان سفارش (payload) پیدا شد، توکنش برمیگردد تا سرور ثبتش کند.
|
||||
*/
|
||||
private fun recoverOwned(sku: String, orderId: String, cause: Throwable) {
|
||||
val p = payment ?: return send(orderId, error = "failed", message = cause.message)
|
||||
p.getSubscribedProducts {
|
||||
querySucceed { owned ->
|
||||
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
|
||||
if (match != null) send(orderId, token = match.purchaseToken)
|
||||
else send(orderId, error = "failed", message = cause.message)
|
||||
}
|
||||
queryFailed { send(orderId, error = "failed", message = cause.message) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* قیمتِ ثبتشده در پیشخانِ بازار برای هر SKU (متنِ آمادهی نمایش، مثلِ «۸۹۸,۰۰۰ تومان»)، تا فرانت روی کاشیِ
|
||||
* بازار همان را نشان دهد. API سرور-به-سرورِ بازار قیمت نمیدهد؛ فقط Poolakey روی گوشی.
|
||||
* نتیجه: رویدادِ «tadrisino:bazaar-prices» با { prices: { sku: price } } یا { error }.
|
||||
*/
|
||||
private fun prices(skus: List<String>) {
|
||||
val p = payment ?: return sendPrices(error = "bazaar not connected")
|
||||
p.getSubscriptionSkuDetails(skus) {
|
||||
getSkuDetailsSucceed { list ->
|
||||
val map = JSONObject()
|
||||
list.forEach { map.put(it.sku, it.price) }
|
||||
sendPrices(prices = map)
|
||||
}
|
||||
getSkuDetailsFailed { sendPrices(error = it.message ?: "failed") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendPrices(prices: JSONObject? = null, error: String? = null) {
|
||||
val detail = JSONObject()
|
||||
if (prices != null) detail.put("prices", prices)
|
||||
if (error != null) detail.put("error", error)
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript(
|
||||
"window.dispatchEvent(new CustomEvent('tadrisino:bazaar-prices', { detail: $detail }));", null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(orderId: String, token: String? = null, error: String? = null, message: String? = null) {
|
||||
val detail = JSONObject().put("orderId", orderId)
|
||||
if (token != null) detail.put("purchaseToken", token)
|
||||
if (error != null) detail.put("error", error)
|
||||
if (message != null) detail.put("message", message)
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript(
|
||||
"window.dispatchEvent(new CustomEvent('tadrisino:bazaar', { detail: $detail }));", null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** متدهای این کلاس روی threadِ WebView صدا زده میشوند، نه UI. */
|
||||
private inner class Bridge {
|
||||
/** قراردادش در src/lib/bazaar.tsِ فرانت است. */
|
||||
@JavascriptInterface
|
||||
fun bazaarBuy(sku: String, orderId: String, dynamicPriceToken: String) {
|
||||
runOnUiThread {
|
||||
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
|
||||
buy(sku, orderId, dynamicPriceToken)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* لرزشِ کوتاهِ رابط (src/lib/sound.tsِ فرانت). `navigator.vibrate` در همهی WebViewها اجرا نمیشود؛
|
||||
* این مستقیم از Vibratorِ گوشی است. ورودی آرایهی JSON از میلیثانیهها: [لرزش، مکث، لرزش، …].
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun vibrate(patternJson: String) {
|
||||
val pattern = try {
|
||||
val arr = JSONArray(patternJson)
|
||||
LongArray(arr.length()) { i -> arr.getLong(i).coerceIn(0, 1000) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "bad vibrate pattern", e)
|
||||
return
|
||||
}
|
||||
if (pattern.isEmpty()) return
|
||||
val vibrator = if (Build.VERSION.SDK_INT >= 31) {
|
||||
(getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getSystemService(VIBRATOR_SERVICE) as Vibrator
|
||||
}
|
||||
// [لرزش، مکث، …] → قالبِ اندروید که با مکث شروع میشود.
|
||||
val timings = longArrayOf(0) + pattern
|
||||
if (Build.VERSION.SDK_INT >= 26) vibrator.vibrate(VibrationEffect.createWaveform(timings, -1))
|
||||
else @Suppress("DEPRECATION") vibrator.vibrate(timings, -1)
|
||||
}
|
||||
|
||||
/** فرانت اولین صفحهی واقعی را کشید؛ اسپلشِ سیستم میتواند برود. */
|
||||
@JavascriptInterface
|
||||
fun appReady() {
|
||||
webReady = true
|
||||
runOnUiThread { hideOffline() }
|
||||
}
|
||||
|
||||
/** skusJson آرایهی JSON از SKUهای اشتراک. */
|
||||
@JavascriptInterface
|
||||
fun bazaarPrices(skusJson: String) {
|
||||
val skus = try {
|
||||
val arr = JSONArray(skusJson)
|
||||
List(arr.length()) { i -> arr.getString(i) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "bad skus", e)
|
||||
return
|
||||
}
|
||||
runOnUiThread {
|
||||
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
|
||||
prices(skus)
|
||||
}
|
||||
}
|
||||
|
||||
/** فرانت با این میفهمد درگاه داخلِ اپ باز میشود (isPaymentPage)، نه در مرورگرِ بیرونی. */
|
||||
@JavascriptInterface
|
||||
fun paysInApp(): Boolean = true
|
||||
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
connection?.disconnect()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Tadrisino"
|
||||
private const val RETRY_MS = 5_000L
|
||||
private const val READY_WATCHDOG_MS = 15_000L
|
||||
}
|
||||
}
|
||||
BIN
android/app/src/main/res/drawable-nodpi/splash_icon_art.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
8
android/app/src/main/res/drawable/splash_icon.xml
Normal 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%" />
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">تدریسینو ریاضی</string>
|
||||
</resources>
|
||||
18
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Tadrisino" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">#ED6956</item>
|
||||
<item name="android:windowBackground">@android:color/white</item>
|
||||
</style>
|
||||
|
||||
<!--
|
||||
اسپلشِ سیستم: آیکونِ اپ روی سفید، تا وقتی صفحهی وب خودش بگوید آماده است (appReady) روی صفحه میماند.
|
||||
پیش از این، اسپلش میآمد، بعد WebView سفیدِ خالی تا رسیدنِ HTML، بعد اسپلشِ وب، بعد خانه.
|
||||
سفید است چون windowBackgroundِ پایین هم سفید است و اسپلشِ وب (index.html) هم سفید است.
|
||||
-->
|
||||
<style name="Theme.Tadrisino.Starting" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@android:color/white</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.Tadrisino</item>
|
||||
</style>
|
||||
</resources>
|
||||
4
android/build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.5.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||
}
|
||||
2
android/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
android.useAndroidX=true
|
||||
org.gradle.jvmargs=-Xmx2g
|
||||
18
android/settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// Poolakey (کتابخانهی پرداختِ کافهبازار) فقط روی jitpack منتشر میشود.
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
rootProject.name = "Tadrisino"
|
||||
include(":app")
|
||||
20
deploy.sh
@@ -18,7 +18,16 @@ step() { printf '\n\033[0;32m▸ %s\033[0m\n' "$1"; }
|
||||
fail() { printf '\033[0;31m✖ %s\033[0m\n' "$1" >&2; exit 1; }
|
||||
|
||||
step "گرفتنِ کدِ تازه ($BRANCH)"
|
||||
git fetch origin "$BRANCH" || fail "به gitea وصل نشد."
|
||||
# DEPLOY_BUNDLE=/مسیر/فایل.bundle: کد از یک git bundle، نه از gitea (مثلِ deploy.shِ بکاند): برای وقتی که سرور
|
||||
# به gitea نمیرسد یا اعتبارنامه ندارد. فقط وقتی صریحاً داده شود؛ بیآن نرسیدن به gitea دیپلو را میایستاند.
|
||||
if [ -n "${DEPLOY_BUNDLE:-}" ]; then
|
||||
[ -f "$DEPLOY_BUNDLE" ] || fail "DEPLOY_BUNDLE=$DEPLOY_BUNDLE وجود ندارد."
|
||||
git bundle verify "$DEPLOY_BUNDLE" >/dev/null 2>&1 || fail "bundle معتبر نیست یا کامیتِ پایهاش اینجا نیست."
|
||||
git fetch "$DEPLOY_BUNDLE" "$BRANCH:refs/remotes/origin/$BRANCH" || fail "گرفتن از bundle نشد."
|
||||
else
|
||||
git fetch origin "$BRANCH" || fail "به gitea وصل نشد (اگر فقط از این سرور: DEPLOY_BUNDLE، بالای همین خط)."
|
||||
fi
|
||||
printf ' origin/%s = %s\n' "$BRANCH" "$(git rev-parse --short "origin/$BRANCH")"
|
||||
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||
mkdir -p .deploy-backups
|
||||
PATCH=".deploy-backups/local-changes-$(date +%Y%m%d-%H%M%S).patch"
|
||||
@@ -41,10 +50,17 @@ if [ -f dist/config.js ] && ! cmp -s dist/config.js public/config.js; then
|
||||
printf ' config.jsِ ویرایششدهی سرور نگه داشته شد.\n'
|
||||
fi
|
||||
|
||||
# فایلهای buildهای قبلی (نامشان hash دارد و با هم برخورد نمیکنند) تا ۳۰ روز میمانند: گوشیای که اپ از قبل
|
||||
# در آن باز است هنوز index.htmlِ قبلی را دارد و با باز کردنِ صفحهای که بار نشده همان نامها را میخواهد؛ بیاین
|
||||
# ۴۰۴ و صفحهی سفید میگرفت. cp -p زمانِ فایل را نگه میدارد، پس هر فایل ۳۰ روز بعد از buildِ خودش کنار میرود.
|
||||
if [ -d dist/assets ]; then
|
||||
find dist/assets -type f -mtime -30 -exec cp -p --update=none {} dist.next/assets/ \;
|
||||
fi
|
||||
|
||||
step "جابهجایی"
|
||||
rm -rf dist.prev
|
||||
[ -d dist ] && mv dist dist.prev
|
||||
mv dist.next dist
|
||||
API="$(grep -o 'https\?://[a-zA-Z0-9.-]*/api' dist/assets/*.js | head -1 | cut -d: -f2-)"
|
||||
API="$(grep -oh 'https\?://[a-zA-Z0-9.-]*/api' dist/assets/*.js | head -1)"
|
||||
printf ' منتشر شد. آدرسِ API در build: %s\n' "${API:-(همدامنه /api یا config.js)}"
|
||||
printf ' کشِ آروان را برای index.html و config.js پاک کنید اگر تغییری دیده نشد.\n'
|
||||
|
||||
32
index.html
@@ -4,15 +4,39 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#ED6956" />
|
||||
<meta name="description" content="تدریسینو — یادگیریِ ریاضیِ پایهی سوم تا دوازدهم، قدمبهقدم و با دستیارِ هوشمند." />
|
||||
<meta name="description" content="تدریسینو ریاضی — یادگیری ریاضی پایه سوم تا دوازدهم، قدمبهقدم و با دستیار هوشمند." />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>تدریسینو</title>
|
||||
<title>تدریسینو ریاضی</title>
|
||||
<!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار میشود. -->
|
||||
<script src="/config.js"></script>
|
||||
<!-- اسپلشِ ثابت: همینجا در HTML است تا از اولین فریم دیده شود (نه صفحهی سفید تا بار شدنِ JS)؛
|
||||
SplashScreen در React عینِ همین را میکشد، پس هنگامِ تحویل چیزی نمیپرد. فونتِ سیستم، چون
|
||||
Vazirmatn بعد از CSS میرسد و عوضشدنِ فونت خودش یک پرش است. -->
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preload" as="image" href="/app-icon.png" />
|
||||
<script>
|
||||
(function () {
|
||||
var b = window.TadrisinoAndroid;
|
||||
if (b) document.documentElement.className = typeof b.appReady === 'function' ? 'in-app' : 'in-old-app';
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.boot-splash { position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 20px; background: #fff; }
|
||||
.boot-splash img { width: 144px; height: 144px; display: block; }
|
||||
/* داخل اپ اندروید اسپلش بومی روی صفحه است و سایت زیرش بار میشود؛ اسپلش وب نباید یک بار دیگر
|
||||
«عوض شدن» نشان دهد. APK تازه (appReady دارد): اسپلش وب نامرئی است. APK قدیمی: فقط آیکون،
|
||||
بینام، تا کمتر از اسپلش سیستم فرق کند. */
|
||||
.in-app .boot-splash > * { visibility: hidden; }
|
||||
.in-old-app .boot-splash span { display: none; }
|
||||
.in-old-app .boot-splash img { width: 160px; height: 160px; }
|
||||
.boot-splash span { font: 900 min(34px, 8.5vw)/1.2 system-ui, -apple-system, sans-serif; color: #1a1a2e; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="root">
|
||||
<div class="boot-splash" dir="rtl"><img src="/app-icon.png" alt="" width="144" height="144" /><span>تدریسینو ریاضی</span></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
28
pay.html
Normal file
@@ -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
|
After Width: | Height: | Size: 58 KiB |
@@ -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: "",
|
||||
};
|
||||
|
||||
@@ -21,6 +21,8 @@ export class ApiError extends Error {
|
||||
readonly status: number,
|
||||
readonly detail: string,
|
||||
readonly body: Record<string, unknown> = {},
|
||||
/** فقط برای status 0: درخواست بهخاطرِ timeout قطع شد (اینترنت کند)، نه رسیدننداشتن (اینترنت قطع). */
|
||||
readonly timedOut = false,
|
||||
) {
|
||||
super(detail);
|
||||
this.name = 'ApiError';
|
||||
@@ -41,13 +43,13 @@ export interface TokenStorage {
|
||||
set(tokens: Tokens | null): void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'tadrisino.tokens';
|
||||
export const TOKENS_STORAGE_KEY = 'tadrisino.tokens';
|
||||
|
||||
/** localStorage در حالتِ خصوصیِ بعضی مرورگرها throw میکند؛ اپ نباید بخوابد. */
|
||||
export const localTokenStorage: TokenStorage = {
|
||||
get() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(TOKENS_STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as Tokens) : null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -55,8 +57,8 @@ export const localTokenStorage: TokenStorage = {
|
||||
},
|
||||
set(tokens) {
|
||||
try {
|
||||
if (tokens) localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
if (tokens) localStorage.setItem(TOKENS_STORAGE_KEY, JSON.stringify(tokens));
|
||||
else localStorage.removeItem(TOKENS_STORAGE_KEY);
|
||||
} catch {
|
||||
/* ذخیره نشد؛ نشست فقط تا بستنِ صفحه میماند */
|
||||
}
|
||||
@@ -75,13 +77,16 @@ export interface HttpClientOptions {
|
||||
baseUrl: string;
|
||||
storage?: TokenStorage;
|
||||
timeoutMs?: number;
|
||||
readTimeoutMs?: number;
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||
|
||||
function backoffMs(attempt: number, retryAfterMs?: number): number {
|
||||
if (retryAfterMs != null) return Math.min(retryAfterMs, 30_000);
|
||||
// Retry-After کمینه است، نه زمانِ دقیق: وقتی سرور زیرِ بار هزاران گوشی را همزمان رد میکند، اگر همه
|
||||
// دقیقاً بعد از همان چند ثانیه برگردند همان اوج دوباره ساخته میشود. پس بینِ ۱ تا ۲ برابرِ آن پخش میشوند.
|
||||
if (retryAfterMs != null) return Math.min(retryAfterMs * (1 + Math.random()), 30_000);
|
||||
return Math.random() * Math.min(1000 * 2 ** attempt, 8000);
|
||||
}
|
||||
|
||||
@@ -159,11 +164,14 @@ 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>>();
|
||||
const signedOutListeners = new Set<() => void>();
|
||||
let refreshing: Promise<Tokens | null> | null = null;
|
||||
let refreshing: Promise<Tokens | 'invalid' | ApiError> | null = null;
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = storage.get()?.token;
|
||||
@@ -175,27 +183,47 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
signedOutListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
async function refreshTokens(): Promise<Tokens | null> {
|
||||
/**
|
||||
* نتیجهی تمدید سه حالت دارد و فقط یکیاش یعنی خروج:
|
||||
* • Tokens — تمدید شد.
|
||||
* • 'invalid' — سرور گفت refresh token معتبر نیست (۴۰۱)؛ نشست واقعاً تمام است.
|
||||
* • ApiError — سرور در دسترس نبود یا سقف را زد (۴۲۹، ۵xx، قطعیِ شبکه)؛ نشست سالم است.
|
||||
* تا ۲۰۲۶-۱۰-۰۳ هر شکستی خروج حساب میشد: پشتِ وایفایِ مدرسه که سقفِ تمدیدِ IP پر میشد،
|
||||
* دانشآموزِ شمارهدار به صفحهی ورود پرت میشد و باید دوباره پیامک میگرفت.
|
||||
*/
|
||||
async function refreshTokens(): Promise<Tokens | 'invalid' | ApiError> {
|
||||
const current = storage.get();
|
||||
if (!current?.refreshToken) return null;
|
||||
if (!current?.refreshToken) return 'invalid';
|
||||
if (refreshing) return refreshing;
|
||||
|
||||
refreshing = (async () => {
|
||||
refreshing = (async (): Promise<Tokens | 'invalid' | ApiError> => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken: current.refreshToken }),
|
||||
signal: timeoutSignal(defaultTimeout),
|
||||
credentials: 'omit',
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken: current.refreshToken }),
|
||||
signal: timeoutSignal(defaultTimeout),
|
||||
credentials: 'omit',
|
||||
});
|
||||
} catch {
|
||||
return new ApiError(0, 'ارتباط با سرور برقرار نشد');
|
||||
}
|
||||
if (response.status === 401) {
|
||||
// تبِ دیگری همین توکن را زودتر تمدید کرده (سرور توکنِ قبلی را باطل میکند) و توکنِ تازه
|
||||
// را در همان localStorageِ مشترک گذاشته؛ آن نشست سالم است و نباید هر دو تب خارج شوند.
|
||||
const latest = storage.get();
|
||||
if (latest?.refreshToken && latest.refreshToken !== current.refreshToken) return latest;
|
||||
return 'invalid';
|
||||
}
|
||||
if (!response.ok) return errorFrom(response, await parseBody(response));
|
||||
const data = (await response.json()) as Tokens;
|
||||
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
|
||||
// کاربر در همین فاصله خارج شد (یا حسابش را حذف کرد): توکنِ تازه نباید نشستِ پاکشده را روی گوشی زنده کند.
|
||||
if (!storage.get()) return 'invalid';
|
||||
storage.set(next);
|
||||
return next;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
queueMicrotask(() => {
|
||||
refreshing = null;
|
||||
@@ -211,16 +239,18 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
body: unknown,
|
||||
opts: RequestOptions,
|
||||
): Promise<Response> {
|
||||
// بدنهی تابعی سرِ هر ارسال از نو ساخته میشود (خروج: refreshTokenِ همان لحظه، نه نسخهی پیش از refresh).
|
||||
const payload = typeof body === 'function' ? (body as () => unknown)() : body;
|
||||
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (payload !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
|
||||
|
||||
const timer = timeoutSignal(opts.timeoutMs ?? defaultTimeout);
|
||||
const timer = timeoutSignal(opts.timeoutMs ?? (method === 'GET' ? readTimeout : defaultTimeout));
|
||||
try {
|
||||
return await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
body: payload === undefined ? undefined : JSON.stringify(payload),
|
||||
signal: opts.signal ? anySignal([timer, opts.signal]) : timer,
|
||||
// احرازِ هویت هدرِ Bearer است، نه کوکی؛ سرور هم CORS را بدونِ credentials دارد.
|
||||
credentials: 'omit',
|
||||
@@ -228,7 +258,7 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
} catch (err) {
|
||||
if (opts.signal?.aborted) throw err;
|
||||
const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
||||
throw new ApiError(0, timedOut ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد');
|
||||
throw new ApiError(0, timedOut ? 'زمان درخواست تمام شد' : 'ارتباط با سرور برقرار نشد', {}, timedOut);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,12 +269,17 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
body: unknown,
|
||||
opts: RequestOptions,
|
||||
): Promise<Response> {
|
||||
const hadToken = Boolean(storage.get()?.token);
|
||||
const sentToken = storage.get()?.token;
|
||||
let response = await send(method, path, body, opts);
|
||||
if (response.status === 401 && hadToken) {
|
||||
if (response.status === 401 && sentToken) {
|
||||
// درخواستی که با توکنِ کهنه رفته و ۴۰۱اش بعد از تمامِ refreshِ درخواستِ دیگری رسیده، نباید دوباره
|
||||
// refresh کند (refreshing آن موقع دیگر null است): توکنِ تازه در storage هست، فقط با آن دوباره بفرست.
|
||||
const latest = storage.get()?.token;
|
||||
if (latest && latest !== sentToken) return send(method, path, body, opts);
|
||||
const refreshed = await refreshTokens();
|
||||
if (refreshed) response = await send(method, path, body, opts);
|
||||
else signOut();
|
||||
if (refreshed === 'invalid') signOut();
|
||||
else if (refreshed instanceof ApiError) throw refreshed; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه
|
||||
else response = await send(method, path, body, opts);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
151
src/api/index.ts
@@ -4,13 +4,26 @@
|
||||
* هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است.
|
||||
*/
|
||||
import { resolveApiBaseUrl } from '@/config';
|
||||
import { ApiError, createHttpClient, newIdempotencyKey } from './http';
|
||||
import { homeSnapshot } from '@/state/homeSnapshot';
|
||||
import { ApiError, createHttpClient, localTokenStorage, newIdempotencyKey, type TokenStorage } from './http';
|
||||
import type * as T from './types';
|
||||
|
||||
export { ApiError };
|
||||
export type * from './types';
|
||||
|
||||
export const http = createHttpClient({ baseUrl: resolveApiBaseUrl() });
|
||||
/**
|
||||
* پاک شدنِ توکن (خروج، حذفِ حساب، نشستِ باطل در http.signOut) عکسِ خانه را هم پاک میکند؛ هر سه از همین
|
||||
* `set(null)` میگذرند، پس راهی برای ماندنِ خانهی کاربرِ قبلی روی گوشیِ مشترک نمیماند.
|
||||
*/
|
||||
const tokenStorage: TokenStorage = {
|
||||
get: () => localTokenStorage.get(),
|
||||
set(tokens) {
|
||||
if (!tokens) homeSnapshot.clear();
|
||||
localTokenStorage.set(tokens);
|
||||
},
|
||||
};
|
||||
|
||||
export const http = createHttpClient({ baseUrl: resolveApiBaseUrl(), storage: tokenStorage });
|
||||
|
||||
const q = (params: Record<string, string | number | undefined>) => {
|
||||
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '');
|
||||
@@ -18,34 +31,54 @@ const q = (params: Record<string, string | number | undefined>) => {
|
||||
};
|
||||
|
||||
function saveTokens(data: T.AuthResponse) {
|
||||
// ورودِ تازه ممکن است کاربرِ دیگری باشد؛ عکسِ خانه فقط از پاسخِ سرورِ همین نشست ساخته میشود.
|
||||
homeSnapshot.clear();
|
||||
http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' });
|
||||
return data;
|
||||
}
|
||||
|
||||
/** یک بخشِ مسیر؛ شناسهای که از آدرسِ صفحه یا سرور میآید نباید با «/» یا «..» مسیرِ دیگری از API را صدا بزند. */
|
||||
const seg = (value: string | number) => encodeURIComponent(String(value));
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
isSignedIn: () => Boolean(http.storage.get()?.token),
|
||||
|
||||
/** مهمان: فقط با شناسهی دستگاه. refreshToken هم میگیرد، پس بعد از یک ساعت نمیافتد. */
|
||||
guest: (deviceId: string) =>
|
||||
http.post<T.AuthResponse>('/auth/session', { type: 'guest', deviceId }).then(saveTokens),
|
||||
|
||||
/** گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمیگردد. */
|
||||
/**
|
||||
* گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمیگردد.
|
||||
* مهلتِ ۴۵ ثانیه، نه ۱۵ِ پیشفرض: بکاند تا ۱۰+۳۰ ثانیه منتظرِ کاوهنگار میماند
|
||||
* (۲۰۲۶-۰۹-۲۷، پیامک رسید ولی جوابِ کاوهنگار دیر آمد). اگر این زودتر قطع کند،
|
||||
* کاربر «ارتباط برقرار نشد» میبیند در حالی که کد در گوشیاش است.
|
||||
*/
|
||||
requestOtp: (phone: string) =>
|
||||
http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }),
|
||||
http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }, { timeoutMs: 45_000 }),
|
||||
|
||||
/**
|
||||
* گامِ ۲: تأییدِ کد. اگر الان مهمان باشد، سرور همان کاربر را نگه میدارد و
|
||||
* پیشرفتش از دست نمیرود (توکنِ مهمان همراهِ همین درخواست میرود).
|
||||
* گامِ ۲: تأییدِ کد. شمارهی تازه حسابِ تازه میسازد.
|
||||
*/
|
||||
login: (phone: string, otp: string) =>
|
||||
http.post<T.AuthResponse>('/auth/login', { phone, otp }).then(saveTokens),
|
||||
|
||||
/**
|
||||
* حذفِ همیشگیِ حساب. کاربر کدِ تازهی پیامکی میدهد (اول requestOtp با شمارهی خودش).
|
||||
* بعد از موفقیت توکنها پاک میشوند؛ دیگر کاربری پشتشان نیست.
|
||||
*/
|
||||
deleteAccount: (body: { otp: string }) =>
|
||||
http.post<{ success: boolean }>('/user/delete', body).then((r) => {
|
||||
http.storage.set(null);
|
||||
return r;
|
||||
}),
|
||||
|
||||
/** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */
|
||||
async logout() {
|
||||
const refreshToken = http.storage.get()?.refreshToken;
|
||||
// بدنه تابع است تا سرِ هر ارسال خوانده شود: اگر access token منقضی بوده، ۴۰۱ ← refresh ← تکرار، و
|
||||
// refresh token عوض شده است؛ فرستادنِ نسخهی قبلی نشستِ تازه را ۳۰ روز روی سرور باطلنشده میگذاشت.
|
||||
const body = () => {
|
||||
const refreshToken = http.storage.get()?.refreshToken;
|
||||
return refreshToken ? { refreshToken } : {};
|
||||
};
|
||||
try {
|
||||
await http.post('/auth/logout', refreshToken ? { refreshToken } : {});
|
||||
await http.post('/auth/logout', body);
|
||||
} finally {
|
||||
http.storage.set(null);
|
||||
}
|
||||
@@ -56,7 +89,7 @@ export const api = {
|
||||
|
||||
profile: {
|
||||
get: () => http.get<T.UserProfile>('/user/profile'),
|
||||
update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
|
||||
update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'birthDate' | 'bio'>>) =>
|
||||
http.patch<T.UserProfile>('/user/profile', patch),
|
||||
setGrade: (gradeId: number) =>
|
||||
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
|
||||
@@ -65,31 +98,56 @@ export const api = {
|
||||
summary: () => http.get<T.UserSummary>('/user/summary'),
|
||||
hearts: () => http.get<T.Hearts>('/user/hearts'),
|
||||
streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'),
|
||||
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
|
||||
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
|
||||
|
||||
/** بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. */
|
||||
home: () => http.get<T.HomeResponse>('/home'),
|
||||
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${nodeId}/lessons`),
|
||||
/**
|
||||
* بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. بدونِ subject اولین کتابِ پایه؛
|
||||
* کتابِ ناموجود در پایه ۴۰۰ میدهد (برای کلیدِ ذخیرهشدهی قدیمی از `fetchHome` استفاده کنید).
|
||||
*/
|
||||
home: (subject?: string) => http.get<T.HomeResponse>(`/home${q({ subject })}`),
|
||||
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
|
||||
|
||||
/**
|
||||
* آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیشفرض ۴). ترتیب برای هر
|
||||
* کاربر ثابت است، پس رفرش همان آزمون را میدهد.
|
||||
*/
|
||||
nodeQuiz: (nodeId: string) => http.get<T.NodeQuiz>(`/nodes/${seg(nodeId)}/quiz`),
|
||||
/** پایانِ آزمون، جزیره را با هر نمرهای تمام میکند و جزیرهی بعدی را باز. */
|
||||
nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
|
||||
http.create<T.NodeQuizComplete>(`/nodes/${seg(nodeId)}/quiz/complete`, result, { idempotencyKey }),
|
||||
|
||||
lesson: {
|
||||
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${lessonId}/intro`),
|
||||
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${seg(lessonId)}/intro`),
|
||||
questions: (lessonId: number) =>
|
||||
http.get<{ questions: T.Question[] }>(`/lessons/${lessonId}/questions`).then((r) => r.questions),
|
||||
http.get<{ questions: T.Question[] }>(`/lessons/${seg(lessonId)}/questions`).then((r) => r.questions),
|
||||
/**
|
||||
* کلیدِ یکتا را یکبار برای هر جواب بسازید و در تکرار همان را بفرستید تا
|
||||
* امتیاز و قلب دوبار حساب نشود.
|
||||
*/
|
||||
answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) =>
|
||||
http.post<T.AnswerResult>(
|
||||
`/questions/${questionId}/answer`,
|
||||
`/questions/${seg(questionId)}/answer`,
|
||||
{ selectedOptionId: Number(selectedOptionId) },
|
||||
{ idempotencyKey, retry: true },
|
||||
),
|
||||
/** مجموعِ XP و سکهی کاربر اینجا بهروز میشود، نه سرِ هر جواب. */
|
||||
complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
|
||||
http.create<T.LessonComplete>(`/lessons/${lessonId}/complete`, result, { idempotencyKey }),
|
||||
http.create<T.LessonComplete>(`/lessons/${seg(lessonId)}/complete`, result, { idempotencyKey }),
|
||||
report: (questionId: string, reason: T.ReportReason, note?: string) =>
|
||||
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
|
||||
`/questions/${questionId}/report`,
|
||||
`/questions/${seg(questionId)}/report`,
|
||||
{ reason, ...(note ? { note } : {}) },
|
||||
),
|
||||
},
|
||||
|
||||
/** لایک/دیسلایک و گزارشِ ایراد روی سوال و درس (۰ = پسگرفتنِ رأی). */
|
||||
feedback: {
|
||||
vote: (kind: 'question' | 'lesson', id: string, value: 1 | -1 | 0) =>
|
||||
http.post<{ value: number }>(`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/vote`, { value }),
|
||||
report: (kind: 'question' | 'lesson', id: string, reason: string, note?: string) =>
|
||||
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
|
||||
`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/report`,
|
||||
{ reason, ...(note ? { note } : {}) },
|
||||
),
|
||||
},
|
||||
@@ -104,12 +162,19 @@ export const api = {
|
||||
},
|
||||
|
||||
ai: {
|
||||
/** «امروز X سوالِ رایگان + Y سوالِ خریدهشده»، پیش از اولین سوال. */
|
||||
quota: () => http.get<T.TutorQuota>('/ai/quota'),
|
||||
|
||||
/**
|
||||
* پرسش با پاسخِ تکهتکه. `lessonId` پاسخ را به کتابِ همان درس مستند میکند؛
|
||||
* `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> {
|
||||
@@ -128,57 +193,61 @@ export const api = {
|
||||
return done;
|
||||
},
|
||||
feedback: (messageId: string, isHelpful: boolean, reason?: string) =>
|
||||
http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, {
|
||||
http.post<{ success: boolean }>(`/ai/messages/${seg(messageId)}/feedback`, {
|
||||
isHelpful,
|
||||
...(reason ? { reason } : {}),
|
||||
}),
|
||||
history: (conversationId: string) =>
|
||||
http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>(
|
||||
`/ai/conversations/${conversationId}/messages`,
|
||||
`/ai/conversations/${seg(conversationId)}/messages`,
|
||||
),
|
||||
},
|
||||
|
||||
notifications: {
|
||||
list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'),
|
||||
markRead: (id: number) => http.patch(`/notifications/${id}/read`),
|
||||
markRead: (id: number) => http.patch(`/notifications/${seg(id)}/read`),
|
||||
markAllRead: () => http.patch('/notifications/read-all'),
|
||||
},
|
||||
|
||||
subscription: {
|
||||
plans: () => http.get<{ plans: T.Plan[] }>('/subscription/plans').then((r) => r.plans),
|
||||
plans: () => http.get<T.PlansResponse>('/subscription/plans'),
|
||||
status: () => http.get<T.SubscriptionStatus>('/subscription/status'),
|
||||
/** سفارش میسازد؛ paymentUrl خودش سراغِ درگاه میرود. */
|
||||
purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }),
|
||||
/**
|
||||
* خرید داخلِ اپِ اندرویدِ بازار. idempotencyKey ثابت است تا تلاشِ دوباره بعد از قطعِ اینترنت
|
||||
* همان سفارش را برگرداند (src/lib/bazaar.ts).
|
||||
*/
|
||||
purchaseBazaar: (planId: string, idempotencyKey: string) =>
|
||||
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }),
|
||||
/** SKU، نوعِ محصول و (اگر تخفیف خورده) قیمتِ امضاشده برای بازار؛ بعد از آن کدِ تخفیف نمینشیند. */
|
||||
startBazaar: (orderId: string) => http.post<T.BazaarStartResponse>(`/payment/bazaar/orders/${seg(orderId)}/start`),
|
||||
/** توکنِ خریدِ Poolakey را سرور از خودِ بازار میسنجد؛ گفتهی اپ کافی نیست. */
|
||||
verifyBazaar: (orderId: string, purchaseToken: string) =>
|
||||
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),
|
||||
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
|
||||
redeem: (code: string, orderId: string) =>
|
||||
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
|
||||
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${orderId}`),
|
||||
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${seg(orderId)}`),
|
||||
/** پرداختها و فاکتورهای همین کاربر، تازهترین اول. */
|
||||
invoices: () => http.get<{ invoices: T.Invoice[] }>('/payment/invoices').then((r) => r.invoices),
|
||||
},
|
||||
|
||||
support: {
|
||||
createTicket: (subject: string, category: string) =>
|
||||
http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }),
|
||||
ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${ticketId}`),
|
||||
/** تیکتهای همین کاربر، تازهترین اول (۲۰ تای آخر). */
|
||||
tickets: () => http.get<{ tickets: T.TicketSummary[] }>('/support/tickets').then((r) => r.tickets),
|
||||
ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${seg(ticketId)}`),
|
||||
reply: (ticketId: string, text: string) =>
|
||||
http.create<{ messageId: string; success: boolean }>(`/support/tickets/${ticketId}/messages`, { text }),
|
||||
http.create<{ messageId: string; success: boolean }>(`/support/tickets/${seg(ticketId)}/messages`, { text }),
|
||||
bugReport: (title: string, description: string) =>
|
||||
http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }),
|
||||
},
|
||||
|
||||
family: {
|
||||
link: () => http.get<T.FamilyLink>('/family/link'),
|
||||
invite: (childPhone: string) =>
|
||||
http.post<{ familyLinkId: string; inviteCode: string; status: string }>('/family/link/invite', { childPhone }),
|
||||
accept: (inviteCode: string) =>
|
||||
http.post<{ familyLinkId: string; status: string }>('/family/link/accept', { inviteCode }),
|
||||
weeklyReport: () => http.get<T.FamilyWeeklyReport>('/family/weekly-report'),
|
||||
settings: () => http.get<T.FamilySettings>('/family/settings'),
|
||||
updateSettings: (patch: Partial<T.FamilySettings>) => http.patch('/family/settings', patch),
|
||||
},
|
||||
|
||||
affiliate: {
|
||||
me: () => http.get<T.AffiliateMe>('/affiliate/me'),
|
||||
/** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل میکند. */
|
||||
/** کاربرِ تازه را به صاحبِ کدِ معرف وصل میکند. */
|
||||
track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }),
|
||||
},
|
||||
};
|
||||
|
||||
189
src/api/types.ts
@@ -14,7 +14,6 @@ export interface AuthResponse {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
isNewUser?: boolean;
|
||||
isGuest?: boolean;
|
||||
}
|
||||
|
||||
export interface Grade {
|
||||
@@ -28,13 +27,15 @@ export interface Grade {
|
||||
|
||||
export interface UserProfile {
|
||||
userId: string;
|
||||
/** شناسهی نمایشی «User482917»؛ همان که پشتیبانی در ادمین جستجو میکند. */
|
||||
publicId: string;
|
||||
phone: string | null;
|
||||
name: string;
|
||||
avatar: string;
|
||||
isGuest: boolean;
|
||||
gradeId: number | null;
|
||||
birthYear: number | null;
|
||||
parentPhone: string;
|
||||
/** تاریخِ تولدِ شمسی «1385-04-12»؛ null وقتی فقط سال داده شده یا هیچ. */
|
||||
birthDate: string | null;
|
||||
bio: string;
|
||||
}
|
||||
|
||||
@@ -45,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;
|
||||
}
|
||||
|
||||
@@ -67,6 +71,8 @@ export interface HomeUnit {
|
||||
id: string;
|
||||
title: string;
|
||||
blurb: string;
|
||||
/** این کاربر برای بازیِ این فصل اشتراک لازم دارد (فقط فصلِ اولِ هر پایه رایگان است). */
|
||||
requiresSubscription: boolean;
|
||||
nodes: HomeNode[];
|
||||
}
|
||||
|
||||
@@ -76,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;
|
||||
}
|
||||
@@ -105,10 +126,19 @@ export interface LessonIntro {
|
||||
kind: 'quiz' | 'video';
|
||||
example: string;
|
||||
steps: string[];
|
||||
/** شکلهای ثابتِ درس (یک بار ساخته و تأییدشده؛ هیچ تولیدِ زندهای نیست). */
|
||||
figures: LessonFigure[];
|
||||
videoUrl: string;
|
||||
durationMinutes: number | null;
|
||||
}
|
||||
|
||||
export interface LessonFigure {
|
||||
where: 'intro' | 'example' | 'step';
|
||||
/** فقط برای where='step'، از صفر. */
|
||||
index?: number;
|
||||
diagram: DiagramSpec;
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
id: string;
|
||||
value: string;
|
||||
@@ -119,6 +149,33 @@ export interface Question {
|
||||
type: string;
|
||||
prompt: string;
|
||||
options: QuestionOption[];
|
||||
/** شکلِ سوال (همان قالبِ شکلِ درسها)؛ null یعنی شکل ندارد. */
|
||||
figure?: DiagramSpec | null;
|
||||
}
|
||||
|
||||
export interface NodeQuizQuestion extends Question {
|
||||
lessonId: string;
|
||||
lessonTitle: string;
|
||||
}
|
||||
|
||||
export interface NodeQuiz {
|
||||
nodeId: string;
|
||||
nodeTitle: string;
|
||||
unitTitle: string;
|
||||
/** ممکن است از requested کمتر باشد (جزیرهی کمسوال). */
|
||||
questionCount: number;
|
||||
requested: number;
|
||||
questions: NodeQuizQuestion[];
|
||||
}
|
||||
|
||||
export interface NodeQuizComplete {
|
||||
nodeId: string;
|
||||
stars: number;
|
||||
xpEarned: number;
|
||||
coinsEarned: number;
|
||||
/** جزیرهی بعدی که حالا باز است؛ null یعنی آخرینِ پایه بود. */
|
||||
nextNodeId: string | null;
|
||||
stats: HomeStats;
|
||||
}
|
||||
|
||||
export interface AnswerResult {
|
||||
@@ -182,16 +239,24 @@ export interface AskDone {
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
suggestedFollowUps: string[];
|
||||
/** سوالهای رایگانِ باقیِ امروز. */
|
||||
remainingQuota: number;
|
||||
usedChunks: UsedChunk[];
|
||||
diagram?: Diagram | null;
|
||||
}
|
||||
|
||||
export interface ConversationMessage {
|
||||
messageId: string;
|
||||
role: 'user' | 'assistant' | string;
|
||||
text: string;
|
||||
diagram?: Diagram | null;
|
||||
}
|
||||
|
||||
/* ─── شکلِ پاسخِ دستیار: شِمایش در src/diagram/types.ts ─────────────────── */
|
||||
|
||||
import type { DiagramSpec } from '@/diagram/types';
|
||||
export type Diagram = DiagramSpec;
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number;
|
||||
type: string;
|
||||
@@ -212,17 +277,59 @@ export interface Plan {
|
||||
durationDays: number;
|
||||
features: string[];
|
||||
badge: string;
|
||||
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
|
||||
aiDailyQuota: number;
|
||||
/** SKUِ همین اشتراک در کافهبازار؛ خالی یعنی از بازار خریدنی نیست. */
|
||||
bazaarSku: string;
|
||||
}
|
||||
|
||||
export interface PlansResponse {
|
||||
plans: Plan[];
|
||||
/** سقفِ روزانهی کاربرِ بیاشتراک، برای مقایسه روی کارت. */
|
||||
freeAiDailyQuota: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionStatus {
|
||||
/** اشتراکِ زمانیِ فعال (یک/سه/ششماهه). */
|
||||
active: boolean;
|
||||
/** نامِ پلنِ فعال؛ null بیاشتراک. */
|
||||
planTitle: string | null;
|
||||
/** طولِ دورهی پلنِ فعال به روز؛ مخرجِ نوارِ «چقدر مانده». */
|
||||
planDurationDays: number | null;
|
||||
/** سقفِ سوالِ روزانهی همین کاربر از دستیار. */
|
||||
aiDailyQuota: number;
|
||||
planId: string | null;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/** GET /ai/quota */
|
||||
export interface TutorQuota {
|
||||
freeDaily: number;
|
||||
remainingQuota: number;
|
||||
}
|
||||
|
||||
export interface PurchaseResponse {
|
||||
orderId: string;
|
||||
paymentUrl: string;
|
||||
/** زرینپال: آدرسِ درگاه. بازار: null. */
|
||||
paymentUrl: string | null;
|
||||
/** فقط بازار: SKUی که اپِ اندروید میخرد. */
|
||||
bazaarSku?: string;
|
||||
}
|
||||
|
||||
/** POST /payment/bazaar/orders/{id}/start — بعد از کدِ تخفیف، درست پیش از صفحهی خریدِ بازار. */
|
||||
export interface BazaarStartResponse {
|
||||
orderId: string;
|
||||
sku: string;
|
||||
/** مبلغِ سفارش (تومان)، پس از کدِ تخفیف. */
|
||||
amount: number;
|
||||
/** JWTِ «تخفیف پویا»؛ null یعنی بازار قیمتِ پیشخان را میگیرد. */
|
||||
dynamicPriceToken: string | null;
|
||||
}
|
||||
|
||||
export interface BazaarVerifyResponse {
|
||||
orderId: string;
|
||||
status: string;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export interface RedeemResponse {
|
||||
@@ -239,6 +346,16 @@ export interface OrderStatus {
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/** یک ردیف از فهرستِ تیکتهای کاربر (GET /support/tickets). */
|
||||
export interface TicketSummary {
|
||||
ticketId: string;
|
||||
subject: string;
|
||||
category: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
supportReplies: number;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
ticketId: string;
|
||||
subject: string;
|
||||
@@ -246,24 +363,58 @@ export interface Ticket {
|
||||
messages: { id: string; sender: string; text: string }[];
|
||||
}
|
||||
|
||||
export interface FamilyLink {
|
||||
familyLinkId: string;
|
||||
status: string;
|
||||
childName: string;
|
||||
/** یک سفارش به شکلِ فاکتور (GET /payment/invoices). مبلغها توماناند. */
|
||||
export interface Invoice {
|
||||
orderId: string;
|
||||
invoiceNumber: string;
|
||||
/** «canceled» و «refunded» را فقط ادمین میگذارد (لغو و بازپرداخت، از ۱۴۰۵-۰۷). */
|
||||
status: 'pending' | 'paid' | 'failed' | 'canceled' | 'refunded';
|
||||
provider: 'gateway' | 'bazaar' | string;
|
||||
planTitle: string;
|
||||
/** قیمتِ پایه پیش از تخفیف؛ null برای سفارشهای قدیمیتر از ثبتِ فاکتور. */
|
||||
listPrice: number | null;
|
||||
discountAmount: number | null;
|
||||
discountCode: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
paidAt: string | null;
|
||||
/** کدِ پیگیریِ بانکی/بازار؛ خالی برای پرداختنشده یا کدِ تخفیفِ ۱۰۰٪. */
|
||||
refId: string;
|
||||
}
|
||||
|
||||
export interface FamilyWeeklyReport {
|
||||
weekStart: string;
|
||||
xpEarned: number;
|
||||
lessonsCompleted: number;
|
||||
islandsCompleted: number;
|
||||
currentStreak: number;
|
||||
/** یک روز از گزارشِ هفتگی. `minutes` تخمینِ سرور است (از فاصلهی جوابها)، نه زمانِ باز بودنِ اپ. */
|
||||
export interface ReportDay {
|
||||
date: string;
|
||||
minutes: number;
|
||||
questions: number;
|
||||
correct: number;
|
||||
xp: number;
|
||||
lessons: number;
|
||||
islands: number;
|
||||
aiQuestions: number;
|
||||
}
|
||||
|
||||
export interface FamilySettings {
|
||||
dailyLimitMinutes: number;
|
||||
quietHoursStart: string;
|
||||
quietHoursEnd: string;
|
||||
export interface ReportTotals {
|
||||
minutes: number;
|
||||
activeDays: number;
|
||||
questions: number;
|
||||
correct: number;
|
||||
xp: number;
|
||||
/** یکتا: درس یا جزیرهی تکراری یک بار شمرده میشود. */
|
||||
lessons: number;
|
||||
islands: number;
|
||||
aiQuestions: number;
|
||||
}
|
||||
|
||||
/** GET /user/weekly-report: هفت روزِ اخیر و هفت روزِ قبلش، هر دو قدیمی به جدید. */
|
||||
export interface WeeklyReport {
|
||||
today: string;
|
||||
days: ReportDay[];
|
||||
previousDays: ReportDay[];
|
||||
thisWeek: ReportTotals;
|
||||
lastWeek: ReportTotals;
|
||||
streak: { current: number; longest: number };
|
||||
}
|
||||
|
||||
export interface AffiliateMe {
|
||||
|
||||
204
src/app/App.tsx
@@ -1,11 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { AnimatePresence, MotionConfig, motion } from 'motion/react';
|
||||
import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
|
||||
import { TOKENS_STORAGE_KEY } from '@/api/http';
|
||||
import type { Prefetch } from '@/state/AppData';
|
||||
import { AppShell } from '@/components/AppShell';
|
||||
import { ToastProvider } from '@/components/Toast';
|
||||
import { ErrorState } from '@/components/ui';
|
||||
import { KEYS, store } from '@/lib/storage';
|
||||
import { askStore } from '@/state/askStore';
|
||||
import { homeSnapshot } from '@/state/homeSnapshot';
|
||||
import { fetchHome, subjectStore } from '@/state/subject';
|
||||
import { clearAsyncCache } from '@/lib/useAsync';
|
||||
import { LoginScreen } from '@/screens/LoginScreen';
|
||||
import { OnboardingScreen } from '@/screens/OnboardingScreen';
|
||||
import { SetupScreen } from '@/screens/SetupScreen';
|
||||
@@ -19,30 +24,46 @@ type Flow =
|
||||
| { kind: 'onboarding' }
|
||||
| { kind: 'login' }
|
||||
| { kind: 'setup'; profile: UserProfile; grades: Grade[] }
|
||||
| { kind: 'main'; profile: UserProfile; grades: Grade[] };
|
||||
|
||||
/** کمترین زمانِ نمایشِ اسپلش، تا صفحه چشمک نزند. */
|
||||
const SPLASH_MIN_MS = 700;
|
||||
| { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch; stale?: boolean };
|
||||
|
||||
/**
|
||||
* جریانِ ورود: اسپلش → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ.
|
||||
* خودِ اپ (تبها و صفحهها) در MainApp است.
|
||||
* جریانِ ورود: اسپلشِ ثابت (تا بررسیِ نشست) → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ.
|
||||
* کاربرِ واردشدهای که عکسِ خانه روی گوشی دارد (state/homeSnapshot.ts) بیانتظار به خانه میرود و
|
||||
* دادهها پشتِ صحنه تازه میشوند. خودِ اپ (تبها و صفحهها) در MainApp است.
|
||||
*/
|
||||
export function App() {
|
||||
const [flow, setFlow] = useState<Flow>({ kind: 'boot' });
|
||||
// با هر rebootFresh یکی بالا میرود و جزوِ کلیدِ صفحه است. بیآن «main → boot → main» برای AnimatePresence
|
||||
// همان کلید است و MainAppِ در حالِ خروج (با دادهی عکسِ غلط) بهجای MainAppِ تازه سرِ جایش میماند.
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
/** پروفایل و پایهها را میگیرد و تصمیم میگیرد کاربر کجا برود. */
|
||||
const enter = useCallback(async () => {
|
||||
const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]);
|
||||
await trackReferral(profile);
|
||||
setFlow(profile.name && profile.gradeId ? { kind: 'main', profile, grades } : { kind: 'setup', profile, 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, prefetch });
|
||||
else setFlow({ kind: 'setup', profile, grades });
|
||||
}, []);
|
||||
|
||||
const boot = useCallback(async () => {
|
||||
setFlow({ kind: 'boot' });
|
||||
const started = Date.now();
|
||||
let next: Flow | null = null;
|
||||
if (api.auth.isSignedIn()) {
|
||||
const snap = homeSnapshot.read();
|
||||
if (snap) {
|
||||
setFlow({
|
||||
kind: 'main',
|
||||
profile: snap.profile,
|
||||
grades: snap.grades,
|
||||
prefetch: { home: snap.home, summary: snap.summary },
|
||||
stale: true,
|
||||
});
|
||||
void trackReferral();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await enter();
|
||||
} catch (error) {
|
||||
@@ -54,8 +75,6 @@ export function App() {
|
||||
} else {
|
||||
next = store.get(KEYS.onboarded) ? { kind: 'login' } : { kind: 'onboarding' };
|
||||
}
|
||||
const wait = SPLASH_MIN_MS - (Date.now() - started);
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||
if (next) setFlow(next);
|
||||
}, [enter]);
|
||||
|
||||
@@ -63,16 +82,53 @@ export function App() {
|
||||
void boot();
|
||||
}, [boot]);
|
||||
|
||||
// داخلِ اپِ اندروید اسپلشِ سیستم تا این لحظه روی صفحه میماند؛ پس از اولین صفحهی واقعی (نه boot) میرود
|
||||
// و کاربر مستقیم همان صفحه را میبیند. ۳۰۰ms: تا محوشدنِ ورودِ صفحه تمام شود. APKِ قدیمیتر appReady ندارد.
|
||||
useEffect(() => {
|
||||
if (flow.kind === 'boot') return;
|
||||
const t = setTimeout(() => window.TadrisinoAndroid?.appReady?.(), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [flow.kind]);
|
||||
|
||||
// هر جا نشست باطل شد (refresh هم شکست خورد)، برگرد به صفحهی ورود.
|
||||
useEffect(
|
||||
() =>
|
||||
http.onSignedOut(() => {
|
||||
askStore.clear();
|
||||
clearAsyncCache();
|
||||
setFlow({ kind: 'login' });
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// عکسِ خانه مالِ کسِ دیگری بود یا پروفایل حالا ناقص است: عکس کنار میرود و بوتِ معمولی تصمیم میگیرد.
|
||||
const rebootFresh = useCallback(() => {
|
||||
homeSnapshot.clear();
|
||||
askStore.clear();
|
||||
clearAsyncCache();
|
||||
setGeneration((g) => g + 1);
|
||||
void boot();
|
||||
}, [boot]);
|
||||
|
||||
// تبِ دیگری از همین مرورگر خارج شد یا وارد شد (localStorage مشترک است). بیاین، تبِ خارجشده داخلِ اپ میماند و
|
||||
// هر درخواستش بیتوکن خطا میگرفت؛ تبِ بیرونمانده هم تا رفرش در صفحهی ورود میماند. تمدیدِ توکن (هر دو مقدار
|
||||
// پُر) کاری ندارد.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== TOKENS_STORAGE_KEY && e.key !== null) return;
|
||||
const signedIn = api.auth.isSignedIn();
|
||||
if (!signedIn && (e.key === null || e.oldValue)) {
|
||||
askStore.clear();
|
||||
clearAsyncCache();
|
||||
setFlow({ kind: 'login' });
|
||||
} else if (signedIn && e.key !== null && !e.oldValue) {
|
||||
rebootFresh();
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, [rebootFresh]);
|
||||
|
||||
const afterLogin = async () => {
|
||||
try {
|
||||
await enter();
|
||||
@@ -82,62 +138,80 @@ export function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<ToastProvider>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={flow.kind}
|
||||
className="flex h-full flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{flow.kind === 'boot' && <SplashScreen />}
|
||||
{flow.kind === 'bootError' && (
|
||||
<div className="flex h-full flex-col justify-center">
|
||||
<ErrorState error={flow.error} onRetry={() => void boot()} />
|
||||
</div>
|
||||
)}
|
||||
{flow.kind === 'onboarding' && (
|
||||
<OnboardingScreen
|
||||
onDone={() => {
|
||||
store.set(KEYS.onboarded, '1');
|
||||
setFlow({ kind: 'login' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{flow.kind === 'login' && <LoginScreen onDone={() => void afterLogin()} />}
|
||||
{flow.kind === 'setup' && (
|
||||
<SetupScreen
|
||||
profile={flow.profile}
|
||||
grades={flow.grades}
|
||||
onDone={(profile) => setFlow({ kind: 'main', profile, grades: flow.grades })}
|
||||
/>
|
||||
)}
|
||||
{flow.kind === 'main' && (
|
||||
<MainApp
|
||||
profile={flow.profile}
|
||||
grades={flow.grades}
|
||||
payment={launchPayment}
|
||||
onSignedOut={() => setFlow({ kind: 'login' })}
|
||||
onRegistered={trackReferral}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</ToastProvider>
|
||||
</AppShell>
|
||||
// reducedMotion="user": اگر «کاهشِ حرکت» در گوشی روشن باشد، motion جابهجاییها را حذف میکند.
|
||||
<MotionConfig reducedMotion="user">
|
||||
<AppShell>
|
||||
<ToastProvider>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`${flow.kind}:${generation}`}
|
||||
className="flex h-full flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
// اسپلش بیمحو: با عوضشدنِ صفحه فوراً میرود و صفحهی بعد خودش محو میشود.
|
||||
transition={{ duration: flow.kind === 'boot' ? 0 : 0.2 }}
|
||||
>
|
||||
{flow.kind === 'boot' && <SplashScreen />}
|
||||
{flow.kind === 'bootError' && (
|
||||
<div className="flex h-full flex-col justify-center">
|
||||
<ErrorState error={flow.error} onRetry={() => void boot()} />
|
||||
</div>
|
||||
)}
|
||||
{flow.kind === 'onboarding' && (
|
||||
<OnboardingScreen
|
||||
onDone={() => {
|
||||
store.set(KEYS.onboarded, '1');
|
||||
setFlow({ kind: 'login' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{flow.kind === 'login' && <LoginScreen onDone={() => void afterLogin()} />}
|
||||
{flow.kind === 'setup' && (
|
||||
<SetupScreen
|
||||
profile={flow.profile}
|
||||
grades={flow.grades}
|
||||
onDone={(profile) => setFlow({ kind: 'main', profile, grades: flow.grades })}
|
||||
/>
|
||||
)}
|
||||
{flow.kind === 'main' && (
|
||||
<MainApp
|
||||
profile={flow.profile}
|
||||
grades={flow.grades}
|
||||
prefetch={flow.prefetch}
|
||||
stale={flow.stale}
|
||||
onIdentityChanged={rebootFresh}
|
||||
payment={launchPayment}
|
||||
onSignedOut={() => {
|
||||
clearAsyncCache();
|
||||
setFlow({ kind: 'login' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</ToastProvider>
|
||||
</AppShell>
|
||||
</MotionConfig>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* اگر کاربر با لینکِ دعوت آمده بود و حالا با شماره وارد شده، معرفی را ثبت کن.
|
||||
* مهمان را سرور نمیپذیرد، پس کد تا ثبتِ شماره نگه داشته میشود.
|
||||
*/
|
||||
async function trackReferral(profile: UserProfile) {
|
||||
/** دادهی صفحهی اول؛ هر کدام که نرسید 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);
|
||||
if (!code || profile.isGuest) return;
|
||||
if (!code) return;
|
||||
try {
|
||||
await api.affiliate.track(code);
|
||||
store.remove(KEYS.referralCode);
|
||||
|
||||
@@ -1,82 +1,296 @@
|
||||
import { useEffect, useState } 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';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { PrimaryButton } from '@/components/ui';
|
||||
import { AskScreen } from '@/screens/AskScreen';
|
||||
import { CharactersScreen } from '@/screens/CharactersScreen';
|
||||
import { FamilyScreen } from '@/screens/FamilyScreen';
|
||||
import { LoadingState, PrimaryButton } from '@/components/ui';
|
||||
import { HomeScreen } from '@/screens/HomeScreen';
|
||||
import { InviteScreen } from '@/screens/InviteScreen';
|
||||
import { LeaderboardScreen } from '@/screens/LeaderboardScreen';
|
||||
import { LessonScreen } from '@/screens/LessonScreen';
|
||||
import { LoginScreen } from '@/screens/LoginScreen';
|
||||
import { MapScreen } from '@/screens/MapScreen';
|
||||
import { NotificationsScreen } from '@/screens/NotificationsScreen';
|
||||
import { ProfileScreen } from '@/screens/ProfileScreen';
|
||||
import { QuizScreen } from '@/screens/QuizScreen';
|
||||
import { StreakScreen } from '@/screens/StreakScreen';
|
||||
import { SubscriptionScreen } from '@/screens/SubscriptionScreen';
|
||||
import { SupportScreen } from '@/screens/SupportScreen';
|
||||
import { UnitScreen } from '@/screens/UnitScreen';
|
||||
import type { PaymentReturn } from './launchParams';
|
||||
|
||||
/**
|
||||
* خانه همراهِ خودِ اپ بار میشود؛ بقیهی صفحهها هر کدام فایلِ جدا دارند تا بارِ اول
|
||||
* سبک باشد (قبلاً کلِ اپ یک فایلِ ۵۰۵ کیلوبایتی بود، و دستیار با ماژولِ شکلها
|
||||
* بزرگترین تکهاش). بعد از بالا آمدنِ خانه همه در پسزمینه گرفته میشوند
|
||||
* (preloadScreens)، پس رفتن به صفحهی بعدی منتظرِ شبکه نمیماند.
|
||||
*/
|
||||
const loaders: (() => Promise<unknown>)[] = [];
|
||||
function screen<M extends Record<K, ComponentType<any>>, K extends keyof M>(load: () => Promise<M>, name: K) {
|
||||
loaders.push(load);
|
||||
return lazy(async () => ({ default: (await load())[name] }));
|
||||
}
|
||||
const AskScreen = screen(() => import('@/screens/AskScreen'), 'AskScreen');
|
||||
const CharactersScreen = screen(() => import('@/screens/CharactersScreen'), 'CharactersScreen');
|
||||
const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScreen');
|
||||
const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen');
|
||||
const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen');
|
||||
const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen');
|
||||
const LessonsScreen = screen(() => import('@/screens/LessonsScreen'), 'LessonsScreen');
|
||||
const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen');
|
||||
const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen');
|
||||
const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen');
|
||||
const QuizScreen = screen(() => import('@/screens/QuizScreen'), 'QuizScreen');
|
||||
const StreakScreen = screen(() => import('@/screens/StreakScreen'), 'StreakScreen');
|
||||
const SubscriptionScreen = screen(() => import('@/screens/SubscriptionScreen'), 'SubscriptionScreen');
|
||||
const SupportScreen = screen(() => import('@/screens/SupportScreen'), 'SupportScreen');
|
||||
const UnitScreen = screen(() => import('@/screens/UnitScreen'), 'UnitScreen');
|
||||
|
||||
let preloaded = false;
|
||||
function preloadScreens() {
|
||||
if (preloaded) return;
|
||||
preloaded = true;
|
||||
const run = () => loaders.forEach((load) => void load().catch(() => {}));
|
||||
if ('requestIdleCallback' in window) window.requestIdleCallback(run, { timeout: 2500 });
|
||||
else setTimeout(run, 1200);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
profile: UserProfile;
|
||||
grades: Grade[];
|
||||
prefetch?: Prefetch;
|
||||
/** دادهها از عکسِ خانه روی گوشی آمدهاند و پشتِ صحنه تازه میشوند (AppData). */
|
||||
stale?: boolean;
|
||||
onIdentityChanged?: () => void;
|
||||
payment: PaymentReturn | null;
|
||||
onSignedOut: () => void;
|
||||
onRegistered: (profile: UserProfile) => Promise<void>;
|
||||
}
|
||||
|
||||
/** اپِ اصلی بعد از ورود: تبها، صفحهها و دادهی مشترک. */
|
||||
export function MainApp({ profile, grades, payment, onSignedOut, onRegistered }: Props) {
|
||||
export function MainApp({ profile, grades, prefetch, stale, onIdentityChanged, payment, onSignedOut }: Props) {
|
||||
useEffect(preloadScreens, []);
|
||||
return (
|
||||
<AppDataProvider initialProfile={profile} grades={grades}>
|
||||
<AppDataProvider
|
||||
initialProfile={profile}
|
||||
grades={grades}
|
||||
prefetch={prefetch}
|
||||
stale={stale}
|
||||
onIdentityChanged={onIdentityChanged}
|
||||
>
|
||||
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
|
||||
<Screens onSignedOut={onSignedOut} onRegistered={onRegistered} />
|
||||
<Screens onSignedOut={onSignedOut} />
|
||||
{payment && <PaymentResult payment={payment} />}
|
||||
</NavigatorProvider>
|
||||
</AppDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onRegistered'>) {
|
||||
/**
|
||||
* صحنهی صفحهها. صفحهی تازه اول پنهان زیرِ صفحهی فعلی ساخته میشود و فقط وقتی دادهی اولش رسید نشان داده
|
||||
* میشود (ScreenGate در useAsync)، با یک حرکتِ کوتاه. قبلاً صفحه همان لحظه نیمهکاره میآمد (کارتِ خالی، «نداری»،
|
||||
* عددِ صفر) و چند صد میلیثانیه بعد دادهی واقعی جایش میپرید؛ همان «چشمک»ی که روی گوشی دیده میشد.
|
||||
*
|
||||
* اگر جواب کند باشد، بعد از HOLD_MAX_MS صفحه با لودرِ خودش میآید تا لمسِ کاربر بیجواب نماند؛ در این فاصله
|
||||
* نوارِ باریکِ بالا نشان میدهد که کاری در جریان است. عددها از راهنمای رایجِ پاسخگویی آمدهاند (زیرِ ~۱۰۰ms
|
||||
* «آنی»، تا ~۴۰۰ms هنوز پیوسته)؛ روی گوشیِ واقعی با شبکهی ضعیف سنجیده نشدهاند.
|
||||
*/
|
||||
const HOLD_MAX_MS = 450;
|
||||
const HOLD_BAR_AFTER_MS = 120;
|
||||
|
||||
interface Layer {
|
||||
id: number;
|
||||
route: Route;
|
||||
direction: NavDirection;
|
||||
/** ریشهی پشته است (تب)، نه صفحهای که روی آن باز شده؛ نوارِ پایین فقط آنجا هست. */
|
||||
root: boolean;
|
||||
shown: boolean;
|
||||
}
|
||||
|
||||
function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
|
||||
const nav = useNav();
|
||||
const { 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 }}
|
||||
>
|
||||
<RouteView route={route} onSignedOut={onSignedOut} onRegistered={onRegistered} />
|
||||
</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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick<Props, 'onSignedOut' | 'onRegistered'>) {
|
||||
const ENTER: Record<NavDirection, { opacity: number; x: number }> = {
|
||||
// در راستبهچپ صفحهی بعدی از چپ میآید و برگشت از راست؛ تعویضِ تب فقط محو است، مثلِ اپهای بومی.
|
||||
push: { opacity: 0, x: -28 },
|
||||
replace: { opacity: 0, x: -28 },
|
||||
back: { opacity: 0, x: 28 },
|
||||
tab: { opacity: 0, x: 0 },
|
||||
};
|
||||
|
||||
function ScreenLayer({ layer, onReady, onSettled, onSignedOut }: {
|
||||
layer: Layer;
|
||||
onReady: (id: number) => void;
|
||||
onSettled: (id: number) => void;
|
||||
} & Pick<Props, 'onSignedOut'>) {
|
||||
const { id, shown } = layer;
|
||||
const initiallyShown = useRef(shown).current;
|
||||
const gate = useGate(initiallyShown ? null : () => onReady(id));
|
||||
// اگر onAnimationComplete نرسید (مثلاً «کاهشِ حرکت» یا قطعِ انیمیشن)، صفحههای زیرین باز هم برداشته شوند.
|
||||
useEffect(() => {
|
||||
if (!shown || initiallyShown) return;
|
||||
const t = setTimeout(() => onSettled(id), 700);
|
||||
return () => clearTimeout(t);
|
||||
}, [shown, initiallyShown, id, onSettled]);
|
||||
|
||||
return (
|
||||
<ScreenGateContext.Provider value={gate}>
|
||||
<motion.div
|
||||
className="absolute inset-0 flex flex-col bg-page"
|
||||
initial={initiallyShown ? false : ENTER[layer.direction]}
|
||||
animate={shown ? { opacity: 1, x: 0 } : ENTER[layer.direction]}
|
||||
transition={{ duration: layer.direction === 'tab' ? 0.14 : 0.2, ease: [0.2, 0.8, 0.2, 1] }}
|
||||
onAnimationComplete={() => shown && onSettled(id)}
|
||||
data-layer={id}
|
||||
style={{ pointerEvents: shown ? 'auto' : 'none', zIndex: id }}
|
||||
aria-hidden={!shown}
|
||||
// صفحهی پنهان نباید با Tab صفحهکلید فوکوس بگیرد. (React 18 «inert» را فقط بهصورتِ رشته رد میکند.)
|
||||
{...((!shown ? { inert: '' } : {}) as object)}
|
||||
>
|
||||
<Suspense fallback={<SuspenseHold />}>
|
||||
<RouteView route={layer.route} onSignedOut={onSignedOut} />
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
</ScreenGateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* شمارندهی holdهای صفحه. وقتی بعد از mount هیچ holdی باز نماند، یا HOLD_MAX_MS گذشت، onReady یک بار صدا
|
||||
* زده میشود. React افکتهای فرزند را پیش از پدر اجرا میکند، پس تا افکتِ اینجا همهی useAsyncهای صفحه
|
||||
* hold گرفتهاند.
|
||||
*/
|
||||
function useGate(onReady: (() => void) | null): ScreenGate | null {
|
||||
const readyRef = useRef(onReady);
|
||||
readyRef.current = onReady;
|
||||
const state = useRef({ count: 0, mounted: false, done: onReady === null });
|
||||
const fire = useCallback(() => {
|
||||
const s = state.current;
|
||||
if (s.done) return;
|
||||
s.done = true;
|
||||
readyRef.current?.();
|
||||
}, []);
|
||||
const gate = useMemo<ScreenGate | null>(
|
||||
() =>
|
||||
onReady === null
|
||||
? null
|
||||
: {
|
||||
hold() {
|
||||
const s = state.current;
|
||||
if (s.done) return () => {};
|
||||
s.count++;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
s.count--;
|
||||
// یک microtask صبر: در StrictMode (و جابهجاییِ Suspense) افکتها پاک و دوباره اجرا میشوند و
|
||||
// hold تازه بلافاصله گرفته میشود؛ بیاین صبر دروازه پیش از رسیدنِ داده باز میشد.
|
||||
queueMicrotask(() => {
|
||||
if (s.mounted && s.count === 0) fire();
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
// فقط بارِ اول؛ لایهای که یک بار نشان داده شد دیگر دروازه ندارد.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const s = state.current;
|
||||
if (s.done) return;
|
||||
s.mounted = true;
|
||||
if (s.count === 0) {
|
||||
fire();
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(fire, HOLD_MAX_MS);
|
||||
return () => {
|
||||
s.mounted = false;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [fire]);
|
||||
return gate;
|
||||
}
|
||||
|
||||
/** تا فایلِ صفحه از شبکه نرسیده، دروازه بسته میماند (معمولاً از قبل در پسزمینه گرفته شده). */
|
||||
function SuspenseHold() {
|
||||
const gate = useContext(ScreenGateContext);
|
||||
useEffect(() => {
|
||||
const release = gate?.hold();
|
||||
// آزاد کردن خودش یک microtask صبر میکند (useGate)، پس useAsyncهای صفحهی تازه پیش از آن hold گرفتهاند.
|
||||
return () => release?.();
|
||||
}, [gate]);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
/** نوارِ باریکِ بالای صفحه وقتی صفحهی بعدی بیش از HOLD_BAR_AFTER_MS منتظرِ داده است. */
|
||||
function HoldBar({ active }: { active: boolean }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setVisible(true), HOLD_BAR_AFTER_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [active]);
|
||||
return (
|
||||
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 z-[9999] h-[3px] overflow-hidden">
|
||||
{visible && <div className="hold-bar h-full w-1/3 rounded-full bg-white/90 shadow-[0_0_6px_rgba(237,105,86,.8)]" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
|
||||
switch (route.name) {
|
||||
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} />;
|
||||
@@ -88,59 +302,78 @@ function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick
|
||||
case 'characters': return <CharactersScreen />;
|
||||
case 'streak': return <StreakScreen />;
|
||||
case 'support': return <SupportScreen />;
|
||||
case 'family': return <FamilyScreen />;
|
||||
case 'report': return <ReportScreen />;
|
||||
case 'invoices': return <InvoicesScreen />;
|
||||
case 'invite': return <InviteScreen />;
|
||||
case 'register': return <RegisterView onRegistered={onRegistered} />;
|
||||
}
|
||||
}
|
||||
|
||||
/** مهمانی که شماره ثبت میکند؛ همان کاربر میماند و فقط پروفایل تازه میشود. */
|
||||
function RegisterView({ onRegistered }: Pick<Props, 'onRegistered'>) {
|
||||
const nav = useNav();
|
||||
const { setProfile } = useAppData();
|
||||
const toast = useToast();
|
||||
return (
|
||||
<LoginScreen
|
||||
upgrade
|
||||
onCancel={nav.back}
|
||||
onDone={async () => {
|
||||
try {
|
||||
const profile = await api.profile.get();
|
||||
setProfile(profile);
|
||||
await onRegistered(profile);
|
||||
} catch {
|
||||
/* ورود انجام شده؛ پروفایل با بازگشت به اپ تازه میشود */
|
||||
}
|
||||
toast('شمارهت ثبت شد! پیشرفتت امنه.', 'success');
|
||||
nav.back();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* نتیجهی برگشت از درگاه. فقط پاسخِ سرور تعیین میکند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر
|
||||
* لینکِ ساختگی میتواند «paid» بگذارد.
|
||||
*/
|
||||
/** سرور سفارشِ pending را دقیقهای یک بار از درگاه میپرسد؛ ۴ بار با فاصلهی ۲۰ ثانیه حدودِ یک دقیقه است. */
|
||||
const PENDING_CHECKS = 4;
|
||||
const PENDING_CHECK_MS = 20_000;
|
||||
|
||||
/** نتیجهی برگشت از درگاه؛ وضعیتِ سفارش از خودِ سرور هم دوباره خوانده میشود. */
|
||||
function PaymentResult({ payment }: { payment: PaymentReturn }) {
|
||||
const { refreshSummary } = useAppData();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [paid, setPaid] = useState(payment.status === 'paid');
|
||||
const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking');
|
||||
|
||||
useEffect(() => {
|
||||
api.subscription
|
||||
.order(payment.orderId)
|
||||
.then((order) => setPaid(order.status === 'paid'))
|
||||
.catch(() => {});
|
||||
// pending یعنی تأییدِ درگاه هنوز نرسیده (مثلاً verify هنگامِ برگشت جواب نداد)، نه «پرداخت نشد»: سرور با هر
|
||||
// خواندنِ وضعیت دوباره از زرینپال میپرسد (حداکثر دقیقهای یک بار)، پس چند بار با فاصله میپرسیم.
|
||||
let alive = true;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const check = (attempt: number) => {
|
||||
api.subscription
|
||||
.order(payment.orderId)
|
||||
.then((order) => {
|
||||
if (!alive) return;
|
||||
if (order.status === 'paid') {
|
||||
setState('paid');
|
||||
void refreshSummary();
|
||||
} else if (order.status === 'pending' && attempt < PENDING_CHECKS) {
|
||||
timer = setTimeout(() => check(attempt + 1), PENDING_CHECK_MS);
|
||||
} else {
|
||||
setState(order.status === 'pending' ? 'unknown' : 'failed');
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn('payment result lookup failed', err);
|
||||
if (alive) setState('unknown');
|
||||
});
|
||||
};
|
||||
check(1);
|
||||
void refreshSummary();
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [payment.orderId, refreshSummary]);
|
||||
|
||||
if (state === 'checking') {
|
||||
return (
|
||||
<Sheet open={open} onClose={() => setOpen(false)}>
|
||||
<LoadingState text="دارم پرداخت رو بررسی میکنم…" />
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
const paid = state === 'paid';
|
||||
return (
|
||||
<Sheet open={open} onClose={() => setOpen(false)}>
|
||||
<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 ? 'پرداخت موفق بود! 🎉' : 'پرداخت انجام نشد'}</h2>
|
||||
<h2 className="text-lg font-black text-ink">
|
||||
{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>
|
||||
|
||||
@@ -22,7 +22,8 @@ function readLaunchParams(): PaymentReturn | null {
|
||||
if (ref || orderId) {
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
return orderId ? { orderId, status: status ?? '' } : null;
|
||||
// فقط شمارهی سفارش: هر چیزِ دیگری (مثلاً «../../user/delete») از یک لینکِ ساختگی آمده است.
|
||||
return orderId && /^\d{1,12}$/.test(orderId) ? { orderId, status: status ?? '' } : null;
|
||||
}
|
||||
|
||||
/** یکبار، هنگامِ بار شدنِ اپ. */
|
||||
|
||||
BIN
src/assets/pay/bazaar.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/pay/zarinpal.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
@@ -1,66 +0,0 @@
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
import { currentJalaliYear, toFa } from '@/lib/format';
|
||||
import { sounds } from '@/lib/sound';
|
||||
|
||||
const MIN_AGE = 7;
|
||||
const MAX_AGE = 19;
|
||||
const DEFAULT_AGE = 13;
|
||||
|
||||
/** سرور سالِ تولدِ شمسی میخواهد، نه سن. */
|
||||
export const birthYearFromAge = (age: number) => currentJalaliYear() - age;
|
||||
|
||||
export function ageFromBirthYear(year: number | null | undefined): number | null {
|
||||
if (!year) return null;
|
||||
const age = currentJalaliYear() - year;
|
||||
return age >= MIN_AGE && age <= MAX_AGE ? age : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخابِ سن با دو دکمهی − و +. جای «چرخِ» قبلی نشست که عددِ وسطش با مقدارِ
|
||||
* واقعی نمیخواند و کلیک روی عددهایش کار نمیکرد.
|
||||
*/
|
||||
export function AgePicker({ value, onChange }: { value: number | null; onChange: (age: number) => void }) {
|
||||
const current = value ?? DEFAULT_AGE;
|
||||
const step = (delta: number) => {
|
||||
const next = Math.max(MIN_AGE, Math.min(MAX_AGE, current + delta));
|
||||
if (next !== current || value === null) {
|
||||
sounds.tick();
|
||||
onChange(next);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-2xl border-2 border-line bg-white p-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="کمتر"
|
||||
onClick={() => step(-1)}
|
||||
disabled={value !== null && current <= MIN_AGE}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
|
||||
>
|
||||
<Minus size={20} strokeWidth={3} />
|
||||
</button>
|
||||
<div className="text-center" aria-live="polite">
|
||||
{value === null ? (
|
||||
<button type="button" onClick={() => onChange(DEFAULT_AGE)} className="text-sm font-bold text-muted">
|
||||
بزن تا انتخاب کنی
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl font-black text-coral">{toFa(value)}</span>
|
||||
<span className="mr-1 text-sm font-bold text-muted">سال</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بیشتر"
|
||||
onClick={() => step(1)}
|
||||
disabled={value !== null && current >= MAX_AGE}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
|
||||
>
|
||||
<Plus size={20} strokeWidth={3} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
src/components/BirthDatePicker.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toEnDigits, toFa } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* تاریخِ تولدِ شمسی با سه فهرستِ روز/ماه/سال (از ۱۴۰۵-۰۷ به جای «چند سالته؟»).
|
||||
* <select> عمداً بومی است: روی گوشی همان چرخِ آشنای خودِ گوشی باز میشود و تقویمِ
|
||||
* ماهانهای که برای تاریخِ سی سال پیش باید دهها بار ورق بخورد لازم نیست.
|
||||
*
|
||||
* سرور تاریخ را شمسی میگیرد («1385-04-12») و خودش به میلادی تبدیل میکند
|
||||
* (accounts/jalali.py)؛ اینجا هیچ تبدیلی انجام نمیشود، فقط تعدادِ روزهای ماه.
|
||||
*/
|
||||
export const JALALI_MONTHS = [
|
||||
'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور',
|
||||
'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند',
|
||||
];
|
||||
/** همان بازهی سرور (accounts/serializers.py: MIN_AGE / MAX_AGE). */
|
||||
const MIN_AGE = 5;
|
||||
const MAX_AGE = 99;
|
||||
/** از این سن به بعد فرمِ آشنایی بهجای «کدوم پایهای؟» میپرسد «ریاضیِ کدوم پایه؟». */
|
||||
export const ADULT_AGE = 19;
|
||||
|
||||
type Parts = { y: number; m: number; d: number };
|
||||
|
||||
function jalaliParts(date: Date): Parts {
|
||||
const parts = new Intl.DateTimeFormat('en-US-u-ca-persian', { year: 'numeric', month: 'numeric', day: 'numeric' }).formatToParts(date);
|
||||
const get = (type: string) => parseInt(toEnDigits(parts.find((p) => p.type === type)?.value ?? '0'), 10);
|
||||
return { y: get('year'), m: get('month'), d: get('day') };
|
||||
}
|
||||
|
||||
export const todayJalali = () => jalaliParts(new Date());
|
||||
|
||||
/** کبیسه یعنی سیامِ اسفند وجود دارد؛ آن روز همیشه یکی از ۱۹ تا ۲۱ مارسِ سالِ بعدِ میلادی است. */
|
||||
function isLeap(y: number): boolean {
|
||||
return [19, 20, 21].some((day) => {
|
||||
const p = jalaliParts(new Date(y + 622, 2, day, 12));
|
||||
return p.y === y && p.m === 12 && p.d === 30;
|
||||
});
|
||||
}
|
||||
|
||||
function monthLength(y: number, m: number): number {
|
||||
if (m <= 6) return 31;
|
||||
if (m <= 11) return 30;
|
||||
return isLeap(y) ? 30 : 29;
|
||||
}
|
||||
|
||||
function parse(value: string | null | undefined): Parts | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
|
||||
return match ? { y: +match[1], m: +match[2], d: +match[3] } : null;
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** «1385-04-12» → «۱۲ تیر ۱۳۸۵». */
|
||||
export function formatBirthDate(value: string | null): string {
|
||||
const p = parse(value);
|
||||
return p ? `${toFa(p.d)} ${JALALI_MONTHS[p.m - 1]} ${toFa(p.y)}` : '';
|
||||
}
|
||||
|
||||
/** سنِ کامل از تاریخِ تولد؛ برای کاربرِ قدیمی که فقط سال داده، تقریبی از سال. */
|
||||
export function ageOf(birthDate: string | null, birthYear: number | null): number | null {
|
||||
const today = todayJalali();
|
||||
const born = parse(birthDate);
|
||||
if (born) return today.y - born.y - (today.m < born.m || (today.m === born.m && today.d < born.d) ? 1 : 0);
|
||||
return birthYear ? today.y - birthYear : null;
|
||||
}
|
||||
|
||||
export function BirthDatePicker({
|
||||
value,
|
||||
fallbackYear = null,
|
||||
onChange,
|
||||
}: {
|
||||
value: string | null;
|
||||
/** سالِ تولدِ کاربرِ قدیمی (بیتاریخ) تا فهرستِ سال خالی باز نشود. */
|
||||
fallbackYear?: number | null;
|
||||
/** فقط وقتی هر سه انتخاب شدهاند تاریخ میدهد؛ تا آن موقع null. */
|
||||
onChange: (value: string | null) => void;
|
||||
}) {
|
||||
const initial = parse(value);
|
||||
const [y, setY] = useState<number | ''>(initial?.y ?? fallbackYear ?? '');
|
||||
const [m, setM] = useState<number | ''>(initial?.m ?? '');
|
||||
const [d, setD] = useState<number | ''>(initial?.d ?? '');
|
||||
// والد هر بار تابعِ تازه میسازد؛ با ref، تغییرِ تابع خودش onChange را دوباره صدا نمیزند.
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const thisYear = todayJalali().y;
|
||||
const years = Array.from({ length: MAX_AGE - MIN_AGE + 1 }, (_, i) => thisYear - MIN_AGE - i);
|
||||
const days = monthLength(typeof y === 'number' ? y : thisYear, typeof m === 'number' ? m : 1);
|
||||
|
||||
// سیویکِ مهر یا سیامِ اسفندِ غیرکبیسه با عوض شدنِ ماه/سال معتبر نمیماند.
|
||||
useEffect(() => {
|
||||
if (typeof d === 'number' && d > days) setD(days);
|
||||
}, [d, days]);
|
||||
|
||||
useEffect(() => {
|
||||
const complete = y !== '' && m !== '' && d !== '' && d <= days;
|
||||
onChangeRef.current(complete ? `${y}-${pad(m)}-${pad(d)}` : null);
|
||||
}, [y, m, d, days]);
|
||||
|
||||
const select = 'w-full appearance-none rounded-2xl border-2 bg-white px-3 py-3.5 text-center text-sm font-bold outline-none focus:border-coral';
|
||||
const tone = (v: number | '') => (v === '' ? 'border-line text-muted' : 'border-coral text-ink');
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_1.5fr_1.3fr] gap-2">
|
||||
<select aria-label="روز تولد" value={d} onChange={(e) => setD(e.target.value ? +e.target.value : '')} className={`${select} ${tone(d)}`}>
|
||||
<option value="">روز</option>
|
||||
{Array.from({ length: days }, (_, i) => i + 1).map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
|
||||
</select>
|
||||
<select aria-label="ماه تولد" value={m} onChange={(e) => setM(e.target.value ? +e.target.value : '')} className={`${select} ${tone(m)}`}>
|
||||
<option value="">ماه</option>
|
||||
{JALALI_MONTHS.map((name, i) => <option key={name} value={i + 1}>{name}</option>)}
|
||||
</select>
|
||||
<select aria-label="سال تولد" value={y} onChange={(e) => setY(e.target.value ? +e.target.value : '')} className={`${select} ${tone(y)}`}>
|
||||
<option value="">سال</option>
|
||||
{years.map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Brain, Crown, Home, Map, UserRound, type LucideIcon } from 'lucide-react';
|
||||
import { sounds } from '@/lib/sound';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
import { onWhite } from '@/lib/color';
|
||||
import { Brain, ShoppingBag, Home, BookOpen, UserRound, type LucideIcon } from 'lucide-react';
|
||||
import type { TabName } from '@/nav/routes';
|
||||
|
||||
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: Crown, 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,14 +23,18 @@ 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 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-[10px] font-bold">{label}</span>
|
||||
<span className="text-[11px] font-bold">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
13
src/components/BrandFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/BrandMark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/components/FeedbackBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
src/components/IslandPath.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { forwardRef, memo, useMemo } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Check, Lock, Play, Star } from 'lucide-react';
|
||||
import type { HomeNode } from '@/api';
|
||||
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { shade } from '@/lib/color';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { useFirstVisit } from '@/lib/firstVisit';
|
||||
import { SEYAR_SVG, type SeyarChar, type SeyarMood } from '@/mascots/seyar';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
|
||||
const NODE_SIZE = 76;
|
||||
const SPACING = 128;
|
||||
const TOP = 70;
|
||||
|
||||
/**
|
||||
* مسیرِ جزیرههای یک فصل، مثلِ دولینگو. جزیرهی فعلی یک دکمهی «شروع» بالای
|
||||
* سرش دارد که مستقیم آزمون را باز میکند؛ زدنِ خودِ جزیره برگهی جزئیات را.
|
||||
*
|
||||
* `currentRef` به جزیرهی فعلی وصل میشود تا خانه بتواند تا آنجا اسکرول کند.
|
||||
*/
|
||||
export const IslandPath = forwardRef<HTMLDivElement, {
|
||||
nodes: HomeNode[];
|
||||
color: string;
|
||||
/** جزیرهای که باید «شروع» بخورد؛ null یعنی در این فصل نیست. */
|
||||
currentId: string | null;
|
||||
onOpen: (node: HomeNode) => void;
|
||||
onStart: (node: HomeNode) => void;
|
||||
}>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) {
|
||||
const points = useMemo(() => zigzagPositions(nodes.length, SPACING, TOP), [nodes.length]);
|
||||
const intro = useFirstVisit(`islands:${nodes[0]?.id ?? ''}`);
|
||||
const height = points.length ? points[points.length - 1].y + 80 : 120;
|
||||
const { char } = useCompanion();
|
||||
const buddies = useMemo(() => buddySpots(nodes, points, currentId), [nodes, points, currentId]);
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
|
||||
<ZigzagPath points={points} height={height} colorOf={(i) => (nodes[i].status === 'completed' ? color : null)} />
|
||||
{buddies.map((b) => (
|
||||
<PathBuddy key={b.segment} char={char} {...b} />
|
||||
))}
|
||||
{nodes.map((node, i) => {
|
||||
const locked = node.status === 'locked';
|
||||
const done = node.status === 'completed';
|
||||
const current = node.id === currentId;
|
||||
const Icon = iconFor(node.icon, i);
|
||||
return (
|
||||
<motion.div
|
||||
key={node.id}
|
||||
ref={current ? currentRef : undefined}
|
||||
initial={intro ? { opacity: 0, scale: 0.7 } : { opacity: 1, scale: 1 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: Math.min(i, 8) * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ left: `${points[i].x}%`, top: points[i].y }}
|
||||
>
|
||||
{current && (
|
||||
<>
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => onStart(node)}
|
||||
animate={{ y: [0, -5, 0] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
|
||||
className="absolute -top-[62px] left-1/2 z-20 flex -translate-x-1/2 items-center gap-1.5 whitespace-nowrap rounded-2xl px-4 py-2 text-sm font-black text-white"
|
||||
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.25)}` }}
|
||||
>
|
||||
<Play size={14} fill="white" /> {node.status === 'in_progress' ? 'ادامه' : 'شروع'}
|
||||
<span className="absolute -bottom-1.5 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45" style={{ backgroundColor: color }} />
|
||||
</motion.button>
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.06, 0.3] }}
|
||||
transition={{ duration: 1.8, repeat: Infinity }}
|
||||
className="absolute inset-[-12px] rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${node.title}${locked ? ' (قفل)' : done ? ' (تمامشده)' : ''}`}
|
||||
onClick={() => onOpen(node)}
|
||||
className="relative z-10 flex items-center justify-center rounded-full transition-transform active:translate-y-1"
|
||||
style={{
|
||||
width: NODE_SIZE,
|
||||
height: NODE_SIZE,
|
||||
backgroundColor: locked ? '#e7e6ef' : done ? color : '#fff',
|
||||
border: current ? `5px solid ${color}` : 'none',
|
||||
boxShadow: locked ? '0 6px 0 #cfcedb' : done ? `0 7px 0 ${shade(color, -0.25)}` : `0 7px 0 ${shade(color, -0.25)}`,
|
||||
}}
|
||||
>
|
||||
{locked ? (
|
||||
<Lock size={24} strokeWidth={2.5} className="text-[#a9a8b8]" />
|
||||
) : done ? (
|
||||
<Check size={32} color="white" strokeWidth={3.5} />
|
||||
) : (
|
||||
<Icon size={30} style={{ color }} strokeWidth={2.5} />
|
||||
)}
|
||||
{done && (
|
||||
<Star size={16} className="absolute -right-1 -top-1 text-[#FFC53D]" fill="#FFC53D" />
|
||||
)}
|
||||
</button>
|
||||
{/* اسم کنارِ جزیره، نه زیرش: فضای بینِ دو جزیره مالِ دکمهی «شروع» است
|
||||
و اسمِ زیرِ جزیرهی قبلی زیرِ آن دکمه پنهان میشد. سمتی که جای بیشتری دارد. */}
|
||||
<div
|
||||
className={`absolute top-1/2 w-28 -translate-y-1/2 text-[11px] font-bold leading-4 ${
|
||||
points[i].x >= 50 ? 'right-full mr-3 text-right' : 'left-full ml-3 text-left'
|
||||
} ${current ? 'text-[12px] text-ink' : locked ? 'text-faint' : 'text-muted'}`}
|
||||
>
|
||||
<span className="line-clamp-3"><MathText text={node.title} /></span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface BuddySpot {
|
||||
segment: number;
|
||||
x: number;
|
||||
y: number;
|
||||
mood: SeyarMood;
|
||||
current: boolean;
|
||||
dim: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* جای همراههای کنارِ مسیر: وسطِ یک پیچ (بینِ دو جزیره)، در سمتی که مسیر از آن دور است. در آن ارتفاع نه اسمِ جزیرهای
|
||||
* هست (اسمها همترازِ خودِ جزیرهاند، ±۲۴ پیکسل) نه دکمهی «شروع» (بالای جزیرهی فعلی). همراهِ اصلی زیرِ جزیرهی فعلی
|
||||
* تشویق میکند؛ بقیه هر سه پیچ یکی، تا مسیر شلوغ نشود. فاصلهها با SPACING = ۱۲۸ و اندازهی ≤ ۶۴ سنجیده شدهاند، نه با
|
||||
* عرضِ صفحهی کمتر از ۳۲۰ پیکسل.
|
||||
*/
|
||||
function buddySpots(nodes: HomeNode[], points: { x: number; y: number }[], currentId: string | null): BuddySpot[] {
|
||||
if (points.length < 2) return [];
|
||||
const currentIndex = nodes.findIndex((n) => n.id === currentId);
|
||||
const mainSegment = currentIndex < 0 ? -1 : Math.min(currentIndex, points.length - 2);
|
||||
const spots: BuddySpot[] = [];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const main = i === mainSegment;
|
||||
if (!main && (i % 3 !== 1 || Math.abs(i - mainSegment) <= 1)) continue;
|
||||
const midX = (points[i].x + points[i + 1].x) / 2;
|
||||
const x = Math.min(84, Math.max(16, midX >= 50 ? midX - 34 : midX + 34));
|
||||
const locked = nodes[i + 1].status === 'locked' && !main;
|
||||
spots.push({
|
||||
segment: i,
|
||||
x,
|
||||
y: (points[i].y + points[i + 1].y) / 2,
|
||||
mood: main ? 'excited' : locked ? 'idle' : 'happy',
|
||||
current: main,
|
||||
dim: locked,
|
||||
});
|
||||
}
|
||||
return spots;
|
||||
}
|
||||
|
||||
/** همراهِ انتخابیِ کاربر کنارِ مسیر؛ فقط تزئین است و لمس را به جزیرهها رد میکند. */
|
||||
const PathBuddy = memo(function PathBuddy({ char, segment, x, y, mood, current, dim }: BuddySpot & { char: SeyarChar }) {
|
||||
const size = current ? 62 : 46;
|
||||
const svg = useMemo(() => SEYAR_SVG[char](mood), [char, mood]);
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ left: `${x}%`, top: y, opacity: dim ? 0.5 : 1 }}
|
||||
>
|
||||
{current && (
|
||||
<>
|
||||
<Sparkle className="spark absolute -right-2 top-0" />
|
||||
<Sparkle className="spark absolute -left-3 top-5" style={{ animationDelay: '0.8s' }} />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={current ? 'buddy-hop' : 'buddy-sway'}
|
||||
style={{ width: size, height: size, lineHeight: 0, animationDelay: `${(segment % 4) * 0.35}s` }}
|
||||
// SVG از seyar.ts ساخته میشود و هیچ ورودیِ کاربر در آن نیست.
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Sparkle({ className, style }: { className: string; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" className={className} style={style}>
|
||||
<path d="M12 0 L14.5 9.5 L24 12 L14.5 14.5 L12 24 L9.5 14.5 L0 12 L9.5 9.5 Z" fill="#FFC53D" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
110
src/components/IslandSheet.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { BookOpen, CheckCircle2, ChevronDown, Circle, Lock, Play, RotateCw } from 'lucide-react';
|
||||
import { api, type HomeNode } from '@/api';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { ErrorState, PrimaryButton } from '@/components/ui';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
|
||||
/**
|
||||
* برگهی جزیره. دو دکمه: «اول درسها رو بخون» (رنگی و بالا، چون تستکنندهها
|
||||
* دکمهی کمرنگِ قبلی را پیدا نمیکردند) و «شروعِ آزمونِ جزیره». فهرستِ درسها زیرِ دکمهی اول باز میشود.
|
||||
*/
|
||||
export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
|
||||
const nav = useNav();
|
||||
const [showLessons, setShowLessons] = useState(false);
|
||||
const lessons = useAsync(
|
||||
() => (node && showLessons ? api.nodeLessons(node.id) : Promise.resolve(null)),
|
||||
[node?.id, showLessons],
|
||||
);
|
||||
const locked = node?.status === 'locked';
|
||||
const done = node?.status === 'completed';
|
||||
|
||||
const close = () => {
|
||||
setShowLessons(false);
|
||||
onClose();
|
||||
};
|
||||
const start = () => {
|
||||
if (!node) return;
|
||||
close();
|
||||
nav.push({ name: 'quiz', nodeId: node.id });
|
||||
};
|
||||
const openLesson = (lessonId: number) => {
|
||||
if (!node) return;
|
||||
close();
|
||||
nav.push({ name: 'lesson', lessonId, nodeId: node.id });
|
||||
};
|
||||
|
||||
// فقط درسهایی که سوال دارند در lessonIds هستند.
|
||||
const playable = new Set(node?.lessonIds ?? []);
|
||||
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
|
||||
|
||||
return (
|
||||
<Sheet open={node !== null} onClose={close} title={node ? <MathText text={node.title} /> : undefined}>
|
||||
{locked ? (
|
||||
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
|
||||
<Lock size={24} className="text-muted" />
|
||||
</span>
|
||||
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره قبلی رو تموم کن تا باز بشه.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-sm leading-7 text-body">
|
||||
{done
|
||||
? 'این جزیره رو تموم کردی! میتونی دوباره تمرینش کنی (امتیاز تازه نداره).'
|
||||
: 'اگه مطمئن نیستی، اول درسها رو بخون؛ بعد آزمون کوتاه جزیره رو بده. هر جوابی بدی، آخرش جزیره بعدی باز میشه.'}
|
||||
</p>
|
||||
|
||||
<PrimaryButton color={color} onClick={start}>
|
||||
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروع آزمون جزیره</>}
|
||||
</PrimaryButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLessons((s) => !s)}
|
||||
className="mt-3 mb-1 flex w-full items-center justify-center gap-2 rounded-2xl border-2 py-3 text-sm font-black transition-colors active:translate-y-[2px]"
|
||||
style={{ borderColor: '#39FF14', color: '#15803d', backgroundColor: showLessons ? '#39FF1422' : 'white' }}
|
||||
aria-expanded={showLessons}
|
||||
>
|
||||
<BookOpen size={18} /> {done ? 'مرور درسها' : 'اول درسها رو بخون'}
|
||||
<ChevronDown size={16} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showLessons && (
|
||||
lessons.loading ? (
|
||||
<div className="py-4 text-center text-sm text-muted">دارم درسها رو میارم…</div>
|
||||
) : lessons.error ? (
|
||||
<ErrorState error={lessons.error} onRetry={lessons.reload} />
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 mt-1 text-xs text-muted">
|
||||
{toFa(list.filter((l) => l.done).length)} از {toFa(list.length)} درس خونده شده
|
||||
</p>
|
||||
<ul className="flex max-h-60 flex-col gap-1.5 overflow-y-auto">
|
||||
{list.map((lesson) => (
|
||||
<li key={lesson.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLesson(Number(lesson.id))}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-line bg-white px-3 py-2.5 text-right text-sm"
|
||||
>
|
||||
{lesson.done ? (
|
||||
<CheckCircle2 size={18} className="flex-shrink-0 text-mint" />
|
||||
) : (
|
||||
<Circle size={18} className="flex-shrink-0 text-faint" />
|
||||
)}
|
||||
<span className={`flex-1 ${lesson.done ? 'text-muted' : 'text-ink'}`}><MathText text={lesson.title} /></span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
222
src/components/NoHeartsScreen.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Brain, Heart, HeartCrack, Infinity as InfinityIcon, Crown, X } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { PrimaryButton, Screen } from '@/components/ui';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
|
||||
/**
|
||||
* «قلبهات تموم شد»: با قلبِ صفر سرور هیچ جوابی نمیپذیرد (۴۰۳ با code=no_hearts) و پایانِ درس یا
|
||||
* جزیره را هم بیجواب نمیپذیرد. این صفحه شمارشِ معکوسِ قلبِ بعدی را زنده نشان میدهد و وقتی رسید،
|
||||
* خودش از سرور میپرسد و دکمهی «ادامه بده» را میآورد.
|
||||
*/
|
||||
|
||||
/** همان HEART_REFILL_INTERVALِ سرور (progress/models.py)؛ فقط برای پرشدنِ نوار و قلبِ اول. */
|
||||
const REFILL_MS = 5 * 60 * 60 * 1000;
|
||||
const MAX_HEARTS = 5;
|
||||
|
||||
function useNow(): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
return now;
|
||||
}
|
||||
|
||||
/** «۴:۰۷:۳۲» */
|
||||
function clock(ms: number): string {
|
||||
const s = Math.max(0, Math.ceil(ms / 1000));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return toFa(`${h}:${pad(m)}:${pad(s % 60)}`);
|
||||
}
|
||||
|
||||
/** «ساعتِ ۱۸:۳۰ امروز» / «… فردا» / «… شنبه» */
|
||||
function whenLabel(iso: string, now: number): string {
|
||||
const at = new Date(iso);
|
||||
const time = new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit', hour12: false }).format(at);
|
||||
const dayOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const days = Math.round((dayOf(at) - dayOf(new Date(now))) / 86_400_000);
|
||||
const day = days <= 0 ? 'امروز' : days === 1 ? 'فردا' : new Intl.DateTimeFormat('fa-IR', { weekday: 'long' }).format(at);
|
||||
return `ساعت ${time} ${day}`;
|
||||
}
|
||||
|
||||
/** سرور هر بار regen را خودش حساب میکند؛ پرسیدنِ هر ۱۵ ثانیه برای یک صفحهی منتظر بار نیست. */
|
||||
const DUE_RECHECK_MS = 15_000;
|
||||
|
||||
export function NoHeartsScreen({
|
||||
nextRefillAt,
|
||||
fullRefillAt,
|
||||
onRefilled,
|
||||
}: {
|
||||
nextRefillAt: string | null;
|
||||
fullRefillAt: string | null;
|
||||
/** قلب برگشت و کاربر «ادامه بده» زد. */
|
||||
onRefilled: () => void;
|
||||
}) {
|
||||
const companionName = useCompanion().name;
|
||||
const nav = useNav();
|
||||
const { companion } = useAppData();
|
||||
const now = useNow();
|
||||
const [next, setNext] = useState(nextRefillAt);
|
||||
const [full, setFull] = useState(fullRefillAt);
|
||||
const [back, setBack] = useState(false);
|
||||
|
||||
const left = next ? Date.parse(next) - now : null;
|
||||
const progress = left === null ? 0 : Math.min(1, Math.max(0, 1 - left / REFILL_MS));
|
||||
const due = left !== null && left <= 0;
|
||||
|
||||
// زمانِ قلبِ بعدی رسید: از سرور میپرسیم (ساعتِ گوشی ممکن است جلو باشد).
|
||||
useEffect(() => {
|
||||
if (!due || back) return;
|
||||
let alive = true;
|
||||
const check = () =>
|
||||
api.hearts().then((h) => {
|
||||
if (!alive) return;
|
||||
if (h.isUnlimited || h.currentHearts > 0) setBack(true);
|
||||
else {
|
||||
setNext(h.nextRefillAt);
|
||||
setFull(h.fullRefillAt);
|
||||
}
|
||||
}).catch((err: unknown) => console.warn('hearts check failed', err));
|
||||
void check();
|
||||
// ساعتِ گوشیِ جلوتر از سرور: سرور هنوز «صفر» و همان nextRefillAt را میدهد و `due` عوض نمیشود؛ بیتکرار،
|
||||
// صفحه روی شمارشِ صفر میماند تا کاربر بیرون برود. پس تا برگشتنِ قلب با فاصله دوباره میپرسیم.
|
||||
const timer = setInterval(() => void check(), DUE_RECHECK_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [due, back]);
|
||||
|
||||
return (
|
||||
<Screen className="bg-gradient-to-b from-[#FFF1EE] via-page to-page">
|
||||
<div className="flex justify-start px-5 pt-safe">
|
||||
<button type="button" aria-label="برگشت" onClick={nav.back} className="mt-5 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow-sm">
|
||||
<X size={16} className="text-coral" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center gap-5 overflow-y-auto px-6 pb-8 pt-2 text-center [&>*]:shrink-0">
|
||||
<motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="relative">
|
||||
<div className="rounded-full bg-white p-4 shadow-[0_8px_30px_rgba(237,105,86,0.18)]">
|
||||
<SeyarMascot char={companion} mood={back ? 'excited' : 'depressed'} size={104} />
|
||||
</div>
|
||||
<motion.span
|
||||
className="absolute -left-2 -top-1 flex h-11 w-11 items-center justify-center rounded-full bg-white shadow"
|
||||
animate={back ? { scale: [1, 1.25, 1] } : { rotate: [0, -8, 8, 0] }}
|
||||
transition={{ duration: back ? 0.6 : 2.4, repeat: back ? 0 : Infinity }}
|
||||
>
|
||||
{back ? <Heart size={24} className="text-coral" fill="#ED6956" /> : <HeartCrack size={24} className="text-coral" />}
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-ink">{back ? 'یه قلب برگشت!' : 'قلبهات تموم شد'}</h1>
|
||||
<p className="mt-1.5 text-sm leading-7 text-muted">
|
||||
{back ? 'بریم ادامه بدیم؛ این بار با دقت بیشتر' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HeartSlots progress={back ? 1 : progress} />
|
||||
|
||||
{back ? (
|
||||
<div className="w-full">
|
||||
<PrimaryButton onClick={onRefilled}>ادامه بده</PrimaryButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full rounded-3xl border border-line bg-white p-5 shadow-sm">
|
||||
<div className="text-xs font-bold text-muted">قلب بعدی</div>
|
||||
{left !== null ? (
|
||||
<>
|
||||
<div dir="ltr" className="mt-1 text-4xl font-black tracking-wide text-coral tabular-nums">{clock(left)}</div>
|
||||
<div className="mt-4 h-2.5 overflow-hidden rounded-full bg-soft">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-l from-coral to-[#FF9A7A]"
|
||||
animate={{ width: `${Math.max(progress * 100, 3)}%` }}
|
||||
transition={{ ease: 'linear', duration: 1 }}
|
||||
/>
|
||||
</div>
|
||||
{full && (
|
||||
<p className="mt-3 text-xs leading-6 text-muted">
|
||||
همه {toFa(MAX_HEARTS)} قلب: <b className="text-body">{whenLabel(full, now)}</b>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-body">کمی صبر کن تا قلبهات دوباره پر بشن.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!back && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nav.switchTab('subscription')}
|
||||
className="relative w-full overflow-hidden rounded-3xl bg-gradient-to-l from-coral to-orange p-5 text-right text-white shadow-lg shadow-coral/30 active:scale-[0.99]"
|
||||
>
|
||||
<span className="absolute -left-6 -top-6 h-24 w-24 rounded-full bg-white/10" />
|
||||
<span className="flex items-center gap-2 text-base font-black">
|
||||
<Heart size={18} fill="#fff" /> <InfinityIcon size={18} /> نمیخوای منتظر بمونی؟
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-6 text-white/90">
|
||||
با اشتراک، قلبهات نامحدود میشه؛ هر چقدر بخوای تمرین کن.
|
||||
</span>
|
||||
<span className="mt-3 inline-flex items-center gap-1.5 rounded-2xl bg-white px-4 py-2 text-sm font-black text-coral">
|
||||
<Crown size={15} /> دیدن اشتراکها
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nav.switchTab('ask')}
|
||||
className="flex w-full items-center gap-3 rounded-3xl border border-line bg-white p-4 text-right active:scale-[0.99]"
|
||||
>
|
||||
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl bg-[#F5F1FF] text-violet">
|
||||
<Brain size={20} />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
<span className="block text-sm font-black text-ink">تا اون موقع از «{companionName}» بپرس</span>
|
||||
<span className="block text-xs leading-6 text-muted">جایی که گیر کردی رو با هم مرور کنیم؛ قلب خرج نمیکنه.</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" onClick={nav.back} className="py-1 text-sm font-bold text-muted">
|
||||
باشه، بعداً
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
/** پنج جای قلب؛ اولی همراهِ زمان از پایین پر میشود. */
|
||||
function HeartSlots({ progress }: { progress: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5" aria-label={`قلب بعدی ${toFa(Math.round(progress * 100))} درصد پر شده`}>
|
||||
{Array.from({ length: MAX_HEARTS }, (_, i) => (
|
||||
<span key={i} className="relative h-8 w-8">
|
||||
<Heart size={32} className="absolute inset-0 text-[#F2DCD8]" fill="#FBEDEA" />
|
||||
{i === 0 && (
|
||||
<motion.span
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ clipPath: `inset(${(1 - progress) * 100}% 0 0 0)` }}
|
||||
animate={progress < 1 ? { opacity: [0.75, 1, 0.75] } : { scale: [1, 1.3, 1] }}
|
||||
transition={{ duration: progress < 1 ? 1.6 : 0.5, repeat: progress < 1 ? Infinity : 0 }}
|
||||
>
|
||||
<Heart size={32} className="text-coral" fill="#ED6956" />
|
||||
</motion.span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useId, useRef, type ReactNode } from 'react';
|
||||
import { AnimatePresence, motion, useDragControls } from 'motion/react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useBackStep } from '@/nav/Navigator';
|
||||
|
||||
/** برگهای که از پایین بالا میآید؛ با زدنِ پسزمینه یا Escape بسته میشود. */
|
||||
/**
|
||||
* برگهای که از پایین بالا میآید؛ با زدنِ پسزمینه، Escape، یا کشیدنِ دستگیره/سرِ برگه به پایین بسته میشود.
|
||||
* کشیدن فقط از بالای برگه شروع میشود تا با اسکرولِ محتوای بلندِ برگه قاطی نشود.
|
||||
*/
|
||||
export function Sheet({
|
||||
open,
|
||||
onClose,
|
||||
@@ -14,38 +18,71 @@ export function Sheet({
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const drag = useDragControls();
|
||||
const titleId = useId();
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
// فوکوس با باز شدن به برگه میرود (صفحهخوان و صفحهکلید از همانجا ادامه میدهند) و با بسته شدن به دکمهای
|
||||
// که بازش کرد برمیگردد. ورودیای که خودش autoFocus دارد فوکوسش را نگه میدارد.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const t = setTimeout(() => {
|
||||
if (!panel.current?.contains(document.activeElement)) panel.current?.focus({ preventScroll: true });
|
||||
}, 0);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
if (opener?.isConnected) opener.focus({ preventScroll: true });
|
||||
};
|
||||
}, [open]);
|
||||
// دکمهی برگشتِ گوشی اول برگه را میبندد، نه صفحهی زیرش را.
|
||||
useBackStep(open, onClose);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col justify-end">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-[#14142a]/45"
|
||||
className="absolute inset-0 bg-[#14142a]/55"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
ref={panel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="relative max-h-[85%] overflow-y-auto rounded-t-[28px] bg-white px-5 pt-3"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
// Escape فقط برگهی رویی را میبندد (فوکوس داخلِ همان است)؛ قبلاً هر برگهی باز روی window گوش میداد
|
||||
// و یک Escape برگهی زیرین را هم میبست.
|
||||
tabIndex={-1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
className="relative max-h-[85%] overflow-y-auto rounded-t-[28px] bg-white px-5 pt-3 outline-none"
|
||||
style={{ paddingBottom: 'calc(2rem + env(safe-area-inset-bottom))' }}
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
|
||||
drag="y"
|
||||
dragControls={drag}
|
||||
dragListener={false}
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.9 }}
|
||||
onDragEnd={(_, info) => {
|
||||
if (info.offset.y > 90 || info.velocity.y > 600) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-soft" />
|
||||
<div className="-mx-5 -mt-3 cursor-grab touch-none px-5 pb-3 pt-3" onPointerDown={(e) => drag.start(e)}>
|
||||
<div className="mx-auto h-1.5 w-10 rounded-full bg-line" />
|
||||
</div>
|
||||
{title && (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-black text-ink">{title}</h2>
|
||||
<div className="mb-4 flex touch-none items-center justify-between gap-3" onPointerDown={(e) => drag.start(e)}>
|
||||
<h2 id={titleId} className="text-base font-black text-ink">{title}</h2>
|
||||
<button type="button" aria-label="بستن" onClick={onClose} className="rounded-full bg-soft p-1.5 text-muted">
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
127
src/components/StreakFlame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/SubjectBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react';
|
||||
import { useEffect, useState, type ButtonHTMLAttributes, type CSSProperties, type ReactNode } from 'react';
|
||||
import { ChevronRight, RotateCw } from 'lucide-react';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { onWhite, shade } from '@/lib/color';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
|
||||
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -13,6 +15,7 @@ export function ScreenHeader({
|
||||
color,
|
||||
back = false,
|
||||
mascot,
|
||||
exact = false,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
@@ -20,20 +23,29 @@ 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 }}
|
||||
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 && (
|
||||
{back && (typeof back === 'function' || nav.canGoBack) && (
|
||||
<IconButton label="بازگشت" onClick={onBack} className="mb-3 bg-white/15">
|
||||
<ChevronRight size={18} strokeWidth={2.5} />
|
||||
</IconButton>
|
||||
@@ -43,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>
|
||||
@@ -85,8 +97,8 @@ export function PrimaryButton({
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || loading}
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-transform active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100 ${className}`}
|
||||
style={{ backgroundColor: color, boxShadow: `0 10px 24px -8px ${color}88`, ...style }}
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-[transform,box-shadow] active:translate-y-[3px] active:shadow-none disabled:opacity-50 disabled:active:translate-y-0 ${className}`}
|
||||
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.22)}`, ...style }}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? <Spinner /> : children}
|
||||
@@ -98,7 +110,7 @@ export function SecondaryButton({ className = '', children, ...rest }: ButtonHTM
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:scale-[0.98] disabled:opacity-50 ${className}`}
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-b-4 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:translate-y-[2px] active:border-b-2 disabled:opacity-50 ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
@@ -110,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 }}
|
||||
/>
|
||||
@@ -119,23 +131,35 @@ export function Spinner({ size = 20, className = '' }: { size?: number; classNam
|
||||
|
||||
/* ─── حالتهای بارگذاری / خطا / خالی ───────────────────────────────────────── */
|
||||
|
||||
function StateBox({ char, mood, text, children }: { char: SeyarChar; mood: SeyarMood; text: ReactNode; children?: ReactNode }) {
|
||||
function StateBox({ char, mood, text, children }: { char?: SeyarChar; mood: SeyarMood; text: ReactNode; children?: ReactNode }) {
|
||||
const companion = useCompanion().char;
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'sigma' as SeyarChar }) {
|
||||
return <StateBox char={char} mood="idle" text={text} />;
|
||||
/**
|
||||
* لودر فقط بعد از `delayMs` دیده میشود. پاسخِ API معمولاً زیرِ ۳۰۰ms میرسد و نشان دادنِ لودر برای
|
||||
* همان چند فریم، پرشِ ریزِ «لودر ← محتوا» میساخت؛ حالا تا آن لحظه جای خالی میماند. اگر پاسخ
|
||||
* کند باشد کاربر باز هم لودر را میبیند. در مرورگر با شبکهی کند آزموده نشده.
|
||||
*/
|
||||
export function LoadingState({ text = 'یه لحظه صبر کن…', char, delayMs = 300 }: { text?: string; char?: SeyarChar; delayMs?: number }) {
|
||||
const [show, setShow] = useState(delayMs <= 0);
|
||||
useEffect(() => {
|
||||
if (delayMs <= 0) return;
|
||||
const t = setTimeout(() => setShow(true), delayMs);
|
||||
return () => clearTimeout(t);
|
||||
}, [delayMs]);
|
||||
return show ? <StateBox char={char} mood="idle" text={text} /> : null;
|
||||
}
|
||||
|
||||
export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?: () => void; text?: string }) {
|
||||
return (
|
||||
<StateBox char="pi" mood="sad" text={text ?? friendlyError(error)}>
|
||||
<StateBox mood="sad" text={text ?? friendlyError(error)}>
|
||||
{onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -149,10 +173,18 @@ export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ text, char = 'pi' as SeyarChar }: { text: string; char?: SeyarChar }) {
|
||||
export function EmptyState({ text, char }: { text: string; char?: SeyarChar }) {
|
||||
return <StateBox char={char} mood="idle" text={text} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* جداکنندهی میانِ دو تکهی متن. «·» کنارِ صفرِ فارسی (که خودش یک نقطه است)
|
||||
* خوانده نمیشد: «فصلِ ۱ · ۰ از ۴» شبیهِ «۱۰۰» دیده میشد.
|
||||
*/
|
||||
export function Sep() {
|
||||
return <span aria-hidden className="mx-1.5 inline-block h-2.5 w-px bg-current align-middle opacity-40" />;
|
||||
}
|
||||
|
||||
export function InlineError({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-xs leading-6 text-red-600">
|
||||
|
||||
41
src/dev/DiagramGallery.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Diagram } from '@/diagram';
|
||||
import type { DiagramSpec } from '@/diagram/types';
|
||||
import { SAMPLES } from './diagramSamples';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
|
||||
const TEXTS = [
|
||||
'۵) پس وقتی از ۲ سه واحد به چپ برویم، به عدد -۱ میرسیم.',
|
||||
'✅ جواب: رأس سهمی در (1, -1) است.',
|
||||
'۴) ۲ - ۳ = -۱.',
|
||||
'مجموع زاویههای داخلی = (5 - 2) × 180 = 3 × 180 = 540 درجه',
|
||||
'بازه [-2, 3) یعنی از −۲ تا ۳، خود ۳ نه.',
|
||||
'sin 30° = 0.5 و cos 30° ≈ 0.87 است.',
|
||||
'در کتاب ۲/۵ یعنی دو و نیم (اختیاری).',
|
||||
];
|
||||
|
||||
/**
|
||||
* گالریِ شکلها، فقط در حالتِ توسعه: http://localhost:5173/?gallery
|
||||
* برای وارسیِ چشمی بعد از هر تغییر در src/diagram. اگر window.__GALLERY__ ست شده
|
||||
* باشد (تستِ خودکار با خروجیِ واقعیِ مدل)، همانها را نشان میدهد.
|
||||
*/
|
||||
export function DiagramGallery() {
|
||||
const injected = (window as unknown as { __GALLERY__?: { title: string; spec: DiagramSpec }[] }).__GALLERY__;
|
||||
const items = injected ?? SAMPLES;
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-white p-3">
|
||||
{!injected && (
|
||||
<div className="mx-auto mb-3 max-w-[420px] space-y-1.5 rounded-2xl border border-line p-3 text-sm leading-7" data-texts>
|
||||
{TEXTS.map((t) => <p key={t} className="rounded-xl bg-soft px-3 py-1.5"><MathText text={t} /></p>)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mx-auto grid max-w-[1200px] grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((it, i) => (
|
||||
<section key={i} data-sample={i} className="rounded-2xl border border-line p-2">
|
||||
<h2 className="mb-1 text-xs font-black text-body">{i + 1}. {it.title}</h2>
|
||||
<Diagram spec={it.spec} />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
247
src/dev/diagramSamples.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { DiagramSpec } from '@/diagram/types';
|
||||
|
||||
/** نمونههای دستنویس از هر نوع شکل، از روی مسئلههای واقعیِ کتابها. فقط برای گالری. */
|
||||
export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
{
|
||||
title: 'یازدهم — سهمی با ریشه، رأس، محور تقارن و عرض از مبدأ',
|
||||
spec: { kind: 'plane', title: 'y = x² − 2x − 3', axes: true, points: {}, items: [
|
||||
{ type: 'function', expr: 'x^2-2x-3', from: -2.5, to: 4.5, label: 'y = x² − 2x − 3', marks: ['roots', 'vertex', 'axis', 'yIntercept'] },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دهم — دایره مثلثاتی، سینوس و کسینوس ۳۰°',
|
||||
spec: { kind: 'plane', title: 'زاویه ۳۰ درجه', axes: true, points: { O: [0, 0], A: [1, 0], P: { polar: [1, 30] }, H: { foot: ['P', 'O', 'A'] } }, items: [
|
||||
{ type: 'circle', center: 'O', r: 1 },
|
||||
{ type: 'segment', from: 'O', to: 'P' },
|
||||
{ type: 'segment', from: 'P', to: 'H', dashed: true, color: 'green', label: 'sin 30° = 0.5' },
|
||||
{ type: 'segment', from: 'O', to: 'H', color: 'orange', label: 'cos 30°' },
|
||||
{ type: 'angle', vertex: 'O', from: 'A', to: 'P', label: '30°' },
|
||||
{ type: 'rightAngle', vertex: 'H', from: 'O', to: 'P' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'نهم — مثلث قائمالزاویه، فیثاغورس',
|
||||
spec: { kind: 'plane', title: 'وتر = ۵', axes: false, points: { A: [0, 0], B: [4, 0], C: [0, 3] }, items: [
|
||||
{ type: 'polygon', points: ['A', 'B', 'C'] },
|
||||
{ type: 'rightAngle', vertex: 'A', from: 'B', to: 'C' },
|
||||
{ type: 'segment', from: 'A', to: 'B', label: '4' },
|
||||
{ type: 'segment', from: 'A', to: 'C', label: '3' },
|
||||
{ type: 'segment', from: 'B', to: 'C', label: '?', color: 'red' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'هشتم — خطوط موازی و مورب، زاویههای مساوی',
|
||||
spec: { kind: 'plane', title: 'a ∥ b و d مورب', axes: false, points: { _a1: [0, 2], _a2: [6, 2], _b1: [0, 0], _b2: [6, 0], _d1: [1.2, -1], _d2: [4.8, 3], P: { intersect: [['_a1', '_a2'], ['_d1', '_d2']] }, Q: { intersect: [['_b1', '_b2'], ['_d1', '_d2']] } }, items: [
|
||||
{ type: 'line', through: ['_a1', '_a2'], label: 'a' },
|
||||
{ type: 'line', through: ['_b1', '_b2'], label: 'b' },
|
||||
{ type: 'line', through: ['_d1', '_d2'], color: 'purple', label: 'd' },
|
||||
{ type: 'angle', vertex: 'P', from: '_a2', to: '_d2', label: 'Â1', arcs: 1 },
|
||||
{ type: 'angle', vertex: 'Q', from: '_b2', to: '_d2', label: 'B̂1', arcs: 1 },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'هشتم — زاویه محاطی و مرکزی روبهروی یک کمان',
|
||||
spec: { kind: 'plane', title: 'زاویه محاطی نصف مرکزی است', axes: false, points: { O: [0, 0], A: { polar: [2, 200] }, B: { polar: [2, -20] }, C: { polar: [2, 100] } }, items: [
|
||||
{ type: 'circle', center: 'O', r: 2 },
|
||||
{ type: 'arc', center: 'O', r: 2, start: 200, end: 340, label: 'AB' },
|
||||
{ type: 'segment', from: 'C', to: 'A' }, { type: 'segment', from: 'C', to: 'B' },
|
||||
{ type: 'segment', from: 'O', to: 'A', color: 'blue' }, { type: 'segment', from: 'O', to: 'B', color: 'blue' },
|
||||
{ type: 'angle', vertex: 'C', from: 'A', to: 'B', label: '70°' },
|
||||
{ type: 'angle', vertex: 'O', from: 'A', to: 'B', label: '140°', color: 'blue' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'نهم — متوازیالاضلاع و وسط ضلعها، ضلعهای مساوی',
|
||||
spec: { kind: 'plane', title: 'M و N وسط ضلعها', axes: false, points: { A: [0, 0], B: [4, 0], C: [5, 2.5], D: [1, 2.5], M: { mid: ['A', 'B'] }, N: { mid: ['C', 'D'] } }, items: [
|
||||
{ type: 'polygon', points: ['A', 'B', 'C', 'D'] },
|
||||
{ type: 'segment', from: 'A', to: 'M', ticks: 1 }, { type: 'segment', from: 'M', to: 'B', ticks: 1 },
|
||||
{ type: 'segment', from: 'D', to: 'N', ticks: 2 }, { type: 'segment', from: 'N', to: 'C', ticks: 2 },
|
||||
{ type: 'segment', from: 'M', to: 'N', dashed: true, color: 'purple' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'هفتم — دوران ۹۰° مثلث حول C در جهت عقربهها، روی شبکه',
|
||||
spec: { kind: 'plane', title: 'دوران ۹۰°', axes: false, grid: true, points: { A: [1, 1], B: [4, 1], C: [1, 3] }, items: [
|
||||
{ type: 'polygon', points: ['A', 'B', 'C'] },
|
||||
{ type: 'image', of: ['A', 'B', 'C'], transform: { rotate: { center: 'C', deg: -90 } } },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'هشتم — بردار و جمع بردارها روی شبکه',
|
||||
spec: { kind: 'plane', title: 'AB + BC = AC', axes: false, grid: true, points: { A: [0, 0], B: [3, 1], C: [4, 4] }, items: [
|
||||
{ type: 'vector', from: 'A', to: 'B', label: 'a' },
|
||||
{ type: 'vector', from: 'B', to: 'C', label: 'b', color: 'blue' },
|
||||
{ type: 'vector', from: 'A', to: 'C', label: 'a + b', color: 'green' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'نهم — دو خط و محل برخورد',
|
||||
spec: { kind: 'plane', title: 'y = 2x − 1 و y = −x + 5', axes: true, points: { P: { intersect: [[[0, -1], [1, 1]], [[0, 5], [1, 4]]] } }, items: [
|
||||
{ type: 'function', expr: '2x-1', from: -1, to: 4, label: 'y = 2x − 1' },
|
||||
{ type: 'function', expr: '-x+5', from: -1, to: 6, label: 'y = −x + 5' },
|
||||
{ type: 'point', point: 'P', label: 'P (2, 3)' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'یازدهم — نمودار سینوس با مضربهای π',
|
||||
spec: { kind: 'plane', title: 'y = sin x', axes: true, trig: true, points: {}, items: [
|
||||
{ type: 'function', expr: 'sin(x)', from: -6.2832, to: 6.2832, label: 'y = sin x', marks: ['roots', 'extrema'] },
|
||||
{ type: 'hline', y: 0.5, color: 'orange', label: 'y = ½' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'یازدهم — تابع چندضابطهای و حد (نقطه توخالی)',
|
||||
spec: { kind: 'plane', title: 'حد چپ و راست در x = 1', axes: true, points: {}, items: [
|
||||
{ type: 'piecewise', pieces: [{ expr: 'x+1', from: -2, to: 1, closedFrom: true, closedTo: false }, { expr: '-x+4', from: 1, to: 4, closedFrom: true, closedTo: true }] },
|
||||
{ type: 'vline', x: 1, label: 'x = 1' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دوازدهم — خط مماس بر نمودار',
|
||||
spec: { kind: 'plane', title: 'مماس بر y = x² در x = 1', axes: true, points: {}, items: [
|
||||
{ type: 'function', expr: 'x^2', from: -2, to: 2.5, label: 'y = x²' },
|
||||
{ type: 'tangent', expr: 'x^2', at: 1, label: 'y = 2x − 1' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دوازدهم — مجانبها: y = 1/(x − 2) + 1',
|
||||
spec: { kind: 'plane', title: 'مجانب قائم و افقی', axes: true, xRange: [-2, 6], yRange: [-4, 6], points: {}, items: [
|
||||
{ type: 'function', expr: '1/(x-2)+1', from: -2, to: 6, label: 'y = 1/(x−2) + 1' },
|
||||
{ type: 'vline', x: 2, label: 'x = 2', color: 'red' },
|
||||
{ type: 'hline', y: 1, label: 'y = 1', color: 'red' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دوازدهم — اکسترممهای نسبی تابع درجه سوم',
|
||||
spec: { kind: 'plane', title: 'y = x³ − 3x', axes: true, points: {}, items: [
|
||||
{ type: 'function', expr: 'x^3-3x', from: -2.3, to: 2.3, label: 'y = x³ − 3x', marks: ['extrema', 'roots'] },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'یازدهم — تابع و وارونش، قرینه نسبت به y = x',
|
||||
spec: { kind: 'plane', title: 'f و f⁻¹', axes: true, xRange: [-1, 8], yRange: [-1, 8], points: {}, items: [
|
||||
{ type: 'function', expr: '2^x', from: -1, to: 3, label: 'y = 2ˣ' },
|
||||
{ type: 'function', expr: 'log(x)/log(2)', from: 0.3, to: 8, label: 'y = log₂x' },
|
||||
{ type: 'function', expr: 'x', from: -1, to: 8, dashed: true, color: 'gray', label: 'y = x' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دوازدهم — مساحت زیر نمودار',
|
||||
spec: { kind: 'plane', title: 'ناحیه بین y = √x و محور x', axes: true, points: {}, items: [
|
||||
{ type: 'area', expr: 'sqrt(x)', from: 0, to: 4, color: 'green', label: 'S' },
|
||||
{ type: 'function', expr: 'sqrt(x)', from: 0, to: 5, label: 'y = √x', color: 'green' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دهم — ناحیه جواب نامعادله',
|
||||
spec: { kind: 'plane', title: 'y > 2x − 1', axes: true, xRange: [-3, 3], yRange: [-4, 4], points: {}, items: [
|
||||
{ type: 'region', expr: '2x-1', side: 'above', strict: true, label: 'y > 2x − 1' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'هشتم — محور اعداد: از ۲ سه واحد به چپ',
|
||||
spec: { kind: 'numberline', title: '۲ − ۳ = −۱', min: -4, max: 4, points: [{ at: 2, label: 'شروع' }, { at: -1, label: 'پایان' }], jumps: [{ from: 2, to: -1, label: '۳ واحد به چپ' }] },
|
||||
},
|
||||
{
|
||||
title: 'دهم — بازهها روی محور',
|
||||
spec: { kind: 'numberline', title: '[−۲، ۳) و (۱، +∞)', min: -4, max: 5, points: [], jumps: [], intervals: [
|
||||
{ from: -2, to: 3, closedFrom: true, closedTo: false, label: '[−2, 3)' },
|
||||
{ from: 1, to: null, closedFrom: false, label: '(1, +∞)' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'ششم — کسرها روی محور',
|
||||
spec: { kind: 'numberline', title: 'سهچهارم', min: 0, max: 2, step: 0.25, fractions: true, points: [{ at: 0.75, label: 'سهچهارم' }], jumps: [] },
|
||||
},
|
||||
{
|
||||
title: 'ششم — کسر روی دایره',
|
||||
spec: { kind: 'fraction', title: 'سههشتم', shape: 'circle', parts: 8, shaded: 3 },
|
||||
},
|
||||
{
|
||||
title: 'ششم — ۲۰٪ از ۲۵ خانه',
|
||||
spec: { kind: 'grid', title: '۲۰ درصد', rows: 5, cols: 5, shaded: 5 },
|
||||
},
|
||||
{
|
||||
title: 'سوم — مدل ضرب: ۳ گروه ۴تایی',
|
||||
spec: { kind: 'groups', title: '۳ × ۴', groups: 3, each: 4, item: 'circle' },
|
||||
},
|
||||
{
|
||||
title: 'سوم — ساعت ۳ و ۲۵ دقیقه',
|
||||
spec: { kind: 'clock', title: 'ساعت ۳:۲۵', hour: 3, minute: 25 },
|
||||
},
|
||||
{
|
||||
title: 'سوم — نمودار ستونی آبمیوهها',
|
||||
spec: { kind: 'bar', title: 'آبمیوه مورد علاقه', labels: ['پرتقال', 'سیب', 'انگور', 'هلو'], values: [8, 5, 3, 4], unit: 'نفر' },
|
||||
},
|
||||
{
|
||||
title: 'هشتم — نمودار دایرهای',
|
||||
spec: { kind: 'pie', title: 'ورزش مورد علاقه', labels: ['فوتبال', 'والیبال', 'شنا', 'بقیه'], values: [12, 6, 4, 2] },
|
||||
},
|
||||
{
|
||||
title: 'دهم — دمای زمین بر حسب عمق (خطشکسته)',
|
||||
spec: { kind: 'linechart', title: 'دما (°C)', labels: ['1', '2', '3', '4', '5', '6'], values: [55, 90, 125, 160, 195, 230], unit: '°C' },
|
||||
},
|
||||
{
|
||||
title: 'دهم — جدول تعیین علامت',
|
||||
spec: { kind: 'table', title: 'y = (x + 1)(x − 2)', headers: ['x', '−∞', '−1', '', '2', '+∞'], rows: [['y', '+', '0', '−', '0', '+']] },
|
||||
},
|
||||
{
|
||||
title: 'دهم — نمودار ون: A ∩ B',
|
||||
spec: { kind: 'venn', title: 'A = {1,2,3,4} و B = {3,4,5}', sets: ['A', 'B'], regions: { A: ['1', '2'], AB: ['3', '4'], B: ['5'], out: ['6'] }, shade: ['AB'] },
|
||||
},
|
||||
{
|
||||
title: 'دهم — ون با سه مجموعه',
|
||||
spec: { kind: 'venn', title: 'A − (B ∪ C)', sets: ['A', 'B', 'C'], regions: { A: ['1'], B: ['2'], C: ['3'], AB: ['4'], ABC: ['5'] }, shade: ['A'] },
|
||||
},
|
||||
{
|
||||
title: 'دهم — نمودار پیکانی تابع',
|
||||
spec: { kind: 'mapping', title: 'f(x) = 3(x − 1)²', left: ['0', '0.5', '1'], right: ['3', '0.75', '0'], arrows: [[0, 0], [1, 1], [2, 2]], leftLabel: 'دامنه', rightLabel: 'برد' },
|
||||
},
|
||||
{
|
||||
title: 'نهم — نمودار درختی دو بار پرتاب سکه',
|
||||
spec: { kind: 'tree', title: '۴ حالت', root: { label: 'شروع', children: [
|
||||
{ label: 'رو', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] },
|
||||
{ label: 'پشت', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] },
|
||||
] } },
|
||||
},
|
||||
{ title: 'هفتم — مکعبمستطیل', spec: { kind: 'solid', title: 'حجم = ۵ × ۳ × ۴', shape: 'cuboid', dims: { a: 5, b: 3, c: 4 }, labels: { a: '5 cm', b: '3 cm', c: '4 cm' } } },
|
||||
{ title: 'هفتم — مکعب', spec: { kind: 'solid', title: 'مکعب', shape: 'cube', dims: { a: 3 }, labels: { a: 'a' } } },
|
||||
{ title: 'هفتم — منشور سهپهلو', spec: { kind: 'solid', title: 'منشور مثلثی', shape: 'prism', dims: { n: 3, r: 1.2, h: 2 }, labels: { h: 'h' } } },
|
||||
{ title: 'نهم — منشور ششپهلو', spec: { kind: 'solid', title: 'منشور ششضلعی', shape: 'prism', dims: { n: 6, r: 1.2, h: 1.6 }, labels: { h: '8 cm' } } },
|
||||
{ title: 'نهم — هرم مربعالقاعده با ارتفاع', spec: { kind: 'solid', title: 'هرم', shape: 'pyramid', dims: { n: 4, r: 1.3, h: 2 }, labels: { h: 'h', a: 'a' } } },
|
||||
{ title: 'نهم — هرم پنجپهلو', spec: { kind: 'solid', title: 'قاعده ABCDE', shape: 'pyramid', dims: { n: 5, r: 1.3, h: 2 }, labels: { h: 'OH' } } },
|
||||
{ title: 'نهم — استوانه', spec: { kind: 'solid', title: 'لیوان استوانهای', shape: 'cylinder', dims: { r: 6, h: 10 }, labels: { r: '6 cm', h: 'h' } } },
|
||||
{ title: 'نهم — مخروط', spec: { kind: 'solid', title: 'ظرف مخروطی', shape: 'cone', dims: { r: 4, h: 12 }, labels: { r: '4 cm', h: '12 cm' } } },
|
||||
{ title: 'نهم — کره', spec: { kind: 'solid', title: 'کره', shape: 'sphere', dims: { r: 1 }, labels: { r: 'r' } } },
|
||||
{ title: 'نهم — نیمکره', spec: { kind: 'solid', title: 'پیمانه نیمکره', shape: 'hemisphere', dims: { r: 12 }, labels: { r: '12 cm' } } },
|
||||
{ title: 'ششم — مقایسه یکپنجم و نهدهم', spec: { kind: 'fraction', title: 'کدام بزرگتر است؟', shape: 'rect', parts: 5, shaded: 1, compare: [{ parts: 5, shaded: 1, label: 'یکپنجم' }, { parts: 10, shaded: 9, label: 'نهدهم' }] } },
|
||||
{ title: 'ششم — دو و هفتدهم', spec: { kind: 'fraction', title: '۲٫۷', shape: 'circle', parts: 10, shaded: 7, wholes: 2 } },
|
||||
{ title: 'دهم — دایره و خط مماس از معادله', spec: { kind: 'plane', title: 'فاصله مرکز تا خط', axes: true, points: { O: [-1, 2] }, items: [
|
||||
{ type: 'line', eq: [3, 5, 4], label: '3x + 5y = 4' }, { type: 'circle', center: 'O', r: 0.5145, color: 'green' },
|
||||
] } },
|
||||
{ title: 'هشتم — زاویه خارجی پنجضلعی با امتداد ضلع', spec: { kind: 'plane', title: 'زاویه خارجی = ۷۲°', axes: false, points: { A: { polar: [2, 234] }, B: { polar: [2, 306] }, C: { polar: [2, 18] }, D: { polar: [2, 90] }, E: { polar: [2, 162] }, X: { along: ['A', 'B', 1.6] } }, items: [
|
||||
{ type: 'polygon', points: ['A', 'B', 'C', 'D', 'E'] }, { type: 'segment', from: 'B', to: 'X', dashed: true }, { type: 'angle', vertex: 'B', from: 'X', to: 'C', label: '72°' },
|
||||
] } },
|
||||
{ title: 'سوم — ضرب روی محور اعداد (تیکهای زیاد)', spec: { kind: 'numberline', title: '۲ × ۱۱', min: 0, max: 25, points: [{ at: 11, label: '' }, { at: 22, label: '' }], jumps: [{ from: 0, to: 11, label: '۱۱' }, { from: 11, to: 22, label: '۱۱' }] } },
|
||||
{ title: 'نهم — مثلث از روی داده: AB = 6، AC = 8، زاویه A قائمه', spec: { kind: 'plane', title: 'فیثاغورس', axes: false, points: {}, items: [
|
||||
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 6, AC: 8 }, angles: { A: 90 }, labels: { BC: 'x' } },
|
||||
] } },
|
||||
{ title: 'هشتم — مثلث با سه ضلع ۵، ۷، ۹ و ارتفاع از C', spec: { kind: 'plane', title: 'ارتفاع وارد بر AB', axes: false, points: {}, items: [
|
||||
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 9, BC: 7, CA: 5 }, altitude: 'C', labels: { h: 'h' } },
|
||||
] } },
|
||||
{ title: 'هفتم — مثلث با دو زاویه (۴۰° و ۷۵°)', spec: { kind: 'plane', title: 'زاویه سوم؟', axes: false, points: {}, items: [
|
||||
{ type: 'triangle', names: ['A', 'B', 'C'], angles: { A: 40, B: 75 }, labels: { C: '?' } },
|
||||
] } },
|
||||
{ title: 'منفرجه — ارتفاع بیرون مثلث', spec: { kind: 'plane', title: 'زاویه B = ۱۲۰°', axes: false, points: {}, items: [
|
||||
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 4, BC: 3 }, angles: { B: 120 }, altitude: 'C' },
|
||||
] } },
|
||||
{ title: 'ششم — مستطیل باغچه ۸ × ۵', spec: { kind: 'plane', title: 'باغچه', axes: false, points: {}, items: [{ type: 'quad', shape: 'rectangle', names: ['A', 'B', 'C', 'D'], w: 8, h: 5 }] } },
|
||||
{ title: 'مربع با ضلع ۴', spec: { kind: 'plane', title: 'مربع', axes: false, points: {}, items: [{ type: 'quad', shape: 'square', names: ['A', 'B', 'C', 'D'], side: 4 }] } },
|
||||
{ title: 'ششم — متوازیالاضلاع: قاعده ۷، ارتفاع ۴', spec: { kind: 'plane', title: 'مساحت = قاعده × ارتفاع', axes: false, points: {}, items: [{ type: 'quad', shape: 'parallelogram', names: ['A', 'B', 'C', 'D'], w: 7, h: 4 }] } },
|
||||
{ title: 'هفتم — لوزی با قطرهای ۸ و ۶', spec: { kind: 'plane', title: 'مساحت = نصف حاصلضرب قطرها', axes: false, points: {}, items: [{ type: 'quad', shape: 'rhombus', names: ['A', 'B', 'C', 'D'], d1: 8, d2: 6 }] } },
|
||||
{ title: 'هشتم — ذوزنقه: قاعدههای ۱۰ و ۶، ارتفاع ۴', spec: { kind: 'plane', title: 'ذوزنقه', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 10, top: 6, h: 4 }] } },
|
||||
{ title: 'ذوزنقه قائمالزاویه', spec: { kind: 'plane', title: 'ذوزنقه قائم', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 8, top: 5, h: 4, right: true }] } },
|
||||
{ title: 'هفتم — الگوی مربعی ۱، ۴، ۹ و شکل بعدی', spec: { kind: 'pattern', title: 'الگوی n²', layout: 'square', unit: 'square', stages: [1, 2, 3], next: true } },
|
||||
{ title: 'هفتم — الگوی چوبکبریت', spec: { kind: 'pattern', title: 'چوبکبریت: 3n + 1', layout: 'row', unit: 'stick', stages: [1, 2, 3, 4], next: true } },
|
||||
{ title: 'دهم — عددهای مثلثی با نقطه', spec: { kind: 'pattern', title: '۱، ۳، ۶، ۱۰', layout: 'triangle', unit: 'dot', stages: [1, 2, 3, 4] } },
|
||||
];
|
||||
101
src/diagram/charts.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { ChartSpec } from './types';
|
||||
import { COLORS, GRID, INK, Label, SOFT, bidi, fa, mathText, niceStep, seriesColors, ticksBetween } from './svg';
|
||||
|
||||
const W = 330;
|
||||
|
||||
/** نمودارِ ستونی، خطشکسته و دایرهای. درصدِ دایرهای را کد از روی مقدارها حساب میکند. */
|
||||
export function Chart({ spec }: { spec: ChartSpec }) {
|
||||
const n = Math.min(spec.labels.length, spec.values.length);
|
||||
if (!n) return null;
|
||||
const labels = spec.labels.slice(0, n), values = spec.values.slice(0, n);
|
||||
return spec.kind === 'pie' ? <Pie labels={labels} values={values} /> : <Bars kind={spec.kind} labels={labels} values={values} unit={spec.unit} />;
|
||||
}
|
||||
|
||||
function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; labels: string[]; values: number[]; unit?: string }) {
|
||||
const H = 230, left = 38, right = 12, top = 22, bottom = 44;
|
||||
const lo = Math.min(0, ...values), hi = Math.max(0, ...values);
|
||||
const step = niceStep(hi - lo || 1, 5);
|
||||
const y0 = Math.floor(lo / step) * step, y1 = Math.ceil(hi / step) * step || step;
|
||||
const plotW = W - left - right, plotH = H - top - bottom;
|
||||
const Y = (v: number) => top + ((y1 - v) / (y1 - y0)) * plotH;
|
||||
const slot = plotW / labels.length;
|
||||
const X = (i: number) => left + slot * (i + 0.5);
|
||||
const barColors = seriesColors(labels, values.length);
|
||||
const barW = Math.min(38, slot * 0.62);
|
||||
const long = labels.some((l) => l.length > 6);
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img">
|
||||
{ticksBetween(y0, y1, step).map((t) => (
|
||||
<g key={t}>
|
||||
<line x1={left} x2={W - right} y1={Y(t)} y2={Y(t)} stroke={Math.abs(t) < 1e-9 ? SOFT : GRID} strokeWidth={Math.abs(t) < 1e-9 ? 1.5 : 1} />
|
||||
<text x={left - 6} y={Y(t) + 3.5} fontSize={9.5} fill={SOFT} textAnchor="end">{fa(t)}</text>
|
||||
</g>
|
||||
))}
|
||||
{unit && <text x={left - 4} y={12} fontSize={9.5} fill={SOFT} textAnchor="end" {...bidi(mathText(unit))}>{mathText(unit)}</text>}
|
||||
{kind === 'bar'
|
||||
? values.map((v, i) => {
|
||||
const c = barColors[i];
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={X(i) - barW / 2} y={Math.min(Y(v), Y(0))} width={barW} height={Math.abs(Y(v) - Y(0))} rx={4} fill={c} />
|
||||
<Label x={X(i)} y={v >= 0 ? Y(v) - 5 : Y(v) + 13} color={INK} size={10.5}>{fa(v)}</Label>
|
||||
</g>
|
||||
);
|
||||
})
|
||||
: (
|
||||
<>
|
||||
<path d={values.map((v, i) => `${i ? 'L' : 'M'}${X(i)},${Y(v)}`).join(' ')} fill="none" stroke={COLORS.blue} strokeWidth={2.6} strokeLinejoin="round" />
|
||||
{values.map((v, i) => (
|
||||
<g key={i}>
|
||||
<circle cx={X(i)} cy={Y(v)} r={4.2} fill={COLORS.blue} stroke="#fff" strokeWidth={1.5} />
|
||||
<Label x={X(i)} y={Y(v) - 8} color={INK} size={10}>{fa(v)}</Label>
|
||||
</g>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{labels.map((l, i) => (
|
||||
<text key={i} x={X(i)} y={H - bottom + 16} fontSize={long ? 9 : 10.5} fontWeight={700} fill={INK} textAnchor={long ? 'end' : 'middle'}
|
||||
transform={long ? `rotate(-30 ${X(i)} ${H - bottom + 16})` : undefined} {...bidi(mathText(l))}>{mathText(l)}</text>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Pie({ labels, values }: { labels: string[]; values: number[] }) {
|
||||
const total = values.reduce((a, v) => a + Math.max(0, v), 0);
|
||||
if (total <= 0) return null;
|
||||
const sliceColors = seriesColors(labels, values.length);
|
||||
const cx = 105, cy = 105, r = 88;
|
||||
let a = -Math.PI / 2;
|
||||
const slices = values.map((v, i) => {
|
||||
const frac = Math.max(0, v) / total, a0 = a, a1 = a + frac * 2 * Math.PI;
|
||||
a = a1;
|
||||
return { i, frac, a0, a1, c: sliceColors[i] };
|
||||
});
|
||||
const H = Math.max(215, 24 + labels.length * 22);
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img">
|
||||
{slices.map((s) => {
|
||||
if (s.frac >= 0.9999) return <circle key={s.i} cx={cx} cy={cy} r={r} fill={s.c} stroke="#fff" strokeWidth={2} />;
|
||||
const large = s.a1 - s.a0 > Math.PI ? 1 : 0;
|
||||
const p0 = [cx + r * Math.cos(s.a0), cy + r * Math.sin(s.a0)], p1 = [cx + r * Math.cos(s.a1), cy + r * Math.sin(s.a1)];
|
||||
return <path key={s.i} d={`M${cx},${cy} L${p0.join(',')} A${r},${r} 0 ${large} 1 ${p1.join(',')} Z`} fill={s.c} stroke="#fff" strokeWidth={2} />;
|
||||
})}
|
||||
{slices.filter((s) => s.frac >= 0.06).map((s) => {
|
||||
const m = (s.a0 + s.a1) / 2;
|
||||
return (
|
||||
<text key={`t${s.i}`} x={cx + r * 0.62 * Math.cos(m)} y={cy + r * 0.62 * Math.sin(m) + 4} fontSize={11} fontWeight={800} fill="#fff" textAnchor="middle">
|
||||
{fa(s.frac * 100, 1)}٪
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
{labels.map((l, i) => (
|
||||
<g key={`l${i}`}>
|
||||
<rect x={214} y={18 + i * 22} width={12} height={12} rx={3} fill={slices[i].c} />
|
||||
<text x={232} y={28 + i * 22} fontSize={10.5} fontWeight={700} fill={INK} style={{ unicodeBidi: 'plaintext' }}>{`${l} (${fa(values[i])})`}</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
179
src/diagram/expr.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* پارسرِ کوچکِ عبارتِ ریاضی برای نمودارِ دستیار (`x^2-2*x`، `sin(x)+1`).
|
||||
*
|
||||
* عمداً eval یا new Function نیست: عبارت از مدل میآید و مدل متنِ کاربر را
|
||||
* خوانده است. فقط عدد، x، pi، e، + − * / ^، پرانتز و چند تابعِ شناختهشده
|
||||
* پذیرفته میشود؛ هر چیزِ دیگر خطا است و نمودار کشیده نمیشود. ضربِ ضمنی
|
||||
* (2x، 3(x+1)، x sin(x)) هم پشتیبانی میشود چون مدل گاهی مینویسدش.
|
||||
*/
|
||||
type Node =
|
||||
| { k: 'num'; v: number }
|
||||
| { k: 'x' }
|
||||
| { k: 'neg'; a: Node }
|
||||
| { k: 'bin'; op: '+' | '-' | '*' | '/' | '^'; a: Node; b: Node }
|
||||
| { k: 'fn'; f: string; a: Node };
|
||||
|
||||
const FUNCS: Record<string, (v: number) => number> = {
|
||||
sin: (v) => Math.sin(v), cos: (v) => Math.cos(v), tan: (v) => Math.tan(v),
|
||||
sqrt: (v) => Math.sqrt(v), abs: (v) => Math.abs(v), log: (v) => Math.log10(v), ln: (v) => Math.log(v),
|
||||
};
|
||||
|
||||
function tokenize(src: string): string[] {
|
||||
const s = src.replace(/\s+/g, '').replace(/\*\*/g, '^').replace(/[−–]/g, '-').replace(/[×·]/g, '*').replace(/÷/g, '/').replace(/²/g, '^2').replace(/³/g, '^3').replace(/π/g, 'pi');
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const rest = s.slice(i);
|
||||
const num = /^(\d+\.?\d*|\.\d+)/.exec(rest);
|
||||
if (num) { out.push(num[0]); i += num[0].length; continue; }
|
||||
const word = /^[a-z]+/.exec(rest);
|
||||
if (word) {
|
||||
const w = word[0];
|
||||
if (w === 'x' || w === 'pi' || w === 'e' || w in FUNCS) { out.push(w); i += w.length; continue; }
|
||||
// «xsin» یا «pix»: نامِ چسبیده را تکهتکه کن
|
||||
const known = ['sqrt', 'sin', 'cos', 'tan', 'abs', 'log', 'ln', 'pi', 'x', 'e'].find((k) => w.startsWith(k));
|
||||
if (!known) throw new Error(`نام ناشناخته: ${w}`);
|
||||
out.push(known); i += known.length; continue;
|
||||
}
|
||||
if ('+-*/^()'.includes(s[i])) { out.push(s[i]); i++; continue; }
|
||||
throw new Error(`نویسه ناشناخته: ${s[i]}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseExpr(src: string): (x: number) => number {
|
||||
if (src.length > 120) throw new Error('عبارت بلند است');
|
||||
const t = tokenize(src);
|
||||
let p = 0;
|
||||
const peek = () => t[p];
|
||||
const eat = (tok?: string) => {
|
||||
const v = t[p++];
|
||||
if (tok !== undefined && v !== tok) throw new Error(`انتظار ${tok}`);
|
||||
return v;
|
||||
};
|
||||
const startsAtom = (tok: string | undefined) => tok !== undefined && (/^[\d.]/.test(tok) || tok === 'x' || tok === 'pi' || tok === 'e' || tok === '(' || tok in FUNCS);
|
||||
|
||||
function expr(): Node {
|
||||
let a = term();
|
||||
while (peek() === '+' || peek() === '-') { const op = eat() as '+' | '-'; a = { k: 'bin', op, a, b: term() }; }
|
||||
return a;
|
||||
}
|
||||
function term(): Node {
|
||||
let a = unary();
|
||||
for (;;) {
|
||||
if (peek() === '*' || peek() === '/') { const op = eat() as '*' | '/'; a = { k: 'bin', op, a, b: unary() }; }
|
||||
else if (startsAtom(peek())) a = { k: 'bin', op: '*', a, b: power() }; // ضربِ ضمنی
|
||||
else return a;
|
||||
}
|
||||
}
|
||||
function unary(): Node {
|
||||
if (peek() === '-') { eat(); return { k: 'neg', a: unary() }; }
|
||||
if (peek() === '+') { eat(); return unary(); }
|
||||
return power();
|
||||
}
|
||||
function power(): Node {
|
||||
const base = atom();
|
||||
if (peek() === '^') { eat(); return { k: 'bin', op: '^', a: base, b: unary() }; }
|
||||
return base;
|
||||
}
|
||||
function atom(): Node {
|
||||
const tok = eat();
|
||||
if (tok === undefined) throw new Error('عبارت ناتمام است');
|
||||
if (/^[\d.]/.test(tok)) return { k: 'num', v: Number(tok) };
|
||||
if (tok === 'x') return { k: 'x' };
|
||||
if (tok === 'pi') return { k: 'num', v: Math.PI };
|
||||
if (tok === 'e') return { k: 'num', v: Math.E };
|
||||
if (tok in FUNCS) {
|
||||
if (peek() === '(') { eat('('); const a = expr(); eat(')'); return { k: 'fn', f: tok, a }; }
|
||||
return { k: 'fn', f: tok, a: power() };
|
||||
}
|
||||
if (tok === '(') { const a = expr(); eat(')'); return a; }
|
||||
throw new Error(`غیرمنتظره: ${tok}`);
|
||||
}
|
||||
|
||||
const tree = expr();
|
||||
if (p !== t.length) throw new Error('عبارت اضافه دارد');
|
||||
|
||||
const ev = (n: Node, x: number): number => {
|
||||
switch (n.k) {
|
||||
case 'num': return n.v;
|
||||
case 'x': return x;
|
||||
case 'neg': return -ev(n.a, x);
|
||||
case 'fn': return FUNCS[n.f](ev(n.a, x));
|
||||
case 'bin': {
|
||||
const a = ev(n.a, x), b = ev(n.b, x);
|
||||
return n.op === '+' ? a + b : n.op === '-' ? a - b : n.op === '*' ? a * b : n.op === '/' ? a / b : Math.pow(a, b);
|
||||
}
|
||||
}
|
||||
};
|
||||
return (x) => ev(tree, x);
|
||||
}
|
||||
|
||||
/* ─── تحلیلِ عددی: ریشه، اکسترمم، شیب ───────────────────────────────────────
|
||||
* همه با دو برابر کردنِ دقت (bisection) تا ۱e-10؛ برای نمایش به دو رقمِ اعشار گرد
|
||||
* میشوند. ریشهی مضاعف (مثلِ x² در صفر) علامت عوض نمیکند، پس جدا از روی
|
||||
* اکسترممی که مقدارش صفر است پیدا میشود.
|
||||
*/
|
||||
|
||||
type F = (x: number) => number;
|
||||
|
||||
function bisect(g: F, a: number, b: number): number {
|
||||
let ga = g(a);
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const m = (a + b) / 2, gm = g(m);
|
||||
if (!Number.isFinite(gm)) return m;
|
||||
if (Math.sign(gm) === Math.sign(ga)) { a = m; ga = gm; } else b = m;
|
||||
if (b - a < 1e-11) break;
|
||||
}
|
||||
return (a + b) / 2;
|
||||
}
|
||||
|
||||
/** مشتقِ عددی (تفاضلِ مرکزی). */
|
||||
export function derivative(f: F, x: number): number {
|
||||
const h = 1e-5 * Math.max(1, Math.abs(x));
|
||||
return (f(x + h) - f(x - h)) / (2 * h);
|
||||
}
|
||||
|
||||
const snap = (x: number) => {
|
||||
const r = Math.round(x);
|
||||
if (Math.abs(x - r) < 1e-7) return r;
|
||||
const r2 = Math.round(x * 1e6) / 1e6;
|
||||
return Math.abs(x - r2) < 1e-9 ? r2 : x;
|
||||
};
|
||||
|
||||
export function extrema(f: F, from: number, to: number): { x: number; y: number; kind: 'max' | 'min' }[] {
|
||||
const n = 600, out: { x: number; y: number; kind: 'max' | 'min' }[] = [];
|
||||
const d = (x: number) => derivative(f, x);
|
||||
let px = from, pd = d(px);
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const x = from + ((to - from) * i) / n, dx = d(x);
|
||||
if (Number.isFinite(pd) && Number.isFinite(dx) && pd !== 0 && Math.sign(dx) !== Math.sign(pd)) {
|
||||
const xe = snap(bisect(d, px, x));
|
||||
const y = f(xe);
|
||||
// لبهی مجانب (1/x) علامتِ مشتق را بیاکسترمم عوض نمیکند: تابع باید پیوسته باشد.
|
||||
if (Number.isFinite(y) && Math.abs(f(px) - f(x)) < 1e3 * (Math.abs(y) + 1)) out.push({ x: xe, y: snap(y), kind: pd > 0 ? 'max' : 'min' });
|
||||
}
|
||||
px = x; pd = dx;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function roots(f: F, from: number, to: number): number[] {
|
||||
const n = 800, out: number[] = [];
|
||||
let px = from, py = f(px);
|
||||
if (Math.abs(py) < 1e-12) out.push(from);
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const x = from + ((to - from) * i) / n, y = f(x);
|
||||
if (Math.abs(y) < 1e-12) out.push(x);
|
||||
else if (Number.isFinite(py) && Number.isFinite(y) && py !== 0 && Math.sign(y) !== Math.sign(py)) {
|
||||
const r = bisect(f, px, x);
|
||||
// پرشِ علامت بدونِ گذر از صفر (مجانب) ریشه نیست.
|
||||
if (Math.abs(f(r)) < 1e-6) out.push(r);
|
||||
}
|
||||
px = x; py = y;
|
||||
}
|
||||
for (const e of extrema(f, from, to)) if (Math.abs(e.y) < 1e-9) out.push(e.x);
|
||||
const uniq: number[] = [];
|
||||
for (const r of out.map(snap).sort((a, b) => a - b)) if (!uniq.length || Math.abs(r - uniq[uniq.length - 1]) > 1e-6) uniq.push(r);
|
||||
return uniq;
|
||||
}
|
||||
61
src/diagram/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Component, memo, type ReactNode } from 'react';
|
||||
import type { DiagramSpec } from './types';
|
||||
import { Plane } from './plane';
|
||||
import { NumberLine } from './numberline';
|
||||
import { Solid } from './solid';
|
||||
import { Space } from './space';
|
||||
import { Chart } from './charts';
|
||||
import { Mapping, Tree, Venn } from './sets';
|
||||
import { Clock, Fraction, Grid, Groups, Pattern, Table } from './misc';
|
||||
import { HideCounts, PAPER } from './svg';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
|
||||
export type { DiagramSpec } from './types';
|
||||
|
||||
/**
|
||||
* شکلِ جوابِ دستیار. توصیف از بکاند میآید (rag/services/diagram.py، اعتبارسنجیشده)
|
||||
* و اینجا فقط به عنصرِ React/SVG تبدیل میشود؛ هیچ HTMLی از مدل وارد صفحه نمیشود.
|
||||
*
|
||||
* هر خطایی در رسم (شکلی که کد پیشبینی نکرده) فقط همین شکل را پنهان میکند؛
|
||||
* متنِ جواب سرِ جایش میماند.
|
||||
*/
|
||||
function Body({ spec }: { spec: DiagramSpec }): ReactNode {
|
||||
switch (spec.kind) {
|
||||
case 'plane': return <Plane spec={spec} />;
|
||||
case 'numberline': return <NumberLine spec={spec} />;
|
||||
case 'solid': return <Solid spec={spec} />;
|
||||
case 'space': return <Space spec={spec} />;
|
||||
case 'bar': case 'linechart': case 'pie': return <Chart spec={spec} />;
|
||||
case 'venn': return <Venn spec={spec} />;
|
||||
case 'mapping': return <Mapping spec={spec} />;
|
||||
case 'tree': return <Tree spec={spec} />;
|
||||
case 'fraction': return <Fraction spec={spec} />;
|
||||
case 'grid': return <Grid spec={spec} />;
|
||||
case 'groups': return <Groups spec={spec} />;
|
||||
case 'clock': return <Clock spec={spec} />;
|
||||
case 'table': return <Table spec={spec} />;
|
||||
case 'pattern': return <Pattern spec={spec} />;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
class Guard extends Component<{ children: ReactNode }, { broken: boolean }> {
|
||||
state = { broken: false };
|
||||
static getDerivedStateFromError() { return { broken: true }; }
|
||||
render() { return this.state.broken ? null : this.props.children; }
|
||||
}
|
||||
|
||||
function DiagramBase({ spec, hideCounts = false }: { spec: DiagramSpec; hideCounts?: boolean }) {
|
||||
return (
|
||||
<Guard>
|
||||
{/* dir=ltr: محور و مختصات چپبهراستاند؛ در زمینهی rtl «−۱» به «۱−» برمیگشت.
|
||||
برچسبهای فارسی با unicode-bidi: plaintext جهتِ خودشان را میگیرند. */}
|
||||
<figure dir="ltr" className="mt-2 overflow-hidden rounded-2xl border border-line p-2" style={{ backgroundColor: PAPER }}>
|
||||
<HideCounts.Provider value={hideCounts}><Body spec={spec} /></HideCounts.Provider>
|
||||
{spec.title && <figcaption dir="rtl" className="mt-1 text-center text-[11px] font-bold text-muted"><MathText text={spec.title} /></figcaption>}
|
||||
</figure>
|
||||
</Guard>
|
||||
);
|
||||
}
|
||||
|
||||
export const Diagram = memo(DiagramBase);
|
||||
294
src/diagram/misc.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import type { ClockSpec, FractionSpec, GridSpec, GroupsSpec, PatternSpec, TableSpec } from './types';
|
||||
import { useContext } from 'react';
|
||||
import { COLORS, HideCounts, INK, bidi, color, mathText } from './svg';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
const W = 320;
|
||||
const SHADE = COLORS.yellow;
|
||||
|
||||
/* ─── کسر: دایره یا نوارِ تکهتکه ─────────────────────────────────────────── */
|
||||
|
||||
export function Fraction({ spec }: { spec: FractionSpec }) {
|
||||
const hide = useContext(HideCounts);
|
||||
if (spec.compare?.length) return <FractionCompare items={spec.compare} hide={hide} />;
|
||||
if (spec.wholes) return <MixedNumber wholes={spec.wholes} parts={spec.parts} shaded={spec.shaded} shape={spec.shape} hide={hide} />;
|
||||
const { parts, shaded } = spec;
|
||||
const fill = (i: number) => (i < shaded ? SHADE : '#fff');
|
||||
const caption = hide ? null : <text x={W / 2} y={spec.shape === 'rect' ? 88 : 166} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(shaded)} تکه از ${toFa(parts)} تکه`}</text>;
|
||||
if (spec.shape === 'rect') {
|
||||
const w = (W - 40) / parts;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} 96`} className="mx-auto block w-full max-w-[320px]" role="img">
|
||||
{/* از راست به چپ رنگ میشود، مثلِ خواندنِ فارسی */}
|
||||
{Array.from({ length: parts }, (_, i) => <rect key={i} x={W - 20 - (i + 1) * w} y={18} width={w} height={48} fill={fill(i)} stroke={INK} strokeWidth={2} />)}
|
||||
{caption}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const cx = W / 2, cy = 80, r = 66;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} 176`} className="mx-auto block w-full max-w-[260px]" role="img">
|
||||
{parts === 1 ? <circle cx={cx} cy={cy} r={r} fill={fill(0)} stroke={INK} strokeWidth={2} /> : Array.from({ length: parts }, (_, i) => {
|
||||
const a0 = -Math.PI / 2 + (2 * Math.PI * i) / parts, a1 = -Math.PI / 2 + (2 * Math.PI * (i + 1)) / parts;
|
||||
return <path key={i} d={`M${cx},${cy} L${cx + r * Math.cos(a0)},${cy + r * Math.sin(a0)} A${r},${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${cx + r * Math.cos(a1)},${cy + r * Math.sin(a1)} Z`} fill={fill(i)} stroke={INK} strokeWidth={2} strokeLinejoin="round" />;
|
||||
})}
|
||||
{caption}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** چند کسر روی نوارهای هماندازه، برای مقایسه (یکپنجم و نُهدهم). */
|
||||
function FractionCompare({ items, hide }: { items: { parts: number; shaded: number; label?: string }[]; hide: boolean }) {
|
||||
const barW = W - 110, h = 30, gap = 16;
|
||||
const H = items.length * (h + gap) + 8;
|
||||
const cols = [COLORS.yellow, COLORS.teal, COLORS.orange, COLORS.purple];
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
|
||||
{items.map((f, r) => {
|
||||
const y = 8 + r * (h + gap), w = barW / f.parts;
|
||||
return (
|
||||
<g key={r}>
|
||||
{Array.from({ length: f.parts }, (_, i) => (
|
||||
<rect key={i} x={W - 12 - (i + 1) * w} y={y} width={w} height={h} fill={i < f.shaded ? cols[r % cols.length] : '#fff'} stroke={INK} strokeWidth={1.6} />
|
||||
))}
|
||||
<text x={W - 20 - barW} y={y + h / 2 + 4} fontSize={11.5} fontWeight={800} fill={INK} textAnchor="end" style={{ unicodeBidi: 'plaintext' }}>
|
||||
{f.label ?? (hide ? '' : `${toFa(f.shaded)} از ${toFa(f.parts)}`)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** عددِ مخلوط: چند واحدِ کامل و یک کسر (دو و هفتدهم). */
|
||||
function MixedNumber({ wholes, parts, shaded, shape, hide }: { wholes: number; parts: number; shaded: number; shape: 'circle' | 'rect'; hide: boolean }) {
|
||||
const n = wholes + 1, r = Math.min(40, (W - 20) / n / 2 - 6), cy = 52;
|
||||
const cx = (i: number) => W - 10 - r - 6 - i * (2 * r + 12);
|
||||
const slice = (x: number, i: number, full: boolean) => {
|
||||
const a0 = -Math.PI / 2 + (2 * Math.PI * i) / parts, a1 = -Math.PI / 2 + (2 * Math.PI * (i + 1)) / parts;
|
||||
return <path key={i} d={`M${x},${cy} L${x + r * Math.cos(a0)},${cy + r * Math.sin(a0)} A${r},${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x + r * Math.cos(a1)},${cy + r * Math.sin(a1)} Z`} fill={full || i < shaded ? SHADE : '#fff'} stroke={INK} strokeWidth={1.6} />;
|
||||
};
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} 120`} className="mx-auto block w-full max-w-[340px]" role="img">
|
||||
{Array.from({ length: wholes }, (_, w) => (shape === 'rect'
|
||||
? <rect key={w} x={cx(w) - r} y={cy - r} width={2 * r} height={2 * r} fill={SHADE} stroke={INK} strokeWidth={2} />
|
||||
: <circle key={w} cx={cx(w)} cy={cy} r={r} fill={SHADE} stroke={INK} strokeWidth={2} />))}
|
||||
<g>{parts === 1 ? <circle cx={cx(wholes)} cy={cy} r={r} fill={shaded ? SHADE : '#fff'} stroke={INK} strokeWidth={2} /> : Array.from({ length: parts }, (_, i) => slice(cx(wholes), i, false))}</g>
|
||||
{!hide && <text x={W / 2} y={112} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
|
||||
{`${toFa(wholes)} واحد کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`}
|
||||
</text>}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── شبکهی خانهها: درصد، مساحت با شمردن، کسر روی جدول ─────────────────── */
|
||||
|
||||
export function Grid({ spec }: { spec: GridSpec }) {
|
||||
const hide = useContext(HideCounts);
|
||||
const rows = spec.rows, cols = spec.cols;
|
||||
const cell = Math.min(26, (W - 30) / cols, 220 / rows);
|
||||
const gw = cols * cell, gh = rows * cell;
|
||||
const x0 = (W - gw) / 2, y0 = 12;
|
||||
const on = new Set<string>();
|
||||
if (typeof spec.shaded === 'number') {
|
||||
// سطر به سطر، از بالا و از راست (جهتِ خواندنِ فارسی)
|
||||
for (let i = 0; i < Math.min(spec.shaded, rows * cols); i++) on.add(`${Math.floor(i / cols)},${cols - 1 - (i % cols)}`);
|
||||
} else spec.shaded.forEach(([r, c]) => on.add(`${r},${c}`));
|
||||
const col = color(spec.color, SHADE);
|
||||
const count = on.size;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${gh + 44}`} className="mx-auto block w-full max-w-[320px]" role="img">
|
||||
{Array.from({ length: rows }, (_, r) => Array.from({ length: cols }, (_, c) => (
|
||||
<rect key={`${r}-${c}`} x={x0 + c * cell} y={y0 + r * cell} width={cell} height={cell} fill={on.has(`${r},${c}`) ? col : '#fff'} stroke="#B8BCCD" strokeWidth={1} />
|
||||
)))}
|
||||
<rect x={x0} y={y0} width={gw} height={gh} fill="none" stroke={INK} strokeWidth={2} />
|
||||
{!hide && <text x={W / 2} y={gh + 34} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
|
||||
{`${toFa(count)} خانه از ${toFa(rows * cols)} خانه${100 % (rows * cols) === 0 || (rows * cols) % 10 === 0 ? `، یعنی ${toFa(Math.round((count / (rows * cols)) * 1000) / 10)}٪` : ''}`}
|
||||
</text>}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── گروهها: مدلِ ضرب و تقسیم برای دبستان ───────────────────────────────── */
|
||||
|
||||
export function Groups({ spec }: { spec: GroupsSpec }) {
|
||||
const hide = useContext(HideCounts);
|
||||
const g = spec.groups, each = spec.each;
|
||||
const perRow = Math.min(g, g <= 4 ? g : Math.ceil(g / 2));
|
||||
const rowsG = Math.ceil(g / perRow);
|
||||
const inner = Math.ceil(Math.sqrt(each));
|
||||
const dot = Math.min(14, 64 / inner);
|
||||
const boxW = inner * dot + 16, boxH = Math.ceil(each / inner) * dot + 16;
|
||||
const gap = 12;
|
||||
const totalW = perRow * boxW + (perRow - 1) * gap;
|
||||
const scale = Math.min(1, (W - 20) / totalW);
|
||||
const H = (rowsG * (boxH + gap) + 30) * scale + 10;
|
||||
const shape = spec.item ?? 'circle';
|
||||
const cols = [COLORS.red, COLORS.blue, COLORS.green, COLORS.orange, COLORS.purple];
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[320px]" role="img">
|
||||
<g transform={`translate(${(W - totalW * scale) / 2} 8) scale(${scale})`}>
|
||||
{Array.from({ length: g }, (_, gi) => {
|
||||
const bx = (gi % perRow) * (boxW + gap), by = Math.floor(gi / perRow) * (boxH + gap);
|
||||
const c = cols[gi % cols.length];
|
||||
return (
|
||||
<g key={gi} transform={`translate(${bx} ${by})`}>
|
||||
<rect width={boxW} height={boxH} rx={10} fill={`${c}12`} stroke={c} strokeWidth={2} strokeDasharray="5 3" />
|
||||
{Array.from({ length: each }, (_, i) => {
|
||||
const x = 8 + (i % inner) * dot + dot / 2, y = 8 + Math.floor(i / inner) * dot + dot / 2, s = dot * 0.36;
|
||||
if (shape === 'square') return <rect key={i} x={x - s} y={y - s} width={s * 2} height={s * 2} rx={2} fill={c} />;
|
||||
if (shape === 'star') return <path key={i} d={star(x, y, s * 1.2)} fill={c} />;
|
||||
return <circle key={i} cx={x} cy={y} r={s} fill={c} />;
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
{(spec.label != null || !hide) && <text x={W / 2} y={H - 8} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" {...bidi(mathText(spec.label ?? `${g} × ${each} = ${g * each}`))}>
|
||||
{mathText(spec.label ?? `${g} × ${each} = ${g * each}`)}
|
||||
</text>}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function star(cx: number, cy: number, r: number): string {
|
||||
const pts = Array.from({ length: 10 }, (_, i) => {
|
||||
const t = -Math.PI / 2 + (i * Math.PI) / 5, rr = i % 2 ? r * 0.45 : r;
|
||||
return `${cx + rr * Math.cos(t)},${cy + rr * Math.sin(t)}`;
|
||||
});
|
||||
return `M${pts.join(' L')} Z`;
|
||||
}
|
||||
|
||||
/* ─── ساعت ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function Clock({ spec }: { spec: ClockSpec }) {
|
||||
const cx = W / 2, cy = 100, r = 84;
|
||||
const h = ((spec.hour % 12) + spec.minute / 60) * 30, m = spec.minute * 6;
|
||||
const hand = (deg: number, len: number, w: number, c: string) => {
|
||||
const t = ((deg - 90) * Math.PI) / 180;
|
||||
return <line x1={cx} y1={cy} x2={cx + len * Math.cos(t)} y2={cy + len * Math.sin(t)} stroke={c} strokeWidth={w} strokeLinecap="round" />;
|
||||
};
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} 200`} className="mx-auto block w-full max-w-[240px]" role="img">
|
||||
<circle cx={cx} cy={cy} r={r} fill="#fff" stroke={INK} strokeWidth={3} />
|
||||
{Array.from({ length: 60 }, (_, i) => {
|
||||
const t = (i * 6 - 90) * (Math.PI / 180), big = i % 5 === 0;
|
||||
return <line key={i} x1={cx + (r - (big ? 10 : 5)) * Math.cos(t)} y1={cy + (r - (big ? 10 : 5)) * Math.sin(t)} x2={cx + (r - 2) * Math.cos(t)} y2={cy + (r - 2) * Math.sin(t)} stroke={INK} strokeWidth={big ? 2.2 : 1} />;
|
||||
})}
|
||||
{Array.from({ length: 12 }, (_, i) => {
|
||||
const n = i + 1, t = (n * 30 - 90) * (Math.PI / 180);
|
||||
return <text key={n} x={cx + (r - 22) * Math.cos(t)} y={cy + (r - 22) * Math.sin(t) + 5} fontSize={14} fontWeight={800} fill={INK} textAnchor="middle">{toFa(n)}</text>;
|
||||
})}
|
||||
{hand(h, r * 0.5, 5, COLORS.blue)}
|
||||
{hand(m, r * 0.78, 3, COLORS.red)}
|
||||
<circle cx={cx} cy={cy} r={4.5} fill={INK} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── جدول (مقدارها، تعیینِ علامت) ───────────────────────────────────────── */
|
||||
|
||||
export function Table({ spec }: { spec: TableSpec }) {
|
||||
const cellStyle = (v: string) => {
|
||||
const t = v.trim();
|
||||
if (t === '+') return { color: COLORS.green, fontWeight: 900 };
|
||||
if (t === '-' || t === '−') return { color: COLORS.red, fontWeight: 900 };
|
||||
if (t === '0' || t === '۰') return { color: INK, fontWeight: 900 };
|
||||
return undefined;
|
||||
};
|
||||
return (
|
||||
// جدولِ ریاضی (تعیینِ علامت، مقدارها) چپبهراست است، مثلِ محورِ x.
|
||||
<div className="overflow-x-auto" dir="ltr">
|
||||
<table className="mx-auto border-collapse text-center text-[12px]">
|
||||
{spec.headers.length > 0 && (
|
||||
<thead>
|
||||
<tr>{spec.headers.map((h, i) => <th key={i} className="border border-[#d8dae6] bg-[#eef0fb] px-2.5 py-1.5 font-black text-ink" style={bidi(mathText(h)).style}>{mathText(h)}</th>)}</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{spec.rows.map((row, r) => (
|
||||
<tr key={r}>
|
||||
{row.map((c, i) => (
|
||||
<td key={i} className={`border border-[#d8dae6] px-2.5 py-1.5 ${i === 0 ? 'bg-[#f6f7fc] font-bold' : 'bg-white'}`} style={{ ...cellStyle(c), ...bidi(mathText(c)).style }}>
|
||||
{mathText(c)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── الگوی شکلی: شکلِ ۱، ۲، ۳، … و «شکلِ بعدی؟» (فصلِ الگو و دنباله) ────────── */
|
||||
|
||||
/** خانههای هر مرحله در واحدِ شبکه؛ اولین مرحله راست است، مثلِ کتاب. */
|
||||
function patternCells(layout: PatternSpec['layout'], n: number): [number, number][] {
|
||||
const out: [number, number][] = [];
|
||||
if (layout === 'square') for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) out.push([c, r]);
|
||||
else if (layout === 'rect') for (let r = 0; r < n; r++) for (let c = 0; c <= n; c++) out.push([c, r]);
|
||||
else if (layout === 'triangle') for (let r = 0; r < n; r++) for (let c = 0; c <= r; c++) out.push([c + (n - 1 - r) / 2, r]);
|
||||
else if (layout === 'stair') for (let c = 0; c < n; c++) for (let r = 0; r <= c; r++) out.push([c, n - 1 - r]);
|
||||
else if (layout === 'L') { for (let r = 0; r < n; r++) out.push([0, r]); for (let c = 1; c < n; c++) out.push([c, n - 1]); }
|
||||
else for (let c = 0; c < n; c++) out.push([c, 0]);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function Pattern({ spec }: { spec: PatternSpec }) {
|
||||
const hide = useContext(HideCounts);
|
||||
const stages = spec.stages;
|
||||
const sticks = spec.unit === 'stick';
|
||||
const sizes = stages.map((n) => {
|
||||
const cells = sticks ? [] : patternCells(spec.layout, n);
|
||||
const w = sticks ? n : Math.max(1, ...cells.map(([c]) => c + 1));
|
||||
const h = sticks ? 1 : Math.max(1, ...cells.map(([, r]) => r + 1));
|
||||
return { n, cells, w, h };
|
||||
});
|
||||
const gapU = 1.2;
|
||||
const totalU = sizes.reduce((t, s) => t + s.w, 0) + gapU * (sizes.length - 1 + (spec.next ? 1 : 0)) + (spec.next ? 1.4 : 0);
|
||||
const maxH = Math.max(...sizes.map((s) => s.h));
|
||||
const u = Math.min(22, (W - 24) / totalU, 150 / maxH);
|
||||
const H = maxH * u + 46;
|
||||
const base = 10 + maxH * u;
|
||||
const col = color(spec.color, COLORS.blue);
|
||||
let x = W - 12;
|
||||
const groups = sizes.map((s, i) => {
|
||||
x -= s.w * u;
|
||||
const x0 = x;
|
||||
x -= gapU * u;
|
||||
const count = sticks ? 3 * s.n + 1 : s.cells.length;
|
||||
return (
|
||||
<g key={i}>
|
||||
{sticks
|
||||
? Array.from({ length: s.n + 1 }, (_, k) => <line key={`v${k}`} x1={x0 + k * u} y1={base - u + 2} x2={x0 + k * u} y2={base - 2} stroke={COLORS.orange} strokeWidth={3} strokeLinecap="round" />)
|
||||
.concat(Array.from({ length: s.n }, (_, k) => [
|
||||
<line key={`t${k}`} x1={x0 + k * u + 2} y1={base - u} x2={x0 + (k + 1) * u - 2} y2={base - u} stroke={COLORS.orange} strokeWidth={3} strokeLinecap="round" />,
|
||||
<line key={`b${k}`} x1={x0 + k * u + 2} y1={base} x2={x0 + (k + 1) * u - 2} y2={base} stroke={COLORS.orange} strokeWidth={3} strokeLinecap="round" />,
|
||||
]).flat())
|
||||
: s.cells.map(([c, r], k) => {
|
||||
const cx = x0 + (s.w - 1 - c) * u, cy = base - (s.h - r) * u;
|
||||
return spec.unit === 'dot'
|
||||
? <circle key={k} cx={cx + u / 2} cy={cy + u / 2} r={u * 0.32} fill={col} />
|
||||
: <rect key={k} x={cx + 1} y={cy + 1} width={u - 2} height={u - 2} rx={2} fill={`${col}33`} stroke={col} strokeWidth={1.5} />;
|
||||
})}
|
||||
<text x={x0 + (s.w * u) / 2} y={base + 16} fontSize={11} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکل ${toFa(i + 1)}`}</text>
|
||||
{spec.counts !== false && !hide && <text x={x0 + (s.w * u) / 2} y={base + 30} fontSize={10.5} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(count)} ${sticks ? 'چوب' : 'تا'}`}</text>}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
const nx = x - 1.4 * u + gapU * u;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
|
||||
{groups}
|
||||
{spec.next && (
|
||||
<g>
|
||||
<rect x={nx} y={base - 1.4 * u} width={1.4 * u} height={1.4 * u} rx={6} fill="#fff" stroke={COLORS.red} strokeWidth={2} strokeDasharray="4 3" />
|
||||
<text x={nx + 0.7 * u} y={base - 0.45 * u} fontSize={u * 0.8} fontWeight={900} fill={COLORS.red} textAnchor="middle">?</text>
|
||||
<text x={nx + 0.7 * u} y={base + 16} fontSize={11} fontWeight={800} fill={COLORS.red} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکل ${toFa(stages.length + 1)}`}</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
106
src/diagram/numberline.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { NumberLineSpec } from './types';
|
||||
import { Arrowheads, COLORS, INK, Label, SERIES, SOFT, StackedFraction, arrowId, color, fa, gcd, mathText, niceStep, ticksBetween } from './svg';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
const W = 330;
|
||||
const PAD = 26;
|
||||
|
||||
/**
|
||||
* محورِ اعداد: نقطه (توپُر یا توخالی)، پرش (فلشِ خمیده)، و بازه (پارهخطِ رنگی با
|
||||
* سرِ باز/بسته، یا تا بینهایت). با `fractions` تیکها کسرِ دوطبقهاند (۱/۴، ۲/۴)،
|
||||
* چون در کتابِ دبستان «/» ممیز است.
|
||||
*/
|
||||
export function NumberLine({ spec }: { spec: NumberLineSpec }) {
|
||||
const { min, max } = spec;
|
||||
const intervals = spec.intervals ?? [];
|
||||
const lanes = intervals.length;
|
||||
const jumpH = spec.jumps.length ? 52 : 0;
|
||||
const LANE = intervals.some((iv) => iv.label) ? 24 : 14;
|
||||
const base = 22 + jumpH + lanes * LANE + 10;
|
||||
const H = base + (spec.fractions ? 58 : 44);
|
||||
const X = (v: number) => PAD + ((v - min) / (max - min)) * (W - 2 * PAD);
|
||||
// محورِ عددهای صحیح گامِ صحیحِ «گرد» میگیرد (۱، ۲، ۵، ۱۰، …)، نه ۲٫۵
|
||||
const intStep = (span: number) => [1, 2, 5, 10, 20, 25, 50, 100, 200, 500, 1000].find((c) => span / c <= 12) ?? niceStep(span, 12);
|
||||
const integers = Number.isInteger(min) && Number.isInteger(max);
|
||||
const step = spec.step && spec.step > 0 && (max - min) / spec.step <= 40 ? spec.step : integers ? intStep(max - min) : niceStep(max - min, 12);
|
||||
const ticks = ticksBetween(min, max, step);
|
||||
const labelled = new Set(spec.points.map((p) => p.at));
|
||||
// بیش از ۱۴ تیک: فقط بعضی عدد میگیرند (صفر، مضربهای ۵ یا ۱۰، نقطههای خواستهشده)،
|
||||
// وگرنه عددها روی هم میافتند.
|
||||
const every = ticks.length > 14 ? Math.ceil(ticks.length / 10) : 1;
|
||||
const showLabel = (t: number, i: number) => every === 1 || labelled.has(t) || Math.abs(t) < 1e-9 || i % every === 0;
|
||||
const arrows = new Set<string>([INK, COLORS.red]);
|
||||
|
||||
const tickLabel = (t: number) => {
|
||||
if (spec.fractions && step < 1) {
|
||||
const den = Math.round(1 / step);
|
||||
const num = Math.round(t * den);
|
||||
if (num % den === 0) return null; // عددِ صحیح با رقمِ معمولی
|
||||
const g = gcd(num, den);
|
||||
return { num: toFa(Math.abs(num / g)), den: toFa(den / g), neg: num < 0 };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img" aria-label={spec.title || 'محور اعداد'}>
|
||||
<Arrowheads ids={[...arrows, ...intervals.map((iv, i) => color(iv.color, COLORS[SERIES[i % SERIES.length]]))]} />
|
||||
<line x1={PAD - 16} y1={base} x2={W - PAD + 16} y2={base} stroke={INK} strokeWidth={2}
|
||||
markerEnd={`url(#${arrowId(INK)})`} markerStart={`url(#${arrowId(INK)})`} />
|
||||
{ticks.map((t, ti) => {
|
||||
const fr = showLabel(t, ti) ? tickLabel(t) : null;
|
||||
const hideNumber = !showLabel(t, ti);
|
||||
const on = labelled.has(t);
|
||||
const whole = Math.abs(t - Math.round(t)) < 1e-9;
|
||||
return (
|
||||
<g key={t}>
|
||||
<line x1={X(t)} x2={X(t)} y1={base - (whole ? 6 : 4)} y2={base + (whole ? 6 : 4)} stroke={INK} strokeWidth={Math.abs(t) < 1e-9 ? 2.2 : 1.3} />
|
||||
{fr ? (
|
||||
<StackedFraction x={X(t)} y={base + 16} num={`${fr.neg ? '−' : ''}${fr.num}`} den={fr.den} color={on ? INK : SOFT} size={9} />
|
||||
) : hideNumber ? null : (
|
||||
<text x={X(t)} y={base + 20} fontSize={on ? 11.5 : 10} fontWeight={on ? 800 : 500} fill={on ? INK : SOFT} textAnchor="middle">{fa(t)}</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{intervals.map((iv, i) => {
|
||||
const col = color(iv.color, COLORS[SERIES[i % SERIES.length]]);
|
||||
const y = base - 10 - i * LANE;
|
||||
const a = iv.from === null ? PAD - 12 : X(iv.from), b = iv.to === null ? W - PAD + 12 : X(iv.to);
|
||||
return (
|
||||
<g key={`iv${i}`}>
|
||||
<line x1={a} x2={b} y1={y} y2={y} stroke={col} strokeWidth={4} strokeLinecap="round"
|
||||
markerStart={iv.from === null ? `url(#${arrowId(col)})` : undefined} markerEnd={iv.to === null ? `url(#${arrowId(col)})` : undefined} />
|
||||
{iv.from !== null && <circle cx={a} cy={y} r={4.6} fill={iv.closedFrom ? col : '#fff'} stroke={col} strokeWidth={2.2} />}
|
||||
{iv.to !== null && <circle cx={b} cy={y} r={4.6} fill={iv.closedTo ? col : '#fff'} stroke={col} strokeWidth={2.2} />}
|
||||
{iv.label && <Label x={(Math.max(a, PAD) + Math.min(b, W - PAD)) / 2} y={y - 7} color={col} size={10.5}>{mathText(iv.label)}</Label>}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{spec.jumps.map((j, i) => {
|
||||
const a = X(j.from), b = X(j.to);
|
||||
const lift = Math.min(42, Math.abs(b - a) * 0.45 + 12);
|
||||
const y = base - 6 - lanes * LANE;
|
||||
return (
|
||||
<g key={`j${i}`}>
|
||||
<path d={`M${a},${y} Q${(a + b) / 2},${y - lift * 1.5} ${b},${y}`} fill="none" stroke={COLORS.red} strokeWidth={2.3} markerEnd={`url(#${arrowId(COLORS.red)})`} />
|
||||
{j.label && <Label x={(a + b) / 2} y={y - lift * 0.8 - 4} color={COLORS.red} size={10.5}>{mathText(j.label)}</Label>}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{spec.points.map((p, i) => {
|
||||
const col = color(p.color, i === spec.points.length - 1 && spec.points.length > 1 ? COLORS.red : COLORS.blue);
|
||||
const numeric = /^[-−+]?[\d۰-۹.,٫/]+$/.test(p.label.trim());
|
||||
return (
|
||||
<g key={`p${i}`}>
|
||||
<circle cx={X(p.at)} cy={base} r={5.6} fill={p.open ? '#fff' : col} stroke={p.open ? col : '#fff'} strokeWidth={p.open ? 2.4 : 1.6} />
|
||||
{p.label && !numeric && <Label x={X(p.at)} y={base + (spec.fractions ? 50 : 36)} color={col} size={10.5}>{p.label}</Label>}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
796
src/diagram/plane.tsx
Normal file
@@ -0,0 +1,796 @@
|
||||
import { useId, type ReactNode } from 'react';
|
||||
import type { ColorName, FunctionMark, PlaneItem, PlaneSpec, PointDef, Ref, Transform } from './types';
|
||||
import { derivative, extrema, parseExpr, roots } from './expr';
|
||||
import { expandQuad, expandTriangle, type Layout } from './shapes';
|
||||
import { Arrowheads, COLORS, GRID, INK, Label, PAPER, PERSIAN_LETTER, bidi, SERIES, SOFT, arrowId, color, fa, ltr, mathText, niceStep, piLabel, ticksBetween } from './svg';
|
||||
|
||||
/**
|
||||
* صفحهی مختصات و هندسه. همهی مختصات اینجا حساب میشوند:
|
||||
* نقطهها polar، وسطِ پارهخط، پای عمود، محلِ برخوردِ دو خط، نقطهی روی نمودار
|
||||
* تابعها ریشه، رأس/اکسترمم، عرض از مبدأ، محورِ تقارن، شیبِ مماس
|
||||
* تبدیلها دوران، قرینه، انتقال، تجانس
|
||||
* مدل فقط میگوید کدام را میخواهد. هر تکهای که حل نشود (نقطهی ناشناخته، فرمولِ
|
||||
* نامعتبر) بیصدا حذف میشود و بقیهی شکل کشیده میشود.
|
||||
*/
|
||||
|
||||
type XY = [number, number];
|
||||
const W = 320;
|
||||
const PAD = 24;
|
||||
|
||||
const GEOMETRY = new Set(['circle', 'arc', 'sector', 'angle', 'rightAngle', 'polygon', 'image']);
|
||||
|
||||
/* ─── هندسهی نقطهها ─────────────────────────────────────────────────────── */
|
||||
|
||||
function footOf(p: XY, a: XY, b: XY): XY {
|
||||
const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
|
||||
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy || 1);
|
||||
return [a[0] + t * dx, a[1] + t * dy];
|
||||
}
|
||||
|
||||
function intersect(a: XY, b: XY, c: XY, d: XY): XY | null {
|
||||
const den = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
|
||||
if (Math.abs(den) < 1e-12) return null;
|
||||
const t = ((a[0] - c[0]) * (c[1] - d[1]) - (a[1] - c[1]) * (c[0] - d[0])) / den;
|
||||
return [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])];
|
||||
}
|
||||
|
||||
function transformPoint(p: XY, tr: Transform, ref: (r: Ref) => XY | null): XY | null {
|
||||
if ('rotate' in tr) {
|
||||
const c = ref(tr.rotate.center);
|
||||
if (!c) return null;
|
||||
// دوران مثبت = پادساعتگرد، مثلِ کتاب؛ «در جهتِ عقربهها» یعنی زاویهی منفی.
|
||||
const t = (tr.rotate.deg * Math.PI) / 180, [x, y] = [p[0] - c[0], p[1] - c[1]];
|
||||
return [c[0] + x * Math.cos(t) - y * Math.sin(t), c[1] + x * Math.sin(t) + y * Math.cos(t)];
|
||||
}
|
||||
if ('translate' in tr) return [p[0] + tr.translate[0], p[1] + tr.translate[1]];
|
||||
if ('scale' in tr) {
|
||||
const c = ref(tr.scale.center);
|
||||
return c ? [c[0] + tr.scale.k * (p[0] - c[0]), c[1] + tr.scale.k * (p[1] - c[1])] : null;
|
||||
}
|
||||
const r = tr.reflect;
|
||||
if (r === 'x') return [p[0], -p[1]];
|
||||
if (r === 'y') return [-p[0], p[1]];
|
||||
if (r === 'y=x') return [p[1], p[0]];
|
||||
const a = ref(r[0]), b = ref(r[1]);
|
||||
if (!a || !b) return null;
|
||||
const f = footOf(p, a, b);
|
||||
return [2 * f[0] - p[0], 2 * f[1] - p[1]];
|
||||
}
|
||||
|
||||
/** نقطههای نامدار، با حلِ ارجاعِ زنجیرهای و بدونِ حلقهی بیپایان. */
|
||||
function resolvePoints(spec: PlaneSpec) {
|
||||
const named = new Map<string, XY>();
|
||||
const resolving = new Set<string>();
|
||||
const ref = (r: Ref): XY | null => (typeof r === 'string' ? byName(r) : [r[0], r[1]]);
|
||||
function byName(name: string): XY | null {
|
||||
if (named.has(name)) return named.get(name)!;
|
||||
const def: PointDef | undefined = spec.points[name];
|
||||
if (def === undefined || resolving.has(name)) return null;
|
||||
resolving.add(name);
|
||||
const xy = build(def);
|
||||
resolving.delete(name);
|
||||
if (xy && Number.isFinite(xy[0]) && Number.isFinite(xy[1])) named.set(name, xy);
|
||||
return named.get(name) ?? null;
|
||||
}
|
||||
function build(def: PointDef): XY | null {
|
||||
if (Array.isArray(def)) return [def[0], def[1]];
|
||||
if ('polar' in def) {
|
||||
const o = def.from !== undefined ? ref(def.from) : [0, 0];
|
||||
if (!o) return null;
|
||||
const t = (def.polar[1] * Math.PI) / 180;
|
||||
return [o[0] + def.polar[0] * Math.cos(t), o[1] + def.polar[0] * Math.sin(t)];
|
||||
}
|
||||
if ('mid' in def) {
|
||||
const a = ref(def.mid[0]), b = ref(def.mid[1]);
|
||||
return a && b ? [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] : null;
|
||||
}
|
||||
if ('foot' in def) {
|
||||
const p = ref(def.foot[0]), a = ref(def.foot[1]), b = ref(def.foot[2]);
|
||||
return p && a && b ? footOf(p, a, b) : null;
|
||||
}
|
||||
if ('intersect' in def) {
|
||||
const [[a, b], [c, d]] = def.intersect;
|
||||
const pa = ref(a), pb = ref(b), pc = ref(c), pd = ref(d);
|
||||
return pa && pb && pc && pd ? intersect(pa, pb, pc, pd) : null;
|
||||
}
|
||||
if ('along' in def) {
|
||||
const a = ref(def.along[0]), b = ref(def.along[1]), t = def.along[2];
|
||||
return a && b ? [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])] : null;
|
||||
}
|
||||
if ('translate' in def) {
|
||||
const o = ref(def.from);
|
||||
return o ? [o[0] + def.translate[0], o[1] + def.translate[1]] : null;
|
||||
}
|
||||
if ('fx' in def) {
|
||||
try {
|
||||
const x = def.fx[1];
|
||||
return [x, parseExpr(def.fx[0])(x)];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Object.keys(spec.points).forEach(byName);
|
||||
return { named, ref };
|
||||
}
|
||||
|
||||
/* ─── نمونهبرداری از نمودار، با شکستن در مجانب ──────────────────────────── */
|
||||
|
||||
function sample(f: (x: number) => number, from: number, to: number, n = 360): XY[][] {
|
||||
const runs: XY[][] = [];
|
||||
let run: XY[] = [];
|
||||
let prev: number | null = null;
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const x = from + ((to - from) * i) / n, y = f(x);
|
||||
const jump = prev !== null && Number.isFinite(y) && Math.abs(y - prev) > 50 * (Math.abs(prev) + Math.abs(y) + 1) / Math.max(1, n / 60);
|
||||
if (!Number.isFinite(y) || jump) {
|
||||
if (run.length > 1) runs.push(run);
|
||||
run = Number.isFinite(y) ? [[x, y]] : [];
|
||||
} else run.push([x, y]);
|
||||
prev = Number.isFinite(y) ? y : null;
|
||||
}
|
||||
if (run.length > 1) runs.push(run);
|
||||
return runs;
|
||||
}
|
||||
|
||||
interface Curve { f: (x: number) => number; from: number; to: number; runs: XY[][] }
|
||||
|
||||
function compile(expr: string, from: number, to: number): Curve | null {
|
||||
try {
|
||||
const f = parseExpr(expr);
|
||||
const runs = sample(f, from, to);
|
||||
return runs.length ? { f, from, to, runs } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── برچسبِ ترکیبی: «رأس (۱، −۱)» دو سطر میشود تا جهتِ متن به هم نریزد ── */
|
||||
|
||||
export function splitLabel(text: string): [string, string] | null {
|
||||
if (!PERSIAN_LETTER.test(text)) return null;
|
||||
const m = /^([^()\d۰-۹=]*?[-ۿ][^()\d۰-۹=]*?)\s*([(\d۰-۹−\-+].*)$/.exec(text);
|
||||
return m && /[\d۰-۹]/.test(m[2]) ? [m[1].trim(), m[2].trim()] : null;
|
||||
}
|
||||
|
||||
/** رقمِ لاتین به فارسی و منفیِ درست، در متنِ ریاضیِ برچسبها. */
|
||||
export { mathText };
|
||||
|
||||
function RichLabel({ x, y, text, fill, size = 11, anchor = 'middle' }: { x: number; y: number; text: string; fill: string; size?: number; anchor?: 'start' | 'middle' | 'end' }) {
|
||||
const parts = splitLabel(text);
|
||||
if (!parts) return <Label x={x} y={y} color={fill} size={size} anchor={anchor}>{mathText(text)}</Label>;
|
||||
return (
|
||||
<g>
|
||||
<Label x={x} y={y - size * 0.55} color={fill} size={size} anchor={anchor}>{parts[0]}</Label>
|
||||
<text x={x} y={y + size * 0.75} fontSize={size - 0.5} fontWeight={700} fill={fill} textAnchor={anchor} {...bidi(mathText(parts[1]))}
|
||||
stroke={PAPER} strokeWidth={3} paintOrder="stroke">{mathText(parts[1])}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── خودِ شکل ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* قطعههایی که به قطعههای سادهتر باز میشوند، پیش از هر حسابی:
|
||||
* regularPolygon → نقطههای polar با زاویههای مساوی + polygon (مدل مختصاتِ رأسِ پنجضلعیِ
|
||||
* منتظم را خودش حدس زده بود و شکل منتظم نشد)
|
||||
* exteriorAngle → نقطهی پنهان روی امتدادِ ضلع + پارهخطِ خطچین + angle (مدل با angle همیشه
|
||||
* زاویهی داخلی را میکشید)
|
||||
* triangle، quad → رأسها از روی ضلع و زاویه و ارتفاعِ مسئله (shapes.ts)
|
||||
*/
|
||||
const COMPOUND = new Set(['regularPolygon', 'exteriorAngle', 'triangle', 'quad']);
|
||||
|
||||
function expand(spec: PlaneSpec): PlaneSpec {
|
||||
if (!spec.items.some((i) => COMPOUND.has(i.type))) return spec;
|
||||
const points: Record<string, PointDef> = { ...spec.points };
|
||||
const items: PlaneItem[] = [];
|
||||
const layout: Layout = { placed: [] };
|
||||
const taken = (n: string) => n in points || spec.items.some((i) => (i.type === 'triangle' || i.type === 'quad') && (i.names as string[]).includes(n));
|
||||
spec.items.forEach((item, idx) => {
|
||||
if (item.type === 'triangle') {
|
||||
// دادهی متناقض (۳، ۴، ۱۰) مثلثی نمیسازد: فقط همین item حذف میشود
|
||||
items.push(...(expandTriangle(item, idx, points, taken, layout) ?? []));
|
||||
} else if (item.type === 'quad') {
|
||||
items.push(...expandQuad(item, idx, points, layout));
|
||||
} else if (item.type === 'regularPolygon') {
|
||||
const center = item.center ?? '_rc' + idx;
|
||||
if (!item.center) points['_rc' + idx] = [0, 0];
|
||||
// پیشفرض: ضلعِ پایین افقی، مثلِ کتاب
|
||||
const start = item.start ?? -90 - 180 / item.n;
|
||||
const names = Array.from({ length: item.n }, (_, i) => item.names?.[i] ?? `_r${idx}_${i}`);
|
||||
names.forEach((name, i) => { points[name] = { polar: [item.r, start + (360 * i) / item.n], from: center }; });
|
||||
items.push({ type: 'polygon', points: names, fill: item.fill, color: item.color, dashed: item.dashed, label: item.label });
|
||||
} else if (item.type === 'exteriorAngle') {
|
||||
const ext = `_ext${idx}`;
|
||||
points[ext] = { along: [item.from, item.vertex, 1.7] };
|
||||
items.push({ type: 'segment', from: item.vertex, to: ext, dashed: true, color: 'gray' });
|
||||
items.push({ type: 'angle', vertex: item.vertex, from: ext, to: item.to, label: item.label, color: item.color });
|
||||
} else items.push(item);
|
||||
});
|
||||
return { ...spec, points, items };
|
||||
}
|
||||
|
||||
/** نامهایی که یک item یا تعریفِ نقطهی دیگری به آنها ارجاع داده. */
|
||||
function referencedNames(spec: PlaneSpec): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const walk = (v: unknown) => {
|
||||
if (typeof v === 'string') { if (v in spec.points) out.add(v); }
|
||||
else if (Array.isArray(v)) v.forEach(walk);
|
||||
else if (v && typeof v === 'object') Object.values(v).forEach(walk);
|
||||
};
|
||||
spec.items.forEach(walk);
|
||||
Object.values(spec.points).forEach(walk);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function Plane({ spec: raw }: { spec: PlaneSpec }) {
|
||||
const spec = expand(raw);
|
||||
// شناسهی یکتا: چند شکل در یک گفتگو نباید clipPathِ همدیگر را بگیرند.
|
||||
const clipId = `dg-plot-${useId().replace(/:/g, '')}`;
|
||||
const { named, ref } = resolvePoints(spec);
|
||||
|
||||
// تصویرِ تبدیلها پیش از همه، تا A′ مثلِ هر نقطهی دیگر قابلِ ارجاع باشد.
|
||||
const images: { item: Extract<PlaneItem, { type: 'image' }>; pts: XY[]; names: string[] }[] = [];
|
||||
for (const item of spec.items) {
|
||||
if (item.type !== 'image') continue;
|
||||
const pts: XY[] = [], names: string[] = [];
|
||||
item.of.forEach((r, i) => {
|
||||
const p = ref(r);
|
||||
const q = p && transformPoint(p, item.transform, ref);
|
||||
if (!q) return;
|
||||
const name = item.names?.[i] ?? (typeof r === 'string' ? `${r}′` : '');
|
||||
pts.push(q); names.push(name);
|
||||
if (name && !named.has(name)) named.set(name, q);
|
||||
});
|
||||
if (pts.length) images.push({ item, pts, names });
|
||||
}
|
||||
|
||||
// نمودارها، برای کادر و برای رسم.
|
||||
let series = 0;
|
||||
const nextColor = (c?: ColorName) => COLORS[c ?? SERIES[series++ % SERIES.length]];
|
||||
const curves = new Map<PlaneItem, Curve[]>();
|
||||
for (const item of spec.items) {
|
||||
if (item.type === 'function' || item.type === 'area' || item.type === 'region') {
|
||||
const range = item.type === 'region' ? [item.from ?? -50, item.to ?? 50] : [item.from, item.to];
|
||||
const c = compile(item.expr, range[0], range[1]);
|
||||
if (c) curves.set(item, [c]);
|
||||
if (item.type === 'area' && item.expr2) {
|
||||
const c2 = compile(item.expr2, item.from, item.to);
|
||||
if (c && c2) curves.set(item, [c, c2]);
|
||||
}
|
||||
} else if (item.type === 'piecewise') {
|
||||
const cs = item.pieces.map((p) => compile(p.expr, p.from, p.to)).filter((c): c is Curve => c !== null);
|
||||
if (cs.length) curves.set(item, cs);
|
||||
} else if (item.type === 'tangent') {
|
||||
try {
|
||||
const f = parseExpr(item.expr);
|
||||
if (Number.isFinite(f(item.at))) curves.set(item, [{ f, from: item.at, to: item.at, runs: [] }]);
|
||||
} catch { /* فرمولِ نامعتبر */ }
|
||||
}
|
||||
}
|
||||
|
||||
// نقطههای ویژهی نمودار (ریشه، رأس، …) که کد حساب میکند.
|
||||
const markPts: { xy: XY; kind: FunctionMark | 'max' | 'min'; color: string }[] = [];
|
||||
const axisLines: number[] = [];
|
||||
for (const item of spec.items) {
|
||||
if (item.type !== 'function' || !item.marks?.length) continue;
|
||||
const c = curves.get(item)?.[0];
|
||||
if (!c) continue;
|
||||
const want = new Set(item.marks);
|
||||
if (want.has('roots')) for (const r of roots(c.f, c.from, c.to).slice(0, 6)) markPts.push({ xy: [r, 0], kind: 'roots', color: COLORS.green });
|
||||
if (want.has('vertex') || want.has('extrema') || want.has('axis')) {
|
||||
const ex = extrema(c.f, c.from, c.to).slice(0, 6);
|
||||
if (want.has('vertex') || want.has('extrema')) for (const e of ex) markPts.push({ xy: [e.x, e.y], kind: want.has('vertex') ? 'vertex' : e.kind, color: COLORS.red });
|
||||
if (want.has('axis') && ex.length === 1) axisLines.push(ex[0].x);
|
||||
}
|
||||
if (want.has('yIntercept') && c.from <= 0 && c.to >= 0) {
|
||||
const y = c.f(0);
|
||||
if (Number.isFinite(y)) markPts.push({ xy: [0, y], kind: 'yIntercept', color: COLORS.orange });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── کادر ── */
|
||||
const xs: number[] = [], ys: number[] = [];
|
||||
const add = (p: XY | null) => { if (p) { xs.push(p[0]); ys.push(p[1]); } };
|
||||
named.forEach((p) => add(p));
|
||||
images.forEach((im) => im.pts.forEach(add));
|
||||
markPts.forEach((m) => add(m.xy));
|
||||
for (const item of spec.items) {
|
||||
switch (item.type) {
|
||||
case 'circle': case 'arc': case 'sector': {
|
||||
const c = ref(item.center);
|
||||
const through = item.type === 'circle' && item.through ? ref(item.through) : null;
|
||||
const r = item.type === 'circle' ? (through && c ? Math.hypot(through[0] - c[0], through[1] - c[1]) : item.r ?? 0) : item.r;
|
||||
if (c) { add([c[0] - r, c[1] - r]); add([c[0] + r, c[1] + r]); }
|
||||
break;
|
||||
}
|
||||
case 'segment': case 'arrow': case 'vector': add(ref(item.from)); add(ref(item.to)); break;
|
||||
case 'ray': add(ref(item.from)); add(ref(item.through)); break;
|
||||
case 'line': { const [a, b] = lineEnds(item, ref); if (!item.eq && !item.equation) { add(a); add(b); } else if (a) add(a); break; }
|
||||
case 'polygon': item.points.forEach((p) => add(ref(p))); break;
|
||||
case 'point': add(ref(item.point)); break;
|
||||
case 'text': add(item.at); break;
|
||||
case 'vline': xs.push(item.x); break;
|
||||
case 'hline': ys.push(item.y); break;
|
||||
case 'tangent': { const c = curves.get(item)?.[0]; if (c) add([item.at, c.f(item.at)]); break; }
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
for (const [item, cs] of curves) {
|
||||
if (item.type === 'region' || item.type === 'tangent') continue;
|
||||
for (const c of cs) {
|
||||
xs.push(c.from, c.to);
|
||||
const all = c.runs.flat().map((p) => p[1]).sort((a, b) => a - b);
|
||||
if (all.length) ys.push(all[Math.floor(all.length * 0.04)], all[Math.ceil(all.length * 0.96) - 1]);
|
||||
}
|
||||
}
|
||||
if (spec.axes) { xs.push(0); ys.push(0); }
|
||||
if (!xs.length || !ys.length) return null;
|
||||
|
||||
let [x0, x1] = spec.xRange ?? [Math.min(...xs), Math.max(...xs)];
|
||||
let [y0, y1] = spec.yRange ?? [Math.min(...ys), Math.max(...ys)];
|
||||
if (x1 - x0 < 1e-9) { x0 -= 1; x1 += 1; }
|
||||
if (y1 - y0 < 1e-9) { y0 -= 1; y1 += 1; }
|
||||
if (!spec.xRange) { const m = (x1 - x0) * 0.1 + 0.15; x0 -= m; x1 += m; }
|
||||
if (!spec.yRange) { const m = (y1 - y0) * 0.1 + 0.15; y0 -= m; y1 += m; }
|
||||
|
||||
// مقیاسِ یکسان برای هندسه (دایره نباید بیضی شود)؛ برای نمودارِ تنها، ارتفاع سقف دارد.
|
||||
const geometry = spec.items.some((i) => GEOMETRY.has(i.type));
|
||||
const plotW = W - 2 * PAD;
|
||||
let sx = plotW / (x1 - x0), sy = sx;
|
||||
let H = (y1 - y0) * sy + 2 * PAD;
|
||||
const [minH, maxH] = [170, 380];
|
||||
if (H > maxH) {
|
||||
if (geometry) { sy = sx = (maxH - 2 * PAD) / (y1 - y0); } else sy = (maxH - 2 * PAD) / (y1 - y0);
|
||||
H = maxH;
|
||||
} else if (H < minH && !geometry) {
|
||||
sy = (minH - 2 * PAD) / (y1 - y0);
|
||||
H = minH;
|
||||
} else H = Math.max(H, 120);
|
||||
const ox = (W - (x1 - x0) * sx) / 2, oy = (H - (y1 - y0) * sy) / 2;
|
||||
const X = (x: number) => ox + (x - x0) * sx;
|
||||
const Y = (y: number) => oy + (y1 - y) * sy;
|
||||
const P = (p: XY): XY => [X(p[0]), Y(p[1])];
|
||||
const path = (pts: XY[]) => pts.map((p, i) => `${i ? 'L' : 'M'}${X(p[0]).toFixed(2)},${Y(p[1]).toFixed(2)}`).join(' ');
|
||||
|
||||
const layers = { back: [] as ReactNode[], fill: [] as ReactNode[], mid: [] as ReactNode[], curves: [] as ReactNode[], marks: [] as ReactNode[], points: [] as ReactNode[], labels: [] as ReactNode[] };
|
||||
const arrowColors = new Set<string>([SOFT]);
|
||||
let key = 0;
|
||||
const k = () => key++;
|
||||
const clip = `url(#${clipId})`;
|
||||
|
||||
const centroid: XY = (() => {
|
||||
const all = [...named.values()];
|
||||
return all.length ? [all.reduce((a, p) => a + p[0], 0) / all.length, all.reduce((a, p) => a + p[1], 0) / all.length] : [(x0 + x1) / 2, (y0 + y1) / 2];
|
||||
})();
|
||||
const away = (p: XY, dist = 13): XY => {
|
||||
const [px, py] = P(p), [cx, cy] = P(centroid);
|
||||
let [dx, dy] = [px - cx, py - cy];
|
||||
const n = Math.hypot(dx, dy);
|
||||
if (n < 1e-6) { dx = 0.6; dy = -0.8; } else { dx /= n; dy /= n; }
|
||||
return [px + dx * dist, py + dy * dist + 4];
|
||||
};
|
||||
|
||||
/* ── شبکه و محور ── */
|
||||
const trig = spec.trig ?? false;
|
||||
const xstep = trig ? (x1 - x0 > 5 * Math.PI ? Math.PI : Math.PI / 2) : spec.grid ? Math.max(1, niceStep(x1 - x0, 12)) : niceStep(x1 - x0, 8);
|
||||
const ystep = spec.grid ? Math.max(1, niceStep(y1 - y0, 12)) : niceStep(y1 - y0, 7);
|
||||
const xticks = ticksBetween(x0, x1, xstep), yticks = ticksBetween(y0, y1, ystep);
|
||||
if (spec.axes || spec.grid) {
|
||||
layers.back.push(
|
||||
<g key={k()} stroke={GRID} strokeWidth={1}>
|
||||
{xticks.map((t) => <line key={`gx${t}`} x1={X(t)} x2={X(t)} y1={Y(y0)} y2={Y(y1)} />)}
|
||||
{yticks.map((t) => <line key={`gy${t}`} y1={Y(t)} y2={Y(t)} x1={X(x0)} x2={X(x1)} />)}
|
||||
</g>,
|
||||
);
|
||||
}
|
||||
if (spec.axes) {
|
||||
const ax = x0 <= 0 && x1 >= 0 ? X(0) : X(x0), ay = y0 <= 0 && y1 >= 0 ? Y(0) : Y(y0);
|
||||
layers.back.push(
|
||||
<g key={k()} stroke={SOFT} strokeWidth={1.4}>
|
||||
<line x1={X(x0)} y1={ay} x2={X(x1) + 6} y2={ay} markerEnd={`url(#${arrowId(SOFT)})`} />
|
||||
<line x1={ax} y1={Y(y0)} x2={ax} y2={Y(y1) - 6} markerEnd={`url(#${arrowId(SOFT)})`} />
|
||||
</g>,
|
||||
<g key={k()} fill={SOFT} fontSize={9.5} fontWeight={600}>
|
||||
{xticks.filter((t) => Math.abs(t) > 1e-9).map((t) => (
|
||||
<g key={`tx${t}`}>
|
||||
<line x1={X(t)} x2={X(t)} y1={ay - 3} y2={ay + 3} stroke={SOFT} />
|
||||
<text x={X(t)} y={ay + 13} textAnchor="middle">{trig ? piLabel(t) : fa(t)}</text>
|
||||
</g>
|
||||
))}
|
||||
{yticks.filter((t) => Math.abs(t) > 1e-9).map((t) => (
|
||||
<g key={`ty${t}`}>
|
||||
<line x1={ax - 3} x2={ax + 3} y1={Y(t)} y2={Y(t)} stroke={SOFT} />
|
||||
<text x={ax - 6} y={Y(t) + 3.5} textAnchor="end">{fa(t)}</text>
|
||||
</g>
|
||||
))}
|
||||
{x0 <= 0 && x1 >= 0 && y0 <= 0 && y1 >= 0 && <text x={ax - 6} y={ay + 13} textAnchor="end">۰</text>}
|
||||
<text x={X(x1) + 2} y={ay - 7} textAnchor="end" fontStyle="italic">x</text>
|
||||
<text x={ax + 7} y={Y(y1) + 4} fontStyle="italic">y</text>
|
||||
</g>,
|
||||
);
|
||||
}
|
||||
|
||||
/* ── تکهها ── */
|
||||
for (const item of spec.items) {
|
||||
const itemColor = (fallback: string) => color(item.color, fallback);
|
||||
switch (item.type) {
|
||||
case 'circle': {
|
||||
const c = ref(item.center);
|
||||
if (!c) break;
|
||||
const through = item.through ? ref(item.through) : null;
|
||||
const r = through ? Math.hypot(through[0] - c[0], through[1] - c[1]) : item.r ?? 0;
|
||||
if (r <= 0) break;
|
||||
const col = itemColor(COLORS.blue);
|
||||
layers.mid.push(<ellipse key={k()} cx={X(c[0])} cy={Y(c[1])} rx={r * sx} ry={r * sy} fill={item.fill ? `${col}1f` : 'none'} stroke={col} strokeWidth={2.2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
|
||||
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(c[0] + r * 0.72)} y={Y(c[1] + r * 0.72) - 4} text={item.label} fill={col} />);
|
||||
break;
|
||||
}
|
||||
case 'arc': case 'sector': {
|
||||
const c = ref(item.center);
|
||||
if (!c) break;
|
||||
const col = itemColor(item.type === 'sector' ? COLORS.orange : COLORS.red);
|
||||
const a0 = (item.start * Math.PI) / 180, a1 = (item.end * Math.PI) / 180;
|
||||
const pts: XY[] = [];
|
||||
for (let i = 0; i <= 64; i++) { const t = a0 + ((a1 - a0) * i) / 64; pts.push([c[0] + item.r * Math.cos(t), c[1] + item.r * Math.sin(t)]); }
|
||||
const d = item.type === 'sector' ? `${path([c, ...pts])} Z` : path(pts);
|
||||
layers.mid.push(<path key={k()} d={d} fill={item.type === 'sector' ? `${col}33` : 'none'} stroke={col} strokeWidth={item.type === 'arc' ? 3 : 2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
|
||||
if (item.label) {
|
||||
const m = (a0 + a1) / 2, rr = item.type === 'arc' ? item.r * 1.16 : item.r * 0.6;
|
||||
layers.labels.push(<RichLabel key={k()} x={X(c[0] + rr * Math.cos(m))} y={Y(c[1] + rr * Math.sin(m)) + 4} text={item.label} fill={col} />);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'polygon': {
|
||||
const pts = item.points.map(ref).filter((p): p is XY => p !== null);
|
||||
if (pts.length < 3) break;
|
||||
const col = itemColor(COLORS.blue);
|
||||
layers.fill.push(<path key={k()} d={`${path(pts)} Z`} fill={item.fill === false ? 'none' : `${col}1a`} stroke={item.color ? col : INK} strokeWidth={2.2} strokeLinejoin="round" strokeDasharray={item.dashed ? '6 5' : undefined} />);
|
||||
if (item.label) {
|
||||
const cx = pts.reduce((a, p) => a + p[0], 0) / pts.length, cy = pts.reduce((a, p) => a + p[1], 0) / pts.length;
|
||||
layers.labels.push(<RichLabel key={k()} x={X(cx)} y={Y(cy) + 4} text={item.label} fill={col} />);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'segment': case 'arrow': case 'vector': {
|
||||
const a = ref(item.from), b = ref(item.to);
|
||||
if (!a || !b) break;
|
||||
const isArrow = item.type !== 'segment';
|
||||
const col = itemColor(isArrow ? (item.type === 'vector' ? COLORS.purple : COLORS.red) : INK);
|
||||
if (isArrow) arrowColors.add(col);
|
||||
const [ax, ay] = P(a), [bx, by] = P(b);
|
||||
layers.mid.push(<line key={k()} x1={ax} y1={ay} x2={bx} y2={by} stroke={col} strokeWidth={isArrow ? 2.4 : 2.2} strokeLinecap="round"
|
||||
strokeDasharray={item.dashed ? '6 5' : undefined} markerEnd={isArrow ? `url(#${arrowId(col)})` : undefined} />);
|
||||
if (item.type === 'segment' && item.ticks) layers.mid.push(<Ticks key={k()} a={[ax, ay]} b={[bx, by]} n={item.ticks} col={col} />);
|
||||
if (item.label) {
|
||||
const [lx, ly, anchor] = sideOffset([ax, ay], [bx, by], P(centroid));
|
||||
layers.labels.push(<RichLabel key={k()} x={lx} y={ly} anchor={anchor} text={item.label} fill={item.color ? col : COLORS.blue} />);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'line': case 'ray': {
|
||||
const [a, b] = item.type === 'line' ? lineEnds(item, ref) : [ref(item.from), ref(item.through)];
|
||||
if (!a || !b || (a[0] === b[0] && a[1] === b[1])) break;
|
||||
const col = itemColor(COLORS.blue);
|
||||
const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
|
||||
const t = (Math.abs(x1 - x0) + Math.abs(y1 - y0)) * 3 / Math.hypot(dx, dy);
|
||||
const s: XY = item.type === 'line' ? [a[0] - dx * t, a[1] - dy * t] : a;
|
||||
const e: XY = [a[0] + dx * t, a[1] + dy * t];
|
||||
layers.mid.push(<line key={k()} clipPath={clip} x1={X(s[0])} y1={Y(s[1])} x2={X(e[0])} y2={Y(e[1])} stroke={col} strokeWidth={2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
|
||||
if (item.label) {
|
||||
const lp = edgePoint(a, b, x0, x1, y0, y1);
|
||||
layers.labels.push(<RichLabel key={k()} x={X(lp[0])} y={Y(lp[1]) - 6} text={item.label} fill={col} />);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'vline': case 'hline': {
|
||||
const col = itemColor(COLORS.gray);
|
||||
const dash = item.dashed === false ? undefined : '5 4';
|
||||
if (item.type === 'vline') {
|
||||
layers.mid.push(<line key={k()} x1={X(item.x)} x2={X(item.x)} y1={Y(y0)} y2={Y(y1)} stroke={col} strokeWidth={1.8} strokeDasharray={dash} />);
|
||||
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(item.x) + 4} y={Y(y1) + 12} text={item.label} fill={col} />);
|
||||
} else {
|
||||
layers.mid.push(<line key={k()} y1={Y(item.y)} y2={Y(item.y)} x1={X(x0)} x2={X(x1)} stroke={col} strokeWidth={1.8} strokeDasharray={dash} />);
|
||||
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(x0) + 30} y={Y(item.y) - 7} text={item.label} fill={col} />);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'function': case 'piecewise': {
|
||||
const cs = curves.get(item);
|
||||
if (!cs) break;
|
||||
const col = nextColor(item.color);
|
||||
cs.forEach((c) => c.runs.forEach((run) => layers.curves.push(
|
||||
<path key={k()} clipPath={clip} d={path(run)} fill="none" stroke={col} strokeWidth={2.6} strokeLinejoin="round" strokeLinecap="round" strokeDasharray={item.dashed ? '7 5' : undefined} />,
|
||||
)));
|
||||
if (item.type === 'piecewise') {
|
||||
item.pieces.forEach((p) => {
|
||||
let f: (x: number) => number;
|
||||
try { f = parseExpr(p.expr); } catch { return; }
|
||||
([[p.from, p.closedFrom ?? true], [p.to, p.closedTo ?? true]] as [number, boolean][]).forEach(([x, closed]) => {
|
||||
const y = f(x);
|
||||
if (Number.isFinite(y) && Math.abs(x) < 1e3) layers.points.push(<circle key={k()} cx={X(x)} cy={Y(y)} r={4} fill={closed ? col : '#fff'} stroke={col} strokeWidth={2} />);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (item.label) {
|
||||
// سرِ راستِ بخشِ دیدهشدهی نمودار، کمی دورتر از خودِ خط.
|
||||
const vis = cs.flatMap((c) => c.runs.flat()).filter(([x, y]) => x >= x0 && x <= x1 && y >= y0 + (y1 - y0) * 0.04 && y <= y1 - (y1 - y0) * 0.04);
|
||||
const at = vis[vis.length - 1];
|
||||
if (at) {
|
||||
const slope = vis.length > 2 ? (Y(at[1]) - Y(vis[vis.length - 3][1])) : 0;
|
||||
layers.labels.push(<RichLabel key={k()} x={Math.min(W - 44, Math.max(44, X(at[0]) - 30))} y={Math.max(14, Math.min(H - 8, Y(at[1]) + (slope < 0 ? 16 : -10)))} text={item.label} fill={col} />);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tangent': {
|
||||
const c = curves.get(item)?.[0];
|
||||
if (!c) break;
|
||||
const col = itemColor(COLORS.red);
|
||||
const y = c.f(item.at), m = derivative(c.f, item.at);
|
||||
const span = (x1 - x0) * 2;
|
||||
layers.curves.push(<line key={k()} clipPath={clip} x1={X(item.at - span)} y1={Y(y - m * span)} x2={X(item.at + span)} y2={Y(y + m * span)} stroke={col} strokeWidth={2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
|
||||
layers.points.push(<circle key={k()} cx={X(item.at)} cy={Y(y)} r={4.2} fill={col} stroke="#fff" strokeWidth={1.5} />);
|
||||
const text = item.label ?? `شیب = ${fa(m)}`;
|
||||
layers.labels.push(<RichLabel key={k()} x={Math.min(W - 44, X(item.at) + 36)} y={Y(y) + (m > 0 ? 18 : -10)} text={text} fill={col} />);
|
||||
break;
|
||||
}
|
||||
case 'area': {
|
||||
const cs = curves.get(item);
|
||||
if (!cs) break;
|
||||
const col = nextColor(item.color);
|
||||
const top = cs[0].runs.flat();
|
||||
const bottom: XY[] = cs[1] ? cs[1].runs.flat().slice().reverse() : [[item.to, 0], [item.from, 0]];
|
||||
layers.fill.push(<path key={k()} clipPath={clip} d={`${path([...top, ...bottom])} Z`} fill={`${col}38`} stroke="none" />);
|
||||
if (item.label) {
|
||||
const mid = top[Math.floor(top.length / 2)];
|
||||
layers.labels.push(<RichLabel key={k()} x={X(mid[0])} y={(Y(mid[1]) + Y(cs[1] ? cs[1].f(mid[0]) : 0)) / 2 + 4} text={item.label} fill={col} />);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'region': {
|
||||
const c = curves.get(item)?.[0];
|
||||
if (!c) break;
|
||||
const col = itemColor(COLORS.blue);
|
||||
const edge = item.side === 'above' ? y1 + (y1 - y0) : y0 - (y1 - y0);
|
||||
c.runs.forEach((run) => {
|
||||
const poly: XY[] = [...run, [run[run.length - 1][0], edge], [run[0][0], edge]];
|
||||
layers.fill.push(<path key={k()} clipPath={clip} d={`${path(poly)} Z`} fill={`${col}26`} stroke="none" />);
|
||||
layers.curves.push(<path key={k()} clipPath={clip} d={path(run)} fill="none" stroke={col} strokeWidth={2.2} strokeDasharray={item.strict ? '6 5' : undefined} />);
|
||||
});
|
||||
if (item.label) {
|
||||
// جایی داخلِ ناحیه: بینِ نمودار و لبهی کادر، در یکچهارمِ عرض از سمتی که ناحیه پهنتر است.
|
||||
const candidates = [0.2, 0.5, 0.8].map((t) => x0 + (x1 - x0) * t);
|
||||
let best = candidates[0], room = -Infinity;
|
||||
for (const x of candidates) {
|
||||
const y = c.f(x);
|
||||
const r = item.side === 'above' ? y1 - y : y - y0;
|
||||
if (Number.isFinite(r) && r > room) { room = r; best = x; }
|
||||
}
|
||||
const yc = c.f(best);
|
||||
const yl = item.side === 'above' ? (Math.min(yc, y1) + y1) / 2 : (Math.max(yc, y0) + y0) / 2;
|
||||
layers.labels.push(<RichLabel key={k()} x={X(best)} y={Y(yl)} text={item.label} fill={col} />);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'angle': case 'rightAngle': {
|
||||
const v = ref(item.vertex), a = ref(item.from), b = ref(item.to);
|
||||
if (!v || !a || !b) break;
|
||||
const col = itemColor(COLORS.red);
|
||||
const [vx, vy] = P(v), [ax, ay] = P(a), [bx, by] = P(b);
|
||||
const ta = Math.atan2(ay - vy, ax - vx), tb = Math.atan2(by - vy, bx - vx);
|
||||
let d = tb - ta;
|
||||
while (d <= -Math.PI) d += 2 * Math.PI;
|
||||
while (d > Math.PI) d -= 2 * Math.PI;
|
||||
const r = Math.max(12, Math.min(26, Math.hypot(ax - vx, ay - vy) * 0.35, Math.hypot(bx - vx, by - vy) * 0.35));
|
||||
if (item.type === 'rightAngle') {
|
||||
const q = Math.min(r, 12);
|
||||
const ua: XY = [Math.cos(ta), Math.sin(ta)], ub: XY = [Math.cos(tb), Math.sin(tb)];
|
||||
const p1: XY = [vx + ua[0] * q, vy + ua[1] * q], p3: XY = [vx + ub[0] * q, vy + ub[1] * q];
|
||||
const p2: XY = [p1[0] + ub[0] * q, p1[1] + ub[1] * q];
|
||||
layers.marks.push(<path key={k()} d={`M${p1.join(',')} L${p2.join(',')} L${p3.join(',')}`} fill={`${col}22`} stroke={col} strokeWidth={1.8} />);
|
||||
} else {
|
||||
const arcs = Math.max(1, Math.min(3, item.arcs ?? 1));
|
||||
for (let i = 0; i < arcs; i++) {
|
||||
const rr = r + i * 4;
|
||||
const s: XY = [vx + rr * Math.cos(ta), vy + rr * Math.sin(ta)], e: XY = [vx + rr * Math.cos(ta + d), vy + rr * Math.sin(ta + d)];
|
||||
layers.marks.push(
|
||||
<path key={k()} d={i === 0 ? `M${vx},${vy} L${s.join(',')} A${rr},${rr} 0 0 ${d > 0 ? 1 : 0} ${e.join(',')} Z` : `M${s.join(',')} A${rr},${rr} 0 0 ${d > 0 ? 1 : 0} ${e.join(',')}`}
|
||||
fill={i === 0 ? `${col}2a` : 'none'} stroke={col} strokeWidth={1.7} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (item.label) {
|
||||
const m = ta + d / 2, lr = r + 15 + ((item.type === 'angle' ? item.arcs ?? 1 : 1) - 1) * 4;
|
||||
layers.labels.push(<RichLabel key={k()} x={vx + lr * Math.cos(m)} y={vy + lr * Math.sin(m) + 4} text={item.label} fill={col} />);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
layers.labels.push(<RichLabel key={k()} x={X(item.at[0])} y={Y(item.at[1])} text={item.text} fill={color(item.color, INK)} />);
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── تصویرِ تبدیلها ── */
|
||||
for (const im of images) {
|
||||
const col = color(im.item.color, COLORS.purple);
|
||||
if (im.pts.length >= 3) layers.fill.push(<path key={k()} d={`${path(im.pts)} Z`} fill={`${col}14`} stroke={col} strokeWidth={2.2} strokeDasharray={im.item.dashed === false ? undefined : '6 4'} strokeLinejoin="round" />);
|
||||
else if (im.pts.length === 2) layers.mid.push(<line key={k()} x1={X(im.pts[0][0])} y1={Y(im.pts[0][1])} x2={X(im.pts[1][0])} y2={Y(im.pts[1][1])} stroke={col} strokeWidth={2.2} strokeDasharray="6 4" />);
|
||||
im.pts.forEach((p, i) => {
|
||||
layers.points.push(<circle key={k()} cx={X(p[0])} cy={Y(p[1])} r={3.4} fill={col} />);
|
||||
if (im.names[i]) { const [lx, ly] = away(p); layers.labels.push(<Label key={k()} x={lx} y={ly} color={col} weight={800}>{im.names[i]}</Label>); }
|
||||
});
|
||||
}
|
||||
|
||||
/* ── نقطههای ویژهی نمودار ── */
|
||||
for (const x of axisLines) {
|
||||
layers.mid.push(<line key={k()} x1={X(x)} x2={X(x)} y1={Y(y0)} y2={Y(y1)} stroke={COLORS.gray} strokeWidth={1.6} strokeDasharray="5 4" />);
|
||||
layers.labels.push(<Label key={k()} x={X(x)} y={Y(y1) + 12} color={COLORS.gray} size={10}>{`x = ${fa(x)}`}</Label>);
|
||||
}
|
||||
const markName: Record<string, string> = { vertex: 'رأس', max: 'ماکزیمم', min: 'مینیمم', yIntercept: '', roots: '' };
|
||||
// عددِ نزدیک به مضربِ π/2 در نمودارِ مثلثاتی به صورتِ π نوشته میشود (۳٫۱۴ → π).
|
||||
const fx = (x: number) => (trig && Math.abs(x / (Math.PI / 2) - Math.round(x / (Math.PI / 2))) < 1e-6 ? piLabel(x) : fa(x));
|
||||
// بیش از چهار نقطه از یک نوع (ریشههای سینوس) فقط نقطه میگیرند، نه برچسب؛ وگرنه روی هم میافتند.
|
||||
const kindCount = new Map<string, number>();
|
||||
markPts.forEach((m) => kindCount.set(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind, (kindCount.get(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind) ?? 0) + 1));
|
||||
// اگر مدل خودش روی همان ریشه یا رأس نقطه گذاشته (نامدار یا با مختصات)، برچسبِ ما
|
||||
// تکرار نشود؛ وگرنه «(۳، ۰)» دو بار و روی هم نوشته میشد.
|
||||
const namedXY = [
|
||||
...named.values(),
|
||||
...spec.items.flatMap((it) => (it.type === 'point' && typeof it.point !== 'string' ? [it.point as XY] : [])),
|
||||
];
|
||||
for (const m of markPts) {
|
||||
if (namedXY.some((q) => Math.abs(q[0] - m.xy[0]) < 1e-3 && Math.abs(q[1] - m.xy[1]) < 1e-3)) continue;
|
||||
const [px, py] = P(m.xy);
|
||||
if (px < 0 || px > W || py < 0 || py > H) continue;
|
||||
layers.points.push(<circle key={k()} cx={px} cy={py} r={4.3} fill={m.color} stroke="#fff" strokeWidth={1.6} />);
|
||||
const crowded = (kindCount.get(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind) ?? 0) > 4;
|
||||
if (crowded) continue;
|
||||
const coord = ltr(`(${fx(m.xy[0])}, ${fa(m.xy[1])})`);
|
||||
const below = m.kind === 'min' || m.kind === 'vertex' ? m.xy[1] <= (y0 + y1) / 2 : m.kind === 'roots';
|
||||
const ly = below ? py + 16 : py - 9;
|
||||
const name = markName[m.kind];
|
||||
layers.labels.push(
|
||||
<g key={k()}>
|
||||
{name && <Label x={px} y={below ? ly + 12 : ly - 12} color={m.color} size={10}>{name}</Label>}
|
||||
<text x={px} y={ly} fontSize={10} fontWeight={800} fill={m.color} textAnchor="middle" {...bidi(coord)} stroke={PAPER} strokeWidth={3} paintOrder="stroke">{coord}</text>
|
||||
</g>,
|
||||
);
|
||||
}
|
||||
|
||||
/* ── نقطهها ── */
|
||||
const explicit = new Map<string, Extract<PlaneItem, { type: 'point' }>>();
|
||||
const loosePoints: Extract<PlaneItem, { type: 'point' }>[] = [];
|
||||
for (const item of spec.items) {
|
||||
if (item.type !== 'point') continue;
|
||||
if (typeof item.point === 'string') explicit.set(item.point, item); else loosePoints.push(item);
|
||||
}
|
||||
const drawPoint = (xy: XY, label: string, col: string, open = false) => {
|
||||
const [px, py] = P(xy);
|
||||
layers.points.push(<circle key={k()} cx={px} cy={py} r={open ? 4 : 3.8} fill={open ? '#fff' : col} stroke={open ? col : '#fff'} strokeWidth={open ? 2 : 1.4} />);
|
||||
if (label) { const [lx, ly] = away(xy, 14); layers.labels.push(<RichLabel key={k()} x={lx} y={ly} text={label} fill={col} />); }
|
||||
};
|
||||
const imageNames = new Set(images.flatMap((im) => im.names));
|
||||
// نقطهای که هیچ تکهای به آن ارجاع نداده رسم نمیشود (مدل هفت نقطهی امتداد تعریف کرده
|
||||
// بود که هیچجا به کار نرفتند و شکل را شلوغ کردند) — مگر شکل فقط از نقطه ساخته شده باشد.
|
||||
const used = referencedNames(spec);
|
||||
const onlyPoints = spec.items.length === 0;
|
||||
named.forEach((xy, name) => {
|
||||
if (name.startsWith('_') || imageNames.has(name)) return;
|
||||
if (!onlyPoints && !used.has(name)) return;
|
||||
const it = explicit.get(name);
|
||||
// مبدأ روی محور خودش «۰» دارد؛ فقط اگر مدل صریحاً نقطهاش را خواسته باشد برچسب میگیرد.
|
||||
if (spec.axes && !it && Math.abs(xy[0]) < 1e-9 && Math.abs(xy[1]) < 1e-9) return;
|
||||
drawPoint(xy, it?.label ?? name, color(it?.color, INK), it?.open);
|
||||
});
|
||||
loosePoints.forEach((it) => drawPoint(it.point as XY, it.label ?? '', color(it.color, INK), it.open));
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img" aria-label={spec.title || 'شکل'}>
|
||||
<defs>
|
||||
<clipPath id={clipId}><rect x={X(x0)} y={Y(y1)} width={(x1 - x0) * sx} height={(y1 - y0) * sy} /></clipPath>
|
||||
</defs>
|
||||
<Arrowheads ids={[...arrowColors]} />
|
||||
{layers.back}{layers.fill}{layers.mid}{layers.curves}{layers.marks}{layers.points}{layers.labels}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* دو نقطه از خط: یا through، یا از معادلهی ax + by = c (ضریبها از صورتِ مسئله؛
|
||||
* شیب را کد حساب میکند، چون مدل در بازنویسیِ 3x + 5y = 4 به y = … علامت را گم کرده بود).
|
||||
*/
|
||||
function lineEnds(item: Extract<PlaneItem, { type: 'line' }>, ref: (r: Ref) => XY | null): [XY | null, XY | null] {
|
||||
const coef = item.equation ? parseLinear(item.equation) : item.eq ?? null;
|
||||
if (coef) {
|
||||
// ax + by + c = 0
|
||||
const [a, b, c] = coef;
|
||||
if (Math.abs(b) > 1e-12) return [[0, -c / b], [1, (-c - a) / b]];
|
||||
if (Math.abs(a) > 1e-12) return [[-c / a, 0], [-c / a, 1]];
|
||||
return [null, null];
|
||||
}
|
||||
return item.through ? [ref(item.through[0]), ref(item.through[1])] : [null, null];
|
||||
}
|
||||
|
||||
/**
|
||||
* «3x + 5y = 4»، «y = 12x − 16»، «2y = x + 1»، «x = 2.5» → [a, b, c] با ax + by + c = 0.
|
||||
* هر چیزِ غیرخطی (x²، sin) null است و خط کشیده نمیشود.
|
||||
*/
|
||||
export function parseLinear(src: string): [number, number, number] | null {
|
||||
const text = toLatin(src).replace(/\s+/g, '').replace(/[−–]/g, '-').replace(/\*/g, '');
|
||||
const sides = text.split('=');
|
||||
if (sides.length !== 2) return null;
|
||||
const side = (t: string): [number, number, number] | null => {
|
||||
if (!t) return null;
|
||||
const terms = t.replace(/-/g, '+-').split('+').filter(Boolean);
|
||||
const acc: [number, number, number] = [0, 0, 0];
|
||||
for (const term of terms) {
|
||||
const m = /^(-?)(\d*\.?\d*)(?:\/(\d+\.?\d*))?([xy]?)$/.exec(term);
|
||||
if (!m || (!m[2] && !m[4])) return null;
|
||||
let v = m[2] ? Number(m[2]) : 1;
|
||||
if (m[3]) v /= Number(m[3]);
|
||||
if (m[1]) v = -v;
|
||||
acc[m[4] === 'x' ? 0 : m[4] === 'y' ? 1 : 2] += v;
|
||||
}
|
||||
return acc;
|
||||
};
|
||||
const l = side(sides[0]), r = side(sides[1]);
|
||||
if (!l || !r) return null;
|
||||
const out: [number, number, number] = [l[0] - r[0], l[1] - r[1], l[2] - r[2]];
|
||||
return Math.abs(out[0]) + Math.abs(out[1]) > 1e-12 ? out : null;
|
||||
}
|
||||
|
||||
const toLatin = (s: string) => s.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
|
||||
|
||||
/** علامتِ تساویِ ضلع: یک، دو یا سه خطِ کوچک عمود بر وسطِ پارهخط. */
|
||||
function Ticks({ a, b, n, col }: { a: XY; b: XY; n: number; col: string }) {
|
||||
const [mx, my] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
|
||||
const [ux, uy] = [(b[0] - a[0]) / len, (b[1] - a[1]) / len];
|
||||
const [nx, ny] = [-uy, ux];
|
||||
const count = Math.max(1, Math.min(3, n));
|
||||
return (
|
||||
<g stroke={col} strokeWidth={2}>
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
const o = (i - (count - 1) / 2) * 4.5;
|
||||
const cx = mx + ux * o, cy = my + uy * o;
|
||||
return <line key={i} x1={cx - nx * 6} y1={cy - ny * 6} x2={cx + nx * 6} y2={cy + ny * 6} />;
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* جای برچسبِ ضلع: وسطِ پارهخط، به سمتِ بیرونِ شکل. برای خطِ تقریباً عمودی برچسب
|
||||
* از کنارِ خط شروع میشود (نه وسطچین)، وگرنه متنِ بلند روی خودِ خط میافتد.
|
||||
*/
|
||||
function sideOffset(a: XY, b: XY, center: XY): [number, number, 'start' | 'middle' | 'end'] {
|
||||
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
|
||||
let nx = -(b[1] - a[1]), ny = b[0] - a[0];
|
||||
const n = Math.hypot(nx, ny) || 1;
|
||||
nx /= n; ny /= n;
|
||||
if ((mx - center[0]) * nx + (my - center[1]) * ny < 0) { nx = -nx; ny = -ny; }
|
||||
const anchor = nx > 0.6 ? 'start' : nx < -0.6 ? 'end' : 'middle';
|
||||
const d = anchor === 'middle' ? 14 : 8;
|
||||
return [mx + nx * d, my + ny * d + 4, anchor];
|
||||
}
|
||||
|
||||
/** نقطهای از خط که داخلِ کادر و نزدیکِ لبهی راستِ آن است، برای برچسبِ خط. */
|
||||
function edgePoint(a: XY, b: XY, x0: number, x1: number, y0: number, y1: number): XY {
|
||||
let [dx, dy] = [b[0] - a[0], b[1] - a[1]];
|
||||
const n = Math.hypot(dx, dy) || 1;
|
||||
[dx, dy] = [dx / n, dy / n];
|
||||
if (dx < 0 || (dx === 0 && dy < 0)) [dx, dy] = [-dx, -dy];
|
||||
const inside = (p: XY) => p[0] > x0 + (x1 - x0) * 0.1 && p[0] < x1 - (x1 - x0) * 0.1 && p[1] > y0 + (y1 - y0) * 0.1 && p[1] < y1 - (y1 - y0) * 0.1;
|
||||
const span = Math.hypot(x1 - x0, y1 - y0);
|
||||
let best: XY | null = null;
|
||||
for (let t = -span * 2; t <= span * 2; t += span / 200) {
|
||||
const p: XY = [a[0] + dx * t, a[1] + dy * t];
|
||||
if (inside(p)) best = p; // آخرین نقطهی داخلِ کادر در جهتِ راست
|
||||
}
|
||||
return best ?? [(x0 + x1) / 2, (y0 + y1) / 2];
|
||||
}
|
||||
166
src/diagram/sets.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useId } from 'react';
|
||||
import type { MappingSpec, TreeNode, TreeSpec, VennSpec } from './types';
|
||||
import { Arrowheads, COLORS, INK, Label, SERIES, arrowId, mathText } from './svg';
|
||||
|
||||
const W = 320;
|
||||
|
||||
/* ─── نمودارِ ون ──────────────────────────────────────────────────────────
|
||||
* ناحیهها با نامِ مجموعههایی که در آناند: «A» فقط A، «AB» اشتراکِ A و B (و نه
|
||||
* C)، «ABC»، و «out» بیرونِ همه. سایه با برشِ تو در تو (clipPath) و ماسکِ
|
||||
* «نه-این-مجموعه» ساخته میشود، پس ناحیهی سایهخورده دقیقاً همان ناحیه است. */
|
||||
|
||||
export function Venn({ spec }: { spec: VennSpec }) {
|
||||
const uid = useId().replace(/:/g, '');
|
||||
const sets = spec.sets.slice(0, 3);
|
||||
if (sets.length < 1) return null;
|
||||
const H = sets.length === 3 ? 250 : 190;
|
||||
const R = sets.length === 3 ? 62 : 70;
|
||||
const centers: [number, number][] = sets.length === 1 ? [[W / 2, 95]]
|
||||
: sets.length === 2 ? [[W / 2 - 45, 95], [W / 2 + 45, 95]]
|
||||
: [[W / 2 - 40, 95], [W / 2 + 40, 95], [W / 2, 160]];
|
||||
const ids = sets.map((_, i) => `v${uid}-${i}`);
|
||||
const keyOf = (inside: number[]) => inside.map((i) => sets[i]).join('');
|
||||
// مرکزِ تقریبیِ هر ناحیه برای نوشتنِ اعضا
|
||||
const regionCenter = (code: string): [number, number] => {
|
||||
if (code === 'out') return [22, H - 16];
|
||||
const inside = sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0);
|
||||
const all = centers.map((_, i) => i);
|
||||
const cxIn = inside.reduce((a, i) => a + centers[i][0], 0) / inside.length, cyIn = inside.reduce((a, i) => a + centers[i][1], 0) / inside.length;
|
||||
const outside = all.filter((i) => !inside.includes(i));
|
||||
if (!outside.length || inside.length > 1) {
|
||||
if (inside.length === sets.length) return [cxIn, cyIn];
|
||||
const o = outside.length ? centers[outside[0]] : [cxIn, cyIn];
|
||||
return [cxIn + (cxIn - o[0]) * 0.18, cyIn + (cyIn - o[1]) * 0.18];
|
||||
}
|
||||
const ox = outside.reduce((a, i) => a + centers[i][0], 0) / outside.length, oy = outside.reduce((a, i) => a + centers[i][1], 0) / outside.length;
|
||||
const dx = cxIn - ox, dy = cyIn - oy, n = Math.hypot(dx, dy) || 1;
|
||||
return [cxIn + (dx / n) * R * 0.42, cyIn + (dy / n) * R * 0.42];
|
||||
};
|
||||
const shaded = (spec.shade ?? []).filter((c) => c === 'out' || [...c].every((ch) => sets.includes(ch)));
|
||||
|
||||
const shadeRegion = (code: string, idx: number) => {
|
||||
const fill = `${COLORS.orange}66`;
|
||||
if (code === 'out') {
|
||||
return (
|
||||
<g key={`s${idx}`}>
|
||||
<mask id={`${ids[0]}-out`}><rect x={0} y={0} width={W} height={H} fill="#fff" />{centers.map((c, i) => <circle key={i} cx={c[0]} cy={c[1]} r={R} fill="#000" />)}</mask>
|
||||
<rect x={4} y={4} width={W - 8} height={H - 8} fill={fill} mask={`url(#${ids[0]}-out)`} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
const inside = sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0);
|
||||
const outside = sets.map((_, i) => i).filter((i) => !inside.includes(i));
|
||||
const mid = `${ids[0]}-m${idx}`;
|
||||
let node = (
|
||||
<g>
|
||||
{outside.length > 0 && <mask id={mid}><rect x={0} y={0} width={W} height={H} fill="#fff" />{outside.map((i) => <circle key={i} cx={centers[i][0]} cy={centers[i][1]} r={R} fill="#000" />)}</mask>}
|
||||
<rect x={0} y={0} width={W} height={H} fill={fill} mask={outside.length ? `url(#${mid})` : undefined} />
|
||||
</g>
|
||||
);
|
||||
for (const i of inside) node = <g clipPath={`url(#${ids[i]})`}>{node}</g>;
|
||||
return <g key={`s${idx}`}>{node}</g>;
|
||||
};
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
|
||||
<defs>{centers.map((c, i) => <clipPath key={i} id={ids[i]}><circle cx={c[0]} cy={c[1]} r={R} /></clipPath>)}</defs>
|
||||
<rect x={4} y={4} width={W - 8} height={H - 8} rx={10} fill="none" stroke={INK} strokeWidth={1.5} />
|
||||
<text x={W - 12} y={20} fontSize={12} fontWeight={800} fill={INK} textAnchor="end" fontStyle="italic">U</text>
|
||||
{shaded.map(shadeRegion)}
|
||||
{centers.map((c, i) => {
|
||||
const col = COLORS[SERIES[i]];
|
||||
return (
|
||||
<g key={i}>
|
||||
<circle cx={c[0]} cy={c[1]} r={R} fill={`${col}14`} stroke={col} strokeWidth={2.4} />
|
||||
<Label x={c[0] + (i === 1 ? R * 0.72 : i === 0 ? -R * 0.72 : 0)} y={i === 2 ? c[1] + R + 14 : c[1] - R * 0.78} color={col} size={spec.names?.[i] ? 11.5 : 13} weight={900}>{spec.names?.[i] ?? sets[i]}</Label>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{Object.entries(spec.regions).map(([code, members]) => {
|
||||
const valid = code === 'out' || [...code].every((ch) => sets.includes(ch));
|
||||
if (!valid || !members.length) return null;
|
||||
const [x, y] = regionCenter(code === 'out' ? 'out' : keyOf(sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0)));
|
||||
const lines = chunk(members.slice(0, 12), 3);
|
||||
return lines.map((line, li) => (
|
||||
<Label key={`${code}${li}`} x={x} y={y + (li - (lines.length - 1) / 2) * 14 + 4} color={INK} size={11} anchor={code === 'out' ? 'start' : 'middle'}>
|
||||
{mathText(line.join('، '))}
|
||||
</Label>
|
||||
));
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const chunk = <T,>(xs: T[], n: number): T[][] => Array.from({ length: Math.ceil(xs.length / n) }, (_, i) => xs.slice(i * n, i * n + n));
|
||||
|
||||
/* ─── نمودارِ پیکانی (تابع از A به B) ─────────────────────────────────────── */
|
||||
|
||||
export function Mapping({ spec }: { spec: MappingSpec }) {
|
||||
const L = spec.left.slice(0, 10), Rt = spec.right.slice(0, 10);
|
||||
const rows = Math.max(L.length, Rt.length, 1);
|
||||
const H = 56 + rows * 28;
|
||||
const yOf = (i: number, n: number) => 40 + ((i + 0.5) * (rows * 28)) / n;
|
||||
// جهتِ نگاشت چپ به راست است، مثلِ کتاب (دامنه سمتِ چپ)
|
||||
const lx = 78, rx = W - 78;
|
||||
const col = COLORS.red;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
|
||||
<Arrowheads ids={[col]} />
|
||||
{[[lx, L, spec.leftLabel, COLORS.blue], [rx, Rt, spec.rightLabel, COLORS.green]].map(([x, items, label, c]) => (
|
||||
<g key={String(x)}>
|
||||
<ellipse cx={x as number} cy={40 + (rows * 28) / 2} rx={42} ry={(rows * 28) / 2 + 14} fill={`${c}14`} stroke={c as string} strokeWidth={2.2} />
|
||||
{label && <Label x={x as number} y={18} color={c as string} size={12} weight={900}>{label as string}</Label>}
|
||||
{(items as string[]).map((it, i) => (
|
||||
<Label key={i} x={x as number} y={yOf(i, (items as string[]).length) + 4} color={INK} size={12}>{mathText(it)}</Label>
|
||||
))}
|
||||
</g>
|
||||
))}
|
||||
{spec.arrows.filter(([a, b]) => a < L.length && b < Rt.length).map(([a, b], i) => (
|
||||
<line key={i} x1={lx + 18} y1={yOf(a, L.length)} x2={rx - 18} y2={yOf(b, Rt.length)} stroke={col} strokeWidth={1.8} markerEnd={`url(#${arrowId(col)})`} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── نمودارِ درختی (احتمال و شمارش) ──────────────────────────────────────── */
|
||||
|
||||
export function Tree({ spec }: { spec: TreeSpec }) {
|
||||
interface Laid { node: TreeNode; x: number; y: number; depth: number; parent?: Laid }
|
||||
const laid: Laid[] = [];
|
||||
let leaf = 0;
|
||||
const depthOf = (n: TreeNode): number => 1 + Math.max(0, ...(n.children ?? []).map(depthOf));
|
||||
const depth = Math.min(4, depthOf(spec.root));
|
||||
const levelH = 62;
|
||||
const place = (n: TreeNode, d: number, parent?: Laid): Laid => {
|
||||
const kids = d < 3 ? (n.children ?? []).slice(0, 6) : [];
|
||||
const me: Laid = { node: n, x: 0, y: 24 + d * levelH, depth: d, parent };
|
||||
if (!kids.length) { me.x = leaf++; laid.push(me); return me; }
|
||||
const placed = kids.map((c) => place(c, d + 1, me));
|
||||
me.x = (placed[0].x + placed[placed.length - 1].x) / 2;
|
||||
laid.push(me);
|
||||
return me;
|
||||
};
|
||||
place(spec.root, 0);
|
||||
const leaves = Math.max(1, leaf);
|
||||
const H = 24 + (depth - 1) * levelH + 34;
|
||||
const X = (x: number) => 24 + ((x + 0.5) / leaves) * (W - 48);
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
|
||||
{laid.filter((n) => n.parent).map((n, i) => {
|
||||
const p = n.parent!;
|
||||
return (
|
||||
<g key={`e${i}`}>
|
||||
<line x1={X(p.x)} y1={p.y + 9} x2={X(n.x)} y2={n.y - 11} stroke={COLORS.gray} strokeWidth={1.8} />
|
||||
{n.node.p && <Label x={(X(p.x) + X(n.x)) / 2 + (n.x < p.x ? -10 : n.x > p.x ? 10 : 12)} y={(p.y + n.y) / 2 + 3} color={COLORS.red} size={10}>{mathText(n.node.p)}</Label>}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{laid.map((n, i) => (
|
||||
<g key={`n${i}`}>
|
||||
<rect x={X(n.x) - 26} y={n.y - 11} width={52} height={21} rx={10} fill={n.depth === 0 ? `${COLORS.blue}22` : '#fff'} stroke={COLORS.blue} strokeWidth={1.6} />
|
||||
<text x={X(n.x)} y={n.y + 4} fontSize={10.5} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{mathText(n.node.label).slice(0, 10)}</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
300
src/diagram/shapes.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import type { ColorName, PlaneItem, PointDef, Ref } from './types';
|
||||
|
||||
/**
|
||||
* مثلث و چهارضلعی از روی دادههای مسئله، نه از روی مختصاتِ حدسیِ مدل.
|
||||
*
|
||||
* مدل میگوید «مثلثی با AB = 6، AC = 8 و زاویهی A = 90°» یا «ذوزنقه با قاعدههای ۸ و ۵ و
|
||||
* ارتفاعِ ۳»؛ اینجا با قانونِ کسینوسها و سینوسها (مثلث) یا فرمولِ هر چهارضلعی رأسها
|
||||
* حساب میشوند و به نقطه + polygon + برچسب باز میشوند. سنجشِ ۲۰۲۶-۰۹-۲۸: مربعی که مدل
|
||||
* مختصاتش را خودش نوشت کج درآمد؛ این ابزار آن خطا را از دستِ مدل بیرون میآورد.
|
||||
* دادهی ناقص با پیشفرضِ ساده پر میشود (ضلعِ نامعلوم = میانگینِ ضلعهای معلوم)، پس
|
||||
* شکل همیشه کشیده میشود؛ دادهی متناقض (۳، ۴، ۱۰) مثلثی نمیسازد و item حذف میشود.
|
||||
*/
|
||||
|
||||
type XY = [number, number];
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
export interface TriangleItem {
|
||||
type: 'triangle';
|
||||
names: [string, string, string];
|
||||
/** کلید: دو نامِ رأس («AB» یا «BA») */
|
||||
sides?: Record<string, number>;
|
||||
/** کلید: نامِ رأس، درجه */
|
||||
angles?: Record<string, number>;
|
||||
/** برچسبِ دلخواه برای ضلع («BC»: «x»)، رأس («A»: «?») یا ارتفاع («h») */
|
||||
labels?: Record<string, string>;
|
||||
/** رأسی که ارتفاع از آن بر ضلعِ روبهرو کشیده شود */
|
||||
altitude?: string;
|
||||
/** جای رأسِ اول */
|
||||
at?: XY;
|
||||
color?: ColorName;
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
export interface QuadItem {
|
||||
type: 'quad';
|
||||
shape: 'rectangle' | 'square' | 'parallelogram' | 'rhombus' | 'trapezoid';
|
||||
names: [string, string, string, string];
|
||||
w?: number; h?: number; side?: number; angle?: number; top?: number; d1?: number; d2?: number;
|
||||
right?: boolean;
|
||||
at?: XY;
|
||||
labels?: Record<string, string>;
|
||||
color?: ColorName;
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
interface Solved { a: number; b: number; c: number; A: number; B: number; C: number }
|
||||
|
||||
/** a روبهروی A و ...؛ زاویهها به درجه. null یعنی دادهها مثلث نمیسازند. */
|
||||
export function solveTriangle(sides: Partial<Record<'a' | 'b' | 'c', number>>, angles: Partial<Record<'A' | 'B' | 'C', number>>): Solved | null {
|
||||
const s: Record<string, number | undefined> = { ...sides };
|
||||
const g: Record<string, number | undefined> = { ...angles };
|
||||
const opp = { a: 'A', b: 'B', c: 'C' } as const;
|
||||
const known = (o: Record<string, number | undefined>, k: string) => o[k] !== undefined && Number.isFinite(o[k]!);
|
||||
const step = () => {
|
||||
const ks = ['A', 'B', 'C'].filter((k) => known(g, k));
|
||||
if (ks.length === 2) {
|
||||
const rest = 180 - ks.reduce((t, k) => t + g[k]!, 0);
|
||||
if (rest <= 0) return false;
|
||||
g[['A', 'B', 'C'].find((k) => !known(g, k))!] = rest;
|
||||
}
|
||||
const ss = (['a', 'b', 'c'] as const).filter((k) => known(s, k));
|
||||
if (ss.length === 3 && ['A', 'B', 'C'].some((k) => !known(g, k))) {
|
||||
const { a, b, c } = s as Record<string, number>;
|
||||
if (a + b <= c || a + c <= b || b + c <= a) return false;
|
||||
g.A = Math.acos((b * b + c * c - a * a) / (2 * b * c)) / RAD;
|
||||
g.B = Math.acos((a * a + c * c - b * b) / (2 * a * c)) / RAD;
|
||||
g.C = 180 - g.A - g.B;
|
||||
}
|
||||
// دو ضلع و زاویهی بینشان
|
||||
for (const [x, y, z] of [['b', 'c', 'a'], ['a', 'c', 'b'], ['a', 'b', 'c']] as const) {
|
||||
if (known(s, x) && known(s, y) && !known(s, z) && known(g, opp[z])) {
|
||||
s[z] = Math.sqrt(s[x]! ** 2 + s[y]! ** 2 - 2 * s[x]! * s[y]! * Math.cos(g[opp[z]]! * RAD));
|
||||
}
|
||||
}
|
||||
// قانونِ سینوسها
|
||||
const pair = (['a', 'b', 'c'] as const).find((k) => known(s, k) && known(g, opp[k]));
|
||||
if (pair) {
|
||||
const k = s[pair]! / Math.sin(g[opp[pair]]! * RAD);
|
||||
for (const side of ['a', 'b', 'c'] as const) {
|
||||
const ang = opp[side];
|
||||
if (!known(s, side) && known(g, ang)) s[side] = k * Math.sin(g[ang]! * RAD);
|
||||
else if (known(s, side) && !known(g, ang)) {
|
||||
const r = s[side]! / k;
|
||||
if (r > 1 + 1e-9) return false;
|
||||
g[ang] = Math.asin(Math.min(1, r)) / RAD;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const done = () => ['a', 'b', 'c'].every((k) => known(s, k)) && ['A', 'B', 'C'].every((k) => known(g, k));
|
||||
const count = () => ['a', 'b', 'c'].filter((k) => known(s, k)).length + ['A', 'B', 'C'].filter((k) => known(g, k)).length;
|
||||
for (let round = 0; round < 12 && !done(); round++) {
|
||||
const before = count();
|
||||
if (!step()) return null;
|
||||
if (done()) break;
|
||||
if (count() > before) continue;
|
||||
// گیر کرد: دادهی ناقص. ضلعِ نامعلومِ اول = میانگینِ ضلعهای معلوم (متساویالساقین/متساویالاضلاع)
|
||||
const ss = (['a', 'b', 'c'] as const).filter((k) => known(s, k));
|
||||
if (!ss.length) { s.c = 4; continue; }
|
||||
const missing = (['a', 'b', 'c'] as const).find((k) => !known(s, k));
|
||||
if (missing) s[missing] = ss.reduce((t, k) => t + s[k]!, 0) / ss.length;
|
||||
}
|
||||
if (!done()) return null;
|
||||
const out = { a: s.a!, b: s.b!, c: s.c!, A: g.A!, B: g.B!, C: g.C! };
|
||||
if (Object.values(out).some((v) => !Number.isFinite(v) || v <= 0) || Math.abs(out.A + out.B + out.C - 180) > 0.5) return null;
|
||||
return out;
|
||||
}
|
||||
|
||||
const num = (v: number) => String(Math.round(v * 100) / 100);
|
||||
|
||||
/** رأسهای شکلهای مرکبی که تا اینجا جا گرفتهاند (برای چیدنِ چند شکل کنارِ هم). */
|
||||
export interface Layout { placed: XY[] }
|
||||
|
||||
/**
|
||||
* جای شکل: رأسها در دستگاهِ محلی (رأسِ اول در مبدأ، ضلعِ اول روی محورِ x) ساخته شدهاند.
|
||||
* دو رأسِ اول از قبل مختصات دارند → شکل روی همان ضلع ساخته میشود (ذوزنقه روی ضلعِ مستطیل)
|
||||
* فقط رأسِ اول → همانجا؛ "at" → همانجا
|
||||
* هیچکدام، ولی شکلِ دیگری قبلاً کشیده شده → کنارِ آن، نه روی آن (دو مربعِ کنارِ هم)
|
||||
*/
|
||||
function placement(names: string[], local: XY[], points: Record<string, PointDef>, layout: Layout, at?: XY): (p: XY) => XY {
|
||||
const plain = (n: string) => (Array.isArray(points[n]) ? (points[n] as XY) : null);
|
||||
const p0 = plain(names[0]), p1 = plain(names[1]);
|
||||
let T: (p: XY) => XY = (p) => p;
|
||||
if (p0 && p1 && Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) > 1e-9) {
|
||||
const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
|
||||
const u: XY = [(p1[0] - p0[0]) / len, (p1[1] - p0[1]) / len];
|
||||
T = (p) => [p0[0] + p[0] * u[0] - p[1] * u[1], p0[1] + p[0] * u[1] + p[1] * u[0]];
|
||||
} else if (p0) T = (p) => [p0[0] + p[0], p0[1] + p[1]];
|
||||
else if (at) T = (p) => [at[0] + p[0], at[1] + p[1]];
|
||||
else if (layout.placed.length) {
|
||||
const right = Math.max(...layout.placed.map((q) => q[0]));
|
||||
const bottom = Math.min(...layout.placed.map((q) => q[1]));
|
||||
const minX = Math.min(...local.map((q) => q[0])), minY = Math.min(...local.map((q) => q[1]));
|
||||
const gap = Math.max(1, 0.2 * (Math.max(...local.map((q) => q[0])) - minX));
|
||||
T = (p) => [p[0] - minX + right + gap, p[1] - minY + bottom];
|
||||
}
|
||||
layout.placed.push(...local.map(T));
|
||||
return T;
|
||||
}
|
||||
|
||||
function sideKey(key: string, names: string[]): [number, number] | null {
|
||||
for (let i = 0; i < names.length; i++) for (let j = 0; j < names.length; j++) {
|
||||
if (i !== j && key === names[i] + names[j]) return [i, j];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** itemهای ساده (نقطه، polygon، پارهخطِ برچسبدار، زاویه) بهجای triangle. */
|
||||
export function expandTriangle(item: TriangleItem, idx: number, points: Record<string, PointDef>, taken: (n: string) => boolean, layout: Layout): PlaneItem[] | null {
|
||||
const [P, Q, R] = item.names;
|
||||
const sides: Partial<Record<'a' | 'b' | 'c', number>> = {};
|
||||
const oppOf = (i: number, j: number) => (['a', 'b', 'c'] as const)[3 - i - j];
|
||||
Object.entries(item.sides ?? {}).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sides[oppOf(p[0], p[1])] = v; });
|
||||
const angles: Partial<Record<'A' | 'B' | 'C', number>> = {};
|
||||
Object.entries(item.angles ?? {}).forEach(([k, v]) => { const i = item.names.indexOf(k); if (i >= 0) angles[(['A', 'B', 'C'] as const)[i]] = v; });
|
||||
// مثلثی روی نقطههایی که مدل خودش تعریف کرده (مختصات، polar، تبدیل) و بیدادهی ضلع و زاویه:
|
||||
// همان نقطهها، دستنخورده (سنجشِ ۲۰۲۶-۰۹-۲۹: جابهجا کردنشان تصویرِ تقارن را خراب کرد)
|
||||
const hasData = Object.keys(sides).length + Object.keys(angles).length > 0;
|
||||
if (!(hasData || !item.names.every((n) => n in points))) {
|
||||
layout.placed.push(...item.names.map((n) => points[n]).filter((v): v is XY => Array.isArray(v)));
|
||||
} else {
|
||||
const t = solveTriangle(sides, angles);
|
||||
if (!t) return null;
|
||||
const local: XY[] = [[0, 0], [t.c, 0], [t.b * Math.cos(t.A * RAD), t.b * Math.sin(t.A * RAD)]];
|
||||
const T = placement(item.names, local, points, layout, item.at);
|
||||
item.names.forEach((n, i) => { points[n] = T(local[i]); });
|
||||
}
|
||||
const style = { color: item.color };
|
||||
const out: PlaneItem[] = [{ type: 'polygon', points: [P, Q, R], fill: item.fill ?? true, ...style }];
|
||||
const labels = item.labels ?? {};
|
||||
const sideLabel = new Map<string, string>();
|
||||
Object.entries(item.sides ?? {}).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sideLabel.set([p[0], p[1]].sort().join(), num(v)); });
|
||||
Object.entries(labels).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sideLabel.set([p[0], p[1]].sort().join(), v); });
|
||||
// همهی ضلعها پارهخطاند (نه فقط برچسبدارها) تا ضخامتِ ضلعها یکی باشد
|
||||
[[0, 1], [1, 2], [0, 2]].forEach(([i, j]) => {
|
||||
out.push({ type: 'segment', from: item.names[i], to: item.names[j], label: sideLabel.get(`${i},${j}`), ...style });
|
||||
});
|
||||
item.names.forEach((n, i) => {
|
||||
const given = item.angles?.[n];
|
||||
const label = labels[n] ?? (given !== undefined ? `${num(given)}°` : undefined);
|
||||
if (label === undefined) return;
|
||||
const [from, to] = [item.names[(i + 1) % 3], item.names[(i + 2) % 3]];
|
||||
if (given !== undefined && Math.abs(given - 90) < 1e-6 && labels[n] === undefined) out.push({ type: 'rightAngle', vertex: n, from, to });
|
||||
else out.push({ type: 'angle', vertex: n, from, to, label, color: 'red' });
|
||||
});
|
||||
const alt = item.altitude;
|
||||
if (alt && item.names.includes(alt)) {
|
||||
const [u, v] = item.names.filter((n) => n !== alt);
|
||||
const foot = taken('H') ? `_h${idx}` : 'H';
|
||||
points[foot] = { foot: [alt, u, v] };
|
||||
// امتدادِ ضلع فقط وقتی پای ارتفاع بیرونِ مثلث میافتد (مثلثِ منفرجه)
|
||||
const [pu, pv, pa] = [u, v, alt].map((n) => (Array.isArray(points[n]) ? (points[n] as XY) : ([0, 0] as XY)));
|
||||
const d: XY = [pv[0] - pu[0], pv[1] - pu[1]];
|
||||
const tt = ((pa[0] - pu[0]) * d[0] + (pa[1] - pu[1]) * d[1]) / (d[0] * d[0] + d[1] * d[1] || 1);
|
||||
if (tt < 0) out.push({ type: 'segment', from: foot, to: u, dashed: true, color: 'gray' });
|
||||
if (tt > 1) out.push({ type: 'segment', from: v, to: foot, dashed: true, color: 'gray' });
|
||||
out.push({ type: 'segment', from: alt, to: foot, dashed: true, color: 'green', label: labels.h });
|
||||
out.push({ type: 'rightAngle', vertex: foot, from: alt, to: v });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function expandQuad(item: QuadItem, idx: number, points: Record<string, PointDef>, layout: Layout): PlaneItem[] {
|
||||
const [A, B, C, D] = item.names;
|
||||
const labels = item.labels ?? {};
|
||||
const style = { color: item.color };
|
||||
const lab = (key: string, value?: number) => labels[key] ?? (value !== undefined ? num(value) : undefined);
|
||||
const out: PlaneItem[] = [];
|
||||
const seg = (from: Ref, to: Ref, label?: string, extra: Partial<Extract<PlaneItem, { type: 'segment' }>> = {}) => {
|
||||
out.push({ type: 'segment', from, to, label, ...style, ...extra });
|
||||
};
|
||||
// ضلعهایی که برچسب یا علامت نگرفتند، بعداً با همان ضخامت کشیده میشوند
|
||||
const edges = () => {
|
||||
const drawn = new Set(out.filter((i) => i.type === 'segment' && !i.dashed).map((i) => { const s = i as { from: Ref; to: Ref }; return [s.from, s.to].sort().join(); }));
|
||||
[[A, B], [B, C], [C, D], [D, A]].forEach(([p, q]) => { if (!drawn.has([p, q].sort().join())) seg(p, q); });
|
||||
};
|
||||
// بیهیچ اندازه و روی چهار نقطهای که مدل خودش تعریف کرده: همان نقطهها
|
||||
const keep = ['w', 'h', 'side', 'top', 'd1', 'd2', 'angle'].every((k) => item[k as keyof QuadItem] === undefined)
|
||||
&& item.names.every((n) => n in points);
|
||||
let T: (p: XY) => XY = (p) => p;
|
||||
const put = (xy: XY[]) => {
|
||||
if (keep) { layout.placed.push(...item.names.map((n) => points[n]).filter((v): v is XY => Array.isArray(v))); return; }
|
||||
T = placement(item.names, xy, points, layout, item.at);
|
||||
[A, B, C, D].forEach((n, i) => { points[n] = T(xy[i]); });
|
||||
};
|
||||
const height = (label?: string) => {
|
||||
const foot = `_q${idx}`;
|
||||
points[foot] = { foot: [D, A, B] };
|
||||
out.push({ type: 'segment', from: D, to: foot, dashed: true, color: 'green', label });
|
||||
out.push({ type: 'rightAngle', vertex: foot, from: D, to: B });
|
||||
};
|
||||
const θ = (item.angle ?? 60) * RAD;
|
||||
switch (item.shape) {
|
||||
case 'rectangle': {
|
||||
const w = item.w ?? 6, h = item.h ?? 4;
|
||||
put([[0, 0], [w, 0], [w, h], [0, h]]);
|
||||
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style }, { type: 'rightAngle', vertex: A, from: B, to: D });
|
||||
seg(A, B, lab(A + B, item.w)); seg(B, C, lab(B + C, item.h));
|
||||
break;
|
||||
}
|
||||
case 'square': {
|
||||
const s = item.side ?? item.w ?? 4;
|
||||
put([[0, 0], [s, 0], [s, s], [0, s]]);
|
||||
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style }, { type: 'rightAngle', vertex: A, from: B, to: D });
|
||||
seg(A, B, lab(A + B, item.side ?? item.w), { ticks: 1 }); seg(B, C, lab(B + C), { ticks: 1 });
|
||||
seg(C, D, lab(C + D), { ticks: 1 }); seg(D, A, lab(D + A), { ticks: 1 });
|
||||
break;
|
||||
}
|
||||
case 'parallelogram': {
|
||||
const w = item.w ?? 6;
|
||||
const s = item.side ?? (item.h ? item.h / Math.sin(θ) : 3.5);
|
||||
const d: XY = [s * Math.cos(θ), s * Math.sin(θ)];
|
||||
put([[0, 0], [w, 0], [w + d[0], d[1]], d]);
|
||||
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
|
||||
seg(A, B, lab(A + B, item.w)); seg(A, D, lab(A + D, item.side));
|
||||
if (item.h !== undefined || labels.h) height(lab('h', item.h));
|
||||
if (item.angle !== undefined) out.push({ type: 'angle', vertex: A, from: B, to: D, label: `${num(item.angle)}°`, color: 'red' });
|
||||
break;
|
||||
}
|
||||
case 'rhombus': {
|
||||
if (item.d1 !== undefined || item.d2 !== undefined) {
|
||||
const d1 = item.d1 ?? 6, d2 = item.d2 ?? 4, o = `_o${idx}`;
|
||||
put([[-d1 / 2, 0], [0, -d2 / 2], [d1 / 2, 0], [0, d2 / 2]]);
|
||||
points[o] = T([0, 0]);
|
||||
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
|
||||
// برچسبِ قطرها کنارِ هم نمینشیند (هر دو وسطشان در O است): روی یکچهارمِ هر قطر
|
||||
seg(A, C, undefined, { dashed: true }); seg(B, D, undefined, { dashed: true });
|
||||
const l1 = lab('d1', item.d1), l2 = lab('d2', item.d2);
|
||||
if (l1) out.push({ type: 'text', at: T([d1 / 4, d2 * 0.07]), text: l1, color: 'blue' });
|
||||
if (l2) out.push({ type: 'text', at: T([d1 * 0.07, d2 / 4]), text: l2, color: 'blue' });
|
||||
out.push({ type: 'rightAngle', vertex: o, from: C, to: D });
|
||||
[[A, B], [B, C], [C, D], [D, A]].forEach(([p, q]) => seg(p, q, lab(p + q), { ticks: 1 }));
|
||||
} else {
|
||||
const s = item.side ?? item.w ?? 4;
|
||||
put([[0, 0], [s, 0], [s + s * Math.cos(θ), s * Math.sin(θ)], [s * Math.cos(θ), s * Math.sin(θ)]]);
|
||||
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
|
||||
seg(A, B, lab(A + B, item.side ?? item.w), { ticks: 1 });
|
||||
[[B, C], [C, D], [D, A]].forEach(([p, q]) => seg(p, q, lab(p + q), { ticks: 1 }));
|
||||
if (item.h !== undefined || labels.h) height(lab('h', item.h));
|
||||
if (item.angle !== undefined) out.push({ type: 'angle', vertex: A, from: B, to: D, label: `${num(item.angle)}°`, color: 'red' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'trapezoid': {
|
||||
const w = item.w ?? 6, t = item.top ?? w * 0.6, h = item.h ?? 3;
|
||||
const x0 = item.right ? 0 : (w - t) / 2;
|
||||
put([[0, 0], [w, 0], [x0 + t, h], [x0, h]]);
|
||||
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
|
||||
seg(A, B, lab(A + B, item.w)); seg(D, C, lab(D + C, item.top));
|
||||
if (item.right) {
|
||||
out.push({ type: 'rightAngle', vertex: A, from: B, to: D });
|
||||
seg(A, D, lab(A + D, item.h) ?? lab('h'));
|
||||
} else if (item.h !== undefined || labels.h) height(lab('h', item.h));
|
||||
break;
|
||||
}
|
||||
}
|
||||
edges();
|
||||
return out;
|
||||
}
|
||||
156
src/diagram/solid.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { SolidSpec } from './types';
|
||||
import { COLORS, INK, Label, mathText } from './svg';
|
||||
|
||||
/**
|
||||
* حجمهای هندسی، با تصویرِ مایلِ کتابِ درسی: وجهِ جلو واقعی، عمق با زاویهی ۳۵°
|
||||
* و نصفِ اندازه؛ یالهای پنهان خطچین. اندازهها (a، b، c، r، h) نسبتِ واقعی را
|
||||
* نگه میدارند؛ برچسبها همان متنیاند که مدل داده («۵ cm»).
|
||||
*/
|
||||
type XY = [number, number];
|
||||
const W = 300;
|
||||
const H = 250;
|
||||
const FILL = `${COLORS.blue}1c`;
|
||||
const TOP = `${COLORS.blue}33`;
|
||||
const SIDE = `${COLORS.blue}10`;
|
||||
const HID = '5 4';
|
||||
const DEPTH = 0.5;
|
||||
const ANG = (35 * Math.PI) / 180;
|
||||
const SQUASH = 0.32; // دایرهی قاعده از نمای مایل
|
||||
|
||||
export function Solid({ spec }: { spec: SolidSpec }) {
|
||||
const d = spec.dims ?? {};
|
||||
const l = spec.labels ?? {};
|
||||
const els: ReactNode[] = [];
|
||||
let key = 0;
|
||||
const k = () => key++;
|
||||
const edge = (a: XY, b: XY, hidden = false, col = INK) =>
|
||||
els.push(<line key={k()} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]} stroke={hidden ? '#8A8FA3' : col} strokeWidth={hidden ? 1.5 : 2.2} strokeDasharray={hidden ? HID : undefined} strokeLinecap="round" />);
|
||||
const face = (pts: XY[], fill: string) => els.push(<path key={k()} d={`M${pts.map((p) => p.join(',')).join(' L')} Z`} fill={fill} stroke="none" />);
|
||||
const label = (x: number, y: number, text: string | undefined, col = COLORS.red) => {
|
||||
if (text) els.push(<Label key={k()} x={x} y={y} color={col} size={12}>{mathText(text)}</Label>);
|
||||
};
|
||||
const dim = (a: XY, b: XY, text: string | undefined) => {
|
||||
// خطِ اندازه: خطچینِ قرمز با برچسب وسطش
|
||||
els.push(<line key={k()} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]} stroke={COLORS.red} strokeWidth={1.8} strokeDasharray="4 3" />);
|
||||
if (text) label((a[0] + b[0]) / 2 + 12, (a[1] + b[1]) / 2 + 4, text);
|
||||
};
|
||||
|
||||
const shape = spec.shape;
|
||||
if (shape === 'cube' || shape === 'cuboid') {
|
||||
let a = d.a ?? 1, b = d.b ?? (shape === 'cube' ? a : 1), c = d.c ?? (shape === 'cube' ? a : 1);
|
||||
if (shape === 'cube') b = c = a;
|
||||
const ox = Math.cos(ANG) * DEPTH * b, oy = Math.sin(ANG) * DEPTH * b;
|
||||
const s = Math.min(170 / (a + ox), 170 / (c + oy));
|
||||
const x0 = (W - (a + ox) * s) / 2, y0 = H - 30;
|
||||
const f = (x: number, y: number, z: number): XY => [x0 + (x + z * Math.cos(ANG) * DEPTH) * s, y0 - (y + z * Math.sin(ANG) * DEPTH) * s];
|
||||
const [A, B, C, D] = [f(0, 0, 0), f(a, 0, 0), f(a, c, 0), f(0, c, 0)];
|
||||
const [E, F, G, Hh] = [f(0, 0, b), f(a, 0, b), f(a, c, b), f(0, c, b)];
|
||||
face([D, C, G, Hh], TOP); face([B, F, G, C], SIDE); face([A, B, C, D], FILL);
|
||||
edge(A, E, true); edge(E, F, true); edge(E, Hh, true);
|
||||
[[A, B], [B, C], [C, D], [D, A], [B, F], [F, G], [G, C], [G, Hh], [Hh, D]].forEach(([p, q]) => edge(p, q));
|
||||
label((A[0] + B[0]) / 2, A[1] + 17, l.a);
|
||||
label(A[0] - 18, (A[1] + D[1]) / 2 + 4, l.c ?? (shape === 'cube' ? undefined : undefined));
|
||||
label((B[0] + F[0]) / 2 + 16, (B[1] + F[1]) / 2 + 10, l.b);
|
||||
} else if (shape === 'prism' || shape === 'pyramid') {
|
||||
const n = Math.max(3, Math.min(8, Math.round(d.n ?? (shape === 'pyramid' ? 4 : 3))));
|
||||
const r = d.r ?? 1, h = d.h ?? 1.6;
|
||||
const s = Math.min(95 / r, 165 / h);
|
||||
const cx = W / 2, by = H - 44;
|
||||
const rot = n === 4 ? Math.PI / 4 : Math.PI / 2 + Math.PI / n;
|
||||
const ang = Array.from({ length: n }, (_, i) => rot + (2 * Math.PI * i) / n);
|
||||
// sin(t) > 0 یعنی پایینِ بیضی در تصویر = جلوی حجم، نزدیک به بیننده.
|
||||
const base: XY[] = ang.map((t) => [cx + r * s * Math.cos(t), by + r * s * SQUASH * Math.sin(t)]);
|
||||
const midAng = (i: number) => ang[i] + Math.PI / n;
|
||||
const inr = r * Math.cos(Math.PI / n);
|
||||
// دیده شدنِ وجهِ جانبیِ i (بینِ رأسِ i و i+1): عمودِ بیرونی رو به بیننده.
|
||||
// نما کمی از بالاست (tan ≈ SQUASH)، پس وجهِ مایلِ هرم کمی زودتر دیده میشود.
|
||||
const faceVisible = (i: number) => shape === 'prism'
|
||||
? Math.sin(midAng(i)) > 1e-9
|
||||
: h * Math.sin(midAng(i)) + inr * SQUASH > 1e-9;
|
||||
const vertexVisible = (i: number) => faceVisible(i) || faceVisible((i - 1 + n) % n);
|
||||
if (shape === 'prism') {
|
||||
const top: XY[] = base.map((p) => [p[0], p[1] - h * s]);
|
||||
face(top, TOP);
|
||||
for (let i = 0; i < n; i++) if (faceVisible(i)) face([base[i], base[(i + 1) % n], top[(i + 1) % n], top[i]], FILL);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
edge(base[i], base[j], !faceVisible(i));
|
||||
edge(top[i], top[j]);
|
||||
edge(base[i], top[i], !vertexVisible(i));
|
||||
}
|
||||
const right = base.reduce((m, p, i) => (p[0] > base[m][0] ? i : m), 0);
|
||||
if (l.h) dim([top[right][0] + 16, top[right][1]], [base[right][0] + 16, base[right][1]], l.h);
|
||||
} else {
|
||||
const apex: XY = [cx, by - h * s];
|
||||
for (let i = 0; i < n; i++) if (faceVisible(i)) face([base[i], base[(i + 1) % n], apex], i % 2 ? FILL : SIDE);
|
||||
for (let i = 0; i < n; i++) {
|
||||
edge(base[i], base[(i + 1) % n], !faceVisible(i));
|
||||
edge(base[i], apex, !vertexVisible(i));
|
||||
}
|
||||
if (l.h) {
|
||||
edge(apex, [cx, by], true, COLORS.red);
|
||||
els.push(<path key={k()} d={`M${cx + 8},${by} L${cx + 8},${by - 8} L${cx},${by - 8}`} fill="none" stroke={COLORS.red} strokeWidth={1.4} />);
|
||||
label(cx + 16, (apex[1] + by) / 2, l.h);
|
||||
}
|
||||
}
|
||||
// ضلعِ قاعده: پایینترین یالِ جلو
|
||||
if (l.a) {
|
||||
let best = 0;
|
||||
for (let i = 0; i < n; i++) if (base[i][1] + base[(i + 1) % n][1] > base[best][1] + base[(best + 1) % n][1]) best = i;
|
||||
label((base[best][0] + base[(best + 1) % n][0]) / 2, (base[best][1] + base[(best + 1) % n][1]) / 2 + 17, l.a);
|
||||
}
|
||||
} else if (shape === 'cylinder' || shape === 'cone') {
|
||||
const r = d.r ?? 1, h = d.h ?? 2;
|
||||
const s = Math.min(110 / r, 170 / (h + r * SQUASH * 2));
|
||||
const cx = W / 2, by = H - 36, rx = r * s, ry = r * s * SQUASH;
|
||||
const topY = by - h * s;
|
||||
const halfEllipse = (cy: number, front: boolean) => `M${cx - rx},${cy} A${rx},${ry} 0 0 ${front ? 0 : 1} ${cx + rx},${cy}`;
|
||||
if (shape === 'cylinder') {
|
||||
els.push(<path key={k()} d={`M${cx - rx},${topY} L${cx - rx},${by} A${rx},${ry} 0 0 0 ${cx + rx},${by} L${cx + rx},${topY} Z`} fill={FILL} />);
|
||||
els.push(<ellipse key={k()} cx={cx} cy={topY} rx={rx} ry={ry} fill={TOP} stroke={INK} strokeWidth={2.2} />);
|
||||
edge([cx - rx, topY], [cx - rx, by]); edge([cx + rx, topY], [cx + rx, by]);
|
||||
els.push(<path key={k()} d={halfEllipse(by, true)} fill="none" stroke={INK} strokeWidth={2.2} />);
|
||||
els.push(<path key={k()} d={halfEllipse(by, false)} fill="none" stroke="#8A8FA3" strokeWidth={1.5} strokeDasharray={HID} />);
|
||||
if (l.r) { dim([cx, topY], [cx + rx, topY], undefined); els.push(<circle key={k()} cx={cx} cy={topY} r={2.6} fill={INK} />); label(cx + rx / 2, topY - 8, l.r); }
|
||||
if (l.h) dim([cx + rx + 16, topY], [cx + rx + 16, by], l.h);
|
||||
} else {
|
||||
const apex: XY = [cx, topY];
|
||||
// مماس از رأس بر بیضیِ قاعده: نقطهی تماس جایی است که sin t = −ry / ارتفاعِ تصویر
|
||||
// (از صفر شدنِ ضربِ خارجیِ «رأس→نقطه» در «مشتقِ بیضی» درمیآید).
|
||||
const st = ry / Math.max(1e-6, by - topY), ct = Math.sqrt(Math.max(0, 1 - st * st));
|
||||
const tl: XY = [cx - rx * ct, by - ry * st], tr: XY = [cx + rx * ct, by - ry * st];
|
||||
els.push(<path key={k()} d={`M${apex.join(',')} L${tl.join(',')} A${rx},${ry} 0 1 0 ${tr.join(',')} Z`} fill={FILL} />);
|
||||
els.push(<path key={k()} d={`M${tl.join(',')} A${rx},${ry} 0 1 0 ${tr.join(',')}`} fill="none" stroke={INK} strokeWidth={2.2} />);
|
||||
els.push(<path key={k()} d={`M${tl.join(',')} A${rx},${ry} 0 0 1 ${tr.join(',')}`} fill="none" stroke="#8A8FA3" strokeWidth={1.5} strokeDasharray={HID} />);
|
||||
edge(apex, tl); edge(apex, tr);
|
||||
if (l.h) {
|
||||
edge(apex, [cx, by], true, COLORS.red);
|
||||
els.push(<path key={k()} d={`M${cx + 8},${by} L${cx + 8},${by - 8} L${cx},${by - 8}`} fill="none" stroke={COLORS.red} strokeWidth={1.4} />);
|
||||
label(cx + 22, (topY + by) / 2 - 10, l.h);
|
||||
}
|
||||
if (l.r) { dim([cx, by], [cx + rx, by], undefined); label(cx + rx / 2, by + ry + 16, l.r); }
|
||||
}
|
||||
} else if (shape === 'sphere' || shape === 'hemisphere') {
|
||||
const r = d.r ?? 1;
|
||||
const R = 92, cx = W / 2, cy = shape === 'sphere' ? H / 2 + 4 : H - 70, ry = R * SQUASH;
|
||||
if (shape === 'sphere') {
|
||||
els.push(<circle key={k()} cx={cx} cy={cy} r={R} fill={FILL} stroke={INK} strokeWidth={2.2} />);
|
||||
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${ry} 0 0 0 ${cx + R},${cy}`} fill="none" stroke={INK} strokeWidth={1.6} />);
|
||||
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${ry} 0 0 1 ${cx + R},${cy}`} fill="none" stroke="#8A8FA3" strokeWidth={1.4} strokeDasharray={HID} />);
|
||||
} else {
|
||||
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${R} 0 0 1 ${cx + R},${cy} A${R},${ry} 0 0 1 ${cx - R},${cy} Z`} fill={FILL} />);
|
||||
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${R} 0 0 1 ${cx + R},${cy}`} fill="none" stroke={INK} strokeWidth={2.2} />);
|
||||
els.push(<ellipse key={k()} cx={cx} cy={cy} rx={R} ry={ry} fill={TOP} stroke={INK} strokeWidth={2} />);
|
||||
}
|
||||
els.push(<circle key={k()} cx={cx} cy={cy} r={2.8} fill={INK} />);
|
||||
if (l.r) { dim([cx, cy], [cx + R, cy], undefined); label(cx + R / 2, cy - 8, l.r); }
|
||||
void r;
|
||||
} else return null;
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[300px]" role="img" aria-label={spec.title || 'حجم'}>
|
||||
{els}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
125
src/diagram/space.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { SpaceSpec } from './types';
|
||||
import { COLORS, INK, Label, SOFT, color, mathText } from './svg';
|
||||
|
||||
/**
|
||||
* فضای سهبعدی با تصویرِ مایلِ ثابت: x رو به بیننده (پایینچپ)، y راست، z بالا. چرخش و تعامل ندارد.
|
||||
* هر نقطه (x, y, z) به صفحه میرود با sx = y − 0.46·x ، sy = z − 0.36·x و کلِ شکل برای جا شدن در
|
||||
* کادر مقیاس میشود. جهتِ x کوتاهشده است (نسبتِ ۰٫۵۸) تا طولِ ظاهری بزرگتر از واقعی دیده نشود.
|
||||
*/
|
||||
type XY = [number, number];
|
||||
type P3 = [number, number, number];
|
||||
const W = 320;
|
||||
const H = 270;
|
||||
const PAD = 34;
|
||||
const proj = ([x, y, z]: P3): XY => [y - 0.46 * x, z - 0.36 * x];
|
||||
|
||||
export function Space({ spec }: { spec: SpaceSpec }) {
|
||||
const pts: Record<string, P3> = { O: [0, 0, 0], ...spec.points };
|
||||
const items = spec.items ?? [];
|
||||
const get = (n: string): P3 | undefined => pts[n];
|
||||
|
||||
// کرانِ شکل: همهی نقطهها، جعبهها و محورها (محور تا کمی بیشتر از بزرگترین مختصات)
|
||||
const all: P3[] = Object.values(pts);
|
||||
for (const it of items) if (it.type === 'box') all.push(it.to);
|
||||
const ext = (k: 0 | 1 | 2) => Math.max(1, ...all.map((p) => Math.abs(p[k])));
|
||||
const ax: P3 = [ext(0) * 1.15, ext(1) * 1.15, ext(2) * 1.15];
|
||||
const corners: P3[] = [...all];
|
||||
if (spec.axes !== false) corners.push([ax[0], 0, 0], [0, ax[1], 0], [0, 0, ax[2]]);
|
||||
const xs = corners.map((c) => proj(c)[0]), ys = corners.map((c) => proj(c)[1]);
|
||||
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
|
||||
const s = Math.min((W - 2 * PAD) / Math.max(x1 - x0, 1e-6), (H - 2 * PAD) / Math.max(y1 - y0, 1e-6));
|
||||
const ox = (W - (x1 - x0) * s) / 2 - x0 * s;
|
||||
const oy = (H - (y1 - y0) * s) / 2 + y1 * s;
|
||||
const T = (p: P3): XY => {
|
||||
const [u, v] = proj(p);
|
||||
return [ox + u * s, oy - v * s];
|
||||
};
|
||||
|
||||
const els: ReactNode[] = [];
|
||||
let key = 0;
|
||||
const k = () => key++;
|
||||
const line = (a: P3, b: P3, col: string, w = 1.8, dash?: string, arrow = false) => {
|
||||
const A = T(a), B = T(b);
|
||||
els.push(<line key={k()} x1={A[0]} y1={A[1]} x2={B[0]} y2={B[1]} stroke={col} strokeWidth={w} strokeDasharray={dash} strokeLinecap="round" />);
|
||||
if (arrow) {
|
||||
const ang = Math.atan2(B[1] - A[1], B[0] - A[0]);
|
||||
const h = 9, a1 = ang + Math.PI - 0.38, a2 = ang + Math.PI + 0.38;
|
||||
els.push(<path key={k()} d={`M${B[0]},${B[1]} L${B[0] + h * Math.cos(a1)},${B[1] + h * Math.sin(a1)} L${B[0] + h * Math.cos(a2)},${B[1] + h * Math.sin(a2)} Z`} fill={col} />);
|
||||
}
|
||||
};
|
||||
const text = (p: XY, t: string | undefined, col = INK, dx = 8, dy = -7) => {
|
||||
if (t) els.push(<Label key={k()} x={p[0] + dx} y={p[1] + dy} color={col} size={12}>{mathText(t)}</Label>);
|
||||
};
|
||||
|
||||
// صفحههای مختصات بهصورت شبکهی کمرنگ پشت همه چیز
|
||||
if (spec.axes !== false) {
|
||||
const mk = (a: P3, b: P3, c: P3, d: P3) => {
|
||||
const q = [a, b, c, d].map(T);
|
||||
els.push(<path key={k()} d={`M${q.map((p) => p.join(',')).join(' L')} Z`} fill={`${COLORS.blue}0c`} stroke="none" />);
|
||||
};
|
||||
mk([0, 0, 0], [0, ax[1], 0], [ax[0], ax[1], 0], [ax[0], 0, 0]);
|
||||
line([0, 0, 0], [ax[0], 0, 0], SOFT, 1.6, undefined, true);
|
||||
line([0, 0, 0], [0, ax[1], 0], SOFT, 1.6, undefined, true);
|
||||
line([0, 0, 0], [0, 0, ax[2]], SOFT, 1.6, undefined, true);
|
||||
text(T([ax[0], 0, 0]), 'x', SOFT, -10, 12);
|
||||
text(T([0, ax[1], 0]), 'y', SOFT, 8, 4);
|
||||
text(T([0, 0, ax[2]]), 'z', SOFT, 8, 6);
|
||||
}
|
||||
|
||||
// چندضلعیها اول (نیمهشفاف)، بعد جعبهها، پارهخطها، نقطهها
|
||||
for (const it of items) {
|
||||
if (it.type !== 'polygon') continue;
|
||||
const ps = it.points.map(get).filter((p): p is P3 => !!p);
|
||||
if (ps.length < 3) continue;
|
||||
const q = ps.map(T);
|
||||
const c = color(it.color, COLORS.blue);
|
||||
els.push(<path key={k()} d={`M${q.map((p) => p.join(',')).join(' L')} Z`} fill={`${c}26`} stroke={c} strokeWidth={1.8} strokeLinejoin="round" />);
|
||||
text([q.reduce((a, p) => a + p[0], 0) / q.length, q.reduce((a, p) => a + p[1], 0) / q.length], it.label, c, 0, 4);
|
||||
}
|
||||
for (const it of items) {
|
||||
if (it.type !== 'box') continue;
|
||||
const [a, b, c] = it.to;
|
||||
const col = color(it.color, COLORS.teal);
|
||||
const v = (i: number, j: number, l: number): P3 => [i * a, j * b, l * c];
|
||||
const hidden: [P3, P3][] = [[v(0, 0, 0), v(1, 0, 0)], [v(0, 0, 0), v(0, 1, 0)], [v(0, 0, 0), v(0, 0, 1)]];
|
||||
const shown: [P3, P3][] = [
|
||||
[v(1, 0, 0), v(1, 1, 0)], [v(0, 1, 0), v(1, 1, 0)],
|
||||
[v(1, 0, 0), v(1, 0, 1)], [v(0, 1, 0), v(0, 1, 1)], [v(1, 1, 0), v(1, 1, 1)],
|
||||
[v(0, 0, 1), v(1, 0, 1)], [v(0, 0, 1), v(0, 1, 1)], [v(1, 0, 1), v(1, 1, 1)], [v(0, 1, 1), v(1, 1, 1)],
|
||||
];
|
||||
hidden.forEach(([p, q]) => line(p, q, col, 1.3, '4 3'));
|
||||
shown.forEach(([p, q]) => line(p, q, col, 1.8));
|
||||
}
|
||||
for (const it of items) {
|
||||
if (it.type === 'segment' || it.type === 'vector') {
|
||||
const a = get(it.from), b = get(it.to);
|
||||
if (!a || !b) continue;
|
||||
const col = color(it.color, it.type === 'vector' ? COLORS.blue : INK);
|
||||
line(a, b, col, 2.2, it.dashed ? '5 4' : undefined, it.type === 'vector');
|
||||
const A = T(a), B = T(b);
|
||||
text([(A[0] + B[0]) / 2, (A[1] + B[1]) / 2], it.label, col, 8, -6);
|
||||
} else if (it.type === 'drop') {
|
||||
const p = get(it.point);
|
||||
if (!p) continue;
|
||||
const col = color(it.color, SOFT);
|
||||
line(p, [p[0], p[1], 0], col, 1.2, '3 3');
|
||||
line([p[0], p[1], 0], [p[0], 0, 0], col, 1.2, '3 3');
|
||||
line([p[0], p[1], 0], [0, p[1], 0], col, 1.2, '3 3');
|
||||
}
|
||||
}
|
||||
for (const it of items) {
|
||||
if (it.type !== 'point') continue;
|
||||
const p = get(it.point);
|
||||
if (!p) continue;
|
||||
const q = T(p), col = color(it.color, INK);
|
||||
els.push(<circle key={k()} cx={q[0]} cy={q[1]} r={3.6} fill={col} />);
|
||||
text(q, it.label ?? it.point, col, 8, -6);
|
||||
}
|
||||
// مبدأ
|
||||
const o = T([0, 0, 0]);
|
||||
els.push(<circle key={k()} cx={o[0]} cy={o[1]} r={2.6} fill={SOFT} />);
|
||||
if (!items.some((i) => i.type === 'point' && i.point === 'O')) text(o, 'O', SOFT, -9, 13);
|
||||
|
||||
return <svg viewBox={`0 0 ${W} ${H}`} width="100%" role="img" aria-label="شکلِ سهبعدی">{els}</svg>;
|
||||
}
|
||||
165
src/diagram/svg.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { createContext, type ReactNode } from 'react';
|
||||
import type { ColorName } from './types';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* رنگها و قطعههای مشترکِ همهی شکلها.
|
||||
*
|
||||
* رنگها معنا دارند و در همهی شکلها یکیاند: آبی شکلِ اصلی، قرمز زاویه و چیزی که
|
||||
* باید دیده شود، سبز جواب، نارنجی/بنفش سریهای دوم و سوم. مدل فقط نامِ رنگ را
|
||||
* میدهد، نه کدِ hex، تا شکلها همیشه با رنگهای اپ بخوانند.
|
||||
*/
|
||||
export const COLORS: Record<ColorName, string> = {
|
||||
blue: '#3F5BD8',
|
||||
red: '#E5484D',
|
||||
green: '#1F9D55',
|
||||
orange: '#EE8A12',
|
||||
purple: '#8B5CF6',
|
||||
teal: '#0E9F9F',
|
||||
pink: '#D9468F',
|
||||
gray: '#8A8FA3',
|
||||
black: '#26263D',
|
||||
yellow: '#F5B400',
|
||||
};
|
||||
|
||||
/** ترتیبِ رنگِ سریها وقتی مدل رنگ نداده (نمودارِ دوم، ستونِ سوم، …). */
|
||||
export const SERIES: ColorName[] = ['blue', 'red', 'green', 'orange', 'purple', 'teal', 'pink', 'yellow'];
|
||||
|
||||
/** رنگِ هر ستون یا برشِ نمودار: اگر برچسب نامِ یک رنگ باشد («قرمز»، «مهرهی آبی (۳)») همان رنگ، وگرنه رنگهای
|
||||
* پیشفرض به ترتیب، بدون رنگهایی که برچسبی به نامشان هست؛ تا «قرمز» در چرخنده یا جدولِ فراوانی آبی کشیده نشود. */
|
||||
const COLOR_WORDS: [string, ColorName][] = [
|
||||
['قرمز', 'red'], ['سرخ', 'red'], ['آبی', 'blue'], ['سبز', 'green'], ['نارنجی', 'orange'], ['بنفش', 'purple'],
|
||||
['صورتی', 'pink'], ['زرد', 'yellow'], ['خاکستری', 'gray'], ['مشکی', 'black'], ['سیاه', 'black'],
|
||||
];
|
||||
const namedColor = (label: string): ColorName | undefined =>
|
||||
COLOR_WORDS.find(([w]) => new RegExp(`(^|[\\s(«])${w}($|[\\s)»])`).test(label))?.[1];
|
||||
export function seriesColors(labels: string[], n: number): string[] {
|
||||
const named = Array.from({ length: n }, (_, i) => namedColor(labels[i] ?? ''));
|
||||
const free = SERIES.filter((c) => !named.includes(c));
|
||||
let k = 0;
|
||||
return named.map((c) => COLORS[c ?? free[k++ % free.length]]);
|
||||
}
|
||||
|
||||
export const INK = COLORS.black;
|
||||
export const SOFT = '#9EA3B8';
|
||||
export const GRID = '#E4E6F0';
|
||||
export const PAPER = '#FBFBFF';
|
||||
|
||||
export const color = (name: ColorName | undefined, fallback: string) => (name ? COLORS[name] : fallback);
|
||||
|
||||
/** عدد با رقمِ فارسی، منفیِ درست (−) و بدونِ صفرهای بیمعنای اعشار. */
|
||||
export function fa(n: number, digits = 2): string {
|
||||
if (!Number.isFinite(n)) return '';
|
||||
const r = Math.round(n * 10 ** digits) / 10 ** digits;
|
||||
const s = (Object.is(r, -0) ? 0 : r).toString();
|
||||
return toFa(s.replace('-', '−').replace('.', '٫'));
|
||||
}
|
||||
|
||||
/** حرفِ فارسی/عربی — نه رقم: ۰–۹ِ فارسی (U+06F0…) عمداً بیرون است. */
|
||||
export const PERSIAN_LETTER = /[\u0620-\u064A\u066E-\u06D3\u06FA-\u06FF]/;
|
||||
|
||||
/**
|
||||
* متنِ ریاضی را چپبهراست قفل میکند. رقمِ فارسی در الگوریتمِ جهتِ یونیکد «عددِ
|
||||
* عربی» است و علامتهای بینِ دو رقم را راستبهچپ میکند: «۳ × ۴ = ۱۲» به شکلِ
|
||||
* «۱۲ = ۴ × ۳» دیده میشد، حتی با dir=ltr. نشانهی نامرئیِ LRM بینِ نویسهها
|
||||
* همهی علامتها را چپبهراست نگه میدارد. فقط برای متنی که حرفِ فارسی ندارد.
|
||||
*/
|
||||
export function ltr(s: string): string {
|
||||
if (!s || PERSIAN_LETTER.test(s)) return s;
|
||||
return '\u200E' + [...s].join('\u200E') + '\u200E';
|
||||
}
|
||||
|
||||
/** رقمِ لاتین به فارسی، منفیِ درست (−)، و قفلِ چپبهراست برای متنِ ریاضیِ خالص. */
|
||||
export function mathText(s: string): string {
|
||||
const t = toFa(s.replace(/-/g, '−'));
|
||||
return ltr(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* جهتِ متنِ SVG: متنِ ریاضیِ خالص با bidi-override چپبهراست (در Chrome نه LRM و نه
|
||||
* direction=ltr رقمِ فارسی را از برعکس شدن نجات نداد: «۳ × ۴ = ۱۲» → «۱۲ = ۴ × ۳»)،
|
||||
* و متنِ فارسی جهتش را از خودش میگیرد.
|
||||
*/
|
||||
export function bidi(children: ReactNode): { direction?: 'ltr'; style: { unicodeBidi: 'plaintext' | 'bidi-override'; direction?: 'ltr' } } {
|
||||
const math = typeof children === 'string' && !PERSIAN_LETTER.test(children);
|
||||
// bidi-override: نویسهها دقیقاً به ترتیبِ نوشتن، چپ به راست. برای ریاضیِ خالص درست
|
||||
// همان چیزی است که میخواهیم؛ برای متنِ فارسی هرگز، چون حروف را برعکس میکند.
|
||||
return math ? { direction: 'ltr', style: { unicodeBidi: 'bidi-override', direction: 'ltr' } } : { style: { unicodeBidi: 'plaintext' } };
|
||||
}
|
||||
|
||||
/** برچسب با هالهی سفید تا روی خطها خوانا بماند؛ جهتش را از متنِ خودش میگیرد. */
|
||||
export function Label({
|
||||
x, y, color: c = INK, size = 11, weight = 700, anchor = 'middle', children,
|
||||
}: { x: number; y: number; color?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; children: ReactNode }) {
|
||||
return (
|
||||
<text x={x} y={y} fontSize={size} fontWeight={weight} fill={c} textAnchor={anchor}
|
||||
stroke={PAPER} strokeWidth={3.2} strokeLinejoin="round" paintOrder="stroke" {...bidi(children)}>
|
||||
{children}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
/** سرِ فلش؛ هر رنگ یک marker (SVG رنگِ marker را از خط به ارث نمیبرد). */
|
||||
export function Arrowheads({ ids }: { ids: string[] }) {
|
||||
return (
|
||||
<defs>
|
||||
{[...new Set(ids)].map((c) => (
|
||||
<marker key={c} id={arrowId(c)} viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill={c} />
|
||||
</marker>
|
||||
))}
|
||||
</defs>
|
||||
);
|
||||
}
|
||||
export const arrowId = (hex: string) => `dg-ah-${hex.replace('#', '')}`;
|
||||
|
||||
/** کسرِ دوطبقه (صورت روی مخرج)؛ در کتابِ دبستان «/» ممیز است، پس کسر همیشه طبقهای کشیده میشود. */
|
||||
export function StackedFraction({ x, y, num, den, color: c = INK, size = 10 }: { x: number; y: number; num: string; den: string; color?: string; size?: number }) {
|
||||
const w = Math.max(num.length, den.length) * size * 0.62 + 4;
|
||||
return (
|
||||
<g>
|
||||
<text x={x} y={y - 2} fontSize={size} fontWeight={700} fill={c} textAnchor="middle">{num}</text>
|
||||
<line x1={x - w / 2} x2={x + w / 2} y1={y + 1} y2={y + 1} stroke={c} strokeWidth={1} />
|
||||
<text x={x} y={y + size + 2} fontSize={size} fontWeight={700} fill={c} textAnchor="middle">{den}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function niceStep(span: number, maxTicks: number): number {
|
||||
const raw = span / Math.max(1, maxTicks);
|
||||
const pow = 10 ** Math.floor(Math.log10(raw || 1));
|
||||
const m = raw / pow;
|
||||
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * pow;
|
||||
}
|
||||
|
||||
export function ticksBetween(lo: number, hi: number, step: number): number[] {
|
||||
const out: number[] = [];
|
||||
for (let t = Math.ceil(lo / step - 1e-9) * step; t <= hi + 1e-9; t += step) out.push(Math.round(t * 1e9) / 1e9);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** بزرگترین مقسومعلیهِ مشترک، برای ساده کردنِ کسرِ برچسبها. */
|
||||
export function gcd(a: number, b: number): number {
|
||||
a = Math.abs(a); b = Math.abs(b);
|
||||
while (b) [a, b] = [b, a % b];
|
||||
return a || 1;
|
||||
}
|
||||
|
||||
/** برچسبِ مضربِ π برای محورِ مثلثاتی: π/2، π، 3π/2، −π، … */
|
||||
export function piLabel(t: number): string {
|
||||
const k = Math.round(t / (Math.PI / 2));
|
||||
if (k === 0) return '۰';
|
||||
const g = gcd(k, 2);
|
||||
const num = k / g, den = 2 / g;
|
||||
const sign = num < 0 ? '−' : '';
|
||||
const n = Math.abs(num);
|
||||
const head = n === 1 ? 'π' : `${toFa(n)}π`;
|
||||
return den === 1 ? `${sign}${head}` : `${sign}${head}/${toFa(den)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* زیرِ شکلِ سوالِ آزمون، شمارشِ خودکار («۲ تکه از ۵ تکه»، «۶ خانه از ۱۲ خانه»، «۶ × ۴ = ۲۴»، «۹ تا»)
|
||||
* جوابِ سوال را لو میداد؛ آزمون این را روشن میکند و شکل فقط خودِ داده را نشان میدهد.
|
||||
* در درس و جوابِ دستیار شمارش میماند چون آنجا توضیح است، نه سوال.
|
||||
*/
|
||||
export const HideCounts = createContext(false);
|
||||
130
src/diagram/types.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* شِمای شکلِ دستیار. همان قراردادی که بکاند (rag/services/diagram.py) اعتبارسنجی
|
||||
* میکند و مدل طبقِ قاعدهی پرامپت مینویسد. هر تغییر اینجا، آنجا هم لازم است.
|
||||
*
|
||||
* اصل: مدل «چه» را میگوید (نقطهی وسط، پای عمود، ریشههای تابع)، کدِ اپ «کجا» را
|
||||
* حساب میکند. هیچ مختصاتی که بشود حساب کرد از مدل گرفته نمیشود.
|
||||
*/
|
||||
|
||||
import type { QuadItem, TriangleItem } from './shapes';
|
||||
|
||||
export type ColorName = 'blue' | 'red' | 'green' | 'orange' | 'purple' | 'teal' | 'pink' | 'gray' | 'black' | 'yellow';
|
||||
|
||||
export type Ref = string | [number, number];
|
||||
|
||||
/** تعریفِ نقطه: مختصات، یا ساختنی از نقطههای دیگر. */
|
||||
export type PointDef =
|
||||
| [number, number]
|
||||
| { polar: [number, number]; from?: Ref }
|
||||
| { mid: [Ref, Ref] }
|
||||
| { foot: [Ref, Ref, Ref] }
|
||||
| { intersect: [[Ref, Ref], [Ref, Ref]] }
|
||||
| { along: [Ref, Ref, number] }
|
||||
| { fx: [string, number] }
|
||||
/** نقطهی انتقالیافته با بردارِ [dx, dy] */
|
||||
| { translate: [number, number]; from: Ref };
|
||||
|
||||
interface Style {
|
||||
color?: ColorName;
|
||||
dashed?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export type Transform =
|
||||
| { rotate: { center: Ref; deg: number } }
|
||||
| { reflect: 'x' | 'y' | 'y=x' | [Ref, Ref] }
|
||||
| { translate: [number, number] }
|
||||
| { scale: { center: Ref; k: number } };
|
||||
|
||||
export type FunctionMark = 'roots' | 'vertex' | 'extrema' | 'yIntercept' | 'axis';
|
||||
|
||||
export type PlaneItem =
|
||||
| ({ type: 'point'; point: Ref; open?: boolean } & Style)
|
||||
| ({ type: 'segment'; from: Ref; to: Ref; ticks?: number } & Style)
|
||||
| ({ type: 'arrow' | 'vector'; from: Ref; to: Ref } & Style)
|
||||
| ({ type: 'line'; through?: [Ref, Ref]; /** «3x + 5y = 4»، همانطور که در مسئله نوشته شده */ equation?: string; /** ax + by + c = 0 */ eq?: [number, number, number] } & Style)
|
||||
| ({ type: 'ray'; from: Ref; through: Ref } & Style)
|
||||
| ({ type: 'vline'; x: number } & Style)
|
||||
| ({ type: 'hline'; y: number } & Style)
|
||||
| ({ type: 'circle'; center: Ref; r?: number; through?: Ref; fill?: boolean } & Style)
|
||||
| ({ type: 'arc' | 'sector'; center: Ref; r: number; start: number; end: number } & Style)
|
||||
| ({ type: 'angle'; vertex: Ref; from: Ref; to: Ref; arcs?: number } & Style)
|
||||
| ({ type: 'rightAngle'; vertex: Ref; from: Ref; to: Ref } & Style)
|
||||
| ({ type: 'polygon'; points: Ref[]; fill?: boolean } & Style)
|
||||
| ({ type: 'regularPolygon'; n: number; r: number; center?: Ref; start?: number; names?: string[]; fill?: boolean } & Style)
|
||||
| ({ type: 'exteriorAngle'; vertex: Ref; from: Ref; to: Ref } & Style)
|
||||
| TriangleItem
|
||||
| QuadItem
|
||||
| ({ type: 'function'; expr: string; from: number; to: number; marks?: FunctionMark[] } & Style)
|
||||
| ({ type: 'piecewise'; pieces: { expr: string; from: number; to: number; closedFrom?: boolean; closedTo?: boolean }[] } & Style)
|
||||
| ({ type: 'tangent'; expr: string; at: number } & Style)
|
||||
| ({ type: 'area'; expr: string; expr2?: string; from: number; to: number } & Style)
|
||||
| ({ type: 'region'; expr: string; side: 'above' | 'below'; from?: number; to?: number; strict?: boolean } & Style)
|
||||
| ({ type: 'image'; of: Ref[]; transform: Transform; names?: string[] } & Style)
|
||||
| { type: 'text'; at: [number, number]; text: string; color?: ColorName };
|
||||
|
||||
export interface PlaneSpec {
|
||||
kind: 'plane';
|
||||
title: string;
|
||||
axes: boolean;
|
||||
grid?: boolean;
|
||||
/** محورِ x با مضربهای π/2 */
|
||||
trig?: boolean;
|
||||
xRange?: [number, number];
|
||||
yRange?: [number, number];
|
||||
points: Record<string, PointDef>;
|
||||
items: PlaneItem[];
|
||||
}
|
||||
|
||||
export interface NumberLineSpec {
|
||||
kind: 'numberline';
|
||||
title: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
/** برچسبِ تیکها بهصورتِ کسر (۱/۴، ۲/۴، …) */
|
||||
fractions?: boolean;
|
||||
points: { at: number; label: string; open?: boolean; color?: ColorName }[];
|
||||
jumps: { from: number; to: number; label: string }[];
|
||||
intervals?: { from: number | null; to: number | null; closedFrom?: boolean; closedTo?: boolean; color?: ColorName; label?: string }[];
|
||||
}
|
||||
|
||||
export interface FractionSpec {
|
||||
kind: 'fraction'; title: string; shape: 'circle' | 'rect'; parts: number; shaded: number;
|
||||
/** واحدهای کاملِ عددِ مخلوط (دو و سهچهارم → ۲) */
|
||||
wholes?: number;
|
||||
/** مقایسهی چند کسر با نوارهای هماندازه */
|
||||
compare?: { parts: number; shaded: number; label?: string }[];
|
||||
}
|
||||
export interface GridSpec { kind: 'grid'; title: string; rows: number; cols: number; shaded: number | [number, number][]; color?: ColorName }
|
||||
export interface GroupsSpec { kind: 'groups'; title: string; groups: number; each: number; item?: 'circle' | 'square' | 'star'; label?: string }
|
||||
/** الگوی شکلی: stages تعدادِ پایهی هر مرحله (row: n در یک ردیف، square: n×n، rect: n×(n+1)،
|
||||
* triangle: مثلثی، stair: پلکانی، L)؛ stick: n مربعِ چوبکبریتی در یک ردیف. */
|
||||
export interface PatternSpec { kind: 'pattern'; title: string; layout: 'row' | 'square' | 'rect' | 'triangle' | 'stair' | 'L'; unit: 'square' | 'dot' | 'stick'; stages: number[]; next?: boolean; counts?: boolean; color?: ColorName }
|
||||
export interface ClockSpec { kind: 'clock'; title: string; hour: number; minute: number }
|
||||
export interface ChartSpec { kind: 'bar' | 'linechart' | 'pie'; title: string; labels: string[]; values: number[]; unit?: string }
|
||||
export interface TableSpec { kind: 'table'; title: string; headers: string[]; rows: string[][] }
|
||||
export interface VennSpec { kind: 'venn'; title: string; sets: string[]; regions: Record<string, string[]>; shade?: string[]; /** نامِ نمایشیِ مجموعهها («والیبال»)؛ بیآن همان A، B، C */ names?: string[] }
|
||||
export interface MappingSpec { kind: 'mapping'; title: string; left: string[]; right: string[]; arrows: [number, number][]; leftLabel?: string; rightLabel?: string }
|
||||
export interface TreeNode { label: string; p?: string; children?: TreeNode[] }
|
||||
export interface TreeSpec { kind: 'tree'; title: string; root: TreeNode }
|
||||
export interface SolidSpec {
|
||||
kind: 'solid';
|
||||
title: string;
|
||||
shape: 'cube' | 'cuboid' | 'prism' | 'pyramid' | 'cylinder' | 'cone' | 'sphere' | 'hemisphere';
|
||||
/** a پهنا، b عمق، c ارتفاع (مکعبمستطیل)؛ n تعدادِ ضلعِ قاعده؛ r شعاع؛ h ارتفاع */
|
||||
dims?: { a?: number; b?: number; c?: number; n?: number; r?: number; h?: number };
|
||||
labels?: { a?: string; b?: string; c?: string; r?: string; h?: string };
|
||||
}
|
||||
|
||||
export type SpaceItem =
|
||||
| { type: 'point' | 'drop'; point: string; label?: string; color?: ColorName }
|
||||
| { type: 'vector' | 'segment'; from: string; to: string; label?: string; dashed?: boolean; color?: ColorName }
|
||||
| { type: 'polygon'; points: string[]; label?: string; color?: ColorName }
|
||||
| { type: 'box'; to: [number, number, number]; label?: string; color?: ColorName };
|
||||
/** فضای سهبعدی با تصویرِ مایلِ ثابت (x رو به بیننده، y راست، z بالا). */
|
||||
export interface SpaceSpec { kind: 'space'; title: string; axes?: boolean; points: Record<string, [number, number, number]>; items: SpaceItem[] }
|
||||
|
||||
export type DiagramSpec =
|
||||
| PlaneSpec | NumberLineSpec | FractionSpec | GridSpec | GroupsSpec | ClockSpec
|
||||
| ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec | PatternSpec | SpaceSpec;
|
||||
248
src/lib/MathText.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { Fragment } from 'react';
|
||||
import { plainMath, plainPersian } from './plainMath';
|
||||
|
||||
/**
|
||||
* متنِ فارسی که ریاضی لایش است («عدد −۱ میشود»، «رأس (۱، −۴) است»).
|
||||
*
|
||||
* رقمِ فارسی در الگوریتمِ جهتِ یونیکد «عددِ عربی» است و در متنِ راستبهچپ علامتهای
|
||||
* کنارش را جابهجا میکند: «−۱» بهصورتِ «۱−» و «(۱، −۴)» بهصورتِ «(−۴ ،۱)» دیده
|
||||
* میشد. هر تکهای که حرفِ فارسی ندارد ولی رقم دارد، اینجا با bidi-override چپبهراست
|
||||
* قفل میشود؛ جملهی فارسی دورش دست نمیخورد.
|
||||
*
|
||||
* فهرستِ داخلِ جمله («از −۳ به −۲، −۱، ۰ میرسیم»، «الگویِ ۲، ۵، ۸، …») مالِ جمله است و
|
||||
* از راست خوانده میشود؛ پس «،» و «؛» بیرونِ پرانتز و آکولاد تکه را میشکنند و هر عضو
|
||||
* جدا قفل میشود. داخلِ `{۱، ۲، ۳}` و `(۱، −۴)` ریاضی است و یکجا قفل میماند. سنجیده
|
||||
* با مختصاتِ رسمشده در Chrome روی همهی سوالها و گزینههای بانک و چند جوابِ دستیار
|
||||
* (۲۰۲۶-۰۹-۲۹)؛ پیش از آن هر ۴۸۲ فهرستِ داخلِ جمله برعکس دیده میشد.
|
||||
*
|
||||
* آزموده روی Chrome (دسکتاپ و موبایلِ شبیهسازیشده)؛ روی Safariِ واقعیِ آیفون دیده نشده.
|
||||
*/
|
||||
// حروف، اِعراب (کسرهی «خودِ»)، و نیمفاصله؛ رقمهای ۰–۹ عمداً بیروناند.
|
||||
const LETTER = '\\u0620-\\u065F\\u066E-\\u06D3\\u06FA-\\u06FF\\u200C';
|
||||
// «نویسهی ریاضی»: رقم یا حرفِ لاتین (نامِ تابع و متغیر: f، x، A). بدونِ حرفِ لاتین «−f(x)» تکه حساب
|
||||
// نمیشد و در متنِ راستبهچپ بهصورتِ «f(x)−» دیده میشد (گزارشِ ۲۰۲۶-۱۰-۱۲).
|
||||
const MATH_CH = '0-9۰-۹A-Za-z';
|
||||
// تکهای بدونِ حرفِ فارسی و بدونِ خطِ تازه که دستِکم یک نویسهی ریاضی دارد
|
||||
const RUN = new RegExp(`[^${LETTER}\\n]*[${MATH_CH}][^${LETTER}\\n]*`, 'g');
|
||||
const HAS_MATH = new RegExp(`[${MATH_CH}]`);
|
||||
// نشانههایی که مالِ جملهی فارسیاند، نه ریاضی: از دو سرِ تکه بیرون میمانند
|
||||
const EDGE_START = /^[\s،؛:!؟«»"'.=]+/;
|
||||
const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
|
||||
// isolate، نه embedding: تکهی قفلشده از بیرون یک واحدِ خنثی است. با bidi-overrideِ ساده
|
||||
// تکهها از بیرون «حرفِ چپبهراست» حساب میشدند و «،»ِ بینِ دو عضوِ فهرست را هم به
|
||||
// سمتِ خودشان میکشیدند، پس فهرست باز برعکس میشد.
|
||||
const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const;
|
||||
|
||||
// فرمولِ «واقعی» (نه عددِ تنها): نمادِ رابطه یا عمل دارد و دستِکم چهار نویسه است.
|
||||
const FORMULA = /[=<>≤≥≠∈∉⊂⊆∪∩+×÷^√∑∫]|[0-9۰-۹A-Za-z)][\s]*[−\-/][\s]*[0-9۰-۹A-Za-z(]/;
|
||||
|
||||
/**
|
||||
* `display`: فرمولِ وسطِ جملهی فارسی در خطِ جدا و وسطچین مینشیند (سوالها و درسها)، تا
|
||||
* چشم وسطِ فرمول به خطِ بعد نیفتد. عددِ تنها و عبارتِ کوتاهِ بدونِ نمادِ عمل داخلِ جمله میماند.
|
||||
* آزموده نشده: صفحهی واقعیِ اپ در Chrome و Safari؛ فقط منطقِ تشخیص با نمونههای بانک سنجیده شد.
|
||||
*/
|
||||
// ماتریس «[[۲، ۷]، [۴، ۱]]»: ردیفها داخلِ کروشهی تو در تو. اگر مثلِ متنِ عادی بگذاریم، در خطِ باریک
|
||||
// وسطِ ردیف شکسته میشود و راستبهچپ بودنِ جمله عددها را جابهجا نشان میدهد (گزارشِ مالک ۲۰۲۶-۱۰-۱۴).
|
||||
const MATRIX = /\[\s*\[[^\[\]]*\](?:\s*[،,]\s*\[[^\[\]]*\])*\s*\]/g;
|
||||
|
||||
function Matrix({ src }: { src: string }) {
|
||||
const rows = [...src.matchAll(/\[([^\[\]]*)\]/g)].map((m) => m[1].split(/[،,]/).map((c) => plainPersian(plainMath(c.trim()))));
|
||||
const border = '2px solid currentColor';
|
||||
return (
|
||||
<span
|
||||
dir="ltr"
|
||||
style={{ display: 'inline-block', verticalAlign: 'middle', margin: '4px 6px', padding: '2px 8px', borderLeft: border, borderRight: border, borderRadius: 6, unicodeBidi: 'isolate' }}
|
||||
>
|
||||
{rows.map((row, i) => (
|
||||
<span key={i} style={{ display: 'flex', justifyContent: 'center', gap: 14, lineHeight: 1.7, whiteSpace: 'nowrap' }}>
|
||||
{row.map((c, j) => <span key={j} style={{ minWidth: 18, textAlign: 'center' }}>{c}</span>)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* سطرهای ماتریس که خطِبهخط نوشته شدهاند («b₁₁ b₁₂ ⋯ b(1n)» زیرِ «b₂₁ b₂₂ ⋯ b(2n)» و «⋮ ⋮ ⋱ ⋮»):
|
||||
* بدونِ ستونبندی، نقطههای عمودی زیرِ درایهها نمیافتند و فاصلهی دوتایی هم در HTML فرو میریزد.
|
||||
* دو خطِ پشتِ هم یا بیشتر که بدونِ حرفِ فارسیاند و تعدادِ توکنشان برابر (≥۳) است، یک جدول میشوند.
|
||||
* سطرِ اول میتواند برچسب داشته باشد («B(m×n) =»).
|
||||
*/
|
||||
const PERSIAN = /[-ۿ]/;
|
||||
function parseRow(line: string): { label: string; cells: string[] } | null {
|
||||
const t = line.trim();
|
||||
if (!t || PERSIAN.test(t)) return null;
|
||||
const m = /^(.*?\s=\s+)(\S.*)$/.exec(t);
|
||||
const label = m ? m[1].trim() : '';
|
||||
const cells = (m ? m[2] : t).split(/\s+/);
|
||||
// معادله («f(1) = 1² + 1 = 2») سطرِ ماتریس نیست: توکنِ عملگر یعنی ماتریس نیست.
|
||||
// نمادِ تکی (+ = ⊆ → …) جز نقطهچینها و شمارهی فهرست («۲)») هم یعنی ماتریس نیست.
|
||||
if (cells.some((c) => (/^[^\p{L}\p{N}]$/u.test(c) && !'⋯⋮⋱…'.includes(c)) || /^[0-9۰-۹]+[.)]$/.test(c))) return null;
|
||||
return cells.length >= 3 ? { label, cells } : null;
|
||||
}
|
||||
|
||||
function splitGrids(raw: string): (string | { rows: { label: string; cells: string[] }[] })[] | null {
|
||||
const lines = raw.split('\n');
|
||||
const parts: (string | { rows: { label: string; cells: string[] }[] })[] = [];
|
||||
let buf: string[] = [], found = false, i = 0;
|
||||
while (i < lines.length) {
|
||||
const first = parseRow(lines[i]);
|
||||
let j = i;
|
||||
const rows: { label: string; cells: string[] }[] = [];
|
||||
while (first && j < lines.length) {
|
||||
const r = parseRow(lines[j]);
|
||||
if (!r || r.cells.length !== first.cells.length) break;
|
||||
rows.push(r);
|
||||
j++;
|
||||
}
|
||||
if (rows.length >= 2) {
|
||||
if (buf.length) parts.push(buf.join('\n'));
|
||||
buf = [];
|
||||
parts.push({ rows });
|
||||
found = true;
|
||||
i = j;
|
||||
} else {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (buf.length) parts.push(buf.join('\n'));
|
||||
return found ? parts : null;
|
||||
}
|
||||
|
||||
function Grid({ rows }: { rows: { label: string; cells: string[] }[] }) {
|
||||
const border = '2px solid currentColor';
|
||||
const label = rows.find((r) => r.label)?.label ?? '';
|
||||
const clean = (c: string) => plainPersian(plainMath(c));
|
||||
return (
|
||||
<span dir="ltr" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', gap: '2px 8px', margin: '6px 0', maxWidth: '100%', unicodeBidi: 'isolate', fontWeight: 700 }}>
|
||||
{label && <span style={{ whiteSpace: 'nowrap' }}>{clean(label)}</span>}
|
||||
<span style={{ display: 'inline-grid', gridTemplateColumns: `repeat(${rows[0].cells.length}, auto)`, columnGap: 10, rowGap: 4, padding: '2px 8px', maxWidth: '100%', overflowX: 'auto', borderLeft: border, borderRight: border, borderRadius: 6 }}>
|
||||
{rows.flatMap((r, i) => r.cells.map((c, j) => <span key={`${i}-${j}`} style={{ textAlign: 'center', whiteSpace: 'nowrap', lineHeight: 1.7 }}>{clean(c)}</span>))}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MathText({ text: raw, display = false }: { text: string; display?: boolean }) {
|
||||
const grids = splitGrids(raw);
|
||||
if (grids) {
|
||||
return <>{grids.map((p, k) => (typeof p === 'string' ? <MathText key={k} text={p} display={display} /> : <Grid key={k} rows={p.rows} />))}</>;
|
||||
}
|
||||
if (!MATRIX.test(raw)) { MATRIX.lastIndex = 0; return <Inline raw={raw} display={display} />; }
|
||||
MATRIX.lastIndex = 0;
|
||||
const out: React.ReactNode[] = [];
|
||||
let last = 0, k = 0;
|
||||
for (const m of raw.matchAll(MATRIX)) {
|
||||
const at = m.index ?? 0;
|
||||
if (at > last) out.push(<Inline key={`t${k++}`} raw={raw.slice(last, at)} display={display} />);
|
||||
out.push(<Matrix key={`m${k++}`} src={m[0]} />);
|
||||
last = at + m[0].length;
|
||||
}
|
||||
if (last < raw.length) out.push(<Inline key={`t${k++}`} raw={raw.slice(last)} display={display} />);
|
||||
return <>{out}</>;
|
||||
}
|
||||
|
||||
function Inline({ raw, display }: { raw: string; display: boolean }) {
|
||||
const first = build(raw, display);
|
||||
// دو فرمولِ بلوکی در یک جمله («a₁ = … و a₂ = …») هر کدام خطِ جدا میگرفتند و «و» و «.» تنها میماندند.
|
||||
const { nodes } = first.blocks > 1 ? build(raw, false) : first;
|
||||
return <>{nodes}</>;
|
||||
}
|
||||
|
||||
function build(raw: string, display: boolean): { nodes: React.ReactNode[]; blocks: number } {
|
||||
const text = plainPersian(plainMath(raw));
|
||||
let blocks = 0;
|
||||
const out: React.ReactNode[] = [];
|
||||
const depth = bracketDepth(text);
|
||||
let last = 0, i = 0;
|
||||
for (const m of text.matchAll(RUN)) {
|
||||
for (const [start, piece] of splitList(m[0], m.index ?? 0, depth)) {
|
||||
let run = piece;
|
||||
// شمارهی مرحله در سرِ خط («۴) …») مالِ جملهی فارسی است و باید سمتِ راست بماند.
|
||||
const atLineStart = start === 0 || text[start - 1] === '\n';
|
||||
const step = atLineStart ? /^\s*[0-9۰-۹]{1,2}\)\s*/.exec(run)?.[0] ?? '' : '';
|
||||
const lead = step + (EDGE_START.exec(run.slice(step.length))?.[0] ?? '');
|
||||
run = run.slice(lead.length);
|
||||
const tail = EDGE_END.exec(run)?.[0] ?? '';
|
||||
run = run.slice(0, run.length - tail.length);
|
||||
// «(۱، −۴)» باید کامل قفل شود، ولی پرانتزِ بیجفت در لبهی تکه مالِ جمله است:
|
||||
// در «۱۰ − ۲ (با محاسبه…)» پرانتزِ باز جزوِ جملهی فارسیِ بعدی است و اگر قفل شود
|
||||
// به سمتِ غلط میرود.
|
||||
const edge = trimUnpaired(run);
|
||||
const head = lead + edge.head;
|
||||
run = edge.run;
|
||||
if (!run || !HAS_MATH.test(run)) continue;
|
||||
out.push(text.slice(last, start + head.length));
|
||||
const before = text.slice(0, start + head.length), after = text.slice(start + head.length + run.length);
|
||||
const inSentence = new RegExp(`[${LETTER}]`).test(before) || new RegExp(`[${LETTER}]`).test(after);
|
||||
if (display && inSentence && run.trim().length >= 4 && FORMULA.test(run) && depth[start + head.length] === 0) {
|
||||
blocks++;
|
||||
out.push(<span key={i++} style={{ ...LTR, display: 'block', textAlign: 'center', margin: '4px 0', fontWeight: 800 }}>{run}</span>);
|
||||
} else {
|
||||
out.push(<span key={i++} style={LTR}>{run}</span>);
|
||||
}
|
||||
last = start + head.length + run.length;
|
||||
}
|
||||
}
|
||||
out.push(text.slice(last));
|
||||
return { nodes: out.map((n, j) => (typeof n === 'string' ? <Fragment key={`t${j}`}>{n}</Fragment> : n)), blocks };
|
||||
}
|
||||
|
||||
/** عمقِ پرانتز/کروشه/آکولاد پیش از هر نویسهی متن. */
|
||||
function bracketDepth(text: string): number[] {
|
||||
const depth: number[] = [];
|
||||
let d = 0;
|
||||
for (const ch of text) {
|
||||
depth.push(d);
|
||||
if ('([{'.includes(ch)) d++;
|
||||
else if (')]}'.includes(ch)) d = Math.max(0, d - 1);
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
/** تکه را روی «،» و «؛»ِ بیرونِ پرانتز میشکند؛ جداکننده خودش بیرون میماند. */
|
||||
function splitList(run: string, offset: number, depth: number[]): [number, string][] {
|
||||
const pieces: [number, string][] = [];
|
||||
let from = 0;
|
||||
for (let k = 0; k < run.length; k++) {
|
||||
if ((run[k] === '،' || run[k] === '؛') && !depth[offset + k]) {
|
||||
pieces.push([offset + from, run.slice(from, k)]);
|
||||
from = k + 1;
|
||||
}
|
||||
}
|
||||
pieces.push([offset + from, run.slice(from)]);
|
||||
return pieces;
|
||||
}
|
||||
|
||||
const count = (s: string, ch: string) => s.split(ch).length - 1;
|
||||
|
||||
/**
|
||||
* پرانتزِ بازِ بیجفت در تهِ تکه و بستهی بیجفت در سرش را (با فاصلهی کنارش) کنار
|
||||
* میگذارد؛ `head` یعنی چه مقدار از سرِ تکه به جملهی فارسی برگشت.
|
||||
*/
|
||||
function trimUnpaired(run: string): { head: string; run: string } {
|
||||
let head = '';
|
||||
const opens = (r: string) => count(r, '(') + count(r, '[') + count(r, '{');
|
||||
const closes = (r: string) => count(r, ')') + count(r, ']') + count(r, '}');
|
||||
while (/[(\[{]\s*$/.test(run) && opens(run) > closes(run)) {
|
||||
run = run.slice(0, run.length - /\s*[(\[{]\s*$/.exec(run)![0].length);
|
||||
}
|
||||
while (/^\s*[)\]}]/.test(run) && closes(run) > opens(run)) {
|
||||
const m = /^\s*[)\]}]\s*/.exec(run)![0];
|
||||
head += m;
|
||||
run = run.slice(m.length);
|
||||
}
|
||||
// «…اگر i<j).»: پرانتزِ بستهی بیجفتِ تهِ تکه مالِ جملهی فارسی است. اگر داخلِ قفلِ چپبهراست بماند،
|
||||
// سمتِ راستِ j با شکلِ «)» دیده میشود، نه سمتِ چپ و آینهشده (گزارشِ مالک ۲۰۲۶-۱۰-۱۵).
|
||||
while (/[)\]}]\s*$/.test(run) && closes(run) > opens(run)) {
|
||||
run = run.slice(0, run.length - /\s*[)\]}]\s*$/.exec(run)![0].length);
|
||||
}
|
||||
// و پرانتزِ بازِ بیجفتِ سرِ تکه («(مثلاً a=7 …» که بستهاش جای دیگری است) هم مالِ جمله است.
|
||||
while (/^\s*[(\[{]/.test(run) && opens(run) > closes(run)) {
|
||||
const m = /^\s*[(\[{]\s*/.exec(run)![0];
|
||||
head += m;
|
||||
run = run.slice(m.length);
|
||||
}
|
||||
return { head, run };
|
||||
}
|
||||
47
src/lib/StepText.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { MathText } from './MathText';
|
||||
import { toFa } from './format';
|
||||
|
||||
/**
|
||||
* متنِ درس که قدمهایش در یک پاراگراف پشتِ هم آمدهاند («۱. مجموعه را بنویسید. ۲. بررسی کنید… ۳. …»):
|
||||
* ۱۰۴۱ مقدمهی درس در دیتابیس همین شکلاند (۱۴۰۵-۰۷) و روی صفحه یک تکهی درهم دیده میشدند. اینجا به
|
||||
* فهرستِ شمارهدار تبدیل میشوند؛ متنِ بیفهرست همان پاراگرافِ قبلی میماند.
|
||||
*
|
||||
* فقط دنبالهی ۱، ۲، ۳، … با «.» یا «)» و فاصلهی بعدش شمرده میشود، پس «۳.۵» (اعشار) و «شکلِ ۲. سپس» که
|
||||
* پیش از آن ۱ ندارد فهرست نمیشوند.
|
||||
*/
|
||||
const MARKER = /(^|\s)([0-9۰-۹]{1,2})[.)](?=\s)/g;
|
||||
const num = (d: string) => Number(d.replace(/[۰-۹]/g, (c) => String(c.charCodeAt(0) - 0x06f0)));
|
||||
|
||||
export function parseNumbered(text: string): { lead: string; items: string[] } | null {
|
||||
const marks: { start: number; end: number }[] = [];
|
||||
for (const m of text.matchAll(MARKER)) {
|
||||
if (num(m[2]) !== marks.length + 1) continue;
|
||||
const start = (m.index ?? 0) + m[1].length;
|
||||
marks.push({ start, end: start + m[2].length + 1 });
|
||||
}
|
||||
if (marks.length < 2) return null;
|
||||
return {
|
||||
lead: text.slice(0, marks[0].start).trim(),
|
||||
items: marks.map((mk, i) => text.slice(mk.end, marks[i + 1]?.start ?? text.length).trim()),
|
||||
};
|
||||
}
|
||||
|
||||
export function StepText({ text, color, className = '' }: { text: string; color: string; className?: string }) {
|
||||
const list = parseNumbered(text);
|
||||
if (!list) return <p className={`whitespace-pre-line ${className}`}><MathText text={text} display /></p>;
|
||||
return (
|
||||
<div className={className}>
|
||||
{list.lead && <p className="mb-2 whitespace-pre-line"><MathText text={list.lead} display /></p>}
|
||||
<ol className="flex flex-col gap-2.5">
|
||||
{list.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-2.5">
|
||||
<span className="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
|
||||
{toFa(i + 1)}
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre-line"><MathText text={item} display /></span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/lib/bazaar.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { store } from './storage';
|
||||
|
||||
/**
|
||||
* پلِ پرداختِ کافهبازار، وقتی همین سایت داخلِ اپِ اندرویدِ تدریسینو (WebView) باز است.
|
||||
*
|
||||
* خریدِ درونبرنامهایِ بازار فقط از کدِ بومیِ اندروید (کتابخانهی Poolakey) شدنی است، پس
|
||||
* اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه میگذارد و این فایل تنها
|
||||
* جایی است که با آن حرف میزند. در مرورگرِ معمولی آن شیء نیست و خرید با زرینپال است.
|
||||
*
|
||||
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
|
||||
* JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
|
||||
* خریدِ اشتراک با subscribeProduct (فقط اشتراک فروخته میشود)؛ orderId همان payloadِ خرید است؛
|
||||
* dynamicPriceToken رشتهی خالی یعنی قیمتِ پیشخان.
|
||||
* JS → اندروید TadrisinoAndroid.bazaarPrices(skusJson) قیمتِ پیشخان؛ جواب با رویدادِ «tadrisino:bazaar-prices»
|
||||
* و detailِ { prices: { sku: «۸۹۸,۰۰۰ تومان» } } یا { error }
|
||||
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
|
||||
* { orderId, purchaseToken } خرید انجام شد
|
||||
* { orderId, error: 'canceled' | 'failed' | 'unavailable', message? }
|
||||
* («unavailable»: بازار نصب نیست/وصل نشد یا قدیمی است؛ message: not_installed | old_bazaar | not_connected)
|
||||
*
|
||||
* `bazaarBuy` از APKهای پس از ۲۰۲۶-۱۰-۱۲ است. APKهای قبلی کلیدِ RSA نداشتند و Poolakey در آنها خاموش بود، پس از
|
||||
* بازار هیچ خریدی نکردهاند؛ در آنها کاشیِ بازار غیرفعال است و «اپ را بهروز کن» میگوید.
|
||||
* هیچکدام از اینها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۱۲).
|
||||
*/
|
||||
|
||||
interface AndroidBridge {
|
||||
bazaarBuy?: (sku: string, orderId: string, dynamicPriceToken: string) => void;
|
||||
/** APKِ قدیمی؛ فقط برای اینکه بدانیم داخلِ اپیم ولی خرید از بازار در آن ممکن نیست. */
|
||||
bazaarPurchase?: (sku: string, orderId: string) => void;
|
||||
bazaarPrices?: (skusJson: string) => void;
|
||||
/** اولین صفحهی واقعی کشیده شد؛ اسپلشِ سیستم میتواند برود (APKهای قدیمیتر این را ندارند). */
|
||||
appReady?: () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
TadrisinoAndroid?: AndroidBridge;
|
||||
}
|
||||
}
|
||||
|
||||
type BridgeResult =
|
||||
| { orderId: string; purchaseToken: string }
|
||||
| { orderId: string; error: 'canceled' | 'failed' | 'unavailable'; message?: string };
|
||||
|
||||
export class BazaarPurchaseError extends Error {
|
||||
constructor(readonly kind: 'canceled' | 'failed' | 'unavailable' | 'timeout', message = '') {
|
||||
super(message || kind);
|
||||
}
|
||||
}
|
||||
|
||||
/** داخلِ اپِ اندرویدِ تدریسینوییم (هر نسخهای). */
|
||||
export function inAndroidApp(): boolean {
|
||||
return typeof window.TadrisinoAndroid === 'object' && window.TadrisinoAndroid !== null;
|
||||
}
|
||||
|
||||
/** اپی که میتواند از بازار اشتراک بخرد. */
|
||||
export function canBuyFromBazaar(): boolean {
|
||||
return typeof window.TadrisinoAndroid?.bazaarBuy === 'function';
|
||||
}
|
||||
|
||||
/** صفحهی خریدِ بازار ممکن است طول بکشد (ورود به بازار، کارتِ بانکی)؛ این سقف فقط جلوی گیر کردنِ همیشگی را میگیرد. */
|
||||
const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
/** خرید را به اپِ اندروید میسپارد و purchaseToken را برمیگرداند. */
|
||||
export function bazaarBuy(sku: string, orderId: string, dynamicPriceToken: string | null): Promise<string> {
|
||||
const bridge = window.TadrisinoAndroid;
|
||||
if (!bridge?.bazaarBuy) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app'));
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => finish(() => reject(new BazaarPurchaseError('timeout'))), PURCHASE_TIMEOUT_MS);
|
||||
function finish(settle: () => void) {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener('tadrisino:bazaar', onResult);
|
||||
settle();
|
||||
}
|
||||
function onResult(event: Event) {
|
||||
const detail = (event as CustomEvent<BridgeResult>).detail;
|
||||
if (!detail || detail.orderId !== orderId) return;
|
||||
if ('purchaseToken' in detail && detail.purchaseToken) finish(() => resolve(detail.purchaseToken));
|
||||
else if ('error' in detail) finish(() => reject(new BazaarPurchaseError(detail.error, detail.message)));
|
||||
}
|
||||
window.addEventListener('tadrisino:bazaar', onResult);
|
||||
try {
|
||||
bridge.bazaarBuy!(sku, orderId, dynamicPriceToken ?? '');
|
||||
} catch (err) {
|
||||
finish(() => reject(new BazaarPurchaseError('failed', String(err))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type PricesResult = { prices?: Record<string, string>; error?: string };
|
||||
|
||||
function askPrices(skus: string[]): Promise<Record<string, string>> {
|
||||
const bridge = window.TadrisinoAndroid;
|
||||
if (!bridge?.bazaarPrices || !skus.length) return Promise.resolve({});
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => done(() => reject(new Error('timeout'))), 8000);
|
||||
function done(settle: () => void) {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener('tadrisino:bazaar-prices', onResult);
|
||||
settle();
|
||||
}
|
||||
function onResult(event: Event) {
|
||||
const detail = (event as CustomEvent<PricesResult>).detail;
|
||||
if (!detail) return;
|
||||
if (detail.prices) done(() => resolve(detail.prices!));
|
||||
else done(() => reject(new Error(detail.error || 'failed')));
|
||||
}
|
||||
window.addEventListener('tadrisino:bazaar-prices', onResult);
|
||||
bridge.bazaarPrices!(JSON.stringify(skus));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* قیمتِ ثبتشده در پیشخانِ بازار برای هر SKU، از خودِ بازار روی گوشی (API سرور قیمت نمیدهد). اتصالِ Poolakey
|
||||
* چند لحظه بعد از باز شدنِ اپ برقرار میشود، پس یک بار دوباره امتحان میشود. خطا یعنی قیمتی نشان داده نمیشود.
|
||||
*/
|
||||
export async function bazaarPrices(skus: string[]): Promise<Record<string, string>> {
|
||||
try {
|
||||
return await askPrices(skus);
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
try {
|
||||
return await askPrices(skus);
|
||||
} catch (err) {
|
||||
console.error('bazaarPrices failed', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotency-Keyِ خریدِ نیمهتمامِ هر SKU. اگر بازار پول را گرفت ولی تأییدِ سرور نرسید (قطعِ
|
||||
* اینترنت)، تلاشِ بعدی باید همان سفارش را بگیرد؛ سفارشِ تازه با توکنِ قبلی تأیید نمیشد چون هر
|
||||
* توکن فقط به یک سفارش میخورد.
|
||||
*/
|
||||
const pendingKey = (sku: string) => `tadrisino.bazaarPending.${sku}`;
|
||||
export const bazaarPending = {
|
||||
get: (sku: string) => store.get(pendingKey(sku)),
|
||||
set: (sku: string, key: string) => store.set(pendingKey(sku), key),
|
||||
clear: (sku: string) => store.remove(pendingKey(sku)),
|
||||
};
|
||||
31
src/lib/color.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/** رنگِ hex را روشن (amount > 0) یا تیره (amount < 0) میکند؛ برای سایهی دکمههای سهبعدی. */
|
||||
export function shade(hex: string, amount: number): string {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) return hex;
|
||||
const n = parseInt(m[1], 16);
|
||||
const channel = (c: number) => {
|
||||
const v = amount < 0 ? c * (1 + amount) : c + (255 - c) * amount;
|
||||
return Math.max(0, Math.min(255, Math.round(v)));
|
||||
};
|
||||
const r = channel((n >> 16) & 255), g = channel((n >> 8) & 255), b = channel(n & 255);
|
||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
function luminance(hex: string): number {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) return 1;
|
||||
const n = parseInt(m[1], 16);
|
||||
const lin = (c: number) => { const v = c / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
|
||||
return 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* همان رنگ، فقط تا جایی تیره میشود که متنِ سفید رویش کنتراستِ دستکم `min` بگیرد
|
||||
* (WCAG: ۳ برای متنِ درشت). رنگِ روشن برای آیکن و نوار میماند و فقط پسزمینهی متنِ سفید از این رد میشود،
|
||||
* پس هیچ رنگِ تازهای وارد اپ نمیشود، فقط سایهی تیرهترِ همان.
|
||||
*/
|
||||
export function onWhite(hex: string, min = 3.2): string {
|
||||
let c = hex;
|
||||
for (let i = 0; i < 12 && 1.05 / (luminance(c) + 0.05) < min; i++) c = shade(c, -0.07);
|
||||
return c;
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
/**
|
||||
* true فقط بارِ اولی که این بخش در این نشست دیده میشود. انیمیشنِ ورودِ کارتها و جزیرهها (یکییکی ظاهر
|
||||
* شدن) بارِ اول جذاب است، ولی با هر برگشت به همان تب تکرار میشد و صفحه چشمک میزد.
|
||||
*/
|
||||
export function useFirstVisit(tag: string): boolean {
|
||||
const [first] = useState(() => {
|
||||
if (seen.has(tag)) return false;
|
||||
seen.add(tag);
|
||||
return true;
|
||||
});
|
||||
return first;
|
||||
}
|
||||
128
src/lib/lineFit.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* شکستنِ بهترِ خط برای متنِ دوطرفچین.
|
||||
*
|
||||
* مرورگر خط را حریصانه میشکند: کلمهای که یک ذره جا نشود به خطِ بعد میرود و خطِ بالا برای
|
||||
* پر شدن فاصلهی بینِ کلمهها را بیشازحد باز میکند. چاپِ حرفهای (الگوریتمِ Knuth–Plass) برای
|
||||
* اینکه کلمهای از خطِ پایین به بالا بیاید، فاصلهها را کمی فشرده میکند. اینجا همان را با
|
||||
* `word-spacing`ِ منفیِ کوچک میکنیم: برای هر پاراگراف چند مقدار امتحان میشود، هر بار خطها
|
||||
* اندازهگیری میشوند و مقداری میماند که فاصلهی نهاییِ خطها به فاصلهی معمولی نزدیکتر است.
|
||||
*
|
||||
* فقط `style.wordSpacing` عوض میشود، نه DOM؛ پس با React و قفلهای جهتِ `MathText` درگیر نمیشود.
|
||||
* آزموده: Chrome (سرِ بیسر، فونتِ واقعیِ Vazirmatn)؛ روی Safari و WebViewِ اندروید دیده نشده.
|
||||
*/
|
||||
|
||||
// برحسبِ em. فاصلهی معمولیِ Vazirmatn حدودِ ۰٫۲۶em است؛ تا ۰٫۰۸em فشردن (~۳۰٪) حدِ چاپِ حرفهای است.
|
||||
const CANDIDATES = [0, -0.02, -0.04, -0.06, -0.08];
|
||||
const SPACE_EM = 0.26;
|
||||
const SELECTOR = 'p, li, .whitespace-pre-line';
|
||||
const MIN_CHARS = 14;
|
||||
// بیشینهی فاصلهی مجازِ بینِ کلمهها نسبت به فاصلهی معمول، پیش از آنکه دوطرفچین کنار گذاشته شود
|
||||
const MAX_GAP = 2.2;
|
||||
|
||||
const observed = new WeakSet<HTMLElement>();
|
||||
|
||||
/** پهنای طبیعیِ هر خط (با ترازِ start) از مستطیلهای Range. */
|
||||
function lineWidths(el: HTMLElement, fontSize: number): number[] {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const rects = [...range.getClientRects()].filter((r) => r.width > 0.5).sort((a, b) => a.top + a.height / 2 - (b.top + b.height / 2));
|
||||
const lines: { y: number; left: number; right: number }[] = [];
|
||||
for (const r of rects) {
|
||||
const y = r.top + r.height / 2;
|
||||
const last = lines[lines.length - 1];
|
||||
if (last && Math.abs(y - last.y) < fontSize * 0.7) {
|
||||
last.left = Math.min(last.left, r.left);
|
||||
last.right = Math.max(last.right, r.right);
|
||||
} else {
|
||||
lines.push({ y, left: r.left, right: r.right });
|
||||
}
|
||||
}
|
||||
return lines.map((l) => l.right - l.left);
|
||||
}
|
||||
|
||||
export function fit(el: HTMLElement) {
|
||||
if (!el.isConnected) return;
|
||||
el.style.wordSpacing = '';
|
||||
el.style.textAlign = '';
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.textAlign !== 'justify' || cs.display === 'flex' || cs.display === 'inline-flex' || cs.display === 'none') return;
|
||||
const text = el.textContent ?? '';
|
||||
if (text.length < MIN_CHARS) return;
|
||||
const fontSize = parseFloat(cs.fontSize);
|
||||
const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
if (!(width > 0)) return;
|
||||
const spaces = (text.match(/\s+/g) ?? []).length;
|
||||
if (spaces < 2) return;
|
||||
const s0 = SPACE_EM * fontSize;
|
||||
|
||||
el.style.textAlign = 'start';
|
||||
let best = 0, bestScore = Infinity, bestWorst = 0;
|
||||
for (const c of CANDIDATES) {
|
||||
el.style.wordSpacing = `${c}em`;
|
||||
const w = lineWidths(el, fontSize);
|
||||
if (w.length < 2) { best = 0; break; }
|
||||
const total = w.reduce((a, b) => a + b, 0);
|
||||
let score = 0, worst = 0;
|
||||
for (let i = 0; i < w.length - 1; i++) {
|
||||
const k = Math.max(1, (spaces * w[i]) / total); // فاصلههای این خط (تخمین)
|
||||
const gap = s0 + c * fontSize + Math.max(0, width - w[i]) / k;
|
||||
// فشردن گرانتر از کشیدن است (چاپِ حرفهای هم همینطور): فشردهشدنِ فاصله زودتر زشت دیده میشود
|
||||
const dev = (gap - s0) / s0;
|
||||
worst = Math.max(worst, gap / s0);
|
||||
score += (dev < 0 ? 2.2 * dev : dev) ** 2;
|
||||
}
|
||||
if (score < bestScore - 1e-6) { bestScore = score; best = c; bestWorst = worst; }
|
||||
}
|
||||
// حتی بهترین شکستن فاصلهای بیش از ۲٫۲ برابرِ معمول میسازد (کلمهی بلند که جا نمیشود): چاپِ
|
||||
// حرفهای هم در این حالت دوطرفچین نمیکند، چون لبهی نامرتب از فاصلهی گشاد بهتر است.
|
||||
if (bestWorst > MAX_GAP) {
|
||||
el.style.wordSpacing = '';
|
||||
el.style.textAlign = 'start';
|
||||
return;
|
||||
}
|
||||
el.style.textAlign = '';
|
||||
el.style.wordSpacing = best ? `${best}em` : '';
|
||||
}
|
||||
|
||||
// همهی اجراها همزمان و پیش از اولین نقاشیِ صفحهاند (MutationObserver/ResizeObserver پیش از paint
|
||||
// صدا زده میشوند)، پس متنِ خام و اصلاحنشده هرگز دیده نمیشود؛ تأخیرِ قبلی (۱۵۰ms) یک چشمک میساخت.
|
||||
const lastWidth = new WeakMap<HTMLElement, number>();
|
||||
|
||||
function watch(el: HTMLElement) {
|
||||
if (observed.has(el)) return;
|
||||
observed.add(el);
|
||||
resizer.observe(el);
|
||||
fit(el);
|
||||
}
|
||||
|
||||
const resizer = new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const el = e.target as HTMLElement;
|
||||
const width = e.contentRect.width;
|
||||
// تغییرِ ارتفاعِ خودِ fit نباید دوباره fit را راه بیندازد
|
||||
if (lastWidth.get(el) === width) continue;
|
||||
lastWidth.set(el, width);
|
||||
fit(el);
|
||||
}
|
||||
});
|
||||
|
||||
function scan(node: ParentNode) {
|
||||
if (node instanceof HTMLElement && node.matches(SELECTOR)) watch(node);
|
||||
node.querySelectorAll<HTMLElement>(SELECTOR).forEach(watch);
|
||||
}
|
||||
|
||||
export function startLineFit() {
|
||||
const root = document.getElementById('root')!;
|
||||
scan(root);
|
||||
new MutationObserver((records) => {
|
||||
const changed = new Set<HTMLElement>();
|
||||
for (const r of records) {
|
||||
r.addedNodes.forEach((n) => { if (n instanceof HTMLElement) scan(n); });
|
||||
// متنِ داخلِ پاراگراف عوض شده (سوالِ بعدی، پاسخِ در حالِ پخش)
|
||||
const host = (r.target instanceof HTMLElement ? r.target : r.target.parentElement)?.closest<HTMLElement>(SELECTOR);
|
||||
if (host) changed.add(host);
|
||||
}
|
||||
changed.forEach(fit);
|
||||
}).observe(root, { childList: true, subtree: true, characterData: true });
|
||||
void document.fonts.ready.then(() => document.querySelectorAll<HTMLElement>(SELECTOR).forEach(fit));
|
||||
}
|
||||
30
src/lib/paymentMethods.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { inAndroidApp } from './bazaar';
|
||||
|
||||
export type PaymentMethod = 'zarinpal' | 'bazaar';
|
||||
|
||||
const KNOWN: PaymentMethod[] = ['zarinpal', 'bazaar'];
|
||||
|
||||
/**
|
||||
* پیشفرض: در مرورگر فقط زرینپال (خریدِ بازار بیرون از اپِ اندروید ممکن نیست)، داخلِ اپ هر دو، زرینپال اول.
|
||||
* برای نسخهای که فقط در کافهبازار منتشر میشود، `app: ["bazaar"]` در dist/config.js؛ build لازم نیست.
|
||||
*/
|
||||
const DEFAULTS: Record<'web' | 'app', PaymentMethod[]> = {
|
||||
web: ['zarinpal'],
|
||||
app: ['zarinpal', 'bazaar'],
|
||||
};
|
||||
|
||||
/**
|
||||
* روشهای پرداختِ اینجا (مرورگر یا اپ)، به ترتیبِ نمایش. نامِ ناشناخته در config.js نادیده گرفته نمیشود که
|
||||
* بیصدا یک روش گم شود: در کنسول خطا میدهد و فهرستِ پیشفرض به کار میرود.
|
||||
*/
|
||||
export function enabledPaymentMethods(): PaymentMethod[] {
|
||||
const channel = inAndroidApp() ? 'app' : 'web';
|
||||
const configured = window.__TADRISINO_CONFIG__?.paymentMethods?.[channel];
|
||||
if (!configured) return DEFAULTS[channel];
|
||||
const unknown = configured.filter((m) => !KNOWN.includes(m as PaymentMethod));
|
||||
if (unknown.length || !configured.length) {
|
||||
console.error(`config.js: paymentMethods.${channel} نامعتبر است`, configured);
|
||||
return DEFAULTS[channel];
|
||||
}
|
||||
return configured as PaymentMethod[];
|
||||
}
|
||||
90
src/lib/plainMath.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* تورِ ایمنیِ نمایش: LaTeX و نشانهگذاریِ خامی که به هر دلیل به صفحه رسیده به یونیکدِ ساده برمیگردد.
|
||||
* بکاند هم همین تبدیل را در seed، دیتابیس و پاسخِ دستیار انجام میدهد
|
||||
* (`content/plain_math.py`، `rag/services/mathtext.py`)؛ اینجا فقط آخرین لایه است تا متنِ کهنه یا
|
||||
* پاسخِ ناخواستهی مدل هرگز با `\frac` و `^{2}` دیده نشود. هر دو باید همرفتار بمانند.
|
||||
*/
|
||||
const SUP: Record<string, string> = {
|
||||
...Object.fromEntries([...'۰۱۲۳۴۵۶۷۸۹'].map((c, i) => [c, '⁰¹²³⁴⁵⁶⁷⁸⁹'[i]])),
|
||||
...Object.fromEntries([...'0123456789'].map((c, i) => [c, '⁰¹²³⁴⁵⁶⁷⁸⁹'[i]])),
|
||||
'+': '⁺', '-': '⁻', '−': '⁻', '=': '⁼', '(': '⁽', ')': '⁾',
|
||||
...Object.fromEntries([...'abcdefghijklmnoprstuvwxyzαβγδεθφχ'].map((c, i) => [c, [...'ᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏˡᵐⁿᵒᵖʳˢᵗᵘᵛʷˣʸᶻᵅᵝᵞᵟᵋᶿᵠᵡ'][i]])),
|
||||
};
|
||||
const SUB: Record<string, string> = {
|
||||
...Object.fromEntries([...'۰۱۲۳۴۵۶۷۸۹'].map((c, i) => [c, '₀₁₂₃₄₅₆₇₈₉'[i]])),
|
||||
...Object.fromEntries([...'0123456789'].map((c, i) => [c, '₀₁₂₃₄₅₆₇₈₉'[i]])),
|
||||
'+': '₊', '-': '₋', '−': '₋', '=': '₌', '(': '₍', ')': '₎',
|
||||
...Object.fromEntries([...'aehijklmnoprstuvx'].map((c, i) => [c, [...'ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ'][i]])),
|
||||
};
|
||||
const SYMBOLS: Record<string, string> = {
|
||||
times: '×', cdot: '·', div: '÷', pm: '±', mp: '∓', theta: 'θ', pi: 'π', alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ',
|
||||
Delta: 'Δ', lambda: 'λ', mu: 'μ', sigma: 'σ', Sigma: 'Σ', phi: 'φ', varphi: 'φ', omega: 'ω', epsilon: 'ε', rho: 'ρ', tau: 'τ',
|
||||
infty: '∞', neq: '≠', ne: '≠', leq: '≤', le: '≤', geq: '≥', ge: '≥', to: '→', rightarrow: '→', leftarrow: '←',
|
||||
Rightarrow: '⇒', implies: '⇒', Leftrightarrow: '⇔', iff: '⇔', perp: '⊥', parallel: '∥', circ: '°', degree: '°', ldots: '…',
|
||||
dots: '…', cdots: '⋯', vdots: '⋮', ddots: '⋱', int: '∫', sum: 'Σ', prod: '∏', partial: '∂', in: '∈', notin: '∉', subset: '⊂', subseteq: '⊆',
|
||||
supset: '⊃', cup: '∪', cap: '∩', emptyset: '∅', varnothing: '∅', forall: '∀', exists: '∃', angle: '∠', triangle: '△',
|
||||
approx: '≈', equiv: '≡', sim: '∼', cong: '≅', mid: '|', vert: '|', neg: '¬', therefore: '∴', setminus: '−',
|
||||
quad: ' ', qquad: ' ', prime: '′',
|
||||
lim: 'lim', sin: 'sin', cos: 'cos', tan: 'tan', cot: 'cot', sec: 'sec', csc: 'csc', log: 'log', ln: 'ln', exp: 'exp',
|
||||
max: 'max', min: 'min', det: 'det', gcd: 'gcd', arcsin: 'arcsin', arccos: 'arccos', arctan: 'arctan',
|
||||
};
|
||||
const BLACKBOARD: Record<string, string> = { R: 'ℝ', Q: 'ℚ', N: 'ℕ', Z: 'ℤ', C: 'ℂ' };
|
||||
const SYMBOL_RE = new RegExp('\\\\(' + Object.keys(SYMBOLS).sort((a, b) => b.length - a.length).join('|') + ')', 'g');
|
||||
const G = '\\{([^{}]*)\\}';
|
||||
const NEEDS = /[\\^$]|[A-Za-z)]_|\*\*|__|`|^#{1,6}\s/m;
|
||||
|
||||
const atomic = (s: string) => /^[\w۰-۹.]+$/.test(s.trim());
|
||||
const wrap = (s: string) => (atomic(s) ? s.trim() : `(${s.trim()})`);
|
||||
const tryMap = (body: string, table: Record<string, string>) => {
|
||||
const b = body.trim();
|
||||
return b && [...b].every((c) => c in table) ? [...b].map((c) => table[c]).join('') : null;
|
||||
};
|
||||
const script = (body: string, table: Record<string, string>, marker: string) => {
|
||||
const mapped = tryMap(body, table);
|
||||
if (mapped) return mapped;
|
||||
const b = body.trim();
|
||||
if (marker === '^') return ` به توان (${b}) `;
|
||||
return /^[\w۰-۹]{1,4}$/.test(b) ? b : `_(${b})`;
|
||||
};
|
||||
|
||||
export function plainMath(text: string): string {
|
||||
if (!text || !NEEDS.test(text)) return text;
|
||||
let s = text;
|
||||
s = s.replace(/\$\$([\s\S]+?)\$\$/g, '$1').replace(/(?<![\w$])\$([^$\n]+?)\$(?![\w$])/g, '$1');
|
||||
s = s.replace(/\\[(\[]\s*|\s*\\[)\]]/g, '');
|
||||
for (let k = 0; k < 4; k++) {
|
||||
s = s.replace(new RegExp(`\\\\(?:vec|overrightarrow|overline|bar|hat|widehat)${G}`, 'g'), '$1');
|
||||
s = s.replace(new RegExp(`\\\\mathbb${G}`, 'g'), (_m, a: string) => BLACKBOARD[a] ?? a);
|
||||
s = s.replace(new RegExp(`\\\\(?:text|mathrm|mathbf|operatorname|textbf)${G}`, 'g'), '$1');
|
||||
s = s.replace(new RegExp(`\\\\[dt]?frac${G}${G}`, 'g'), (_m, a: string, b: string) => `${wrap(a)}/${wrap(b)}`);
|
||||
s = s.replace(new RegExp(`\\\\sqrt${G}`, 'g'), (_m, a: string) => `√${wrap(a)}`);
|
||||
}
|
||||
s = s.replace(new RegExp(`\\\\lim\\s*_${G}`, 'g'), 'lim($1)');
|
||||
s = s.replace(/\\(left|right)\s*/g, '').replace(/\\\|/g, '|').replace(/\\\{/g, '{').replace(/\\\}/g, '}').replace(/\\[, ]/g, ' ');
|
||||
s = s.replace(/(?<![A-Za-z\\])sqrt\(/g, '√(');
|
||||
s = s.replace(SYMBOL_RE, (_m, n: string) => SYMBOLS[n]);
|
||||
// «vdots/ddots» بدونِ بکاسلش (در درسِ ماتریسها همینطور ذخیره شده بود)؛ سطرِ ماتریس نقطهچینِ عمودی/قطری است.
|
||||
s = s.replace(/(?<![A-Za-z\\])(vdots|ddots)(?![A-Za-z])/g, (_m, n: string) => SYMBOLS[n]);
|
||||
s = s.replace(new RegExp(`\\^${G}`, 'g'), (_m, a: string) => script(a, SUP, '^'));
|
||||
s = s.replace(new RegExp(`_${G}`, 'g'), (_m, a: string) => script(a, SUB, '_'));
|
||||
s = s.replace(/\^\(((?:[^()]|\([^()]*\))*)\)/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توان (${a.trim()}) `);
|
||||
s = s.replace(/\^([+\-−]?[0-9۰-۹]+)/g, (m, a: string) => tryMap(a, SUP) ?? m);
|
||||
s = s.replace(/(?<=[A-Za-z0-9۰-۹)₀-₉])\^([A-Za-zα-ωΑ-Ω□])(?![A-Za-z0-9۰-۹])/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توان ${a} `);
|
||||
s = s.replace(/((?<![A-Za-z0-9_])[A-Za-z]|log|ln|lim|[)∫Σ∏])_([0-9۰-۹]+[A-Za-z]?|[A-Za-z]{1,4}[0-9۰-۹]?)(?![A-Za-z0-9_])/g, (_m, p: string, a: string) => (p === 'log' && [...a].some((c) => !(c in SUB)) ? `log پایه ${a}` : p + [...a].map((c) => SUB[c] ?? c).join('')));
|
||||
s = s.replace(/\^([+\-−])/g, (_m, a: string) => SUP[a]);
|
||||
s = s.replace(/(?<=[A-Za-z)'])_([+\-−])/g, (_m, a: string) => SUB[a]);
|
||||
s = s.replace(/\^(√[0-9۰-۹A-Za-z]+)/g, ' به توان $1 ').replace(/\^([A-Za-z]{2,3})(?![A-Za-z0-9])/g, ' به توان $1 ');
|
||||
s = s.replace(/(?<=\S) {2,}(?=\S)/g, ' ');
|
||||
s = s.replace(/\*\*([\s\S]+?)\*\*/g, '$1').replace(/(?<![_\w])__(.+?)__(?![_\w])/g, '$1').replace(/`/g, '');
|
||||
return s.replace(/^\s{0,3}#{1,6}\s+/gm, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* بیاعراب و بی «ی»ِ اضافه («پایهی سوم» → «پایه سوم»)؛ تنوین («قبلاً») میماند. همان قاعدهی بکاند
|
||||
* (`content/plain_math.py::plain_persian`، روی هر پاسخِ API). اینجا برای پاسخِ استریمیِ دستیار است که پیش از
|
||||
* پاکسازیِ نهاییِ سرور تکهتکه میرسد.
|
||||
*/
|
||||
export function plainPersian(text: string): string {
|
||||
if (!text) return text;
|
||||
return text.replace(/[ٌ-ْٔ]/g, '').replace(/هی(?![-ۿ])/g, 'ه');
|
||||
}
|
||||
145
src/lib/sound.ts
@@ -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,29 +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);
|
||||
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);
|
||||
/** لمسِ گزینهی آزمون: یک «تیک»ِ خیلی ریز. */
|
||||
select: () => {
|
||||
note(880, 0, 0.05, 0.025, 'triangle');
|
||||
buzz(8);
|
||||
},
|
||||
wrong: () => tone(220, 0, 0.25, 0.06),
|
||||
finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)),
|
||||
/** `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: () => {
|
||||
note(311, 0, 0.16, 0.045, 'triangle');
|
||||
note(233, 0.12, 0.24, 0.045, 'triangle');
|
||||
buzz([35, 50, 35]);
|
||||
},
|
||||
/** پایانِ درس/جزیره: آرپژِ بالارونده و یک آکوردِ کوتاه. */
|
||||
finish: () => {
|
||||
[C5, E5, G5, C6].forEach((f, i) => note(f, i * 0.09, 0.22, 0.045));
|
||||
[C6, E6, G6].forEach((f) => note(f, 0.4, 0.5, 0.025));
|
||||
buzz([30, 60, 30, 60, 90]);
|
||||
},
|
||||
/** زنجیره روشن ماند: «فوش»ِ آتش و دو نتِ بالارونده. */
|
||||
streak: () => {
|
||||
whoosh(0, 0.55, 0.06);
|
||||
note(G5, 0.45, 0.18, 0.045);
|
||||
note(C6, 0.58, 0.4, 0.05);
|
||||
buzz([20, 40, 20, 40, 160]);
|
||||
},
|
||||
/** لرزشِ بسیار کوتاه برای تعویضِ تب؛ بیصدا. */
|
||||
tap: () => buzz(6),
|
||||
};
|
||||
|
||||
@@ -24,22 +24,13 @@ export const store = {
|
||||
};
|
||||
|
||||
export const KEYS = {
|
||||
deviceId: 'deviceId',
|
||||
onboarded: 'tadrisino.onboarded',
|
||||
conversationId: 'tadrisino.conversationId',
|
||||
ticketIds: 'tadrisino.ticketIds',
|
||||
sound: 'tadrisino.sound',
|
||||
referralCode: 'tadrisino.referralCode',
|
||||
companion: 'tadrisino.companion',
|
||||
/** {gradeId: subjectKey}؛ کتابی که کاربر در هر پایه آخر انتخاب کرده. */
|
||||
subjects: 'tadrisino.subjects',
|
||||
/** عکسِ صفحهی خانه برای بازشدنِ فوری (state/homeSnapshot.ts). */
|
||||
homeSnapshot: 'tadrisino.homeSnapshot',
|
||||
} as const;
|
||||
|
||||
/** شناسهی ثابتِ این مرورگر برای ورودِ مهمان. */
|
||||
export function getDeviceId(): string {
|
||||
let id = store.get(KEYS.deviceId);
|
||||
if (!id) {
|
||||
id = globalThis.crypto && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
store.set(KEYS.deviceId, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
5
src/lib/subscription.ts
Normal file
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
73
src/main.tsx
@@ -1,10 +1,79 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { Component, StrictMode, lazy, Suspense, type ReactNode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './app/App';
|
||||
import './styles/index.css';
|
||||
import { unlockAudioOnFirstTouch } from './lib/sound';
|
||||
import { startLineFit } from './lib/lineFit';
|
||||
|
||||
unlockAudioOnFirstTouch();
|
||||
startLineFit();
|
||||
|
||||
/**
|
||||
* فایلِ یک صفحه بعد از انتشارِ تازه پیدا نشد (اپ از قبل باز بود و نامِ فایلها با هر build عوض میشود): راهِ پیشنهادیِ
|
||||
* Vite یک بار بارگذاریِ دوباره است تا index.htmlِ تازه بیاید. تا ۳۰ ثانیه فقط یک بار، تا خرابیِ واقعیِ شبکه حلقه نشود؛
|
||||
* بعد از آن خطا به RootBoundary میرسد.
|
||||
*/
|
||||
const RELOAD_KEY = 'tadrisino.chunkReloadAt';
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
let last = 0;
|
||||
try {
|
||||
last = Number(sessionStorage.getItem(RELOAD_KEY)) || 0;
|
||||
} catch {
|
||||
/* sessionStorage بسته؛ بینگهبانِ حلقه بارگذاری نمیکنیم */
|
||||
return;
|
||||
}
|
||||
if (Date.now() - last < 30_000) return;
|
||||
event.preventDefault();
|
||||
try {
|
||||
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
/**
|
||||
* آخرین تور: هر خطای رندر که جای دیگری گرفته نشد، بهجای صفحهی سفید (React بیboundary کلِ درخت را برمیدارد)
|
||||
* پیامی با «دوباره» نشان میدهد. سبکها درونخطیاند چون ممکن است خودِ CSS یا فونت هم نرسیده باشد.
|
||||
*/
|
||||
class RootBoundary extends Component<{ children: ReactNode }, { error: unknown }> {
|
||||
state = { error: null as unknown };
|
||||
static getDerivedStateFromError(error: unknown) {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error: unknown) {
|
||||
console.error('render crashed', error);
|
||||
// داخلِ اپِ اندروید اسپلشِ بومی تا appReady میماند و این پیام را میپوشاند.
|
||||
window.TadrisinoAndroid?.appReady?.();
|
||||
}
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
return (
|
||||
<div dir="rtl" role="alert" style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16, padding: 24, background: '#fff', fontFamily: 'system-ui, sans-serif', textAlign: 'center' }}>
|
||||
<p style={{ fontSize: 16, fontWeight: 800, color: '#1a1a2e' }}>یه مشکلی پیش اومد</p>
|
||||
<p style={{ fontSize: 13, color: '#6b6b80', lineHeight: 1.8 }}>پیشرفتت سر جاشه؛ یه بار دیگه بازش کن.</p>
|
||||
<button type="button" onClick={() => window.location.reload()} style={{ border: 0, borderRadius: 16, padding: '12px 32px', background: '#ED6956', color: '#fff', fontSize: 15, fontWeight: 800 }}>
|
||||
دوباره
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// گالریِ شکلها فقط در حالتِ توسعه؛ در build اصلاً وارد نمیشود (import.meta.env.DEV ثابت است).
|
||||
const DiagramGallery = import.meta.env.DEV
|
||||
? lazy(() => import('./dev/DiagramGallery').then((m) => ({ default: m.DiagramGallery })))
|
||||
: null;
|
||||
const showGallery = DiagramGallery && new URLSearchParams(window.location.search).has('gallery');
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<RootBoundary>
|
||||
{showGallery && DiagramGallery ? (
|
||||
<Suspense fallback={null}><DiagramGallery /></Suspense>
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
</RootBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* سهیار: سیگما (Σ)، دلتا (Δ) و پی (π) — ماسکوتهای اپ.
|
||||
* همراههای اپ: سیگما (Σ)، دلتا (Δ)، پی (π) و امگا (ω). «سهیار» نامِ قدیمیِ سهتای اول است؛ در متنِ
|
||||
* کاربر تعدادِ همراهها نوشته نمیشود، چون کاتالوگ از ادمین میآید و بزرگ میشود.
|
||||
*
|
||||
* هر سه از دلِ یک دفترچهی بینهایت آمدهاند و هر کدام از جنسِ یک نمادِ ریاضیاند:
|
||||
* سیگما → راهنمای درس و آزمون دلتا → همتیمیِ تمرینِ سریع پی → دستیار و همراهِ دلگرمی
|
||||
@@ -8,13 +9,14 @@
|
||||
* ورودیها همه از همین فایلاند و هیچ متنِ کاربر واردِ SVG نمیشود.
|
||||
*/
|
||||
|
||||
export type SeyarChar = 'sigma' | 'delta' | 'pi';
|
||||
export type SeyarChar = 'sigma' | 'delta' | 'pi' | 'tedi';
|
||||
export type SeyarMood = 'idle' | 'happy' | 'excited' | 'sad' | 'angry' | 'depressed';
|
||||
|
||||
const PALETTE: Record<SeyarChar, Record<string, string>> = {
|
||||
sigma: { body: "#4C5FD5", inner: "#7A8CFF", belly: "#EAF0FF", ink: "#232355", cheek: "#FFB2C0", mouth: "#FF6B6B", tear: "#8EC9FF", badge: "#FFD23F" },
|
||||
delta: { body: "#FF7A3D", inner: "#FFB25E", belly: "#FFE7C7", ink: "#6E2A12", cheek: "#FF8FA3", mouth: "#D6402A", tear: "#8EC9FF", spike: "#FFEB3B" },
|
||||
pi: { body: "#9B7BFF", inner: "#C6B3FF", belly: "#F1ECFF", ink: "#3B2B66", cheek: "#FFC2D1", mouth: "#FF6F91", tear: "#5EC8E8" },
|
||||
tedi: { body: "#FFC63D", inner: "#FF9E42", belly: "#FFE9B0", ink: "#4A3B32", cheek: "#FFAEB0", mouth: "#E7573F", tear: "#7EC8E3" },
|
||||
};
|
||||
const Y_SHIFT: Record<SeyarMood, number> = { idle: 0, happy: 0, excited: -4, sad: 3, angry: 0, depressed: 9 };
|
||||
const ARM_DELTA: Record<SeyarMood, number> = { idle: 0, happy: 2, excited: 46, sad: -4, angry: -8, depressed: -10 };
|
||||
@@ -87,6 +89,12 @@ function seyarSigmaSVG(mood: SeyarMood): string {
|
||||
</g></svg>`;
|
||||
}
|
||||
|
||||
/** نشانِ حرفِ یونانیِ هر همراه (مثلِ مدالِ Σِ سیگما): دایرهی کوچک روی شکم با نمادِ خودش. */
|
||||
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);
|
||||
@@ -101,6 +109,7 @@ function seyarDeltaSVG(mood: SeyarMood): string {
|
||||
<path d="M100 30 Q112 30 118 42 L160 156 Q166 172 150 174 L50 174 Q34 172 40 156 L82 42 Q88 30 100 30 Z" fill="${C.body}" stroke="${C.ink}" stroke-width="6" stroke-linejoin="round"/>
|
||||
<path d="M100 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, 'Δ', '#FFD23F', C.ink)}
|
||||
</g></svg>`;
|
||||
}
|
||||
|
||||
@@ -119,22 +128,86 @@ function seyarPiSVG(mood: SeyarMood): string {
|
||||
<ellipse cx="100" cy="118" rx="74" ry="72" fill="${C.body}" stroke="${C.ink}" stroke-width="6"/>
|
||||
<ellipse cx="100" cy="134" rx="46" ry="46" fill="${C.belly}"/>
|
||||
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
|
||||
${letterBadge(100, 170, 'π', '#FFD23F', C.ink)}
|
||||
</g></svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* امگا (ω؛ تا ۱۴۰۵-۰۷ «تدی»؛ کلیدِ داخلیاش هنوز `tedi` است چون slugِ سرور همین است) — شکلکِ زردِ پیشنمایشِ موبایل (mascot-pack، ریپوی بکاند، پوشهی
|
||||
* mobile-preview). بدن، گوشها، پاها و دستها عیناً از همان پک است؛ چهرهی
|
||||
* «عصبانی» و «ناامید» را پک نداشت و با همان زبانِ طراحیِ سهیار اضافه شد.
|
||||
*/
|
||||
function tediSVG(mood: SeyarMood): string {
|
||||
const C = PALETTE.tedi;
|
||||
const S = `stroke="${C.ink}" stroke-width="5"`;
|
||||
const dy = Y_SHIFT[mood];
|
||||
const restArmL = `<rect x="20" y="112" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(14 32 135)"/>`;
|
||||
const restArmR = `<rect x="156" y="112" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(-14 168 135)"/>`;
|
||||
const upArmL = `<rect x="14" y="70" width="24" height="50" rx="12" fill="${C.body}" ${S} transform="rotate(28 26 95)"/>`;
|
||||
const upArmR = `<rect x="162" y="70" width="24" height="50" rx="12" fill="${C.body}" ${S} transform="rotate(-28 174 95)"/>`;
|
||||
const droopArmL = `<rect x="24" y="118" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(4 36 141)"/>`;
|
||||
const droopArmR = `<rect x="152" y="118" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(-4 164 141)"/>`;
|
||||
|
||||
let arms = restArmL + restArmR, hands = '', eyes = '', mouth = '', cheeks = '', extra = '';
|
||||
if (mood === 'happy' || mood === 'excited') {
|
||||
eyes = `<path d="M66 100 q10 -13 20 0" fill="none" ${S} stroke-linecap="round"/><path d="M114 100 q10 -13 20 0" fill="none" ${S} stroke-linecap="round"/>`;
|
||||
mouth = `<path d="M78 122 q22 34 44 0 q-22 12 -44 0z" fill="${C.mouth}" ${S} stroke-linejoin="round"/>`;
|
||||
cheeks = `<ellipse cx="64" cy="122" rx="12" ry="8" fill="${C.cheek}"/><ellipse cx="136" cy="122" rx="12" ry="8" fill="${C.cheek}"/>`;
|
||||
if (mood === 'excited') {
|
||||
arms = upArmL + upArmR;
|
||||
hands = `<circle cx="18" cy="66" r="12" fill="${C.body}" ${S}/><circle cx="182" cy="66" r="12" fill="${C.body}" ${S}/>`;
|
||||
extra = `<g stroke="${C.inner}" stroke-width="5" stroke-linecap="round"><path d="M40 30 l-8 -12"/><path d="M160 30 l8 -12"/><path d="M100 8 v-12"/></g>`;
|
||||
}
|
||||
} else if (mood === 'sad') {
|
||||
eyes = `<circle cx="78" cy="104" r="8" fill="${C.ink}"/><circle cx="122" cy="104" r="8" fill="${C.ink}"/>`
|
||||
+ `<path d="M68 94 q10 -6 20 -2" fill="none" ${S} stroke-linecap="round"/><path d="M112 92 q10 -4 20 2" fill="none" ${S} stroke-linecap="round"/>`;
|
||||
mouth = `<path d="M82 142 q18 -18 36 0" fill="none" ${S} stroke-linecap="round"/>`;
|
||||
extra = `<path d="M78 112 q-4 12 0 18 q4 -6 0 -18z" fill="${C.tear}"/>`;
|
||||
} else if (mood === 'angry') {
|
||||
eyes = `<path d="M68 98 l16 5 l-5 9 z" fill="${C.ink}"/><path d="M132 98 l-16 5 l5 9 z" fill="${C.ink}"/>`
|
||||
+ `<path d="M64 88 l22 7" ${S} stroke-linecap="round"/><path d="M136 88 l-22 7" ${S} stroke-linecap="round"/>`;
|
||||
mouth = `<path d="M82 136 l6 -6 l6 6 l6 -6 l6 6 l6 -6 l6 6" fill="none" ${S} stroke-linecap="round" stroke-linejoin="round"/>`;
|
||||
cheeks = `<ellipse cx="62" cy="124" rx="10" ry="6" fill="${C.mouth}" opacity=".5"/><ellipse cx="138" cy="124" rx="10" ry="6" fill="${C.mouth}" opacity=".5"/>`;
|
||||
extra = `<g stroke="${C.ink}" stroke-width="4" stroke-linecap="round" opacity=".75"><path d="M30 20 q4 -8 -2 -14"/><path d="M170 20 q-4 -8 2 -14"/></g>`;
|
||||
} else if (mood === 'depressed') {
|
||||
arms = droopArmL + droopArmR;
|
||||
eyes = `<path d="M68 104 q10 5 20 0" fill="none" stroke="${C.ink}" stroke-width="6" stroke-linecap="round"/><path d="M112 104 q10 5 20 0" fill="none" stroke="${C.ink}" stroke-width="6" stroke-linecap="round"/>`;
|
||||
mouth = `<path d="M88 136 q12 6 24 1" fill="none" ${S} stroke-linecap="round"/>`;
|
||||
extra = `<g><ellipse cx="100" cy="-6" rx="26" ry="10" fill="#B9C2CC" opacity=".9"/><ellipse cx="84" cy="0" rx="16" ry="8" fill="#B9C2CC" opacity=".9"/><ellipse cx="118" cy="0" rx="15" ry="8" fill="#B9C2CC" opacity=".9"/>`
|
||||
+ `<g stroke="#8FA0B3" stroke-width="3.5" stroke-linecap="round" opacity=".8"><line x1="86" y1="10" x2="82" y2="20"/><line x1="100" y1="12" x2="96" y2="24"/><line x1="114" y1="10" x2="110" y2="22"/></g></g>`;
|
||||
} else {
|
||||
eyes = `<circle cx="78" cy="102" r="9" fill="${C.ink}"/><circle cx="122" cy="102" r="9" fill="${C.ink}"/>`
|
||||
+ `<circle cx="81" cy="99" r="3" fill="#fff"/><circle cx="125" cy="99" r="3" fill="#fff"/>`;
|
||||
mouth = `<path d="M84 128 q16 18 32 0" fill="none" ${S} stroke-linecap="round"/>`;
|
||||
}
|
||||
|
||||
return `<svg viewBox="-14 -34 228 250" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 ${dy})">
|
||||
${extra}
|
||||
<ellipse cx="74" cy="185" rx="20" ry="13" fill="${C.inner}" ${S}/><ellipse cx="126" cy="185" rx="20" ry="13" fill="${C.inner}" ${S}/>
|
||||
${arms}
|
||||
<circle cx="56" cy="46" r="24" fill="${C.body}" ${S}/><circle cx="144" cy="46" r="24" fill="${C.body}" ${S}/>
|
||||
<circle cx="56" cy="46" r="12" fill="${C.inner}"/><circle cx="144" cy="46" r="12" fill="${C.inner}"/>
|
||||
<ellipse cx="100" cy="116" rx="72" ry="70" fill="${C.body}" ${S}/>
|
||||
<ellipse cx="100" cy="133" rx="44" ry="45" fill="${C.belly}"/>
|
||||
${hands}${cheeks}<g>${eyes}</g>${mouth}
|
||||
${letterBadge(100, 166, 'ω', '#fff', C.ink)}
|
||||
</g></svg>`;
|
||||
}
|
||||
|
||||
export const SEYAR_SVG: Record<SeyarChar, (mood: SeyarMood) => string> = {
|
||||
sigma: seyarSigmaSVG, delta: seyarDeltaSVG, pi: seyarPiSVG,
|
||||
sigma: seyarSigmaSVG, delta: seyarDeltaSVG, pi: seyarPiSVG, tedi: tediSVG,
|
||||
};
|
||||
export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = {
|
||||
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
|
||||
delta: { name: "دلتا", symbol: "Δ", role: "همتیمیِ تمرینهای سریع" },
|
||||
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
|
||||
delta: { name: "دلتا", symbol: "Δ", role: "همتیمی تمرینهای سریع" },
|
||||
pi: { name: "پی", symbol: "π", role: "همراه دلگرمی" },
|
||||
tedi: { name: "امگا", symbol: "ω", role: "رفیق همیشگی تدریسینو ریاضی" },
|
||||
};
|
||||
|
||||
/** slugِ کاراکترِ سرور (sigma/delta/pi) را به یکی از سهیار نگاشت میکند. */
|
||||
/** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراهها نگاشت میکند. */
|
||||
export function seyarFromSlug(slug: string | null | undefined): SeyarChar | null {
|
||||
const s = (slug ?? '').toLowerCase();
|
||||
if (s === 'sigma' || s === 'delta' || s === 'pi') return s;
|
||||
if (s === 'sigma' || s === 'delta' || s === 'pi' || s === 'tedi') return s;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,68 +1,181 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import type { Route, TabName } from './routes';
|
||||
|
||||
/**
|
||||
* ناوبریِ پشتهای، هماهنگ با تاریخچهی مرورگر.
|
||||
* ناوبریِ پشتهای، هماهنگ با تاریخچهی مرورگر، تا دکمهی «برگشت»ِ گوشی مثلِ اپهای بومی قدمبهقدم عقب برود:
|
||||
* برگهی باز ← صفحهی قبلی ← تبِ خانه ← بیرون از اپ.
|
||||
*
|
||||
* هر push یک ورودی به history اضافه میکند، پس دکمهی back گوشی یا مرورگر
|
||||
* یک صفحه عقب میرود بهجای بیرون انداختنِ کاربر از اپ. تعویضِ تب پشته را
|
||||
* از نو میسازد (مثلِ اپهای بومی).
|
||||
* هر «قدمِ برگشتپذیر» دقیقاً یک ورودیِ history دارد: هر صفحهی pushشده، تبی غیر از خانه، و هر برگه/حالتی که
|
||||
* با useBackStep ثبت شده. تعدادِ لازم (desired) از وضعیت حساب میشود و یک افکت ورودیها را با آن یکی میکند.
|
||||
* دکمهی برگشت یک ورودی برمیدارد (popstate) و اینجا یک قدم در اپ عقب میرویم؛ بستنِ برگه یا عوض کردنِ تب از
|
||||
* داخلِ اپ ورودیِ اضافه را با history.go پاک میکند (و آن popstate نادیده گرفته میشود).
|
||||
*
|
||||
* چرا «تله»ی همیشگی (pushState دوباره داخلِ popstate) نه: کروم ورودیای را که بیلمسِ کاربر ساخته شده در دکمهی
|
||||
* برگشت رد میکند، پس آن تله گاهی کاربر را یکراست بیرون میانداخت. اینجا ورودیها در افکتِ بلافاصله بعد از لمس
|
||||
* ساخته میشوند (فعالسازیِ گذرای کاربر هنوز برقرار است). در کرومِ دسکتاپ و موبایلِ شبیهسازیشده آزموده شد؛
|
||||
* روی WebViewِ اپِ اندروید و Safariِ آیفون با دستگاهِ واقعی نه.
|
||||
*/
|
||||
/** نوعِ آخرین جابهجایی؛ انیمیشنِ ورودِ صفحه از آن میآید (جلو از چپ، عقب از راست، تب فقط محو). */
|
||||
export type NavDirection = 'push' | 'back' | 'tab' | 'replace';
|
||||
|
||||
interface Nav {
|
||||
route: Route;
|
||||
direction: NavDirection;
|
||||
/** تبی که ریشهی پشتهی فعلی است؛ برای روشن کردنِ نوارِ پایین. */
|
||||
tab: TabName | null;
|
||||
canGoBack: boolean;
|
||||
/** هر بار که کاربر تبِ همینحالا باز را دوباره میزند یکی زیاد میشود (برای «برو بالا»). */
|
||||
reselect: number;
|
||||
push: (route: Route) => void;
|
||||
/** صفحهی فعلی را عوض میکند بدونِ اضافه کردن به تاریخچه. */
|
||||
replace: (route: Route) => void;
|
||||
back: () => void;
|
||||
/** از صفحهی فعلی بیرون میرود حتی اگر برگهای باز باشد (برگه هم بسته میشود)؛ `back` فقط خودِ برگه را میبندد. */
|
||||
leave: () => void;
|
||||
switchTab: (tab: TabName) => void;
|
||||
}
|
||||
|
||||
const NavContext = createContext<Nav | null>(null);
|
||||
|
||||
export function NavigatorProvider({ initial, children }: { initial: Route; children: ReactNode }) {
|
||||
const [stack, setStack] = useState<Route[]>([initial]);
|
||||
interface BackSteps {
|
||||
register: (onBack: () => void) => () => void;
|
||||
}
|
||||
const BackContext = createContext<BackSteps | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onPop = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
|
||||
window.addEventListener('popstate', onPop);
|
||||
return () => window.removeEventListener('popstate', onPop);
|
||||
/**
|
||||
* یک قدمِ برگشتپذیرِ داخلِ صفحه (برگهی باز، فهرستی که از «انتخاب» باز شده): تا `active` است دکمهی برگشتِ
|
||||
* گوشی اول `onBack` را صدا میزند. بیرون از اپِ اصلی (صفحهی ورود) کاری نمیکند.
|
||||
*/
|
||||
export function useBackStep(active: boolean, onBack: () => void) {
|
||||
const steps = useContext(BackContext);
|
||||
const ref = useRef(onBack);
|
||||
ref.current = onBack;
|
||||
useEffect(() => (active && steps ? steps.register(() => ref.current()) : undefined), [active, steps]);
|
||||
}
|
||||
|
||||
export function NavigatorProvider({ initial, children }: { initial: Route; children: ReactNode }) {
|
||||
const [{ stack, direction }, setState] = useState<{ stack: Route[]; direction: NavDirection }>({ stack: [initial], direction: 'tab' });
|
||||
const [reselect, setReselect] = useState(0);
|
||||
const [steps, setSteps] = useState<(() => void)[]>([]);
|
||||
const stackRef = useRef(stack);
|
||||
stackRef.current = stack;
|
||||
const stepsRef = useRef(steps);
|
||||
stepsRef.current = steps;
|
||||
|
||||
/** ورودیهایی که خودمان بالای ورودیِ اولِ اپ گذاشتهایم. */
|
||||
const entries = useRef(0);
|
||||
/** popstateهایی که از history.goِ خودمان است، نه از دکمهی کاربر. */
|
||||
const ignorePops = useRef(0);
|
||||
|
||||
/** یک قدم عقب در خودِ اپ (بی history). */
|
||||
const stepBack = useCallback(() => {
|
||||
const open = stepsRef.current;
|
||||
if (open.length) {
|
||||
open[open.length - 1]();
|
||||
return;
|
||||
}
|
||||
const s = stackRef.current;
|
||||
if (s.length > 1) setState({ stack: s.slice(0, -1), direction: 'back' });
|
||||
else if (s[0].name !== 'home') setState({ stack: [{ name: 'home' }], direction: 'back' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPop = () => {
|
||||
if (ignorePops.current > 0) {
|
||||
ignorePops.current--;
|
||||
return;
|
||||
}
|
||||
entries.current = Math.max(0, entries.current - 1);
|
||||
stepBack();
|
||||
};
|
||||
window.addEventListener('popstate', onPop);
|
||||
return () => window.removeEventListener('popstate', onPop);
|
||||
}, [stepBack]);
|
||||
|
||||
// اپِ اصلی بسته شد (خروج، نشستِ باطل): ورودیهایی که گذاشته بودیم برداشته میشوند. بیاین، در صفحهی ورود
|
||||
// (و بعد از ورودِ دوباره) هر کدام یک «برگشت»ِ بیاثر بود و کاربر باید چند بار میزد تا از اپ بیرون برود.
|
||||
// یک tick صبر: StrictMode افکت را پاک و فوراً دوباره اجرا میکند و آن بستهشدنِ واقعی نیست.
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
setTimeout(() => {
|
||||
if (mounted.current || entries.current === 0) return;
|
||||
window.history.go(-entries.current);
|
||||
entries.current = 0;
|
||||
}, 0);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const desired = steps.length + (stack.length - 1) + (stack[0].name !== 'home' ? 1 : 0);
|
||||
useEffect(() => {
|
||||
const diff = desired - entries.current;
|
||||
if (diff > 0) {
|
||||
for (let i = 0; i < diff; i++) window.history.pushState({ tadrisino: true }, '');
|
||||
} else if (diff < 0) {
|
||||
ignorePops.current++;
|
||||
window.history.go(diff);
|
||||
}
|
||||
entries.current = desired;
|
||||
}, [desired]);
|
||||
|
||||
const register = useCallback((onBack: () => void) => {
|
||||
setSteps((list) => [...list, onBack]);
|
||||
return () => setSteps((list) => list.filter((f) => f !== onBack));
|
||||
}, []);
|
||||
const backSteps = useMemo<BackSteps>(() => ({ register }), [register]);
|
||||
|
||||
const push = useCallback((route: Route) => {
|
||||
setStack((s) => [...s, route]);
|
||||
window.history.pushState({ tadrisino: true }, '');
|
||||
setState((prev) => ({ stack: [...prev.stack, route], direction: 'push' }));
|
||||
}, []);
|
||||
|
||||
const replace = useCallback((route: Route) => {
|
||||
setStack((s) => [...s.slice(0, -1), route]);
|
||||
setState((prev) => ({ stack: [...prev.stack.slice(0, -1), route], direction: 'replace' }));
|
||||
}, []);
|
||||
|
||||
const back = useCallback(() => {
|
||||
// popstate خودش صفحه را برمیدارد؛ پس دو منبعِ حقیقت نداریم.
|
||||
window.history.back();
|
||||
// دکمهی برگشتِ داخلِ اپ همان راهِ دکمهی گوشی را میرود تا history و پشته از هم جدا نشوند.
|
||||
if (entries.current > 0) window.history.back();
|
||||
else stepBack();
|
||||
}, [stepBack]);
|
||||
|
||||
const leave = useCallback(() => {
|
||||
setSteps([]);
|
||||
const s = stackRef.current;
|
||||
if (s.length > 1) setState({ stack: s.slice(0, -1), direction: 'back' });
|
||||
else if (s[0].name !== 'home') setState({ stack: [{ name: 'home' }], direction: 'back' });
|
||||
}, []);
|
||||
|
||||
const switchTab = useCallback((tab: TabName) => {
|
||||
setStack([{ name: tab } as Route]);
|
||||
// زدنِ همان تبی که باز و ریشه است صفحه را از نو نمیسازد (قبلاً دوباره بار میشد)؛ مثلِ اپهای بومی به
|
||||
// بالای همان صفحه برمیگردد.
|
||||
const current = stackRef.current;
|
||||
if (current.length === 1 && current[0].name === tab) setReselect((n) => n + 1);
|
||||
else setState({ stack: [{ name: tab } as Route], direction: 'tab' });
|
||||
}, []);
|
||||
|
||||
const value = useMemo<Nav>(() => {
|
||||
const root = stack[0].name;
|
||||
return {
|
||||
route: stack[stack.length - 1],
|
||||
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,
|
||||
leave,
|
||||
switchTab,
|
||||
};
|
||||
}, [stack, push, replace, back, switchTab]);
|
||||
}, [stack, direction, reselect, push, replace, back, leave, switchTab]);
|
||||
|
||||
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
|
||||
return (
|
||||
<NavContext.Provider value={value}>
|
||||
<BackContext.Provider value={backSteps}>{children}</BackContext.Provider>
|
||||
</NavContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNav(): Nav {
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
/** همهی صفحههای اپ و پارامترهایشان. */
|
||||
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 }
|
||||
| { name: 'lesson'; lessonId: number; nodeId?: string }
|
||||
| { name: 'quiz'; lessonId: number; nodeId?: string }
|
||||
/** بدونِ lessonId یعنی آزمونِ کلِ جزیره (nodeId لازم است). */
|
||||
| { name: 'quiz'; lessonId?: number; nodeId?: string }
|
||||
| { name: 'notifications' }
|
||||
| { name: 'leaderboard' }
|
||||
| { name: 'characters' }
|
||||
| { name: 'streak' }
|
||||
| { name: 'support' }
|
||||
| { name: 'family' }
|
||||
| { name: 'invite' }
|
||||
| { name: 'register' };
|
||||
/** «گزارشِ والدین»: والد پیشرفتِ هفته را در حسابِ خودِ دانشآموز میبیند. */
|
||||
| { name: 'report' }
|
||||
/** پرداختها و فاکتورهای خودِ کاربر. */
|
||||
| { 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
@@ -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);
|
||||
}
|
||||
@@ -1,24 +1,42 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { BookOpen, MessageSquarePlus, RotateCw, Send, ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { api, ApiError } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { Screen, ScreenHeader } from '@/components/ui';
|
||||
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 سوالِ امروز»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
|
||||
const [quota, setQuota] = useState<number | null>(null);
|
||||
const [quotaDone, setQuotaDone] = useState(false);
|
||||
useEffect(() => {
|
||||
api.ai
|
||||
.quota()
|
||||
.then((q) => setQuota(q.remainingQuota))
|
||||
.catch((err: unknown) => console.warn('ai quota lookup failed', err))
|
||||
.finally(() => setQuotaDone(true));
|
||||
}, []);
|
||||
// صفحه وقتی میآید که تاریخچه و سهمیه رسیده باشند؛ وگرنه اول خوشآمد و بعد پیامهای قبلی رویش میپرید.
|
||||
useGateHold(!chat.historyLoaded || !quotaDone);
|
||||
const listEnd = useRef<HTMLDivElement>(null);
|
||||
const autoSent = useRef(false);
|
||||
|
||||
@@ -39,10 +57,15 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
askStore.setHistory(
|
||||
r.messages
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId })),
|
||||
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId, diagram: m.diagram ?? null })),
|
||||
),
|
||||
)
|
||||
.catch(() => askStore.reset(null));
|
||||
.catch((err: unknown) => {
|
||||
// فقط گفتگوی ناموجود (۴۰۴: حذف شده یا مالِ حسابِ دیگر) کنار میرود. قبلاً اینترنتِ قطع هم شناسه را پاک
|
||||
// میکرد و گفتگو برای همیشه گم میشد؛ حالا historyLoaded نمیخورد و بارِ بعدِ باز شدنِ صفحه دوباره میپرسد.
|
||||
if (err instanceof ApiError && err.status === 404) askStore.reset(null);
|
||||
else console.warn('ai history lookup failed; keeping the conversation', err);
|
||||
});
|
||||
// فقط بارِ اولِ باز شدنِ صفحه؛ عمداً به chat وابسته نیست.
|
||||
}, []);
|
||||
|
||||
@@ -73,6 +96,9 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
userQuestion: question,
|
||||
conversationId: askStore.get().conversationId ?? undefined,
|
||||
lessonId: askStore.get().lessonId ?? undefined,
|
||||
// بیدرس، کتابِ انتخابیِ دانشآموز (هندسه، گسسته، …) را میفرستیم تا دستیار از همان کتاب جواب بدهد؛
|
||||
// با درس، سرور کتاب را از خودِ درس درمیآورد.
|
||||
subject: askStore.get().lessonId ? undefined : subjectTitle,
|
||||
questionId: forQuestionId,
|
||||
},
|
||||
(delta) => {
|
||||
@@ -85,14 +111,18 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
messageId: done.messageId,
|
||||
usedChunks: done.usedChunks,
|
||||
followUps: done.suggestedFollowUps,
|
||||
diagram: done.diagram ?? null,
|
||||
});
|
||||
askStore.setConversation(done.conversationId);
|
||||
setQuota(done.remainingQuota);
|
||||
} catch (err) {
|
||||
// سقفِ روزانه (نه throttleِ چندثانیهای): «دوباره» تا فردا بیفایده است.
|
||||
const quotaReached = err instanceof ApiError && err.code === 'ai_daily_quota';
|
||||
askStore.update(replyId, {
|
||||
text: friendlyError(err, 'جوابی نرسید'),
|
||||
failedQuestion: question,
|
||||
...(quotaReached ? { quotaReached: true } : { failedQuestion: question, failedQuestionId: forQuestionId }),
|
||||
});
|
||||
if (quotaReached) setQuota(0);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -105,7 +135,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
const question = all[index - 1];
|
||||
askStore.remove(message.localId);
|
||||
if (question?.role === 'user') askStore.remove(question.localId);
|
||||
void send(message.failedQuestion);
|
||||
void send(message.failedQuestion, message.failedQuestionId);
|
||||
}
|
||||
|
||||
const empty = chat.messages.length === 0;
|
||||
@@ -113,27 +143,30 @@ 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 }}
|
||||
>
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => askStore.reset(null)}
|
||||
disabled={busy}
|
||||
className="mt-3 flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold disabled:opacity-50"
|
||||
>
|
||||
<MessageSquarePlus size={14} /> گفتگوی تازه
|
||||
</button>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{quota !== null && <QuotaPills free={quota} />}
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => askStore.reset(null)}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold disabled:opacity-50"
|
||||
>
|
||||
<MessageSquarePlus size={14} /> گفتگوی تازه
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</ScreenHeader>
|
||||
|
||||
<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 && (
|
||||
@@ -170,6 +203,11 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
// جهتِ هر خط از اولین حرفش: «-3 + 5» هنگامِ تایپ چپبهراست میماند. خطی که با
|
||||
// فارسی شروع شود راستبهچپ است و ریاضیِ وسطش در کادرِ تایپ ممکن است جابهجا دیده شود؛
|
||||
// در textarea نمیشود تکهای را قفل کرد. پیامِ فرستادهشده از MathText رد میشود.
|
||||
dir={input ? 'auto' : 'rtl'}
|
||||
style={{ unicodeBidi: 'plaintext', textAlign: 'right' }}
|
||||
placeholder="سوالت رو بنویس…"
|
||||
className="max-h-28 min-h-10 flex-1 resize-none rounded-3xl bg-soft px-4 py-2.5 text-sm leading-6 text-ink outline-none placeholder:text-faint"
|
||||
/>
|
||||
@@ -182,19 +220,19 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
<Send size={16} className="-scale-x-100" />
|
||||
</button>
|
||||
</div>
|
||||
{quota !== null && <p className="mt-1.5 text-center text-[10px] text-muted">امروز {toFa(quota)} سوالِ دیگه میتونی بپرسی</p>}
|
||||
</form>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function Bubble({ role, children }: { role: 'user' | 'assistant'; children: React.ReactNode }) {
|
||||
function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
|
||||
const mine = role === 'user';
|
||||
const 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
|
||||
@@ -203,6 +241,44 @@ function Bubble({ role, children }: { role: 'user' | 'assistant'; children: Reac
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
{extra}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* منتظرِ جوابِ دستیار. سرویسِ مدل کلِ جواب را نگه میدارد و یکجا میفرستد؛ جوابِ
|
||||
* کامل با شکل در ۲۰۲۶-۰۹-۲۹ حدودِ ۳۰ ثانیه طول کشید. پیامِ ثابت در آن مدت شبیهِ
|
||||
* گیر کردن بود، پس متن با زمان جلو میرود. زمانها تقریبیاند و به مرحلهی واقعیِ
|
||||
* سرور وصل نیستند.
|
||||
*/
|
||||
const THINKING_STEPS: [number, string][] = [
|
||||
[0, '{name} داره تو کتابت میگرده'],
|
||||
[4, '{name} داره حلش میکنه'],
|
||||
[15, 'داره قدمها رو مرتب مینویسه'],
|
||||
[28, 'جواب کامل یه کم طول میکشه، الان میرسه'],
|
||||
];
|
||||
|
||||
function Thinking() {
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
useEffect(() => {
|
||||
const started = Date.now();
|
||||
const timer = setInterval(() => setSeconds((Date.now() - started) / 1000), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
const companion = useCompanion();
|
||||
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1].replace('{name}', companion.name);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
|
||||
<SeyarMascot char={companion.char} mood="idle" size={32} />
|
||||
</span>
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted" aria-live="polite">
|
||||
{text}
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span key={i} className="h-1.5 w-1.5 rounded-full bg-violet/60" animate={{ y: [0, -4, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -218,24 +294,11 @@ function Message({
|
||||
onFollowUp: (q: string) => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const nav = useNav();
|
||||
|
||||
if (message.role === 'user') return <Bubble role="user">{message.text}</Bubble>;
|
||||
if (message.role === 'user') return <Bubble role="user"><MathText text={message.text} /></Bubble>;
|
||||
|
||||
if (!message.text) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
|
||||
<SeyarMascot char="pi" mood="idle" size={32} />
|
||||
</span>
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted">
|
||||
پی داره فکر میکنه
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span key={i} className="h-1.5 w-1.5 rounded-full bg-violet/60" animate={{ y: [0, -4, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!message.text) return <Thinking />;
|
||||
|
||||
async function rate(helpful: boolean) {
|
||||
if (!message.messageId || message.feedback) return;
|
||||
@@ -251,9 +314,15 @@ function Message({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Bubble role="assistant">{message.text}</Bubble>
|
||||
<Bubble role="assistant" extra={message.diagram ? <div className="whitespace-normal"><Diagram spec={message.diagram} /></div> : undefined}>
|
||||
<MathText text={message.text} />
|
||||
</Bubble>
|
||||
<div className="flex flex-wrap items-center gap-2 pr-10">
|
||||
{message.failedQuestion ? (
|
||||
{message.quotaReached ? (
|
||||
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-violet px-3 py-1 text-xs font-bold text-white">
|
||||
اشتراک
|
||||
</button>
|
||||
) : message.failedQuestion ? (
|
||||
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">
|
||||
<RotateCw size={12} /> دوباره امتحان کن
|
||||
</button>
|
||||
@@ -289,3 +358,23 @@ function Message({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* شمارنده در سربرگِ دستیار: سوالهای باقیِ امروز. وقتی تمام شد، دکمهی دوم به فروشگاهِ اشتراک میبرد
|
||||
* (سوال جدا فروخته نمیشود؛ مشترک فردا دوباره سهم دارد).
|
||||
*/
|
||||
function QuotaPills({ free }: { free: number }) {
|
||||
const nav = useNav();
|
||||
return (
|
||||
<>
|
||||
<span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}>
|
||||
{free > 0 ? <>{toFa(free)} سوال امروز</> : 'سوالهای امروز تموم شد'}
|
||||
</span>
|
||||
{free === 0 && (
|
||||
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-white/15 px-3 py-1.5 text-xs font-black text-white">
|
||||
سوال بیشتر با اشتراک
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ 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 } from '@/components/ui';
|
||||
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader, Sep } from '@/components/ui';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
@@ -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)} · با امتیازِ بیشتر همراههای بیشتری باز میشن`}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { BookOpen, Flame, Link2, Star, Trophy, UserPlus } from 'lucide-react';
|
||||
import { api, ApiError, type FamilyLink } from '@/api';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { InlineError, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* بخشِ والدین. والد با شمارهی فرزند دعوت میسازد و یک کد میگیرد؛ فرزند
|
||||
* (با همان شماره وارد شده) کد را اینجا وارد میکند. بعد از اتصال، والد
|
||||
* گزارشِ هفتگی را میبیند.
|
||||
*/
|
||||
export function FamilyScreen() {
|
||||
const link = useAsync(
|
||||
() => api.family.link().catch((err) => {
|
||||
if (err instanceof ApiError && err.status === 404) return null;
|
||||
throw err;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader title="بخشِ والدین" subtitle="پیشرفتِ فرزندت رو هر هفته ببین" color="#FF7A3D" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} />
|
||||
<ScreenBody className="flex flex-col gap-4">
|
||||
{link.loading ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
{link.data && <LinkStatus link={link.data} />}
|
||||
{link.data?.status === 'accepted' && <WeeklyReport />}
|
||||
<InviteChild onDone={link.reload} />
|
||||
<AcceptInvite onDone={link.reload} />
|
||||
</>
|
||||
)}
|
||||
</ScreenBody>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkStatus({ link }: { link: FamilyLink }) {
|
||||
const accepted = link.status === 'accepted';
|
||||
return (
|
||||
<div className={`flex items-center gap-3 rounded-3xl p-4 ${accepted ? 'bg-[#EAF8F0]' : 'bg-[#FFF8E1]'}`}>
|
||||
<Link2 size={20} className={accepted ? 'text-mint' : 'text-gold'} />
|
||||
<p className="text-sm leading-7 text-ink">
|
||||
{accepted ? <>به <b>{link.childName || 'فرزندت'}</b> وصلی.</> : 'دعوت فرستاده شده و منتظرِ تأییدِ فرزندته.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeeklyReport() {
|
||||
const report = useAsync(() => api.family.weeklyReport(), []);
|
||||
if (report.loading) return <div className="h-32 animate-pulse rounded-3xl bg-soft" />;
|
||||
if (!report.data) return null;
|
||||
const r = report.data;
|
||||
return (
|
||||
<section className="rounded-3xl border border-line bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-black text-ink">گزارشِ این هفته <span className="text-xs font-medium text-muted">(از {formatJalaliDate(r.weekStart)})</span></h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ icon: Star, label: 'امتیاز', value: r.xpEarned },
|
||||
{ icon: BookOpen, label: 'درسِ تمامشده', value: r.lessonsCompleted },
|
||||
{ icon: Trophy, label: 'جزیرهی تمامشده', value: r.islandsCompleted },
|
||||
{ icon: Flame, label: 'زنجیره (روز)', value: r.currentStreak },
|
||||
].map(({ icon: Icon, label, value }) => (
|
||||
<div key={label} className="flex items-center gap-2 rounded-2xl bg-soft px-3 py-2.5">
|
||||
<Icon size={16} className="text-orange" />
|
||||
<span className="flex-1 text-xs text-body">{label}</span>
|
||||
<span className="text-sm font-black text-ink">{toFa(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteChild({ onDone }: { onDone: () => void }) {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function invite() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const r = await api.family.invite(phone);
|
||||
setCode(r.inviteCode);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(friendlyError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-3xl border border-line bg-white p-4">
|
||||
<h2 className="mb-1 flex items-center gap-2 text-sm font-black text-ink"><UserPlus size={16} className="text-orange" /> من والدم</h2>
|
||||
<p className="mb-3 text-xs leading-6 text-muted">شمارهای که فرزندت باهاش وارد تدریسینو شده رو بنویس.</p>
|
||||
{code ? (
|
||||
<div className="rounded-2xl bg-soft p-4 text-center">
|
||||
<p className="text-xs text-muted">این کد رو به فرزندت بده تا تو بخشِ والدینِ حسابِ خودش وارد کنه:</p>
|
||||
<p dir="ltr" className="mt-2 text-2xl font-black tracking-widest text-ink">{code}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))}
|
||||
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-left outline-none focus:border-orange"
|
||||
/>
|
||||
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
|
||||
<PrimaryButton color="#FF7A3D" loading={busy} disabled={!/^09\d{9}$/.test(phone)} onClick={() => void invite()}>
|
||||
ساختِ کدِ دعوت
|
||||
</PrimaryButton>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AcceptInvite({ onDone }: { onDone: () => void }) {
|
||||
const toast = useToast();
|
||||
const [code, setCode] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function accept() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.family.accept(code.trim());
|
||||
toast('به والدت وصل شدی', 'success');
|
||||
setCode('');
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(friendlyError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-3xl border border-line bg-white p-4">
|
||||
<h2 className="mb-1 text-sm font-black text-ink">من دانشآموزم</h2>
|
||||
<p className="mb-3 text-xs leading-6 text-muted">اگه پدر یا مادرت کدِ دعوت بهت داده، اینجا واردش کن.</p>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.slice(0, 32))}
|
||||
placeholder="کدِ دعوت"
|
||||
dir="ltr"
|
||||
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-orange"
|
||||
/>
|
||||
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
|
||||
<PrimaryButton color="#FF7A3D" loading={busy} disabled={!code.trim()} onClick={() => void accept()}>
|
||||
وصل شو
|
||||
</PrimaryButton>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +1,154 @@
|
||||
import { Bell, Brain, ChevronLeft, Coins, Flame, Map, Play, Star, Trophy } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bell, ChevronLeft, Coins, Crown, Flame, RotateCw, Star } from 'lucide-react';
|
||||
import { ApiError, type HomeNode } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { findContinue, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { Card, ErrorState, ProgressBar, Screen } from '@/components/ui';
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { IslandPath } from '@/components/IslandPath';
|
||||
import { IslandSheet } from '@/components/IslandSheet';
|
||||
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
|
||||
import { onWhite, shade } from '@/lib/color';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { BrandFooter } from '@/components/BrandFooter';
|
||||
import { BrandMark } from '@/components/BrandMark';
|
||||
import { StreakFlame, flameState } from '@/components/StreakFlame';
|
||||
import { SubjectBar } from '@/components/SubjectBar';
|
||||
|
||||
/**
|
||||
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
||||
* یک دکمهی «شروع» روی سرش. قبلاً خانه داشبوردی از شش کارت بود (ادامه، نقشه،
|
||||
* دستیار، فصلها، چالش) و بچه نمیدانست کدام را بزند؛ حالا یک کارِ واضح دارد.
|
||||
*/
|
||||
export function HomeScreen() {
|
||||
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
||||
const { gradeTitle, summary, home, homeError, unreadCount, refreshHome, refreshSummary } = useAppData();
|
||||
const nav = useNav();
|
||||
// فصلِ غیرِ اول بیاشتراک: زدنِ جزیره یکراست فهرستِ اشتراکها را باز میکند.
|
||||
const paywall = () => nav.push({ name: 'subscription' });
|
||||
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
|
||||
const currentRef = useRef<HTMLDivElement>(null);
|
||||
const scrolled = useRef(false);
|
||||
const cont = findContinue(home);
|
||||
const contLesson = cont ? nextLessonOf(cont.node) : null;
|
||||
const contColor = cont ? unitColor(cont.unitIndex) : '#ED6956';
|
||||
const contProgress = cont ? unitProgress(cont.unit) : null;
|
||||
|
||||
const openLesson = (quiz = false) => {
|
||||
if (!cont || contLesson == null) return;
|
||||
nav.push({ name: quiz ? 'quiz' : 'lesson', lessonId: contLesson, nodeId: cont.node.id });
|
||||
};
|
||||
// بارِ اول تا جزیرهی فعلی اسکرول کن، نه هر بار که نقشه تازه میشود.
|
||||
useEffect(() => {
|
||||
if (!home || scrolled.current) return;
|
||||
scrolled.current = true;
|
||||
const frame = requestAnimationFrame(() => currentRef.current?.scrollIntoView({ block: 'center' }));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [home]);
|
||||
|
||||
const flame = flameState(summary?.streak);
|
||||
const streak = summary?.streak;
|
||||
|
||||
const stats = [
|
||||
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', fg: '#FF7A3D', bg: '#FFF1E6', fill: true, to: 'streak' as const },
|
||||
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', fg: '#E8A400', bg: '#FFF8E1', fill: false, to: 'leaderboard' as const },
|
||||
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', fg: '#9B7BFF', bg: '#F5F1FF', fill: false, to: 'characters' 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>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<header className="relative overflow-hidden rounded-b-[32px] bg-coral px-5 pb-11 pt-safe text-white">
|
||||
<div className="pointer-events-none absolute -left-10 -top-10 h-48 w-48 rounded-full bg-white/5" />
|
||||
<div className="relative flex items-center justify-between pt-8">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-white/75">سلام {profile.name || 'دوستِ من'}!</p>
|
||||
<h1 className="mt-0.5 text-xl font-black leading-snug">آمادهای ریاضی رو ادامه بدیم؟</h1>
|
||||
{gradeTitle && (
|
||||
<span className="mt-1.5 inline-block rounded-full bg-white/15 px-2.5 py-0.5 text-xs text-white/85">{gradeTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<button type="button" aria-label="جدولِ امتیازها" onClick={() => nav.push({ name: 'leaderboard' })} className="flex h-10 w-10 items-center justify-center rounded-full bg-white/15">
|
||||
<Trophy size={18} />
|
||||
</button>
|
||||
<button type="button" aria-label="اعلانها" onClick={() => nav.push({ name: 'notifications' })} className="relative flex h-10 w-10 items-center justify-center rounded-full bg-white/15">
|
||||
<Bell size={18} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -left-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#FFD23F] px-1 text-[9px] font-black text-ink">
|
||||
{unreadCount > 9 ? '۹+' : toFa(unreadCount)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="relative z-10 -mt-7 px-4">
|
||||
<div className="flex rounded-3xl border border-line bg-white shadow-lg shadow-black/10">
|
||||
{stats.map(({ icon: Icon, value, label, fg, bg, fill, to }) => (
|
||||
<button key={label} type="button" onClick={() => nav.push({ name: to })} className="flex flex-1 flex-col items-center py-3.5">
|
||||
<span className="mb-1.5 flex h-10 w-10 items-center justify-center rounded-full" style={{ backgroundColor: bg }}>
|
||||
<Icon size={18} style={{ color: fg }} strokeWidth={2.4} fill={fill ? fg : 'none'} />
|
||||
</span>
|
||||
<span className="text-lg font-black leading-none text-ink">{toFa(value)}</span>
|
||||
<span className="mt-1 text-[10px] text-muted">{label}</span>
|
||||
<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="hit rounded-full bg-white/20 px-3 py-1.5 text-xs font-black active:scale-95">
|
||||
{gradeTitle}
|
||||
</button>
|
||||
)}
|
||||
<div className="mr-auto flex items-center gap-1.5">
|
||||
{stats.map(({ icon: Icon, value, label, color, fill, to }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
aria-label={`${label}: ${toFa(value)}`}
|
||||
onClick={() => nav.push({ name: to })}
|
||||
className={`hit flex items-center gap-1 rounded-full px-2.5 py-1.5 text-xs font-black shadow-sm active:scale-95 ${to === 'streak' && flame === 'frozen' ? 'bg-[#E6F6FF] text-[#2B7FB8]' : 'bg-white text-ink'}`}
|
||||
>
|
||||
{to === 'streak' ? <StreakFlame state={flame} size={13} /> : <Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />}
|
||||
{toFa(to === 'streak' && flame === 'frozen' ? streak?.lost ?? 0 : value)}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" aria-label="اعلانها" onClick={() => nav.push({ name: 'notifications' })} className="hit relative flex h-8 w-8 items-center justify-center rounded-full bg-white/20 active:scale-95">
|
||||
<Bell size={16} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -left-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#FFD23F] px-1 text-[9px] font-black text-ink">
|
||||
{unreadCount > 9 ? '۹+' : toFa(unreadCount)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 pb-6 pt-4">
|
||||
{homeError && !home ? (
|
||||
<ErrorState error={homeError} onRetry={() => void refreshHome()} />
|
||||
) : cont && contProgress ? (
|
||||
<Card className="shadow-md shadow-coral/10">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-muted">ادامه از جایی که موندی</p>
|
||||
<h3 className="mt-0.5 text-base font-black text-ink">{cont.node.title}</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-muted">
|
||||
{cont.unit.title} · {toFa(contProgress.done)} از {toFa(contProgress.total)} جزیره
|
||||
</p>
|
||||
</div>
|
||||
<SeyarMascot char={companion} mood="happy" size={62} className="mr-3" />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<ProgressBar percent={contProgress.percent} color={contColor} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLesson()}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl py-3 text-sm font-bold text-white active:scale-[0.98]"
|
||||
style={{ backgroundColor: contColor }}
|
||||
>
|
||||
<Play size={15} fill="white" /> ادامه بده
|
||||
</button>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="flex items-center gap-3 p-5">
|
||||
<SeyarMascot char={companion} mood={home ? 'excited' : 'idle'} size={56} />
|
||||
<p className="text-sm font-medium text-muted">
|
||||
{!home ? 'دارم مسیرت رو میارم…' : home.units.length ? 'همهی جزیرههای این پایه رو تموم کردی! 🎉' : 'هنوز درسی برای این پایه ثبت نشده'}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card onClick={() => nav.switchTab('map')} className="p-4">
|
||||
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-[#EAF2F6]">
|
||||
<Map size={20} className="text-steel" />
|
||||
</span>
|
||||
<div className="text-sm font-bold text-ink">نقشهی فصلها</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted">ببین کجای مسیری</div>
|
||||
</Card>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nav.switchTab('ask')}
|
||||
className="rounded-3xl bg-violet p-4 text-right shadow-md shadow-violet/30 active:scale-[0.98]"
|
||||
>
|
||||
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-white/15">
|
||||
<Brain size={20} className="text-white" />
|
||||
</span>
|
||||
<div className="text-sm font-bold text-white">از پی بپرس</div>
|
||||
<div className="mt-0.5 text-[11px] text-white/75">هر جا گیر کردی</div>
|
||||
</button>
|
||||
</div>
|
||||
<SubjectBar />
|
||||
|
||||
{home && home.units.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-black text-ink">فصلهای {gradeTitle || 'این پایه'}</h2>
|
||||
<span className="text-[11px] text-muted">{toFa(home.units.length)} فصل</span>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!home ? (
|
||||
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />
|
||||
) : home.units.length === 0 ? (
|
||||
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 px-4 pb-10 pt-4">
|
||||
{homeError != null && (
|
||||
// نقشه هست ولی تازه نشد (معمولاً عکسِ ذخیرهشده روی گوشی و اینترنتِ قطع): بچه باید بداند
|
||||
// عددها و قفلها ممکن است مالِ دفعهی قبل باشند.
|
||||
<div role="status" className="flex items-center gap-2 rounded-xl bg-soft px-3 py-2 text-xs leading-6 text-body">
|
||||
<span className="flex-1">
|
||||
{homeError instanceof ApiError && homeError.isNetwork ? 'اینترنت وصل نیست یا کنده' : 'اطلاعات تازه نشد'}؛
|
||||
آخرین اطلاعات ذخیرهشده رو میبینی.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void Promise.all([refreshHome(), refreshSummary()])}
|
||||
className="flex shrink-0 items-center gap-1 rounded-full bg-white px-3 py-1 font-bold text-coral ring-1 ring-line"
|
||||
>
|
||||
<RotateCw size={12} /> دوباره
|
||||
</button>
|
||||
</div>
|
||||
<div className="no-scrollbar -mx-4 flex gap-3 overflow-x-auto px-4 pb-1">
|
||||
{home.units.map((unit, i) => {
|
||||
const p = unitProgress(unit);
|
||||
const color = unitColor(i);
|
||||
const Icon = iconFor(unit.nodes[0]?.icon, i);
|
||||
return (
|
||||
<button
|
||||
key={unit.id}
|
||||
type="button"
|
||||
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
||||
className="w-[140px] flex-shrink-0 rounded-2xl border border-line bg-white p-3.5 text-right shadow-sm active:scale-95"
|
||||
>
|
||||
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl" style={{ backgroundColor: `${color}1f` }}>
|
||||
<Icon size={19} style={{ color }} />
|
||||
</span>
|
||||
<div className="line-clamp-2 min-h-[2.5em] text-[12.5px] font-bold leading-snug text-ink">{unit.title}</div>
|
||||
<div className="mt-1 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
|
||||
<div className="mt-2">
|
||||
<ProgressBar percent={p.percent} color={color} track="#f0eeff" height={6} />
|
||||
)}
|
||||
{home.units.map((unit, i) => {
|
||||
const color = unitColor(i);
|
||||
const p = unitProgress(unit);
|
||||
const isCurrentUnit = cont?.unit.id === unit.id;
|
||||
return (
|
||||
<section key={unit.id} aria-label={unit.title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
||||
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)} جزیره
|
||||
{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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{cont && contLesson != null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLesson(true)}
|
||||
className="flex items-center gap-3 rounded-3xl bg-orange p-4 text-right shadow-md shadow-orange/25 active:scale-[0.98]"
|
||||
>
|
||||
<SeyarMascot char="delta" mood="excited" size={64} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-black text-white">یه سؤال دیگه… فقط یکی!</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-white/80">چالشِ سریع با دلتا · {cont.node.title}</div>
|
||||
</div>
|
||||
<ChevronLeft size={18} className="flex-shrink-0 text-white/80" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div>
|
||||
</div>
|
||||
<ChevronLeft size={18} className="flex-shrink-0 text-white/80" />
|
||||
</button>
|
||||
<div className="pt-12">
|
||||
<IslandPath
|
||||
ref={isCurrentUnit ? currentRef : undefined}
|
||||
nodes={unit.nodes}
|
||||
color={color}
|
||||
currentId={isCurrentUnit ? cont!.node.id : null}
|
||||
onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode({ node, color }))}
|
||||
onStart={(node) => (unit.requiresSubscription ? paywall() : nav.push({ name: 'quiz', nodeId: node.id }))}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
<BrandFooter />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<IslandSheet node={openNode?.node ?? null} color={openNode?.color ?? '#ED6956'} onClose={() => setOpenNode(null)} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,14 +12,14 @@ export function inviteLink(code: string): string {
|
||||
|
||||
export function InviteScreen() {
|
||||
const toast = useToast();
|
||||
const me = useAsync(() => api.affiliate.me(), []);
|
||||
const me = useAsync(() => api.affiliate.me(), [], { key: 'affiliate' });
|
||||
|
||||
async function share(code: string) {
|
||||
const url = inviteLink(code);
|
||||
const text = 'با تدریسینو ریاضی رو قدمبهقدم یاد بگیر:';
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title: 'تدریسینو', text, url });
|
||||
await navigator.share({ title: 'تدریسینو ریاضی', text, url });
|
||||
return;
|
||||
} catch {
|
||||
/* کاربر لغو کرد؛ میرویم سراغِ کپی */
|
||||
@@ -35,7 +35,7 @@ export function InviteScreen() {
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader title="معرفی به دوستان" subtitle="دوستات رو بیار، با هم ریاضی بخونید" color="#5FBF8F" back mascot={{ char: 'delta', mood: 'happy', size: 56 }} />
|
||||
<ScreenHeader title="معرفی به دوستان" subtitle="دوستات رو بیار، با هم ریاضی بخونید" color="#5FBF8F" back mascot={{ mood: 'happy', size: 56 }} />
|
||||
<ScreenBody className="flex flex-col gap-4">
|
||||
{me.loading ? (
|
||||
<LoadingState />
|
||||
@@ -45,7 +45,7 @@ export function InviteScreen() {
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-2 rounded-3xl border border-line bg-white p-5 text-center">
|
||||
<Gift size={28} className="text-mint" />
|
||||
<span className="text-xs text-muted">کدِ معرفیِ تو</span>
|
||||
<span className="text-xs text-muted">کد معرفی تو</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigator.clipboard?.writeText(me.data!.referralCode).then(() => toast('کد کپی شد', 'success'))}
|
||||
@@ -71,11 +71,11 @@ export function InviteScreen() {
|
||||
|
||||
<p className="flex items-start gap-2 rounded-2xl bg-[#EAF8F0] p-3.5 text-xs leading-6 text-body">
|
||||
<Users size={16} className="mt-0.5 flex-shrink-0 text-mint" />
|
||||
دوستت باید با لینکِ تو وارد بشه و شمارهش رو ثبت کنه. اگه اشتراک خرید، {toFa(Math.round(me.data.commissionRate * 100))}٪ مبلغ پورسانتِ توئه.
|
||||
دوستت باید با لینک تو وارد بشه و شمارهش رو ثبت کنه. اگه اشتراک خرید، {toFa(Math.round(me.data.commissionRate * 100))}٪ مبلغ پورسانت توئه.
|
||||
</p>
|
||||
|
||||
<PrimaryButton color="#5FBF8F" onClick={() => void share(me.data!.referralCode)}>
|
||||
<Share2 size={17} /> فرستادنِ لینکِ دعوت
|
||||
<Share2 size={17} /> فرستادن لینک دعوت
|
||||
</PrimaryButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
142
src/screens/InvoicesScreen.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft, Copy, Receipt } from 'lucide-react';
|
||||
import { api, type Invoice } from '@/api';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader, Sep } from '@/components/ui';
|
||||
import { formatJalaliDate, formatToman } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* «پرداختها و فاکتورها» (۱۴۰۵-۰۷): همهی سفارشهای کاربر، با فاکتورِ هر خرید.
|
||||
* سفارشِ «در انتظار» و «ناموفق» هم نشان داده میشود تا کسی که پولش کم شده ولی
|
||||
* اشتراکش فعال نشده، شمارهی فاکتور را به پشتیبانی بدهد. عددها از GET /payment/invoices.
|
||||
*/
|
||||
const STATUS: Record<Invoice['status'], { label: string; className: string }> = {
|
||||
paid: { label: 'پرداختشده', className: 'bg-[#EAF8F0] text-[#2E7D5B]' },
|
||||
pending: { label: 'در انتظار پرداخت', className: 'bg-[#FFF8E1] text-[#8A6100]' },
|
||||
failed: { label: 'ناموفق', className: 'bg-soft text-muted' },
|
||||
canceled: { label: 'لغوشده', className: 'bg-soft text-muted' },
|
||||
refunded: { label: 'بازپرداختشده', className: 'bg-[#EEF1FF] text-indigo' },
|
||||
};
|
||||
|
||||
const PROVIDER: Record<string, string> = { gateway: 'درگاه زرینپال', bazaar: 'کافهبازار' };
|
||||
|
||||
const dateOf = (invoice: Invoice) => formatJalaliDate(invoice.paidAt ?? invoice.createdAt);
|
||||
|
||||
export function InvoicesScreen() {
|
||||
const nav = useNav();
|
||||
const list = useAsync(() => api.subscription.invoices(), [], { key: 'invoices' });
|
||||
const [open, setOpen] = useState<Invoice | null>(null);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader title="پرداختها و فاکتورها" subtitle="همه خریدهات، با شماره فاکتور و کد پیگیری" color="#4C5FD5" back />
|
||||
<ScreenBody className="flex flex-col gap-3">
|
||||
{list.loading ? (
|
||||
<LoadingState />
|
||||
) : list.error || !list.data ? (
|
||||
<ErrorState error={list.error} onRetry={list.reload} />
|
||||
) : list.data.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-3xl border border-line bg-white px-5 py-8 text-center">
|
||||
<Receipt size={32} className="text-faint" />
|
||||
<p className="text-sm text-body">هنوز خریدی نکردی.</p>
|
||||
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدن اشتراکها</PrimaryButton>
|
||||
</div>
|
||||
) : (
|
||||
list.data.map((invoice) => (
|
||||
<button
|
||||
key={invoice.orderId}
|
||||
type="button"
|
||||
onClick={() => setOpen(invoice)}
|
||||
className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4 text-right active:scale-[0.99]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-black text-ink">{invoice.planTitle || 'اشتراک'}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold ${STATUS[invoice.status].className}`}>
|
||||
{STATUS[invoice.status].label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{dateOf(invoice)}<Sep /><span dir="ltr">{invoice.invoiceNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-black text-ink">{formatToman(invoice.amount)}</span>
|
||||
<ChevronLeft size={16} className="text-faint" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</ScreenBody>
|
||||
<InvoiceSheet invoice={open} onClose={() => setOpen(null)} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose: () => void }) {
|
||||
const toast = useToast();
|
||||
|
||||
async function copy(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast('کپی شد', 'success');
|
||||
} catch {
|
||||
toast(text, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
const rows: [string, React.ReactNode][] = invoice
|
||||
? [
|
||||
['شماره فاکتور', <span dir="ltr">{invoice.invoiceNumber}</span>],
|
||||
['تاریخ', dateOf(invoice)],
|
||||
['وضعیت', STATUS[invoice.status].label],
|
||||
['اشتراک', invoice.planTitle || '—'],
|
||||
...(invoice.listPrice !== null ? [['قیمت', formatToman(invoice.listPrice)] as [string, React.ReactNode]] : []),
|
||||
...(invoice.discountAmount ? [[
|
||||
invoice.discountCode ? `تخفیف (کد ${invoice.discountCode})` : 'تخفیف',
|
||||
formatToman(invoice.discountAmount),
|
||||
] as [string, React.ReactNode]] : []),
|
||||
['مبلغ پرداختی', <b>{formatToman(invoice.amount)}</b>],
|
||||
['روش پرداخت', PROVIDER[invoice.provider] ?? invoice.provider],
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Sheet open={invoice !== null} onClose={onClose} title="فاکتور">
|
||||
{invoice && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{rows.map(([label, value]) => (
|
||||
<tr key={label} className="border-b border-line last:border-0">
|
||||
<td className="py-2.5 text-muted">{label}</td>
|
||||
<td className="py-2.5 text-left text-ink">{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{invoice.refId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(invoice.refId)}
|
||||
className="flex items-center justify-between rounded-2xl bg-soft px-4 py-3 text-sm"
|
||||
>
|
||||
<span className="text-muted">کد پیگیری</span>
|
||||
<span className="flex items-center gap-2 font-bold text-ink" dir="ltr">
|
||||
{/* کد همانطور که کپی میشود: با رقمِ فارسی صفرها نقطه دیده میشدند. */}
|
||||
{invoice.refId} <Copy size={14} />
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
(invoice.status === 'pending' || invoice.status === 'failed') && (
|
||||
<p className="text-xs leading-6 text-muted">
|
||||
اگه پولی از حسابت کم شده ولی این خرید «پرداختشده» نیست، شماره فاکتور رو به پشتیبانی بده.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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';
|
||||
@@ -6,22 +7,31 @@ import { useNav } from '@/nav/Navigator';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { moodFromServer } from '@/mascots/seyar';
|
||||
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
|
||||
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader, Sep } from '@/components/ui';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { 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;
|
||||
const figs = (where: 'intro' | 'example' | 'step', index?: number) =>
|
||||
(data?.figures ?? [])
|
||||
.filter((f) => f.where === where && (where !== 'step' || f.index === index))
|
||||
.map((f, k) => <Diagram key={k} spec={f.diagram} />);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title={data?.title ?? '…'}
|
||||
title={data ? <MathText text={data.title} /> : '…'}
|
||||
color={color}
|
||||
back
|
||||
mascot={{ char: companion, mood: moodFromServer(data?.mascotAnimation), size: 72 }}
|
||||
@@ -29,7 +39,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
ref && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1">
|
||||
{/* ترتیبِ lessonIds «درسِ بعدی اول» است، نه ترتیبِ کتاب؛ پس شمارهی درس از آن درنمیآید. */}
|
||||
<Zap size={11} fill="white" /> {ref.node.title} · {toFa(ref.node.lessonIds.length)} درس
|
||||
<Zap size={11} fill="white" /> <MathText text={ref.node.title} /><Sep />{toFa(ref.node.lessonIds.length)} درس
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -43,9 +53,10 @@ 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">{data.introText}</p>
|
||||
<StepText text={data.introText} color={color} className="text-[15px] leading-[1.95] text-body" />
|
||||
{figs('intro')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +67,8 @@ 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">{data.example}</p>
|
||||
<StepText text={data.example} color={color} className="text-[15px] leading-[1.95] text-ink" />
|
||||
{figs('example')}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -69,7 +81,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
|
||||
{toFa(i + 1)}
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre-line">{step}</span>
|
||||
<span className="flex-1 whitespace-pre-line"><MathText text={step} display />{figs('step', i)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
@@ -77,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
|
||||
@@ -91,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>
|
||||
</>
|
||||
|
||||
256
src/screens/LessonsScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,36 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRight, Phone } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { api, ApiError } from '@/api';
|
||||
import { InlineError, PrimaryButton } from '@/components/ui';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { digitsOnly, toFa } from '@/lib/format';
|
||||
import { getDeviceId } from '@/lib/storage';
|
||||
|
||||
const OTP_LENGTH = 6;
|
||||
/** همان cooldownِ سرور (OTP_REQUEST_PHONE_COOLDOWN_SECONDS). */
|
||||
const RESEND_SECONDS = 120;
|
||||
|
||||
/**
|
||||
* ورود با شماره (OTP) یا بهعنوانِ مهمان.
|
||||
*
|
||||
* `upgrade` برای مهمانی است که از صفحهی «من» میخواهد شماره ثبت کند؛ آنجا
|
||||
* گزینهی مهمان نیست و سرور همان کاربر را نگه میدارد.
|
||||
* ورود با شماره (OTP): تنها راهِ ورود است.
|
||||
*/
|
||||
export function LoginScreen({
|
||||
onDone,
|
||||
onCancel,
|
||||
upgrade = false,
|
||||
}: {
|
||||
onDone: () => void;
|
||||
onCancel?: () => void;
|
||||
upgrade?: boolean;
|
||||
}) {
|
||||
export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel?: () => void }) {
|
||||
const [step, setStep] = useState<'phone' | 'otp'>('phone');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [devOtp, setDevOtp] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<'otp' | 'login' | 'guest' | null>(null);
|
||||
const [loading, setLoading] = useState<'login' | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [resendIn, setResendIn] = useState(0);
|
||||
/** درخواستِ پیامک هنوز جواب نداده؛ جدا از `loading` تا واردکردنِ کد قفل نشود. */
|
||||
const [sending, setSending] = useState(false);
|
||||
/** شمارهی آخرین درخواست؛ جوابِ دیرِ درخواستِ قدیمیتر نادیده گرفته میشود. */
|
||||
const attempt = useRef(0);
|
||||
const otpInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const phoneValid = /^09\d{9}$/.test(phone);
|
||||
const otpValid = otp.length >= 4;
|
||||
// کدِ سرور ۶ رقمی است و هر حدسِ غلط یکی از ۵ فرصت را میسوزاند؛ کدِ ناقص نباید فرستاده شود.
|
||||
// فقط در حالتِ توسعه کدِ ثابتِ ۴ رقمیِ DEV_MASTER_OTP هم هست.
|
||||
const otpValid = otp.length === OTP_LENGTH || (import.meta.env.DEV && otp.length === 4);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendIn <= 0) return;
|
||||
@@ -48,25 +42,42 @@ export function LoginScreen({
|
||||
if (step === 'otp') otpInput.current?.focus();
|
||||
}, [step]);
|
||||
|
||||
/**
|
||||
* صفحهی کد فوراً باز میشود و درخواست در پسزمینه جلو میرود. سرور کد را پیش از
|
||||
* تماس با کاوهنگار ذخیره میکند، پس کدِ رسیده همان لحظه قابلِ تأیید است؛ ولی
|
||||
* جوابِ کاوهنگار گاهی بعد از رسیدنِ پیامک میآید (۱۶ ثانیه در ۲۰۲۶-۰۹-۲۷) و
|
||||
* منتظر ماندن برایش کاربر را بیدلیل معطل میکرد.
|
||||
*/
|
||||
async function requestOtp() {
|
||||
if (!phoneValid || loading) return;
|
||||
if (!phoneValid || sending || loading) return;
|
||||
const mine = ++attempt.current;
|
||||
setError('');
|
||||
setLoading('otp');
|
||||
setDevOtp(null);
|
||||
setOtp('');
|
||||
setStep('otp');
|
||||
setResendIn(RESEND_SECONDS);
|
||||
setSending(true);
|
||||
try {
|
||||
const data = await api.auth.requestOtp(phone);
|
||||
if (mine !== attempt.current) return;
|
||||
setDevOtp(data.devOtp ?? null);
|
||||
setOtp('');
|
||||
setStep('otp');
|
||||
setResendIn(RESEND_SECONDS);
|
||||
} catch (err) {
|
||||
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن'));
|
||||
if (mine !== attempt.current) return;
|
||||
setError(friendlyError(err, 'ارسال کد انجام نشد، دوباره امتحان کن'));
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند
|
||||
} else if (err instanceof ApiError && err.isRateLimited && err.retryAfterMs) {
|
||||
setResendIn(Math.ceil(err.retryAfterMs / 1000));
|
||||
} else {
|
||||
setResendIn(0); // پیامکی نرفت و سرور cooldown را برداشته؛ همین حالا دوباره بفرستد
|
||||
}
|
||||
} finally {
|
||||
setLoading(null);
|
||||
if (mine === attempt.current) setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verify(code = otp) {
|
||||
if (code.length < 4 || loading) return;
|
||||
if (!(code.length === OTP_LENGTH || (import.meta.env.DEV && code.length === 4)) || loading) return;
|
||||
setError('');
|
||||
setLoading('login');
|
||||
try {
|
||||
@@ -80,21 +91,6 @@ export function LoginScreen({
|
||||
}
|
||||
}
|
||||
|
||||
async function continueAsGuest() {
|
||||
setError('');
|
||||
setLoading('guest');
|
||||
try {
|
||||
await api.auth.guest(getDeviceId());
|
||||
onDone();
|
||||
} catch (err) {
|
||||
// قبلاً اینجا یک «مهمانِ محلی» بدونِ توکن ساخته میشد و بعد همهی
|
||||
// درخواستها بیصدا شکست میخوردند. حالا همینجا میگوییم چه شده.
|
||||
setError(friendlyError(err, 'ورودِ مهمان انجام نشد، دوباره امتحان کن'));
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
function onOtpChange(value: string) {
|
||||
const code = digitsOnly(value, OTP_LENGTH);
|
||||
setOtp(code);
|
||||
@@ -112,11 +108,11 @@ export function LoginScreen({
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-black">{upgrade ? 'ثبتِ شمارهی موبایل' : 'ورود به تدریسینو'}</h1>
|
||||
<h1 className="text-balance text-xl font-black">ورود به تدریسینو ریاضی</h1>
|
||||
<p className="mt-1 text-sm text-white/75">
|
||||
{step === 'phone'
|
||||
? upgrade ? 'امتیازها و پیشرفتت سرِ جاش میمونه' : 'شمارهی موبایلت رو وارد کن'
|
||||
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامکشده رو وارد کن`}
|
||||
? 'شماره موبایلت رو وارد کن'
|
||||
: `کد ${toFa(OTP_LENGTH)} رقمی پیامکشده رو وارد کن`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/15 p-2">
|
||||
@@ -138,7 +134,7 @@ export function LoginScreen({
|
||||
<span className="mb-2 block text-sm font-bold text-ink">شماره موبایل</span>
|
||||
<input
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))}
|
||||
onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
|
||||
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
@@ -152,7 +148,7 @@ export function LoginScreen({
|
||||
) : (
|
||||
<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}
|
||||
@@ -166,17 +162,20 @@ export function LoginScreen({
|
||||
}`}
|
||||
/>
|
||||
</label>
|
||||
{sending && !error && (
|
||||
<p className="mt-2 text-xs text-muted">پیامک در راهه؛ همین که رسید کد رو بزن</p>
|
||||
)}
|
||||
{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">
|
||||
<button type="button" className="flex items-center gap-1 text-coral" onClick={() => { setStep('phone'); setError(''); setDevOtp(null); }}>
|
||||
<button type="button" className="flex items-center gap-1 text-coral" onClick={() => { attempt.current++; setSending(false); setStep('phone'); setError(''); setDevOtp(null); }}>
|
||||
<Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح
|
||||
</button>
|
||||
<button type="button" disabled={resendIn > 0 || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted">
|
||||
{resendIn > 0 ? `ارسالِ دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسالِ دوباره'}
|
||||
<button type="button" disabled={resendIn > 0 || sending || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted">
|
||||
{resendIn > 0 ? `ارسال دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسال دوباره'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,23 +186,26 @@ export function LoginScreen({
|
||||
<div className="mt-auto flex flex-col gap-3">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
loading={loading === 'otp' || loading === 'login'}
|
||||
loading={loading === 'login'}
|
||||
disabled={step === 'phone' ? !phoneValid : !otpValid}
|
||||
>
|
||||
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'}
|
||||
{step === 'phone' ? 'دریافت کد تأیید' : 'ورود'}
|
||||
</PrimaryButton>
|
||||
{!upgrade && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void continueAsGuest()}
|
||||
disabled={loading !== null}
|
||||
className="py-2 text-center text-sm font-bold text-muted disabled:opacity-40"
|
||||
>
|
||||
{loading === 'guest' ? 'در حالِ ورود…' : 'فعلاً مهمان باشم'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* پرکردنِ خودکارِ گوشی و چسباندن معمولاً «+98 912 …» یا «0098…» میدهد؛ قبلاً فقط ۱۱ رقمِ اول نگه داشته میشد
|
||||
* («98912…») و دکمه بیتوضیح خاموش میماند. سرور هم همین شکلها را میپذیرد (accounts/phone.py).
|
||||
*/
|
||||
function normalizePhoneInput(value: string): string {
|
||||
let digits = digitsOnly(value);
|
||||
if (digits.startsWith('0098')) digits = `0${digits.slice(4)}`;
|
||||
else if (digits.startsWith('98') && digits.length >= 12) digits = `0${digits.slice(2)}`;
|
||||
// «98…» هنوز در حالِ تایپ است (۱۰ و ۱۱ رقمِ وسطِ «+98912…» هم با ۹ شروع میشوند)؛ پیششمارهی ۰۹۸ نداریم.
|
||||
else if (digits.startsWith('9') && !digits.startsWith('98') && digits.length === 10) digits = `0${digits}`;
|
||||
return digits.slice(0, 11);
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
|
||||
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
/** نقشهی فصلهای پایه: هر خانه یک فصل است. */
|
||||
export function MapScreen() {
|
||||
const { home, homeError, refreshHome, gradeTitle } = useAppData();
|
||||
const nav = useNav();
|
||||
const units = home?.units ?? [];
|
||||
const points = zigzagPositions(units.length, 150);
|
||||
const height = points.length ? points[points.length - 1].y + 200 : 400;
|
||||
const overall = Math.round(home?.stats.progressPercent ?? 0);
|
||||
|
||||
return (
|
||||
<Screen className="bg-[#f7f8ff]">
|
||||
<ScreenHeader
|
||||
title="نقشهی فصلها"
|
||||
subtitle={`${gradeTitle ? `${gradeTitle} · ` : ''}${toFa(overall)}٪ از کلِ مسیر`}
|
||||
color="#91ADC6"
|
||||
mascot={{ char: 'sigma', mood: overall > 0 ? 'happy' : 'idle' }}
|
||||
>
|
||||
<div className="mt-4">
|
||||
<ProgressBar percent={overall} color="#fff" track="rgba(255,255,255,.25)" />
|
||||
</div>
|
||||
</ScreenHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!home ? (
|
||||
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم فصلها رو میارم…" />
|
||||
) : units.length === 0 ? (
|
||||
<EmptyState text="هنوز فصلی برای این پایه ثبت نشده" />
|
||||
) : (
|
||||
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
|
||||
<ZigzagPath
|
||||
points={points}
|
||||
height={height}
|
||||
colorOf={(i) => (units[i].nodes.some((n) => n.status !== 'locked') ? unitColor(i) : null)}
|
||||
/>
|
||||
{units.map((unit, i) => {
|
||||
const p = unitProgress(unit);
|
||||
const color = unitColor(i);
|
||||
const Icon = iconFor(unit.nodes[0]?.icon, i);
|
||||
const complete = p.total > 0 && p.done === p.total;
|
||||
return (
|
||||
<motion.div
|
||||
key={unit.id}
|
||||
initial={{ opacity: 0, scale: 0.7 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: i * 0.05, type: 'spring', stiffness: 220, damping: 16 }}
|
||||
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1.5"
|
||||
style={{ left: `${points[i].x}%`, top: points[i].y }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={unit.title}
|
||||
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
||||
className="relative flex h-[84px] w-[84px] items-center justify-center rounded-[26px] transition-transform active:translate-y-1"
|
||||
style={{
|
||||
backgroundColor: complete ? color : '#fff',
|
||||
border: complete ? 'none' : `4px solid ${color}`,
|
||||
boxShadow: complete ? `0 6px 0 ${color}99` : `0 6px 0 ${color}40, 0 0 0 5px white`,
|
||||
}}
|
||||
>
|
||||
{complete ? <Check size={30} color="white" strokeWidth={3.5} /> : <Icon size={30} style={{ color }} strokeWidth={2.2} />}
|
||||
{!complete && p.done > 0 && (
|
||||
<span className="absolute -left-2 -top-2 rounded-full border bg-white px-1.5 py-0.5 text-[10px] font-black shadow" style={{ color, borderColor: color }}>
|
||||
{toFa(p.percent)}٪
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
|
||||
<div className="max-w-[130px] truncate text-xs font-black text-ink">{unit.title}</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 150 }}>
|
||||
<SeyarMascot char="pi" mood={overall >= 100 ? 'excited' : 'happy'} size={72} bob />
|
||||
<div className="mt-1 whitespace-nowrap rounded-full border border-line bg-white px-3 py-1 text-[10px] font-bold text-[#7857E0] shadow-sm">
|
||||
{overall >= 100 ? 'همه رو تموم کردی! 🎉' : 'همینجا منتظرتم'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Bell, Check, Crown, FileText, MessageCircle, Star, Trophy, Users, type LucideIcon } from 'lucide-react';
|
||||
import { Bell, Check, Crown, MessageCircle, Star, Trophy, type LucideIcon } from 'lucide-react';
|
||||
import { api, type NotificationItem } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
|
||||
import { relativeTime } from '@/lib/format';
|
||||
import { relativeTime, toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
|
||||
const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = {
|
||||
xp_earned: { icon: Star, color: '#E8A400', bg: '#FFF8E1' },
|
||||
@@ -12,8 +13,6 @@ const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = {
|
||||
node_completed: { icon: Trophy, color: '#FF7A3D', bg: '#FFF1E6' },
|
||||
order_paid: { icon: Crown, color: '#9B7BFF', bg: '#F5F1FF' },
|
||||
ticket_replied: { icon: MessageCircle, color: '#4C5FD5', bg: '#EEF1FF' },
|
||||
family_link_accepted: { icon: Users, color: '#FF7A3D', bg: '#FFF1E6' },
|
||||
weekly_report_ready: { icon: FileText, color: '#91ADC6', bg: '#EAF2F6' },
|
||||
};
|
||||
const styleOf = (type: string) => STYLE[type] ?? { icon: Bell, color: '#9e9ec0', bg: '#f0eeff' };
|
||||
|
||||
@@ -24,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;
|
||||
|
||||
@@ -52,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">
|
||||
همه خوانده شد
|
||||
@@ -83,7 +82,7 @@ export function NotificationsScreen() {
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block text-sm ${n.isRead ? 'text-muted' : 'font-bold text-ink'}`}>{n.title}</span>
|
||||
{n.body && <span className="mt-0.5 block whitespace-pre-line text-xs leading-6 text-body">{n.body}</span>}
|
||||
{n.body && <span className="mt-0.5 block whitespace-pre-line text-xs leading-6 text-body"><MathText text={toFa(n.body)} /></span>}
|
||||
<span className="mt-1 block text-[10px] text-faint">{relativeTime(n.createdAt)}</span>
|
||||
</span>
|
||||
{!n.isRead && <span className="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-coral" />}
|
||||
|
||||
@@ -7,21 +7,27 @@ import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
|
||||
const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title: string; desc: string }[] = [
|
||||
{
|
||||
char: 'sigma', mood: 'happy', bg: '#4C5FD5',
|
||||
sub: 'از پایهی سوم تا دوازدهم',
|
||||
title: 'به تدریسینو خوش اومدی!',
|
||||
desc: 'من سیگمام. ریاضیِ کتابِ خودت رو جزیره به جزیره با هم جلو میبریم؛ هر جزیره چند درسِ کوتاه و یه عالمه تمرین.',
|
||||
sub: 'از پایه سوم تا دوازدهم',
|
||||
title: 'به تدریسینو ریاضی خوش اومدی!',
|
||||
desc: 'من سیگمام. با هم ریاضی کتاب درسیت رو جزیره به جزیره جلو میبریم؛ هر جزیره چند درس کوتاه داره و کلی تمرین.',
|
||||
},
|
||||
{
|
||||
char: 'delta', mood: 'excited', bg: '#FF7A3D',
|
||||
sub: 'هر روز یه کم',
|
||||
title: 'امتیاز بگیر، زنجیره بساز',
|
||||
desc: 'منم دلتا! با هر جوابِ درست امتیاز و سکه میگیری، روزهای پیاپیت شمرده میشه و تو جدولِ امتیازها بالا میری.',
|
||||
desc: 'منم دلتا! با هر جواب درست امتیاز و سکه میگیری، روزهایی که پشت هم تمرین میکنی شمرده میشه و تو جدول امتیازها بالا میری.',
|
||||
},
|
||||
{
|
||||
char: 'pi', mood: 'happy', bg: '#9B7BFF',
|
||||
sub: 'هر جا گیر کردی',
|
||||
title: 'از پی بپرس',
|
||||
desc: 'من پیام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدمبهقدم توضیح میدم.',
|
||||
desc: 'من پیام. هر جا سوالی داشتی یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتاب درسیت قدمبهقدم توضیح میدم.',
|
||||
},
|
||||
{
|
||||
char: 'tedi', mood: 'excited', bg: '#D9820B',
|
||||
sub: 'یکی از ما مال توئه',
|
||||
title: 'همراهت رو انتخاب کن',
|
||||
desc: 'منم امگا! هر چی امتیازت بیشتر بشه، همراههای تازهتری باز میشن. از صفحه «من» هر کدوممون رو بخوای انتخاب میکنی.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -58,7 +64,7 @@ export function OnboardingScreen({ onDone }: { onDone: () => void }) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-medium text-white/70">{slide.sub}</p>
|
||||
<h1 className="mb-3 text-2xl font-black leading-snug">{slide.title}</h1>
|
||||
<h1 className="mb-3 text-balance text-2xl font-black leading-snug">{slide.title}</h1>
|
||||
<p className="text-sm leading-[1.9] text-white/85">{slide.desc}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
|
||||
Pencil, Smartphone, Sparkles, Star, Trophy, Users, 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';
|
||||
@@ -11,13 +11,16 @@ import type { Route } from '@/nav/routes';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { AgePicker, ageFromBirthYear, birthYearFromAge } from '@/components/AgePicker';
|
||||
import { BirthDatePicker, ageOf, formatBirthDate } from '@/components/BirthDatePicker';
|
||||
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { daysLeft } from '@/lib/subscription';
|
||||
import { BrandFooter } from '@/components/BrandFooter';
|
||||
|
||||
type Editing = 'name' | 'grade' | 'age' | 'logout' | null;
|
||||
type Editing = 'name' | 'grade' | 'birth' | 'account' | 'logout' | 'delete' | null;
|
||||
|
||||
/** «من»: پروفایل، تنظیمات و میانبُر به بخشهای فرعی. */
|
||||
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
@@ -25,7 +28,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
const nav = useNav();
|
||||
const [editing, setEditing] = useState<Editing>(null);
|
||||
const [sound, setSound] = useState(soundEnabled());
|
||||
const age = ageFromBirthYear(profile.birthYear);
|
||||
const age = ageOf(profile.birthDate, profile.birthYear);
|
||||
|
||||
const go = (route: Route) => () => nav.push(route);
|
||||
|
||||
@@ -42,14 +45,13 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
<button type="button" onClick={() => setEditing('name')} className="flex items-center gap-2 text-xl font-black">
|
||||
{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>
|
||||
</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>}
|
||||
{age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>}
|
||||
{profile.isGuest ? (
|
||||
<span className="rounded-full bg-[#FFD23F] px-2.5 py-0.5 font-bold text-ink">مهمان</span>
|
||||
) : (
|
||||
profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>
|
||||
)}
|
||||
{profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,37 +71,30 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 py-5">
|
||||
{profile.isGuest && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={go({ name: 'register' })}
|
||||
className="flex items-center gap-3 rounded-3xl bg-[#FFF8E1] p-4 text-right ring-2 ring-[#FFD23F]"
|
||||
>
|
||||
<Smartphone size={22} className="flex-shrink-0 text-gold" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-black text-ink">شمارهت رو ثبت کن</div>
|
||||
<div className="mt-0.5 text-xs leading-6 text-body">تا اگه گوشیت عوض شد، امتیازها و پیشرفتت گم نشه.</div>
|
||||
</div>
|
||||
<ChevronLeft size={18} className="text-muted" />
|
||||
</button>
|
||||
)}
|
||||
<SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
|
||||
|
||||
<Menu>
|
||||
<Row icon={GraduationCap} label="پایهی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
||||
<Row icon={CalendarDays} label="سن" value={age ? `${toFa(age)} سال` : 'وارد نشده'} onClick={() => setEditing('age')} />
|
||||
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} />
|
||||
<Row icon={GraduationCap} label="پایه تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
||||
<Row
|
||||
icon={CalendarDays}
|
||||
label="تاریخ تولد"
|
||||
value={formatBirthDate(profile.birthDate) || (age ? `${toFa(age)} سال` : 'وارد نشده')}
|
||||
onClick={() => setEditing('birth')}
|
||||
/>
|
||||
<Row icon={Sparkles} label="همراه من" onClick={go({ name: 'characters' })} />
|
||||
</Menu>
|
||||
|
||||
<Menu>
|
||||
<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>
|
||||
{!profile.isGuest && <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />}
|
||||
{!profile.isGuest && <Row icon={Users} label="بخشِ والدین" onClick={go({ name: 'family' })} />}
|
||||
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
|
||||
<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={sound ? Volume2 : VolumeX}
|
||||
label="صداها"
|
||||
@@ -111,29 +106,98 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
/>
|
||||
</Menu>
|
||||
|
||||
<button type="button" onClick={() => setEditing('logout')} className="flex items-center justify-center gap-2 py-3 text-sm font-bold text-red-500">
|
||||
<LogOut size={16} /> خروج از حساب
|
||||
</button>
|
||||
{/* حذفِ حساب یک لایه پایینتر، در «حساب کاربری»، مثلِ اپهای بزرگ (دیجیکالا، اسنپ، دولینگو): پیدا میشود
|
||||
(Google Play و App Store «در دسترس و پیداشدنی» میخواهند) ولی با یک لمسِ اشتباه زیرِ انگشت نیست. */}
|
||||
<Menu>
|
||||
<Row icon={UserRound} label="حساب کاربری" value={profile.phone ? toFa(profile.phone) : undefined} onClick={() => setEditing('account')} />
|
||||
<Row icon={LogOut} label="خروج از حساب" danger onClick={() => setEditing('logout')} />
|
||||
</Menu>
|
||||
<BrandFooter />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
|
||||
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
|
||||
<AgeSheet open={editing === 'age'} onClose={() => setEditing(null)} />
|
||||
<BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
|
||||
<AccountSheet open={editing === 'account'} onClose={() => setEditing(null)} onDelete={() => setEditing('delete')} />
|
||||
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||
<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>
|
||||
@@ -187,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) {
|
||||
@@ -198,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;
|
||||
@@ -222,17 +286,17 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { profile, setProfile } = useAppData();
|
||||
const toast = useToast();
|
||||
const [age, setAge] = useState<number | null>(ageFromBirthYear(profile.birthYear));
|
||||
const [birthDate, setBirthDate] = useState<string | null>(profile.birthDate);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function save() {
|
||||
if (!age) return;
|
||||
if (!birthDate) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
setProfile(await api.profile.update({ birthYear: birthYearFromAge(age) }));
|
||||
setProfile(await api.profile.update({ birthDate }));
|
||||
toast('ذخیره شد', 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -243,17 +307,22 @@ function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="چند سالته؟">
|
||||
<Sheet open={open} onClose={onClose} title="تاریخ تولد">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سال تولدت به شمسی.</p>
|
||||
<div className="mb-5">
|
||||
<AgePicker value={age} onChange={setAge} />
|
||||
<BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
|
||||
</div>
|
||||
<PrimaryButton loading={saving} disabled={!age} onClick={() => void save()}>ذخیره</PrimaryButton>
|
||||
<PrimaryButton loading={saving} disabled={!birthDate} onClick={() => void save()}>ذخیره</PrimaryButton>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* خروج. «ورود با حسابِ دیگر» (۲۰۲۶-۱۰-۱۴) برداشته شد: همان خروج بود با متنِ دیگر. عمداً چند حسابِ واردشده روی یک
|
||||
* گوشی نگه داشته نمیشود: گوشیِ خانوادگی دستبهدست میشود و هر کس بیکد واردِ حسابِ دیگری میشد، و چند refresh
|
||||
* tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر میکرد. خروج و ورودِ دوباره با پیامک همان کار را میکند.
|
||||
*/
|
||||
function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
|
||||
const { profile } = useAppData();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function logout() {
|
||||
@@ -269,11 +338,7 @@ function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: (
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟">
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
{profile.isGuest
|
||||
? 'تو مهمانی: با همین گوشی و مرورگر، «فعلاً مهمان باشم» دوباره تو رو به همین پیشرفت برمیگردونه، ولی از گوشیِ دیگه نه. برای امنتر شدن، اول شمارهت رو ثبت کن.'
|
||||
: 'هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.'}
|
||||
</p>
|
||||
<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="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
|
||||
@@ -281,3 +346,103 @@ function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: (
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** «حساب کاربری»: شماره و شناسه، و پایینش حذفِ حساب (جای استانداردش؛ نه دمِ دستِ صفحهی اصلی). */
|
||||
function AccountSheet({ open, onClose, onDelete }: { open: boolean; onClose: () => void; onDelete: () => void }) {
|
||||
const { profile } = useAppData();
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="حساب کاربری">
|
||||
<div className="mb-6 divide-y divide-line overflow-hidden rounded-2xl border border-line">
|
||||
<div className="flex items-center justify-between px-4 py-3.5 text-sm">
|
||||
<span className="font-bold text-body">شماره موبایل</span>
|
||||
<span dir="ltr" className="font-bold text-ink">{profile.phone ? toFa(profile.phone) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3.5 text-sm">
|
||||
<span className="font-bold text-body">شناسه کاربری</span>
|
||||
<span dir="ltr" className="font-bold tracking-wide text-ink">{profile.publicId}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-2 text-xs leading-6 text-muted">با حذف حساب، همه پیشرفت و اطلاعاتت برای همیشه پاک میشه.</p>
|
||||
<button type="button" onClick={onDelete} className="flex w-full items-center justify-center gap-2 rounded-2xl border border-red-200 py-3 text-sm font-bold text-red-500 active:bg-red-50">
|
||||
<Trash2 size={16} /> حذف حساب کاربری
|
||||
</button>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* حذفِ همیشگیِ حساب (POST /user/delete). شمارهدار با کدِ تازهی پیامکی تأیید میکند تا گوشیِ
|
||||
* بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند.
|
||||
*/
|
||||
function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
|
||||
const { profile } = useAppData();
|
||||
const [sent, setSent] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function sendCode() {
|
||||
if (!profile.phone) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.auth.requestOtp(profile.phone);
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
setError(friendlyError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.auth.deleteAccount({ otp: digitsOnly(value, 6) });
|
||||
askStore.clear();
|
||||
onSignedOut();
|
||||
} catch (err) {
|
||||
setError(friendlyError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="حذف همیشگی حساب">
|
||||
<p className="mb-3 text-sm leading-7 text-body">
|
||||
امتیاز، زنجیره، پیشرفت جزیرهها، گفتگوها با دستیار و اشتراکت پاک میشن و برنمیگردن.
|
||||
سوابق پرداخت فقط برای حسابداری میمونه. اگه اشتراکت رو از کافهبازار خریدی، تمدید خودکارش رو از بخش «اشتراکها»ی بازار لغو کن.
|
||||
</p>
|
||||
{sent ? (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(digitsOnly(e.target.value, 6))}
|
||||
placeholder="کد پیامکشده"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-red-400"
|
||||
/>
|
||||
) : (
|
||||
<p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شماره خودت پیامک میشه.</p>
|
||||
)}
|
||||
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton>
|
||||
{!sent ? (
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادن کد</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
color="#ef4444"
|
||||
loading={busy}
|
||||
disabled={value.length < 6}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
حذف همیشگی
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,68 @@
|
||||
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 LessonComplete, 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';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import type { SeyarMood } from '@/mascots/seyar';
|
||||
import { NoHeartsScreen } from '@/components/NoHeartsScreen';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton, Screen } from '@/components/ui';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { timeUntil, toFa } from '@/lib/format';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { Diagram } from '@/diagram';
|
||||
import { FeedbackBar } from '@/components/FeedbackBar';
|
||||
import { sounds } from '@/lib/sound';
|
||||
import { useGateHold } from '@/lib/useAsync';
|
||||
import { StreakCelebration, StreakFlame } from '@/components/StreakFlame';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
|
||||
type Phase =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'error'; error: unknown }
|
||||
| { kind: 'playing' }
|
||||
| { kind: 'noHearts'; nextRefillAt: string | null }
|
||||
| { kind: 'noHearts'; nextRefillAt: string | null; fullRefillAt: string | null }
|
||||
| { kind: 'finishing' }
|
||||
| { kind: 'finishError'; error: unknown }
|
||||
| { kind: 'result'; result: LessonComplete };
|
||||
| { kind: 'result'; result: QuizResult };
|
||||
|
||||
/** تمرینِ یک درس: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه. */
|
||||
export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
|
||||
const { home, companion, refreshHome, refreshSummary } = useAppData();
|
||||
/** نتیجهی مشترکِ پایانِ درس و پایانِ جزیره. */
|
||||
interface QuizResult {
|
||||
stars: number;
|
||||
xpEarned: number;
|
||||
coinsEarned: number;
|
||||
/** فقط در آزمونِ جزیره. */
|
||||
nextNodeId?: string | null;
|
||||
}
|
||||
|
||||
type PlayQuestion = Question & { lessonId?: string };
|
||||
|
||||
/**
|
||||
* تمرین: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه.
|
||||
*
|
||||
* دو حالت دارد. با `lessonId` تمرینِ همان یک درس است (هر درس معمولاً فقط یک
|
||||
* سوال دارد). بدونِ آن آزمونِ کلِ جزیره است (`/nodes/{id}/quiz`، ۴ سوال از
|
||||
* درسهای مختلف) که با تمام شدنش جزیره تمام و جزیرهی بعدی باز میشود؛ مسیرِ
|
||||
* اصلیِ بازی همین است.
|
||||
*/
|
||||
export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) {
|
||||
const { home, companion, summary, refreshHome, refreshSummary } = useAppData();
|
||||
const look = useCompanion();
|
||||
// زنجیره پیش از این تمرین؛ اگر بعد از ثبت امروز روشن شد، صفحهی «روزِ پیاپی» نشان داده میشود.
|
||||
const streakBefore = useRef(summary?.streak ?? null);
|
||||
const [streakUp, setStreakUp] = useState<{ from: number; to: number } | null>(null);
|
||||
const nav = useNav();
|
||||
const toast = useToast();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [questions, setQuestions] = useState<PlayQuestion[]>([]);
|
||||
const nodeMode = lessonId === undefined;
|
||||
const [hearts, setHearts] = useState<Hearts | null>(null);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
@@ -40,14 +70,16 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [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>());
|
||||
const completeKey = useRef(newIdempotencyKey());
|
||||
|
||||
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
|
||||
const ref = findNode(home, nodeId) ?? (lessonId !== undefined ? findNodeOfLesson(home, lessonId) : null);
|
||||
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
|
||||
const question = questions[index];
|
||||
const isLast = index === questions.length - 1;
|
||||
@@ -58,7 +90,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setPhase({ kind: 'loading' });
|
||||
Promise.all([api.lesson.questions(lessonId), api.hearts().catch(() => null)])
|
||||
const load: Promise<PlayQuestion[]> = nodeMode
|
||||
? api.nodeQuiz(nodeId ?? '').then((r) => r.questions)
|
||||
: api.lesson.questions(lessonId);
|
||||
Promise.all([load, api.hearts().catch(() => null)])
|
||||
.then(([qs, h]) => {
|
||||
if (!alive) return;
|
||||
setQuestions(qs);
|
||||
@@ -67,20 +102,33 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
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 });
|
||||
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, reload]);
|
||||
}, [lessonId, nodeId, nodeMode, reload]);
|
||||
|
||||
async function choose(optionId: string) {
|
||||
if (!question || selected !== null || submitting) return;
|
||||
/** انتخاب فقط گزینه را آبی میکند؛ تا «بررسی» نزده، چیزی ثبت یا رو نمیشود و میشود عوضش کرد. */
|
||||
function choose(optionId: string) {
|
||||
if (!question || answer || submitting) return;
|
||||
if (optionId !== selected) sounds.select();
|
||||
setSelected(optionId);
|
||||
}
|
||||
|
||||
async function check() {
|
||||
if (!question || selected === null || answer || submitting) return;
|
||||
const optionId = selected;
|
||||
setSubmitting(true);
|
||||
let key = answerKeys.current.get(question.id);
|
||||
if (!key) {
|
||||
@@ -93,14 +141,21 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
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', nextRefillAt: (err.body.nextRefillAt as string | null) ?? null });
|
||||
setPhase({
|
||||
kind: 'noHearts',
|
||||
nextRefillAt: (err.body.nextRefillAt as string | null) ?? null,
|
||||
fullRefillAt: (err.body.fullRefillAt as string | null) ?? null,
|
||||
});
|
||||
} else {
|
||||
toast(friendlyError(err, 'جوابت ثبت نشد، دوباره بزن'), 'error');
|
||||
}
|
||||
@@ -112,7 +167,11 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
async function next() {
|
||||
if (outOfHearts && !isLast) {
|
||||
const h = await api.hearts().catch(() => null);
|
||||
setPhase({ kind: 'noHearts', nextRefillAt: h?.nextRefillAt ?? hearts?.nextRefillAt ?? null });
|
||||
setPhase({
|
||||
kind: 'noHearts',
|
||||
nextRefillAt: h?.nextRefillAt ?? hearts?.nextRefillAt ?? null,
|
||||
fullRefillAt: h?.fullRefillAt ?? hearts?.fullRefillAt ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isLast) {
|
||||
@@ -127,23 +186,69 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
async function finish() {
|
||||
setPhase({ kind: 'finishing' });
|
||||
try {
|
||||
const result = await api.lesson.complete(
|
||||
lessonId,
|
||||
{ correctCount, totalQuestions: questions.length },
|
||||
completeKey.current,
|
||||
);
|
||||
const payload = { correctCount, totalQuestions: questions.length };
|
||||
const result: QuizResult = nodeMode
|
||||
? await api.nodeQuizComplete(nodeId ?? '', payload, completeKey.current)
|
||||
: 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') {
|
||||
setPhase({
|
||||
kind: 'noHearts',
|
||||
nextRefillAt: (error.body.nextRefillAt as string | null) ?? null,
|
||||
fullRefillAt: (error.body.fullRefillAt as string | null) ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPhase({ kind: 'finishError', error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* قلب برگشت: آزمون از همان سوال ادامه مییابد. قبلاً کلِ آزمون از نو بار میشد و سوال و «درستها» صفر میشدند؛
|
||||
* دانشآموزی که وسطِ جزیره قلبش تمام شده بود بعد از ساعتها انتظار باید سوالهای جوابداده را دوباره میزد.
|
||||
*/
|
||||
function resume() {
|
||||
if (!questions.length) {
|
||||
setReload((n) => n + 1);
|
||||
return;
|
||||
}
|
||||
api.hearts().then(
|
||||
(h) => {
|
||||
setHearts(h);
|
||||
if (!h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
|
||||
else setPhase({ kind: 'playing' });
|
||||
},
|
||||
(err: unknown) => toast(friendlyError(err, 'وضعیت قلبها معلوم نشد، دوباره بزن'), 'error'),
|
||||
);
|
||||
}
|
||||
|
||||
const askWhy = () =>
|
||||
nav.push({ name: 'ask', lessonId, questionId: question?.id, prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟' });
|
||||
nav.push({
|
||||
name: 'ask',
|
||||
lessonId: lessonId ?? (question?.lessonId ? Number(question.lessonId) : undefined),
|
||||
questionId: question?.id,
|
||||
prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟',
|
||||
});
|
||||
|
||||
/* ─── حالتهای غیرِ بازی ─────────────────────────────────────────────── */
|
||||
|
||||
@@ -158,8 +263,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
if (phase.kind === 'noHearts') return <NoHearts nextRefillAt={phase.nextRefillAt} />;
|
||||
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id} color={color} />;
|
||||
if (phase.kind === 'noHearts') {
|
||||
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={resume} />;
|
||||
}
|
||||
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} streakUp={streakUp} />;
|
||||
if (phase.kind === 'finishing' || phase.kind === 'finishError') {
|
||||
return (
|
||||
<Screen>
|
||||
@@ -169,7 +276,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
<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>
|
||||
</>
|
||||
@@ -182,7 +289,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex flex-1 flex-col justify-center gap-4 px-8">
|
||||
<ErrorState text="این درس هنوز سوالی نداره" />
|
||||
<ErrorState text={nodeMode ? 'این جزیره هنوز سوالی نداره' : 'این درس هنوز سوالی نداره'} />
|
||||
<SecondaryButton onClick={nav.back}>برگشت</SecondaryButton>
|
||||
</div>
|
||||
</Screen>
|
||||
@@ -193,16 +300,16 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
|
||||
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';
|
||||
@@ -218,8 +325,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-soft">
|
||||
<div className="h-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} />
|
||||
@@ -236,17 +345,27 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
|
||||
}`}>
|
||||
{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">{question.prompt}</h2>
|
||||
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} display /></h2>
|
||||
{question.figure ? <div className="mt-3"><Diagram spec={question.figure} hideCounts /></div> : null}
|
||||
</div>
|
||||
|
||||
<div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
|
||||
@@ -254,41 +373,61 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
<motion.button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => void choose(option.id)}
|
||||
disabled={selected !== null && !answer}
|
||||
whileTap={{ scale: selected === null ? 0.95 : 1 }}
|
||||
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`}
|
||||
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 dir="auto">{option.value}</span>
|
||||
<span><MathText text={option.value} /></span>
|
||||
{answer && option.id === answer.correctOptionId && <Check size={16} className="text-green-500" strokeWidth={3} />}
|
||||
{answer && option.id === selected && !answer.correct && <X size={16} className="text-red-400" strokeWidth={3} />}
|
||||
</motion.button>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<Sheet open={confirmExit} onClose={() => setConfirmExit(false)} title="از تمرین بیرون بری؟">
|
||||
<p className="mb-5 text-sm leading-7 text-muted">جوابهایی که دادی ثبت شده، ولی این درس تا آخرش رو نزنی تمومشده حساب نمیشه.</p>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
جوابهایی که دادی ثبت شده، ولی {nodeMode ? 'این جزیره' : 'این درس'} تا آخرش رو نزنی تمومشده حساب نمیشه.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه میدم</PrimaryButton>
|
||||
<SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton>
|
||||
<SecondaryButton onClick={() => nav.leave()}>خروج</SecondaryButton>
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
<ReportSheet open={reporting} questionId={question.id} onClose={() => setReporting(false)} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -297,7 +436,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>
|
||||
);
|
||||
}
|
||||
@@ -325,38 +464,31 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NoHearts({ nextRefillAt }: { nextRefillAt: string | null }) {
|
||||
const nav = useNav();
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||
<SeyarMascot char="pi" mood="depressed" size={110} />
|
||||
<h2 className="text-xl font-black text-ink">قلبهات تموم شد</h2>
|
||||
<p className="text-sm leading-7 text-muted">
|
||||
{nextRefillAt ? <>قلبِ بعدی تا <b className="text-coral">{timeUntil(nextRefillAt)}</b> دیگه پر میشه.</> : 'کمی صبر کن تا قلبهات دوباره پر بشن.'}
|
||||
<br />
|
||||
با اشتراکِ ویژه قلبها نامحدودن.
|
||||
</p>
|
||||
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ اشتراک</PrimaryButton>
|
||||
<SecondaryButton onClick={nav.back}>باشه، بعداً</SecondaryButton>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function Result({
|
||||
result, total, correct, lessonId, nodeId, color,
|
||||
}: { result: LessonComplete; 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;
|
||||
|
||||
// بعد از ثبت، نقشه عوض شده؛ درسِ بعدی را از نسخهی تازهی نقشه میخوانیم.
|
||||
async function goNext() {
|
||||
if (nodeMode) {
|
||||
if (nextNode) nav.replace({ name: 'quiz', nodeId: nextNode });
|
||||
else nav.back();
|
||||
return;
|
||||
}
|
||||
setLoadingNext(true);
|
||||
try {
|
||||
const fresh = await api.home();
|
||||
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId);
|
||||
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) {
|
||||
nav.replace({ name: 'lesson', lessonId: next, nodeId: ref.node.id });
|
||||
@@ -381,9 +513,17 @@ function Result({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
|
||||
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
|
||||
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی</p>}
|
||||
<p className="mt-1 text-sm text-muted">{toFa(correct)} جواب درست از {toFa(total)} سوال</p>
|
||||
{/* نمرهی کم: تلاش و راه را ستایش کن، نه «باهوشی» را (پژوهشهای ذهنیتِ رشد)؛ و راهِ بعدی را نشان بده. */}
|
||||
{result.stars <= 1 && (
|
||||
<p className="mt-2 text-xs leading-6 text-body">
|
||||
اشتباهها همون جاهاییان که مغزت داره یاد میگیره. هر وقت خواستی از «درسها» دوباره مرورش کن.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div 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>
|
||||
@@ -394,73 +534,72 @@ 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()}>
|
||||
ادامه <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={() => then(nav.back)} className="-mt-2 py-2 text-sm font-bold text-muted">
|
||||
فعلاً بسه، برگردیم
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||