29 Commits

Author SHA1 Message Date
5327fed1dd Android: reproducible signed build (keystore and Bazaar RSA key read from outside the repo), Gradle wrapper, build-apk.sh
First time the project has been built: debug and release both produce an APK.
Release refuses to build without keystore or RSA key.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 14:05:48 +03:30
b11016c6ea LoadingState appears only after 300ms: fast API answers no longer flash the loader (packs screen and every other data screen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 17:05:52 +03:30
ec1bfc721d Page switch: show the loading box only after 300ms, not for the 1-2 frames lazy() always takes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 17:02:18 +03:30
5b211c2786 Lesson screen: render fixed lesson figures under intro, example and steps
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 16:26:13 +03:30
0db6526933 Remove every guest remnant: profile badge, register flow, delete-by-word
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 18:20:10 +03:30
af2e1f57dd Login: no guest option; every user signs in with a phone number
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 17:38:18 +03:30
1025321b5f Android WebView: bridge only on our own https page, external schemes blocked, file access off; payment URL must be http(s)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 09:44:47 +03:30
e850f6dfbd Payment return decided by the server only; encoded path ids; no sign-out on a two-tab refresh race
- ?orderId must be numeric; every id in an API path is encodeURIComponent'd.
- The payment sheet shows success only from GET /payment/orders/{id}; a failed lookup says the
  status is unknown instead of "paid".
- A 401 from /auth/refresh is not a sign-out when another tab already stored a rotated token.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1
.gitignore vendored
View File

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

View File

@@ -75,7 +75,9 @@ src/
state/ داده‌ی مشترک (پروفایل، نقشه، …) و گفتگوی دستیار
screens/ یک فایل برای هر صفحه
components/ اجزای مشترک (سربرگ، برگه‌ی پایین، …)
mascots/ سه‌یار: سیگما، دلتا، پی
mascots/ همراه‌ها: سیگما، دلتا، پی، تدی
diagram/ شکلِ جوابِ دستیار: توصیفِ JSON از بک‌اند → SVG؛ همه‌ی مختصات این‌جا حساب می‌شود
(plane، shapes، expr، solid، charts، sets، misc، numberline). گالری: ?gallery فقط در dev
lib/ اعدادِ فارسی، تاریخِ شمسی، صدا، localStorage
```

9
android/.gitignore vendored Normal file
View File

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

65
android/README.md Normal file
View File

@@ -0,0 +1,65 @@
# اپِ اندرویدِ تدریسینو (پوسته‌ی WebView + پرداختِ بازار)
همان سایتِ `math.tadrisino2.ir` داخلِ یک WebView. تنها کدِ بومی پرداختِ درون‌برنامه‌ایِ
کافه‌بازار است (کتابخانه‌ی Poolakey)، چون بازار برای اپی که در آن منتشر می‌شود پرداختِ
محصولِ دیجیتال را فقط از راهِ خودش می‌پذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
فرانت به‌روز می‌شود، بی‌آن‌که نسخه‌ی تازه‌ی اپ لازم باشد.
> ⚠️ APIِ Poolakey از README خودِ کتابخانه است و خرید روی گوشی هنوز آزموده نشده.
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
| چه | کجا گذاشته می‌شود | وضعیت |
|---|---|---|
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.app` حدس است؛ تأیید نشده |
| کلیدِ RSAِ عمومیِ اپ | `bazaarRsaKey` در فایلِ تنظیماتِ بیرون از ریپو (پایین) | ندارد؛ بدونِ آن build نوعِ release نمی‌شود |
| SKUِ هر بسته (محصولِ **مصرف‌شدنی**) | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچ‌کدام تعریف نشده |
قیمتِ هر محصول در پیشخانِ بازار باید با قیمتِ همان بسته در ادمین یکی باشد؛ سرور مبلغ را از
بازار نمی‌خواند.
## تنظیماتِ بیرون از ریپو
keystore و کلیدِ RSA هرگز داخلِ ریپو نمی‌آیند. یک فایل با دسترسیِ ۶۰۰ بسازید: `~/.tadrisino/android.properties`
(یا مسیرِ دلخواه در `TADRISINO_ANDROID_CONFIG`):
```
storeFile=/مسیر/کامل/tadrisino-release.jks
storePassword=...
keyAlias=...
keyPassword=...
bazaarRsaKey=<کلیدِ RSA از پیشخانِ بازار، یک خط>
```
| مقدار | از کجا | اگر نبود |
|---|---|---|
| `storeFile` تا `keyPassword` | keystoreِ شرکت. **همان فایلِ APKِ فعلی را امضا کرده؛ نسخه‌ی تازه با کلیدِ دیگر روی بازار آپدیت نمی‌شود.** دو نسخه‌ی پشتیبان در دو جای جدا | build نوعِ release با پیامِ روشن می‌ایستد |
| `bazaarRsaKey` | پیشخان ← اپ ← پرداخت درون‌برنامه‌ای | build نوعِ release می‌ایستد (نه اپِ بی‌خرید) |
## build
نیازها (یک بار): JDK 17 (`brew install openjdk@17`) و Android SDK با `platforms;android-34` و `build-tools;34.0.0` در `~/Library/Android/sdk`.
```bash
cd android && ./build-apk.sh
```
خروجی `android/build-out/Tadrisino-<نسخه>-<versionCode>.apk` است. پیش از هر انتشار `versionCode` را در `app/build.gradle.kts`
بالا ببرید (بازار نسخه‌ی با شماره‌ی تکراری را نمی‌پذیرد). اسکریپت امضا را می‌سنجد و اثرانگشتِ گواهی را چاپ می‌کند؛ باید با APKِ قبلی یکی باشد.
APKِ آزمایشی بی‌نیاز از کلید: `./gradlew assembleDebug`.
## جریانِ خرید (برای عیب‌یابی)
```
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
فرانت TadrisinoAndroid.bazaarPurchase(sku, orderId)
اپ Poolakey.purchaseProduct(sku, payload=orderId) → purchaseToken
اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken}
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار می‌پرسد
فرانت TadrisinoAndroid.bazaarConsume(token) ← تا همان بسته دوباره خریدنی باشد
```
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» می‌زند: فرانت همان سفارش را می‌فرستد
و اپ توکنِ ماندهٔ همان سفارش را از `getPurchasedProducts` پیدا می‌کند.
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بک‌اند.

View File

@@ -0,0 +1,81 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
// تنظیماتِ حساس (keystore و کلیدِ RSAِ بازار) در ریپو نیستند. از فایلی بیرون از ریپو خوانده می‌شوند:
// $TADRISINO_ANDROID_CONFIG یا ~/.tadrisino/android.properties
// کلیدها: storeFile, storePassword, keyAlias, keyPassword, bazaarRsaKey. شرحِ کامل: README.md.
val configFile = File(System.getenv("TADRISINO_ANDROID_CONFIG") ?: "${System.getProperty("user.home")}/.tadrisino/android.properties")
val secrets = Properties().apply { if (configFile.exists()) configFile.inputStream().use { load(it) } }
// فقط وقتی build از نوعِ release است الزامی است؛ debug بدونِ آن هم ساخته می‌شود.
val wantsRelease = gradle.startParameter.taskNames.any { it.contains("release", ignoreCase = true) }
fun secret(name: String): String {
val v = secrets.getProperty(name)?.trim().orEmpty()
if (wantsRelease && v.isEmpty()) {
throw GradleException("build نوعِ release «$name» را لازم دارد. در ${configFile.path} نیست یا خالی است (README.md ← «تنظیماتِ بیرون از ریپو»).")
}
return v
}
android {
// ⚠️ اسمِ پکیج باید همانی باشد که در پیشخانِ بازار ثبت است. com.tadrisino.app را بازار
// برای حسابِ شرکت «Product is not found» جواب داد (یعنی هست)، ولی سنیور تأیید نکرده
// که همان اپِ ریاضی است (CLAUDE.mdِ بک‌اند، ۲۰۲۶-۰۹-۳۰).
namespace = "com.tadrisino.app"
compileSdk = 34
defaultConfig {
applicationId = "com.tadrisino.app"
minSdk = 23
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
// آدرسِ همان فرانتِ وب. دامنه عوض شود، فقط همین خط.
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (بخشِ «پرداخت درون‌برنامه‌ای»). کلیدِ عمومی
// است و در اپ می‌نشیند؛ رمز نیست. تا پر نشود، اپ خرید را شروع نمی‌کند.
// از فایلِ بیرون از ریپو؛ برای build نوعِ release خالی بودنش خطا می‌دهد (نه اپِ بی‌خرید).
buildConfigField("String", "BAZAAR_RSA_KEY", "\"${secret("bazaarRsaKey")}\"")
}
signingConfigs {
create("release") {
// release بدونِ keystore ساخته نمی‌شود؛ هرگز بی‌صدا با کلیدِ debug امضا نمی‌شود.
if (wantsRelease) {
storeFile = file(secret("storeFile"))
storePassword = secret("storePassword")
keyAlias = secret("keyAlias")
keyPassword = secret("keyPassword")
}
}
}
buildFeatures {
buildConfig = true
}
buildTypes {
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-ktx:1.9.2")
implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- برای لرزشِ کوتاهِ جوابِ درست/غلط (navigator.vibrate در WebView). -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- اندروید ۱۱+: بدونِ این، اپ نمی‌تواند سرویسِ پرداختِ بازار را پیدا کند. -->
<queries>
<package android:name="com.farsitel.bazaar" />
</queries>
<application
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Tadrisino">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,180 @@
package com.tadrisino.app
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import ir.cafebazaar.poolakey.Connection
import ir.cafebazaar.poolakey.Payment
import ir.cafebazaar.poolakey.config.PaymentConfiguration
import ir.cafebazaar.poolakey.config.SecurityCheck
import ir.cafebazaar.poolakey.request.PurchaseRequest
import org.json.JSONObject
/**
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درون‌برنامه‌ایِ بازار است.
*
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
* JS → اینجا TadrisinoAndroid.bazaarPurchase(sku, orderId)
* TadrisinoAndroid.bazaarConsume(purchaseToken)
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
* { orderId, error: "canceled" | "failed", message }
*
* سمتِ سرور توکن را از خودِ بازار می‌سنجد (`POST /api/payment/bazaar/verify`)، پس این‌جا
* فقط واسطه است و به هیچ نتیجه‌ای اعتماد نمی‌کند.
*
* ⚠️ نوشته شده بی‌آن‌که build شود (ماشینِ نویسنده Android SDK نداشت، ۲۰۲۶-۱۰-۰۳)؛ APIِ
* Poolakey از README خودش است. پیش از انتشار با Android Studio بسازید و یک خریدِ واقعی بزنید.
*/
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
private var payment: Payment? = null
private var connection: Connection? = null
private val webHost: String = Uri.parse(BuildConfig.WEB_URL).host ?: ""
/** فقط خودِ سایت و فقط با https؛ «http://دامنه‌ی ما» قابلِ دست‌کاریِ شبکه است و نباید پل داشته باشد. */
private fun isOurPage(uri: Uri?): Boolean = uri != null && uri.scheme == "https" && uri.host == webHost
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
webView = WebView(this)
setContentView(webView)
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true // توکنِ ورود در localStorage است
// WebView فقط آدرسِ وب را می‌خواند؛ دسترسی به فایل و content:// لازم نیست.
webView.settings.allowFileAccess = false
webView.settings.allowContentAccess = false
webView.webViewClient = object : WebViewClient() {
// هر آدرسی بیرونِ دامنه‌ی خودمان در مرورگرِ گوشی باز می‌شود، نه داخلِ اپ؛ پس پل فقط
// در صفحه‌های خودمان در دسترس است.
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
if (isOurPage(request.url)) return false
// فقط http(s) به مرورگرِ گوشی می‌رود؛ intent:، file: و schemeهای سفارشی بی‌صدا بسته می‌شوند
// (پیش‌تر هر آدرسی بیرونِ دامنه به ACTION_VIEW می‌رفت و صفحه می‌توانست اپِ دلخواه باز کند).
if (request.url.scheme == "https" || request.url.scheme == "http") {
startActivity(Intent(Intent.ACTION_VIEW, request.url))
}
return true
}
}
webView.addJavascriptInterface(Bridge(), "TadrisinoAndroid")
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
// اپِ وب برای هر صفحه history.pushState می‌زند؛ دکمه‌ی back همان را برمی‌گرداند.
override fun handleOnBackPressed() {
if (webView.canGoBack()) webView.goBack() else finish()
}
})
connectToBazaar()
if (savedInstanceState == null) webView.loadUrl(BuildConfig.WEB_URL)
}
private fun connectToBazaar() {
if (BuildConfig.BAZAAR_RSA_KEY.isEmpty()) {
Log.e(TAG, "BAZAAR_RSA_KEY is empty; in-app purchase disabled")
return
}
val config = PaymentConfiguration(
localSecurityCheck = SecurityCheck.Enable(rsaPublicKey = BuildConfig.BAZAAR_RSA_KEY),
)
payment = Payment(context = this, config = config).also { p ->
connection = p.connect {
connectionSucceed { Log.i(TAG, "bazaar connected") }
connectionFailed { Log.e(TAG, "bazaar connection failed", it) }
disconnected { Log.w(TAG, "bazaar disconnected") }
}
}
}
private fun purchase(sku: String, orderId: String) {
val p = payment
if (p == null) {
send(orderId, error = "failed", message = "bazaar not connected")
return
}
p.purchaseProduct(
registry = activityResultRegistry,
request = PurchaseRequest(productId = sku, payload = orderId),
) {
purchaseSucceed { send(orderId, token = it.purchaseToken) }
purchaseCanceled { send(orderId, error = "canceled") }
purchaseFailed { recoverOwned(sku, orderId, it) }
failedToBeginFlow { recoverOwned(sku, orderId, it) }
}
}
/**
* خریدِ قبلیِ همین محصول که مصرف نشده (پول رفته ولی تأییدِ سرور نرسیده بود) جلوی خریدِ
* دوباره را می‌گیرد. فرانت در تلاشِ بعدی همان سفارش را می‌فرستد؛ اگر توکنِ ماندهٔ همان
* سفارش (payload) پیدا شد، همان برمی‌گردد تا سرور ثبتش کند.
*/
private fun recoverOwned(sku: String, orderId: String, cause: Throwable) {
val p = payment ?: return send(orderId, error = "failed", message = cause.message)
p.getPurchasedProducts {
querySucceed { owned ->
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
if (match != null) send(orderId, token = match.purchaseToken)
else send(orderId, error = "failed", message = cause.message)
}
queryFailed { send(orderId, error = "failed", message = cause.message) }
}
}
private fun consume(purchaseToken: String) {
payment?.consumeProduct(purchaseToken) {
consumeSucceed { Log.i(TAG, "consumed") }
consumeFailed { Log.e(TAG, "consume failed", it) }
}
}
private fun send(orderId: String, token: String? = null, error: String? = null, message: String? = null) {
val detail = JSONObject().put("orderId", orderId)
if (token != null) detail.put("purchaseToken", token)
if (error != null) detail.put("error", error)
if (message != null) detail.put("message", message)
runOnUiThread {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('tadrisino:bazaar', { detail: $detail }));", null,
)
}
}
/** متدهای این کلاس روی threadِ WebView صدا زده می‌شوند، نه UI. */
private inner class Bridge {
@JavascriptInterface
fun bazaarPurchase(sku: String, orderId: String) {
runOnUiThread {
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
purchase(sku, orderId)
}
}
@JavascriptInterface
fun bazaarConsume(purchaseToken: String) {
runOnUiThread {
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
consume(purchaseToken)
}
}
}
override fun onDestroy() {
connection?.disconnect()
super.onDestroy()
}
companion object {
private const val TAG = "Tadrisino"
}
}

