forked from Tadrisino/MathApp_frontend
Compare commits
43 Commits
19ca3d7de1
...
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 |
138
README.md
138
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,65 +39,87 @@ VITE_DEV_PROXY_TARGET=https://mathapi.tadrisino2.ir
|
||||
| `npm run build` | type-check و بعد build در `dist/` |
|
||||
| `npm run preview` | اجرای همان build |
|
||||
|
||||
## آدرسِ بکاند و عوض کردنِ دامنه
|
||||
|
||||
دامنه هیچجای کد نیامده. آدرسِ API به این ترتیب خوانده میشود
|
||||
(`src/config.ts`):
|
||||
|
||||
1. **`dist/config.js`** (از `public/config.js`) — در زمانِ اجرا. بعد از build هم روی
|
||||
سرور قابلِ ویرایش است و build دوباره نمیخواهد.
|
||||
2. **`VITE_API_BASE_URL`** — در زمانِ build، از `.env.production`.
|
||||
3. **`/api`** — همدامنه، وقتی nginxِ دامنهی فرانت مسیرِ `/api` را به بکاند پراکسی کند.
|
||||
|
||||
برای جابهجایی دامنه:
|
||||
|
||||
- **سریع، بدونِ build:** روی سرور در `dist/config.js` بنویسید
|
||||
`apiBaseUrl: "https://api.new-domain.ir/api"` و کشِ CDN را برای همین فایل پاک کنید.
|
||||
- **ماندگار:** خطِ `VITE_API_BASE_URL` در `.env.production` را عوض کنید و build بگیرید.
|
||||
- **سمتِ بکاند** هم دامنهی تازهی فرانت باید مجاز باشد (`FRONTEND_URL` در `.env`
|
||||
بکاند). جزئیات در `docs/operations.md` همان ریپو.
|
||||
|
||||
## دیپلوی
|
||||
|
||||
روی سرور (`~/mathapp_frontend_new`، که nginxِ `math.tadrisino2.ir` از `dist/`ش سرو میکند):
|
||||
|
||||
```bash
|
||||
./deploy.sh # fetch، npm ci، type-check، build در dist.next و جابهجاییِ بیوقفه
|
||||
```
|
||||
|
||||
buildِ قبلی در `dist.prev` میماند (برگشت: `rm -rf dist && mv dist.prev dist`)، و
|
||||
`dist/config.js`ِ ویرایششده روی سرور به buildِ تازه منتقل میشود.
|
||||
|
||||
جای دیگر: `npm ci && npm run build` و سرو کردنِ `dist/`. اپ مسیرِ جدا (route) ندارد و همهچیز از `/` بار میشود؛
|
||||
فقط `index.html` و `config.js` نباید طولانی کش شوند (باقیِ فایلها نامِ hashدار دارند).
|
||||
درگاهِ پرداخت کاربر را به `/?orderId=…&status=…` برمیگرداند و لینکِ دعوت
|
||||
`/?ref=CODE` است؛ هر دو را خودِ اپ میخواند.
|
||||
---
|
||||
|
||||
## ساختار
|
||||
|
||||
```
|
||||
src/
|
||||
config.ts آدرسِ API (بالا را ببینید)
|
||||
config.ts آدرسِ API (بخشِ پایین)
|
||||
api/ تنها درِ ارتباط با سرور — هیچ جای دیگر fetch نمیزند
|
||||
http.ts timeout، تکرارِ GET، refreshِ تکنسخهای، SSE
|
||||
http.ts توکن، timeout، تکرارِ GET، refreshِ تکنسخهای، SSE، Idempotency-Key
|
||||
index.ts یک تابع برای هر اندپوینت
|
||||
types.ts شکلِ پاسخها، از روی پاسخِ واقعیِ سرور
|
||||
app/ App (جریانِ ورود) و MainApp (تبها و صفحهها)
|
||||
nav/ ناوبریِ پشتهای، هماهنگ با دکمهی back گوشی
|
||||
state/ دادهی مشترک (پروفایل، نقشه، …) و گفتگوی دستیار
|
||||
state/ دادهی مشترک (پروفایل، نقشه، کتابِ انتخابشده، همراه) و گفتگوی دستیار
|
||||
screens/ یک فایل برای هر صفحه
|
||||
components/ اجزای مشترک (سربرگ، برگهی پایین، …)
|
||||
mascots/ همراهها: سیگما، دلتا، پی، تدی
|
||||
diagram/ شکلِ جوابِ دستیار: توصیفِ JSON از بکاند → SVG؛ همهی مختصات اینجا حساب میشود
|
||||
(plane، shapes، expr، solid، charts، sets، misc، numberline). گالری: ?gallery فقط در dev
|
||||
lib/ اعدادِ فارسی، تاریخِ شمسی، صدا، localStorage
|
||||
components/ اجزای مشترک: مسیرِ جزیرهها، نوارِ کتابها، برگهی پایین، بازخورد، …
|
||||
mascots/ همراهها (سیگما، دلتا، پی، امگا): SVG از کد، شش حالتِ چهره
|
||||
diagram/ شکلِ درس و جوابِ دستیار: توصیفِ JSON از بکاند → SVG؛ همهی مختصات اینجا حساب میشود
|
||||
lib/ اعدادِ فارسی، تاریخِ شمسی، MathText، پیامِ خطا، صدا، پلِ کافهبازار، روشهای پرداخت
|
||||
android/ پوستهی اندروید (README خودش)
|
||||
pay.html · src/pay.ts صفحهی واسطِ پرداختِ زرینپال برای اپِ اندرویدِ فعلی
|
||||
```
|
||||
|
||||
قاعدهها:
|
||||
|
||||
- **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ
|
||||
`۰-۹` میفرستد و `\d` در JS آنها را نمیشناسد).
|
||||
- **کلاینت را از حافظه ننویسید.** قرارداد را از اسکیمای بکاند بخوانید
|
||||
(`python manage.py spectacular --file schema.yaml --format openapi`) و هر تغییرِ `types.ts` را با پاسخِ واقعیِ سرور بسنجید.
|
||||
- **صفحهها هرگز مستقیم سرور را صدا نمیزنند:** همیشه `screens/* → api/index.ts → api/http.ts`.
|
||||
- **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ `۰-۹` میفرستد و `\d` در JS آنها را نمیشناسد).
|
||||
- **رنگها** در `src/styles/index.css` (`@theme`) تعریف شدهاند: `bg-coral`، `text-ink`، …
|
||||
- **خطا:** پیامِ کاربر همیشه از `friendlyError` میآید، نه متنِ خامِ سرور.
|
||||
- **کلاینت را از حافظه ننویسید.** قرارداد را از `/api/docs/` بکاند بخوانید و تغییرِ
|
||||
`types.ts` را با پاسخِ واقعیِ سرور بسنجید.
|
||||
- **بازشدنِ فوری:** کاربرِ واردشده خانه را از عکسِ روی گوشی میبیند (`state/homeSnapshot.ts`: پروفایل، پایهها، نقشه،
|
||||
آمار) و همان درخواستهای بوت پشتِ صحنه تازهاش میکنند. عکس با پاک شدنِ توکن و ورودِ تازه پاک میشود، با بیلدِ دیگر
|
||||
یا کهنهتر از ۷ روز خوانده نمیشود، و درستی (قفل، اشتراک، قلب) همیشه با سرور است. دادهی تازهای که در خانه دیده
|
||||
میشود باید در `AppData` بماند تا در عکس هم برود.
|
||||
- **متنِ فارسی** بیاعراب و بی «ی»ِ اضافهی نوشتهشده («پایهی سوم») است (تصمیمِ شرکت)؛ تنوین («قبلاً») میماند. متنِ تازه
|
||||
را همینطور بنویسید. متنِ سرور را بکاند هنگامِ ساختنِ پاسخ پاک میکند و `plainPersian` در `MathText` همان کار را برای
|
||||
پاسخِ زندهی دستیار. `lib/plainMath.ts` هر LaTeX یا markdownی را که برسد ساده میکند و باید با `content/plain_math.py`ِ
|
||||
بکاند همرفتار بماند.
|
||||
|
||||
---
|
||||
|
||||
## آدرسِ بکاند و عوض کردنِ دامنه
|
||||
|
||||
دامنه هیچجای کد نیامده. آدرسِ API به این ترتیب خوانده میشود (`src/config.ts`):
|
||||
|
||||
1. **`dist/config.js`** (از `public/config.js`) — در زمانِ اجرا؛ بعد از build هم روی سرور ویرایششدنی است.
|
||||
2. **`VITE_API_BASE_URL`** — در زمانِ build، از `.env.production`.
|
||||
3. **`/api`** — همدامنه، وقتی nginxِ دامنهی فرانت مسیرِ `/api` را به بکاند پراکسی کند.
|
||||
|
||||
برای جابهجاییِ دامنه:
|
||||
|
||||
- **سریع، بدونِ build:** روی سرور در `dist/config.js` بنویسید `apiBaseUrl: "https://<دامنهی تازه>/api"` و کشِ آروان را
|
||||
برای همین فایل پاک کنید.
|
||||
- **ماندگار:** خطِ `VITE_API_BASE_URL` در `.env.production` را عوض کنید و build بگیرید.
|
||||
- **سمتِ بکاند** دامنهی تازهی فرانت باید مجاز باشد (`FRONTEND_URL`)، و در CSPِ nginx (`connect-src`) دامنهی تازهی API
|
||||
بیاید. رویهی کامل: `docs/operations.md`ِ بکاند، بخشِ «عوض کردنِ دامنه».
|
||||
|
||||
## روشهای پرداخت
|
||||
|
||||
برگهی پرداخت روشها را از `paymentMethods` در `dist/config.js` میخواند (`src/lib/paymentMethods.ts`)، جدا برای مرورگر
|
||||
(`web`) و داخلِ اپِ اندروید (`app`)؛ مقدارها `"zarinpal"` و `"bazaar"`. پیشفرض: مرورگر فقط زرینپال، اپ هر دو. نسخهای که
|
||||
فقط در کافهبازار است: `paymentMethods: { app: ["bazaar"] }`؛ build لازم نیست، فقط کشِ آروانِ این فایل را پاک کنید.
|
||||
|
||||
---
|
||||
|
||||
## انتشار
|
||||
|
||||
روی سرور (`~/mathapp_frontend_new`، که nginxِ `math.tadrisino2.ir` از `dist/`ش سرو میکند):
|
||||
|
||||
```bash
|
||||
cd ~/mathapp_frontend_new && ./deploy.sh # fetch، npm ci، type-check، build در dist.next و جابهجاییِ بیوقفه
|
||||
```
|
||||
|
||||
buildِ قبلی در `dist.prev` میماند (برگشت: `rm -rf dist && mv dist.prev dist`) و `dist/config.js`ِ ویرایششده روی سرور به
|
||||
buildِ تازه منتقل میشود. سرور مشترکِ شرکت است: پیش از هر کار آنجا قواعدِ `CLAUDE.md`ِ بکاند (بخشِ «Working on the
|
||||
servers») را بخوانید.
|
||||
|
||||
جای دیگر: `npm ci && npm run build` و سرو کردنِ `dist/`. اپ مسیرِ جدا (route) ندارد و همهچیز از `/` بار میشود؛ فقط
|
||||
`index.html` و `config.js` نباید طولانی کش شوند (باقیِ فایلها نامِ hashدار دارند). درگاهِ پرداخت کاربر را به
|
||||
`/?orderId=…&status=…` برمیگرداند و لینکِ دعوت `/?ref=CODE` است؛ هر دو را خودِ اپ میخواند.
|
||||
|
||||
CSPِ سایت در nginx است (`ops/nginx/zareian-front-headers.conf` در مخزنِ بکاند). اگر اسکریپتِ درونخطیِ `index.html` عوض
|
||||
شود، هشِ تازهاش باید در همان فایل بنشیند، وگرنه مرورگر آن را اجرا نمیکند.
|
||||
|
||||
@@ -5,20 +5,63 @@
|
||||
محصولِ دیجیتال را فقط از راهِ خودش میپذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
|
||||
فرانت بهروز میشود، بیآنکه نسخهی تازهی اپ لازم باشد.
|
||||
|
||||
> ⚠️ **این پروژه هنوز یک بار هم build نشده.** روی ماشینی نوشته شد که Android SDK نداشت
|
||||
> (۲۰۲۶-۱۰-۰۳). APIِ Poolakey از README خودِ کتابخانه است. اولین کار: باز کردن در Android
|
||||
> Studio، build، و یک خریدِ واقعی روی گوشی.
|
||||
> **ساخت و امضا با همتیمی است (MH-W90، m.hassanabadi)** با روشِ امضای خودش (`my-release-key.keystore` +
|
||||
> propertyهای Gradle)؛ تصمیمِ مالک این است که همان روش بماند. پیش از دست زدن به امضا از مالک بپرس و هرگز
|
||||
> keystore را commit نکن. `applicationId` واقعی `com.tadrisino.math.app` است (از APKِ ساختهشده با aapt2).
|
||||
> هیچ خریدِ واقعیِ بازار هنوز آزموده نشده.
|
||||
|
||||
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
|
||||
## نسخهی بعدیِ APK — چه چیزی در کد هست و در اپِ نصبشده نیست
|
||||
|
||||
| چه | کجا گذاشته میشود | وضعیت |
|
||||
اینها در `MainActivity.kt` نوشته شدهاند (۱۴۰۵-۰۷، `468f5b2` و `737414f`) ولی هیچکدام build نشدهاند؛ اپی که الان روی
|
||||
گوشیهاست بدونِ آنهاست. فرانت با هر دو نسخه کار میکند (اگر پل نبود، راهِ قدیم را میرود).
|
||||
|
||||
| تغییر | چرا | بعد از build روی گوشی بسنج |
|
||||
|---|---|---|
|
||||
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.app` حدس است؛ تأیید نشده |
|
||||
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | ندارد؛ بدونِ آن خرید شروع نمیشود |
|
||||
| SKUِ هر بسته (محصولِ **مصرفشدنی**) | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچکدام تعریف نشده |
|
||||
| `TadrisinoAndroid.vibrate(patternJson)` با Vibratorِ گوشی | `navigator.vibrate` در WebView همیشه کار نمیکند؛ مالک گفت اپ لرزش ندارد | انتخابِ گزینهی آزمون، جوابِ درست/غلط، پایانِ جزیره و صفحهی «روزِ پیاپی» باید بلرزند؛ با خاموش کردنِ «صداها» در پروفایل نه |
|
||||
| `mediaPlaybackRequiresUserGesture = false` | صداهای رابط بعد از پاسخِ سرور پخش میشوند، نه در لحظهی لمس | صدای جوابِ درست/غلط و پایانِ تمرین |
|
||||
| `isPaymentPage` + `paysInApp()` | درگاهِ زرینپال داخلِ خودِ اپ باز میشود. اپِ فعلی آن را به مرورگرِ گوشی میدهد که Referer نمیفرستد و زرینپال بیReferer «دسترسی از این دامنه مجاز نمیباشد» میدهد؛ فعلاً فرانت از راهِ `pay.html` (با `http://`) دورش میزند | «پرداخت» با کارتِ بانکی: صفحهی زرینپال و بانک داخلِ اپ، و بعد از پرداخت برگشت به اپ با پیامِ «پرداخت موفق بود» (PaymentResult). دکمهی برگشت وسطِ صفحهی بانک چه میکند را هم ببین |
|
||||
| اسپلشِ یکمرحلهای: `installSplashScreen()` + `setKeepOnScreenCondition` + `TadrisinoAndroid.appReady()` (`446cf36`، `ab02bb4`) | اپِ فعلی سه تکه میشود: اسپلشِ اندروید ← WebViewِ سفیدِ خالی ← اسپلشِ وب (آیکون + نام) ← خانه. با APKِ تازه اسپلشِ بومی تا اولین صفحهی واقعیِ وب میماند و مستقیم میرود روی همان صفحه؛ اسپلشِ وب داخلِ اپ نامرئی است (در APKِ قدیمی فقط آیکونِ بینام). فرانت `appReady` را بعد از اولین صفحهی غیرboot صدا میزند و APKِ قدیمی آن را ندارد، پس بیاثر است | روی گوشی: باز کردنِ اپ با اینترنت باید آیکون ← خانه باشد، بیسفیدی و بیتصویرِ دوم؛ اندازهی آیکون روی اندروید ۱۲+ (`drawable/splash_icon.xml`: ۲۸۸dp، حاشیه ۲۲٪) |
|
||||
| خریدِ اشتراک از کافهبازار: کلیدِ RSA، `bazaarBuy` (`subscribeProduct`)، قیمتِ پیشخان (`bazaarPrices`)، «تخفیف پویا»؛ `versionCode` ۲ | بیاینها از بازار هیچ خریدی ممکن نیست (APKهای قبلی کلید نداشتند) | داخلِ اپ ← فروشگاه ← اشتراکِ سهماهه: «کارتِ بانکی» پیشفرض، کاشیِ «کافهبازار» فعال با قیمتِ بازار زیرِ آن؛ خرید با حسابِ تست، برگشت با «اشتراکت فعاله»؛ در `/admin/payment/order/` سفارشِ بازار «پرداختشده». یک بار هم «انصراف» در صفحهی بازار |
|
||||
| صفحهی بیاتصال: `onReceivedError`/`onReceivedHttpError` ← همان تصویر + «اینترنت قطع است / کند است؛ در حال اتصال…»، تلاشِ دوباره هر ۵ ثانیه، و مجوزِ `ACCESS_NETWORK_STATE` | کاربر هرگز صفحهی خطای WebView یا متنِ فنی نمیبیند. اتصالِ کند (بیخطا) عمداً هیچ متنِ اضافه نمیآورد و روی اسپلش میماند | هواپیما روشن ← باز کردنِ اپ ← خاموش کردن: باید خودش وصل شود. سرورِ ۵۰۲ (مثلاً nginx خاموش) هم همین صفحه. اگر صفحه لود شد ولی ۱۵ ثانیه `appReady` نیامد، لاگِ ERROR و رهاشدنِ اسپلش (باگِ JS) |
|
||||
|
||||
قیمتِ هر محصول در پیشخانِ بازار باید با قیمتِ همان بسته در ادمین یکی باشد؛ سرور مبلغ را از
|
||||
بازار نمیخواند.
|
||||
**اسپلش و بیاینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصبشده امتحان شد).** چیزی که روی گوشی دیده میشود
|
||||
از وب قابل رفع نیست، چون وب هنوز بار نشده یا اصلاً نمیرسد:
|
||||
|
||||
- اپِ نصبشده: اسپلشِ اندروید ← صفحهی سفید ← اسپلشِ وب ← خانه. با فیلترشکن (یا هر وقت `math.tadrisino2.ir` نرسد)
|
||||
فقط **سفید** میماند و هیچ پیامی نیست، چون HTML هرگز نرسیده و هیچ کدِ ما اجرا نمیشود. علتِ دقیقِ آن سفیدی (HTML نرسیده
|
||||
یا API نرسیده) روی فیلترشکن بازتولید **نشد**؛ APKِ تازه هر دو را با همان صفحهی بیاتصال میپوشاند.
|
||||
- APKِ تازه (دو ردیفِ بالا) باید: ۱) با اینترنت، اسپلشِ بومی مستقیم به خانه برود و وسطش هیچچیز نیفتد؛ ۲) بیاینترنت یا
|
||||
کند، **زیرِ همان تصویرِ ثابت** بگوید «اینترنت قطع است / کند است»، نه سفید و نه متنِ خطا.
|
||||
- آزمونِ پذیرش روی گوشی، پیش از انتشار: اینترنتِ عادی؛ هواپیما؛ فیلترشکنِ روشن (همان که سفید میداد)؛ اینترنتِ خیلی کند.
|
||||
در هیچکدام نباید سفیدِ خالی یا صفحهی خطای WebView دیده شود.
|
||||
|
||||
پیشنهادهایی که هنوز نوشته نشدهاند:
|
||||
|
||||
- **«برای خروج دوباره بزن»**: دکمهی برگشتِ گوشی حالا قدمبهقدم در خودِ وب عقب میرود (`src/nav/Navigator.tsx`) و
|
||||
از خانه یکراست بیرون میرود. اگر تأییدِ خروج خواستید، در `handleOnBackPressed` وقتی `!webView.canGoBack()` یک
|
||||
Toast بدهید و فقط برگشتِ دوم در دو ثانیه `finish()` کند. در وب شدنی نیست (کروم ورودیِ history بیلمس را رد میکند).
|
||||
- `versionCode`/`versionName` را برای هر نسخهای که منتشر میشود بالا ببرید؛ الان ۲ و 1.1.0 است (بازار فقط `versionCode`ِ بزرگتر را میپذیرد).
|
||||
|
||||
قانونِ بازار: داخلِ اپِ بازار حالا پرداخت با درگاه هم پیشنهاد میشود (خواستِ مالک). بازار پرداختِ محتوای دیجیتال بیرون از
|
||||
پرداختِ درونبرنامهای را محدود میکند؛ پیش از انتشار در بازار این را با بازار یا مالک روشن کنید.
|
||||
|
||||
ساخت روی مکِ مالک: SDK در `~/Library/Android/sdk`، JDK 17 (`/opt/homebrew/opt/openjdk@17`)، و چون Google Maven از
|
||||
ایران بیVPN ۴۰۴ میدهد، `~/.gradle/init.d/google-mirror.gradle` آینهی Aliyun را اضافه میکند؛ ساختِ اول ~۹ دقیقه.
|
||||
|
||||
## پیش از build — آنچه از پیشخانِ بازار لازم است
|
||||
|
||||
| چه | کجا گذاشته میشود | وضعیت (۲۰۲۶-۱۰-۱۲) |
|
||||
|---|---|---|
|
||||
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app`؛ بازار محصولهای ۱۰۳۴۲–۱۰۳۴۴ را زیرِ همین نام شناخت (API، توکنِ ساختگی) |
|
||||
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | گذاشته شد |
|
||||
| SKUِ هر پلن | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | یکماهه `10342`، سهماهه `10343`، ششماهه `10344`؛ بستهی سوال فروخته نمیشود |
|
||||
| کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور |
|
||||
|
||||
فقط اشتراک فروخته میشود. قیمت: فرانت `TadrisinoAndroid.bazaarPrices(skusJson)` را صدا میزند و قیمتِ پیشخان را روی
|
||||
کاشیِ بازار نشان میدهد. خرید: `TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)` با `subscribeProduct`
|
||||
(مصرف نمیشود، بازار خودش تمدید میکند). قیمتِ پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف
|
||||
خورده، سرور قیمتِ کمشده را امضا میکند.
|
||||
`compileDebugKotlin` سبز است؛ هیچ خریدی روی گوشی آزموده نشده.
|
||||
|
||||
## build
|
||||
|
||||
@@ -31,14 +74,15 @@
|
||||
|
||||
```
|
||||
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
|
||||
فرانت TadrisinoAndroid.bazaarPurchase(sku, orderId)
|
||||
اپ Poolakey.purchaseProduct(sku, payload=orderId) → purchaseToken
|
||||
فرانت (اختیاری) POST /api/discount/redeem {code, orderId}
|
||||
فرانت POST /api/payment/bazaar/orders/{orderId}/start → sku، dynamicPriceToken (اگر تخفیف خورده)
|
||||
فرانت TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
|
||||
اپ Poolakey.subscribeProduct(sku, payload=orderId) → purchaseToken
|
||||
اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken}
|
||||
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار میپرسد
|
||||
فرانت TadrisinoAndroid.bazaarConsume(token) ← تا همان بسته دوباره خریدنی باشد
|
||||
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار میپرسد و اشتراک را فعال میکند
|
||||
```
|
||||
|
||||
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» میزند: فرانت همان سفارش را میفرستد
|
||||
و اپ توکنِ ماندهٔ همان سفارش را از `getPurchasedProducts` پیدا میکند.
|
||||
و اپ توکنِ همان سفارش را از `getSubscribedProducts` پیدا میکند.
|
||||
|
||||
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بکاند.
|
||||
|
||||
@@ -64,13 +64,15 @@ android {
|
||||
applicationId = "com.tadrisino.math.app"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
// بازار نسخهی تازه را فقط با versionCodeِ بزرگتر میپذیرد؛ هر build که منتشر میشود یکی بالا.
|
||||
versionCode = 2
|
||||
versionName = "1.1.0"
|
||||
|
||||
// URL of the web frontend.
|
||||
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
|
||||
// Public RSA key from the Cafe Bazaar panel. Purchases won't start until it is filled in.
|
||||
buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
|
||||
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (پرداخت درونبرنامهای)؛ سینیور، ۲۰۲۶-۱۰-۱۲. عمومی است و در APK
|
||||
// میرود؛ Poolakey با آن امضای خرید را روی گوشی میسنجد. سنجشِ اصلی سمتِ سرور است.
|
||||
buildConfigField("String", "BAZAAR_RSA_KEY", "\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwC9EBw0RW6jeTdHkrJwfB/mX+klpjpVXVb2stpGDcixeqHiLE+/Iow+PiMbZEQfxZDuED6ELvZlzQX+ALOlGRPxaa1ctl6/Uy6wGaPXhGPDpZL6jPrXV88mjJzMcEoB4sf3yNb+6YNKrvKhRyXXR0JdMUmPMqMe8igmQZTv/UF+QEMNqv7lg7qw9Yl+fG802nqjasft7qUPQK+YHJYPS4PJ5Pj/YpTkGDdDmVi7AIMCAwEAAQ==\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
@@ -105,5 +107,7 @@ android {
|
||||
dependencies {
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.activity:activity-ktx:1.9.2")
|
||||
// اسپلشِ سیستم تا آمادهشدنِ اولین صفحهی وب میماند (MainActivity.keepSplash).
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- فقط برای فرقِ «اینترنت قطع است» و «اینترنت کند است» در اسپلشِ بیاتصال؛ مجوزِ عادی، بیپرسش. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- برای لرزشِ کوتاهِ جوابِ درست/غلط (navigator.vibrate در WebView). -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
@@ -21,6 +23,7 @@
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Tadrisino.Starting"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -2,40 +2,131 @@ package com.tadrisino.app
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import ir.cafebazaar.poolakey.Connection
|
||||
import ir.cafebazaar.poolakey.ConnectionState
|
||||
import ir.cafebazaar.poolakey.Payment
|
||||
import ir.cafebazaar.poolakey.config.PaymentConfiguration
|
||||
import ir.cafebazaar.poolakey.config.SecurityCheck
|
||||
import ir.cafebazaar.poolakey.exception.BazaarNotFoundException
|
||||
import ir.cafebazaar.poolakey.exception.BazaarNotSupportedException
|
||||
import ir.cafebazaar.poolakey.exception.DynamicPriceNotSupportedException
|
||||
import ir.cafebazaar.poolakey.exception.IAPNotSupportedException
|
||||
import ir.cafebazaar.poolakey.exception.SubsNotSupportedException
|
||||
import ir.cafebazaar.poolakey.request.PurchaseRequest
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درونبرنامهایِ بازار است.
|
||||
*
|
||||
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
|
||||
* JS → اینجا TadrisinoAndroid.bazaarPurchase(sku, orderId)
|
||||
* TadrisinoAndroid.bazaarConsume(purchaseToken)
|
||||
* JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken) (اشتراک)
|
||||
* TadrisinoAndroid.bazaarPrices(skusJson) → رویدادِ «tadrisino:bazaar-prices»
|
||||
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
|
||||
* { orderId, error: "canceled" | "failed", message }
|
||||
* { orderId, error: "canceled" | "failed" | "unavailable", message }
|
||||
*
|
||||
* سمتِ سرور توکن را از خودِ بازار میسنجد (`POST /api/payment/bazaar/verify`)، پس اینجا
|
||||
* فقط واسطه است و به هیچ نتیجهای اعتماد نمیکند.
|
||||
*
|
||||
* ⚠️ نوشته شده بیآنکه build شود (ماشینِ نویسنده Android SDK نداشت، ۲۰۲۶-۱۰-۰۳)؛ APIِ
|
||||
* Poolakey از README خودش است. پیش از انتشار با Android Studio بسازید و یک خریدِ واقعی بزنید.
|
||||
* ⚠️ کامپایل میشود (compileDebugKotlin، ۲۰۲۶-۱۰-۱۲) و امضای توابعِ Poolakey 2.2.0 از خودِ کتابخانه خوانده شد،
|
||||
* ولی روی گوشی اجرا نشده؛ پیش از انتشار یک خریدِ واقعی بزنید.
|
||||
*/
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var webView: WebView
|
||||
|
||||
/**
|
||||
* اسپلشِ سیستم تا وقتی true نشده روی صفحه میماند، هرچقدر اتصال کند باشد (خواستِ مالک): فرانت وقتی
|
||||
* اولین صفحهی واقعی (ورود/خانه) کشیده شد `TadrisinoAndroid.appReady()` را صدا میزند.
|
||||
* فقط دو حالت زودتر رهایش میکند: صفحهی اصلی بالا نیامد (آفلاین/خطای سرور → [showOffline]، با همان
|
||||
* تصویر و یک جمله)، یا صفحه لود شد ولی تا [READY_WATCHDOG_MS] appReady نیامد (خطای JS؛ لاگ میشود).
|
||||
*/
|
||||
@Volatile private var webReady = false
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private lateinit var offlineView: View
|
||||
private lateinit var offlineText: TextView
|
||||
private var offlineShown = false
|
||||
private val retryLoad = object : Runnable {
|
||||
override fun run() {
|
||||
if (!offlineShown) return
|
||||
webView.loadUrl(BuildConfig.WEB_URL)
|
||||
handler.postDelayed(this, RETRY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dp(v: Int): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics).toInt()
|
||||
|
||||
/** همان تصویرِ اسپلشِ سیستم (۲۸۸dp، همان drawable) با یک جمله زیرش؛ جای آیکون با اسپلشِ سیستم یکی است. */
|
||||
private fun buildOfflineView(): View = FrameLayout(this).apply {
|
||||
setBackgroundColor(Color.WHITE)
|
||||
visibility = View.GONE
|
||||
isClickable = true
|
||||
addView(ImageView(context).apply { setImageResource(R.drawable.splash_icon) },
|
||||
FrameLayout.LayoutParams(dp(288), dp(288), Gravity.CENTER))
|
||||
addView(TextView(context).apply {
|
||||
offlineText = this
|
||||
text = "اینترنت قطع است"
|
||||
setTextColor(Color.parseColor("#1a1a2e"))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
||||
layoutDirection = View.LAYOUT_DIRECTION_RTL
|
||||
gravity = Gravity.CENTER
|
||||
}, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER)
|
||||
.apply { topMargin = dp(150) })
|
||||
}
|
||||
|
||||
/** هیچ متنِ فنیِ خطا نشان داده نمیشود؛ فقط یکی از دو جمله: «قطع است» (شبکهای نیست) یا «کند است». */
|
||||
private fun hasInternet(): Boolean {
|
||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
private fun showOffline() {
|
||||
offlineText.text = if (hasInternet()) "اینترنت کند است؛ در حال اتصال…" else "اینترنت قطع است؛ در حال اتصال…"
|
||||
if (!offlineShown) {
|
||||
offlineShown = true
|
||||
webView.visibility = View.INVISIBLE // صفحهی خطای پیشفرضِ WebView دیده نشود
|
||||
offlineView.visibility = View.VISIBLE
|
||||
handler.postDelayed(retryLoad, RETRY_MS)
|
||||
}
|
||||
webReady = true
|
||||
}
|
||||
|
||||
private fun hideOffline() {
|
||||
if (!offlineShown) return
|
||||
offlineShown = false
|
||||
handler.removeCallbacks(retryLoad)
|
||||
offlineView.visibility = View.GONE
|
||||
webView.visibility = View.VISIBLE
|
||||
}
|
||||
private var payment: Payment? = null
|
||||
private var connection: Connection? = null
|
||||
private val webHost: String = Uri.parse(BuildConfig.WEB_URL).host ?: ""
|
||||
@@ -43,22 +134,58 @@ class MainActivity : AppCompatActivity() {
|
||||
/** فقط خودِ سایت و فقط با https؛ «http://دامنهی ما» قابلِ دستکاریِ شبکه است و نباید پل داشته باشد. */
|
||||
private fun isOurPage(uri: Uri?): Boolean = uri != null && uri.scheme == "https" && uri.host == webHost
|
||||
|
||||
/** API و درگاه: زیردامنههای دامنهی خودمان (mathapi…)، زرینپال و صفحههای بانکیِ شاپرک؛ فقط https. پل اینجا کار نمیکند. */
|
||||
private fun isPaymentPage(uri: Uri?): Boolean {
|
||||
val host = uri?.host ?: return false
|
||||
if (uri.scheme != "https") return false
|
||||
val ours = webHost.substringAfter('.')
|
||||
return host == ours || host.endsWith(".$ours") ||
|
||||
host == "zarinpal.com" || host.endsWith(".zarinpal.com") || host.endsWith(".shaparak.ir")
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen().setKeepOnScreenCondition { !webReady }
|
||||
super.onCreate(savedInstanceState)
|
||||
webView = WebView(this)
|
||||
setContentView(webView)
|
||||
offlineView = buildOfflineView()
|
||||
setContentView(FrameLayout(this).apply {
|
||||
addView(webView)
|
||||
addView(offlineView)
|
||||
})
|
||||
|
||||
webView.settings.javaScriptEnabled = true
|
||||
webView.settings.domStorageEnabled = true // توکنِ ورود در localStorage است
|
||||
// WebView فقط آدرسِ وب را میخواند؛ دسترسی به فایل و content:// لازم نیست.
|
||||
webView.settings.allowFileAccess = false
|
||||
webView.settings.allowContentAccess = false
|
||||
// صداهای کوتاهِ رابط (Web Audio) بعد از پاسخِ سرور پخش میشوند، نه درست در لحظهی لمس.
|
||||
webView.settings.mediaPlaybackRequiresUserGesture = false
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
// صفحهی اصلی بالا نیامد (بیاینترنت یا خطای سرور): همان تصویر با «در حال اتصال»، و هر چند ثانیه دوباره.
|
||||
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: android.webkit.WebResourceError) {
|
||||
if (request.isForMainFrame) showOffline()
|
||||
}
|
||||
|
||||
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) {
|
||||
if (request.isForMainFrame && errorResponse.statusCode >= 500) showOffline()
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
if (offlineShown) return // صفحهی خطا هم onPageFinished میزند؛ تا بارِ موفق همان تصویر بماند
|
||||
handler.postDelayed({
|
||||
if (!webReady) Log.e(TAG, "page loaded but appReady never came in ${READY_WATCHDOG_MS}ms; releasing the splash")
|
||||
webReady = true
|
||||
}, READY_WATCHDOG_MS)
|
||||
}
|
||||
|
||||
// هر آدرسی بیرونِ دامنهی خودمان در مرورگرِ گوشی باز میشود، نه داخلِ اپ؛ پس پل فقط
|
||||
// در صفحههای خودمان در دسترس است.
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||
if (isOurPage(request.url)) return false
|
||||
// پرداخت با درگاه داخلِ خودِ اپ میماند: زرینپال بیRefererِ دامنهی ما صفحه را باز نمیکند و مرورگرِ
|
||||
// بیرونی Referer نمیفرستد. بعد از پرداخت، زرینپال به سایتِ ما برمیگرداند و نتیجه همینجا دیده میشود.
|
||||
if (isPaymentPage(request.url)) return false
|
||||
// فقط http(s) به مرورگرِ گوشی میرود؛ intent:، file: و schemeهای سفارشی بیصدا بسته میشوند
|
||||
// (پیشتر هر آدرسی بیرونِ دامنه به ACTION_VIEW میرفت و صفحه میتوانست اپِ دلخواه باز کند).
|
||||
if (request.url.scheme == "https" || request.url.scheme == "http") {
|
||||
@@ -88,6 +215,7 @@ class MainActivity : AppCompatActivity() {
|
||||
val config = PaymentConfiguration(
|
||||
localSecurityCheck = SecurityCheck.Enable(rsaPublicKey = BuildConfig.BAZAAR_RSA_KEY),
|
||||
)
|
||||
connection?.disconnect()
|
||||
payment = Payment(context = this, config = config).also { p ->
|
||||
connection = p.connect {
|
||||
connectionSucceed { Log.i(TAG, "bazaar connected") }
|
||||
@@ -97,31 +225,53 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun purchase(sku: String, orderId: String) {
|
||||
/**
|
||||
* خریدِ اشتراکِ بازار (subscribeProduct؛ فقط اشتراک فروخته میشود و مصرف نمیشود، بازار خودش تمدید میکند).
|
||||
* dynamicPriceToken خالی یعنی قیمتِ پیشخان؛ پر یعنی قیمتی که سرور با کلیدِ «تخفیف پویا» امضا کرده
|
||||
* (بازارِ ۱۳.۳ به بعد).
|
||||
*/
|
||||
private fun buy(sku: String, orderId: String, dynamicPriceToken: String) {
|
||||
val p = payment
|
||||
if (p == null) {
|
||||
send(orderId, error = "failed", message = "bazaar not connected")
|
||||
if (p == null || connection?.getState() !is ConnectionState.Connected) {
|
||||
// بازار نصب نیست، هنوز وصل نشده، یا اتصال افتاده؛ برای تلاشِ بعدی دوباره وصل میشویم.
|
||||
connectToBazaar()
|
||||
send(orderId, error = "unavailable", message = "not_connected")
|
||||
return
|
||||
}
|
||||
p.purchaseProduct(
|
||||
registry = activityResultRegistry,
|
||||
request = PurchaseRequest(productId = sku, payload = orderId),
|
||||
) {
|
||||
val request = PurchaseRequest(
|
||||
productId = sku, payload = orderId, dynamicPriceToken = dynamicPriceToken.ifEmpty { null },
|
||||
)
|
||||
p.subscribeProduct(activityResultRegistry, request) {
|
||||
purchaseSucceed { send(orderId, token = it.purchaseToken) }
|
||||
purchaseCanceled { send(orderId, error = "canceled") }
|
||||
purchaseFailed { recoverOwned(sku, orderId, it) }
|
||||
failedToBeginFlow { recoverOwned(sku, orderId, it) }
|
||||
purchaseFailed { failed(sku, orderId, it) }
|
||||
failedToBeginFlow { failed(sku, orderId, it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* خریدِ قبلیِ همین محصول که مصرف نشده (پول رفته ولی تأییدِ سرور نرسیده بود) جلوی خریدِ
|
||||
* دوباره را میگیرد. فرانت در تلاشِ بعدی همان سفارش را میفرستد؛ اگر توکنِ ماندهٔ همان
|
||||
* سفارش (payload) پیدا شد، همان برمیگردد تا سرور ثبتش کند.
|
||||
* بازارِ نصبنشده یا قدیمی (بی پشتیبانیِ اشتراک یا «تخفیف پویا») کاری است که کاربر باید بکند، نه خطای خرید؛
|
||||
* فرانت برایش پیامِ جدا دارد. بقیهی خطاها ممکن است خریدی باشند که پولش رفته، پس از بازار پرسیده میشوند.
|
||||
*/
|
||||
private fun failed(sku: String, orderId: String, cause: Throwable) {
|
||||
val reason = when (cause) {
|
||||
is BazaarNotFoundException -> "not_installed"
|
||||
is BazaarNotSupportedException, is SubsNotSupportedException, is IAPNotSupportedException,
|
||||
is DynamicPriceNotSupportedException -> "old_bazaar"
|
||||
else -> null
|
||||
}
|
||||
Log.e(TAG, "bazaar purchase failed: ${reason ?: cause.javaClass.simpleName}", cause)
|
||||
if (reason != null) send(orderId, error = "unavailable", message = reason)
|
||||
else recoverOwned(sku, orderId, cause)
|
||||
}
|
||||
|
||||
/**
|
||||
* اشتراکِ همین محصول که پولش رفته ولی تأییدِ سرور نرسیده بود جلوی خریدِ دوباره را میگیرد. فرانت در تلاشِ
|
||||
* بعدی همان سفارش را میفرستد؛ اگر اشتراکِ همان سفارش (payload) پیدا شد، توکنش برمیگردد تا سرور ثبتش کند.
|
||||
*/
|
||||
private fun recoverOwned(sku: String, orderId: String, cause: Throwable) {
|
||||
val p = payment ?: return send(orderId, error = "failed", message = cause.message)
|
||||
p.getPurchasedProducts {
|
||||
p.getSubscribedProducts {
|
||||
querySucceed { owned ->
|
||||
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
|
||||
if (match != null) send(orderId, token = match.purchaseToken)
|
||||
@@ -131,10 +281,31 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(purchaseToken: String) {
|
||||
payment?.consumeProduct(purchaseToken) {
|
||||
consumeSucceed { Log.i(TAG, "consumed") }
|
||||
consumeFailed { Log.e(TAG, "consume failed", it) }
|
||||
/**
|
||||
* قیمتِ ثبتشده در پیشخانِ بازار برای هر SKU (متنِ آمادهی نمایش، مثلِ «۸۹۸,۰۰۰ تومان»)، تا فرانت روی کاشیِ
|
||||
* بازار همان را نشان دهد. API سرور-به-سرورِ بازار قیمت نمیدهد؛ فقط Poolakey روی گوشی.
|
||||
* نتیجه: رویدادِ «tadrisino:bazaar-prices» با { prices: { sku: price } } یا { error }.
|
||||
*/
|
||||
private fun prices(skus: List<String>) {
|
||||
val p = payment ?: return sendPrices(error = "bazaar not connected")
|
||||
p.getSubscriptionSkuDetails(skus) {
|
||||
getSkuDetailsSucceed { list ->
|
||||
val map = JSONObject()
|
||||
list.forEach { map.put(it.sku, it.price) }
|
||||
sendPrices(prices = map)
|
||||
}
|
||||
getSkuDetailsFailed { sendPrices(error = it.message ?: "failed") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendPrices(prices: JSONObject? = null, error: String? = null) {
|
||||
val detail = JSONObject()
|
||||
if (prices != null) detail.put("prices", prices)
|
||||
if (error != null) detail.put("error", error)
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript(
|
||||
"window.dispatchEvent(new CustomEvent('tadrisino:bazaar-prices', { detail: $detail }));", null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,21 +323,68 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
/** متدهای این کلاس روی threadِ WebView صدا زده میشوند، نه UI. */
|
||||
private inner class Bridge {
|
||||
/** قراردادش در src/lib/bazaar.tsِ فرانت است. */
|
||||
@JavascriptInterface
|
||||
fun bazaarPurchase(sku: String, orderId: String) {
|
||||
fun bazaarBuy(sku: String, orderId: String, dynamicPriceToken: String) {
|
||||
runOnUiThread {
|
||||
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
|
||||
purchase(sku, orderId)
|
||||
buy(sku, orderId, dynamicPriceToken)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* لرزشِ کوتاهِ رابط (src/lib/sound.tsِ فرانت). `navigator.vibrate` در همهی WebViewها اجرا نمیشود؛
|
||||
* این مستقیم از Vibratorِ گوشی است. ورودی آرایهی JSON از میلیثانیهها: [لرزش، مکث، لرزش، …].
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun bazaarConsume(purchaseToken: String) {
|
||||
fun vibrate(patternJson: String) {
|
||||
val pattern = try {
|
||||
val arr = JSONArray(patternJson)
|
||||
LongArray(arr.length()) { i -> arr.getLong(i).coerceIn(0, 1000) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "bad vibrate pattern", e)
|
||||
return
|
||||
}
|
||||
if (pattern.isEmpty()) return
|
||||
val vibrator = if (Build.VERSION.SDK_INT >= 31) {
|
||||
(getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getSystemService(VIBRATOR_SERVICE) as Vibrator
|
||||
}
|
||||
// [لرزش، مکث، …] → قالبِ اندروید که با مکث شروع میشود.
|
||||
val timings = longArrayOf(0) + pattern
|
||||
if (Build.VERSION.SDK_INT >= 26) vibrator.vibrate(VibrationEffect.createWaveform(timings, -1))
|
||||
else @Suppress("DEPRECATION") vibrator.vibrate(timings, -1)
|
||||
}
|
||||
|
||||
/** فرانت اولین صفحهی واقعی را کشید؛ اسپلشِ سیستم میتواند برود. */
|
||||
@JavascriptInterface
|
||||
fun appReady() {
|
||||
webReady = true
|
||||
runOnUiThread { hideOffline() }
|
||||
}
|
||||
|
||||
/** skusJson آرایهی JSON از SKUهای اشتراک. */
|
||||
@JavascriptInterface
|
||||
fun bazaarPrices(skusJson: String) {
|
||||
val skus = try {
|
||||
val arr = JSONArray(skusJson)
|
||||
List(arr.length()) { i -> arr.getString(i) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "bad skus", e)
|
||||
return
|
||||
}
|
||||
runOnUiThread {
|
||||
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
|
||||
consume(purchaseToken)
|
||||
prices(skus)
|
||||
}
|
||||
}
|
||||
|
||||
/** فرانت با این میفهمد درگاه داخلِ اپ باز میشود (isPaymentPage)، نه در مرورگرِ بیرونی. */
|
||||
@JavascriptInterface
|
||||
fun paysInApp(): Boolean = true
|
||||
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -176,5 +394,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Tadrisino"
|
||||
private const val RETRY_MS = 5_000L
|
||||
private const val READY_WATCHDOG_MS = 15_000L
|
||||
}
|
||||
}
|
||||
|
||||
BIN
android/app/src/main/res/drawable-nodpi/splash_icon_art.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/splash_icon_art.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
8
android/app/src/main/res/drawable/splash_icon.xml
Normal file
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%" />
|
||||
@@ -4,4 +4,15 @@
|
||||
<item name="android:statusBarColor">#ED6956</item>
|
||||
<item name="android:windowBackground">@android:color/white</item>
|
||||
</style>
|
||||
|
||||
<!--
|
||||
اسپلشِ سیستم: آیکونِ اپ روی سفید، تا وقتی صفحهی وب خودش بگوید آماده است (appReady) روی صفحه میماند.
|
||||
پیش از این، اسپلش میآمد، بعد WebView سفیدِ خالی تا رسیدنِ HTML، بعد اسپلشِ وب، بعد خانه.
|
||||
سفید است چون windowBackgroundِ پایین هم سفید است و اسپلشِ وب (index.html) هم سفید است.
|
||||
-->
|
||||
<style name="Theme.Tadrisino.Starting" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@android:color/white</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.Tadrisino</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
18
deploy.sh
18
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,6 +50,13 @@ if [ -f dist/config.js ] && ! cmp -s dist/config.js public/config.js; then
|
||||
printf ' config.jsِ ویرایششدهی سرور نگه داشته شد.\n'
|
||||
fi
|
||||
|
||||
# فایلهای buildهای قبلی (نامشان hash دارد و با هم برخورد نمیکنند) تا ۳۰ روز میمانند: گوشیای که اپ از قبل
|
||||
# در آن باز است هنوز index.htmlِ قبلی را دارد و با باز کردنِ صفحهای که بار نشده همان نامها را میخواهد؛ بیاین
|
||||
# ۴۰۴ و صفحهی سفید میگرفت. cp -p زمانِ فایل را نگه میدارد، پس هر فایل ۳۰ روز بعد از buildِ خودش کنار میرود.
|
||||
if [ -d dist/assets ]; then
|
||||
find dist/assets -type f -mtime -30 -exec cp -p --update=none {} dist.next/assets/ \;
|
||||
fi
|
||||
|
||||
step "جابهجایی"
|
||||
rm -rf dist.prev
|
||||
[ -d dist ] && mv dist dist.prev
|
||||
|
||||
32
index.html
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
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
BIN
public/app-icon.png
Normal file
Binary file not shown.
|
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,6 +164,9 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
const baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||
const storage = options.storage ?? localTokenStorage;
|
||||
const defaultTimeout = options.timeoutMs ?? 15_000;
|
||||
// خواندنها (GET) تکرارپذیرند و کاربر پشتِ اسپلش منتظر است: ۸ ثانیه کافی است، بعدش تلاشِ دوباره یا پیامِ «اینترنت کند است».
|
||||
// نوشتنها (POST/PATCH) همان ۱۵ میمانند: قطعِ زودهنگام یعنی کاربر دوباره میزند و ممکن است ثبتِ دوباره شود.
|
||||
const readTimeout = options.readTimeoutMs ?? 8_000;
|
||||
const maxRetries = options.maxRetries ?? 2;
|
||||
|
||||
const inFlight = new Map<string, Promise<unknown>>();
|
||||
@@ -212,6 +220,8 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
if (!response.ok) return errorFrom(response, await parseBody(response));
|
||||
const data = (await response.json()) as Tokens;
|
||||
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
|
||||
// کاربر در همین فاصله خارج شد (یا حسابش را حذف کرد): توکنِ تازه نباید نشستِ پاکشده را روی گوشی زنده کند.
|
||||
if (!storage.get()) return 'invalid';
|
||||
storage.set(next);
|
||||
return next;
|
||||
} finally {
|
||||
@@ -229,16 +239,18 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
body: unknown,
|
||||
opts: RequestOptions,
|
||||
): Promise<Response> {
|
||||
// بدنهی تابعی سرِ هر ارسال از نو ساخته میشود (خروج: refreshTokenِ همان لحظه، نه نسخهی پیش از refresh).
|
||||
const payload = typeof body === 'function' ? (body as () => unknown)() : body;
|
||||
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (payload !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
|
||||
|
||||
const timer = timeoutSignal(opts.timeoutMs ?? defaultTimeout);
|
||||
const timer = timeoutSignal(opts.timeoutMs ?? (method === 'GET' ? readTimeout : defaultTimeout));
|
||||
try {
|
||||
return await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
body: payload === undefined ? undefined : JSON.stringify(payload),
|
||||
signal: opts.signal ? anySignal([timer, opts.signal]) : timer,
|
||||
// احرازِ هویت هدرِ Bearer است، نه کوکی؛ سرور هم CORS را بدونِ credentials دارد.
|
||||
credentials: 'omit',
|
||||
@@ -246,7 +258,7 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
} catch (err) {
|
||||
if (opts.signal?.aborted) throw err;
|
||||
const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
||||
throw new ApiError(0, timedOut ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد');
|
||||
throw new ApiError(0, timedOut ? 'زمان درخواست تمام شد' : 'ارتباط با سرور برقرار نشد', {}, timedOut);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,9 +269,13 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
body: unknown,
|
||||
opts: RequestOptions,
|
||||
): Promise<Response> {
|
||||
const hadToken = Boolean(storage.get()?.token);
|
||||
const sentToken = storage.get()?.token;
|
||||
let response = await send(method, path, body, opts);
|
||||
if (response.status === 401 && hadToken) {
|
||||
if (response.status === 401 && sentToken) {
|
||||
// درخواستی که با توکنِ کهنه رفته و ۴۰۱اش بعد از تمامِ refreshِ درخواستِ دیگری رسیده، نباید دوباره
|
||||
// refresh کند (refreshing آن موقع دیگر null است): توکنِ تازه در storage هست، فقط با آن دوباره بفرست.
|
||||
const latest = storage.get()?.token;
|
||||
if (latest && latest !== sentToken) return send(method, path, body, opts);
|
||||
const refreshed = await refreshTokens();
|
||||
if (refreshed === 'invalid') signOut();
|
||||
else if (refreshed instanceof ApiError) throw refreshed; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه
|
||||
|
||||
@@ -4,13 +4,26 @@
|
||||
* هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است.
|
||||
*/
|
||||
import { resolveApiBaseUrl } from '@/config';
|
||||
import { ApiError, createHttpClient, newIdempotencyKey } from './http';
|
||||
import { homeSnapshot } from '@/state/homeSnapshot';
|
||||
import { ApiError, createHttpClient, localTokenStorage, newIdempotencyKey, type TokenStorage } from './http';
|
||||
import type * as T from './types';
|
||||
|
||||
export { ApiError };
|
||||
export type * from './types';
|
||||
|
||||
export const http = createHttpClient({ baseUrl: resolveApiBaseUrl() });
|
||||
/**
|
||||
* پاک شدنِ توکن (خروج، حذفِ حساب، نشستِ باطل در http.signOut) عکسِ خانه را هم پاک میکند؛ هر سه از همین
|
||||
* `set(null)` میگذرند، پس راهی برای ماندنِ خانهی کاربرِ قبلی روی گوشیِ مشترک نمیماند.
|
||||
*/
|
||||
const tokenStorage: TokenStorage = {
|
||||
get: () => localTokenStorage.get(),
|
||||
set(tokens) {
|
||||
if (!tokens) homeSnapshot.clear();
|
||||
localTokenStorage.set(tokens);
|
||||
},
|
||||
};
|
||||
|
||||
export const http = createHttpClient({ baseUrl: resolveApiBaseUrl(), storage: tokenStorage });
|
||||
|
||||
const q = (params: Record<string, string | number | undefined>) => {
|
||||
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '');
|
||||
@@ -18,6 +31,8 @@ const q = (params: Record<string, string | number | undefined>) => {
|
||||
};
|
||||
|
||||
function saveTokens(data: T.AuthResponse) {
|
||||
// ورودِ تازه ممکن است کاربرِ دیگری باشد؛ عکسِ خانه فقط از پاسخِ سرورِ همین نشست ساخته میشود.
|
||||
homeSnapshot.clear();
|
||||
http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' });
|
||||
return data;
|
||||
}
|
||||
@@ -56,9 +71,14 @@ export const api = {
|
||||
|
||||
/** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */
|
||||
async logout() {
|
||||
// بدنه تابع است تا سرِ هر ارسال خوانده شود: اگر access token منقضی بوده، ۴۰۱ ← refresh ← تکرار، و
|
||||
// refresh token عوض شده است؛ فرستادنِ نسخهی قبلی نشستِ تازه را ۳۰ روز روی سرور باطلنشده میگذاشت.
|
||||
const body = () => {
|
||||
const refreshToken = http.storage.get()?.refreshToken;
|
||||
return refreshToken ? { refreshToken } : {};
|
||||
};
|
||||
try {
|
||||
await http.post('/auth/logout', refreshToken ? { refreshToken } : {});
|
||||
await http.post('/auth/logout', body);
|
||||
} finally {
|
||||
http.storage.set(null);
|
||||
}
|
||||
@@ -81,8 +101,11 @@ export const api = {
|
||||
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
|
||||
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
|
||||
|
||||
/** بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. */
|
||||
home: () => http.get<T.HomeResponse>('/home'),
|
||||
/**
|
||||
* بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. بدونِ subject اولین کتابِ پایه؛
|
||||
* کتابِ ناموجود در پایه ۴۰۰ میدهد (برای کلیدِ ذخیرهشدهی قدیمی از `fetchHome` استفاده کنید).
|
||||
*/
|
||||
home: (subject?: string) => http.get<T.HomeResponse>(`/home${q({ subject })}`),
|
||||
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
|
||||
|
||||
/**
|
||||
@@ -118,6 +141,17 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
/** لایک/دیسلایک و گزارشِ ایراد روی سوال و درس (۰ = پسگرفتنِ رأی). */
|
||||
feedback: {
|
||||
vote: (kind: 'question' | 'lesson', id: string, value: 1 | -1 | 0) =>
|
||||
http.post<{ value: number }>(`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/vote`, { value }),
|
||||
report: (kind: 'question' | 'lesson', id: string, reason: string, note?: string) =>
|
||||
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
|
||||
`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/report`,
|
||||
{ reason, ...(note ? { note } : {}) },
|
||||
),
|
||||
},
|
||||
|
||||
leaderboard: (period: T.LeaderboardPeriod) =>
|
||||
http.get<{ period: string; entries: T.LeaderboardEntry[] }>(`/leaderboard${q({ period })}`).then((r) => r.entries),
|
||||
|
||||
@@ -136,7 +170,11 @@ export const api = {
|
||||
* `questionId` به دستیار میگوید دانشآموز کدام سوال را غلط زده.
|
||||
*/
|
||||
async ask(
|
||||
input: { userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string },
|
||||
input: {
|
||||
userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string;
|
||||
/** نامِ کتاب («هندسه»)؛ برای پرسشِ بیدرس تا دستیار از کتابِ همان انتخابِ کاربر جواب بدهد. */
|
||||
subject?: string;
|
||||
},
|
||||
onDelta: (text: string) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T.AskDone> {
|
||||
@@ -182,6 +220,8 @@ export const api = {
|
||||
*/
|
||||
purchaseBazaar: (planId: string, idempotencyKey: string) =>
|
||||
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }),
|
||||
/** SKU، نوعِ محصول و (اگر تخفیف خورده) قیمتِ امضاشده برای بازار؛ بعد از آن کدِ تخفیف نمینشیند. */
|
||||
startBazaar: (orderId: string) => http.post<T.BazaarStartResponse>(`/payment/bazaar/orders/${seg(orderId)}/start`),
|
||||
/** توکنِ خریدِ Poolakey را سرور از خودِ بازار میسنجد؛ گفتهی اپ کافی نیست. */
|
||||
verifyBazaar: (orderId: string, purchaseToken: string) =>
|
||||
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),
|
||||
|
||||
@@ -46,7 +46,10 @@ export interface UserSummary {
|
||||
gradeId: number | null;
|
||||
xp: number;
|
||||
coins: number;
|
||||
streak: { current: number; longest: number };
|
||||
/**
|
||||
* activeToday: امروز تمرین شده (آتش روشن). lost: طولِ زنجیرهای که دیروز شکست (> ۰ یعنی «یخزده»).
|
||||
*/
|
||||
streak: { current: number; longest: number; activeToday: boolean; lost: number };
|
||||
subscription: string | null;
|
||||
}
|
||||
|
||||
@@ -79,7 +82,22 @@ export interface HomeStats {
|
||||
coins: number;
|
||||
}
|
||||
|
||||
/** یک کتابِ ریاضیِ پایه (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته). */
|
||||
export interface Subject {
|
||||
/** همان که به `api.home(subject)` داده میشود. */
|
||||
key: string;
|
||||
title: string;
|
||||
/** نامِ آیکنِ lucide. */
|
||||
icon: string;
|
||||
color: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface HomeResponse {
|
||||
/** کتابی که `units` از آن است؛ null فقط وقتی پایه فصلی ندارد. */
|
||||
subject: Subject | null;
|
||||
/** همهی کتابهای این پایه؛ نوارِ کتابها فقط با بیش از یکی نشان داده میشود. */
|
||||
subjects: Subject[];
|
||||
units: HomeUnit[];
|
||||
stats: HomeStats;
|
||||
}
|
||||
@@ -131,6 +149,8 @@ export interface Question {
|
||||
type: string;
|
||||
prompt: string;
|
||||
options: QuestionOption[];
|
||||
/** شکلِ سوال (همان قالبِ شکلِ درسها)؛ null یعنی شکل ندارد. */
|
||||
figure?: DiagramSpec | null;
|
||||
}
|
||||
|
||||
export interface NodeQuizQuestion extends Question {
|
||||
@@ -221,8 +241,6 @@ export interface AskDone {
|
||||
suggestedFollowUps: string[];
|
||||
/** سوالهای رایگانِ باقیِ امروز. */
|
||||
remainingQuota: number;
|
||||
/** اعتبارِ بستههای سوال؛ منقضی نمیشود. */
|
||||
credits?: number;
|
||||
usedChunks: UsedChunk[];
|
||||
diagram?: Diagram | null;
|
||||
}
|
||||
@@ -261,13 +279,7 @@ export interface Plan {
|
||||
badge: string;
|
||||
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
|
||||
aiDailyQuota: number;
|
||||
/** «pack» بستهی سوال؛ «subscription» اشتراکِ زمانی که همهی فصلها را باز میکند. */
|
||||
kind: 'pack' | 'subscription';
|
||||
/** بسته: سوالهایی که به اعتبار اضافه میشود. */
|
||||
questionCredits: number;
|
||||
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
|
||||
heartsDays: number;
|
||||
/** SKUِ همین بسته در کافهبازار؛ خالی یعنی داخلِ اپِ بازار خریدنی نیست. */
|
||||
/** SKUِ همین اشتراک در کافهبازار؛ خالی یعنی از بازار خریدنی نیست. */
|
||||
bazaarSku: string;
|
||||
}
|
||||
|
||||
@@ -278,7 +290,7 @@ export interface PlansResponse {
|
||||
}
|
||||
|
||||
export interface SubscriptionStatus {
|
||||
/** اشتراکِ زمانیِ فعال (یک/سه/ششماهه)، نه بسته. */
|
||||
/** اشتراکِ زمانیِ فعال (یک/سه/ششماهه). */
|
||||
active: boolean;
|
||||
/** نامِ پلنِ فعال؛ null بیاشتراک. */
|
||||
planTitle: string | null;
|
||||
@@ -288,17 +300,12 @@ export interface SubscriptionStatus {
|
||||
aiDailyQuota: number;
|
||||
planId: string | null;
|
||||
expiresAt: string | null;
|
||||
/** سوالهای خریدهشدهی باقیمانده. */
|
||||
questionCredits: number;
|
||||
/** پایانِ قلبِ نامحدودِ هدیهی بستهها؛ null یعنی ندارد. */
|
||||
unlimitedHeartsUntil: string | null;
|
||||
}
|
||||
|
||||
/** GET /ai/quota */
|
||||
export interface TutorQuota {
|
||||
freeDaily: number;
|
||||
remainingQuota: number;
|
||||
credits: number;
|
||||
}
|
||||
|
||||
export interface PurchaseResponse {
|
||||
@@ -309,6 +316,16 @@ export interface PurchaseResponse {
|
||||
bazaarSku?: string;
|
||||
}
|
||||
|
||||
/** POST /payment/bazaar/orders/{id}/start — بعد از کدِ تخفیف، درست پیش از صفحهی خریدِ بازار. */
|
||||
export interface BazaarStartResponse {
|
||||
orderId: string;
|
||||
sku: string;
|
||||
/** مبلغِ سفارش (تومان)، پس از کدِ تخفیف. */
|
||||
amount: number;
|
||||
/** JWTِ «تخفیف پویا»؛ null یعنی بازار قیمتِ پیشخان را میگیرد. */
|
||||
dynamicPriceToken: string | null;
|
||||
}
|
||||
|
||||
export interface BazaarVerifyResponse {
|
||||
orderId: string;
|
||||
status: string;
|
||||
|
||||
104
src/app/App.tsx
104
src/app/App.tsx
@@ -1,11 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AnimatePresence, MotionConfig, motion } from 'motion/react';
|
||||
import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
|
||||
import { TOKENS_STORAGE_KEY } from '@/api/http';
|
||||
import type { Prefetch } from '@/state/AppData';
|
||||
import { AppShell } from '@/components/AppShell';
|
||||
import { ToastProvider } from '@/components/Toast';
|
||||
import { ErrorState } from '@/components/ui';
|
||||
import { KEYS, store } from '@/lib/storage';
|
||||
import { askStore } from '@/state/askStore';
|
||||
import { homeSnapshot } from '@/state/homeSnapshot';
|
||||
import { fetchHome, subjectStore } from '@/state/subject';
|
||||
import { clearAsyncCache } from '@/lib/useAsync';
|
||||
import { LoginScreen } from '@/screens/LoginScreen';
|
||||
import { OnboardingScreen } from '@/screens/OnboardingScreen';
|
||||
import { SetupScreen } from '@/screens/SetupScreen';
|
||||
@@ -19,31 +24,46 @@ type Flow =
|
||||
| { kind: 'onboarding' }
|
||||
| { kind: 'login' }
|
||||
| { kind: 'setup'; profile: UserProfile; grades: Grade[] }
|
||||
| { kind: 'main'; profile: UserProfile; grades: Grade[] };
|
||||
|
||||
/** کمترین زمانِ نمایشِ اسپلش، تا صفحه چشمک نزند. */
|
||||
const SPLASH_MIN_MS = 700;
|
||||
| { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch; stale?: boolean };
|
||||
|
||||
/**
|
||||
* جریانِ ورود: اسپلش → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ.
|
||||
* خودِ اپ (تبها و صفحهها) در MainApp است.
|
||||
* جریانِ ورود: اسپلشِ ثابت (تا بررسیِ نشست) → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ.
|
||||
* کاربرِ واردشدهای که عکسِ خانه روی گوشی دارد (state/homeSnapshot.ts) بیانتظار به خانه میرود و
|
||||
* دادهها پشتِ صحنه تازه میشوند. خودِ اپ (تبها و صفحهها) در MainApp است.
|
||||
*/
|
||||
export function App() {
|
||||
const [flow, setFlow] = useState<Flow>({ kind: 'boot' });
|
||||
// با هر rebootFresh یکی بالا میرود و جزوِ کلیدِ صفحه است. بیآن «main → boot → main» برای AnimatePresence
|
||||
// همان کلید است و MainAppِ در حالِ خروج (با دادهی عکسِ غلط) بهجای MainAppِ تازه سرِ جایش میماند.
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
/** پروفایل و پایهها را میگیرد و تصمیم میگیرد کاربر کجا برود. */
|
||||
const enter = useCallback(async () => {
|
||||
const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]);
|
||||
// نقشه و آمار همزمان با پروفایل گرفته میشوند تا خانه کامل بیاید، نه اول «دارم مسیرت رو میارم» و آمارِ صفر.
|
||||
// شکستشان ورود را نمیخواباند: AppData همانها را دوباره میگیرد و خطای نقشه را روی خانه نشان میدهد.
|
||||
const profileReq = api.profile.get();
|
||||
const [profile, grades, prefetch] = await Promise.all([profileReq, api.grades(), prefetchMain(profileReq)]);
|
||||
await trackReferral();
|
||||
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades });
|
||||
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades, prefetch });
|
||||
else setFlow({ kind: 'setup', profile, grades });
|
||||
}, []);
|
||||
|
||||
const boot = useCallback(async () => {
|
||||
setFlow({ kind: 'boot' });
|
||||
const started = Date.now();
|
||||
let next: Flow | null = null;
|
||||
if (api.auth.isSignedIn()) {
|
||||
const snap = homeSnapshot.read();
|
||||
if (snap) {
|
||||
setFlow({
|
||||
kind: 'main',
|
||||
profile: snap.profile,
|
||||
grades: snap.grades,
|
||||
prefetch: { home: snap.home, summary: snap.summary },
|
||||
stale: true,
|
||||
});
|
||||
void trackReferral();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await enter();
|
||||
} catch (error) {
|
||||
@@ -55,8 +75,6 @@ export function App() {
|
||||
} else {
|
||||
next = store.get(KEYS.onboarded) ? { kind: 'login' } : { kind: 'onboarding' };
|
||||
}
|
||||
const wait = SPLASH_MIN_MS - (Date.now() - started);
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||
if (next) setFlow(next);
|
||||
}, [enter]);
|
||||
|
||||
@@ -64,16 +82,53 @@ export function App() {
|
||||
void boot();
|
||||
}, [boot]);
|
||||
|
||||
// داخلِ اپِ اندروید اسپلشِ سیستم تا این لحظه روی صفحه میماند؛ پس از اولین صفحهی واقعی (نه boot) میرود
|
||||
// و کاربر مستقیم همان صفحه را میبیند. ۳۰۰ms: تا محوشدنِ ورودِ صفحه تمام شود. APKِ قدیمیتر appReady ندارد.
|
||||
useEffect(() => {
|
||||
if (flow.kind === 'boot') return;
|
||||
const t = setTimeout(() => window.TadrisinoAndroid?.appReady?.(), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [flow.kind]);
|
||||
|
||||
// هر جا نشست باطل شد (refresh هم شکست خورد)، برگرد به صفحهی ورود.
|
||||
useEffect(
|
||||
() =>
|
||||
http.onSignedOut(() => {
|
||||
askStore.clear();
|
||||
clearAsyncCache();
|
||||
setFlow({ kind: 'login' });
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// عکسِ خانه مالِ کسِ دیگری بود یا پروفایل حالا ناقص است: عکس کنار میرود و بوتِ معمولی تصمیم میگیرد.
|
||||
const rebootFresh = useCallback(() => {
|
||||
homeSnapshot.clear();
|
||||
askStore.clear();
|
||||
clearAsyncCache();
|
||||
setGeneration((g) => g + 1);
|
||||
void boot();
|
||||
}, [boot]);
|
||||
|
||||
// تبِ دیگری از همین مرورگر خارج شد یا وارد شد (localStorage مشترک است). بیاین، تبِ خارجشده داخلِ اپ میماند و
|
||||
// هر درخواستش بیتوکن خطا میگرفت؛ تبِ بیرونمانده هم تا رفرش در صفحهی ورود میماند. تمدیدِ توکن (هر دو مقدار
|
||||
// پُر) کاری ندارد.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== TOKENS_STORAGE_KEY && e.key !== null) return;
|
||||
const signedIn = api.auth.isSignedIn();
|
||||
if (!signedIn && (e.key === null || e.oldValue)) {
|
||||
askStore.clear();
|
||||
clearAsyncCache();
|
||||
setFlow({ kind: 'login' });
|
||||
} else if (signedIn && e.key !== null && !e.oldValue) {
|
||||
rebootFresh();
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, [rebootFresh]);
|
||||
|
||||
const afterLogin = async () => {
|
||||
try {
|
||||
await enter();
|
||||
@@ -87,14 +142,15 @@ export function App() {
|
||||
<MotionConfig reducedMotion="user">
|
||||
<AppShell>
|
||||
<ToastProvider>
|
||||
<AnimatePresence mode="wait">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={flow.kind}
|
||||
key={`${flow.kind}:${generation}`}
|
||||
className="flex h-full flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
// اسپلش بیمحو: با عوضشدنِ صفحه فوراً میرود و صفحهی بعد خودش محو میشود.
|
||||
transition={{ duration: flow.kind === 'boot' ? 0 : 0.2 }}
|
||||
>
|
||||
{flow.kind === 'boot' && <SplashScreen />}
|
||||
{flow.kind === 'bootError' && (
|
||||
@@ -122,8 +178,14 @@ export function App() {
|
||||
<MainApp
|
||||
profile={flow.profile}
|
||||
grades={flow.grades}
|
||||
prefetch={flow.prefetch}
|
||||
stale={flow.stale}
|
||||
onIdentityChanged={rebootFresh}
|
||||
payment={launchPayment}
|
||||
onSignedOut={() => setFlow({ kind: 'login' })}
|
||||
onSignedOut={() => {
|
||||
clearAsyncCache();
|
||||
setFlow({ kind: 'login' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
@@ -134,6 +196,18 @@ export function App() {
|
||||
);
|
||||
}
|
||||
|
||||
/** دادهی صفحهی اول؛ هر کدام که نرسید null میماند و AppData خودش دوباره میگیرد. */
|
||||
async function prefetchMain(profile: Promise<UserProfile>): Promise<Prefetch> {
|
||||
// کتابِ انتخابشده به پایه بسته است؛ فقط وقتی چیزی ذخیره شده منتظرِ پروفایل میمانیم (پایههای ≤ دهم
|
||||
// معمولاً هیچ انتخابی ندارند و نقشه همزمان با پروفایل میآید).
|
||||
const gradeId = Object.keys(subjectStore.snapshot()).length ? (await profile).gradeId : null;
|
||||
const [home, summary] = await Promise.allSettled([fetchHome(gradeId), api.summary()]);
|
||||
return {
|
||||
home: home.status === 'fulfilled' ? home.value : null,
|
||||
summary: summary.status === 'fulfilled' ? summary.value : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** اگر کاربر با لینکِ دعوت آمده بود و حالا وارد شده، معرفی را ثبت کن. */
|
||||
async function trackReferral() {
|
||||
const code = store.get(KEYS.referralCode);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState, type ComponentType } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { api, type Grade, type UserProfile } from '@/api';
|
||||
import { AppDataProvider, useAppData } from '@/state/AppData';
|
||||
import { NavigatorProvider, useNav } from '@/nav/Navigator';
|
||||
import { AppDataProvider, useAppData, type Prefetch } from '@/state/AppData';
|
||||
import { NavigatorProvider, useNav, type NavDirection } from '@/nav/Navigator';
|
||||
import { ScreenGateContext, type ScreenGate } from '@/lib/useAsync';
|
||||
import { isTab, type Route } from '@/nav/routes';
|
||||
import { BottomNav } from '@/components/BottomNav';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
@@ -50,15 +51,25 @@ function preloadScreens() {
|
||||
interface Props {
|
||||
profile: UserProfile;
|
||||
grades: Grade[];
|
||||
prefetch?: Prefetch;
|
||||
/** دادهها از عکسِ خانه روی گوشی آمدهاند و پشتِ صحنه تازه میشوند (AppData). */
|
||||
stale?: boolean;
|
||||
onIdentityChanged?: () => void;
|
||||
payment: PaymentReturn | null;
|
||||
onSignedOut: () => void;
|
||||
}
|
||||
|
||||
/** اپِ اصلی بعد از ورود: تبها، صفحهها و دادهی مشترک. */
|
||||
export function MainApp({ profile, grades, payment, onSignedOut }: Props) {
|
||||
export function MainApp({ profile, grades, prefetch, stale, onIdentityChanged, payment, onSignedOut }: Props) {
|
||||
useEffect(preloadScreens, []);
|
||||
return (
|
||||
<AppDataProvider initialProfile={profile} grades={grades}>
|
||||
<AppDataProvider
|
||||
initialProfile={profile}
|
||||
grades={grades}
|
||||
prefetch={prefetch}
|
||||
stale={stale}
|
||||
onIdentityChanged={onIdentityChanged}
|
||||
>
|
||||
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
|
||||
<Screens onSignedOut={onSignedOut} />
|
||||
{payment && <PaymentResult payment={payment} />}
|
||||
@@ -67,50 +78,221 @@ export function MainApp({ profile, grades, payment, onSignedOut }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* صحنهی صفحهها. صفحهی تازه اول پنهان زیرِ صفحهی فعلی ساخته میشود و فقط وقتی دادهی اولش رسید نشان داده
|
||||
* میشود (ScreenGate در useAsync)، با یک حرکتِ کوتاه. قبلاً صفحه همان لحظه نیمهکاره میآمد (کارتِ خالی، «نداری»،
|
||||
* عددِ صفر) و چند صد میلیثانیه بعد دادهی واقعی جایش میپرید؛ همان «چشمک»ی که روی گوشی دیده میشد.
|
||||
*
|
||||
* اگر جواب کند باشد، بعد از HOLD_MAX_MS صفحه با لودرِ خودش میآید تا لمسِ کاربر بیجواب نماند؛ در این فاصله
|
||||
* نوارِ باریکِ بالا نشان میدهد که کاری در جریان است. عددها از راهنمای رایجِ پاسخگویی آمدهاند (زیرِ ~۱۰۰ms
|
||||
* «آنی»، تا ~۴۰۰ms هنوز پیوسته)؛ روی گوشیِ واقعی با شبکهی ضعیف سنجیده نشدهاند.
|
||||
*/
|
||||
const HOLD_MAX_MS = 450;
|
||||
const HOLD_BAR_AFTER_MS = 120;
|
||||
|
||||
interface Layer {
|
||||
id: number;
|
||||
route: Route;
|
||||
direction: NavDirection;
|
||||
/** ریشهی پشته است (تب)، نه صفحهای که روی آن باز شده؛ نوارِ پایین فقط آنجا هست. */
|
||||
root: boolean;
|
||||
shown: boolean;
|
||||
}
|
||||
|
||||
function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
|
||||
const nav = useNav();
|
||||
const { route } = nav;
|
||||
const showNav = isTab(route.name) && nav.tab === route.name;
|
||||
// صفحهی اولِ بعد از ورود بیانیمیشنِ ورود میآید، ولی این را روی خودِ motion.div میگذاریم نه
|
||||
// `<AnimatePresence initial={false}>`: آن یکی به همهی فرزندهای motionِ صفحهی اول هم سرایت میکند و
|
||||
// انیمیشنهای تکرارشوندهشان (ماسکوتِ بالاپایین، حلقهی جزیره، دکمهی «شروع») تا اولین تعویضِ تب شروع نمیشدند.
|
||||
const firstRender = useRef(true);
|
||||
const { route, direction, canGoBack } = nav;
|
||||
const nextId = useRef(1);
|
||||
// صفحهی اولِ بعد از ورود بیانتظار و بیحرکت میآید (دادهی خانه پیش از آن در اسپلش گرفته شده).
|
||||
const [layers, setLayers] = useState<Layer[]>(() => [{ id: 0, route, direction, root: true, shown: true }]);
|
||||
|
||||
useEffect(() => {
|
||||
firstRender.current = false;
|
||||
setLayers((prev) => {
|
||||
if (prev[prev.length - 1].route === route) return prev;
|
||||
// صفحهای که هنوز نیامده و کاربر از آن گذشته، همانجا کنار میرود.
|
||||
const visible = prev.filter((l) => l.shown);
|
||||
return [...visible, { id: nextId.current++, route, direction, root: !canGoBack, shown: false }];
|
||||
});
|
||||
}, [route, direction, canGoBack]);
|
||||
|
||||
const reveal = useCallback((id: number) => {
|
||||
setLayers((prev) => prev.map((l) => (l.id === id ? { ...l, shown: true } : l)));
|
||||
}, []);
|
||||
const settle = useCallback((id: number) => {
|
||||
// صفحهی تازه کامل آمد؛ هرچه زیرش است برداشته شود.
|
||||
setLayers((prev) => {
|
||||
const i = prev.findIndex((l) => l.id === id);
|
||||
return i > 0 ? prev.slice(i) : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const top = [...layers].reverse().find((l) => l.shown) ?? layers[0];
|
||||
|
||||
// زدنِ دوبارهی تبِ باز: بخشِ اسکرولشوندهی صفحهی دیدهشده نرم به بالا میرود.
|
||||
const stage = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!nav.reselect) return;
|
||||
const layer = stage.current?.querySelector<HTMLElement>(`[data-layer="${top.id}"]`);
|
||||
layer?.querySelectorAll<HTMLElement>('.overflow-y-auto').forEach((el) => el.scrollTo({ top: 0, behavior: 'smooth' }));
|
||||
// فقط با زدنِ دوباره، نه با عوض شدنِ لایه.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nav.reselect]);
|
||||
const waiting = !layers[layers.length - 1].shown;
|
||||
// نوارِ پایین از صفحهای که دیده میشود پیروی میکند، نه از صفحهای که هنوز پنهان است؛ وگرنه ارتفاعِ صفحهی
|
||||
// فعلی پیش از رفتن عوض میشد و میپرید. تبِ روشن ولی همان لحظهی لمس عوض میشود (بازخوردِ فوری).
|
||||
const showNav = isTab(top.route.name) && top.root;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex-1 overflow-hidden bg-page">
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.div
|
||||
key={JSON.stringify(route)}
|
||||
className="absolute inset-0 flex flex-col bg-page"
|
||||
initial={firstRender.current ? false : { opacity: 0, x: -14 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
// صفحهی قبلی نمیپرد و محو نمیشود، زیرِ صفحهی تازه میماند تا کامل بیاید. با محوشدنِ همزمانِ
|
||||
// هر دو، پسزمینهی تیرهی body از وسط دیده میشد و تعویضِ تب «گلیچ» میزد (از روی کد؛ با
|
||||
// فیلمِ دستگاهِ واقعی سنجیده نشده).
|
||||
exit={{ opacity: 1, x: 0, transition: { duration: 0.16 } }}
|
||||
transition={{ duration: 0.16, ease: 'easeOut' }}
|
||||
>
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<RouteView route={route} onSignedOut={onSignedOut} />
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<div ref={stage} className="relative flex-1 overflow-hidden bg-page">
|
||||
{layers.map((layer) => (
|
||||
<ScreenLayer key={layer.id} layer={layer} onReady={reveal} onSettled={settle} onSignedOut={onSignedOut} />
|
||||
))}
|
||||
<HoldBar active={waiting} />
|
||||
</div>
|
||||
{showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ENTER: Record<NavDirection, { opacity: number; x: number }> = {
|
||||
// در راستبهچپ صفحهی بعدی از چپ میآید و برگشت از راست؛ تعویضِ تب فقط محو است، مثلِ اپهای بومی.
|
||||
push: { opacity: 0, x: -28 },
|
||||
replace: { opacity: 0, x: -28 },
|
||||
back: { opacity: 0, x: 28 },
|
||||
tab: { opacity: 0, x: 0 },
|
||||
};
|
||||
|
||||
function ScreenLayer({ layer, onReady, onSettled, onSignedOut }: {
|
||||
layer: Layer;
|
||||
onReady: (id: number) => void;
|
||||
onSettled: (id: number) => void;
|
||||
} & Pick<Props, 'onSignedOut'>) {
|
||||
const { id, shown } = layer;
|
||||
const initiallyShown = useRef(shown).current;
|
||||
const gate = useGate(initiallyShown ? null : () => onReady(id));
|
||||
// اگر onAnimationComplete نرسید (مثلاً «کاهشِ حرکت» یا قطعِ انیمیشن)، صفحههای زیرین باز هم برداشته شوند.
|
||||
useEffect(() => {
|
||||
if (!shown || initiallyShown) return;
|
||||
const t = setTimeout(() => onSettled(id), 700);
|
||||
return () => clearTimeout(t);
|
||||
}, [shown, initiallyShown, id, onSettled]);
|
||||
|
||||
return (
|
||||
<ScreenGateContext.Provider value={gate}>
|
||||
<motion.div
|
||||
className="absolute inset-0 flex flex-col bg-page"
|
||||
initial={initiallyShown ? false : ENTER[layer.direction]}
|
||||
animate={shown ? { opacity: 1, x: 0 } : ENTER[layer.direction]}
|
||||
transition={{ duration: layer.direction === 'tab' ? 0.14 : 0.2, ease: [0.2, 0.8, 0.2, 1] }}
|
||||
onAnimationComplete={() => shown && onSettled(id)}
|
||||
data-layer={id}
|
||||
style={{ pointerEvents: shown ? 'auto' : 'none', zIndex: id }}
|
||||
aria-hidden={!shown}
|
||||
// صفحهی پنهان نباید با Tab صفحهکلید فوکوس بگیرد. (React 18 «inert» را فقط بهصورتِ رشته رد میکند.)
|
||||
{...((!shown ? { inert: '' } : {}) as object)}
|
||||
>
|
||||
<Suspense fallback={<SuspenseHold />}>
|
||||
<RouteView route={layer.route} onSignedOut={onSignedOut} />
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
</ScreenGateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* شمارندهی holdهای صفحه. وقتی بعد از mount هیچ holdی باز نماند، یا HOLD_MAX_MS گذشت، onReady یک بار صدا
|
||||
* زده میشود. React افکتهای فرزند را پیش از پدر اجرا میکند، پس تا افکتِ اینجا همهی useAsyncهای صفحه
|
||||
* hold گرفتهاند.
|
||||
*/
|
||||
function useGate(onReady: (() => void) | null): ScreenGate | null {
|
||||
const readyRef = useRef(onReady);
|
||||
readyRef.current = onReady;
|
||||
const state = useRef({ count: 0, mounted: false, done: onReady === null });
|
||||
const fire = useCallback(() => {
|
||||
const s = state.current;
|
||||
if (s.done) return;
|
||||
s.done = true;
|
||||
readyRef.current?.();
|
||||
}, []);
|
||||
const gate = useMemo<ScreenGate | null>(
|
||||
() =>
|
||||
onReady === null
|
||||
? null
|
||||
: {
|
||||
hold() {
|
||||
const s = state.current;
|
||||
if (s.done) return () => {};
|
||||
s.count++;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
s.count--;
|
||||
// یک microtask صبر: در StrictMode (و جابهجاییِ Suspense) افکتها پاک و دوباره اجرا میشوند و
|
||||
// hold تازه بلافاصله گرفته میشود؛ بیاین صبر دروازه پیش از رسیدنِ داده باز میشد.
|
||||
queueMicrotask(() => {
|
||||
if (s.mounted && s.count === 0) fire();
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
// فقط بارِ اول؛ لایهای که یک بار نشان داده شد دیگر دروازه ندارد.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const s = state.current;
|
||||
if (s.done) return;
|
||||
s.mounted = true;
|
||||
if (s.count === 0) {
|
||||
fire();
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(fire, HOLD_MAX_MS);
|
||||
return () => {
|
||||
s.mounted = false;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [fire]);
|
||||
return gate;
|
||||
}
|
||||
|
||||
/** تا فایلِ صفحه از شبکه نرسیده، دروازه بسته میماند (معمولاً از قبل در پسزمینه گرفته شده). */
|
||||
function SuspenseHold() {
|
||||
const gate = useContext(ScreenGateContext);
|
||||
useEffect(() => {
|
||||
const release = gate?.hold();
|
||||
// آزاد کردن خودش یک microtask صبر میکند (useGate)، پس useAsyncهای صفحهی تازه پیش از آن hold گرفتهاند.
|
||||
return () => release?.();
|
||||
}, [gate]);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
/** نوارِ باریکِ بالای صفحه وقتی صفحهی بعدی بیش از HOLD_BAR_AFTER_MS منتظرِ داده است. */
|
||||
function HoldBar({ active }: { active: boolean }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setVisible(true), HOLD_BAR_AFTER_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [active]);
|
||||
return (
|
||||
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 z-[9999] h-[3px] overflow-hidden">
|
||||
{visible && <div className="hold-bar h-full w-1/3 rounded-full bg-white/90 shadow-[0_0_6px_rgba(237,105,86,.8)]" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
|
||||
switch (route.name) {
|
||||
case 'home': return <HomeScreen />;
|
||||
case 'lessons': return <LessonsScreen />;
|
||||
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
|
||||
case 'subscription': return <SubscriptionScreen key={route.view ?? ''} initialView={route.view} />;
|
||||
case 'subscription': return <SubscriptionScreen />;
|
||||
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
|
||||
case 'unit': return <UnitScreen unitId={route.unitId} />;
|
||||
case 'lesson': return <LessonScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
|
||||
@@ -130,20 +312,45 @@ function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSig
|
||||
* نتیجهی برگشت از درگاه. فقط پاسخِ سرور تعیین میکند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر
|
||||
* لینکِ ساختگی میتواند «paid» بگذارد.
|
||||
*/
|
||||
/** سرور سفارشِ pending را دقیقهای یک بار از درگاه میپرسد؛ ۴ بار با فاصلهی ۲۰ ثانیه حدودِ یک دقیقه است. */
|
||||
const PENDING_CHECKS = 4;
|
||||
const PENDING_CHECK_MS = 20_000;
|
||||
|
||||
function PaymentResult({ payment }: { payment: PaymentReturn }) {
|
||||
const { refreshSummary } = useAppData();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking');
|
||||
|
||||
useEffect(() => {
|
||||
// pending یعنی تأییدِ درگاه هنوز نرسیده (مثلاً verify هنگامِ برگشت جواب نداد)، نه «پرداخت نشد»: سرور با هر
|
||||
// خواندنِ وضعیت دوباره از زرینپال میپرسد (حداکثر دقیقهای یک بار)، پس چند بار با فاصله میپرسیم.
|
||||
let alive = true;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const check = (attempt: number) => {
|
||||
api.subscription
|
||||
.order(payment.orderId)
|
||||
.then((order) => setState(order.status === 'paid' ? 'paid' : 'failed'))
|
||||
.then((order) => {
|
||||
if (!alive) return;
|
||||
if (order.status === 'paid') {
|
||||
setState('paid');
|
||||
void refreshSummary();
|
||||
} else if (order.status === 'pending' && attempt < PENDING_CHECKS) {
|
||||
timer = setTimeout(() => check(attempt + 1), PENDING_CHECK_MS);
|
||||
} else {
|
||||
setState(order.status === 'pending' ? 'unknown' : 'failed');
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn('payment result lookup failed', err);
|
||||
setState('unknown');
|
||||
if (alive) setState('unknown');
|
||||
});
|
||||
};
|
||||
check(1);
|
||||
void refreshSummary();
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [payment.orderId, refreshSummary]);
|
||||
|
||||
if (state === 'checking') {
|
||||
@@ -159,14 +366,14 @@ function PaymentResult({ payment }: { payment: PaymentReturn }) {
|
||||
<div className="flex flex-col items-center gap-3 pb-2 text-center">
|
||||
{paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />}
|
||||
<h2 className="text-lg font-black text-ink">
|
||||
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیتِ پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
|
||||
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیت پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
|
||||
</h2>
|
||||
<p className="text-sm leading-7 text-muted">
|
||||
{paid
|
||||
? 'اشتراکت فعال شد؛ از الان قلبهات نامحدوده.'
|
||||
: state === 'unknown'
|
||||
? 'چند دقیقهی دیگه از «پرداختها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال میشه یا پول خودکار برمیگرده.'
|
||||
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمیگرده یا اشتراکت فعال میشه.'}
|
||||
? 'چند دقیقه دیگه از «پرداختها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال میشه یا پول خودکار برمیگرده.'
|
||||
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طی چند ساعت خودکار برمیگرده یا اشتراکت فعال میشه.'}
|
||||
</p>
|
||||
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
|
||||
</div>
|
||||
|
||||
BIN
src/assets/pay/bazaar.png
Normal file
BIN
src/assets/pay/bazaar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/pay/zarinpal.png
Normal file
BIN
src/assets/pay/zarinpal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -102,15 +102,15 @@ export function BirthDatePicker({
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_1.5fr_1.3fr] gap-2">
|
||||
<select aria-label="روزِ تولد" value={d} onChange={(e) => setD(e.target.value ? +e.target.value : '')} className={`${select} ${tone(d)}`}>
|
||||
<select aria-label="روز تولد" value={d} onChange={(e) => setD(e.target.value ? +e.target.value : '')} className={`${select} ${tone(d)}`}>
|
||||
<option value="">روز</option>
|
||||
{Array.from({ length: days }, (_, i) => i + 1).map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
|
||||
</select>
|
||||
<select aria-label="ماهِ تولد" value={m} onChange={(e) => setM(e.target.value ? +e.target.value : '')} className={`${select} ${tone(m)}`}>
|
||||
<select aria-label="ماه تولد" value={m} onChange={(e) => setM(e.target.value ? +e.target.value : '')} className={`${select} ${tone(m)}`}>
|
||||
<option value="">ماه</option>
|
||||
{JALALI_MONTHS.map((name, i) => <option key={name} value={i + 1}>{name}</option>)}
|
||||
</select>
|
||||
<select aria-label="سالِ تولد" value={y} onChange={(e) => setY(e.target.value ? +e.target.value : '')} className={`${select} ${tone(y)}`}>
|
||||
<select aria-label="سال تولد" value={y} onChange={(e) => setY(e.target.value ? +e.target.value : '')} className={`${select} ${tone(y)}`}>
|
||||
<option value="">سال</option>
|
||||
{years.map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { sounds } from '@/lib/sound';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
import { onWhite } from '@/lib/color';
|
||||
import { Brain, ShoppingBag, Home, BookOpen, UserRound, type LucideIcon } from 'lucide-react';
|
||||
import type { TabName } from '@/nav/routes';
|
||||
|
||||
@@ -5,11 +8,13 @@ const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
|
||||
{ id: 'home', icon: Home, label: 'خانه' },
|
||||
{ id: 'lessons', icon: BookOpen, label: 'درسها' },
|
||||
{ id: 'ask', icon: Brain, label: 'دستیار' },
|
||||
{ id: 'subscription', icon: ShoppingBag, label: 'بستهها' },
|
||||
{ id: 'subscription', icon: ShoppingBag, label: 'اشتراک' },
|
||||
{ id: 'profile', icon: UserRound, label: 'من' },
|
||||
];
|
||||
|
||||
export function BottomNav({ active, onChange }: { active: TabName | null; onChange: (tab: TabName) => void }) {
|
||||
// تبِ روشن به رنگِ همراهِ انتخابی (تیرهشده تا روی سفید خوانا بماند؛ امگای طلایی بیآن کمرنگ بود).
|
||||
const tint = onWhite(useCompanion().color);
|
||||
return (
|
||||
<nav className="flex flex-shrink-0 items-center justify-around border-t border-line bg-white px-2 pb-safe pt-1.5">
|
||||
{ITEMS.map(({ id, icon: Icon, label }) => {
|
||||
@@ -18,11 +23,15 @@ export function BottomNav({ active, onChange }: { active: TabName | null; onChan
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
onClick={() => {
|
||||
sounds.tap();
|
||||
onChange(id);
|
||||
}}
|
||||
aria-current={on ? 'page' : undefined}
|
||||
className={`flex min-w-14 flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-[color,transform] active:scale-90 ${on ? 'text-coral' : 'text-muted'}`}
|
||||
className={`flex min-w-14 flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-[color,transform] active:scale-90 ${on ? '' : 'text-muted'}`}
|
||||
style={on ? { color: tint } : undefined}
|
||||
>
|
||||
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
|
||||
<span className="rounded-xl p-1.5 transition-colors" style={on ? { backgroundColor: `${tint}1F` } : undefined}>
|
||||
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
|
||||
</span>
|
||||
<span className="text-[11px] font-bold">{label}</span>
|
||||
|
||||
@@ -6,8 +6,8 @@ export function BrandFooter() {
|
||||
return (
|
||||
<footer className="flex flex-col items-center gap-1 pb-4 pt-3 text-center">
|
||||
<BrandMark size={24} tone="dark" />
|
||||
<span className="text-[10px] text-muted">ریاضی، قدمبهقدم، از روی کتابِ درسی</span>
|
||||
<span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو · همهی حقوق محفوظ است</span>
|
||||
<span className="text-[10px] text-muted">ریاضی، قدمبهقدم، از روی کتاب درسی</span>
|
||||
<span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو ریاضی · همه حقوق محفوظ است</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import { useId } from 'react';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
|
||||
/**
|
||||
* نشانِ تدریسینو: یک کاشیِ گرد با «π» و جرقهی کوچکِ چشمکزن، کنارِ نامِ تدریسینو.
|
||||
* خودِ SVG است (بیفایلِ تصویر)، پس در هر اندازه تیز است و چیزی دانلود نمیشود.
|
||||
* نشانِ تدریسینو: یک کاشیِ گرد با نمادِ همراهِ انتخابیِ کاربر (Σ، Δ، π، ω) به رنگِ همان همراه، و جرقهی کوچکِ
|
||||
* چشمکزن، کنارِ نامِ تدریسینو. پیشتر همیشه «π»ِ نارنجی بود و با همراهِ کاربر جور نبود (خواستِ مالک، ۱۴۰۵-۰۷).
|
||||
* خودِ SVG است (بیفایلِ تصویر).
|
||||
*/
|
||||
export function BrandMark({ size = 28, tone = 'light', withName = true }: { size?: number; tone?: 'light' | 'dark'; withName?: boolean }) {
|
||||
const ink = tone === 'light' ? '#fff' : '#3C3F8F';
|
||||
const c = useCompanion();
|
||||
const gid = useId();
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5" aria-label="تدریسینو">
|
||||
<span className="inline-flex items-center gap-1.5" aria-label="تدریسینو ریاضی">
|
||||
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="bm-g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor="#FF9A5C" />
|
||||
<stop offset="1" stopColor="#ED6956" />
|
||||
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor={c.light} />
|
||||
<stop offset="1" stopColor={c.color} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="1" y="1" width="30" height="30" rx="9" fill="url(#bm-g)" stroke={tone === 'light' ? 'rgba(255,255,255,.55)' : 'none'} />
|
||||
<path d="M9 11.5h14M12.5 11.5v10M19 11.5v8.2c0 1.2.7 1.8 1.8 1.8" stroke="#fff" strokeWidth="2.6" strokeLinecap="round" fill="none" />
|
||||
<rect x="1" y="1" width="30" height="30" rx="9" fill={`url(#${gid})`} stroke={tone === 'light' ? 'rgba(255,255,255,.55)' : 'none'} />
|
||||
<text x="16" y="17" textAnchor="middle" dominantBaseline="central" fill="#fff" fontSize="19" fontWeight="900" fontFamily="Vazirmatn, system-ui, sans-serif">
|
||||
{c.symbol}
|
||||
</text>
|
||||
<path className="spark" d="M25 4.5l.9 1.9 1.9.9-1.9.9-.9 1.9-.9-1.9-1.9-.9 1.9-.9z" fill="#FFD23F" />
|
||||
</svg>
|
||||
{withName && <span className="font-black tracking-tight" style={{ color: ink, fontSize: size * 0.62 }}>تدریسینو</span>}
|
||||
{withName && <span className="font-black tracking-tight" style={{ color: ink, fontSize: size * 0.62 }}>تدریسینو ریاضی</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
131
src/components/FeedbackBar.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { forwardRef, memo, useMemo } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Check, Lock, Play, Star } from 'lucide-react';
|
||||
import type { HomeNode } from '@/api';
|
||||
@@ -6,6 +6,9 @@ import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { shade } from '@/lib/color';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { useFirstVisit } from '@/lib/firstVisit';
|
||||
import { SEYAR_SVG, type SeyarChar, type SeyarMood } from '@/mascots/seyar';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
|
||||
const NODE_SIZE = 76;
|
||||
const SPACING = 128;
|
||||
@@ -25,12 +28,18 @@ export const IslandPath = forwardRef<HTMLDivElement, {
|
||||
onOpen: (node: HomeNode) => void;
|
||||
onStart: (node: HomeNode) => void;
|
||||
}>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) {
|
||||
const points = zigzagPositions(nodes.length, SPACING, TOP);
|
||||
const points = useMemo(() => zigzagPositions(nodes.length, SPACING, TOP), [nodes.length]);
|
||||
const intro = useFirstVisit(`islands:${nodes[0]?.id ?? ''}`);
|
||||
const height = points.length ? points[points.length - 1].y + 80 : 120;
|
||||
const { char } = useCompanion();
|
||||
const buddies = useMemo(() => buddySpots(nodes, points, currentId), [nodes, points, currentId]);
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
|
||||
<ZigzagPath points={points} height={height} colorOf={(i) => (nodes[i].status === 'completed' ? color : null)} />
|
||||
{buddies.map((b) => (
|
||||
<PathBuddy key={b.segment} char={char} {...b} />
|
||||
))}
|
||||
{nodes.map((node, i) => {
|
||||
const locked = node.status === 'locked';
|
||||
const done = node.status === 'completed';
|
||||
@@ -40,7 +49,7 @@ export const IslandPath = forwardRef<HTMLDivElement, {
|
||||
<motion.div
|
||||
key={node.id}
|
||||
ref={current ? currentRef : undefined}
|
||||
initial={{ opacity: 0, scale: 0.7 }}
|
||||
initial={intro ? { opacity: 0, scale: 0.7 } : { opacity: 1, scale: 1 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: Math.min(i, 8) * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
@@ -98,7 +107,7 @@ export const IslandPath = forwardRef<HTMLDivElement, {
|
||||
points[i].x >= 50 ? 'right-full mr-3 text-right' : 'left-full ml-3 text-left'
|
||||
} ${current ? 'text-[12px] text-ink' : locked ? 'text-faint' : 'text-muted'}`}
|
||||
>
|
||||
<span className="line-clamp-2"><MathText text={node.title} /></span>
|
||||
<span className="line-clamp-3"><MathText text={node.title} /></span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
@@ -106,3 +115,75 @@ export const IslandPath = forwardRef<HTMLDivElement, {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface BuddySpot {
|
||||
segment: number;
|
||||
x: number;
|
||||
y: number;
|
||||
mood: SeyarMood;
|
||||
current: boolean;
|
||||
dim: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* جای همراههای کنارِ مسیر: وسطِ یک پیچ (بینِ دو جزیره)، در سمتی که مسیر از آن دور است. در آن ارتفاع نه اسمِ جزیرهای
|
||||
* هست (اسمها همترازِ خودِ جزیرهاند، ±۲۴ پیکسل) نه دکمهی «شروع» (بالای جزیرهی فعلی). همراهِ اصلی زیرِ جزیرهی فعلی
|
||||
* تشویق میکند؛ بقیه هر سه پیچ یکی، تا مسیر شلوغ نشود. فاصلهها با SPACING = ۱۲۸ و اندازهی ≤ ۶۴ سنجیده شدهاند، نه با
|
||||
* عرضِ صفحهی کمتر از ۳۲۰ پیکسل.
|
||||
*/
|
||||
function buddySpots(nodes: HomeNode[], points: { x: number; y: number }[], currentId: string | null): BuddySpot[] {
|
||||
if (points.length < 2) return [];
|
||||
const currentIndex = nodes.findIndex((n) => n.id === currentId);
|
||||
const mainSegment = currentIndex < 0 ? -1 : Math.min(currentIndex, points.length - 2);
|
||||
const spots: BuddySpot[] = [];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const main = i === mainSegment;
|
||||
if (!main && (i % 3 !== 1 || Math.abs(i - mainSegment) <= 1)) continue;
|
||||
const midX = (points[i].x + points[i + 1].x) / 2;
|
||||
const x = Math.min(84, Math.max(16, midX >= 50 ? midX - 34 : midX + 34));
|
||||
const locked = nodes[i + 1].status === 'locked' && !main;
|
||||
spots.push({
|
||||
segment: i,
|
||||
x,
|
||||
y: (points[i].y + points[i + 1].y) / 2,
|
||||
mood: main ? 'excited' : locked ? 'idle' : 'happy',
|
||||
current: main,
|
||||
dim: locked,
|
||||
});
|
||||
}
|
||||
return spots;
|
||||
}
|
||||
|
||||
/** همراهِ انتخابیِ کاربر کنارِ مسیر؛ فقط تزئین است و لمس را به جزیرهها رد میکند. */
|
||||
const PathBuddy = memo(function PathBuddy({ char, segment, x, y, mood, current, dim }: BuddySpot & { char: SeyarChar }) {
|
||||
const size = current ? 62 : 46;
|
||||
const svg = useMemo(() => SEYAR_SVG[char](mood), [char, mood]);
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ left: `${x}%`, top: y, opacity: dim ? 0.5 : 1 }}
|
||||
>
|
||||
{current && (
|
||||
<>
|
||||
<Sparkle className="spark absolute -right-2 top-0" />
|
||||
<Sparkle className="spark absolute -left-3 top-5" style={{ animationDelay: '0.8s' }} />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={current ? 'buddy-hop' : 'buddy-sway'}
|
||||
style={{ width: size, height: size, lineHeight: 0, animationDelay: `${(segment % 4) * 0.35}s` }}
|
||||
// SVG از seyar.ts ساخته میشود و هیچ ورودیِ کاربر در آن نیست.
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Sparkle({ className, style }: { className: string; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" className={className} style={style}>
|
||||
<path d="M12 0 L14.5 9.5 L24 12 L14.5 14.5 L12 24 L9.5 14.5 L0 12 L9.5 9.5 Z" fill="#FFC53D" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,18 +48,18 @@ export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; c
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
|
||||
<Lock size={24} className="text-muted" />
|
||||
</span>
|
||||
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیرهی قبلی رو تموم کن تا باز بشه.</p>
|
||||
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره قبلی رو تموم کن تا باز بشه.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-sm leading-7 text-body">
|
||||
{done
|
||||
? 'این جزیره رو تموم کردی! میتونی دوباره تمرینش کنی (امتیازِ تازه نداره).'
|
||||
: 'اگه مطمئن نیستی، اول درسها رو بخون؛ بعد آزمونِ کوتاهِ جزیره رو بده. هر جوابی بدی، آخرش جزیرهی بعدی باز میشه.'}
|
||||
? 'این جزیره رو تموم کردی! میتونی دوباره تمرینش کنی (امتیاز تازه نداره).'
|
||||
: 'اگه مطمئن نیستی، اول درسها رو بخون؛ بعد آزمون کوتاه جزیره رو بده. هر جوابی بدی، آخرش جزیره بعدی باز میشه.'}
|
||||
</p>
|
||||
|
||||
<PrimaryButton color={color} onClick={start}>
|
||||
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروعِ آزمونِ جزیره</>}
|
||||
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروع آزمون جزیره</>}
|
||||
</PrimaryButton>
|
||||
<button
|
||||
type="button"
|
||||
@@ -68,7 +68,7 @@ export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; c
|
||||
style={{ borderColor: '#39FF14', color: '#15803d', backgroundColor: showLessons ? '#39FF1422' : 'white' }}
|
||||
aria-expanded={showLessons}
|
||||
>
|
||||
<BookOpen size={18} /> {done ? 'مرورِ درسها' : 'اول درسها رو بخون'}
|
||||
<BookOpen size={18} /> {done ? 'مرور درسها' : 'اول درسها رو بخون'}
|
||||
<ChevronDown size={16} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Brain, Heart, HeartCrack, Infinity as InfinityIcon, ShoppingBag, X } from 'lucide-react';
|
||||
import { Brain, Heart, HeartCrack, Infinity as InfinityIcon, Crown, X } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { PrimaryButton, Screen } from '@/components/ui';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
|
||||
/**
|
||||
* «قلبهات تموم شد»: با قلبِ صفر سرور هیچ جوابی نمیپذیرد (۴۰۳ با code=no_hearts) و پایانِ درس یا
|
||||
@@ -43,9 +44,12 @@ function whenLabel(iso: string, now: number): string {
|
||||
const dayOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const days = Math.round((dayOf(at) - dayOf(new Date(now))) / 86_400_000);
|
||||
const day = days <= 0 ? 'امروز' : days === 1 ? 'فردا' : new Intl.DateTimeFormat('fa-IR', { weekday: 'long' }).format(at);
|
||||
return `ساعتِ ${time} ${day}`;
|
||||
return `ساعت ${time} ${day}`;
|
||||
}
|
||||
|
||||
/** سرور هر بار regen را خودش حساب میکند؛ پرسیدنِ هر ۱۵ ثانیه برای یک صفحهی منتظر بار نیست. */
|
||||
const DUE_RECHECK_MS = 15_000;
|
||||
|
||||
export function NoHeartsScreen({
|
||||
nextRefillAt,
|
||||
fullRefillAt,
|
||||
@@ -56,6 +60,7 @@ export function NoHeartsScreen({
|
||||
/** قلب برگشت و کاربر «ادامه بده» زد. */
|
||||
onRefilled: () => void;
|
||||
}) {
|
||||
const companionName = useCompanion().name;
|
||||
const nav = useNav();
|
||||
const { companion } = useAppData();
|
||||
const now = useNow();
|
||||
@@ -79,10 +84,14 @@ export function NoHeartsScreen({
|
||||
setNext(h.nextRefillAt);
|
||||
setFull(h.fullRefillAt);
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
}).catch((err: unknown) => console.warn('hearts check failed', err));
|
||||
void check();
|
||||
// ساعتِ گوشیِ جلوتر از سرور: سرور هنوز «صفر» و همان nextRefillAt را میدهد و `due` عوض نمیشود؛ بیتکرار،
|
||||
// صفحه روی شمارشِ صفر میماند تا کاربر بیرون برود. پس تا برگشتنِ قلب با فاصله دوباره میپرسیم.
|
||||
const timer = setInterval(() => void check(), DUE_RECHECK_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [due, back]);
|
||||
|
||||
@@ -111,7 +120,7 @@ export function NoHeartsScreen({
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-ink">{back ? 'یه قلب برگشت!' : 'قلبهات تموم شد'}</h1>
|
||||
<p className="mt-1.5 text-sm leading-7 text-muted">
|
||||
{back ? 'بریم ادامه بدیم؛ این بار با دقتِ بیشتر' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'}
|
||||
{back ? 'بریم ادامه بدیم؛ این بار با دقت بیشتر' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +132,7 @@ export function NoHeartsScreen({
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full rounded-3xl border border-line bg-white p-5 shadow-sm">
|
||||
<div className="text-xs font-bold text-muted">قلبِ بعدی</div>
|
||||
<div className="text-xs font-bold text-muted">قلب بعدی</div>
|
||||
{left !== null ? (
|
||||
<>
|
||||
<div dir="ltr" className="mt-1 text-4xl font-black tracking-wide text-coral tabular-nums">{clock(left)}</div>
|
||||
@@ -136,7 +145,7 @@ export function NoHeartsScreen({
|
||||
</div>
|
||||
{full && (
|
||||
<p className="mt-3 text-xs leading-6 text-muted">
|
||||
همهی {toFa(MAX_HEARTS)} قلب: <b className="text-body">{whenLabel(full, now)}</b>
|
||||
همه {toFa(MAX_HEARTS)} قلب: <b className="text-body">{whenLabel(full, now)}</b>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -158,10 +167,10 @@ export function NoHeartsScreen({
|
||||
<Heart size={18} fill="#fff" /> <InfinityIcon size={18} /> نمیخوای منتظر بمونی؟
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-6 text-white/90">
|
||||
هر بستهی سوال چند روز قلبِ نامحدود هدیه داره؛ هر چقدر بخوای تمرین کن.
|
||||
با اشتراک، قلبهات نامحدود میشه؛ هر چقدر بخوای تمرین کن.
|
||||
</span>
|
||||
<span className="mt-3 inline-flex items-center gap-1.5 rounded-2xl bg-white px-4 py-2 text-sm font-black text-coral">
|
||||
<ShoppingBag size={15} /> دیدنِ بستهها
|
||||
<Crown size={15} /> دیدن اشتراکها
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -174,7 +183,7 @@ export function NoHeartsScreen({
|
||||
<Brain size={20} />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
<span className="block text-sm font-black text-ink">تا اون موقع از «پی» بپرس</span>
|
||||
<span className="block text-sm font-black text-ink">تا اون موقع از «{companionName}» بپرس</span>
|
||||
<span className="block text-xs leading-6 text-muted">جایی که گیر کردی رو با هم مرور کنیم؛ قلب خرج نمیکنه.</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -192,7 +201,7 @@ export function NoHeartsScreen({
|
||||
/** پنج جای قلب؛ اولی همراهِ زمان از پایین پر میشود. */
|
||||
function HeartSlots({ progress }: { progress: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5" aria-label={`قلبِ بعدی ${toFa(Math.round(progress * 100))} درصد پر شده`}>
|
||||
<div className="flex items-center gap-2.5" aria-label={`قلب بعدی ${toFa(Math.round(progress * 100))} درصد پر شده`}>
|
||||
{Array.from({ length: MAX_HEARTS }, (_, i) => (
|
||||
<span key={i} className="relative h-8 w-8">
|
||||
<Heart size={32} className="absolute inset-0 text-[#F2DCD8]" fill="#FBEDEA" />
|
||||
|
||||
@@ -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
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
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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { onWhite, shade } from '@/lib/color';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
|
||||
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -22,12 +23,14 @@ export function ScreenHeader({
|
||||
color: string;
|
||||
/** دکمهی بازگشت؛ true یعنی صفحهی قبل در پشته. */
|
||||
back?: boolean | (() => void);
|
||||
mascot?: { char: SeyarChar; mood?: SeyarMood; size?: number };
|
||||
/** بی `char` همراهِ انتخابیِ کاربر است؛ همهی صفحهها همین را میخواهند. */
|
||||
mascot?: { char?: SeyarChar; mood?: SeyarMood; size?: number };
|
||||
/** رنگ را همانطور که هست بگذار (بیتیرهکردن برای کنتراست)؛ انتخابِ مالک برای چند صفحه. */
|
||||
exact?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const nav = useNav();
|
||||
const companion = useCompanion().char;
|
||||
const onBack = typeof back === 'function' ? back : nav.back;
|
||||
const bg = exact ? color : onWhite(color);
|
||||
return (
|
||||
@@ -52,7 +55,7 @@ export function ScreenHeader({
|
||||
<h1 className="text-xl font-black leading-snug">{title}</h1>
|
||||
{subtitle && <div className="mt-1 text-xs text-white/75">{subtitle}</div>}
|
||||
</div>
|
||||
{mascot && <SeyarMascot char={mascot.char} mood={mascot.mood ?? 'happy'} size={mascot.size ?? 64} />}
|
||||
{mascot && <SeyarMascot char={mascot.char ?? companion} mood={mascot.mood ?? 'happy'} size={mascot.size ?? 64} />}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
@@ -119,7 +122,7 @@ export function Spinner({ size = 20, className = '' }: { size?: number; classNam
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label="در حالِ بارگذاری"
|
||||
aria-label="در حال بارگذاری"
|
||||
className={`inline-block animate-spin rounded-full border-2 border-current border-t-transparent ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
@@ -128,10 +131,11 @@ export function Spinner({ size = 20, className = '' }: { size?: number; classNam
|
||||
|
||||
/* ─── حالتهای بارگذاری / خطا / خالی ───────────────────────────────────────── */
|
||||
|
||||
function StateBox({ char, mood, text, children }: { char: SeyarChar; mood: SeyarMood; text: ReactNode; children?: ReactNode }) {
|
||||
function StateBox({ char, mood, text, children }: { char?: SeyarChar; mood: SeyarMood; text: ReactNode; children?: ReactNode }) {
|
||||
const companion = useCompanion().char;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-8 py-16 text-center">
|
||||
<SeyarMascot char={char} mood={mood} size={72} />
|
||||
<SeyarMascot char={char ?? companion} mood={mood} size={72} />
|
||||
<p className="text-sm leading-7 text-muted">{text}</p>
|
||||
{children}
|
||||
</div>
|
||||
@@ -143,7 +147,7 @@ function StateBox({ char, mood, text, children }: { char: SeyarChar; mood: Seyar
|
||||
* همان چند فریم، پرشِ ریزِ «لودر ← محتوا» میساخت؛ حالا تا آن لحظه جای خالی میماند. اگر پاسخ
|
||||
* کند باشد کاربر باز هم لودر را میبیند. در مرورگر با شبکهی کند آزموده نشده.
|
||||
*/
|
||||
export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'sigma' as SeyarChar, delayMs = 300 }) {
|
||||
export function LoadingState({ text = 'یه لحظه صبر کن…', char, delayMs = 300 }: { text?: string; char?: SeyarChar; delayMs?: number }) {
|
||||
const [show, setShow] = useState(delayMs <= 0);
|
||||
useEffect(() => {
|
||||
if (delayMs <= 0) return;
|
||||
@@ -155,7 +159,7 @@ export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'si
|
||||
|
||||
export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?: () => void; text?: string }) {
|
||||
return (
|
||||
<StateBox char="pi" mood="sad" text={text ?? friendlyError(error)}>
|
||||
<StateBox mood="sad" text={text ?? friendlyError(error)}>
|
||||
{onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -169,7 +173,7 @@ export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ text, char = 'pi' as SeyarChar }: { text: string; char?: SeyarChar }) {
|
||||
export function EmptyState({ text, char }: { text: string; char?: SeyarChar }) {
|
||||
return <StateBox char={char} mood="idle" text={text} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const TEXTS = [
|
||||
'✅ جواب: رأس سهمی در (1, -1) است.',
|
||||
'۴) ۲ - ۳ = -۱.',
|
||||
'مجموع زاویههای داخلی = (5 - 2) × 180 = 3 × 180 = 540 درجه',
|
||||
'بازهی [-2, 3) یعنی از −۲ تا ۳، خودِ ۳ نه.',
|
||||
'بازه [-2, 3) یعنی از −۲ تا ۳، خود ۳ نه.',
|
||||
'sin 30° = 0.5 و cos 30° ≈ 0.87 است.',
|
||||
'در کتاب ۲/۵ یعنی دو و نیم (اختیاری).',
|
||||
];
|
||||
|
||||
@@ -3,14 +3,14 @@ import type { DiagramSpec } from '@/diagram/types';
|
||||
/** نمونههای دستنویس از هر نوع شکل، از روی مسئلههای واقعیِ کتابها. فقط برای گالری. */
|
||||
export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
{
|
||||
title: 'یازدهم — سهمی با ریشه، رأس، محورِ تقارن و عرض از مبدأ',
|
||||
title: 'یازدهم — سهمی با ریشه، رأس، محور تقارن و عرض از مبدأ',
|
||||
spec: { kind: 'plane', title: 'y = x² − 2x − 3', axes: true, points: {}, items: [
|
||||
{ type: 'function', expr: 'x^2-2x-3', from: -2.5, to: 4.5, label: 'y = x² − 2x − 3', marks: ['roots', 'vertex', 'axis', 'yIntercept'] },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دهم — دایرهی مثلثاتی، سینوس و کسینوسِ ۳۰°',
|
||||
spec: { kind: 'plane', title: 'زاویهی ۳۰ درجه', axes: true, points: { O: [0, 0], A: [1, 0], P: { polar: [1, 30] }, H: { foot: ['P', 'O', 'A'] } }, items: [
|
||||
title: 'دهم — دایره مثلثاتی، سینوس و کسینوس ۳۰°',
|
||||
spec: { kind: 'plane', title: 'زاویه ۳۰ درجه', axes: true, points: { O: [0, 0], A: [1, 0], P: { polar: [1, 30] }, H: { foot: ['P', 'O', 'A'] } }, items: [
|
||||
{ type: 'circle', center: 'O', r: 1 },
|
||||
{ type: 'segment', from: 'O', to: 'P' },
|
||||
{ type: 'segment', from: 'P', to: 'H', dashed: true, color: 'green', label: 'sin 30° = 0.5' },
|
||||
@@ -20,7 +20,7 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'نهم — مثلثِ قائمالزاویه، فیثاغورس',
|
||||
title: 'نهم — مثلث قائمالزاویه، فیثاغورس',
|
||||
spec: { kind: 'plane', title: 'وتر = ۵', axes: false, points: { A: [0, 0], B: [4, 0], C: [0, 3] }, items: [
|
||||
{ type: 'polygon', points: ['A', 'B', 'C'] },
|
||||
{ type: 'rightAngle', vertex: 'A', from: 'B', to: 'C' },
|
||||
@@ -30,7 +30,7 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'هشتم — خطوطِ موازی و مورب، زاویههای مساوی',
|
||||
title: 'هشتم — خطوط موازی و مورب، زاویههای مساوی',
|
||||
spec: { kind: 'plane', title: 'a ∥ b و d مورب', axes: false, points: { _a1: [0, 2], _a2: [6, 2], _b1: [0, 0], _b2: [6, 0], _d1: [1.2, -1], _d2: [4.8, 3], P: { intersect: [['_a1', '_a2'], ['_d1', '_d2']] }, Q: { intersect: [['_b1', '_b2'], ['_d1', '_d2']] } }, items: [
|
||||
{ type: 'line', through: ['_a1', '_a2'], label: 'a' },
|
||||
{ type: 'line', through: ['_b1', '_b2'], label: 'b' },
|
||||
@@ -40,8 +40,8 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'هشتم — زاویهی محاطی و مرکزیِ روبهروی یک کمان',
|
||||
spec: { kind: 'plane', title: 'زاویهی محاطی نصفِ مرکزی است', axes: false, points: { O: [0, 0], A: { polar: [2, 200] }, B: { polar: [2, -20] }, C: { polar: [2, 100] } }, items: [
|
||||
title: 'هشتم — زاویه محاطی و مرکزی روبهروی یک کمان',
|
||||
spec: { kind: 'plane', title: 'زاویه محاطی نصف مرکزی است', axes: false, points: { O: [0, 0], A: { polar: [2, 200] }, B: { polar: [2, -20] }, C: { polar: [2, 100] } }, items: [
|
||||
{ type: 'circle', center: 'O', r: 2 },
|
||||
{ type: 'arc', center: 'O', r: 2, start: 200, end: 340, label: 'AB' },
|
||||
{ type: 'segment', from: 'C', to: 'A' }, { type: 'segment', from: 'C', to: 'B' },
|
||||
@@ -51,8 +51,8 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'نهم — متوازیالاضلاع و وسطِ ضلعها، ضلعهای مساوی',
|
||||
spec: { kind: 'plane', title: 'M و N وسطِ ضلعها', axes: false, points: { A: [0, 0], B: [4, 0], C: [5, 2.5], D: [1, 2.5], M: { mid: ['A', 'B'] }, N: { mid: ['C', 'D'] } }, items: [
|
||||
title: 'نهم — متوازیالاضلاع و وسط ضلعها، ضلعهای مساوی',
|
||||
spec: { kind: 'plane', title: 'M و N وسط ضلعها', axes: false, points: { A: [0, 0], B: [4, 0], C: [5, 2.5], D: [1, 2.5], M: { mid: ['A', 'B'] }, N: { mid: ['C', 'D'] } }, items: [
|
||||
{ type: 'polygon', points: ['A', 'B', 'C', 'D'] },
|
||||
{ type: 'segment', from: 'A', to: 'M', ticks: 1 }, { type: 'segment', from: 'M', to: 'B', ticks: 1 },
|
||||
{ type: 'segment', from: 'D', to: 'N', ticks: 2 }, { type: 'segment', from: 'N', to: 'C', ticks: 2 },
|
||||
@@ -60,14 +60,14 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'هفتم — دورانِ ۹۰° مثلث حولِ C در جهتِ عقربهها، روی شبکه',
|
||||
spec: { kind: 'plane', title: 'دورانِ ۹۰°', axes: false, grid: true, points: { A: [1, 1], B: [4, 1], C: [1, 3] }, items: [
|
||||
title: 'هفتم — دوران ۹۰° مثلث حول C در جهت عقربهها، روی شبکه',
|
||||
spec: { kind: 'plane', title: 'دوران ۹۰°', axes: false, grid: true, points: { A: [1, 1], B: [4, 1], C: [1, 3] }, items: [
|
||||
{ type: 'polygon', points: ['A', 'B', 'C'] },
|
||||
{ type: 'image', of: ['A', 'B', 'C'], transform: { rotate: { center: 'C', deg: -90 } } },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'هشتم — بردار و جمعِ بردارها روی شبکه',
|
||||
title: 'هشتم — بردار و جمع بردارها روی شبکه',
|
||||
spec: { kind: 'plane', title: 'AB + BC = AC', axes: false, grid: true, points: { A: [0, 0], B: [3, 1], C: [4, 4] }, items: [
|
||||
{ type: 'vector', from: 'A', to: 'B', label: 'a' },
|
||||
{ type: 'vector', from: 'B', to: 'C', label: 'b', color: 'blue' },
|
||||
@@ -75,7 +75,7 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'نهم — دو خط و محلِ برخورد',
|
||||
title: 'نهم — دو خط و محل برخورد',
|
||||
spec: { kind: 'plane', title: 'y = 2x − 1 و y = −x + 5', axes: true, points: { P: { intersect: [[[0, -1], [1, 1]], [[0, 5], [1, 4]]] } }, items: [
|
||||
{ type: 'function', expr: '2x-1', from: -1, to: 4, label: 'y = 2x − 1' },
|
||||
{ type: 'function', expr: '-x+5', from: -1, to: 6, label: 'y = −x + 5' },
|
||||
@@ -83,21 +83,21 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'یازدهم — نمودارِ سینوس با مضربهای π',
|
||||
title: 'یازدهم — نمودار سینوس با مضربهای π',
|
||||
spec: { kind: 'plane', title: 'y = sin x', axes: true, trig: true, points: {}, items: [
|
||||
{ type: 'function', expr: 'sin(x)', from: -6.2832, to: 6.2832, label: 'y = sin x', marks: ['roots', 'extrema'] },
|
||||
{ type: 'hline', y: 0.5, color: 'orange', label: 'y = ½' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'یازدهم — تابعِ چندضابطهای و حد (نقطهی توخالی)',
|
||||
spec: { kind: 'plane', title: 'حدِ چپ و راست در x = 1', axes: true, points: {}, items: [
|
||||
title: 'یازدهم — تابع چندضابطهای و حد (نقطه توخالی)',
|
||||
spec: { kind: 'plane', title: 'حد چپ و راست در x = 1', axes: true, points: {}, items: [
|
||||
{ type: 'piecewise', pieces: [{ expr: 'x+1', from: -2, to: 1, closedFrom: true, closedTo: false }, { expr: '-x+4', from: 1, to: 4, closedFrom: true, closedTo: true }] },
|
||||
{ type: 'vline', x: 1, label: 'x = 1' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دوازدهم — خطِ مماس بر نمودار',
|
||||
title: 'دوازدهم — خط مماس بر نمودار',
|
||||
spec: { kind: 'plane', title: 'مماس بر y = x² در x = 1', axes: true, points: {}, items: [
|
||||
{ type: 'function', expr: 'x^2', from: -2, to: 2.5, label: 'y = x²' },
|
||||
{ type: 'tangent', expr: 'x^2', at: 1, label: 'y = 2x − 1' },
|
||||
@@ -105,14 +105,14 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
},
|
||||
{
|
||||
title: 'دوازدهم — مجانبها: y = 1/(x − 2) + 1',
|
||||
spec: { kind: 'plane', title: 'مجانبِ قائم و افقی', axes: true, xRange: [-2, 6], yRange: [-4, 6], points: {}, items: [
|
||||
spec: { kind: 'plane', title: 'مجانب قائم و افقی', axes: true, xRange: [-2, 6], yRange: [-4, 6], points: {}, items: [
|
||||
{ type: 'function', expr: '1/(x-2)+1', from: -2, to: 6, label: 'y = 1/(x−2) + 1' },
|
||||
{ type: 'vline', x: 2, label: 'x = 2', color: 'red' },
|
||||
{ type: 'hline', y: 1, label: 'y = 1', color: 'red' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دوازدهم — اکسترممهای نسبیِ تابعِ درجهی سوم',
|
||||
title: 'دوازدهم — اکسترممهای نسبی تابع درجه سوم',
|
||||
spec: { kind: 'plane', title: 'y = x³ − 3x', axes: true, points: {}, items: [
|
||||
{ type: 'function', expr: 'x^3-3x', from: -2.3, to: 2.3, label: 'y = x³ − 3x', marks: ['extrema', 'roots'] },
|
||||
] },
|
||||
@@ -126,20 +126,20 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دوازدهم — مساحتِ زیرِ نمودار',
|
||||
spec: { kind: 'plane', title: 'ناحیهی بینِ y = √x و محورِ x', axes: true, points: {}, items: [
|
||||
title: 'دوازدهم — مساحت زیر نمودار',
|
||||
spec: { kind: 'plane', title: 'ناحیه بین y = √x و محور x', axes: true, points: {}, items: [
|
||||
{ type: 'area', expr: 'sqrt(x)', from: 0, to: 4, color: 'green', label: 'S' },
|
||||
{ type: 'function', expr: 'sqrt(x)', from: 0, to: 5, label: 'y = √x', color: 'green' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دهم — ناحیهی جوابِ نامعادله',
|
||||
title: 'دهم — ناحیه جواب نامعادله',
|
||||
spec: { kind: 'plane', title: 'y > 2x − 1', axes: true, xRange: [-3, 3], yRange: [-4, 4], points: {}, items: [
|
||||
{ type: 'region', expr: '2x-1', side: 'above', strict: true, label: 'y > 2x − 1' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'هشتم — محورِ اعداد: از ۲ سه واحد به چپ',
|
||||
title: 'هشتم — محور اعداد: از ۲ سه واحد به چپ',
|
||||
spec: { kind: 'numberline', title: '۲ − ۳ = −۱', min: -4, max: 4, points: [{ at: 2, label: 'شروع' }, { at: -1, label: 'پایان' }], jumps: [{ from: 2, to: -1, label: '۳ واحد به چپ' }] },
|
||||
},
|
||||
{
|
||||
@@ -162,7 +162,7 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
spec: { kind: 'grid', title: '۲۰ درصد', rows: 5, cols: 5, shaded: 5 },
|
||||
},
|
||||
{
|
||||
title: 'سوم — مدلِ ضرب: ۳ گروهِ ۴تایی',
|
||||
title: 'سوم — مدل ضرب: ۳ گروه ۴تایی',
|
||||
spec: { kind: 'groups', title: '۳ × ۴', groups: 3, each: 4, item: 'circle' },
|
||||
},
|
||||
{
|
||||
@@ -170,23 +170,23 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
spec: { kind: 'clock', title: 'ساعت ۳:۲۵', hour: 3, minute: 25 },
|
||||
},
|
||||
{
|
||||
title: 'سوم — نمودارِ ستونیِ آبمیوهها',
|
||||
spec: { kind: 'bar', title: 'آبمیوهی موردِ علاقه', labels: ['پرتقال', 'سیب', 'انگور', 'هلو'], values: [8, 5, 3, 4], unit: 'نفر' },
|
||||
title: 'سوم — نمودار ستونی آبمیوهها',
|
||||
spec: { kind: 'bar', title: 'آبمیوه مورد علاقه', labels: ['پرتقال', 'سیب', 'انگور', 'هلو'], values: [8, 5, 3, 4], unit: 'نفر' },
|
||||
},
|
||||
{
|
||||
title: 'هشتم — نمودارِ دایرهای',
|
||||
spec: { kind: 'pie', title: 'ورزشِ موردِ علاقه', labels: ['فوتبال', 'والیبال', 'شنا', 'بقیه'], values: [12, 6, 4, 2] },
|
||||
title: 'هشتم — نمودار دایرهای',
|
||||
spec: { kind: 'pie', title: 'ورزش مورد علاقه', labels: ['فوتبال', 'والیبال', 'شنا', 'بقیه'], values: [12, 6, 4, 2] },
|
||||
},
|
||||
{
|
||||
title: 'دهم — دمای زمین بر حسبِ عمق (خطشکسته)',
|
||||
title: 'دهم — دمای زمین بر حسب عمق (خطشکسته)',
|
||||
spec: { kind: 'linechart', title: 'دما (°C)', labels: ['1', '2', '3', '4', '5', '6'], values: [55, 90, 125, 160, 195, 230], unit: '°C' },
|
||||
},
|
||||
{
|
||||
title: 'دهم — جدولِ تعیینِ علامت',
|
||||
title: 'دهم — جدول تعیین علامت',
|
||||
spec: { kind: 'table', title: 'y = (x + 1)(x − 2)', headers: ['x', '−∞', '−1', '', '2', '+∞'], rows: [['y', '+', '0', '−', '0', '+']] },
|
||||
},
|
||||
{
|
||||
title: 'دهم — نمودارِ ون: A ∩ B',
|
||||
title: 'دهم — نمودار ون: A ∩ B',
|
||||
spec: { kind: 'venn', title: 'A = {1,2,3,4} و B = {3,4,5}', sets: ['A', 'B'], regions: { A: ['1', '2'], AB: ['3', '4'], B: ['5'], out: ['6'] }, shade: ['AB'] },
|
||||
},
|
||||
{
|
||||
@@ -194,11 +194,11 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
spec: { kind: 'venn', title: 'A − (B ∪ C)', sets: ['A', 'B', 'C'], regions: { A: ['1'], B: ['2'], C: ['3'], AB: ['4'], ABC: ['5'] }, shade: ['A'] },
|
||||
},
|
||||
{
|
||||
title: 'دهم — نمودارِ پیکانیِ تابع',
|
||||
title: 'دهم — نمودار پیکانی تابع',
|
||||
spec: { kind: 'mapping', title: 'f(x) = 3(x − 1)²', left: ['0', '0.5', '1'], right: ['3', '0.75', '0'], arrows: [[0, 0], [1, 1], [2, 2]], leftLabel: 'دامنه', rightLabel: 'برد' },
|
||||
},
|
||||
{
|
||||
title: 'نهم — نمودارِ درختیِ دو بار پرتابِ سکه',
|
||||
title: 'نهم — نمودار درختی دو بار پرتاب سکه',
|
||||
spec: { kind: 'tree', title: '۴ حالت', root: { label: 'شروع', children: [
|
||||
{ label: 'رو', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] },
|
||||
{ label: 'پشت', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] },
|
||||
@@ -206,42 +206,42 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
},
|
||||
{ title: 'هفتم — مکعبمستطیل', spec: { kind: 'solid', title: 'حجم = ۵ × ۳ × ۴', shape: 'cuboid', dims: { a: 5, b: 3, c: 4 }, labels: { a: '5 cm', b: '3 cm', c: '4 cm' } } },
|
||||
{ title: 'هفتم — مکعب', spec: { kind: 'solid', title: 'مکعب', shape: 'cube', dims: { a: 3 }, labels: { a: 'a' } } },
|
||||
{ title: 'هفتم — منشورِ سهپهلو', spec: { kind: 'solid', title: 'منشورِ مثلثی', shape: 'prism', dims: { n: 3, r: 1.2, h: 2 }, labels: { h: 'h' } } },
|
||||
{ title: 'نهم — منشورِ ششپهلو', spec: { kind: 'solid', title: 'منشورِ ششضلعی', shape: 'prism', dims: { n: 6, r: 1.2, h: 1.6 }, labels: { h: '8 cm' } } },
|
||||
{ title: 'نهم — هرمِ مربعالقاعده با ارتفاع', spec: { kind: 'solid', title: 'هرم', shape: 'pyramid', dims: { n: 4, r: 1.3, h: 2 }, labels: { h: 'h', a: 'a' } } },
|
||||
{ title: 'نهم — هرمِ پنجپهلو', spec: { kind: 'solid', title: 'قاعدهی ABCDE', shape: 'pyramid', dims: { n: 5, r: 1.3, h: 2 }, labels: { h: 'OH' } } },
|
||||
{ title: 'نهم — استوانه', spec: { kind: 'solid', title: 'لیوانِ استوانهای', shape: 'cylinder', dims: { r: 6, h: 10 }, labels: { r: '6 cm', h: 'h' } } },
|
||||
{ title: 'نهم — مخروط', spec: { kind: 'solid', title: 'ظرفِ مخروطی', shape: 'cone', dims: { r: 4, h: 12 }, labels: { r: '4 cm', h: '12 cm' } } },
|
||||
{ title: 'هفتم — منشور سهپهلو', spec: { kind: 'solid', title: 'منشور مثلثی', shape: 'prism', dims: { n: 3, r: 1.2, h: 2 }, labels: { h: 'h' } } },
|
||||
{ title: 'نهم — منشور ششپهلو', spec: { kind: 'solid', title: 'منشور ششضلعی', shape: 'prism', dims: { n: 6, r: 1.2, h: 1.6 }, labels: { h: '8 cm' } } },
|
||||
{ title: 'نهم — هرم مربعالقاعده با ارتفاع', spec: { kind: 'solid', title: 'هرم', shape: 'pyramid', dims: { n: 4, r: 1.3, h: 2 }, labels: { h: 'h', a: 'a' } } },
|
||||
{ title: 'نهم — هرم پنجپهلو', spec: { kind: 'solid', title: 'قاعده ABCDE', shape: 'pyramid', dims: { n: 5, r: 1.3, h: 2 }, labels: { h: 'OH' } } },
|
||||
{ title: 'نهم — استوانه', spec: { kind: 'solid', title: 'لیوان استوانهای', shape: 'cylinder', dims: { r: 6, h: 10 }, labels: { r: '6 cm', h: 'h' } } },
|
||||
{ title: 'نهم — مخروط', spec: { kind: 'solid', title: 'ظرف مخروطی', shape: 'cone', dims: { r: 4, h: 12 }, labels: { r: '4 cm', h: '12 cm' } } },
|
||||
{ title: 'نهم — کره', spec: { kind: 'solid', title: 'کره', shape: 'sphere', dims: { r: 1 }, labels: { r: 'r' } } },
|
||||
{ title: 'نهم — نیمکره', spec: { kind: 'solid', title: 'پیمانهی نیمکره', shape: 'hemisphere', dims: { r: 12 }, labels: { r: '12 cm' } } },
|
||||
{ title: 'ششم — مقایسهی یکپنجم و نُهدهم', spec: { kind: 'fraction', title: 'کدام بزرگتر است؟', shape: 'rect', parts: 5, shaded: 1, compare: [{ parts: 5, shaded: 1, label: 'یکپنجم' }, { parts: 10, shaded: 9, label: 'نُهدهم' }] } },
|
||||
{ title: 'نهم — نیمکره', spec: { kind: 'solid', title: 'پیمانه نیمکره', shape: 'hemisphere', dims: { r: 12 }, labels: { r: '12 cm' } } },
|
||||
{ title: 'ششم — مقایسه یکپنجم و نهدهم', spec: { kind: 'fraction', title: 'کدام بزرگتر است؟', shape: 'rect', parts: 5, shaded: 1, compare: [{ parts: 5, shaded: 1, label: 'یکپنجم' }, { parts: 10, shaded: 9, label: 'نهدهم' }] } },
|
||||
{ title: 'ششم — دو و هفتدهم', spec: { kind: 'fraction', title: '۲٫۷', shape: 'circle', parts: 10, shaded: 7, wholes: 2 } },
|
||||
{ title: 'دهم — دایره و خطِ مماس از معادله', spec: { kind: 'plane', title: 'فاصلهی مرکز تا خط', axes: true, points: { O: [-1, 2] }, items: [
|
||||
{ title: 'دهم — دایره و خط مماس از معادله', spec: { kind: 'plane', title: 'فاصله مرکز تا خط', axes: true, points: { O: [-1, 2] }, items: [
|
||||
{ type: 'line', eq: [3, 5, 4], label: '3x + 5y = 4' }, { type: 'circle', center: 'O', r: 0.5145, color: 'green' },
|
||||
] } },
|
||||
{ title: 'هشتم — زاویهی خارجیِ پنجضلعی با امتدادِ ضلع', spec: { kind: 'plane', title: 'زاویهی خارجی = ۷۲°', axes: false, points: { A: { polar: [2, 234] }, B: { polar: [2, 306] }, C: { polar: [2, 18] }, D: { polar: [2, 90] }, E: { polar: [2, 162] }, X: { along: ['A', 'B', 1.6] } }, items: [
|
||||
{ title: 'هشتم — زاویه خارجی پنجضلعی با امتداد ضلع', spec: { kind: 'plane', title: 'زاویه خارجی = ۷۲°', axes: false, points: { A: { polar: [2, 234] }, B: { polar: [2, 306] }, C: { polar: [2, 18] }, D: { polar: [2, 90] }, E: { polar: [2, 162] }, X: { along: ['A', 'B', 1.6] } }, items: [
|
||||
{ type: 'polygon', points: ['A', 'B', 'C', 'D', 'E'] }, { type: 'segment', from: 'B', to: 'X', dashed: true }, { type: 'angle', vertex: 'B', from: 'X', to: 'C', label: '72°' },
|
||||
] } },
|
||||
{ title: 'سوم — ضرب روی محورِ اعداد (تیکهای زیاد)', spec: { kind: 'numberline', title: '۲ × ۱۱', min: 0, max: 25, points: [{ at: 11, label: '' }, { at: 22, label: '' }], jumps: [{ from: 0, to: 11, label: '۱۱' }, { from: 11, to: 22, label: '۱۱' }] } },
|
||||
{ title: 'نهم — مثلث از روی داده: AB = 6، AC = 8، زاویهی A قائمه', spec: { kind: 'plane', title: 'فیثاغورس', axes: false, points: {}, items: [
|
||||
{ title: 'سوم — ضرب روی محور اعداد (تیکهای زیاد)', spec: { kind: 'numberline', title: '۲ × ۱۱', min: 0, max: 25, points: [{ at: 11, label: '' }, { at: 22, label: '' }], jumps: [{ from: 0, to: 11, label: '۱۱' }, { from: 11, to: 22, label: '۱۱' }] } },
|
||||
{ title: 'نهم — مثلث از روی داده: AB = 6، AC = 8، زاویه A قائمه', spec: { kind: 'plane', title: 'فیثاغورس', axes: false, points: {}, items: [
|
||||
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 6, AC: 8 }, angles: { A: 90 }, labels: { BC: 'x' } },
|
||||
] } },
|
||||
{ title: 'هشتم — مثلث با سه ضلع ۵، ۷، ۹ و ارتفاع از C', spec: { kind: 'plane', title: 'ارتفاعِ وارد بر AB', axes: false, points: {}, items: [
|
||||
{ title: 'هشتم — مثلث با سه ضلع ۵، ۷، ۹ و ارتفاع از C', spec: { kind: 'plane', title: 'ارتفاع وارد بر AB', axes: false, points: {}, items: [
|
||||
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 9, BC: 7, CA: 5 }, altitude: 'C', labels: { h: 'h' } },
|
||||
] } },
|
||||
{ title: 'هفتم — مثلث با دو زاویه (۴۰° و ۷۵°)', spec: { kind: 'plane', title: 'زاویهی سوم؟', axes: false, points: {}, items: [
|
||||
{ title: 'هفتم — مثلث با دو زاویه (۴۰° و ۷۵°)', spec: { kind: 'plane', title: 'زاویه سوم؟', axes: false, points: {}, items: [
|
||||
{ type: 'triangle', names: ['A', 'B', 'C'], angles: { A: 40, B: 75 }, labels: { C: '?' } },
|
||||
] } },
|
||||
{ title: 'منفرجه — ارتفاع بیرونِ مثلث', spec: { kind: 'plane', title: 'زاویهی B = ۱۲۰°', axes: false, points: {}, items: [
|
||||
{ title: 'منفرجه — ارتفاع بیرون مثلث', spec: { kind: 'plane', title: 'زاویه B = ۱۲۰°', axes: false, points: {}, items: [
|
||||
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 4, BC: 3 }, angles: { B: 120 }, altitude: 'C' },
|
||||
] } },
|
||||
{ title: 'ششم — مستطیلِ باغچه ۸ × ۵', spec: { kind: 'plane', title: 'باغچه', axes: false, points: {}, items: [{ type: 'quad', shape: 'rectangle', names: ['A', 'B', 'C', 'D'], w: 8, h: 5 }] } },
|
||||
{ title: 'مربع با ضلعِ ۴', spec: { kind: 'plane', title: 'مربع', axes: false, points: {}, items: [{ type: 'quad', shape: 'square', names: ['A', 'B', 'C', 'D'], side: 4 }] } },
|
||||
{ title: 'ششم — مستطیل باغچه ۸ × ۵', spec: { kind: 'plane', title: 'باغچه', axes: false, points: {}, items: [{ type: 'quad', shape: 'rectangle', names: ['A', 'B', 'C', 'D'], w: 8, h: 5 }] } },
|
||||
{ title: 'مربع با ضلع ۴', spec: { kind: 'plane', title: 'مربع', axes: false, points: {}, items: [{ type: 'quad', shape: 'square', names: ['A', 'B', 'C', 'D'], side: 4 }] } },
|
||||
{ title: 'ششم — متوازیالاضلاع: قاعده ۷، ارتفاع ۴', spec: { kind: 'plane', title: 'مساحت = قاعده × ارتفاع', axes: false, points: {}, items: [{ type: 'quad', shape: 'parallelogram', names: ['A', 'B', 'C', 'D'], w: 7, h: 4 }] } },
|
||||
{ title: 'هفتم — لوزی با قطرهای ۸ و ۶', spec: { kind: 'plane', title: 'مساحت = نصفِ حاصلضربِ قطرها', axes: false, points: {}, items: [{ type: 'quad', shape: 'rhombus', names: ['A', 'B', 'C', 'D'], d1: 8, d2: 6 }] } },
|
||||
{ title: 'هفتم — لوزی با قطرهای ۸ و ۶', spec: { kind: 'plane', title: 'مساحت = نصف حاصلضرب قطرها', axes: false, points: {}, items: [{ type: 'quad', shape: 'rhombus', names: ['A', 'B', 'C', 'D'], d1: 8, d2: 6 }] } },
|
||||
{ title: 'هشتم — ذوزنقه: قاعدههای ۱۰ و ۶، ارتفاع ۴', spec: { kind: 'plane', title: 'ذوزنقه', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 10, top: 6, h: 4 }] } },
|
||||
{ title: 'ذوزنقهی قائمالزاویه', spec: { kind: 'plane', title: 'ذوزنقهی قائم', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 8, top: 5, h: 4, right: true }] } },
|
||||
{ title: 'هفتم — الگوی مربعی ۱، ۴، ۹ و شکلِ بعدی', spec: { kind: 'pattern', title: 'الگوی n²', layout: 'square', unit: 'square', stages: [1, 2, 3], next: true } },
|
||||
{ title: 'ذوزنقه قائمالزاویه', spec: { kind: 'plane', title: 'ذوزنقه قائم', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 8, top: 5, h: 4, right: true }] } },
|
||||
{ title: 'هفتم — الگوی مربعی ۱، ۴، ۹ و شکل بعدی', spec: { kind: 'pattern', title: 'الگوی n²', layout: 'square', unit: 'square', stages: [1, 2, 3], next: true } },
|
||||
{ title: 'هفتم — الگوی چوبکبریت', spec: { kind: 'pattern', title: 'چوبکبریت: 3n + 1', layout: 'row', unit: 'stick', stages: [1, 2, 3, 4], next: true } },
|
||||
{ title: 'دهم — عددهای مثلثی با نقطه', spec: { kind: 'pattern', title: '۱، ۳، ۶، ۱۰', layout: 'triangle', unit: 'dot', stages: [1, 2, 3, 4] } },
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChartSpec } from './types';
|
||||
import { COLORS, GRID, INK, Label, SERIES, SOFT, bidi, fa, mathText, niceStep, ticksBetween } from './svg';
|
||||
import { COLORS, GRID, INK, Label, SOFT, bidi, fa, mathText, niceStep, seriesColors, ticksBetween } from './svg';
|
||||
|
||||
const W = 330;
|
||||
|
||||
@@ -20,6 +20,7 @@ function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; label
|
||||
const Y = (v: number) => top + ((y1 - v) / (y1 - y0)) * plotH;
|
||||
const slot = plotW / labels.length;
|
||||
const X = (i: number) => left + slot * (i + 0.5);
|
||||
const barColors = seriesColors(labels, values.length);
|
||||
const barW = Math.min(38, slot * 0.62);
|
||||
const long = labels.some((l) => l.length > 6);
|
||||
|
||||
@@ -34,7 +35,7 @@ function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; label
|
||||
{unit && <text x={left - 4} y={12} fontSize={9.5} fill={SOFT} textAnchor="end" {...bidi(mathText(unit))}>{mathText(unit)}</text>}
|
||||
{kind === 'bar'
|
||||
? values.map((v, i) => {
|
||||
const c = COLORS[SERIES[i % SERIES.length]];
|
||||
const c = barColors[i];
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={X(i) - barW / 2} y={Math.min(Y(v), Y(0))} width={barW} height={Math.abs(Y(v) - Y(0))} rx={4} fill={c} />
|
||||
@@ -64,12 +65,13 @@ function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; label
|
||||
function Pie({ labels, values }: { labels: string[]; values: number[] }) {
|
||||
const total = values.reduce((a, v) => a + Math.max(0, v), 0);
|
||||
if (total <= 0) return null;
|
||||
const sliceColors = seriesColors(labels, values.length);
|
||||
const cx = 105, cy = 105, r = 88;
|
||||
let a = -Math.PI / 2;
|
||||
const slices = values.map((v, i) => {
|
||||
const frac = Math.max(0, v) / total, a0 = a, a1 = a + frac * 2 * Math.PI;
|
||||
a = a1;
|
||||
return { i, frac, a0, a1, c: COLORS[SERIES[i % SERIES.length]] };
|
||||
return { i, frac, a0, a1, c: sliceColors[i] };
|
||||
});
|
||||
const H = Math.max(215, 24 + labels.length * 22);
|
||||
return (
|
||||
|
||||
@@ -32,11 +32,11 @@ function tokenize(src: string): string[] {
|
||||
if (w === 'x' || w === 'pi' || w === 'e' || w in FUNCS) { out.push(w); i += w.length; continue; }
|
||||
// «xsin» یا «pix»: نامِ چسبیده را تکهتکه کن
|
||||
const known = ['sqrt', 'sin', 'cos', 'tan', 'abs', 'log', 'ln', 'pi', 'x', 'e'].find((k) => w.startsWith(k));
|
||||
if (!known) throw new Error(`نامِ ناشناخته: ${w}`);
|
||||
if (!known) throw new Error(`نام ناشناخته: ${w}`);
|
||||
out.push(known); i += known.length; continue;
|
||||
}
|
||||
if ('+-*/^()'.includes(s[i])) { out.push(s[i]); i++; continue; }
|
||||
throw new Error(`نویسهی ناشناخته: ${s[i]}`);
|
||||
throw new Error(`نویسه ناشناخته: ${s[i]}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export function parseExpr(src: string): (x: number) => number {
|
||||
const peek = () => t[p];
|
||||
const eat = (tok?: string) => {
|
||||
const v = t[p++];
|
||||
if (tok !== undefined && v !== tok) throw new Error(`انتظارِ ${tok}`);
|
||||
if (tok !== undefined && v !== tok) throw new Error(`انتظار ${tok}`);
|
||||
return v;
|
||||
};
|
||||
const startsAtom = (tok: string | undefined) => tok !== undefined && (/^[\d.]/.test(tok) || tok === 'x' || tok === 'pi' || tok === 'e' || tok === '(' || tok in FUNCS);
|
||||
|
||||
@@ -3,10 +3,11 @@ import type { DiagramSpec } from './types';
|
||||
import { Plane } from './plane';
|
||||
import { NumberLine } from './numberline';
|
||||
import { Solid } from './solid';
|
||||
import { Space } from './space';
|
||||
import { Chart } from './charts';
|
||||
import { Mapping, Tree, Venn } from './sets';
|
||||
import { Clock, Fraction, Grid, Groups, Pattern, Table } from './misc';
|
||||
import { PAPER } from './svg';
|
||||
import { HideCounts, PAPER } from './svg';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
|
||||
export type { DiagramSpec } from './types';
|
||||
@@ -23,6 +24,7 @@ function Body({ spec }: { spec: DiagramSpec }): ReactNode {
|
||||
case 'plane': return <Plane spec={spec} />;
|
||||
case 'numberline': return <NumberLine spec={spec} />;
|
||||
case 'solid': return <Solid spec={spec} />;
|
||||
case 'space': return <Space spec={spec} />;
|
||||
case 'bar': case 'linechart': case 'pie': return <Chart spec={spec} />;
|
||||
case 'venn': return <Venn spec={spec} />;
|
||||
case 'mapping': return <Mapping spec={spec} />;
|
||||
@@ -43,13 +45,13 @@ class Guard extends Component<{ children: ReactNode }, { broken: boolean }> {
|
||||
render() { return this.state.broken ? null : this.props.children; }
|
||||
}
|
||||
|
||||
function DiagramBase({ spec }: { spec: DiagramSpec }) {
|
||||
function DiagramBase({ spec, hideCounts = false }: { spec: DiagramSpec; hideCounts?: boolean }) {
|
||||
return (
|
||||
<Guard>
|
||||
{/* dir=ltr: محور و مختصات چپبهراستاند؛ در زمینهی rtl «−۱» به «۱−» برمیگشت.
|
||||
برچسبهای فارسی با unicode-bidi: plaintext جهتِ خودشان را میگیرند. */}
|
||||
<figure dir="ltr" className="mt-2 overflow-hidden rounded-2xl border border-line p-2" style={{ backgroundColor: PAPER }}>
|
||||
<Body spec={spec} />
|
||||
<HideCounts.Provider value={hideCounts}><Body spec={spec} /></HideCounts.Provider>
|
||||
{spec.title && <figcaption dir="rtl" className="mt-1 text-center text-[11px] font-bold text-muted"><MathText text={spec.title} /></figcaption>}
|
||||
</figure>
|
||||
</Guard>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ClockSpec, FractionSpec, GridSpec, GroupsSpec, PatternSpec, TableSpec } from './types';
|
||||
import { COLORS, INK, bidi, color, mathText } from './svg';
|
||||
import { useContext } from 'react';
|
||||
import { COLORS, HideCounts, INK, bidi, color, mathText } from './svg';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
const W = 320;
|
||||
@@ -8,11 +9,12 @@ const SHADE = COLORS.yellow;
|
||||
/* ─── کسر: دایره یا نوارِ تکهتکه ─────────────────────────────────────────── */
|
||||
|
||||
export function Fraction({ spec }: { spec: FractionSpec }) {
|
||||
if (spec.compare?.length) return <FractionCompare items={spec.compare} />;
|
||||
if (spec.wholes) return <MixedNumber wholes={spec.wholes} parts={spec.parts} shaded={spec.shaded} shape={spec.shape} />;
|
||||
const hide = useContext(HideCounts);
|
||||
if (spec.compare?.length) return <FractionCompare items={spec.compare} hide={hide} />;
|
||||
if (spec.wholes) return <MixedNumber wholes={spec.wholes} parts={spec.parts} shaded={spec.shaded} shape={spec.shape} hide={hide} />;
|
||||
const { parts, shaded } = spec;
|
||||
const fill = (i: number) => (i < shaded ? SHADE : '#fff');
|
||||
const caption = <text x={W / 2} y={spec.shape === 'rect' ? 88 : 166} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(shaded)} تکه از ${toFa(parts)} تکه`}</text>;
|
||||
const caption = hide ? null : <text x={W / 2} y={spec.shape === 'rect' ? 88 : 166} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(shaded)} تکه از ${toFa(parts)} تکه`}</text>;
|
||||
if (spec.shape === 'rect') {
|
||||
const w = (W - 40) / parts;
|
||||
return (
|
||||
@@ -36,7 +38,7 @@ export function Fraction({ spec }: { spec: FractionSpec }) {
|
||||
}
|
||||
|
||||
/** چند کسر روی نوارهای هماندازه، برای مقایسه (یکپنجم و نُهدهم). */
|
||||
function FractionCompare({ items }: { items: { parts: number; shaded: number; label?: string }[] }) {
|
||||
function FractionCompare({ items, hide }: { items: { parts: number; shaded: number; label?: string }[]; hide: boolean }) {
|
||||
const barW = W - 110, h = 30, gap = 16;
|
||||
const H = items.length * (h + gap) + 8;
|
||||
const cols = [COLORS.yellow, COLORS.teal, COLORS.orange, COLORS.purple];
|
||||
@@ -50,7 +52,7 @@ function FractionCompare({ items }: { items: { parts: number; shaded: number; la
|
||||
<rect key={i} x={W - 12 - (i + 1) * w} y={y} width={w} height={h} fill={i < f.shaded ? cols[r % cols.length] : '#fff'} stroke={INK} strokeWidth={1.6} />
|
||||
))}
|
||||
<text x={W - 20 - barW} y={y + h / 2 + 4} fontSize={11.5} fontWeight={800} fill={INK} textAnchor="end" style={{ unicodeBidi: 'plaintext' }}>
|
||||
{f.label ?? `${toFa(f.shaded)} از ${toFa(f.parts)}`}
|
||||
{f.label ?? (hide ? '' : `${toFa(f.shaded)} از ${toFa(f.parts)}`)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
@@ -60,7 +62,7 @@ function FractionCompare({ items }: { items: { parts: number; shaded: number; la
|
||||
}
|
||||
|
||||
/** عددِ مخلوط: چند واحدِ کامل و یک کسر (دو و هفتدهم). */
|
||||
function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts: number; shaded: number; shape: 'circle' | 'rect' }) {
|
||||
function MixedNumber({ wholes, parts, shaded, shape, hide }: { wholes: number; parts: number; shaded: number; shape: 'circle' | 'rect'; hide: boolean }) {
|
||||
const n = wholes + 1, r = Math.min(40, (W - 20) / n / 2 - 6), cy = 52;
|
||||
const cx = (i: number) => W - 10 - r - 6 - i * (2 * r + 12);
|
||||
const slice = (x: number, i: number, full: boolean) => {
|
||||
@@ -73,9 +75,9 @@ function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts:
|
||||
? <rect key={w} x={cx(w) - r} y={cy - r} width={2 * r} height={2 * r} fill={SHADE} stroke={INK} strokeWidth={2} />
|
||||
: <circle key={w} cx={cx(w)} cy={cy} r={r} fill={SHADE} stroke={INK} strokeWidth={2} />))}
|
||||
<g>{parts === 1 ? <circle cx={cx(wholes)} cy={cy} r={r} fill={shaded ? SHADE : '#fff'} stroke={INK} strokeWidth={2} /> : Array.from({ length: parts }, (_, i) => slice(cx(wholes), i, false))}</g>
|
||||
<text x={W / 2} y={112} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
|
||||
{`${toFa(wholes)} واحدِ کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`}
|
||||
</text>
|
||||
{!hide && <text x={W / 2} y={112} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
|
||||
{`${toFa(wholes)} واحد کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`}
|
||||
</text>}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +85,7 @@ function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts:
|
||||
/* ─── شبکهی خانهها: درصد، مساحت با شمردن، کسر روی جدول ─────────────────── */
|
||||
|
||||
export function Grid({ spec }: { spec: GridSpec }) {
|
||||
const hide = useContext(HideCounts);
|
||||
const rows = spec.rows, cols = spec.cols;
|
||||
const cell = Math.min(26, (W - 30) / cols, 220 / rows);
|
||||
const gw = cols * cell, gh = rows * cell;
|
||||
@@ -100,9 +103,9 @@ export function Grid({ spec }: { spec: GridSpec }) {
|
||||
<rect key={`${r}-${c}`} x={x0 + c * cell} y={y0 + r * cell} width={cell} height={cell} fill={on.has(`${r},${c}`) ? col : '#fff'} stroke="#B8BCCD" strokeWidth={1} />
|
||||
)))}
|
||||
<rect x={x0} y={y0} width={gw} height={gh} fill="none" stroke={INK} strokeWidth={2} />
|
||||
<text x={W / 2} y={gh + 34} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
|
||||
{!hide && <text x={W / 2} y={gh + 34} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
|
||||
{`${toFa(count)} خانه از ${toFa(rows * cols)} خانه${100 % (rows * cols) === 0 || (rows * cols) % 10 === 0 ? `، یعنی ${toFa(Math.round((count / (rows * cols)) * 1000) / 10)}٪` : ''}`}
|
||||
</text>
|
||||
</text>}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -110,6 +113,7 @@ export function Grid({ spec }: { spec: GridSpec }) {
|
||||
/* ─── گروهها: مدلِ ضرب و تقسیم برای دبستان ───────────────────────────────── */
|
||||
|
||||
export function Groups({ spec }: { spec: GroupsSpec }) {
|
||||
const hide = useContext(HideCounts);
|
||||
const g = spec.groups, each = spec.each;
|
||||
const perRow = Math.min(g, g <= 4 ? g : Math.ceil(g / 2));
|
||||
const rowsG = Math.ceil(g / perRow);
|
||||
@@ -141,9 +145,9 @@ export function Groups({ spec }: { spec: GroupsSpec }) {
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
<text x={W / 2} y={H - 8} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" {...bidi(mathText(spec.label ?? `${g} × ${each} = ${g * each}`))}>
|
||||
{(spec.label != null || !hide) && <text x={W / 2} y={H - 8} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" {...bidi(mathText(spec.label ?? `${g} × ${each} = ${g * each}`))}>
|
||||
{mathText(spec.label ?? `${g} × ${each} = ${g * each}`)}
|
||||
</text>
|
||||
</text>}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -233,6 +237,7 @@ function patternCells(layout: PatternSpec['layout'], n: number): [number, number
|
||||
}
|
||||
|
||||
export function Pattern({ spec }: { spec: PatternSpec }) {
|
||||
const hide = useContext(HideCounts);
|
||||
const stages = spec.stages;
|
||||
const sticks = spec.unit === 'stick';
|
||||
const sizes = stages.map((n) => {
|
||||
@@ -268,8 +273,8 @@ export function Pattern({ spec }: { spec: PatternSpec }) {
|
||||
? <circle key={k} cx={cx + u / 2} cy={cy + u / 2} r={u * 0.32} fill={col} />
|
||||
: <rect key={k} x={cx + 1} y={cy + 1} width={u - 2} height={u - 2} rx={2} fill={`${col}33`} stroke={col} strokeWidth={1.5} />;
|
||||
})}
|
||||
<text x={x0 + (s.w * u) / 2} y={base + 16} fontSize={11} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکلِ ${toFa(i + 1)}`}</text>
|
||||
{spec.counts !== false && <text x={x0 + (s.w * u) / 2} y={base + 30} fontSize={10.5} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(count)} ${sticks ? 'چوب' : 'تا'}`}</text>}
|
||||
<text x={x0 + (s.w * u) / 2} y={base + 16} fontSize={11} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکل ${toFa(i + 1)}`}</text>
|
||||
{spec.counts !== false && !hide && <text x={x0 + (s.w * u) / 2} y={base + 30} fontSize={10.5} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(count)} ${sticks ? 'چوب' : 'تا'}`}</text>}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
@@ -281,7 +286,7 @@ export function Pattern({ spec }: { spec: PatternSpec }) {
|
||||
<g>
|
||||
<rect x={nx} y={base - 1.4 * u} width={1.4 * u} height={1.4 * u} rx={6} fill="#fff" stroke={COLORS.red} strokeWidth={2} strokeDasharray="4 3" />
|
||||
<text x={nx + 0.7 * u} y={base - 0.45 * u} fontSize={u * 0.8} fontWeight={900} fill={COLORS.red} textAnchor="middle">?</text>
|
||||
<text x={nx + 0.7 * u} y={base + 16} fontSize={11} fontWeight={800} fill={COLORS.red} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکلِ ${toFa(stages.length + 1)}`}</text>
|
||||
<text x={nx + 0.7 * u} y={base + 16} fontSize={11} fontWeight={800} fill={COLORS.red} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکل ${toFa(stages.length + 1)}`}</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function NumberLine({ spec }: { spec: NumberLineSpec }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img" aria-label={spec.title || 'محورِ اعداد'}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img" aria-label={spec.title || 'محور اعداد'}>
|
||||
<Arrowheads ids={[...arrows, ...intervals.map((iv, i) => color(iv.color, COLORS[SERIES[i % SERIES.length]]))]} />
|
||||
<line x1={PAD - 16} y1={base} x2={W - PAD + 16} y2={base} stroke={INK} strokeWidth={2}
|
||||
markerEnd={`url(#${arrowId(INK)})`} markerStart={`url(#${arrowId(INK)})`} />
|
||||
|
||||
125
src/diagram/space.tsx
Normal file
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>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createContext, type ReactNode } from 'react';
|
||||
import type { ColorName } from './types';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
@@ -25,6 +25,21 @@ export const COLORS: Record<ColorName, string> = {
|
||||
/** ترتیبِ رنگِ سریها وقتی مدل رنگ نداده (نمودارِ دوم، ستونِ سوم، …). */
|
||||
export const SERIES: ColorName[] = ['blue', 'red', 'green', 'orange', 'purple', 'teal', 'pink', 'yellow'];
|
||||
|
||||
/** رنگِ هر ستون یا برشِ نمودار: اگر برچسب نامِ یک رنگ باشد («قرمز»، «مهرهی آبی (۳)») همان رنگ، وگرنه رنگهای
|
||||
* پیشفرض به ترتیب، بدون رنگهایی که برچسبی به نامشان هست؛ تا «قرمز» در چرخنده یا جدولِ فراوانی آبی کشیده نشود. */
|
||||
const COLOR_WORDS: [string, ColorName][] = [
|
||||
['قرمز', 'red'], ['سرخ', 'red'], ['آبی', 'blue'], ['سبز', 'green'], ['نارنجی', 'orange'], ['بنفش', 'purple'],
|
||||
['صورتی', 'pink'], ['زرد', 'yellow'], ['خاکستری', 'gray'], ['مشکی', 'black'], ['سیاه', 'black'],
|
||||
];
|
||||
const namedColor = (label: string): ColorName | undefined =>
|
||||
COLOR_WORDS.find(([w]) => new RegExp(`(^|[\\s(«])${w}($|[\\s)»])`).test(label))?.[1];
|
||||
export function seriesColors(labels: string[], n: number): string[] {
|
||||
const named = Array.from({ length: n }, (_, i) => namedColor(labels[i] ?? ''));
|
||||
const free = SERIES.filter((c) => !named.includes(c));
|
||||
let k = 0;
|
||||
return named.map((c) => COLORS[c ?? free[k++ % free.length]]);
|
||||
}
|
||||
|
||||
export const INK = COLORS.black;
|
||||
export const SOFT = '#9EA3B8';
|
||||
export const GRID = '#E4E6F0';
|
||||
@@ -141,3 +156,10 @@ export function piLabel(t: number): string {
|
||||
const head = n === 1 ? 'π' : `${toFa(n)}π`;
|
||||
return den === 1 ? `${sign}${head}` : `${sign}${head}/${toFa(den)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* زیرِ شکلِ سوالِ آزمون، شمارشِ خودکار («۲ تکه از ۵ تکه»، «۶ خانه از ۱۲ خانه»، «۶ × ۴ = ۲۴»، «۹ تا»)
|
||||
* جوابِ سوال را لو میداد؛ آزمون این را روشن میکند و شکل فقط خودِ داده را نشان میدهد.
|
||||
* در درس و جوابِ دستیار شمارش میماند چون آنجا توضیح است، نه سوال.
|
||||
*/
|
||||
export const HideCounts = createContext(false);
|
||||
|
||||
@@ -117,6 +117,14 @@ export interface SolidSpec {
|
||||
labels?: { a?: string; b?: string; c?: string; r?: string; h?: string };
|
||||
}
|
||||
|
||||
export type SpaceItem =
|
||||
| { type: 'point' | 'drop'; point: string; label?: string; color?: ColorName }
|
||||
| { type: 'vector' | 'segment'; from: string; to: string; label?: string; dashed?: boolean; color?: ColorName }
|
||||
| { type: 'polygon'; points: string[]; label?: string; color?: ColorName }
|
||||
| { type: 'box'; to: [number, number, number]; label?: string; color?: ColorName };
|
||||
/** فضای سهبعدی با تصویرِ مایلِ ثابت (x رو به بیننده، y راست، z بالا). */
|
||||
export interface SpaceSpec { kind: 'space'; title: string; axes?: boolean; points: Record<string, [number, number, number]>; items: SpaceItem[] }
|
||||
|
||||
export type DiagramSpec =
|
||||
| PlaneSpec | NumberLineSpec | FractionSpec | GridSpec | GroupsSpec | ClockSpec
|
||||
| ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec | PatternSpec;
|
||||
| ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec | PatternSpec | SpaceSpec;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment } from 'react';
|
||||
import { plainMath, plainPersian } from './plainMath';
|
||||
|
||||
/**
|
||||
* متنِ فارسی که ریاضی لایش است («عدد −۱ میشود»، «رأس (۱، −۴) است»).
|
||||
@@ -11,15 +12,19 @@ import { Fragment } from 'react';
|
||||
* فهرستِ داخلِ جمله («از −۳ به −۲، −۱، ۰ میرسیم»، «الگویِ ۲، ۵، ۸، …») مالِ جمله است و
|
||||
* از راست خوانده میشود؛ پس «،» و «؛» بیرونِ پرانتز و آکولاد تکه را میشکنند و هر عضو
|
||||
* جدا قفل میشود. داخلِ `{۱، ۲، ۳}` و `(۱، −۴)` ریاضی است و یکجا قفل میماند. سنجیده
|
||||
* با مختصاتِ رسمشده در Chrome روی همهی سؤالها و گزینههای بانک و چند جوابِ دستیار
|
||||
* با مختصاتِ رسمشده در Chrome روی همهی سوالها و گزینههای بانک و چند جوابِ دستیار
|
||||
* (۲۰۲۶-۰۹-۲۹)؛ پیش از آن هر ۴۸۲ فهرستِ داخلِ جمله برعکس دیده میشد.
|
||||
*
|
||||
* آزموده روی Chrome (دسکتاپ و موبایلِ شبیهسازیشده)؛ روی Safariِ واقعیِ آیفون دیده نشده.
|
||||
*/
|
||||
// حروف، اِعراب (کسرهی «خودِ»)، و نیمفاصله؛ رقمهای ۰–۹ عمداً بیروناند.
|
||||
const LETTER = '\\u0620-\\u065F\\u066E-\\u06D3\\u06FA-\\u06FF\\u200C';
|
||||
// تکهای بدونِ حرفِ فارسی و بدونِ خطِ تازه که دستِکم یک رقم دارد
|
||||
const RUN = new RegExp(`[^${LETTER}\\n]*[0-9۰-۹][^${LETTER}\\n]*`, 'g');
|
||||
// «نویسهی ریاضی»: رقم یا حرفِ لاتین (نامِ تابع و متغیر: f، x، A). بدونِ حرفِ لاتین «−f(x)» تکه حساب
|
||||
// نمیشد و در متنِ راستبهچپ بهصورتِ «f(x)−» دیده میشد (گزارشِ ۲۰۲۶-۱۰-۱۲).
|
||||
const MATH_CH = '0-9۰-۹A-Za-z';
|
||||
// تکهای بدونِ حرفِ فارسی و بدونِ خطِ تازه که دستِکم یک نویسهی ریاضی دارد
|
||||
const RUN = new RegExp(`[^${LETTER}\\n]*[${MATH_CH}][^${LETTER}\\n]*`, 'g');
|
||||
const HAS_MATH = new RegExp(`[${MATH_CH}]`);
|
||||
// نشانههایی که مالِ جملهی فارسیاند، نه ریاضی: از دو سرِ تکه بیرون میمانند
|
||||
const EDGE_START = /^[\s،؛:!؟«»"'.=]+/;
|
||||
const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
|
||||
@@ -28,7 +33,126 @@ const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
|
||||
// سمتِ خودشان میکشیدند، پس فهرست باز برعکس میشد.
|
||||
const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const;
|
||||
|
||||
export function MathText({ text }: { text: string }) {
|
||||
// فرمولِ «واقعی» (نه عددِ تنها): نمادِ رابطه یا عمل دارد و دستِکم چهار نویسه است.
|
||||
const FORMULA = /[=<>≤≥≠∈∉⊂⊆∪∩+×÷^√∑∫]|[0-9۰-۹A-Za-z)][\s]*[−\-/][\s]*[0-9۰-۹A-Za-z(]/;
|
||||
|
||||
/**
|
||||
* `display`: فرمولِ وسطِ جملهی فارسی در خطِ جدا و وسطچین مینشیند (سوالها و درسها)، تا
|
||||
* چشم وسطِ فرمول به خطِ بعد نیفتد. عددِ تنها و عبارتِ کوتاهِ بدونِ نمادِ عمل داخلِ جمله میماند.
|
||||
* آزموده نشده: صفحهی واقعیِ اپ در Chrome و Safari؛ فقط منطقِ تشخیص با نمونههای بانک سنجیده شد.
|
||||
*/
|
||||
// ماتریس «[[۲، ۷]، [۴، ۱]]»: ردیفها داخلِ کروشهی تو در تو. اگر مثلِ متنِ عادی بگذاریم، در خطِ باریک
|
||||
// وسطِ ردیف شکسته میشود و راستبهچپ بودنِ جمله عددها را جابهجا نشان میدهد (گزارشِ مالک ۲۰۲۶-۱۰-۱۴).
|
||||
const MATRIX = /\[\s*\[[^\[\]]*\](?:\s*[،,]\s*\[[^\[\]]*\])*\s*\]/g;
|
||||
|
||||
function Matrix({ src }: { src: string }) {
|
||||
const rows = [...src.matchAll(/\[([^\[\]]*)\]/g)].map((m) => m[1].split(/[،,]/).map((c) => plainPersian(plainMath(c.trim()))));
|
||||
const border = '2px solid currentColor';
|
||||
return (
|
||||
<span
|
||||
dir="ltr"
|
||||
style={{ display: 'inline-block', verticalAlign: 'middle', margin: '4px 6px', padding: '2px 8px', borderLeft: border, borderRight: border, borderRadius: 6, unicodeBidi: 'isolate' }}
|
||||
>
|
||||
{rows.map((row, i) => (
|
||||
<span key={i} style={{ display: 'flex', justifyContent: 'center', gap: 14, lineHeight: 1.7, whiteSpace: 'nowrap' }}>
|
||||
{row.map((c, j) => <span key={j} style={{ minWidth: 18, textAlign: 'center' }}>{c}</span>)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* سطرهای ماتریس که خطِبهخط نوشته شدهاند («b₁₁ b₁₂ ⋯ b(1n)» زیرِ «b₂₁ b₂₂ ⋯ b(2n)» و «⋮ ⋮ ⋱ ⋮»):
|
||||
* بدونِ ستونبندی، نقطههای عمودی زیرِ درایهها نمیافتند و فاصلهی دوتایی هم در HTML فرو میریزد.
|
||||
* دو خطِ پشتِ هم یا بیشتر که بدونِ حرفِ فارسیاند و تعدادِ توکنشان برابر (≥۳) است، یک جدول میشوند.
|
||||
* سطرِ اول میتواند برچسب داشته باشد («B(m×n) =»).
|
||||
*/
|
||||
const PERSIAN = /[-ۿ]/;
|
||||
function parseRow(line: string): { label: string; cells: string[] } | null {
|
||||
const t = line.trim();
|
||||
if (!t || PERSIAN.test(t)) return null;
|
||||
const m = /^(.*?\s=\s+)(\S.*)$/.exec(t);
|
||||
const label = m ? m[1].trim() : '';
|
||||
const cells = (m ? m[2] : t).split(/\s+/);
|
||||
// معادله («f(1) = 1² + 1 = 2») سطرِ ماتریس نیست: توکنِ عملگر یعنی ماتریس نیست.
|
||||
// نمادِ تکی (+ = ⊆ → …) جز نقطهچینها و شمارهی فهرست («۲)») هم یعنی ماتریس نیست.
|
||||
if (cells.some((c) => (/^[^\p{L}\p{N}]$/u.test(c) && !'⋯⋮⋱…'.includes(c)) || /^[0-9۰-۹]+[.)]$/.test(c))) return null;
|
||||
return cells.length >= 3 ? { label, cells } : null;
|
||||
}
|
||||
|
||||
function splitGrids(raw: string): (string | { rows: { label: string; cells: string[] }[] })[] | null {
|
||||
const lines = raw.split('\n');
|
||||
const parts: (string | { rows: { label: string; cells: string[] }[] })[] = [];
|
||||
let buf: string[] = [], found = false, i = 0;
|
||||
while (i < lines.length) {
|
||||
const first = parseRow(lines[i]);
|
||||
let j = i;
|
||||
const rows: { label: string; cells: string[] }[] = [];
|
||||
while (first && j < lines.length) {
|
||||
const r = parseRow(lines[j]);
|
||||
if (!r || r.cells.length !== first.cells.length) break;
|
||||
rows.push(r);
|
||||
j++;
|
||||
}
|
||||
if (rows.length >= 2) {
|
||||
if (buf.length) parts.push(buf.join('\n'));
|
||||
buf = [];
|
||||
parts.push({ rows });
|
||||
found = true;
|
||||
i = j;
|
||||
} else {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (buf.length) parts.push(buf.join('\n'));
|
||||
return found ? parts : null;
|
||||
}
|
||||
|
||||
function Grid({ rows }: { rows: { label: string; cells: string[] }[] }) {
|
||||
const border = '2px solid currentColor';
|
||||
const label = rows.find((r) => r.label)?.label ?? '';
|
||||
const clean = (c: string) => plainPersian(plainMath(c));
|
||||
return (
|
||||
<span dir="ltr" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', gap: '2px 8px', margin: '6px 0', maxWidth: '100%', unicodeBidi: 'isolate', fontWeight: 700 }}>
|
||||
{label && <span style={{ whiteSpace: 'nowrap' }}>{clean(label)}</span>}
|
||||
<span style={{ display: 'inline-grid', gridTemplateColumns: `repeat(${rows[0].cells.length}, auto)`, columnGap: 10, rowGap: 4, padding: '2px 8px', maxWidth: '100%', overflowX: 'auto', borderLeft: border, borderRight: border, borderRadius: 6 }}>
|
||||
{rows.flatMap((r, i) => r.cells.map((c, j) => <span key={`${i}-${j}`} style={{ textAlign: 'center', whiteSpace: 'nowrap', lineHeight: 1.7 }}>{clean(c)}</span>))}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MathText({ text: raw, display = false }: { text: string; display?: boolean }) {
|
||||
const grids = splitGrids(raw);
|
||||
if (grids) {
|
||||
return <>{grids.map((p, k) => (typeof p === 'string' ? <MathText key={k} text={p} display={display} /> : <Grid key={k} rows={p.rows} />))}</>;
|
||||
}
|
||||
if (!MATRIX.test(raw)) { MATRIX.lastIndex = 0; return <Inline raw={raw} display={display} />; }
|
||||
MATRIX.lastIndex = 0;
|
||||
const out: React.ReactNode[] = [];
|
||||
let last = 0, k = 0;
|
||||
for (const m of raw.matchAll(MATRIX)) {
|
||||
const at = m.index ?? 0;
|
||||
if (at > last) out.push(<Inline key={`t${k++}`} raw={raw.slice(last, at)} display={display} />);
|
||||
out.push(<Matrix key={`m${k++}`} src={m[0]} />);
|
||||
last = at + m[0].length;
|
||||
}
|
||||
if (last < raw.length) out.push(<Inline key={`t${k++}`} raw={raw.slice(last)} display={display} />);
|
||||
return <>{out}</>;
|
||||
}
|
||||
|
||||
function Inline({ raw, display }: { raw: string; display: boolean }) {
|
||||
const first = build(raw, display);
|
||||
// دو فرمولِ بلوکی در یک جمله («a₁ = … و a₂ = …») هر کدام خطِ جدا میگرفتند و «و» و «.» تنها میماندند.
|
||||
const { nodes } = first.blocks > 1 ? build(raw, false) : first;
|
||||
return <>{nodes}</>;
|
||||
}
|
||||
|
||||
function build(raw: string, display: boolean): { nodes: React.ReactNode[]; blocks: number } {
|
||||
const text = plainPersian(plainMath(raw));
|
||||
let blocks = 0;
|
||||
const out: React.ReactNode[] = [];
|
||||
const depth = bracketDepth(text);
|
||||
let last = 0, i = 0;
|
||||
@@ -48,14 +172,21 @@ export function MathText({ text }: { text: string }) {
|
||||
const edge = trimUnpaired(run);
|
||||
const head = lead + edge.head;
|
||||
run = edge.run;
|
||||
if (!run || !/[0-9۰-۹]/.test(run)) continue;
|
||||
if (!run || !HAS_MATH.test(run)) continue;
|
||||
out.push(text.slice(last, start + head.length));
|
||||
const before = text.slice(0, start + head.length), after = text.slice(start + head.length + run.length);
|
||||
const inSentence = new RegExp(`[${LETTER}]`).test(before) || new RegExp(`[${LETTER}]`).test(after);
|
||||
if (display && inSentence && run.trim().length >= 4 && FORMULA.test(run) && depth[start + head.length] === 0) {
|
||||
blocks++;
|
||||
out.push(<span key={i++} style={{ ...LTR, display: 'block', textAlign: 'center', margin: '4px 0', fontWeight: 800 }}>{run}</span>);
|
||||
} else {
|
||||
out.push(<span key={i++} style={LTR}>{run}</span>);
|
||||
}
|
||||
last = start + head.length + run.length;
|
||||
}
|
||||
}
|
||||
out.push(text.slice(last));
|
||||
return <>{out.map((n, j) => (typeof n === 'string' ? <Fragment key={`t${j}`}>{n}</Fragment> : n))}</>;
|
||||
return { nodes: out.map((n, j) => (typeof n === 'string' ? <Fragment key={`t${j}`}>{n}</Fragment> : n)), blocks };
|
||||
}
|
||||
|
||||
/** عمقِ پرانتز/کروشه/آکولاد پیش از هر نویسهی متن. */
|
||||
@@ -102,5 +233,16 @@ function trimUnpaired(run: string): { head: string; run: string } {
|
||||
head += m;
|
||||
run = run.slice(m.length);
|
||||
}
|
||||
// «…اگر i<j).»: پرانتزِ بستهی بیجفتِ تهِ تکه مالِ جملهی فارسی است. اگر داخلِ قفلِ چپبهراست بماند،
|
||||
// سمتِ راستِ j با شکلِ «)» دیده میشود، نه سمتِ چپ و آینهشده (گزارشِ مالک ۲۰۲۶-۱۰-۱۵).
|
||||
while (/[)\]}]\s*$/.test(run) && closes(run) > opens(run)) {
|
||||
run = run.slice(0, run.length - /\s*[)\]}]\s*$/.exec(run)![0].length);
|
||||
}
|
||||
// و پرانتزِ بازِ بیجفتِ سرِ تکه («(مثلاً a=7 …» که بستهاش جای دیگری است) هم مالِ جمله است.
|
||||
while (/^\s*[(\[{]/.test(run) && opens(run) > closes(run)) {
|
||||
const m = /^\s*[(\[{]\s*/.exec(run)![0];
|
||||
head += m;
|
||||
run = run.slice(m.length);
|
||||
}
|
||||
return { head, run };
|
||||
}
|
||||
|
||||
47
src/lib/StepText.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
@@ -5,24 +5,31 @@ import { store } from './storage';
|
||||
*
|
||||
* خریدِ درونبرنامهایِ بازار فقط از کدِ بومیِ اندروید (کتابخانهی Poolakey) شدنی است، پس
|
||||
* اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه میگذارد و این فایل تنها
|
||||
* جایی است که با آن حرف میزند. در مرورگرِ معمولی آن شیء نیست، `inBazaarApp()` false است و
|
||||
* خرید مثلِ قبل به زرینپال میرود.
|
||||
* جایی است که با آن حرف میزند. در مرورگرِ معمولی آن شیء نیست و خرید با زرینپال است.
|
||||
*
|
||||
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
|
||||
* JS → اندروید TadrisinoAndroid.bazaarPurchase(sku, orderId) خرید را شروع کن؛ orderId همان
|
||||
* developerPayloadِ خرید است.
|
||||
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) بعد از تأییدِ سرور؛ بدونِ آن
|
||||
* همان بسته دوباره خریدنی نیست.
|
||||
* JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
|
||||
* خریدِ اشتراک با subscribeProduct (فقط اشتراک فروخته میشود)؛ orderId همان payloadِ خرید است؛
|
||||
* dynamicPriceToken رشتهی خالی یعنی قیمتِ پیشخان.
|
||||
* JS → اندروید TadrisinoAndroid.bazaarPrices(skusJson) قیمتِ پیشخان؛ جواب با رویدادِ «tadrisino:bazaar-prices»
|
||||
* و detailِ { prices: { sku: «۸۹۸,۰۰۰ تومان» } } یا { error }
|
||||
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
|
||||
* { orderId, purchaseToken } خرید انجام شد
|
||||
* { orderId, error: 'canceled' | 'failed', message? }
|
||||
* { orderId, error: 'canceled' | 'failed' | 'unavailable', message? }
|
||||
* («unavailable»: بازار نصب نیست/وصل نشد یا قدیمی است؛ message: not_installed | old_bazaar | not_connected)
|
||||
*
|
||||
* هیچکدام از اینها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۰۳): اپِ اندروید ساخته نشده.
|
||||
* `bazaarBuy` از APKهای پس از ۲۰۲۶-۱۰-۱۲ است. APKهای قبلی کلیدِ RSA نداشتند و Poolakey در آنها خاموش بود، پس از
|
||||
* بازار هیچ خریدی نکردهاند؛ در آنها کاشیِ بازار غیرفعال است و «اپ را بهروز کن» میگوید.
|
||||
* هیچکدام از اینها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۱۲).
|
||||
*/
|
||||
|
||||
interface AndroidBridge {
|
||||
bazaarBuy?: (sku: string, orderId: string, dynamicPriceToken: string) => void;
|
||||
/** APKِ قدیمی؛ فقط برای اینکه بدانیم داخلِ اپیم ولی خرید از بازار در آن ممکن نیست. */
|
||||
bazaarPurchase?: (sku: string, orderId: string) => void;
|
||||
bazaarConsume?: (purchaseToken: string) => void;
|
||||
bazaarPrices?: (skusJson: string) => void;
|
||||
/** اولین صفحهی واقعی کشیده شد؛ اسپلشِ سیستم میتواند برود (APKهای قدیمیتر این را ندارند). */
|
||||
appReady?: () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -33,25 +40,31 @@ declare global {
|
||||
|
||||
type BridgeResult =
|
||||
| { orderId: string; purchaseToken: string }
|
||||
| { orderId: string; error: 'canceled' | 'failed'; message?: string };
|
||||
| { orderId: string; error: 'canceled' | 'failed' | 'unavailable'; message?: string };
|
||||
|
||||
export class BazaarPurchaseError extends Error {
|
||||
constructor(readonly kind: 'canceled' | 'failed' | 'timeout', message = '') {
|
||||
constructor(readonly kind: 'canceled' | 'failed' | 'unavailable' | 'timeout', message = '') {
|
||||
super(message || kind);
|
||||
}
|
||||
}
|
||||
|
||||
export function inBazaarApp(): boolean {
|
||||
return typeof window.TadrisinoAndroid?.bazaarPurchase === 'function';
|
||||
/** داخلِ اپِ اندرویدِ تدریسینوییم (هر نسخهای). */
|
||||
export function inAndroidApp(): boolean {
|
||||
return typeof window.TadrisinoAndroid === 'object' && window.TadrisinoAndroid !== null;
|
||||
}
|
||||
|
||||
/** اپی که میتواند از بازار اشتراک بخرد. */
|
||||
export function canBuyFromBazaar(): boolean {
|
||||
return typeof window.TadrisinoAndroid?.bazaarBuy === 'function';
|
||||
}
|
||||
|
||||
/** صفحهی خریدِ بازار ممکن است طول بکشد (ورود به بازار، کارتِ بانکی)؛ این سقف فقط جلوی گیر کردنِ همیشگی را میگیرد. */
|
||||
const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
/** خرید را به اپِ اندروید میسپارد و purchaseToken را برمیگرداند. */
|
||||
export function bazaarPurchase(sku: string, orderId: string): Promise<string> {
|
||||
export function bazaarBuy(sku: string, orderId: string, dynamicPriceToken: string | null): Promise<string> {
|
||||
const bridge = window.TadrisinoAndroid;
|
||||
if (!bridge?.bazaarPurchase) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app'));
|
||||
if (!bridge?.bazaarBuy) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app'));
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => finish(() => reject(new BazaarPurchaseError('timeout'))), PURCHASE_TIMEOUT_MS);
|
||||
function finish(settle: () => void) {
|
||||
@@ -67,19 +80,51 @@ export function bazaarPurchase(sku: string, orderId: string): Promise<string> {
|
||||
}
|
||||
window.addEventListener('tadrisino:bazaar', onResult);
|
||||
try {
|
||||
bridge.bazaarPurchase!(sku, orderId);
|
||||
bridge.bazaarBuy!(sku, orderId, dynamicPriceToken ?? '');
|
||||
} catch (err) {
|
||||
finish(() => reject(new BazaarPurchaseError('failed', String(err))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function bazaarConsume(purchaseToken: string) {
|
||||
type PricesResult = { prices?: Record<string, string>; error?: string };
|
||||
|
||||
function askPrices(skus: string[]): Promise<Record<string, string>> {
|
||||
const bridge = window.TadrisinoAndroid;
|
||||
if (!bridge?.bazaarPrices || !skus.length) return Promise.resolve({});
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => done(() => reject(new Error('timeout'))), 8000);
|
||||
function done(settle: () => void) {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener('tadrisino:bazaar-prices', onResult);
|
||||
settle();
|
||||
}
|
||||
function onResult(event: Event) {
|
||||
const detail = (event as CustomEvent<PricesResult>).detail;
|
||||
if (!detail) return;
|
||||
if (detail.prices) done(() => resolve(detail.prices!));
|
||||
else done(() => reject(new Error(detail.error || 'failed')));
|
||||
}
|
||||
window.addEventListener('tadrisino:bazaar-prices', onResult);
|
||||
bridge.bazaarPrices!(JSON.stringify(skus));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* قیمتِ ثبتشده در پیشخانِ بازار برای هر SKU، از خودِ بازار روی گوشی (API سرور قیمت نمیدهد). اتصالِ Poolakey
|
||||
* چند لحظه بعد از باز شدنِ اپ برقرار میشود، پس یک بار دوباره امتحان میشود. خطا یعنی قیمتی نشان داده نمیشود.
|
||||
*/
|
||||
export async function bazaarPrices(skus: string[]): Promise<Record<string, string>> {
|
||||
try {
|
||||
window.TadrisinoAndroid?.bazaarConsume?.(purchaseToken);
|
||||
return await askPrices(skus);
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
try {
|
||||
return await askPrices(skus);
|
||||
} catch (err) {
|
||||
// مصرف نشدن یعنی خریدِ دوبارهی همین بسته تا مصرفِ بعدی گیر میکند؛ اعتبارِ کاربر سالم است.
|
||||
console.error('bazaarConsume failed', err);
|
||||
console.error('bazaarPrices failed', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
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
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
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
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, 'ه');
|
||||
}
|
||||
142
src/lib/sound.ts
142
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,46 +53,113 @@ export function setSoundEnabled(on: boolean) {
|
||||
store.set(KEYS.sound, on ? 'on' : 'off');
|
||||
}
|
||||
|
||||
function tone(frequency: number, start: number, duration: number, volume = 0.05) {
|
||||
/** یک نتِ نرم: حملهی کوتاه و افتِ نمایی، با کمی هارمونیک تا شبیهِ بوقِ سینوسیِ خالی نباشد. */
|
||||
function note(frequency: number, start: number, duration: number, volume = 0.05, type: OscillatorType = 'sine') {
|
||||
const audio = context();
|
||||
if (!audio || !soundEnabled()) return;
|
||||
if (audio.state === 'suspended') void audio.resume();
|
||||
const t = audio.currentTime + start;
|
||||
const osc = audio.createOscillator();
|
||||
const gain = audio.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(frequency, t);
|
||||
gain.gain.setValueAtTime(0.0001, t);
|
||||
gain.gain.exponentialRampToValueAtTime(volume, t + 0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(volume, t + 0.012);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
|
||||
osc.connect(gain).connect(audio.destination);
|
||||
gain.connect(audio.destination);
|
||||
for (const [mult, level] of [[1, 1], [2, 0.18], [3, 0.06]] as const) {
|
||||
const osc = audio.createOscillator();
|
||||
const g = audio.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(frequency * mult, t);
|
||||
g.gain.value = level;
|
||||
osc.connect(g).connect(gain);
|
||||
osc.start(t);
|
||||
osc.stop(t + duration + 0.02);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* لرزشِ کوتاه کنارِ صدا، با همان کلیدِ «صدا» خاموش میشود. فقط اندروید/کروم دارد؛
|
||||
* Safariِ آیفون `vibrate` ندارد و اینجا هیچ کاری نمیکند.
|
||||
* لرزش. اپِ اندروید (android/MainActivity.kt) از نسخهی بعدی `TadrisinoAndroid.vibrate` دارد که مستقیم از
|
||||
* Vibratorِ گوشی میلرزاند؛ تا آن نسخه ساخته نشده `navigator.vibrate` امتحان میشود، که همهی WebViewها
|
||||
* اجرایش نمیکنند. Safariِ آیفون لرزش ندارد.
|
||||
*/
|
||||
function buzz(pattern: number | number[]) {
|
||||
if (!soundEnabled() || typeof navigator.vibrate !== 'function') return;
|
||||
if (!soundEnabled()) return;
|
||||
const bridge = (window as unknown as { TadrisinoAndroid?: AndroidHaptics }).TadrisinoAndroid;
|
||||
try {
|
||||
navigator.vibrate(pattern);
|
||||
} catch {
|
||||
/* بعضی WebViewها بیاجازه throw میکنند؛ لرزش فقط تزئینی است */
|
||||
if (bridge?.vibrate) bridge.vibrate(JSON.stringify(Array.isArray(pattern) ? pattern : [pattern]));
|
||||
else if (typeof navigator.vibrate === 'function') navigator.vibrate(pattern);
|
||||
} catch (err) {
|
||||
console.warn('vibrate failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// نتها (هرتز)
|
||||
const C5 = 523.25, E5 = 659.25, G5 = 783.99, C6 = 1046.5, E6 = 1318.5, G6 = 1568;
|
||||
|
||||
export const sounds = {
|
||||
tick: () => tone(720, 0, 0.045, 0.04),
|
||||
correct: () => {
|
||||
tone(660, 0, 0.12);
|
||||
tone(990, 0.1, 0.18);
|
||||
buzz(30);
|
||||
/** لمسِ گزینهی آزمون: یک «تیک»ِ خیلی ریز. */
|
||||
select: () => {
|
||||
note(880, 0, 0.05, 0.025, 'triangle');
|
||||
buzz(8);
|
||||
},
|
||||
/** `pitch`: نیمپرده بالاتر برای همراهِ انتخابی (state/companion)؛ هر همراه صدای خودش را دارد. */
|
||||
correct: (pitch = 0) => {
|
||||
const k = 2 ** (pitch / 12);
|
||||
note(E5 * k, 0, 0.12, 0.05);
|
||||
note(C6 * k, 0.09, 0.22, 0.05);
|
||||
buzz(25);
|
||||
},
|
||||
/** دلتا: جوابِ درستِ پشتِ هم یک لرزشِ دوتایی و یک نتِ بالاتر اضافه دارد. */
|
||||
combo: () => {
|
||||
note(G6, 0.2, 0.15, 0.03);
|
||||
buzz([20, 40, 30]);
|
||||
},
|
||||
wrong: () => {
|
||||
tone(220, 0, 0.25, 0.06);
|
||||
buzz([40, 60, 40]);
|
||||
note(311, 0, 0.16, 0.045, 'triangle');
|
||||
note(233, 0.12, 0.24, 0.045, 'triangle');
|
||||
buzz([35, 50, 35]);
|
||||
},
|
||||
finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)),
|
||||
/** پایانِ درس/جزیره: آرپژِ بالارونده و یک آکوردِ کوتاه. */
|
||||
finish: () => {
|
||||
[C5, E5, G5, C6].forEach((f, i) => note(f, i * 0.09, 0.22, 0.045));
|
||||
[C6, E6, G6].forEach((f) => note(f, 0.4, 0.5, 0.025));
|
||||
buzz([30, 60, 30, 60, 90]);
|
||||
},
|
||||
/** زنجیره روشن ماند: «فوش»ِ آتش و دو نتِ بالارونده. */
|
||||
streak: () => {
|
||||
whoosh(0, 0.55, 0.06);
|
||||
note(G5, 0.45, 0.18, 0.045);
|
||||
note(C6, 0.58, 0.4, 0.05);
|
||||
buzz([20, 40, 20, 40, 160]);
|
||||
},
|
||||
/** لرزشِ بسیار کوتاه برای تعویضِ تب؛ بیصدا. */
|
||||
tap: () => buzz(6),
|
||||
};
|
||||
|
||||
@@ -28,4 +28,9 @@ export const KEYS = {
|
||||
conversationId: 'tadrisino.conversationId',
|
||||
sound: 'tadrisino.sound',
|
||||
referralCode: 'tadrisino.referralCode',
|
||||
companion: 'tadrisino.companion',
|
||||
/** {gradeId: subjectKey}؛ کتابی که کاربر در هر پایه آخر انتخاب کرده. */
|
||||
subjects: 'tadrisino.subjects',
|
||||
/** عکسِ صفحهی خانه برای بازشدنِ فوری (state/homeSnapshot.ts). */
|
||||
homeSnapshot: 'tadrisino.homeSnapshot',
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState, type DependencyList } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type DependencyList } from 'react';
|
||||
|
||||
export interface AsyncState<T> {
|
||||
data: T | undefined;
|
||||
@@ -8,39 +8,104 @@ export interface AsyncState<T> {
|
||||
setData: (update: T | ((prev: T | undefined) => T | undefined)) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* کشِ همین نشست برای پاسخهای GET، با کلیدی که صفحه میدهد. صفحهای که دوباره باز میشود (برگشت، تعویضِ تب)
|
||||
* فوراً با دادهی قبلی رسم میشود و همان لحظه بیصدا از سرور تازه میشود (stale-while-revalidate). قبلاً هر بار
|
||||
* اول کارتِ خالی یا «نداری» دیده میشد و چند صد میلیثانیه بعد دادهی واقعی جایش میپرید.
|
||||
* با خروج از حساب پاک میشود (`clearAsyncCache`)، تا دادهی یک کاربر به کاربرِ بعدی نرسد.
|
||||
*/
|
||||
const cache = new Map<string, unknown>();
|
||||
|
||||
export function clearAsyncCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* «دروازه»ی صفحهی تازه: Navigator صفحهی بعدی را پنهان زیرِ صفحهی فعلی میسازد و تا وقتی دادهی اولش
|
||||
* نرسیده (یا سقفِ انتظار نگذشته) نشانش نمیدهد. هر useAsyncی که بارِ اولش بیکش است یک hold میگیرد.
|
||||
* بیرون از صفحهها (مثلاً جریانِ ورود) این context خالی است و کاری نمیکند.
|
||||
*/
|
||||
export interface ScreenGate {
|
||||
hold: () => () => void;
|
||||
}
|
||||
export const ScreenGateContext = createContext<ScreenGate | null>(null);
|
||||
|
||||
/**
|
||||
* برای صفحهای که دادهاش را بی useAsync میگیرد (آزمون، دستیار): تا `loading` درست است، صفحهی تازه پنهان
|
||||
* میماند. همان قاعدهی useAsync: دروازهی بازشده hold را نادیده میگیرد.
|
||||
*/
|
||||
export function useGateHold(loading: boolean) {
|
||||
const gate = useContext(ScreenGateContext);
|
||||
useEffect(() => (loading && gate ? gate.hold() : undefined), [loading, gate]);
|
||||
}
|
||||
|
||||
/**
|
||||
* دادهای که از سرور میآید، با حالتهای بارگذاری/خطا و «دوباره».
|
||||
* پاسخِ درخواستِ کهنه (بعد از عوض شدنِ deps) نادیده گرفته میشود.
|
||||
* با `key`، پاسخ در کشِ نشست میماند و بارِ بعد فوراً نشان داده میشود (بالای همین فایل).
|
||||
*/
|
||||
export function useAsync<T>(load: () => Promise<T>, deps: DependencyList): AsyncState<T> {
|
||||
const [data, setData] = useState<T | undefined>(undefined);
|
||||
export function useAsync<T>(load: () => Promise<T>, deps: DependencyList, opts: { key?: string } = {}): AsyncState<T> {
|
||||
const { key } = opts;
|
||||
const [data, setDataState] = useState<T | undefined>(() => (key !== undefined ? (cache.get(key) as T | undefined) : undefined));
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(() => key === undefined || !cache.has(key));
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const loadRef = useRef(load);
|
||||
loadRef.current = load;
|
||||
const gate = useContext(ScreenGateContext);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const hit = key !== undefined && cache.has(key);
|
||||
if (hit) {
|
||||
// کلید عوض شده (مثلاً «هفتگی» ← «همیشه») و برای کلیدِ تازه کش هست: همان را نشان بده و بیلودر تازه کن.
|
||||
setDataState(cache.get(key) as T);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
// دروازهای که یک بار باز شده hold را نادیده میگیرد؛ پس «دوباره» و تازهسازی صفحه را پنهان نمیکنند.
|
||||
const release = !hit && gate ? gate.hold() : null;
|
||||
loadRef.current().then(
|
||||
(value) => {
|
||||
if (!alive) return;
|
||||
setData(value);
|
||||
if (key !== undefined) cache.set(key, value);
|
||||
setDataState(value);
|
||||
setLoading(false);
|
||||
release?.();
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (!alive) return;
|
||||
if (hit) {
|
||||
// دادهی کششده روی صفحه است؛ شکستِ تازهسازی آن را با صفحهی خطا عوض نمیکند، ولی دیده میشود.
|
||||
console.warn(`refresh of ${key} failed; showing cached data`, err);
|
||||
} else {
|
||||
setError(err);
|
||||
}
|
||||
setLoading(false);
|
||||
release?.();
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
release?.();
|
||||
};
|
||||
}, [...deps, nonce]);
|
||||
}, [...deps, key, nonce]);
|
||||
|
||||
const reload = useCallback(() => setNonce((n) => n + 1), []);
|
||||
const setData = useCallback(
|
||||
(update: T | ((prev: T | undefined) => T | undefined)) => {
|
||||
setDataState((prev) => {
|
||||
const next = typeof update === 'function' ? (update as (p: T | undefined) => T | undefined)(prev) : update;
|
||||
if (key !== undefined) {
|
||||
if (next === undefined) cache.delete(key);
|
||||
else cache.set(key, next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[key],
|
||||
);
|
||||
return { data, error, loading, reload, setData };
|
||||
}
|
||||
|
||||
61
src/main.tsx
61
src/main.tsx
@@ -1,7 +1,64 @@
|
||||
import { StrictMode, lazy, Suspense } from 'react';
|
||||
import { Component, StrictMode, lazy, Suspense, type ReactNode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './app/App';
|
||||
import './styles/index.css';
|
||||
import { unlockAudioOnFirstTouch } from './lib/sound';
|
||||
import { startLineFit } from './lib/lineFit';
|
||||
|
||||
unlockAudioOnFirstTouch();
|
||||
startLineFit();
|
||||
|
||||
/**
|
||||
* فایلِ یک صفحه بعد از انتشارِ تازه پیدا نشد (اپ از قبل باز بود و نامِ فایلها با هر build عوض میشود): راهِ پیشنهادیِ
|
||||
* Vite یک بار بارگذاریِ دوباره است تا index.htmlِ تازه بیاید. تا ۳۰ ثانیه فقط یک بار، تا خرابیِ واقعیِ شبکه حلقه نشود؛
|
||||
* بعد از آن خطا به RootBoundary میرسد.
|
||||
*/
|
||||
const RELOAD_KEY = 'tadrisino.chunkReloadAt';
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
let last = 0;
|
||||
try {
|
||||
last = Number(sessionStorage.getItem(RELOAD_KEY)) || 0;
|
||||
} catch {
|
||||
/* sessionStorage بسته؛ بینگهبانِ حلقه بارگذاری نمیکنیم */
|
||||
return;
|
||||
}
|
||||
if (Date.now() - last < 30_000) return;
|
||||
event.preventDefault();
|
||||
try {
|
||||
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
/**
|
||||
* آخرین تور: هر خطای رندر که جای دیگری گرفته نشد، بهجای صفحهی سفید (React بیboundary کلِ درخت را برمیدارد)
|
||||
* پیامی با «دوباره» نشان میدهد. سبکها درونخطیاند چون ممکن است خودِ CSS یا فونت هم نرسیده باشد.
|
||||
*/
|
||||
class RootBoundary extends Component<{ children: ReactNode }, { error: unknown }> {
|
||||
state = { error: null as unknown };
|
||||
static getDerivedStateFromError(error: unknown) {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error: unknown) {
|
||||
console.error('render crashed', error);
|
||||
// داخلِ اپِ اندروید اسپلشِ بومی تا appReady میماند و این پیام را میپوشاند.
|
||||
window.TadrisinoAndroid?.appReady?.();
|
||||
}
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
return (
|
||||
<div dir="rtl" role="alert" style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16, padding: 24, background: '#fff', fontFamily: 'system-ui, sans-serif', textAlign: 'center' }}>
|
||||
<p style={{ fontSize: 16, fontWeight: 800, color: '#1a1a2e' }}>یه مشکلی پیش اومد</p>
|
||||
<p style={{ fontSize: 13, color: '#6b6b80', lineHeight: 1.8 }}>پیشرفتت سر جاشه؛ یه بار دیگه بازش کن.</p>
|
||||
<button type="button" onClick={() => window.location.reload()} style={{ border: 0, borderRadius: 16, padding: '12px 32px', background: '#ED6956', color: '#fff', fontSize: 15, fontWeight: 800 }}>
|
||||
دوباره
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// گالریِ شکلها فقط در حالتِ توسعه؛ در build اصلاً وارد نمیشود (import.meta.env.DEV ثابت است).
|
||||
const DiagramGallery = import.meta.env.DEV
|
||||
@@ -11,10 +68,12 @@ const showGallery = DiagramGallery && new URLSearchParams(window.location.search
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<RootBoundary>
|
||||
{showGallery && DiagramGallery ? (
|
||||
<Suspense fallback={null}><DiagramGallery /></Suspense>
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
</RootBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -199,9 +199,9 @@ export const SEYAR_SVG: Record<SeyarChar, (mood: SeyarMood) => string> = {
|
||||
};
|
||||
export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = {
|
||||
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
|
||||
delta: { name: "دلتا", symbol: "Δ", role: "همتیمیِ تمرینهای سریع" },
|
||||
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
|
||||
tedi: { name: "امگا", symbol: "ω", role: "رفیقِ همیشگیِ تدریسینو" },
|
||||
delta: { name: "دلتا", symbol: "Δ", role: "همتیمی تمرینهای سریع" },
|
||||
pi: { name: "پی", symbol: "π", role: "همراه دلگرمی" },
|
||||
tedi: { name: "امگا", symbol: "ω", role: "رفیق همیشگی تدریسینو ریاضی" },
|
||||
};
|
||||
|
||||
/** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراهها نگاشت میکند. */
|
||||
|
||||
@@ -1,68 +1,181 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import type { Route, TabName } from './routes';
|
||||
|
||||
/**
|
||||
* ناوبریِ پشتهای، هماهنگ با تاریخچهی مرورگر.
|
||||
* ناوبریِ پشتهای، هماهنگ با تاریخچهی مرورگر، تا دکمهی «برگشت»ِ گوشی مثلِ اپهای بومی قدمبهقدم عقب برود:
|
||||
* برگهی باز ← صفحهی قبلی ← تبِ خانه ← بیرون از اپ.
|
||||
*
|
||||
* هر push یک ورودی به history اضافه میکند، پس دکمهی back گوشی یا مرورگر
|
||||
* یک صفحه عقب میرود بهجای بیرون انداختنِ کاربر از اپ. تعویضِ تب پشته را
|
||||
* از نو میسازد (مثلِ اپهای بومی).
|
||||
* هر «قدمِ برگشتپذیر» دقیقاً یک ورودیِ history دارد: هر صفحهی pushشده، تبی غیر از خانه، و هر برگه/حالتی که
|
||||
* با useBackStep ثبت شده. تعدادِ لازم (desired) از وضعیت حساب میشود و یک افکت ورودیها را با آن یکی میکند.
|
||||
* دکمهی برگشت یک ورودی برمیدارد (popstate) و اینجا یک قدم در اپ عقب میرویم؛ بستنِ برگه یا عوض کردنِ تب از
|
||||
* داخلِ اپ ورودیِ اضافه را با history.go پاک میکند (و آن popstate نادیده گرفته میشود).
|
||||
*
|
||||
* چرا «تله»ی همیشگی (pushState دوباره داخلِ popstate) نه: کروم ورودیای را که بیلمسِ کاربر ساخته شده در دکمهی
|
||||
* برگشت رد میکند، پس آن تله گاهی کاربر را یکراست بیرون میانداخت. اینجا ورودیها در افکتِ بلافاصله بعد از لمس
|
||||
* ساخته میشوند (فعالسازیِ گذرای کاربر هنوز برقرار است). در کرومِ دسکتاپ و موبایلِ شبیهسازیشده آزموده شد؛
|
||||
* روی WebViewِ اپِ اندروید و Safariِ آیفون با دستگاهِ واقعی نه.
|
||||
*/
|
||||
/** نوعِ آخرین جابهجایی؛ انیمیشنِ ورودِ صفحه از آن میآید (جلو از چپ، عقب از راست، تب فقط محو). */
|
||||
export type NavDirection = 'push' | 'back' | 'tab' | 'replace';
|
||||
|
||||
interface Nav {
|
||||
route: Route;
|
||||
direction: NavDirection;
|
||||
/** تبی که ریشهی پشتهی فعلی است؛ برای روشن کردنِ نوارِ پایین. */
|
||||
tab: TabName | null;
|
||||
canGoBack: boolean;
|
||||
/** هر بار که کاربر تبِ همینحالا باز را دوباره میزند یکی زیاد میشود (برای «برو بالا»). */
|
||||
reselect: number;
|
||||
push: (route: Route) => void;
|
||||
/** صفحهی فعلی را عوض میکند بدونِ اضافه کردن به تاریخچه. */
|
||||
replace: (route: Route) => void;
|
||||
back: () => void;
|
||||
/** از صفحهی فعلی بیرون میرود حتی اگر برگهای باز باشد (برگه هم بسته میشود)؛ `back` فقط خودِ برگه را میبندد. */
|
||||
leave: () => void;
|
||||
switchTab: (tab: TabName) => void;
|
||||
}
|
||||
|
||||
const NavContext = createContext<Nav | null>(null);
|
||||
|
||||
export function NavigatorProvider({ initial, children }: { initial: Route; children: ReactNode }) {
|
||||
const [stack, setStack] = useState<Route[]>([initial]);
|
||||
interface BackSteps {
|
||||
register: (onBack: () => void) => () => void;
|
||||
}
|
||||
const BackContext = createContext<BackSteps | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onPop = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
|
||||
window.addEventListener('popstate', onPop);
|
||||
return () => window.removeEventListener('popstate', onPop);
|
||||
/**
|
||||
* یک قدمِ برگشتپذیرِ داخلِ صفحه (برگهی باز، فهرستی که از «انتخاب» باز شده): تا `active` است دکمهی برگشتِ
|
||||
* گوشی اول `onBack` را صدا میزند. بیرون از اپِ اصلی (صفحهی ورود) کاری نمیکند.
|
||||
*/
|
||||
export function useBackStep(active: boolean, onBack: () => void) {
|
||||
const steps = useContext(BackContext);
|
||||
const ref = useRef(onBack);
|
||||
ref.current = onBack;
|
||||
useEffect(() => (active && steps ? steps.register(() => ref.current()) : undefined), [active, steps]);
|
||||
}
|
||||
|
||||
export function NavigatorProvider({ initial, children }: { initial: Route; children: ReactNode }) {
|
||||
const [{ stack, direction }, setState] = useState<{ stack: Route[]; direction: NavDirection }>({ stack: [initial], direction: 'tab' });
|
||||
const [reselect, setReselect] = useState(0);
|
||||
const [steps, setSteps] = useState<(() => void)[]>([]);
|
||||
const stackRef = useRef(stack);
|
||||
stackRef.current = stack;
|
||||
const stepsRef = useRef(steps);
|
||||
stepsRef.current = steps;
|
||||
|
||||
/** ورودیهایی که خودمان بالای ورودیِ اولِ اپ گذاشتهایم. */
|
||||
const entries = useRef(0);
|
||||
/** popstateهایی که از history.goِ خودمان است، نه از دکمهی کاربر. */
|
||||
const ignorePops = useRef(0);
|
||||
|
||||
/** یک قدم عقب در خودِ اپ (بی history). */
|
||||
const stepBack = useCallback(() => {
|
||||
const open = stepsRef.current;
|
||||
if (open.length) {
|
||||
open[open.length - 1]();
|
||||
return;
|
||||
}
|
||||
const s = stackRef.current;
|
||||
if (s.length > 1) setState({ stack: s.slice(0, -1), direction: 'back' });
|
||||
else if (s[0].name !== 'home') setState({ stack: [{ name: 'home' }], direction: 'back' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPop = () => {
|
||||
if (ignorePops.current > 0) {
|
||||
ignorePops.current--;
|
||||
return;
|
||||
}
|
||||
entries.current = Math.max(0, entries.current - 1);
|
||||
stepBack();
|
||||
};
|
||||
window.addEventListener('popstate', onPop);
|
||||
return () => window.removeEventListener('popstate', onPop);
|
||||
}, [stepBack]);
|
||||
|
||||
// اپِ اصلی بسته شد (خروج، نشستِ باطل): ورودیهایی که گذاشته بودیم برداشته میشوند. بیاین، در صفحهی ورود
|
||||
// (و بعد از ورودِ دوباره) هر کدام یک «برگشت»ِ بیاثر بود و کاربر باید چند بار میزد تا از اپ بیرون برود.
|
||||
// یک tick صبر: StrictMode افکت را پاک و فوراً دوباره اجرا میکند و آن بستهشدنِ واقعی نیست.
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
setTimeout(() => {
|
||||
if (mounted.current || entries.current === 0) return;
|
||||
window.history.go(-entries.current);
|
||||
entries.current = 0;
|
||||
}, 0);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const desired = steps.length + (stack.length - 1) + (stack[0].name !== 'home' ? 1 : 0);
|
||||
useEffect(() => {
|
||||
const diff = desired - entries.current;
|
||||
if (diff > 0) {
|
||||
for (let i = 0; i < diff; i++) window.history.pushState({ tadrisino: true }, '');
|
||||
} else if (diff < 0) {
|
||||
ignorePops.current++;
|
||||
window.history.go(diff);
|
||||
}
|
||||
entries.current = desired;
|
||||
}, [desired]);
|
||||
|
||||
const register = useCallback((onBack: () => void) => {
|
||||
setSteps((list) => [...list, onBack]);
|
||||
return () => setSteps((list) => list.filter((f) => f !== onBack));
|
||||
}, []);
|
||||
const backSteps = useMemo<BackSteps>(() => ({ register }), [register]);
|
||||
|
||||
const push = useCallback((route: Route) => {
|
||||
setStack((s) => [...s, route]);
|
||||
window.history.pushState({ tadrisino: true }, '');
|
||||
setState((prev) => ({ stack: [...prev.stack, route], direction: 'push' }));
|
||||
}, []);
|
||||
|
||||
const replace = useCallback((route: Route) => {
|
||||
setStack((s) => [...s.slice(0, -1), route]);
|
||||
setState((prev) => ({ stack: [...prev.stack.slice(0, -1), route], direction: 'replace' }));
|
||||
}, []);
|
||||
|
||||
const back = useCallback(() => {
|
||||
// popstate خودش صفحه را برمیدارد؛ پس دو منبعِ حقیقت نداریم.
|
||||
window.history.back();
|
||||
// دکمهی برگشتِ داخلِ اپ همان راهِ دکمهی گوشی را میرود تا history و پشته از هم جدا نشوند.
|
||||
if (entries.current > 0) window.history.back();
|
||||
else stepBack();
|
||||
}, [stepBack]);
|
||||
|
||||
const leave = useCallback(() => {
|
||||
setSteps([]);
|
||||
const s = stackRef.current;
|
||||
if (s.length > 1) setState({ stack: s.slice(0, -1), direction: 'back' });
|
||||
else if (s[0].name !== 'home') setState({ stack: [{ name: 'home' }], direction: 'back' });
|
||||
}, []);
|
||||
|
||||
const switchTab = useCallback((tab: TabName) => {
|
||||
setStack([{ name: tab } as Route]);
|
||||
// زدنِ همان تبی که باز و ریشه است صفحه را از نو نمیسازد (قبلاً دوباره بار میشد)؛ مثلِ اپهای بومی به
|
||||
// بالای همان صفحه برمیگردد.
|
||||
const current = stackRef.current;
|
||||
if (current.length === 1 && current[0].name === tab) setReselect((n) => n + 1);
|
||||
else setState({ stack: [{ name: tab } as Route], direction: 'tab' });
|
||||
}, []);
|
||||
|
||||
const value = useMemo<Nav>(() => {
|
||||
const root = stack[0].name;
|
||||
return {
|
||||
route: stack[stack.length - 1],
|
||||
direction,
|
||||
tab: ['home', 'lessons', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null,
|
||||
canGoBack: stack.length > 1,
|
||||
reselect,
|
||||
push,
|
||||
replace,
|
||||
back,
|
||||
leave,
|
||||
switchTab,
|
||||
};
|
||||
}, [stack, push, replace, back, switchTab]);
|
||||
}, [stack, direction, reselect, push, replace, back, leave, switchTab]);
|
||||
|
||||
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
|
||||
return (
|
||||
<NavContext.Provider value={value}>
|
||||
<BackContext.Provider value={backSteps}>{children}</BackContext.Provider>
|
||||
</NavContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNav(): Nav {
|
||||
|
||||
@@ -4,7 +4,7 @@ export type Route =
|
||||
| { name: 'lessons' }
|
||||
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
|
||||
/** view: مستقیم به فهرستِ اشتراکها یا بستهها (مثلاً از فصلِ قفل)؛ بیآن انتخابِ دوگزینهای. */
|
||||
| { name: 'subscription'; view?: 'subscription' | 'pack' }
|
||||
| { name: 'subscription' }
|
||||
| { name: 'profile' }
|
||||
| { name: 'unit'; unitId: string }
|
||||
| { name: 'lesson'; lessonId: number; nodeId?: string }
|
||||
|
||||
50
src/pay.ts
Normal file
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);
|
||||
}
|
||||
@@ -12,20 +12,31 @@ import { Diagram } from '@/diagram';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { useGateHold } from '@/lib/useAsync';
|
||||
import { iAm, useCompanion } from '@/state/companion';
|
||||
|
||||
const STARTERS = ['این فصل دربارهی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
|
||||
const STARTERS = ['این فصل درباره چیه؟', 'یه مثال ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
|
||||
|
||||
/** «از پی بپرس»: دستیارِ ریاضی که از روی کتابِ پایهی کاربر جواب میدهد. */
|
||||
/** «از {همراه} بپرس»: دستیارِ ریاضی که از روی کتابِ پایهی کاربر جواب میدهد؛ چهره و نامش همراهِ انتخابیِ کاربر است. */
|
||||
export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) {
|
||||
const { profile } = useAppData();
|
||||
const { profile, home } = useAppData();
|
||||
const subjectTitle = home?.subject?.title;
|
||||
const companion = useCompanion();
|
||||
const chat = useAskState();
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
// «X سوالِ رایگانِ امروز + Y سوالِ خریدهشده»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
|
||||
const [quota, setQuota] = useState<{ free: number; credits: number } | null>(null);
|
||||
// «X سوالِ امروز»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
|
||||
const [quota, setQuota] = useState<number | null>(null);
|
||||
const [quotaDone, setQuotaDone] = useState(false);
|
||||
useEffect(() => {
|
||||
api.ai.quota().then((q) => setQuota({ free: q.remainingQuota, credits: q.credits })).catch(() => {});
|
||||
api.ai
|
||||
.quota()
|
||||
.then((q) => setQuota(q.remainingQuota))
|
||||
.catch((err: unknown) => console.warn('ai quota lookup failed', err))
|
||||
.finally(() => setQuotaDone(true));
|
||||
}, []);
|
||||
// صفحه وقتی میآید که تاریخچه و سهمیه رسیده باشند؛ وگرنه اول خوشآمد و بعد پیامهای قبلی رویش میپرید.
|
||||
useGateHold(!chat.historyLoaded || !quotaDone);
|
||||
const listEnd = useRef<HTMLDivElement>(null);
|
||||
const autoSent = useRef(false);
|
||||
|
||||
@@ -49,7 +60,12 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId, diagram: m.diagram ?? null })),
|
||||
),
|
||||
)
|
||||
.catch(() => askStore.reset(null));
|
||||
.catch((err: unknown) => {
|
||||
// فقط گفتگوی ناموجود (۴۰۴: حذف شده یا مالِ حسابِ دیگر) کنار میرود. قبلاً اینترنتِ قطع هم شناسه را پاک
|
||||
// میکرد و گفتگو برای همیشه گم میشد؛ حالا historyLoaded نمیخورد و بارِ بعدِ باز شدنِ صفحه دوباره میپرسد.
|
||||
if (err instanceof ApiError && err.status === 404) askStore.reset(null);
|
||||
else console.warn('ai history lookup failed; keeping the conversation', err);
|
||||
});
|
||||
// فقط بارِ اولِ باز شدنِ صفحه؛ عمداً به chat وابسته نیست.
|
||||
}, []);
|
||||
|
||||
@@ -80,6 +96,9 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
userQuestion: question,
|
||||
conversationId: askStore.get().conversationId ?? undefined,
|
||||
lessonId: askStore.get().lessonId ?? undefined,
|
||||
// بیدرس، کتابِ انتخابیِ دانشآموز (هندسه، گسسته، …) را میفرستیم تا دستیار از همان کتاب جواب بدهد؛
|
||||
// با درس، سرور کتاب را از خودِ درس درمیآورد.
|
||||
subject: askStore.get().lessonId ? undefined : subjectTitle,
|
||||
questionId: forQuestionId,
|
||||
},
|
||||
(delta) => {
|
||||
@@ -95,15 +114,15 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
diagram: done.diagram ?? null,
|
||||
});
|
||||
askStore.setConversation(done.conversationId);
|
||||
setQuota((q) => ({ free: done.remainingQuota, credits: done.credits ?? q?.credits ?? 0 }));
|
||||
setQuota(done.remainingQuota);
|
||||
} catch (err) {
|
||||
// سقفِ روزانه (نه throttleِ چندثانیهای): «دوباره» تا فردا بیفایده است.
|
||||
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
|
||||
const quotaReached = err instanceof ApiError && err.code === 'ai_daily_quota';
|
||||
askStore.update(replyId, {
|
||||
text: friendlyError(err, 'جوابی نرسید'),
|
||||
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
|
||||
...(quotaReached ? { quotaReached: true } : { failedQuestion: question, failedQuestionId: forQuestionId }),
|
||||
});
|
||||
if (quotaReached) setQuota({ free: 0, credits: 0 });
|
||||
if (quotaReached) setQuota(0);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -116,7 +135,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
const question = all[index - 1];
|
||||
askStore.remove(message.localId);
|
||||
if (question?.role === 'user') askStore.remove(question.localId);
|
||||
void send(message.failedQuestion);
|
||||
void send(message.failedQuestion, message.failedQuestionId);
|
||||
}
|
||||
|
||||
const empty = chat.messages.length === 0;
|
||||
@@ -124,14 +143,14 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title="از پی بپرس"
|
||||
subtitle={chat.lessonId ? 'از روی کتابِ همین درس جواب میدم' : 'دستیارِ ریاضی، از روی کتابِ خودت'}
|
||||
color="#9B7BFF"
|
||||
title={`از ${companion.name} بپرس`}
|
||||
subtitle={chat.lessonId ? 'از روی کتاب همین درس جواب میدم' : 'دستیار ریاضی، از روی کتاب خودت'}
|
||||
color={companion.color}
|
||||
back={lessonId !== undefined || prompt !== undefined}
|
||||
mascot={{ char: 'pi', mood: busy ? 'excited' : 'idle', size: 60 }}
|
||||
mascot={{ mood: busy ? 'excited' : 'idle', size: 60 }}
|
||||
>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{quota !== null && <QuotaPills free={quota.free} credits={quota.credits} />}
|
||||
{quota !== null && <QuotaPills free={quota} />}
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -147,7 +166,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
|
||||
<Bubble role="assistant">
|
||||
سلام {profile.name || 'دوستِ من'}! من پیام 🙂 هر سوالی از ریاضیِ کتابت داری بپرس؛ قدمبهقدم با هم حلش میکنیم.
|
||||
سلام {profile.name || 'دوست من'}! {`من ${iAm(companion.name)}`} 🙂 هر سوالی از ریاضی کتابت داری بپرس؛ قدمبهقدم با هم حلش میکنیم.
|
||||
</Bubble>
|
||||
|
||||
{empty && chat.historyLoaded && (
|
||||
@@ -208,11 +227,12 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
|
||||
function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
|
||||
const mine = role === 'user';
|
||||
const companion = useCompanion().char;
|
||||
return (
|
||||
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||
{!mine && (
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
|
||||
<SeyarMascot char="pi" mood="happy" size={32} />
|
||||
<SeyarMascot char={companion} mood="happy" size={32} />
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
@@ -234,10 +254,10 @@ function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; childre
|
||||
* سرور وصل نیستند.
|
||||
*/
|
||||
const THINKING_STEPS: [number, string][] = [
|
||||
[0, 'پی داره تو کتابت میگرده'],
|
||||
[4, 'پی داره حلش میکنه'],
|
||||
[0, '{name} داره تو کتابت میگرده'],
|
||||
[4, '{name} داره حلش میکنه'],
|
||||
[15, 'داره قدمها رو مرتب مینویسه'],
|
||||
[28, 'جوابِ کامل یه کم طول میکشه، الان میرسه'],
|
||||
[28, 'جواب کامل یه کم طول میکشه، الان میرسه'],
|
||||
];
|
||||
|
||||
function Thinking() {
|
||||
@@ -247,11 +267,12 @@ function Thinking() {
|
||||
const timer = setInterval(() => setSeconds((Date.now() - started) / 1000), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1];
|
||||
const companion = useCompanion();
|
||||
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1].replace('{name}', companion.name);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
|
||||
<SeyarMascot char="pi" mood="idle" size={32} />
|
||||
<SeyarMascot char={companion.char} mood="idle" size={32} />
|
||||
</span>
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted" aria-live="polite">
|
||||
{text}
|
||||
@@ -299,7 +320,7 @@ function Message({
|
||||
<div className="flex flex-wrap items-center gap-2 pr-10">
|
||||
{message.quotaReached ? (
|
||||
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-violet px-3 py-1 text-xs font-bold text-white">
|
||||
خریدِ بستهی سوال
|
||||
اشتراک
|
||||
</button>
|
||||
) : message.failedQuestion ? (
|
||||
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">
|
||||
@@ -339,23 +360,21 @@ function Message({
|
||||
}
|
||||
|
||||
/**
|
||||
* شمارنده در سربرگِ دستیار: سوالهای رایگانِ باقیِ امروز و سوالهای خریدهشده، هر کدام جدا و
|
||||
* درشت، تا بچه پیش از پرسیدن بداند چقدر دارد. زدن روی دومی صفحهی بستهها را باز میکند.
|
||||
* شمارنده در سربرگِ دستیار: سوالهای باقیِ امروز. وقتی تمام شد، دکمهی دوم به فروشگاهِ اشتراک میبرد
|
||||
* (سوال جدا فروخته نمیشود؛ مشترک فردا دوباره سهم دارد).
|
||||
*/
|
||||
function QuotaPills({ free, credits }: { free: number; credits: number }) {
|
||||
function QuotaPills({ free }: { free: number }) {
|
||||
const nav = useNav();
|
||||
return (
|
||||
<>
|
||||
<span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}>
|
||||
{free > 0 ? <>{toFa(free)} سوالِ رایگانِ امروز</> : 'رایگانِ امروز تموم شد'}
|
||||
{free > 0 ? <>{toFa(free)} سوال امروز</> : 'سوالهای امروز تموم شد'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nav.switchTab('subscription')}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-black ${credits > 0 ? 'bg-white text-violet' : 'bg-white/15 text-white'}`}
|
||||
>
|
||||
{credits > 0 ? <>{toFa(credits)} سوالِ خریدهشده</> : '+ خریدِ سوال'}
|
||||
{free === 0 && (
|
||||
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-white/15 px-3 py-1.5 text-xs font-black text-white">
|
||||
سوال بیشتر با اشتراک
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { api, type Character } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { SEYAR_INFO, seyarFromSlug } from '@/mascots/seyar';
|
||||
import { seyarFromSlug } from '@/mascots/seyar';
|
||||
import { COMPANIONS } from '@/state/companion';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader, Sep } from '@/components/ui';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
@@ -17,7 +18,7 @@ import { toFa } from '@/lib/format';
|
||||
export function CharactersScreen() {
|
||||
const { summary, setCompanion } = useAppData();
|
||||
const toast = useToast();
|
||||
const list = useAsync(() => api.characters.list(), []);
|
||||
const list = useAsync(() => api.characters.list(), [], { key: 'characters' });
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
async function select(c: Character) {
|
||||
@@ -39,7 +40,7 @@ export function CharactersScreen() {
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title="همراهت رو انتخاب کن"
|
||||
subtitle={<>امتیازِ تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیازِ بیشتر همراههای بیشتری باز میشن</>}
|
||||
subtitle={<>امتیاز تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیاز بیشتر همراههای بیشتری باز میشن</>}
|
||||
color="#9B7BFF"
|
||||
back
|
||||
/>
|
||||
@@ -54,6 +55,7 @@ export function CharactersScreen() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{list.data.map((c) => {
|
||||
const seyar = seyarFromSlug(c.slug);
|
||||
const look = seyar ? COMPANIONS[seyar] : null;
|
||||
return (
|
||||
<button
|
||||
key={c.characterId}
|
||||
@@ -62,11 +64,12 @@ export function CharactersScreen() {
|
||||
onClick={() => void select(c)}
|
||||
aria-pressed={c.selected}
|
||||
className={`relative flex flex-col items-center gap-2 rounded-3xl border-2 p-4 transition-transform ${
|
||||
c.selected ? 'border-violet bg-[#F5F1FF]' : c.unlocked ? 'border-line bg-white active:scale-95' : 'border-line bg-[#f4f3fa]'
|
||||
c.selected ? '' : c.unlocked ? 'border-line bg-white active:scale-95' : 'border-line bg-[#f4f3fa]'
|
||||
}`}
|
||||
style={c.selected && look ? { borderColor: look.color, backgroundColor: `${look.color}12` } : undefined}
|
||||
>
|
||||
{c.selected && (
|
||||
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-violet">
|
||||
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-violet" style={look ? { backgroundColor: look.color } : undefined}>
|
||||
<Check size={11} className="text-white" strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
@@ -84,15 +87,14 @@ export function CharactersScreen() {
|
||||
<Lock size={16} className="text-[#5a5580]" />
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-bold text-ink">{c.name}</span>
|
||||
<span className="text-sm font-bold text-ink">{look ? `${look.symbol} ${c.name}` : c.name}</span>
|
||||
{look && (
|
||||
<span className="text-center text-[11px] leading-5 text-body">
|
||||
<b style={{ color: look.color }}>{look.trait.title}:</b> {look.trait.text}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-center text-[10px] leading-5 text-muted">
|
||||
{!c.unlocked
|
||||
? `با ${toFa(c.unlockXp)} امتیاز باز میشه`
|
||||
: c.selected
|
||||
? 'همراهِ فعلی'
|
||||
: seyar
|
||||
? SEYAR_INFO[seyar].role
|
||||
: 'بازشده'}
|
||||
{!c.unlocked ? `با ${toFa(c.unlockXp)} امتیاز باز میشه` : c.selected ? 'همراه فعلی' : 'بزن تا همراهت بشه'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bell, ChevronLeft, Coins, Crown, Flame, Hand, PartyPopper, Star } from 'lucide-react';
|
||||
import type { HomeNode } from '@/api';
|
||||
import { Bell, ChevronLeft, Coins, Crown, Flame, RotateCw, Star } from 'lucide-react';
|
||||
import { ApiError, type HomeNode } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { IslandPath } from '@/components/IslandPath';
|
||||
import { IslandSheet } from '@/components/IslandSheet';
|
||||
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
|
||||
import { onWhite, shade } from '@/lib/color';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { BrandFooter } from '@/components/BrandFooter';
|
||||
import { BrandMark } from '@/components/BrandMark';
|
||||
import { StreakFlame, flameState } from '@/components/StreakFlame';
|
||||
import { SubjectBar } from '@/components/SubjectBar';
|
||||
|
||||
/**
|
||||
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
||||
@@ -20,10 +20,10 @@ import { BrandMark } from '@/components/BrandMark';
|
||||
* دستیار، فصلها، چالش) و بچه نمیدانست کدام را بزند؛ حالا یک کارِ واضح دارد.
|
||||
*/
|
||||
export function HomeScreen() {
|
||||
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
||||
const { gradeTitle, summary, home, homeError, unreadCount, refreshHome, refreshSummary } = useAppData();
|
||||
const nav = useNav();
|
||||
// فصلِ غیرِ اول بیاشتراک: زدنِ جزیره یکراست فهرستِ اشتراکها را باز میکند.
|
||||
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
|
||||
const paywall = () => nav.push({ name: 'subscription' });
|
||||
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
|
||||
const currentRef = useRef<HTMLDivElement>(null);
|
||||
const scrolled = useRef(false);
|
||||
@@ -37,24 +37,18 @@ export function HomeScreen() {
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [home]);
|
||||
|
||||
const name = profile.name || 'دوستِ من';
|
||||
const bubble = !home
|
||||
? 'دارم مسیرت رو میارم…'
|
||||
: !home.units.length
|
||||
? 'هنوز درسی برای این پایه ثبت نشده.'
|
||||
: cont
|
||||
? <>سلام {name}! <Hand size={15} className="mb-0.5 inline text-[#E8A400]" /> وقتشه جزیرهی <b>«<MathText text={cont.node.title} />»</b> رو شروع کنی؛ دکمهی <b>شروع</b> رو بزن.</>
|
||||
: <>آفرین {name}! همهی جزیرههای این پایه رو تموم کردی <PartyPopper size={15} className="mb-0.5 inline text-[#E8A400]" /></>;
|
||||
const flame = flameState(summary?.streak);
|
||||
const streak = summary?.streak;
|
||||
|
||||
const stats = [
|
||||
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
|
||||
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روز پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
|
||||
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', color: '#E8A400', fill: true, to: 'leaderboard' as const },
|
||||
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-4 pt-safe text-white shadow-md shadow-coral/20">
|
||||
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-3 pt-safe text-white shadow-md shadow-coral/20">
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<BrandMark size={26} withName={false} />
|
||||
{gradeTitle && (
|
||||
@@ -69,10 +63,10 @@ export function HomeScreen() {
|
||||
type="button"
|
||||
aria-label={`${label}: ${toFa(value)}`}
|
||||
onClick={() => nav.push({ name: to })}
|
||||
className="hit flex items-center gap-1 rounded-full bg-white px-2.5 py-1.5 text-xs font-black text-ink shadow-sm active:scale-95"
|
||||
className={`hit flex items-center gap-1 rounded-full px-2.5 py-1.5 text-xs font-black shadow-sm active:scale-95 ${to === 'streak' && flame === 'frozen' ? 'bg-[#E6F6FF] text-[#2B7FB8]' : 'bg-white text-ink'}`}
|
||||
>
|
||||
<Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />
|
||||
{toFa(value)}
|
||||
{to === 'streak' ? <StreakFlame state={flame} size={13} /> : <Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />}
|
||||
{toFa(to === 'streak' && flame === 'frozen' ? streak?.lost ?? 0 : value)}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" aria-label="اعلانها" onClick={() => nav.push({ name: 'notifications' })} className="hit relative flex h-8 w-8 items-center justify-center rounded-full bg-white/20 active:scale-95">
|
||||
@@ -86,14 +80,10 @@ export function HomeScreen() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
<SeyarMascot char={companion} mood={cont ? 'happy' : home ? 'excited' : 'idle'} size={64} bob />
|
||||
<div className="relative mb-2 flex-1 rounded-2xl rounded-br-sm bg-white px-3.5 py-2.5 text-[13px] leading-6 text-ink shadow-sm">
|
||||
{bubble}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<SubjectBar />
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!home ? (
|
||||
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />
|
||||
@@ -101,6 +91,23 @@ export function HomeScreen() {
|
||||
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 px-4 pb-10 pt-4">
|
||||
{homeError != null && (
|
||||
// نقشه هست ولی تازه نشد (معمولاً عکسِ ذخیرهشده روی گوشی و اینترنتِ قطع): بچه باید بداند
|
||||
// عددها و قفلها ممکن است مالِ دفعهی قبل باشند.
|
||||
<div role="status" className="flex items-center gap-2 rounded-xl bg-soft px-3 py-2 text-xs leading-6 text-body">
|
||||
<span className="flex-1">
|
||||
{homeError instanceof ApiError && homeError.isNetwork ? 'اینترنت وصل نیست یا کنده' : 'اطلاعات تازه نشد'}؛
|
||||
آخرین اطلاعات ذخیرهشده رو میبینی.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void Promise.all([refreshHome(), refreshSummary()])}
|
||||
className="flex shrink-0 items-center gap-1 rounded-full bg-white px-3 py-1 font-bold text-coral ring-1 ring-line"
|
||||
>
|
||||
<RotateCw size={12} /> دوباره
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{home.units.map((unit, i) => {
|
||||
const color = unitColor(i);
|
||||
const p = unitProgress(unit);
|
||||
@@ -115,7 +122,7 @@ export function HomeScreen() {
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] font-bold text-white/80">
|
||||
فصلِ {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
|
||||
فصل {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
|
||||
{unit.requiresSubscription && <span className="mr-1.5 inline-flex h-5 items-center gap-1 rounded-full bg-white/25 px-2 align-middle leading-none"><Crown size={11} /> با اشتراک</span>}
|
||||
{isCurrentUnit && <span className="mr-1.5 inline-flex h-5 items-center rounded-full bg-white/25 px-2 align-middle leading-none">الان اینجایی</span>}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -15,24 +15,24 @@ import { formatJalaliDate, formatToman } from '@/lib/format';
|
||||
*/
|
||||
const STATUS: Record<Invoice['status'], { label: string; className: string }> = {
|
||||
paid: { label: 'پرداختشده', className: 'bg-[#EAF8F0] text-[#2E7D5B]' },
|
||||
pending: { label: 'در انتظارِ پرداخت', className: 'bg-[#FFF8E1] text-[#8A6100]' },
|
||||
pending: { label: 'در انتظار پرداخت', className: 'bg-[#FFF8E1] text-[#8A6100]' },
|
||||
failed: { label: 'ناموفق', className: 'bg-soft text-muted' },
|
||||
canceled: { label: 'لغوشده', className: 'bg-soft text-muted' },
|
||||
refunded: { label: 'بازپرداختشده', className: 'bg-[#EEF1FF] text-indigo' },
|
||||
};
|
||||
|
||||
const PROVIDER: Record<string, string> = { gateway: 'درگاهِ زرینپال', bazaar: 'کافهبازار' };
|
||||
const PROVIDER: Record<string, string> = { gateway: 'درگاه زرینپال', bazaar: 'کافهبازار' };
|
||||
|
||||
const dateOf = (invoice: Invoice) => formatJalaliDate(invoice.paidAt ?? invoice.createdAt);
|
||||
|
||||
export function InvoicesScreen() {
|
||||
const nav = useNav();
|
||||
const list = useAsync(() => api.subscription.invoices(), []);
|
||||
const list = useAsync(() => api.subscription.invoices(), [], { key: 'invoices' });
|
||||
const [open, setOpen] = useState<Invoice | null>(null);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader title="پرداختها و فاکتورها" subtitle="همهی خریدهات، با شمارهی فاکتور و کدِ پیگیری" color="#4C5FD5" back />
|
||||
<ScreenHeader title="پرداختها و فاکتورها" subtitle="همه خریدهات، با شماره فاکتور و کد پیگیری" color="#4C5FD5" back />
|
||||
<ScreenBody className="flex flex-col gap-3">
|
||||
{list.loading ? (
|
||||
<LoadingState />
|
||||
@@ -42,7 +42,7 @@ export function InvoicesScreen() {
|
||||
<div className="flex flex-col items-center gap-4 rounded-3xl border border-line bg-white px-5 py-8 text-center">
|
||||
<Receipt size={32} className="text-faint" />
|
||||
<p className="text-sm text-body">هنوز خریدی نکردی.</p>
|
||||
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ اشتراکها</PrimaryButton>
|
||||
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدن اشتراکها</PrimaryButton>
|
||||
</div>
|
||||
) : (
|
||||
list.data.map((invoice) => (
|
||||
@@ -88,17 +88,17 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
|
||||
|
||||
const rows: [string, React.ReactNode][] = invoice
|
||||
? [
|
||||
['شمارهی فاکتور', <span dir="ltr">{invoice.invoiceNumber}</span>],
|
||||
['شماره فاکتور', <span dir="ltr">{invoice.invoiceNumber}</span>],
|
||||
['تاریخ', dateOf(invoice)],
|
||||
['وضعیت', STATUS[invoice.status].label],
|
||||
['اشتراک', invoice.planTitle || '—'],
|
||||
...(invoice.listPrice !== null ? [['قیمت', formatToman(invoice.listPrice)] as [string, React.ReactNode]] : []),
|
||||
...(invoice.discountAmount ? [[
|
||||
invoice.discountCode ? `تخفیف (کدِ ${invoice.discountCode})` : 'تخفیف',
|
||||
invoice.discountCode ? `تخفیف (کد ${invoice.discountCode})` : 'تخفیف',
|
||||
formatToman(invoice.discountAmount),
|
||||
] as [string, React.ReactNode]] : []),
|
||||
['مبلغِ پرداختی', <b>{formatToman(invoice.amount)}</b>],
|
||||
['روشِ پرداخت', PROVIDER[invoice.provider] ?? invoice.provider],
|
||||
['مبلغ پرداختی', <b>{formatToman(invoice.amount)}</b>],
|
||||
['روش پرداخت', PROVIDER[invoice.provider] ?? invoice.provider],
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -122,7 +122,7 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
|
||||
onClick={() => void copy(invoice.refId)}
|
||||
className="flex items-center justify-between rounded-2xl bg-soft px-4 py-3 text-sm"
|
||||
>
|
||||
<span className="text-muted">کدِ پیگیری</span>
|
||||
<span className="text-muted">کد پیگیری</span>
|
||||
<span className="flex items-center gap-2 font-bold text-ink" dir="ltr">
|
||||
{/* کد همانطور که کپی میشود: با رقمِ فارسی صفرها نقطه دیده میشدند. */}
|
||||
{invoice.refId} <Copy size={14} />
|
||||
@@ -131,7 +131,7 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
|
||||
) : (
|
||||
(invoice.status === 'pending' || invoice.status === 'failed') && (
|
||||
<p className="text-xs leading-6 text-muted">
|
||||
اگه پولی از حسابت کم شده ولی این خرید «پرداختشده» نیست، شمارهی فاکتور رو به پشتیبانی بده.
|
||||
اگه پولی از حسابت کم شده ولی این خرید «پرداختشده» نیست، شماره فاکتور رو به پشتیبانی بده.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
@@ -10,12 +11,15 @@ import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHead
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { Diagram } from '@/diagram';
|
||||
import { StepText } from '@/lib/StepText';
|
||||
import { FeedbackBar } from '@/components/FeedbackBar';
|
||||
|
||||
/** معرفیِ درس، پیش از تمرین. */
|
||||
export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
|
||||
const { home, companion } = useAppData();
|
||||
const companionName = useCompanion().name;
|
||||
const nav = useNav();
|
||||
const intro = useAsync(() => api.lesson.intro(lessonId), [lessonId]);
|
||||
const intro = useAsync(() => api.lesson.intro(lessonId), [lessonId], { key: `lesson-intro:${lessonId}` });
|
||||
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
|
||||
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
|
||||
const data = intro.data;
|
||||
@@ -49,9 +53,9 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<SeyarMascot char="sigma" mood="idle" size={56} />
|
||||
<SeyarMascot char={companion} mood="idle" size={56} />
|
||||
<div className="flex-1 rounded-3xl rounded-tr-sm border border-line bg-white p-4 shadow-sm">
|
||||
<p className="whitespace-pre-line text-sm leading-[1.95] text-body"><MathText text={data.introText} /></p>
|
||||
<StepText text={data.introText} color={color} className="text-[15px] leading-[1.95] text-body" />
|
||||
{figs('intro')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,7 +67,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
{data.example && (
|
||||
<section className="rounded-3xl border border-line bg-white p-4">
|
||||
<h2 className="mb-2 text-xs font-black text-muted">مثال</h2>
|
||||
<p className="whitespace-pre-line text-sm leading-[1.95] text-ink"><MathText text={data.example} /></p>
|
||||
<StepText text={data.example} color={color} className="text-[15px] leading-[1.95] text-ink" />
|
||||
{figs('example')}
|
||||
</section>
|
||||
)}
|
||||
@@ -77,7 +81,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
|
||||
{toFa(i + 1)}
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre-line"><MathText text={step} />{figs('step', i)}</span>
|
||||
<span className="flex-1 whitespace-pre-line"><MathText text={step} display />{figs('step', i)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
@@ -85,13 +89,14 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-3 pt-2">
|
||||
<FeedbackBar kind="lesson" id={String(lessonId)} />
|
||||
{data.questionCount === 0 ? (
|
||||
<div className="rounded-2xl border-2 border-dashed border-[#dedced] bg-white py-4 text-center text-sm font-bold text-muted">
|
||||
تمرینِ این درس بهزودی اضافه میشه
|
||||
تمرین این درس بهزودی اضافه میشه
|
||||
</div>
|
||||
) : (
|
||||
<PrimaryButton color={color} onClick={() => nav.replace({ name: 'quiz', lessonId, nodeId: ref?.node.id ?? nodeId })}>
|
||||
<Play size={18} fill="white" /> شروعِ تمرین ({toFa(data.questionCount)} سوال)
|
||||
<Play size={18} fill="white" /> شروع تمرین ({toFa(data.questionCount)} سوال)
|
||||
</PrimaryButton>
|
||||
)}
|
||||
<button
|
||||
@@ -99,7 +104,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
onClick={() => nav.push({ name: 'ask', lessonId })}
|
||||
className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]"
|
||||
>
|
||||
<Brain size={16} /> دربارهی این درس از پی بپرس
|
||||
<Brain size={16} /> درباره این درس از {companionName} بپرس
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,8 @@ import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { useFirstVisit } from '@/lib/firstVisit';
|
||||
import { SubjectBar } from '@/components/SubjectBar';
|
||||
|
||||
/**
|
||||
* تبِ «درسها»: فصل ← جزیره ← درس. فصلها اول بستهاند و بالای صفحه یک کارتِ «ادامه بده» هست.
|
||||
@@ -36,10 +38,10 @@ export function LessonsScreen() {
|
||||
<Screen className="bg-page">
|
||||
<ScreenHeader
|
||||
title="درسها"
|
||||
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>}
|
||||
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمون جزیره رو بده</>}
|
||||
color="#2FB36B"
|
||||
exact
|
||||
mascot={{ char: 'sigma', mood: 'happy' }}
|
||||
mascot={{ mood: 'happy' }}
|
||||
>
|
||||
{home && totalNodes > 0 && (
|
||||
<div className="mt-4">
|
||||
@@ -52,6 +54,8 @@ export function LessonsScreen() {
|
||||
)}
|
||||
</ScreenHeader>
|
||||
|
||||
<SubjectBar />
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-4">
|
||||
{!home ? (
|
||||
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم درسها رو میارم…" />
|
||||
@@ -117,15 +121,16 @@ function UnitCard({
|
||||
const Icon = iconFor(unit.nodes[0]?.icon, index);
|
||||
const nav = useNav();
|
||||
const premium = unit.requiresSubscription;
|
||||
const intro = useFirstVisit(`unit-card:${unit.id}`);
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
initial={intro ? { opacity: 0, y: 12 } : { opacity: 1, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index, 8) * 0.04 }}
|
||||
className="overflow-hidden rounded-2xl bg-white"
|
||||
style={{ border: `2px solid ${open ? color : '#e9e6f7'}`, boxShadow: open ? `0 4px 0 ${color}40` : '0 2px 0 #e9e6f7' }}
|
||||
>
|
||||
<button type="button" onClick={premium ? () => nav.push({ name: 'subscription', view: 'subscription' }) : onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
|
||||
<button type="button" onClick={premium ? () => nav.push({ name: 'subscription' }) : onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
|
||||
<span
|
||||
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: complete ? color : `${color}22`, color: complete ? '#fff' : color, opacity: untouched ? 0.55 : 1 }}
|
||||
@@ -193,7 +198,7 @@ function NodeRow({
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-bold leading-6 text-ink"><MathText text={node.title} /></span>
|
||||
<span className="block text-[11px] text-muted">
|
||||
{locked ? 'جزیرهی قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`}
|
||||
{locked ? 'جزیره قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`}
|
||||
</span>
|
||||
</span>
|
||||
{isCurrent && !open && (
|
||||
|
||||
@@ -28,7 +28,9 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
const otpInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const phoneValid = /^09\d{9}$/.test(phone);
|
||||
const otpValid = otp.length >= 4;
|
||||
// کدِ سرور ۶ رقمی است و هر حدسِ غلط یکی از ۵ فرصت را میسوزاند؛ کدِ ناقص نباید فرستاده شود.
|
||||
// فقط در حالتِ توسعه کدِ ثابتِ ۴ رقمیِ DEV_MASTER_OTP هم هست.
|
||||
const otpValid = otp.length === OTP_LENGTH || (import.meta.env.DEV && otp.length === 4);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendIn <= 0) return;
|
||||
@@ -61,7 +63,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
setDevOtp(data.devOtp ?? null);
|
||||
} catch (err) {
|
||||
if (mine !== attempt.current) return;
|
||||
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن'));
|
||||
setError(friendlyError(err, 'ارسال کد انجام نشد، دوباره امتحان کن'));
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند
|
||||
} else if (err instanceof ApiError && err.isRateLimited && err.retryAfterMs) {
|
||||
@@ -75,7 +77,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
}
|
||||
|
||||
async function verify(code = otp) {
|
||||
if (code.length < 4 || loading) return;
|
||||
if (!(code.length === OTP_LENGTH || (import.meta.env.DEV && code.length === 4)) || loading) return;
|
||||
setError('');
|
||||
setLoading('login');
|
||||
try {
|
||||
@@ -106,11 +108,11 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-black">ورود به تدریسینو</h1>
|
||||
<h1 className="text-balance text-xl font-black">ورود به تدریسینو ریاضی</h1>
|
||||
<p className="mt-1 text-sm text-white/75">
|
||||
{step === 'phone'
|
||||
? 'شمارهی موبایلت رو وارد کن'
|
||||
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامکشده رو وارد کن`}
|
||||
? 'شماره موبایلت رو وارد کن'
|
||||
: `کد ${toFa(OTP_LENGTH)} رقمی پیامکشده رو وارد کن`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/15 p-2">
|
||||
@@ -132,7 +134,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
<span className="mb-2 block text-sm font-bold text-ink">شماره موبایل</span>
|
||||
<input
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))}
|
||||
onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
|
||||
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
@@ -146,7 +148,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
) : (
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-bold text-ink">کدِ تأیید</span>
|
||||
<span className="mb-2 block text-sm font-bold text-ink">کد تأیید</span>
|
||||
<input
|
||||
ref={otpInput}
|
||||
value={otp}
|
||||
@@ -165,7 +167,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
)}
|
||||
{devOtp && (
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
کدِ آزمایشیِ حالتِ توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
|
||||
کد آزمایشی حالت توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex items-center justify-between text-xs font-bold">
|
||||
@@ -173,7 +175,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
<Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح
|
||||
</button>
|
||||
<button type="button" disabled={resendIn > 0 || sending || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted">
|
||||
{resendIn > 0 ? `ارسالِ دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسالِ دوباره'}
|
||||
{resendIn > 0 ? `ارسال دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسال دوباره'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,10 +189,23 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
loading={loading === 'login'}
|
||||
disabled={step === 'phone' ? !phoneValid : !otpValid}
|
||||
>
|
||||
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'}
|
||||
{step === 'phone' ? 'دریافت کد تأیید' : 'ورود'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* پرکردنِ خودکارِ گوشی و چسباندن معمولاً «+98 912 …» یا «0098…» میدهد؛ قبلاً فقط ۱۱ رقمِ اول نگه داشته میشد
|
||||
* («98912…») و دکمه بیتوضیح خاموش میماند. سرور هم همین شکلها را میپذیرد (accounts/phone.py).
|
||||
*/
|
||||
function normalizePhoneInput(value: string): string {
|
||||
let digits = digitsOnly(value);
|
||||
if (digits.startsWith('0098')) digits = `0${digits.slice(4)}`;
|
||||
else if (digits.startsWith('98') && digits.length >= 12) digits = `0${digits.slice(2)}`;
|
||||
// «98…» هنوز در حالِ تایپ است (۱۰ و ۱۱ رقمِ وسطِ «+98912…» هم با ۹ شروع میشوند)؛ پیششمارهی ۰۹۸ نداریم.
|
||||
else if (digits.startsWith('9') && !digits.startsWith('98') && digits.length === 10) digits = `0${digits}`;
|
||||
return digits.slice(0, 11);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function NotificationsScreen() {
|
||||
const r = await api.notifications.list();
|
||||
setUnreadCount(r.unreadCount);
|
||||
return r.items;
|
||||
}, []);
|
||||
}, [], { key: 'notifications' });
|
||||
const items = list.data ?? [];
|
||||
const unread = items.filter((n) => !n.isRead).length;
|
||||
|
||||
@@ -51,7 +51,7 @@ export function NotificationsScreen() {
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader title="اعلانها" color="#4C5FD5" back mascot={{ char: 'sigma', mood: 'idle', size: 54 }}>
|
||||
<ScreenHeader title="اعلانها" color="#4C5FD5" back mascot={{ mood: 'idle', size: 54 }}>
|
||||
{unread > 0 && (
|
||||
<button type="button" onClick={() => void markAll()} className="mt-3 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold">
|
||||
همه خوانده شد
|
||||
|
||||
@@ -7,27 +7,27 @@ import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
|
||||
const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title: string; desc: string }[] = [
|
||||
{
|
||||
char: 'sigma', mood: 'happy', bg: '#4C5FD5',
|
||||
sub: 'از پایهی سوم تا دوازدهم',
|
||||
title: 'به تدریسینو خوش اومدی!',
|
||||
desc: 'من سیگمام. ریاضیِ کتابِ خودت رو جزیره به جزیره با هم جلو میبریم؛ هر جزیره چند درسِ کوتاه و یه عالمه تمرین.',
|
||||
sub: 'از پایه سوم تا دوازدهم',
|
||||
title: 'به تدریسینو ریاضی خوش اومدی!',
|
||||
desc: 'من سیگمام. با هم ریاضی کتاب درسیت رو جزیره به جزیره جلو میبریم؛ هر جزیره چند درس کوتاه داره و کلی تمرین.',
|
||||
},
|
||||
{
|
||||
char: 'delta', mood: 'excited', bg: '#FF7A3D',
|
||||
sub: 'هر روز یه کم',
|
||||
title: 'امتیاز بگیر، زنجیره بساز',
|
||||
desc: 'منم دلتا! با هر جوابِ درست امتیاز و سکه میگیری، روزهای پیاپیت شمرده میشه و تو جدولِ امتیازها بالا میری.',
|
||||
desc: 'منم دلتا! با هر جواب درست امتیاز و سکه میگیری، روزهایی که پشت هم تمرین میکنی شمرده میشه و تو جدول امتیازها بالا میری.',
|
||||
},
|
||||
{
|
||||
char: 'pi', mood: 'happy', bg: '#9B7BFF',
|
||||
sub: 'هر جا گیر کردی',
|
||||
title: 'از پی بپرس',
|
||||
desc: 'من پیام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدمبهقدم توضیح میدم.',
|
||||
desc: 'من پیام. هر جا سوالی داشتی یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتاب درسیت قدمبهقدم توضیح میدم.',
|
||||
},
|
||||
{
|
||||
char: 'tedi', mood: 'excited', bg: '#D9820B',
|
||||
sub: 'یکی از ما مالِ توئه',
|
||||
sub: 'یکی از ما مال توئه',
|
||||
title: 'همراهت رو انتخاب کن',
|
||||
desc: 'منم امگا! هر چی امتیازت بیشتر بشه، همراههای تازه باز میشن. از صفحهی «من» هر کدوممون رو خواستی کنارت میذاری.',
|
||||
desc: 'منم امگا! هر چی امتیازت بیشتر بشه، همراههای تازهتری باز میشن. از صفحه «من» هر کدوممون رو بخوای انتخاب میکنی.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -64,7 +64,7 @@ export function OnboardingScreen({ onDone }: { onDone: () => void }) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-medium text-white/70">{slide.sub}</p>
|
||||
<h1 className="mb-3 text-2xl font-black leading-snug">{slide.title}</h1>
|
||||
<h1 className="mb-3 text-balance text-2xl font-black leading-snug">{slide.title}</h1>
|
||||
<p className="text-sm leading-[1.9] text-white/85">{slide.desc}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Crown, Heart, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
|
||||
FileText, Pencil, Repeat, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX,
|
||||
FileText, Pencil, Sparkles, Star, Trash2, Trophy, UserRound, Volume2, VolumeX,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
@@ -20,7 +20,7 @@ import { useAsync } from '@/lib/useAsync';
|
||||
import { daysLeft } from '@/lib/subscription';
|
||||
import { BrandFooter } from '@/components/BrandFooter';
|
||||
|
||||
type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null;
|
||||
type Editing = 'name' | 'grade' | 'birth' | 'account' | 'logout' | 'delete' | null;
|
||||
|
||||
/** «من»: پروفایل، تنظیمات و میانبُر به بخشهای فرعی. */
|
||||
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
@@ -46,7 +46,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
{profile.name || 'بینام'} <Pencil size={14} className="text-white/70" />
|
||||
</button>
|
||||
<div className="mt-0.5 text-xs text-white/75">
|
||||
شناسهی کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span>
|
||||
شناسه کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80">
|
||||
{gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
|
||||
@@ -74,27 +74,27 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
<SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
|
||||
|
||||
<Menu>
|
||||
<Row icon={GraduationCap} label="پایهی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
||||
<Row icon={GraduationCap} label="پایه تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
||||
<Row
|
||||
icon={CalendarDays}
|
||||
label="تاریخِ تولد"
|
||||
label="تاریخ تولد"
|
||||
value={formatBirthDate(profile.birthDate) || (age ? `${toFa(age)} سال` : 'وارد نشده')}
|
||||
onClick={() => setEditing('birth')}
|
||||
/>
|
||||
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} />
|
||||
<Row icon={Sparkles} label="همراه من" onClick={go({ name: 'characters' })} />
|
||||
</Menu>
|
||||
|
||||
<Menu>
|
||||
<Row icon={BarChart3} label="گزارشِ والدین" onClick={go({ name: 'report' })} />
|
||||
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} />
|
||||
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} />
|
||||
<Row icon={BarChart3} label="گزارش والدین" onClick={go({ name: 'report' })} />
|
||||
<Row icon={Flame} label="تقویم زنجیره" onClick={go({ name: 'streak' })} />
|
||||
<Row icon={Trophy} label="جدول امتیازها" onClick={go({ name: 'leaderboard' })} />
|
||||
<Row icon={Bell} label="اعلانها" onClick={go({ name: 'notifications' })} />
|
||||
</Menu>
|
||||
|
||||
<Menu>
|
||||
<Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />
|
||||
<Row icon={FileText} label="پرداختها و فاکتورها" onClick={go({ name: 'invoices' })} />
|
||||
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
|
||||
<Row icon={LifeBuoy} label="پشتیبانی و گزارش مشکل" onClick={go({ name: 'support' })} />
|
||||
<Row
|
||||
icon={sound ? Volume2 : VolumeX}
|
||||
label="صداها"
|
||||
@@ -106,13 +106,12 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
/>
|
||||
</Menu>
|
||||
|
||||
{/* حذفِ حساب یک لایه پایینتر، در «حساب کاربری»، مثلِ اپهای بزرگ (دیجیکالا، اسنپ، دولینگو): پیدا میشود
|
||||
(Google Play و App Store «در دسترس و پیداشدنی» میخواهند) ولی با یک لمسِ اشتباه زیرِ انگشت نیست. */}
|
||||
<Menu>
|
||||
<Row icon={Repeat} label="ورود با حسابِ دیگر" onClick={() => setEditing('switch')} />
|
||||
<Row icon={LogOut} label="خروج از حساب" onClick={() => setEditing('logout')} />
|
||||
<Row icon={UserRound} label="حساب کاربری" value={profile.phone ? toFa(profile.phone) : undefined} onClick={() => setEditing('account')} />
|
||||
<Row icon={LogOut} label="خروج از حساب" danger onClick={() => setEditing('logout')} />
|
||||
</Menu>
|
||||
<button type="button" onClick={() => setEditing('delete')} className="flex items-center justify-center gap-2 py-2 text-xs font-bold text-red-500">
|
||||
<Trash2 size={14} /> حذفِ حساب
|
||||
</button>
|
||||
<BrandFooter />
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,7 +119,8 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
|
||||
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
|
||||
<BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
|
||||
<LogoutSheet open={editing === 'logout' || editing === 'switch'} switching={editing === 'switch'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||
<AccountSheet open={editing === 'account'} onClose={() => setEditing(null)} onDelete={() => setEditing('delete')} />
|
||||
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||
<DeleteAccountSheet open={editing === 'delete'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||
</Screen>
|
||||
);
|
||||
@@ -131,7 +131,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
* موعد روزها را روی هم میگذارد، پس نوار در ۱۰۰٪ بسته میشود. زدنِ کارت فروشگاه را باز میکند.
|
||||
*/
|
||||
function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||
const status = useAsync(() => api.subscription.status(), []);
|
||||
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
|
||||
const s = status.data;
|
||||
if (!s) return <div className="h-[132px] animate-pulse rounded-3xl bg-soft" />;
|
||||
const days = s.active ? daysLeft(s.expiresAt) : 0;
|
||||
@@ -148,7 +148,7 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`glyph flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl ${s.active ? 'bg-white/20' : 'bg-[#FFF4D6] text-[#E0A200]'}`}><Crown size={20} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراکِ تدریسینو</span>
|
||||
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراک تدریسینو ریاضی</span>
|
||||
<span className="block text-base font-black">{s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}</span>
|
||||
</span>
|
||||
<span className="text-left">
|
||||
@@ -166,7 +166,7 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={total}
|
||||
aria-valuenow={days ?? total}
|
||||
aria-label="روزهای ماندهٔ اشتراک"
|
||||
aria-label="روزهای مانده اشتراک"
|
||||
>
|
||||
{percent > 0 && (
|
||||
<div className="bar-fill absolute inset-y-0 right-0 overflow-hidden rounded-full" style={{ width: `${percent}%`, backgroundColor: low ? '#FFD23F' : '#fff' }}>
|
||||
@@ -178,18 +178,13 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||
<div className={`mt-2 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[11px] ${s.active ? 'text-white/85' : 'text-muted'}`}>
|
||||
{s.active ? (
|
||||
<>
|
||||
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقتِ تمدیده'}</span>}
|
||||
<span className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلبِ نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
|
||||
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقت تمدیده'}</span>}
|
||||
<span className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلب نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
|
||||
</>
|
||||
) : (
|
||||
<span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همهی فصلها و قلبِ نامحدود باز میشه.</span>
|
||||
<span>فصل اول هر پایه رایگانه؛ با اشتراک همه فصلها و قلب نامحدود باز میشه.</span>
|
||||
)}
|
||||
</div>
|
||||
{s.questionCredits > 0 && (
|
||||
<div className={`mt-1.5 flex items-center gap-1 text-[11px] font-bold ${s.active ? 'text-white/90' : 'text-violet'}`}>
|
||||
<Sparkles size={11} /> {toFa(s.questionCredits)} سوالِ خریدهشده در اعتبارت
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -198,11 +193,11 @@ function Menu({ children }: { children: ReactNode }) {
|
||||
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
|
||||
}
|
||||
|
||||
function Row({ icon: Icon, label, value, onClick }: { icon: typeof Bell; label: string; value?: string; onClick: () => void }) {
|
||||
function Row({ icon: Icon, label, value, danger = false, onClick }: { icon: typeof Bell; label: string; value?: string; danger?: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="flex w-full items-center gap-3 px-4 py-3.5 text-right active:bg-soft">
|
||||
<Icon size={18} className="flex-shrink-0 text-indigo" />
|
||||
<span className="flex-1 text-sm font-bold text-ink">{label}</span>
|
||||
<Icon size={18} className={`flex-shrink-0 ${danger ? 'text-red-500' : 'text-indigo'}`} />
|
||||
<span className={`flex-1 text-sm font-bold ${danger ? 'text-red-500' : 'text-ink'}`}>{label}</span>
|
||||
{value && <span className="text-xs text-muted">{value}</span>}
|
||||
<ChevronLeft size={16} className="text-faint" />
|
||||
</button>
|
||||
@@ -256,7 +251,7 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
await api.profile.setGrade(gradeId);
|
||||
setProfile({ ...profile, gradeId });
|
||||
askStore.reset(null);
|
||||
await Promise.all([refreshHome(), refreshSummary()]);
|
||||
await Promise.all([refreshHome(gradeId), refreshSummary()]);
|
||||
toast('پایهت عوض شد', 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -267,8 +262,8 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="پایهی تحصیلی">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">پیشرفتِ هر پایه جدا نگه داشته میشه؛ با برگشتن، سرِ جاشه.</p>
|
||||
<Sheet open={open} onClose={onClose} title="پایه تحصیلی">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">پیشرفت هر پایه جدا نگه داشته میشه؛ با برگشتن، سر جاشه.</p>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{grades.map((g) => {
|
||||
const on = g.id === profile.gradeId;
|
||||
@@ -312,8 +307,8 @@ function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="تاریخِ تولد">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سالِ تولدت به شمسی.</p>
|
||||
<Sheet open={open} onClose={onClose} title="تاریخ تولد">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سال تولدت به شمسی.</p>
|
||||
<div className="mb-5">
|
||||
<BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
|
||||
</div>
|
||||
@@ -323,11 +318,11 @@ function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* خروج، و «ورود با حسابِ دیگر» که همان خروج است با متنِ دیگر. عمداً چند حسابِ واردشده روی
|
||||
* یک گوشی نگه داشته نمیشود: گوشیِ خانوادگی دستبهدست میشود و هر کس بیکد واردِ حسابِ
|
||||
* دیگری میشد، و چند refresh tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر میکرد.
|
||||
* خروج. «ورود با حسابِ دیگر» (۲۰۲۶-۱۰-۱۴) برداشته شد: همان خروج بود با متنِ دیگر. عمداً چند حسابِ واردشده روی یک
|
||||
* گوشی نگه داشته نمیشود: گوشیِ خانوادگی دستبهدست میشود و هر کس بیکد واردِ حسابِ دیگری میشد، و چند refresh
|
||||
* tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر میکرد. خروج و ورودِ دوباره با پیامک همان کار را میکند.
|
||||
*/
|
||||
function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open: boolean; switching?: boolean; onClose: () => void; onSignedOut: () => void }) {
|
||||
function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function logout() {
|
||||
@@ -342,22 +337,39 @@ function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open:
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title={switching ? 'ورود با حسابِ دیگر' : 'از حسابت خارج بشی؟'}>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
{switching && 'اول از این حساب خارج میشی، بعد با شمارهی دیگه وارد شو. '}
|
||||
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
|
||||
</p>
|
||||
<p className="mb-5 -mt-3 text-xs text-muted">ممنون که با تدریسینو یاد میگیری 💛</p>
|
||||
<Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟">
|
||||
<p className="mb-5 text-sm leading-7 text-muted">هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سر جاشه.</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<SecondaryButton onClick={onClose}>نه، میمونم</SecondaryButton>
|
||||
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>
|
||||
{switching ? 'خروج و ورود با حسابِ دیگر' : 'خروج'}
|
||||
</PrimaryButton>
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** «حساب کاربری»: شماره و شناسه، و پایینش حذفِ حساب (جای استانداردش؛ نه دمِ دستِ صفحهی اصلی). */
|
||||
function AccountSheet({ open, onClose, onDelete }: { open: boolean; onClose: () => void; onDelete: () => void }) {
|
||||
const { profile } = useAppData();
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="حساب کاربری">
|
||||
<div className="mb-6 divide-y divide-line overflow-hidden rounded-2xl border border-line">
|
||||
<div className="flex items-center justify-between px-4 py-3.5 text-sm">
|
||||
<span className="font-bold text-body">شماره موبایل</span>
|
||||
<span dir="ltr" className="font-bold text-ink">{profile.phone ? toFa(profile.phone) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3.5 text-sm">
|
||||
<span className="font-bold text-body">شناسه کاربری</span>
|
||||
<span dir="ltr" className="font-bold tracking-wide text-ink">{profile.publicId}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-2 text-xs leading-6 text-muted">با حذف حساب، همه پیشرفت و اطلاعاتت برای همیشه پاک میشه.</p>
|
||||
<button type="button" onClick={onDelete} className="flex w-full items-center justify-center gap-2 rounded-2xl border border-red-200 py-3 text-sm font-bold text-red-500 active:bg-red-50">
|
||||
<Trash2 size={16} /> حذف حساب کاربری
|
||||
</button>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* حذفِ همیشگیِ حساب (POST /user/delete). شمارهدار با کدِ تازهی پیامکی تأیید میکند تا گوشیِ
|
||||
* بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند.
|
||||
@@ -398,28 +410,28 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="حذفِ همیشگیِ حساب">
|
||||
<Sheet open={open} onClose={onClose} title="حذف همیشگی حساب">
|
||||
<p className="mb-3 text-sm leading-7 text-body">
|
||||
امتیاز، زنجیره، پیشرفتِ جزیرهها، گفتگوها با دستیار، سوالهای خریدهشده و قلبِ نامحدودت پاک میشن و برنمیگردن.
|
||||
سوابقِ پرداخت فقط برای حسابداری میمونه.
|
||||
امتیاز، زنجیره، پیشرفت جزیرهها، گفتگوها با دستیار و اشتراکت پاک میشن و برنمیگردن.
|
||||
سوابق پرداخت فقط برای حسابداری میمونه. اگه اشتراکت رو از کافهبازار خریدی، تمدید خودکارش رو از بخش «اشتراکها»ی بازار لغو کن.
|
||||
</p>
|
||||
{sent ? (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(digitsOnly(e.target.value, 6))}
|
||||
placeholder="کدِ پیامکشده"
|
||||
placeholder="کد پیامکشده"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-red-400"
|
||||
/>
|
||||
) : (
|
||||
<p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شمارهی خودت پیامک میشه.</p>
|
||||
<p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شماره خودت پیامک میشه.</p>
|
||||
)}
|
||||
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton>
|
||||
{!sent ? (
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادنِ کد</PrimaryButton>
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادن کد</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
color="#ef4444"
|
||||
@@ -427,7 +439,7 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
|
||||
disabled={value.length < 6}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
حذفِ همیشگی
|
||||
حذف همیشگی
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Brain, Check, ChevronLeft, Coins, Compass, Flag, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react';
|
||||
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
|
||||
import { Brain, Check, ChevronLeft, Coins, Compass, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react';
|
||||
import { api, ApiError, type AnswerResult, type Hearts, type Question } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { fetchHome } from '@/state/subject';
|
||||
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { newIdempotencyKey } from '@/api/http';
|
||||
@@ -15,7 +16,12 @@ import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton,
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { Diagram } from '@/diagram';
|
||||
import { FeedbackBar } from '@/components/FeedbackBar';
|
||||
import { sounds } from '@/lib/sound';
|
||||
import { useGateHold } from '@/lib/useAsync';
|
||||
import { StreakCelebration, StreakFlame } from '@/components/StreakFlame';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
|
||||
type Phase =
|
||||
| { kind: 'loading' }
|
||||
@@ -46,7 +52,11 @@ type PlayQuestion = Question & { lessonId?: string };
|
||||
* اصلیِ بازی همین است.
|
||||
*/
|
||||
export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) {
|
||||
const { home, companion, refreshHome, refreshSummary } = useAppData();
|
||||
const { home, companion, summary, refreshHome, refreshSummary } = useAppData();
|
||||
const look = useCompanion();
|
||||
// زنجیره پیش از این تمرین؛ اگر بعد از ثبت امروز روشن شد، صفحهی «روزِ پیاپی» نشان داده میشود.
|
||||
const streakBefore = useRef(summary?.streak ?? null);
|
||||
const [streakUp, setStreakUp] = useState<{ from: number; to: number } | null>(null);
|
||||
const nav = useNav();
|
||||
const toast = useToast();
|
||||
|
||||
@@ -60,8 +70,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [correctCount, setCorrectCount] = useState(0);
|
||||
const [confirmExit, setConfirmExit] = useState(false);
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [reload, setReload] = useState(0);
|
||||
// پشتِ هم درست؛ برای «سه تا پشتِ هم!» (حسِ پیشرفت وسطِ آزمون، نه فقط آخرش).
|
||||
const [combo, setCombo] = useState(0);
|
||||
useGateHold(phase.kind === 'loading');
|
||||
|
||||
// یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمیشود.
|
||||
const answerKeys = useRef(new Map<string, string>());
|
||||
@@ -90,6 +102,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
setSelected(null);
|
||||
setAnswer(null);
|
||||
setCorrectCount(0);
|
||||
setCombo(0);
|
||||
answerKeys.current.clear();
|
||||
completeKey.current = newIdempotencyKey();
|
||||
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
|
||||
@@ -98,7 +111,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
.catch((error: unknown) => {
|
||||
if (!alive) return;
|
||||
// فصلِ غیرِ اول بیاشتراک (۴۰۲)؛ صفحهی خطا معنا ندارد، کاربر یکراست به اشتراکها میرود.
|
||||
if (error instanceof ApiError && error.code === 'subscription_required') nav.replace({ name: 'subscription', view: 'subscription' });
|
||||
if (error instanceof ApiError && error.code === 'subscription_required') nav.replace({ name: 'subscription' });
|
||||
else setPhase({ kind: 'error', error });
|
||||
});
|
||||
return () => {
|
||||
@@ -109,6 +122,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
/** انتخاب فقط گزینه را آبی میکند؛ تا «بررسی» نزده، چیزی ثبت یا رو نمیشود و میشود عوضش کرد. */
|
||||
function choose(optionId: string) {
|
||||
if (!question || answer || submitting) return;
|
||||
if (optionId !== selected) sounds.select();
|
||||
setSelected(optionId);
|
||||
}
|
||||
|
||||
@@ -127,8 +141,11 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h));
|
||||
if (result.correct) {
|
||||
setCorrectCount((c) => c + 1);
|
||||
sounds.correct();
|
||||
setCombo(combo + 1);
|
||||
if (look.char === 'delta' && combo + 1 >= 2) sounds.combo();
|
||||
sounds.correct(look.pitch);
|
||||
} else {
|
||||
setCombo(0);
|
||||
sounds.wrong();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -175,9 +192,23 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
: await api.lesson.complete(lessonId, payload, completeKey.current);
|
||||
setPhase({ kind: 'result', result });
|
||||
sounds.finish();
|
||||
if (result.stars >= 2) void import('canvas-confetti').then(({ default: confetti }) => confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } }));
|
||||
// کاغذرنگی به رنگِ همراه؛ امگا («جشنساز») با هر نمرهای جشن میگیرد، بقیه از دو ستاره.
|
||||
if (result.stars >= 2 || look.char === 'tedi') {
|
||||
const colors = [look.color, look.light, '#FFD23F', '#ffffff'];
|
||||
void import('canvas-confetti').then(({ default: confetti }) =>
|
||||
confetti({ particleCount: look.char === 'tedi' ? 140 : 90, spread: look.char === 'tedi' ? 100 : 70, origin: { y: 0.35 }, colors }),
|
||||
);
|
||||
}
|
||||
void refreshHome();
|
||||
void refreshSummary();
|
||||
const before = streakBefore.current;
|
||||
if (before && !before.activeToday) {
|
||||
api.summary()
|
||||
.then((fresh) => {
|
||||
if (fresh.streak.activeToday) setStreakUp({ from: before.current, to: fresh.streak.current });
|
||||
})
|
||||
.catch((err: unknown) => console.warn('streak check after finish failed', err));
|
||||
}
|
||||
} catch (error) {
|
||||
// سرور پایانِ درس/جزیره را بیقلب و بیجواب نمیپذیرد (۱۴۰۵-۰۷)؛ همان صفحهی انتظار.
|
||||
if (error instanceof ApiError && error.code === 'no_hearts') {
|
||||
@@ -192,6 +223,25 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* قلب برگشت: آزمون از همان سوال ادامه مییابد. قبلاً کلِ آزمون از نو بار میشد و سوال و «درستها» صفر میشدند؛
|
||||
* دانشآموزی که وسطِ جزیره قلبش تمام شده بود بعد از ساعتها انتظار باید سوالهای جوابداده را دوباره میزد.
|
||||
*/
|
||||
function resume() {
|
||||
if (!questions.length) {
|
||||
setReload((n) => n + 1);
|
||||
return;
|
||||
}
|
||||
api.hearts().then(
|
||||
(h) => {
|
||||
setHearts(h);
|
||||
if (!h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
|
||||
else setPhase({ kind: 'playing' });
|
||||
},
|
||||
(err: unknown) => toast(friendlyError(err, 'وضعیت قلبها معلوم نشد، دوباره بزن'), 'error'),
|
||||
);
|
||||
}
|
||||
|
||||
const askWhy = () =>
|
||||
nav.push({
|
||||
name: 'ask',
|
||||
@@ -214,9 +264,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
);
|
||||
}
|
||||
if (phase.kind === 'noHearts') {
|
||||
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={() => setReload((n) => n + 1)} />;
|
||||
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={resume} />;
|
||||
}
|
||||
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} />;
|
||||
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} streakUp={streakUp} />;
|
||||
if (phase.kind === 'finishing' || phase.kind === 'finishError') {
|
||||
return (
|
||||
<Screen>
|
||||
@@ -226,7 +276,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
<p className="text-sm text-muted">دارم نتیجهت رو ثبت میکنم…</p>
|
||||
) : (
|
||||
<>
|
||||
<InlineError>نتیجهی این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
|
||||
<InlineError>نتیجه این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
|
||||
<PrimaryButton color={color} onClick={() => void finish()}>دوباره ثبت کن</PrimaryButton>
|
||||
<SecondaryButton onClick={nav.back}>بیخیال، برگرد</SecondaryButton>
|
||||
</>
|
||||
@@ -250,12 +300,12 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
|
||||
const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad';
|
||||
const bubble = !answer
|
||||
? selected === null ? 'جوابِ درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
|
||||
? selected === null ? 'جواب درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
|
||||
: answer.correct
|
||||
? 'آفرین! درست بود'
|
||||
? combo >= 2 ? `${toFa(combo)} تا پشت هم درست! داری عالی پیش میری` : PRAISE[index % PRAISE.length]
|
||||
: outOfHearts
|
||||
? 'قلبهات تموم شد؛ یه کم استراحت کنیم.'
|
||||
: 'اشکالی نداره، جوابِ درست رو ببین.';
|
||||
: NUDGE[index % NUDGE.length];
|
||||
const shortOptions = question.options.every((o) => o.value.length <= 14);
|
||||
|
||||
const optionClass = (id: string) => {
|
||||
@@ -275,8 +325,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-soft">
|
||||
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${((index + (answer ? 1 : 0)) / questions.length) * 100}%`, backgroundColor: color }} />
|
||||
</div>
|
||||
<span className="w-10 flex-shrink-0 text-left text-xs font-bold text-muted">
|
||||
{toFa(index + 1)}/{toFa(questions.length)}
|
||||
<TraitChip char={look.char} color={look.color} correct={correctCount} combo={combo} />
|
||||
<span className="flex-shrink-0 text-left text-xs font-bold text-muted">
|
||||
{/* «۱/۴» در اپِ ریاضی کسر خوانده میشد. */}
|
||||
{toFa(index + 1)} از {toFa(questions.length)}
|
||||
</span>
|
||||
</div>
|
||||
<HeartsRow hearts={hearts} />
|
||||
@@ -293,10 +345,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
|
||||
}`}>
|
||||
{bubble}
|
||||
{answer && <Reaction key={question.id} correct={answer.correct} seed={index} />}
|
||||
{answer && <Reaction key={`r${question.id}`} correct={answer.correct} seed={index} />}
|
||||
{answer?.correct && answer.xpEarned > 0 && (
|
||||
<motion.span
|
||||
key={question.id}
|
||||
key={`xp${question.id}`}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.8 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 14 }}
|
||||
@@ -310,12 +362,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
|
||||
<div className="rounded-3xl border border-line bg-white p-5 shadow-sm">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted">سوالِ {toFa(index + 1)}</span>
|
||||
<button type="button" onClick={() => setReporting(true)} className="flex items-center gap-1 text-[11px] font-bold text-faint">
|
||||
<Flag size={12} /> گزارشِ ایراد
|
||||
</button>
|
||||
<span className="text-xs font-medium text-muted">سوال {toFa(index + 1)}</span>
|
||||
</div>
|
||||
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} /></h2>
|
||||
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} display /></h2>
|
||||
{question.figure ? <div className="mt-3"><Diagram spec={question.figure} hideCounts /></div> : null}
|
||||
</div>
|
||||
|
||||
<div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
|
||||
@@ -336,18 +386,29 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FeedbackBar kind="question" id={question.id} />
|
||||
</div>
|
||||
|
||||
{answer && !answer.correct && look.char === 'pi' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mx-4 mb-2 flex items-start gap-2 rounded-2xl bg-[#F5F1FF] px-3 py-2.5 text-xs font-bold leading-6 text-[#5B3FA0]"
|
||||
>
|
||||
<span className="text-base leading-5">π</span>
|
||||
<span>{PI_TIPS[index % PI_TIPS.length]}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="flex flex-shrink-0 flex-col gap-2.5 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
|
||||
{answer ? (
|
||||
<>
|
||||
{!answer.correct && (
|
||||
<button type="button" onClick={askWhy} className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]">
|
||||
<Brain size={16} /> از پی بپرس چرا غلط شد
|
||||
<Brain size={16} /> از {look.name} بپرس چرا غلط شد
|
||||
</button>
|
||||
)}
|
||||
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
|
||||
{isLast ? 'پایانِ تمرین' : 'ادامه'} <ChevronLeft size={18} />
|
||||
{isLast ? 'پایان تمرین' : 'ادامه'} <ChevronLeft size={18} />
|
||||
</PrimaryButton>
|
||||
</>
|
||||
) : (
|
||||
@@ -363,11 +424,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه میدم</PrimaryButton>
|
||||
<SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton>
|
||||
<SecondaryButton onClick={() => nav.leave()}>خروج</SecondaryButton>
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
<ReportSheet open={reporting} questionId={question.id} onClose={() => setReporting(false)} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -376,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>
|
||||
);
|
||||
}
|
||||
@@ -405,11 +465,15 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
|
||||
}
|
||||
|
||||
function Result({
|
||||
result, total, correct, lessonId, nodeId, color,
|
||||
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string }) {
|
||||
result, total, correct, lessonId, nodeId, color, streakUp,
|
||||
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string; streakUp: { from: number; to: number } | null }) {
|
||||
const nav = useNav();
|
||||
const { companion } = useAppData();
|
||||
const { companion, profile } = useAppData();
|
||||
const [loadingNext, setLoadingNext] = useState(false);
|
||||
// بعد از نتیجه، اگر زنجیره امروز روشن شد، یک صفحهی جشنِ جدا (مثلِ دولینگو) و بعد مقصدِ انتخابشده.
|
||||
const [streakThen, setStreakThen] = useState<(() => void) | null>(null);
|
||||
const then = (go: () => void) => (streakUp ? setStreakThen(() => go) : go());
|
||||
if (streakThen && streakUp) return <StreakCelebration from={streakUp.from} to={streakUp.to} onDone={streakThen} />;
|
||||
|
||||
const nodeMode = lessonId === undefined;
|
||||
const nextNode = result.nextNodeId ?? null;
|
||||
@@ -423,7 +487,7 @@ function Result({
|
||||
}
|
||||
setLoadingNext(true);
|
||||
try {
|
||||
const fresh = await api.home();
|
||||
const fresh = await fetchHome(profile.gradeId);
|
||||
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId!);
|
||||
const next = ref ? nextLessonOf(ref.node) : null;
|
||||
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
|
||||
@@ -450,9 +514,16 @@ function Result({
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
|
||||
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی</p>}
|
||||
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
|
||||
<p className="mt-1 text-sm text-muted">{toFa(correct)} جواب درست از {toFa(total)} سوال</p>
|
||||
{/* نمرهی کم: تلاش و راه را ستایش کن، نه «باهوشی» را (پژوهشهای ذهنیتِ رشد)؛ و راهِ بعدی را نشان بده. */}
|
||||
{result.stars <= 1 && (
|
||||
<p className="mt-2 text-xs leading-6 text-body">
|
||||
اشتباهها همون جاهاییان که مغزت داره یاد میگیره. هر وقت خواستی از «درسها» دوباره مرورش کن.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full gap-3">
|
||||
{/* «+۰ امتیاز، +۰ سکه» بعد از یک دورِ بد فقط نمک روی زخم بود؛ پاداشِ صفر نشان داده نمیشود. */}
|
||||
{(result.xpEarned > 0 || result.coinsEarned > 0) && <div className="flex w-full gap-3">
|
||||
<div className="flex flex-1 flex-col items-center rounded-2xl border border-line bg-white py-3">
|
||||
<Star size={18} className="text-gold" />
|
||||
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.xpEarned)}</span>
|
||||
@@ -463,12 +534,12 @@ function Result({
|
||||
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.coinsEarned)}</span>
|
||||
<span className="text-[10px] text-muted">سکه</span>
|
||||
</div>
|
||||
</div>
|
||||
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
|
||||
{nodeMode && nextNode ? 'بریم جزیرهی بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
||||
</div>}
|
||||
<PrimaryButton color={color} loading={loadingNext} onClick={() => then(() => void goNext())}>
|
||||
{nodeMode && nextNode ? 'بریم جزیره بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
||||
</PrimaryButton>
|
||||
{nodeMode && nextNode && (
|
||||
<button type="button" onClick={nav.back} className="-mt-2 py-2 text-sm font-bold text-muted">
|
||||
<button type="button" onClick={() => then(nav.back)} className="-mt-2 py-2 text-sm font-bold text-muted">
|
||||
فعلاً بسه، برگردیم
|
||||
</button>
|
||||
)}
|
||||
@@ -477,67 +548,42 @@ function Result({
|
||||
);
|
||||
}
|
||||
|
||||
const REASONS: { id: ReportReason; label: string }[] = [
|
||||
{ id: 'wrong_answer', label: 'جوابِ درست اشتباهه' },
|
||||
{ id: 'unclear', label: 'صورتِ سوال گنگه' },
|
||||
{ id: 'typo', label: 'غلطِ تایپی داره' },
|
||||
{ id: 'too_hard', label: 'برای این درس خیلی سخته' },
|
||||
{ id: 'other', label: 'یه چیزِ دیگه' },
|
||||
/**
|
||||
* پیامِ بعد از جواب. یک جملهی ثابت بعد از چند بار دیگر خوانده نمیشود؛ چند تا به نوبت. برای جوابِ غلط
|
||||
* تلاش و راه ستایش میشود و قدمِ بعدی گفته میشود، نه اینکه «اشکالی نداره» تنها بماند.
|
||||
*/
|
||||
const PRAISE = ['آفرین! درست بود', 'دقیقاً همینه!', 'عالی! خوب فکر کردی', 'درسته! داری راه میفتی'];
|
||||
const NUDGE = ['نزدیک بودی! جواب درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد میگیریم.', 'این یکی سخت بود؛ جواب درست سبز شده.'];
|
||||
|
||||
/** «راهنمای مهربون»ِ پی: نکتهی عمومیِ کوتاه بعد از جوابِ غلط؛ نه جوابِ سوال، فقط عادتِ خوبِ حل کردن. */
|
||||
const PI_TIPS = [
|
||||
'صورت سوال رو یه بار دیگه آروم بخون؛ کلمه «نیست» یا «بیشترین» زیاد جا میمونه.',
|
||||
'علامت منفیها رو یه بار دیگه چک کن.',
|
||||
'اول داخل پرانتز، بعد ضرب و تقسیم، آخرش جمع و تفریق.',
|
||||
'جوابت رو بذار توی سوال؛ اگه درست درنیومد یه جا اشتباه شده.',
|
||||
];
|
||||
|
||||
/** گزارشِ سوالِ ایراددار؛ مستقیم به صفِ بررسیِ محتوا میرود. */
|
||||
function ReportSheet({ open, questionId, onClose }: { open: boolean; questionId: string; onClose: () => void }) {
|
||||
const toast = useToast();
|
||||
const [reason, setReason] = useState<ReportReason | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setReason(null);
|
||||
setNote('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function send() {
|
||||
if (!reason) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const r = await api.lesson.report(questionId, reason, note.trim());
|
||||
toast(r.alreadyReported ? 'قبلاً گزارشش رو داده بودی؛ ممنون!' : 'ممنون! گزارشت رسید و بررسی میشه.', 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast(friendlyError(err, 'گزارش ارسال نشد'), 'error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ویژگیِ همراه کنارِ نوارِ پیشرفتِ آزمون: سیگما جمعِ جوابای درست (Σ)، دلتا شعلهی جوابای پشتِ هم. پی و امگا
|
||||
* ویژگیشان جای دیگری است (نکته بعد از غلط، جشنِ پایان)، پس اینجا چیزی نشان نمیدهند.
|
||||
*/
|
||||
function TraitChip({ char, color, correct, combo }: { char: string; color: string; correct: number; combo: number }) {
|
||||
if (char === 'sigma') {
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="این سوال چه ایرادی داره؟">
|
||||
<div className="mb-4 flex flex-col gap-2">
|
||||
{REASONS.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => setReason(r.id)}
|
||||
className={`rounded-2xl border-2 px-4 py-3 text-right text-sm font-bold ${reason === r.id ? 'border-coral bg-[#fff4f2] text-coral' : 'border-line text-body'}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value.slice(0, 500))}
|
||||
placeholder="توضیحِ بیشتر (اختیاری)"
|
||||
rows={2}
|
||||
className="mb-4 w-full resize-none rounded-2xl border-2 border-line px-4 py-3 text-sm outline-none focus:border-coral"
|
||||
/>
|
||||
<PrimaryButton disabled={!reason} loading={sending} onClick={() => void send()}>ارسالِ گزارش</PrimaryButton>
|
||||
</Sheet>
|
||||
<motion.span key={correct} initial={{ scale: 1.4 }} animate={{ scale: 1 }} className="flex-shrink-0 rounded-full px-2 py-0.5 text-xs font-black text-white" style={{ backgroundColor: color }}>
|
||||
Σ {toFa(correct)}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
if (char === 'delta' && combo >= 2) {
|
||||
return (
|
||||
<motion.span key={combo} initial={{ scale: 0.4, rotate: -12 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500, damping: 12 }} className="flex flex-shrink-0 items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-black text-white" style={{ backgroundColor: color }}>
|
||||
<StreakFlame state="lit" size={11} className="[&_path]:fill-white" /> ×{toFa(combo)}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** آیکنِ کوچکِ بعد از جواب (همان خانوادهی آیکنِ اپ، نه ایموجیِ کیبورد)؛ هر سوال یکی از مجموعه، با یک تکانِ کوتاه. */
|
||||
const WIN: [LucideIcon, string][] = [[Brain, '#9B7BFF'], [Zap, '#E8A400'], [Target, '#ED6956'], [ThumbsUp, '#3E9E73'], [Sparkles, '#4C5FD5']];
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import type { SeyarMood } from '@/mascots/seyar';
|
||||
import { ErrorState, LoadingState, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
|
||||
/*
|
||||
* «گزارشِ والدین». تا ۱۴۰۵-۰۷ والد حسابِ جدا و کدِ دعوت داشت؛ به خواستِ سینیور
|
||||
@@ -37,16 +38,16 @@ const percent = (part: number, whole: number) => (whole ? Math.round((part / who
|
||||
|
||||
export function ReportScreen() {
|
||||
const { profile } = useAppData();
|
||||
const report = useAsync(() => api.weeklyReport(), []);
|
||||
const report = useAsync(() => api.weeklyReport(), [], { key: 'weekly-report' });
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title="گزارشِ والدین"
|
||||
subtitle={`پیشرفتِ ${profile.name || 'دانشآموز'} در ۷ روزِ اخیر`}
|
||||
title="گزارش والدین"
|
||||
subtitle={`پیشرفت ${profile.name || 'دانشآموز'} در ۷ روز اخیر`}
|
||||
color={THIS_WEEK}
|
||||
back
|
||||
mascot={{ char: 'sigma', mood: 'happy', size: 56 }}
|
||||
mascot={{ mood: 'happy', size: 56 }}
|
||||
/>
|
||||
<ScreenBody className="flex flex-col gap-4">
|
||||
{report.loading ? (
|
||||
@@ -68,43 +69,43 @@ function Report({ data, name }: { data: WeeklyReport; name: string }) {
|
||||
<Verdict now={now} before={before} name={name} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<Stat icon={Clock} label="زمانِ تمرین" value={`حدود ${toFa(now.minutes)} دقیقه`} delta={change(now.minutes, before.minutes, 'دقیقه')} />
|
||||
<Stat icon={Clock} label="زمان تمرین" value={`حدود ${toFa(now.minutes)} دقیقه`} delta={change(now.minutes, before.minutes, 'دقیقه')} />
|
||||
<Stat icon={CalendarCheck} label="روزهای فعال" value={`${toFa(now.activeDays)} از ۷ روز`} delta={change(now.activeDays, before.activeDays, 'روز')} />
|
||||
<Stat icon={Target} label="سوالِ حلشده" value={toFa(now.questions)} delta={change(now.questions, before.questions, '')} />
|
||||
<Stat icon={Target} label="سوال حلشده" value={toFa(now.questions)} delta={change(now.questions, before.questions, '')} />
|
||||
<Stat
|
||||
icon={CheckCircle2}
|
||||
label="درصدِ جوابِ درست"
|
||||
label="درصد جواب درست"
|
||||
value={now.questions ? `${toFa(percent(now.correct, now.questions))}٪` : '—'}
|
||||
delta={now.questions && before.questions ? change(percent(now.correct, now.questions), percent(before.correct, before.questions), '') : null}
|
||||
/>
|
||||
{/* آزمونِ جزیره و آزمونِ تکدرس هر دو «تمام کردن» اند؛ جدا شمردنشان برای والد فرقی نمیکند. */}
|
||||
<Stat icon={BookOpen} label="جزیره و درسِ تمامشده" value={toFa(now.islands + now.lessons)} delta={change(now.islands + now.lessons, before.islands + before.lessons, '')} />
|
||||
<Stat icon={BookOpen} label="جزیره و درس تمامشده" value={toFa(now.islands + now.lessons)} delta={change(now.islands + now.lessons, before.islands + before.lessons, '')} />
|
||||
<Stat icon={MessageCircle} label="سوال از دستیار" value={toFa(now.aiQuestions)} delta={change(now.aiQuestions, before.aiQuestions, '')} />
|
||||
</div>
|
||||
|
||||
{data.streak.longest > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-2xl bg-[#FFF1E6] px-4 py-3 text-xs text-body">
|
||||
<Flame size={16} className="text-orange" fill="#FF7A3D" />
|
||||
زنجیرهی فعلی <b className="text-ink">{toFa(data.streak.current)}</b> روز؛ بیشترین <b className="text-ink">{toFa(data.streak.longest)}</b> روز
|
||||
زنجیره فعلی <b className="text-ink">{toFa(data.streak.current)}</b> روز؛ بیشترین <b className="text-ink">{toFa(data.streak.longest)}</b> روز
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BarChart
|
||||
title="زمانِ تمرین در هر روز"
|
||||
description="ستونِ آبی هر روزِ این هفته است و ستونِ نارنجیِ کنارش همان روز در هفتهی پیش؛ آبیِ بلندتر یعنی بیشتر از هفتهی پیش."
|
||||
title="زمان تمرین در هر روز"
|
||||
description="ستون آبی هر روز این هفته است و ستون نارنجی کنارش همان روز در هفته پیش؛ آبی بلندتر یعنی بیشتر از هفته پیش."
|
||||
unit="دقیقه"
|
||||
days={data.days}
|
||||
mode="grouped"
|
||||
series={[
|
||||
{ label: 'این هفته', color: THIS_WEEK, values: data.days.map((d) => d.minutes) },
|
||||
{ label: 'هفتهی پیش', color: LAST_WEEK, values: data.previousDays.map((d) => d.minutes) },
|
||||
{ label: 'هفته پیش', color: LAST_WEEK, values: data.previousDays.map((d) => d.minutes) },
|
||||
]}
|
||||
note="زمان تقریبی است: از فاصلهی جوابها و پرسشها حساب میشود، پس خواندنِ درس بدونِ جواب دادن در آن نیست."
|
||||
note="زمان تقریبی است: از فاصله جوابها و پرسشها حساب میشود، پس خواندن درس بدون جواب دادن در آن نیست."
|
||||
/>
|
||||
|
||||
<BarChart
|
||||
title="سوالهای حلشده در هر روز"
|
||||
description="کلِ ستون تعدادِ سوالهایی است که آن روز جواب داده؛ بخشِ سبز جوابهای درست است و بخشِ طلایی نادرست."
|
||||
description="کل ستون تعداد سوالهایی است که آن روز جواب داده؛ بخش سبز جوابهای درست است و بخش طلایی نادرست."
|
||||
unit="سوال"
|
||||
days={data.days}
|
||||
mode="stacked"
|
||||
@@ -127,25 +128,26 @@ function verdict(now: ReportTotals, before: ReportTotals, name: string): { mood:
|
||||
const diff = now.minutes - before.minutes;
|
||||
const meaningful = Math.abs(diff) >= 3 && Math.abs(diff) >= before.minutes * 0.1;
|
||||
if (!now.minutes && !before.minutes) {
|
||||
return { mood: 'idle', text: 'این هفته هنوز تمرینی ثبت نشده. یه جزیرهی کوتاه شروعِ خوبیه!' };
|
||||
return { mood: 'idle', text: 'این هفته هنوز تمرینی ثبت نشده. یه جزیره کوتاه شروع خوبیه!' };
|
||||
}
|
||||
if (!before.minutes) {
|
||||
return { mood: 'excited', text: `آفرین! ${who}این هفته شروع کرده و ${toFa(now.activeDays)} روز تمرین کرده.` };
|
||||
}
|
||||
if (meaningful && diff > 0) {
|
||||
return { mood: 'excited', text: `آفرین! ${who}این هفته حدودِ ${toFa(diff)} دقیقه بیشتر از هفتهی پیش تمرین کرده.` };
|
||||
return { mood: 'excited', text: `آفرین! ${who}این هفته حدود ${toFa(diff)} دقیقه بیشتر از هفته پیش تمرین کرده.` };
|
||||
}
|
||||
if (meaningful && diff < 0) {
|
||||
return { mood: 'idle', text: `این هفته حدودِ ${toFa(-diff)} دقیقه کمتر از هفتهی پیش تمرین شده. یه تشویقِ کوچیک کمک میکنه!` };
|
||||
return { mood: 'idle', text: `این هفته حدود ${toFa(-diff)} دقیقه کمتر از هفته پیش تمرین شده. یه تشویق کوچیک کمک میکنه!` };
|
||||
}
|
||||
return { mood: 'happy', text: `${who}مثلِ هفتهی پیش منظم تمرین کرده؛ همینطور ادامه بده!` };
|
||||
return { mood: 'happy', text: `${who}مثل هفته پیش منظم تمرین کرده؛ همینطور ادامه بده!` };
|
||||
}
|
||||
|
||||
function Verdict({ now, before, name }: { now: ReportTotals; before: ReportTotals; name: string }) {
|
||||
const { mood, text } = verdict(now, before, name);
|
||||
const companion = useCompanion().char;
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4">
|
||||
<SeyarMascot char="sigma" mood={mood} size={52} />
|
||||
<SeyarMascot char={companion} mood={mood} size={52} />
|
||||
<p className="flex-1 text-sm font-bold leading-7 text-ink">{text}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -160,8 +162,8 @@ function change(now: number, before: number, unit: string): Delta {
|
||||
const u = unit ? ` ${unit}` : '';
|
||||
// صفر در برابرِ صفر خبری ندارد؛ زیرِ همهی کاشیهای حسابِ تازه «مثلِ هفتهی پیش» مینشست.
|
||||
if (!now && !before) return null;
|
||||
if (diff === 0) return { text: 'مثلِ هفتهی پیش', up: null };
|
||||
return { text: `${toFa(Math.abs(diff))}${u} ${diff > 0 ? 'بیشتر' : 'کمتر'} از هفتهی پیش`, up: diff > 0 };
|
||||
if (diff === 0) return { text: 'مثل هفته پیش', up: null };
|
||||
return { text: `${toFa(Math.abs(diff))}${u} ${diff > 0 ? 'بیشتر' : 'کمتر'} از هفته پیش`, up: diff > 0 };
|
||||
}
|
||||
|
||||
function Stat({ icon: Icon, label, value, delta }: { icon: LucideIcon; label: string; value: string; delta: Delta }) {
|
||||
@@ -324,7 +326,7 @@ function DayTable({ days, previous }: { days: ReportDay[]; previous: ReportDay[]
|
||||
<tr>
|
||||
<th className="py-1 text-right font-bold">روز</th>
|
||||
<th className="font-bold">دقیقه</th>
|
||||
<th className="font-bold">هفتهی پیش</th>
|
||||
<th className="font-bold">هفته پیش</th>
|
||||
<th className="font-bold">سوال</th>
|
||||
<th className="font-bold">درست</th>
|
||||
</tr>
|
||||
|
||||
@@ -58,7 +58,7 @@ export function SetupScreen({
|
||||
<div className="absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
|
||||
<div className="relative flex items-center justify-between pt-8">
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-bold text-white/75">فقط چندتا سؤالِ کوچیک</div>
|
||||
<div className="mb-1 text-xs font-bold text-white/75">فقط چندتا سوال کوچیک</div>
|
||||
<h1 className="text-2xl font-black">بیا با هم آشنا بشیم!</h1>
|
||||
</div>
|
||||
<div className="rounded-3xl bg-white/15 p-2">
|
||||
@@ -84,14 +84,14 @@ export function SetupScreen({
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-sm font-black text-ink">
|
||||
تاریخِ تولدت <span className="text-xs font-medium text-muted">(به شمسی، اختیاری)</span>
|
||||
تاریخ تولدت <span className="text-xs font-medium text-muted">(به شمسی، اختیاری)</span>
|
||||
</span>
|
||||
<BirthDatePicker value={birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-3 block text-sm font-black text-ink">
|
||||
{adult ? 'ریاضیِ کدوم پایه رو میخوای یاد بگیری؟' : 'کدوم پایهای؟'}
|
||||
{adult ? 'ریاضی کدوم پایه رو میخوای یاد بگیری؟' : 'کدوم پایهای؟'}
|
||||
{adult && <span className="mt-1 block text-xs font-medium text-muted">هر وقت خواستی از پروفایل عوضش کن.</span>}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -129,7 +129,7 @@ export function SetupScreen({
|
||||
{error && <InlineError>{error}</InlineError>}
|
||||
|
||||
<PrimaryButton onClick={() => void submit()} loading={saving} className="mt-auto">
|
||||
شروعِ یادگیری <ChevronLeft size={19} strokeWidth={3} />
|
||||
شروع یادگیری <ChevronLeft size={19} strokeWidth={3} />
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { BrandMark } from '@/components/BrandMark';
|
||||
|
||||
/** نمادهای ریاضی که آرام از پایینِ صفحه بالا میروند؛ فقط CSS (transform/opacity)، بیبارِ اضافه. */
|
||||
const GLYPHS = [
|
||||
{ c: 'π', x: 12, d: 0 }, { c: '+', x: 28, d: 2.6 }, { c: '√', x: 46, d: 1.2 },
|
||||
{ c: '÷', x: 64, d: 3.8 }, { c: '∑', x: 80, d: 0.6 }, { c: '×', x: 90, d: 4.6 },
|
||||
];
|
||||
|
||||
/** تا وقتی نشستِ قبلی بررسی میشود نمایش داده میشود؛ عمداً تأخیرِ ساختگی ندارد. */
|
||||
/**
|
||||
* اسپلشِ ثابت: آیکونِ اپ و نام، بیانیمیشن و بیهمراه (همراهِ انتخابی هنوز معلوم نیست و تکرارش
|
||||
* بد است). عینِ `.boot-splash` در index.html است تا تحویل از HTML به React دیده نشود؛ استایلش
|
||||
* هم آنجا است چون باید پیش از رسیدنِ CSS و JS کار کند.
|
||||
*/
|
||||
export function SplashScreen() {
|
||||
return (
|
||||
<div className="relative flex h-full flex-col items-center justify-center overflow-hidden bg-rose">
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2">
|
||||
{GLYPHS.map((g) => (
|
||||
<span key={g.c} className="rise absolute bottom-0 text-3xl font-black text-white/60" style={{ left: `${g.x}%`, animationDelay: `${g.d}s` }}>
|
||||
{g.c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
|
||||
className="relative flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="rounded-[32px] bg-white/15 p-6 backdrop-blur-sm">
|
||||
<SeyarMascot char="sigma" mood="happy" size={130} />
|
||||
</div>
|
||||
<BrandMark size={44} />
|
||||
<p className="text-sm text-white/80">ریاضی، قدمبهقدم، از روی کتابِ درسی</p>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1.8, repeat: Infinity, ease: 'linear' }}
|
||||
className="absolute bottom-20 h-6 w-6 rounded-full border-2 border-white/25 border-t-white"
|
||||
/>
|
||||
<div className="boot-splash" dir="rtl">
|
||||
<img src="/app-icon.png" alt="" width={144} height={144} />
|
||||
<span>تدریسینو ریاضی</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { api } from '@/api';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { ErrorState, LoadingState, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { StreakFlame, flameState } from '@/components/StreakFlame';
|
||||
|
||||
const WEEKS = 6;
|
||||
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
|
||||
@@ -29,35 +31,37 @@ function recentWeeks(today = new Date()): Date[][] {
|
||||
const dayOfMonth = (d: Date) => new Intl.DateTimeFormat('fa-IR-u-ca-persian', { day: 'numeric' }).format(d);
|
||||
|
||||
export function StreakScreen() {
|
||||
const calendar = useAsync(() => api.streakCalendar(), []);
|
||||
const calendar = useAsync(() => api.streakCalendar(), [], { key: 'streak-calendar' });
|
||||
const { summary } = useAppData();
|
||||
const flame = flameState(summary?.streak);
|
||||
const active = new Set(calendar.data?.activeDays ?? []);
|
||||
const today = isoDay(new Date());
|
||||
const weeks = recentWeeks();
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader title="زنجیرهی روزها" subtitle="هر روز حداقل یه درس، زنجیرهت رو زنده نگه میداره" color="#FF7A3D" back mascot={{ char: 'delta', mood: 'excited', size: 56 }} />
|
||||
<ScreenHeader title="زنجیره روزها" subtitle="هر روز حداقل یه درس، زنجیرهت رو زنده نگه میداره" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }} />
|
||||
<ScreenBody className="flex flex-col gap-4">
|
||||
{calendar.loading ? (
|
||||
<LoadingState char="delta" />
|
||||
<LoadingState />
|
||||
) : calendar.error || !calendar.data ? (
|
||||
<ErrorState error={calendar.error} onRetry={calendar.reload} />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: 'زنجیرهی فعلی', value: calendar.data.currentStreak },
|
||||
{ label: 'زنجیره فعلی', value: calendar.data.currentStreak },
|
||||
{ label: 'بیشترین زنجیره', value: calendar.data.longestStreak },
|
||||
].map((s) => (
|
||||
].map((s, i) => (
|
||||
<div key={s.label} className="flex flex-col items-center rounded-3xl border border-line bg-white py-4">
|
||||
<Flame size={24} className="text-orange" fill="#FF7A3D" />
|
||||
{i === 0 ? <StreakFlame state={flame} size={26} /> : <Flame size={24} className="text-orange" fill="#FF7A3D" />}
|
||||
<span className="mt-1 text-2xl font-black text-ink">{toFa(s.value)}</span>
|
||||
<span className="text-xs text-muted">{s.label} (روز)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<StreakGoal current={calendar.data.currentStreak} />
|
||||
<StreakGoal current={calendar.data.currentStreak} doneToday={active.has(today)} lost={summary?.streak.lost ?? 0} />
|
||||
|
||||
<div className="rounded-3xl border border-line bg-white p-4">
|
||||
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
|
||||
@@ -96,14 +100,24 @@ export function StreakScreen() {
|
||||
/** نقطهعطفهای زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعیای پشتش نیست (پاداش تصمیمِ محصولی است). */
|
||||
const MILESTONES = [3, 7, 14, 30, 60, 100];
|
||||
|
||||
function StreakGoal({ current }: { current: number }) {
|
||||
function StreakGoal({ current, doneToday, lost }: { current: number; doneToday: boolean; lost: number }) {
|
||||
const next = MILESTONES.find((m) => m > current);
|
||||
const reached = MILESTONES.filter((m) => m <= current).pop();
|
||||
// زنجیرهی صفر: «۳ روز دیگه میشه زنجیرهی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته میشود.
|
||||
// زنجیرهی روشن و امروزِ خالی: یادآوریِ اینکه امروز چیزی مانده تا زنجیره نسوزد.
|
||||
const today = current === 0 && lost > 0
|
||||
? `زنجیره ${toFa(lost)} روزهت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.`
|
||||
: current === 0
|
||||
? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیرهت روشن بشه.'
|
||||
: !doneToday
|
||||
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیره ${toFa(current)} روزهت نسوزه.`
|
||||
: null;
|
||||
return (
|
||||
<div className="rounded-3xl border border-line bg-[#FFF3E8] px-4 py-3">
|
||||
{today && <p className="mb-1 text-xs font-bold leading-6 text-[#B4501C]">{today}</p>}
|
||||
<p className="text-sm font-black leading-7 text-ink">
|
||||
{reached ? <><Flame size={16} fill="#FF7A3D" className="mb-0.5 ml-1 inline text-orange" />{toFa(current)} روزهای! </> : null}
|
||||
{next ? `${toFa(next - current)} روز دیگه میشه زنجیرهی ${toFa(next)} روزه.` : 'از همهی نقطهعطفها رد شدی، افسانهای!'}
|
||||
{next ? `${toFa(next - current)} روز دیگه میشه زنجیره ${toFa(next)} روزه.` : 'از همه نقطهعطفها رد شدی، افسانهای!'}
|
||||
</p>
|
||||
{next && (
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white">
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Check, ChevronLeft, ChevronRight, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
|
||||
import { api, type Plan, type SubscriptionStatus, type TutorQuota } from '@/api';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, ChevronDown, Crown, Lock, Ticket } from 'lucide-react';
|
||||
import { api, type Plan, type SubscriptionStatus } from '@/api';
|
||||
import { daysLeft } from '@/lib/subscription';
|
||||
import { newIdempotencyKey } from '@/api/http';
|
||||
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
|
||||
import { BazaarPurchaseError, bazaarBuy, bazaarPending, bazaarPrices, canBuyFromBazaar, inAndroidApp } from '@/lib/bazaar';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { formatJalaliDate, formatToman, toFa } from '@/lib/format';
|
||||
import { formatNumber, formatToman, toFa } from '@/lib/format';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
import { enabledPaymentMethods, type PaymentMethod } from '@/lib/paymentMethods';
|
||||
import zarinpalLogo from '@/assets/pay/zarinpal.png';
|
||||
import bazaarLogo from '@/assets/pay/bazaar.png';
|
||||
|
||||
function durationLabel(days: number): string {
|
||||
if (days === 30 || days === 31) return 'یکماهه';
|
||||
@@ -18,224 +22,216 @@ function durationLabel(days: number): string {
|
||||
return days ? `${toFa(days)} روزه` : '';
|
||||
}
|
||||
|
||||
type View = 'subscription' | 'pack';
|
||||
/** «۷۹۸ هزار تومان» بهجای «۷۹۸٬۰۰۰ تومان»؛ صفرهای پشتِ هم برای بچه خواندنِ قیمت را سخت میکرد. */
|
||||
function price(value: number): string {
|
||||
return value >= 1000 && value % 1000 === 0 ? `${formatNumber(value / 1000)} هزار تومان` : formatToman(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/ششماهه؛ همهی فصلها، قلبِ نامحدود، سقفِ روزانهی دستیار)
|
||||
* و «بستهی سوال» (سوالِ بیانقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
|
||||
* یکجا کاربر را گیج نکند. از فصلِ قفل یکراست به فهرستِ اشتراک میآید (`initialView`).
|
||||
* فروشگاه فقط اشتراک دارد (یک/سه/ششماهه؛ تصمیمِ سینیور ۲۰۲۶-۱۰-۱۲: بستهی سوال دیگر فروخته نمیشود). هر کارت
|
||||
* خودش میگوید چه میگیری (خواستِ سینیور): مزیتهای پلن (`features`، از ادمین) و سقفِ روزانهی دستیار از `aiDailyQuota`،
|
||||
* تا عدد با سقفِ واقعی جدا نیفتد. یک دکمهی بزرگ پایینِ صفحه؛ روشِ پرداخت (زرینپال یا کافهبازار) در برگهی پرداخت.
|
||||
*/
|
||||
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
||||
const plans = useAsync(() => api.subscription.plans(), []);
|
||||
const status = useAsync(() => api.subscription.status(), []);
|
||||
const quota = useAsync(() => api.ai.quota(), []);
|
||||
const [view, setView] = useState<View | null>(initialView ?? null);
|
||||
export function SubscriptionScreen() {
|
||||
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
|
||||
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
const [buying, setBuying] = useState<Plan | null>(null);
|
||||
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درونبرنامهایِ خودش است؛ محصولی که
|
||||
// SKU ندارد آنجا خریدنی نیست (به زرینپال فرستاده نمیشود).
|
||||
const bazaar = inBazaarApp();
|
||||
// داخلِ اپی که از بازار میخرد: قیمتِ پیشخانِ هر SKU، تا کاشیِ بازار همان را بگوید که بازار میگیرد.
|
||||
const [storePrices, setStorePrices] = useState<Record<string, string>>({});
|
||||
const look = useCompanion();
|
||||
|
||||
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
|
||||
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
|
||||
const reload = () => { status.reload(); quota.reload(); };
|
||||
const list = plans.data?.plans.filter((p) => p.kind === view) ?? [];
|
||||
const monthly = plans.data?.plans.find((p) => p.kind === 'subscription' && p.durationDays <= 31);
|
||||
// پرداخت با درگاه داخلِ اپ در مرورگرِ گوشی باز میشود؛ برگشت به اپ یعنی وضعیت را دوباره بپرس.
|
||||
useEffect(() => {
|
||||
const onVisible = () => document.visibilityState === 'visible' && status.reload();
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||
}, [status.reload]);
|
||||
|
||||
const list = plans.data?.plans ?? [];
|
||||
useEffect(() => {
|
||||
const skus = list.filter((p) => p.bazaarSku).map((p) => p.bazaarSku);
|
||||
if (!canBuyFromBazaar() || !skus.length) return;
|
||||
let alive = true;
|
||||
void bazaarPrices(skus).then((prices) => alive && setStorePrices(prices));
|
||||
return () => { alive = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [plans.data]);
|
||||
const monthly = list.find((p) => p.durationDays <= 31);
|
||||
const selected = list.find((p) => p.id === picked) ?? list.find((p) => p.popular) ?? list[0];
|
||||
const active = status.data?.active ?? false;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ScreenHeader
|
||||
title={view === 'pack' ? 'بستهی سوال' : view === 'subscription' ? 'اشتراکِ تدریسینو' : 'فروشگاه'}
|
||||
subtitle={view === 'pack' ? 'سوالِ بیشتر از دستیار، بدونِ انقضا' : 'همهی فصلها، قلبِ نامحدود'}
|
||||
title={active ? 'تمدید اشتراک' : 'اشتراک تدریسینو ریاضی'}
|
||||
subtitle="همه فصلها، قلب نامحدود"
|
||||
color="#ED6956"
|
||||
exact
|
||||
mascot={{ char: 'pi', mood: 'excited', size: 60 }}
|
||||
back
|
||||
mascot={{ mood: 'excited', size: 60 }}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 py-5">
|
||||
<StatusCard status={status.data ?? null} credits={credits} heartsUntil={heartsUntil} quota={quota.data ?? null} />
|
||||
|
||||
{view === null ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<ChoiceCard
|
||||
icon={<Crown size={24} />}
|
||||
tone="#FF7A3D"
|
||||
title="اشتراکِ ماهانه"
|
||||
text="همهی فصلها و جزیرهها باز، با قلبِ نامحدود"
|
||||
hint={monthly ? `از ${formatToman(monthly.price)}` : undefined}
|
||||
onClick={() => setView('subscription')}
|
||||
/>
|
||||
<ChoiceCard
|
||||
icon={<Sparkles size={24} />}
|
||||
tone="#9B7BFF"
|
||||
title="بستهی سوال"
|
||||
text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچوقت منقضی نمیشه"
|
||||
onClick={() => setView('pack')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!initialView && (
|
||||
<button type="button" onClick={() => setView(null)} className="flex items-center gap-1 self-start text-xs font-bold text-coral">
|
||||
<ChevronRight size={14} /> برگشت به انتخاب
|
||||
</button>
|
||||
)}
|
||||
{plans.loading ? (
|
||||
<StatusCard status={status.data ?? null} loading={status.loading && !status.data} />
|
||||
{plans.loading && !plans.data ? (
|
||||
<LoadingState text="دارم فهرست رو میارم…" />
|
||||
) : plans.error ? (
|
||||
) : plans.error && !plans.data ? (
|
||||
<ErrorState error={plans.error} onRetry={plans.reload} />
|
||||
) : !list.length ? (
|
||||
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
|
||||
) : (
|
||||
list.map((plan) => (
|
||||
<PackCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
monthlyPrice={monthly?.price}
|
||||
// اشتراک در بازار هنوز SKU و تمدیدِ آزمودهشده ندارد؛ آنجا فقط بسته خریدنی است.
|
||||
available={!bazaar || (plan.kind === 'pack' && !!plan.bazaarSku)}
|
||||
onBuy={() => setBuying(plan)}
|
||||
/>
|
||||
))
|
||||
<div role="radiogroup" aria-label="مدت اشتراک" className="flex flex-col gap-3">
|
||||
{list.map((plan) => (
|
||||
<PlanRow key={plan.id} plan={plan} assistant={look.name} monthlyPrice={monthly?.price} selected={plan.id === selected?.id} onSelect={() => setPicked(plan.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{view === 'pack' && (
|
||||
<p className="text-center text-xs leading-6 text-muted">
|
||||
هر روز {toFa(status.data?.aiDailyQuota ?? plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول از سقفِ روزانهته؛ بعدش از اعتبارِ بسته کم میشه.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
|
||||
{bazaar ? 'پرداخت از طریقِ کافهبازار انجام میشه.' : 'پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); reload(); }} />
|
||||
{selected && (
|
||||
<div className="flex-shrink-0 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
|
||||
<PrimaryButton color="#FF7A3D" onClick={() => setBuying(selected)}>
|
||||
{`خرید اشتراک ${durationLabel(selected.durationDays) || selected.title}`}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CheckoutSheet plan={buying} storePrices={storePrices} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) {
|
||||
/**
|
||||
* وضعیتِ کاربر بالای فروشگاه: مشترک یک کارتِ واضح با روزهای مانده میبیند؛ بیاشتراک هیچ کارتی (کارتهای
|
||||
* اشتراکِ زیر خودشان حرف را میزنند). تا وضعیت نرسیده جای خالیِ هماندازه میآید، نه «نداری» که برای مشترک غلط بود.
|
||||
*/
|
||||
function StatusCard({ status, loading }: { status: SubscriptionStatus | null; loading: boolean }) {
|
||||
if (loading) return <div className="h-[104px] animate-pulse rounded-3xl bg-soft" />;
|
||||
if (!status?.active) return null;
|
||||
const days = daysLeft(status.expiresAt);
|
||||
const total = status.planDurationDays ?? 30;
|
||||
const percent = days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)));
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="flex items-center gap-3 rounded-3xl border-2 border-line bg-white p-4 text-right active:scale-[0.99]">
|
||||
<span className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl text-white" style={{ backgroundColor: tone }}>{icon}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-base font-black text-ink">{title}</span>
|
||||
<span className="mt-0.5 block text-xs leading-6 text-muted">{text}</span>
|
||||
{hint && <span className="mt-1 block text-xs font-black" style={{ color: tone }}>{hint}</span>}
|
||||
</span>
|
||||
<ChevronLeft size={18} className="flex-shrink-0 text-faint" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */
|
||||
function StatusCard({ status, credits, heartsUntil, quota }: {
|
||||
status: SubscriptionStatus | null; credits: number; heartsUntil: string | null; quota: TutorQuota | null;
|
||||
}) {
|
||||
const days = status?.active ? daysLeft(status.expiresAt) : null;
|
||||
return (
|
||||
<div className="rounded-3xl border border-line bg-white p-4">
|
||||
<div className="rounded-3xl bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] p-4 text-white shadow-lg shadow-orange/25">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl ${status?.active ? 'bg-[#FFF4D6] text-[#E0A200]' : 'bg-soft text-muted'}`}><Crown size={22} /></span>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-bold text-muted">اشتراکِ تو</div>
|
||||
<div className="text-base font-black text-ink">
|
||||
{status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</span>}</> : 'نداری'}
|
||||
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-white/20"><Crown size={24} /></span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-black">اشتراکت فعاله</div>
|
||||
<div className="text-sm text-white/85">{status.planTitle ?? 'اشتراک'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{quota && (
|
||||
<div className="text-left text-xs leading-6 text-muted">
|
||||
سوالِ امروز<br />
|
||||
<b className="text-base text-ink">{toFa(quota.remainingQuota)}</b> از {toFa(status?.aiDailyQuota ?? quota.freeDaily)}
|
||||
{days !== null && (
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-black leading-none">{toFa(days)}</div>
|
||||
<div className="mt-1 text-xs text-white/85">روز مونده</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-xs font-bold">
|
||||
<span className="flex items-center gap-1.5 text-violet"><Sparkles size={13} /> اعتبار: {toFa(credits)} سوال</span>
|
||||
{(status?.active || heartsUntil) && (
|
||||
<span className="flex items-center gap-1.5 text-coral">
|
||||
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}</>}
|
||||
</span>
|
||||
)}
|
||||
{status?.active && status.expiresAt && <span className="text-muted">تا {formatJalaliDate(status.expiresAt)}</span>}
|
||||
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-black/15">
|
||||
<div className="h-full rounded-full bg-white" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthlyPrice?: number; available: boolean; onBuy: () => void }) {
|
||||
/** یک کارتِ انتخابی: نام و قیمت درشت، و زیرش آنچه این اشتراک میدهد (خواستِ سینیور: روی هر کارت). */
|
||||
function PlanRow({ plan, assistant, monthlyPrice, selected, onSelect }: { plan: Plan; assistant: string; monthlyPrice?: number; selected: boolean; onSelect: () => void }) {
|
||||
const months = Math.round(plan.durationDays / 30);
|
||||
const saving = plan.kind === 'subscription' && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
|
||||
const featured = plan.popular;
|
||||
const isPack = plan.kind === 'pack';
|
||||
const tone = featured ? 'text-white/80' : 'text-muted';
|
||||
const saving = monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
|
||||
const tone = '#FF7A3D';
|
||||
const perks = [...plan.features, `${toFa(plan.aiDailyQuota)} سوال در روز از ${assistant}`];
|
||||
return (
|
||||
<div className="relative pt-3">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={onSelect}
|
||||
className="relative flex flex-col gap-3 rounded-3xl border-2 border-b-4 bg-white px-4 py-4 text-right transition-colors active:translate-y-0.5"
|
||||
style={{ borderColor: selected ? tone : '#e9e6f7', backgroundColor: selected ? `${tone}0F` : '#fff' }}
|
||||
>
|
||||
{plan.badge && (
|
||||
<span className="absolute right-5 top-0 z-10 rounded-full bg-[#FFD23F] px-3 py-1 text-[11px] font-black text-ink shadow">{plan.badge}</span>
|
||||
<span className="absolute -top-3 left-4 rounded-full px-3 py-0.5 text-xs font-black text-white shadow-sm" style={{ backgroundColor: tone }}>
|
||||
{plan.badge}
|
||||
</span>
|
||||
)}
|
||||
<div className={`rounded-3xl p-5 ${featured ? 'bg-orange text-white shadow-lg shadow-orange/30' : 'border border-line bg-white text-ink'}`}>
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black">{isPack ? <>{toFa(plan.questionCredits)} <span className="text-base">سوال</span></> : plan.title}</h3>
|
||||
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ هوشمند، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-base font-black">{formatToman(plan.price)}</p>
|
||||
{months > 1 && !isPack && <p className={`text-[11px] ${tone}`}>ماهی {formatToman(Math.round(plan.price / months / 1000) * 1000)}</p>}
|
||||
{saving > 0 && <p className={`mt-0.5 text-[11px] font-black ${featured ? 'text-white' : 'text-mint'}`}>{toFa(saving)}٪ ارزانتر</p>}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="mb-4 flex flex-col gap-2 text-sm">
|
||||
{isPack && plan.heartsDays > 0 && (
|
||||
<li className="flex items-center gap-2 font-bold">
|
||||
<Heart size={14} fill={featured ? '#fff' : '#ED6956'} className={featured ? 'text-white' : 'text-coral'} />
|
||||
هدیه: {toFa(plan.heartsDays)} روز قلبِ نامحدود
|
||||
</li>
|
||||
)}
|
||||
{!isPack && plan.aiDailyQuota > 0 && (
|
||||
<li className="flex items-center gap-2 font-bold"><Sparkles size={14} /> {toFa(plan.aiDailyQuota)} سوال در روز از دستیار</li>
|
||||
)}
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-center gap-2">
|
||||
<Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
|
||||
<span className={featured ? 'text-white/90' : 'text-body'}>{f}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
<span
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2"
|
||||
style={{ borderColor: selected ? tone : '#d6d3ea', backgroundColor: selected ? tone : '#fff' }}
|
||||
>
|
||||
{selected && <Check size={14} strokeWidth={3.5} className="text-white" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-lg font-black text-ink">{durationLabel(plan.durationDays) || plan.title}</span>
|
||||
{saving > 0 && <span className="mt-0.5 block text-sm font-bold text-[#2E9E6A]">{toFa(saving)}٪ ارزانتر از ماهانه</span>}
|
||||
</span>
|
||||
<span className="text-left text-base font-black text-ink">{price(plan.price)}</span>
|
||||
</span>
|
||||
<ul className="flex flex-col gap-1.5 border-t border-line pt-3">
|
||||
{perks.map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-sm font-bold text-body">
|
||||
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-orange text-white"><Check size={12} strokeWidth={3.5} /></span>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBuy}
|
||||
disabled={!available}
|
||||
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-50 ${featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'}`}
|
||||
>
|
||||
{available ? 'خرید' : 'بهزودی در بازار'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** چرا بازار برای این پلن و اینجا ممکن نیست؛ null یعنی ممکن است. */
|
||||
function bazaarBlocker(plan: Plan | null): string | null {
|
||||
if (!plan?.bazaarSku) return 'این مورد در بازار نیست';
|
||||
if (!inAndroidApp()) return 'فقط در اپ اندروید';
|
||||
if (!canBuyFromBazaar()) return 'اپ رو از بازار بهروز کن';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* خرید: سفارش ساخته میشود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت میشود،
|
||||
* و بعد کاربر در همین برگه به درگاه میرود. window.open بعد از await را
|
||||
* Safariِ آیفون مسدود میکند؛ برای همین جابهجاییِ همین صفحه است.
|
||||
* خرید: سفارش ساخته میشود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت میشود، و بعد کاربر به درگاه یا به
|
||||
* صفحهی خریدِ بازار میرود. window.open بعد از await را Safariِ آیفون مسدود میکند؛ برای همین درگاه
|
||||
* جابهجاییِ همین صفحه است.
|
||||
*
|
||||
* روشهای پرداخت به شکلِ آشنای برگهی پرداختِ اپها میآیند: هر روش یک ردیف با نشانِ خودِ درگاه، نام و یک
|
||||
* دایرهی انتخاب، و دکمهی پایین مبلغ را میگوید (خواستِ مالک، ۲۰۲۶-۱۰-۱۴: «حسِ اطمینان»). کدام روشها در مرورگر و
|
||||
* کدام داخلِ اپ دیده شوند از config.js میآید (src/lib/paymentMethods.ts)، تا نسخهی کافهبازار فقط بازار را نشان دهد
|
||||
* و سایت فقط زرینپال را، بی build. روشی که فهرست شده ولی اینجا ممکن نیست (APKِ قدیمی) غیرفعال با دلیل میآید.
|
||||
* ⚠️ قانونِ کافهبازار پرداختِ محتوای دیجیتال بیرون از پرداختِ درونبرنامهای را محدود میکند؛ زرینپال داخلِ اپ
|
||||
* تصمیمِ مالک است و با بازار هماهنگ نشده.
|
||||
*/
|
||||
function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) {
|
||||
function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | null; storePrices: Record<string, string>; onClose: () => void; onPaid: () => void }) {
|
||||
const blocker = bazaarBlocker(plan);
|
||||
const methods = useMemo(enabledPaymentMethods, []);
|
||||
const firstUsable = methods.find((m) => m !== 'bazaar' || !blocker) ?? methods[0];
|
||||
const [method, setMethod] = useState<PaymentMethod>(firstUsable);
|
||||
const [code, setCode] = useState('');
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [done, setDone] = useState<'code' | 'bazaar' | null>(null);
|
||||
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
|
||||
|
||||
// هر بار که برگه برای پلنی باز میشود، اولین روشِ ممکن انتخاب است (ترتیب از config.js؛ پیشفرض زرینپال اول).
|
||||
useEffect(() => {
|
||||
if (plan) setMethod(firstUsable);
|
||||
}, [plan, firstUsable]);
|
||||
// برگشت از صفحهی درگاه با دکمهی «برگشت»: مرورگر صفحه را از bfcache با همان busy=true برمیگرداند و دکمهی
|
||||
// «پرداخت» تا ابد میچرخید.
|
||||
useEffect(() => {
|
||||
const onShow = (e: PageTransitionEvent) => {
|
||||
if (e.persisted) setBusy(false);
|
||||
};
|
||||
window.addEventListener('pageshow', onShow);
|
||||
return () => window.removeEventListener('pageshow', onShow);
|
||||
}, []);
|
||||
const storePrice = plan?.bazaarSku ? storePrices[plan.bazaarSku] : undefined;
|
||||
|
||||
/**
|
||||
* داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید.
|
||||
* کدِ تخفیف اینجا نیست: بازار قیمتِ ثابتِ محصول را میگیرد و سرور تخفیف را رد میکند.
|
||||
* بازار: سفارش (با کلیدِ ثابتِ همین SKU) → کدِ تخفیف → شروع (SKU، نوع، قیمتِ امضاشده) → خرید در بازار
|
||||
* → سنجشِ سرور. اشتراکِ بازار مصرف (consume) نمیشود؛ بازار خودش آخرِ هر دوره تمدیدش میکند.
|
||||
*/
|
||||
async function payWithBazaar(plan: Plan) {
|
||||
async function payWithBazaar(plan: Plan): Promise<'bazaar' | 'code' | null> {
|
||||
const sku = plan.bazaarSku;
|
||||
let key = bazaarPending.get(sku);
|
||||
if (key) {
|
||||
@@ -250,30 +246,49 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
||||
bazaarPending.set(sku, key);
|
||||
}
|
||||
const order = await api.subscription.purchaseBazaar(plan.id, key);
|
||||
if (code.trim()) {
|
||||
const current = await api.subscription.order(order.orderId);
|
||||
// سفارشِ مانده از تلاشِ قبلی ممکن است همین کد را از قبل گرفته باشد.
|
||||
if (current.amount === plan.price) {
|
||||
const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
|
||||
if (redeemed.orderStatus === 'paid') {
|
||||
bazaarPending.clear(sku);
|
||||
return 'code';
|
||||
}
|
||||
}
|
||||
}
|
||||
const start = await api.subscription.startBazaar(order.orderId);
|
||||
let token: string;
|
||||
try {
|
||||
token = await bazaarPurchase(order.bazaarSku || sku, order.orderId);
|
||||
token = await bazaarBuy(start.sku, order.orderId, start.dynamicPriceToken);
|
||||
} catch (err) {
|
||||
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return false;
|
||||
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return null;
|
||||
throw err;
|
||||
}
|
||||
const result = await api.subscription.verifyBazaar(order.orderId, token);
|
||||
if (result.status !== 'paid') throw new Error('bazaar purchase not confirmed');
|
||||
bazaarPending.clear(sku);
|
||||
bazaarConsume(token);
|
||||
return true;
|
||||
return 'bazaar';
|
||||
}
|
||||
|
||||
async function pay() {
|
||||
if (!plan || busy) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
if (bazaar) {
|
||||
if (method === 'bazaar') {
|
||||
try {
|
||||
if (await payWithBazaar(plan)) setDone('bazaar');
|
||||
const outcome = await payWithBazaar(plan);
|
||||
if (outcome) setDone(outcome);
|
||||
} catch (err) {
|
||||
if (err instanceof BazaarPurchaseError && err.kind === 'unavailable') {
|
||||
// کارِ کاربر است، نه خطای خرید: پولی جابهجا نشده.
|
||||
setError(err.message === 'old_bazaar'
|
||||
? 'کافهبازار گوشیت قدیمیه؛ از خود بازار بهروزش کن و دوباره بزن، یا با کارت بانکی پرداخت کن.'
|
||||
: 'کافهبازار روی گوشیت پیدا نشد یا وصل نشد. مطمئن شو نصبه و وارد حسابت شدی، بعد دوباره بزن؛ یا با کارت بانکی پرداخت کن.');
|
||||
} else {
|
||||
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
|
||||
}
|
||||
}
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
@@ -290,7 +305,18 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
||||
if (!order.paymentUrl) throw new Error('order without paymentUrl');
|
||||
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
|
||||
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
|
||||
const shell = (window as unknown as { TadrisinoAndroid?: { paysInApp?: () => boolean } }).TadrisinoAndroid;
|
||||
if (inAndroidApp() && !shell?.paysInApp?.()) {
|
||||
// اپِ اندرویدِ قدیمی هر آدرسِ بیرونی را در مرورگرِ گوشی باز میکند و آن مرورگر Referer نمیفرستد؛ زرینپال
|
||||
// بیReferer میگوید «دسترسی از این دامنه مجاز نمیباشد». پس اول pay.htmlِ خودمان در مرورگر باز میشود
|
||||
// (src/pay.ts) و آن به درگاه میرود. «http://» عمداً است: WebView فقط httpsِ همین دامنه را در خودش نگه
|
||||
// میدارد؛ آروان بلافاصله به https میبرد و لینک در fragment است، پس به سرور نمیرسد.
|
||||
window.location.assign(`http://${window.location.host}/pay.html#u=${encodeURIComponent(order.paymentUrl)}`);
|
||||
setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500);
|
||||
} else {
|
||||
// مرورگر، یا اپی که پرداخت را در خودش باز میکند (paysInApp): همین صفحه به درگاه میرود و Referer دارد.
|
||||
window.location.assign(order.paymentUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
|
||||
setBusy(false);
|
||||
@@ -306,24 +332,43 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
||||
setDone(null);
|
||||
};
|
||||
|
||||
const doneText =
|
||||
done === 'gateway' ? 'صفحه پرداخت در مرورگر گوشی باز شد. بعد از پرداخت برگرد همینجا؛ خریدت خودش فعال میشه.'
|
||||
: done === 'code' ? 'کد تخفیفت کل مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
|
||||
: 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخر هر دوره خودش تمدیدش میکنه؛ لغو تمدید از بخش «اشتراکها»ی خود بازاره.';
|
||||
|
||||
return (
|
||||
<Sheet open={plan !== null} onClose={close} title={done ? 'خریدت انجام شد! 🎉' : plan?.title}>
|
||||
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? 'پرداخت' : ''}>
|
||||
{done ? (
|
||||
<>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
|
||||
</p>
|
||||
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">{doneText}</p>
|
||||
<PrimaryButton onClick={close}>{done === 'gateway' ? 'باشه' : 'عالیه!'}</PrimaryButton>
|
||||
</>
|
||||
) : plan && (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3">
|
||||
<span className="text-sm text-body">مبلغ</span>
|
||||
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
|
||||
<div className="mb-5 flex items-center gap-3 rounded-2xl bg-soft px-4 py-3.5">
|
||||
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-[#FFF4D6] text-[#E0A200]"><Crown size={20} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-black text-ink">اشتراک {durationLabel(plan.durationDays) || plan.title}</span>
|
||||
<span className="block text-xs text-muted">همه فصلها، قلب نامحدود</span>
|
||||
</span>
|
||||
<span className="text-left text-base font-black text-ink">{method === 'bazaar' && storePrice ? storePrice : price(plan.price)}</span>
|
||||
</div>
|
||||
{bazaar ? null : showCode ? (
|
||||
<div role="radiogroup" aria-label="روش پرداخت" className="mb-4">
|
||||
<span className="mb-2 block text-sm font-black text-ink">روش پرداخت</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{methods.map((m) =>
|
||||
m === 'zarinpal' ? (
|
||||
<MethodRow key={m} selected={method === m} onSelect={() => setMethod(m)} logo={zarinpalLogo} title="زرینپال" note="کارت بانکی، همه بانکهای عضو شتاب" />
|
||||
) : (
|
||||
<MethodRow key={m} selected={method === m} onSelect={() => setMethod(m)} logo={bazaarLogo} title="کافهبازار" note={blocker ?? (storePrice ? `${storePrice} · با حساب بازارت` : 'با حساب بازارت')} disabled={!!blocker} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showCode ? (
|
||||
<label className="mb-4 block">
|
||||
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
|
||||
<span className="mb-2 block text-sm font-bold text-body">کد تخفیف</span>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.trim().slice(0, 40))}
|
||||
@@ -332,17 +377,56 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
||||
placeholder="CODE"
|
||||
className="w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-coral"
|
||||
/>
|
||||
<span className="mt-1.5 block text-[11px] text-muted">مبلغِ نهایی بعد از اعمالِ کد در درگاه نشون داده میشه.</span>
|
||||
</label>
|
||||
) : (
|
||||
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex items-center gap-1.5 text-sm font-bold text-coral">
|
||||
<Ticket size={15} /> کدِ تخفیف دارم
|
||||
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex w-full items-center gap-2 rounded-2xl border border-dashed border-line px-4 py-3 text-sm font-bold text-body active:bg-soft">
|
||||
<Ticket size={16} className="text-coral" /> <span className="flex-1 text-right">کد تخفیف دارم</span> <ChevronDown size={16} className="text-faint" />
|
||||
</button>
|
||||
)}
|
||||
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
|
||||
<PrimaryButton loading={busy} onClick={() => void pay()}>پرداخت</PrimaryButton>
|
||||
<PrimaryButton loading={busy} disabled={method === 'bazaar' && !!blocker} onClick={() => void pay()}>
|
||||
{`پرداخت ${method === 'bazaar' && storePrice ? storePrice : price(plan.price)}`}
|
||||
</PrimaryButton>
|
||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
|
||||
<Lock size={13} />
|
||||
{method === 'bazaar'
|
||||
? 'پرداخت امن در خود کافهبازار؛ آخر هر دوره خودکار تمدید میشه.'
|
||||
: 'پرداخت امن در درگاه زرینپال'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** یک روشِ پرداخت به شکلِ ردیفِ آشنای برگههای پرداخت: نشانِ درگاه، نام و توضیح، و دایرهی انتخاب. */
|
||||
function MethodRow({ selected, onSelect, logo, title, note, disabled = false }: { selected: boolean; onSelect: () => void; logo: string; title: string; note: string; disabled?: boolean }) {
|
||||
const on = selected && !disabled;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border-2 px-3 py-3 text-right transition-colors ${
|
||||
disabled ? 'cursor-not-allowed border-line bg-soft opacity-60' : on ? 'border-coral bg-[#fff6f4]' : 'border-line bg-white active:bg-soft'
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl border border-line bg-white">
|
||||
<img src={logo} alt="" width={30} height={30} className={disabled ? 'grayscale' : ''} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[15px] font-black text-ink">{title}</span>
|
||||
<span className="block truncate text-xs text-muted">{note}</span>
|
||||
</span>
|
||||
<span
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2"
|
||||
style={{ borderColor: on ? '#ED6956' : '#d6d3ea', backgroundColor: on ? '#ED6956' : '#fff' }}
|
||||
>
|
||||
{on && <Check size={14} strokeWidth={3.5} className="text-white" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ const CATEGORIES = [
|
||||
{ id: 'learning', label: 'درس و سوالها' },
|
||||
{ id: 'payment', label: 'پرداخت و اشتراک' },
|
||||
{ id: 'account', label: 'حساب و ورود' },
|
||||
{ id: 'other', label: 'چیزِ دیگه' },
|
||||
{ id: 'other', label: 'چیز دیگه' },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
open: 'باز',
|
||||
in_progress: 'در حالِ بررسی',
|
||||
in_progress: 'در حال بررسی',
|
||||
answered: 'پاسخ داده شد',
|
||||
closed: 'بسته',
|
||||
};
|
||||
@@ -31,7 +31,7 @@ export function SupportScreen() {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
// فهرست از سرور میآید، پس تیکت و پاسخش با عوض شدنِ گوشی هم دیده میشود.
|
||||
const tickets = useAsync(() => api.support.tickets(), []);
|
||||
const tickets = useAsync(() => api.support.tickets(), [], { key: 'support-tickets' });
|
||||
|
||||
async function submit() {
|
||||
if (!subject.trim() || !text.trim()) return;
|
||||
@@ -58,10 +58,10 @@ export function SupportScreen() {
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader title="پشتیبانی" subtitle="سوال، مشکل یا پیشنهاد داری؟ بنویس." color="#4C5FD5" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} />
|
||||
<ScreenHeader title="پشتیبانی" subtitle="سوال، مشکل یا پیشنهاد داری؟ بنویس." color="#4C5FD5" back mascot={{ mood: 'happy', size: 56 }} />
|
||||
<ScreenBody className="flex flex-col gap-4">
|
||||
<div className="flex gap-1 rounded-2xl bg-soft p-1">
|
||||
{([['ticket', 'پیام به پشتیبانی', MessageCircle], ['bug', 'گزارشِ خطای اپ', Bug]] as const).map(([id, label, Icon]) => (
|
||||
{([['ticket', 'پیام به پشتیبانی', MessageCircle], ['bug', 'گزارش خطای اپ', Bug]] as const).map(([id, label, Icon]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
@@ -108,7 +108,7 @@ export function SupportScreen() {
|
||||
|
||||
{!!tickets.data?.length && (
|
||||
<section>
|
||||
<h2 className="mb-2 mt-2 text-sm font-black text-ink">پیامهای قبلیِ تو</h2>
|
||||
<h2 className="mb-2 mt-2 text-sm font-black text-ink">پیامهای قبلی تو</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{tickets.data.map((t) => <TicketCard key={t.ticketId} summary={t} />)}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { MathText } from '@/lib/MathText';
|
||||
export function UnitScreen({ unitId }: { unitId: string }) {
|
||||
const { home, homeError, refreshHome, companion } = useAppData();
|
||||
const nav = useNav();
|
||||
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
|
||||
const paywall = () => nav.push({ name: 'subscription' });
|
||||
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
|
||||
const found = findUnit(home, unitId);
|
||||
|
||||
@@ -43,7 +43,7 @@ export function UnitScreen({ unitId }: { unitId: string }) {
|
||||
>
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 flex justify-between text-[10px] text-white/75">
|
||||
<span>پیشرفتِ تو</span>
|
||||
<span>پیشرفت تو</span>
|
||||
<span>{toFa(progress.percent)}٪</span>
|
||||
</div>
|
||||
<ProgressBar percent={progress.percent} color="#fff" track="rgba(0,0,0,.1)" height={10} />
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { api, type Character, type Grade, type HomeResponse, type UserProfile, type UserSummary } from '@/api';
|
||||
import { seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
|
||||
import { companionStore, useCompanion } from '@/state/companion';
|
||||
import { homeSnapshot } from '@/state/homeSnapshot';
|
||||
import { fetchHome, subjectStore } from '@/state/subject';
|
||||
|
||||
/**
|
||||
* دادههایی که چند صفحه با هم لازم دارند: پروفایل، آمار، نقشه، همراهِ انتخابی
|
||||
@@ -17,7 +20,12 @@ interface AppData {
|
||||
/** همراهی که کاربر در صفحهی کاراکترها انتخاب کرده؛ پیشفرض سیگما. */
|
||||
companion: SeyarChar;
|
||||
unreadCount: number;
|
||||
refreshHome: () => Promise<void>;
|
||||
/** `gradeId` را فقط وقتی بدهید که پایه همین الان عوض شده و state هنوز رندر نشده. */
|
||||
refreshHome: (gradeId?: number) => Promise<void>;
|
||||
/** عوضِ کتابِ نقشه (هندسه، حسابان، …)؛ آزمونها و درسها هر دو همین را میخوانند. */
|
||||
selectSubject: (key: string) => void;
|
||||
/** کتابی که روی `home` است؛ بینقشه (هنوز نرسیده) null. */
|
||||
subjectKey: string | null;
|
||||
refreshSummary: () => Promise<void>;
|
||||
refreshUnread: () => Promise<void>;
|
||||
setProfile: (profile: UserProfile) => void;
|
||||
@@ -27,30 +35,76 @@ interface AppData {
|
||||
|
||||
const AppDataContext = createContext<AppData | null>(null);
|
||||
|
||||
/** آنچه در اسپلش پیش از ورود به اپ گرفته شد (App.prefetchMain). */
|
||||
export interface Prefetch {
|
||||
home: HomeResponse | null;
|
||||
summary: UserSummary | null;
|
||||
}
|
||||
|
||||
export function AppDataProvider({
|
||||
initialProfile,
|
||||
grades,
|
||||
grades: initialGrades,
|
||||
prefetch,
|
||||
stale = false,
|
||||
onIdentityChanged,
|
||||
children,
|
||||
}: {
|
||||
initialProfile: UserProfile;
|
||||
grades: Grade[];
|
||||
prefetch?: Prefetch;
|
||||
/**
|
||||
* دادهی اولیه عکسِ ذخیرهشده روی گوشی است (state/homeSnapshot.ts)، نه پاسخِ همین حالا: همهچیز پشتِ صحنه
|
||||
* دوباره گرفته میشود. همان چهار درخواستِ بوت، پس بارِ سرور بیشتر نمیشود؛ فقط کاربر منتظرشان نمیماند.
|
||||
*/
|
||||
stale?: boolean;
|
||||
/** پروفایلِ تازه با عکس نمیخواند (کاربرِ دیگر، یا بینام/بیپایه که باید به تکمیلِ پروفایل برود). */
|
||||
onIdentityChanged?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [profile, setProfile] = useState(initialProfile);
|
||||
const [summary, setSummary] = useState<UserSummary | null>(null);
|
||||
const [home, setHome] = useState<HomeResponse | null>(null);
|
||||
const [grades, setGrades] = useState(initialGrades);
|
||||
const [summary, setSummary] = useState<UserSummary | null>(prefetch?.summary ?? null);
|
||||
const [home, setHome] = useState<HomeResponse | null>(prefetch?.home ?? null);
|
||||
const [homeError, setHomeError] = useState<unknown>(null);
|
||||
const [companion, setCompanionChar] = useState<SeyarChar>('sigma');
|
||||
// همراه در companionStore است تا بیرون از AppData (اسپلش، نشان، لودرها) هم خوانده شود.
|
||||
const companion = useCompanion().char;
|
||||
const setCompanionChar = companionStore.set;
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
const refreshHome = useCallback(async () => {
|
||||
const gradeId = profile.gradeId;
|
||||
// نقشهی کتابهایی که دیده شده؛ جابهجایی به کتابِ دیدهشده فوری است و پشتصحنه تازه میشود.
|
||||
const homeCache = useRef(new Map<string, HomeResponse>());
|
||||
// فقط پاسخِ آخرین درخواست روی صفحه مینشیند: وقتی پایه یا کتاب وسطِ راه عوض شود (مثلاً پروفایلِ تازهی
|
||||
// پشتِ صحنه پایهی دیگری گفت)، پاسخِ کندترِ درخواستِ قبلی نباید نقشهی تازه را بپوشاند.
|
||||
const homeSeq = useRef(0);
|
||||
|
||||
const refreshHome = useCallback(async (forGrade?: number) => {
|
||||
const target = forGrade ?? gradeId;
|
||||
const seq = ++homeSeq.current;
|
||||
try {
|
||||
setHome(await api.home());
|
||||
const fresh = await fetchHome(target);
|
||||
if (fresh.subject) homeCache.current.set(`${target}:${fresh.subject.key}`, fresh);
|
||||
if (seq !== homeSeq.current) return;
|
||||
setHome(fresh);
|
||||
setHomeError(null);
|
||||
} catch (err) {
|
||||
setHomeError(err);
|
||||
if (seq === homeSeq.current) setHomeError(err);
|
||||
}
|
||||
}, []);
|
||||
}, [gradeId]);
|
||||
|
||||
const selectSubject = useCallback(
|
||||
(key: string) => {
|
||||
if (gradeId == null) return;
|
||||
subjectStore.set(gradeId, key);
|
||||
const cached = homeCache.current.get(`${gradeId}:${key}`);
|
||||
// کتابِ دیدهشده فوری میآید؛ دیدهنشده یک لحظه «در حال آوردن» است تا نقشهی کتابِ قبلی زیرِ نامِ
|
||||
// کتابِ تازه نماند.
|
||||
setHome(cached ?? null);
|
||||
setHomeError(null);
|
||||
void refreshHome();
|
||||
},
|
||||
[gradeId, refreshHome],
|
||||
);
|
||||
|
||||
const refreshSummary = useCallback(async () => {
|
||||
try {
|
||||
@@ -68,19 +122,76 @@ export function AppDataProvider({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// بارِ اول فقط یک بار؛ عوض شدنِ پایه refreshHome را تازه میسازد و نباید همهی اینها دوباره گرفته شوند.
|
||||
const started = useRef(false);
|
||||
// عکس مالِ این کاربر نبود: این AppData تا برداشته شدن دیگر عکس نمینویسد (پاسخِ دیرِ نقشه با پروفایلِ غلط).
|
||||
const identityLost = useRef(false);
|
||||
useEffect(() => {
|
||||
void refreshHome();
|
||||
void refreshSummary();
|
||||
if (started.current) return;
|
||||
started.current = true;
|
||||
// اعلانها و همراه برای صفحهی اول لازم نیستند.
|
||||
const loadExtras = () => {
|
||||
if (!api.auth.isSignedIn()) return;
|
||||
void refreshUnread();
|
||||
api.characters
|
||||
.list()
|
||||
.then((list) => {
|
||||
const selected = seyarFromSlug(list.find((c) => c.selected)?.slug);
|
||||
if (selected) setCompanionChar(selected);
|
||||
// بیانتخاب یعنی پیشفرضِ سرور (سیگما)؛ همراهِ کاربرِ قبلی روی این دستگاه نباید بماند.
|
||||
setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma');
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err));
|
||||
};
|
||||
if (stale) {
|
||||
api.profile
|
||||
.get()
|
||||
.then((fresh) => {
|
||||
if (fresh.userId !== initialProfile.userId || !fresh.name || fresh.gradeId == null) {
|
||||
identityLost.current = true;
|
||||
onIdentityChanged?.();
|
||||
return false;
|
||||
}
|
||||
setProfile(fresh);
|
||||
// پایه در دستگاهِ دیگری عوض شده؛ نقشهای که همزمان با پایهی عکس گرفته شد به کار نمیآید.
|
||||
if (fresh.gradeId !== initialProfile.gradeId) void refreshHome(fresh.gradeId);
|
||||
return true;
|
||||
})
|
||||
// ۴۰۱ را http خودش به خروج میبرد. بقیه (اینترنتِ قطع، ۵xx): پروفایلِ عکس میماند و نوارِ «اطلاعاتِ
|
||||
// ذخیرهشده» در خانه (homeError) خبر میدهد؛ نقشه همزمان همین درخواستها را میزند و همانجا شکست میخورد.
|
||||
.catch((err: unknown) => {
|
||||
console.warn('profile refresh failed; showing the stored one', err);
|
||||
return true;
|
||||
})
|
||||
// بعد از پروفایل، نه همزمان: با توکنِ یکساعتهی منقضی هر درخواستِ همزمان یک ۴۰۱ و یک تکرار میشود؛
|
||||
// اینطور تعدادِ درخواستها همان بوتِ قبلی است (۴ + refresh + ۴ تکرار + ۲).
|
||||
.then((stays) => stays && loadExtras());
|
||||
api
|
||||
.grades()
|
||||
.then(setGrades)
|
||||
.catch((err: unknown) => console.warn('grades refresh failed; keeping the stored list', err));
|
||||
void refreshHome();
|
||||
void refreshSummary();
|
||||
} else {
|
||||
// آنچه در اسپلش رسیده دوباره گرفته نمیشود؛ فقط کمها.
|
||||
if (!prefetch?.home) void refreshHome();
|
||||
if (!prefetch?.summary) void refreshSummary();
|
||||
loadExtras();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshHome, refreshSummary, refreshUnread]);
|
||||
|
||||
// هر دادهی تازه عکسِ خانه را بهروز میکند. بارِ اولِ حالتِ stale همان عکسِ خواندهشده است و نوشته نمیشود،
|
||||
// تا savedAt فقط با پاسخِ سرور جلو برود.
|
||||
const skipSnapshotWrite = useRef(stale);
|
||||
useEffect(() => {
|
||||
if (skipSnapshotWrite.current) {
|
||||
skipSnapshotWrite.current = false;
|
||||
return;
|
||||
}
|
||||
// پاسخی که بعد از خروج رسید (توکن پاک شده) نباید عکسِ تازه بسازد.
|
||||
if (!home || !summary || identityLost.current || !api.auth.isSignedIn()) return;
|
||||
homeSnapshot.save({ profile, grades, home, summary });
|
||||
}, [profile, grades, home, summary]);
|
||||
|
||||
const value = useMemo<AppData>(
|
||||
() => ({
|
||||
profile,
|
||||
@@ -92,13 +203,15 @@ export function AppDataProvider({
|
||||
companion,
|
||||
unreadCount,
|
||||
refreshHome,
|
||||
selectSubject,
|
||||
subjectKey: home?.subject?.key ?? null,
|
||||
refreshSummary,
|
||||
refreshUnread,
|
||||
setProfile,
|
||||
setCompanion: (character) => setCompanionChar(seyarFromSlug(character.slug) ?? 'sigma'),
|
||||
setUnreadCount,
|
||||
}),
|
||||
[profile, grades, summary, home, homeError, companion, unreadCount, refreshHome, refreshSummary, refreshUnread],
|
||||
[profile, grades, summary, home, homeError, companion, unreadCount, refreshHome, selectSubject, refreshSummary, refreshUnread],
|
||||
);
|
||||
|
||||
return <AppDataContext.Provider value={value}>{children}</AppDataContext.Provider>;
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface ChatMessage {
|
||||
feedback?: 'up' | 'down';
|
||||
/** پیامِ خطا بهجای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده میشود. */
|
||||
failedQuestion?: string;
|
||||
/** سوالِ آزمونی که پرسش دربارهاش بود («چرا غلط بود؟»)؛ «دوباره» باید همان را دوباره بفرستد. */
|
||||
failedQuestionId?: string;
|
||||
/** سهمیهی رایگانِ امروز تمام شده؛ بهجای «دوباره»، راهِ اشتراک نشان داده میشود. */
|
||||
quotaReached?: boolean;
|
||||
}
|
||||
|
||||
70
src/state/companion.ts
Normal file
70
src/state/companion.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { KEYS, store } from '@/lib/storage';
|
||||
import { SEYAR_INFO, seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
|
||||
|
||||
/**
|
||||
* همراهِ انتخابیِ کاربر، یک جا برای کلِ اپ: نشانِ سرِ خانه، ماسکوتِ سربرگها و لودرها، نام و چهرهی دستیار، و ویژگیِ
|
||||
* کوچکِ هر همراه. پیشتر هر صفحه همراهِ خودش را ثابت نوشته بود (فروشگاه و دستیار «پی»، درسها «سیگما»، …) و انتخابِ
|
||||
* کاربر فقط در خانه و درس دیده میشد.
|
||||
*
|
||||
* در localStorage هم میماند تا اسپلش و صفحهی ورود پیش از رسیدنِ پاسخِ سرور همان همراه را نشان دهند؛ منبعِ حقیقت
|
||||
* همچنان سرور است (AppData بعد از ورود از /characters میخواند و این را بهروز میکند).
|
||||
*/
|
||||
export interface CompanionLook {
|
||||
char: SeyarChar;
|
||||
name: string;
|
||||
symbol: string;
|
||||
/** رنگِ اصلی (بدنهی ماسکوت) و رنگِ روشنترِ کنارش، برای نشان و تهرنگ. */
|
||||
color: string;
|
||||
light: string;
|
||||
/** ویژگیِ این همراه؛ در صفحهی همراهها نوشته شده و در آزمون دیده میشود (QuizScreen). */
|
||||
trait: { title: string; text: string };
|
||||
/** گامِ صدای «درست» نسبت به سیگما (نیمپرده)؛ هر همراه صدای خودش را دارد. */
|
||||
pitch: number;
|
||||
}
|
||||
|
||||
export const COMPANIONS: Record<SeyarChar, CompanionLook> = {
|
||||
sigma: {
|
||||
char: 'sigma', name: SEYAR_INFO.sigma.name, symbol: 'Σ', color: '#4C5FD5', light: '#7A8CFF', pitch: 0,
|
||||
trait: { title: 'جمعزن', text: 'تو آزمون جوابای درستت رو یکییکی برات جمع میزنه.' },
|
||||
},
|
||||
delta: {
|
||||
char: 'delta', name: SEYAR_INFO.delta.name, symbol: 'Δ', color: '#FF7A3D', light: '#FFB25E', pitch: 2,
|
||||
trait: { title: 'شعله سرعت', text: 'جوابای درست پشت هم رو آتیشی میکنه و تشویقت میکنه.' },
|
||||
},
|
||||
pi: {
|
||||
char: 'pi', name: SEYAR_INFO.pi.name, symbol: 'π', color: '#9B7BFF', light: '#C6B3FF', pitch: 4,
|
||||
trait: { title: 'راهنمای مهربون', text: 'وقتی غلط میزنی یه نکته کوچیک میگه که دفعه بعد گیر نکنی.' },
|
||||
},
|
||||
tedi: {
|
||||
char: 'tedi', name: SEYAR_INFO.tedi.name, symbol: 'ω', color: '#E8A400', light: '#FFC63D', pitch: 7,
|
||||
trait: { title: 'جشنساز', text: 'آخر هر جزیره، هر نمرهای بگیری، برات جشن طلایی میگیره.' },
|
||||
},
|
||||
};
|
||||
|
||||
let current: SeyarChar = seyarFromSlug(store.get(KEYS.companion)) ?? 'sigma';
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export const companionStore = {
|
||||
get: () => current,
|
||||
set(char: SeyarChar) {
|
||||
if (char === current) return;
|
||||
current = char;
|
||||
store.set(KEYS.companion, char);
|
||||
listeners.forEach((l) => l());
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
|
||||
export function useCompanion(): CompanionLook {
|
||||
const char = useSyncExternalStore(companionStore.subscribe, companionStore.get, companionStore.get);
|
||||
return COMPANIONS[char];
|
||||
}
|
||||
|
||||
/** «من سیگمام»، «من پیام». */
|
||||
export function iAm(name: string): string {
|
||||
return name.endsWith('ی') ? `${name}ام` : `${name}م`;
|
||||
}
|
||||
63
src/state/homeSnapshot.ts
Normal file
63
src/state/homeSnapshot.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Grade, HomeResponse, UserProfile, UserSummary } from '@/api';
|
||||
import { KEYS, store } from '@/lib/storage';
|
||||
|
||||
/**
|
||||
* عکسِ صفحهی خانه روی گوشی: آخرین پروفایل، پایهها، نقشه و آمار. اپ با آن فوراً خانه را نشان میدهد و
|
||||
* همان درخواستهای همیشگی را پشتِ صحنه میزند (AppData، `stale`)؛ بدونِ آن اسپلش تا رسیدنِ هر چهار پاسخ
|
||||
* میماند، که روی اینترنتِ ضعیف و با توکنِ یکساعتهی منقضی (۴۰۱ ← refresh ← تکرار) چند رفتوبرگشتِ پشتِ هم است.
|
||||
*
|
||||
* درستی را سرور نگه میدارد، نه این عکس: جزیرهی قفل (۴۰۳)، اشتراکِ تمامشده (۴۰۲) و قلبِ صفر (۴۰۳) همانجا
|
||||
* رد میشوند؛ عکسِ قدیمی فقط ممکن است یکی دو ثانیه عددِ کهنه نشان دهد.
|
||||
*
|
||||
* با پاک شدنِ توکن (خروج، حذفِ حساب، نشستِ باطل) و با ورودِ تازه پاک میشود (api/index.ts)، تا خانهی یک
|
||||
* کاربر روی گوشیِ مشترک به کاربرِ بعدی نرسد.
|
||||
*/
|
||||
export interface HomeSnapshot {
|
||||
build: string;
|
||||
savedAt: number;
|
||||
profile: UserProfile;
|
||||
grades: Grade[];
|
||||
home: HomeResponse;
|
||||
summary: UserSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* عکسِ کهنهتر از این کنار گذاشته میشود و اپ مثلِ قبل منتظرِ سرور میماند. انتخابِ محصولی: کسی که یک هفته
|
||||
* نیامده، آمدنِ یکبارهی نقشهی تازه را به دیدنِ نقشهی یک هفته پیش ترجیح میدهد.
|
||||
*/
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const homeSnapshot = {
|
||||
/** عکسِ قابلِ استفاده، یا null: نبود، مالِ بیلدِ دیگری بود (شکلِ داده ممکن است عوض شده باشد)، کهنه یا ناقص بود. */
|
||||
read(): HomeSnapshot | null {
|
||||
const raw = store.get(KEYS.homeSnapshot);
|
||||
if (!raw) return null;
|
||||
let snap: Partial<HomeSnapshot>;
|
||||
try {
|
||||
snap = JSON.parse(raw) as Partial<HomeSnapshot>;
|
||||
} catch (err) {
|
||||
console.warn('home snapshot unreadable; dropped', err);
|
||||
store.remove(KEYS.homeSnapshot);
|
||||
return null;
|
||||
}
|
||||
const usable =
|
||||
snap.build === __APP_BUILD__ &&
|
||||
typeof snap.savedAt === 'number' &&
|
||||
Date.now() - snap.savedAt < MAX_AGE_MS &&
|
||||
// بینام یا بیپایه یعنی کاربر باید به «تکمیلِ پروفایل» برود؛ آن تصمیم با پاسخِ سرور است.
|
||||
Boolean(snap.profile?.name) &&
|
||||
snap.profile?.gradeId != null &&
|
||||
Array.isArray(snap.grades) &&
|
||||
Array.isArray(snap.home?.units) &&
|
||||
snap.summary != null;
|
||||
return usable ? (snap as HomeSnapshot) : null;
|
||||
},
|
||||
|
||||
save(data: Omit<HomeSnapshot, 'build' | 'savedAt'>) {
|
||||
store.set(KEYS.homeSnapshot, JSON.stringify({ ...data, build: __APP_BUILD__, savedAt: Date.now() }));
|
||||
},
|
||||
|
||||
clear() {
|
||||
store.remove(KEYS.homeSnapshot);
|
||||
},
|
||||
};
|
||||
68
src/state/subject.ts
Normal file
68
src/state/subject.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { api, ApiError, type HomeResponse } from '@/api';
|
||||
import { KEYS, store } from '@/lib/storage';
|
||||
|
||||
/**
|
||||
* کتابی که دانشآموز در هر پایه انتخاب کرده («هندسه»، «حسابان»، …). یک منبعِ حقیقت برای هر دو تبِ «آزمونها» و
|
||||
* «درسها»: هر دو از `home` در AppData میخوانند و `home` همیشه نقشهی همین کتاب است، پس جابهجایی در یکی در
|
||||
* دیگری هم دیده میشود.
|
||||
*
|
||||
* هر پایه جدا نگه داشته میشود (با عوضکردنِ پایه، انتخابِ پایهی قبلی نباید به دوازدهم بپرد) و در
|
||||
* localStorage میماند تا بعد از بستنِ اپ همان کتاب باز شود. منبعِ حقیقتِ فهرستِ کتابها سرور است
|
||||
* (`home.subjects`)؛ این فقط «کدام» را میگوید.
|
||||
*/
|
||||
type SubjectMap = Record<string, string>;
|
||||
|
||||
function read(): SubjectMap {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(store.get(KEYS.subjects) ?? '{}');
|
||||
return parsed && typeof parsed === 'object' ? (parsed as SubjectMap) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
let current: SubjectMap = read();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export const subjectStore = {
|
||||
get: (gradeId: number): string | null => current[String(gradeId)] ?? null,
|
||||
set(gradeId: number, key: string | null) {
|
||||
const id = String(gradeId);
|
||||
if ((current[id] ?? null) === key) return;
|
||||
const next = { ...current };
|
||||
if (key) next[id] = key;
|
||||
else delete next[id];
|
||||
current = next;
|
||||
store.set(KEYS.subjects, JSON.stringify(next));
|
||||
listeners.forEach((l) => l());
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
snapshot: () => current,
|
||||
};
|
||||
|
||||
/** کتابِ انتخابشدهی این پایه؛ null یعنی هنوز انتخابی نشده (سرور اولین کتاب را میدهد). */
|
||||
export function useSelectedSubject(gradeId: number | null | undefined): string | null {
|
||||
const map = useSyncExternalStore(subjectStore.subscribe, subjectStore.snapshot, subjectStore.snapshot);
|
||||
return gradeId == null ? null : map[String(gradeId)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* نقشهی کتابِ انتخابشده. کلیدِ ذخیرهشدهای که سرور دیگر نمیشناسد (کتاب برداشته شده یا پایه عوض شده)
|
||||
* ۴۰۰ میگیرد؛ آن را پاک میکنیم و نقشهی پیشفرض را میگیریم تا کاربر صفحهی خالی نبیند.
|
||||
*/
|
||||
export async function fetchHome(gradeId: number | null | undefined): Promise<HomeResponse> {
|
||||
const wanted = gradeId == null ? null : subjectStore.get(gradeId);
|
||||
try {
|
||||
return await api.home(wanted ?? undefined);
|
||||
} catch (err) {
|
||||
if (wanted && gradeId != null && err instanceof ApiError && err.status === 400) {
|
||||
subjectStore.set(gradeId, null);
|
||||
return api.home();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,24 @@
|
||||
input, textarea {
|
||||
font: inherit;
|
||||
}
|
||||
/* متنِ چندخطی (پاراگراف، گامهای درس، متنِ سوال، حبابِ پی) دوطرفچین است تا لبهی چپ
|
||||
مرتب باشد. اینها در لایهی baseاند، پس هر جا کلاسِ text-center / text-left داده شده
|
||||
برنده است و تیترها و برچسبهای کوتاه سرِ جایشان میمانند. خطِ آخر طبقِ معمول راستچین
|
||||
میماند. text-justify فقط فاصلهی بینِ کلمهها را باز میکند (کشیدهنویسی در CSS نیست). */
|
||||
p, li, .whitespace-pre-line {
|
||||
text-align: justify;
|
||||
text-justify: inter-word;
|
||||
/* از تککلمهی یتیم در خطِ آخر جلوگیری میکند */
|
||||
text-wrap: pretty;
|
||||
}
|
||||
/* پاراگرافِ داخلِ بخشِ وسطچین (حالتِ خالی، آنبوردینگ، اسپلش) وسطچین میماند؛
|
||||
بدونِ این، قاعدهی بالا ارثِ text-center را میشکست. */
|
||||
.text-center :is(p, li, .whitespace-pre-line) {
|
||||
text-align: center;
|
||||
}
|
||||
.text-left :is(p, li, .whitespace-pre-line) {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
@@ -117,5 +135,23 @@
|
||||
.shine { animation: shine 3.2s ease-in-out infinite; will-change: transform; }
|
||||
@keyframes spark { 0%, 100% { opacity: .25; transform: scale(.7); } 50% { opacity: 1; transform: scale(1); } }
|
||||
.spark { animation: spark 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes rise { 0% { transform: translateY(16px); opacity: 0; } 15%, 85% { opacity: .55; } 100% { transform: translateY(-110px); opacity: 0; } }
|
||||
.rise { animation: rise 7s linear infinite; will-change: transform, opacity; }
|
||||
|
||||
/* نوارِ انتظارِ میانِ دو صفحه (MainApp → HoldBar). */
|
||||
@keyframes hold-bar { 0% { transform: translateX(120%); } 100% { transform: translateX(-320%); } }
|
||||
.hold-bar { animation: hold-bar 0.9s cubic-bezier(.4,0,.2,1) infinite; will-change: transform; }
|
||||
|
||||
/* شعلهی زنجیره (StreakFlame): دو لایه با ریتمِ متفاوت، از پایین تکان میخورند. */
|
||||
@keyframes flame-a { 0%, 100% { transform: scale(1, 1) skewX(0deg); } 33% { transform: scale(.97, 1.04) skewX(-2deg); } 66% { transform: scale(1.02, .97) skewX(2deg); } }
|
||||
@keyframes flame-b { 0%, 100% { transform: scale(1, 1); } 50% { transform: scale(.94, 1.08); } }
|
||||
.flame-outer { transform-box: fill-box; transform-origin: 50% 100%; animation: flame-a 1.4s ease-in-out infinite; }
|
||||
.flame-inner { transform-box: fill-box; transform-origin: 50% 100%; animation: flame-b .9s ease-in-out infinite; }
|
||||
@keyframes ice-glint { 0%, 70%, 100% { opacity: 0; } 80% { opacity: 1; } }
|
||||
.ice-glint { animation: ice-glint 2.8s ease-in-out infinite; }
|
||||
@keyframes spark-rise { 0% { transform: translateY(0) scale(1); opacity: 0; } 15% { opacity: 1; } 100% { transform: translateY(-180px) scale(.3); opacity: 0; } }
|
||||
.spark-rise { animation: spark-rise 1.8s ease-out infinite; }
|
||||
|
||||
/* همراههای کنارِ مسیرِ جزیرهها (IslandPath): فقط transform، تا روی گوشیِ ضعیف هم روی GPU بماند. */
|
||||
@keyframes buddy-hop { 0%, 55%, 100% { transform: translateY(0) scale(1, 1); } 20% { transform: translateY(-12px) scale(.96, 1.05); } 40% { transform: translateY(0) scale(1.06, .94); } }
|
||||
@keyframes buddy-sway { 0%, 100% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } }
|
||||
.buddy-hop { animation: buddy-hop 1.6s ease-in-out infinite; transform-origin: 50% 100%; }
|
||||
.buddy-sway { animation: buddy-sway 3.2s ease-in-out infinite; transform-origin: 50% 100%; }
|
||||
|
||||
9
src/vite-env.d.ts
vendored
9
src/vite-env.d.ts
vendored
@@ -1,10 +1,17 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** شناسهی هر بیلد (vite.config.ts)؛ عکسِ خانهی بیلدِ دیگر خوانده نمیشود. */
|
||||
declare const __APP_BUILD__: string;
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__TADRISINO_CONFIG__?: { apiBaseUrl?: string };
|
||||
__TADRISINO_CONFIG__?: {
|
||||
apiBaseUrl?: string;
|
||||
/** روشهای پرداختِ فعال در مرورگر (web) و داخلِ اپِ اندروید (app)؛ src/lib/paymentMethods.ts. */
|
||||
paymentMethods?: { web?: string[]; app?: string[] };
|
||||
};
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,16 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
// عکسِ خانه (src/state/homeSnapshot.ts) فقط با همان بیلدی خوانده میشود که نوشتهاش؛ بعد از هر دیپلوی
|
||||
// بارِ اول مثلِ قبل از سرور میآید، تا دادهای با شکلِ قدیمی به کدِ تازه نرسد.
|
||||
define: { __APP_BUILD__: JSON.stringify(Date.now().toString(36)) },
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
// pay.html: صفحهی واسطهی درگاه برای اپِ اندروید (src/pay.ts).
|
||||
input: { main: path.resolve(__dirname, 'index.html'), pay: path.resolve(__dirname, 'pay.html') },
|
||||
output: {
|
||||
// کتابخانهها با هر انتشار عوض نمیشوند؛ فایلِ جدا یعنی بعد از هر دیپلوی
|
||||
// از کشِ مرورگر میآیند و فقط کدِ خودِ اپ دوباره دانلود میشود.
|
||||
|
||||
Reference in New Issue
Block a user