Compare commits
45 Commits
rebuild/v2
...
19ca3d7de1
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ca3d7de1 | |||
| f74b6ad279 | |||
| a8a5351862 | |||
| 8879b42b1c | |||
| 6930c83e07 | |||
| d0a2822658 | |||
| 50f6ade9f8 | |||
| 22fc870176 | |||
| 54b872026d | |||
| 54bd865835 | |||
| dc1fb15a55 | |||
| 09ca1a270a | |||
| 838049c469 | |||
| 5327fed1dd | |||
| 2fbcf6e6e0 | |||
| 5b98c054ea | |||
| c64b3939ba | |||
| b11016c6ea | |||
| ec1bfc721d | |||
| 5b211c2786 | |||
| 0db6526933 | |||
| af2e1f57dd | |||
| 1025321b5f | |||
| e850f6dfbd | |||
| 647ccf2c88 | |||
| f8cf86e4e8 | |||
| adecf25486 | |||
| 33f5f8d009 | |||
| befaaeb7bb | |||
| a2782da3bd | |||
| b860c77b91 | |||
| 44871894e0 | |||
| de7cd0bfa8 | |||
| 0e56d9c8aa | |||
| db6d694ab0 | |||
| 0957ef476b | |||
| 63f4ea8da0 | |||
| 2f8947ba94 | |||
| dffa940f8a | |||
| ec4c844531 | |||
| 355bae7035 | |||
| b5d97c0cda | |||
| 25127d471e | |||
| 2b4db90fe4 | |||
| 33e539e562 |
1
.gitignore
vendored
@@ -8,3 +8,4 @@ dist
|
|||||||
dist.next
|
dist.next
|
||||||
dist.prev
|
dist.prev
|
||||||
.deploy-backups
|
.deploy-backups
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"java.configuration.updateBuildConfiguration": "interactive"
|
||||||
|
}
|
||||||
@@ -75,7 +75,9 @@ src/
|
|||||||
state/ دادهی مشترک (پروفایل، نقشه، …) و گفتگوی دستیار
|
state/ دادهی مشترک (پروفایل، نقشه، …) و گفتگوی دستیار
|
||||||
screens/ یک فایل برای هر صفحه
|
screens/ یک فایل برای هر صفحه
|
||||||
components/ اجزای مشترک (سربرگ، برگهی پایین، …)
|
components/ اجزای مشترک (سربرگ، برگهی پایین، …)
|
||||||
mascots/ سهیار: سیگما، دلتا، پی
|
mascots/ همراهها: سیگما، دلتا، پی، تدی
|
||||||
|
diagram/ شکلِ جوابِ دستیار: توصیفِ JSON از بکاند → SVG؛ همهی مختصات اینجا حساب میشود
|
||||||
|
(plane، shapes، expr، solid، charts، sets، misc، numberline). گالری: ?gallery فقط در dev
|
||||||
lib/ اعدادِ فارسی، تاریخِ شمسی، صدا، localStorage
|
lib/ اعدادِ فارسی، تاریخِ شمسی، صدا، localStorage
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
8
android/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
app/build/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
44
android/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# اپِ اندرویدِ تدریسینو (پوستهی WebView + پرداختِ بازار)
|
||||||
|
|
||||||
|
همان سایتِ `math.tadrisino2.ir` داخلِ یک WebView. تنها کدِ بومی پرداختِ درونبرنامهایِ
|
||||||
|
کافهبازار است (کتابخانهی Poolakey)، چون بازار برای اپی که در آن منتشر میشود پرداختِ
|
||||||
|
محصولِ دیجیتال را فقط از راهِ خودش میپذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
|
||||||
|
فرانت بهروز میشود، بیآنکه نسخهی تازهی اپ لازم باشد.
|
||||||
|
|
||||||
|
> ⚠️ **این پروژه هنوز یک بار هم build نشده.** روی ماشینی نوشته شد که Android SDK نداشت
|
||||||
|
> (۲۰۲۶-۱۰-۰۳). APIِ Poolakey از README خودِ کتابخانه است. اولین کار: باز کردن در Android
|
||||||
|
> Studio، build، و یک خریدِ واقعی روی گوشی.
|
||||||
|
|
||||||
|
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
|
||||||
|
|
||||||
|
| چه | کجا گذاشته میشود | وضعیت |
|
||||||
|
|---|---|---|
|
||||||
|
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.app` حدس است؛ تأیید نشده |
|
||||||
|
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | ندارد؛ بدونِ آن خرید شروع نمیشود |
|
||||||
|
| SKUِ هر بسته (محصولِ **مصرفشدنی**) | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچکدام تعریف نشده |
|
||||||
|
|
||||||
|
قیمتِ هر محصول در پیشخانِ بازار باید با قیمتِ همان بسته در ادمین یکی باشد؛ سرور مبلغ را از
|
||||||
|
بازار نمیخواند.
|
||||||
|
|
||||||
|
## build
|
||||||
|
|
||||||
|
1. Android Studio ← Open ← همین پوشهی `android/`. Studio خودش Gradle wrapper را میسازد.
|
||||||
|
2. `Build ← Generate Signed App Bundle / APK`، با کلیدِ امضای شرکت. **کلیدِ امضا را گم نکنید**:
|
||||||
|
بدونِ همان کلید هیچ نسخهی بعدی روی بازار بهروزرسانی نمیشود.
|
||||||
|
3. نصب روی گوشیای که کافهبازار دارد و با حسابِ تست وارد شده.
|
||||||
|
|
||||||
|
## جریانِ خرید (برای عیبیابی)
|
||||||
|
|
||||||
|
```
|
||||||
|
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
|
||||||
|
فرانت 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`ِ بکاند.
|
||||||
109
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// plugins {
|
||||||
|
// id("com.android.application")
|
||||||
|
// id("org.jetbrains.kotlin.android")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// android {
|
||||||
|
// // ⚠️ اسمِ پکیج باید همانی باشد که در پیشخانِ بازار ثبت است. com.tadrisino.app را بازار
|
||||||
|
// // برای حسابِ شرکت «Product is not found» جواب داد (یعنی هست)، ولی سنیور تأیید نکرده
|
||||||
|
// // که همان اپِ ریاضی است (CLAUDE.mdِ بکاند، ۲۰۲۶-۰۹-۳۰).
|
||||||
|
// namespace = "com.tadrisino.app"
|
||||||
|
// compileSdk = 34
|
||||||
|
|
||||||
|
// defaultConfig {
|
||||||
|
// applicationId = "com.tadrisino.math.app"
|
||||||
|
// minSdk = 23
|
||||||
|
// targetSdk = 34
|
||||||
|
// versionCode = 1
|
||||||
|
// versionName = "1.0.0"
|
||||||
|
|
||||||
|
// // آدرسِ همان فرانتِ وب. دامنه عوض شود، فقط همین خط.
|
||||||
|
// buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
|
||||||
|
// // کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (بخشِ «پرداخت درونبرنامهای»). کلیدِ عمومی
|
||||||
|
// // است و در اپ مینشیند؛ رمز نیست. تا پر نشود، اپ خرید را شروع نمیکند.
|
||||||
|
// buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// buildFeatures {
|
||||||
|
// buildConfig = true
|
||||||
|
// }
|
||||||
|
|
||||||
|
// buildTypes {
|
||||||
|
// release {
|
||||||
|
// isMinifyEnabled = false
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// compileOptions {
|
||||||
|
// sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
// targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
// }
|
||||||
|
// kotlinOptions {
|
||||||
|
// jvmTarget = "17"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// dependencies {
|
||||||
|
// implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
// implementation("androidx.activity:activity-ktx:1.9.2")
|
||||||
|
// implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
|
||||||
|
// }
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
// Package name must match the one registered on Cafe Bazaar.
|
||||||
|
// Confirm the final applicationId before publishing.
|
||||||
|
namespace = "com.tadrisino.app"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.tadrisino.math.app"
|
||||||
|
minSdk = 23
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
|
||||||
|
// URL of the web frontend.
|
||||||
|
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
|
||||||
|
// Public RSA key from the Cafe Bazaar panel. Purchases won't start until it is filled in.
|
||||||
|
buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file("my-release-key.keystore")
|
||||||
|
storePassword = providers.gradleProperty("MYAPP_STORE_PASSWORD").orNull
|
||||||
|
keyAlias = "my-key-alias"
|
||||||
|
keyPassword = providers.gradleProperty("MYAPP_KEY_PASSWORD").orNull
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.9.2")
|
||||||
|
implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
|
||||||
|
}
|
||||||
31
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?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:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Tadrisino">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
180
android/app/src/main/java/com/tadrisino/app/MainActivity.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">تدریسینو ریاضی</string>
|
||||||
|
</resources>
|
||||||
7
android/app/src/main/res/values/themes.xml
Normal 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>
|
||||||
4
android/build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.5.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||||
|
}
|
||||||
2
android/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
org.gradle.jvmargs=-Xmx2g
|
||||||
18
android/settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
// Poolakey (کتابخانهی پرداختِ کافهبازار) فقط روی jitpack منتشر میشود.
|
||||||
|
maven { url = uri("https://jitpack.io") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "Tadrisino"
|
||||||
|
include(":app")
|
||||||
@@ -45,6 +45,6 @@ step "جابهجایی"
|
|||||||
rm -rf dist.prev
|
rm -rf dist.prev
|
||||||
[ -d dist ] && mv dist dist.prev
|
[ -d dist ] && mv dist dist.prev
|
||||||
mv dist.next dist
|
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 ' منتشر شد. آدرسِ API در build: %s\n' "${API:-(همدامنه /api یا config.js)}"
|
||||||
printf ' کشِ آروان را برای index.html و config.js پاک کنید اگر تغییری دیده نشد.\n'
|
printf ' کشِ آروان را برای index.html و config.js پاک کنید اگر تغییری دیده نشد.\n'
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export function createHttpClient(options: HttpClientOptions) {
|
|||||||
|
|
||||||
const inFlight = new Map<string, Promise<unknown>>();
|
const inFlight = new Map<string, Promise<unknown>>();
|
||||||
const signedOutListeners = new Set<() => void>();
|
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> {
|
function authHeaders(): Record<string, string> {
|
||||||
const token = storage.get()?.token;
|
const token = storage.get()?.token;
|
||||||
@@ -175,27 +175,45 @@ export function createHttpClient(options: HttpClientOptions) {
|
|||||||
signedOutListeners.forEach((listener) => listener());
|
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();
|
const current = storage.get();
|
||||||
if (!current?.refreshToken) return null;
|
if (!current?.refreshToken) return 'invalid';
|
||||||
if (refreshing) return refreshing;
|
if (refreshing) return refreshing;
|
||||||
|
|
||||||
refreshing = (async () => {
|
refreshing = (async (): Promise<Tokens | 'invalid' | ApiError> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${baseUrl}/auth/refresh`, {
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${baseUrl}/auth/refresh`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ refreshToken: current.refreshToken }),
|
body: JSON.stringify({ refreshToken: current.refreshToken }),
|
||||||
signal: timeoutSignal(defaultTimeout),
|
signal: timeoutSignal(defaultTimeout),
|
||||||
credentials: 'omit',
|
credentials: 'omit',
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
} 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 data = (await response.json()) as Tokens;
|
||||||
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
|
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
|
||||||
storage.set(next);
|
storage.set(next);
|
||||||
return next;
|
return next;
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
} finally {
|
} finally {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
refreshing = null;
|
refreshing = null;
|
||||||
@@ -243,8 +261,9 @@ export function createHttpClient(options: HttpClientOptions) {
|
|||||||
let response = await send(method, path, body, opts);
|
let response = await send(method, path, body, opts);
|
||||||
if (response.status === 401 && hadToken) {
|
if (response.status === 401 && hadToken) {
|
||||||
const refreshed = await refreshTokens();
|
const refreshed = await refreshTokens();
|
||||||
if (refreshed) response = await send(method, path, body, opts);
|
if (refreshed === 'invalid') signOut();
|
||||||
else signOut();
|
else if (refreshed instanceof ApiError) throw refreshed; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه
|
||||||
|
else response = await send(method, path, body, opts);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,25 +22,38 @@ function saveTokens(data: T.AuthResponse) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** یک بخشِ مسیر؛ شناسهای که از آدرسِ صفحه یا سرور میآید نباید با «/» یا «..» مسیرِ دیگری از API را صدا بزند. */
|
||||||
|
const seg = (value: string | number) => encodeURIComponent(String(value));
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
auth: {
|
auth: {
|
||||||
isSignedIn: () => Boolean(http.storage.get()?.token),
|
isSignedIn: () => Boolean(http.storage.get()?.token),
|
||||||
|
|
||||||
/** مهمان: فقط با شناسهی دستگاه. refreshToken هم میگیرد، پس بعد از یک ساعت نمیافتد. */
|
/**
|
||||||
guest: (deviceId: string) =>
|
* گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمیگردد.
|
||||||
http.post<T.AuthResponse>('/auth/session', { type: 'guest', deviceId }).then(saveTokens),
|
* مهلتِ ۴۵ ثانیه، نه ۱۵ِ پیشفرض: بکاند تا ۱۰+۳۰ ثانیه منتظرِ کاوهنگار میماند
|
||||||
|
* (۲۰۲۶-۰۹-۲۷، پیامک رسید ولی جوابِ کاوهنگار دیر آمد). اگر این زودتر قطع کند،
|
||||||
/** گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمیگردد. */
|
* کاربر «ارتباط برقرار نشد» میبیند در حالی که کد در گوشیاش است.
|
||||||
|
*/
|
||||||
requestOtp: (phone: string) =>
|
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) =>
|
login: (phone: string, otp: string) =>
|
||||||
http.post<T.AuthResponse>('/auth/login', { phone, otp }).then(saveTokens),
|
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) و بعد پاک کردنِ حافظه. */
|
/** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */
|
||||||
async logout() {
|
async logout() {
|
||||||
const refreshToken = http.storage.get()?.refreshToken;
|
const refreshToken = http.storage.get()?.refreshToken;
|
||||||
@@ -56,7 +69,7 @@ export const api = {
|
|||||||
|
|
||||||
profile: {
|
profile: {
|
||||||
get: () => http.get<T.UserProfile>('/user/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),
|
http.patch<T.UserProfile>('/user/profile', patch),
|
||||||
setGrade: (gradeId: number) =>
|
setGrade: (gradeId: number) =>
|
||||||
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
|
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
|
||||||
@@ -65,31 +78,42 @@ export const api = {
|
|||||||
summary: () => http.get<T.UserSummary>('/user/summary'),
|
summary: () => http.get<T.UserSummary>('/user/summary'),
|
||||||
hearts: () => http.get<T.Hearts>('/user/hearts'),
|
hearts: () => http.get<T.Hearts>('/user/hearts'),
|
||||||
streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'),
|
streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'),
|
||||||
|
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
|
||||||
|
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
|
||||||
|
|
||||||
/** بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. */
|
/** بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. */
|
||||||
home: () => http.get<T.HomeResponse>('/home'),
|
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: {
|
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) =>
|
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()) =>
|
answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) =>
|
||||||
http.post<T.AnswerResult>(
|
http.post<T.AnswerResult>(
|
||||||
`/questions/${questionId}/answer`,
|
`/questions/${seg(questionId)}/answer`,
|
||||||
{ selectedOptionId: Number(selectedOptionId) },
|
{ selectedOptionId: Number(selectedOptionId) },
|
||||||
{ idempotencyKey, retry: true },
|
{ idempotencyKey, retry: true },
|
||||||
),
|
),
|
||||||
/** مجموعِ XP و سکهی کاربر اینجا بهروز میشود، نه سرِ هر جواب. */
|
/** مجموعِ XP و سکهی کاربر اینجا بهروز میشود، نه سرِ هر جواب. */
|
||||||
complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
|
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) =>
|
report: (questionId: string, reason: T.ReportReason, note?: string) =>
|
||||||
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
|
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
|
||||||
`/questions/${questionId}/report`,
|
`/questions/${seg(questionId)}/report`,
|
||||||
{ reason, ...(note ? { note } : {}) },
|
{ reason, ...(note ? { note } : {}) },
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -104,6 +128,9 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ai: {
|
ai: {
|
||||||
|
/** «امروز X سوالِ رایگان + Y سوالِ خریدهشده»، پیش از اولین سوال. */
|
||||||
|
quota: () => http.get<T.TutorQuota>('/ai/quota'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* پرسش با پاسخِ تکهتکه. `lessonId` پاسخ را به کتابِ همان درس مستند میکند؛
|
* پرسش با پاسخِ تکهتکه. `lessonId` پاسخ را به کتابِ همان درس مستند میکند؛
|
||||||
* `questionId` به دستیار میگوید دانشآموز کدام سوال را غلط زده.
|
* `questionId` به دستیار میگوید دانشآموز کدام سوال را غلط زده.
|
||||||
@@ -128,57 +155,59 @@ export const api = {
|
|||||||
return done;
|
return done;
|
||||||
},
|
},
|
||||||
feedback: (messageId: string, isHelpful: boolean, reason?: string) =>
|
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,
|
isHelpful,
|
||||||
...(reason ? { reason } : {}),
|
...(reason ? { reason } : {}),
|
||||||
}),
|
}),
|
||||||
history: (conversationId: string) =>
|
history: (conversationId: string) =>
|
||||||
http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>(
|
http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>(
|
||||||
`/ai/conversations/${conversationId}/messages`,
|
`/ai/conversations/${seg(conversationId)}/messages`,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
notifications: {
|
notifications: {
|
||||||
list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/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'),
|
markAllRead: () => http.patch('/notifications/read-all'),
|
||||||
},
|
},
|
||||||
|
|
||||||
subscription: {
|
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'),
|
status: () => http.get<T.SubscriptionStatus>('/subscription/status'),
|
||||||
/** سفارش میسازد؛ paymentUrl خودش سراغِ درگاه میرود. */
|
/** سفارش میسازد؛ paymentUrl خودش سراغِ درگاه میرود. */
|
||||||
purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }),
|
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) =>
|
redeem: (code: string, orderId: string) =>
|
||||||
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
|
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: {
|
support: {
|
||||||
createTicket: (subject: string, category: string) =>
|
createTicket: (subject: string, category: string) =>
|
||||||
http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }),
|
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) =>
|
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) =>
|
bugReport: (title: string, description: string) =>
|
||||||
http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }),
|
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: {
|
affiliate: {
|
||||||
me: () => http.get<T.AffiliateMe>('/affiliate/me'),
|
me: () => http.get<T.AffiliateMe>('/affiliate/me'),
|
||||||
/** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل میکند. */
|
/** کاربرِ تازه را به صاحبِ کدِ معرف وصل میکند. */
|
||||||
track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }),
|
track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
170
src/api/types.ts
@@ -14,7 +14,6 @@ export interface AuthResponse {
|
|||||||
token: string;
|
token: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
isNewUser?: boolean;
|
isNewUser?: boolean;
|
||||||
isGuest?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Grade {
|
export interface Grade {
|
||||||
@@ -28,13 +27,15 @@ export interface Grade {
|
|||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
/** شناسهی نمایشی «User482917»؛ همان که پشتیبانی در ادمین جستجو میکند. */
|
||||||
|
publicId: string;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
isGuest: boolean;
|
|
||||||
gradeId: number | null;
|
gradeId: number | null;
|
||||||
birthYear: number | null;
|
birthYear: number | null;
|
||||||
parentPhone: string;
|
/** تاریخِ تولدِ شمسی «1385-04-12»؛ null وقتی فقط سال داده شده یا هیچ. */
|
||||||
|
birthDate: string | null;
|
||||||
bio: string;
|
bio: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +68,8 @@ export interface HomeUnit {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
blurb: string;
|
blurb: string;
|
||||||
|
/** این کاربر برای بازیِ این فصل اشتراک لازم دارد (فقط فصلِ اولِ هر پایه رایگان است). */
|
||||||
|
requiresSubscription: boolean;
|
||||||
nodes: HomeNode[];
|
nodes: HomeNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +108,19 @@ export interface LessonIntro {
|
|||||||
kind: 'quiz' | 'video';
|
kind: 'quiz' | 'video';
|
||||||
example: string;
|
example: string;
|
||||||
steps: string[];
|
steps: string[];
|
||||||
|
/** شکلهای ثابتِ درس (یک بار ساخته و تأییدشده؛ هیچ تولیدِ زندهای نیست). */
|
||||||
|
figures: LessonFigure[];
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
durationMinutes: number | null;
|
durationMinutes: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LessonFigure {
|
||||||
|
where: 'intro' | 'example' | 'step';
|
||||||
|
/** فقط برای where='step'، از صفر. */
|
||||||
|
index?: number;
|
||||||
|
diagram: DiagramSpec;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QuestionOption {
|
export interface QuestionOption {
|
||||||
id: string;
|
id: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -121,6 +133,31 @@ export interface Question {
|
|||||||
options: QuestionOption[];
|
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 {
|
export interface AnswerResult {
|
||||||
correct: boolean;
|
correct: boolean;
|
||||||
correctOptionId: string;
|
correctOptionId: string;
|
||||||
@@ -182,16 +219,26 @@ export interface AskDone {
|
|||||||
conversationId: string;
|
conversationId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
suggestedFollowUps: string[];
|
suggestedFollowUps: string[];
|
||||||
|
/** سوالهای رایگانِ باقیِ امروز. */
|
||||||
remainingQuota: number;
|
remainingQuota: number;
|
||||||
|
/** اعتبارِ بستههای سوال؛ منقضی نمیشود. */
|
||||||
|
credits?: number;
|
||||||
usedChunks: UsedChunk[];
|
usedChunks: UsedChunk[];
|
||||||
|
diagram?: Diagram | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConversationMessage {
|
export interface ConversationMessage {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
role: 'user' | 'assistant' | string;
|
role: 'user' | 'assistant' | string;
|
||||||
text: string;
|
text: string;
|
||||||
|
diagram?: Diagram | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── شکلِ پاسخِ دستیار: شِمایش در src/diagram/types.ts ─────────────────── */
|
||||||
|
|
||||||
|
import type { DiagramSpec } from '@/diagram/types';
|
||||||
|
export type Diagram = DiagramSpec;
|
||||||
|
|
||||||
export interface NotificationItem {
|
export interface NotificationItem {
|
||||||
id: number;
|
id: number;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -212,17 +259,60 @@ export interface Plan {
|
|||||||
durationDays: number;
|
durationDays: number;
|
||||||
features: string[];
|
features: string[];
|
||||||
badge: 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 {
|
export interface SubscriptionStatus {
|
||||||
|
/** اشتراکِ زمانیِ فعال (یک/سه/ششماهه)، نه بسته. */
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
/** نامِ پلنِ فعال؛ null بیاشتراک. */
|
||||||
|
planTitle: string | null;
|
||||||
|
/** طولِ دورهی پلنِ فعال به روز؛ مخرجِ نوارِ «چقدر مانده». */
|
||||||
|
planDurationDays: number | null;
|
||||||
|
/** سقفِ سوالِ روزانهی همین کاربر از دستیار. */
|
||||||
|
aiDailyQuota: number;
|
||||||
planId: string | null;
|
planId: string | null;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
|
/** سوالهای خریدهشدهی باقیمانده. */
|
||||||
|
questionCredits: number;
|
||||||
|
/** پایانِ قلبِ نامحدودِ هدیهی بستهها؛ null یعنی ندارد. */
|
||||||
|
unlimitedHeartsUntil: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /ai/quota */
|
||||||
|
export interface TutorQuota {
|
||||||
|
freeDaily: number;
|
||||||
|
remainingQuota: number;
|
||||||
|
credits: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseResponse {
|
export interface PurchaseResponse {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
paymentUrl: string;
|
/** زرینپال: آدرسِ درگاه. بازار: null. */
|
||||||
|
paymentUrl: string | null;
|
||||||
|
/** فقط بازار: SKUی که اپِ اندروید میخرد. */
|
||||||
|
bazaarSku?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BazaarVerifyResponse {
|
||||||
|
orderId: string;
|
||||||
|
status: string;
|
||||||
|
valid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RedeemResponse {
|
export interface RedeemResponse {
|
||||||
@@ -239,6 +329,16 @@ export interface OrderStatus {
|
|||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** یک ردیف از فهرستِ تیکتهای کاربر (GET /support/tickets). */
|
||||||
|
export interface TicketSummary {
|
||||||
|
ticketId: string;
|
||||||
|
subject: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
supportReplies: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Ticket {
|
export interface Ticket {
|
||||||
ticketId: string;
|
ticketId: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -246,24 +346,58 @@ export interface Ticket {
|
|||||||
messages: { id: string; sender: string; text: string }[];
|
messages: { id: string; sender: string; text: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FamilyLink {
|
/** یک سفارش به شکلِ فاکتور (GET /payment/invoices). مبلغها توماناند. */
|
||||||
familyLinkId: string;
|
export interface Invoice {
|
||||||
status: string;
|
orderId: string;
|
||||||
childName: 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 {
|
/** یک روز از گزارشِ هفتگی. `minutes` تخمینِ سرور است (از فاصلهی جوابها)، نه زمانِ باز بودنِ اپ. */
|
||||||
weekStart: string;
|
export interface ReportDay {
|
||||||
xpEarned: number;
|
date: string;
|
||||||
lessonsCompleted: number;
|
minutes: number;
|
||||||
islandsCompleted: number;
|
questions: number;
|
||||||
currentStreak: number;
|
correct: number;
|
||||||
|
xp: number;
|
||||||
|
lessons: number;
|
||||||
|
islands: number;
|
||||||
|
aiQuestions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FamilySettings {
|
export interface ReportTotals {
|
||||||
dailyLimitMinutes: number;
|
minutes: number;
|
||||||
quietHoursStart: string;
|
activeDays: number;
|
||||||
quietHoursEnd: string;
|
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 {
|
export interface AffiliateMe {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
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 { api, ApiError, http, type Grade, type UserProfile } from '@/api';
|
||||||
import { AppShell } from '@/components/AppShell';
|
import { AppShell } from '@/components/AppShell';
|
||||||
import { ToastProvider } from '@/components/Toast';
|
import { ToastProvider } from '@/components/Toast';
|
||||||
@@ -34,8 +34,9 @@ export function App() {
|
|||||||
/** پروفایل و پایهها را میگیرد و تصمیم میگیرد کاربر کجا برود. */
|
/** پروفایل و پایهها را میگیرد و تصمیم میگیرد کاربر کجا برود. */
|
||||||
const enter = useCallback(async () => {
|
const enter = useCallback(async () => {
|
||||||
const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]);
|
const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]);
|
||||||
await trackReferral(profile);
|
await trackReferral();
|
||||||
setFlow(profile.name && profile.gradeId ? { kind: 'main', profile, grades } : { kind: 'setup', profile, grades });
|
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades });
|
||||||
|
else setFlow({ kind: 'setup', profile, grades });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const boot = useCallback(async () => {
|
const boot = useCallback(async () => {
|
||||||
@@ -82,6 +83,8 @@ export function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// reducedMotion="user": اگر «کاهشِ حرکت» در گوشی روشن باشد، motion جابهجاییها را حذف میکند.
|
||||||
|
<MotionConfig reducedMotion="user">
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
@@ -121,23 +124,20 @@ export function App() {
|
|||||||
grades={flow.grades}
|
grades={flow.grades}
|
||||||
payment={launchPayment}
|
payment={launchPayment}
|
||||||
onSignedOut={() => setFlow({ kind: 'login' })}
|
onSignedOut={() => setFlow({ kind: 'login' })}
|
||||||
onRegistered={trackReferral}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
</MotionConfig>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** اگر کاربر با لینکِ دعوت آمده بود و حالا وارد شده، معرفی را ثبت کن. */
|
||||||
* اگر کاربر با لینکِ دعوت آمده بود و حالا با شماره وارد شده، معرفی را ثبت کن.
|
async function trackReferral() {
|
||||||
* مهمان را سرور نمیپذیرد، پس کد تا ثبتِ شماره نگه داشته میشود.
|
|
||||||
*/
|
|
||||||
async function trackReferral(profile: UserProfile) {
|
|
||||||
const code = store.get(KEYS.referralCode);
|
const code = store.get(KEYS.referralCode);
|
||||||
if (!code || profile.isGuest) return;
|
if (!code) return;
|
||||||
try {
|
try {
|
||||||
await api.affiliate.track(code);
|
await api.affiliate.track(code);
|
||||||
store.remove(KEYS.referralCode);
|
store.remove(KEYS.referralCode);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { lazy, Suspense, useEffect, useRef, useState, type ComponentType } from 'react';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||||
import { api, type Grade, type UserProfile } from '@/api';
|
import { api, type Grade, type UserProfile } from '@/api';
|
||||||
@@ -7,64 +7,96 @@ import { NavigatorProvider, useNav } from '@/nav/Navigator';
|
|||||||
import { isTab, type Route } from '@/nav/routes';
|
import { isTab, type Route } from '@/nav/routes';
|
||||||
import { BottomNav } from '@/components/BottomNav';
|
import { BottomNav } from '@/components/BottomNav';
|
||||||
import { Sheet } from '@/components/Sheet';
|
import { Sheet } from '@/components/Sheet';
|
||||||
import { useToast } from '@/components/Toast';
|
import { LoadingState, PrimaryButton } from '@/components/ui';
|
||||||
import { PrimaryButton } from '@/components/ui';
|
|
||||||
import { AskScreen } from '@/screens/AskScreen';
|
|
||||||
import { CharactersScreen } from '@/screens/CharactersScreen';
|
|
||||||
import { FamilyScreen } from '@/screens/FamilyScreen';
|
|
||||||
import { HomeScreen } from '@/screens/HomeScreen';
|
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';
|
import type { PaymentReturn } from './launchParams';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* خانه همراهِ خودِ اپ بار میشود؛ بقیهی صفحهها هر کدام فایلِ جدا دارند تا بارِ اول
|
||||||
|
* سبک باشد (قبلاً کلِ اپ یک فایلِ ۵۰۵ کیلوبایتی بود، و دستیار با ماژولِ شکلها
|
||||||
|
* بزرگترین تکهاش). بعد از بالا آمدنِ خانه همه در پسزمینه گرفته میشوند
|
||||||
|
* (preloadScreens)، پس رفتن به صفحهی بعدی منتظرِ شبکه نمیماند.
|
||||||
|
*/
|
||||||
|
const loaders: (() => Promise<unknown>)[] = [];
|
||||||
|
function screen<M extends Record<K, ComponentType<any>>, K extends keyof M>(load: () => Promise<M>, name: K) {
|
||||||
|
loaders.push(load);
|
||||||
|
return lazy(async () => ({ default: (await load())[name] }));
|
||||||
|
}
|
||||||
|
const AskScreen = screen(() => import('@/screens/AskScreen'), 'AskScreen');
|
||||||
|
const CharactersScreen = screen(() => import('@/screens/CharactersScreen'), 'CharactersScreen');
|
||||||
|
const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScreen');
|
||||||
|
const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen');
|
||||||
|
const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen');
|
||||||
|
const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen');
|
||||||
|
const LessonsScreen = screen(() => import('@/screens/LessonsScreen'), 'LessonsScreen');
|
||||||
|
const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen');
|
||||||
|
const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen');
|
||||||
|
const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen');
|
||||||
|
const QuizScreen = screen(() => import('@/screens/QuizScreen'), 'QuizScreen');
|
||||||
|
const StreakScreen = screen(() => import('@/screens/StreakScreen'), 'StreakScreen');
|
||||||
|
const SubscriptionScreen = screen(() => import('@/screens/SubscriptionScreen'), 'SubscriptionScreen');
|
||||||
|
const SupportScreen = screen(() => import('@/screens/SupportScreen'), 'SupportScreen');
|
||||||
|
const UnitScreen = screen(() => import('@/screens/UnitScreen'), 'UnitScreen');
|
||||||
|
|
||||||
|
let preloaded = false;
|
||||||
|
function preloadScreens() {
|
||||||
|
if (preloaded) return;
|
||||||
|
preloaded = true;
|
||||||
|
const run = () => loaders.forEach((load) => void load().catch(() => {}));
|
||||||
|
if ('requestIdleCallback' in window) window.requestIdleCallback(run, { timeout: 2500 });
|
||||||
|
else setTimeout(run, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
profile: UserProfile;
|
profile: UserProfile;
|
||||||
grades: Grade[];
|
grades: Grade[];
|
||||||
payment: PaymentReturn | null;
|
payment: PaymentReturn | null;
|
||||||
onSignedOut: () => void;
|
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 (
|
return (
|
||||||
<AppDataProvider initialProfile={profile} grades={grades}>
|
<AppDataProvider initialProfile={profile} grades={grades}>
|
||||||
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
|
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
|
||||||
<Screens onSignedOut={onSignedOut} onRegistered={onRegistered} />
|
<Screens onSignedOut={onSignedOut} />
|
||||||
{payment && <PaymentResult payment={payment} />}
|
{payment && <PaymentResult payment={payment} />}
|
||||||
</NavigatorProvider>
|
</NavigatorProvider>
|
||||||
</AppDataProvider>
|
</AppDataProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onRegistered'>) {
|
function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
const { route } = nav;
|
const { route } = nav;
|
||||||
const showNav = isTab(route.name) && nav.tab === route.name;
|
const showNav = isTab(route.name) && nav.tab === route.name;
|
||||||
|
// صفحهی اولِ بعد از ورود بیانیمیشنِ ورود میآید، ولی این را روی خودِ motion.div میگذاریم نه
|
||||||
|
// `<AnimatePresence initial={false}>`: آن یکی به همهی فرزندهای motionِ صفحهی اول هم سرایت میکند و
|
||||||
|
// انیمیشنهای تکرارشوندهشان (ماسکوتِ بالاپایین، حلقهی جزیره، دکمهی «شروع») تا اولین تعویضِ تب شروع نمیشدند.
|
||||||
|
const firstRender = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
firstRender.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex-1 overflow-hidden">
|
<div className="relative flex-1 overflow-hidden bg-page">
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
<AnimatePresence mode="popLayout">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={JSON.stringify(route)}
|
key={JSON.stringify(route)}
|
||||||
className="absolute inset-0 flex flex-col"
|
className="absolute inset-0 flex flex-col bg-page"
|
||||||
initial={{ opacity: 0, x: -18 }}
|
initial={firstRender.current ? false : { opacity: 0, x: -14 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: 18 }}
|
// صفحهی قبلی نمیپرد و محو نمیشود، زیرِ صفحهی تازه میماند تا کامل بیاید. با محوشدنِ همزمانِ
|
||||||
transition={{ duration: 0.18 }}
|
// هر دو، پسزمینهی تیرهی body از وسط دیده میشد و تعویضِ تب «گلیچ» میزد (از روی کد؛ با
|
||||||
|
// فیلمِ دستگاهِ واقعی سنجیده نشده).
|
||||||
|
exit={{ opacity: 1, x: 0, transition: { duration: 0.16 } }}
|
||||||
|
transition={{ duration: 0.16, ease: 'easeOut' }}
|
||||||
>
|
>
|
||||||
<RouteView route={route} onSignedOut={onSignedOut} onRegistered={onRegistered} />
|
<Suspense fallback={<LoadingState />}>
|
||||||
|
<RouteView route={route} onSignedOut={onSignedOut} />
|
||||||
|
</Suspense>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,12 +105,12 @@ 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) {
|
switch (route.name) {
|
||||||
case 'home': return <HomeScreen />;
|
case 'home': return <HomeScreen />;
|
||||||
case 'map': return <MapScreen />;
|
case 'lessons': return <LessonsScreen />;
|
||||||
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
|
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
|
||||||
case 'subscription': return <SubscriptionScreen />;
|
case 'subscription': return <SubscriptionScreen key={route.view ?? ''} initialView={route.view} />;
|
||||||
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
|
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
|
||||||
case 'unit': return <UnitScreen unitId={route.unitId} />;
|
case 'unit': return <UnitScreen unitId={route.unitId} />;
|
||||||
case 'lesson': return <LessonScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
|
case 'lesson': return <LessonScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
|
||||||
@@ -88,58 +120,52 @@ function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick
|
|||||||
case 'characters': return <CharactersScreen />;
|
case 'characters': return <CharactersScreen />;
|
||||||
case 'streak': return <StreakScreen />;
|
case 'streak': return <StreakScreen />;
|
||||||
case 'support': return <SupportScreen />;
|
case 'support': return <SupportScreen />;
|
||||||
case 'family': return <FamilyScreen />;
|
case 'report': return <ReportScreen />;
|
||||||
|
case 'invoices': return <InvoicesScreen />;
|
||||||
case 'invite': return <InviteScreen />;
|
case 'invite': return <InviteScreen />;
|
||||||
case 'register': return <RegisterView onRegistered={onRegistered} />;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** مهمانی که شماره ثبت میکند؛ همان کاربر میماند و فقط پروفایل تازه میشود. */
|
/**
|
||||||
function RegisterView({ onRegistered }: Pick<Props, 'onRegistered'>) {
|
* نتیجهی برگشت از درگاه. فقط پاسخِ سرور تعیین میکند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر
|
||||||
const nav = useNav();
|
* لینکِ ساختگی میتواند «paid» بگذارد.
|
||||||
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();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** نتیجهی برگشت از درگاه؛ وضعیتِ سفارش از خودِ سرور هم دوباره خوانده میشود. */
|
|
||||||
function PaymentResult({ payment }: { payment: PaymentReturn }) {
|
function PaymentResult({ payment }: { payment: PaymentReturn }) {
|
||||||
const { refreshSummary } = useAppData();
|
const { refreshSummary } = useAppData();
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [paid, setPaid] = useState(payment.status === 'paid');
|
const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.subscription
|
api.subscription
|
||||||
.order(payment.orderId)
|
.order(payment.orderId)
|
||||||
.then((order) => setPaid(order.status === 'paid'))
|
.then((order) => setState(order.status === 'paid' ? 'paid' : 'failed'))
|
||||||
.catch(() => {});
|
.catch((err: unknown) => {
|
||||||
|
console.warn('payment result lookup failed', err);
|
||||||
|
setState('unknown');
|
||||||
|
});
|
||||||
void refreshSummary();
|
void refreshSummary();
|
||||||
}, [payment.orderId, refreshSummary]);
|
}, [payment.orderId, refreshSummary]);
|
||||||
|
|
||||||
|
if (state === 'checking') {
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={() => setOpen(false)}>
|
||||||
|
<LoadingState text="دارم پرداخت رو بررسی میکنم…" />
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const paid = state === 'paid';
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onClose={() => setOpen(false)}>
|
<Sheet open={open} onClose={() => setOpen(false)}>
|
||||||
<div className="flex flex-col items-center gap-3 pb-2 text-center">
|
<div className="flex flex-col items-center gap-3 pb-2 text-center">
|
||||||
{paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />}
|
{paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />}
|
||||||
<h2 className="text-lg font-black text-ink">{paid ? 'پرداخت موفق بود! 🎉' : 'پرداخت انجام نشد'}</h2>
|
<h2 className="text-lg font-black text-ink">
|
||||||
|
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیتِ پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
|
||||||
|
</h2>
|
||||||
<p className="text-sm leading-7 text-muted">
|
<p className="text-sm leading-7 text-muted">
|
||||||
{paid
|
{paid
|
||||||
? 'اشتراکت فعال شد؛ از الان قلبهات نامحدوده.'
|
? 'اشتراکت فعال شد؛ از الان قلبهات نامحدوده.'
|
||||||
|
: state === 'unknown'
|
||||||
|
? 'چند دقیقهی دیگه از «پرداختها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال میشه یا پول خودکار برمیگرده.'
|
||||||
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمیگرده یا اشتراکت فعال میشه.'}
|
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمیگرده یا اشتراکت فعال میشه.'}
|
||||||
</p>
|
</p>
|
||||||
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
|
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ function readLaunchParams(): PaymentReturn | null {
|
|||||||
if (ref || orderId) {
|
if (ref || orderId) {
|
||||||
window.history.replaceState(null, '', window.location.pathname);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** یکبار، هنگامِ بار شدنِ اپ. */
|
/** یکبار، هنگامِ بار شدنِ اپ. */
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { Minus, Plus } from 'lucide-react';
|
|
||||||
import { currentJalaliYear, toFa } from '@/lib/format';
|
|
||||||
import { sounds } from '@/lib/sound';
|
|
||||||
|
|
||||||
const MIN_AGE = 7;
|
|
||||||
const MAX_AGE = 19;
|
|
||||||
const DEFAULT_AGE = 13;
|
|
||||||
|
|
||||||
/** سرور سالِ تولدِ شمسی میخواهد، نه سن. */
|
|
||||||
export const birthYearFromAge = (age: number) => currentJalaliYear() - age;
|
|
||||||
|
|
||||||
export function ageFromBirthYear(year: number | null | undefined): number | null {
|
|
||||||
if (!year) return null;
|
|
||||||
const age = currentJalaliYear() - year;
|
|
||||||
return age >= MIN_AGE && age <= MAX_AGE ? age : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* انتخابِ سن با دو دکمهی − و +. جای «چرخِ» قبلی نشست که عددِ وسطش با مقدارِ
|
|
||||||
* واقعی نمیخواند و کلیک روی عددهایش کار نمیکرد.
|
|
||||||
*/
|
|
||||||
export function AgePicker({ value, onChange }: { value: number | null; onChange: (age: number) => void }) {
|
|
||||||
const current = value ?? DEFAULT_AGE;
|
|
||||||
const step = (delta: number) => {
|
|
||||||
const next = Math.max(MIN_AGE, Math.min(MAX_AGE, current + delta));
|
|
||||||
if (next !== current || value === null) {
|
|
||||||
sounds.tick();
|
|
||||||
onChange(next);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between rounded-2xl border-2 border-line bg-white p-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="کمتر"
|
|
||||||
onClick={() => step(-1)}
|
|
||||||
disabled={value !== null && current <= MIN_AGE}
|
|
||||||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
|
|
||||||
>
|
|
||||||
<Minus size={20} strokeWidth={3} />
|
|
||||||
</button>
|
|
||||||
<div className="text-center" aria-live="polite">
|
|
||||||
{value === null ? (
|
|
||||||
<button type="button" onClick={() => onChange(DEFAULT_AGE)} className="text-sm font-bold text-muted">
|
|
||||||
بزن تا انتخاب کنی
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="text-3xl font-black text-coral">{toFa(value)}</span>
|
|
||||||
<span className="mr-1 text-sm font-bold text-muted">سال</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="بیشتر"
|
|
||||||
onClick={() => step(1)}
|
|
||||||
disabled={value !== null && current >= MAX_AGE}
|
|
||||||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
|
|
||||||
>
|
|
||||||
<Plus size={20} strokeWidth={3} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
119
src/components/BirthDatePicker.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { toEnDigits, toFa } from '@/lib/format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تاریخِ تولدِ شمسی با سه فهرستِ روز/ماه/سال (از ۱۴۰۵-۰۷ به جای «چند سالته؟»).
|
||||||
|
* <select> عمداً بومی است: روی گوشی همان چرخِ آشنای خودِ گوشی باز میشود و تقویمِ
|
||||||
|
* ماهانهای که برای تاریخِ سی سال پیش باید دهها بار ورق بخورد لازم نیست.
|
||||||
|
*
|
||||||
|
* سرور تاریخ را شمسی میگیرد («1385-04-12») و خودش به میلادی تبدیل میکند
|
||||||
|
* (accounts/jalali.py)؛ اینجا هیچ تبدیلی انجام نمیشود، فقط تعدادِ روزهای ماه.
|
||||||
|
*/
|
||||||
|
export const JALALI_MONTHS = [
|
||||||
|
'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور',
|
||||||
|
'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند',
|
||||||
|
];
|
||||||
|
/** همان بازهی سرور (accounts/serializers.py: MIN_AGE / MAX_AGE). */
|
||||||
|
const MIN_AGE = 5;
|
||||||
|
const MAX_AGE = 99;
|
||||||
|
/** از این سن به بعد فرمِ آشنایی بهجای «کدوم پایهای؟» میپرسد «ریاضیِ کدوم پایه؟». */
|
||||||
|
export const ADULT_AGE = 19;
|
||||||
|
|
||||||
|
type Parts = { y: number; m: number; d: number };
|
||||||
|
|
||||||
|
function jalaliParts(date: Date): Parts {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US-u-ca-persian', { year: 'numeric', month: 'numeric', day: 'numeric' }).formatToParts(date);
|
||||||
|
const get = (type: string) => parseInt(toEnDigits(parts.find((p) => p.type === type)?.value ?? '0'), 10);
|
||||||
|
return { y: get('year'), m: get('month'), d: get('day') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const todayJalali = () => jalaliParts(new Date());
|
||||||
|
|
||||||
|
/** کبیسه یعنی سیامِ اسفند وجود دارد؛ آن روز همیشه یکی از ۱۹ تا ۲۱ مارسِ سالِ بعدِ میلادی است. */
|
||||||
|
function isLeap(y: number): boolean {
|
||||||
|
return [19, 20, 21].some((day) => {
|
||||||
|
const p = jalaliParts(new Date(y + 622, 2, day, 12));
|
||||||
|
return p.y === y && p.m === 12 && p.d === 30;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthLength(y: number, m: number): number {
|
||||||
|
if (m <= 6) return 31;
|
||||||
|
if (m <= 11) return 30;
|
||||||
|
return isLeap(y) ? 30 : 29;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(value: string | null | undefined): Parts | null {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
|
||||||
|
return match ? { y: +match[1], m: +match[2], d: +match[3] } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
/** «1385-04-12» → «۱۲ تیر ۱۳۸۵». */
|
||||||
|
export function formatBirthDate(value: string | null): string {
|
||||||
|
const p = parse(value);
|
||||||
|
return p ? `${toFa(p.d)} ${JALALI_MONTHS[p.m - 1]} ${toFa(p.y)}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** سنِ کامل از تاریخِ تولد؛ برای کاربرِ قدیمی که فقط سال داده، تقریبی از سال. */
|
||||||
|
export function ageOf(birthDate: string | null, birthYear: number | null): number | null {
|
||||||
|
const today = todayJalali();
|
||||||
|
const born = parse(birthDate);
|
||||||
|
if (born) return today.y - born.y - (today.m < born.m || (today.m === born.m && today.d < born.d) ? 1 : 0);
|
||||||
|
return birthYear ? today.y - birthYear : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BirthDatePicker({
|
||||||
|
value,
|
||||||
|
fallbackYear = null,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string | null;
|
||||||
|
/** سالِ تولدِ کاربرِ قدیمی (بیتاریخ) تا فهرستِ سال خالی باز نشود. */
|
||||||
|
fallbackYear?: number | null;
|
||||||
|
/** فقط وقتی هر سه انتخاب شدهاند تاریخ میدهد؛ تا آن موقع null. */
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const initial = parse(value);
|
||||||
|
const [y, setY] = useState<number | ''>(initial?.y ?? fallbackYear ?? '');
|
||||||
|
const [m, setM] = useState<number | ''>(initial?.m ?? '');
|
||||||
|
const [d, setD] = useState<number | ''>(initial?.d ?? '');
|
||||||
|
// والد هر بار تابعِ تازه میسازد؛ با ref، تغییرِ تابع خودش onChange را دوباره صدا نمیزند.
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
|
||||||
|
const thisYear = todayJalali().y;
|
||||||
|
const years = Array.from({ length: MAX_AGE - MIN_AGE + 1 }, (_, i) => thisYear - MIN_AGE - i);
|
||||||
|
const days = monthLength(typeof y === 'number' ? y : thisYear, typeof m === 'number' ? m : 1);
|
||||||
|
|
||||||
|
// سیویکِ مهر یا سیامِ اسفندِ غیرکبیسه با عوض شدنِ ماه/سال معتبر نمیماند.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof d === 'number' && d > days) setD(days);
|
||||||
|
}, [d, days]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const complete = y !== '' && m !== '' && d !== '' && d <= days;
|
||||||
|
onChangeRef.current(complete ? `${y}-${pad(m)}-${pad(d)}` : null);
|
||||||
|
}, [y, m, d, days]);
|
||||||
|
|
||||||
|
const select = 'w-full appearance-none rounded-2xl border-2 bg-white px-3 py-3.5 text-center text-sm font-bold outline-none focus:border-coral';
|
||||||
|
const tone = (v: number | '') => (v === '' ? 'border-line text-muted' : 'border-coral text-ink');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[1fr_1.5fr_1.3fr] gap-2">
|
||||||
|
<select aria-label="روزِ تولد" value={d} onChange={(e) => setD(e.target.value ? +e.target.value : '')} className={`${select} ${tone(d)}`}>
|
||||||
|
<option value="">روز</option>
|
||||||
|
{Array.from({ length: days }, (_, i) => i + 1).map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
|
||||||
|
</select>
|
||||||
|
<select aria-label="ماهِ تولد" value={m} onChange={(e) => setM(e.target.value ? +e.target.value : '')} className={`${select} ${tone(m)}`}>
|
||||||
|
<option value="">ماه</option>
|
||||||
|
{JALALI_MONTHS.map((name, i) => <option key={name} value={i + 1}>{name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select aria-label="سالِ تولد" value={y} onChange={(e) => setY(e.target.value ? +e.target.value : '')} className={`${select} ${tone(y)}`}>
|
||||||
|
<option value="">سال</option>
|
||||||
|
{years.map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Brain, Crown, Home, Map, UserRound, type LucideIcon } from 'lucide-react';
|
import { Brain, ShoppingBag, Home, BookOpen, UserRound, type LucideIcon } from 'lucide-react';
|
||||||
import type { TabName } from '@/nav/routes';
|
import type { TabName } from '@/nav/routes';
|
||||||
|
|
||||||
const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
|
const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
|
||||||
{ id: 'home', icon: Home, label: 'خانه' },
|
{ id: 'home', icon: Home, label: 'خانه' },
|
||||||
{ id: 'map', icon: Map, label: 'نقشه' },
|
{ id: 'lessons', icon: BookOpen, label: 'درسها' },
|
||||||
{ id: 'ask', icon: Brain, label: 'دستیار' },
|
{ id: 'ask', icon: Brain, label: 'دستیار' },
|
||||||
{ id: 'subscription', icon: Crown, label: 'اشتراک' },
|
{ id: 'subscription', icon: ShoppingBag, label: 'بستهها' },
|
||||||
{ id: 'profile', icon: UserRound, label: 'من' },
|
{ id: 'profile', icon: UserRound, label: 'من' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -20,12 +20,12 @@ export function BottomNav({ active, onChange }: { active: TabName | null; onChan
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(id)}
|
onClick={() => onChange(id)}
|
||||||
aria-current={on ? 'page' : undefined}
|
aria-current={on ? 'page' : undefined}
|
||||||
className={`flex flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-colors ${on ? 'text-coral' : 'text-muted'}`}
|
className={`flex min-w-14 flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-[color,transform] active:scale-90 ${on ? 'text-coral' : 'text-muted'}`}
|
||||||
>
|
>
|
||||||
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
|
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
|
||||||
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
|
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-bold">{label}</span>
|
<span className="text-[11px] font-bold">{label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
13
src/components/BrandFooter.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { toFa } from '@/lib/format';
|
||||||
|
import { BrandMark } from '@/components/BrandMark';
|
||||||
|
|
||||||
|
/** امضای تدریسینو پایینِ صفحهها: نشان، نام و حقِ نشر، کوچک و کمرنگ. */
|
||||||
|
export function BrandFooter() {
|
||||||
|
return (
|
||||||
|
<footer className="flex flex-col items-center gap-1 pb-4 pt-3 text-center">
|
||||||
|
<BrandMark size={24} tone="dark" />
|
||||||
|
<span className="text-[10px] text-muted">ریاضی، قدمبهقدم، از روی کتابِ درسی</span>
|
||||||
|
<span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو · همهی حقوق محفوظ است</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/BrandMark.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* نشانِ تدریسینو: یک کاشیِ گرد با «π» و جرقهی کوچکِ چشمکزن، کنارِ نامِ تدریسینو.
|
||||||
|
* خودِ SVG است (بیفایلِ تصویر)، پس در هر اندازه تیز است و چیزی دانلود نمیشود.
|
||||||
|
*/
|
||||||
|
export function BrandMark({ size = 28, tone = 'light', withName = true }: { size?: number; tone?: 'light' | 'dark'; withName?: boolean }) {
|
||||||
|
const ink = tone === 'light' ? '#fff' : '#3C3F8F';
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5" aria-label="تدریسینو">
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bm-g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stopColor="#FF9A5C" />
|
||||||
|
<stop offset="1" stopColor="#ED6956" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="1" y="1" width="30" height="30" rx="9" fill="url(#bm-g)" stroke={tone === 'light' ? 'rgba(255,255,255,.55)' : 'none'} />
|
||||||
|
<path d="M9 11.5h14M12.5 11.5v10M19 11.5v8.2c0 1.2.7 1.8 1.8 1.8" stroke="#fff" strokeWidth="2.6" strokeLinecap="round" fill="none" />
|
||||||
|
<path className="spark" d="M25 4.5l.9 1.9 1.9.9-1.9.9-.9 1.9-.9-1.9-1.9-.9 1.9-.9z" fill="#FFD23F" />
|
||||||
|
</svg>
|
||||||
|
{withName && <span className="font-black tracking-tight" style={{ color: ink, fontSize: size * 0.62 }}>تدریسینو</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/components/IslandPath.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
110
src/components/IslandSheet.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { BookOpen, CheckCircle2, ChevronDown, Circle, Lock, Play, RotateCw } from 'lucide-react';
|
||||||
|
import { api, type HomeNode } from '@/api';
|
||||||
|
import { useNav } from '@/nav/Navigator';
|
||||||
|
import { useAsync } from '@/lib/useAsync';
|
||||||
|
import { Sheet } from '@/components/Sheet';
|
||||||
|
import { ErrorState, PrimaryButton } from '@/components/ui';
|
||||||
|
import { toFa } from '@/lib/format';
|
||||||
|
import { MathText } from '@/lib/MathText';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* برگهی جزیره. دو دکمه: «اول درسها رو بخون» (رنگی و بالا، چون تستکنندهها
|
||||||
|
* دکمهی کمرنگِ قبلی را پیدا نمیکردند) و «شروعِ آزمونِ جزیره». فهرستِ درسها زیرِ دکمهی اول باز میشود.
|
||||||
|
*/
|
||||||
|
export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
|
||||||
|
const nav = useNav();
|
||||||
|
const [showLessons, setShowLessons] = useState(false);
|
||||||
|
const lessons = useAsync(
|
||||||
|
() => (node && showLessons ? api.nodeLessons(node.id) : Promise.resolve(null)),
|
||||||
|
[node?.id, showLessons],
|
||||||
|
);
|
||||||
|
const locked = node?.status === 'locked';
|
||||||
|
const done = node?.status === 'completed';
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setShowLessons(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const start = () => {
|
||||||
|
if (!node) return;
|
||||||
|
close();
|
||||||
|
nav.push({ name: 'quiz', nodeId: node.id });
|
||||||
|
};
|
||||||
|
const openLesson = (lessonId: number) => {
|
||||||
|
if (!node) return;
|
||||||
|
close();
|
||||||
|
nav.push({ name: 'lesson', lessonId, nodeId: node.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
// فقط درسهایی که سوال دارند در lessonIds هستند.
|
||||||
|
const playable = new Set(node?.lessonIds ?? []);
|
||||||
|
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={node !== null} onClose={close} title={node ? <MathText text={node.title} /> : undefined}>
|
||||||
|
{locked ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
||||||
|
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
|
||||||
|
<Lock size={24} className="text-muted" />
|
||||||
|
</span>
|
||||||
|
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیرهی قبلی رو تموم کن تا باز بشه.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-4 text-sm leading-7 text-body">
|
||||||
|
{done
|
||||||
|
? 'این جزیره رو تموم کردی! میتونی دوباره تمرینش کنی (امتیازِ تازه نداره).'
|
||||||
|
: 'اگه مطمئن نیستی، اول درسها رو بخون؛ بعد آزمونِ کوتاهِ جزیره رو بده. هر جوابی بدی، آخرش جزیرهی بعدی باز میشه.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<PrimaryButton color={color} onClick={start}>
|
||||||
|
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروعِ آزمونِ جزیره</>}
|
||||||
|
</PrimaryButton>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowLessons((s) => !s)}
|
||||||
|
className="mt-3 mb-1 flex w-full items-center justify-center gap-2 rounded-2xl border-2 py-3 text-sm font-black transition-colors active:translate-y-[2px]"
|
||||||
|
style={{ borderColor: '#39FF14', color: '#15803d', backgroundColor: showLessons ? '#39FF1422' : 'white' }}
|
||||||
|
aria-expanded={showLessons}
|
||||||
|
>
|
||||||
|
<BookOpen size={18} /> {done ? 'مرورِ درسها' : 'اول درسها رو بخون'}
|
||||||
|
<ChevronDown size={16} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showLessons && (
|
||||||
|
lessons.loading ? (
|
||||||
|
<div className="py-4 text-center text-sm text-muted">دارم درسها رو میارم…</div>
|
||||||
|
) : lessons.error ? (
|
||||||
|
<ErrorState error={lessons.error} onRetry={lessons.reload} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 mt-1 text-xs text-muted">
|
||||||
|
{toFa(list.filter((l) => l.done).length)} از {toFa(list.length)} درس خونده شده
|
||||||
|
</p>
|
||||||
|
<ul className="flex max-h-60 flex-col gap-1.5 overflow-y-auto">
|
||||||
|
{list.map((lesson) => (
|
||||||
|
<li key={lesson.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openLesson(Number(lesson.id))}
|
||||||
|
className="flex w-full items-center gap-3 rounded-2xl border border-line bg-white px-3 py-2.5 text-right text-sm"
|
||||||
|
>
|
||||||
|
{lesson.done ? (
|
||||||
|
<CheckCircle2 size={18} className="flex-shrink-0 text-mint" />
|
||||||
|
) : (
|
||||||
|
<Circle size={18} className="flex-shrink-0 text-faint" />
|
||||||
|
)}
|
||||||
|
<span className={`flex-1 ${lesson.done ? 'text-muted' : 'text-ink'}`}><MathText text={lesson.title} /></span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
src/components/NoHeartsScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { ChevronRight, RotateCw } from 'lucide-react';
|
||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||||
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
|
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
|
||||||
import { friendlyError } from '@/lib/errors';
|
import { friendlyError } from '@/lib/errors';
|
||||||
import { useNav } from '@/nav/Navigator';
|
import { useNav } from '@/nav/Navigator';
|
||||||
|
import { onWhite, shade } from '@/lib/color';
|
||||||
|
|
||||||
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
|
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export function ScreenHeader({
|
|||||||
color,
|
color,
|
||||||
back = false,
|
back = false,
|
||||||
mascot,
|
mascot,
|
||||||
|
exact = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
@@ -21,19 +23,26 @@ export function ScreenHeader({
|
|||||||
/** دکمهی بازگشت؛ true یعنی صفحهی قبل در پشته. */
|
/** دکمهی بازگشت؛ true یعنی صفحهی قبل در پشته. */
|
||||||
back?: boolean | (() => void);
|
back?: boolean | (() => void);
|
||||||
mascot?: { char: SeyarChar; mood?: SeyarMood; size?: number };
|
mascot?: { char: SeyarChar; mood?: SeyarMood; size?: number };
|
||||||
|
/** رنگ را همانطور که هست بگذار (بیتیرهکردن برای کنتراست)؛ انتخابِ مالک برای چند صفحه. */
|
||||||
|
exact?: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
const onBack = typeof back === 'function' ? back : nav.back;
|
const onBack = typeof back === 'function' ? back : nav.back;
|
||||||
|
const bg = exact ? color : onWhite(color);
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
|
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: bg, backgroundImage: `linear-gradient(160deg, ${shade(bg, 0.12)} 0%, ${bg} 55%, ${shade(bg, -0.06)} 100%)` }}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
|
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
|
||||||
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
|
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
|
||||||
|
{/* نمادهای ریاضی که آرام شناورند؛ فقط transform/opacity و بیتصویر، پس ارزاناند. */}
|
||||||
|
<span aria-hidden className="glyph pointer-events-none absolute left-[42%] top-5 text-3xl font-black text-white/15" style={{ animationDelay: '0s' }}>π</span>
|
||||||
|
<span aria-hidden className="glyph pointer-events-none absolute right-[34%] bottom-3 text-2xl font-black text-white/15" style={{ animationDelay: '-2s' }}>∑</span>
|
||||||
|
<span aria-hidden className="glyph pointer-events-none absolute left-[26%] bottom-2 text-2xl font-black text-white/15" style={{ animationDelay: '-4s' }}>√</span>
|
||||||
<div className="relative z-10 pt-6">
|
<div className="relative z-10 pt-6">
|
||||||
{back && (
|
{back && (typeof back === 'function' || nav.canGoBack) && (
|
||||||
<IconButton label="بازگشت" onClick={onBack} className="mb-3 bg-white/15">
|
<IconButton label="بازگشت" onClick={onBack} className="mb-3 bg-white/15">
|
||||||
<ChevronRight size={18} strokeWidth={2.5} />
|
<ChevronRight size={18} strokeWidth={2.5} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -85,8 +94,8 @@ export function PrimaryButton({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled || loading}
|
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}`}
|
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 10px 24px -8px ${color}88`, ...style }}
|
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.22)}`, ...style }}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{loading ? <Spinner /> : children}
|
{loading ? <Spinner /> : children}
|
||||||
@@ -98,7 +107,7 @@ export function SecondaryButton({ className = '', children, ...rest }: ButtonHTM
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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}
|
{...rest}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -129,8 +138,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 }) {
|
export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?: () => void; text?: string }) {
|
||||||
@@ -153,6 +173,14 @@ export function EmptyState({ text, char = 'pi' as SeyarChar }: { text: string; c
|
|||||||
return <StateBox char={char} mood="idle" text={text} />;
|
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 }) {
|
export function InlineError({ children }: { children: ReactNode }) {
|
||||||
return (
|
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">
|
<div role="alert" className="rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-xs leading-6 text-red-600">
|
||||||
|
|||||||
41
src/dev/DiagramGallery.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Diagram } from '@/diagram';
|
||||||
|
import type { DiagramSpec } from '@/diagram/types';
|
||||||
|
import { SAMPLES } from './diagramSamples';
|
||||||
|
import { MathText } from '@/lib/MathText';
|
||||||
|
|
||||||
|
const TEXTS = [
|
||||||
|
'۵) پس وقتی از ۲ سه واحد به چپ برویم، به عدد -۱ میرسیم.',
|
||||||
|
'✅ جواب: رأس سهمی در (1, -1) است.',
|
||||||
|
'۴) ۲ - ۳ = -۱.',
|
||||||
|
'مجموع زاویههای داخلی = (5 - 2) × 180 = 3 × 180 = 540 درجه',
|
||||||
|
'بازهی [-2, 3) یعنی از −۲ تا ۳، خودِ ۳ نه.',
|
||||||
|
'sin 30° = 0.5 و cos 30° ≈ 0.87 است.',
|
||||||
|
'در کتاب ۲/۵ یعنی دو و نیم (اختیاری).',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* گالریِ شکلها، فقط در حالتِ توسعه: http://localhost:5173/?gallery
|
||||||
|
* برای وارسیِ چشمی بعد از هر تغییر در src/diagram. اگر window.__GALLERY__ ست شده
|
||||||
|
* باشد (تستِ خودکار با خروجیِ واقعیِ مدل)، همانها را نشان میدهد.
|
||||||
|
*/
|
||||||
|
export function DiagramGallery() {
|
||||||
|
const injected = (window as unknown as { __GALLERY__?: { title: string; spec: DiagramSpec }[] }).__GALLERY__;
|
||||||
|
const items = injected ?? SAMPLES;
|
||||||
|
return (
|
||||||
|
<div dir="rtl" className="min-h-screen bg-white p-3">
|
||||||
|
{!injected && (
|
||||||
|
<div className="mx-auto mb-3 max-w-[420px] space-y-1.5 rounded-2xl border border-line p-3 text-sm leading-7" data-texts>
|
||||||
|
{TEXTS.map((t) => <p key={t} className="rounded-xl bg-soft px-3 py-1.5"><MathText text={t} /></p>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mx-auto grid max-w-[1200px] grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<section key={i} data-sample={i} className="rounded-2xl border border-line p-2">
|
||||||
|
<h2 className="mb-1 text-xs font-black text-body">{i + 1}. {it.title}</h2>
|
||||||
|
<Diagram spec={it.spec} />
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
src/dev/diagramSamples.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import type { DiagramSpec } from '@/diagram/types';
|
||||||
|
|
||||||
|
/** نمونههای دستنویس از هر نوع شکل، از روی مسئلههای واقعیِ کتابها. فقط برای گالری. */
|
||||||
|
export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||||
|
{
|
||||||
|
title: 'یازدهم — سهمی با ریشه، رأس، محورِ تقارن و عرض از مبدأ',
|
||||||
|
spec: { kind: 'plane', title: 'y = x² − 2x − 3', axes: true, points: {}, items: [
|
||||||
|
{ type: 'function', expr: 'x^2-2x-3', from: -2.5, to: 4.5, label: 'y = x² − 2x − 3', marks: ['roots', 'vertex', 'axis', 'yIntercept'] },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دهم — دایرهی مثلثاتی، سینوس و کسینوسِ ۳۰°',
|
||||||
|
spec: { kind: 'plane', title: 'زاویهی ۳۰ درجه', axes: true, points: { O: [0, 0], A: [1, 0], P: { polar: [1, 30] }, H: { foot: ['P', 'O', 'A'] } }, items: [
|
||||||
|
{ type: 'circle', center: 'O', r: 1 },
|
||||||
|
{ type: 'segment', from: 'O', to: 'P' },
|
||||||
|
{ type: 'segment', from: 'P', to: 'H', dashed: true, color: 'green', label: 'sin 30° = 0.5' },
|
||||||
|
{ type: 'segment', from: 'O', to: 'H', color: 'orange', label: 'cos 30°' },
|
||||||
|
{ type: 'angle', vertex: 'O', from: 'A', to: 'P', label: '30°' },
|
||||||
|
{ type: 'rightAngle', vertex: 'H', from: 'O', to: 'P' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'نهم — مثلثِ قائمالزاویه، فیثاغورس',
|
||||||
|
spec: { kind: 'plane', title: 'وتر = ۵', axes: false, points: { A: [0, 0], B: [4, 0], C: [0, 3] }, items: [
|
||||||
|
{ type: 'polygon', points: ['A', 'B', 'C'] },
|
||||||
|
{ type: 'rightAngle', vertex: 'A', from: 'B', to: 'C' },
|
||||||
|
{ type: 'segment', from: 'A', to: 'B', label: '4' },
|
||||||
|
{ type: 'segment', from: 'A', to: 'C', label: '3' },
|
||||||
|
{ type: 'segment', from: 'B', to: 'C', label: '?', color: 'red' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'هشتم — خطوطِ موازی و مورب، زاویههای مساوی',
|
||||||
|
spec: { kind: 'plane', title: 'a ∥ b و d مورب', axes: false, points: { _a1: [0, 2], _a2: [6, 2], _b1: [0, 0], _b2: [6, 0], _d1: [1.2, -1], _d2: [4.8, 3], P: { intersect: [['_a1', '_a2'], ['_d1', '_d2']] }, Q: { intersect: [['_b1', '_b2'], ['_d1', '_d2']] } }, items: [
|
||||||
|
{ type: 'line', through: ['_a1', '_a2'], label: 'a' },
|
||||||
|
{ type: 'line', through: ['_b1', '_b2'], label: 'b' },
|
||||||
|
{ type: 'line', through: ['_d1', '_d2'], color: 'purple', label: 'd' },
|
||||||
|
{ type: 'angle', vertex: 'P', from: '_a2', to: '_d2', label: 'Â1', arcs: 1 },
|
||||||
|
{ type: 'angle', vertex: 'Q', from: '_b2', to: '_d2', label: 'B̂1', arcs: 1 },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'هشتم — زاویهی محاطی و مرکزیِ روبهروی یک کمان',
|
||||||
|
spec: { kind: 'plane', title: 'زاویهی محاطی نصفِ مرکزی است', axes: false, points: { O: [0, 0], A: { polar: [2, 200] }, B: { polar: [2, -20] }, C: { polar: [2, 100] } }, items: [
|
||||||
|
{ type: 'circle', center: 'O', r: 2 },
|
||||||
|
{ type: 'arc', center: 'O', r: 2, start: 200, end: 340, label: 'AB' },
|
||||||
|
{ type: 'segment', from: 'C', to: 'A' }, { type: 'segment', from: 'C', to: 'B' },
|
||||||
|
{ type: 'segment', from: 'O', to: 'A', color: 'blue' }, { type: 'segment', from: 'O', to: 'B', color: 'blue' },
|
||||||
|
{ type: 'angle', vertex: 'C', from: 'A', to: 'B', label: '70°' },
|
||||||
|
{ type: 'angle', vertex: 'O', from: 'A', to: 'B', label: '140°', color: 'blue' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'نهم — متوازیالاضلاع و وسطِ ضلعها، ضلعهای مساوی',
|
||||||
|
spec: { kind: 'plane', title: 'M و N وسطِ ضلعها', axes: false, points: { A: [0, 0], B: [4, 0], C: [5, 2.5], D: [1, 2.5], M: { mid: ['A', 'B'] }, N: { mid: ['C', 'D'] } }, items: [
|
||||||
|
{ type: 'polygon', points: ['A', 'B', 'C', 'D'] },
|
||||||
|
{ type: 'segment', from: 'A', to: 'M', ticks: 1 }, { type: 'segment', from: 'M', to: 'B', ticks: 1 },
|
||||||
|
{ type: 'segment', from: 'D', to: 'N', ticks: 2 }, { type: 'segment', from: 'N', to: 'C', ticks: 2 },
|
||||||
|
{ type: 'segment', from: 'M', to: 'N', dashed: true, color: 'purple' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'هفتم — دورانِ ۹۰° مثلث حولِ C در جهتِ عقربهها، روی شبکه',
|
||||||
|
spec: { kind: 'plane', title: 'دورانِ ۹۰°', axes: false, grid: true, points: { A: [1, 1], B: [4, 1], C: [1, 3] }, items: [
|
||||||
|
{ type: 'polygon', points: ['A', 'B', 'C'] },
|
||||||
|
{ type: 'image', of: ['A', 'B', 'C'], transform: { rotate: { center: 'C', deg: -90 } } },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'هشتم — بردار و جمعِ بردارها روی شبکه',
|
||||||
|
spec: { kind: 'plane', title: 'AB + BC = AC', axes: false, grid: true, points: { A: [0, 0], B: [3, 1], C: [4, 4] }, items: [
|
||||||
|
{ type: 'vector', from: 'A', to: 'B', label: 'a' },
|
||||||
|
{ type: 'vector', from: 'B', to: 'C', label: 'b', color: 'blue' },
|
||||||
|
{ type: 'vector', from: 'A', to: 'C', label: 'a + b', color: 'green' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'نهم — دو خط و محلِ برخورد',
|
||||||
|
spec: { kind: 'plane', title: 'y = 2x − 1 و y = −x + 5', axes: true, points: { P: { intersect: [[[0, -1], [1, 1]], [[0, 5], [1, 4]]] } }, items: [
|
||||||
|
{ type: 'function', expr: '2x-1', from: -1, to: 4, label: 'y = 2x − 1' },
|
||||||
|
{ type: 'function', expr: '-x+5', from: -1, to: 6, label: 'y = −x + 5' },
|
||||||
|
{ type: 'point', point: 'P', label: 'P (2, 3)' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'یازدهم — نمودارِ سینوس با مضربهای π',
|
||||||
|
spec: { kind: 'plane', title: 'y = sin x', axes: true, trig: true, points: {}, items: [
|
||||||
|
{ type: 'function', expr: 'sin(x)', from: -6.2832, to: 6.2832, label: 'y = sin x', marks: ['roots', 'extrema'] },
|
||||||
|
{ type: 'hline', y: 0.5, color: 'orange', label: 'y = ½' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'یازدهم — تابعِ چندضابطهای و حد (نقطهی توخالی)',
|
||||||
|
spec: { kind: 'plane', title: 'حدِ چپ و راست در x = 1', axes: true, points: {}, items: [
|
||||||
|
{ type: 'piecewise', pieces: [{ expr: 'x+1', from: -2, to: 1, closedFrom: true, closedTo: false }, { expr: '-x+4', from: 1, to: 4, closedFrom: true, closedTo: true }] },
|
||||||
|
{ type: 'vline', x: 1, label: 'x = 1' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دوازدهم — خطِ مماس بر نمودار',
|
||||||
|
spec: { kind: 'plane', title: 'مماس بر y = x² در x = 1', axes: true, points: {}, items: [
|
||||||
|
{ type: 'function', expr: 'x^2', from: -2, to: 2.5, label: 'y = x²' },
|
||||||
|
{ type: 'tangent', expr: 'x^2', at: 1, label: 'y = 2x − 1' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دوازدهم — مجانبها: y = 1/(x − 2) + 1',
|
||||||
|
spec: { kind: 'plane', title: 'مجانبِ قائم و افقی', axes: true, xRange: [-2, 6], yRange: [-4, 6], points: {}, items: [
|
||||||
|
{ type: 'function', expr: '1/(x-2)+1', from: -2, to: 6, label: 'y = 1/(x−2) + 1' },
|
||||||
|
{ type: 'vline', x: 2, label: 'x = 2', color: 'red' },
|
||||||
|
{ type: 'hline', y: 1, label: 'y = 1', color: 'red' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دوازدهم — اکسترممهای نسبیِ تابعِ درجهی سوم',
|
||||||
|
spec: { kind: 'plane', title: 'y = x³ − 3x', axes: true, points: {}, items: [
|
||||||
|
{ type: 'function', expr: 'x^3-3x', from: -2.3, to: 2.3, label: 'y = x³ − 3x', marks: ['extrema', 'roots'] },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'یازدهم — تابع و وارونش، قرینه نسبت به y = x',
|
||||||
|
spec: { kind: 'plane', title: 'f و f⁻¹', axes: true, xRange: [-1, 8], yRange: [-1, 8], points: {}, items: [
|
||||||
|
{ type: 'function', expr: '2^x', from: -1, to: 3, label: 'y = 2ˣ' },
|
||||||
|
{ type: 'function', expr: 'log(x)/log(2)', from: 0.3, to: 8, label: 'y = log₂x' },
|
||||||
|
{ type: 'function', expr: 'x', from: -1, to: 8, dashed: true, color: 'gray', label: 'y = x' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دوازدهم — مساحتِ زیرِ نمودار',
|
||||||
|
spec: { kind: 'plane', title: 'ناحیهی بینِ y = √x و محورِ x', axes: true, points: {}, items: [
|
||||||
|
{ type: 'area', expr: 'sqrt(x)', from: 0, to: 4, color: 'green', label: 'S' },
|
||||||
|
{ type: 'function', expr: 'sqrt(x)', from: 0, to: 5, label: 'y = √x', color: 'green' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دهم — ناحیهی جوابِ نامعادله',
|
||||||
|
spec: { kind: 'plane', title: 'y > 2x − 1', axes: true, xRange: [-3, 3], yRange: [-4, 4], points: {}, items: [
|
||||||
|
{ type: 'region', expr: '2x-1', side: 'above', strict: true, label: 'y > 2x − 1' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'هشتم — محورِ اعداد: از ۲ سه واحد به چپ',
|
||||||
|
spec: { kind: 'numberline', title: '۲ − ۳ = −۱', min: -4, max: 4, points: [{ at: 2, label: 'شروع' }, { at: -1, label: 'پایان' }], jumps: [{ from: 2, to: -1, label: '۳ واحد به چپ' }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دهم — بازهها روی محور',
|
||||||
|
spec: { kind: 'numberline', title: '[−۲، ۳) و (۱، +∞)', min: -4, max: 5, points: [], jumps: [], intervals: [
|
||||||
|
{ from: -2, to: 3, closedFrom: true, closedTo: false, label: '[−2, 3)' },
|
||||||
|
{ from: 1, to: null, closedFrom: false, label: '(1, +∞)' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'ششم — کسرها روی محور',
|
||||||
|
spec: { kind: 'numberline', title: 'سهچهارم', min: 0, max: 2, step: 0.25, fractions: true, points: [{ at: 0.75, label: 'سهچهارم' }], jumps: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'ششم — کسر روی دایره',
|
||||||
|
spec: { kind: 'fraction', title: 'سههشتم', shape: 'circle', parts: 8, shaded: 3 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'ششم — ۲۰٪ از ۲۵ خانه',
|
||||||
|
spec: { kind: 'grid', title: '۲۰ درصد', rows: 5, cols: 5, shaded: 5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'سوم — مدلِ ضرب: ۳ گروهِ ۴تایی',
|
||||||
|
spec: { kind: 'groups', title: '۳ × ۴', groups: 3, each: 4, item: 'circle' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'سوم — ساعت ۳ و ۲۵ دقیقه',
|
||||||
|
spec: { kind: 'clock', title: 'ساعت ۳:۲۵', hour: 3, minute: 25 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'سوم — نمودارِ ستونیِ آبمیوهها',
|
||||||
|
spec: { kind: 'bar', title: 'آبمیوهی موردِ علاقه', labels: ['پرتقال', 'سیب', 'انگور', 'هلو'], values: [8, 5, 3, 4], unit: 'نفر' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'هشتم — نمودارِ دایرهای',
|
||||||
|
spec: { kind: 'pie', title: 'ورزشِ موردِ علاقه', labels: ['فوتبال', 'والیبال', 'شنا', 'بقیه'], values: [12, 6, 4, 2] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دهم — دمای زمین بر حسبِ عمق (خطشکسته)',
|
||||||
|
spec: { kind: 'linechart', title: 'دما (°C)', labels: ['1', '2', '3', '4', '5', '6'], values: [55, 90, 125, 160, 195, 230], unit: '°C' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دهم — جدولِ تعیینِ علامت',
|
||||||
|
spec: { kind: 'table', title: 'y = (x + 1)(x − 2)', headers: ['x', '−∞', '−1', '', '2', '+∞'], rows: [['y', '+', '0', '−', '0', '+']] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دهم — نمودارِ ون: A ∩ B',
|
||||||
|
spec: { kind: 'venn', title: 'A = {1,2,3,4} و B = {3,4,5}', sets: ['A', 'B'], regions: { A: ['1', '2'], AB: ['3', '4'], B: ['5'], out: ['6'] }, shade: ['AB'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دهم — ون با سه مجموعه',
|
||||||
|
spec: { kind: 'venn', title: 'A − (B ∪ C)', sets: ['A', 'B', 'C'], regions: { A: ['1'], B: ['2'], C: ['3'], AB: ['4'], ABC: ['5'] }, shade: ['A'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دهم — نمودارِ پیکانیِ تابع',
|
||||||
|
spec: { kind: 'mapping', title: 'f(x) = 3(x − 1)²', left: ['0', '0.5', '1'], right: ['3', '0.75', '0'], arrows: [[0, 0], [1, 1], [2, 2]], leftLabel: 'دامنه', rightLabel: 'برد' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'نهم — نمودارِ درختیِ دو بار پرتابِ سکه',
|
||||||
|
spec: { kind: 'tree', title: '۴ حالت', root: { label: 'شروع', children: [
|
||||||
|
{ label: 'رو', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] },
|
||||||
|
{ label: 'پشت', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] },
|
||||||
|
] } },
|
||||||
|
},
|
||||||
|
{ title: 'هفتم — مکعبمستطیل', spec: { kind: 'solid', title: 'حجم = ۵ × ۳ × ۴', shape: 'cuboid', dims: { a: 5, b: 3, c: 4 }, labels: { a: '5 cm', b: '3 cm', c: '4 cm' } } },
|
||||||
|
{ title: 'هفتم — مکعب', spec: { kind: 'solid', title: 'مکعب', shape: 'cube', dims: { a: 3 }, labels: { a: 'a' } } },
|
||||||
|
{ title: 'هفتم — منشورِ سهپهلو', spec: { kind: 'solid', title: 'منشورِ مثلثی', shape: 'prism', dims: { n: 3, r: 1.2, h: 2 }, labels: { h: 'h' } } },
|
||||||
|
{ title: 'نهم — منشورِ ششپهلو', spec: { kind: 'solid', title: 'منشورِ ششضلعی', shape: 'prism', dims: { n: 6, r: 1.2, h: 1.6 }, labels: { h: '8 cm' } } },
|
||||||
|
{ title: 'نهم — هرمِ مربعالقاعده با ارتفاع', spec: { kind: 'solid', title: 'هرم', shape: 'pyramid', dims: { n: 4, r: 1.3, h: 2 }, labels: { h: 'h', a: 'a' } } },
|
||||||
|
{ title: 'نهم — هرمِ پنجپهلو', spec: { kind: 'solid', title: 'قاعدهی ABCDE', shape: 'pyramid', dims: { n: 5, r: 1.3, h: 2 }, labels: { h: 'OH' } } },
|
||||||
|
{ title: 'نهم — استوانه', spec: { kind: 'solid', title: 'لیوانِ استوانهای', shape: 'cylinder', dims: { r: 6, h: 10 }, labels: { r: '6 cm', h: 'h' } } },
|
||||||
|
{ title: 'نهم — مخروط', spec: { kind: 'solid', title: 'ظرفِ مخروطی', shape: 'cone', dims: { r: 4, h: 12 }, labels: { r: '4 cm', h: '12 cm' } } },
|
||||||
|
{ title: 'نهم — کره', spec: { kind: 'solid', title: 'کره', shape: 'sphere', dims: { r: 1 }, labels: { r: 'r' } } },
|
||||||
|
{ title: 'نهم — نیمکره', spec: { kind: 'solid', title: 'پیمانهی نیمکره', shape: 'hemisphere', dims: { r: 12 }, labels: { r: '12 cm' } } },
|
||||||
|
{ title: 'ششم — مقایسهی یکپنجم و نُهدهم', spec: { kind: 'fraction', title: 'کدام بزرگتر است؟', shape: 'rect', parts: 5, shaded: 1, compare: [{ parts: 5, shaded: 1, label: 'یکپنجم' }, { parts: 10, shaded: 9, label: 'نُهدهم' }] } },
|
||||||
|
{ title: 'ششم — دو و هفتدهم', spec: { kind: 'fraction', title: '۲٫۷', shape: 'circle', parts: 10, shaded: 7, wholes: 2 } },
|
||||||
|
{ title: 'دهم — دایره و خطِ مماس از معادله', spec: { kind: 'plane', title: 'فاصلهی مرکز تا خط', axes: true, points: { O: [-1, 2] }, items: [
|
||||||
|
{ type: 'line', eq: [3, 5, 4], label: '3x + 5y = 4' }, { type: 'circle', center: 'O', r: 0.5145, color: 'green' },
|
||||||
|
] } },
|
||||||
|
{ title: 'هشتم — زاویهی خارجیِ پنجضلعی با امتدادِ ضلع', spec: { kind: 'plane', title: 'زاویهی خارجی = ۷۲°', axes: false, points: { A: { polar: [2, 234] }, B: { polar: [2, 306] }, C: { polar: [2, 18] }, D: { polar: [2, 90] }, E: { polar: [2, 162] }, X: { along: ['A', 'B', 1.6] } }, items: [
|
||||||
|
{ type: 'polygon', points: ['A', 'B', 'C', 'D', 'E'] }, { type: 'segment', from: 'B', to: 'X', dashed: true }, { type: 'angle', vertex: 'B', from: 'X', to: 'C', label: '72°' },
|
||||||
|
] } },
|
||||||
|
{ title: 'سوم — ضرب روی محورِ اعداد (تیکهای زیاد)', spec: { kind: 'numberline', title: '۲ × ۱۱', min: 0, max: 25, points: [{ at: 11, label: '' }, { at: 22, label: '' }], jumps: [{ from: 0, to: 11, label: '۱۱' }, { from: 11, to: 22, label: '۱۱' }] } },
|
||||||
|
{ title: 'نهم — مثلث از روی داده: AB = 6، AC = 8، زاویهی A قائمه', spec: { kind: 'plane', title: 'فیثاغورس', axes: false, points: {}, items: [
|
||||||
|
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 6, AC: 8 }, angles: { A: 90 }, labels: { BC: 'x' } },
|
||||||
|
] } },
|
||||||
|
{ title: 'هشتم — مثلث با سه ضلع ۵، ۷، ۹ و ارتفاع از C', spec: { kind: 'plane', title: 'ارتفاعِ وارد بر AB', axes: false, points: {}, items: [
|
||||||
|
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 9, BC: 7, CA: 5 }, altitude: 'C', labels: { h: 'h' } },
|
||||||
|
] } },
|
||||||
|
{ title: 'هفتم — مثلث با دو زاویه (۴۰° و ۷۵°)', spec: { kind: 'plane', title: 'زاویهی سوم؟', axes: false, points: {}, items: [
|
||||||
|
{ type: 'triangle', names: ['A', 'B', 'C'], angles: { A: 40, B: 75 }, labels: { C: '?' } },
|
||||||
|
] } },
|
||||||
|
{ title: 'منفرجه — ارتفاع بیرونِ مثلث', spec: { kind: 'plane', title: 'زاویهی B = ۱۲۰°', axes: false, points: {}, items: [
|
||||||
|
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 4, BC: 3 }, angles: { B: 120 }, altitude: 'C' },
|
||||||
|
] } },
|
||||||
|
{ title: 'ششم — مستطیلِ باغچه ۸ × ۵', spec: { kind: 'plane', title: 'باغچه', axes: false, points: {}, items: [{ type: 'quad', shape: 'rectangle', names: ['A', 'B', 'C', 'D'], w: 8, h: 5 }] } },
|
||||||
|
{ title: 'مربع با ضلعِ ۴', spec: { kind: 'plane', title: 'مربع', axes: false, points: {}, items: [{ type: 'quad', shape: 'square', names: ['A', 'B', 'C', 'D'], side: 4 }] } },
|
||||||
|
{ title: 'ششم — متوازیالاضلاع: قاعده ۷، ارتفاع ۴', spec: { kind: 'plane', title: 'مساحت = قاعده × ارتفاع', axes: false, points: {}, items: [{ type: 'quad', shape: 'parallelogram', names: ['A', 'B', 'C', 'D'], w: 7, h: 4 }] } },
|
||||||
|
{ title: 'هفتم — لوزی با قطرهای ۸ و ۶', spec: { kind: 'plane', title: 'مساحت = نصفِ حاصلضربِ قطرها', axes: false, points: {}, items: [{ type: 'quad', shape: 'rhombus', names: ['A', 'B', 'C', 'D'], d1: 8, d2: 6 }] } },
|
||||||
|
{ title: 'هشتم — ذوزنقه: قاعدههای ۱۰ و ۶، ارتفاع ۴', spec: { kind: 'plane', title: 'ذوزنقه', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 10, top: 6, h: 4 }] } },
|
||||||
|
{ title: 'ذوزنقهی قائمالزاویه', spec: { kind: 'plane', title: 'ذوزنقهی قائم', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 8, top: 5, h: 4, right: true }] } },
|
||||||
|
{ title: 'هفتم — الگوی مربعی ۱، ۴، ۹ و شکلِ بعدی', spec: { kind: 'pattern', title: 'الگوی n²', layout: 'square', unit: 'square', stages: [1, 2, 3], next: true } },
|
||||||
|
{ title: 'هفتم — الگوی چوبکبریت', spec: { kind: 'pattern', title: 'چوبکبریت: 3n + 1', layout: 'row', unit: 'stick', stages: [1, 2, 3, 4], next: true } },
|
||||||
|
{ title: 'دهم — عددهای مثلثی با نقطه', spec: { kind: 'pattern', title: '۱، ۳، ۶، ۱۰', layout: 'triangle', unit: 'dot', stages: [1, 2, 3, 4] } },
|
||||||
|
];
|
||||||
99
src/diagram/charts.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,106 @@
|
|||||||
|
import type { NumberLineSpec } from './types';
|
||||||
|
import { Arrowheads, COLORS, INK, Label, SERIES, SOFT, StackedFraction, arrowId, color, fa, gcd, mathText, niceStep, ticksBetween } from './svg';
|
||||||
|
import { toFa } from '@/lib/format';
|
||||||
|
|
||||||
|
const W = 330;
|
||||||
|
const PAD = 26;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* محورِ اعداد: نقطه (توپُر یا توخالی)، پرش (فلشِ خمیده)، و بازه (پارهخطِ رنگی با
|
||||||
|
* سرِ باز/بسته، یا تا بینهایت). با `fractions` تیکها کسرِ دوطبقهاند (۱/۴، ۲/۴)،
|
||||||
|
* چون در کتابِ دبستان «/» ممیز است.
|
||||||
|
*/
|
||||||
|
export function NumberLine({ spec }: { spec: NumberLineSpec }) {
|
||||||
|
const { min, max } = spec;
|
||||||
|
const intervals = spec.intervals ?? [];
|
||||||
|
const lanes = intervals.length;
|
||||||
|
const jumpH = spec.jumps.length ? 52 : 0;
|
||||||
|
const LANE = intervals.some((iv) => iv.label) ? 24 : 14;
|
||||||
|
const base = 22 + jumpH + lanes * LANE + 10;
|
||||||
|
const H = base + (spec.fractions ? 58 : 44);
|
||||||
|
const X = (v: number) => PAD + ((v - min) / (max - min)) * (W - 2 * PAD);
|
||||||
|
// محورِ عددهای صحیح گامِ صحیحِ «گرد» میگیرد (۱، ۲، ۵، ۱۰، …)، نه ۲٫۵
|
||||||
|
const intStep = (span: number) => [1, 2, 5, 10, 20, 25, 50, 100, 200, 500, 1000].find((c) => span / c <= 12) ?? niceStep(span, 12);
|
||||||
|
const integers = Number.isInteger(min) && Number.isInteger(max);
|
||||||
|
const step = spec.step && spec.step > 0 && (max - min) / spec.step <= 40 ? spec.step : integers ? intStep(max - min) : niceStep(max - min, 12);
|
||||||
|
const ticks = ticksBetween(min, max, step);
|
||||||
|
const labelled = new Set(spec.points.map((p) => p.at));
|
||||||
|
// بیش از ۱۴ تیک: فقط بعضی عدد میگیرند (صفر، مضربهای ۵ یا ۱۰، نقطههای خواستهشده)،
|
||||||
|
// وگرنه عددها روی هم میافتند.
|
||||||
|
const every = ticks.length > 14 ? Math.ceil(ticks.length / 10) : 1;
|
||||||
|
const showLabel = (t: number, i: number) => every === 1 || labelled.has(t) || Math.abs(t) < 1e-9 || i % every === 0;
|
||||||
|
const arrows = new Set<string>([INK, COLORS.red]);
|
||||||
|
|
||||||
|
const tickLabel = (t: number) => {
|
||||||
|
if (spec.fractions && step < 1) {
|
||||||
|
const den = Math.round(1 / step);
|
||||||
|
const num = Math.round(t * den);
|
||||||
|
if (num % den === 0) return null; // عددِ صحیح با رقمِ معمولی
|
||||||
|
const g = gcd(num, den);
|
||||||
|
return { num: toFa(Math.abs(num / g)), den: toFa(den / g), neg: num < 0 };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img" aria-label={spec.title || 'محورِ اعداد'}>
|
||||||
|
<Arrowheads ids={[...arrows, ...intervals.map((iv, i) => color(iv.color, COLORS[SERIES[i % SERIES.length]]))]} />
|
||||||
|
<line x1={PAD - 16} y1={base} x2={W - PAD + 16} y2={base} stroke={INK} strokeWidth={2}
|
||||||
|
markerEnd={`url(#${arrowId(INK)})`} markerStart={`url(#${arrowId(INK)})`} />
|
||||||
|
{ticks.map((t, ti) => {
|
||||||
|
const fr = showLabel(t, ti) ? tickLabel(t) : null;
|
||||||
|
const hideNumber = !showLabel(t, ti);
|
||||||
|
const on = labelled.has(t);
|
||||||
|
const whole = Math.abs(t - Math.round(t)) < 1e-9;
|
||||||
|
return (
|
||||||
|
<g key={t}>
|
||||||
|
<line x1={X(t)} x2={X(t)} y1={base - (whole ? 6 : 4)} y2={base + (whole ? 6 : 4)} stroke={INK} strokeWidth={Math.abs(t) < 1e-9 ? 2.2 : 1.3} />
|
||||||
|
{fr ? (
|
||||||
|
<StackedFraction x={X(t)} y={base + 16} num={`${fr.neg ? '−' : ''}${fr.num}`} den={fr.den} color={on ? INK : SOFT} size={9} />
|
||||||
|
) : hideNumber ? null : (
|
||||||
|
<text x={X(t)} y={base + 20} fontSize={on ? 11.5 : 10} fontWeight={on ? 800 : 500} fill={on ? INK : SOFT} textAnchor="middle">{fa(t)}</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{intervals.map((iv, i) => {
|
||||||
|
const col = color(iv.color, COLORS[SERIES[i % SERIES.length]]);
|
||||||
|
const y = base - 10 - i * LANE;
|
||||||
|
const a = iv.from === null ? PAD - 12 : X(iv.from), b = iv.to === null ? W - PAD + 12 : X(iv.to);
|
||||||
|
return (
|
||||||
|
<g key={`iv${i}`}>
|
||||||
|
<line x1={a} x2={b} y1={y} y2={y} stroke={col} strokeWidth={4} strokeLinecap="round"
|
||||||
|
markerStart={iv.from === null ? `url(#${arrowId(col)})` : undefined} markerEnd={iv.to === null ? `url(#${arrowId(col)})` : undefined} />
|
||||||
|
{iv.from !== null && <circle cx={a} cy={y} r={4.6} fill={iv.closedFrom ? col : '#fff'} stroke={col} strokeWidth={2.2} />}
|
||||||
|
{iv.to !== null && <circle cx={b} cy={y} r={4.6} fill={iv.closedTo ? col : '#fff'} stroke={col} strokeWidth={2.2} />}
|
||||||
|
{iv.label && <Label x={(Math.max(a, PAD) + Math.min(b, W - PAD)) / 2} y={y - 7} color={col} size={10.5}>{mathText(iv.label)}</Label>}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{spec.jumps.map((j, i) => {
|
||||||
|
const a = X(j.from), b = X(j.to);
|
||||||
|
const lift = Math.min(42, Math.abs(b - a) * 0.45 + 12);
|
||||||
|
const y = base - 6 - lanes * LANE;
|
||||||
|
return (
|
||||||
|
<g key={`j${i}`}>
|
||||||
|
<path d={`M${a},${y} Q${(a + b) / 2},${y - lift * 1.5} ${b},${y}`} fill="none" stroke={COLORS.red} strokeWidth={2.3} markerEnd={`url(#${arrowId(COLORS.red)})`} />
|
||||||
|
{j.label && <Label x={(a + b) / 2} y={y - lift * 0.8 - 4} color={COLORS.red} size={10.5}>{mathText(j.label)}</Label>}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{spec.points.map((p, i) => {
|
||||||
|
const col = color(p.color, i === spec.points.length - 1 && spec.points.length > 1 ? COLORS.red : COLORS.blue);
|
||||||
|
const numeric = /^[-−+]?[\d۰-۹.,٫/]+$/.test(p.label.trim());
|
||||||
|
return (
|
||||||
|
<g key={`p${i}`}>
|
||||||
|
<circle cx={X(p.at)} cy={base} r={5.6} fill={p.open ? '#fff' : col} stroke={p.open ? col : '#fff'} strokeWidth={p.open ? 2.4 : 1.6} />
|
||||||
|
{p.label && !numeric && <Label x={X(p.at)} y={base + (spec.fractions ? 50 : 36)} color={col} size={10.5}>{p.label}</Label>}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
796
src/diagram/plane.tsx
Normal file
@@ -0,0 +1,796 @@
|
|||||||
|
import { useId, type ReactNode } from 'react';
|
||||||
|
import type { ColorName, FunctionMark, PlaneItem, PlaneSpec, PointDef, Ref, Transform } from './types';
|
||||||
|
import { derivative, extrema, parseExpr, roots } from './expr';
|
||||||
|
import { expandQuad, expandTriangle, type Layout } from './shapes';
|
||||||
|
import { Arrowheads, COLORS, GRID, INK, Label, PAPER, PERSIAN_LETTER, bidi, SERIES, SOFT, arrowId, color, fa, ltr, mathText, niceStep, piLabel, ticksBetween } from './svg';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* صفحهی مختصات و هندسه. همهی مختصات اینجا حساب میشوند:
|
||||||
|
* نقطهها polar، وسطِ پارهخط، پای عمود، محلِ برخوردِ دو خط، نقطهی روی نمودار
|
||||||
|
* تابعها ریشه، رأس/اکسترمم، عرض از مبدأ، محورِ تقارن، شیبِ مماس
|
||||||
|
* تبدیلها دوران، قرینه، انتقال، تجانس
|
||||||
|
* مدل فقط میگوید کدام را میخواهد. هر تکهای که حل نشود (نقطهی ناشناخته، فرمولِ
|
||||||
|
* نامعتبر) بیصدا حذف میشود و بقیهی شکل کشیده میشود.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type XY = [number, number];
|
||||||
|
const W = 320;
|
||||||
|
const PAD = 24;
|
||||||
|
|
||||||
|
const GEOMETRY = new Set(['circle', 'arc', 'sector', 'angle', 'rightAngle', 'polygon', 'image']);
|
||||||
|
|
||||||
|
/* ─── هندسهی نقطهها ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function footOf(p: XY, a: XY, b: XY): XY {
|
||||||
|
const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
|
||||||
|
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy || 1);
|
||||||
|
return [a[0] + t * dx, a[1] + t * dy];
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersect(a: XY, b: XY, c: XY, d: XY): XY | null {
|
||||||
|
const den = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
|
||||||
|
if (Math.abs(den) < 1e-12) return null;
|
||||||
|
const t = ((a[0] - c[0]) * (c[1] - d[1]) - (a[1] - c[1]) * (c[0] - d[0])) / den;
|
||||||
|
return [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformPoint(p: XY, tr: Transform, ref: (r: Ref) => XY | null): XY | null {
|
||||||
|
if ('rotate' in tr) {
|
||||||
|
const c = ref(tr.rotate.center);
|
||||||
|
if (!c) return null;
|
||||||
|
// دوران مثبت = پادساعتگرد، مثلِ کتاب؛ «در جهتِ عقربهها» یعنی زاویهی منفی.
|
||||||
|
const t = (tr.rotate.deg * Math.PI) / 180, [x, y] = [p[0] - c[0], p[1] - c[1]];
|
||||||
|
return [c[0] + x * Math.cos(t) - y * Math.sin(t), c[1] + x * Math.sin(t) + y * Math.cos(t)];
|
||||||
|
}
|
||||||
|
if ('translate' in tr) return [p[0] + tr.translate[0], p[1] + tr.translate[1]];
|
||||||
|
if ('scale' in tr) {
|
||||||
|
const c = ref(tr.scale.center);
|
||||||
|
return c ? [c[0] + tr.scale.k * (p[0] - c[0]), c[1] + tr.scale.k * (p[1] - c[1])] : null;
|
||||||
|
}
|
||||||
|
const r = tr.reflect;
|
||||||
|
if (r === 'x') return [p[0], -p[1]];
|
||||||
|
if (r === 'y') return [-p[0], p[1]];
|
||||||
|
if (r === 'y=x') return [p[1], p[0]];
|
||||||
|
const a = ref(r[0]), b = ref(r[1]);
|
||||||
|
if (!a || !b) return null;
|
||||||
|
const f = footOf(p, a, b);
|
||||||
|
return [2 * f[0] - p[0], 2 * f[1] - p[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نقطههای نامدار، با حلِ ارجاعِ زنجیرهای و بدونِ حلقهی بیپایان. */
|
||||||
|
function resolvePoints(spec: PlaneSpec) {
|
||||||
|
const named = new Map<string, XY>();
|
||||||
|
const resolving = new Set<string>();
|
||||||
|
const ref = (r: Ref): XY | null => (typeof r === 'string' ? byName(r) : [r[0], r[1]]);
|
||||||
|
function byName(name: string): XY | null {
|
||||||
|
if (named.has(name)) return named.get(name)!;
|
||||||
|
const def: PointDef | undefined = spec.points[name];
|
||||||
|
if (def === undefined || resolving.has(name)) return null;
|
||||||
|
resolving.add(name);
|
||||||
|
const xy = build(def);
|
||||||
|
resolving.delete(name);
|
||||||
|
if (xy && Number.isFinite(xy[0]) && Number.isFinite(xy[1])) named.set(name, xy);
|
||||||
|
return named.get(name) ?? null;
|
||||||
|
}
|
||||||
|
function build(def: PointDef): XY | null {
|
||||||
|
if (Array.isArray(def)) return [def[0], def[1]];
|
||||||
|
if ('polar' in def) {
|
||||||
|
const o = def.from !== undefined ? ref(def.from) : [0, 0];
|
||||||
|
if (!o) return null;
|
||||||
|
const t = (def.polar[1] * Math.PI) / 180;
|
||||||
|
return [o[0] + def.polar[0] * Math.cos(t), o[1] + def.polar[0] * Math.sin(t)];
|
||||||
|
}
|
||||||
|
if ('mid' in def) {
|
||||||
|
const a = ref(def.mid[0]), b = ref(def.mid[1]);
|
||||||
|
return a && b ? [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] : null;
|
||||||
|
}
|
||||||
|
if ('foot' in def) {
|
||||||
|
const p = ref(def.foot[0]), a = ref(def.foot[1]), b = ref(def.foot[2]);
|
||||||
|
return p && a && b ? footOf(p, a, b) : null;
|
||||||
|
}
|
||||||
|
if ('intersect' in def) {
|
||||||
|
const [[a, b], [c, d]] = def.intersect;
|
||||||
|
const pa = ref(a), pb = ref(b), pc = ref(c), pd = ref(d);
|
||||||
|
return pa && pb && pc && pd ? intersect(pa, pb, pc, pd) : null;
|
||||||
|
}
|
||||||
|
if ('along' in def) {
|
||||||
|
const a = ref(def.along[0]), b = ref(def.along[1]), t = def.along[2];
|
||||||
|
return a && b ? [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])] : null;
|
||||||
|
}
|
||||||
|
if ('translate' in def) {
|
||||||
|
const o = ref(def.from);
|
||||||
|
return o ? [o[0] + def.translate[0], o[1] + def.translate[1]] : null;
|
||||||
|
}
|
||||||
|
if ('fx' in def) {
|
||||||
|
try {
|
||||||
|
const x = def.fx[1];
|
||||||
|
return [x, parseExpr(def.fx[0])(x)];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object.keys(spec.points).forEach(byName);
|
||||||
|
return { named, ref };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── نمونهبرداری از نمودار، با شکستن در مجانب ──────────────────────────── */
|
||||||
|
|
||||||
|
function sample(f: (x: number) => number, from: number, to: number, n = 360): XY[][] {
|
||||||
|
const runs: XY[][] = [];
|
||||||
|
let run: XY[] = [];
|
||||||
|
let prev: number | null = null;
|
||||||
|
for (let i = 0; i <= n; i++) {
|
||||||
|
const x = from + ((to - from) * i) / n, y = f(x);
|
||||||
|
const jump = prev !== null && Number.isFinite(y) && Math.abs(y - prev) > 50 * (Math.abs(prev) + Math.abs(y) + 1) / Math.max(1, n / 60);
|
||||||
|
if (!Number.isFinite(y) || jump) {
|
||||||
|
if (run.length > 1) runs.push(run);
|
||||||
|
run = Number.isFinite(y) ? [[x, y]] : [];
|
||||||
|
} else run.push([x, y]);
|
||||||
|
prev = Number.isFinite(y) ? y : null;
|
||||||
|
}
|
||||||
|
if (run.length > 1) runs.push(run);
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Curve { f: (x: number) => number; from: number; to: number; runs: XY[][] }
|
||||||
|
|
||||||
|
function compile(expr: string, from: number, to: number): Curve | null {
|
||||||
|
try {
|
||||||
|
const f = parseExpr(expr);
|
||||||
|
const runs = sample(f, from, to);
|
||||||
|
return runs.length ? { f, from, to, runs } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── برچسبِ ترکیبی: «رأس (۱، −۱)» دو سطر میشود تا جهتِ متن به هم نریزد ── */
|
||||||
|
|
||||||
|
export function splitLabel(text: string): [string, string] | null {
|
||||||
|
if (!PERSIAN_LETTER.test(text)) return null;
|
||||||
|
const m = /^([^()\d۰-۹=]*?[-ۿ][^()\d۰-۹=]*?)\s*([(\d۰-۹−\-+].*)$/.exec(text);
|
||||||
|
return m && /[\d۰-۹]/.test(m[2]) ? [m[1].trim(), m[2].trim()] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** رقمِ لاتین به فارسی و منفیِ درست، در متنِ ریاضیِ برچسبها. */
|
||||||
|
export { mathText };
|
||||||
|
|
||||||
|
function RichLabel({ x, y, text, fill, size = 11, anchor = 'middle' }: { x: number; y: number; text: string; fill: string; size?: number; anchor?: 'start' | 'middle' | 'end' }) {
|
||||||
|
const parts = splitLabel(text);
|
||||||
|
if (!parts) return <Label x={x} y={y} color={fill} size={size} anchor={anchor}>{mathText(text)}</Label>;
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<Label x={x} y={y - size * 0.55} color={fill} size={size} anchor={anchor}>{parts[0]}</Label>
|
||||||
|
<text x={x} y={y + size * 0.75} fontSize={size - 0.5} fontWeight={700} fill={fill} textAnchor={anchor} {...bidi(mathText(parts[1]))}
|
||||||
|
stroke={PAPER} strokeWidth={3} paintOrder="stroke">{mathText(parts[1])}</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── خودِ شکل ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* قطعههایی که به قطعههای سادهتر باز میشوند، پیش از هر حسابی:
|
||||||
|
* regularPolygon → نقطههای polar با زاویههای مساوی + polygon (مدل مختصاتِ رأسِ پنجضلعیِ
|
||||||
|
* منتظم را خودش حدس زده بود و شکل منتظم نشد)
|
||||||
|
* exteriorAngle → نقطهی پنهان روی امتدادِ ضلع + پارهخطِ خطچین + angle (مدل با angle همیشه
|
||||||
|
* زاویهی داخلی را میکشید)
|
||||||
|
* triangle، quad → رأسها از روی ضلع و زاویه و ارتفاعِ مسئله (shapes.ts)
|
||||||
|
*/
|
||||||
|
const COMPOUND = new Set(['regularPolygon', 'exteriorAngle', 'triangle', 'quad']);
|
||||||
|
|
||||||
|
function expand(spec: PlaneSpec): PlaneSpec {
|
||||||
|
if (!spec.items.some((i) => COMPOUND.has(i.type))) return spec;
|
||||||
|
const points: Record<string, PointDef> = { ...spec.points };
|
||||||
|
const items: PlaneItem[] = [];
|
||||||
|
const layout: Layout = { placed: [] };
|
||||||
|
const taken = (n: string) => n in points || spec.items.some((i) => (i.type === 'triangle' || i.type === 'quad') && (i.names as string[]).includes(n));
|
||||||
|
spec.items.forEach((item, idx) => {
|
||||||
|
if (item.type === 'triangle') {
|
||||||
|
// دادهی متناقض (۳، ۴، ۱۰) مثلثی نمیسازد: فقط همین item حذف میشود
|
||||||
|
items.push(...(expandTriangle(item, idx, points, taken, layout) ?? []));
|
||||||
|
} else if (item.type === 'quad') {
|
||||||
|
items.push(...expandQuad(item, idx, points, layout));
|
||||||
|
} else if (item.type === 'regularPolygon') {
|
||||||
|
const center = item.center ?? '_rc' + idx;
|
||||||
|
if (!item.center) points['_rc' + idx] = [0, 0];
|
||||||
|
// پیشفرض: ضلعِ پایین افقی، مثلِ کتاب
|
||||||
|
const start = item.start ?? -90 - 180 / item.n;
|
||||||
|
const names = Array.from({ length: item.n }, (_, i) => item.names?.[i] ?? `_r${idx}_${i}`);
|
||||||
|
names.forEach((name, i) => { points[name] = { polar: [item.r, start + (360 * i) / item.n], from: center }; });
|
||||||
|
items.push({ type: 'polygon', points: names, fill: item.fill, color: item.color, dashed: item.dashed, label: item.label });
|
||||||
|
} else if (item.type === 'exteriorAngle') {
|
||||||
|
const ext = `_ext${idx}`;
|
||||||
|
points[ext] = { along: [item.from, item.vertex, 1.7] };
|
||||||
|
items.push({ type: 'segment', from: item.vertex, to: ext, dashed: true, color: 'gray' });
|
||||||
|
items.push({ type: 'angle', vertex: item.vertex, from: ext, to: item.to, label: item.label, color: item.color });
|
||||||
|
} else items.push(item);
|
||||||
|
});
|
||||||
|
return { ...spec, points, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نامهایی که یک item یا تعریفِ نقطهی دیگری به آنها ارجاع داده. */
|
||||||
|
function referencedNames(spec: PlaneSpec): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
const walk = (v: unknown) => {
|
||||||
|
if (typeof v === 'string') { if (v in spec.points) out.add(v); }
|
||||||
|
else if (Array.isArray(v)) v.forEach(walk);
|
||||||
|
else if (v && typeof v === 'object') Object.values(v).forEach(walk);
|
||||||
|
};
|
||||||
|
spec.items.forEach(walk);
|
||||||
|
Object.values(spec.points).forEach(walk);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Plane({ spec: raw }: { spec: PlaneSpec }) {
|
||||||
|
const spec = expand(raw);
|
||||||
|
// شناسهی یکتا: چند شکل در یک گفتگو نباید clipPathِ همدیگر را بگیرند.
|
||||||
|
const clipId = `dg-plot-${useId().replace(/:/g, '')}`;
|
||||||
|
const { named, ref } = resolvePoints(spec);
|
||||||
|
|
||||||
|
// تصویرِ تبدیلها پیش از همه، تا A′ مثلِ هر نقطهی دیگر قابلِ ارجاع باشد.
|
||||||
|
const images: { item: Extract<PlaneItem, { type: 'image' }>; pts: XY[]; names: string[] }[] = [];
|
||||||
|
for (const item of spec.items) {
|
||||||
|
if (item.type !== 'image') continue;
|
||||||
|
const pts: XY[] = [], names: string[] = [];
|
||||||
|
item.of.forEach((r, i) => {
|
||||||
|
const p = ref(r);
|
||||||
|
const q = p && transformPoint(p, item.transform, ref);
|
||||||
|
if (!q) return;
|
||||||
|
const name = item.names?.[i] ?? (typeof r === 'string' ? `${r}′` : '');
|
||||||
|
pts.push(q); names.push(name);
|
||||||
|
if (name && !named.has(name)) named.set(name, q);
|
||||||
|
});
|
||||||
|
if (pts.length) images.push({ item, pts, names });
|
||||||
|
}
|
||||||
|
|
||||||
|
// نمودارها، برای کادر و برای رسم.
|
||||||
|
let series = 0;
|
||||||
|
const nextColor = (c?: ColorName) => COLORS[c ?? SERIES[series++ % SERIES.length]];
|
||||||
|
const curves = new Map<PlaneItem, Curve[]>();
|
||||||
|
for (const item of spec.items) {
|
||||||
|
if (item.type === 'function' || item.type === 'area' || item.type === 'region') {
|
||||||
|
const range = item.type === 'region' ? [item.from ?? -50, item.to ?? 50] : [item.from, item.to];
|
||||||
|
const c = compile(item.expr, range[0], range[1]);
|
||||||
|
if (c) curves.set(item, [c]);
|
||||||
|
if (item.type === 'area' && item.expr2) {
|
||||||
|
const c2 = compile(item.expr2, item.from, item.to);
|
||||||
|
if (c && c2) curves.set(item, [c, c2]);
|
||||||
|
}
|
||||||
|
} else if (item.type === 'piecewise') {
|
||||||
|
const cs = item.pieces.map((p) => compile(p.expr, p.from, p.to)).filter((c): c is Curve => c !== null);
|
||||||
|
if (cs.length) curves.set(item, cs);
|
||||||
|
} else if (item.type === 'tangent') {
|
||||||
|
try {
|
||||||
|
const f = parseExpr(item.expr);
|
||||||
|
if (Number.isFinite(f(item.at))) curves.set(item, [{ f, from: item.at, to: item.at, runs: [] }]);
|
||||||
|
} catch { /* فرمولِ نامعتبر */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// نقطههای ویژهی نمودار (ریشه، رأس، …) که کد حساب میکند.
|
||||||
|
const markPts: { xy: XY; kind: FunctionMark | 'max' | 'min'; color: string }[] = [];
|
||||||
|
const axisLines: number[] = [];
|
||||||
|
for (const item of spec.items) {
|
||||||
|
if (item.type !== 'function' || !item.marks?.length) continue;
|
||||||
|
const c = curves.get(item)?.[0];
|
||||||
|
if (!c) continue;
|
||||||
|
const want = new Set(item.marks);
|
||||||
|
if (want.has('roots')) for (const r of roots(c.f, c.from, c.to).slice(0, 6)) markPts.push({ xy: [r, 0], kind: 'roots', color: COLORS.green });
|
||||||
|
if (want.has('vertex') || want.has('extrema') || want.has('axis')) {
|
||||||
|
const ex = extrema(c.f, c.from, c.to).slice(0, 6);
|
||||||
|
if (want.has('vertex') || want.has('extrema')) for (const e of ex) markPts.push({ xy: [e.x, e.y], kind: want.has('vertex') ? 'vertex' : e.kind, color: COLORS.red });
|
||||||
|
if (want.has('axis') && ex.length === 1) axisLines.push(ex[0].x);
|
||||||
|
}
|
||||||
|
if (want.has('yIntercept') && c.from <= 0 && c.to >= 0) {
|
||||||
|
const y = c.f(0);
|
||||||
|
if (Number.isFinite(y)) markPts.push({ xy: [0, y], kind: 'yIntercept', color: COLORS.orange });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── کادر ── */
|
||||||
|
const xs: number[] = [], ys: number[] = [];
|
||||||
|
const add = (p: XY | null) => { if (p) { xs.push(p[0]); ys.push(p[1]); } };
|
||||||
|
named.forEach((p) => add(p));
|
||||||
|
images.forEach((im) => im.pts.forEach(add));
|
||||||
|
markPts.forEach((m) => add(m.xy));
|
||||||
|
for (const item of spec.items) {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'circle': case 'arc': case 'sector': {
|
||||||
|
const c = ref(item.center);
|
||||||
|
const through = item.type === 'circle' && item.through ? ref(item.through) : null;
|
||||||
|
const r = item.type === 'circle' ? (through && c ? Math.hypot(through[0] - c[0], through[1] - c[1]) : item.r ?? 0) : item.r;
|
||||||
|
if (c) { add([c[0] - r, c[1] - r]); add([c[0] + r, c[1] + r]); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'segment': case 'arrow': case 'vector': add(ref(item.from)); add(ref(item.to)); break;
|
||||||
|
case 'ray': add(ref(item.from)); add(ref(item.through)); break;
|
||||||
|
case 'line': { const [a, b] = lineEnds(item, ref); if (!item.eq && !item.equation) { add(a); add(b); } else if (a) add(a); break; }
|
||||||
|
case 'polygon': item.points.forEach((p) => add(ref(p))); break;
|
||||||
|
case 'point': add(ref(item.point)); break;
|
||||||
|
case 'text': add(item.at); break;
|
||||||
|
case 'vline': xs.push(item.x); break;
|
||||||
|
case 'hline': ys.push(item.y); break;
|
||||||
|
case 'tangent': { const c = curves.get(item)?.[0]; if (c) add([item.at, c.f(item.at)]); break; }
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [item, cs] of curves) {
|
||||||
|
if (item.type === 'region' || item.type === 'tangent') continue;
|
||||||
|
for (const c of cs) {
|
||||||
|
xs.push(c.from, c.to);
|
||||||
|
const all = c.runs.flat().map((p) => p[1]).sort((a, b) => a - b);
|
||||||
|
if (all.length) ys.push(all[Math.floor(all.length * 0.04)], all[Math.ceil(all.length * 0.96) - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (spec.axes) { xs.push(0); ys.push(0); }
|
||||||
|
if (!xs.length || !ys.length) return null;
|
||||||
|
|
||||||
|
let [x0, x1] = spec.xRange ?? [Math.min(...xs), Math.max(...xs)];
|
||||||
|
let [y0, y1] = spec.yRange ?? [Math.min(...ys), Math.max(...ys)];
|
||||||
|
if (x1 - x0 < 1e-9) { x0 -= 1; x1 += 1; }
|
||||||
|
if (y1 - y0 < 1e-9) { y0 -= 1; y1 += 1; }
|
||||||
|
if (!spec.xRange) { const m = (x1 - x0) * 0.1 + 0.15; x0 -= m; x1 += m; }
|
||||||
|
if (!spec.yRange) { const m = (y1 - y0) * 0.1 + 0.15; y0 -= m; y1 += m; }
|
||||||
|
|
||||||
|
// مقیاسِ یکسان برای هندسه (دایره نباید بیضی شود)؛ برای نمودارِ تنها، ارتفاع سقف دارد.
|
||||||
|
const geometry = spec.items.some((i) => GEOMETRY.has(i.type));
|
||||||
|
const plotW = W - 2 * PAD;
|
||||||
|
let sx = plotW / (x1 - x0), sy = sx;
|
||||||
|
let H = (y1 - y0) * sy + 2 * PAD;
|
||||||
|
const [minH, maxH] = [170, 380];
|
||||||
|
if (H > maxH) {
|
||||||
|
if (geometry) { sy = sx = (maxH - 2 * PAD) / (y1 - y0); } else sy = (maxH - 2 * PAD) / (y1 - y0);
|
||||||
|
H = maxH;
|
||||||
|
} else if (H < minH && !geometry) {
|
||||||
|
sy = (minH - 2 * PAD) / (y1 - y0);
|
||||||
|
H = minH;
|
||||||
|
} else H = Math.max(H, 120);
|
||||||
|
const ox = (W - (x1 - x0) * sx) / 2, oy = (H - (y1 - y0) * sy) / 2;
|
||||||
|
const X = (x: number) => ox + (x - x0) * sx;
|
||||||
|
const Y = (y: number) => oy + (y1 - y) * sy;
|
||||||
|
const P = (p: XY): XY => [X(p[0]), Y(p[1])];
|
||||||
|
const path = (pts: XY[]) => pts.map((p, i) => `${i ? 'L' : 'M'}${X(p[0]).toFixed(2)},${Y(p[1]).toFixed(2)}`).join(' ');
|
||||||
|
|
||||||
|
const layers = { back: [] as ReactNode[], fill: [] as ReactNode[], mid: [] as ReactNode[], curves: [] as ReactNode[], marks: [] as ReactNode[], points: [] as ReactNode[], labels: [] as ReactNode[] };
|
||||||
|
const arrowColors = new Set<string>([SOFT]);
|
||||||
|
let key = 0;
|
||||||
|
const k = () => key++;
|
||||||
|
const clip = `url(#${clipId})`;
|
||||||
|
|
||||||
|
const centroid: XY = (() => {
|
||||||
|
const all = [...named.values()];
|
||||||
|
return all.length ? [all.reduce((a, p) => a + p[0], 0) / all.length, all.reduce((a, p) => a + p[1], 0) / all.length] : [(x0 + x1) / 2, (y0 + y1) / 2];
|
||||||
|
})();
|
||||||
|
const away = (p: XY, dist = 13): XY => {
|
||||||
|
const [px, py] = P(p), [cx, cy] = P(centroid);
|
||||||
|
let [dx, dy] = [px - cx, py - cy];
|
||||||
|
const n = Math.hypot(dx, dy);
|
||||||
|
if (n < 1e-6) { dx = 0.6; dy = -0.8; } else { dx /= n; dy /= n; }
|
||||||
|
return [px + dx * dist, py + dy * dist + 4];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── شبکه و محور ── */
|
||||||
|
const trig = spec.trig ?? false;
|
||||||
|
const xstep = trig ? (x1 - x0 > 5 * Math.PI ? Math.PI : Math.PI / 2) : spec.grid ? Math.max(1, niceStep(x1 - x0, 12)) : niceStep(x1 - x0, 8);
|
||||||
|
const ystep = spec.grid ? Math.max(1, niceStep(y1 - y0, 12)) : niceStep(y1 - y0, 7);
|
||||||
|
const xticks = ticksBetween(x0, x1, xstep), yticks = ticksBetween(y0, y1, ystep);
|
||||||
|
if (spec.axes || spec.grid) {
|
||||||
|
layers.back.push(
|
||||||
|
<g key={k()} stroke={GRID} strokeWidth={1}>
|
||||||
|
{xticks.map((t) => <line key={`gx${t}`} x1={X(t)} x2={X(t)} y1={Y(y0)} y2={Y(y1)} />)}
|
||||||
|
{yticks.map((t) => <line key={`gy${t}`} y1={Y(t)} y2={Y(t)} x1={X(x0)} x2={X(x1)} />)}
|
||||||
|
</g>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (spec.axes) {
|
||||||
|
const ax = x0 <= 0 && x1 >= 0 ? X(0) : X(x0), ay = y0 <= 0 && y1 >= 0 ? Y(0) : Y(y0);
|
||||||
|
layers.back.push(
|
||||||
|
<g key={k()} stroke={SOFT} strokeWidth={1.4}>
|
||||||
|
<line x1={X(x0)} y1={ay} x2={X(x1) + 6} y2={ay} markerEnd={`url(#${arrowId(SOFT)})`} />
|
||||||
|
<line x1={ax} y1={Y(y0)} x2={ax} y2={Y(y1) - 6} markerEnd={`url(#${arrowId(SOFT)})`} />
|
||||||
|
</g>,
|
||||||
|
<g key={k()} fill={SOFT} fontSize={9.5} fontWeight={600}>
|
||||||
|
{xticks.filter((t) => Math.abs(t) > 1e-9).map((t) => (
|
||||||
|
<g key={`tx${t}`}>
|
||||||
|
<line x1={X(t)} x2={X(t)} y1={ay - 3} y2={ay + 3} stroke={SOFT} />
|
||||||
|
<text x={X(t)} y={ay + 13} textAnchor="middle">{trig ? piLabel(t) : fa(t)}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{yticks.filter((t) => Math.abs(t) > 1e-9).map((t) => (
|
||||||
|
<g key={`ty${t}`}>
|
||||||
|
<line x1={ax - 3} x2={ax + 3} y1={Y(t)} y2={Y(t)} stroke={SOFT} />
|
||||||
|
<text x={ax - 6} y={Y(t) + 3.5} textAnchor="end">{fa(t)}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{x0 <= 0 && x1 >= 0 && y0 <= 0 && y1 >= 0 && <text x={ax - 6} y={ay + 13} textAnchor="end">۰</text>}
|
||||||
|
<text x={X(x1) + 2} y={ay - 7} textAnchor="end" fontStyle="italic">x</text>
|
||||||
|
<text x={ax + 7} y={Y(y1) + 4} fontStyle="italic">y</text>
|
||||||
|
</g>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── تکهها ── */
|
||||||
|
for (const item of spec.items) {
|
||||||
|
const itemColor = (fallback: string) => color(item.color, fallback);
|
||||||
|
switch (item.type) {
|
||||||
|
case 'circle': {
|
||||||
|
const c = ref(item.center);
|
||||||
|
if (!c) break;
|
||||||
|
const through = item.through ? ref(item.through) : null;
|
||||||
|
const r = through ? Math.hypot(through[0] - c[0], through[1] - c[1]) : item.r ?? 0;
|
||||||
|
if (r <= 0) break;
|
||||||
|
const col = itemColor(COLORS.blue);
|
||||||
|
layers.mid.push(<ellipse key={k()} cx={X(c[0])} cy={Y(c[1])} rx={r * sx} ry={r * sy} fill={item.fill ? `${col}1f` : 'none'} stroke={col} strokeWidth={2.2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
|
||||||
|
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(c[0] + r * 0.72)} y={Y(c[1] + r * 0.72) - 4} text={item.label} fill={col} />);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'arc': case 'sector': {
|
||||||
|
const c = ref(item.center);
|
||||||
|
if (!c) break;
|
||||||
|
const col = itemColor(item.type === 'sector' ? COLORS.orange : COLORS.red);
|
||||||
|
const a0 = (item.start * Math.PI) / 180, a1 = (item.end * Math.PI) / 180;
|
||||||
|
const pts: XY[] = [];
|
||||||
|
for (let i = 0; i <= 64; i++) { const t = a0 + ((a1 - a0) * i) / 64; pts.push([c[0] + item.r * Math.cos(t), c[1] + item.r * Math.sin(t)]); }
|
||||||
|
const d = item.type === 'sector' ? `${path([c, ...pts])} Z` : path(pts);
|
||||||
|
layers.mid.push(<path key={k()} d={d} fill={item.type === 'sector' ? `${col}33` : 'none'} stroke={col} strokeWidth={item.type === 'arc' ? 3 : 2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
|
||||||
|
if (item.label) {
|
||||||
|
const m = (a0 + a1) / 2, rr = item.type === 'arc' ? item.r * 1.16 : item.r * 0.6;
|
||||||
|
layers.labels.push(<RichLabel key={k()} x={X(c[0] + rr * Math.cos(m))} y={Y(c[1] + rr * Math.sin(m)) + 4} text={item.label} fill={col} />);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'polygon': {
|
||||||
|
const pts = item.points.map(ref).filter((p): p is XY => p !== null);
|
||||||
|
if (pts.length < 3) break;
|
||||||
|
const col = itemColor(COLORS.blue);
|
||||||
|
layers.fill.push(<path key={k()} d={`${path(pts)} Z`} fill={item.fill === false ? 'none' : `${col}1a`} stroke={item.color ? col : INK} strokeWidth={2.2} strokeLinejoin="round" strokeDasharray={item.dashed ? '6 5' : undefined} />);
|
||||||
|
if (item.label) {
|
||||||
|
const cx = pts.reduce((a, p) => a + p[0], 0) / pts.length, cy = pts.reduce((a, p) => a + p[1], 0) / pts.length;
|
||||||
|
layers.labels.push(<RichLabel key={k()} x={X(cx)} y={Y(cy) + 4} text={item.label} fill={col} />);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'segment': case 'arrow': case 'vector': {
|
||||||
|
const a = ref(item.from), b = ref(item.to);
|
||||||
|
if (!a || !b) break;
|
||||||
|
const isArrow = item.type !== 'segment';
|
||||||
|
const col = itemColor(isArrow ? (item.type === 'vector' ? COLORS.purple : COLORS.red) : INK);
|
||||||
|
if (isArrow) arrowColors.add(col);
|
||||||
|
const [ax, ay] = P(a), [bx, by] = P(b);
|
||||||
|
layers.mid.push(<line key={k()} x1={ax} y1={ay} x2={bx} y2={by} stroke={col} strokeWidth={isArrow ? 2.4 : 2.2} strokeLinecap="round"
|
||||||
|
strokeDasharray={item.dashed ? '6 5' : undefined} markerEnd={isArrow ? `url(#${arrowId(col)})` : undefined} />);
|
||||||
|
if (item.type === 'segment' && item.ticks) layers.mid.push(<Ticks key={k()} a={[ax, ay]} b={[bx, by]} n={item.ticks} col={col} />);
|
||||||
|
if (item.label) {
|
||||||
|
const [lx, ly, anchor] = sideOffset([ax, ay], [bx, by], P(centroid));
|
||||||
|
layers.labels.push(<RichLabel key={k()} x={lx} y={ly} anchor={anchor} text={item.label} fill={item.color ? col : COLORS.blue} />);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'line': case 'ray': {
|
||||||
|
const [a, b] = item.type === 'line' ? lineEnds(item, ref) : [ref(item.from), ref(item.through)];
|
||||||
|
if (!a || !b || (a[0] === b[0] && a[1] === b[1])) break;
|
||||||
|
const col = itemColor(COLORS.blue);
|
||||||
|
const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
|
||||||
|
const t = (Math.abs(x1 - x0) + Math.abs(y1 - y0)) * 3 / Math.hypot(dx, dy);
|
||||||
|
const s: XY = item.type === 'line' ? [a[0] - dx * t, a[1] - dy * t] : a;
|
||||||
|
const e: XY = [a[0] + dx * t, a[1] + dy * t];
|
||||||
|
layers.mid.push(<line key={k()} clipPath={clip} x1={X(s[0])} y1={Y(s[1])} x2={X(e[0])} y2={Y(e[1])} stroke={col} strokeWidth={2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
|
||||||
|
if (item.label) {
|
||||||
|
const lp = edgePoint(a, b, x0, x1, y0, y1);
|
||||||
|
layers.labels.push(<RichLabel key={k()} x={X(lp[0])} y={Y(lp[1]) - 6} text={item.label} fill={col} />);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'vline': case 'hline': {
|
||||||
|
const col = itemColor(COLORS.gray);
|
||||||
|
const dash = item.dashed === false ? undefined : '5 4';
|
||||||
|
if (item.type === 'vline') {
|
||||||
|
layers.mid.push(<line key={k()} x1={X(item.x)} x2={X(item.x)} y1={Y(y0)} y2={Y(y1)} stroke={col} strokeWidth={1.8} strokeDasharray={dash} />);
|
||||||
|
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(item.x) + 4} y={Y(y1) + 12} text={item.label} fill={col} />);
|
||||||
|
} else {
|
||||||
|
layers.mid.push(<line key={k()} y1={Y(item.y)} y2={Y(item.y)} x1={X(x0)} x2={X(x1)} stroke={col} strokeWidth={1.8} strokeDasharray={dash} />);
|
||||||
|
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(x0) + 30} y={Y(item.y) - 7} text={item.label} fill={col} />);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'function': case 'piecewise': {
|
||||||
|
const cs = curves.get(item);
|
||||||
|
if (!cs) break;
|
||||||
|
const col = nextColor(item.color);
|
||||||
|
cs.forEach((c) => c.runs.forEach((run) => layers.curves.push(
|
||||||
|
<path key={k()} clipPath={clip} d={path(run)} fill="none" stroke={col} strokeWidth={2.6} strokeLinejoin="round" strokeLinecap="round" strokeDasharray={item.dashed ? '7 5' : undefined} />,
|
||||||
|
)));
|
||||||
|
if (item.type === 'piecewise') {
|
||||||
|
item.pieces.forEach((p) => {
|
||||||
|
let f: (x: number) => number;
|
||||||
|
try { f = parseExpr(p.expr); } catch { return; }
|
||||||
|
([[p.from, p.closedFrom ?? true], [p.to, p.closedTo ?? true]] as [number, boolean][]).forEach(([x, closed]) => {
|
||||||
|
const y = f(x);
|
||||||
|
if (Number.isFinite(y) && Math.abs(x) < 1e3) layers.points.push(<circle key={k()} cx={X(x)} cy={Y(y)} r={4} fill={closed ? col : '#fff'} stroke={col} strokeWidth={2} />);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (item.label) {
|
||||||
|
// سرِ راستِ بخشِ دیدهشدهی نمودار، کمی دورتر از خودِ خط.
|
||||||
|
const vis = cs.flatMap((c) => c.runs.flat()).filter(([x, y]) => x >= x0 && x <= x1 && y >= y0 + (y1 - y0) * 0.04 && y <= y1 - (y1 - y0) * 0.04);
|
||||||
|
const at = vis[vis.length - 1];
|
||||||
|
if (at) {
|
||||||
|
const slope = vis.length > 2 ? (Y(at[1]) - Y(vis[vis.length - 3][1])) : 0;
|
||||||
|
layers.labels.push(<RichLabel key={k()} x={Math.min(W - 44, Math.max(44, X(at[0]) - 30))} y={Math.max(14, Math.min(H - 8, Y(at[1]) + (slope < 0 ? 16 : -10)))} text={item.label} fill={col} />);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'tangent': {
|
||||||
|
const c = curves.get(item)?.[0];
|
||||||
|
if (!c) break;
|
||||||
|
const col = itemColor(COLORS.red);
|
||||||
|
const y = c.f(item.at), m = derivative(c.f, item.at);
|
||||||
|
const span = (x1 - x0) * 2;
|
||||||
|
layers.curves.push(<line key={k()} clipPath={clip} x1={X(item.at - span)} y1={Y(y - m * span)} x2={X(item.at + span)} y2={Y(y + m * span)} stroke={col} strokeWidth={2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
|
||||||
|
layers.points.push(<circle key={k()} cx={X(item.at)} cy={Y(y)} r={4.2} fill={col} stroke="#fff" strokeWidth={1.5} />);
|
||||||
|
const text = item.label ?? `شیب = ${fa(m)}`;
|
||||||
|
layers.labels.push(<RichLabel key={k()} x={Math.min(W - 44, X(item.at) + 36)} y={Y(y) + (m > 0 ? 18 : -10)} text={text} fill={col} />);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'area': {
|
||||||
|
const cs = curves.get(item);
|
||||||
|
if (!cs) break;
|
||||||
|
const col = nextColor(item.color);
|
||||||
|
const top = cs[0].runs.flat();
|
||||||
|
const bottom: XY[] = cs[1] ? cs[1].runs.flat().slice().reverse() : [[item.to, 0], [item.from, 0]];
|
||||||
|
layers.fill.push(<path key={k()} clipPath={clip} d={`${path([...top, ...bottom])} Z`} fill={`${col}38`} stroke="none" />);
|
||||||
|
if (item.label) {
|
||||||
|
const mid = top[Math.floor(top.length / 2)];
|
||||||
|
layers.labels.push(<RichLabel key={k()} x={X(mid[0])} y={(Y(mid[1]) + Y(cs[1] ? cs[1].f(mid[0]) : 0)) / 2 + 4} text={item.label} fill={col} />);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'region': {
|
||||||
|
const c = curves.get(item)?.[0];
|
||||||
|
if (!c) break;
|
||||||
|
const col = itemColor(COLORS.blue);
|
||||||
|
const edge = item.side === 'above' ? y1 + (y1 - y0) : y0 - (y1 - y0);
|
||||||
|
c.runs.forEach((run) => {
|
||||||
|
const poly: XY[] = [...run, [run[run.length - 1][0], edge], [run[0][0], edge]];
|
||||||
|
layers.fill.push(<path key={k()} clipPath={clip} d={`${path(poly)} Z`} fill={`${col}26`} stroke="none" />);
|
||||||
|
layers.curves.push(<path key={k()} clipPath={clip} d={path(run)} fill="none" stroke={col} strokeWidth={2.2} strokeDasharray={item.strict ? '6 5' : undefined} />);
|
||||||
|
});
|
||||||
|
if (item.label) {
|
||||||
|
// جایی داخلِ ناحیه: بینِ نمودار و لبهی کادر، در یکچهارمِ عرض از سمتی که ناحیه پهنتر است.
|
||||||
|
const candidates = [0.2, 0.5, 0.8].map((t) => x0 + (x1 - x0) * t);
|
||||||
|
let best = candidates[0], room = -Infinity;
|
||||||
|
for (const x of candidates) {
|
||||||
|
const y = c.f(x);
|
||||||
|
const r = item.side === 'above' ? y1 - y : y - y0;
|
||||||
|
if (Number.isFinite(r) && r > room) { room = r; best = x; }
|
||||||
|
}
|
||||||
|
const yc = c.f(best);
|
||||||
|
const yl = item.side === 'above' ? (Math.min(yc, y1) + y1) / 2 : (Math.max(yc, y0) + y0) / 2;
|
||||||
|
layers.labels.push(<RichLabel key={k()} x={X(best)} y={Y(yl)} text={item.label} fill={col} />);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'angle': case 'rightAngle': {
|
||||||
|
const v = ref(item.vertex), a = ref(item.from), b = ref(item.to);
|
||||||
|
if (!v || !a || !b) break;
|
||||||
|
const col = itemColor(COLORS.red);
|
||||||
|
const [vx, vy] = P(v), [ax, ay] = P(a), [bx, by] = P(b);
|
||||||
|
const ta = Math.atan2(ay - vy, ax - vx), tb = Math.atan2(by - vy, bx - vx);
|
||||||
|
let d = tb - ta;
|
||||||
|
while (d <= -Math.PI) d += 2 * Math.PI;
|
||||||
|
while (d > Math.PI) d -= 2 * Math.PI;
|
||||||
|
const r = Math.max(12, Math.min(26, Math.hypot(ax - vx, ay - vy) * 0.35, Math.hypot(bx - vx, by - vy) * 0.35));
|
||||||
|
if (item.type === 'rightAngle') {
|
||||||
|
const q = Math.min(r, 12);
|
||||||
|
const ua: XY = [Math.cos(ta), Math.sin(ta)], ub: XY = [Math.cos(tb), Math.sin(tb)];
|
||||||
|
const p1: XY = [vx + ua[0] * q, vy + ua[1] * q], p3: XY = [vx + ub[0] * q, vy + ub[1] * q];
|
||||||
|
const p2: XY = [p1[0] + ub[0] * q, p1[1] + ub[1] * q];
|
||||||
|
layers.marks.push(<path key={k()} d={`M${p1.join(',')} L${p2.join(',')} L${p3.join(',')}`} fill={`${col}22`} stroke={col} strokeWidth={1.8} />);
|
||||||
|
} else {
|
||||||
|
const arcs = Math.max(1, Math.min(3, item.arcs ?? 1));
|
||||||
|
for (let i = 0; i < arcs; i++) {
|
||||||
|
const rr = r + i * 4;
|
||||||
|
const s: XY = [vx + rr * Math.cos(ta), vy + rr * Math.sin(ta)], e: XY = [vx + rr * Math.cos(ta + d), vy + rr * Math.sin(ta + d)];
|
||||||
|
layers.marks.push(
|
||||||
|
<path key={k()} d={i === 0 ? `M${vx},${vy} L${s.join(',')} A${rr},${rr} 0 0 ${d > 0 ? 1 : 0} ${e.join(',')} Z` : `M${s.join(',')} A${rr},${rr} 0 0 ${d > 0 ? 1 : 0} ${e.join(',')}`}
|
||||||
|
fill={i === 0 ? `${col}2a` : 'none'} stroke={col} strokeWidth={1.7} />,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.label) {
|
||||||
|
const m = ta + d / 2, lr = r + 15 + ((item.type === 'angle' ? item.arcs ?? 1 : 1) - 1) * 4;
|
||||||
|
layers.labels.push(<RichLabel key={k()} x={vx + lr * Math.cos(m)} y={vy + lr * Math.sin(m) + 4} text={item.label} fill={col} />);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
layers.labels.push(<RichLabel key={k()} x={X(item.at[0])} y={Y(item.at[1])} text={item.text} fill={color(item.color, INK)} />);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── تصویرِ تبدیلها ── */
|
||||||
|
for (const im of images) {
|
||||||
|
const col = color(im.item.color, COLORS.purple);
|
||||||
|
if (im.pts.length >= 3) layers.fill.push(<path key={k()} d={`${path(im.pts)} Z`} fill={`${col}14`} stroke={col} strokeWidth={2.2} strokeDasharray={im.item.dashed === false ? undefined : '6 4'} strokeLinejoin="round" />);
|
||||||
|
else if (im.pts.length === 2) layers.mid.push(<line key={k()} x1={X(im.pts[0][0])} y1={Y(im.pts[0][1])} x2={X(im.pts[1][0])} y2={Y(im.pts[1][1])} stroke={col} strokeWidth={2.2} strokeDasharray="6 4" />);
|
||||||
|
im.pts.forEach((p, i) => {
|
||||||
|
layers.points.push(<circle key={k()} cx={X(p[0])} cy={Y(p[1])} r={3.4} fill={col} />);
|
||||||
|
if (im.names[i]) { const [lx, ly] = away(p); layers.labels.push(<Label key={k()} x={lx} y={ly} color={col} weight={800}>{im.names[i]}</Label>); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── نقطههای ویژهی نمودار ── */
|
||||||
|
for (const x of axisLines) {
|
||||||
|
layers.mid.push(<line key={k()} x1={X(x)} x2={X(x)} y1={Y(y0)} y2={Y(y1)} stroke={COLORS.gray} strokeWidth={1.6} strokeDasharray="5 4" />);
|
||||||
|
layers.labels.push(<Label key={k()} x={X(x)} y={Y(y1) + 12} color={COLORS.gray} size={10}>{`x = ${fa(x)}`}</Label>);
|
||||||
|
}
|
||||||
|
const markName: Record<string, string> = { vertex: 'رأس', max: 'ماکزیمم', min: 'مینیمم', yIntercept: '', roots: '' };
|
||||||
|
// عددِ نزدیک به مضربِ π/2 در نمودارِ مثلثاتی به صورتِ π نوشته میشود (۳٫۱۴ → π).
|
||||||
|
const fx = (x: number) => (trig && Math.abs(x / (Math.PI / 2) - Math.round(x / (Math.PI / 2))) < 1e-6 ? piLabel(x) : fa(x));
|
||||||
|
// بیش از چهار نقطه از یک نوع (ریشههای سینوس) فقط نقطه میگیرند، نه برچسب؛ وگرنه روی هم میافتند.
|
||||||
|
const kindCount = new Map<string, number>();
|
||||||
|
markPts.forEach((m) => kindCount.set(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind, (kindCount.get(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind) ?? 0) + 1));
|
||||||
|
// اگر مدل خودش روی همان ریشه یا رأس نقطه گذاشته (نامدار یا با مختصات)، برچسبِ ما
|
||||||
|
// تکرار نشود؛ وگرنه «(۳، ۰)» دو بار و روی هم نوشته میشد.
|
||||||
|
const namedXY = [
|
||||||
|
...named.values(),
|
||||||
|
...spec.items.flatMap((it) => (it.type === 'point' && typeof it.point !== 'string' ? [it.point as XY] : [])),
|
||||||
|
];
|
||||||
|
for (const m of markPts) {
|
||||||
|
if (namedXY.some((q) => Math.abs(q[0] - m.xy[0]) < 1e-3 && Math.abs(q[1] - m.xy[1]) < 1e-3)) continue;
|
||||||
|
const [px, py] = P(m.xy);
|
||||||
|
if (px < 0 || px > W || py < 0 || py > H) continue;
|
||||||
|
layers.points.push(<circle key={k()} cx={px} cy={py} r={4.3} fill={m.color} stroke="#fff" strokeWidth={1.6} />);
|
||||||
|
const crowded = (kindCount.get(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind) ?? 0) > 4;
|
||||||
|
if (crowded) continue;
|
||||||
|
const coord = ltr(`(${fx(m.xy[0])}, ${fa(m.xy[1])})`);
|
||||||
|
const below = m.kind === 'min' || m.kind === 'vertex' ? m.xy[1] <= (y0 + y1) / 2 : m.kind === 'roots';
|
||||||
|
const ly = below ? py + 16 : py - 9;
|
||||||
|
const name = markName[m.kind];
|
||||||
|
layers.labels.push(
|
||||||
|
<g key={k()}>
|
||||||
|
{name && <Label x={px} y={below ? ly + 12 : ly - 12} color={m.color} size={10}>{name}</Label>}
|
||||||
|
<text x={px} y={ly} fontSize={10} fontWeight={800} fill={m.color} textAnchor="middle" {...bidi(coord)} stroke={PAPER} strokeWidth={3} paintOrder="stroke">{coord}</text>
|
||||||
|
</g>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── نقطهها ── */
|
||||||
|
const explicit = new Map<string, Extract<PlaneItem, { type: 'point' }>>();
|
||||||
|
const loosePoints: Extract<PlaneItem, { type: 'point' }>[] = [];
|
||||||
|
for (const item of spec.items) {
|
||||||
|
if (item.type !== 'point') continue;
|
||||||
|
if (typeof item.point === 'string') explicit.set(item.point, item); else loosePoints.push(item);
|
||||||
|
}
|
||||||
|
const drawPoint = (xy: XY, label: string, col: string, open = false) => {
|
||||||
|
const [px, py] = P(xy);
|
||||||
|
layers.points.push(<circle key={k()} cx={px} cy={py} r={open ? 4 : 3.8} fill={open ? '#fff' : col} stroke={open ? col : '#fff'} strokeWidth={open ? 2 : 1.4} />);
|
||||||
|
if (label) { const [lx, ly] = away(xy, 14); layers.labels.push(<RichLabel key={k()} x={lx} y={ly} text={label} fill={col} />); }
|
||||||
|
};
|
||||||
|
const imageNames = new Set(images.flatMap((im) => im.names));
|
||||||
|
// نقطهای که هیچ تکهای به آن ارجاع نداده رسم نمیشود (مدل هفت نقطهی امتداد تعریف کرده
|
||||||
|
// بود که هیچجا به کار نرفتند و شکل را شلوغ کردند) — مگر شکل فقط از نقطه ساخته شده باشد.
|
||||||
|
const used = referencedNames(spec);
|
||||||
|
const onlyPoints = spec.items.length === 0;
|
||||||
|
named.forEach((xy, name) => {
|
||||||
|
if (name.startsWith('_') || imageNames.has(name)) return;
|
||||||
|
if (!onlyPoints && !used.has(name)) return;
|
||||||
|
const it = explicit.get(name);
|
||||||
|
// مبدأ روی محور خودش «۰» دارد؛ فقط اگر مدل صریحاً نقطهاش را خواسته باشد برچسب میگیرد.
|
||||||
|
if (spec.axes && !it && Math.abs(xy[0]) < 1e-9 && Math.abs(xy[1]) < 1e-9) return;
|
||||||
|
drawPoint(xy, it?.label ?? name, color(it?.color, INK), it?.open);
|
||||||
|
});
|
||||||
|
loosePoints.forEach((it) => drawPoint(it.point as XY, it.label ?? '', color(it.color, INK), it.open));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img" aria-label={spec.title || 'شکل'}>
|
||||||
|
<defs>
|
||||||
|
<clipPath id={clipId}><rect x={X(x0)} y={Y(y1)} width={(x1 - x0) * sx} height={(y1 - y0) * sy} /></clipPath>
|
||||||
|
</defs>
|
||||||
|
<Arrowheads ids={[...arrowColors]} />
|
||||||
|
{layers.back}{layers.fill}{layers.mid}{layers.curves}{layers.marks}{layers.points}{layers.labels}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* دو نقطه از خط: یا through، یا از معادلهی ax + by = c (ضریبها از صورتِ مسئله؛
|
||||||
|
* شیب را کد حساب میکند، چون مدل در بازنویسیِ 3x + 5y = 4 به y = … علامت را گم کرده بود).
|
||||||
|
*/
|
||||||
|
function lineEnds(item: Extract<PlaneItem, { type: 'line' }>, ref: (r: Ref) => XY | null): [XY | null, XY | null] {
|
||||||
|
const coef = item.equation ? parseLinear(item.equation) : item.eq ?? null;
|
||||||
|
if (coef) {
|
||||||
|
// ax + by + c = 0
|
||||||
|
const [a, b, c] = coef;
|
||||||
|
if (Math.abs(b) > 1e-12) return [[0, -c / b], [1, (-c - a) / b]];
|
||||||
|
if (Math.abs(a) > 1e-12) return [[-c / a, 0], [-c / a, 1]];
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
return item.through ? [ref(item.through[0]), ref(item.through[1])] : [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «3x + 5y = 4»، «y = 12x − 16»، «2y = x + 1»، «x = 2.5» → [a, b, c] با ax + by + c = 0.
|
||||||
|
* هر چیزِ غیرخطی (x²، sin) null است و خط کشیده نمیشود.
|
||||||
|
*/
|
||||||
|
export function parseLinear(src: string): [number, number, number] | null {
|
||||||
|
const text = toLatin(src).replace(/\s+/g, '').replace(/[−–]/g, '-').replace(/\*/g, '');
|
||||||
|
const sides = text.split('=');
|
||||||
|
if (sides.length !== 2) return null;
|
||||||
|
const side = (t: string): [number, number, number] | null => {
|
||||||
|
if (!t) return null;
|
||||||
|
const terms = t.replace(/-/g, '+-').split('+').filter(Boolean);
|
||||||
|
const acc: [number, number, number] = [0, 0, 0];
|
||||||
|
for (const term of terms) {
|
||||||
|
const m = /^(-?)(\d*\.?\d*)(?:\/(\d+\.?\d*))?([xy]?)$/.exec(term);
|
||||||
|
if (!m || (!m[2] && !m[4])) return null;
|
||||||
|
let v = m[2] ? Number(m[2]) : 1;
|
||||||
|
if (m[3]) v /= Number(m[3]);
|
||||||
|
if (m[1]) v = -v;
|
||||||
|
acc[m[4] === 'x' ? 0 : m[4] === 'y' ? 1 : 2] += v;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
};
|
||||||
|
const l = side(sides[0]), r = side(sides[1]);
|
||||||
|
if (!l || !r) return null;
|
||||||
|
const out: [number, number, number] = [l[0] - r[0], l[1] - r[1], l[2] - r[2]];
|
||||||
|
return Math.abs(out[0]) + Math.abs(out[1]) > 1e-12 ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toLatin = (s: string) => s.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
|
||||||
|
|
||||||
|
/** علامتِ تساویِ ضلع: یک، دو یا سه خطِ کوچک عمود بر وسطِ پارهخط. */
|
||||||
|
function Ticks({ a, b, n, col }: { a: XY; b: XY; n: number; col: string }) {
|
||||||
|
const [mx, my] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||||
|
const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
|
||||||
|
const [ux, uy] = [(b[0] - a[0]) / len, (b[1] - a[1]) / len];
|
||||||
|
const [nx, ny] = [-uy, ux];
|
||||||
|
const count = Math.max(1, Math.min(3, n));
|
||||||
|
return (
|
||||||
|
<g stroke={col} strokeWidth={2}>
|
||||||
|
{Array.from({ length: count }, (_, i) => {
|
||||||
|
const o = (i - (count - 1) / 2) * 4.5;
|
||||||
|
const cx = mx + ux * o, cy = my + uy * o;
|
||||||
|
return <line key={i} x1={cx - nx * 6} y1={cy - ny * 6} x2={cx + nx * 6} y2={cy + ny * 6} />;
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جای برچسبِ ضلع: وسطِ پارهخط، به سمتِ بیرونِ شکل. برای خطِ تقریباً عمودی برچسب
|
||||||
|
* از کنارِ خط شروع میشود (نه وسطچین)، وگرنه متنِ بلند روی خودِ خط میافتد.
|
||||||
|
*/
|
||||||
|
function sideOffset(a: XY, b: XY, center: XY): [number, number, 'start' | 'middle' | 'end'] {
|
||||||
|
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
|
||||||
|
let nx = -(b[1] - a[1]), ny = b[0] - a[0];
|
||||||
|
const n = Math.hypot(nx, ny) || 1;
|
||||||
|
nx /= n; ny /= n;
|
||||||
|
if ((mx - center[0]) * nx + (my - center[1]) * ny < 0) { nx = -nx; ny = -ny; }
|
||||||
|
const anchor = nx > 0.6 ? 'start' : nx < -0.6 ? 'end' : 'middle';
|
||||||
|
const d = anchor === 'middle' ? 14 : 8;
|
||||||
|
return [mx + nx * d, my + ny * d + 4, anchor];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نقطهای از خط که داخلِ کادر و نزدیکِ لبهی راستِ آن است، برای برچسبِ خط. */
|
||||||
|
function edgePoint(a: XY, b: XY, x0: number, x1: number, y0: number, y1: number): XY {
|
||||||
|
let [dx, dy] = [b[0] - a[0], b[1] - a[1]];
|
||||||
|
const n = Math.hypot(dx, dy) || 1;
|
||||||
|
[dx, dy] = [dx / n, dy / n];
|
||||||
|
if (dx < 0 || (dx === 0 && dy < 0)) [dx, dy] = [-dx, -dy];
|
||||||
|
const inside = (p: XY) => p[0] > x0 + (x1 - x0) * 0.1 && p[0] < x1 - (x1 - x0) * 0.1 && p[1] > y0 + (y1 - y0) * 0.1 && p[1] < y1 - (y1 - y0) * 0.1;
|
||||||
|
const span = Math.hypot(x1 - x0, y1 - y0);
|
||||||
|
let best: XY | null = null;
|
||||||
|
for (let t = -span * 2; t <= span * 2; t += span / 200) {
|
||||||
|
const p: XY = [a[0] + dx * t, a[1] + dy * t];
|
||||||
|
if (inside(p)) best = p; // آخرین نقطهی داخلِ کادر در جهتِ راست
|
||||||
|
}
|
||||||
|
return best ?? [(x0 + x1) / 2, (y0 + y1) / 2];
|
||||||
|
}
|
||||||
166
src/diagram/sets.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useId } from 'react';
|
||||||
|
import type { MappingSpec, TreeNode, TreeSpec, VennSpec } from './types';
|
||||||
|
import { Arrowheads, COLORS, INK, Label, SERIES, arrowId, mathText } from './svg';
|
||||||
|
|
||||||
|
const W = 320;
|
||||||
|
|
||||||
|
/* ─── نمودارِ ون ──────────────────────────────────────────────────────────
|
||||||
|
* ناحیهها با نامِ مجموعههایی که در آناند: «A» فقط A، «AB» اشتراکِ A و B (و نه
|
||||||
|
* C)، «ABC»، و «out» بیرونِ همه. سایه با برشِ تو در تو (clipPath) و ماسکِ
|
||||||
|
* «نه-این-مجموعه» ساخته میشود، پس ناحیهی سایهخورده دقیقاً همان ناحیه است. */
|
||||||
|
|
||||||
|
export function Venn({ spec }: { spec: VennSpec }) {
|
||||||
|
const uid = useId().replace(/:/g, '');
|
||||||
|
const sets = spec.sets.slice(0, 3);
|
||||||
|
if (sets.length < 1) return null;
|
||||||
|
const H = sets.length === 3 ? 250 : 190;
|
||||||
|
const R = sets.length === 3 ? 62 : 70;
|
||||||
|
const centers: [number, number][] = sets.length === 1 ? [[W / 2, 95]]
|
||||||
|
: sets.length === 2 ? [[W / 2 - 45, 95], [W / 2 + 45, 95]]
|
||||||
|
: [[W / 2 - 40, 95], [W / 2 + 40, 95], [W / 2, 160]];
|
||||||
|
const ids = sets.map((_, i) => `v${uid}-${i}`);
|
||||||
|
const keyOf = (inside: number[]) => inside.map((i) => sets[i]).join('');
|
||||||
|
// مرکزِ تقریبیِ هر ناحیه برای نوشتنِ اعضا
|
||||||
|
const regionCenter = (code: string): [number, number] => {
|
||||||
|
if (code === 'out') return [22, H - 16];
|
||||||
|
const inside = sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0);
|
||||||
|
const all = centers.map((_, i) => i);
|
||||||
|
const cxIn = inside.reduce((a, i) => a + centers[i][0], 0) / inside.length, cyIn = inside.reduce((a, i) => a + centers[i][1], 0) / inside.length;
|
||||||
|
const outside = all.filter((i) => !inside.includes(i));
|
||||||
|
if (!outside.length || inside.length > 1) {
|
||||||
|
if (inside.length === sets.length) return [cxIn, cyIn];
|
||||||
|
const o = outside.length ? centers[outside[0]] : [cxIn, cyIn];
|
||||||
|
return [cxIn + (cxIn - o[0]) * 0.18, cyIn + (cyIn - o[1]) * 0.18];
|
||||||
|
}
|
||||||
|
const ox = outside.reduce((a, i) => a + centers[i][0], 0) / outside.length, oy = outside.reduce((a, i) => a + centers[i][1], 0) / outside.length;
|
||||||
|
const dx = cxIn - ox, dy = cyIn - oy, n = Math.hypot(dx, dy) || 1;
|
||||||
|
return [cxIn + (dx / n) * R * 0.42, cyIn + (dy / n) * R * 0.42];
|
||||||
|
};
|
||||||
|
const shaded = (spec.shade ?? []).filter((c) => c === 'out' || [...c].every((ch) => sets.includes(ch)));
|
||||||
|
|
||||||
|
const shadeRegion = (code: string, idx: number) => {
|
||||||
|
const fill = `${COLORS.orange}66`;
|
||||||
|
if (code === 'out') {
|
||||||
|
return (
|
||||||
|
<g key={`s${idx}`}>
|
||||||
|
<mask id={`${ids[0]}-out`}><rect x={0} y={0} width={W} height={H} fill="#fff" />{centers.map((c, i) => <circle key={i} cx={c[0]} cy={c[1]} r={R} fill="#000" />)}</mask>
|
||||||
|
<rect x={4} y={4} width={W - 8} height={H - 8} fill={fill} mask={`url(#${ids[0]}-out)`} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const inside = sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0);
|
||||||
|
const outside = sets.map((_, i) => i).filter((i) => !inside.includes(i));
|
||||||
|
const mid = `${ids[0]}-m${idx}`;
|
||||||
|
let node = (
|
||||||
|
<g>
|
||||||
|
{outside.length > 0 && <mask id={mid}><rect x={0} y={0} width={W} height={H} fill="#fff" />{outside.map((i) => <circle key={i} cx={centers[i][0]} cy={centers[i][1]} r={R} fill="#000" />)}</mask>}
|
||||||
|
<rect x={0} y={0} width={W} height={H} fill={fill} mask={outside.length ? `url(#${mid})` : undefined} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
for (const i of inside) node = <g clipPath={`url(#${ids[i]})`}>{node}</g>;
|
||||||
|
return <g key={`s${idx}`}>{node}</g>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
|
||||||
|
<defs>{centers.map((c, i) => <clipPath key={i} id={ids[i]}><circle cx={c[0]} cy={c[1]} r={R} /></clipPath>)}</defs>
|
||||||
|
<rect x={4} y={4} width={W - 8} height={H - 8} rx={10} fill="none" stroke={INK} strokeWidth={1.5} />
|
||||||
|
<text x={W - 12} y={20} fontSize={12} fontWeight={800} fill={INK} textAnchor="end" fontStyle="italic">U</text>
|
||||||
|
{shaded.map(shadeRegion)}
|
||||||
|
{centers.map((c, i) => {
|
||||||
|
const col = COLORS[SERIES[i]];
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
<circle cx={c[0]} cy={c[1]} r={R} fill={`${col}14`} stroke={col} strokeWidth={2.4} />
|
||||||
|
<Label x={c[0] + (i === 1 ? R * 0.72 : i === 0 ? -R * 0.72 : 0)} y={i === 2 ? c[1] + R + 14 : c[1] - R * 0.78} color={col} size={spec.names?.[i] ? 11.5 : 13} weight={900}>{spec.names?.[i] ?? sets[i]}</Label>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{Object.entries(spec.regions).map(([code, members]) => {
|
||||||
|
const valid = code === 'out' || [...code].every((ch) => sets.includes(ch));
|
||||||
|
if (!valid || !members.length) return null;
|
||||||
|
const [x, y] = regionCenter(code === 'out' ? 'out' : keyOf(sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0)));
|
||||||
|
const lines = chunk(members.slice(0, 12), 3);
|
||||||
|
return lines.map((line, li) => (
|
||||||
|
<Label key={`${code}${li}`} x={x} y={y + (li - (lines.length - 1) / 2) * 14 + 4} color={INK} size={11} anchor={code === 'out' ? 'start' : 'middle'}>
|
||||||
|
{mathText(line.join('، '))}
|
||||||
|
</Label>
|
||||||
|
));
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = <T,>(xs: T[], n: number): T[][] => Array.from({ length: Math.ceil(xs.length / n) }, (_, i) => xs.slice(i * n, i * n + n));
|
||||||
|
|
||||||
|
/* ─── نمودارِ پیکانی (تابع از A به B) ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function Mapping({ spec }: { spec: MappingSpec }) {
|
||||||
|
const L = spec.left.slice(0, 10), Rt = spec.right.slice(0, 10);
|
||||||
|
const rows = Math.max(L.length, Rt.length, 1);
|
||||||
|
const H = 56 + rows * 28;
|
||||||
|
const yOf = (i: number, n: number) => 40 + ((i + 0.5) * (rows * 28)) / n;
|
||||||
|
// جهتِ نگاشت چپ به راست است، مثلِ کتاب (دامنه سمتِ چپ)
|
||||||
|
const lx = 78, rx = W - 78;
|
||||||
|
const col = COLORS.red;
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
|
||||||
|
<Arrowheads ids={[col]} />
|
||||||
|
{[[lx, L, spec.leftLabel, COLORS.blue], [rx, Rt, spec.rightLabel, COLORS.green]].map(([x, items, label, c]) => (
|
||||||
|
<g key={String(x)}>
|
||||||
|
<ellipse cx={x as number} cy={40 + (rows * 28) / 2} rx={42} ry={(rows * 28) / 2 + 14} fill={`${c}14`} stroke={c as string} strokeWidth={2.2} />
|
||||||
|
{label && <Label x={x as number} y={18} color={c as string} size={12} weight={900}>{label as string}</Label>}
|
||||||
|
{(items as string[]).map((it, i) => (
|
||||||
|
<Label key={i} x={x as number} y={yOf(i, (items as string[]).length) + 4} color={INK} size={12}>{mathText(it)}</Label>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{spec.arrows.filter(([a, b]) => a < L.length && b < Rt.length).map(([a, b], i) => (
|
||||||
|
<line key={i} x1={lx + 18} y1={yOf(a, L.length)} x2={rx - 18} y2={yOf(b, Rt.length)} stroke={col} strokeWidth={1.8} markerEnd={`url(#${arrowId(col)})`} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── نمودارِ درختی (احتمال و شمارش) ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function Tree({ spec }: { spec: TreeSpec }) {
|
||||||
|
interface Laid { node: TreeNode; x: number; y: number; depth: number; parent?: Laid }
|
||||||
|
const laid: Laid[] = [];
|
||||||
|
let leaf = 0;
|
||||||
|
const depthOf = (n: TreeNode): number => 1 + Math.max(0, ...(n.children ?? []).map(depthOf));
|
||||||
|
const depth = Math.min(4, depthOf(spec.root));
|
||||||
|
const levelH = 62;
|
||||||
|
const place = (n: TreeNode, d: number, parent?: Laid): Laid => {
|
||||||
|
const kids = d < 3 ? (n.children ?? []).slice(0, 6) : [];
|
||||||
|
const me: Laid = { node: n, x: 0, y: 24 + d * levelH, depth: d, parent };
|
||||||
|
if (!kids.length) { me.x = leaf++; laid.push(me); return me; }
|
||||||
|
const placed = kids.map((c) => place(c, d + 1, me));
|
||||||
|
me.x = (placed[0].x + placed[placed.length - 1].x) / 2;
|
||||||
|
laid.push(me);
|
||||||
|
return me;
|
||||||
|
};
|
||||||
|
place(spec.root, 0);
|
||||||
|
const leaves = Math.max(1, leaf);
|
||||||
|
const H = 24 + (depth - 1) * levelH + 34;
|
||||||
|
const X = (x: number) => 24 + ((x + 0.5) / leaves) * (W - 48);
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
|
||||||
|
{laid.filter((n) => n.parent).map((n, i) => {
|
||||||
|
const p = n.parent!;
|
||||||
|
return (
|
||||||
|
<g key={`e${i}`}>
|
||||||
|
<line x1={X(p.x)} y1={p.y + 9} x2={X(n.x)} y2={n.y - 11} stroke={COLORS.gray} strokeWidth={1.8} />
|
||||||
|
{n.node.p && <Label x={(X(p.x) + X(n.x)) / 2 + (n.x < p.x ? -10 : n.x > p.x ? 10 : 12)} y={(p.y + n.y) / 2 + 3} color={COLORS.red} size={10}>{mathText(n.node.p)}</Label>}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{laid.map((n, i) => (
|
||||||
|
<g key={`n${i}`}>
|
||||||
|
<rect x={X(n.x) - 26} y={n.y - 11} width={52} height={21} rx={10} fill={n.depth === 0 ? `${COLORS.blue}22` : '#fff'} stroke={COLORS.blue} strokeWidth={1.6} />
|
||||||
|
<text x={X(n.x)} y={n.y + 4} fontSize={10.5} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{mathText(n.node.label).slice(0, 10)}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
300
src/diagram/shapes.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import type { ColorName, PlaneItem, PointDef, Ref } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مثلث و چهارضلعی از روی دادههای مسئله، نه از روی مختصاتِ حدسیِ مدل.
|
||||||
|
*
|
||||||
|
* مدل میگوید «مثلثی با AB = 6، AC = 8 و زاویهی A = 90°» یا «ذوزنقه با قاعدههای ۸ و ۵ و
|
||||||
|
* ارتفاعِ ۳»؛ اینجا با قانونِ کسینوسها و سینوسها (مثلث) یا فرمولِ هر چهارضلعی رأسها
|
||||||
|
* حساب میشوند و به نقطه + polygon + برچسب باز میشوند. سنجشِ ۲۰۲۶-۰۹-۲۸: مربعی که مدل
|
||||||
|
* مختصاتش را خودش نوشت کج درآمد؛ این ابزار آن خطا را از دستِ مدل بیرون میآورد.
|
||||||
|
* دادهی ناقص با پیشفرضِ ساده پر میشود (ضلعِ نامعلوم = میانگینِ ضلعهای معلوم)، پس
|
||||||
|
* شکل همیشه کشیده میشود؛ دادهی متناقض (۳، ۴، ۱۰) مثلثی نمیسازد و item حذف میشود.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type XY = [number, number];
|
||||||
|
const RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
export interface TriangleItem {
|
||||||
|
type: 'triangle';
|
||||||
|
names: [string, string, string];
|
||||||
|
/** کلید: دو نامِ رأس («AB» یا «BA») */
|
||||||
|
sides?: Record<string, number>;
|
||||||
|
/** کلید: نامِ رأس، درجه */
|
||||||
|
angles?: Record<string, number>;
|
||||||
|
/** برچسبِ دلخواه برای ضلع («BC»: «x»)، رأس («A»: «?») یا ارتفاع («h») */
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
/** رأسی که ارتفاع از آن بر ضلعِ روبهرو کشیده شود */
|
||||||
|
altitude?: string;
|
||||||
|
/** جای رأسِ اول */
|
||||||
|
at?: XY;
|
||||||
|
color?: ColorName;
|
||||||
|
fill?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuadItem {
|
||||||
|
type: 'quad';
|
||||||
|
shape: 'rectangle' | 'square' | 'parallelogram' | 'rhombus' | 'trapezoid';
|
||||||
|
names: [string, string, string, string];
|
||||||
|
w?: number; h?: number; side?: number; angle?: number; top?: number; d1?: number; d2?: number;
|
||||||
|
right?: boolean;
|
||||||
|
at?: XY;
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
color?: ColorName;
|
||||||
|
fill?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Solved { a: number; b: number; c: number; A: number; B: number; C: number }
|
||||||
|
|
||||||
|
/** a روبهروی A و ...؛ زاویهها به درجه. null یعنی دادهها مثلث نمیسازند. */
|
||||||
|
export function solveTriangle(sides: Partial<Record<'a' | 'b' | 'c', number>>, angles: Partial<Record<'A' | 'B' | 'C', number>>): Solved | null {
|
||||||
|
const s: Record<string, number | undefined> = { ...sides };
|
||||||
|
const g: Record<string, number | undefined> = { ...angles };
|
||||||
|
const opp = { a: 'A', b: 'B', c: 'C' } as const;
|
||||||
|
const known = (o: Record<string, number | undefined>, k: string) => o[k] !== undefined && Number.isFinite(o[k]!);
|
||||||
|
const step = () => {
|
||||||
|
const ks = ['A', 'B', 'C'].filter((k) => known(g, k));
|
||||||
|
if (ks.length === 2) {
|
||||||
|
const rest = 180 - ks.reduce((t, k) => t + g[k]!, 0);
|
||||||
|
if (rest <= 0) return false;
|
||||||
|
g[['A', 'B', 'C'].find((k) => !known(g, k))!] = rest;
|
||||||
|
}
|
||||||
|
const ss = (['a', 'b', 'c'] as const).filter((k) => known(s, k));
|
||||||
|
if (ss.length === 3 && ['A', 'B', 'C'].some((k) => !known(g, k))) {
|
||||||
|
const { a, b, c } = s as Record<string, number>;
|
||||||
|
if (a + b <= c || a + c <= b || b + c <= a) return false;
|
||||||
|
g.A = Math.acos((b * b + c * c - a * a) / (2 * b * c)) / RAD;
|
||||||
|
g.B = Math.acos((a * a + c * c - b * b) / (2 * a * c)) / RAD;
|
||||||
|
g.C = 180 - g.A - g.B;
|
||||||
|
}
|
||||||
|
// دو ضلع و زاویهی بینشان
|
||||||
|
for (const [x, y, z] of [['b', 'c', 'a'], ['a', 'c', 'b'], ['a', 'b', 'c']] as const) {
|
||||||
|
if (known(s, x) && known(s, y) && !known(s, z) && known(g, opp[z])) {
|
||||||
|
s[z] = Math.sqrt(s[x]! ** 2 + s[y]! ** 2 - 2 * s[x]! * s[y]! * Math.cos(g[opp[z]]! * RAD));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// قانونِ سینوسها
|
||||||
|
const pair = (['a', 'b', 'c'] as const).find((k) => known(s, k) && known(g, opp[k]));
|
||||||
|
if (pair) {
|
||||||
|
const k = s[pair]! / Math.sin(g[opp[pair]]! * RAD);
|
||||||
|
for (const side of ['a', 'b', 'c'] as const) {
|
||||||
|
const ang = opp[side];
|
||||||
|
if (!known(s, side) && known(g, ang)) s[side] = k * Math.sin(g[ang]! * RAD);
|
||||||
|
else if (known(s, side) && !known(g, ang)) {
|
||||||
|
const r = s[side]! / k;
|
||||||
|
if (r > 1 + 1e-9) return false;
|
||||||
|
g[ang] = Math.asin(Math.min(1, r)) / RAD;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const done = () => ['a', 'b', 'c'].every((k) => known(s, k)) && ['A', 'B', 'C'].every((k) => known(g, k));
|
||||||
|
const count = () => ['a', 'b', 'c'].filter((k) => known(s, k)).length + ['A', 'B', 'C'].filter((k) => known(g, k)).length;
|
||||||
|
for (let round = 0; round < 12 && !done(); round++) {
|
||||||
|
const before = count();
|
||||||
|
if (!step()) return null;
|
||||||
|
if (done()) break;
|
||||||
|
if (count() > before) continue;
|
||||||
|
// گیر کرد: دادهی ناقص. ضلعِ نامعلومِ اول = میانگینِ ضلعهای معلوم (متساویالساقین/متساویالاضلاع)
|
||||||
|
const ss = (['a', 'b', 'c'] as const).filter((k) => known(s, k));
|
||||||
|
if (!ss.length) { s.c = 4; continue; }
|
||||||
|
const missing = (['a', 'b', 'c'] as const).find((k) => !known(s, k));
|
||||||
|
if (missing) s[missing] = ss.reduce((t, k) => t + s[k]!, 0) / ss.length;
|
||||||
|
}
|
||||||
|
if (!done()) return null;
|
||||||
|
const out = { a: s.a!, b: s.b!, c: s.c!, A: g.A!, B: g.B!, C: g.C! };
|
||||||
|
if (Object.values(out).some((v) => !Number.isFinite(v) || v <= 0) || Math.abs(out.A + out.B + out.C - 180) > 0.5) return null;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = (v: number) => String(Math.round(v * 100) / 100);
|
||||||
|
|
||||||
|
/** رأسهای شکلهای مرکبی که تا اینجا جا گرفتهاند (برای چیدنِ چند شکل کنارِ هم). */
|
||||||
|
export interface Layout { placed: XY[] }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جای شکل: رأسها در دستگاهِ محلی (رأسِ اول در مبدأ، ضلعِ اول روی محورِ x) ساخته شدهاند.
|
||||||
|
* دو رأسِ اول از قبل مختصات دارند → شکل روی همان ضلع ساخته میشود (ذوزنقه روی ضلعِ مستطیل)
|
||||||
|
* فقط رأسِ اول → همانجا؛ "at" → همانجا
|
||||||
|
* هیچکدام، ولی شکلِ دیگری قبلاً کشیده شده → کنارِ آن، نه روی آن (دو مربعِ کنارِ هم)
|
||||||
|
*/
|
||||||
|
function placement(names: string[], local: XY[], points: Record<string, PointDef>, layout: Layout, at?: XY): (p: XY) => XY {
|
||||||
|
const plain = (n: string) => (Array.isArray(points[n]) ? (points[n] as XY) : null);
|
||||||
|
const p0 = plain(names[0]), p1 = plain(names[1]);
|
||||||
|
let T: (p: XY) => XY = (p) => p;
|
||||||
|
if (p0 && p1 && Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) > 1e-9) {
|
||||||
|
const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
|
||||||
|
const u: XY = [(p1[0] - p0[0]) / len, (p1[1] - p0[1]) / len];
|
||||||
|
T = (p) => [p0[0] + p[0] * u[0] - p[1] * u[1], p0[1] + p[0] * u[1] + p[1] * u[0]];
|
||||||
|
} else if (p0) T = (p) => [p0[0] + p[0], p0[1] + p[1]];
|
||||||
|
else if (at) T = (p) => [at[0] + p[0], at[1] + p[1]];
|
||||||
|
else if (layout.placed.length) {
|
||||||
|
const right = Math.max(...layout.placed.map((q) => q[0]));
|
||||||
|
const bottom = Math.min(...layout.placed.map((q) => q[1]));
|
||||||
|
const minX = Math.min(...local.map((q) => q[0])), minY = Math.min(...local.map((q) => q[1]));
|
||||||
|
const gap = Math.max(1, 0.2 * (Math.max(...local.map((q) => q[0])) - minX));
|
||||||
|
T = (p) => [p[0] - minX + right + gap, p[1] - minY + bottom];
|
||||||
|
}
|
||||||
|
layout.placed.push(...local.map(T));
|
||||||
|
return T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sideKey(key: string, names: string[]): [number, number] | null {
|
||||||
|
for (let i = 0; i < names.length; i++) for (let j = 0; j < names.length; j++) {
|
||||||
|
if (i !== j && key === names[i] + names[j]) return [i, j];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** itemهای ساده (نقطه، polygon، پارهخطِ برچسبدار، زاویه) بهجای triangle. */
|
||||||
|
export function expandTriangle(item: TriangleItem, idx: number, points: Record<string, PointDef>, taken: (n: string) => boolean, layout: Layout): PlaneItem[] | null {
|
||||||
|
const [P, Q, R] = item.names;
|
||||||
|
const sides: Partial<Record<'a' | 'b' | 'c', number>> = {};
|
||||||
|
const oppOf = (i: number, j: number) => (['a', 'b', 'c'] as const)[3 - i - j];
|
||||||
|
Object.entries(item.sides ?? {}).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sides[oppOf(p[0], p[1])] = v; });
|
||||||
|
const angles: Partial<Record<'A' | 'B' | 'C', number>> = {};
|
||||||
|
Object.entries(item.angles ?? {}).forEach(([k, v]) => { const i = item.names.indexOf(k); if (i >= 0) angles[(['A', 'B', 'C'] as const)[i]] = v; });
|
||||||
|
// مثلثی روی نقطههایی که مدل خودش تعریف کرده (مختصات، polar، تبدیل) و بیدادهی ضلع و زاویه:
|
||||||
|
// همان نقطهها، دستنخورده (سنجشِ ۲۰۲۶-۰۹-۲۹: جابهجا کردنشان تصویرِ تقارن را خراب کرد)
|
||||||
|
const hasData = Object.keys(sides).length + Object.keys(angles).length > 0;
|
||||||
|
if (!(hasData || !item.names.every((n) => n in points))) {
|
||||||
|
layout.placed.push(...item.names.map((n) => points[n]).filter((v): v is XY => Array.isArray(v)));
|
||||||
|
} else {
|
||||||
|
const t = solveTriangle(sides, angles);
|
||||||
|
if (!t) return null;
|
||||||
|
const local: XY[] = [[0, 0], [t.c, 0], [t.b * Math.cos(t.A * RAD), t.b * Math.sin(t.A * RAD)]];
|
||||||
|
const T = placement(item.names, local, points, layout, item.at);
|
||||||
|
item.names.forEach((n, i) => { points[n] = T(local[i]); });
|
||||||
|
}
|
||||||
|
const style = { color: item.color };
|
||||||
|
const out: PlaneItem[] = [{ type: 'polygon', points: [P, Q, R], fill: item.fill ?? true, ...style }];
|
||||||
|
const labels = item.labels ?? {};
|
||||||
|
const sideLabel = new Map<string, string>();
|
||||||
|
Object.entries(item.sides ?? {}).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sideLabel.set([p[0], p[1]].sort().join(), num(v)); });
|
||||||
|
Object.entries(labels).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sideLabel.set([p[0], p[1]].sort().join(), v); });
|
||||||
|
// همهی ضلعها پارهخطاند (نه فقط برچسبدارها) تا ضخامتِ ضلعها یکی باشد
|
||||||
|
[[0, 1], [1, 2], [0, 2]].forEach(([i, j]) => {
|
||||||
|
out.push({ type: 'segment', from: item.names[i], to: item.names[j], label: sideLabel.get(`${i},${j}`), ...style });
|
||||||
|
});
|
||||||
|
item.names.forEach((n, i) => {
|
||||||
|
const given = item.angles?.[n];
|
||||||
|
const label = labels[n] ?? (given !== undefined ? `${num(given)}°` : undefined);
|
||||||
|
if (label === undefined) return;
|
||||||
|
const [from, to] = [item.names[(i + 1) % 3], item.names[(i + 2) % 3]];
|
||||||
|
if (given !== undefined && Math.abs(given - 90) < 1e-6 && labels[n] === undefined) out.push({ type: 'rightAngle', vertex: n, from, to });
|
||||||
|
else out.push({ type: 'angle', vertex: n, from, to, label, color: 'red' });
|
||||||
|
});
|
||||||
|
const alt = item.altitude;
|
||||||
|
if (alt && item.names.includes(alt)) {
|
||||||
|
const [u, v] = item.names.filter((n) => n !== alt);
|
||||||
|
const foot = taken('H') ? `_h${idx}` : 'H';
|
||||||
|
points[foot] = { foot: [alt, u, v] };
|
||||||
|
// امتدادِ ضلع فقط وقتی پای ارتفاع بیرونِ مثلث میافتد (مثلثِ منفرجه)
|
||||||
|
const [pu, pv, pa] = [u, v, alt].map((n) => (Array.isArray(points[n]) ? (points[n] as XY) : ([0, 0] as XY)));
|
||||||
|
const d: XY = [pv[0] - pu[0], pv[1] - pu[1]];
|
||||||
|
const tt = ((pa[0] - pu[0]) * d[0] + (pa[1] - pu[1]) * d[1]) / (d[0] * d[0] + d[1] * d[1] || 1);
|
||||||
|
if (tt < 0) out.push({ type: 'segment', from: foot, to: u, dashed: true, color: 'gray' });
|
||||||
|
if (tt > 1) out.push({ type: 'segment', from: v, to: foot, dashed: true, color: 'gray' });
|
||||||
|
out.push({ type: 'segment', from: alt, to: foot, dashed: true, color: 'green', label: labels.h });
|
||||||
|
out.push({ type: 'rightAngle', vertex: foot, from: alt, to: v });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandQuad(item: QuadItem, idx: number, points: Record<string, PointDef>, layout: Layout): PlaneItem[] {
|
||||||
|
const [A, B, C, D] = item.names;
|
||||||
|
const labels = item.labels ?? {};
|
||||||
|
const style = { color: item.color };
|
||||||
|
const lab = (key: string, value?: number) => labels[key] ?? (value !== undefined ? num(value) : undefined);
|
||||||
|
const out: PlaneItem[] = [];
|
||||||
|
const seg = (from: Ref, to: Ref, label?: string, extra: Partial<Extract<PlaneItem, { type: 'segment' }>> = {}) => {
|
||||||
|
out.push({ type: 'segment', from, to, label, ...style, ...extra });
|
||||||
|
};
|
||||||
|
// ضلعهایی که برچسب یا علامت نگرفتند، بعداً با همان ضخامت کشیده میشوند
|
||||||
|
const edges = () => {
|
||||||
|
const drawn = new Set(out.filter((i) => i.type === 'segment' && !i.dashed).map((i) => { const s = i as { from: Ref; to: Ref }; return [s.from, s.to].sort().join(); }));
|
||||||
|
[[A, B], [B, C], [C, D], [D, A]].forEach(([p, q]) => { if (!drawn.has([p, q].sort().join())) seg(p, q); });
|
||||||
|
};
|
||||||
|
// بیهیچ اندازه و روی چهار نقطهای که مدل خودش تعریف کرده: همان نقطهها
|
||||||
|
const keep = ['w', 'h', 'side', 'top', 'd1', 'd2', 'angle'].every((k) => item[k as keyof QuadItem] === undefined)
|
||||||
|
&& item.names.every((n) => n in points);
|
||||||
|
let T: (p: XY) => XY = (p) => p;
|
||||||
|
const put = (xy: XY[]) => {
|
||||||
|
if (keep) { layout.placed.push(...item.names.map((n) => points[n]).filter((v): v is XY => Array.isArray(v))); return; }
|
||||||
|
T = placement(item.names, xy, points, layout, item.at);
|
||||||
|
[A, B, C, D].forEach((n, i) => { points[n] = T(xy[i]); });
|
||||||
|
};
|
||||||
|
const height = (label?: string) => {
|
||||||
|
const foot = `_q${idx}`;
|
||||||
|
points[foot] = { foot: [D, A, B] };
|
||||||
|
out.push({ type: 'segment', from: D, to: foot, dashed: true, color: 'green', label });
|
||||||
|
out.push({ type: 'rightAngle', vertex: foot, from: D, to: B });
|
||||||
|
};
|
||||||
|
const θ = (item.angle ?? 60) * RAD;
|
||||||
|
switch (item.shape) {
|
||||||
|
case 'rectangle': {
|
||||||
|
const w = item.w ?? 6, h = item.h ?? 4;
|
||||||
|
put([[0, 0], [w, 0], [w, h], [0, h]]);
|
||||||
|
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style }, { type: 'rightAngle', vertex: A, from: B, to: D });
|
||||||
|
seg(A, B, lab(A + B, item.w)); seg(B, C, lab(B + C, item.h));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'square': {
|
||||||
|
const s = item.side ?? item.w ?? 4;
|
||||||
|
put([[0, 0], [s, 0], [s, s], [0, s]]);
|
||||||
|
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style }, { type: 'rightAngle', vertex: A, from: B, to: D });
|
||||||
|
seg(A, B, lab(A + B, item.side ?? item.w), { ticks: 1 }); seg(B, C, lab(B + C), { ticks: 1 });
|
||||||
|
seg(C, D, lab(C + D), { ticks: 1 }); seg(D, A, lab(D + A), { ticks: 1 });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'parallelogram': {
|
||||||
|
const w = item.w ?? 6;
|
||||||
|
const s = item.side ?? (item.h ? item.h / Math.sin(θ) : 3.5);
|
||||||
|
const d: XY = [s * Math.cos(θ), s * Math.sin(θ)];
|
||||||
|
put([[0, 0], [w, 0], [w + d[0], d[1]], d]);
|
||||||
|
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
|
||||||
|
seg(A, B, lab(A + B, item.w)); seg(A, D, lab(A + D, item.side));
|
||||||
|
if (item.h !== undefined || labels.h) height(lab('h', item.h));
|
||||||
|
if (item.angle !== undefined) out.push({ type: 'angle', vertex: A, from: B, to: D, label: `${num(item.angle)}°`, color: 'red' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rhombus': {
|
||||||
|
if (item.d1 !== undefined || item.d2 !== undefined) {
|
||||||
|
const d1 = item.d1 ?? 6, d2 = item.d2 ?? 4, o = `_o${idx}`;
|
||||||
|
put([[-d1 / 2, 0], [0, -d2 / 2], [d1 / 2, 0], [0, d2 / 2]]);
|
||||||
|
points[o] = T([0, 0]);
|
||||||
|
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
|
||||||
|
// برچسبِ قطرها کنارِ هم نمینشیند (هر دو وسطشان در O است): روی یکچهارمِ هر قطر
|
||||||
|
seg(A, C, undefined, { dashed: true }); seg(B, D, undefined, { dashed: true });
|
||||||
|
const l1 = lab('d1', item.d1), l2 = lab('d2', item.d2);
|
||||||
|
if (l1) out.push({ type: 'text', at: T([d1 / 4, d2 * 0.07]), text: l1, color: 'blue' });
|
||||||
|
if (l2) out.push({ type: 'text', at: T([d1 * 0.07, d2 / 4]), text: l2, color: 'blue' });
|
||||||
|
out.push({ type: 'rightAngle', vertex: o, from: C, to: D });
|
||||||
|
[[A, B], [B, C], [C, D], [D, A]].forEach(([p, q]) => seg(p, q, lab(p + q), { ticks: 1 }));
|
||||||
|
} else {
|
||||||
|
const s = item.side ?? item.w ?? 4;
|
||||||
|
put([[0, 0], [s, 0], [s + s * Math.cos(θ), s * Math.sin(θ)], [s * Math.cos(θ), s * Math.sin(θ)]]);
|
||||||
|
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
|
||||||
|
seg(A, B, lab(A + B, item.side ?? item.w), { ticks: 1 });
|
||||||
|
[[B, C], [C, D], [D, A]].forEach(([p, q]) => seg(p, q, lab(p + q), { ticks: 1 }));
|
||||||
|
if (item.h !== undefined || labels.h) height(lab('h', item.h));
|
||||||
|
if (item.angle !== undefined) out.push({ type: 'angle', vertex: A, from: B, to: D, label: `${num(item.angle)}°`, color: 'red' });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'trapezoid': {
|
||||||
|
const w = item.w ?? 6, t = item.top ?? w * 0.6, h = item.h ?? 3;
|
||||||
|
const x0 = item.right ? 0 : (w - t) / 2;
|
||||||
|
put([[0, 0], [w, 0], [x0 + t, h], [x0, h]]);
|
||||||
|
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
|
||||||
|
seg(A, B, lab(A + B, item.w)); seg(D, C, lab(D + C, item.top));
|
||||||
|
if (item.right) {
|
||||||
|
out.push({ type: 'rightAngle', vertex: A, from: B, to: D });
|
||||||
|
seg(A, D, lab(A + D, item.h) ?? lab('h'));
|
||||||
|
} else if (item.h !== undefined || labels.h) height(lab('h', item.h));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edges();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
156
src/diagram/solid.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { SolidSpec } from './types';
|
||||||
|
import { COLORS, INK, Label, mathText } from './svg';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* حجمهای هندسی، با تصویرِ مایلِ کتابِ درسی: وجهِ جلو واقعی، عمق با زاویهی ۳۵°
|
||||||
|
* و نصفِ اندازه؛ یالهای پنهان خطچین. اندازهها (a، b، c، r، h) نسبتِ واقعی را
|
||||||
|
* نگه میدارند؛ برچسبها همان متنیاند که مدل داده («۵ cm»).
|
||||||
|
*/
|
||||||
|
type XY = [number, number];
|
||||||
|
const W = 300;
|
||||||
|
const H = 250;
|
||||||
|
const FILL = `${COLORS.blue}1c`;
|
||||||
|
const TOP = `${COLORS.blue}33`;
|
||||||
|
const SIDE = `${COLORS.blue}10`;
|
||||||
|
const HID = '5 4';
|
||||||
|
const DEPTH = 0.5;
|
||||||
|
const ANG = (35 * Math.PI) / 180;
|
||||||
|
const SQUASH = 0.32; // دایرهی قاعده از نمای مایل
|
||||||
|
|
||||||
|
export function Solid({ spec }: { spec: SolidSpec }) {
|
||||||
|
const d = spec.dims ?? {};
|
||||||
|
const l = spec.labels ?? {};
|
||||||
|
const els: ReactNode[] = [];
|
||||||
|
let key = 0;
|
||||||
|
const k = () => key++;
|
||||||
|
const edge = (a: XY, b: XY, hidden = false, col = INK) =>
|
||||||
|
els.push(<line key={k()} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]} stroke={hidden ? '#8A8FA3' : col} strokeWidth={hidden ? 1.5 : 2.2} strokeDasharray={hidden ? HID : undefined} strokeLinecap="round" />);
|
||||||
|
const face = (pts: XY[], fill: string) => els.push(<path key={k()} d={`M${pts.map((p) => p.join(',')).join(' L')} Z`} fill={fill} stroke="none" />);
|
||||||
|
const label = (x: number, y: number, text: string | undefined, col = COLORS.red) => {
|
||||||
|
if (text) els.push(<Label key={k()} x={x} y={y} color={col} size={12}>{mathText(text)}</Label>);
|
||||||
|
};
|
||||||
|
const dim = (a: XY, b: XY, text: string | undefined) => {
|
||||||
|
// خطِ اندازه: خطچینِ قرمز با برچسب وسطش
|
||||||
|
els.push(<line key={k()} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]} stroke={COLORS.red} strokeWidth={1.8} strokeDasharray="4 3" />);
|
||||||
|
if (text) label((a[0] + b[0]) / 2 + 12, (a[1] + b[1]) / 2 + 4, text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shape = spec.shape;
|
||||||
|
if (shape === 'cube' || shape === 'cuboid') {
|
||||||
|
let a = d.a ?? 1, b = d.b ?? (shape === 'cube' ? a : 1), c = d.c ?? (shape === 'cube' ? a : 1);
|
||||||
|
if (shape === 'cube') b = c = a;
|
||||||
|
const ox = Math.cos(ANG) * DEPTH * b, oy = Math.sin(ANG) * DEPTH * b;
|
||||||
|
const s = Math.min(170 / (a + ox), 170 / (c + oy));
|
||||||
|
const x0 = (W - (a + ox) * s) / 2, y0 = H - 30;
|
||||||
|
const f = (x: number, y: number, z: number): XY => [x0 + (x + z * Math.cos(ANG) * DEPTH) * s, y0 - (y + z * Math.sin(ANG) * DEPTH) * s];
|
||||||
|
const [A, B, C, D] = [f(0, 0, 0), f(a, 0, 0), f(a, c, 0), f(0, c, 0)];
|
||||||
|
const [E, F, G, Hh] = [f(0, 0, b), f(a, 0, b), f(a, c, b), f(0, c, b)];
|
||||||
|
face([D, C, G, Hh], TOP); face([B, F, G, C], SIDE); face([A, B, C, D], FILL);
|
||||||
|
edge(A, E, true); edge(E, F, true); edge(E, Hh, true);
|
||||||
|
[[A, B], [B, C], [C, D], [D, A], [B, F], [F, G], [G, C], [G, Hh], [Hh, D]].forEach(([p, q]) => edge(p, q));
|
||||||
|
label((A[0] + B[0]) / 2, A[1] + 17, l.a);
|
||||||
|
label(A[0] - 18, (A[1] + D[1]) / 2 + 4, l.c ?? (shape === 'cube' ? undefined : undefined));
|
||||||
|
label((B[0] + F[0]) / 2 + 16, (B[1] + F[1]) / 2 + 10, l.b);
|
||||||
|
} else if (shape === 'prism' || shape === 'pyramid') {
|
||||||
|
const n = Math.max(3, Math.min(8, Math.round(d.n ?? (shape === 'pyramid' ? 4 : 3))));
|
||||||
|
const r = d.r ?? 1, h = d.h ?? 1.6;
|
||||||
|
const s = Math.min(95 / r, 165 / h);
|
||||||
|
const cx = W / 2, by = H - 44;
|
||||||
|
const rot = n === 4 ? Math.PI / 4 : Math.PI / 2 + Math.PI / n;
|
||||||
|
const ang = Array.from({ length: n }, (_, i) => rot + (2 * Math.PI * i) / n);
|
||||||
|
// sin(t) > 0 یعنی پایینِ بیضی در تصویر = جلوی حجم، نزدیک به بیننده.
|
||||||
|
const base: XY[] = ang.map((t) => [cx + r * s * Math.cos(t), by + r * s * SQUASH * Math.sin(t)]);
|
||||||
|
const midAng = (i: number) => ang[i] + Math.PI / n;
|
||||||
|
const inr = r * Math.cos(Math.PI / n);
|
||||||
|
// دیده شدنِ وجهِ جانبیِ i (بینِ رأسِ i و i+1): عمودِ بیرونی رو به بیننده.
|
||||||
|
// نما کمی از بالاست (tan ≈ SQUASH)، پس وجهِ مایلِ هرم کمی زودتر دیده میشود.
|
||||||
|
const faceVisible = (i: number) => shape === 'prism'
|
||||||
|
? Math.sin(midAng(i)) > 1e-9
|
||||||
|
: h * Math.sin(midAng(i)) + inr * SQUASH > 1e-9;
|
||||||
|
const vertexVisible = (i: number) => faceVisible(i) || faceVisible((i - 1 + n) % n);
|
||||||
|
if (shape === 'prism') {
|
||||||
|
const top: XY[] = base.map((p) => [p[0], p[1] - h * s]);
|
||||||
|
face(top, TOP);
|
||||||
|
for (let i = 0; i < n; i++) if (faceVisible(i)) face([base[i], base[(i + 1) % n], top[(i + 1) % n], top[i]], FILL);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const j = (i + 1) % n;
|
||||||
|
edge(base[i], base[j], !faceVisible(i));
|
||||||
|
edge(top[i], top[j]);
|
||||||
|
edge(base[i], top[i], !vertexVisible(i));
|
||||||
|
}
|
||||||
|
const right = base.reduce((m, p, i) => (p[0] > base[m][0] ? i : m), 0);
|
||||||
|
if (l.h) dim([top[right][0] + 16, top[right][1]], [base[right][0] + 16, base[right][1]], l.h);
|
||||||
|
} else {
|
||||||
|
const apex: XY = [cx, by - h * s];
|
||||||
|
for (let i = 0; i < n; i++) if (faceVisible(i)) face([base[i], base[(i + 1) % n], apex], i % 2 ? FILL : SIDE);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
edge(base[i], base[(i + 1) % n], !faceVisible(i));
|
||||||
|
edge(base[i], apex, !vertexVisible(i));
|
||||||
|
}
|
||||||
|
if (l.h) {
|
||||||
|
edge(apex, [cx, by], true, COLORS.red);
|
||||||
|
els.push(<path key={k()} d={`M${cx + 8},${by} L${cx + 8},${by - 8} L${cx},${by - 8}`} fill="none" stroke={COLORS.red} strokeWidth={1.4} />);
|
||||||
|
label(cx + 16, (apex[1] + by) / 2, l.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ضلعِ قاعده: پایینترین یالِ جلو
|
||||||
|
if (l.a) {
|
||||||
|
let best = 0;
|
||||||
|
for (let i = 0; i < n; i++) if (base[i][1] + base[(i + 1) % n][1] > base[best][1] + base[(best + 1) % n][1]) best = i;
|
||||||
|
label((base[best][0] + base[(best + 1) % n][0]) / 2, (base[best][1] + base[(best + 1) % n][1]) / 2 + 17, l.a);
|
||||||
|
}
|
||||||
|
} else if (shape === 'cylinder' || shape === 'cone') {
|
||||||
|
const r = d.r ?? 1, h = d.h ?? 2;
|
||||||
|
const s = Math.min(110 / r, 170 / (h + r * SQUASH * 2));
|
||||||
|
const cx = W / 2, by = H - 36, rx = r * s, ry = r * s * SQUASH;
|
||||||
|
const topY = by - h * s;
|
||||||
|
const halfEllipse = (cy: number, front: boolean) => `M${cx - rx},${cy} A${rx},${ry} 0 0 ${front ? 0 : 1} ${cx + rx},${cy}`;
|
||||||
|
if (shape === 'cylinder') {
|
||||||
|
els.push(<path key={k()} d={`M${cx - rx},${topY} L${cx - rx},${by} A${rx},${ry} 0 0 0 ${cx + rx},${by} L${cx + rx},${topY} Z`} fill={FILL} />);
|
||||||
|
els.push(<ellipse key={k()} cx={cx} cy={topY} rx={rx} ry={ry} fill={TOP} stroke={INK} strokeWidth={2.2} />);
|
||||||
|
edge([cx - rx, topY], [cx - rx, by]); edge([cx + rx, topY], [cx + rx, by]);
|
||||||
|
els.push(<path key={k()} d={halfEllipse(by, true)} fill="none" stroke={INK} strokeWidth={2.2} />);
|
||||||
|
els.push(<path key={k()} d={halfEllipse(by, false)} fill="none" stroke="#8A8FA3" strokeWidth={1.5} strokeDasharray={HID} />);
|
||||||
|
if (l.r) { dim([cx, topY], [cx + rx, topY], undefined); els.push(<circle key={k()} cx={cx} cy={topY} r={2.6} fill={INK} />); label(cx + rx / 2, topY - 8, l.r); }
|
||||||
|
if (l.h) dim([cx + rx + 16, topY], [cx + rx + 16, by], l.h);
|
||||||
|
} else {
|
||||||
|
const apex: XY = [cx, topY];
|
||||||
|
// مماس از رأس بر بیضیِ قاعده: نقطهی تماس جایی است که sin t = −ry / ارتفاعِ تصویر
|
||||||
|
// (از صفر شدنِ ضربِ خارجیِ «رأس→نقطه» در «مشتقِ بیضی» درمیآید).
|
||||||
|
const st = ry / Math.max(1e-6, by - topY), ct = Math.sqrt(Math.max(0, 1 - st * st));
|
||||||
|
const tl: XY = [cx - rx * ct, by - ry * st], tr: XY = [cx + rx * ct, by - ry * st];
|
||||||
|
els.push(<path key={k()} d={`M${apex.join(',')} L${tl.join(',')} A${rx},${ry} 0 1 0 ${tr.join(',')} Z`} fill={FILL} />);
|
||||||
|
els.push(<path key={k()} d={`M${tl.join(',')} A${rx},${ry} 0 1 0 ${tr.join(',')}`} fill="none" stroke={INK} strokeWidth={2.2} />);
|
||||||
|
els.push(<path key={k()} d={`M${tl.join(',')} A${rx},${ry} 0 0 1 ${tr.join(',')}`} fill="none" stroke="#8A8FA3" strokeWidth={1.5} strokeDasharray={HID} />);
|
||||||
|
edge(apex, tl); edge(apex, tr);
|
||||||
|
if (l.h) {
|
||||||
|
edge(apex, [cx, by], true, COLORS.red);
|
||||||
|
els.push(<path key={k()} d={`M${cx + 8},${by} L${cx + 8},${by - 8} L${cx},${by - 8}`} fill="none" stroke={COLORS.red} strokeWidth={1.4} />);
|
||||||
|
label(cx + 22, (topY + by) / 2 - 10, l.h);
|
||||||
|
}
|
||||||
|
if (l.r) { dim([cx, by], [cx + rx, by], undefined); label(cx + rx / 2, by + ry + 16, l.r); }
|
||||||
|
}
|
||||||
|
} else if (shape === 'sphere' || shape === 'hemisphere') {
|
||||||
|
const r = d.r ?? 1;
|
||||||
|
const R = 92, cx = W / 2, cy = shape === 'sphere' ? H / 2 + 4 : H - 70, ry = R * SQUASH;
|
||||||
|
if (shape === 'sphere') {
|
||||||
|
els.push(<circle key={k()} cx={cx} cy={cy} r={R} fill={FILL} stroke={INK} strokeWidth={2.2} />);
|
||||||
|
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${ry} 0 0 0 ${cx + R},${cy}`} fill="none" stroke={INK} strokeWidth={1.6} />);
|
||||||
|
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${ry} 0 0 1 ${cx + R},${cy}`} fill="none" stroke="#8A8FA3" strokeWidth={1.4} strokeDasharray={HID} />);
|
||||||
|
} else {
|
||||||
|
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${R} 0 0 1 ${cx + R},${cy} A${R},${ry} 0 0 1 ${cx - R},${cy} Z`} fill={FILL} />);
|
||||||
|
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${R} 0 0 1 ${cx + R},${cy}`} fill="none" stroke={INK} strokeWidth={2.2} />);
|
||||||
|
els.push(<ellipse key={k()} cx={cx} cy={cy} rx={R} ry={ry} fill={TOP} stroke={INK} strokeWidth={2} />);
|
||||||
|
}
|
||||||
|
els.push(<circle key={k()} cx={cx} cy={cy} r={2.8} fill={INK} />);
|
||||||
|
if (l.r) { dim([cx, cy], [cx + R, cy], undefined); label(cx + R / 2, cy - 8, l.r); }
|
||||||
|
void r;
|
||||||
|
} else return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[300px]" role="img" aria-label={spec.title || 'حجم'}>
|
||||||
|
{els}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
src/diagram/svg.tsx
Normal 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
@@ -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
@@ -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
@@ -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)),
|
||||||
|
};
|
||||||
31
src/lib/color.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/** رنگِ hex را روشن (amount > 0) یا تیره (amount < 0) میکند؛ برای سایهی دکمههای سهبعدی. */
|
||||||
|
export function shade(hex: string, amount: number): string {
|
||||||
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
|
if (!m) return hex;
|
||||||
|
const n = parseInt(m[1], 16);
|
||||||
|
const channel = (c: number) => {
|
||||||
|
const v = amount < 0 ? c * (1 + amount) : c + (255 - c) * amount;
|
||||||
|
return Math.max(0, Math.min(255, Math.round(v)));
|
||||||
|
};
|
||||||
|
const r = channel((n >> 16) & 255), g = channel((n >> 8) & 255), b = channel(n & 255);
|
||||||
|
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function luminance(hex: string): number {
|
||||||
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
|
if (!m) return 1;
|
||||||
|
const n = parseInt(m[1], 16);
|
||||||
|
const lin = (c: number) => { const v = c / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
|
||||||
|
return 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* همان رنگ، فقط تا جایی تیره میشود که متنِ سفید رویش کنتراستِ دستکم `min` بگیرد
|
||||||
|
* (WCAG: ۳ برای متنِ درشت). رنگِ روشن برای آیکن و نوار میماند و فقط پسزمینهی متنِ سفید از این رد میشود،
|
||||||
|
* پس هیچ رنگِ تازهای وارد اپ نمیشود، فقط سایهی تیرهترِ همان.
|
||||||
|
*/
|
||||||
|
export function onWhite(hex: string, min = 3.2): string {
|
||||||
|
let c = hex;
|
||||||
|
for (let i = 0; i < 12 && 1.05 / (luminance(c) + 0.05) < min; i++) c = shade(c, -0.07);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
@@ -43,12 +43,29 @@ function tone(frequency: number, start: number, duration: number, volume = 0.05)
|
|||||||
osc.stop(t + duration + 0.02);
|
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 = {
|
export const sounds = {
|
||||||
tick: () => tone(720, 0, 0.045, 0.04),
|
tick: () => tone(720, 0, 0.045, 0.04),
|
||||||
correct: () => {
|
correct: () => {
|
||||||
tone(660, 0, 0.12);
|
tone(660, 0, 0.12);
|
||||||
tone(990, 0.1, 0.18);
|
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)),
|
finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,22 +24,8 @@ export const store = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const KEYS = {
|
export const KEYS = {
|
||||||
deviceId: 'deviceId',
|
|
||||||
onboarded: 'tadrisino.onboarded',
|
onboarded: 'tadrisino.onboarded',
|
||||||
conversationId: 'tadrisino.conversationId',
|
conversationId: 'tadrisino.conversationId',
|
||||||
ticketIds: 'tadrisino.ticketIds',
|
|
||||||
sound: 'tadrisino.sound',
|
sound: 'tadrisino.sound',
|
||||||
referralCode: 'tadrisino.referralCode',
|
referralCode: 'tadrisino.referralCode',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** شناسهی ثابتِ این مرورگر برای ورودِ مهمان. */
|
|
||||||
export function getDeviceId(): string {
|
|
||||||
let id = store.get(KEYS.deviceId);
|
|
||||||
if (!id) {
|
|
||||||
id = globalThis.crypto && 'randomUUID' in crypto
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
||||||
store.set(KEYS.deviceId, id);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|||||||
5
src/lib/subscription.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/** چند روز تا پایانِ اشتراک مانده (رو به بالا)؛ null برای اشتراکِ بیتاریخ (هدیهی نامحدود). */
|
||||||
|
export function daysLeft(expiresAt: string | null): number | null {
|
||||||
|
if (!expiresAt) return null;
|
||||||
|
return Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000));
|
||||||
|
}
|
||||||
12
src/main.tsx
@@ -1,10 +1,20 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode, lazy, Suspense } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { App } from './app/App';
|
import { App } from './app/App';
|
||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
|
|
||||||
|
// گالریِ شکلها فقط در حالتِ توسعه؛ در 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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
{showGallery && DiagramGallery ? (
|
||||||
|
<Suspense fallback={null}><DiagramGallery /></Suspense>
|
||||||
|
) : (
|
||||||
<App />
|
<App />
|
||||||
|
)}
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* سهیار: سیگما (Σ)، دلتا (Δ) و پی (π) — ماسکوتهای اپ.
|
* همراههای اپ: سیگما (Σ)، دلتا (Δ)، پی (π) و امگا (ω). «سهیار» نامِ قدیمیِ سهتای اول است؛ در متنِ
|
||||||
|
* کاربر تعدادِ همراهها نوشته نمیشود، چون کاتالوگ از ادمین میآید و بزرگ میشود.
|
||||||
*
|
*
|
||||||
* هر سه از دلِ یک دفترچهی بینهایت آمدهاند و هر کدام از جنسِ یک نمادِ ریاضیاند:
|
* هر سه از دلِ یک دفترچهی بینهایت آمدهاند و هر کدام از جنسِ یک نمادِ ریاضیاند:
|
||||||
* سیگما → راهنمای درس و آزمون دلتا → همتیمیِ تمرینِ سریع پی → دستیار و همراهِ دلگرمی
|
* سیگما → راهنمای درس و آزمون دلتا → همتیمیِ تمرینِ سریع پی → دستیار و همراهِ دلگرمی
|
||||||
@@ -8,13 +9,14 @@
|
|||||||
* ورودیها همه از همین فایلاند و هیچ متنِ کاربر واردِ SVG نمیشود.
|
* ورودیها همه از همین فایلاند و هیچ متنِ کاربر واردِ SVG نمیشود.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type SeyarChar = 'sigma' | 'delta' | 'pi';
|
export type SeyarChar = 'sigma' | 'delta' | 'pi' | 'tedi';
|
||||||
export type SeyarMood = 'idle' | 'happy' | 'excited' | 'sad' | 'angry' | 'depressed';
|
export type SeyarMood = 'idle' | 'happy' | 'excited' | 'sad' | 'angry' | 'depressed';
|
||||||
|
|
||||||
const PALETTE: Record<SeyarChar, Record<string, string>> = {
|
const PALETTE: Record<SeyarChar, Record<string, string>> = {
|
||||||
sigma: { body: "#4C5FD5", inner: "#7A8CFF", belly: "#EAF0FF", ink: "#232355", cheek: "#FFB2C0", mouth: "#FF6B6B", tear: "#8EC9FF", badge: "#FFD23F" },
|
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" },
|
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" },
|
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 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 };
|
const ARM_DELTA: Record<SeyarMood, number> = { idle: 0, happy: 2, excited: 46, sad: -4, angry: -8, depressed: -10 };
|
||||||
@@ -87,6 +89,12 @@ function seyarSigmaSVG(mood: SeyarMood): string {
|
|||||||
</g></svg>`;
|
</g></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** نشانِ حرفِ یونانیِ هر همراه (مثلِ مدالِ Σِ سیگما): دایرهی کوچک روی شکم با نمادِ خودش. */
|
||||||
|
function letterBadge(cx: number, cy: number, entity: string, fill: string, ink: string): string {
|
||||||
|
return `<circle cx="${cx}" cy="${cy}" r="13" fill="${fill}" stroke="${ink}" stroke-width="4"/>`
|
||||||
|
+ `<text x="${cx}" y="${cy + 6.5}" font-size="18" text-anchor="middle" font-family="Georgia,serif" font-weight="bold" fill="${ink}">${entity}</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
function seyarDeltaSVG(mood: SeyarMood): string {
|
function seyarDeltaSVG(mood: SeyarMood): string {
|
||||||
const C = PALETTE.delta;
|
const C = PALETTE.delta;
|
||||||
const f = face(mood, 84, 108, 100, 128, C.ink, C.mouth, C.tear, C.cheek, 32);
|
const f = face(mood, 84, 108, 100, 128, C.ink, C.mouth, C.tear, C.cheek, 32);
|
||||||
@@ -101,6 +109,7 @@ function seyarDeltaSVG(mood: SeyarMood): string {
|
|||||||
<path d="M100 30 Q112 30 118 42 L160 156 Q166 172 150 174 L50 174 Q34 172 40 156 L82 42 Q88 30 100 30 Z" fill="${C.body}" stroke="${C.ink}" stroke-width="6" stroke-linejoin="round"/>
|
<path d="M100 30 Q112 30 118 42 L160 156 Q166 172 150 174 L50 174 Q34 172 40 156 L82 42 Q88 30 100 30 Z" fill="${C.body}" stroke="${C.ink}" stroke-width="6" stroke-linejoin="round"/>
|
||||||
<path d="M100 74 Q108 74 112 84 L136 148 Q140 158 128 158 L72 158 Q60 158 64 148 L88 84 Q92 74 100 74 Z" fill="${C.belly}"/>
|
<path d="M100 74 Q108 74 112 84 L136 148 Q140 158 128 158 L72 158 Q60 158 64 148 L88 84 Q92 74 100 74 Z" fill="${C.belly}"/>
|
||||||
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
|
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
|
||||||
|
${letterBadge(100, 160, 'Δ', '#FFD23F', C.ink)}
|
||||||
</g></svg>`;
|
</g></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,22 +128,86 @@ function seyarPiSVG(mood: SeyarMood): string {
|
|||||||
<ellipse cx="100" cy="118" rx="74" ry="72" fill="${C.body}" stroke="${C.ink}" stroke-width="6"/>
|
<ellipse cx="100" cy="118" rx="74" ry="72" fill="${C.body}" stroke="${C.ink}" stroke-width="6"/>
|
||||||
<ellipse cx="100" cy="134" rx="46" ry="46" fill="${C.belly}"/>
|
<ellipse cx="100" cy="134" rx="46" ry="46" fill="${C.belly}"/>
|
||||||
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
|
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
|
||||||
|
${letterBadge(100, 170, 'π', '#FFD23F', C.ink)}
|
||||||
|
</g></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* امگا (ω؛ تا ۱۴۰۵-۰۷ «تدی»؛ کلیدِ داخلیاش هنوز `tedi` است چون slugِ سرور همین است) — شکلکِ زردِ پیشنمایشِ موبایل (mascot-pack، ریپوی بکاند، پوشهی
|
||||||
|
* mobile-preview). بدن، گوشها، پاها و دستها عیناً از همان پک است؛ چهرهی
|
||||||
|
* «عصبانی» و «ناامید» را پک نداشت و با همان زبانِ طراحیِ سهیار اضافه شد.
|
||||||
|
*/
|
||||||
|
function tediSVG(mood: SeyarMood): string {
|
||||||
|
const C = PALETTE.tedi;
|
||||||
|
const S = `stroke="${C.ink}" stroke-width="5"`;
|
||||||
|
const dy = Y_SHIFT[mood];
|
||||||
|
const restArmL = `<rect x="20" y="112" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(14 32 135)"/>`;
|
||||||
|
const restArmR = `<rect x="156" y="112" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(-14 168 135)"/>`;
|
||||||
|
const upArmL = `<rect x="14" y="70" width="24" height="50" rx="12" fill="${C.body}" ${S} transform="rotate(28 26 95)"/>`;
|
||||||
|
const upArmR = `<rect x="162" y="70" width="24" height="50" rx="12" fill="${C.body}" ${S} transform="rotate(-28 174 95)"/>`;
|
||||||
|
const droopArmL = `<rect x="24" y="118" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(4 36 141)"/>`;
|
||||||
|
const droopArmR = `<rect x="152" y="118" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(-4 164 141)"/>`;
|
||||||
|
|
||||||
|
let arms = restArmL + restArmR, hands = '', eyes = '', mouth = '', cheeks = '', extra = '';
|
||||||
|
if (mood === 'happy' || mood === 'excited') {
|
||||||
|
eyes = `<path d="M66 100 q10 -13 20 0" fill="none" ${S} stroke-linecap="round"/><path d="M114 100 q10 -13 20 0" fill="none" ${S} stroke-linecap="round"/>`;
|
||||||
|
mouth = `<path d="M78 122 q22 34 44 0 q-22 12 -44 0z" fill="${C.mouth}" ${S} stroke-linejoin="round"/>`;
|
||||||
|
cheeks = `<ellipse cx="64" cy="122" rx="12" ry="8" fill="${C.cheek}"/><ellipse cx="136" cy="122" rx="12" ry="8" fill="${C.cheek}"/>`;
|
||||||
|
if (mood === 'excited') {
|
||||||
|
arms = upArmL + upArmR;
|
||||||
|
hands = `<circle cx="18" cy="66" r="12" fill="${C.body}" ${S}/><circle cx="182" cy="66" r="12" fill="${C.body}" ${S}/>`;
|
||||||
|
extra = `<g stroke="${C.inner}" stroke-width="5" stroke-linecap="round"><path d="M40 30 l-8 -12"/><path d="M160 30 l8 -12"/><path d="M100 8 v-12"/></g>`;
|
||||||
|
}
|
||||||
|
} else if (mood === 'sad') {
|
||||||
|
eyes = `<circle cx="78" cy="104" r="8" fill="${C.ink}"/><circle cx="122" cy="104" r="8" fill="${C.ink}"/>`
|
||||||
|
+ `<path d="M68 94 q10 -6 20 -2" fill="none" ${S} stroke-linecap="round"/><path d="M112 92 q10 -4 20 2" fill="none" ${S} stroke-linecap="round"/>`;
|
||||||
|
mouth = `<path d="M82 142 q18 -18 36 0" fill="none" ${S} stroke-linecap="round"/>`;
|
||||||
|
extra = `<path d="M78 112 q-4 12 0 18 q4 -6 0 -18z" fill="${C.tear}"/>`;
|
||||||
|
} else if (mood === 'angry') {
|
||||||
|
eyes = `<path d="M68 98 l16 5 l-5 9 z" fill="${C.ink}"/><path d="M132 98 l-16 5 l5 9 z" fill="${C.ink}"/>`
|
||||||
|
+ `<path d="M64 88 l22 7" ${S} stroke-linecap="round"/><path d="M136 88 l-22 7" ${S} stroke-linecap="round"/>`;
|
||||||
|
mouth = `<path d="M82 136 l6 -6 l6 6 l6 -6 l6 6 l6 -6 l6 6" fill="none" ${S} stroke-linecap="round" stroke-linejoin="round"/>`;
|
||||||
|
cheeks = `<ellipse cx="62" cy="124" rx="10" ry="6" fill="${C.mouth}" opacity=".5"/><ellipse cx="138" cy="124" rx="10" ry="6" fill="${C.mouth}" opacity=".5"/>`;
|
||||||
|
extra = `<g stroke="${C.ink}" stroke-width="4" stroke-linecap="round" opacity=".75"><path d="M30 20 q4 -8 -2 -14"/><path d="M170 20 q-4 -8 2 -14"/></g>`;
|
||||||
|
} else if (mood === 'depressed') {
|
||||||
|
arms = droopArmL + droopArmR;
|
||||||
|
eyes = `<path d="M68 104 q10 5 20 0" fill="none" stroke="${C.ink}" stroke-width="6" stroke-linecap="round"/><path d="M112 104 q10 5 20 0" fill="none" stroke="${C.ink}" stroke-width="6" stroke-linecap="round"/>`;
|
||||||
|
mouth = `<path d="M88 136 q12 6 24 1" fill="none" ${S} stroke-linecap="round"/>`;
|
||||||
|
extra = `<g><ellipse cx="100" cy="-6" rx="26" ry="10" fill="#B9C2CC" opacity=".9"/><ellipse cx="84" cy="0" rx="16" ry="8" fill="#B9C2CC" opacity=".9"/><ellipse cx="118" cy="0" rx="15" ry="8" fill="#B9C2CC" opacity=".9"/>`
|
||||||
|
+ `<g stroke="#8FA0B3" stroke-width="3.5" stroke-linecap="round" opacity=".8"><line x1="86" y1="10" x2="82" y2="20"/><line x1="100" y1="12" x2="96" y2="24"/><line x1="114" y1="10" x2="110" y2="22"/></g></g>`;
|
||||||
|
} else {
|
||||||
|
eyes = `<circle cx="78" cy="102" r="9" fill="${C.ink}"/><circle cx="122" cy="102" r="9" fill="${C.ink}"/>`
|
||||||
|
+ `<circle cx="81" cy="99" r="3" fill="#fff"/><circle cx="125" cy="99" r="3" fill="#fff"/>`;
|
||||||
|
mouth = `<path d="M84 128 q16 18 32 0" fill="none" ${S} stroke-linecap="round"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<svg viewBox="-14 -34 228 250" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 ${dy})">
|
||||||
|
${extra}
|
||||||
|
<ellipse cx="74" cy="185" rx="20" ry="13" fill="${C.inner}" ${S}/><ellipse cx="126" cy="185" rx="20" ry="13" fill="${C.inner}" ${S}/>
|
||||||
|
${arms}
|
||||||
|
<circle cx="56" cy="46" r="24" fill="${C.body}" ${S}/><circle cx="144" cy="46" r="24" fill="${C.body}" ${S}/>
|
||||||
|
<circle cx="56" cy="46" r="12" fill="${C.inner}"/><circle cx="144" cy="46" r="12" fill="${C.inner}"/>
|
||||||
|
<ellipse cx="100" cy="116" rx="72" ry="70" fill="${C.body}" ${S}/>
|
||||||
|
<ellipse cx="100" cy="133" rx="44" ry="45" fill="${C.belly}"/>
|
||||||
|
${hands}${cheeks}<g>${eyes}</g>${mouth}
|
||||||
|
${letterBadge(100, 166, 'ω', '#fff', C.ink)}
|
||||||
</g></svg>`;
|
</g></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SEYAR_SVG: Record<SeyarChar, (mood: SeyarMood) => string> = {
|
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 }> = {
|
export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = {
|
||||||
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
|
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
|
||||||
delta: { name: "دلتا", symbol: "Δ", role: "همتیمیِ تمرینهای سریع" },
|
delta: { name: "دلتا", symbol: "Δ", role: "همتیمیِ تمرینهای سریع" },
|
||||||
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
|
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
|
||||||
|
tedi: { name: "امگا", symbol: "ω", role: "رفیقِ همیشگیِ تدریسینو" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** slugِ کاراکترِ سرور (sigma/delta/pi) را به یکی از سهیار نگاشت میکند. */
|
/** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراهها نگاشت میکند. */
|
||||||
export function seyarFromSlug(slug: string | null | undefined): SeyarChar | null {
|
export function seyarFromSlug(slug: string | null | undefined): SeyarChar | null {
|
||||||
const s = (slug ?? '').toLowerCase();
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function NavigatorProvider({ initial, children }: { initial: Route; child
|
|||||||
const root = stack[0].name;
|
const root = stack[0].name;
|
||||||
return {
|
return {
|
||||||
route: stack[stack.length - 1],
|
route: stack[stack.length - 1],
|
||||||
tab: ['home', 'map', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null,
|
tab: ['home', 'lessons', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null,
|
||||||
canGoBack: stack.length > 1,
|
canGoBack: stack.length > 1,
|
||||||
push,
|
push,
|
||||||
replace,
|
replace,
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
/** همهی صفحههای اپ و پارامترهایشان. */
|
/** همهی صفحههای اپ و پارامترهایشان. */
|
||||||
export type Route =
|
export type Route =
|
||||||
| { name: 'home' }
|
| { name: 'home' }
|
||||||
| { name: 'map' }
|
| { name: 'lessons' }
|
||||||
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
|
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
|
||||||
| { name: 'subscription' }
|
/** view: مستقیم به فهرستِ اشتراکها یا بستهها (مثلاً از فصلِ قفل)؛ بیآن انتخابِ دوگزینهای. */
|
||||||
|
| { name: 'subscription'; view?: 'subscription' | 'pack' }
|
||||||
| { name: 'profile' }
|
| { name: 'profile' }
|
||||||
| { name: 'unit'; unitId: string }
|
| { name: 'unit'; unitId: string }
|
||||||
| { name: 'lesson'; lessonId: number; nodeId?: string }
|
| { name: 'lesson'; lessonId: number; nodeId?: string }
|
||||||
| { name: 'quiz'; lessonId: number; nodeId?: string }
|
/** بدونِ lessonId یعنی آزمونِ کلِ جزیره (nodeId لازم است). */
|
||||||
|
| { name: 'quiz'; lessonId?: number; nodeId?: string }
|
||||||
| { name: 'notifications' }
|
| { name: 'notifications' }
|
||||||
| { name: 'leaderboard' }
|
| { name: 'leaderboard' }
|
||||||
| { name: 'characters' }
|
| { name: 'characters' }
|
||||||
| { name: 'streak' }
|
| { name: 'streak' }
|
||||||
| { name: 'support' }
|
| { name: 'support' }
|
||||||
| { name: 'family' }
|
/** «گزارشِ والدین»: والد پیشرفتِ هفته را در حسابِ خودِ دانشآموز میبیند. */
|
||||||
| { name: 'invite' }
|
| { name: 'report' }
|
||||||
| { name: 'register' };
|
/** پرداختها و فاکتورهای خودِ کاربر. */
|
||||||
|
| { name: 'invoices' }
|
||||||
|
| { name: 'invite' };
|
||||||
|
|
||||||
export type TabName = 'home' | 'map' | 'ask' | 'subscription' | 'profile';
|
export type TabName = 'home' | 'lessons' | 'ask' | 'subscription' | 'profile';
|
||||||
|
|
||||||
export const TABS: TabName[] = ['home', 'map', 'ask', 'subscription', 'profile'];
|
export const TABS: TabName[] = ['home', 'lessons', 'ask', 'subscription', 'profile'];
|
||||||
|
|
||||||
export function isTab(name: Route['name']): name is TabName {
|
export function isTab(name: Route['name']): name is TabName {
|
||||||
return (TABS as string[]).includes(name);
|
return (TABS as string[]).includes(name);
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { BookOpen, MessageSquarePlus, RotateCw, Send, ThumbsDown, ThumbsUp } from 'lucide-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 { useAppData } from '@/state/AppData';
|
||||||
import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
|
import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
|
||||||
|
import { useNav } from '@/nav/Navigator';
|
||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||||
import { useToast } from '@/components/Toast';
|
import { useToast } from '@/components/Toast';
|
||||||
import { Screen, ScreenHeader } from '@/components/ui';
|
import { Screen, ScreenHeader } from '@/components/ui';
|
||||||
|
import { Diagram } from '@/diagram';
|
||||||
|
import { MathText } from '@/lib/MathText';
|
||||||
import { friendlyError } from '@/lib/errors';
|
import { friendlyError } from '@/lib/errors';
|
||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
|
|
||||||
@@ -18,7 +21,11 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
|||||||
const chat = useAskState();
|
const chat = useAskState();
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
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 listEnd = useRef<HTMLDivElement>(null);
|
||||||
const autoSent = useRef(false);
|
const autoSent = useRef(false);
|
||||||
|
|
||||||
@@ -39,7 +46,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
|||||||
askStore.setHistory(
|
askStore.setHistory(
|
||||||
r.messages
|
r.messages
|
||||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||||
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId })),
|
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId, diagram: m.diagram ?? null })),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.catch(() => askStore.reset(null));
|
.catch(() => askStore.reset(null));
|
||||||
@@ -85,14 +92,18 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
|||||||
messageId: done.messageId,
|
messageId: done.messageId,
|
||||||
usedChunks: done.usedChunks,
|
usedChunks: done.usedChunks,
|
||||||
followUps: done.suggestedFollowUps,
|
followUps: done.suggestedFollowUps,
|
||||||
|
diagram: done.diagram ?? null,
|
||||||
});
|
});
|
||||||
askStore.setConversation(done.conversationId);
|
askStore.setConversation(done.conversationId);
|
||||||
setQuota(done.remainingQuota);
|
setQuota((q) => ({ free: done.remainingQuota, credits: done.credits ?? q?.credits ?? 0 }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// سقفِ روزانه (نه throttleِ چندثانیهای): «دوباره» تا فردا بیفایده است.
|
||||||
|
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
|
||||||
askStore.update(replyId, {
|
askStore.update(replyId, {
|
||||||
text: friendlyError(err, 'جوابی نرسید'),
|
text: friendlyError(err, 'جوابی نرسید'),
|
||||||
failedQuestion: question,
|
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
|
||||||
});
|
});
|
||||||
|
if (quotaReached) setQuota({ free: 0, credits: 0 });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -119,16 +130,19 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
|||||||
back={lessonId !== undefined || prompt !== undefined}
|
back={lessonId !== undefined || prompt !== undefined}
|
||||||
mascot={{ char: 'pi', mood: busy ? 'excited' : 'idle', size: 60 }}
|
mascot={{ char: 'pi', mood: busy ? 'excited' : 'idle', size: 60 }}
|
||||||
>
|
>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{quota !== null && <QuotaPills free={quota.free} credits={quota.credits} />}
|
||||||
{!empty && (
|
{!empty && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => askStore.reset(null)}
|
onClick={() => askStore.reset(null)}
|
||||||
disabled={busy}
|
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"
|
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} /> گفتگوی تازه
|
<MessageSquarePlus size={14} /> گفتگوی تازه
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</ScreenHeader>
|
</ScreenHeader>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
|
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
|
||||||
@@ -170,6 +184,11 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
|
// جهتِ هر خط از اولین حرفش: «-3 + 5» هنگامِ تایپ چپبهراست میماند. خطی که با
|
||||||
|
// فارسی شروع شود راستبهچپ است و ریاضیِ وسطش در کادرِ تایپ ممکن است جابهجا دیده شود؛
|
||||||
|
// در textarea نمیشود تکهای را قفل کرد. پیامِ فرستادهشده از MathText رد میشود.
|
||||||
|
dir={input ? 'auto' : 'rtl'}
|
||||||
|
style={{ unicodeBidi: 'plaintext', textAlign: 'right' }}
|
||||||
placeholder="سوالت رو بنویس…"
|
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"
|
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" />
|
<Send size={16} className="-scale-x-100" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{quota !== null && <p className="mt-1.5 text-center text-[10px] text-muted">امروز {toFa(quota)} سوالِ دیگه میتونی بپرسی</p>}
|
|
||||||
</form>
|
</form>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Bubble({ role, children }: { role: 'user' | 'assistant'; children: React.ReactNode }) {
|
function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
|
||||||
const mine = role === 'user';
|
const mine = role === 'user';
|
||||||
return (
|
return (
|
||||||
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
|
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||||
@@ -203,6 +221,43 @@ function Bubble({ role, children }: { role: 'user' | 'assistant'; children: Reac
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{children}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -218,24 +273,11 @@ function Message({
|
|||||||
onFollowUp: (q: string) => void;
|
onFollowUp: (q: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const toast = useToast();
|
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) {
|
if (!message.text) return <Thinking />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rate(helpful: boolean) {
|
async function rate(helpful: boolean) {
|
||||||
if (!message.messageId || message.feedback) return;
|
if (!message.messageId || message.feedback) return;
|
||||||
@@ -251,9 +293,15 @@ function Message({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<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">
|
<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">
|
<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} /> دوباره امتحان کن
|
<RotateCw size={12} /> دوباره امتحان کن
|
||||||
</button>
|
</button>
|
||||||
@@ -289,3 +337,25 @@ function Message({
|
|||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useAsync } from '@/lib/useAsync';
|
|||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||||
import { SEYAR_INFO, seyarFromSlug } from '@/mascots/seyar';
|
import { SEYAR_INFO, seyarFromSlug } from '@/mascots/seyar';
|
||||||
import { useToast } from '@/components/Toast';
|
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 { friendlyError } from '@/lib/errors';
|
||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export function CharactersScreen() {
|
|||||||
<Screen>
|
<Screen>
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
title="همراهت رو انتخاب کن"
|
title="همراهت رو انتخاب کن"
|
||||||
subtitle={`امتیازِ تو: ${toFa(summary?.xp ?? 0)} · با امتیازِ بیشتر همراههای بیشتری باز میشن`}
|
subtitle={<>امتیازِ تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیازِ بیشتر همراههای بیشتری باز میشن</>}
|
||||||
color="#9B7BFF"
|
color="#9B7BFF"
|
||||||
back
|
back
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { BookOpen, Flame, Link2, Star, Trophy, UserPlus } from 'lucide-react';
|
|
||||||
import { api, ApiError, type FamilyLink } from '@/api';
|
|
||||||
import { useAsync } from '@/lib/useAsync';
|
|
||||||
import { useToast } from '@/components/Toast';
|
|
||||||
import { InlineError, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
|
|
||||||
import { friendlyError } from '@/lib/errors';
|
|
||||||
import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* بخشِ والدین. والد با شمارهی فرزند دعوت میسازد و یک کد میگیرد؛ فرزند
|
|
||||||
* (با همان شماره وارد شده) کد را اینجا وارد میکند. بعد از اتصال، والد
|
|
||||||
* گزارشِ هفتگی را میبیند.
|
|
||||||
*/
|
|
||||||
export function FamilyScreen() {
|
|
||||||
const link = useAsync(
|
|
||||||
() => api.family.link().catch((err) => {
|
|
||||||
if (err instanceof ApiError && err.status === 404) return null;
|
|
||||||
throw err;
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen>
|
|
||||||
<ScreenHeader title="بخشِ والدین" subtitle="پیشرفتِ فرزندت رو هر هفته ببین" color="#FF7A3D" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} />
|
|
||||||
<ScreenBody className="flex flex-col gap-4">
|
|
||||||
{link.loading ? (
|
|
||||||
<LoadingState />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{link.data && <LinkStatus link={link.data} />}
|
|
||||||
{link.data?.status === 'accepted' && <WeeklyReport />}
|
|
||||||
<InviteChild onDone={link.reload} />
|
|
||||||
<AcceptInvite onDone={link.reload} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ScreenBody>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LinkStatus({ link }: { link: FamilyLink }) {
|
|
||||||
const accepted = link.status === 'accepted';
|
|
||||||
return (
|
|
||||||
<div className={`flex items-center gap-3 rounded-3xl p-4 ${accepted ? 'bg-[#EAF8F0]' : 'bg-[#FFF8E1]'}`}>
|
|
||||||
<Link2 size={20} className={accepted ? 'text-mint' : 'text-gold'} />
|
|
||||||
<p className="text-sm leading-7 text-ink">
|
|
||||||
{accepted ? <>به <b>{link.childName || 'فرزندت'}</b> وصلی.</> : 'دعوت فرستاده شده و منتظرِ تأییدِ فرزندته.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WeeklyReport() {
|
|
||||||
const report = useAsync(() => api.family.weeklyReport(), []);
|
|
||||||
if (report.loading) return <div className="h-32 animate-pulse rounded-3xl bg-soft" />;
|
|
||||||
if (!report.data) return null;
|
|
||||||
const r = report.data;
|
|
||||||
return (
|
|
||||||
<section className="rounded-3xl border border-line bg-white p-4">
|
|
||||||
<h2 className="mb-3 text-sm font-black text-ink">گزارشِ این هفته <span className="text-xs font-medium text-muted">(از {formatJalaliDate(r.weekStart)})</span></h2>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{[
|
|
||||||
{ icon: Star, label: 'امتیاز', value: r.xpEarned },
|
|
||||||
{ icon: BookOpen, label: 'درسِ تمامشده', value: r.lessonsCompleted },
|
|
||||||
{ icon: Trophy, label: 'جزیرهی تمامشده', value: r.islandsCompleted },
|
|
||||||
{ icon: Flame, label: 'زنجیره (روز)', value: r.currentStreak },
|
|
||||||
].map(({ icon: Icon, label, value }) => (
|
|
||||||
<div key={label} className="flex items-center gap-2 rounded-2xl bg-soft px-3 py-2.5">
|
|
||||||
<Icon size={16} className="text-orange" />
|
|
||||||
<span className="flex-1 text-xs text-body">{label}</span>
|
|
||||||
<span className="text-sm font-black text-ink">{toFa(value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InviteChild({ onDone }: { onDone: () => void }) {
|
|
||||||
const [phone, setPhone] = useState('');
|
|
||||||
const [code, setCode] = useState<string | null>(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
async function invite() {
|
|
||||||
setBusy(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const r = await api.family.invite(phone);
|
|
||||||
setCode(r.inviteCode);
|
|
||||||
onDone();
|
|
||||||
} catch (err) {
|
|
||||||
setError(friendlyError(err));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="rounded-3xl border border-line bg-white p-4">
|
|
||||||
<h2 className="mb-1 flex items-center gap-2 text-sm font-black text-ink"><UserPlus size={16} className="text-orange" /> من والدم</h2>
|
|
||||||
<p className="mb-3 text-xs leading-6 text-muted">شمارهای که فرزندت باهاش وارد تدریسینو شده رو بنویس.</p>
|
|
||||||
{code ? (
|
|
||||||
<div className="rounded-2xl bg-soft p-4 text-center">
|
|
||||||
<p className="text-xs text-muted">این کد رو به فرزندت بده تا تو بخشِ والدینِ حسابِ خودش وارد کنه:</p>
|
|
||||||
<p dir="ltr" className="mt-2 text-2xl font-black tracking-widest text-ink">{code}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
value={phone}
|
|
||||||
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))}
|
|
||||||
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
|
|
||||||
dir="ltr"
|
|
||||||
inputMode="numeric"
|
|
||||||
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-left outline-none focus:border-orange"
|
|
||||||
/>
|
|
||||||
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
|
|
||||||
<PrimaryButton color="#FF7A3D" loading={busy} disabled={!/^09\d{9}$/.test(phone)} onClick={() => void invite()}>
|
|
||||||
ساختِ کدِ دعوت
|
|
||||||
</PrimaryButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AcceptInvite({ onDone }: { onDone: () => void }) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [code, setCode] = useState('');
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
async function accept() {
|
|
||||||
setBusy(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await api.family.accept(code.trim());
|
|
||||||
toast('به والدت وصل شدی', 'success');
|
|
||||||
setCode('');
|
|
||||||
onDone();
|
|
||||||
} catch (err) {
|
|
||||||
setError(friendlyError(err));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="rounded-3xl border border-line bg-white p-4">
|
|
||||||
<h2 className="mb-1 text-sm font-black text-ink">من دانشآموزم</h2>
|
|
||||||
<p className="mb-3 text-xs leading-6 text-muted">اگه پدر یا مادرت کدِ دعوت بهت داده، اینجا واردش کن.</p>
|
|
||||||
<input
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value.slice(0, 32))}
|
|
||||||
placeholder="کدِ دعوت"
|
|
||||||
dir="ltr"
|
|
||||||
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-orange"
|
|
||||||
/>
|
|
||||||
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
|
|
||||||
<PrimaryButton color="#FF7A3D" loading={busy} disabled={!code.trim()} onClick={() => void accept()}>
|
|
||||||
وصل شو
|
|
||||||
</PrimaryButton>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,180 +1,147 @@
|
|||||||
import { Bell, Brain, ChevronLeft, Coins, Flame, Map, Play, Star, Trophy } from 'lucide-react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Bell, ChevronLeft, Coins, Crown, Flame, Hand, PartyPopper, Star } from 'lucide-react';
|
||||||
|
import type { HomeNode } from '@/api';
|
||||||
import { useAppData } from '@/state/AppData';
|
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 { useNav } from '@/nav/Navigator';
|
||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||||
import { Card, ErrorState, ProgressBar, Screen } from '@/components/ui';
|
import { IslandPath } from '@/components/IslandPath';
|
||||||
import { iconFor } from '@/components/NodeIcon';
|
import { IslandSheet } from '@/components/IslandSheet';
|
||||||
|
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
|
||||||
|
import { onWhite, shade } from '@/lib/color';
|
||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
|
import { MathText } from '@/lib/MathText';
|
||||||
|
import { BrandFooter } from '@/components/BrandFooter';
|
||||||
|
import { BrandMark } from '@/components/BrandMark';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
||||||
|
* یک دکمهی «شروع» روی سرش. قبلاً خانه داشبوردی از شش کارت بود (ادامه، نقشه،
|
||||||
|
* دستیار، فصلها، چالش) و بچه نمیدانست کدام را بزند؛ حالا یک کارِ واضح دارد.
|
||||||
|
*/
|
||||||
export function HomeScreen() {
|
export function HomeScreen() {
|
||||||
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
|
// فصلِ غیرِ اول بیاشتراک: زدنِ جزیره یکراست فهرستِ اشتراکها را باز میکند.
|
||||||
|
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
|
||||||
|
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
|
||||||
|
const currentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrolled = useRef(false);
|
||||||
const cont = findContinue(home);
|
const 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;
|
useEffect(() => {
|
||||||
nav.push({ name: quiz ? 'quiz' : 'lesson', lessonId: contLesson, nodeId: cont.node.id });
|
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}! <Hand size={15} className="mb-0.5 inline text-[#E8A400]" /> وقتشه جزیرهی <b>«<MathText text={cont.node.title} />»</b> رو شروع کنی؛ دکمهی <b>شروع</b> رو بزن.</>
|
||||||
|
: <>آفرین {name}! همهی جزیرههای این پایه رو تموم کردی <PartyPopper size={15} className="mb-0.5 inline text-[#E8A400]" /></>;
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', fg: '#FF7A3D', bg: '#FFF1E6', fill: true, to: 'streak' as const },
|
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
|
||||||
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', fg: '#E8A400', bg: '#FFF8E1', fill: false, to: 'leaderboard' as const },
|
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', color: '#E8A400', fill: true, to: 'leaderboard' as const },
|
||||||
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', fg: '#9B7BFF', bg: '#F5F1FF', fill: false, to: 'characters' as const },
|
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-4 pt-safe text-white shadow-md shadow-coral/20">
|
||||||
<header className="relative overflow-hidden rounded-b-[32px] bg-coral px-5 pb-11 pt-safe text-white">
|
<div className="flex items-center gap-2 pt-4">
|
||||||
<div className="pointer-events-none absolute -left-10 -top-10 h-48 w-48 rounded-full bg-white/5" />
|
<BrandMark size={26} withName={false} />
|
||||||
<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 && (
|
{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>
|
<button type="button" onClick={() => nav.switchTab('profile')} className="hit rounded-full bg-white/20 px-3 py-1.5 text-xs font-black active:scale-95">
|
||||||
)}
|
{gradeTitle}
|
||||||
</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>
|
||||||
<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} />
|
<div className="mr-auto flex items-center gap-1.5">
|
||||||
|
{stats.map(({ icon: Icon, value, label, color, fill, to }) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
aria-label={`${label}: ${toFa(value)}`}
|
||||||
|
onClick={() => nav.push({ name: to })}
|
||||||
|
className="hit flex items-center gap-1 rounded-full bg-white px-2.5 py-1.5 text-xs font-black text-ink shadow-sm active:scale-95"
|
||||||
|
>
|
||||||
|
<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="hit relative flex h-8 w-8 items-center justify-center rounded-full bg-white/20 active:scale-95">
|
||||||
|
<Bell size={16} />
|
||||||
{unreadCount > 0 && (
|
{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">
|
<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)}
|
{unreadCount > 9 ? '۹+' : toFa(unreadCount)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-end gap-2">
|
||||||
|
<SeyarMascot char={companion} mood={cont ? 'happy' : home ? 'excited' : 'idle'} size={64} bob />
|
||||||
|
<div className="relative mb-2 flex-1 rounded-2xl rounded-br-sm bg-white px-3.5 py-2.5 text-[13px] leading-6 text-ink shadow-sm">
|
||||||
|
{bubble}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="relative z-10 -mt-7 px-4">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex rounded-3xl border border-line bg-white shadow-lg shadow-black/10">
|
{!home ? (
|
||||||
{stats.map(({ icon: Icon, value, label, fg, bg, fill, to }) => (
|
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />
|
||||||
<button key={label} type="button" onClick={() => nav.push({ name: to })} className="flex flex-1 flex-col items-center py-3.5">
|
) : home.units.length === 0 ? (
|
||||||
<span className="mb-1.5 flex h-10 w-10 items-center justify-center rounded-full" style={{ backgroundColor: bg }}>
|
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
|
||||||
<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>
|
|
||||||
</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">
|
<div className="flex flex-col gap-2 px-4 pb-10 pt-4">
|
||||||
<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>
|
|
||||||
|
|
||||||
{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) => {
|
{home.units.map((unit, i) => {
|
||||||
const p = unitProgress(unit);
|
|
||||||
const color = unitColor(i);
|
const color = unitColor(i);
|
||||||
const Icon = iconFor(unit.nodes[0]?.icon, i);
|
const p = unitProgress(unit);
|
||||||
|
const isCurrentUnit = cont?.unit.id === unit.id;
|
||||||
return (
|
return (
|
||||||
|
<section key={unit.id} aria-label={unit.title}>
|
||||||
<button
|
<button
|
||||||
key={unit.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
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"
|
className="sticky top-2 z-10 flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-right text-white active:translate-y-0.5"
|
||||||
|
style={{ backgroundColor: onWhite(color), boxShadow: `0 4px 0 ${shade(onWhite(color), -0.25)}` }}
|
||||||
>
|
>
|
||||||
<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>
|
|
||||||
</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="min-w-0 flex-1">
|
||||||
<div className="text-sm font-black text-white">یه سؤال دیگه… فقط یکی!</div>
|
<div className="text-[11px] font-bold text-white/80">
|
||||||
<div className="mt-0.5 truncate text-[11px] text-white/80">چالشِ سریع با دلتا · {cont.node.title}</div>
|
فصلِ {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
|
||||||
|
{unit.requiresSubscription && <span className="mr-1.5 inline-flex h-5 items-center gap-1 rounded-full bg-white/25 px-2 align-middle leading-none"><Crown size={11} /> با اشتراک</span>}
|
||||||
|
{isCurrentUnit && <span className="mr-1.5 inline-flex h-5 items-center rounded-full bg-white/25 px-2 align-middle leading-none">الان اینجایی</span>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronLeft size={18} className="flex-shrink-0 text-white/80" />
|
<ChevronLeft size={18} className="flex-shrink-0 text-white/80" />
|
||||||
</button>
|
</button>
|
||||||
|
<div className="pt-12">
|
||||||
|
<IslandPath
|
||||||
|
ref={isCurrentUnit ? currentRef : undefined}
|
||||||
|
nodes={unit.nodes}
|
||||||
|
color={color}
|
||||||
|
currentId={isCurrentUnit ? cont!.node.id : null}
|
||||||
|
onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode({ node, color }))}
|
||||||
|
onStart={(node) => (unit.requiresSubscription ? paywall() : nav.push({ name: 'quiz', nodeId: node.id }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<BrandFooter />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<IslandSheet node={openNode?.node ?? null} color={openNode?.color ?? '#ED6956'} onClose={() => setOpenNode(null)} />
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/screens/InvoicesScreen.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronLeft, Copy, Receipt } from 'lucide-react';
|
||||||
|
import { api, type Invoice } from '@/api';
|
||||||
|
import { useNav } from '@/nav/Navigator';
|
||||||
|
import { useAsync } from '@/lib/useAsync';
|
||||||
|
import { useToast } from '@/components/Toast';
|
||||||
|
import { Sheet } from '@/components/Sheet';
|
||||||
|
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader, Sep } from '@/components/ui';
|
||||||
|
import { formatJalaliDate, formatToman } from '@/lib/format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «پرداختها و فاکتورها» (۱۴۰۵-۰۷): همهی سفارشهای کاربر، با فاکتورِ هر خرید.
|
||||||
|
* سفارشِ «در انتظار» و «ناموفق» هم نشان داده میشود تا کسی که پولش کم شده ولی
|
||||||
|
* اشتراکش فعال نشده، شمارهی فاکتور را به پشتیبانی بدهد. عددها از GET /payment/invoices.
|
||||||
|
*/
|
||||||
|
const STATUS: Record<Invoice['status'], { label: string; className: string }> = {
|
||||||
|
paid: { label: 'پرداختشده', className: 'bg-[#EAF8F0] text-[#2E7D5B]' },
|
||||||
|
pending: { label: 'در انتظارِ پرداخت', className: 'bg-[#FFF8E1] text-[#8A6100]' },
|
||||||
|
failed: { label: 'ناموفق', className: 'bg-soft text-muted' },
|
||||||
|
canceled: { label: 'لغوشده', className: 'bg-soft text-muted' },
|
||||||
|
refunded: { label: 'بازپرداختشده', className: 'bg-[#EEF1FF] text-indigo' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER: Record<string, string> = { gateway: 'درگاهِ زرینپال', bazaar: 'کافهبازار' };
|
||||||
|
|
||||||
|
const dateOf = (invoice: Invoice) => formatJalaliDate(invoice.paidAt ?? invoice.createdAt);
|
||||||
|
|
||||||
|
export function InvoicesScreen() {
|
||||||
|
const nav = useNav();
|
||||||
|
const list = useAsync(() => api.subscription.invoices(), []);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@ import { useNav } from '@/nav/Navigator';
|
|||||||
import { useAsync } from '@/lib/useAsync';
|
import { useAsync } from '@/lib/useAsync';
|
||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||||
import { moodFromServer } from '@/mascots/seyar';
|
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 { toFa } from '@/lib/format';
|
||||||
|
import { MathText } from '@/lib/MathText';
|
||||||
|
import { Diagram } from '@/diagram';
|
||||||
|
|
||||||
/** معرفیِ درس، پیش از تمرین. */
|
/** معرفیِ درس، پیش از تمرین. */
|
||||||
export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
|
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 ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
|
||||||
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
|
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
|
||||||
const data = intro.data;
|
const data = intro.data;
|
||||||
|
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 (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
title={data?.title ?? '…'}
|
title={data ? <MathText text={data.title} /> : '…'}
|
||||||
color={color}
|
color={color}
|
||||||
back
|
back
|
||||||
mascot={{ char: companion, mood: moodFromServer(data?.mascotAnimation), size: 72 }}
|
mascot={{ char: companion, mood: moodFromServer(data?.mascotAnimation), size: 72 }}
|
||||||
@@ -29,7 +35,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
|||||||
ref && (
|
ref && (
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1">
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1">
|
||||||
{/* ترتیبِ lessonIds «درسِ بعدی اول» است، نه ترتیبِ کتاب؛ پس شمارهی درس از آن درنمیآید. */}
|
{/* ترتیبِ 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>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -45,7 +51,8 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<SeyarMascot char="sigma" mood="idle" size={56} />
|
<SeyarMascot char="sigma" mood="idle" size={56} />
|
||||||
<div className="flex-1 rounded-3xl rounded-tr-sm border border-line bg-white p-4 shadow-sm">
|
<div className="flex-1 rounded-3xl rounded-tr-sm border border-line bg-white p-4 shadow-sm">
|
||||||
<p className="whitespace-pre-line text-sm leading-[1.95] text-body">{data.introText}</p>
|
<p className="whitespace-pre-line text-sm leading-[1.95] text-body"><MathText text={data.introText} /></p>
|
||||||
|
{figs('intro')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -56,7 +63,8 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
|||||||
{data.example && (
|
{data.example && (
|
||||||
<section className="rounded-3xl border border-line bg-white p-4">
|
<section className="rounded-3xl border border-line bg-white p-4">
|
||||||
<h2 className="mb-2 text-xs font-black text-muted">مثال</h2>
|
<h2 className="mb-2 text-xs font-black text-muted">مثال</h2>
|
||||||
<p className="whitespace-pre-line text-sm leading-[1.95] text-ink">{data.example}</p>
|
<p className="whitespace-pre-line text-sm leading-[1.95] text-ink"><MathText text={data.example} /></p>
|
||||||
|
{figs('example')}
|
||||||
</section>
|
</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 }}>
|
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
|
||||||
{toFa(i + 1)}
|
{toFa(i + 1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 whitespace-pre-line">{step}</span>
|
<span className="flex-1 whitespace-pre-line"><MathText text={step} />{figs('step', i)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
251
src/screens/LessonsScreen.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { Brain, Check, ChevronDown, Crown, Lock, PartyPopper, Play, Sparkles } from 'lucide-react';
|
||||||
|
import { api, type HomeNode, type HomeUnit } from '@/api';
|
||||||
|
import { useAppData } from '@/state/AppData';
|
||||||
|
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||||||
|
import { onWhite } from '@/lib/color';
|
||||||
|
import { useNav } from '@/nav/Navigator';
|
||||||
|
import { useAsync } from '@/lib/useAsync';
|
||||||
|
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui';
|
||||||
|
import { iconFor } from '@/components/NodeIcon';
|
||||||
|
import { toFa } from '@/lib/format';
|
||||||
|
import { MathText } from '@/lib/MathText';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تبِ «درسها»: فصل ← جزیره ← درس. فصلها اول بستهاند و بالای صفحه یک کارتِ «ادامه بده» هست.
|
||||||
|
* بازبودنِ فصل/جزیره در متغیرِ ماژول نگه داشته میشود، چون با رفتن به درس و برگشتن صفحه از نو ساخته
|
||||||
|
* میشود (کلیدِ مسیر در MainApp) و کاربر جایش را گم میکرد. با بستنِ اپ پاک میشود، عمداً.
|
||||||
|
*/
|
||||||
|
const remembered: { unit: string | null; node: string | null } = { unit: null, node: null };
|
||||||
|
|
||||||
|
export function LessonsScreen() {
|
||||||
|
const { home, homeError, refreshHome, gradeTitle } = useAppData();
|
||||||
|
const nav = useNav();
|
||||||
|
const units = home?.units ?? [];
|
||||||
|
const current = findContinue(home);
|
||||||
|
const [openUnit, setOpenUnitState] = useState<string | null>(remembered.unit);
|
||||||
|
const [openNode, setOpenNodeState] = useState<string | null>(remembered.node);
|
||||||
|
const setOpenUnit = (id: string | null) => { remembered.unit = id; setOpenUnitState(id); };
|
||||||
|
const setOpenNode = (id: string | null) => { remembered.node = id; setOpenNodeState(id); };
|
||||||
|
|
||||||
|
const totalNodes = units.reduce((n, u) => n + u.nodes.length, 0);
|
||||||
|
const doneNodes = units.reduce((n, u) => n + unitProgress(u).done, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen className="bg-page">
|
||||||
|
<ScreenHeader
|
||||||
|
title="درسها"
|
||||||
|
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>}
|
||||||
|
color="#2FB36B"
|
||||||
|
exact
|
||||||
|
mascot={{ char: 'sigma', mood: 'happy' }}
|
||||||
|
>
|
||||||
|
{home && totalNodes > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="mb-1.5 flex justify-between text-[11px] text-white/85">
|
||||||
|
<span>جزیرههایی که تموم کردی</span>
|
||||||
|
<span>{toFa(doneNodes)} از {toFa(totalNodes)}</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar percent={Math.round((doneNodes / totalNodes) * 100)} color="#fff" track="rgba(255,255,255,.25)" height={10} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScreenHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-4">
|
||||||
|
{!home ? (
|
||||||
|
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم درسها رو میارم…" />
|
||||||
|
) : units.length === 0 ? (
|
||||||
|
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{current && current.node.nextLessonId != null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => nav.push({ name: 'lesson', lessonId: current.node.nextLessonId as number, nodeId: current.node.id })}
|
||||||
|
className="flex items-center gap-3 rounded-2xl px-4 py-3.5 text-right text-white"
|
||||||
|
style={{ backgroundColor: onWhite(unitColor(current.unitIndex)), boxShadow: `0 4px 0 ${onWhite(unitColor(current.unitIndex))}99` }}
|
||||||
|
>
|
||||||
|
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-white/25">
|
||||||
|
<Play size={20} fill="white" />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-[11px] font-bold text-white/85">ادامه بده از همینجا</span>
|
||||||
|
<span className="block text-sm font-black leading-6"><MathText text={current.node.title} /></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{units.map((unit, i) => (
|
||||||
|
<UnitCard
|
||||||
|
key={unit.id}
|
||||||
|
index={i}
|
||||||
|
unit={unit}
|
||||||
|
color={unitColor(i)}
|
||||||
|
currentNodeId={current?.node.id ?? null}
|
||||||
|
open={openUnit === unit.id}
|
||||||
|
onToggle={() => setOpenUnit(openUnit === unit.id ? null : unit.id)}
|
||||||
|
openNode={openNode}
|
||||||
|
onToggleNode={(id) => setOpenNode(openNode === id ? null : id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => nav.switchTab('ask')}
|
||||||
|
className="mt-1 flex items-center gap-3 rounded-2xl border border-line bg-white px-4 py-3 text-right"
|
||||||
|
>
|
||||||
|
<Brain size={22} className="flex-shrink-0 text-violet" />
|
||||||
|
<span className="text-sm leading-6 text-body">یه جایی از درس رو نفهمیدی؟ <b className="text-ink">از دستیار بپرس</b></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnitCard({
|
||||||
|
index, unit, color, currentNodeId, open, onToggle, openNode, onToggleNode,
|
||||||
|
}: {
|
||||||
|
index: number; unit: HomeUnit; color: string; currentNodeId: string | null; open: boolean; onToggle: () => void;
|
||||||
|
openNode: string | null; onToggleNode: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const p = unitProgress(unit);
|
||||||
|
const complete = p.total > 0 && p.done === p.total;
|
||||||
|
const untouched = unit.nodes.every((n) => n.status === 'locked');
|
||||||
|
const Icon = iconFor(unit.nodes[0]?.icon, index);
|
||||||
|
const nav = useNav();
|
||||||
|
const premium = unit.requiresSubscription;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: Math.min(index, 8) * 0.04 }}
|
||||||
|
className="overflow-hidden rounded-2xl bg-white"
|
||||||
|
style={{ border: `2px solid ${open ? color : '#e9e6f7'}`, boxShadow: open ? `0 4px 0 ${color}40` : '0 2px 0 #e9e6f7' }}
|
||||||
|
>
|
||||||
|
<button type="button" onClick={premium ? () => nav.push({ name: 'subscription', view: 'subscription' }) : onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
|
||||||
|
<span
|
||||||
|
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-2xl"
|
||||||
|
style={{ backgroundColor: complete ? color : `${color}22`, color: complete ? '#fff' : color, opacity: untouched ? 0.55 : 1 }}
|
||||||
|
>
|
||||||
|
{complete ? <Check size={26} strokeWidth={3.5} /> : untouched ? <Lock size={22} /> : <Icon size={26} strokeWidth={2.2} />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-[11px] font-bold" style={{ color }}>
|
||||||
|
{complete ? <><PartyPopper size={12} className="mb-0.5 ml-1 inline" />تموم شد</> : `${toFa(p.total)} جزیره`}
|
||||||
|
{premium && <span className="mr-1.5 inline-flex items-center gap-0.5 rounded-full bg-[#FFF4D6] px-1.5 text-[10px] text-[#A86A00]"><Crown size={10} /> با اشتراک</span>}
|
||||||
|
</span>
|
||||||
|
<span className="block text-sm font-black leading-6 text-ink"><MathText text={unit.title} /></span>
|
||||||
|
<span className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="flex-1"><ProgressBar percent={p.percent} color={color} height={6} /></span>
|
||||||
|
<span className="text-[11px] text-muted">{toFa(p.done)}/{toFa(p.total)}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{premium ? <Crown size={18} className="flex-shrink-0 text-[#E0A200]" /> : <ChevronDown size={18} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />}
|
||||||
|
</button>
|
||||||
|
{open && !premium && (
|
||||||
|
<ul className="flex flex-col gap-1.5 border-t border-line bg-[#fbfaff] px-3 py-3">
|
||||||
|
{unit.nodes.map((node, ni) => (
|
||||||
|
<NodeRow
|
||||||
|
key={node.id}
|
||||||
|
num={ni + 1}
|
||||||
|
node={node}
|
||||||
|
color={color}
|
||||||
|
isCurrent={node.id === currentNodeId}
|
||||||
|
open={openNode === node.id}
|
||||||
|
onToggle={() => onToggleNode(node.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeRow({
|
||||||
|
num, node, color, isCurrent, open, onToggle,
|
||||||
|
}: { num: number; node: HomeNode; color: string; isCurrent: boolean; open: boolean; onToggle: () => void }) {
|
||||||
|
const nav = useNav();
|
||||||
|
const locked = node.status === 'locked';
|
||||||
|
const done = node.status === 'completed';
|
||||||
|
const lessons = useAsync(() => (open && !locked ? api.nodeLessons(node.id) : Promise.resolve(null)), [node.id, open, locked]);
|
||||||
|
const playable = new Set(node.lessonIds);
|
||||||
|
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
|
||||||
|
const loaded = !lessons.loading && !lessons.error && lessons.data !== null && lessons.data !== undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="rounded-xl border bg-white" style={{ borderColor: isCurrent ? color : '#e9e6f7' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={locked}
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-right disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-xs font-black"
|
||||||
|
style={done ? { backgroundColor: '#3E9E73', color: '#fff' } : { backgroundColor: locked ? '#f0eeff' : `${color}22`, color: locked ? '#727299' : color }}
|
||||||
|
>
|
||||||
|
{done ? <Check size={15} strokeWidth={3.5} /> : locked ? <Lock size={13} /> : toFa(num)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-sm font-bold leading-6 text-ink"><MathText text={node.title} /></span>
|
||||||
|
<span className="block text-[11px] text-muted">
|
||||||
|
{locked ? 'جزیرهی قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{isCurrent && !open && (
|
||||||
|
<span className="flex-shrink-0 rounded-full px-2 py-0.5 text-[10px] font-black text-white" style={{ backgroundColor: color }}>
|
||||||
|
بعدی
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!locked && <ChevronDown size={16} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && !locked && (
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
{lessons.error ? (
|
||||||
|
<ErrorState error={lessons.error} onRetry={lessons.reload} />
|
||||||
|
) : !loaded ? (
|
||||||
|
<div className="flex flex-col gap-1.5" aria-busy="true" aria-label="دارم درسها رو میارم">
|
||||||
|
{[0, 1, 2].map((k) => <div key={k} className="h-10 animate-pulse rounded-xl bg-soft" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{list.map((l, li) => (
|
||||||
|
<li key={l.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => nav.push({ name: 'lesson', lessonId: Number(l.id), nodeId: node.id })}
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-xl bg-[#f7f8ff] px-3 py-2 text-right text-sm active:scale-[.99]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-black"
|
||||||
|
style={l.done ? { backgroundColor: '#3E9E73', color: '#fff' } : { backgroundColor: '#fff', color: '#727299', border: '1px solid #e9e6f7' }}
|
||||||
|
>
|
||||||
|
{l.done ? <Check size={13} strokeWidth={3.5} /> : toFa(li + 1)}
|
||||||
|
</span>
|
||||||
|
<span className={`flex-1 ${l.done ? 'text-muted' : 'text-ink'}`}><MathText text={l.title} /></span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => nav.push({ name: 'quiz', nodeId: node.id })}
|
||||||
|
className="mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-black text-white active:translate-y-[2px]"
|
||||||
|
style={{ backgroundColor: onWhite(color), boxShadow: `0 3px 0 ${onWhite(color)}99` }}
|
||||||
|
>
|
||||||
|
{done ? <Sparkles size={15} /> : <Play size={15} fill="white" />}
|
||||||
|
{done ? 'دوباره تمرین کن' : 'درسها رو خوندی؟ برو آزمون'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +1,30 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { ChevronRight, Phone } from 'lucide-react';
|
import { ChevronRight, Phone } from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api, ApiError } from '@/api';
|
||||||
import { InlineError, PrimaryButton } from '@/components/ui';
|
import { InlineError, PrimaryButton } from '@/components/ui';
|
||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||||
import { friendlyError } from '@/lib/errors';
|
import { friendlyError } from '@/lib/errors';
|
||||||
import { digitsOnly, toFa } from '@/lib/format';
|
import { digitsOnly, toFa } from '@/lib/format';
|
||||||
import { getDeviceId } from '@/lib/storage';
|
|
||||||
|
|
||||||
const OTP_LENGTH = 6;
|
const OTP_LENGTH = 6;
|
||||||
/** همان cooldownِ سرور (OTP_REQUEST_PHONE_COOLDOWN_SECONDS). */
|
/** همان cooldownِ سرور (OTP_REQUEST_PHONE_COOLDOWN_SECONDS). */
|
||||||
const RESEND_SECONDS = 120;
|
const RESEND_SECONDS = 120;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ورود با شماره (OTP) یا بهعنوانِ مهمان.
|
* ورود با شماره (OTP): تنها راهِ ورود است.
|
||||||
*
|
|
||||||
* `upgrade` برای مهمانی است که از صفحهی «من» میخواهد شماره ثبت کند؛ آنجا
|
|
||||||
* گزینهی مهمان نیست و سرور همان کاربر را نگه میدارد.
|
|
||||||
*/
|
*/
|
||||||
export function LoginScreen({
|
export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel?: () => void }) {
|
||||||
onDone,
|
|
||||||
onCancel,
|
|
||||||
upgrade = false,
|
|
||||||
}: {
|
|
||||||
onDone: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
upgrade?: boolean;
|
|
||||||
}) {
|
|
||||||
const [step, setStep] = useState<'phone' | 'otp'>('phone');
|
const [step, setStep] = useState<'phone' | 'otp'>('phone');
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [otp, setOtp] = useState('');
|
const [otp, setOtp] = useState('');
|
||||||
const [devOtp, setDevOtp] = useState<string | null>(null);
|
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 [error, setError] = useState('');
|
||||||
const [resendIn, setResendIn] = useState(0);
|
const [resendIn, setResendIn] = useState(0);
|
||||||
|
/** درخواستِ پیامک هنوز جواب نداده؛ جدا از `loading` تا واردکردنِ کد قفل نشود. */
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
/** شمارهی آخرین درخواست؛ جوابِ دیرِ درخواستِ قدیمیتر نادیده گرفته میشود. */
|
||||||
|
const attempt = useRef(0);
|
||||||
const otpInput = useRef<HTMLInputElement>(null);
|
const otpInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const phoneValid = /^09\d{9}$/.test(phone);
|
const phoneValid = /^09\d{9}$/.test(phone);
|
||||||
@@ -48,20 +40,37 @@ export function LoginScreen({
|
|||||||
if (step === 'otp') otpInput.current?.focus();
|
if (step === 'otp') otpInput.current?.focus();
|
||||||
}, [step]);
|
}, [step]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* صفحهی کد فوراً باز میشود و درخواست در پسزمینه جلو میرود. سرور کد را پیش از
|
||||||
|
* تماس با کاوهنگار ذخیره میکند، پس کدِ رسیده همان لحظه قابلِ تأیید است؛ ولی
|
||||||
|
* جوابِ کاوهنگار گاهی بعد از رسیدنِ پیامک میآید (۱۶ ثانیه در ۲۰۲۶-۰۹-۲۷) و
|
||||||
|
* منتظر ماندن برایش کاربر را بیدلیل معطل میکرد.
|
||||||
|
*/
|
||||||
async function requestOtp() {
|
async function requestOtp() {
|
||||||
if (!phoneValid || loading) return;
|
if (!phoneValid || sending || loading) return;
|
||||||
|
const mine = ++attempt.current;
|
||||||
setError('');
|
setError('');
|
||||||
setLoading('otp');
|
setDevOtp(null);
|
||||||
try {
|
|
||||||
const data = await api.auth.requestOtp(phone);
|
|
||||||
setDevOtp(data.devOtp ?? null);
|
|
||||||
setOtp('');
|
setOtp('');
|
||||||
setStep('otp');
|
setStep('otp');
|
||||||
setResendIn(RESEND_SECONDS);
|
setResendIn(RESEND_SECONDS);
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const data = await api.auth.requestOtp(phone);
|
||||||
|
if (mine !== attempt.current) return;
|
||||||
|
setDevOtp(data.devOtp ?? null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (mine !== attempt.current) return;
|
||||||
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن'));
|
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن'));
|
||||||
|
if (err instanceof ApiError && err.status === 400) {
|
||||||
|
setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند
|
||||||
|
} else if (err instanceof ApiError && err.isRateLimited && err.retryAfterMs) {
|
||||||
|
setResendIn(Math.ceil(err.retryAfterMs / 1000));
|
||||||
|
} else {
|
||||||
|
setResendIn(0); // پیامکی نرفت و سرور cooldown را برداشته؛ همین حالا دوباره بفرستد
|
||||||
|
}
|
||||||
} finally {
|
} 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) {
|
function onOtpChange(value: string) {
|
||||||
const code = digitsOnly(value, OTP_LENGTH);
|
const code = digitsOnly(value, OTP_LENGTH);
|
||||||
setOtp(code);
|
setOtp(code);
|
||||||
@@ -112,10 +106,10 @@ export function LoginScreen({
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<p className="mt-1 text-sm text-white/75">
|
||||||
{step === 'phone'
|
{step === 'phone'
|
||||||
? upgrade ? 'امتیازها و پیشرفتت سرِ جاش میمونه' : 'شمارهی موبایلت رو وارد کن'
|
? 'شمارهی موبایلت رو وارد کن'
|
||||||
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامکشده رو وارد کن`}
|
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامکشده رو وارد کن`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,16 +160,19 @@ export function LoginScreen({
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{sending && !error && (
|
||||||
|
<p className="mt-2 text-xs text-muted">پیامک در راهه؛ همین که رسید کد رو بزن</p>
|
||||||
|
)}
|
||||||
{devOtp && (
|
{devOtp && (
|
||||||
<p className="mt-2 text-xs text-muted">
|
<p className="mt-2 text-xs text-muted">
|
||||||
کدِ آزمایشیِ حالتِ توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
|
کدِ آزمایشیِ حالتِ توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 flex items-center justify-between text-xs font-bold">
|
<div className="mt-3 flex items-center justify-between text-xs font-bold">
|
||||||
<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> · اصلاح
|
<Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح
|
||||||
</button>
|
</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)} ثانیه` : 'ارسالِ دوباره'}
|
{resendIn > 0 ? `ارسالِ دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسالِ دوباره'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,21 +184,11 @@ export function LoginScreen({
|
|||||||
<div className="mt-auto flex flex-col gap-3">
|
<div className="mt-auto flex flex-col gap-3">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={loading === 'otp' || loading === 'login'}
|
loading={loading === 'login'}
|
||||||
disabled={step === 'phone' ? !phoneValid : !otpValid}
|
disabled={step === 'phone' ? !phoneValid : !otpValid}
|
||||||
>
|
>
|
||||||
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'}
|
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'}
|
||||||
</PrimaryButton>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import { motion } from 'motion/react';
|
|
||||||
import { Check } from 'lucide-react';
|
|
||||||
import { useAppData } from '@/state/AppData';
|
|
||||||
import { unitColor, unitProgress } from '@/state/curriculum';
|
|
||||||
import { useNav } from '@/nav/Navigator';
|
|
||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
|
||||||
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
|
|
||||||
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
|
||||||
import { iconFor } from '@/components/NodeIcon';
|
|
||||||
import { toFa } from '@/lib/format';
|
|
||||||
|
|
||||||
/** نقشهی فصلهای پایه: هر خانه یک فصل است. */
|
|
||||||
export function MapScreen() {
|
|
||||||
const { home, homeError, refreshHome, gradeTitle } = useAppData();
|
|
||||||
const nav = useNav();
|
|
||||||
const units = home?.units ?? [];
|
|
||||||
const points = zigzagPositions(units.length, 150);
|
|
||||||
const height = points.length ? points[points.length - 1].y + 200 : 400;
|
|
||||||
const overall = Math.round(home?.stats.progressPercent ?? 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen className="bg-[#f7f8ff]">
|
|
||||||
<ScreenHeader
|
|
||||||
title="نقشهی فصلها"
|
|
||||||
subtitle={`${gradeTitle ? `${gradeTitle} · ` : ''}${toFa(overall)}٪ از کلِ مسیر`}
|
|
||||||
color="#91ADC6"
|
|
||||||
mascot={{ char: 'sigma', mood: overall > 0 ? 'happy' : 'idle' }}
|
|
||||||
>
|
|
||||||
<div className="mt-4">
|
|
||||||
<ProgressBar percent={overall} color="#fff" track="rgba(255,255,255,.25)" />
|
|
||||||
</div>
|
|
||||||
</ScreenHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{!home ? (
|
|
||||||
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم فصلها رو میارم…" />
|
|
||||||
) : units.length === 0 ? (
|
|
||||||
<EmptyState text="هنوز فصلی برای این پایه ثبت نشده" />
|
|
||||||
) : (
|
|
||||||
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
|
|
||||||
<ZigzagPath
|
|
||||||
points={points}
|
|
||||||
height={height}
|
|
||||||
colorOf={(i) => (units[i].nodes.some((n) => n.status !== 'locked') ? unitColor(i) : null)}
|
|
||||||
/>
|
|
||||||
{units.map((unit, i) => {
|
|
||||||
const p = unitProgress(unit);
|
|
||||||
const color = unitColor(i);
|
|
||||||
const Icon = iconFor(unit.nodes[0]?.icon, i);
|
|
||||||
const complete = p.total > 0 && p.done === p.total;
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={unit.id}
|
|
||||||
initial={{ opacity: 0, scale: 0.7 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ delay: i * 0.05, type: 'spring', stiffness: 220, damping: 16 }}
|
|
||||||
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1.5"
|
|
||||||
style={{ left: `${points[i].x}%`, top: points[i].y }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={unit.title}
|
|
||||||
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
|
||||||
className="relative flex h-[84px] w-[84px] items-center justify-center rounded-[26px] transition-transform active:translate-y-1"
|
|
||||||
style={{
|
|
||||||
backgroundColor: complete ? color : '#fff',
|
|
||||||
border: complete ? 'none' : `4px solid ${color}`,
|
|
||||||
boxShadow: complete ? `0 6px 0 ${color}99` : `0 6px 0 ${color}40, 0 0 0 5px white`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{complete ? <Check size={30} color="white" strokeWidth={3.5} /> : <Icon size={30} style={{ color }} strokeWidth={2.2} />}
|
|
||||||
{!complete && p.done > 0 && (
|
|
||||||
<span className="absolute -left-2 -top-2 rounded-full border bg-white px-1.5 py-0.5 text-[10px] font-black shadow" style={{ color, borderColor: color }}>
|
|
||||||
{toFa(p.percent)}٪
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
|
|
||||||
<div className="max-w-[130px] truncate text-xs font-black text-ink">{unit.title}</div>
|
|
||||||
<div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 150 }}>
|
|
||||||
<SeyarMascot char="pi" mood={overall >= 100 ? 'excited' : 'happy'} size={72} bob />
|
|
||||||
<div className="mt-1 whitespace-nowrap rounded-full border border-line bg-white px-3 py-1 text-[10px] font-bold text-[#7857E0] shadow-sm">
|
|
||||||
{overall >= 100 ? 'همه رو تموم کردی! 🎉' : 'همینجا منتظرتم'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Bell, Check, Crown, FileText, MessageCircle, Star, Trophy, Users, type LucideIcon } from 'lucide-react';
|
import { Bell, Check, Crown, MessageCircle, Star, Trophy, type LucideIcon } from 'lucide-react';
|
||||||
import { api, type NotificationItem } from '@/api';
|
import { api, type NotificationItem } from '@/api';
|
||||||
import { useAppData } from '@/state/AppData';
|
import { useAppData } from '@/state/AppData';
|
||||||
import { useAsync } from '@/lib/useAsync';
|
import { useAsync } from '@/lib/useAsync';
|
||||||
import { useToast } from '@/components/Toast';
|
import { useToast } from '@/components/Toast';
|
||||||
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
|
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 }> = {
|
const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = {
|
||||||
xp_earned: { icon: Star, color: '#E8A400', bg: '#FFF8E1' },
|
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' },
|
node_completed: { icon: Trophy, color: '#FF7A3D', bg: '#FFF1E6' },
|
||||||
order_paid: { icon: Crown, color: '#9B7BFF', bg: '#F5F1FF' },
|
order_paid: { icon: Crown, color: '#9B7BFF', bg: '#F5F1FF' },
|
||||||
ticket_replied: { icon: MessageCircle, color: '#4C5FD5', bg: '#EEF1FF' },
|
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' };
|
const styleOf = (type: string) => STYLE[type] ?? { icon: Bell, color: '#9e9ec0', bg: '#f0eeff' };
|
||||||
|
|
||||||
@@ -83,7 +82,7 @@ export function NotificationsScreen() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className={`block text-sm ${n.isRead ? 'text-muted' : 'font-bold text-ink'}`}>{n.title}</span>
|
<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 className="mt-1 block text-[10px] text-faint">{relativeTime(n.createdAt)}</span>
|
||||||
</span>
|
</span>
|
||||||
{!n.isRead && <span className="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-coral" />}
|
{!n.isRead && <span className="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-coral" />}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title
|
|||||||
title: 'از پی بپرس',
|
title: 'از پی بپرس',
|
||||||
desc: 'من پیام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدمبهقدم توضیح میدم.',
|
desc: 'من پیام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدمبهقدم توضیح میدم.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
char: 'tedi', mood: 'excited', bg: '#D9820B',
|
||||||
|
sub: 'یکی از ما مالِ توئه',
|
||||||
|
title: 'همراهت رو انتخاب کن',
|
||||||
|
desc: 'منم امگا! هر چی امتیازت بیشتر بشه، همراههای تازه باز میشن. از صفحهی «من» هر کدوممون رو خواستی کنارت میذاری.',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function OnboardingScreen({ onDone }: { onDone: () => void }) {
|
export function OnboardingScreen({ onDone }: { onDone: () => void }) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
|
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Crown, Heart, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
|
||||||
Pencil, Smartphone, Sparkles, Star, Trophy, Users, Volume2, VolumeX,
|
FileText, Pencil, Repeat, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { useAppData } from '@/state/AppData';
|
import { useAppData } from '@/state/AppData';
|
||||||
@@ -11,13 +11,16 @@ import type { Route } from '@/nav/routes';
|
|||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||||
import { Sheet } from '@/components/Sheet';
|
import { Sheet } from '@/components/Sheet';
|
||||||
import { useToast } from '@/components/Toast';
|
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 { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
|
||||||
import { friendlyError } from '@/lib/errors';
|
import { friendlyError } from '@/lib/errors';
|
||||||
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
|
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
|
||||||
import { toFa } from '@/lib/format';
|
import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
|
||||||
|
import { useAsync } from '@/lib/useAsync';
|
||||||
|
import { daysLeft } from '@/lib/subscription';
|
||||||
|
import { BrandFooter } from '@/components/BrandFooter';
|
||||||
|
|
||||||
type Editing = 'name' | 'grade' | 'age' | 'logout' | null;
|
type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null;
|
||||||
|
|
||||||
/** «من»: پروفایل، تنظیمات و میانبُر به بخشهای فرعی. */
|
/** «من»: پروفایل، تنظیمات و میانبُر به بخشهای فرعی. */
|
||||||
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||||
@@ -25,7 +28,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
|||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
const [editing, setEditing] = useState<Editing>(null);
|
const [editing, setEditing] = useState<Editing>(null);
|
||||||
const [sound, setSound] = useState(soundEnabled());
|
const [sound, setSound] = useState(soundEnabled());
|
||||||
const age = ageFromBirthYear(profile.birthYear);
|
const age = ageOf(profile.birthDate, profile.birthYear);
|
||||||
|
|
||||||
const go = (route: Route) => () => nav.push(route);
|
const go = (route: Route) => () => nav.push(route);
|
||||||
|
|
||||||
@@ -42,14 +45,13 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
|||||||
<button type="button" onClick={() => setEditing('name')} className="flex items-center gap-2 text-xl font-black">
|
<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" />
|
{profile.name || 'بینام'} <Pencil size={14} className="text-white/70" />
|
||||||
</button>
|
</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">
|
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80">
|
||||||
{gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
|
{gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
|
||||||
{age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>}
|
{age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>}
|
||||||
{profile.isGuest ? (
|
{profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,36 +71,29 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 px-4 py-5">
|
<div className="flex flex-col gap-4 px-4 py-5">
|
||||||
{profile.isGuest && (
|
<SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
|
||||||
<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>
|
<Menu>
|
||||||
<Row icon={GraduationCap} label="پایهی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
<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' })} />
|
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} />
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<Menu>
|
<Menu>
|
||||||
|
<Row icon={BarChart3} label="گزارشِ والدین" onClick={go({ name: 'report' })} />
|
||||||
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} />
|
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} />
|
||||||
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} />
|
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} />
|
||||||
<Row icon={Bell} label="اعلانها" onClick={go({ name: 'notifications' })} />
|
<Row icon={Bell} label="اعلانها" onClick={go({ name: 'notifications' })} />
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<Menu>
|
<Menu>
|
||||||
{!profile.isGuest && <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />}
|
<Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />
|
||||||
{!profile.isGuest && <Row icon={Users} label="بخشِ والدین" onClick={go({ name: 'family' })} />}
|
<Row icon={FileText} label="پرداختها و فاکتورها" onClick={go({ name: 'invoices' })} />
|
||||||
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
|
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
|
||||||
<Row
|
<Row
|
||||||
icon={sound ? Volume2 : VolumeX}
|
icon={sound ? Volume2 : VolumeX}
|
||||||
@@ -111,20 +106,94 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<button type="button" onClick={() => setEditing('logout')} className="flex items-center justify-center gap-2 py-3 text-sm font-bold text-red-500">
|
<Menu>
|
||||||
<LogOut size={16} /> خروج از حساب
|
<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>
|
</button>
|
||||||
|
<BrandFooter />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
|
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
|
||||||
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
|
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
|
||||||
<AgeSheet open={editing === 'age'} onClose={() => setEditing(null)} />
|
<BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
|
||||||
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
<LogoutSheet open={editing === 'logout' || editing === 'switch'} switching={editing === 'switch'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||||
|
<DeleteAccountSheet open={editing === 'delete'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اشتراک بهصورتِ نوار: چند روز از کلِ دوره مانده. مخرج طولِ پلن است (planDurationDays)؛ تمدیدِ زودتر از
|
||||||
|
* موعد روزها را روی هم میگذارد، پس نوار در ۱۰۰٪ بسته میشود. زدنِ کارت فروشگاه را باز میکند.
|
||||||
|
*/
|
||||||
|
function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||||
|
const status = useAsync(() => api.subscription.status(), []);
|
||||||
|
const s = status.data;
|
||||||
|
if (!s) return <div className="h-[132px] animate-pulse rounded-3xl bg-soft" />;
|
||||||
|
const days = s.active ? daysLeft(s.expiresAt) : 0;
|
||||||
|
const total = s.planDurationDays ?? 30;
|
||||||
|
// اشتراکِ بیتاریخ (هدیه) پر نشان داده میشود.
|
||||||
|
const percent = s.active ? (days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)))) : 0;
|
||||||
|
const low = s.active && days !== null && days <= 5;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className={`relative overflow-hidden rounded-3xl p-4 text-right active:scale-[0.99] ${s.active ? 'bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] text-white shadow-lg shadow-orange/25' : 'border-2 border-dashed border-orange/50 bg-white text-ink'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`glyph flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl ${s.active ? 'bg-white/20' : 'bg-[#FFF4D6] text-[#E0A200]'}`}><Crown size={20} /></span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراکِ تدریسینو</span>
|
||||||
|
<span className="block text-base font-black">{s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-left">
|
||||||
|
{s.active && days !== null ? (
|
||||||
|
<><b className="block text-2xl font-black leading-none">{toFa(days)}</b><span className="text-[10px] text-white/80">روز مونده</span></>
|
||||||
|
) : (
|
||||||
|
<ChevronLeft size={18} className={s.active ? 'text-white/80' : 'text-faint'} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`relative mt-3 h-3 overflow-hidden rounded-full ${s.active ? 'bg-black/15' : 'bg-soft'}`}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={total}
|
||||||
|
aria-valuenow={days ?? total}
|
||||||
|
aria-label="روزهای ماندهٔ اشتراک"
|
||||||
|
>
|
||||||
|
{percent > 0 && (
|
||||||
|
<div className="bar-fill absolute inset-y-0 right-0 overflow-hidden rounded-full" style={{ width: `${percent}%`, backgroundColor: low ? '#FFD23F' : '#fff' }}>
|
||||||
|
<span className="shine absolute inset-y-0 w-1/3 bg-gradient-to-l from-transparent via-white/70 to-transparent" style={{ mixBlendMode: 'overlay' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`mt-2 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[11px] ${s.active ? 'text-white/85' : 'text-muted'}`}>
|
||||||
|
{s.active ? (
|
||||||
|
<>
|
||||||
|
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقتِ تمدیده'}</span>}
|
||||||
|
<span className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلبِ نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همهی فصلها و قلبِ نامحدود باز میشه.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{s.questionCredits > 0 && (
|
||||||
|
<div className={`mt-1.5 flex items-center gap-1 text-[11px] font-bold ${s.active ? 'text-white/90' : 'text-violet'}`}>
|
||||||
|
<Sparkles size={11} /> {toFa(s.questionCredits)} سوالِ خریدهشده در اعتبارت
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Menu({ children }: { children: ReactNode }) {
|
function Menu({ children }: { children: ReactNode }) {
|
||||||
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
|
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
|
||||||
}
|
}
|
||||||
@@ -222,17 +291,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 { profile, setProfile } = useAppData();
|
||||||
const toast = useToast();
|
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);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!age) return;
|
if (!birthDate) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
setProfile(await api.profile.update({ birthYear: birthYearFromAge(age) }));
|
setProfile(await api.profile.update({ birthDate }));
|
||||||
toast('ذخیره شد', 'success');
|
toast('ذخیره شد', 'success');
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -243,17 +312,22 @@ function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onClose={onClose} title="چند سالته؟">
|
<Sheet open={open} onClose={onClose} title="تاریخِ تولد">
|
||||||
|
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سالِ تولدت به شمسی.</p>
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<AgePicker value={age} onChange={setAge} />
|
<BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
|
||||||
</div>
|
</div>
|
||||||
<PrimaryButton loading={saving} disabled={!age} onClick={() => void save()}>ذخیره</PrimaryButton>
|
<PrimaryButton loading={saving} disabled={!birthDate} onClick={() => void save()}>ذخیره</PrimaryButton>
|
||||||
</Sheet>
|
</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);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
@@ -268,15 +342,94 @@ function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟">
|
<Sheet open={open} onClose={onClose} title={switching ? 'ورود با حسابِ دیگر' : 'از حسابت خارج بشی؟'}>
|
||||||
<p className="mb-5 text-sm leading-7 text-muted">
|
<p className="mb-5 text-sm leading-7 text-muted">
|
||||||
{profile.isGuest
|
{switching && 'اول از این حساب خارج میشی، بعد با شمارهی دیگه وارد شو. '}
|
||||||
? 'تو مهمانی: با همین گوشی و مرورگر، «فعلاً مهمان باشم» دوباره تو رو به همین پیشرفت برمیگردونه، ولی از گوشیِ دیگه نه. برای امنتر شدن، اول شمارهت رو ثبت کن.'
|
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
|
||||||
: 'هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.'}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mb-5 -mt-3 text-xs text-muted">ممنون که با تدریسینو یاد میگیری 💛</p>
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
<SecondaryButton onClick={onClose}>نه، میمونم</SecondaryButton>
|
<SecondaryButton onClick={onClose}>نه، میمونم</SecondaryButton>
|
||||||
<PrimaryButton color="#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>
|
</div>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,38 +1,58 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import confetti from 'canvas-confetti';
|
import { Brain, Check, ChevronLeft, Coins, Compass, Flag, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react';
|
||||||
import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react';
|
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
|
||||||
import { api, ApiError, type AnswerResult, type Hearts, type LessonComplete, type Question, type ReportReason } from '@/api';
|
|
||||||
import { useAppData } from '@/state/AppData';
|
import { useAppData } from '@/state/AppData';
|
||||||
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
||||||
import { useNav } from '@/nav/Navigator';
|
import { useNav } from '@/nav/Navigator';
|
||||||
import { newIdempotencyKey } from '@/api/http';
|
import { newIdempotencyKey } from '@/api/http';
|
||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||||
import type { SeyarMood } from '@/mascots/seyar';
|
import type { SeyarMood } from '@/mascots/seyar';
|
||||||
|
import { NoHeartsScreen } from '@/components/NoHeartsScreen';
|
||||||
import { Sheet } from '@/components/Sheet';
|
import { Sheet } from '@/components/Sheet';
|
||||||
import { useToast } from '@/components/Toast';
|
import { useToast } from '@/components/Toast';
|
||||||
import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton, Screen } from '@/components/ui';
|
import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton, Screen } from '@/components/ui';
|
||||||
import { friendlyError } from '@/lib/errors';
|
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';
|
import { sounds } from '@/lib/sound';
|
||||||
|
|
||||||
type Phase =
|
type Phase =
|
||||||
| { kind: 'loading' }
|
| { kind: 'loading' }
|
||||||
| { kind: 'error'; error: unknown }
|
| { kind: 'error'; error: unknown }
|
||||||
| { kind: 'playing' }
|
| { kind: 'playing' }
|
||||||
| { kind: 'noHearts'; nextRefillAt: string | null }
|
| { kind: 'noHearts'; nextRefillAt: string | null; fullRefillAt: string | null }
|
||||||
| { kind: 'finishing' }
|
| { kind: 'finishing' }
|
||||||
| { kind: 'finishError'; error: unknown }
|
| { 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 { home, companion, refreshHome, refreshSummary } = useAppData();
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
|
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 [hearts, setHearts] = useState<Hearts | null>(null);
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
@@ -47,7 +67,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
|||||||
const answerKeys = useRef(new Map<string, string>());
|
const answerKeys = useRef(new Map<string, string>());
|
||||||
const completeKey = useRef(newIdempotencyKey());
|
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 color = ref ? unitColor(ref.unitIndex) : '#ED6956';
|
||||||
const question = questions[index];
|
const question = questions[index];
|
||||||
const isLast = index === questions.length - 1;
|
const isLast = index === questions.length - 1;
|
||||||
@@ -58,7 +78,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setPhase({ kind: 'loading' });
|
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]) => {
|
.then(([qs, h]) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setQuestions(qs);
|
setQuestions(qs);
|
||||||
@@ -69,18 +92,29 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
|||||||
setCorrectCount(0);
|
setCorrectCount(0);
|
||||||
answerKeys.current.clear();
|
answerKeys.current.clear();
|
||||||
completeKey.current = newIdempotencyKey();
|
completeKey.current = newIdempotencyKey();
|
||||||
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt });
|
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
|
||||||
else setPhase({ kind: 'playing' });
|
else setPhase({ kind: 'playing' });
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => alive && setPhase({ kind: 'error', error }));
|
.catch((error: unknown) => {
|
||||||
|
if (!alive) return;
|
||||||
|
// فصلِ غیرِ اول بیاشتراک (۴۰۲)؛ صفحهی خطا معنا ندارد، کاربر یکراست به اشتراکها میرود.
|
||||||
|
if (error instanceof ApiError && error.code === 'subscription_required') nav.replace({ name: 'subscription', view: 'subscription' });
|
||||||
|
else setPhase({ kind: 'error', error });
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [lessonId, reload]);
|
}, [lessonId, nodeId, nodeMode, reload]);
|
||||||
|
|
||||||
async function choose(optionId: string) {
|
/** انتخاب فقط گزینه را آبی میکند؛ تا «بررسی» نزده، چیزی ثبت یا رو نمیشود و میشود عوضش کرد. */
|
||||||
if (!question || selected !== null || submitting) return;
|
function choose(optionId: string) {
|
||||||
|
if (!question || answer || submitting) return;
|
||||||
setSelected(optionId);
|
setSelected(optionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
if (!question || selected === null || answer || submitting) return;
|
||||||
|
const optionId = selected;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
let key = answerKeys.current.get(question.id);
|
let key = answerKeys.current.get(question.id);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -98,9 +132,13 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
|||||||
sounds.wrong();
|
sounds.wrong();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSelected(null);
|
// گزینه انتخابشده میماند تا کاربر دوباره «بررسی» بزند.
|
||||||
if (err instanceof ApiError && err.code === 'no_hearts') {
|
if (err instanceof ApiError && err.code === 'no_hearts') {
|
||||||
setPhase({ 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 {
|
} else {
|
||||||
toast(friendlyError(err, 'جوابت ثبت نشد، دوباره بزن'), 'error');
|
toast(friendlyError(err, 'جوابت ثبت نشد، دوباره بزن'), 'error');
|
||||||
}
|
}
|
||||||
@@ -112,7 +150,11 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
|||||||
async function next() {
|
async function next() {
|
||||||
if (outOfHearts && !isLast) {
|
if (outOfHearts && !isLast) {
|
||||||
const h = await api.hearts().catch(() => null);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!isLast) {
|
if (!isLast) {
|
||||||
@@ -127,23 +169,36 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
|||||||
async function finish() {
|
async function finish() {
|
||||||
setPhase({ kind: 'finishing' });
|
setPhase({ kind: 'finishing' });
|
||||||
try {
|
try {
|
||||||
const result = await api.lesson.complete(
|
const payload = { correctCount, totalQuestions: questions.length };
|
||||||
lessonId,
|
const result: QuizResult = nodeMode
|
||||||
{ correctCount, totalQuestions: questions.length },
|
? await api.nodeQuizComplete(nodeId ?? '', payload, completeKey.current)
|
||||||
completeKey.current,
|
: await api.lesson.complete(lessonId, payload, completeKey.current);
|
||||||
);
|
|
||||||
setPhase({ kind: 'result', result });
|
setPhase({ kind: 'result', result });
|
||||||
sounds.finish();
|
sounds.finish();
|
||||||
if (result.stars >= 2) void confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } });
|
if (result.stars >= 2) void import('canvas-confetti').then(({ default: confetti }) => confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } }));
|
||||||
void refreshHome();
|
void refreshHome();
|
||||||
void refreshSummary();
|
void refreshSummary();
|
||||||
} catch (error) {
|
} 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 });
|
setPhase({ kind: 'finishError', error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const askWhy = () =>
|
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 +213,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
|||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (phase.kind === 'noHearts') return <NoHearts nextRefillAt={phase.nextRefillAt} />;
|
if (phase.kind === 'noHearts') {
|
||||||
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id} color={color} />;
|
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') {
|
if (phase.kind === 'finishing' || phase.kind === 'finishError') {
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
@@ -182,7 +239,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
|||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<div className="flex flex-1 flex-col justify-center gap-4 px-8">
|
<div className="flex flex-1 flex-col justify-center gap-4 px-8">
|
||||||
<ErrorState text="این درس هنوز سوالی نداره" />
|
<ErrorState text={nodeMode ? 'این جزیره هنوز سوالی نداره' : 'این درس هنوز سوالی نداره'} />
|
||||||
<SecondaryButton onClick={nav.back}>برگشت</SecondaryButton>
|
<SecondaryButton onClick={nav.back}>برگشت</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
</Screen>
|
</Screen>
|
||||||
@@ -193,16 +250,16 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
|||||||
|
|
||||||
const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad';
|
const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad';
|
||||||
const bubble = !answer
|
const bubble = !answer
|
||||||
? 'جوابِ درست رو انتخاب کن!'
|
? selected === null ? 'جوابِ درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
|
||||||
: answer.correct
|
: answer.correct
|
||||||
? 'آفرین! درست بود ⚡'
|
? 'آفرین! درست بود'
|
||||||
: outOfHearts
|
: outOfHearts
|
||||||
? 'قلبهات تموم شد؛ یه کم استراحت کنیم.'
|
? 'قلبهات تموم شد؛ یه کم استراحت کنیم.'
|
||||||
: 'اشکالی نداره، جوابِ درست رو ببین.';
|
: 'اشکالی نداره، جوابِ درست رو ببین.';
|
||||||
const shortOptions = question.options.every((o) => o.value.length <= 14);
|
const shortOptions = question.options.every((o) => o.value.length <= 14);
|
||||||
|
|
||||||
const optionClass = (id: string) => {
|
const optionClass = (id: string) => {
|
||||||
if (!answer) return selected === id ? 'border-coral bg-[#fff4f2] text-ink' : 'border-line bg-white text-ink';
|
if (!answer) return selected === id ? 'border-[#84D8FF] bg-[#DDF4FF] text-[#1581BA]' : 'border-line bg-white text-ink';
|
||||||
if (id === answer.correctOptionId) return 'border-green-400 bg-[#e5fdf0] text-green-700';
|
if (id === answer.correctOptionId) return 'border-green-400 bg-[#e5fdf0] text-green-700';
|
||||||
if (id === selected) return 'border-red-300 bg-[#fff0f0] text-red-500';
|
if (id === selected) return 'border-red-300 bg-[#fff0f0] text-red-500';
|
||||||
return 'border-line bg-white text-faint';
|
return 'border-line bg-white text-faint';
|
||||||
@@ -236,6 +293,18 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
|||||||
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
|
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
|
||||||
}`}>
|
}`}>
|
||||||
{bubble}
|
{bubble}
|
||||||
|
{answer && <Reaction key={question.id} correct={answer.correct} seed={index} />}
|
||||||
|
{answer?.correct && answer.xpEarned > 0 && (
|
||||||
|
<motion.span
|
||||||
|
key={question.id}
|
||||||
|
initial={{ opacity: 0, y: 8, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 400, damping: 14 }}
|
||||||
|
className="mr-2 inline-block rounded-full bg-[#FFF3C4] px-2 py-0.5 text-[11px] font-black text-[#8A5A00]"
|
||||||
|
>
|
||||||
|
+{toFa(answer.xpEarned)} <Star size={11} className="mb-0.5 inline" fill="#E8A400" color="#E8A400" />
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -246,7 +315,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
|||||||
<Flag size={12} /> گزارشِ ایراد
|
<Flag size={12} /> گزارشِ ایراد
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
|
<div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
|
||||||
@@ -254,34 +323,44 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
|||||||
<motion.button
|
<motion.button
|
||||||
key={option.id}
|
key={option.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void choose(option.id)}
|
onClick={() => choose(option.id)}
|
||||||
disabled={selected !== null && !answer}
|
disabled={submitting || answer !== null}
|
||||||
whileTap={{ scale: selected === null ? 0.95 : 1 }}
|
aria-pressed={selected === option.id}
|
||||||
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)}`}
|
whileTap={{ scale: answer ? 1 : 0.95 }}
|
||||||
|
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 border-b-4 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`}
|
||||||
>
|
>
|
||||||
<span dir="auto">{option.value}</span>
|
<span><MathText text={option.value} /></span>
|
||||||
{answer && option.id === answer.correctOptionId && <Check size={16} className="text-green-500" strokeWidth={3} />}
|
{answer && option.id === 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} />}
|
{answer && option.id === selected && !answer.correct && <X size={16} className="text-red-400" strokeWidth={3} />}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{answer && (
|
</div>
|
||||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="mt-1 flex flex-col gap-2.5">
|
|
||||||
|
<div className="flex flex-shrink-0 flex-col gap-2.5 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
|
||||||
|
{answer ? (
|
||||||
|
<>
|
||||||
{!answer.correct && (
|
{!answer.correct && (
|
||||||
<button type="button" onClick={askWhy} className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]">
|
<button type="button" onClick={askWhy} className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]">
|
||||||
<Brain size={16} /> از پی بپرس چرا غلط شد
|
<Brain size={16} /> از پی بپرس چرا غلط شد
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
|
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
|
||||||
{isLast ? 'پایانِ تمرین' : 'سوالِ بعدی'} <ChevronLeft size={18} />
|
{isLast ? 'پایانِ تمرین' : 'ادامه'} <ChevronLeft size={18} />
|
||||||
|
</PrimaryButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<PrimaryButton color={color} disabled={selected === null} loading={submitting} onClick={() => void check()}>
|
||||||
|
بررسی
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Sheet open={confirmExit} onClose={() => setConfirmExit(false)} title="از تمرین بیرون بری؟">
|
<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">
|
<div className="flex flex-col gap-2.5">
|
||||||
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه میدم</PrimaryButton>
|
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه میدم</PrimaryButton>
|
||||||
<SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton>
|
<SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton>
|
||||||
@@ -325,38 +404,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({
|
function Result({
|
||||||
result, total, correct, lessonId, nodeId, color,
|
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 nav = useNav();
|
||||||
const { companion } = useAppData();
|
const { companion } = useAppData();
|
||||||
const [loadingNext, setLoadingNext] = useState(false);
|
const [loadingNext, setLoadingNext] = useState(false);
|
||||||
|
|
||||||
|
const nodeMode = lessonId === undefined;
|
||||||
|
const nextNode = result.nextNodeId ?? null;
|
||||||
|
|
||||||
// بعد از ثبت، نقشه عوض شده؛ درسِ بعدی را از نسخهی تازهی نقشه میخوانیم.
|
// بعد از ثبت، نقشه عوض شده؛ درسِ بعدی را از نسخهی تازهی نقشه میخوانیم.
|
||||||
async function goNext() {
|
async function goNext() {
|
||||||
|
if (nodeMode) {
|
||||||
|
if (nextNode) nav.replace({ name: 'quiz', nodeId: nextNode });
|
||||||
|
else nav.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoadingNext(true);
|
setLoadingNext(true);
|
||||||
try {
|
try {
|
||||||
const fresh = await api.home();
|
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;
|
const next = ref ? nextLessonOf(ref.node) : null;
|
||||||
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
|
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
|
||||||
nav.replace({ name: 'lesson', lessonId: next, nodeId: ref.node.id });
|
nav.replace({ name: 'lesson', lessonId: next, nodeId: ref.node.id });
|
||||||
@@ -381,6 +449,7 @@ function Result({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
|
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
|
||||||
|
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی</p>}
|
||||||
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
|
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full gap-3">
|
<div className="flex w-full gap-3">
|
||||||
@@ -396,8 +465,13 @@ function Result({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
|
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
|
||||||
ادامه <ChevronLeft size={18} />
|
{nodeMode && nextNode ? 'بریم جزیرهی بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
|
{nodeMode && nextNode && (
|
||||||
|
<button type="button" onClick={nav.back} className="-mt-2 py-2 text-sm font-bold text-muted">
|
||||||
|
فعلاً بسه، برگردیم
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
@@ -464,3 +538,22 @@ function ReportSheet({ open, questionId, onClose }: { open: boolean; questionId:
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** آیکنِ کوچکِ بعد از جواب (همان خانوادهی آیکنِ اپ، نه ایموجیِ کیبورد)؛ هر سوال یکی از مجموعه، با یک تکانِ کوتاه. */
|
||||||
|
const WIN: [LucideIcon, string][] = [[Brain, '#9B7BFF'], [Zap, '#E8A400'], [Target, '#ED6956'], [ThumbsUp, '#3E9E73'], [Sparkles, '#4C5FD5']];
|
||||||
|
const MISS: [LucideIcon, string][] = [[Lightbulb, '#E8A400'], [Ruler, '#4C5FD5'], [Compass, '#9B7BFF']];
|
||||||
|
function Reaction({ correct, seed }: { correct: boolean; seed: number }) {
|
||||||
|
const set = correct ? WIN : MISS;
|
||||||
|
const [Icon, color] = set[seed % set.length];
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
aria-hidden
|
||||||
|
className="mr-1.5 inline-block align-middle"
|
||||||
|
initial={{ scale: 0.4, rotate: 0 }}
|
||||||
|
animate={{ scale: 1, rotate: [0, -14, 14, -8, 8, 0] }}
|
||||||
|
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<Icon size={18} color={color} strokeWidth={2.4} />
|
||||||
|
</motion.span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
346
src/screens/ReportScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,16 @@ import { useState } from 'react';
|
|||||||
import { Check, ChevronLeft } from 'lucide-react';
|
import { Check, ChevronLeft } from 'lucide-react';
|
||||||
import { api, type Grade, type UserProfile } from '@/api';
|
import { api, type Grade, type UserProfile } from '@/api';
|
||||||
import { InlineError, PrimaryButton } from '@/components/ui';
|
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 { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||||
import { friendlyError } from '@/lib/errors';
|
import { friendlyError } from '@/lib/errors';
|
||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
|
|
||||||
/** «بیا با هم آشنا بشیم»: اسم، سن و پایه، بارِ اولِ ورود. */
|
/**
|
||||||
|
* «بیا با هم آشنا بشیم»: اسم، تاریخِ تولد و پایه، بارِ اولِ ورود.
|
||||||
|
* بزرگسال هم میآید (کسی که فقط یک مبحث را میخواهد یاد بگیرد)؛ برای او «پایه»
|
||||||
|
* یعنی ریاضیِ کدام پایه، نه کلاسی که در آن است.
|
||||||
|
*/
|
||||||
export function SetupScreen({
|
export function SetupScreen({
|
||||||
profile,
|
profile,
|
||||||
grades,
|
grades,
|
||||||
@@ -18,7 +22,7 @@ export function SetupScreen({
|
|||||||
onDone: (profile: UserProfile) => void;
|
onDone: (profile: UserProfile) => void;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(profile.name);
|
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 [gradeId, setGradeId] = useState<number | null>(profile.gradeId);
|
||||||
const [touched, setTouched] = useState(false);
|
const [touched, setTouched] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -26,6 +30,8 @@ export function SetupScreen({
|
|||||||
|
|
||||||
const nameMissing = touched && !name.trim();
|
const nameMissing = touched && !name.trim();
|
||||||
const gradeMissing = touched && !gradeId;
|
const gradeMissing = touched && !gradeId;
|
||||||
|
const age = ageOf(birthDate, null);
|
||||||
|
const adult = age !== null && age >= ADULT_AGE;
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
setTouched(true);
|
setTouched(true);
|
||||||
@@ -36,7 +42,7 @@ export function SetupScreen({
|
|||||||
await api.profile.setGrade(gradeId);
|
await api.profile.setGrade(gradeId);
|
||||||
const updated = await api.profile.update({
|
const updated = await api.profile.update({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
...(age ? { birthYear: birthYearFromAge(age) } : {}),
|
...(birthDate ? { birthDate } : {}),
|
||||||
});
|
});
|
||||||
onDone({ ...updated, gradeId });
|
onDone({ ...updated, gradeId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -78,13 +84,16 @@ export function SetupScreen({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-2 block text-sm font-black text-ink">
|
<span className="mb-2 block text-sm font-black text-ink">
|
||||||
چند سالته؟ <span className="text-xs font-medium text-muted">(اختیاری)</span>
|
تاریخِ تولدت <span className="text-xs font-medium text-muted">(به شمسی، اختیاری)</span>
|
||||||
</span>
|
</span>
|
||||||
<AgePicker value={age} onChange={setAge} />
|
<BirthDatePicker value={birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{grades.map((g) => {
|
{grades.map((g) => {
|
||||||
const selected = gradeId === g.id;
|
const selected = gradeId === g.id;
|
||||||
@@ -114,7 +123,7 @@ export function SetupScreen({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{error && <InlineError>{error}</InlineError>}
|
{error && <InlineError>{error}</InlineError>}
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||||
|
import { BrandMark } from '@/components/BrandMark';
|
||||||
|
|
||||||
/** تا وقتی نشستِ قبلی بررسی میشود نمایش داده میشود. */
|
/** نمادهای ریاضی که آرام از پایینِ صفحه بالا میروند؛ فقط CSS (transform/opacity)، بیبارِ اضافه. */
|
||||||
|
const GLYPHS = [
|
||||||
|
{ c: 'π', x: 12, d: 0 }, { c: '+', x: 28, d: 2.6 }, { c: '√', x: 46, d: 1.2 },
|
||||||
|
{ c: '÷', x: 64, d: 3.8 }, { c: '∑', x: 80, d: 0.6 }, { c: '×', x: 90, d: 4.6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** تا وقتی نشستِ قبلی بررسی میشود نمایش داده میشود؛ عمداً تأخیرِ ساختگی ندارد. */
|
||||||
export function SplashScreen() {
|
export function SplashScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center bg-rose">
|
<div className="relative flex h-full flex-col items-center justify-center overflow-hidden bg-rose">
|
||||||
|
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2">
|
||||||
|
{GLYPHS.map((g) => (
|
||||||
|
<span key={g.c} className="rise absolute bottom-0 text-3xl font-black text-white/60" style={{ left: `${g.x}%`, animationDelay: `${g.d}s` }}>
|
||||||
|
{g.c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.5, opacity: 0 }}
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
|
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
|
||||||
className="flex flex-col items-center gap-4"
|
className="relative flex flex-col items-center gap-4"
|
||||||
>
|
>
|
||||||
<div className="rounded-[32px] bg-white/15 p-6 backdrop-blur-sm">
|
<div className="rounded-[32px] bg-white/15 p-6 backdrop-blur-sm">
|
||||||
<SeyarMascot char="sigma" mood="happy" size={130} />
|
<SeyarMascot char="sigma" mood="happy" size={130} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-black text-white">تدریسینو</h1>
|
<BrandMark size={44} />
|
||||||
<p className="text-sm text-white/75">ریاضی، قدمبهقدم، با سهیار</p>
|
<p className="text-sm text-white/80">ریاضی، قدمبهقدم، از روی کتابِ درسی</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export function StreakScreen() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StreakGoal current={calendar.data.currentStreak} />
|
||||||
|
|
||||||
<div className="rounded-3xl border border-line bg-white p-4">
|
<div className="rounded-3xl border border-line bg-white p-4">
|
||||||
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
|
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
|
||||||
{WEEKDAYS.map((d) => <span key={d}>{d}</span>)}
|
{WEEKDAYS.map((d) => <span key={d}>{d}</span>)}
|
||||||
@@ -90,3 +92,24 @@ export function StreakScreen() {
|
|||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** نقطهعطفهای زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعیای پشتش نیست (پاداش تصمیمِ محصولی است). */
|
||||||
|
const MILESTONES = [3, 7, 14, 30, 60, 100];
|
||||||
|
|
||||||
|
function StreakGoal({ current }: { current: number }) {
|
||||||
|
const next = MILESTONES.find((m) => m > current);
|
||||||
|
const reached = MILESTONES.filter((m) => m <= current).pop();
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-line bg-[#FFF3E8] px-4 py-3">
|
||||||
|
<p className="text-sm font-black leading-7 text-ink">
|
||||||
|
{reached ? <><Flame size={16} fill="#FF7A3D" className="mb-0.5 ml-1 inline text-orange" />{toFa(current)} روزهای! </> : null}
|
||||||
|
{next ? `${toFa(next - current)} روز دیگه میشه زنجیرهی ${toFa(next)} روزه.` : 'از همهی نقطهعطفها رد شدی، افسانهای!'}
|
||||||
|
</p>
|
||||||
|
{next && (
|
||||||
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white">
|
||||||
|
<div className="h-full rounded-full bg-orange transition-[width]" style={{ width: `${Math.round((current / next) * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { Check, Crown, Heart, Ticket } from 'lucide-react';
|
import { Check, ChevronLeft, ChevronRight, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
|
||||||
import { api, type Plan } from '@/api';
|
import { api, type Plan, type SubscriptionStatus, type TutorQuota } from '@/api';
|
||||||
|
import { daysLeft } from '@/lib/subscription';
|
||||||
|
import { newIdempotencyKey } from '@/api/http';
|
||||||
|
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
|
||||||
import { useAsync } from '@/lib/useAsync';
|
import { useAsync } from '@/lib/useAsync';
|
||||||
import { Sheet } from '@/components/Sheet';
|
import { Sheet } from '@/components/Sheet';
|
||||||
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
|
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
|
||||||
@@ -11,103 +14,209 @@ function durationLabel(days: number): string {
|
|||||||
if (days === 30 || days === 31) return 'یکماهه';
|
if (days === 30 || days === 31) return 'یکماهه';
|
||||||
if (days >= 360 && days <= 370) return 'یکساله';
|
if (days >= 360 && days <= 370) return 'یکساله';
|
||||||
if (days === 90) return 'سهماهه';
|
if (days === 90) return 'سهماهه';
|
||||||
|
if (days === 180) return 'ششماهه';
|
||||||
return days ? `${toFa(days)} روزه` : '';
|
return days ? `${toFa(days)} روزه` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubscriptionScreen() {
|
type View = 'subscription' | 'pack';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/ششماهه؛ همهی فصلها، قلبِ نامحدود، سقفِ روزانهی دستیار)
|
||||||
|
* و «بستهی سوال» (سوالِ بیانقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
|
||||||
|
* یکجا کاربر را گیج نکند. از فصلِ قفل یکراست به فهرستِ اشتراک میآید (`initialView`).
|
||||||
|
*/
|
||||||
|
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
||||||
const plans = useAsync(() => api.subscription.plans(), []);
|
const plans = useAsync(() => api.subscription.plans(), []);
|
||||||
const status = useAsync(() => api.subscription.status(), []);
|
const status = useAsync(() => api.subscription.status(), []);
|
||||||
|
const quota = useAsync(() => api.ai.quota(), []);
|
||||||
|
const [view, setView] = useState<View | null>(initialView ?? null);
|
||||||
const [buying, setBuying] = useState<Plan | null>(null);
|
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(); };
|
||||||
|
const list = plans.data?.plans.filter((p) => p.kind === view) ?? [];
|
||||||
|
const monthly = plans.data?.plans.find((p) => p.kind === 'subscription' && p.durationDays <= 31);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ScreenHeader title="اشتراکِ ویژه" subtitle="یادگیری بدونِ وقفه" color="#ED6956" mascot={{ char: 'sigma', mood: 'excited', size: 60 }} />
|
<ScreenHeader
|
||||||
|
title={view === 'pack' ? 'بستهی سوال' : view === 'subscription' ? 'اشتراکِ تدریسینو' : 'فروشگاه'}
|
||||||
|
subtitle={view === 'pack' ? 'سوالِ بیشتر از دستیار، بدونِ انقضا' : 'همهی فصلها، قلبِ نامحدود'}
|
||||||
|
color="#ED6956"
|
||||||
|
exact
|
||||||
|
mascot={{ char: 'pi', mood: 'excited', size: 60 }}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 px-4 py-5">
|
<div className="flex flex-col gap-4 px-4 py-5">
|
||||||
{status.data?.active && (
|
<StatusCard status={status.data ?? null} credits={credits} heartsUntil={heartsUntil} quota={quota.data ?? null} />
|
||||||
<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" />
|
{view === null ? (
|
||||||
<div className="text-sm leading-7 text-ink">
|
<div className="flex flex-col gap-3">
|
||||||
<b>اشتراکت فعاله.</b>
|
<ChoiceCard
|
||||||
{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}.</>}
|
icon={<Crown size={24} />}
|
||||||
<div className="text-xs text-muted">اگه دوباره بخری، از روزِ پایانِ همین اشتراک تمدید میشه.</div>
|
tone="#FF7A3D"
|
||||||
</div>
|
title="اشتراکِ ماهانه"
|
||||||
|
text="همهی فصلها و جزیرهها باز، با قلبِ نامحدود"
|
||||||
|
hint={monthly ? `از ${formatToman(monthly.price)}` : undefined}
|
||||||
|
onClick={() => setView('subscription')}
|
||||||
|
/>
|
||||||
|
<ChoiceCard
|
||||||
|
icon={<Sparkles size={24} />}
|
||||||
|
tone="#9B7BFF"
|
||||||
|
title="بستهی سوال"
|
||||||
|
text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچوقت منقضی نمیشه"
|
||||||
|
onClick={() => setView('pack')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!initialView && (
|
||||||
|
<button type="button" onClick={() => setView(null)} className="flex items-center gap-1 self-start text-xs font-bold text-coral">
|
||||||
|
<ChevronRight size={14} /> برگشت به انتخاب
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{plans.loading ? (
|
{plans.loading ? (
|
||||||
<LoadingState text="دارم پلنها رو میارم…" />
|
<LoadingState text="دارم فهرست رو میارم…" />
|
||||||
) : plans.error ? (
|
) : plans.error ? (
|
||||||
<ErrorState error={plans.error} onRetry={plans.reload} />
|
<ErrorState error={plans.error} onRetry={plans.reload} />
|
||||||
) : !plans.data?.length ? (
|
) : !list.length ? (
|
||||||
<EmptyState text="فعلاً پلنی برای خرید تعریف نشده" />
|
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
|
||||||
) : (
|
) : (
|
||||||
plans.data.map((plan) => {
|
list.map((plan) => (
|
||||||
const featured = plan.popular;
|
<PackCard
|
||||||
const isCurrent = plan.id === activePlanId;
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
monthlyPrice={monthly?.price}
|
||||||
|
// اشتراک در بازار هنوز SKU و تمدیدِ آزمودهشده ندارد؛ آنجا فقط بسته خریدنی است.
|
||||||
|
available={!bazaar || (plan.kind === 'pack' && !!plan.bazaarSku)}
|
||||||
|
onBuy={() => setBuying(plan)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{view === 'pack' && (
|
||||||
|
<p className="text-center text-xs leading-6 text-muted">
|
||||||
|
هر روز {toFa(status.data?.aiDailyQuota ?? plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول از سقفِ روزانهته؛ بعدش از اعتبارِ بسته کم میشه.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
|
||||||
|
{bazaar ? 'پرداخت از طریقِ کافهبازار انجام میشه.' : 'پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); reload(); }} />
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div key={plan.id} className="relative pt-3">
|
<button type="button" onClick={onClick} className="flex items-center gap-3 rounded-3xl border-2 border-line bg-white p-4 text-right active:scale-[0.99]">
|
||||||
{plan.badge && (
|
<span className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl text-white" style={{ backgroundColor: tone }}>{icon}</span>
|
||||||
<span className="absolute right-5 top-0 z-10 rounded-full bg-[#FFD23F] px-3 py-1 text-[11px] font-black text-ink shadow">
|
<span className="min-w-0 flex-1">
|
||||||
{plan.badge}
|
<span className="block text-base font-black text-ink">{title}</span>
|
||||||
|
<span className="mt-0.5 block text-xs leading-6 text-muted">{text}</span>
|
||||||
|
{hint && <span className="mt-1 block text-xs font-black" style={{ color: tone }}>{hint}</span>}
|
||||||
</span>
|
</span>
|
||||||
|
<ChevronLeft size={18} className="flex-shrink-0 text-faint" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */
|
||||||
|
function StatusCard({ status, credits, heartsUntil, quota }: {
|
||||||
|
status: SubscriptionStatus | null; credits: number; heartsUntil: string | null; quota: TutorQuota | null;
|
||||||
|
}) {
|
||||||
|
const days = status?.active ? daysLeft(status.expiresAt) : null;
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-line bg-white p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl ${status?.active ? 'bg-[#FFF4D6] text-[#E0A200]' : 'bg-soft text-muted'}`}><Crown size={22} /></span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xs font-bold text-muted">اشتراکِ تو</div>
|
||||||
|
<div className="text-base font-black text-ink">
|
||||||
|
{status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</span>}</> : 'نداری'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{quota && (
|
||||||
|
<div className="text-left text-xs leading-6 text-muted">
|
||||||
|
سوالِ امروز<br />
|
||||||
|
<b className="text-base text-ink">{toFa(quota.remainingQuota)}</b> از {toFa(status?.aiDailyQuota ?? quota.freeDaily)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-xs font-bold">
|
||||||
|
<span className="flex items-center gap-1.5 text-violet"><Sparkles size={13} /> اعتبار: {toFa(credits)} سوال</span>
|
||||||
|
{(status?.active || heartsUntil) && (
|
||||||
|
<span className="flex items-center gap-1.5 text-coral">
|
||||||
|
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}</>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status?.active && status.expiresAt && <span className="text-muted">تا {formatJalaliDate(status.expiresAt)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthlyPrice?: number; available: boolean; onBuy: () => void }) {
|
||||||
|
const months = Math.round(plan.durationDays / 30);
|
||||||
|
const saving = plan.kind === 'subscription' && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
|
||||||
|
const featured = plan.popular;
|
||||||
|
const isPack = plan.kind === 'pack';
|
||||||
|
const tone = featured ? 'text-white/80' : 'text-muted';
|
||||||
|
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={`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 className="mb-3 flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-black">{plan.title}</h3>
|
<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 ${featured ? 'text-white/75' : 'text-muted'}`}>{durationLabel(plan.durationDays)}</p>
|
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ هوشمند، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="text-base font-black">{plan.price === 0 ? 'رایگان' : formatToman(plan.price)}</p>
|
<p className="text-base font-black">{formatToman(plan.price)}</p>
|
||||||
{plan.discountPercent > 0 && (
|
{months > 1 && !isPack && <p className={`text-[11px] ${tone}`}>ماهی {formatToman(Math.round(plan.price / months / 1000) * 1000)}</p>}
|
||||||
<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'}`}>
|
{saving > 0 && <p className={`mt-0.5 text-[11px] font-black ${featured ? 'text-white' : 'text-mint'}`}>{toFa(saving)}٪ ارزانتر</p>}
|
||||||
{toFa(plan.discountPercent)}٪ تخفیف
|
</div>
|
||||||
</span>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{plan.features.length > 0 && (
|
|
||||||
<ul className="mb-4 flex flex-col gap-2">
|
|
||||||
{plan.features.map((f) => (
|
{plan.features.map((f) => (
|
||||||
<li key={f} className="flex items-center gap-2 text-sm">
|
<li key={f} className="flex items-center gap-2">
|
||||||
<Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
|
<Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
|
||||||
<span className={featured ? 'text-white/90' : 'text-body'}>{f}</span>
|
<span className={featured ? 'text-white/90' : 'text-body'}>{f}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={plan.price === 0 || isCurrent}
|
onClick={onBuy}
|
||||||
onClick={() => setBuying(plan)}
|
disabled={!available}
|
||||||
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-60 ${
|
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'}`}
|
||||||
featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isCurrent ? 'اشتراکِ فعلیِ تو' : plan.price === 0 ? 'رایگان' : status.data?.active ? 'تمدید با این پلن' : 'انتخابِ این پلن'}
|
{available ? 'خرید' : 'بهزودی در بازار'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="pb-2 text-center text-[11px] leading-6 text-faint">پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaidWithCode={() => { setBuying(null); status.reload(); }} />
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,27 +224,72 @@ export function SubscriptionScreen() {
|
|||||||
* و بعد کاربر در همین برگه به درگاه میرود. window.open بعد از await را
|
* و بعد کاربر در همین برگه به درگاه میرود. window.open بعد از await را
|
||||||
* Safariِ آیفون مسدود میکند؛ برای همین جابهجاییِ همین صفحه است.
|
* 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 [code, setCode] = useState('');
|
||||||
const [showCode, setShowCode] = useState(false);
|
const [showCode, setShowCode] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [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() {
|
async function pay() {
|
||||||
if (!plan || busy) return;
|
if (!plan || busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
if (bazaar) {
|
||||||
|
try {
|
||||||
|
if (await payWithBazaar(plan)) setDone('bazaar');
|
||||||
|
} catch (err) {
|
||||||
|
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const order = await api.subscription.purchase(plan.id);
|
const order = await api.subscription.purchase(plan.id);
|
||||||
if (code.trim()) {
|
if (code.trim()) {
|
||||||
const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
|
const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
|
||||||
if (redeemed.orderStatus === 'paid') {
|
if (redeemed.orderStatus === 'paid') {
|
||||||
setFreeDone(true);
|
setDone('code');
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
return;
|
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);
|
window.location.assign(order.paymentUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
|
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
|
||||||
@@ -144,19 +298,21 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
|
|||||||
}
|
}
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
if (freeDone) onPaidWithCode();
|
if (done) onPaid();
|
||||||
else onClose();
|
else onClose();
|
||||||
setCode('');
|
setCode('');
|
||||||
setShowCode(false);
|
setShowCode(false);
|
||||||
setError('');
|
setError('');
|
||||||
setFreeDone(false);
|
setDone(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={plan !== null} onClose={close} title={freeDone ? 'اشتراکت فعال شد! 🎉' : plan?.title}>
|
<Sheet open={plan !== null} onClose={close} title={done ? 'خریدت انجام شد! 🎉' : plan?.title}>
|
||||||
{freeDone ? (
|
{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>
|
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
||||||
</>
|
</>
|
||||||
) : plan && (
|
) : plan && (
|
||||||
@@ -165,7 +321,7 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
|
|||||||
<span className="text-sm text-body">مبلغ</span>
|
<span className="text-sm text-body">مبلغ</span>
|
||||||
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
|
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
|
||||||
</div>
|
</div>
|
||||||
{showCode ? (
|
{bazaar ? null : showCode ? (
|
||||||
<label className="mb-4 block">
|
<label className="mb-4 block">
|
||||||
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
|
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Bug, MessageCircle, Send } from 'lucide-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 { useAsync } from '@/lib/useAsync';
|
||||||
import { KEYS, store } from '@/lib/storage';
|
|
||||||
import { useToast } from '@/components/Toast';
|
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 { friendlyError } from '@/lib/errors';
|
||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
|
import { MathText } from '@/lib/MathText';
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ id: 'learning', label: 'درس و سوالها' },
|
{ id: 'learning', label: 'درس و سوالها' },
|
||||||
@@ -22,15 +22,6 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
closed: 'بسته',
|
closed: 'بسته',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** سرور فهرستِ تیکتها را نمیدهد؛ شناسهها را همینجا نگه میداریم. */
|
|
||||||
function savedTicketIds(): string[] {
|
|
||||||
try {
|
|
||||||
return JSON.parse(store.get(KEYS.ticketIds) ?? '[]') as string[];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SupportScreen() {
|
export function SupportScreen() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [mode, setMode] = useState<'ticket' | 'bug'>('ticket');
|
const [mode, setMode] = useState<'ticket' | 'bug'>('ticket');
|
||||||
@@ -39,7 +30,8 @@ export function SupportScreen() {
|
|||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [ticketIds, setTicketIds] = useState(savedTicketIds);
|
// فهرست از سرور میآید، پس تیکت و پاسخش با عوض شدنِ گوشی هم دیده میشود.
|
||||||
|
const tickets = useAsync(() => api.support.tickets(), []);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!subject.trim() || !text.trim()) return;
|
if (!subject.trim() || !text.trim()) return;
|
||||||
@@ -52,9 +44,7 @@ export function SupportScreen() {
|
|||||||
} else {
|
} else {
|
||||||
const t = await api.support.createTicket(subject.trim(), category);
|
const t = await api.support.createTicket(subject.trim(), category);
|
||||||
await api.support.reply(t.ticketId, text.trim());
|
await api.support.reply(t.ticketId, text.trim());
|
||||||
const ids = [t.ticketId, ...ticketIds].slice(0, 20);
|
tickets.reload();
|
||||||
store.set(KEYS.ticketIds, JSON.stringify(ids));
|
|
||||||
setTicketIds(ids);
|
|
||||||
toast('پیامت رسید؛ جواب همینجا و در اعلانها میاد.', 'success');
|
toast('پیامت رسید؛ جواب همینجا و در اعلانها میاد.', 'success');
|
||||||
}
|
}
|
||||||
setSubject('');
|
setSubject('');
|
||||||
@@ -116,11 +106,11 @@ export function SupportScreen() {
|
|||||||
<Send size={16} className="-scale-x-100" /> ارسال
|
<Send size={16} className="-scale-x-100" /> ارسال
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
|
|
||||||
{ticketIds.length > 0 && (
|
{!!tickets.data?.length && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 mt-2 text-sm font-black text-ink">پیامهای قبلیِ تو</h2>
|
<h2 className="mb-2 mt-2 text-sm font-black text-ink">پیامهای قبلیِ تو</h2>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{ticketIds.map((id) => <TicketCard key={id} ticketId={id} />)}
|
{tickets.data.map((t) => <TicketCard key={t.ticketId} summary={t} />)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -129,26 +119,24 @@ export function SupportScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TicketCard({ ticketId }: { ticketId: string }) {
|
function TicketCard({ summary }: { summary: TicketSummary }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const ticket = useAsync(() => api.support.ticket(ticketId), [ticketId]);
|
|
||||||
const [open, setOpen] = useState(false);
|
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 [reply, setReply] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const t = detail.data;
|
||||||
if (ticket.error || !ticket.data) {
|
const status = t?.status ?? summary.status;
|
||||||
return ticket.loading ? <div className="h-14 animate-pulse rounded-2xl bg-soft" /> : null;
|
const staffReplies = t ? t.messages.filter((m) => m.sender !== 'user').length : summary.supportReplies;
|
||||||
}
|
|
||||||
const t: Ticket = ticket.data;
|
|
||||||
const staffReplies = t.messages.filter((m) => m.sender !== 'user').length;
|
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
if (!reply.trim()) return;
|
if (!reply.trim()) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await api.support.reply(ticketId, reply.trim());
|
await api.support.reply(summary.ticketId, reply.trim());
|
||||||
setReply('');
|
setReply('');
|
||||||
ticket.reload();
|
detail.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(friendlyError(err), 'error');
|
toast(friendlyError(err), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -160,24 +148,28 @@ function TicketCard({ ticketId }: { ticketId: string }) {
|
|||||||
<div className="rounded-2xl border border-line bg-white">
|
<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">
|
<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="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">
|
<span className="text-[11px] text-muted">
|
||||||
{STATUS_LABEL[t.status] ?? t.status}
|
{STATUS_LABEL[status] ?? status}
|
||||||
{staffReplies > 0 && ` · ${toFa(staffReplies)} پاسخ`}
|
{staffReplies > 0 && <><Sep />{toFa(staffReplies)} پاسخ</>}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
{staffReplies > 0 && !open && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-indigo" aria-hidden />}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="flex flex-col gap-2 border-t border-line p-3.5">
|
<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
|
<div
|
||||||
key={m.id}
|
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'}`}
|
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{t.status !== 'closed' && (
|
{t && status !== 'closed' && (
|
||||||
<div className="mt-1 flex gap-2">
|
<div className="mt-1 flex gap-2">
|
||||||
<input
|
<input
|
||||||
value={reply}
|
value={reply}
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion } from 'motion/react';
|
import { Crown, Sparkles } from 'lucide-react';
|
||||||
import { Check, CheckCircle2, Circle, Lock, Play, Sparkles } from 'lucide-react';
|
import type { HomeNode } from '@/api';
|
||||||
import { api, type HomeNode } from '@/api';
|
|
||||||
import { useAppData } from '@/state/AppData';
|
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 { useNav } from '@/nav/Navigator';
|
||||||
import { useAsync } from '@/lib/useAsync';
|
import { IslandPath } from '@/components/IslandPath';
|
||||||
import { Sheet } from '@/components/Sheet';
|
import { IslandSheet } from '@/components/IslandSheet';
|
||||||
import { EmptyState, ErrorState, LoadingState, PrimaryButton, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
|
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
|
||||||
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
|
||||||
import { iconFor } from '@/components/NodeIcon';
|
|
||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
|
import { MathText } from '@/lib/MathText';
|
||||||
const NODE_SIZE = 76;
|
|
||||||
|
|
||||||
/** نقشهی جزیرههای یک فصل، مثلِ مسیرِ دولینگو. */
|
/** نقشهی جزیرههای یک فصل، مثلِ مسیرِ دولینگو. */
|
||||||
export function UnitScreen({ unitId }: { unitId: string }) {
|
export function UnitScreen({ unitId }: { unitId: string }) {
|
||||||
const { home, homeError, refreshHome, companion } = useAppData();
|
const { home, homeError, refreshHome, companion } = useAppData();
|
||||||
|
const nav = useNav();
|
||||||
|
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
|
||||||
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
|
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
|
||||||
const found = findUnit(home, unitId);
|
const found = findUnit(home, unitId);
|
||||||
|
|
||||||
@@ -32,14 +30,13 @@ export function UnitScreen({ unitId }: { unitId: string }) {
|
|||||||
const { unit, unitIndex } = found;
|
const { unit, unitIndex } = found;
|
||||||
const color = unitColor(unitIndex);
|
const color = unitColor(unitIndex);
|
||||||
const progress = unitProgress(unit);
|
const progress = unitProgress(unit);
|
||||||
const points = zigzagPositions(unit.nodes.length, 125, 90);
|
const current = unit.nodes.find((n) => n.status !== 'completed' && n.status !== 'locked') ?? null;
|
||||||
const height = points.length ? points[points.length - 1].y + 150 : 400;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen className="bg-[#f7f8ff]">
|
<Screen className="bg-page">
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
title={unit.title}
|
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}
|
color={color}
|
||||||
back
|
back
|
||||||
mascot={{ char: companion, mood: progress.percent >= 50 ? 'happy' : 'idle', size: 72 }}
|
mascot={{ char: companion, mood: progress.percent >= 50 ? 'happy' : 'idle', size: 72 }}
|
||||||
@@ -53,160 +50,34 @@ export function UnitScreen({ unitId }: { unitId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</ScreenHeader>
|
</ScreenHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto pb-6 pt-10">
|
||||||
{unit.nodes.length === 0 ? (
|
{unit.nodes.length === 0 ? (
|
||||||
<EmptyState text="این فصل هنوز جزیرهای نداره" />
|
<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
|
<IslandPath
|
||||||
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"
|
nodes={unit.nodes}
|
||||||
style={{ color, borderColor: color }}
|
color={color}
|
||||||
>
|
currentId={current?.id ?? null}
|
||||||
{node.title}
|
onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode(node))}
|
||||||
<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 }} />
|
onStart={(node) => (unit.requiresSubscription ? paywall() : nav.push({ name: 'quiz', nodeId: node.id }))}
|
||||||
</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 }}
|
|
||||||
/>
|
/>
|
||||||
</>
|
{unit.requiresSubscription && (
|
||||||
)}
|
<button type="button" onClick={paywall} className="mx-auto mt-6 flex items-center gap-2 rounded-2xl bg-orange px-5 py-3 text-sm font-black text-white shadow-lg shadow-orange/30">
|
||||||
<button
|
<Crown size={16} /> این فصل با اشتراک باز میشه
|
||||||
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>
|
</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="mt-6 flex flex-col items-center">
|
||||||
);
|
<span className="mb-1 text-[11px] font-bold text-muted">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
|
||||||
})}
|
|
||||||
<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>
|
|
||||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>
|
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>
|
||||||
<Sparkles size={20} style={{ color }} />
|
<Sparkles size={20} style={{ color }} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NodeSheet node={openNode} color={color} onClose={() => setOpenNode(null)} />
|
<IslandSheet node={openNode} color={color} onClose={() => setOpenNode(null)} />
|
||||||
</Screen>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useSyncExternalStore } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
import type { UsedChunk } from '@/api';
|
import type { Diagram, UsedChunk } from '@/api';
|
||||||
import { KEYS, store } from '@/lib/storage';
|
import { KEYS, store } from '@/lib/storage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,9 +13,13 @@ export interface ChatMessage {
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
usedChunks?: UsedChunk[];
|
usedChunks?: UsedChunk[];
|
||||||
followUps?: string[];
|
followUps?: string[];
|
||||||
|
/** شکلِ پاسخ؛ اپ آن را خودش به SVG تبدیل میکند (components/Diagram). */
|
||||||
|
diagram?: Diagram | null;
|
||||||
feedback?: 'up' | 'down';
|
feedback?: 'up' | 'down';
|
||||||
/** پیامِ خطا بهجای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده میشود. */
|
/** پیامِ خطا بهجای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده میشود. */
|
||||||
failedQuestion?: string;
|
failedQuestion?: string;
|
||||||
|
/** سهمیهی رایگانِ امروز تمام شده؛ بهجای «دوباره»، راهِ اشتراک نشان داده میشود. */
|
||||||
|
quotaReached?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AskState {
|
interface AskState {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface NodeRef {
|
|||||||
export function findContinue(home: HomeResponse | null): NodeRef | null {
|
export function findContinue(home: HomeResponse | null): NodeRef | null {
|
||||||
if (!home) return null;
|
if (!home) return null;
|
||||||
for (let i = 0; i < home.units.length; i++) {
|
for (let i = 0; i < home.units.length; i++) {
|
||||||
|
if (home.units[i].requiresSubscription) continue;
|
||||||
const node = home.units[i].nodes.find((n) => n.status !== 'completed' && n.status !== 'locked');
|
const node = home.units[i].nodes.find((n) => n.status !== 'completed' && n.status !== 'locked');
|
||||||
if (node) return { unit: home.units[i], unitIndex: i, node };
|
if (node) return { unit: home.units[i], unitIndex: i, node };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,14 @@
|
|||||||
|
|
||||||
--color-ink: #1a1a2e; /* متنِ اصلی */
|
--color-ink: #1a1a2e; /* متنِ اصلی */
|
||||||
--color-body: #3a3a5c; /* متنِ بدنه */
|
--color-body: #3a3a5c; /* متنِ بدنه */
|
||||||
--color-muted: #9e9ec0; /* متنِ کمرنگ */
|
/* متنِ کمرنگ. تا ۲۰۲۶-۱۰-۰۳ #9e9ec0 بود: کنتراست ۲٫۶ روی سفید، زیرِ حدِ ۴٫۵ِ WCAG، و
|
||||||
|
همین رنگ زیرنویسِ ۱۰–۱۲ پیکسلیِ همهی صفحهها و آیکونِ تبهای غیرفعال است. این یکی
|
||||||
|
روی سفید ۴٫۵ و روی پسزمینهی صفحه ۴٫۲ است. */
|
||||||
|
--color-muted: #727299;
|
||||||
--color-faint: #b0b0c8;
|
--color-faint: #b0b0c8;
|
||||||
--color-line: #ede9ff; /* حاشیهی کارت */
|
--color-line: #e9e6f7; /* حاشیهی کارت */
|
||||||
--color-soft: #f0eeff; /* پسزمینهی ملایم */
|
--color-soft: #f0eeff; /* پسزمینهی ملایم */
|
||||||
--color-page: #f0f4ff; /* پسزمینهی صفحه */
|
--color-page: #f6f5fb; /* پسزمینهی صفحه؛ کمی گرمتر از آبیِ قبلی */
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -43,10 +46,36 @@
|
|||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
/* بیتأخیرِ دابلتپ و بیزوم با دو ضربه روی دکمهها */
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: not-allowed;
|
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 {
|
input, textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
@@ -66,3 +95,27 @@
|
|||||||
@utility pb-safe {
|
@utility pb-safe {
|
||||||
padding-bottom: max(env(safe-area-inset-bottom), 0px);
|
padding-bottom: max(env(safe-area-inset-bottom), 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* نمادهای شناورِ سربرگ (ui.tsx). فقط transform، پس روی GPU میماند. */
|
||||||
|
@keyframes glyph-float {
|
||||||
|
0%, 100% { transform: translateY(0) rotate(-6deg); }
|
||||||
|
50% { transform: translateY(-7px) rotate(6deg); }
|
||||||
|
}
|
||||||
|
.glyph {
|
||||||
|
animation: glyph-float 6s ease-in-out infinite;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ناحیهی لمسِ بزرگتر بدونِ تغییرِ ظاهر: دکمههای کوچکِ سربرگ (۲۸–۳۲ پیکسل) برای انگشت کوچکاند. */
|
||||||
|
.hit { position: relative; }
|
||||||
|
.hit::after { content: ''; position: absolute; inset: -7px -3px; }
|
||||||
|
|
||||||
|
/* نوارِ اشتراک و نشانِ تدریسینو: فقط transform/opacity تا روی GPU بماند و روانی حفظ شود. */
|
||||||
|
@keyframes bar-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||||
|
.bar-fill { transform-origin: right; animation: bar-fill 0.9s cubic-bezier(.2,.8,.2,1) both; }
|
||||||
|
@keyframes shine { 0% { transform: translateX(120%); } 60%, 100% { transform: translateX(-220%); } }
|
||||||
|
.shine { animation: shine 3.2s ease-in-out infinite; will-change: transform; }
|
||||||
|
@keyframes spark { 0%, 100% { opacity: .25; transform: scale(.7); } 50% { opacity: 1; transform: scale(1); } }
|
||||||
|
.spark { animation: spark 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||||
|
@keyframes rise { 0% { transform: translateY(16px); opacity: 0; } 15%, 85% { opacity: .55; } 100% { transform: translateY(-110px); opacity: 0; } }
|
||||||
|
.rise { animation: rise 7s linear infinite; will-change: transform, opacity; }
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ export default defineConfig(({ mode }) => {
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: { '@': path.resolve(__dirname, './src') },
|
alias: { '@': path.resolve(__dirname, './src') },
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// کتابخانهها با هر انتشار عوض نمیشوند؛ فایلِ جدا یعنی بعد از هر دیپلوی
|
||||||
|
// از کشِ مرورگر میآیند و فقط کدِ خودِ اپ دوباره دانلود میشود.
|
||||||
|
manualChunks: {
|
||||||
|
react: ['react', 'react-dom'],
|
||||||
|
motion: ['motion/react'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
// در توسعه /api همدامنه است و به بکاند پاس داده میشود؛ CORS لازم نیست.
|
// در توسعه /api همدامنه است و به بکاند پاس داده میشود؛ CORS لازم نیست.
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||