View File

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

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Tadrisino" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:statusBarColor">#ED6956</item>
<item name="android:windowBackground">@android:color/white</item>
</style>
</resources>

33
android/build-apk.sh Executable file
View File

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

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

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

View File

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

Binary file not shown.

View File

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

248
android/gradlew vendored Executable file
View File

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

112
android/gradlew.bat vendored Normal file
View File

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

View File

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

View File

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

View File

@@ -163,7 +163,7 @@ export function createHttpClient(options: HttpClientOptions) {
const inFlight = new Map<string, Promise<unknown>>();
const signedOutListeners = new Set<() => void>();
let refreshing: Promise<Tokens | null> | null = null;
let refreshing: Promise<Tokens | 'invalid' | ApiError> | null = null;
function authHeaders(): Record<string, string> {
const token = storage.get()?.token;
@@ -175,27 +175,45 @@ export function createHttpClient(options: HttpClientOptions) {
signedOutListeners.forEach((listener) => listener());
}
async function refreshTokens(): Promise<Tokens | null> {
/**
* نتیجه‌ی تمدید سه حالت دارد و فقط یکی‌اش یعنی خروج:
* • Tokens — تمدید شد.
* • 'invalid' — سرور گفت refresh token معتبر نیست (۴۰۱)؛ نشست واقعاً تمام است.
* • ApiError — سرور در دسترس نبود یا سقف را زد (۴۲۹، ۵xx، قطعیِ شبکه)؛ نشست سالم است.
* تا ۲۰۲۶-۱۰-۰۳ هر شکستی خروج حساب می‌شد: پشتِ وای‌فایِ مدرسه که سقفِ تمدیدِ IP پر می‌شد،
* دانش‌آموزِ شماره‌دار به صفحه‌ی ورود پرت می‌شد و باید دوباره پیامک می‌گرفت.
*/
async function refreshTokens(): Promise<Tokens | 'invalid' | ApiError> {
const current = storage.get();
if (!current?.refreshToken) return null;
if (!current?.refreshToken) return 'invalid';
if (refreshing) return refreshing;
refreshing = (async () => {
refreshing = (async (): Promise<Tokens | 'invalid' | ApiError> => {
try {
const response = await fetch(`${baseUrl}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: current.refreshToken }),
signal: timeoutSignal(defaultTimeout),
credentials: 'omit',
});
if (!response.ok) return null;
let response: Response;
try {
response = await fetch(`${baseUrl}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: current.refreshToken }),
signal: timeoutSignal(defaultTimeout),
credentials: 'omit',
});
} catch {
return new ApiError(0, 'ارتباط با سرور برقرار نشد');
}
if (response.status === 401) {
// تبِ دیگری همین توکن را زودتر تمدید کرده (سرور توکنِ قبلی را باطل می‌کند) و توکنِ تازه
// را در همان localStorageِ مشترک گذاشته؛ آن نشست سالم است و نباید هر دو تب خارج شوند.
const latest = storage.get();
if (latest?.refreshToken && latest.refreshToken !== current.refreshToken) return latest;
return 'invalid';
}
if (!response.ok) return errorFrom(response, await parseBody(response));
const data = (await response.json()) as Tokens;
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
storage.set(next);
return next;
} catch {
return null;
} finally {
queueMicrotask(() => {
refreshing = null;
@@ -243,8 +261,9 @@ export function createHttpClient(options: HttpClientOptions) {
let response = await send(method, path, body, opts);
if (response.status === 401 && hadToken) {
const refreshed = await refreshTokens();
if (refreshed) response = await send(method, path, body, opts);
else signOut();
if (refreshed === 'invalid') signOut();
else if (refreshed instanceof ApiError) throw refreshed; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه
else response = await send(method, path, body, opts);
}
return response;
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { AnimatePresence, MotionConfig, motion } from 'motion/react';
import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
import { AppShell } from '@/components/AppShell';
import { ToastProvider } from '@/components/Toast';
@@ -34,8 +34,9 @@ export function App() {
/** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */
const enter = useCallback(async () => {
const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]);
await trackReferral(profile);
setFlow(profile.name && profile.gradeId ? { kind: 'main', profile, grades } : { kind: 'setup', profile, grades });
await trackReferral();
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades });
else setFlow({ kind: 'setup', profile, grades });
}, []);
const boot = useCallback(async () => {
@@ -82,62 +83,61 @@ export function App() {
};
return (
<AppShell>
<ToastProvider>
<AnimatePresence mode="wait">
<motion.div
key={flow.kind}
className="flex h-full flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{flow.kind === 'boot' && <SplashScreen />}
{flow.kind === 'bootError' && (
<div className="flex h-full flex-col justify-center">
<ErrorState error={flow.error} onRetry={() => void boot()} />
</div>
)}
{flow.kind === 'onboarding' && (
<OnboardingScreen
onDone={() => {
store.set(KEYS.onboarded, '1');
setFlow({ kind: 'login' });
}}
/>
)}
{flow.kind === 'login' && <LoginScreen onDone={() => void afterLogin()} />}
{flow.kind === 'setup' && (
<SetupScreen
profile={flow.profile}
grades={flow.grades}
onDone={(profile) => setFlow({ kind: 'main', profile, grades: flow.grades })}
/>
)}
{flow.kind === 'main' && (
<MainApp
profile={flow.profile}
grades={flow.grades}
payment={launchPayment}
onSignedOut={() => setFlow({ kind: 'login' })}
onRegistered={trackReferral}
/>
)}
</motion.div>
</AnimatePresence>
</ToastProvider>
</AppShell>
// reducedMotion="user": اگر «کاهشِ حرکت» در گوشی روشن باشد، motion جابه‌جایی‌ها را حذف می‌کند.
<MotionConfig reducedMotion="user">
<AppShell>
<ToastProvider>
<AnimatePresence mode="wait">
<motion.div
key={flow.kind}
className="flex h-full flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{flow.kind === 'boot' && <SplashScreen />}
{flow.kind === 'bootError' && (
<div className="flex h-full flex-col justify-center">
<ErrorState error={flow.error} onRetry={() => void boot()} />
</div>
)}
{flow.kind === 'onboarding' && (
<OnboardingScreen
onDone={() => {
store.set(KEYS.onboarded, '1');
setFlow({ kind: 'login' });
}}
/>
)}
{flow.kind === 'login' && <LoginScreen onDone={() => void afterLogin()} />}
{flow.kind === 'setup' && (
<SetupScreen
profile={flow.profile}
grades={flow.grades}
onDone={(profile) => setFlow({ kind: 'main', profile, grades: flow.grades })}
/>
)}
{flow.kind === 'main' && (
<MainApp
profile={flow.profile}
grades={flow.grades}
payment={launchPayment}
onSignedOut={() => setFlow({ kind: 'login' })}
/>
)}
</motion.div>
</AnimatePresence>
</ToastProvider>
</AppShell>
</MotionConfig>
);
}
/**
* اگر کاربر با لینکِ دعوت آمده بود و حالا با شماره وارد شده، معرفی را ثبت کن.
* مهمان را سرور نمی‌پذیرد، پس کد تا ثبتِ شماره نگه داشته می‌شود.
*/
async function trackReferral(profile: UserProfile) {
/** اگر کاربر با لینکِ دعوت آمده بود و حالا وارد شده، معرفی را ثبت کن. */
async function trackReferral() {
const code = store.get(KEYS.referralCode);
if (!code || profile.isGuest) return;
if (!code) return;
try {
await api.affiliate.track(code);
store.remove(KEYS.referralCode);

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { lazy, Suspense, useEffect, useState, type ComponentType } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { CheckCircle2, XCircle } from 'lucide-react';
import { api, type Grade, type UserProfile } from '@/api';
@@ -7,47 +7,67 @@ import { NavigatorProvider, useNav } from '@/nav/Navigator';
import { isTab, type Route } from '@/nav/routes';
import { BottomNav } from '@/components/BottomNav';
import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast';
import { PrimaryButton } from '@/components/ui';
import { AskScreen } from '@/screens/AskScreen';
import { CharactersScreen } from '@/screens/CharactersScreen';
import { FamilyScreen } from '@/screens/FamilyScreen';
import { LoadingState, PrimaryButton } from '@/components/ui';
import { HomeScreen } from '@/screens/HomeScreen';
import { InviteScreen } from '@/screens/InviteScreen';
import { LeaderboardScreen } from '@/screens/LeaderboardScreen';
import { LessonScreen } from '@/screens/LessonScreen';
import { LoginScreen } from '@/screens/LoginScreen';
import { MapScreen } from '@/screens/MapScreen';
import { NotificationsScreen } from '@/screens/NotificationsScreen';
import { ProfileScreen } from '@/screens/ProfileScreen';
import { QuizScreen } from '@/screens/QuizScreen';
import { StreakScreen } from '@/screens/StreakScreen';
import { SubscriptionScreen } from '@/screens/SubscriptionScreen';
import { SupportScreen } from '@/screens/SupportScreen';
import { UnitScreen } from '@/screens/UnitScreen';
import type { PaymentReturn } from './launchParams';
/**
* خانه همراهِ خودِ اپ بار می‌شود؛ بقیه‌ی صفحه‌ها هر کدام فایلِ جدا دارند تا بارِ اول
* سبک باشد (قبلاً کلِ اپ یک فایلِ ۵۰۵ کیلوبایتی بود، و دستیار با ماژولِ شکل‌ها
* بزرگ‌ترین تکه‌اش). بعد از بالا آمدنِ خانه همه در پس‌زمینه گرفته می‌شوند
* (preloadScreens)، پس رفتن به صفحه‌ی بعدی منتظرِ شبکه نمی‌ماند.
*/
const loaders: (() => Promise<unknown>)[] = [];
function screen<M extends Record<K, ComponentType<any>>, K extends keyof M>(load: () => Promise<M>, name: K) {
loaders.push(load);
return lazy(async () => ({ default: (await load())[name] }));
}
const AskScreen = screen(() => import('@/screens/AskScreen'), 'AskScreen');
const CharactersScreen = screen(() => import('@/screens/CharactersScreen'), 'CharactersScreen');
const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScreen');
const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen');
const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen');
const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen');
const MapScreen = screen(() => import('@/screens/MapScreen'), 'MapScreen');
const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen');
const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen');
const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen');
const QuizScreen = screen(() => import('@/screens/QuizScreen'), 'QuizScreen');
const StreakScreen = screen(() => import('@/screens/StreakScreen'), 'StreakScreen');
const SubscriptionScreen = screen(() => import('@/screens/SubscriptionScreen'), 'SubscriptionScreen');
const SupportScreen = screen(() => import('@/screens/SupportScreen'), 'SupportScreen');
const UnitScreen = screen(() => import('@/screens/UnitScreen'), 'UnitScreen');
let preloaded = false;
function preloadScreens() {
if (preloaded) return;
preloaded = true;
const run = () => loaders.forEach((load) => void load().catch(() => {}));
if ('requestIdleCallback' in window) window.requestIdleCallback(run, { timeout: 2500 });
else setTimeout(run, 1200);
}
interface Props {
profile: UserProfile;
grades: Grade[];
payment: PaymentReturn | null;
onSignedOut: () => void;
onRegistered: (profile: UserProfile) => Promise<void>;
}
/** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */
export function MainApp({ profile, grades, payment, onSignedOut, onRegistered }: Props) {
export function MainApp({ profile, grades, payment, onSignedOut }: Props) {
useEffect(preloadScreens, []);
return (
<AppDataProvider initialProfile={profile} grades={grades}>
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
<Screens onSignedOut={onSignedOut} onRegistered={onRegistered} />
<Screens onSignedOut={onSignedOut} />
{payment && <PaymentResult payment={payment} />}
</NavigatorProvider>
</AppDataProvider>
);
}
function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onRegistered'>) {
function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
const nav = useNav();
const { route } = nav;
const showNav = isTab(route.name) && nav.tab === route.name;
@@ -64,7 +84,9 @@ function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onR
exit={{ opacity: 0, x: 18 }}
transition={{ duration: 0.18 }}
>
<RouteView route={route} onSignedOut={onSignedOut} onRegistered={onRegistered} />
<Suspense fallback={<LoadingState />}>
<RouteView route={route} onSignedOut={onSignedOut} />
</Suspense>
</motion.div>
</AnimatePresence>
</div>
@@ -73,7 +95,7 @@ function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onR
);
}
function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick<Props, 'onSignedOut' | 'onRegistered'>) {
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
switch (route.name) {
case 'home': return <HomeScreen />;
case 'map': return <MapScreen />;
@@ -88,59 +110,53 @@ function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick
case 'characters': return <CharactersScreen />;
case 'streak': return <StreakScreen />;
case 'support': return <SupportScreen />;
case 'family': return <FamilyScreen />;
case 'report': return <ReportScreen />;
case 'invoices': return <InvoicesScreen />;
case 'invite': return <InviteScreen />;
case 'register': return <RegisterView onRegistered={onRegistered} />;
}
}
/** مهمانی که شماره ثبت می‌کند؛ همان کاربر می‌ماند و فقط پروفایل تازه می‌شود. */
function RegisterView({ onRegistered }: Pick<Props, 'onRegistered'>) {
const nav = useNav();
const { setProfile } = useAppData();
const toast = useToast();
return (
<LoginScreen
upgrade
onCancel={nav.back}
onDone={async () => {
try {
const profile = await api.profile.get();
setProfile(profile);
await onRegistered(profile);
} catch {
/* ورود انجام شده؛ پروفایل با بازگشت به اپ تازه می‌شود */
}
toast('شماره‌ت ثبت شد! پیشرفتت امنه.', 'success');
nav.back();
}}
/>
);
}
/** نتیجه‌ی برگشت از درگاه؛ وضعیتِ سفارش از خودِ سرور هم دوباره خوانده می‌شود. */
/**
* نتیجه‌ی برگشت از درگاه. فقط پاسخِ سرور تعیین می‌کند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر
* لینکِ ساختگی می‌تواند «paid» بگذارد.
*/
function PaymentResult({ payment }: { payment: PaymentReturn }) {
const { refreshSummary } = useAppData();
const [open, setOpen] = useState(true);
const [paid, setPaid] = useState(payment.status === 'paid');
const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking');
useEffect(() => {
api.subscription
.order(payment.orderId)
.then((order) => setPaid(order.status === 'paid'))
.catch(() => {});
.then((order) => setState(order.status === 'paid' ? 'paid' : 'failed'))
.catch((err: unknown) => {
console.warn('payment result lookup failed', err);
setState('unknown');
});
void refreshSummary();
}, [payment.orderId, refreshSummary]);
if (state === 'checking') {
return (
<Sheet open={open} onClose={() => setOpen(false)}>
<LoadingState text="دارم پرداخت رو بررسی می‌کنم…" />
</Sheet>
);
}
const paid = state === 'paid';
return (
<Sheet open={open} onClose={() => setOpen(false)}>
<div className="flex flex-col items-center gap-3 pb-2 text-center">
{paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />}
<h2 className="text-lg font-black text-ink">{paid ? 'پرداخت موفق بود! 🎉' : 'پرداخت انجام نشد'}</h2>
<h2 className="text-lg font-black text-ink">
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیتِ پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
</h2>
<p className="text-sm leading-7 text-muted">
{paid
? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.'
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'}
: state === 'unknown'
? 'چند دقیقه‌ی دیگه از «پرداخت‌ها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال می‌شه یا پول خودکار برمی‌گرده.'
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'}
</p>
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
</div>

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import { Brain, Crown, Home, Map, UserRound, type LucideIcon } from 'lucide-react';
import { Brain, ShoppingBag, Home, Map, UserRound, type LucideIcon } from 'lucide-react';
import type { TabName } from '@/nav/routes';
const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
{ id: 'home', icon: Home, label: 'خانه' },
{ id: 'map', icon: Map, label: 'نقشه' },
{ id: 'ask', icon: Brain, label: 'دستیار' },
{ id: 'subscription', icon: Crown, label: 'اشتراک' },
{ id: 'subscription', icon: ShoppingBag, label: 'بسته‌ها' },
{ id: 'profile', icon: UserRound, label: 'من' },
];
@@ -20,12 +20,12 @@ export function BottomNav({ active, onChange }: { active: TabName | null; onChan
type="button"
onClick={() => onChange(id)}
aria-current={on ? 'page' : undefined}
className={`flex flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-colors ${on ? 'text-coral' : 'text-muted'}`}
className={`flex min-w-14 flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-colors ${on ? 'text-coral' : 'text-muted'}`}
>
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
</span>
<span className="text-[10px] font-bold">{label}</span>
<span className="text-[11px] font-bold">{label}</span>
</button>
);
})}

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react';
import { useEffect, useState, type ButtonHTMLAttributes, type CSSProperties, type ReactNode } from 'react';
import { ChevronRight, RotateCw } from 'lucide-react';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
import { friendlyError } from '@/lib/errors';
import { useNav } from '@/nav/Navigator';
import { shade } from '@/lib/color';
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
@@ -28,12 +29,12 @@ export function ScreenHeader({
return (
<header
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
style={{ backgroundColor: color }}
style={{ backgroundColor: color, backgroundImage: `linear-gradient(160deg, ${shade(color, 0.12)} 0%, ${color} 55%, ${shade(color, -0.06)} 100%)` }}
>
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
<div className="relative z-10 pt-6">
{back && (
{back && (typeof back === 'function' || nav.canGoBack) && (
<IconButton label="بازگشت" onClick={onBack} className="mb-3 bg-white/15">
<ChevronRight size={18} strokeWidth={2.5} />
</IconButton>
@@ -85,8 +86,8 @@ export function PrimaryButton({
<button
type="button"
disabled={disabled || loading}
className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-transform active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100 ${className}`}
style={{ backgroundColor: color, boxShadow: `0 10px 24px -8px ${color}88`, ...style }}
className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-[transform,box-shadow] active:translate-y-[3px] active:shadow-none disabled:opacity-50 disabled:active:translate-y-0 ${className}`}
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.22)}`, ...style }}
{...rest}
>
{loading ? <Spinner /> : children}
@@ -98,7 +99,7 @@ export function SecondaryButton({ className = '', children, ...rest }: ButtonHTM
return (
<button
type="button"
className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:scale-[0.98] disabled:opacity-50 ${className}`}
className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-b-4 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:translate-y-[2px] active:border-b-2 disabled:opacity-50 ${className}`}
{...rest}
>
{children}
@@ -129,8 +130,19 @@ function StateBox({ char, mood, text, children }: { char: SeyarChar; mood: Seyar
);
}
export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'sigma' as SeyarChar }) {
return <StateBox char={char} mood="idle" text={text} />;
/**
* لودر فقط بعد از `delayMs` دیده می‌شود. پاسخِ API معمولاً زیرِ ۳۰۰ms می‌رسد و نشان دادنِ لودر برای
* همان چند فریم، پرشِ ریزِ «لودر ← محتوا» می‌ساخت؛ حالا تا آن لحظه جای خالی می‌ماند. اگر پاسخ
* کند باشد کاربر باز هم لودر را می‌بیند. در مرورگر با شبکه‌ی کند آزموده نشده.
*/
export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'sigma' as SeyarChar, delayMs = 300 }) {
const [show, setShow] = useState(delayMs <= 0);
useEffect(() => {
if (delayMs <= 0) return;
const t = setTimeout(() => setShow(true), delayMs);
return () => clearTimeout(t);
}, [delayMs]);
return show ? <StateBox char={char} mood="idle" text={text} /> : null;
}
export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?: () => void; text?: string }) {
@@ -153,6 +165,14 @@ export function EmptyState({ text, char = 'pi' as SeyarChar }: { text: string; c
return <StateBox char={char} mood="idle" text={text} />;
}
/**
* جداکننده‌ی میانِ دو تکه‌ی متن. «·» کنارِ صفرِ فارسی (که خودش یک نقطه است)
* خوانده نمی‌شد: «فصلِ ۱ · ۰ از ۴» شبیهِ «۱۰۰» دیده می‌شد.
*/
export function Sep() {
return <span aria-hidden className="mx-1.5 inline-block h-2.5 w-px bg-current align-middle opacity-40" />;
}
export function InlineError({ children }: { children: ReactNode }) {
return (
<div role="alert" className="rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-xs leading-6 text-red-600">

View File

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

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

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

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

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

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

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

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

@@ -0,0 +1,59 @@
import { Component, memo, type ReactNode } from 'react';
import type { DiagramSpec } from './types';
import { Plane } from './plane';
import { NumberLine } from './numberline';
import { Solid } from './solid';
import { Chart } from './charts';
import { Mapping, Tree, Venn } from './sets';
import { Clock, Fraction, Grid, Groups, Pattern, Table } from './misc';
import { PAPER } from './svg';
import { MathText } from '@/lib/MathText';
export type { DiagramSpec } from './types';
/**
* شکلِ جوابِ دستیار. توصیف از بک‌اند می‌آید (rag/services/diagram.py، اعتبارسنجی‌شده)
* و اینجا فقط به عنصرِ React/SVG تبدیل می‌شود؛ هیچ HTMLی از مدل وارد صفحه نمی‌شود.
*
* هر خطایی در رسم (شکلی که کد پیش‌بینی نکرده) فقط همین شکل را پنهان می‌کند؛
* متنِ جواب سرِ جایش می‌ماند.
*/
function Body({ spec }: { spec: DiagramSpec }): ReactNode {
switch (spec.kind) {
case 'plane': return <Plane spec={spec} />;
case 'numberline': return <NumberLine spec={spec} />;
case 'solid': return <Solid spec={spec} />;
case 'bar': case 'linechart': case 'pie': return <Chart spec={spec} />;
case 'venn': return <Venn spec={spec} />;
case 'mapping': return <Mapping spec={spec} />;
case 'tree': return <Tree spec={spec} />;
case 'fraction': return <Fraction spec={spec} />;
case 'grid': return <Grid spec={spec} />;
case 'groups': return <Groups spec={spec} />;
case 'clock': return <Clock spec={spec} />;
case 'table': return <Table spec={spec} />;
case 'pattern': return <Pattern spec={spec} />;
default: return null;
}
}
class Guard extends Component<{ children: ReactNode }, { broken: boolean }> {
state = { broken: false };
static getDerivedStateFromError() { return { broken: true }; }
render() { return this.state.broken ? null : this.props.children; }
}
function DiagramBase({ spec }: { spec: DiagramSpec }) {
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} />
{spec.title && <figcaption dir="rtl" className="mt-1 text-center text-[11px] font-bold text-muted"><MathText text={spec.title} /></figcaption>}
</figure>
</Guard>
);
}
export const Diagram = memo(DiagramBase);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,96 @@
import { store } from './storage';
/**
* پلِ پرداختِ کافه‌بازار، وقتی همین سایت داخلِ اپِ اندرویدِ تدریسینو (WebView) باز است.
*
* خریدِ درون‌برنامه‌ایِ بازار فقط از کدِ بومیِ اندروید (کتابخانه‌ی Poolakey) شدنی است، پس
* اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه می‌گذارد و این فایل تنها
* جایی است که با آن حرف می‌زند. در مرورگرِ معمولی آن شیء نیست، `inBazaarApp()` false است و
* خرید مثلِ قبل به زرین‌پال می‌رود.
*
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
* JS → اندروید TadrisinoAndroid.bazaarPurchase(sku, orderId) خرید را شروع کن؛ orderId همان
* developerPayloadِ خرید است.
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) بعد از تأییدِ سرور؛ بدونِ آن
* همان بسته دوباره خریدنی نیست.
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
* { orderId, purchaseToken } خرید انجام شد
* { orderId, error: 'canceled' | 'failed', message? }
*
* هیچ‌کدام از این‌ها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۰۳): اپِ اندروید ساخته نشده.
*/
interface AndroidBridge {
bazaarPurchase?: (sku: string, orderId: string) => void;
bazaarConsume?: (purchaseToken: string) => void;
}
declare global {
interface Window {
TadrisinoAndroid?: AndroidBridge;
}
}
type BridgeResult =
| { orderId: string; purchaseToken: string }
| { orderId: string; error: 'canceled' | 'failed'; message?: string };
export class BazaarPurchaseError extends Error {
constructor(readonly kind: 'canceled' | 'failed' | 'timeout', message = '') {
super(message || kind);
}
}
export function inBazaarApp(): boolean {
return typeof window.TadrisinoAndroid?.bazaarPurchase === 'function';
}
/** صفحه‌ی خریدِ بازار ممکن است طول بکشد (ورود به بازار، کارتِ بانکی)؛ این سقف فقط جلوی گیر کردنِ همیشگی را می‌گیرد. */
const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000;
/** خرید را به اپِ اندروید می‌سپارد و purchaseToken را برمی‌گرداند. */
export function bazaarPurchase(sku: string, orderId: string): Promise<string> {
const bridge = window.TadrisinoAndroid;
if (!bridge?.bazaarPurchase) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app'));
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => finish(() => reject(new BazaarPurchaseError('timeout'))), PURCHASE_TIMEOUT_MS);
function finish(settle: () => void) {
window.clearTimeout(timer);
window.removeEventListener('tadrisino:bazaar', onResult);
settle();
}
function onResult(event: Event) {
const detail = (event as CustomEvent<BridgeResult>).detail;
if (!detail || detail.orderId !== orderId) return;
if ('purchaseToken' in detail && detail.purchaseToken) finish(() => resolve(detail.purchaseToken));
else if ('error' in detail) finish(() => reject(new BazaarPurchaseError(detail.error, detail.message)));
}
window.addEventListener('tadrisino:bazaar', onResult);
try {
bridge.bazaarPurchase!(sku, orderId);
} catch (err) {
finish(() => reject(new BazaarPurchaseError('failed', String(err))));
}
});
}
export function bazaarConsume(purchaseToken: string) {
try {
window.TadrisinoAndroid?.bazaarConsume?.(purchaseToken);
} catch (err) {
// مصرف نشدن یعنی خریدِ دوباره‌ی همین بسته تا مصرفِ بعدی گیر می‌کند؛ اعتبارِ کاربر سالم است.
console.error('bazaarConsume failed', err);
}
}
/**
* Idempotency-Keyِ خریدِ نیمه‌تمامِ هر SKU. اگر بازار پول را گرفت ولی تأییدِ سرور نرسید (قطعِ
* اینترنت)، تلاشِ بعدی باید همان سفارش را بگیرد؛ سفارشِ تازه با توکنِ قبلی تأیید نمی‌شد چون هر
* توکن فقط به یک سفارش می‌خورد.
*/
const pendingKey = (sku: string) => `tadrisino.bazaarPending.${sku}`;
export const bazaarPending = {
get: (sku: string) => store.get(pendingKey(sku)),
set: (sku: string, key: string) => store.set(pendingKey(sku), key),
clear: (sku: string) => store.remove(pendingKey(sku)),
};

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

@@ -0,0 +1,12 @@
/** رنگِ hex را روشن (amount > 0) یا تیره (amount < 0) می‌کند؛ برای سایه‌ی دکمه‌های سه‌بعدی. */
export function shade(hex: string, amount: number): string {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return hex;
const n = parseInt(m[1], 16);
const channel = (c: number) => {
const v = amount < 0 ? c * (1 + amount) : c + (255 - c) * amount;
return Math.max(0, Math.min(255, Math.round(v)));
};
const r = channel((n >> 16) & 255), g = channel((n >> 8) & 255), b = channel(n & 255);
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}

View File

@@ -43,12 +43,29 @@ function tone(frequency: number, start: number, duration: number, volume = 0.05)
osc.stop(t + duration + 0.02);
}
/**
* لرزشِ کوتاه کنارِ صدا، با همان کلیدِ «صدا» خاموش می‌شود. فقط اندروید/کروم دارد؛
* Safariِ آیفون `vibrate` ندارد و این‌جا هیچ کاری نمی‌کند.
*/
function buzz(pattern: number | number[]) {
if (!soundEnabled() || typeof navigator.vibrate !== 'function') return;
try {
navigator.vibrate(pattern);
} catch {
/* بعضی WebViewها بی‌اجازه throw می‌کنند؛ لرزش فقط تزئینی است */
}
}
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);
},
wrong: () => {
tone(220, 0, 0.25, 0.06);
buzz([40, 60, 40]);
},
wrong: () => tone(220, 0, 0.25, 0.06),
finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)),
};

View File

@@ -24,22 +24,8 @@ export const store = {
};
export const KEYS = {
deviceId: 'deviceId',
onboarded: 'tadrisino.onboarded',
conversationId: 'tadrisino.conversationId',
ticketIds: 'tadrisino.ticketIds',
sound: 'tadrisino.sound',
referralCode: 'tadrisino.referralCode',
} as const;
/** شناسه‌ی ثابتِ این مرورگر برای ورودِ مهمان. */
export function getDeviceId(): string {
let id = store.get(KEYS.deviceId);
if (!id) {
id = globalThis.crypto && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
store.set(KEYS.deviceId, id);
}
return id;
}

View File

@@ -1,10 +1,20 @@
import { StrictMode } from 'react';
import { StrictMode, lazy, Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import './styles/index.css';
// گالریِ شکل‌ها فقط در حالتِ توسعه؛ در build اصلاً وارد نمی‌شود (import.meta.env.DEV ثابت است).
const DiagramGallery = import.meta.env.DEV
? lazy(() => import('./dev/DiagramGallery').then((m) => ({ default: m.DiagramGallery })))
: null;
const showGallery = DiagramGallery && new URLSearchParams(window.location.search).has('gallery');
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
{showGallery && DiagramGallery ? (
<Suspense fallback={null}><DiagramGallery /></Suspense>
) : (
<App />
)}
</StrictMode>,
);

View File

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

View File

@@ -7,15 +7,18 @@ export type Route =
| { name: 'profile' }
| { name: 'unit'; unitId: string }
| { name: 'lesson'; lessonId: number; nodeId?: string }
| { name: 'quiz'; lessonId: number; nodeId?: string }
/** بدونِ lessonId یعنی آزمونِ کلِ جزیره (nodeId لازم است). */
| { name: 'quiz'; lessonId?: number; nodeId?: string }
| { name: 'notifications' }
| { name: 'leaderboard' }
| { name: 'characters' }
| { name: 'streak' }
| { name: 'support' }
| { name: 'family' }
| { name: 'invite' }
| { name: 'register' };
/** «گزارشِ والدین»: والد پیشرفتِ هفته را در حسابِ خودِ دانش‌آموز می‌بیند. */
| { name: 'report' }
/** پرداخت‌ها و فاکتورهای خودِ کاربر. */
| { name: 'invoices' }
| { name: 'invite' };
export type TabName = 'home' | 'map' | 'ask' | 'subscription' | 'profile';

View File

@@ -1,12 +1,15 @@
import { useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react';
import { BookOpen, MessageSquarePlus, RotateCw, Send, ThumbsDown, ThumbsUp } from 'lucide-react';
import { api } from '@/api';
import { api, ApiError } from '@/api';
import { useAppData } from '@/state/AppData';
import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { useToast } from '@/components/Toast';
import { Screen, ScreenHeader } from '@/components/ui';
import { Diagram } from '@/diagram';
import { MathText } from '@/lib/MathText';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
@@ -18,7 +21,11 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
const chat = useAskState();
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
const [quota, setQuota] = useState<number | null>(null);
// «X سوالِ رایگانِ امروز + Y سوالِ خریده‌شده»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
const [quota, setQuota] = useState<{ free: number; credits: number } | null>(null);
useEffect(() => {
api.ai.quota().then((q) => setQuota({ free: q.remainingQuota, credits: q.credits })).catch(() => {});
}, []);
const listEnd = useRef<HTMLDivElement>(null);
const autoSent = useRef(false);
@@ -39,7 +46,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
askStore.setHistory(
r.messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId })),
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId, diagram: m.diagram ?? null })),
),
)
.catch(() => askStore.reset(null));
@@ -85,14 +92,18 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
messageId: done.messageId,
usedChunks: done.usedChunks,
followUps: done.suggestedFollowUps,
diagram: done.diagram ?? null,
});
askStore.setConversation(done.conversationId);
setQuota(done.remainingQuota);
setQuota((q) => ({ free: done.remainingQuota, credits: done.credits ?? q?.credits ?? 0 }));
} catch (err) {
// سقفِ روزانه (نه throttleِ چندثانیه‌ای): «دوباره» تا فردا بی‌فایده است.
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
askStore.update(replyId, {
text: friendlyError(err, 'جوابی نرسید'),
failedQuestion: question,
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
});
if (quotaReached) setQuota({ free: 0, credits: 0 });
} finally {
setBusy(false);
}
@@ -119,16 +130,19 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
back={lessonId !== undefined || prompt !== undefined}
mascot={{ char: 'pi', mood: busy ? 'excited' : 'idle', size: 60 }}
>
{!empty && (
<button
type="button"
onClick={() => askStore.reset(null)}
disabled={busy}
className="mt-3 flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold disabled:opacity-50"
>
<MessageSquarePlus size={14} /> گفتگوی تازه
</button>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
{quota !== null && <QuotaPills free={quota.free} credits={quota.credits} />}
{!empty && (
<button
type="button"
onClick={() => askStore.reset(null)}
disabled={busy}
className="flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold disabled:opacity-50"
>
<MessageSquarePlus size={14} /> گفتگوی تازه
</button>
)}
</div>
</ScreenHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
@@ -170,6 +184,11 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
}
}}
rows={1}
// جهتِ هر خط از اولین حرفش: «-3 + 5» هنگامِ تایپ چپ‌به‌راست می‌ماند. خطی که با
// فارسی شروع شود راست‌به‌چپ است و ریاضیِ وسطش در کادرِ تایپ ممکن است جابه‌جا دیده شود؛
// در textarea نمی‌شود تکه‌ای را قفل کرد. پیامِ فرستاده‌شده از MathText رد می‌شود.
dir={input ? 'auto' : 'rtl'}
style={{ unicodeBidi: 'plaintext', textAlign: 'right' }}
placeholder="سوالت رو بنویس…"
className="max-h-28 min-h-10 flex-1 resize-none rounded-3xl bg-soft px-4 py-2.5 text-sm leading-6 text-ink outline-none placeholder:text-faint"
/>
@@ -182,13 +201,12 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
<Send size={16} className="-scale-x-100" />
</button>
</div>
{quota !== null && <p className="mt-1.5 text-center text-[10px] text-muted">امروز {toFa(quota)} سوالِ دیگه می‌تونی بپرسی</p>}
</form>
</Screen>
);
}
function Bubble({ role, children }: { role: 'user' | 'assistant'; children: React.ReactNode }) {
function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
const mine = role === 'user';
return (
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
@@ -203,6 +221,43 @@ function Bubble({ role, children }: { role: 'user' | 'assistant'; children: Reac
}`}
>
{children}
{extra}
</div>
</div>
);
}
/**
* منتظرِ جوابِ دستیار. سرویسِ مدل کلِ جواب را نگه می‌دارد و یک‌جا می‌فرستد؛ جوابِ
* کامل با شکل در ۲۰۲۶-۰۹-۲۹ حدودِ ۳۰ ثانیه طول کشید. پیامِ ثابت در آن مدت شبیهِ
* گیر کردن بود، پس متن با زمان جلو می‌رود. زمان‌ها تقریبی‌اند و به مرحله‌ی واقعیِ
* سرور وصل نیستند.
*/
const THINKING_STEPS: [number, string][] = [
[0, 'پی داره تو کتابت می‌گرده'],
[4, 'پی داره حلش می‌کنه'],
[15, 'داره قدم‌ها رو مرتب می‌نویسه'],
[28, 'جوابِ کامل یه کم طول می‌کشه، الان می‌رسه'],
];
function Thinking() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const started = Date.now();
const timer = setInterval(() => setSeconds((Date.now() - started) / 1000), 1000);
return () => clearInterval(timer);
}, []);
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1];
return (
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
<SeyarMascot char="pi" mood="idle" size={32} />
</span>
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted" aria-live="polite">
{text}
{[0, 1, 2].map((i) => (
<motion.span key={i} className="h-1.5 w-1.5 rounded-full bg-violet/60" animate={{ y: [0, -4, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }} />
))}
</div>
</div>
);
@@ -218,24 +273,11 @@ function Message({
onFollowUp: (q: string) => void;
}) {
const toast = useToast();
const nav = useNav();
if (message.role === 'user') return <Bubble role="user">{message.text}</Bubble>;
if (message.role === 'user') return <Bubble role="user"><MathText text={message.text} /></Bubble>;
if (!message.text) {
return (
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
<SeyarMascot char="pi" mood="idle" size={32} />
</span>
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted">
پی داره فکر می‌کنه
{[0, 1, 2].map((i) => (
<motion.span key={i} className="h-1.5 w-1.5 rounded-full bg-violet/60" animate={{ y: [0, -4, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }} />
))}
</div>
</div>
);
}
if (!message.text) return <Thinking />;
async function rate(helpful: boolean) {
if (!message.messageId || message.feedback) return;
@@ -251,9 +293,15 @@ function Message({
return (
<div className="flex flex-col gap-1.5">
<Bubble role="assistant">{message.text}</Bubble>
<Bubble role="assistant" extra={message.diagram ? <div className="whitespace-normal"><Diagram spec={message.diagram} /></div> : undefined}>
<MathText text={message.text} />
</Bubble>
<div className="flex flex-wrap items-center gap-2 pr-10">
{message.failedQuestion ? (
{message.quotaReached ? (
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-violet px-3 py-1 text-xs font-bold text-white">
خریدِ بسته‌ی سوال
</button>
) : message.failedQuestion ? (
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">
<RotateCw size={12} /> دوباره امتحان کن
</button>
@@ -289,3 +337,25 @@ function Message({
</div>
);
}
/**
* شمارنده در سربرگِ دستیار: سوال‌های رایگانِ باقیِ امروز و سوال‌های خریده‌شده، هر کدام جدا و
* درشت، تا بچه پیش از پرسیدن بداند چقدر دارد. زدن روی دومی صفحه‌ی بسته‌ها را باز می‌کند.
*/
function QuotaPills({ free, credits }: { free: number; credits: number }) {
const nav = useNav();
return (
<>
<span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}>
{free > 0 ? <>{toFa(free)} سوالِ رایگانِ امروز</> : 'رایگانِ امروز تموم شد'}
</span>
<button
type="button"
onClick={() => nav.switchTab('subscription')}
className={`rounded-full px-3 py-1.5 text-xs font-black ${credits > 0 ? 'bg-white text-violet' : 'bg-white/15 text-white'}`}
>
{credits > 0 ? <>{toFa(credits)} سوالِ خریده‌شده</> : '+ خریدِ سوال'}
</button>
</>
);
}

View File

@@ -6,7 +6,7 @@ import { useAsync } from '@/lib/useAsync';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { SEYAR_INFO, seyarFromSlug } from '@/mascots/seyar';
import { useToast } from '@/components/Toast';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader, Sep } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
@@ -39,7 +39,7 @@ export function CharactersScreen() {
<Screen>
<ScreenHeader
title="همراهت رو انتخاب کن"
subtitle={`امتیازِ تو: ${toFa(summary?.xp ?? 0)} · با امتیازِ بیشتر همراه‌های بیشتری باز می‌شن`}
subtitle={<>امتیازِ تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیازِ بیشتر همراه‌های بیشتری باز می‌شن</>}
color="#9B7BFF"
back
/>

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,10 @@ import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { moodFromServer } from '@/mascots/seyar';
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader, Sep } from '@/components/ui';
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
import { Diagram } from '@/diagram';
/** معرفیِ درس، پیش از تمرین. */
export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
@@ -17,11 +19,15 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
const data = intro.data;
const figs = (where: 'intro' | 'example' | 'step', index?: number) =>
(data?.figures ?? [])
.filter((f) => f.where === where && (where !== 'step' || f.index === index))
.map((f, k) => <Diagram key={k} spec={f.diagram} />);
return (
<Screen>
<ScreenHeader
title={data?.title ?? '…'}
title={data ? <MathText text={data.title} /> : '…'}
color={color}
back
mascot={{ char: companion, mood: moodFromServer(data?.mascotAnimation), size: 72 }}
@@ -29,7 +35,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
ref && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1">
{/* ترتیبِ lessonIds «درسِ بعدی اول» است، نه ترتیبِ کتاب؛ پس شماره‌ی درس از آن درنمی‌آید. */}
<Zap size={11} fill="white" /> {ref.node.title} · {toFa(ref.node.lessonIds.length)} درس
<Zap size={11} fill="white" /> <MathText text={ref.node.title} /><Sep />{toFa(ref.node.lessonIds.length)} درس
</span>
)
}
@@ -45,7 +51,8 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
<div className="flex items-start gap-3">
<SeyarMascot char="sigma" mood="idle" size={56} />
<div className="flex-1 rounded-3xl rounded-tr-sm border border-line bg-white p-4 shadow-sm">
<p className="whitespace-pre-line text-sm leading-[1.95] text-body">{data.introText}</p>
<p className="whitespace-pre-line text-sm leading-[1.95] text-body"><MathText text={data.introText} /></p>
{figs('intro')}
</div>
</div>
@@ -56,7 +63,8 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
{data.example && (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-2 text-xs font-black text-muted">مثال</h2>
<p className="whitespace-pre-line text-sm leading-[1.95] text-ink">{data.example}</p>
<p className="whitespace-pre-line text-sm leading-[1.95] text-ink"><MathText text={data.example} /></p>
{figs('example')}
</section>
)}
@@ -69,7 +77,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
{toFa(i + 1)}
</span>
<span className="flex-1 whitespace-pre-line">{step}</span>
<span className="flex-1 whitespace-pre-line"><MathText text={step} />{figs('step', i)}</span>
</li>
))}
</ol>

View File

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

View File

@@ -4,7 +4,7 @@ import { useAppData } from '@/state/AppData';
import { unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui';
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
import { iconFor } from '@/components/NodeIcon';
import { toFa } from '@/lib/format';
@@ -22,7 +22,7 @@ export function MapScreen() {
<Screen className="bg-[#f7f8ff]">
<ScreenHeader
title="نقشه‌ی فصل‌ها"
subtitle={`${gradeTitle ? `${gradeTitle} · ` : ''}${toFa(overall)}٪ از کلِ مسیر`}
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}{toFa(overall)}٪ از کلِ مسیر</>}
color="#91ADC6"
mascot={{ char: 'sigma', mood: overall > 0 ? 'happy' : 'idle' }}
>
@@ -76,7 +76,7 @@ export function MapScreen() {
)}
</button>
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
<div className="max-w-[130px] truncate text-xs font-black text-ink">{unit.title}</div>
<div className="line-clamp-2 max-w-[170px] text-xs font-black leading-5 text-ink">{unit.title}</div>
<div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
</div>
</motion.div>

View File

@@ -1,10 +1,11 @@
import { Bell, Check, Crown, FileText, MessageCircle, Star, Trophy, Users, type LucideIcon } from 'lucide-react';
import { Bell, Check, Crown, MessageCircle, Star, Trophy, type LucideIcon } from 'lucide-react';
import { api, type NotificationItem } from '@/api';
import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
import { relativeTime } from '@/lib/format';
import { relativeTime, toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = {
xp_earned: { icon: Star, color: '#E8A400', bg: '#FFF8E1' },
@@ -12,8 +13,6 @@ const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = {
node_completed: { icon: Trophy, color: '#FF7A3D', bg: '#FFF1E6' },
order_paid: { icon: Crown, color: '#9B7BFF', bg: '#F5F1FF' },
ticket_replied: { icon: MessageCircle, color: '#4C5FD5', bg: '#EEF1FF' },
family_link_accepted: { icon: Users, color: '#FF7A3D', bg: '#FFF1E6' },
weekly_report_ready: { icon: FileText, color: '#91ADC6', bg: '#EAF2F6' },
};
const styleOf = (type: string) => STYLE[type] ?? { icon: Bell, color: '#9e9ec0', bg: '#f0eeff' };
@@ -83,7 +82,7 @@ export function NotificationsScreen() {
</span>
<span className="min-w-0 flex-1">
<span className={`block text-sm ${n.isRead ? 'text-muted' : 'font-bold text-ink'}`}>{n.title}</span>
{n.body && <span className="mt-0.5 block whitespace-pre-line text-xs leading-6 text-body">{n.body}</span>}
{n.body && <span className="mt-0.5 block whitespace-pre-line text-xs leading-6 text-body"><MathText text={toFa(n.body)} /></span>}
<span className="mt-1 block text-[10px] text-faint">{relativeTime(n.createdAt)}</span>
</span>
{!n.isRead && <span className="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-coral" />}

View File

@@ -23,6 +23,12 @@ const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title
title: 'از پی بپرس',
desc: 'من پی‌ام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدم‌به‌قدم توضیح می‌دم.',
},
{
char: 'tedi', mood: 'excited', bg: '#D9820B',
sub: 'یکی از ما مالِ توئه',
title: 'همراهت رو انتخاب کن',
desc: 'منم تدی! هر چی امتیازت بیشتر بشه، همراه‌های تازه باز می‌شن. از صفحه‌ی «من» هر کدوممون رو خواستی کنارت می‌ذاری.',
},
];
export function OnboardingScreen({ onDone }: { onDone: () => void }) {

View File

@@ -1,7 +1,7 @@
import { useState, type ReactNode } from 'react';
import {
Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
Pencil, Smartphone, Sparkles, Star, Trophy, Users, Volume2, VolumeX,
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
FileText, Pencil, Repeat, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX,
} from 'lucide-react';
import { api } from '@/api';
import { useAppData } from '@/state/AppData';
@@ -11,13 +11,13 @@ import type { Route } from '@/nav/routes';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast';
import { AgePicker, ageFromBirthYear, birthYearFromAge } from '@/components/AgePicker';
import { BirthDatePicker, ageOf, formatBirthDate } from '@/components/BirthDatePicker';
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
import { toFa } from '@/lib/format';
import { digitsOnly, toFa } from '@/lib/format';
type Editing = 'name' | 'grade' | 'age' | 'logout' | null;
type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null;
/** «من»: پروفایل، تنظیمات و میان‌بُر به بخش‌های فرعی. */
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
@@ -25,7 +25,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
const nav = useNav();
const [editing, setEditing] = useState<Editing>(null);
const [sound, setSound] = useState(soundEnabled());
const age = ageFromBirthYear(profile.birthYear);
const age = ageOf(profile.birthDate, profile.birthYear);
const go = (route: Route) => () => nav.push(route);
@@ -42,14 +42,13 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<button type="button" onClick={() => setEditing('name')} className="flex items-center gap-2 text-xl font-black">
{profile.name || 'بی‌نام'} <Pencil size={14} className="text-white/70" />
</button>
<div className="mt-0.5 text-xs text-white/75">
شناسه‌ی کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80">
{gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
{age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>}
{profile.isGuest ? (
<span className="rounded-full bg-[#FFD23F] px-2.5 py-0.5 font-bold text-ink">مهمان</span>
) : (
profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>
)}
{profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>}
</div>
</div>
</div>
@@ -69,36 +68,27 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
</header>
<div className="flex flex-col gap-4 px-4 py-5">
{profile.isGuest && (
<button
type="button"
onClick={go({ name: 'register' })}
className="flex items-center gap-3 rounded-3xl bg-[#FFF8E1] p-4 text-right ring-2 ring-[#FFD23F]"
>
<Smartphone size={22} className="flex-shrink-0 text-gold" />
<div className="flex-1">
<div className="text-sm font-black text-ink">شماره‌ت رو ثبت کن</div>
<div className="mt-0.5 text-xs leading-6 text-body">تا اگه گوشیت عوض شد، امتیازها و پیشرفتت گم نشه.</div>
</div>
<ChevronLeft size={18} className="text-muted" />
</button>
)}
<Menu>
<Row icon={GraduationCap} label="پایه‌ی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
<Row icon={CalendarDays} label="سن" value={age ? `${toFa(age)} سال` : 'وارد نشده'} onClick={() => setEditing('age')} />
<Row
icon={CalendarDays}
label="تاریخِ تولد"
value={formatBirthDate(profile.birthDate) || (age ? `${toFa(age)} سال` : 'وارد نشده')}
onClick={() => setEditing('birth')}
/>
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} />
</Menu>
<Menu>
<Row icon={BarChart3} label="گزارشِ والدین" onClick={go({ name: 'report' })} />
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} />
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} />
<Row icon={Bell} label="اعلان‌ها" onClick={go({ name: 'notifications' })} />
</Menu>
<Menu>
{!profile.isGuest && <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />}
{!profile.isGuest && <Row icon={Users} label="بخشِ والدین" onClick={go({ name: 'family' })} />}
<Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />
<Row icon={FileText} label="پرداخت‌ها و فاکتورها" onClick={go({ name: 'invoices' })} />
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
<Row
icon={sound ? Volume2 : VolumeX}
@@ -111,16 +101,21 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
/>
</Menu>
<button type="button" onClick={() => setEditing('logout')} className="flex items-center justify-center gap-2 py-3 text-sm font-bold text-red-500">
<LogOut size={16} /> خروج از حساب
<Menu>
<Row icon={Repeat} label="ورود با حسابِ دیگر" onClick={() => setEditing('switch')} />
<Row icon={LogOut} label="خروج از حساب" 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>
</div>
</div>
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
<AgeSheet open={editing === 'age'} onClose={() => setEditing(null)} />
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
<BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
<LogoutSheet open={editing === 'logout' || editing === 'switch'} switching={editing === 'switch'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
<DeleteAccountSheet open={editing === 'delete'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
</Screen>
);
}
@@ -222,17 +217,17 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
);
}
function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const { profile, setProfile } = useAppData();
const toast = useToast();
const [age, setAge] = useState<number | null>(ageFromBirthYear(profile.birthYear));
const [birthDate, setBirthDate] = useState<string | null>(profile.birthDate);
const [saving, setSaving] = useState(false);
async function save() {
if (!age) return;
if (!birthDate) return;
setSaving(true);
try {
setProfile(await api.profile.update({ birthYear: birthYearFromAge(age) }));
setProfile(await api.profile.update({ birthDate }));
toast('ذخیره شد', 'success');
onClose();
} catch (err) {
@@ -243,17 +238,22 @@ function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
}
return (
<Sheet open={open} onClose={onClose} title="چند سالته؟">
<Sheet open={open} onClose={onClose} title="تاریخِ تولد">
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سالِ تولدت به شمسی.</p>
<div className="mb-5">
<AgePicker value={age} onChange={setAge} />
<BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
</div>
<PrimaryButton loading={saving} disabled={!age} onClick={() => void save()}>ذخیره</PrimaryButton>
<PrimaryButton loading={saving} disabled={!birthDate} onClick={() => void save()}>ذخیره</PrimaryButton>
</Sheet>
);
}
function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
const { profile } = useAppData();
/**
* خروج، و «ورود با حسابِ دیگر» که همان خروج است با متنِ دیگر. عمداً چند حسابِ واردشده روی
* یک گوشی نگه داشته نمی‌شود: گوشیِ خانوادگی دست‌به‌دست می‌شود و هر کس بی‌کد واردِ حسابِ
* دیگری می‌شد، و چند refresh tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر می‌کرد.
*/
function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open: boolean; switching?: boolean; onClose: () => void; onSignedOut: () => void }) {
const [busy, setBusy] = useState(false);
async function logout() {
@@ -268,15 +268,93 @@ function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: (
}
return (
<Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟">
<Sheet open={open} onClose={onClose} title={switching ? 'ورود با حسابِ دیگر' : 'از حسابت خارج بشی؟'}>
<p className="mb-5 text-sm leading-7 text-muted">
{profile.isGuest
? 'تو مهمانی: با همین گوشی و مرورگر، «فعلاً مهمان باشم» دوباره تو رو به همین پیشرفت برمی‌گردونه، ولی از گوشیِ دیگه نه. برای امن‌تر شدن، اول شماره‌ت رو ثبت کن.'
: 'هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.'}
{switching && 'اول از این حساب خارج می‌شی، بعد با شماره‌ی دیگه وارد شو. '}
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
</p>
<div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، می‌مونم</SecondaryButton>
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>
{switching ? 'خروج و ورود با حسابِ دیگر' : 'خروج'}
</PrimaryButton>
</div>
</Sheet>
);
}
/**
* حذفِ همیشگیِ حساب (POST /user/delete). شماره‌دار با کدِ تازه‌ی پیامکی تأیید می‌کند تا گوشیِ
* بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند.
*/
function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
const { profile } = useAppData();
const [sent, setSent] = useState(false);
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function sendCode() {
if (!profile.phone) return;
setBusy(true);
setError('');
try {
await api.auth.requestOtp(profile.phone);
setSent(true);
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
}
async function remove() {
setBusy(true);
setError('');
try {
await api.auth.deleteAccount({ otp: digitsOnly(value, 6) });
askStore.clear();
onSignedOut();
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
}
return (
<Sheet open={open} onClose={onClose} title="حذفِ همیشگیِ حساب">
<p className="mb-3 text-sm leading-7 text-body">
امتیاز، زنجیره، پیشرفتِ جزیره‌ها، گفتگوها با دستیار، سوال‌های خریده‌شده و قلبِ نامحدودت پاک می‌شن و برنمی‌گردن.
سوابقِ پرداخت فقط برای حسابداری می‌مونه.
</p>
{sent ? (
<input
value={value}
onChange={(e) => setValue(digitsOnly(e.target.value, 6))}
placeholder="کدِ پیامک‌شده"
inputMode="numeric"
dir="ltr"
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-red-400"
/>
) : (
<p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شماره‌ی خودت پیامک می‌شه.</p>
)}
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton>
{!sent ? (
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادنِ کد</PrimaryButton>
) : (
<PrimaryButton
color="#ef4444"
loading={busy}
disabled={value.length < 6}
onClick={() => void remove()}
>
حذفِ همیشگی
</PrimaryButton>
)}
</div>
</Sheet>
);

View File

@@ -2,37 +2,58 @@ import { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import confetti from 'canvas-confetti';
import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react';
import { api, ApiError, type AnswerResult, type Hearts, type LessonComplete, type Question, type ReportReason } from '@/api';
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
import { useAppData } from '@/state/AppData';
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { newIdempotencyKey } from '@/api/http';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarMood } from '@/mascots/seyar';
import { NoHeartsScreen } from '@/components/NoHeartsScreen';
import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast';
import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton, Screen } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { timeUntil, toFa } from '@/lib/format';
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
import { sounds } from '@/lib/sound';
type Phase =
| { kind: 'loading' }
| { kind: 'error'; error: unknown }
| { kind: 'playing' }
| { kind: 'noHearts'; nextRefillAt: string | null }
| { kind: 'noHearts'; nextRefillAt: string | null; fullRefillAt: string | null }
| { kind: 'finishing' }
| { kind: 'finishError'; error: unknown }
| { kind: 'result'; result: LessonComplete };
| { kind: 'result'; result: QuizResult };
/** تمرینِ یک درس: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه. */
export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
/** نتیجه‌ی مشترکِ پایانِ درس و پایانِ جزیره. */
interface QuizResult {
stars: number;
xpEarned: number;
coinsEarned: number;
/** فقط در آزمونِ جزیره. */
nextNodeId?: string | null;
}
type PlayQuestion = Question & { lessonId?: string };
/**
* تمرین: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه.
*
* دو حالت دارد. با `lessonId` تمرینِ همان یک درس است (هر درس معمولاً فقط یک
* سوال دارد). بدونِ آن آزمونِ کلِ جزیره است (`/nodes/{id}/quiz`، ۴ سوال از
* درس‌های مختلف) که با تمام شدنش جزیره تمام و جزیره‌ی بعدی باز می‌شود؛ مسیرِ
* اصلیِ بازی همین است.
*/
export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) {
const { home, companion, refreshHome, refreshSummary } = useAppData();
const nav = useNav();
const toast = useToast();
const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
const [questions, setQuestions] = useState<Question[]>([]);
const [questions, setQuestions] = useState<PlayQuestion[]>([]);
const nodeMode = lessonId === undefined;
const [hearts, setHearts] = useState<Hearts | null>(null);
const [index, setIndex] = useState(0);
const [selected, setSelected] = useState<string | null>(null);
@@ -47,7 +68,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
const answerKeys = useRef(new Map<string, string>());
const completeKey = useRef(newIdempotencyKey());
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
const ref = findNode(home, nodeId) ?? (lessonId !== undefined ? findNodeOfLesson(home, lessonId) : null);
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
const question = questions[index];
const isLast = index === questions.length - 1;
@@ -58,7 +79,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
useEffect(() => {
let alive = true;
setPhase({ kind: 'loading' });
Promise.all([api.lesson.questions(lessonId), api.hearts().catch(() => null)])
const load: Promise<PlayQuestion[]> = nodeMode
? api.nodeQuiz(nodeId ?? '').then((r) => r.questions)
: api.lesson.questions(lessonId);
Promise.all([load, api.hearts().catch(() => null)])
.then(([qs, h]) => {
if (!alive) return;
setQuestions(qs);
@@ -69,14 +93,14 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
setCorrectCount(0);
answerKeys.current.clear();
completeKey.current = newIdempotencyKey();
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt });
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
else setPhase({ kind: 'playing' });
})
.catch((error: unknown) => alive && setPhase({ kind: 'error', error }));
return () => {
alive = false;
};
}, [lessonId, reload]);
}, [lessonId, nodeId, nodeMode, reload]);
async function choose(optionId: string) {
if (!question || selected !== null || submitting) return;
@@ -100,7 +124,11 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
} catch (err) {
setSelected(null);
if (err instanceof ApiError && err.code === 'no_hearts') {
setPhase({ kind: 'noHearts', nextRefillAt: (err.body.nextRefillAt as string | null) ?? null });
setPhase({
kind: 'noHearts',
nextRefillAt: (err.body.nextRefillAt as string | null) ?? null,
fullRefillAt: (err.body.fullRefillAt as string | null) ?? null,
});
} else {
toast(friendlyError(err, 'جوابت ثبت نشد، دوباره بزن'), 'error');
}
@@ -112,7 +140,11 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
async function next() {
if (outOfHearts && !isLast) {
const h = await api.hearts().catch(() => null);
setPhase({ kind: 'noHearts', nextRefillAt: h?.nextRefillAt ?? hearts?.nextRefillAt ?? null });
setPhase({
kind: 'noHearts',
nextRefillAt: h?.nextRefillAt ?? hearts?.nextRefillAt ?? null,
fullRefillAt: h?.fullRefillAt ?? hearts?.fullRefillAt ?? null,
});
return;
}
if (!isLast) {
@@ -127,23 +159,36 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
async function finish() {
setPhase({ kind: 'finishing' });
try {
const result = await api.lesson.complete(
lessonId,
{ correctCount, totalQuestions: questions.length },
completeKey.current,
);
const payload = { correctCount, totalQuestions: questions.length };
const result: QuizResult = nodeMode
? await api.nodeQuizComplete(nodeId ?? '', payload, completeKey.current)
: await api.lesson.complete(lessonId, payload, completeKey.current);
setPhase({ kind: 'result', result });
sounds.finish();
if (result.stars >= 2) void confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } });
void refreshHome();
void refreshSummary();
} catch (error) {
// سرور پایانِ درس/جزیره را بی‌قلب و بی‌جواب نمی‌پذیرد (۱۴۰۵-۰۷)؛ همان صفحه‌ی انتظار.
if (error instanceof ApiError && error.code === 'no_hearts') {
setPhase({
kind: 'noHearts',
nextRefillAt: (error.body.nextRefillAt as string | null) ?? null,
fullRefillAt: (error.body.fullRefillAt as string | null) ?? null,
});
return;
}
setPhase({ kind: 'finishError', error });
}
}
const askWhy = () =>
nav.push({ name: 'ask', lessonId, questionId: question?.id, prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟' });
nav.push({
name: 'ask',
lessonId: lessonId ?? (question?.lessonId ? Number(question.lessonId) : undefined),
questionId: question?.id,
prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟',
});
/* ─── حالت‌های غیرِ بازی ─────────────────────────────────────────────── */
@@ -158,8 +203,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
</Screen>
);
}
if (phase.kind === 'noHearts') return <NoHearts nextRefillAt={phase.nextRefillAt} />;
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id} color={color} />;
if (phase.kind === 'noHearts') {
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={() => setReload((n) => n + 1)} />;
}
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} />;
if (phase.kind === 'finishing' || phase.kind === 'finishError') {
return (
<Screen>
@@ -182,7 +229,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
return (
<Screen>
<div className="flex flex-1 flex-col justify-center gap-4 px-8">
<ErrorState text="این درس هنوز سوالی نداره" />
<ErrorState text={nodeMode ? 'این جزیره هنوز سوالی نداره' : 'این درس هنوز سوالی نداره'} />
<SecondaryButton onClick={nav.back}>برگشت</SecondaryButton>
</div>
</Screen>
@@ -246,7 +293,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
<Flag size={12} /> گزارشِ ایراد
</button>
</div>
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink">{question.prompt}</h2>
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} /></h2>
</div>
<div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
@@ -257,9 +304,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
onClick={() => void choose(option.id)}
disabled={selected !== null && !answer}
whileTap={{ scale: selected === null ? 0.95 : 1 }}
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`}
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 border-b-4 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`}
>
<span dir="auto">{option.value}</span>
<span><MathText text={option.value} /></span>
{answer && option.id === answer.correctOptionId && <Check size={16} className="text-green-500" strokeWidth={3} />}
{answer && option.id === selected && !answer.correct && <X size={16} className="text-red-400" strokeWidth={3} />}
</motion.button>
@@ -281,7 +328,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
</div>
<Sheet open={confirmExit} onClose={() => setConfirmExit(false)} title="از تمرین بیرون بری؟">
<p className="mb-5 text-sm leading-7 text-muted">جواب‌هایی که دادی ثبت شده، ولی این درس تا آخرش رو نزنی تموم‌شده حساب نمی‌شه.</p>
<p className="mb-5 text-sm leading-7 text-muted">
جواب‌هایی که دادی ثبت شده، ولی {nodeMode ? 'این جزیره' : 'این درس'} تا آخرش رو نزنی تموم‌شده حساب نمی‌شه.
</p>
<div className="flex flex-col gap-2.5">
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه می‌دم</PrimaryButton>
<SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton>
@@ -325,38 +374,27 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
);
}
function NoHearts({ nextRefillAt }: { nextRefillAt: string | null }) {
const nav = useNav();
return (
<Screen>
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
<SeyarMascot char="pi" mood="depressed" size={110} />
<h2 className="text-xl font-black text-ink">قلب‌هات تموم شد</h2>
<p className="text-sm leading-7 text-muted">
{nextRefillAt ? <>قلبِ بعدی تا <b className="text-coral">{timeUntil(nextRefillAt)}</b> دیگه پر می‌شه.</> : 'کمی صبر کن تا قلب‌هات دوباره پر بشن.'}
<br />
با اشتراکِ ویژه قلب‌ها نامحدودن.
</p>
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ اشتراک</PrimaryButton>
<SecondaryButton onClick={nav.back}>باشه، بعداً</SecondaryButton>
</div>
</Screen>
);
}
function Result({
result, total, correct, lessonId, nodeId, color,
}: { result: LessonComplete; total: number; correct: number; lessonId: number; nodeId?: string; color: string }) {
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string }) {
const nav = useNav();
const { companion } = useAppData();
const [loadingNext, setLoadingNext] = useState(false);
const nodeMode = lessonId === undefined;
const nextNode = result.nextNodeId ?? null;
// بعد از ثبت، نقشه عوض شده؛ درسِ بعدی را از نسخه‌ی تازه‌ی نقشه می‌خوانیم.
async function goNext() {
if (nodeMode) {
if (nextNode) nav.replace({ name: 'quiz', nodeId: nextNode });
else nav.back();
return;
}
setLoadingNext(true);
try {
const fresh = await api.home();
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId);
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId!);
const next = ref ? nextLessonOf(ref.node) : null;
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
nav.replace({ name: 'lesson', lessonId: next, nodeId: ref.node.id });
@@ -381,6 +419,7 @@ function Result({
</div>
<div>
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی 🏝️</p>}
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
</div>
<div className="flex w-full gap-3">
@@ -396,8 +435,13 @@ function Result({
</div>
</div>
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
ادامه <ChevronLeft size={18} />
{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>
)}
</div>
</Screen>
);

View File

@@ -0,0 +1,346 @@
import { useState, type ReactNode } from 'react';
import { BookOpen, CalendarCheck, CheckCircle2, Clock, Flame, MessageCircle, Target, type LucideIcon } from 'lucide-react';
import { api, type ReportDay, type ReportTotals, type WeeklyReport } from '@/api';
import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync';
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';
/*
* «گزارشِ والدین». تا ۱۴۰۵-۰۷ والد حسابِ جدا و کدِ دعوت داشت؛ به خواستِ سینیور
* حذف شد و والد همین صفحه را در حسابِ خودِ دانش‌آموز می‌بیند. همه‌ی عددها از
* GET /user/weekly-report می‌آیند: ۷ روزِ اخیر در کنارِ ۷ روزِ قبلش.
*
* رنگِ نمودارها با validate_palette (مهارتِ dataviz) سنجیده شده: هر جفت برای
* کوررنگیِ قرمز-سبز هم جداست. نارنجی و طلایی کنتراستِ کمتر از ۳:۱ با سفید دارند،
* برای همین عددِ هر روز با زدن روی ستون و در جدولِ پایین هم نوشته می‌شود.
*/
const THIS_WEEK = '#4C5FD5';
const LAST_WEEK = '#FF7A3D';
const CORRECT = '#2E9E73';
const WRONG = '#E8A400';
const WEEKDAY_SHORT = ['ی', 'د', 'س', 'چ', 'پ', 'ج', 'ش'];
/** «2026-10-01» به تاریخِ محلی؛ new Date(iso) آن را UTC می‌خواند و روز جابه‌جا می‌شد. */
function localDate(iso: string): Date {
const [y, m, d] = iso.split('-').map(Number);
return new Date(y, m - 1, d);
}
const longDay = (iso: string) =>
new Intl.DateTimeFormat('fa-IR-u-ca-persian', { weekday: 'long', day: 'numeric', month: 'long' }).format(localDate(iso));
const percent = (part: number, whole: number) => (whole ? Math.round((part / whole) * 100) : 0);
export function ReportScreen() {
const { profile } = useAppData();
const report = useAsync(() => api.weeklyReport(), []);
return (
<Screen>
<ScreenHeader
title="گزارشِ والدین"
subtitle={`پیشرفتِ ${profile.name || 'دانش‌آموز'} در ۷ روزِ اخیر`}
color={THIS_WEEK}
back
mascot={{ char: 'sigma', mood: 'happy', size: 56 }}
/>
<ScreenBody className="flex flex-col gap-4">
{report.loading ? (
<LoadingState />
) : report.error || !report.data ? (
<ErrorState error={report.error} onRetry={report.reload} />
) : (
<Report data={report.data} name={profile.name} />
)}
</ScreenBody>
</Screen>
);
}
function Report({ data, name }: { data: WeeklyReport; name: string }) {
const { thisWeek: now, lastWeek: before } = data;
return (
<>
<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={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={CheckCircle2}
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={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> روز
</div>
)}
<BarChart
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) },
]}
note="زمان تقریبی است: از فاصله‌ی جواب‌ها و پرسش‌ها حساب می‌شود، پس خواندنِ درس بدونِ جواب دادن در آن نیست."
/>
<BarChart
title="سوال‌های حل‌شده در هر روز"
description="کلِ ستون تعدادِ سوال‌هایی است که آن روز جواب داده؛ بخشِ سبز جواب‌های درست است و بخشِ طلایی نادرست."
unit="سوال"
days={data.days}
mode="stacked"
series={[
{ label: 'درست', color: CORRECT, values: data.days.map((d) => d.correct) },
{ label: 'نادرست', color: WRONG, values: data.days.map((d) => d.questions - d.correct) },
]}
/>
<DayTable days={data.days} previous={data.previousDays} />
</>
);
}
/* ─── جمله‌ی بالای صفحه ─────────────────────────────────────────────────────── */
/** مقایسه با زمانِ تمرین؛ تغییرِ کمتر از ۱۰٪ یا ۳ دقیقه «مثلِ هفته‌ی پیش» است، نه بالا و پایینِ اتفاقی. */
function verdict(now: ReportTotals, before: ReportTotals, name: string): { mood: SeyarMood; text: string } {
const who = name ? `${name} ` : '';
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: 'این هفته هنوز تمرینی ثبت نشده. یه جزیره‌ی کوتاه شروعِ خوبیه!' };
}
if (!before.minutes) {
return { mood: 'excited', text: `آفرین! ${who}این هفته شروع کرده و ${toFa(now.activeDays)} روز تمرین کرده.` };
}
if (meaningful && diff > 0) {
return { mood: 'excited', text: `آفرین! ${who}این هفته حدودِ ${toFa(diff)} دقیقه بیشتر از هفته‌ی پیش تمرین کرده.` };
}
if (meaningful && diff < 0) {
return { mood: 'idle', text: `این هفته حدودِ ${toFa(-diff)} دقیقه کمتر از هفته‌ی پیش تمرین شده. یه تشویقِ کوچیک کمک می‌کنه!` };
}
return { mood: 'happy', text: `${who}مثلِ هفته‌ی پیش منظم تمرین کرده؛ همین‌طور ادامه بده!` };
}
function Verdict({ now, before, name }: { now: ReportTotals; before: ReportTotals; name: string }) {
const { mood, text } = verdict(now, before, name);
return (
<div className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4">
<SeyarMascot char="sigma" mood={mood} size={52} />
<p className="flex-1 text-sm font-bold leading-7 text-ink">{text}</p>
</div>
);
}
/* ─── کاشی‌های عدد ─────────────────────────────────────────────────────────── */
type Delta = { text: string; up: boolean | null } | null;
function change(now: number, before: number, unit: string): Delta {
const diff = now - before;
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 };
}
function Stat({ icon: Icon, label, value, delta }: { icon: LucideIcon; label: string; value: string; delta: Delta }) {
return (
<div className="flex flex-col gap-1 rounded-2xl border border-line bg-white px-3 py-3">
<span className="flex items-center gap-1.5 text-[11px] font-bold text-muted">
<Icon size={13} /> {label}
</span>
<span className="text-lg font-black text-ink">{value}</span>
{delta && (
<span className={`text-[11px] font-bold ${delta.up ? 'text-[#2E7D5B]' : 'text-muted'}`}>
{delta.up === null ? '' : delta.up ? '▲ ' : '▼ '}
{delta.text}
</span>
)}
</div>
);
}
/* ─── نمودارِ ستونی ─────────────────────────────────────────────────────────── */
interface Series {
label: string;
color: string;
values: number[];
}
/** سقفِ گرد برای محورِ عمودی: ۱، ۲، ۵ × ۱۰ⁿ. */
function niceMax(value: number): number {
if (value <= 0) return 1;
const pow = 10 ** Math.floor(Math.log10(value));
return [1, 2, 5, 10].map((m) => m * pow).find((m) => m >= value) ?? value;
}
const PLOT_HEIGHT = 128;
function BarChart({
title,
description,
unit,
days,
series,
mode,
note,
}: {
title: string;
/** یک جمله برای والد: این نمودار چه می‌گوید. */
description: string;
unit: string;
days: ReportDay[];
series: Series[];
/** grouped: ستون‌های کنارِ هم (مقایسه)؛ stacked: روی هم (بخش‌های یک کل). */
mode: 'grouped' | 'stacked';
note?: ReactNode;
}) {
const [selected, setSelected] = useState(days.length - 1);
const totals = days.map((_, i) => series.reduce((sum, s) => sum + s.values[i], 0));
const peak = mode === 'stacked' ? Math.max(...totals) : Math.max(...series.flatMap((s) => s.values));
const max = niceMax(peak);
const empty = peak === 0;
const height = (v: number) => (v / max) * PLOT_HEIGHT;
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="text-sm font-black text-ink">{title}</h2>
<p className="mt-1 text-[11px] leading-5 text-muted">{description}</p>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-body">
{series.map((s) => (
<span key={s.label} className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: s.color }} />
{s.label}
</span>
))}
</div>
{/* خواندنِ روزِ انتخاب‌شده؛ همان کارِ tooltip را روی گوشی می‌کند. */}
<p className="mt-2 min-h-5 text-xs text-body" aria-live="polite">
<b className="text-ink">{longDay(days[selected].date)}:</b>{' '}
{series.map((s) => `${s.label} ${toFa(s.values[selected])} ${unit}`).join('، ')}
</p>
<div className="relative mt-2" style={{ height: PLOT_HEIGHT + 22 }}>
{/* خطِ راهنمای سقف و نیمه؛ کم‌رنگ تا با ستون‌ها رقابت نکند. */}
{[1, 0.5].map((f) => (
<div key={f} className="pointer-events-none absolute inset-x-0 border-t border-dashed border-line" style={{ bottom: 22 + PLOT_HEIGHT * f }}>
{/* نیمه‌ی سقفِ ۱ یا ۵ اعشاری است («۰.۵»)؛ فقط عددِ صحیح نوشته می‌شود. */}
{!empty && Number.isInteger(max * f) && (
<span className="absolute -top-2 left-0 bg-white pr-1 text-[10px] text-muted">{toFa(max * f)}</span>
)}
</div>
))}
<div className="absolute inset-x-0 border-t border-faint" style={{ bottom: 22 }} />
{empty && (
<p className="absolute inset-x-0 text-center text-xs text-muted" style={{ bottom: 22 + PLOT_HEIGHT / 2 - 8 }}>
هنوز چیزی ثبت نشده
</p>
)}
<div className="absolute inset-0 flex gap-1 pl-6">
{days.map((day, i) => {
const isToday = i === days.length - 1;
return (
<button
key={day.date}
type="button"
onClick={() => setSelected(i)}
aria-label={`${longDay(day.date)}: ${series.map((s) => `${s.label} ${toFa(s.values[i])} ${unit}`).join('، ')}`}
aria-pressed={selected === i}
className={`flex flex-1 flex-col items-center justify-end rounded-xl ${selected === i ? 'bg-soft' : ''}`}
>
<div className="flex items-end justify-center gap-0.5" style={{ height: PLOT_HEIGHT }}>
{mode === 'grouped' ? (
series.map((s) => (
<span
key={s.label}
className="w-2.5 rounded-t"
style={{ height: Math.max(height(s.values[i]), s.values[i] ? 3 : 0), backgroundColor: s.color }}
/>
))
) : (
// بخش‌ها از پایین روی هم؛ فاصله‌ی سفیدِ ۲ پیکسلی جدایشان می‌کند.
<span className="flex w-4 flex-col-reverse gap-0.5">
{series.map((s, k) =>
s.values[i] ? (
<span
key={s.label}
className={k === series.length - 1 || series.slice(k + 1).every((n) => !n.values[i]) ? 'rounded-t' : ''}
style={{ height: Math.max(height(s.values[i]) - 2, 3), backgroundColor: s.color }}
/>
) : null,
)}
</span>
)}
</div>
<span className={`mt-1 h-4 text-[10px] ${isToday ? 'font-black text-ink' : 'text-muted'}`}>
{isToday ? 'امروز' : WEEKDAY_SHORT[localDate(day.date).getDay()]}
</span>
</button>
);
})}
</div>
</div>
{note && <p className="mt-3 text-[11px] leading-5 text-muted">{note}</p>}
</section>
);
}
/* ─── جدولِ روزها ───────────────────────────────────────────────────────────── */
/** همان عددهای نمودارها، برای کسی که رنگ‌ها را از هم جدا نمی‌بیند یا عددِ دقیق می‌خواهد. */
function DayTable({ days, previous }: { days: ReportDay[]; previous: ReportDay[] }) {
return (
<details className="rounded-3xl border border-line bg-white p-4 text-xs">
<summary className="cursor-pointer font-black text-ink">عددهای هر روز</summary>
<table className="mt-3 w-full text-center">
<thead className="text-[11px] text-muted">
<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>
</tr>
</thead>
<tbody className="text-body">
{days.map((d, i) => (
<tr key={d.date} className="border-t border-line">
<td className="py-1.5 text-right">{longDay(d.date)}</td>
<td>{toFa(d.minutes)}</td>
<td>{toFa(previous[i]?.minutes ?? 0)}</td>
<td>{toFa(d.questions)}</td>
<td>{toFa(d.correct)}</td>
</tr>
))}
</tbody>
</table>
</details>
);
}

