Compare commits
29 Commits
rebuild/v2
...
android-bu
| Author | SHA1 | Date | |
|---|---|---|---|
| 5327fed1dd | |||
| 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
1
.gitignore
vendored
@@ -8,3 +8,4 @@ dist
|
||||
dist.next
|
||||
dist.prev
|
||||
.deploy-backups
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -75,7 +75,9 @@ src/
|
||||
state/ دادهی مشترک (پروفایل، نقشه، …) و گفتگوی دستیار
|
||||
screens/ یک فایل برای هر صفحه
|
||||
components/ اجزای مشترک (سربرگ، برگهی پایین، …)
|
||||
mascots/ سهیار: سیگما، دلتا، پی
|
||||
mascots/ همراهها: سیگما، دلتا، پی، تدی
|
||||
diagram/ شکلِ جوابِ دستیار: توصیفِ JSON از بکاند → SVG؛ همهی مختصات اینجا حساب میشود
|
||||
(plane، shapes، expr، solid، charts، sets، misc، numberline). گالری: ?gallery فقط در dev
|
||||
lib/ اعدادِ فارسی، تاریخِ شمسی، صدا، localStorage
|
||||
```
|
||||
|
||||
|
||||
9
android/.gitignore
vendored
Normal file
9
android/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
*.jks
|
||||
*.keystore
|
||||
build-out/
|
||||
65
android/README.md
Normal file
65
android/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# اپِ اندرویدِ تدریسینو (پوستهی WebView + پرداختِ بازار)
|
||||
|
||||
همان سایتِ `math.tadrisino2.ir` داخلِ یک WebView. تنها کدِ بومی پرداختِ درونبرنامهایِ
|
||||
کافهبازار است (کتابخانهی Poolakey)، چون بازار برای اپی که در آن منتشر میشود پرداختِ
|
||||
محصولِ دیجیتال را فقط از راهِ خودش میپذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
|
||||
فرانت بهروز میشود، بیآنکه نسخهی تازهی اپ لازم باشد.
|
||||
|
||||
> ⚠️ APIِ Poolakey از README خودِ کتابخانه است و خرید روی گوشی هنوز آزموده نشده.
|
||||
|
||||
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
|
||||
|
||||
| چه | کجا گذاشته میشود | وضعیت |
|
||||
|---|---|---|
|
||||
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.app` حدس است؛ تأیید نشده |
|
||||
| کلیدِ RSAِ عمومیِ اپ | `bazaarRsaKey` در فایلِ تنظیماتِ بیرون از ریپو (پایین) | ندارد؛ بدونِ آن build نوعِ release نمیشود |
|
||||
| SKUِ هر بسته (محصولِ **مصرفشدنی**) | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچکدام تعریف نشده |
|
||||
|
||||
قیمتِ هر محصول در پیشخانِ بازار باید با قیمتِ همان بسته در ادمین یکی باشد؛ سرور مبلغ را از
|
||||
بازار نمیخواند.
|
||||
|
||||
## تنظیماتِ بیرون از ریپو
|
||||
|
||||
keystore و کلیدِ RSA هرگز داخلِ ریپو نمیآیند. یک فایل با دسترسیِ ۶۰۰ بسازید: `~/.tadrisino/android.properties`
|
||||
(یا مسیرِ دلخواه در `TADRISINO_ANDROID_CONFIG`):
|
||||
|
||||
```
|
||||
storeFile=/مسیر/کامل/tadrisino-release.jks
|
||||
storePassword=...
|
||||
keyAlias=...
|
||||
keyPassword=...
|
||||
bazaarRsaKey=<کلیدِ RSA از پیشخانِ بازار، یک خط>
|
||||
```
|
||||
|
||||
| مقدار | از کجا | اگر نبود |
|
||||
|---|---|---|
|
||||
| `storeFile` تا `keyPassword` | keystoreِ شرکت. **همان فایلِ APKِ فعلی را امضا کرده؛ نسخهی تازه با کلیدِ دیگر روی بازار آپدیت نمیشود.** دو نسخهی پشتیبان در دو جای جدا | build نوعِ release با پیامِ روشن میایستد |
|
||||
| `bazaarRsaKey` | پیشخان ← اپ ← پرداخت درونبرنامهای | build نوعِ release میایستد (نه اپِ بیخرید) |
|
||||
|
||||
## build
|
||||
|
||||
نیازها (یک بار): JDK 17 (`brew install openjdk@17`) و Android SDK با `platforms;android-34` و `build-tools;34.0.0` در `~/Library/Android/sdk`.
|
||||
|
||||
```bash
|
||||
cd android && ./build-apk.sh
|
||||
```
|
||||
|
||||
خروجی `android/build-out/Tadrisino-<نسخه>-<versionCode>.apk` است. پیش از هر انتشار `versionCode` را در `app/build.gradle.kts`
|
||||
بالا ببرید (بازار نسخهی با شمارهی تکراری را نمیپذیرد). اسکریپت امضا را میسنجد و اثرانگشتِ گواهی را چاپ میکند؛ باید با APKِ قبلی یکی باشد.
|
||||
APKِ آزمایشی بینیاز از کلید: `./gradlew assembleDebug`.
|
||||
|
||||
## جریانِ خرید (برای عیبیابی)
|
||||
|
||||
```
|
||||
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
|
||||
فرانت TadrisinoAndroid.bazaarPurchase(sku, orderId)
|
||||
اپ Poolakey.purchaseProduct(sku, payload=orderId) → purchaseToken
|
||||
اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken}
|
||||
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار میپرسد
|
||||
فرانت TadrisinoAndroid.bazaarConsume(token) ← تا همان بسته دوباره خریدنی باشد
|
||||
```
|
||||
|
||||
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» میزند: فرانت همان سفارش را میفرستد
|
||||
و اپ توکنِ ماندهٔ همان سفارش را از `getPurchasedProducts` پیدا میکند.
|
||||
|
||||
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بکاند.
|
||||
81
android/app/build.gradle.kts
Normal file
81
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,81 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
// تنظیماتِ حساس (keystore و کلیدِ RSAِ بازار) در ریپو نیستند. از فایلی بیرون از ریپو خوانده میشوند:
|
||||
// $TADRISINO_ANDROID_CONFIG یا ~/.tadrisino/android.properties
|
||||
// کلیدها: storeFile, storePassword, keyAlias, keyPassword, bazaarRsaKey. شرحِ کامل: README.md.
|
||||
val configFile = File(System.getenv("TADRISINO_ANDROID_CONFIG") ?: "${System.getProperty("user.home")}/.tadrisino/android.properties")
|
||||
val secrets = Properties().apply { if (configFile.exists()) configFile.inputStream().use { load(it) } }
|
||||
// فقط وقتی build از نوعِ release است الزامی است؛ debug بدونِ آن هم ساخته میشود.
|
||||
val wantsRelease = gradle.startParameter.taskNames.any { it.contains("release", ignoreCase = true) }
|
||||
fun secret(name: String): String {
|
||||
val v = secrets.getProperty(name)?.trim().orEmpty()
|
||||
if (wantsRelease && v.isEmpty()) {
|
||||
throw GradleException("build نوعِ release «$name» را لازم دارد. در ${configFile.path} نیست یا خالی است (README.md ← «تنظیماتِ بیرون از ریپو»).")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
android {
|
||||
// ⚠️ اسمِ پکیج باید همانی باشد که در پیشخانِ بازار ثبت است. com.tadrisino.app را بازار
|
||||
// برای حسابِ شرکت «Product is not found» جواب داد (یعنی هست)، ولی سنیور تأیید نکرده
|
||||
// که همان اپِ ریاضی است (CLAUDE.mdِ بکاند، ۲۰۲۶-۰۹-۳۰).
|
||||
namespace = "com.tadrisino.app"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.tadrisino.app"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
|
||||
// آدرسِ همان فرانتِ وب. دامنه عوض شود، فقط همین خط.
|
||||
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
|
||||
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (بخشِ «پرداخت درونبرنامهای»). کلیدِ عمومی
|
||||
// است و در اپ مینشیند؛ رمز نیست. تا پر نشود، اپ خرید را شروع نمیکند.
|
||||
// از فایلِ بیرون از ریپو؛ برای build نوعِ release خالی بودنش خطا میدهد (نه اپِ بیخرید).
|
||||
buildConfigField("String", "BAZAAR_RSA_KEY", "\"${secret("bazaarRsaKey")}\"")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
// release بدونِ keystore ساخته نمیشود؛ هرگز بیصدا با کلیدِ debug امضا نمیشود.
|
||||
if (wantsRelease) {
|
||||
storeFile = file(secret("storeFile"))
|
||||
storePassword = secret("storePassword")
|
||||
keyAlias = secret("keyAlias")
|
||||
keyPassword = secret("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.activity:activity-ktx:1.9.2")
|
||||
implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
|
||||
}
|
||||
29
android/app/src/main/AndroidManifest.xml
Normal file
29
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- برای لرزشِ کوتاهِ جوابِ درست/غلط (navigator.vibrate در WebView). -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- اندروید ۱۱+: بدونِ این، اپ نمیتواند سرویسِ پرداختِ بازار را پیدا کند. -->
|
||||
<queries>
|
||||
<package android:name="com.farsitel.bazaar" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Tadrisino">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
180
android/app/src/main/java/com/tadrisino/app/MainActivity.kt
Normal file
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"
|
||||
}
|
||||
}
|
||||
4
android/app/src/main/res/values/strings.xml
Normal file
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
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>
|
||||
33
android/build-apk.sh
Executable file
33
android/build-apk.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# یک دستور برای ساختِ APKِ امضاشده: ./build-apk.sh
|
||||
#
|
||||
# چیزی را حدس نمیزند: JDK، Android SDK، keystore یا کلیدِ RSA نبود، با پیامِ روشن میایستد.
|
||||
# تنظیمات از ~/.tadrisino/android.properties (یا $TADRISINO_ANDROID_CONFIG) میآید، نه از ریپو.
|
||||
# بعد از build، امضا را با apksigner میسنجد و اثرانگشتِ گواهی را چاپ میکند تا با نسخهی قبلی بخوانید:
|
||||
# گواهیِ متفاوت یعنی بازار آپدیت را نمیپذیرد.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
CONFIG="${TADRISINO_ANDROID_CONFIG:-$HOME/.tadrisino/android.properties}"
|
||||
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
||||
if [ -z "${JAVA_HOME:-}" ] && [ -d /opt/homebrew/opt/openjdk@17 ]; then export JAVA_HOME=/opt/homebrew/opt/openjdk@17; fi
|
||||
|
||||
fail() { printf '✖ %s\n' "$1" >&2; exit 1; }
|
||||
[ -n "${JAVA_HOME:-}" ] && [ -x "$JAVA_HOME/bin/java" ] || fail "JDK 17 پیدا نشد (JAVA_HOME). نصب: brew install openjdk@17"
|
||||
[ -d "$ANDROID_HOME/platforms/android-34" ] || fail "Android SDK (platforms;android-34) در $ANDROID_HOME نیست."
|
||||
[ -f "$CONFIG" ] || fail "فایلِ تنظیمات نیست: $CONFIG (README.md ← «تنظیماتِ بیرون از ریپو»)."
|
||||
[ "$(stat -f %Lp "$CONFIG" 2>/dev/null || stat -c %a "$CONFIG")" = "600" ] || fail "دسترسیِ $CONFIG باید ۶۰۰ باشد: chmod 600 \"$CONFIG\""
|
||||
[ -x ./gradlew ] || fail "gradlew نیست (gradle wrapper در ریپو کامیت میشود)."
|
||||
|
||||
./gradlew --no-daemon assembleRelease
|
||||
|
||||
APK="app/build/outputs/apk/release/app-release.apk"
|
||||
[ -f "$APK" ] || fail "APK ساخته نشد: $APK"
|
||||
APKSIGNER="$(ls -d "$ANDROID_HOME"/build-tools/*/apksigner | sort -V | tail -1)"
|
||||
"$APKSIGNER" verify --print-certs "$APK" | grep -E "certificate (DN|SHA-256)"
|
||||
CODE="$(grep -E '^\s*versionCode' app/build.gradle.kts | grep -oE '[0-9]+')"
|
||||
NAME="$(grep -E '^\s*versionName' app/build.gradle.kts | grep -oE '"[^"]+"' | tr -d '"')"
|
||||
OUT="build-out/Tadrisino-$NAME-$CODE.apk"
|
||||
mkdir -p build-out && cp "$APK" "$OUT"
|
||||
printf '✔ %s (versionCode %s)\n اثرانگشتِ گواهی بالا باید با APKِ قبلیِ منتشرشده یکی باشد.\n' "$OUT" "$CODE"
|
||||
4
android/build.gradle.kts
Normal file
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
2
android/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
android.useAndroidX=true
|
||||
org.gradle.jvmargs=-Xmx2g
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
9
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
9
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
248
android/gradlew
vendored
Executable file
248
android/gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
112
android/gradlew.bat
vendored
Normal file
112
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
@rem Catch executions from older scripts and ensure they exit cleanly.
|
||||
@rem This can be removed once we can be reasonably confident that few people
|
||||
@rem will be migrating directly to this new wrapper.
|
||||
goto afterSafetyNet
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
goto exitWithErrorLevel
|
||||
:afterSafetyNet
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
|
||||
|
||||
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
18
android/settings.gradle.kts
Normal file
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
|
||||
[ -d dist ] && mv dist dist.prev
|
||||
mv dist.next dist
|
||||
API="$(grep -o 'https\?://[a-zA-Z0-9.-]*/api' dist/assets/*.js | head -1 | cut -d: -f2-)"
|
||||
API="$(grep -oh 'https\?://[a-zA-Z0-9.-]*/api' dist/assets/*.js | head -1)"
|
||||
printf ' منتشر شد. آدرسِ API در build: %s\n' "${API:-(همدامنه /api یا config.js)}"
|
||||
printf ' کشِ آروان را برای index.html و config.js پاک کنید اگر تغییری دیده نشد.\n'
|
||||
|
||||
@@ -163,7 +163,7 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
|
||||
const inFlight = new Map<string, Promise<unknown>>();
|
||||
const signedOutListeners = new Set<() => void>();
|
||||
let refreshing: Promise<Tokens | null> | null = null;
|
||||
let refreshing: Promise<Tokens | 'invalid' | ApiError> | null = null;
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = storage.get()?.token;
|
||||
@@ -175,27 +175,45 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
signedOutListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
async function refreshTokens(): Promise<Tokens | null> {
|
||||
/**
|
||||
* نتیجهی تمدید سه حالت دارد و فقط یکیاش یعنی خروج:
|
||||
* • Tokens — تمدید شد.
|
||||
* • 'invalid' — سرور گفت refresh token معتبر نیست (۴۰۱)؛ نشست واقعاً تمام است.
|
||||
* • ApiError — سرور در دسترس نبود یا سقف را زد (۴۲۹، ۵xx، قطعیِ شبکه)؛ نشست سالم است.
|
||||
* تا ۲۰۲۶-۱۰-۰۳ هر شکستی خروج حساب میشد: پشتِ وایفایِ مدرسه که سقفِ تمدیدِ IP پر میشد،
|
||||
* دانشآموزِ شمارهدار به صفحهی ورود پرت میشد و باید دوباره پیامک میگرفت.
|
||||
*/
|
||||
async function refreshTokens(): Promise<Tokens | 'invalid' | ApiError> {
|
||||
const current = storage.get();
|
||||
if (!current?.refreshToken) return null;
|
||||
if (!current?.refreshToken) return 'invalid';
|
||||
if (refreshing) return refreshing;
|
||||
|
||||
refreshing = (async () => {
|
||||
refreshing = (async (): Promise<Tokens | 'invalid' | ApiError> => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/auth/refresh`, {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken: current.refreshToken }),
|
||||
signal: timeoutSignal(defaultTimeout),
|
||||
credentials: 'omit',
|
||||
});
|
||||
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 next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
|
||||
storage.set(next);
|
||||
return next;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
queueMicrotask(() => {
|
||||
refreshing = null;
|
||||
@@ -243,8 +261,9 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
let response = await send(method, path, body, opts);
|
||||
if (response.status === 401 && hadToken) {
|
||||
const refreshed = await refreshTokens();
|
||||
if (refreshed) response = await send(method, path, body, opts);
|
||||
else signOut();
|
||||
if (refreshed === 'invalid') signOut();
|
||||
else if (refreshed instanceof ApiError) throw refreshed; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه
|
||||
else response = await send(method, path, body, opts);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -22,25 +22,38 @@ function saveTokens(data: T.AuthResponse) {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** یک بخشِ مسیر؛ شناسهای که از آدرسِ صفحه یا سرور میآید نباید با «/» یا «..» مسیرِ دیگری از API را صدا بزند. */
|
||||
const seg = (value: string | number) => encodeURIComponent(String(value));
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
isSignedIn: () => Boolean(http.storage.get()?.token),
|
||||
|
||||
/** مهمان: فقط با شناسهی دستگاه. refreshToken هم میگیرد، پس بعد از یک ساعت نمیافتد. */
|
||||
guest: (deviceId: string) =>
|
||||
http.post<T.AuthResponse>('/auth/session', { type: 'guest', deviceId }).then(saveTokens),
|
||||
|
||||
/** گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمیگردد. */
|
||||
/**
|
||||
* گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمیگردد.
|
||||
* مهلتِ ۴۵ ثانیه، نه ۱۵ِ پیشفرض: بکاند تا ۱۰+۳۰ ثانیه منتظرِ کاوهنگار میماند
|
||||
* (۲۰۲۶-۰۹-۲۷، پیامک رسید ولی جوابِ کاوهنگار دیر آمد). اگر این زودتر قطع کند،
|
||||
* کاربر «ارتباط برقرار نشد» میبیند در حالی که کد در گوشیاش است.
|
||||
*/
|
||||
requestOtp: (phone: string) =>
|
||||
http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }),
|
||||
http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }, { timeoutMs: 45_000 }),
|
||||
|
||||
/**
|
||||
* گامِ ۲: تأییدِ کد. اگر الان مهمان باشد، سرور همان کاربر را نگه میدارد و
|
||||
* پیشرفتش از دست نمیرود (توکنِ مهمان همراهِ همین درخواست میرود).
|
||||
* گامِ ۲: تأییدِ کد. شمارهی تازه حسابِ تازه میسازد.
|
||||
*/
|
||||
login: (phone: string, otp: string) =>
|
||||
http.post<T.AuthResponse>('/auth/login', { phone, otp }).then(saveTokens),
|
||||
|
||||
/**
|
||||
* حذفِ همیشگیِ حساب. کاربر کدِ تازهی پیامکی میدهد (اول requestOtp با شمارهی خودش).
|
||||
* بعد از موفقیت توکنها پاک میشوند؛ دیگر کاربری پشتشان نیست.
|
||||
*/
|
||||
deleteAccount: (body: { otp: string }) =>
|
||||
http.post<{ success: boolean }>('/user/delete', body).then((r) => {
|
||||
http.storage.set(null);
|
||||
return r;
|
||||
}),
|
||||
|
||||
/** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */
|
||||
async logout() {
|
||||
const refreshToken = http.storage.get()?.refreshToken;
|
||||
@@ -56,7 +69,7 @@ export const api = {
|
||||
|
||||
profile: {
|
||||
get: () => http.get<T.UserProfile>('/user/profile'),
|
||||
update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
|
||||
update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'birthDate' | 'bio'>>) =>
|
||||
http.patch<T.UserProfile>('/user/profile', patch),
|
||||
setGrade: (gradeId: number) =>
|
||||
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
|
||||
@@ -65,31 +78,42 @@ export const api = {
|
||||
summary: () => http.get<T.UserSummary>('/user/summary'),
|
||||
hearts: () => http.get<T.Hearts>('/user/hearts'),
|
||||
streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'),
|
||||
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
|
||||
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
|
||||
|
||||
/** بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. */
|
||||
home: () => http.get<T.HomeResponse>('/home'),
|
||||
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${nodeId}/lessons`),
|
||||
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
|
||||
|
||||
/**
|
||||
* آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیشفرض ۴). ترتیب برای هر
|
||||
* کاربر ثابت است، پس رفرش همان آزمون را میدهد.
|
||||
*/
|
||||
nodeQuiz: (nodeId: string) => http.get<T.NodeQuiz>(`/nodes/${seg(nodeId)}/quiz`),
|
||||
/** پایانِ آزمون، جزیره را با هر نمرهای تمام میکند و جزیرهی بعدی را باز. */
|
||||
nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
|
||||
http.create<T.NodeQuizComplete>(`/nodes/${seg(nodeId)}/quiz/complete`, result, { idempotencyKey }),
|
||||
|
||||
lesson: {
|
||||
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${lessonId}/intro`),
|
||||
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${seg(lessonId)}/intro`),
|
||||
questions: (lessonId: number) =>
|
||||
http.get<{ questions: T.Question[] }>(`/lessons/${lessonId}/questions`).then((r) => r.questions),
|
||||
http.get<{ questions: T.Question[] }>(`/lessons/${seg(lessonId)}/questions`).then((r) => r.questions),
|
||||
/**
|
||||
* کلیدِ یکتا را یکبار برای هر جواب بسازید و در تکرار همان را بفرستید تا
|
||||
* امتیاز و قلب دوبار حساب نشود.
|
||||
*/
|
||||
answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) =>
|
||||
http.post<T.AnswerResult>(
|
||||
`/questions/${questionId}/answer`,
|
||||
`/questions/${seg(questionId)}/answer`,
|
||||
{ selectedOptionId: Number(selectedOptionId) },
|
||||
{ idempotencyKey, retry: true },
|
||||
),
|
||||
/** مجموعِ XP و سکهی کاربر اینجا بهروز میشود، نه سرِ هر جواب. */
|
||||
complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
|
||||
http.create<T.LessonComplete>(`/lessons/${lessonId}/complete`, result, { idempotencyKey }),
|
||||
http.create<T.LessonComplete>(`/lessons/${seg(lessonId)}/complete`, result, { idempotencyKey }),
|
||||
report: (questionId: string, reason: T.ReportReason, note?: string) =>
|
||||
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
|
||||
`/questions/${questionId}/report`,
|
||||
`/questions/${seg(questionId)}/report`,
|
||||
{ reason, ...(note ? { note } : {}) },
|
||||
),
|
||||
},
|
||||
@@ -104,6 +128,9 @@ export const api = {
|
||||
},
|
||||
|
||||
ai: {
|
||||
/** «امروز X سوالِ رایگان + Y سوالِ خریدهشده»، پیش از اولین سوال. */
|
||||
quota: () => http.get<T.TutorQuota>('/ai/quota'),
|
||||
|
||||
/**
|
||||
* پرسش با پاسخِ تکهتکه. `lessonId` پاسخ را به کتابِ همان درس مستند میکند؛
|
||||
* `questionId` به دستیار میگوید دانشآموز کدام سوال را غلط زده.
|
||||
@@ -128,57 +155,59 @@ export const api = {
|
||||
return done;
|
||||
},
|
||||
feedback: (messageId: string, isHelpful: boolean, reason?: string) =>
|
||||
http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, {
|
||||
http.post<{ success: boolean }>(`/ai/messages/${seg(messageId)}/feedback`, {
|
||||
isHelpful,
|
||||
...(reason ? { reason } : {}),
|
||||
}),
|
||||
history: (conversationId: string) =>
|
||||
http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>(
|
||||
`/ai/conversations/${conversationId}/messages`,
|
||||
`/ai/conversations/${seg(conversationId)}/messages`,
|
||||
),
|
||||
},
|
||||
|
||||
notifications: {
|
||||
list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'),
|
||||
markRead: (id: number) => http.patch(`/notifications/${id}/read`),
|
||||
markRead: (id: number) => http.patch(`/notifications/${seg(id)}/read`),
|
||||
markAllRead: () => http.patch('/notifications/read-all'),
|
||||
},
|
||||
|
||||
subscription: {
|
||||
plans: () => http.get<{ plans: T.Plan[] }>('/subscription/plans').then((r) => r.plans),
|
||||
plans: () => http.get<T.PlansResponse>('/subscription/plans'),
|
||||
status: () => http.get<T.SubscriptionStatus>('/subscription/status'),
|
||||
/** سفارش میسازد؛ paymentUrl خودش سراغِ درگاه میرود. */
|
||||
purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }),
|
||||
/**
|
||||
* خرید داخلِ اپِ اندرویدِ بازار. idempotencyKey ثابت است تا تلاشِ دوباره بعد از قطعِ اینترنت
|
||||
* همان سفارش را برگرداند (src/lib/bazaar.ts).
|
||||
*/
|
||||
purchaseBazaar: (planId: string, idempotencyKey: string) =>
|
||||
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }),
|
||||
/** توکنِ خریدِ Poolakey را سرور از خودِ بازار میسنجد؛ گفتهی اپ کافی نیست. */
|
||||
verifyBazaar: (orderId: string, purchaseToken: string) =>
|
||||
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),
|
||||
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
|
||||
redeem: (code: string, orderId: string) =>
|
||||
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
|
||||
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${orderId}`),
|
||||
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${seg(orderId)}`),
|
||||
/** پرداختها و فاکتورهای همین کاربر، تازهترین اول. */
|
||||
invoices: () => http.get<{ invoices: T.Invoice[] }>('/payment/invoices').then((r) => r.invoices),
|
||||
},
|
||||
|
||||
support: {
|
||||
createTicket: (subject: string, category: string) =>
|
||||
http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }),
|
||||
ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${ticketId}`),
|
||||
/** تیکتهای همین کاربر، تازهترین اول (۲۰ تای آخر). */
|
||||
tickets: () => http.get<{ tickets: T.TicketSummary[] }>('/support/tickets').then((r) => r.tickets),
|
||||
ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${seg(ticketId)}`),
|
||||
reply: (ticketId: string, text: string) =>
|
||||
http.create<{ messageId: string; success: boolean }>(`/support/tickets/${ticketId}/messages`, { text }),
|
||||
http.create<{ messageId: string; success: boolean }>(`/support/tickets/${seg(ticketId)}/messages`, { text }),
|
||||
bugReport: (title: string, description: string) =>
|
||||
http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }),
|
||||
},
|
||||
|
||||
family: {
|
||||
link: () => http.get<T.FamilyLink>('/family/link'),
|
||||
invite: (childPhone: string) =>
|
||||
http.post<{ familyLinkId: string; inviteCode: string; status: string }>('/family/link/invite', { childPhone }),
|
||||
accept: (inviteCode: string) =>
|
||||
http.post<{ familyLinkId: string; status: string }>('/family/link/accept', { inviteCode }),
|
||||
weeklyReport: () => http.get<T.FamilyWeeklyReport>('/family/weekly-report'),
|
||||
settings: () => http.get<T.FamilySettings>('/family/settings'),
|
||||
updateSettings: (patch: Partial<T.FamilySettings>) => http.patch('/family/settings', patch),
|
||||
},
|
||||
|
||||
affiliate: {
|
||||
me: () => http.get<T.AffiliateMe>('/affiliate/me'),
|
||||
/** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل میکند. */
|
||||
/** کاربرِ تازه را به صاحبِ کدِ معرف وصل میکند. */
|
||||
track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }),
|
||||
},
|
||||
};
|
||||
|
||||
162
src/api/types.ts
162
src/api/types.ts
@@ -14,7 +14,6 @@ export interface AuthResponse {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
isNewUser?: boolean;
|
||||
isGuest?: boolean;
|
||||
}
|
||||
|
||||
export interface Grade {
|
||||
@@ -28,13 +27,15 @@ export interface Grade {
|
||||
|
||||
export interface UserProfile {
|
||||
userId: string;
|
||||
/** شناسهی نمایشی «User482917»؛ همان که پشتیبانی در ادمین جستجو میکند. */
|
||||
publicId: string;
|
||||
phone: string | null;
|
||||
name: string;
|
||||
avatar: string;
|
||||
isGuest: boolean;
|
||||
gradeId: number | null;
|
||||
birthYear: number | null;
|
||||
parentPhone: string;
|
||||
/** تاریخِ تولدِ شمسی «1385-04-12»؛ null وقتی فقط سال داده شده یا هیچ. */
|
||||
birthDate: string | null;
|
||||
bio: string;
|
||||
}
|
||||
|
||||
@@ -105,10 +106,19 @@ export interface LessonIntro {
|
||||
kind: 'quiz' | 'video';
|
||||
example: string;
|
||||
steps: string[];
|
||||
/** شکلهای ثابتِ درس (یک بار ساخته و تأییدشده؛ هیچ تولیدِ زندهای نیست). */
|
||||
figures: LessonFigure[];
|
||||
videoUrl: string;
|
||||
durationMinutes: number | null;
|
||||
}
|
||||
|
||||
export interface LessonFigure {
|
||||
where: 'intro' | 'example' | 'step';
|
||||
/** فقط برای where='step'، از صفر. */
|
||||
index?: number;
|
||||
diagram: DiagramSpec;
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
id: string;
|
||||
value: string;
|
||||
@@ -121,6 +131,31 @@ export interface Question {
|
||||
options: QuestionOption[];
|
||||
}
|
||||
|
||||
export interface NodeQuizQuestion extends Question {
|
||||
lessonId: string;
|
||||
lessonTitle: string;
|
||||
}
|
||||
|
||||
export interface NodeQuiz {
|
||||
nodeId: string;
|
||||
nodeTitle: string;
|
||||
unitTitle: string;
|
||||
/** ممکن است از requested کمتر باشد (جزیرهی کمسوال). */
|
||||
questionCount: number;
|
||||
requested: number;
|
||||
questions: NodeQuizQuestion[];
|
||||
}
|
||||
|
||||
export interface NodeQuizComplete {
|
||||
nodeId: string;
|
||||
stars: number;
|
||||
xpEarned: number;
|
||||
coinsEarned: number;
|
||||
/** جزیرهی بعدی که حالا باز است؛ null یعنی آخرینِ پایه بود. */
|
||||
nextNodeId: string | null;
|
||||
stats: HomeStats;
|
||||
}
|
||||
|
||||
export interface AnswerResult {
|
||||
correct: boolean;
|
||||
correctOptionId: string;
|
||||
@@ -182,16 +217,26 @@ export interface AskDone {
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
suggestedFollowUps: string[];
|
||||
/** سوالهای رایگانِ باقیِ امروز. */
|
||||
remainingQuota: number;
|
||||
/** اعتبارِ بستههای سوال؛ منقضی نمیشود. */
|
||||
credits?: number;
|
||||
usedChunks: UsedChunk[];
|
||||
diagram?: Diagram | null;
|
||||
}
|
||||
|
||||
export interface ConversationMessage {
|
||||
messageId: string;
|
||||
role: 'user' | 'assistant' | string;
|
||||
text: string;
|
||||
diagram?: Diagram | null;
|
||||
}
|
||||
|
||||
/* ─── شکلِ پاسخِ دستیار: شِمایش در src/diagram/types.ts ─────────────────── */
|
||||
|
||||
import type { DiagramSpec } from '@/diagram/types';
|
||||
export type Diagram = DiagramSpec;
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number;
|
||||
type: string;
|
||||
@@ -212,17 +257,54 @@ export interface Plan {
|
||||
durationDays: number;
|
||||
features: string[];
|
||||
badge: string;
|
||||
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
|
||||
aiDailyQuota: number;
|
||||
/** «pack» بستهی سوال است (از ۱۴۰۵-۰۷)؛ «subscription» اشتراکِ زمانیِ قدیمی. */
|
||||
kind: 'pack' | 'subscription';
|
||||
/** بسته: سوالهایی که به اعتبار اضافه میشود. */
|
||||
questionCredits: number;
|
||||
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
|
||||
heartsDays: number;
|
||||
/** SKUِ همین بسته در کافهبازار؛ خالی یعنی داخلِ اپِ بازار خریدنی نیست. */
|
||||
bazaarSku: string;
|
||||
}
|
||||
|
||||
export interface PlansResponse {
|
||||
plans: Plan[];
|
||||
/** سقفِ روزانهی کاربرِ بیاشتراک، برای مقایسه روی کارت. */
|
||||
freeAiDailyQuota: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionStatus {
|
||||
/** اشتراکِ زمانیِ قدیمی (ماهانه/سالانه)، نه بسته. */
|
||||
active: boolean;
|
||||
planId: string | null;
|
||||
expiresAt: string | null;
|
||||
/** سوالهای خریدهشدهی باقیمانده. */
|
||||
questionCredits: number;
|
||||
/** پایانِ قلبِ نامحدودِ هدیهی بستهها؛ null یعنی ندارد. */
|
||||
unlimitedHeartsUntil: string | null;
|
||||
}
|
||||
|
||||
/** GET /ai/quota */
|
||||
export interface TutorQuota {
|
||||
freeDaily: number;
|
||||
remainingQuota: number;
|
||||
credits: number;
|
||||
}
|
||||
|
||||
export interface PurchaseResponse {
|
||||
orderId: string;
|
||||
paymentUrl: string;
|
||||
/** زرینپال: آدرسِ درگاه. بازار: null. */
|
||||
paymentUrl: string | null;
|
||||
/** فقط بازار: SKUی که اپِ اندروید میخرد. */
|
||||
bazaarSku?: string;
|
||||
}
|
||||
|
||||
export interface BazaarVerifyResponse {
|
||||
orderId: string;
|
||||
status: string;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export interface RedeemResponse {
|
||||
@@ -239,6 +321,16 @@ export interface OrderStatus {
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/** یک ردیف از فهرستِ تیکتهای کاربر (GET /support/tickets). */
|
||||
export interface TicketSummary {
|
||||
ticketId: string;
|
||||
subject: string;
|
||||
category: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
supportReplies: number;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
ticketId: string;
|
||||
subject: string;
|
||||
@@ -246,24 +338,58 @@ export interface Ticket {
|
||||
messages: { id: string; sender: string; text: string }[];
|
||||
}
|
||||
|
||||
export interface FamilyLink {
|
||||
familyLinkId: string;
|
||||
status: string;
|
||||
childName: string;
|
||||
/** یک سفارش به شکلِ فاکتور (GET /payment/invoices). مبلغها توماناند. */
|
||||
export interface Invoice {
|
||||
orderId: string;
|
||||
invoiceNumber: string;
|
||||
/** «canceled» و «refunded» را فقط ادمین میگذارد (لغو و بازپرداخت، از ۱۴۰۵-۰۷). */
|
||||
status: 'pending' | 'paid' | 'failed' | 'canceled' | 'refunded';
|
||||
provider: 'gateway' | 'bazaar' | string;
|
||||
planTitle: string;
|
||||
/** قیمتِ پایه پیش از تخفیف؛ null برای سفارشهای قدیمیتر از ثبتِ فاکتور. */
|
||||
listPrice: number | null;
|
||||
discountAmount: number | null;
|
||||
discountCode: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
paidAt: string | null;
|
||||
/** کدِ پیگیریِ بانکی/بازار؛ خالی برای پرداختنشده یا کدِ تخفیفِ ۱۰۰٪. */
|
||||
refId: string;
|
||||
}
|
||||
|
||||
export interface FamilyWeeklyReport {
|
||||
weekStart: string;
|
||||
xpEarned: number;
|
||||
lessonsCompleted: number;
|
||||
islandsCompleted: number;
|
||||
currentStreak: number;
|
||||
/** یک روز از گزارشِ هفتگی. `minutes` تخمینِ سرور است (از فاصلهی جوابها)، نه زمانِ باز بودنِ اپ. */
|
||||
export interface ReportDay {
|
||||
date: string;
|
||||
minutes: number;
|
||||
questions: number;
|
||||
correct: number;
|
||||
xp: number;
|
||||
lessons: number;
|
||||
islands: number;
|
||||
aiQuestions: number;
|
||||
}
|
||||
|
||||
export interface FamilySettings {
|
||||
dailyLimitMinutes: number;
|
||||
quietHoursStart: string;
|
||||
quietHoursEnd: string;
|
||||
export interface ReportTotals {
|
||||
minutes: number;
|
||||
activeDays: number;
|
||||
questions: number;
|
||||
correct: number;
|
||||
xp: number;
|
||||
/** یکتا: درس یا جزیرهی تکراری یک بار شمرده میشود. */
|
||||
lessons: number;
|
||||
islands: number;
|
||||
aiQuestions: number;
|
||||
}
|
||||
|
||||
/** GET /user/weekly-report: هفت روزِ اخیر و هفت روزِ قبلش، هر دو قدیمی به جدید. */
|
||||
export interface WeeklyReport {
|
||||
today: string;
|
||||
days: ReportDay[];
|
||||
previousDays: ReportDay[];
|
||||
thisWeek: ReportTotals;
|
||||
lastWeek: ReportTotals;
|
||||
streak: { current: number; longest: number };
|
||||
}
|
||||
|
||||
export interface AffiliateMe {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { AnimatePresence, MotionConfig, motion } from 'motion/react';
|
||||
import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
|
||||
import { AppShell } from '@/components/AppShell';
|
||||
import { ToastProvider } from '@/components/Toast';
|
||||
@@ -34,8 +34,9 @@ export function App() {
|
||||
/** پروفایل و پایهها را میگیرد و تصمیم میگیرد کاربر کجا برود. */
|
||||
const enter = useCallback(async () => {
|
||||
const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]);
|
||||
await trackReferral(profile);
|
||||
setFlow(profile.name && profile.gradeId ? { kind: 'main', profile, grades } : { kind: 'setup', profile, grades });
|
||||
await trackReferral();
|
||||
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades });
|
||||
else setFlow({ kind: 'setup', profile, grades });
|
||||
}, []);
|
||||
|
||||
const boot = useCallback(async () => {
|
||||
@@ -82,6 +83,8 @@ export function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
// reducedMotion="user": اگر «کاهشِ حرکت» در گوشی روشن باشد، motion جابهجاییها را حذف میکند.
|
||||
<MotionConfig reducedMotion="user">
|
||||
<AppShell>
|
||||
<ToastProvider>
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -121,23 +124,20 @@ export function App() {
|
||||
grades={flow.grades}
|
||||
payment={launchPayment}
|
||||
onSignedOut={() => setFlow({ kind: 'login' })}
|
||||
onRegistered={trackReferral}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</ToastProvider>
|
||||
</AppShell>
|
||||
</MotionConfig>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* اگر کاربر با لینکِ دعوت آمده بود و حالا با شماره وارد شده، معرفی را ثبت کن.
|
||||
* مهمان را سرور نمیپذیرد، پس کد تا ثبتِ شماره نگه داشته میشود.
|
||||
*/
|
||||
async function trackReferral(profile: UserProfile) {
|
||||
/** اگر کاربر با لینکِ دعوت آمده بود و حالا وارد شده، معرفی را ثبت کن. */
|
||||
async function trackReferral() {
|
||||
const code = store.get(KEYS.referralCode);
|
||||
if (!code || profile.isGuest) return;
|
||||
if (!code) return;
|
||||
try {
|
||||
await api.affiliate.track(code);
|
||||
store.remove(KEYS.referralCode);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { lazy, Suspense, useEffect, useState, type ComponentType } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { api, type Grade, type UserProfile } from '@/api';
|
||||
@@ -7,47 +7,67 @@ import { NavigatorProvider, useNav } from '@/nav/Navigator';
|
||||
import { isTab, type Route } from '@/nav/routes';
|
||||
import { BottomNav } from '@/components/BottomNav';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { PrimaryButton } from '@/components/ui';
|
||||
import { AskScreen } from '@/screens/AskScreen';
|
||||
import { CharactersScreen } from '@/screens/CharactersScreen';
|
||||
import { FamilyScreen } from '@/screens/FamilyScreen';
|
||||
import { LoadingState, PrimaryButton } from '@/components/ui';
|
||||
import { HomeScreen } from '@/screens/HomeScreen';
|
||||
import { InviteScreen } from '@/screens/InviteScreen';
|
||||
import { LeaderboardScreen } from '@/screens/LeaderboardScreen';
|
||||
import { LessonScreen } from '@/screens/LessonScreen';
|
||||
import { LoginScreen } from '@/screens/LoginScreen';
|
||||
import { MapScreen } from '@/screens/MapScreen';
|
||||
import { NotificationsScreen } from '@/screens/NotificationsScreen';
|
||||
import { ProfileScreen } from '@/screens/ProfileScreen';
|
||||
import { QuizScreen } from '@/screens/QuizScreen';
|
||||
import { StreakScreen } from '@/screens/StreakScreen';
|
||||
import { SubscriptionScreen } from '@/screens/SubscriptionScreen';
|
||||
import { SupportScreen } from '@/screens/SupportScreen';
|
||||
import { UnitScreen } from '@/screens/UnitScreen';
|
||||
import type { PaymentReturn } from './launchParams';
|
||||
|
||||
/**
|
||||
* خانه همراهِ خودِ اپ بار میشود؛ بقیهی صفحهها هر کدام فایلِ جدا دارند تا بارِ اول
|
||||
* سبک باشد (قبلاً کلِ اپ یک فایلِ ۵۰۵ کیلوبایتی بود، و دستیار با ماژولِ شکلها
|
||||
* بزرگترین تکهاش). بعد از بالا آمدنِ خانه همه در پسزمینه گرفته میشوند
|
||||
* (preloadScreens)، پس رفتن به صفحهی بعدی منتظرِ شبکه نمیماند.
|
||||
*/
|
||||
const loaders: (() => Promise<unknown>)[] = [];
|
||||
function screen<M extends Record<K, ComponentType<any>>, K extends keyof M>(load: () => Promise<M>, name: K) {
|
||||
loaders.push(load);
|
||||
return lazy(async () => ({ default: (await load())[name] }));
|
||||
}
|
||||
const AskScreen = screen(() => import('@/screens/AskScreen'), 'AskScreen');
|
||||
const CharactersScreen = screen(() => import('@/screens/CharactersScreen'), 'CharactersScreen');
|
||||
const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScreen');
|
||||
const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen');
|
||||
const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen');
|
||||
const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen');
|
||||
const MapScreen = screen(() => import('@/screens/MapScreen'), 'MapScreen');
|
||||
const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen');
|
||||
const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen');
|
||||
const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen');
|
||||
const QuizScreen = screen(() => import('@/screens/QuizScreen'), 'QuizScreen');
|
||||
const StreakScreen = screen(() => import('@/screens/StreakScreen'), 'StreakScreen');
|
||||
const SubscriptionScreen = screen(() => import('@/screens/SubscriptionScreen'), 'SubscriptionScreen');
|
||||
const SupportScreen = screen(() => import('@/screens/SupportScreen'), 'SupportScreen');
|
||||
const UnitScreen = screen(() => import('@/screens/UnitScreen'), 'UnitScreen');
|
||||
|
||||
let preloaded = false;
|
||||
function preloadScreens() {
|
||||
if (preloaded) return;
|
||||
preloaded = true;
|
||||
const run = () => loaders.forEach((load) => void load().catch(() => {}));
|
||||
if ('requestIdleCallback' in window) window.requestIdleCallback(run, { timeout: 2500 });
|
||||
else setTimeout(run, 1200);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
profile: UserProfile;
|
||||
grades: Grade[];
|
||||
payment: PaymentReturn | null;
|
||||
onSignedOut: () => void;
|
||||
onRegistered: (profile: UserProfile) => Promise<void>;
|
||||
}
|
||||
|
||||
/** اپِ اصلی بعد از ورود: تبها، صفحهها و دادهی مشترک. */
|
||||
export function MainApp({ profile, grades, payment, onSignedOut, onRegistered }: Props) {
|
||||
export function MainApp({ profile, grades, payment, onSignedOut }: Props) {
|
||||
useEffect(preloadScreens, []);
|
||||
return (
|
||||
<AppDataProvider initialProfile={profile} grades={grades}>
|
||||
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
|
||||
<Screens onSignedOut={onSignedOut} onRegistered={onRegistered} />
|
||||
<Screens onSignedOut={onSignedOut} />
|
||||
{payment && <PaymentResult payment={payment} />}
|
||||
</NavigatorProvider>
|
||||
</AppDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onRegistered'>) {
|
||||
function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
|
||||
const nav = useNav();
|
||||
const { route } = nav;
|
||||
const showNav = isTab(route.name) && nav.tab === route.name;
|
||||
@@ -64,7 +84,9 @@ function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onR
|
||||
exit={{ opacity: 0, x: 18 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
<RouteView route={route} onSignedOut={onSignedOut} onRegistered={onRegistered} />
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<RouteView route={route} onSignedOut={onSignedOut} />
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -73,7 +95,7 @@ function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onR
|
||||
);
|
||||
}
|
||||
|
||||
function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick<Props, 'onSignedOut' | 'onRegistered'>) {
|
||||
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
|
||||
switch (route.name) {
|
||||
case 'home': return <HomeScreen />;
|
||||
case 'map': return <MapScreen />;
|
||||
@@ -88,58 +110,52 @@ function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick
|
||||
case 'characters': return <CharactersScreen />;
|
||||
case 'streak': return <StreakScreen />;
|
||||
case 'support': return <SupportScreen />;
|
||||
case 'family': return <FamilyScreen />;
|
||||
case 'report': return <ReportScreen />;
|
||||
case 'invoices': return <InvoicesScreen />;
|
||||
case 'invite': return <InviteScreen />;
|
||||
case 'register': return <RegisterView onRegistered={onRegistered} />;
|
||||
}
|
||||
}
|
||||
|
||||
/** مهمانی که شماره ثبت میکند؛ همان کاربر میماند و فقط پروفایل تازه میشود. */
|
||||
function RegisterView({ onRegistered }: Pick<Props, 'onRegistered'>) {
|
||||
const nav = useNav();
|
||||
const { setProfile } = useAppData();
|
||||
const toast = useToast();
|
||||
return (
|
||||
<LoginScreen
|
||||
upgrade
|
||||
onCancel={nav.back}
|
||||
onDone={async () => {
|
||||
try {
|
||||
const profile = await api.profile.get();
|
||||
setProfile(profile);
|
||||
await onRegistered(profile);
|
||||
} catch {
|
||||
/* ورود انجام شده؛ پروفایل با بازگشت به اپ تازه میشود */
|
||||
}
|
||||
toast('شمارهت ثبت شد! پیشرفتت امنه.', 'success');
|
||||
nav.back();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** نتیجهی برگشت از درگاه؛ وضعیتِ سفارش از خودِ سرور هم دوباره خوانده میشود. */
|
||||
/**
|
||||
* نتیجهی برگشت از درگاه. فقط پاسخِ سرور تعیین میکند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر
|
||||
* لینکِ ساختگی میتواند «paid» بگذارد.
|
||||
*/
|
||||
function PaymentResult({ payment }: { payment: PaymentReturn }) {
|
||||
const { refreshSummary } = useAppData();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [paid, setPaid] = useState(payment.status === 'paid');
|
||||
const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking');
|
||||
|
||||
useEffect(() => {
|
||||
api.subscription
|
||||
.order(payment.orderId)
|
||||
.then((order) => setPaid(order.status === 'paid'))
|
||||
.catch(() => {});
|
||||
.then((order) => setState(order.status === 'paid' ? 'paid' : 'failed'))
|
||||
.catch((err: unknown) => {
|
||||
console.warn('payment result lookup failed', err);
|
||||
setState('unknown');
|
||||
});
|
||||
void refreshSummary();
|
||||
}, [payment.orderId, refreshSummary]);
|
||||
|
||||
if (state === 'checking') {
|
||||
return (
|
||||
<Sheet open={open} onClose={() => setOpen(false)}>
|
||||
<LoadingState text="دارم پرداخت رو بررسی میکنم…" />
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
const paid = state === 'paid';
|
||||
return (
|
||||
<Sheet open={open} onClose={() => setOpen(false)}>
|
||||
<div className="flex flex-col items-center gap-3 pb-2 text-center">
|
||||
{paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />}
|
||||
<h2 className="text-lg font-black text-ink">{paid ? 'پرداخت موفق بود! 🎉' : 'پرداخت انجام نشد'}</h2>
|
||||
<h2 className="text-lg font-black text-ink">
|
||||
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیتِ پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
|
||||
</h2>
|
||||
<p className="text-sm leading-7 text-muted">
|
||||
{paid
|
||||
? 'اشتراکت فعال شد؛ از الان قلبهات نامحدوده.'
|
||||
: state === 'unknown'
|
||||
? 'چند دقیقهی دیگه از «پرداختها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال میشه یا پول خودکار برمیگرده.'
|
||||
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمیگرده یا اشتراکت فعال میشه.'}
|
||||
</p>
|
||||
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
|
||||
|
||||
@@ -22,7 +22,8 @@ function readLaunchParams(): PaymentReturn | null {
|
||||
if (ref || orderId) {
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
return orderId ? { orderId, status: status ?? '' } : null;
|
||||
// فقط شمارهی سفارش: هر چیزِ دیگری (مثلاً «../../user/delete») از یک لینکِ ساختگی آمده است.
|
||||
return orderId && /^\d{1,12}$/.test(orderId) ? { orderId, status: status ?? '' } : null;
|
||||
}
|
||||
|
||||
/** یکبار، هنگامِ بار شدنِ اپ. */
|
||||
|
||||
@@ -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
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, Map, UserRound, type LucideIcon } from 'lucide-react';
|
||||
import type { TabName } from '@/nav/routes';
|
||||
|
||||
const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
|
||||
{ id: 'home', icon: Home, label: 'خانه' },
|
||||
{ id: 'map', icon: Map, label: 'نقشه' },
|
||||
{ id: 'ask', icon: Brain, label: 'دستیار' },
|
||||
{ id: 'subscription', icon: Crown, label: 'اشتراک' },
|
||||
{ id: 'subscription', icon: ShoppingBag, label: 'بستهها' },
|
||||
{ id: 'profile', icon: UserRound, label: 'من' },
|
||||
];
|
||||
|
||||
@@ -20,12 +20,12 @@ export function BottomNav({ active, onChange }: { active: TabName | null; onChan
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
aria-current={on ? 'page' : undefined}
|
||||
className={`flex flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-colors ${on ? 'text-coral' : 'text-muted'}`}
|
||||
className={`flex min-w-14 flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-colors ${on ? 'text-coral' : 'text-muted'}`}
|
||||
>
|
||||
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
|
||||
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
|
||||
</span>
|
||||
<span className="text-[10px] font-bold">{label}</span>
|
||||
<span className="text-[11px] font-bold">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
108
src/components/IslandPath.tsx
Normal file
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>
|
||||
);
|
||||
});
|
||||
109
src/components/IslandSheet.tsx
Normal file
109
src/components/IslandSheet.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { BookOpen, CheckCircle2, ChevronDown, Circle, Lock, Play, RotateCw } from 'lucide-react';
|
||||
import { api, type HomeNode } from '@/api';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { ErrorState, PrimaryButton } from '@/components/ui';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
|
||||
/**
|
||||
* برگهی جزیره. کارِ اصلی یک دکمه است: «شروعِ آزمونِ جزیره». فهرستِ درسها
|
||||
* (معرفیِ هر نوع مسئله) زیرِ آن و بسته است، برای بچهای که اول میخواهد بخواند.
|
||||
*/
|
||||
export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
|
||||
const nav = useNav();
|
||||
const [showLessons, setShowLessons] = useState(false);
|
||||
const lessons = useAsync(
|
||||
() => (node && showLessons ? api.nodeLessons(node.id) : Promise.resolve(null)),
|
||||
[node?.id, showLessons],
|
||||
);
|
||||
const locked = node?.status === 'locked';
|
||||
const done = node?.status === 'completed';
|
||||
|
||||
const close = () => {
|
||||
setShowLessons(false);
|
||||
onClose();
|
||||
};
|
||||
const start = () => {
|
||||
if (!node) return;
|
||||
close();
|
||||
nav.push({ name: 'quiz', nodeId: node.id });
|
||||
};
|
||||
const openLesson = (lessonId: number) => {
|
||||
if (!node) return;
|
||||
close();
|
||||
nav.push({ name: 'lesson', lessonId, nodeId: node.id });
|
||||
};
|
||||
|
||||
// فقط درسهایی که سوال دارند در lessonIds هستند.
|
||||
const playable = new Set(node?.lessonIds ?? []);
|
||||
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
|
||||
|
||||
return (
|
||||
<Sheet open={node !== null} onClose={close} title={node ? <MathText text={node.title} /> : undefined}>
|
||||
{locked ? (
|
||||
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
|
||||
<Lock size={24} className="text-muted" />
|
||||
</span>
|
||||
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیرهی قبلی رو تموم کن تا باز بشه.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-sm leading-7 text-body">
|
||||
{done
|
||||
? 'این جزیره رو تموم کردی! میتونی دوباره تمرینش کنی (امتیازِ تازه نداره).'
|
||||
: 'چند سوالِ کوتاه از همین جزیره. هر جوابی بدی، آخرش جزیرهی بعدی باز میشه.'}
|
||||
</p>
|
||||
<PrimaryButton color={color} onClick={start}>
|
||||
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروعِ آزمونِ جزیره</>}
|
||||
</PrimaryButton>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLessons((s) => !s)}
|
||||
className="mt-3 flex w-full items-center justify-center gap-1.5 py-2 text-sm font-bold text-muted"
|
||||
aria-expanded={showLessons}
|
||||
>
|
||||
<BookOpen size={15} /> اول درسهاش رو بخونم
|
||||
<ChevronDown size={15} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showLessons && (
|
||||
lessons.loading ? (
|
||||
<div className="py-4 text-center text-sm text-muted">دارم درسها رو میارم…</div>
|
||||
) : lessons.error ? (
|
||||
<ErrorState error={lessons.error} onRetry={lessons.reload} />
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 mt-1 text-xs text-muted">
|
||||
{toFa(list.filter((l) => l.done).length)} از {toFa(list.length)} درس خونده شده
|
||||
</p>
|
||||
<ul className="flex max-h-60 flex-col gap-1.5 overflow-y-auto">
|
||||
{list.map((lesson) => (
|
||||
<li key={lesson.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLesson(Number(lesson.id))}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-line bg-white px-3 py-2.5 text-right text-sm"
|
||||
>
|
||||
{lesson.done ? (
|
||||
<CheckCircle2 size={18} className="flex-shrink-0 text-mint" />
|
||||
) : (
|
||||
<Circle size={18} className="flex-shrink-0 text-faint" />
|
||||
)}
|
||||
<span className={`flex-1 ${lesson.done ? 'text-muted' : 'text-ink'}`}><MathText text={lesson.title} /></span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
213
src/components/NoHeartsScreen.tsx
Normal file
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 { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { shade } from '@/lib/color';
|
||||
|
||||
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -28,12 +29,12 @@ export function ScreenHeader({
|
||||
return (
|
||||
<header
|
||||
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
style={{ backgroundColor: color, backgroundImage: `linear-gradient(160deg, ${shade(color, 0.12)} 0%, ${color} 55%, ${shade(color, -0.06)} 100%)` }}
|
||||
>
|
||||
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
|
||||
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
|
||||
<div className="relative z-10 pt-6">
|
||||
{back && (
|
||||
{back && (typeof back === 'function' || nav.canGoBack) && (
|
||||
<IconButton label="بازگشت" onClick={onBack} className="mb-3 bg-white/15">
|
||||
<ChevronRight size={18} strokeWidth={2.5} />
|
||||
</IconButton>
|
||||
@@ -85,8 +86,8 @@ export function PrimaryButton({
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || loading}
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-transform active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100 ${className}`}
|
||||
style={{ backgroundColor: color, boxShadow: `0 10px 24px -8px ${color}88`, ...style }}
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-[transform,box-shadow] active:translate-y-[3px] active:shadow-none disabled:opacity-50 disabled:active:translate-y-0 ${className}`}
|
||||
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.22)}`, ...style }}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? <Spinner /> : children}
|
||||
@@ -98,7 +99,7 @@ export function SecondaryButton({ className = '', children, ...rest }: ButtonHTM
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:scale-[0.98] disabled:opacity-50 ${className}`}
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-b-4 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:translate-y-[2px] active:border-b-2 disabled:opacity-50 ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
@@ -129,8 +130,19 @@ function StateBox({ char, mood, text, children }: { char: SeyarChar; mood: Seyar
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'sigma' as SeyarChar }) {
|
||||
return <StateBox char={char} mood="idle" text={text} />;
|
||||
/**
|
||||
* لودر فقط بعد از `delayMs` دیده میشود. پاسخِ API معمولاً زیرِ ۳۰۰ms میرسد و نشان دادنِ لودر برای
|
||||
* همان چند فریم، پرشِ ریزِ «لودر ← محتوا» میساخت؛ حالا تا آن لحظه جای خالی میماند. اگر پاسخ
|
||||
* کند باشد کاربر باز هم لودر را میبیند. در مرورگر با شبکهی کند آزموده نشده.
|
||||
*/
|
||||
export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'sigma' as SeyarChar, delayMs = 300 }) {
|
||||
const [show, setShow] = useState(delayMs <= 0);
|
||||
useEffect(() => {
|
||||
if (delayMs <= 0) return;
|
||||
const t = setTimeout(() => setShow(true), delayMs);
|
||||
return () => clearTimeout(t);
|
||||
}, [delayMs]);
|
||||
return show ? <StateBox char={char} mood="idle" text={text} /> : null;
|
||||
}
|
||||
|
||||
export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?: () => void; text?: string }) {
|
||||
@@ -153,6 +165,14 @@ export function EmptyState({ text, char = 'pi' as SeyarChar }: { text: string; c
|
||||
return <StateBox char={char} mood="idle" text={text} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* جداکنندهی میانِ دو تکهی متن. «·» کنارِ صفرِ فارسی (که خودش یک نقطه است)
|
||||
* خوانده نمیشد: «فصلِ ۱ · ۰ از ۴» شبیهِ «۱۰۰» دیده میشد.
|
||||
*/
|
||||
export function Sep() {
|
||||
return <span aria-hidden className="mx-1.5 inline-block h-2.5 w-px bg-current align-middle opacity-40" />;
|
||||
}
|
||||
|
||||
export function InlineError({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-xs leading-6 text-red-600">
|
||||
|
||||
41
src/dev/DiagramGallery.tsx
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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)),
|
||||
};
|
||||
12
src/lib/color.ts
Normal file
12
src/lib/color.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/** رنگِ hex را روشن (amount > 0) یا تیره (amount < 0) میکند؛ برای سایهی دکمههای سهبعدی. */
|
||||
export function shade(hex: string, amount: number): string {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) return hex;
|
||||
const n = parseInt(m[1], 16);
|
||||
const channel = (c: number) => {
|
||||
const v = amount < 0 ? c * (1 + amount) : c + (255 - c) * amount;
|
||||
return Math.max(0, Math.min(255, Math.round(v)));
|
||||
};
|
||||
const r = channel((n >> 16) & 255), g = channel((n >> 8) & 255), b = channel(n & 255);
|
||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
@@ -43,12 +43,29 @@ function tone(frequency: number, start: number, duration: number, volume = 0.05)
|
||||
osc.stop(t + duration + 0.02);
|
||||
}
|
||||
|
||||
/**
|
||||
* لرزشِ کوتاه کنارِ صدا، با همان کلیدِ «صدا» خاموش میشود. فقط اندروید/کروم دارد؛
|
||||
* Safariِ آیفون `vibrate` ندارد و اینجا هیچ کاری نمیکند.
|
||||
*/
|
||||
function buzz(pattern: number | number[]) {
|
||||
if (!soundEnabled() || typeof navigator.vibrate !== 'function') return;
|
||||
try {
|
||||
navigator.vibrate(pattern);
|
||||
} catch {
|
||||
/* بعضی WebViewها بیاجازه throw میکنند؛ لرزش فقط تزئینی است */
|
||||
}
|
||||
}
|
||||
|
||||
export const sounds = {
|
||||
tick: () => tone(720, 0, 0.045, 0.04),
|
||||
correct: () => {
|
||||
tone(660, 0, 0.12);
|
||||
tone(990, 0.1, 0.18);
|
||||
buzz(30);
|
||||
},
|
||||
wrong: () => {
|
||||
tone(220, 0, 0.25, 0.06);
|
||||
buzz([40, 60, 40]);
|
||||
},
|
||||
wrong: () => tone(220, 0, 0.25, 0.06),
|
||||
finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)),
|
||||
};
|
||||
|
||||
@@ -24,22 +24,8 @@ export const store = {
|
||||
};
|
||||
|
||||
export const KEYS = {
|
||||
deviceId: 'deviceId',
|
||||
onboarded: 'tadrisino.onboarded',
|
||||
conversationId: 'tadrisino.conversationId',
|
||||
ticketIds: 'tadrisino.ticketIds',
|
||||
sound: 'tadrisino.sound',
|
||||
referralCode: 'tadrisino.referralCode',
|
||||
} as const;
|
||||
|
||||
/** شناسهی ثابتِ این مرورگر برای ورودِ مهمان. */
|
||||
export function getDeviceId(): string {
|
||||
let id = store.get(KEYS.deviceId);
|
||||
if (!id) {
|
||||
id = globalThis.crypto && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
store.set(KEYS.deviceId, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
12
src/main.tsx
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 { App } from './app/App';
|
||||
import './styles/index.css';
|
||||
|
||||
// گالریِ شکلها فقط در حالتِ توسعه؛ در build اصلاً وارد نمیشود (import.meta.env.DEV ثابت است).
|
||||
const DiagramGallery = import.meta.env.DEV
|
||||
? lazy(() => import('./dev/DiagramGallery').then((m) => ({ default: m.DiagramGallery })))
|
||||
: null;
|
||||
const showGallery = DiagramGallery && new URLSearchParams(window.location.search).has('gallery');
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
{showGallery && DiagramGallery ? (
|
||||
<Suspense fallback={null}><DiagramGallery /></Suspense>
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* سهیار: سیگما (Σ)، دلتا (Δ) و پی (π) — ماسکوتهای اپ.
|
||||
* همراههای اپ: سیگما (Σ)، دلتا (Δ)، پی (π) و تدی. «سهیار» نامِ قدیمیِ سهتای اول است؛ در متنِ
|
||||
* کاربر تعدادِ همراهها نوشته نمیشود، چون کاتالوگ از ادمین میآید و بزرگ میشود.
|
||||
*
|
||||
* هر سه از دلِ یک دفترچهی بینهایت آمدهاند و هر کدام از جنسِ یک نمادِ ریاضیاند:
|
||||
* سیگما → راهنمای درس و آزمون دلتا → همتیمیِ تمرینِ سریع پی → دستیار و همراهِ دلگرمی
|
||||
@@ -8,13 +9,14 @@
|
||||
* ورودیها همه از همین فایلاند و هیچ متنِ کاربر واردِ SVG نمیشود.
|
||||
*/
|
||||
|
||||
export type SeyarChar = 'sigma' | 'delta' | 'pi';
|
||||
export type SeyarChar = 'sigma' | 'delta' | 'pi' | 'tedi';
|
||||
export type SeyarMood = 'idle' | 'happy' | 'excited' | 'sad' | 'angry' | 'depressed';
|
||||
|
||||
const PALETTE: Record<SeyarChar, Record<string, string>> = {
|
||||
sigma: { body: "#4C5FD5", inner: "#7A8CFF", belly: "#EAF0FF", ink: "#232355", cheek: "#FFB2C0", mouth: "#FF6B6B", tear: "#8EC9FF", badge: "#FFD23F" },
|
||||
delta: { body: "#FF7A3D", inner: "#FFB25E", belly: "#FFE7C7", ink: "#6E2A12", cheek: "#FF8FA3", mouth: "#D6402A", tear: "#8EC9FF", spike: "#FFEB3B" },
|
||||
pi: { body: "#9B7BFF", inner: "#C6B3FF", belly: "#F1ECFF", ink: "#3B2B66", cheek: "#FFC2D1", mouth: "#FF6F91", tear: "#5EC8E8" },
|
||||
tedi: { body: "#FFC63D", inner: "#FF9E42", belly: "#FFE9B0", ink: "#4A3B32", cheek: "#FFAEB0", mouth: "#E7573F", tear: "#7EC8E3" },
|
||||
};
|
||||
const Y_SHIFT: Record<SeyarMood, number> = { idle: 0, happy: 0, excited: -4, sad: 3, angry: 0, depressed: 9 };
|
||||
const ARM_DELTA: Record<SeyarMood, number> = { idle: 0, happy: 2, excited: 46, sad: -4, angry: -8, depressed: -10 };
|
||||
@@ -122,19 +124,81 @@ function seyarPiSVG(mood: SeyarMood): string {
|
||||
</g></svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* تدی — شکلکِ زردِ پیشنمایشِ موبایل (mascot-pack، ریپوی بکاند، پوشهی
|
||||
* mobile-preview). بدن، گوشها، پاها و دستها عیناً از همان پک است؛ چهرهی
|
||||
* «عصبانی» و «ناامید» را پک نداشت و با همان زبانِ طراحیِ سهیار اضافه شد.
|
||||
*/
|
||||
function tediSVG(mood: SeyarMood): string {
|
||||
const C = PALETTE.tedi;
|
||||
const S = `stroke="${C.ink}" stroke-width="5"`;
|
||||
const dy = Y_SHIFT[mood];
|
||||
const restArmL = `<rect x="20" y="112" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(14 32 135)"/>`;
|
||||
const restArmR = `<rect x="156" y="112" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(-14 168 135)"/>`;
|
||||
const upArmL = `<rect x="14" y="70" width="24" height="50" rx="12" fill="${C.body}" ${S} transform="rotate(28 26 95)"/>`;
|
||||
const upArmR = `<rect x="162" y="70" width="24" height="50" rx="12" fill="${C.body}" ${S} transform="rotate(-28 174 95)"/>`;
|
||||
const droopArmL = `<rect x="24" y="118" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(4 36 141)"/>`;
|
||||
const droopArmR = `<rect x="152" y="118" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(-4 164 141)"/>`;
|
||||
|
||||
let arms = restArmL + restArmR, hands = '', eyes = '', mouth = '', cheeks = '', extra = '';
|
||||
if (mood === 'happy' || mood === 'excited') {
|
||||
eyes = `<path d="M66 100 q10 -13 20 0" fill="none" ${S} stroke-linecap="round"/><path d="M114 100 q10 -13 20 0" fill="none" ${S} stroke-linecap="round"/>`;
|
||||
mouth = `<path d="M78 122 q22 34 44 0 q-22 12 -44 0z" fill="${C.mouth}" ${S} stroke-linejoin="round"/>`;
|
||||
cheeks = `<ellipse cx="64" cy="122" rx="12" ry="8" fill="${C.cheek}"/><ellipse cx="136" cy="122" rx="12" ry="8" fill="${C.cheek}"/>`;
|
||||
if (mood === 'excited') {
|
||||
arms = upArmL + upArmR;
|
||||
hands = `<circle cx="18" cy="66" r="12" fill="${C.body}" ${S}/><circle cx="182" cy="66" r="12" fill="${C.body}" ${S}/>`;
|
||||
extra = `<g stroke="${C.inner}" stroke-width="5" stroke-linecap="round"><path d="M40 30 l-8 -12"/><path d="M160 30 l8 -12"/><path d="M100 8 v-12"/></g>`;
|
||||
}
|
||||
} else if (mood === 'sad') {
|
||||
eyes = `<circle cx="78" cy="104" r="8" fill="${C.ink}"/><circle cx="122" cy="104" r="8" fill="${C.ink}"/>`
|
||||
+ `<path d="M68 94 q10 -6 20 -2" fill="none" ${S} stroke-linecap="round"/><path d="M112 92 q10 -4 20 2" fill="none" ${S} stroke-linecap="round"/>`;
|
||||
mouth = `<path d="M82 142 q18 -18 36 0" fill="none" ${S} stroke-linecap="round"/>`;
|
||||
extra = `<path d="M78 112 q-4 12 0 18 q4 -6 0 -18z" fill="${C.tear}"/>`;
|
||||
} else if (mood === 'angry') {
|
||||
eyes = `<path d="M68 98 l16 5 l-5 9 z" fill="${C.ink}"/><path d="M132 98 l-16 5 l5 9 z" fill="${C.ink}"/>`
|
||||
+ `<path d="M64 88 l22 7" ${S} stroke-linecap="round"/><path d="M136 88 l-22 7" ${S} stroke-linecap="round"/>`;
|
||||
mouth = `<path d="M82 136 l6 -6 l6 6 l6 -6 l6 6 l6 -6 l6 6" fill="none" ${S} stroke-linecap="round" stroke-linejoin="round"/>`;
|
||||
cheeks = `<ellipse cx="62" cy="124" rx="10" ry="6" fill="${C.mouth}" opacity=".5"/><ellipse cx="138" cy="124" rx="10" ry="6" fill="${C.mouth}" opacity=".5"/>`;
|
||||
extra = `<g stroke="${C.ink}" stroke-width="4" stroke-linecap="round" opacity=".75"><path d="M30 20 q4 -8 -2 -14"/><path d="M170 20 q-4 -8 2 -14"/></g>`;
|
||||
} else if (mood === 'depressed') {
|
||||
arms = droopArmL + droopArmR;
|
||||
eyes = `<path d="M68 104 q10 5 20 0" fill="none" stroke="${C.ink}" stroke-width="6" stroke-linecap="round"/><path d="M112 104 q10 5 20 0" fill="none" stroke="${C.ink}" stroke-width="6" stroke-linecap="round"/>`;
|
||||
mouth = `<path d="M88 136 q12 6 24 1" fill="none" ${S} stroke-linecap="round"/>`;
|
||||
extra = `<g><ellipse cx="100" cy="-6" rx="26" ry="10" fill="#B9C2CC" opacity=".9"/><ellipse cx="84" cy="0" rx="16" ry="8" fill="#B9C2CC" opacity=".9"/><ellipse cx="118" cy="0" rx="15" ry="8" fill="#B9C2CC" opacity=".9"/>`
|
||||
+ `<g stroke="#8FA0B3" stroke-width="3.5" stroke-linecap="round" opacity=".8"><line x1="86" y1="10" x2="82" y2="20"/><line x1="100" y1="12" x2="96" y2="24"/><line x1="114" y1="10" x2="110" y2="22"/></g></g>`;
|
||||
} else {
|
||||
eyes = `<circle cx="78" cy="102" r="9" fill="${C.ink}"/><circle cx="122" cy="102" r="9" fill="${C.ink}"/>`
|
||||
+ `<circle cx="81" cy="99" r="3" fill="#fff"/><circle cx="125" cy="99" r="3" fill="#fff"/>`;
|
||||
mouth = `<path d="M84 128 q16 18 32 0" fill="none" ${S} stroke-linecap="round"/>`;
|
||||
}
|
||||
|
||||
return `<svg viewBox="-14 -34 228 250" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 ${dy})">
|
||||
${extra}
|
||||
<ellipse cx="74" cy="185" rx="20" ry="13" fill="${C.inner}" ${S}/><ellipse cx="126" cy="185" rx="20" ry="13" fill="${C.inner}" ${S}/>
|
||||
${arms}
|
||||
<circle cx="56" cy="46" r="24" fill="${C.body}" ${S}/><circle cx="144" cy="46" r="24" fill="${C.body}" ${S}/>
|
||||
<circle cx="56" cy="46" r="12" fill="${C.inner}"/><circle cx="144" cy="46" r="12" fill="${C.inner}"/>
|
||||
<ellipse cx="100" cy="116" rx="72" ry="70" fill="${C.body}" ${S}/>
|
||||
<ellipse cx="100" cy="133" rx="44" ry="45" fill="${C.belly}"/>
|
||||
${hands}${cheeks}<g>${eyes}</g>${mouth}
|
||||
</g></svg>`;
|
||||
}
|
||||
|
||||
export const SEYAR_SVG: Record<SeyarChar, (mood: SeyarMood) => string> = {
|
||||
sigma: seyarSigmaSVG, delta: seyarDeltaSVG, pi: seyarPiSVG,
|
||||
sigma: seyarSigmaSVG, delta: seyarDeltaSVG, pi: seyarPiSVG, tedi: tediSVG,
|
||||
};
|
||||
export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = {
|
||||
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
|
||||
delta: { name: "دلتا", symbol: "Δ", role: "همتیمیِ تمرینهای سریع" },
|
||||
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
|
||||
tedi: { name: "تدی", symbol: "★", role: "رفیقِ همیشگیِ تدریسینو" },
|
||||
};
|
||||
|
||||
/** slugِ کاراکترِ سرور (sigma/delta/pi) را به یکی از سهیار نگاشت میکند. */
|
||||
/** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراهها نگاشت میکند. */
|
||||
export function seyarFromSlug(slug: string | null | undefined): SeyarChar | null {
|
||||
const s = (slug ?? '').toLowerCase();
|
||||
if (s === 'sigma' || s === 'delta' || s === 'pi') return s;
|
||||
if (s === 'sigma' || s === 'delta' || s === 'pi' || s === 'tedi') return s;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,18 @@ export type Route =
|
||||
| { name: 'profile' }
|
||||
| { name: 'unit'; unitId: string }
|
||||
| { name: 'lesson'; lessonId: number; nodeId?: string }
|
||||
| { name: 'quiz'; lessonId: number; nodeId?: string }
|
||||
/** بدونِ lessonId یعنی آزمونِ کلِ جزیره (nodeId لازم است). */
|
||||
| { name: 'quiz'; lessonId?: number; nodeId?: string }
|
||||
| { name: 'notifications' }
|
||||
| { name: 'leaderboard' }
|
||||
| { name: 'characters' }
|
||||
| { name: 'streak' }
|
||||
| { name: 'support' }
|
||||
| { name: 'family' }
|
||||
| { name: 'invite' }
|
||||
| { name: 'register' };
|
||||
/** «گزارشِ والدین»: والد پیشرفتِ هفته را در حسابِ خودِ دانشآموز میبیند. */
|
||||
| { name: 'report' }
|
||||
/** پرداختها و فاکتورهای خودِ کاربر. */
|
||||
| { name: 'invoices' }
|
||||
| { name: 'invite' };
|
||||
|
||||
export type TabName = 'home' | 'map' | 'ask' | 'subscription' | 'profile';
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { BookOpen, MessageSquarePlus, RotateCw, Send, ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { api, ApiError } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { Screen, ScreenHeader } from '@/components/ui';
|
||||
import { Diagram } from '@/diagram';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
@@ -18,7 +21,11 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
const chat = useAskState();
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [quota, setQuota] = useState<number | null>(null);
|
||||
// «X سوالِ رایگانِ امروز + Y سوالِ خریدهشده»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
|
||||
const [quota, setQuota] = useState<{ free: number; credits: number } | null>(null);
|
||||
useEffect(() => {
|
||||
api.ai.quota().then((q) => setQuota({ free: q.remainingQuota, credits: q.credits })).catch(() => {});
|
||||
}, []);
|
||||
const listEnd = useRef<HTMLDivElement>(null);
|
||||
const autoSent = useRef(false);
|
||||
|
||||
@@ -39,7 +46,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
askStore.setHistory(
|
||||
r.messages
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId })),
|
||||
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId, diagram: m.diagram ?? null })),
|
||||
),
|
||||
)
|
||||
.catch(() => askStore.reset(null));
|
||||
@@ -85,14 +92,18 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
messageId: done.messageId,
|
||||
usedChunks: done.usedChunks,
|
||||
followUps: done.suggestedFollowUps,
|
||||
diagram: done.diagram ?? null,
|
||||
});
|
||||
askStore.setConversation(done.conversationId);
|
||||
setQuota(done.remainingQuota);
|
||||
setQuota((q) => ({ free: done.remainingQuota, credits: done.credits ?? q?.credits ?? 0 }));
|
||||
} catch (err) {
|
||||
// سقفِ روزانه (نه throttleِ چندثانیهای): «دوباره» تا فردا بیفایده است.
|
||||
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
|
||||
askStore.update(replyId, {
|
||||
text: friendlyError(err, 'جوابی نرسید'),
|
||||
failedQuestion: question,
|
||||
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
|
||||
});
|
||||
if (quotaReached) setQuota({ free: 0, credits: 0 });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -119,16 +130,19 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
back={lessonId !== undefined || prompt !== undefined}
|
||||
mascot={{ char: 'pi', mood: busy ? 'excited' : 'idle', size: 60 }}
|
||||
>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{quota !== null && <QuotaPills free={quota.free} credits={quota.credits} />}
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => askStore.reset(null)}
|
||||
disabled={busy}
|
||||
className="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} /> گفتگوی تازه
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</ScreenHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
|
||||
@@ -170,6 +184,11 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
// جهتِ هر خط از اولین حرفش: «-3 + 5» هنگامِ تایپ چپبهراست میماند. خطی که با
|
||||
// فارسی شروع شود راستبهچپ است و ریاضیِ وسطش در کادرِ تایپ ممکن است جابهجا دیده شود؛
|
||||
// در textarea نمیشود تکهای را قفل کرد. پیامِ فرستادهشده از MathText رد میشود.
|
||||
dir={input ? 'auto' : 'rtl'}
|
||||
style={{ unicodeBidi: 'plaintext', textAlign: 'right' }}
|
||||
placeholder="سوالت رو بنویس…"
|
||||
className="max-h-28 min-h-10 flex-1 resize-none rounded-3xl bg-soft px-4 py-2.5 text-sm leading-6 text-ink outline-none placeholder:text-faint"
|
||||
/>
|
||||
@@ -182,13 +201,12 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
<Send size={16} className="-scale-x-100" />
|
||||
</button>
|
||||
</div>
|
||||
{quota !== null && <p className="mt-1.5 text-center text-[10px] text-muted">امروز {toFa(quota)} سوالِ دیگه میتونی بپرسی</p>}
|
||||
</form>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function Bubble({ role, children }: { role: 'user' | 'assistant'; children: React.ReactNode }) {
|
||||
function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
|
||||
const mine = role === 'user';
|
||||
return (
|
||||
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||
@@ -203,6 +221,43 @@ function Bubble({ role, children }: { role: 'user' | 'assistant'; children: Reac
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
{extra}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* منتظرِ جوابِ دستیار. سرویسِ مدل کلِ جواب را نگه میدارد و یکجا میفرستد؛ جوابِ
|
||||
* کامل با شکل در ۲۰۲۶-۰۹-۲۹ حدودِ ۳۰ ثانیه طول کشید. پیامِ ثابت در آن مدت شبیهِ
|
||||
* گیر کردن بود، پس متن با زمان جلو میرود. زمانها تقریبیاند و به مرحلهی واقعیِ
|
||||
* سرور وصل نیستند.
|
||||
*/
|
||||
const THINKING_STEPS: [number, string][] = [
|
||||
[0, 'پی داره تو کتابت میگرده'],
|
||||
[4, 'پی داره حلش میکنه'],
|
||||
[15, 'داره قدمها رو مرتب مینویسه'],
|
||||
[28, 'جوابِ کامل یه کم طول میکشه، الان میرسه'],
|
||||
];
|
||||
|
||||
function Thinking() {
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
useEffect(() => {
|
||||
const started = Date.now();
|
||||
const timer = setInterval(() => setSeconds((Date.now() - started) / 1000), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1];
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
|
||||
<SeyarMascot char="pi" mood="idle" size={32} />
|
||||
</span>
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted" aria-live="polite">
|
||||
{text}
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span key={i} className="h-1.5 w-1.5 rounded-full bg-violet/60" animate={{ y: [0, -4, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -218,24 +273,11 @@ function Message({
|
||||
onFollowUp: (q: string) => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const nav = useNav();
|
||||
|
||||
if (message.role === 'user') return <Bubble role="user">{message.text}</Bubble>;
|
||||
if (message.role === 'user') return <Bubble role="user"><MathText text={message.text} /></Bubble>;
|
||||
|
||||
if (!message.text) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
|
||||
<SeyarMascot char="pi" mood="idle" size={32} />
|
||||
</span>
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted">
|
||||
پی داره فکر میکنه
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span key={i} className="h-1.5 w-1.5 rounded-full bg-violet/60" animate={{ y: [0, -4, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!message.text) return <Thinking />;
|
||||
|
||||
async function rate(helpful: boolean) {
|
||||
if (!message.messageId || message.feedback) return;
|
||||
@@ -251,9 +293,15 @@ function Message({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Bubble role="assistant">{message.text}</Bubble>
|
||||
<Bubble role="assistant" extra={message.diagram ? <div className="whitespace-normal"><Diagram spec={message.diagram} /></div> : undefined}>
|
||||
<MathText text={message.text} />
|
||||
</Bubble>
|
||||
<div className="flex flex-wrap items-center gap-2 pr-10">
|
||||
{message.failedQuestion ? (
|
||||
{message.quotaReached ? (
|
||||
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-violet px-3 py-1 text-xs font-bold text-white">
|
||||
خریدِ بستهی سوال
|
||||
</button>
|
||||
) : message.failedQuestion ? (
|
||||
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">
|
||||
<RotateCw size={12} /> دوباره امتحان کن
|
||||
</button>
|
||||
@@ -289,3 +337,25 @@ function Message({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* شمارنده در سربرگِ دستیار: سوالهای رایگانِ باقیِ امروز و سوالهای خریدهشده، هر کدام جدا و
|
||||
* درشت، تا بچه پیش از پرسیدن بداند چقدر دارد. زدن روی دومی صفحهی بستهها را باز میکند.
|
||||
*/
|
||||
function QuotaPills({ free, credits }: { free: number; credits: number }) {
|
||||
const nav = useNav();
|
||||
return (
|
||||
<>
|
||||
<span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}>
|
||||
{free > 0 ? <>{toFa(free)} سوالِ رایگانِ امروز</> : 'رایگانِ امروز تموم شد'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nav.switchTab('subscription')}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-black ${credits > 0 ? 'bg-white text-violet' : 'bg-white/15 text-white'}`}
|
||||
>
|
||||
{credits > 0 ? <>{toFa(credits)} سوالِ خریدهشده</> : '+ خریدِ سوال'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useAsync } from '@/lib/useAsync';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { SEYAR_INFO, seyarFromSlug } from '@/mascots/seyar';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
|
||||
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader, Sep } from '@/components/ui';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
@@ -39,7 +39,7 @@ export function CharactersScreen() {
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title="همراهت رو انتخاب کن"
|
||||
subtitle={`امتیازِ تو: ${toFa(summary?.xp ?? 0)} · با امتیازِ بیشتر همراههای بیشتری باز میشن`}
|
||||
subtitle={<>امتیازِ تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیازِ بیشتر همراههای بیشتری باز میشن</>}
|
||||
color="#9B7BFF"
|
||||
back
|
||||
/>
|
||||
|
||||
@@ -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,140 @@
|
||||
import { Bell, Brain, ChevronLeft, Coins, Flame, Map, Play, Star, Trophy } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bell, ChevronLeft, Coins, Flame, Star } from 'lucide-react';
|
||||
import type { HomeNode } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { findContinue, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { Card, ErrorState, ProgressBar, Screen } from '@/components/ui';
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { IslandPath } from '@/components/IslandPath';
|
||||
import { IslandSheet } from '@/components/IslandSheet';
|
||||
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
|
||||
import { shade } from '@/lib/color';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
|
||||
/**
|
||||
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
||||
* یک دکمهی «شروع» روی سرش. قبلاً خانه داشبوردی از شش کارت بود (ادامه، نقشه،
|
||||
* دستیار، فصلها، چالش) و بچه نمیدانست کدام را بزند؛ حالا یک کارِ واضح دارد.
|
||||
*/
|
||||
export function HomeScreen() {
|
||||
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
||||
const nav = useNav();
|
||||
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
|
||||
const currentRef = useRef<HTMLDivElement>(null);
|
||||
const scrolled = useRef(false);
|
||||
const cont = findContinue(home);
|
||||
const contLesson = cont ? nextLessonOf(cont.node) : null;
|
||||
const contColor = cont ? unitColor(cont.unitIndex) : '#ED6956';
|
||||
const contProgress = cont ? unitProgress(cont.unit) : null;
|
||||
|
||||
const openLesson = (quiz = false) => {
|
||||
if (!cont || contLesson == null) return;
|
||||
nav.push({ name: quiz ? 'quiz' : 'lesson', lessonId: contLesson, nodeId: cont.node.id });
|
||||
};
|
||||
// بارِ اول تا جزیرهی فعلی اسکرول کن، نه هر بار که نقشه تازه میشود.
|
||||
useEffect(() => {
|
||||
if (!home || scrolled.current) return;
|
||||
scrolled.current = true;
|
||||
const frame = requestAnimationFrame(() => currentRef.current?.scrollIntoView({ block: 'center' }));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [home]);
|
||||
|
||||
const name = profile.name || 'دوستِ من';
|
||||
const bubble = !home
|
||||
? 'دارم مسیرت رو میارم…'
|
||||
: !home.units.length
|
||||
? 'هنوز درسی برای این پایه ثبت نشده.'
|
||||
: cont
|
||||
? <>سلام {name}! 👋 نوبتِ جزیرهی <b>«<MathText text={cont.node.title} />»</b>ـه. دکمهی <b>شروع</b> رو بزن.</>
|
||||
: <>آفرین {name}! همهی جزیرههای این پایه رو تموم کردی 🎉</>;
|
||||
|
||||
const stats = [
|
||||
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', fg: '#FF7A3D', bg: '#FFF1E6', fill: true, to: 'streak' as const },
|
||||
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', fg: '#E8A400', bg: '#FFF8E1', fill: false, to: 'leaderboard' as const },
|
||||
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', fg: '#9B7BFF', bg: '#F5F1FF', fill: false, to: 'characters' as const },
|
||||
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
|
||||
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', color: '#E8A400', fill: true, to: 'leaderboard' as const },
|
||||
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<header className="relative overflow-hidden rounded-b-[32px] bg-coral px-5 pb-11 pt-safe text-white">
|
||||
<div className="pointer-events-none absolute -left-10 -top-10 h-48 w-48 rounded-full bg-white/5" />
|
||||
<div className="relative flex items-center justify-between pt-8">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-white/75">سلام {profile.name || 'دوستِ من'}!</p>
|
||||
<h1 className="mt-0.5 text-xl font-black leading-snug">آمادهای ریاضی رو ادامه بدیم؟</h1>
|
||||
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-4 pt-safe text-white shadow-md shadow-coral/20">
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
{gradeTitle && (
|
||||
<span className="mt-1.5 inline-block rounded-full bg-white/15 px-2.5 py-0.5 text-xs text-white/85">{gradeTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<button type="button" aria-label="جدولِ امتیازها" onClick={() => nav.push({ name: 'leaderboard' })} className="flex h-10 w-10 items-center justify-center rounded-full bg-white/15">
|
||||
<Trophy size={18} />
|
||||
<button type="button" onClick={() => nav.switchTab('profile')} className="rounded-full bg-white/20 px-3 py-1.5 text-xs font-black">
|
||||
{gradeTitle}
|
||||
</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="flex items-center gap-1 rounded-full bg-white px-2.5 py-1.5 text-xs font-black text-ink shadow-sm"
|
||||
>
|
||||
<Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />
|
||||
{toFa(value)}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" aria-label="اعلانها" onClick={() => nav.push({ name: 'notifications' })} className="relative flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
|
||||
<Bell size={16} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -left-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)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<div className="relative z-10 -mt-7 px-4">
|
||||
<div className="flex rounded-3xl border border-line bg-white shadow-lg shadow-black/10">
|
||||
{stats.map(({ icon: Icon, value, label, fg, bg, fill, to }) => (
|
||||
<button key={label} type="button" onClick={() => nav.push({ name: to })} className="flex flex-1 flex-col items-center py-3.5">
|
||||
<span className="mb-1.5 flex h-10 w-10 items-center justify-center rounded-full" style={{ backgroundColor: bg }}>
|
||||
<Icon size={18} style={{ color: fg }} strokeWidth={2.4} fill={fill ? fg : 'none'} />
|
||||
</span>
|
||||
<span className="text-lg font-black leading-none text-ink">{toFa(value)}</span>
|
||||
<span className="mt-1 text-[10px] text-muted">{label}</span>
|
||||
</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>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!home ? (
|
||||
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />
|
||||
) : home.units.length === 0 ? (
|
||||
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
|
||||
) : (
|
||||
<Card className="flex items-center gap-3 p-5">
|
||||
<SeyarMascot char={companion} mood={home ? 'excited' : 'idle'} size={56} />
|
||||
<p className="text-sm font-medium text-muted">
|
||||
{!home ? 'دارم مسیرت رو میارم…' : home.units.length ? 'همهی جزیرههای این پایه رو تموم کردی! 🎉' : 'هنوز درسی برای این پایه ثبت نشده'}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card onClick={() => nav.switchTab('map')} className="p-4">
|
||||
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-[#EAF2F6]">
|
||||
<Map size={20} className="text-steel" />
|
||||
</span>
|
||||
<div className="text-sm font-bold text-ink">نقشهی فصلها</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted">ببین کجای مسیری</div>
|
||||
</Card>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nav.switchTab('ask')}
|
||||
className="rounded-3xl bg-violet p-4 text-right shadow-md shadow-violet/30 active:scale-[0.98]"
|
||||
>
|
||||
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-white/15">
|
||||
<Brain size={20} className="text-white" />
|
||||
</span>
|
||||
<div className="text-sm font-bold text-white">از پی بپرس</div>
|
||||
<div className="mt-0.5 text-[11px] text-white/75">هر جا گیر کردی</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<div className="flex flex-col gap-2 px-4 pb-10 pt-4">
|
||||
{home.units.map((unit, i) => {
|
||||
const p = unitProgress(unit);
|
||||
const color = unitColor(i);
|
||||
const Icon = iconFor(unit.nodes[0]?.icon, i);
|
||||
const p = unitProgress(unit);
|
||||
const isCurrentUnit = cont?.unit.id === unit.id;
|
||||
return (
|
||||
<section key={unit.id} aria-label={unit.title}>
|
||||
<button
|
||||
key={unit.id}
|
||||
type="button"
|
||||
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
||||
className="w-[140px] flex-shrink-0 rounded-2xl border border-line bg-white p-3.5 text-right shadow-sm active:scale-95"
|
||||
className="flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-right text-white active:translate-y-0.5"
|
||||
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.25)}` }}
|
||||
>
|
||||
<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="text-sm font-black text-white">یه سؤال دیگه… فقط یکی!</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-white/80">چالشِ سریع با دلتا · {cont.node.title}</div>
|
||||
<div className="text-[11px] font-bold text-white/80">
|
||||
فصلِ {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
|
||||
{isCurrentUnit && <span className="mr-1.5 rounded-full bg-white/25 px-2 py-0.5">الان اینجایی</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div>
|
||||
</div>
|
||||
<ChevronLeft size={18} className="flex-shrink-0 text-white/80" />
|
||||
</button>
|
||||
<div className="pt-12">
|
||||
<IslandPath
|
||||
ref={isCurrentUnit ? currentRef : undefined}
|
||||
nodes={unit.nodes}
|
||||
color={color}
|
||||
currentId={isCurrentUnit ? cont!.node.id : null}
|
||||
onOpen={(node) => setOpenNode({ node, color })}
|
||||
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IslandSheet node={openNode?.node ?? null} color={openNode?.color ?? '#ED6956'} onClose={() => setOpenNode(null)} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
142
src/screens/InvoicesScreen.tsx
Normal file
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 { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { moodFromServer } from '@/mascots/seyar';
|
||||
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
|
||||
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader, Sep } from '@/components/ui';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { Diagram } from '@/diagram';
|
||||
|
||||
/** معرفیِ درس، پیش از تمرین. */
|
||||
export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
|
||||
@@ -17,11 +19,15 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
|
||||
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
|
||||
const data = intro.data;
|
||||
const figs = (where: 'intro' | 'example' | 'step', index?: number) =>
|
||||
(data?.figures ?? [])
|
||||
.filter((f) => f.where === where && (where !== 'step' || f.index === index))
|
||||
.map((f, k) => <Diagram key={k} spec={f.diagram} />);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title={data?.title ?? '…'}
|
||||
title={data ? <MathText text={data.title} /> : '…'}
|
||||
color={color}
|
||||
back
|
||||
mascot={{ char: companion, mood: moodFromServer(data?.mascotAnimation), size: 72 }}
|
||||
@@ -29,7 +35,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
ref && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1">
|
||||
{/* ترتیبِ lessonIds «درسِ بعدی اول» است، نه ترتیبِ کتاب؛ پس شمارهی درس از آن درنمیآید. */}
|
||||
<Zap size={11} fill="white" /> {ref.node.title} · {toFa(ref.node.lessonIds.length)} درس
|
||||
<Zap size={11} fill="white" /> <MathText text={ref.node.title} /><Sep />{toFa(ref.node.lessonIds.length)} درس
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +51,8 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
<div className="flex items-start gap-3">
|
||||
<SeyarMascot char="sigma" mood="idle" size={56} />
|
||||
<div className="flex-1 rounded-3xl rounded-tr-sm border border-line bg-white p-4 shadow-sm">
|
||||
<p className="whitespace-pre-line text-sm leading-[1.95] text-body">{data.introText}</p>
|
||||
<p className="whitespace-pre-line text-sm leading-[1.95] text-body"><MathText text={data.introText} /></p>
|
||||
{figs('intro')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +63,8 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
{data.example && (
|
||||
<section className="rounded-3xl border border-line bg-white p-4">
|
||||
<h2 className="mb-2 text-xs font-black text-muted">مثال</h2>
|
||||
<p className="whitespace-pre-line text-sm leading-[1.95] text-ink">{data.example}</p>
|
||||
<p className="whitespace-pre-line text-sm leading-[1.95] text-ink"><MathText text={data.example} /></p>
|
||||
{figs('example')}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -69,7 +77,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
|
||||
{toFa(i + 1)}
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre-line">{step}</span>
|
||||
<span className="flex-1 whitespace-pre-line"><MathText text={step} />{figs('step', i)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRight, Phone } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { api, ApiError } from '@/api';
|
||||
import { InlineError, PrimaryButton } from '@/components/ui';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { digitsOnly, toFa } from '@/lib/format';
|
||||
import { getDeviceId } from '@/lib/storage';
|
||||
|
||||
const OTP_LENGTH = 6;
|
||||
/** همان cooldownِ سرور (OTP_REQUEST_PHONE_COOLDOWN_SECONDS). */
|
||||
const RESEND_SECONDS = 120;
|
||||
|
||||
/**
|
||||
* ورود با شماره (OTP) یا بهعنوانِ مهمان.
|
||||
*
|
||||
* `upgrade` برای مهمانی است که از صفحهی «من» میخواهد شماره ثبت کند؛ آنجا
|
||||
* گزینهی مهمان نیست و سرور همان کاربر را نگه میدارد.
|
||||
* ورود با شماره (OTP): تنها راهِ ورود است.
|
||||
*/
|
||||
export function LoginScreen({
|
||||
onDone,
|
||||
onCancel,
|
||||
upgrade = false,
|
||||
}: {
|
||||
onDone: () => void;
|
||||
onCancel?: () => void;
|
||||
upgrade?: boolean;
|
||||
}) {
|
||||
export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel?: () => void }) {
|
||||
const [step, setStep] = useState<'phone' | 'otp'>('phone');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [devOtp, setDevOtp] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<'otp' | 'login' | 'guest' | null>(null);
|
||||
const [loading, setLoading] = useState<'login' | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [resendIn, setResendIn] = useState(0);
|
||||
/** درخواستِ پیامک هنوز جواب نداده؛ جدا از `loading` تا واردکردنِ کد قفل نشود. */
|
||||
const [sending, setSending] = useState(false);
|
||||
/** شمارهی آخرین درخواست؛ جوابِ دیرِ درخواستِ قدیمیتر نادیده گرفته میشود. */
|
||||
const attempt = useRef(0);
|
||||
const otpInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const phoneValid = /^09\d{9}$/.test(phone);
|
||||
@@ -48,20 +40,37 @@ export function LoginScreen({
|
||||
if (step === 'otp') otpInput.current?.focus();
|
||||
}, [step]);
|
||||
|
||||
/**
|
||||
* صفحهی کد فوراً باز میشود و درخواست در پسزمینه جلو میرود. سرور کد را پیش از
|
||||
* تماس با کاوهنگار ذخیره میکند، پس کدِ رسیده همان لحظه قابلِ تأیید است؛ ولی
|
||||
* جوابِ کاوهنگار گاهی بعد از رسیدنِ پیامک میآید (۱۶ ثانیه در ۲۰۲۶-۰۹-۲۷) و
|
||||
* منتظر ماندن برایش کاربر را بیدلیل معطل میکرد.
|
||||
*/
|
||||
async function requestOtp() {
|
||||
if (!phoneValid || loading) return;
|
||||
if (!phoneValid || sending || loading) return;
|
||||
const mine = ++attempt.current;
|
||||
setError('');
|
||||
setLoading('otp');
|
||||
try {
|
||||
const data = await api.auth.requestOtp(phone);
|
||||
setDevOtp(data.devOtp ?? null);
|
||||
setDevOtp(null);
|
||||
setOtp('');
|
||||
setStep('otp');
|
||||
setResendIn(RESEND_SECONDS);
|
||||
setSending(true);
|
||||
try {
|
||||
const data = await api.auth.requestOtp(phone);
|
||||
if (mine !== attempt.current) return;
|
||||
setDevOtp(data.devOtp ?? null);
|
||||
} catch (err) {
|
||||
if (mine !== attempt.current) return;
|
||||
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن'));
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند
|
||||
} else if (err instanceof ApiError && err.isRateLimited && err.retryAfterMs) {
|
||||
setResendIn(Math.ceil(err.retryAfterMs / 1000));
|
||||
} else {
|
||||
setResendIn(0); // پیامکی نرفت و سرور cooldown را برداشته؛ همین حالا دوباره بفرستد
|
||||
}
|
||||
} finally {
|
||||
setLoading(null);
|
||||
if (mine === attempt.current) setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,21 +89,6 @@ export function LoginScreen({
|
||||
}
|
||||
}
|
||||
|
||||
async function continueAsGuest() {
|
||||
setError('');
|
||||
setLoading('guest');
|
||||
try {
|
||||
await api.auth.guest(getDeviceId());
|
||||
onDone();
|
||||
} catch (err) {
|
||||
// قبلاً اینجا یک «مهمانِ محلی» بدونِ توکن ساخته میشد و بعد همهی
|
||||
// درخواستها بیصدا شکست میخوردند. حالا همینجا میگوییم چه شده.
|
||||
setError(friendlyError(err, 'ورودِ مهمان انجام نشد، دوباره امتحان کن'));
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
function onOtpChange(value: string) {
|
||||
const code = digitsOnly(value, OTP_LENGTH);
|
||||
setOtp(code);
|
||||
@@ -112,10 +106,10 @@ export function LoginScreen({
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-black">{upgrade ? 'ثبتِ شمارهی موبایل' : 'ورود به تدریسینو'}</h1>
|
||||
<h1 className="text-xl font-black">ورود به تدریسینو</h1>
|
||||
<p className="mt-1 text-sm text-white/75">
|
||||
{step === 'phone'
|
||||
? upgrade ? 'امتیازها و پیشرفتت سرِ جاش میمونه' : 'شمارهی موبایلت رو وارد کن'
|
||||
? 'شمارهی موبایلت رو وارد کن'
|
||||
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامکشده رو وارد کن`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -166,16 +160,19 @@ export function LoginScreen({
|
||||
}`}
|
||||
/>
|
||||
</label>
|
||||
{sending && !error && (
|
||||
<p className="mt-2 text-xs text-muted">پیامک در راهه؛ همین که رسید کد رو بزن</p>
|
||||
)}
|
||||
{devOtp && (
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
کدِ آزمایشیِ حالتِ توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex items-center justify-between text-xs font-bold">
|
||||
<button type="button" className="flex items-center gap-1 text-coral" onClick={() => { setStep('phone'); setError(''); setDevOtp(null); }}>
|
||||
<button type="button" className="flex items-center gap-1 text-coral" onClick={() => { attempt.current++; setSending(false); setStep('phone'); setError(''); setDevOtp(null); }}>
|
||||
<Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح
|
||||
</button>
|
||||
<button type="button" disabled={resendIn > 0 || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted">
|
||||
<button type="button" disabled={resendIn > 0 || sending || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted">
|
||||
{resendIn > 0 ? `ارسالِ دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسالِ دوباره'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -187,21 +184,11 @@ export function LoginScreen({
|
||||
<div className="mt-auto flex flex-col gap-3">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
loading={loading === 'otp' || loading === 'login'}
|
||||
loading={loading === 'login'}
|
||||
disabled={step === 'phone' ? !phoneValid : !otpValid}
|
||||
>
|
||||
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'}
|
||||
</PrimaryButton>
|
||||
{!upgrade && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void continueAsGuest()}
|
||||
disabled={loading !== null}
|
||||
className="py-2 text-center text-sm font-bold text-muted disabled:opacity-40"
|
||||
>
|
||||
{loading === 'guest' ? 'در حالِ ورود…' : 'فعلاً مهمان باشم'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAppData } from '@/state/AppData';
|
||||
import { unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
|
||||
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui';
|
||||
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { toFa } from '@/lib/format';
|
||||
@@ -22,7 +22,7 @@ export function MapScreen() {
|
||||
<Screen className="bg-[#f7f8ff]">
|
||||
<ScreenHeader
|
||||
title="نقشهی فصلها"
|
||||
subtitle={`${gradeTitle ? `${gradeTitle} · ` : ''}${toFa(overall)}٪ از کلِ مسیر`}
|
||||
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}{toFa(overall)}٪ از کلِ مسیر</>}
|
||||
color="#91ADC6"
|
||||
mascot={{ char: 'sigma', mood: overall > 0 ? 'happy' : 'idle' }}
|
||||
>
|
||||
@@ -76,7 +76,7 @@ export function MapScreen() {
|
||||
)}
|
||||
</button>
|
||||
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
|
||||
<div className="max-w-[130px] truncate text-xs font-black text-ink">{unit.title}</div>
|
||||
<div className="line-clamp-2 max-w-[170px] text-xs font-black leading-5 text-ink">{unit.title}</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Bell, Check, Crown, FileText, MessageCircle, Star, Trophy, Users, type LucideIcon } from 'lucide-react';
|
||||
import { Bell, Check, Crown, MessageCircle, Star, Trophy, type LucideIcon } from 'lucide-react';
|
||||
import { api, type NotificationItem } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
|
||||
import { relativeTime } from '@/lib/format';
|
||||
import { relativeTime, toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
|
||||
const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = {
|
||||
xp_earned: { icon: Star, color: '#E8A400', bg: '#FFF8E1' },
|
||||
@@ -12,8 +13,6 @@ const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = {
|
||||
node_completed: { icon: Trophy, color: '#FF7A3D', bg: '#FFF1E6' },
|
||||
order_paid: { icon: Crown, color: '#9B7BFF', bg: '#F5F1FF' },
|
||||
ticket_replied: { icon: MessageCircle, color: '#4C5FD5', bg: '#EEF1FF' },
|
||||
family_link_accepted: { icon: Users, color: '#FF7A3D', bg: '#FFF1E6' },
|
||||
weekly_report_ready: { icon: FileText, color: '#91ADC6', bg: '#EAF2F6' },
|
||||
};
|
||||
const styleOf = (type: string) => STYLE[type] ?? { icon: Bell, color: '#9e9ec0', bg: '#f0eeff' };
|
||||
|
||||
@@ -83,7 +82,7 @@ export function NotificationsScreen() {
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block text-sm ${n.isRead ? 'text-muted' : 'font-bold text-ink'}`}>{n.title}</span>
|
||||
{n.body && <span className="mt-0.5 block whitespace-pre-line text-xs leading-6 text-body">{n.body}</span>}
|
||||
{n.body && <span className="mt-0.5 block whitespace-pre-line text-xs leading-6 text-body"><MathText text={toFa(n.body)} /></span>}
|
||||
<span className="mt-1 block text-[10px] text-faint">{relativeTime(n.createdAt)}</span>
|
||||
</span>
|
||||
{!n.isRead && <span className="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-coral" />}
|
||||
|
||||
@@ -23,6 +23,12 @@ const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title
|
||||
title: 'از پی بپرس',
|
||||
desc: 'من پیام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدمبهقدم توضیح میدم.',
|
||||
},
|
||||
{
|
||||
char: 'tedi', mood: 'excited', bg: '#D9820B',
|
||||
sub: 'یکی از ما مالِ توئه',
|
||||
title: 'همراهت رو انتخاب کن',
|
||||
desc: 'منم تدی! هر چی امتیازت بیشتر بشه، همراههای تازه باز میشن. از صفحهی «من» هر کدوممون رو خواستی کنارت میذاری.',
|
||||
},
|
||||
];
|
||||
|
||||
export function OnboardingScreen({ onDone }: { onDone: () => void }) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
|
||||
Pencil, Smartphone, Sparkles, Star, Trophy, Users, Volume2, VolumeX,
|
||||
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
|
||||
FileText, Pencil, Repeat, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
@@ -11,13 +11,13 @@ import type { Route } from '@/nav/routes';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { AgePicker, ageFromBirthYear, birthYearFromAge } from '@/components/AgePicker';
|
||||
import { BirthDatePicker, ageOf, formatBirthDate } from '@/components/BirthDatePicker';
|
||||
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { digitsOnly, toFa } from '@/lib/format';
|
||||
|
||||
type Editing = 'name' | 'grade' | 'age' | 'logout' | null;
|
||||
type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null;
|
||||
|
||||
/** «من»: پروفایل، تنظیمات و میانبُر به بخشهای فرعی. */
|
||||
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
@@ -25,7 +25,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
const nav = useNav();
|
||||
const [editing, setEditing] = useState<Editing>(null);
|
||||
const [sound, setSound] = useState(soundEnabled());
|
||||
const age = ageFromBirthYear(profile.birthYear);
|
||||
const age = ageOf(profile.birthDate, profile.birthYear);
|
||||
|
||||
const go = (route: Route) => () => nav.push(route);
|
||||
|
||||
@@ -42,14 +42,13 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
<button type="button" onClick={() => setEditing('name')} className="flex items-center gap-2 text-xl font-black">
|
||||
{profile.name || 'بینام'} <Pencil size={14} className="text-white/70" />
|
||||
</button>
|
||||
<div className="mt-0.5 text-xs text-white/75">
|
||||
شناسهی کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80">
|
||||
{gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
|
||||
{age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>}
|
||||
{profile.isGuest ? (
|
||||
<span className="rounded-full bg-[#FFD23F] px-2.5 py-0.5 font-bold text-ink">مهمان</span>
|
||||
) : (
|
||||
profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>
|
||||
)}
|
||||
{profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,36 +68,27 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 py-5">
|
||||
{profile.isGuest && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={go({ name: 'register' })}
|
||||
className="flex items-center gap-3 rounded-3xl bg-[#FFF8E1] p-4 text-right ring-2 ring-[#FFD23F]"
|
||||
>
|
||||
<Smartphone size={22} className="flex-shrink-0 text-gold" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-black text-ink">شمارهت رو ثبت کن</div>
|
||||
<div className="mt-0.5 text-xs leading-6 text-body">تا اگه گوشیت عوض شد، امتیازها و پیشرفتت گم نشه.</div>
|
||||
</div>
|
||||
<ChevronLeft size={18} className="text-muted" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Menu>
|
||||
<Row icon={GraduationCap} label="پایهی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
||||
<Row icon={CalendarDays} label="سن" value={age ? `${toFa(age)} سال` : 'وارد نشده'} onClick={() => setEditing('age')} />
|
||||
<Row
|
||||
icon={CalendarDays}
|
||||
label="تاریخِ تولد"
|
||||
value={formatBirthDate(profile.birthDate) || (age ? `${toFa(age)} سال` : 'وارد نشده')}
|
||||
onClick={() => setEditing('birth')}
|
||||
/>
|
||||
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} />
|
||||
</Menu>
|
||||
|
||||
<Menu>
|
||||
<Row icon={BarChart3} label="گزارشِ والدین" onClick={go({ name: 'report' })} />
|
||||
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} />
|
||||
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} />
|
||||
<Row icon={Bell} label="اعلانها" onClick={go({ name: 'notifications' })} />
|
||||
</Menu>
|
||||
|
||||
<Menu>
|
||||
{!profile.isGuest && <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />}
|
||||
{!profile.isGuest && <Row icon={Users} label="بخشِ والدین" onClick={go({ name: 'family' })} />}
|
||||
<Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />
|
||||
<Row icon={FileText} label="پرداختها و فاکتورها" onClick={go({ name: 'invoices' })} />
|
||||
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
|
||||
<Row
|
||||
icon={sound ? Volume2 : VolumeX}
|
||||
@@ -111,16 +101,21 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
/>
|
||||
</Menu>
|
||||
|
||||
<button type="button" onClick={() => setEditing('logout')} className="flex items-center justify-center gap-2 py-3 text-sm font-bold text-red-500">
|
||||
<LogOut size={16} /> خروج از حساب
|
||||
<Menu>
|
||||
<Row icon={Repeat} label="ورود با حسابِ دیگر" onClick={() => setEditing('switch')} />
|
||||
<Row icon={LogOut} label="خروج از حساب" onClick={() => setEditing('logout')} />
|
||||
</Menu>
|
||||
<button type="button" onClick={() => setEditing('delete')} className="flex items-center justify-center gap-2 py-2 text-xs font-bold text-red-500">
|
||||
<Trash2 size={14} /> حذفِ حساب
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
|
||||
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
|
||||
<AgeSheet open={editing === 'age'} onClose={() => setEditing(null)} />
|
||||
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||
<BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
|
||||
<LogoutSheet open={editing === 'logout' || editing === 'switch'} switching={editing === 'switch'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||
<DeleteAccountSheet open={editing === 'delete'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -222,17 +217,17 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { profile, setProfile } = useAppData();
|
||||
const toast = useToast();
|
||||
const [age, setAge] = useState<number | null>(ageFromBirthYear(profile.birthYear));
|
||||
const [birthDate, setBirthDate] = useState<string | null>(profile.birthDate);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function save() {
|
||||
if (!age) return;
|
||||
if (!birthDate) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
setProfile(await api.profile.update({ birthYear: birthYearFromAge(age) }));
|
||||
setProfile(await api.profile.update({ birthDate }));
|
||||
toast('ذخیره شد', 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -243,17 +238,22 @@ function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="چند سالته؟">
|
||||
<Sheet open={open} onClose={onClose} title="تاریخِ تولد">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سالِ تولدت به شمسی.</p>
|
||||
<div className="mb-5">
|
||||
<AgePicker value={age} onChange={setAge} />
|
||||
<BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
|
||||
</div>
|
||||
<PrimaryButton loading={saving} disabled={!age} onClick={() => void save()}>ذخیره</PrimaryButton>
|
||||
<PrimaryButton loading={saving} disabled={!birthDate} onClick={() => void save()}>ذخیره</PrimaryButton>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
|
||||
const { profile } = useAppData();
|
||||
/**
|
||||
* خروج، و «ورود با حسابِ دیگر» که همان خروج است با متنِ دیگر. عمداً چند حسابِ واردشده روی
|
||||
* یک گوشی نگه داشته نمیشود: گوشیِ خانوادگی دستبهدست میشود و هر کس بیکد واردِ حسابِ
|
||||
* دیگری میشد، و چند refresh tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر میکرد.
|
||||
*/
|
||||
function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open: boolean; switching?: boolean; onClose: () => void; onSignedOut: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function logout() {
|
||||
@@ -268,15 +268,93 @@ function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: (
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟">
|
||||
<Sheet open={open} onClose={onClose} title={switching ? 'ورود با حسابِ دیگر' : 'از حسابت خارج بشی؟'}>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
{profile.isGuest
|
||||
? 'تو مهمانی: با همین گوشی و مرورگر، «فعلاً مهمان باشم» دوباره تو رو به همین پیشرفت برمیگردونه، ولی از گوشیِ دیگه نه. برای امنتر شدن، اول شمارهت رو ثبت کن.'
|
||||
: 'هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.'}
|
||||
{switching && 'اول از این حساب خارج میشی، بعد با شمارهی دیگه وارد شو. '}
|
||||
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<SecondaryButton onClick={onClose}>نه، میمونم</SecondaryButton>
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
|
||||
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>
|
||||
{switching ? 'خروج و ورود با حسابِ دیگر' : 'خروج'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* حذفِ همیشگیِ حساب (POST /user/delete). شمارهدار با کدِ تازهی پیامکی تأیید میکند تا گوشیِ
|
||||
* بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند.
|
||||
*/
|
||||
function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
|
||||
const { profile } = useAppData();
|
||||
const [sent, setSent] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function sendCode() {
|
||||
if (!profile.phone) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.auth.requestOtp(profile.phone);
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
setError(friendlyError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.auth.deleteAccount({ otp: digitsOnly(value, 6) });
|
||||
askStore.clear();
|
||||
onSignedOut();
|
||||
} catch (err) {
|
||||
setError(friendlyError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="حذفِ همیشگیِ حساب">
|
||||
<p className="mb-3 text-sm leading-7 text-body">
|
||||
امتیاز، زنجیره، پیشرفتِ جزیرهها، گفتگوها با دستیار، سوالهای خریدهشده و قلبِ نامحدودت پاک میشن و برنمیگردن.
|
||||
سوابقِ پرداخت فقط برای حسابداری میمونه.
|
||||
</p>
|
||||
{sent ? (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(digitsOnly(e.target.value, 6))}
|
||||
placeholder="کدِ پیامکشده"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-red-400"
|
||||
/>
|
||||
) : (
|
||||
<p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شمارهی خودت پیامک میشه.</p>
|
||||
)}
|
||||
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton>
|
||||
{!sent ? (
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادنِ کد</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
color="#ef4444"
|
||||
loading={busy}
|
||||
disabled={value.length < 6}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
حذفِ همیشگی
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -2,37 +2,58 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react';
|
||||
import { api, ApiError, type AnswerResult, type Hearts, type LessonComplete, type Question, type ReportReason } from '@/api';
|
||||
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { newIdempotencyKey } from '@/api/http';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import type { SeyarMood } from '@/mascots/seyar';
|
||||
import { NoHeartsScreen } from '@/components/NoHeartsScreen';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton, Screen } from '@/components/ui';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { timeUntil, toFa } from '@/lib/format';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { sounds } from '@/lib/sound';
|
||||
|
||||
type Phase =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'error'; error: unknown }
|
||||
| { kind: 'playing' }
|
||||
| { kind: 'noHearts'; nextRefillAt: string | null }
|
||||
| { kind: 'noHearts'; nextRefillAt: string | null; fullRefillAt: string | null }
|
||||
| { kind: 'finishing' }
|
||||
| { kind: 'finishError'; error: unknown }
|
||||
| { kind: 'result'; result: LessonComplete };
|
||||
| { kind: 'result'; result: QuizResult };
|
||||
|
||||
/** تمرینِ یک درس: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه. */
|
||||
export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
|
||||
/** نتیجهی مشترکِ پایانِ درس و پایانِ جزیره. */
|
||||
interface QuizResult {
|
||||
stars: number;
|
||||
xpEarned: number;
|
||||
coinsEarned: number;
|
||||
/** فقط در آزمونِ جزیره. */
|
||||
nextNodeId?: string | null;
|
||||
}
|
||||
|
||||
type PlayQuestion = Question & { lessonId?: string };
|
||||
|
||||
/**
|
||||
* تمرین: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه.
|
||||
*
|
||||
* دو حالت دارد. با `lessonId` تمرینِ همان یک درس است (هر درس معمولاً فقط یک
|
||||
* سوال دارد). بدونِ آن آزمونِ کلِ جزیره است (`/nodes/{id}/quiz`، ۴ سوال از
|
||||
* درسهای مختلف) که با تمام شدنش جزیره تمام و جزیرهی بعدی باز میشود؛ مسیرِ
|
||||
* اصلیِ بازی همین است.
|
||||
*/
|
||||
export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) {
|
||||
const { home, companion, refreshHome, refreshSummary } = useAppData();
|
||||
const nav = useNav();
|
||||
const toast = useToast();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [questions, setQuestions] = useState<PlayQuestion[]>([]);
|
||||
const nodeMode = lessonId === undefined;
|
||||
const [hearts, setHearts] = useState<Hearts | null>(null);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
@@ -47,7 +68,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
const answerKeys = useRef(new Map<string, string>());
|
||||
const completeKey = useRef(newIdempotencyKey());
|
||||
|
||||
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
|
||||
const ref = findNode(home, nodeId) ?? (lessonId !== undefined ? findNodeOfLesson(home, lessonId) : null);
|
||||
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
|
||||
const question = questions[index];
|
||||
const isLast = index === questions.length - 1;
|
||||
@@ -58,7 +79,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setPhase({ kind: 'loading' });
|
||||
Promise.all([api.lesson.questions(lessonId), api.hearts().catch(() => null)])
|
||||
const load: Promise<PlayQuestion[]> = nodeMode
|
||||
? api.nodeQuiz(nodeId ?? '').then((r) => r.questions)
|
||||
: api.lesson.questions(lessonId);
|
||||
Promise.all([load, api.hearts().catch(() => null)])
|
||||
.then(([qs, h]) => {
|
||||
if (!alive) return;
|
||||
setQuestions(qs);
|
||||
@@ -69,14 +93,14 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
setCorrectCount(0);
|
||||
answerKeys.current.clear();
|
||||
completeKey.current = newIdempotencyKey();
|
||||
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt });
|
||||
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
|
||||
else setPhase({ kind: 'playing' });
|
||||
})
|
||||
.catch((error: unknown) => alive && setPhase({ kind: 'error', error }));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [lessonId, reload]);
|
||||
}, [lessonId, nodeId, nodeMode, reload]);
|
||||
|
||||
async function choose(optionId: string) {
|
||||
if (!question || selected !== null || submitting) return;
|
||||
@@ -100,7 +124,11 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
} catch (err) {
|
||||
setSelected(null);
|
||||
if (err instanceof ApiError && err.code === 'no_hearts') {
|
||||
setPhase({ kind: 'noHearts', nextRefillAt: (err.body.nextRefillAt as string | null) ?? null });
|
||||
setPhase({
|
||||
kind: 'noHearts',
|
||||
nextRefillAt: (err.body.nextRefillAt as string | null) ?? null,
|
||||
fullRefillAt: (err.body.fullRefillAt as string | null) ?? null,
|
||||
});
|
||||
} else {
|
||||
toast(friendlyError(err, 'جوابت ثبت نشد، دوباره بزن'), 'error');
|
||||
}
|
||||
@@ -112,7 +140,11 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
async function next() {
|
||||
if (outOfHearts && !isLast) {
|
||||
const h = await api.hearts().catch(() => null);
|
||||
setPhase({ kind: 'noHearts', nextRefillAt: h?.nextRefillAt ?? hearts?.nextRefillAt ?? null });
|
||||
setPhase({
|
||||
kind: 'noHearts',
|
||||
nextRefillAt: h?.nextRefillAt ?? hearts?.nextRefillAt ?? null,
|
||||
fullRefillAt: h?.fullRefillAt ?? hearts?.fullRefillAt ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isLast) {
|
||||
@@ -127,23 +159,36 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
async function finish() {
|
||||
setPhase({ kind: 'finishing' });
|
||||
try {
|
||||
const result = await api.lesson.complete(
|
||||
lessonId,
|
||||
{ correctCount, totalQuestions: questions.length },
|
||||
completeKey.current,
|
||||
);
|
||||
const payload = { correctCount, totalQuestions: questions.length };
|
||||
const result: QuizResult = nodeMode
|
||||
? await api.nodeQuizComplete(nodeId ?? '', payload, completeKey.current)
|
||||
: await api.lesson.complete(lessonId, payload, completeKey.current);
|
||||
setPhase({ kind: 'result', result });
|
||||
sounds.finish();
|
||||
if (result.stars >= 2) void confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } });
|
||||
void refreshHome();
|
||||
void refreshSummary();
|
||||
} catch (error) {
|
||||
// سرور پایانِ درس/جزیره را بیقلب و بیجواب نمیپذیرد (۱۴۰۵-۰۷)؛ همان صفحهی انتظار.
|
||||
if (error instanceof ApiError && error.code === 'no_hearts') {
|
||||
setPhase({
|
||||
kind: 'noHearts',
|
||||
nextRefillAt: (error.body.nextRefillAt as string | null) ?? null,
|
||||
fullRefillAt: (error.body.fullRefillAt as string | null) ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPhase({ kind: 'finishError', error });
|
||||
}
|
||||
}
|
||||
|
||||
const askWhy = () =>
|
||||
nav.push({ name: 'ask', lessonId, questionId: question?.id, prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟' });
|
||||
nav.push({
|
||||
name: 'ask',
|
||||
lessonId: lessonId ?? (question?.lessonId ? Number(question.lessonId) : undefined),
|
||||
questionId: question?.id,
|
||||
prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟',
|
||||
});
|
||||
|
||||
/* ─── حالتهای غیرِ بازی ─────────────────────────────────────────────── */
|
||||
|
||||
@@ -158,8 +203,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
if (phase.kind === 'noHearts') return <NoHearts nextRefillAt={phase.nextRefillAt} />;
|
||||
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id} color={color} />;
|
||||
if (phase.kind === 'noHearts') {
|
||||
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={() => setReload((n) => n + 1)} />;
|
||||
}
|
||||
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} />;
|
||||
if (phase.kind === 'finishing' || phase.kind === 'finishError') {
|
||||
return (
|
||||
<Screen>
|
||||
@@ -182,7 +229,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex flex-1 flex-col justify-center gap-4 px-8">
|
||||
<ErrorState text="این درس هنوز سوالی نداره" />
|
||||
<ErrorState text={nodeMode ? 'این جزیره هنوز سوالی نداره' : 'این درس هنوز سوالی نداره'} />
|
||||
<SecondaryButton onClick={nav.back}>برگشت</SecondaryButton>
|
||||
</div>
|
||||
</Screen>
|
||||
@@ -246,7 +293,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
<Flag size={12} /> گزارشِ ایراد
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink">{question.prompt}</h2>
|
||||
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} /></h2>
|
||||
</div>
|
||||
|
||||
<div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
|
||||
@@ -257,9 +304,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
onClick={() => void choose(option.id)}
|
||||
disabled={selected !== null && !answer}
|
||||
whileTap={{ scale: selected === null ? 0.95 : 1 }}
|
||||
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`}
|
||||
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 border-b-4 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`}
|
||||
>
|
||||
<span dir="auto">{option.value}</span>
|
||||
<span><MathText text={option.value} /></span>
|
||||
{answer && option.id === answer.correctOptionId && <Check size={16} className="text-green-500" strokeWidth={3} />}
|
||||
{answer && option.id === selected && !answer.correct && <X size={16} className="text-red-400" strokeWidth={3} />}
|
||||
</motion.button>
|
||||
@@ -281,7 +328,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
</div>
|
||||
|
||||
<Sheet open={confirmExit} onClose={() => setConfirmExit(false)} title="از تمرین بیرون بری؟">
|
||||
<p className="mb-5 text-sm leading-7 text-muted">جوابهایی که دادی ثبت شده، ولی این درس تا آخرش رو نزنی تمومشده حساب نمیشه.</p>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
جوابهایی که دادی ثبت شده، ولی {nodeMode ? 'این جزیره' : 'این درس'} تا آخرش رو نزنی تمومشده حساب نمیشه.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه میدم</PrimaryButton>
|
||||
<SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton>
|
||||
@@ -325,38 +374,27 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NoHearts({ nextRefillAt }: { nextRefillAt: string | null }) {
|
||||
const nav = useNav();
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||
<SeyarMascot char="pi" mood="depressed" size={110} />
|
||||
<h2 className="text-xl font-black text-ink">قلبهات تموم شد</h2>
|
||||
<p className="text-sm leading-7 text-muted">
|
||||
{nextRefillAt ? <>قلبِ بعدی تا <b className="text-coral">{timeUntil(nextRefillAt)}</b> دیگه پر میشه.</> : 'کمی صبر کن تا قلبهات دوباره پر بشن.'}
|
||||
<br />
|
||||
با اشتراکِ ویژه قلبها نامحدودن.
|
||||
</p>
|
||||
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ اشتراک</PrimaryButton>
|
||||
<SecondaryButton onClick={nav.back}>باشه، بعداً</SecondaryButton>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function Result({
|
||||
result, total, correct, lessonId, nodeId, color,
|
||||
}: { result: LessonComplete; total: number; correct: number; lessonId: number; nodeId?: string; color: string }) {
|
||||
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string }) {
|
||||
const nav = useNav();
|
||||
const { companion } = useAppData();
|
||||
const [loadingNext, setLoadingNext] = useState(false);
|
||||
|
||||
const nodeMode = lessonId === undefined;
|
||||
const nextNode = result.nextNodeId ?? null;
|
||||
|
||||
// بعد از ثبت، نقشه عوض شده؛ درسِ بعدی را از نسخهی تازهی نقشه میخوانیم.
|
||||
async function goNext() {
|
||||
if (nodeMode) {
|
||||
if (nextNode) nav.replace({ name: 'quiz', nodeId: nextNode });
|
||||
else nav.back();
|
||||
return;
|
||||
}
|
||||
setLoadingNext(true);
|
||||
try {
|
||||
const fresh = await api.home();
|
||||
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId);
|
||||
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId!);
|
||||
const next = ref ? nextLessonOf(ref.node) : null;
|
||||
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
|
||||
nav.replace({ name: 'lesson', lessonId: next, nodeId: ref.node.id });
|
||||
@@ -381,6 +419,7 @@ function Result({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
|
||||
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی 🏝️</p>}
|
||||
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
|
||||
</div>
|
||||
<div className="flex w-full gap-3">
|
||||
@@ -396,8 +435,13 @@ function Result({
|
||||
</div>
|
||||
</div>
|
||||
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
|
||||
ادامه <ChevronLeft size={18} />
|
||||
{nodeMode && nextNode ? 'بریم جزیرهی بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
||||
</PrimaryButton>
|
||||
{nodeMode && nextNode && (
|
||||
<button type="button" onClick={nav.back} className="-mt-2 py-2 text-sm font-bold text-muted">
|
||||
فعلاً بسه، برگردیم به نقشه
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
|
||||
346
src/screens/ReportScreen.tsx
Normal file
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 { api, type Grade, type UserProfile } from '@/api';
|
||||
import { InlineError, PrimaryButton } from '@/components/ui';
|
||||
import { AgePicker, ageFromBirthYear, birthYearFromAge } from '@/components/AgePicker';
|
||||
import { ADULT_AGE, BirthDatePicker, ageOf } from '@/components/BirthDatePicker';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
/** «بیا با هم آشنا بشیم»: اسم، سن و پایه، بارِ اولِ ورود. */
|
||||
/**
|
||||
* «بیا با هم آشنا بشیم»: اسم، تاریخِ تولد و پایه، بارِ اولِ ورود.
|
||||
* بزرگسال هم میآید (کسی که فقط یک مبحث را میخواهد یاد بگیرد)؛ برای او «پایه»
|
||||
* یعنی ریاضیِ کدام پایه، نه کلاسی که در آن است.
|
||||
*/
|
||||
export function SetupScreen({
|
||||
profile,
|
||||
grades,
|
||||
@@ -18,7 +22,7 @@ export function SetupScreen({
|
||||
onDone: (profile: UserProfile) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(profile.name);
|
||||
const [age, setAge] = useState<number | null>(ageFromBirthYear(profile.birthYear));
|
||||
const [birthDate, setBirthDate] = useState<string | null>(profile.birthDate);
|
||||
const [gradeId, setGradeId] = useState<number | null>(profile.gradeId);
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -26,6 +30,8 @@ export function SetupScreen({
|
||||
|
||||
const nameMissing = touched && !name.trim();
|
||||
const gradeMissing = touched && !gradeId;
|
||||
const age = ageOf(birthDate, null);
|
||||
const adult = age !== null && age >= ADULT_AGE;
|
||||
|
||||
async function submit() {
|
||||
setTouched(true);
|
||||
@@ -36,7 +42,7 @@ export function SetupScreen({
|
||||
await api.profile.setGrade(gradeId);
|
||||
const updated = await api.profile.update({
|
||||
name: name.trim(),
|
||||
...(age ? { birthYear: birthYearFromAge(age) } : {}),
|
||||
...(birthDate ? { birthDate } : {}),
|
||||
});
|
||||
onDone({ ...updated, gradeId });
|
||||
} catch (err) {
|
||||
@@ -78,13 +84,16 @@ export function SetupScreen({
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-sm font-black text-ink">
|
||||
چند سالته؟ <span className="text-xs font-medium text-muted">(اختیاری)</span>
|
||||
تاریخِ تولدت <span className="text-xs font-medium text-muted">(به شمسی، اختیاری)</span>
|
||||
</span>
|
||||
<AgePicker value={age} onChange={setAge} />
|
||||
<BirthDatePicker value={birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-3 block text-sm font-black text-ink">کدوم پایهای؟</span>
|
||||
<span className="mb-3 block text-sm font-black text-ink">
|
||||
{adult ? 'ریاضیِ کدوم پایه رو میخوای یاد بگیری؟' : 'کدوم پایهای؟'}
|
||||
{adult && <span className="mt-1 block text-xs font-medium text-muted">هر وقت خواستی از پروفایل عوضش کن.</span>}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{grades.map((g) => {
|
||||
const selected = gradeId === g.id;
|
||||
@@ -114,7 +123,7 @@ export function SetupScreen({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{gradeMissing && <span className="mt-2 block text-xs text-red-500">پایهت رو انتخاب کن</span>}
|
||||
{gradeMissing && <span className="mt-2 block text-xs text-red-500">{adult ? 'یه پایه رو انتخاب کن' : 'پایهت رو انتخاب کن'}</span>}
|
||||
</div>
|
||||
|
||||
{error && <InlineError>{error}</InlineError>}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function SplashScreen() {
|
||||
<SeyarMascot char="sigma" mood="happy" size={130} />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-white">تدریسینو</h1>
|
||||
<p className="text-sm text-white/75">ریاضی، قدمبهقدم، با سهیار</p>
|
||||
<p className="text-sm text-white/75">ریاضی، قدمبهقدم، با همراههات</p>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Crown, Heart, Ticket } from 'lucide-react';
|
||||
import { Check, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
|
||||
import { api, type Plan } from '@/api';
|
||||
import { newIdempotencyKey } from '@/api/http';
|
||||
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
|
||||
@@ -14,100 +16,129 @@ function durationLabel(days: number): string {
|
||||
return days ? `${toFa(days)} روزه` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* «بستهی سوال» (از ۱۴۰۵-۰۷ بهجای اشتراکِ ماهانه/سالانه). هر کس به اندازهی مصرفش سوال از
|
||||
* دستیار میخرد؛ اعتبار منقضی نمیشود و هر بسته چند روز قلبِ نامحدود هم هدیه دارد. هر روز
|
||||
* اول سوالهای رایگانِ همان روز خرج میشوند، بعد اعتبار.
|
||||
*/
|
||||
export function SubscriptionScreen() {
|
||||
const plans = useAsync(() => api.subscription.plans(), []);
|
||||
const status = useAsync(() => api.subscription.status(), []);
|
||||
const quota = useAsync(() => api.ai.quota(), []);
|
||||
const [buying, setBuying] = useState<Plan | null>(null);
|
||||
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درونبرنامهایِ خودش است؛ بستهای که
|
||||
// SKU ندارد آنجا خریدنی نیست (به زرینپال فرستاده نمیشود).
|
||||
const bazaar = inBazaarApp();
|
||||
|
||||
const activePlanId = status.data?.active ? status.data.planId : null;
|
||||
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
|
||||
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
|
||||
const reload = () => { status.reload(); quota.reload(); };
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ScreenHeader title="اشتراکِ ویژه" subtitle="یادگیری بدونِ وقفه" color="#ED6956" mascot={{ char: 'sigma', mood: 'excited', size: 60 }} />
|
||||
<ScreenHeader title="بستهی سوال" subtitle="هر وقت لازم داشتی، سوال بخر" color="#ED6956" mascot={{ char: 'pi', mood: 'excited', size: 60 }} />
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 py-5">
|
||||
{status.data?.active && (
|
||||
<div className="flex items-center gap-3 rounded-3xl border-2 border-mint bg-[#EAF8F0] p-4">
|
||||
<Crown size={22} className="flex-shrink-0 text-mint" />
|
||||
<div className="text-sm leading-7 text-ink">
|
||||
<b>اشتراکت فعاله.</b>
|
||||
{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}.</>}
|
||||
<div className="text-xs text-muted">اگه دوباره بخری، از روزِ پایانِ همین اشتراک تمدید میشه.</div>
|
||||
<div className="rounded-3xl border border-line bg-white p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-[#F5F1FF] text-violet"><Sparkles size={22} /></span>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-bold text-muted">اعتبارِ تو</div>
|
||||
<div className="text-2xl font-black text-ink">{toFa(credits)} <span className="text-sm font-bold text-body">سوال</span></div>
|
||||
</div>
|
||||
{quota.data && (
|
||||
<div className="text-left text-xs leading-6 text-muted">
|
||||
رایگانِ امروز<br />
|
||||
<b className="text-base text-ink">{toFa(quota.data.remainingQuota)}</b> از {toFa(quota.data.freeDaily)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div 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>
|
||||
<p className="mt-3 border-t border-line pt-3 text-xs leading-6 text-muted">
|
||||
هر روز {toFa(plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول رایگانه؛ بعدش از اعتبارت کم میشه. اعتبار هیچوقت منقضی نمیشه.
|
||||
</p>
|
||||
{heartsUntil && (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-coral">
|
||||
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود تا {formatJalaliDate(heartsUntil)}
|
||||
</p>
|
||||
)}
|
||||
{status.data?.active && (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-mint">
|
||||
<Crown size={13} /> اشتراکِ قبلیت هم فعاله{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}</>}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plans.loading ? (
|
||||
<LoadingState text="دارم پلنها رو میارم…" />
|
||||
<LoadingState text="دارم بستهها رو میارم…" />
|
||||
) : plans.error ? (
|
||||
<ErrorState error={plans.error} onRetry={plans.reload} />
|
||||
) : !plans.data?.length ? (
|
||||
<EmptyState text="فعلاً پلنی برای خرید تعریف نشده" />
|
||||
) : !plans.data?.plans.length ? (
|
||||
<EmptyState text="فعلاً بستهای برای خرید تعریف نشده" />
|
||||
) : (
|
||||
plans.data.map((plan) => {
|
||||
plans.data.plans.map((plan) => (
|
||||
<PackCard key={plan.id} plan={plan} available={!bazaar || !!plan.bazaarSku} onBuy={() => setBuying(plan)} />
|
||||
))
|
||||
)}
|
||||
|
||||
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
|
||||
{bazaar ? 'پرداخت از طریقِ کافهبازار انجام میشه.' : 'پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); reload(); }} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean; onBuy: () => void }) {
|
||||
const featured = plan.popular;
|
||||
const isCurrent = plan.id === activePlanId;
|
||||
const isPack = plan.kind === 'pack';
|
||||
const tone = featured ? 'text-white/80' : 'text-muted';
|
||||
return (
|
||||
<div key={plan.id} className="relative pt-3">
|
||||
<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>
|
||||
<span className="absolute right-5 top-0 z-10 rounded-full bg-[#FFD23F] px-3 py-1 text-[11px] font-black text-ink shadow">{plan.badge}</span>
|
||||
)}
|
||||
<div className={`rounded-3xl p-5 ${featured ? 'bg-orange text-white shadow-lg shadow-orange/30' : 'border border-line bg-white text-ink'}`}>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-black">{plan.title}</h3>
|
||||
<p className={`mt-0.5 text-xs ${featured ? 'text-white/75' : 'text-muted'}`}>{durationLabel(plan.durationDays)}</p>
|
||||
<h3 className="text-2xl font-black">{isPack ? <>{toFa(plan.questionCredits)} <span className="text-base">سوال</span></> : plan.title}</h3>
|
||||
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ «پی»، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-base font-black">{plan.price === 0 ? 'رایگان' : formatToman(plan.price)}</p>
|
||||
{plan.discountPercent > 0 && (
|
||||
<span className={`mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-black ${featured ? 'bg-white/20' : 'bg-[#fde9e5] text-coral'}`}>
|
||||
{toFa(plan.discountPercent)}٪ تخفیف
|
||||
</span>
|
||||
<p className="text-base font-black">{formatToman(plan.price)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="mb-4 flex flex-col gap-2 text-sm">
|
||||
{isPack && plan.heartsDays > 0 && (
|
||||
<li className="flex items-center gap-2 font-bold">
|
||||
<Heart size={14} fill={featured ? '#fff' : '#ED6956'} className={featured ? 'text-white' : 'text-coral'} />
|
||||
هدیه: {toFa(plan.heartsDays)} روز قلبِ نامحدود
|
||||
</li>
|
||||
)}
|
||||
{!isPack && plan.aiDailyQuota > 0 && (
|
||||
<li className="flex items-center gap-2 font-bold"><Sparkles size={14} /> {toFa(plan.aiDailyQuota)} سوال در روز از دستیار</li>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{plan.features.length > 0 && (
|
||||
<ul className="mb-4 flex flex-col gap-2">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-sm">
|
||||
<li key={f} className="flex items-center gap-2">
|
||||
<Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
|
||||
<span className={featured ? 'text-white/90' : 'text-body'}>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={plan.price === 0 || isCurrent}
|
||||
onClick={() => setBuying(plan)}
|
||||
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-60 ${
|
||||
featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'
|
||||
}`}
|
||||
onClick={onBuy}
|
||||
disabled={!available}
|
||||
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-50 ${featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'}`}
|
||||
>
|
||||
{isCurrent ? 'اشتراکِ فعلیِ تو' : plan.price === 0 ? 'رایگان' : status.data?.active ? 'تمدید با این پلن' : 'انتخابِ این پلن'}
|
||||
{available ? 'خرید' : 'بهزودی در بازار'}
|
||||
</button>
|
||||
</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 +146,72 @@ export function SubscriptionScreen() {
|
||||
* و بعد کاربر در همین برگه به درگاه میرود. window.open بعد از await را
|
||||
* Safariِ آیفون مسدود میکند؛ برای همین جابهجاییِ همین صفحه است.
|
||||
*/
|
||||
function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; onClose: () => void; onPaidWithCode: () => void }) {
|
||||
function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) {
|
||||
const [code, setCode] = useState('');
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [freeDone, setFreeDone] = useState(false);
|
||||
const [done, setDone] = useState<'code' | 'bazaar' | null>(null);
|
||||
|
||||
/**
|
||||
* داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید.
|
||||
* کدِ تخفیف اینجا نیست: بازار قیمتِ ثابتِ محصول را میگیرد و سرور تخفیف را رد میکند.
|
||||
*/
|
||||
async function payWithBazaar(plan: Plan) {
|
||||
const sku = plan.bazaarSku;
|
||||
let key = bazaarPending.get(sku);
|
||||
if (key) {
|
||||
// کلیدِ مانده از خریدِ قبلی فقط وقتی به کار میآید که سفارشش هنوز در انتظار است؛ سفارشِ
|
||||
// پرداختشده یا ناموفق با خریدِ تازه قاطی نشود.
|
||||
const previous = await api.subscription.purchaseBazaar(plan.id, key).catch(() => null);
|
||||
const status = previous ? await api.subscription.order(previous.orderId).then((o) => o.status).catch(() => null) : null;
|
||||
if (status !== 'pending') key = null;
|
||||
}
|
||||
if (!key) {
|
||||
key = newIdempotencyKey();
|
||||
bazaarPending.set(sku, key);
|
||||
}
|
||||
const order = await api.subscription.purchaseBazaar(plan.id, key);
|
||||
let token: string;
|
||||
try {
|
||||
token = await bazaarPurchase(order.bazaarSku || sku, order.orderId);
|
||||
} catch (err) {
|
||||
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return false;
|
||||
throw err;
|
||||
}
|
||||
const result = await api.subscription.verifyBazaar(order.orderId, token);
|
||||
if (result.status !== 'paid') throw new Error('bazaar purchase not confirmed');
|
||||
bazaarPending.clear(sku);
|
||||
bazaarConsume(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function pay() {
|
||||
if (!plan || busy) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
if (bazaar) {
|
||||
try {
|
||||
if (await payWithBazaar(plan)) setDone('bazaar');
|
||||
} catch (err) {
|
||||
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
|
||||
}
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const order = await api.subscription.purchase(plan.id);
|
||||
if (code.trim()) {
|
||||
const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
|
||||
if (redeemed.orderStatus === 'paid') {
|
||||
setFreeDone(true);
|
||||
setDone('code');
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!order.paymentUrl) throw new Error('order without paymentUrl');
|
||||
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
|
||||
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
|
||||
window.location.assign(order.paymentUrl);
|
||||
} catch (err) {
|
||||
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
|
||||
@@ -144,19 +220,21 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (freeDone) onPaidWithCode();
|
||||
if (done) onPaid();
|
||||
else onClose();
|
||||
setCode('');
|
||||
setShowCode(false);
|
||||
setError('');
|
||||
setFreeDone(false);
|
||||
setDone(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={plan !== null} onClose={close} title={freeDone ? 'اشتراکت فعال شد! 🎉' : plan?.title}>
|
||||
{freeDone ? (
|
||||
<Sheet open={plan !== null} onClose={close} title={done ? 'خریدت انجام شد! 🎉' : plan?.title}>
|
||||
{done ? (
|
||||
<>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">کدِ تخفیفت کلِ مبلغ رو پوشوند و اشتراکت همین الان فعال شد.</p>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و سوالها به اعتبارت اضافه شدن.'}
|
||||
</p>
|
||||
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
||||
</>
|
||||
) : plan && (
|
||||
@@ -165,7 +243,7 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
|
||||
<span className="text-sm text-body">مبلغ</span>
|
||||
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
|
||||
</div>
|
||||
{showCode ? (
|
||||
{bazaar ? null : showCode ? (
|
||||
<label className="mb-4 block">
|
||||
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
|
||||
<input
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Bug, MessageCircle, Send } from 'lucide-react';
|
||||
import { api, type Ticket } from '@/api';
|
||||
import { api, type TicketSummary } from '@/api';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { KEYS, store } from '@/lib/storage';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { InlineError, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
|
||||
import { InlineError, PrimaryButton, Screen, ScreenBody, ScreenHeader, Sep } from '@/components/ui';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'learning', label: 'درس و سوالها' },
|
||||
@@ -22,15 +22,6 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
closed: 'بسته',
|
||||
};
|
||||
|
||||
/** سرور فهرستِ تیکتها را نمیدهد؛ شناسهها را همینجا نگه میداریم. */
|
||||
function savedTicketIds(): string[] {
|
||||
try {
|
||||
return JSON.parse(store.get(KEYS.ticketIds) ?? '[]') as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function SupportScreen() {
|
||||
const toast = useToast();
|
||||
const [mode, setMode] = useState<'ticket' | 'bug'>('ticket');
|
||||
@@ -39,7 +30,8 @@ export function SupportScreen() {
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [ticketIds, setTicketIds] = useState(savedTicketIds);
|
||||
// فهرست از سرور میآید، پس تیکت و پاسخش با عوض شدنِ گوشی هم دیده میشود.
|
||||
const tickets = useAsync(() => api.support.tickets(), []);
|
||||
|
||||
async function submit() {
|
||||
if (!subject.trim() || !text.trim()) return;
|
||||
@@ -52,9 +44,7 @@ export function SupportScreen() {
|
||||
} else {
|
||||
const t = await api.support.createTicket(subject.trim(), category);
|
||||
await api.support.reply(t.ticketId, text.trim());
|
||||
const ids = [t.ticketId, ...ticketIds].slice(0, 20);
|
||||
store.set(KEYS.ticketIds, JSON.stringify(ids));
|
||||
setTicketIds(ids);
|
||||
tickets.reload();
|
||||
toast('پیامت رسید؛ جواب همینجا و در اعلانها میاد.', 'success');
|
||||
}
|
||||
setSubject('');
|
||||
@@ -116,11 +106,11 @@ export function SupportScreen() {
|
||||
<Send size={16} className="-scale-x-100" /> ارسال
|
||||
</PrimaryButton>
|
||||
|
||||
{ticketIds.length > 0 && (
|
||||
{!!tickets.data?.length && (
|
||||
<section>
|
||||
<h2 className="mb-2 mt-2 text-sm font-black text-ink">پیامهای قبلیِ تو</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{ticketIds.map((id) => <TicketCard key={id} ticketId={id} />)}
|
||||
{tickets.data.map((t) => <TicketCard key={t.ticketId} summary={t} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -129,26 +119,24 @@ export function SupportScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function TicketCard({ ticketId }: { ticketId: string }) {
|
||||
function TicketCard({ summary }: { summary: TicketSummary }) {
|
||||
const toast = useToast();
|
||||
const ticket = useAsync(() => api.support.ticket(ticketId), [ticketId]);
|
||||
const [open, setOpen] = useState(false);
|
||||
// پیامها فقط وقتی کارت باز شود گرفته میشوند؛ فهرست خودش خلاصه را دارد.
|
||||
const detail = useAsync(() => (open ? api.support.ticket(summary.ticketId) : Promise.resolve(null)), [open, summary.ticketId]);
|
||||
const [reply, setReply] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
if (ticket.error || !ticket.data) {
|
||||
return ticket.loading ? <div className="h-14 animate-pulse rounded-2xl bg-soft" /> : null;
|
||||
}
|
||||
const t: Ticket = ticket.data;
|
||||
const staffReplies = t.messages.filter((m) => m.sender !== 'user').length;
|
||||
const t = detail.data;
|
||||
const status = t?.status ?? summary.status;
|
||||
const staffReplies = t ? t.messages.filter((m) => m.sender !== 'user').length : summary.supportReplies;
|
||||
|
||||
async function send() {
|
||||
if (!reply.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await api.support.reply(ticketId, reply.trim());
|
||||
await api.support.reply(summary.ticketId, reply.trim());
|
||||
setReply('');
|
||||
ticket.reload();
|
||||
detail.reload();
|
||||
} catch (err) {
|
||||
toast(friendlyError(err), 'error');
|
||||
} finally {
|
||||
@@ -160,24 +148,28 @@ function TicketCard({ ticketId }: { ticketId: string }) {
|
||||
<div className="rounded-2xl border border-line bg-white">
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-3 p-3.5 text-right">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-bold text-ink">{t.subject}</span>
|
||||
<span className="block truncate text-sm font-bold text-ink">{summary.subject}</span>
|
||||
<span className="text-[11px] text-muted">
|
||||
{STATUS_LABEL[t.status] ?? t.status}
|
||||
{staffReplies > 0 && ` · ${toFa(staffReplies)} پاسخ`}
|
||||
{STATUS_LABEL[status] ?? status}
|
||||
{staffReplies > 0 && <><Sep />{toFa(staffReplies)} پاسخ</>}
|
||||
</span>
|
||||
</span>
|
||||
{staffReplies > 0 && !open && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-indigo" aria-hidden />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="flex flex-col gap-2 border-t border-line p-3.5">
|
||||
{t.messages.map((m) => (
|
||||
{!t && (detail.error
|
||||
? <InlineError>{friendlyError(detail.error)}</InlineError>
|
||||
: <div className="h-10 animate-pulse rounded-2xl bg-soft" />)}
|
||||
{t?.messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-xs leading-6 ${m.sender === 'user' ? 'mr-8 bg-soft text-body' : 'ml-8 bg-[#EEF1FF] text-ink'}`}
|
||||
>
|
||||
{m.text}
|
||||
<MathText text={m.text} />
|
||||
</div>
|
||||
))}
|
||||
{t.status !== 'closed' && (
|
||||
{t && status !== 'closed' && (
|
||||
<div className="mt-1 flex gap-2">
|
||||
<input
|
||||
value={reply}
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Check, CheckCircle2, Circle, Lock, Play, Sparkles } from 'lucide-react';
|
||||
import { api, type HomeNode } from '@/api';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import type { HomeNode } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { findUnit, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { findUnit, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { EmptyState, ErrorState, LoadingState, PrimaryButton, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
|
||||
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { IslandPath } from '@/components/IslandPath';
|
||||
import { IslandSheet } from '@/components/IslandSheet';
|
||||
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
const NODE_SIZE = 76;
|
||||
import { MathText } from '@/lib/MathText';
|
||||
|
||||
/** نقشهی جزیرههای یک فصل، مثلِ مسیرِ دولینگو. */
|
||||
export function UnitScreen({ unitId }: { unitId: string }) {
|
||||
const { home, homeError, refreshHome, companion } = useAppData();
|
||||
const nav = useNav();
|
||||
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
|
||||
const found = findUnit(home, unitId);
|
||||
|
||||
@@ -32,14 +29,13 @@ export function UnitScreen({ unitId }: { unitId: string }) {
|
||||
const { unit, unitIndex } = found;
|
||||
const color = unitColor(unitIndex);
|
||||
const progress = unitProgress(unit);
|
||||
const points = zigzagPositions(unit.nodes.length, 125, 90);
|
||||
const height = points.length ? points[points.length - 1].y + 150 : 400;
|
||||
const current = unit.nodes.find((n) => n.status !== 'completed' && n.status !== 'locked') ?? null;
|
||||
|
||||
return (
|
||||
<Screen className="bg-[#f7f8ff]">
|
||||
<Screen className="bg-page">
|
||||
<ScreenHeader
|
||||
title={unit.title}
|
||||
subtitle={unit.blurb || `${toFa(progress.done)} از ${toFa(progress.total)} جزیره`}
|
||||
subtitle={unit.blurb ? <MathText text={unit.blurb} /> : `${toFa(progress.done)} از ${toFa(progress.total)} جزیره`}
|
||||
color={color}
|
||||
back
|
||||
mascot={{ char: companion, mood: progress.percent >= 50 ? 'happy' : 'idle', size: 72 }}
|
||||
@@ -53,160 +49,29 @@ export function UnitScreen({ unitId }: { unitId: string }) {
|
||||
</div>
|
||||
</ScreenHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto pb-6 pt-10">
|
||||
{unit.nodes.length === 0 ? (
|
||||
<EmptyState text="این فصل هنوز جزیرهای نداره" />
|
||||
) : (
|
||||
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
|
||||
<ZigzagPath points={points} height={height} colorOf={(i) => (unit.nodes[i].status === 'completed' ? color : null)} />
|
||||
{unit.nodes.map((node, i) => {
|
||||
const locked = node.status === 'locked';
|
||||
const done = node.status === 'completed';
|
||||
const current = !locked && !done;
|
||||
const Icon = iconFor(node.icon, i);
|
||||
return (
|
||||
<motion.div
|
||||
key={node.id}
|
||||
initial={{ opacity: 0, scale: 0.7 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: i * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ left: `${points[i].x}%`, top: points[i].y }}
|
||||
>
|
||||
{current && (
|
||||
<>
|
||||
<div
|
||||
className="absolute -top-14 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-2xl border-2 bg-white px-3 py-1.5 text-[11px] font-black shadow-lg"
|
||||
style={{ color, borderColor: color }}
|
||||
>
|
||||
{node.title}
|
||||
<span className="absolute -bottom-2 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 border-b-2 border-r-2 bg-white" style={{ borderColor: color }} />
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.18, 1], opacity: [0.25, 0.05, 0.25] }}
|
||||
transition={{ duration: 1.8, repeat: Infinity }}
|
||||
className="absolute inset-[-13px] rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
<IslandPath
|
||||
nodes={unit.nodes}
|
||||
color={color}
|
||||
currentId={current?.id ?? null}
|
||||
onOpen={setOpenNode}
|
||||
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${node.title}${locked ? ' (قفل)' : done ? ' (تمامشده)' : ''}`}
|
||||
onClick={() => setOpenNode(node)}
|
||||
className="relative z-10 flex items-center justify-center rounded-full transition-transform active:translate-y-1"
|
||||
style={{
|
||||
width: NODE_SIZE,
|
||||
height: NODE_SIZE,
|
||||
backgroundColor: locked ? '#e5e4ec' : done ? color : '#fff',
|
||||
border: current ? `4px solid ${color}` : 'none',
|
||||
boxShadow: locked ? '0 6px 0 #cccbd6' : done ? `0 7px 0 ${color}99` : `0 7px 0 ${color}55, 0 0 0 5px white`,
|
||||
}}
|
||||
>
|
||||
{locked ? (
|
||||
<Lock size={24} strokeWidth={2.5} className="text-[#aaa9b8]" />
|
||||
) : done ? (
|
||||
<Check size={31} color="white" strokeWidth={3.5} />
|
||||
) : (
|
||||
<Icon size={28} style={{ color }} strokeWidth={2.5} />
|
||||
)}
|
||||
</button>
|
||||
{!current && (
|
||||
<div className="absolute left-1/2 top-full mt-2.5 w-28 -translate-x-1/2 truncate rounded-lg bg-[#f7f8ff]/90 text-center text-[10px] font-bold text-muted">
|
||||
{node.title}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 70 }}>
|
||||
<span className="mb-1 text-[10px] font-bold text-[#aaa9bd]">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
|
||||
<div className="mt-6 flex flex-col items-center">
|
||||
<span className="mb-1 text-[11px] font-bold text-muted">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>
|
||||
<Sparkles size={20} style={{ color }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NodeSheet node={openNode} color={color} onClose={() => setOpenNode(null)} />
|
||||
<IslandSheet node={openNode} color={color} onClose={() => setOpenNode(null)} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
/** برگهی جزیره: درسهایش، و دکمهی شروعِ درسِ بعدی. */
|
||||
function NodeSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
|
||||
const nav = useNav();
|
||||
const lessons = useAsync(() => (node ? api.nodeLessons(node.id) : Promise.resolve(null)), [node?.id]);
|
||||
const next = node ? nextLessonOf(node) : null;
|
||||
const locked = node?.status === 'locked';
|
||||
|
||||
const open = (lessonId: number) => {
|
||||
if (!node) return;
|
||||
onClose();
|
||||
nav.push({ name: 'lesson', lessonId, nodeId: node.id });
|
||||
};
|
||||
|
||||
// فقط درسهایی که سوال دارند در lessonIds هستند؛ بقیه هنوز آزمون ندارند.
|
||||
const playable = new Set(node?.lessonIds ?? []);
|
||||
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
|
||||
const doneCount = list.filter((l) => l.done).length;
|
||||
|
||||
return (
|
||||
<Sheet open={node !== null} onClose={onClose} title={node?.title}>
|
||||
{locked ? (
|
||||
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
|
||||
<Lock size={24} className="text-muted" />
|
||||
</span>
|
||||
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیرهی قبلی رو تموم کن تا باز بشه.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{lessons.loading ? (
|
||||
<div className="py-6 text-center text-sm text-muted">دارم درسها رو میارم…</div>
|
||||
) : lessons.error ? (
|
||||
<ErrorState error={lessons.error} onRetry={lessons.reload} />
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
{toFa(doneCount)} از {toFa(list.length)} درس تموم شده
|
||||
</p>
|
||||
<ul className="mb-5 flex max-h-72 flex-col gap-1.5 overflow-y-auto">
|
||||
{list.map((lesson) => {
|
||||
const isNext = Number(lesson.id) === next;
|
||||
return (
|
||||
<li key={lesson.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open(Number(lesson.id))}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-2.5 text-right text-sm ${
|
||||
isNext ? 'border-2 bg-white font-bold' : 'border-line bg-white'
|
||||
}`}
|
||||
style={isNext ? { borderColor: color } : undefined}
|
||||
>
|
||||
{lesson.done ? (
|
||||
<CheckCircle2 size={18} className="flex-shrink-0 text-mint" />
|
||||
) : isNext ? (
|
||||
<Play size={16} className="flex-shrink-0" style={{ color }} fill={color} />
|
||||
) : (
|
||||
<Circle size={18} className="flex-shrink-0 text-faint" />
|
||||
)}
|
||||
<span className={`flex-1 ${lesson.done ? 'text-muted' : 'text-ink'}`}>{lesson.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{next != null && (
|
||||
<PrimaryButton color={color} onClick={() => open(next)}>
|
||||
<Play size={18} fill="white" /> {node?.status === 'completed' ? 'مرور دوباره' : 'شروعِ درسِ بعدی'}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import type { UsedChunk } from '@/api';
|
||||
import type { Diagram, UsedChunk } from '@/api';
|
||||
import { KEYS, store } from '@/lib/storage';
|
||||
|
||||
/**
|
||||
@@ -13,9 +13,13 @@ export interface ChatMessage {
|
||||
messageId?: string;
|
||||
usedChunks?: UsedChunk[];
|
||||
followUps?: string[];
|
||||
/** شکلِ پاسخ؛ اپ آن را خودش به SVG تبدیل میکند (components/Diagram). */
|
||||
diagram?: Diagram | null;
|
||||
feedback?: 'up' | 'down';
|
||||
/** پیامِ خطا بهجای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده میشود. */
|
||||
failedQuestion?: string;
|
||||
/** سهمیهی رایگانِ امروز تمام شده؛ بهجای «دوباره»، راهِ اشتراک نشان داده میشود. */
|
||||
quotaReached?: boolean;
|
||||
}
|
||||
|
||||
interface AskState {
|
||||
|
||||
@@ -21,11 +21,14 @@
|
||||
|
||||
--color-ink: #1a1a2e; /* متنِ اصلی */
|
||||
--color-body: #3a3a5c; /* متنِ بدنه */
|
||||
--color-muted: #9e9ec0; /* متنِ کمرنگ */
|
||||
/* متنِ کمرنگ. تا ۲۰۲۶-۱۰-۰۳ #9e9ec0 بود: کنتراست ۲٫۶ روی سفید، زیرِ حدِ ۴٫۵ِ WCAG، و
|
||||
همین رنگ زیرنویسِ ۱۰–۱۲ پیکسلیِ همهی صفحهها و آیکونِ تبهای غیرفعال است. این یکی
|
||||
روی سفید ۴٫۵ و روی پسزمینهی صفحه ۴٫۲ است. */
|
||||
--color-muted: #727299;
|
||||
--color-faint: #b0b0c8;
|
||||
--color-line: #ede9ff; /* حاشیهی کارت */
|
||||
--color-line: #e9e6f7; /* حاشیهی کارت */
|
||||
--color-soft: #f0eeff; /* پسزمینهی ملایم */
|
||||
--color-page: #f0f4ff; /* پسزمینهی صفحه */
|
||||
--color-page: #f6f5fb; /* پسزمینهی صفحه؛ کمی گرمتر از آبیِ قبلی */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -47,6 +50,30 @@
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* کشیدنِ صفحه به پایین در کروم اندروید کلِ اپ را رفرش میکرد (pull-to-refresh)
|
||||
و وسطِ آزمون جوابِ جاری را میپراند؛ صفحههای داخلی خودشان اسکرول دارند. */
|
||||
html, body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
/* حلقهی فوکوس فقط برای صفحهکلید (focus-visible)، نه برای لمس. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-indigo);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* کاربری که در تنظیماتِ گوشی «کاهشِ حرکت» را زده: انیمیشنها و transitionهای CSS
|
||||
تقریباً آنی میشوند. انیمیشنهای motion را MotionConfig در App خاموش میکند. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
input, textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ export default defineConfig(({ mode }) => {
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// کتابخانهها با هر انتشار عوض نمیشوند؛ فایلِ جدا یعنی بعد از هر دیپلوی
|
||||
// از کشِ مرورگر میآیند و فقط کدِ خودِ اپ دوباره دانلود میشود.
|
||||
manualChunks: {
|
||||
react: ['react', 'react-dom'],
|
||||
motion: ['motion/react'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// در توسعه /api همدامنه است و به بکاند پاس داده میشود؛ CORS لازم نیست.
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user