View File

@@ -2,12 +2,16 @@ import { useState } from 'react';
import { Check, ChevronLeft } from 'lucide-react';
import { api, type Grade, type UserProfile } from '@/api';
import { InlineError, PrimaryButton } from '@/components/ui';
import { AgePicker, ageFromBirthYear, birthYearFromAge } from '@/components/AgePicker';
import { ADULT_AGE, BirthDatePicker, ageOf } from '@/components/BirthDatePicker';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
/** «بیا با هم آشنا بشیم»: اسم، سن و پایه، بارِ اولِ ورود. */
/**
* «بیا با هم آشنا بشیم»: اسم، تاریخِ تولد و پایه، بارِ اولِ ورود.
* بزرگسال هم می‌آید (کسی که فقط یک مبحث را می‌خواهد یاد بگیرد)؛ برای او «پایه»
* یعنی ریاضیِ کدام پایه، نه کلاسی که در آن است.
*/
export function SetupScreen({
profile,
grades,
@@ -18,7 +22,7 @@ export function SetupScreen({
onDone: (profile: UserProfile) => void;
}) {
const [name, setName] = useState(profile.name);
const [age, setAge] = useState<number | null>(ageFromBirthYear(profile.birthYear));
const [birthDate, setBirthDate] = useState<string | null>(profile.birthDate);
const [gradeId, setGradeId] = useState<number | null>(profile.gradeId);
const [touched, setTouched] = useState(false);
const [saving, setSaving] = useState(false);
@@ -26,6 +30,8 @@ export function SetupScreen({
const nameMissing = touched && !name.trim();
const gradeMissing = touched && !gradeId;
const age = ageOf(birthDate, null);
const adult = age !== null && age >= ADULT_AGE;
async function submit() {
setTouched(true);
@@ -36,7 +42,7 @@ export function SetupScreen({
await api.profile.setGrade(gradeId);
const updated = await api.profile.update({
name: name.trim(),
...(age ? { birthYear: birthYearFromAge(age) } : {}),
...(birthDate ? { birthDate } : {}),
});
onDone({ ...updated, gradeId });
} catch (err) {
@@ -78,13 +84,16 @@ 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>
<AgePicker value={age} onChange={setAge} />
<BirthDatePicker value={birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
</div>
<div>
<span className="mb-3 block text-sm font-black text-ink">کدوم پایه‌ای؟</span>
<span className="mb-3 block text-sm font-black text-ink">
{adult ? 'ریاضیِ کدوم پایه رو می‌خوای یاد بگیری؟' : 'کدوم پایه‌ای؟'}
{adult && <span className="mt-1 block text-xs font-medium text-muted">هر وقت خواستی از پروفایل عوضش کن.</span>}
</span>
<div className="grid grid-cols-2 gap-3">
{grades.map((g) => {
const selected = gradeId === g.id;
@@ -114,7 +123,7 @@ export function SetupScreen({
);
})}
</div>
{gradeMissing && <span className="mt-2 block text-xs text-red-500">پایه‌ت رو انتخاب کن</span>}
{gradeMissing && <span className="mt-2 block text-xs text-red-500">{adult ? 'یه پایه رو انتخاب کن' : 'پایه‌ت رو انتخاب کن'}</span>}
</div>
{error && <InlineError>{error}</InlineError>}

View File

@@ -15,7 +15,7 @@ export function SplashScreen() {
<SeyarMascot char="sigma" mood="happy" size={130} />
</div>
<h1 className="text-3xl font-black text-white">تدریسینو</h1>
<p className="text-sm text-white/75">ریاضی، قدم‌به‌قدم، با سه‌یار</p>
<p className="text-sm text-white/75">ریاضی، قدم‌به‌قدم، با همراه‌هات</p>
</motion.div>
<motion.div
animate={{ rotate: 360 }}

View File

@@ -1,6 +1,8 @@
import { useState } from 'react';
import { Check, Crown, Heart, Ticket } from 'lucide-react';
import { Check, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
import { api, type Plan } from '@/api';
import { newIdempotencyKey } from '@/api/http';
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet';
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
@@ -14,128 +16,202 @@ function durationLabel(days: number): string {
return days ? `${toFa(days)} روزه` : '';
}
/**
* «بسته‌ی سوال» (از ۱۴۰۵-۰۷ به‌جای اشتراکِ ماهانه/سالانه). هر کس به اندازه‌ی مصرفش سوال از
* دستیار می‌خرد؛ اعتبار منقضی نمی‌شود و هر بسته چند روز قلبِ نامحدود هم هدیه دارد. هر روز
* اول سوال‌های رایگانِ همان روز خرج می‌شوند، بعد اعتبار.
*/
export function SubscriptionScreen() {
const plans = useAsync(() => api.subscription.plans(), []);
const status = useAsync(() => api.subscription.status(), []);
const quota = useAsync(() => api.ai.quota(), []);
const [buying, setBuying] = useState<Plan | null>(null);
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درون‌برنامه‌ایِ خودش است؛ بسته‌ای که
// SKU ندارد آن‌جا خریدنی نیست (به زرین‌پال فرستاده نمی‌شود).
const bazaar = inBazaarApp();
const activePlanId = status.data?.active ? status.data.planId : null;
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
const reload = () => { status.reload(); quota.reload(); };
return (
<Screen>
<div className="flex-1 overflow-y-auto">
<ScreenHeader title="اشتراکِ ویژه" subtitle="یادگیری بدونِ وقفه" color="#ED6956" mascot={{ char: 'sigma', mood: 'excited', size: 60 }} />
<ScreenHeader title="بسته‌ی سوال" subtitle="هر وقت لازم داشتی، سوال بخر" color="#ED6956" mascot={{ char: 'pi', mood: 'excited', size: 60 }} />
<div className="flex flex-col gap-4 px-4 py-5">
{status.data?.active && (
<div className="flex items-center gap-3 rounded-3xl border-2 border-mint bg-[#EAF8F0] p-4">
<Crown size={22} className="flex-shrink-0 text-mint" />
<div className="text-sm leading-7 text-ink">
<b>اشتراکت فعاله.</b>
{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}.</>}
<div className="text-xs text-muted">اگه دوباره بخری، از روزِ پایانِ همین اشتراک تمدید می‌شه.</div>
<div className="rounded-3xl border border-line bg-white p-4">
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-[#F5F1FF] text-violet"><Sparkles size={22} /></span>
<div className="flex-1">
<div className="text-xs font-bold text-muted">اعتبارِ تو</div>
<div className="text-2xl font-black text-ink">{toFa(credits)} <span className="text-sm font-bold text-body">سوال</span></div>
</div>
{quota.data && (
<div className="text-left text-xs leading-6 text-muted">
رایگانِ امروز<br />
<b className="text-base text-ink">{toFa(quota.data.remainingQuota)}</b> از {toFa(quota.data.freeDaily)}
</div>
)}
</div>
)}
<div className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4">
<Heart size={22} className="flex-shrink-0 text-coral" fill="#ED6956" />
<p className="text-sm leading-7 text-body">با اشتراک، قلب‌هات هیچ‌وقت تموم نمی‌شن و هر چقدر بخوای تمرین می‌کنی.</p>
<p className="mt-3 border-t border-line pt-3 text-xs leading-6 text-muted">
هر روز {toFa(plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول رایگانه؛ بعدش از اعتبارت کم می‌شه. اعتبار هیچ‌وقت منقضی نمی‌شه.
</p>
{heartsUntil && (
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-coral">
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود تا {formatJalaliDate(heartsUntil)}
</p>
)}
{status.data?.active && (
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-mint">
<Crown size={13} /> اشتراکِ قبلیت هم فعاله{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}</>}.
</p>
)}
</div>
{plans.loading ? (
<LoadingState text="دارم پلن‌ها رو میارم…" />
<LoadingState text="دارم بسته‌ها رو میارم…" />
) : plans.error ? (
<ErrorState error={plans.error} onRetry={plans.reload} />
) : !plans.data?.length ? (
<EmptyState text="فعلاً پلنی برای خرید تعریف نشده" />
) : !plans.data?.plans.length ? (
<EmptyState text="فعلاً بسته‌ای برای خرید تعریف نشده" />
) : (
plans.data.map((plan) => {
const featured = plan.popular;
const isCurrent = plan.id === activePlanId;
return (
<div key={plan.id} className="relative pt-3">
{plan.badge && (
<span className="absolute right-5 top-0 z-10 rounded-full bg-[#FFD23F] px-3 py-1 text-[11px] font-black text-ink shadow">
{plan.badge}
</span>
)}
<div className={`rounded-3xl p-5 ${featured ? 'bg-orange text-white shadow-lg shadow-orange/30' : 'border border-line bg-white text-ink'}`}>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-black">{plan.title}</h3>
<p className={`mt-0.5 text-xs ${featured ? 'text-white/75' : 'text-muted'}`}>{durationLabel(plan.durationDays)}</p>
</div>
<div className="text-left">
<p className="text-base font-black">{plan.price === 0 ? 'رایگان' : formatToman(plan.price)}</p>
{plan.discountPercent > 0 && (
<span className={`mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-black ${featured ? 'bg-white/20' : 'bg-[#fde9e5] text-coral'}`}>
{toFa(plan.discountPercent)}٪ تخفیف
</span>
)}
</div>
</div>
{plan.features.length > 0 && (
<ul className="mb-4 flex flex-col gap-2">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm">
<Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
<span className={featured ? 'text-white/90' : 'text-body'}>{f}</span>
</li>
))}
</ul>
)}
<button
type="button"
disabled={plan.price === 0 || isCurrent}
onClick={() => setBuying(plan)}
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-60 ${
featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'
}`}
>
{isCurrent ? 'اشتراکِ فعلیِ تو' : plan.price === 0 ? 'رایگان' : status.data?.active ? 'تمدید با این پلن' : 'انتخابِ این پلن'}
</button>
</div>
</div>
);
})
plans.data.plans.map((plan) => (
<PackCard key={plan.id} plan={plan} available={!bazaar || !!plan.bazaarSku} onBuy={() => setBuying(plan)} />
))
)}
<p className="pb-2 text-center text-[11px] leading-6 text-faint">پرداخت از طریقِ درگاهِ امنِ زرین‌پال انجام می‌شه.</p>
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
{bazaar ? 'پرداخت از طریقِ کافه‌بازار انجام می‌شه.' : 'پرداخت از طریقِ درگاهِ امنِ زرین‌پال انجام می‌شه.'}
</p>
</div>
</div>
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaidWithCode={() => { setBuying(null); status.reload(); }} />
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); reload(); }} />
</Screen>
);
}
function PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean; onBuy: () => void }) {
const featured = plan.popular;
const isPack = plan.kind === 'pack';
const tone = featured ? 'text-white/80' : 'text-muted';
return (
<div className="relative pt-3">
{plan.badge && (
<span className="absolute right-5 top-0 z-10 rounded-full bg-[#FFD23F] px-3 py-1 text-[11px] font-black text-ink shadow">{plan.badge}</span>
)}
<div className={`rounded-3xl p-5 ${featured ? 'bg-orange text-white shadow-lg shadow-orange/30' : 'border border-line bg-white text-ink'}`}>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h3 className="text-2xl font-black">{isPack ? <>{toFa(plan.questionCredits)} <span className="text-base">سوال</span></> : plan.title}</h3>
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ «پی»، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
</div>
<div className="text-left">
<p className="text-base font-black">{formatToman(plan.price)}</p>
</div>
</div>
<ul className="mb-4 flex flex-col gap-2 text-sm">
{isPack && plan.heartsDays > 0 && (
<li className="flex items-center gap-2 font-bold">
<Heart size={14} fill={featured ? '#fff' : '#ED6956'} className={featured ? 'text-white' : 'text-coral'} />
هدیه: {toFa(plan.heartsDays)} روز قلبِ نامحدود
</li>
)}
{!isPack && plan.aiDailyQuota > 0 && (
<li className="flex items-center gap-2 font-bold"><Sparkles size={14} /> {toFa(plan.aiDailyQuota)} سوال در روز از دستیار</li>
)}
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2">
<Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
<span className={featured ? 'text-white/90' : 'text-body'}>{f}</span>
</li>
))}
</ul>
<button
type="button"
onClick={onBuy}
disabled={!available}
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-50 ${featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'}`}
>
{available ? 'خرید' : 'به‌زودی در بازار'}
</button>
</div>
</div>
);
}
/**
* خرید: سفارش ساخته می‌شود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت می‌شود،
* و بعد کاربر در همین برگه به درگاه می‌رود. window.open بعد از await را
* Safariِ آیفون مسدود می‌کند؛ برای همین جابه‌جاییِ همین صفحه است.
*/
function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; onClose: () => void; onPaidWithCode: () => void }) {
function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) {
const [code, setCode] = useState('');
const [showCode, setShowCode] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [freeDone, setFreeDone] = useState(false);
const [done, setDone] = useState<'code' | 'bazaar' | null>(null);
/**
* داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید.
* کدِ تخفیف این‌جا نیست: بازار قیمتِ ثابتِ محصول را می‌گیرد و سرور تخفیف را رد می‌کند.
*/
async function payWithBazaar(plan: Plan) {
const sku = plan.bazaarSku;
let key = bazaarPending.get(sku);
if (key) {
// کلیدِ مانده از خریدِ قبلی فقط وقتی به کار می‌آید که سفارشش هنوز در انتظار است؛ سفارشِ
// پرداخت‌شده یا ناموفق با خریدِ تازه قاطی نشود.
const previous = await api.subscription.purchaseBazaar(plan.id, key).catch(() => null);
const status = previous ? await api.subscription.order(previous.orderId).then((o) => o.status).catch(() => null) : null;
if (status !== 'pending') key = null;
}
if (!key) {
key = newIdempotencyKey();
bazaarPending.set(sku, key);
}
const order = await api.subscription.purchaseBazaar(plan.id, key);
let token: string;
try {
token = await bazaarPurchase(order.bazaarSku || sku, order.orderId);
} catch (err) {
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return false;
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;
}
async function pay() {
if (!plan || busy) return;
setBusy(true);
setError('');
if (bazaar) {
try {
if (await payWithBazaar(plan)) setDone('bazaar');
} catch (err) {
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
}
setBusy(false);
return;
}
try {
const order = await api.subscription.purchase(plan.id);
if (code.trim()) {
const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
if (redeemed.orderStatus === 'paid') {
setFreeDone(true);
setDone('code');
setBusy(false);
return;
}
}
if (!order.paymentUrl) throw new Error('order without paymentUrl');
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
window.location.assign(order.paymentUrl);
} catch (err) {
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
@@ -144,19 +220,21 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
}
const close = () => {
if (freeDone) onPaidWithCode();
if (done) onPaid();
else onClose();
setCode('');
setShowCode(false);
setError('');
setFreeDone(false);
setDone(null);
};
return (
<Sheet open={plan !== null} onClose={close} title={freeDone ? 'اشتراکت فعال شد! 🎉' : plan?.title}>
{freeDone ? (
<Sheet open={plan !== null} onClose={close} title={done ? 'خریدت انجام شد! 🎉' : plan?.title}>
{done ? (
<>
<p className="mb-5 text-sm leading-7 text-muted">کدِ تخفیفت کلِ مبلغ رو پوشوند و اشتراکت همین الان فعال شد.</p>
<p className="mb-5 text-sm leading-7 text-muted">
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و سوال‌ها به اعتبارت اضافه شدن.'}
</p>
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
</>
) : plan && (
@@ -165,7 +243,7 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
<span className="text-sm text-body">مبلغ</span>
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
</div>
{showCode ? (
{bazaar ? null : showCode ? (
<label className="mb-4 block">
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
<input

View File

@@ -1,12 +1,12 @@
import { useState } from 'react';
import { Bug, MessageCircle, Send } from 'lucide-react';
import { api, type Ticket } from '@/api';
import { api, type TicketSummary } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { KEYS, store } from '@/lib/storage';
import { useToast } from '@/components/Toast';
import { InlineError, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { InlineError, PrimaryButton, Screen, ScreenBody, ScreenHeader, Sep } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
const CATEGORIES = [
{ id: 'learning', label: 'درس و سوال‌ها' },
@@ -22,15 +22,6 @@ const STATUS_LABEL: Record<string, string> = {
closed: 'بسته',
};
/** سرور فهرستِ تیکت‌ها را نمی‌دهد؛ شناسه‌ها را همین‌جا نگه می‌داریم. */
function savedTicketIds(): string[] {
try {
return JSON.parse(store.get(KEYS.ticketIds) ?? '[]') as string[];
} catch {
return [];
}
}
export function SupportScreen() {
const toast = useToast();
const [mode, setMode] = useState<'ticket' | 'bug'>('ticket');
@@ -39,7 +30,8 @@ export function SupportScreen() {
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [error, setError] = useState('');
const [ticketIds, setTicketIds] = useState(savedTicketIds);
// فهرست از سرور می‌آید، پس تیکت و پاسخش با عوض شدنِ گوشی هم دیده می‌شود.
const tickets = useAsync(() => api.support.tickets(), []);
async function submit() {
if (!subject.trim() || !text.trim()) return;
@@ -52,9 +44,7 @@ export function SupportScreen() {
} else {
const t = await api.support.createTicket(subject.trim(), category);
await api.support.reply(t.ticketId, text.trim());
const ids = [t.ticketId, ...ticketIds].slice(0, 20);
store.set(KEYS.ticketIds, JSON.stringify(ids));
setTicketIds(ids);
tickets.reload();
toast('پیامت رسید؛ جواب همین‌جا و در اعلان‌ها میاد.', 'success');
}
setSubject('');
@@ -116,11 +106,11 @@ export function SupportScreen() {
<Send size={16} className="-scale-x-100" /> ارسال
</PrimaryButton>
{ticketIds.length > 0 && (
{!!tickets.data?.length && (
<section>
<h2 className="mb-2 mt-2 text-sm font-black text-ink">پیام‌های قبلیِ تو</h2>
<div className="flex flex-col gap-2">
{ticketIds.map((id) => <TicketCard key={id} ticketId={id} />)}
{tickets.data.map((t) => <TicketCard key={t.ticketId} summary={t} />)}
</div>
</section>
)}
@@ -129,26 +119,24 @@ export function SupportScreen() {
);
}
function TicketCard({ ticketId }: { ticketId: string }) {
function TicketCard({ summary }: { summary: TicketSummary }) {
const toast = useToast();
const ticket = useAsync(() => api.support.ticket(ticketId), [ticketId]);
const [open, setOpen] = useState(false);
// پیام‌ها فقط وقتی کارت باز شود گرفته می‌شوند؛ فهرست خودش خلاصه را دارد.
const detail = useAsync(() => (open ? api.support.ticket(summary.ticketId) : Promise.resolve(null)), [open, summary.ticketId]);
const [reply, setReply] = useState('');
const [sending, setSending] = useState(false);
if (ticket.error || !ticket.data) {
return ticket.loading ? <div className="h-14 animate-pulse rounded-2xl bg-soft" /> : null;
}
const t: Ticket = ticket.data;
const staffReplies = t.messages.filter((m) => m.sender !== 'user').length;
const t = detail.data;
const status = t?.status ?? summary.status;
const staffReplies = t ? t.messages.filter((m) => m.sender !== 'user').length : summary.supportReplies;
async function send() {
if (!reply.trim()) return;
setSending(true);
try {
await api.support.reply(ticketId, reply.trim());
await api.support.reply(summary.ticketId, reply.trim());
setReply('');
ticket.reload();
detail.reload();
} catch (err) {
toast(friendlyError(err), 'error');
} finally {
@@ -160,24 +148,28 @@ function TicketCard({ ticketId }: { ticketId: string }) {
<div className="rounded-2xl border border-line bg-white">
<button type="button" onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-3 p-3.5 text-right">
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink">{t.subject}</span>
<span className="block truncate text-sm font-bold text-ink">{summary.subject}</span>
<span className="text-[11px] text-muted">
{STATUS_LABEL[t.status] ?? t.status}
{staffReplies > 0 && ` · ${toFa(staffReplies)} پاسخ`}
{STATUS_LABEL[status] ?? status}
{staffReplies > 0 && <><Sep />{toFa(staffReplies)} پاسخ</>}
</span>
</span>
{staffReplies > 0 && !open && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-indigo" aria-hidden />}
</button>
{open && (
<div className="flex flex-col gap-2 border-t border-line p-3.5">
{t.messages.map((m) => (
{!t && (detail.error
? <InlineError>{friendlyError(detail.error)}</InlineError>
: <div className="h-10 animate-pulse rounded-2xl bg-soft" />)}
{t?.messages.map((m) => (
<div
key={m.id}
className={`whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-xs leading-6 ${m.sender === 'user' ? 'mr-8 bg-soft text-body' : 'ml-8 bg-[#EEF1FF] text-ink'}`}
>
{m.text}
<MathText text={m.text} />
</div>
))}
{t.status !== 'closed' && (
{t && status !== 'closed' && (
<div className="mt-1 flex gap-2">
<input
value={reply}

View File

@@ -1,22 +1,19 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import { Check, CheckCircle2, Circle, Lock, Play, Sparkles } from 'lucide-react';
import { api, type HomeNode } from '@/api';
import { Sparkles } from 'lucide-react';
import type { HomeNode } from '@/api';
import { useAppData } from '@/state/AppData';
import { findUnit, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum';
import { findUnit, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet';
import { EmptyState, ErrorState, LoadingState, PrimaryButton, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
import { iconFor } from '@/components/NodeIcon';
import { IslandPath } from '@/components/IslandPath';
import { IslandSheet } from '@/components/IslandSheet';
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
import { toFa } from '@/lib/format';
const NODE_SIZE = 76;
import { MathText } from '@/lib/MathText';
/** نقشه‌ی جزیره‌های یک فصل، مثلِ مسیرِ دولینگو. */
export function UnitScreen({ unitId }: { unitId: string }) {
const { home, homeError, refreshHome, companion } = useAppData();
const nav = useNav();
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
const found = findUnit(home, unitId);
@@ -32,14 +29,13 @@ export function UnitScreen({ unitId }: { unitId: string }) {
const { unit, unitIndex } = found;
const color = unitColor(unitIndex);
const progress = unitProgress(unit);
const points = zigzagPositions(unit.nodes.length, 125, 90);
const height = points.length ? points[points.length - 1].y + 150 : 400;
const current = unit.nodes.find((n) => n.status !== 'completed' && n.status !== 'locked') ?? null;
return (
<Screen className="bg-[#f7f8ff]">
<Screen className="bg-page">
<ScreenHeader
title={unit.title}
subtitle={unit.blurb || `${toFa(progress.done)} از ${toFa(progress.total)} جزیره`}
subtitle={unit.blurb ? <MathText text={unit.blurb} /> : `${toFa(progress.done)} از ${toFa(progress.total)} جزیره`}
color={color}
back
mascot={{ char: companion, mood: progress.percent >= 50 ? 'happy' : 'idle', size: 72 }}
@@ -53,160 +49,29 @@ export function UnitScreen({ unitId }: { unitId: string }) {
</div>
</ScreenHeader>
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto pb-6 pt-10">
{unit.nodes.length === 0 ? (
<EmptyState text="این فصل هنوز جزیره‌ای نداره" />
) : (
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
<ZigzagPath points={points} height={height} colorOf={(i) => (unit.nodes[i].status === 'completed' ? color : null)} />
{unit.nodes.map((node, i) => {
const locked = node.status === 'locked';
const done = node.status === 'completed';
const current = !locked && !done;
const Icon = iconFor(node.icon, i);
return (
<motion.div
key={node.id}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: `${points[i].x}%`, top: points[i].y }}
>
{current && (
<>
<div
className="absolute -top-14 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-2xl border-2 bg-white px-3 py-1.5 text-[11px] font-black shadow-lg"
style={{ color, borderColor: color }}
>
{node.title}
<span className="absolute -bottom-2 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 border-b-2 border-r-2 bg-white" style={{ borderColor: color }} />
</div>
<motion.div
animate={{ scale: [1, 1.18, 1], opacity: [0.25, 0.05, 0.25] }}
transition={{ duration: 1.8, repeat: Infinity }}
className="absolute inset-[-13px] rounded-full"
style={{ backgroundColor: color }}
/>
</>
)}
<button
type="button"
aria-label={`${node.title}${locked ? ' (قفل)' : done ? ' (تمام‌شده)' : ''}`}
onClick={() => setOpenNode(node)}
className="relative z-10 flex items-center justify-center rounded-full transition-transform active:translate-y-1"
style={{
width: NODE_SIZE,
height: NODE_SIZE,
backgroundColor: locked ? '#e5e4ec' : done ? color : '#fff',
border: current ? `4px solid ${color}` : 'none',
boxShadow: locked ? '0 6px 0 #cccbd6' : done ? `0 7px 0 ${color}99` : `0 7px 0 ${color}55, 0 0 0 5px white`,
}}
>
{locked ? (
<Lock size={24} strokeWidth={2.5} className="text-[#aaa9b8]" />
) : done ? (
<Check size={31} color="white" strokeWidth={3.5} />
) : (
<Icon size={28} style={{ color }} strokeWidth={2.5} />
)}
</button>
{!current && (
<div className="absolute left-1/2 top-full mt-2.5 w-28 -translate-x-1/2 truncate rounded-lg bg-[#f7f8ff]/90 text-center text-[10px] font-bold text-muted">
{node.title}
</div>
)}
</motion.div>
);
})}
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 70 }}>
<span className="mb-1 text-[10px] font-bold text-[#aaa9bd]">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
<>
<IslandPath
nodes={unit.nodes}
color={color}
currentId={current?.id ?? null}
onOpen={setOpenNode}
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
/>
<div className="mt-6 flex flex-col items-center">
<span className="mb-1 text-[11px] font-bold text-muted">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>
<Sparkles size={20} style={{ color }} />
</span>
</div>
</div>
</>
)}
</div>
<NodeSheet node={openNode} color={color} onClose={() => setOpenNode(null)} />
<IslandSheet node={openNode} color={color} onClose={() => setOpenNode(null)} />
</Screen>
);
}
/** برگه‌ی جزیره: درس‌هایش، و دکمه‌ی شروعِ درسِ بعدی. */
function NodeSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
const nav = useNav();
const lessons = useAsync(() => (node ? api.nodeLessons(node.id) : Promise.resolve(null)), [node?.id]);
const next = node ? nextLessonOf(node) : null;
const locked = node?.status === 'locked';
const open = (lessonId: number) => {
if (!node) return;
onClose();
nav.push({ name: 'lesson', lessonId, nodeId: node.id });
};
// فقط درس‌هایی که سوال دارند در lessonIds هستند؛ بقیه هنوز آزمون ندارند.
const playable = new Set(node?.lessonIds ?? []);
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
const doneCount = list.filter((l) => l.done).length;
return (
<Sheet open={node !== null} onClose={onClose} title={node?.title}>
{locked ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
<Lock size={24} className="text-muted" />
</span>
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره‌ی قبلی رو تموم کن تا باز بشه.</p>
</div>
) : (
<>
{lessons.loading ? (
<div className="py-6 text-center text-sm text-muted">دارم درس‌ها رو میارم…</div>
) : lessons.error ? (
<ErrorState error={lessons.error} onRetry={lessons.reload} />
) : (
<>
<p className="mb-3 text-xs text-muted">
{toFa(doneCount)} از {toFa(list.length)} درس تموم شده
</p>
<ul className="mb-5 flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{list.map((lesson) => {
const isNext = Number(lesson.id) === next;
return (
<li key={lesson.id}>
<button
type="button"
onClick={() => open(Number(lesson.id))}
className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-2.5 text-right text-sm ${
isNext ? 'border-2 bg-white font-bold' : 'border-line bg-white'
}`}
style={isNext ? { borderColor: color } : undefined}
>
{lesson.done ? (
<CheckCircle2 size={18} className="flex-shrink-0 text-mint" />
) : isNext ? (
<Play size={16} className="flex-shrink-0" style={{ color }} fill={color} />
) : (
<Circle size={18} className="flex-shrink-0 text-faint" />
)}
<span className={`flex-1 ${lesson.done ? 'text-muted' : 'text-ink'}`}>{lesson.title}</span>
</button>
</li>
);
})}
</ul>
</>
)}
{next != null && (
<PrimaryButton color={color} onClick={() => open(next)}>
<Play size={18} fill="white" /> {node?.status === 'completed' ? 'مرور دوباره' : 'شروعِ درسِ بعدی'}
</PrimaryButton>
)}
</>
)}
</Sheet>
);
}

View File

@@ -1,5 +1,5 @@
import { useSyncExternalStore } from 'react';
import type { UsedChunk } from '@/api';
import type { Diagram, UsedChunk } from '@/api';
import { KEYS, store } from '@/lib/storage';
/**
@@ -13,9 +13,13 @@ export interface ChatMessage {
messageId?: string;
usedChunks?: UsedChunk[];
followUps?: string[];
/** شکلِ پاسخ؛ اپ آن را خودش به SVG تبدیل می‌کند (components/Diagram). */
diagram?: Diagram | null;
feedback?: 'up' | 'down';
/** پیامِ خطا به‌جای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده می‌شود. */
failedQuestion?: string;
/** سهمیه‌ی رایگانِ امروز تمام شده؛ به‌جای «دوباره»، راهِ اشتراک نشان داده می‌شود. */
quotaReached?: boolean;
}
interface AskState {

View File

@@ -21,11 +21,14 @@
--color-ink: #1a1a2e; /* متنِ اصلی */
--color-body: #3a3a5c; /* متنِ بدنه */
--color-muted: #9e9ec0; /* متنِ کم‌رنگ */
/* متنِ کم‌رنگ. تا ۲۰۲۶-۱۰-۰۳ ‎#9e9ec0 بود: کنتراست ۲٫۶ روی سفید، زیرِ حدِ ۴٫۵ِ WCAG، و
همین رنگ زیرنویسِ ۱۰–۱۲ پیکسلیِ همه‌ی صفحه‌ها و آیکونِ تب‌های غیرفعال است. این یکی
روی سفید ۴٫۵ و روی پس‌زمینه‌ی صفحه ۴٫۲ است. */
--color-muted: #727299;
--color-faint: #b0b0c8;
--color-line: #ede9ff; /* حاشیه‌ی کارت */
--color-line: #e9e6f7; /* حاشیه‌ی کارت */
--color-soft: #f0eeff; /* پس‌زمینه‌ی ملایم */
--color-page: #f0f4ff; /* پس‌زمینه‌ی صفحه */
--color-page: #f6f5fb; /* پس‌زمینه‌ی صفحه؛ کمی گرم‌تر از آبیِ قبلی */
}
@layer base {
@@ -47,6 +50,30 @@
button:disabled {
cursor: not-allowed;
}
/* کشیدنِ صفحه به پایین در کروم اندروید کلِ اپ را رفرش می‌کرد (pull-to-refresh)
و وسطِ آزمون جوابِ جاری را می‌پراند؛ صفحه‌های داخلی خودشان اسکرول دارند. */
html, body {
overscroll-behavior: none;
}
/* حلقه‌ی فوکوس فقط برای صفحه‌کلید (focus-visible)، نه برای لمس. */
:focus-visible {
outline: 2px solid var(--color-indigo);
outline-offset: 2px;
}
}
/* کاربری که در تنظیماتِ گوشی «کاهشِ حرکت» را زده: انیمیشن‌ها و transitionهای CSS
تقریباً آنی می‌شوند. انیمیشن‌های motion را MotionConfig در App خاموش می‌کند. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@layer base {
input, textarea {
font: inherit;
}

View File

@@ -12,6 +12,18 @@ export default defineConfig(({ mode }) => {
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
build: {
rollupOptions: {
output: {
// کتابخانه‌ها با هر انتشار عوض نمی‌شوند؛ فایلِ جدا یعنی بعد از هر دیپلوی
// از کشِ مرورگر می‌آیند و فقط کدِ خودِ اپ دوباره دانلود می‌شود.
manualChunks: {
react: ['react', 'react-dom'],
motion: ['motion/react'],
},
},
},
},
server: {
// در توسعه /api هم‌دامنه است و به بک‌اند پاس داده می‌شود؛ CORS لازم نیست.
proxy: {