Compare commits
16 Commits
android-bu
...
19ca3d7de1
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ca3d7de1 | |||
| f74b6ad279 | |||
| a8a5351862 | |||
| 8879b42b1c | |||
| 6930c83e07 | |||
| d0a2822658 | |||
| 50f6ade9f8 | |||
| 22fc870176 | |||
| 54b872026d | |||
| 54bd865835 | |||
| dc1fb15a55 | |||
| 09ca1a270a | |||
| 838049c469 | |||
| 2fbcf6e6e0 | |||
| 5b98c054ea | |||
| c64b3939ba |
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"java.configuration.updateBuildConfiguration": "interactive"
|
||||||
|
}
|
||||||
1
android/.gitignore
vendored
@@ -6,4 +6,3 @@ local.properties
|
|||||||
*.iml
|
*.iml
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
build-out/
|
|
||||||
|
|||||||
@@ -5,48 +5,27 @@
|
|||||||
محصولِ دیجیتال را فقط از راهِ خودش میپذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
|
محصولِ دیجیتال را فقط از راهِ خودش میپذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
|
||||||
فرانت بهروز میشود، بیآنکه نسخهی تازهی اپ لازم باشد.
|
فرانت بهروز میشود، بیآنکه نسخهی تازهی اپ لازم باشد.
|
||||||
|
|
||||||
> ⚠️ APIِ Poolakey از README خودِ کتابخانه است و خرید روی گوشی هنوز آزموده نشده.
|
> ⚠️ **این پروژه هنوز یک بار هم build نشده.** روی ماشینی نوشته شد که Android SDK نداشت
|
||||||
|
> (۲۰۲۶-۱۰-۰۳). APIِ Poolakey از README خودِ کتابخانه است. اولین کار: باز کردن در Android
|
||||||
|
> Studio، build، و یک خریدِ واقعی روی گوشی.
|
||||||
|
|
||||||
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
|
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
|
||||||
|
|
||||||
| چه | کجا گذاشته میشود | وضعیت |
|
| چه | کجا گذاشته میشود | وضعیت |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.app` حدس است؛ تأیید نشده |
|
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.app` حدس است؛ تأیید نشده |
|
||||||
| کلیدِ RSAِ عمومیِ اپ | `bazaarRsaKey` در فایلِ تنظیماتِ بیرون از ریپو (پایین) | ندارد؛ بدونِ آن build نوعِ release نمیشود |
|
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | ندارد؛ بدونِ آن خرید شروع نمیشود |
|
||||||
| SKUِ هر بسته (محصولِ **مصرفشدنی**) | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچکدام تعریف نشده |
|
| 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
|
## build
|
||||||
|
|
||||||
نیازها (یک بار): JDK 17 (`brew install openjdk@17`) و Android SDK با `platforms;android-34` و `build-tools;34.0.0` در `~/Library/Android/sdk`.
|
1. Android Studio ← Open ← همین پوشهی `android/`. Studio خودش Gradle wrapper را میسازد.
|
||||||
|
2. `Build ← Generate Signed App Bundle / APK`، با کلیدِ امضای شرکت. **کلیدِ امضا را گم نکنید**:
|
||||||
```bash
|
بدونِ همان کلید هیچ نسخهی بعدی روی بازار بهروزرسانی نمیشود.
|
||||||
cd android && ./build-apk.sh
|
3. نصب روی گوشیای که کافهبازار دارد و با حسابِ تست وارد شده.
|
||||||
```
|
|
||||||
|
|
||||||
خروجی `android/build-out/Tadrisino-<نسخه>-<versionCode>.apk` است. پیش از هر انتشار `versionCode` را در `app/build.gradle.kts`
|
|
||||||
بالا ببرید (بازار نسخهی با شمارهی تکراری را نمیپذیرد). اسکریپت امضا را میسنجد و اثرانگشتِ گواهی را چاپ میکند؛ باید با APKِ قبلی یکی باشد.
|
|
||||||
APKِ آزمایشی بینیاز از کلید: `./gradlew assembleDebug`.
|
|
||||||
|
|
||||||
## جریانِ خرید (برای عیبیابی)
|
## جریانِ خرید (برای عیبیابی)
|
||||||
|
|
||||||
|
|||||||
@@ -1,63 +1,91 @@
|
|||||||
import java.util.Properties
|
// plugins {
|
||||||
|
// id("com.android.application")
|
||||||
|
// id("org.jetbrains.kotlin.android")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// android {
|
||||||
|
// // ⚠️ اسمِ پکیج باید همانی باشد که در پیشخانِ بازار ثبت است. com.tadrisino.app را بازار
|
||||||
|
// // برای حسابِ شرکت «Product is not found» جواب داد (یعنی هست)، ولی سنیور تأیید نکرده
|
||||||
|
// // که همان اپِ ریاضی است (CLAUDE.mdِ بکاند، ۲۰۲۶-۰۹-۳۰).
|
||||||
|
// namespace = "com.tadrisino.app"
|
||||||
|
// compileSdk = 34
|
||||||
|
|
||||||
|
// defaultConfig {
|
||||||
|
// applicationId = "com.tadrisino.math.app"
|
||||||
|
// minSdk = 23
|
||||||
|
// targetSdk = 34
|
||||||
|
// versionCode = 1
|
||||||
|
// versionName = "1.0.0"
|
||||||
|
|
||||||
|
// // آدرسِ همان فرانتِ وب. دامنه عوض شود، فقط همین خط.
|
||||||
|
// buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
|
||||||
|
// // کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (بخشِ «پرداخت درونبرنامهای»). کلیدِ عمومی
|
||||||
|
// // است و در اپ مینشیند؛ رمز نیست. تا پر نشود، اپ خرید را شروع نمیکند.
|
||||||
|
// buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// buildFeatures {
|
||||||
|
// buildConfig = true
|
||||||
|
// }
|
||||||
|
|
||||||
|
// buildTypes {
|
||||||
|
// release {
|
||||||
|
// isMinifyEnabled = false
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// compileOptions {
|
||||||
|
// sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
// targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
// }
|
||||||
|
// kotlinOptions {
|
||||||
|
// jvmTarget = "17"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// dependencies {
|
||||||
|
// implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
// implementation("androidx.activity:activity-ktx:1.9.2")
|
||||||
|
// implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
|
||||||
|
// }
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
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 {
|
android {
|
||||||
// ⚠️ اسمِ پکیج باید همانی باشد که در پیشخانِ بازار ثبت است. com.tadrisino.app را بازار
|
// Package name must match the one registered on Cafe Bazaar.
|
||||||
// برای حسابِ شرکت «Product is not found» جواب داد (یعنی هست)، ولی سنیور تأیید نکرده
|
// Confirm the final applicationId before publishing.
|
||||||
// که همان اپِ ریاضی است (CLAUDE.mdِ بکاند، ۲۰۲۶-۰۹-۳۰).
|
|
||||||
namespace = "com.tadrisino.app"
|
namespace = "com.tadrisino.app"
|
||||||
compileSdk = 34
|
compileSdk = 34
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.tadrisino.app"
|
applicationId = "com.tadrisino.math.app"
|
||||||
minSdk = 23
|
minSdk = 23
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0.0"
|
versionName = "1.0.0"
|
||||||
|
|
||||||
// آدرسِ همان فرانتِ وب. دامنه عوض شود، فقط همین خط.
|
// URL of the web frontend.
|
||||||
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
|
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
|
||||||
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (بخشِ «پرداخت درونبرنامهای»). کلیدِ عمومی
|
// Public RSA key from the Cafe Bazaar panel. Purchases won't start until it is filled in.
|
||||||
// است و در اپ مینشیند؛ رمز نیست. تا پر نشود، اپ خرید را شروع نمیکند.
|
buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
|
||||||
// از فایلِ بیرون از ریپو؛ برای 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 {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file("my-release-key.keystore")
|
||||||
|
storePassword = providers.gradleProperty("MYAPP_STORE_PASSWORD").orNull
|
||||||
|
keyAlias = "my-key-alias"
|
||||||
|
keyPassword = providers.gradleProperty("MYAPP_KEY_PASSWORD").orNull
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
@@ -78,4 +106,4 @@ dependencies {
|
|||||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
implementation("androidx.activity:activity-ktx:1.9.2")
|
implementation("androidx.activity:activity-ktx:1.9.2")
|
||||||
implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
|
implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Tadrisino">
|
android:theme="@style/Theme.Tadrisino">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">تدریسینو</string>
|
<string name="app_name">تدریسینو ریاضی</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
@@ -1,9 +0,0 @@
|
|||||||
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
@@ -1,248 +0,0 @@
|
|||||||
#!/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
@@ -1,112 +0,0 @@
|
|||||||
@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%
|
|
||||||
@@ -68,6 +68,8 @@ export interface HomeUnit {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
blurb: string;
|
blurb: string;
|
||||||
|
/** این کاربر برای بازیِ این فصل اشتراک لازم دارد (فقط فصلِ اولِ هر پایه رایگان است). */
|
||||||
|
requiresSubscription: boolean;
|
||||||
nodes: HomeNode[];
|
nodes: HomeNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +261,7 @@ export interface Plan {
|
|||||||
badge: string;
|
badge: string;
|
||||||
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
|
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
|
||||||
aiDailyQuota: number;
|
aiDailyQuota: number;
|
||||||
/** «pack» بستهی سوال است (از ۱۴۰۵-۰۷)؛ «subscription» اشتراکِ زمانیِ قدیمی. */
|
/** «pack» بستهی سوال؛ «subscription» اشتراکِ زمانی که همهی فصلها را باز میکند. */
|
||||||
kind: 'pack' | 'subscription';
|
kind: 'pack' | 'subscription';
|
||||||
/** بسته: سوالهایی که به اعتبار اضافه میشود. */
|
/** بسته: سوالهایی که به اعتبار اضافه میشود. */
|
||||||
questionCredits: number;
|
questionCredits: number;
|
||||||
@@ -276,8 +278,14 @@ export interface PlansResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionStatus {
|
export interface SubscriptionStatus {
|
||||||
/** اشتراکِ زمانیِ قدیمی (ماهانه/سالانه)، نه بسته. */
|
/** اشتراکِ زمانیِ فعال (یک/سه/ششماهه)، نه بسته. */
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
/** نامِ پلنِ فعال؛ null بیاشتراک. */
|
||||||
|
planTitle: string | null;
|
||||||
|
/** طولِ دورهی پلنِ فعال به روز؛ مخرجِ نوارِ «چقدر مانده». */
|
||||||
|
planDurationDays: number | null;
|
||||||
|
/** سقفِ سوالِ روزانهی همین کاربر از دستیار. */
|
||||||
|
aiDailyQuota: number;
|
||||||
planId: string | null;
|
planId: string | null;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
/** سوالهای خریدهشدهی باقیمانده. */
|
/** سوالهای خریدهشدهی باقیمانده. */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, Suspense, useEffect, useState, type ComponentType } from 'react';
|
import { lazy, Suspense, useEffect, useRef, useState, type ComponentType } from 'react';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||||
import { api, type Grade, type UserProfile } from '@/api';
|
import { api, type Grade, type UserProfile } from '@/api';
|
||||||
@@ -28,7 +28,7 @@ const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScree
|
|||||||
const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen');
|
const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen');
|
||||||
const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen');
|
const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen');
|
||||||
const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen');
|
const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen');
|
||||||
const MapScreen = screen(() => import('@/screens/MapScreen'), 'MapScreen');
|
const LessonsScreen = screen(() => import('@/screens/LessonsScreen'), 'LessonsScreen');
|
||||||
const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen');
|
const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen');
|
||||||
const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen');
|
const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen');
|
||||||
const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen');
|
const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen');
|
||||||
@@ -71,18 +71,28 @@ function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
|
|||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
const { route } = nav;
|
const { route } = nav;
|
||||||
const showNav = isTab(route.name) && nav.tab === route.name;
|
const showNav = isTab(route.name) && nav.tab === route.name;
|
||||||
|
// صفحهی اولِ بعد از ورود بیانیمیشنِ ورود میآید، ولی این را روی خودِ motion.div میگذاریم نه
|
||||||
|
// `<AnimatePresence initial={false}>`: آن یکی به همهی فرزندهای motionِ صفحهی اول هم سرایت میکند و
|
||||||
|
// انیمیشنهای تکرارشوندهشان (ماسکوتِ بالاپایین، حلقهی جزیره، دکمهی «شروع») تا اولین تعویضِ تب شروع نمیشدند.
|
||||||
|
const firstRender = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
firstRender.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex-1 overflow-hidden">
|
<div className="relative flex-1 overflow-hidden bg-page">
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
<AnimatePresence mode="popLayout">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={JSON.stringify(route)}
|
key={JSON.stringify(route)}
|
||||||
className="absolute inset-0 flex flex-col"
|
className="absolute inset-0 flex flex-col bg-page"
|
||||||
initial={{ opacity: 0, x: -18 }}
|
initial={firstRender.current ? false : { opacity: 0, x: -14 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: 18 }}
|
// صفحهی قبلی نمیپرد و محو نمیشود، زیرِ صفحهی تازه میماند تا کامل بیاید. با محوشدنِ همزمانِ
|
||||||
transition={{ duration: 0.18 }}
|
// هر دو، پسزمینهی تیرهی body از وسط دیده میشد و تعویضِ تب «گلیچ» میزد (از روی کد؛ با
|
||||||
|
// فیلمِ دستگاهِ واقعی سنجیده نشده).
|
||||||
|
exit={{ opacity: 1, x: 0, transition: { duration: 0.16 } }}
|
||||||
|
transition={{ duration: 0.16, ease: 'easeOut' }}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<LoadingState />}>
|
<Suspense fallback={<LoadingState />}>
|
||||||
<RouteView route={route} onSignedOut={onSignedOut} />
|
<RouteView route={route} onSignedOut={onSignedOut} />
|
||||||
@@ -98,9 +108,9 @@ function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
|
|||||||
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
|
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
|
||||||
switch (route.name) {
|
switch (route.name) {
|
||||||
case 'home': return <HomeScreen />;
|
case 'home': return <HomeScreen />;
|
||||||
case 'map': return <MapScreen />;
|
case 'lessons': return <LessonsScreen />;
|
||||||
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
|
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
|
||||||
case 'subscription': return <SubscriptionScreen />;
|
case 'subscription': return <SubscriptionScreen key={route.view ?? ''} initialView={route.view} />;
|
||||||
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
|
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
|
||||||
case 'unit': return <UnitScreen unitId={route.unitId} />;
|
case 'unit': return <UnitScreen unitId={route.unitId} />;
|
||||||
case 'lesson': return <LessonScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
|
case 'lesson': return <LessonScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Brain, ShoppingBag, Home, Map, UserRound, type LucideIcon } from 'lucide-react';
|
import { Brain, ShoppingBag, Home, BookOpen, UserRound, type LucideIcon } from 'lucide-react';
|
||||||
import type { TabName } from '@/nav/routes';
|
import type { TabName } from '@/nav/routes';
|
||||||
|
|
||||||
const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
|
const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
|
||||||
{ id: 'home', icon: Home, label: 'خانه' },
|
{ id: 'home', icon: Home, label: 'خانه' },
|
||||||
{ id: 'map', icon: Map, label: 'نقشه' },
|
{ id: 'lessons', icon: BookOpen, label: 'درسها' },
|
||||||
{ id: 'ask', icon: Brain, label: 'دستیار' },
|
{ id: 'ask', icon: Brain, label: 'دستیار' },
|
||||||
{ id: 'subscription', icon: ShoppingBag, label: 'بستهها' },
|
{ id: 'subscription', icon: ShoppingBag, label: 'بستهها' },
|
||||||
{ id: 'profile', icon: UserRound, label: 'من' },
|
{ id: 'profile', icon: UserRound, label: 'من' },
|
||||||
@@ -20,7 +20,7 @@ export function BottomNav({ active, onChange }: { active: TabName | null; onChan
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(id)}
|
onClick={() => onChange(id)}
|
||||||
aria-current={on ? 'page' : undefined}
|
aria-current={on ? 'page' : undefined}
|
||||||
className={`flex min-w-14 flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-colors ${on ? 'text-coral' : 'text-muted'}`}
|
className={`flex min-w-14 flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-[color,transform] active:scale-90 ${on ? 'text-coral' : 'text-muted'}`}
|
||||||
>
|
>
|
||||||
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
|
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
|
||||||
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
|
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
|
||||||
|
|||||||
13
src/components/BrandFooter.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { toFa } from '@/lib/format';
|
||||||
|
import { BrandMark } from '@/components/BrandMark';
|
||||||
|
|
||||||
|
/** امضای تدریسینو پایینِ صفحهها: نشان، نام و حقِ نشر، کوچک و کمرنگ. */
|
||||||
|
export function BrandFooter() {
|
||||||
|
return (
|
||||||
|
<footer className="flex flex-col items-center gap-1 pb-4 pt-3 text-center">
|
||||||
|
<BrandMark size={24} tone="dark" />
|
||||||
|
<span className="text-[10px] text-muted">ریاضی، قدمبهقدم، از روی کتابِ درسی</span>
|
||||||
|
<span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو · همهی حقوق محفوظ است</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/BrandMark.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* نشانِ تدریسینو: یک کاشیِ گرد با «π» و جرقهی کوچکِ چشمکزن، کنارِ نامِ تدریسینو.
|
||||||
|
* خودِ SVG است (بیفایلِ تصویر)، پس در هر اندازه تیز است و چیزی دانلود نمیشود.
|
||||||
|
*/
|
||||||
|
export function BrandMark({ size = 28, tone = 'light', withName = true }: { size?: number; tone?: 'light' | 'dark'; withName?: boolean }) {
|
||||||
|
const ink = tone === 'light' ? '#fff' : '#3C3F8F';
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5" aria-label="تدریسینو">
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bm-g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stopColor="#FF9A5C" />
|
||||||
|
<stop offset="1" stopColor="#ED6956" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="1" y="1" width="30" height="30" rx="9" fill="url(#bm-g)" stroke={tone === 'light' ? 'rgba(255,255,255,.55)' : 'none'} />
|
||||||
|
<path d="M9 11.5h14M12.5 11.5v10M19 11.5v8.2c0 1.2.7 1.8 1.8 1.8" stroke="#fff" strokeWidth="2.6" strokeLinecap="round" fill="none" />
|
||||||
|
<path className="spark" d="M25 4.5l.9 1.9 1.9.9-1.9.9-.9 1.9-.9-1.9-1.9-.9 1.9-.9z" fill="#FFD23F" />
|
||||||
|
</svg>
|
||||||
|
{withName && <span className="font-black tracking-tight" style={{ color: ink, fontSize: size * 0.62 }}>تدریسینو</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ import { toFa } from '@/lib/format';
|
|||||||
import { MathText } from '@/lib/MathText';
|
import { MathText } from '@/lib/MathText';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* برگهی جزیره. کارِ اصلی یک دکمه است: «شروعِ آزمونِ جزیره». فهرستِ درسها
|
* برگهی جزیره. دو دکمه: «اول درسها رو بخون» (رنگی و بالا، چون تستکنندهها
|
||||||
* (معرفیِ هر نوع مسئله) زیرِ آن و بسته است، برای بچهای که اول میخواهد بخواند.
|
* دکمهی کمرنگِ قبلی را پیدا نمیکردند) و «شروعِ آزمونِ جزیره». فهرستِ درسها زیرِ دکمهی اول باز میشود.
|
||||||
*/
|
*/
|
||||||
export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
|
export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
@@ -55,20 +55,21 @@ export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; c
|
|||||||
<p className="mb-4 text-sm leading-7 text-body">
|
<p className="mb-4 text-sm leading-7 text-body">
|
||||||
{done
|
{done
|
||||||
? 'این جزیره رو تموم کردی! میتونی دوباره تمرینش کنی (امتیازِ تازه نداره).'
|
? 'این جزیره رو تموم کردی! میتونی دوباره تمرینش کنی (امتیازِ تازه نداره).'
|
||||||
: 'چند سوالِ کوتاه از همین جزیره. هر جوابی بدی، آخرش جزیرهی بعدی باز میشه.'}
|
: 'اگه مطمئن نیستی، اول درسها رو بخون؛ بعد آزمونِ کوتاهِ جزیره رو بده. هر جوابی بدی، آخرش جزیرهی بعدی باز میشه.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<PrimaryButton color={color} onClick={start}>
|
<PrimaryButton color={color} onClick={start}>
|
||||||
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروعِ آزمونِ جزیره</>}
|
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروعِ آزمونِ جزیره</>}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowLessons((s) => !s)}
|
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"
|
className="mt-3 mb-1 flex w-full items-center justify-center gap-2 rounded-2xl border-2 py-3 text-sm font-black transition-colors active:translate-y-[2px]"
|
||||||
|
style={{ borderColor: '#39FF14', color: '#15803d', backgroundColor: showLessons ? '#39FF1422' : 'white' }}
|
||||||
aria-expanded={showLessons}
|
aria-expanded={showLessons}
|
||||||
>
|
>
|
||||||
<BookOpen size={15} /> اول درسهاش رو بخونم
|
<BookOpen size={18} /> {done ? 'مرورِ درسها' : 'اول درسها رو بخون'}
|
||||||
<ChevronDown size={15} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} />
|
<ChevronDown size={16} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showLessons && (
|
{showLessons && (
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function NoHeartsScreen({
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-black text-ink">{back ? 'یه قلب برگشت!' : 'قلبهات تموم شد'}</h1>
|
<h1 className="text-2xl font-black text-ink">{back ? 'یه قلب برگشت!' : 'قلبهات تموم شد'}</h1>
|
||||||
<p className="mt-1.5 text-sm leading-7 text-muted">
|
<p className="mt-1.5 text-sm leading-7 text-muted">
|
||||||
{back ? 'بریم ادامه بدیم؛ این بار با دقتِ بیشتر 💪' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'}
|
{back ? 'بریم ادامه بدیم؛ این بار با دقتِ بیشتر' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { SeyarMascot } from '@/mascots/SeyarMascot';
|
|||||||
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
|
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
|
||||||
import { friendlyError } from '@/lib/errors';
|
import { friendlyError } from '@/lib/errors';
|
||||||
import { useNav } from '@/nav/Navigator';
|
import { useNav } from '@/nav/Navigator';
|
||||||
import { shade } from '@/lib/color';
|
import { onWhite, shade } from '@/lib/color';
|
||||||
|
|
||||||
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
|
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ export function ScreenHeader({
|
|||||||
color,
|
color,
|
||||||
back = false,
|
back = false,
|
||||||
mascot,
|
mascot,
|
||||||
|
exact = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
@@ -22,17 +23,24 @@ export function ScreenHeader({
|
|||||||
/** دکمهی بازگشت؛ true یعنی صفحهی قبل در پشته. */
|
/** دکمهی بازگشت؛ true یعنی صفحهی قبل در پشته. */
|
||||||
back?: boolean | (() => void);
|
back?: boolean | (() => void);
|
||||||
mascot?: { char: SeyarChar; mood?: SeyarMood; size?: number };
|
mascot?: { char: SeyarChar; mood?: SeyarMood; size?: number };
|
||||||
|
/** رنگ را همانطور که هست بگذار (بیتیرهکردن برای کنتراست)؛ انتخابِ مالک برای چند صفحه. */
|
||||||
|
exact?: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
const onBack = typeof back === 'function' ? back : nav.back;
|
const onBack = typeof back === 'function' ? back : nav.back;
|
||||||
|
const bg = exact ? color : onWhite(color);
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
|
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
|
||||||
style={{ backgroundColor: color, backgroundImage: `linear-gradient(160deg, ${shade(color, 0.12)} 0%, ${color} 55%, ${shade(color, -0.06)} 100%)` }}
|
style={{ backgroundColor: bg, backgroundImage: `linear-gradient(160deg, ${shade(bg, 0.12)} 0%, ${bg} 55%, ${shade(bg, -0.06)} 100%)` }}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
|
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
|
||||||
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
|
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
|
||||||
|
{/* نمادهای ریاضی که آرام شناورند؛ فقط transform/opacity و بیتصویر، پس ارزاناند. */}
|
||||||
|
<span aria-hidden className="glyph pointer-events-none absolute left-[42%] top-5 text-3xl font-black text-white/15" style={{ animationDelay: '0s' }}>π</span>
|
||||||
|
<span aria-hidden className="glyph pointer-events-none absolute right-[34%] bottom-3 text-2xl font-black text-white/15" style={{ animationDelay: '-2s' }}>∑</span>
|
||||||
|
<span aria-hidden className="glyph pointer-events-none absolute left-[26%] bottom-2 text-2xl font-black text-white/15" style={{ animationDelay: '-4s' }}>√</span>
|
||||||
<div className="relative z-10 pt-6">
|
<div className="relative z-10 pt-6">
|
||||||
{back && (typeof back === 'function' || nav.canGoBack) && (
|
{back && (typeof back === 'function' || nav.canGoBack) && (
|
||||||
<IconButton label="بازگشت" onClick={onBack} className="mb-3 bg-white/15">
|
<IconButton label="بازگشت" onClick={onBack} className="mb-3 bg-white/15">
|
||||||
|
|||||||
@@ -10,3 +10,22 @@ export function shade(hex: string, amount: number): string {
|
|||||||
const r = channel((n >> 16) & 255), g = channel((n >> 8) & 255), b = channel(n & 255);
|
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')}`;
|
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function luminance(hex: string): number {
|
||||||
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
|
if (!m) return 1;
|
||||||
|
const n = parseInt(m[1], 16);
|
||||||
|
const lin = (c: number) => { const v = c / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
|
||||||
|
return 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* همان رنگ، فقط تا جایی تیره میشود که متنِ سفید رویش کنتراستِ دستکم `min` بگیرد
|
||||||
|
* (WCAG: ۳ برای متنِ درشت). رنگِ روشن برای آیکن و نوار میماند و فقط پسزمینهی متنِ سفید از این رد میشود،
|
||||||
|
* پس هیچ رنگِ تازهای وارد اپ نمیشود، فقط سایهی تیرهترِ همان.
|
||||||
|
*/
|
||||||
|
export function onWhite(hex: string, min = 3.2): string {
|
||||||
|
let c = hex;
|
||||||
|
for (let i = 0; i < 12 && 1.05 / (luminance(c) + 0.05) < min; i++) c = shade(c, -0.07);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|||||||
5
src/lib/subscription.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/** چند روز تا پایانِ اشتراک مانده (رو به بالا)؛ null برای اشتراکِ بیتاریخ (هدیهی نامحدود). */
|
||||||
|
export function daysLeft(expiresAt: string | null): number | null {
|
||||||
|
if (!expiresAt) return null;
|
||||||
|
return Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000));
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* همراههای اپ: سیگما (Σ)، دلتا (Δ)، پی (π) و تدی. «سهیار» نامِ قدیمیِ سهتای اول است؛ در متنِ
|
* همراههای اپ: سیگما (Σ)، دلتا (Δ)، پی (π) و امگا (ω). «سهیار» نامِ قدیمیِ سهتای اول است؛ در متنِ
|
||||||
* کاربر تعدادِ همراهها نوشته نمیشود، چون کاتالوگ از ادمین میآید و بزرگ میشود.
|
* کاربر تعدادِ همراهها نوشته نمیشود، چون کاتالوگ از ادمین میآید و بزرگ میشود.
|
||||||
*
|
*
|
||||||
* هر سه از دلِ یک دفترچهی بینهایت آمدهاند و هر کدام از جنسِ یک نمادِ ریاضیاند:
|
* هر سه از دلِ یک دفترچهی بینهایت آمدهاند و هر کدام از جنسِ یک نمادِ ریاضیاند:
|
||||||
@@ -89,6 +89,12 @@ function seyarSigmaSVG(mood: SeyarMood): string {
|
|||||||
</g></svg>`;
|
</g></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** نشانِ حرفِ یونانیِ هر همراه (مثلِ مدالِ Σِ سیگما): دایرهی کوچک روی شکم با نمادِ خودش. */
|
||||||
|
function letterBadge(cx: number, cy: number, entity: string, fill: string, ink: string): string {
|
||||||
|
return `<circle cx="${cx}" cy="${cy}" r="13" fill="${fill}" stroke="${ink}" stroke-width="4"/>`
|
||||||
|
+ `<text x="${cx}" y="${cy + 6.5}" font-size="18" text-anchor="middle" font-family="Georgia,serif" font-weight="bold" fill="${ink}">${entity}</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
function seyarDeltaSVG(mood: SeyarMood): string {
|
function seyarDeltaSVG(mood: SeyarMood): string {
|
||||||
const C = PALETTE.delta;
|
const C = PALETTE.delta;
|
||||||
const f = face(mood, 84, 108, 100, 128, C.ink, C.mouth, C.tear, C.cheek, 32);
|
const f = face(mood, 84, 108, 100, 128, C.ink, C.mouth, C.tear, C.cheek, 32);
|
||||||
@@ -103,6 +109,7 @@ function seyarDeltaSVG(mood: SeyarMood): string {
|
|||||||
<path d="M100 30 Q112 30 118 42 L160 156 Q166 172 150 174 L50 174 Q34 172 40 156 L82 42 Q88 30 100 30 Z" fill="${C.body}" stroke="${C.ink}" stroke-width="6" stroke-linejoin="round"/>
|
<path d="M100 30 Q112 30 118 42 L160 156 Q166 172 150 174 L50 174 Q34 172 40 156 L82 42 Q88 30 100 30 Z" fill="${C.body}" stroke="${C.ink}" stroke-width="6" stroke-linejoin="round"/>
|
||||||
<path d="M100 74 Q108 74 112 84 L136 148 Q140 158 128 158 L72 158 Q60 158 64 148 L88 84 Q92 74 100 74 Z" fill="${C.belly}"/>
|
<path d="M100 74 Q108 74 112 84 L136 148 Q140 158 128 158 L72 158 Q60 158 64 148 L88 84 Q92 74 100 74 Z" fill="${C.belly}"/>
|
||||||
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
|
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
|
||||||
|
${letterBadge(100, 160, 'Δ', '#FFD23F', C.ink)}
|
||||||
</g></svg>`;
|
</g></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,11 +128,12 @@ function seyarPiSVG(mood: SeyarMood): string {
|
|||||||
<ellipse cx="100" cy="118" rx="74" ry="72" fill="${C.body}" stroke="${C.ink}" stroke-width="6"/>
|
<ellipse cx="100" cy="118" rx="74" ry="72" fill="${C.body}" stroke="${C.ink}" stroke-width="6"/>
|
||||||
<ellipse cx="100" cy="134" rx="46" ry="46" fill="${C.belly}"/>
|
<ellipse cx="100" cy="134" rx="46" ry="46" fill="${C.belly}"/>
|
||||||
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
|
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
|
||||||
|
${letterBadge(100, 170, 'π', '#FFD23F', C.ink)}
|
||||||
</g></svg>`;
|
</g></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* تدی — شکلکِ زردِ پیشنمایشِ موبایل (mascot-pack، ریپوی بکاند، پوشهی
|
* امگا (ω؛ تا ۱۴۰۵-۰۷ «تدی»؛ کلیدِ داخلیاش هنوز `tedi` است چون slugِ سرور همین است) — شکلکِ زردِ پیشنمایشِ موبایل (mascot-pack، ریپوی بکاند، پوشهی
|
||||||
* mobile-preview). بدن، گوشها، پاها و دستها عیناً از همان پک است؛ چهرهی
|
* mobile-preview). بدن، گوشها، پاها و دستها عیناً از همان پک است؛ چهرهی
|
||||||
* «عصبانی» و «ناامید» را پک نداشت و با همان زبانِ طراحیِ سهیار اضافه شد.
|
* «عصبانی» و «ناامید» را پک نداشت و با همان زبانِ طراحیِ سهیار اضافه شد.
|
||||||
*/
|
*/
|
||||||
@@ -182,6 +190,7 @@ function tediSVG(mood: SeyarMood): string {
|
|||||||
<ellipse cx="100" cy="116" rx="72" ry="70" fill="${C.body}" ${S}/>
|
<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}"/>
|
<ellipse cx="100" cy="133" rx="44" ry="45" fill="${C.belly}"/>
|
||||||
${hands}${cheeks}<g>${eyes}</g>${mouth}
|
${hands}${cheeks}<g>${eyes}</g>${mouth}
|
||||||
|
${letterBadge(100, 166, 'ω', '#fff', C.ink)}
|
||||||
</g></svg>`;
|
</g></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +201,7 @@ export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role:
|
|||||||
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
|
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
|
||||||
delta: { name: "دلتا", symbol: "Δ", role: "همتیمیِ تمرینهای سریع" },
|
delta: { name: "دلتا", symbol: "Δ", role: "همتیمیِ تمرینهای سریع" },
|
||||||
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
|
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
|
||||||
tedi: { name: "تدی", symbol: "★", role: "رفیقِ همیشگیِ تدریسینو" },
|
tedi: { name: "امگا", symbol: "ω", role: "رفیقِ همیشگیِ تدریسینو" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراهها نگاشت میکند. */
|
/** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراهها نگاشت میکند. */
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function NavigatorProvider({ initial, children }: { initial: Route; child
|
|||||||
const root = stack[0].name;
|
const root = stack[0].name;
|
||||||
return {
|
return {
|
||||||
route: stack[stack.length - 1],
|
route: stack[stack.length - 1],
|
||||||
tab: ['home', 'map', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null,
|
tab: ['home', 'lessons', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null,
|
||||||
canGoBack: stack.length > 1,
|
canGoBack: stack.length > 1,
|
||||||
push,
|
push,
|
||||||
replace,
|
replace,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/** همهی صفحههای اپ و پارامترهایشان. */
|
/** همهی صفحههای اپ و پارامترهایشان. */
|
||||||
export type Route =
|
export type Route =
|
||||||
| { name: 'home' }
|
| { name: 'home' }
|
||||||
| { name: 'map' }
|
| { name: 'lessons' }
|
||||||
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
|
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
|
||||||
| { name: 'subscription' }
|
/** view: مستقیم به فهرستِ اشتراکها یا بستهها (مثلاً از فصلِ قفل)؛ بیآن انتخابِ دوگزینهای. */
|
||||||
|
| { name: 'subscription'; view?: 'subscription' | 'pack' }
|
||||||
| { name: 'profile' }
|
| { name: 'profile' }
|
||||||
| { name: 'unit'; unitId: string }
|
| { name: 'unit'; unitId: string }
|
||||||
| { name: 'lesson'; lessonId: number; nodeId?: string }
|
| { name: 'lesson'; lessonId: number; nodeId?: string }
|
||||||
@@ -20,9 +21,9 @@ export type Route =
|
|||||||
| { name: 'invoices' }
|
| { name: 'invoices' }
|
||||||
| { name: 'invite' };
|
| { name: 'invite' };
|
||||||
|
|
||||||
export type TabName = 'home' | 'map' | 'ask' | 'subscription' | 'profile';
|
export type TabName = 'home' | 'lessons' | 'ask' | 'subscription' | 'profile';
|
||||||
|
|
||||||
export const TABS: TabName[] = ['home', 'map', 'ask', 'subscription', 'profile'];
|
export const TABS: TabName[] = ['home', 'lessons', 'ask', 'subscription', 'profile'];
|
||||||
|
|
||||||
export function isTab(name: Route['name']): name is TabName {
|
export function isTab(name: Route['name']): name is TabName {
|
||||||
return (TABS as string[]).includes(name);
|
return (TABS as string[]).includes(name);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Bell, ChevronLeft, Coins, Flame, Star } from 'lucide-react';
|
import { Bell, ChevronLeft, Coins, Crown, Flame, Hand, PartyPopper, Star } from 'lucide-react';
|
||||||
import type { HomeNode } from '@/api';
|
import type { HomeNode } from '@/api';
|
||||||
import { useAppData } from '@/state/AppData';
|
import { useAppData } from '@/state/AppData';
|
||||||
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||||||
@@ -8,9 +8,11 @@ import { SeyarMascot } from '@/mascots/SeyarMascot';
|
|||||||
import { IslandPath } from '@/components/IslandPath';
|
import { IslandPath } from '@/components/IslandPath';
|
||||||
import { IslandSheet } from '@/components/IslandSheet';
|
import { IslandSheet } from '@/components/IslandSheet';
|
||||||
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
|
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
|
||||||
import { shade } from '@/lib/color';
|
import { onWhite, shade } from '@/lib/color';
|
||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
import { MathText } from '@/lib/MathText';
|
import { MathText } from '@/lib/MathText';
|
||||||
|
import { BrandFooter } from '@/components/BrandFooter';
|
||||||
|
import { BrandMark } from '@/components/BrandMark';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
||||||
@@ -20,6 +22,8 @@ import { MathText } from '@/lib/MathText';
|
|||||||
export function HomeScreen() {
|
export function HomeScreen() {
|
||||||
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
|
// فصلِ غیرِ اول بیاشتراک: زدنِ جزیره یکراست فهرستِ اشتراکها را باز میکند.
|
||||||
|
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
|
||||||
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
|
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
|
||||||
const currentRef = useRef<HTMLDivElement>(null);
|
const currentRef = useRef<HTMLDivElement>(null);
|
||||||
const scrolled = useRef(false);
|
const scrolled = useRef(false);
|
||||||
@@ -39,8 +43,8 @@ export function HomeScreen() {
|
|||||||
: !home.units.length
|
: !home.units.length
|
||||||
? 'هنوز درسی برای این پایه ثبت نشده.'
|
? 'هنوز درسی برای این پایه ثبت نشده.'
|
||||||
: cont
|
: cont
|
||||||
? <>سلام {name}! 👋 نوبتِ جزیرهی <b>«<MathText text={cont.node.title} />»</b>ـه. دکمهی <b>شروع</b> رو بزن.</>
|
? <>سلام {name}! <Hand size={15} className="mb-0.5 inline text-[#E8A400]" /> وقتشه جزیرهی <b>«<MathText text={cont.node.title} />»</b> رو شروع کنی؛ دکمهی <b>شروع</b> رو بزن.</>
|
||||||
: <>آفرین {name}! همهی جزیرههای این پایه رو تموم کردی 🎉</>;
|
: <>آفرین {name}! همهی جزیرههای این پایه رو تموم کردی <PartyPopper size={15} className="mb-0.5 inline text-[#E8A400]" /></>;
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
|
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
|
||||||
@@ -52,8 +56,9 @@ export function HomeScreen() {
|
|||||||
<Screen>
|
<Screen>
|
||||||
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-4 pt-safe text-white shadow-md shadow-coral/20">
|
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-4 pt-safe text-white shadow-md shadow-coral/20">
|
||||||
<div className="flex items-center gap-2 pt-4">
|
<div className="flex items-center gap-2 pt-4">
|
||||||
|
<BrandMark size={26} withName={false} />
|
||||||
{gradeTitle && (
|
{gradeTitle && (
|
||||||
<button type="button" onClick={() => nav.switchTab('profile')} className="rounded-full bg-white/20 px-3 py-1.5 text-xs font-black">
|
<button type="button" onClick={() => nav.switchTab('profile')} className="hit rounded-full bg-white/20 px-3 py-1.5 text-xs font-black active:scale-95">
|
||||||
{gradeTitle}
|
{gradeTitle}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -64,13 +69,13 @@ export function HomeScreen() {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`${label}: ${toFa(value)}`}
|
aria-label={`${label}: ${toFa(value)}`}
|
||||||
onClick={() => nav.push({ name: to })}
|
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"
|
className="hit flex items-center gap-1 rounded-full bg-white px-2.5 py-1.5 text-xs font-black text-ink shadow-sm active:scale-95"
|
||||||
>
|
>
|
||||||
<Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />
|
<Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />
|
||||||
{toFa(value)}
|
{toFa(value)}
|
||||||
</button>
|
</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">
|
<button type="button" aria-label="اعلانها" onClick={() => nav.push({ name: 'notifications' })} className="hit relative flex h-8 w-8 items-center justify-center rounded-full bg-white/20 active:scale-95">
|
||||||
<Bell size={16} />
|
<Bell size={16} />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="absolute -left-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#FFD23F] px-1 text-[9px] font-black text-ink">
|
<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">
|
||||||
@@ -105,13 +110,14 @@ export function HomeScreen() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
||||||
className="flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-right text-white active:translate-y-0.5"
|
className="sticky top-2 z-10 flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-right text-white active:translate-y-0.5"
|
||||||
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.25)}` }}
|
style={{ backgroundColor: onWhite(color), boxShadow: `0 4px 0 ${shade(onWhite(color), -0.25)}` }}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[11px] font-bold text-white/80">
|
<div className="text-[11px] font-bold text-white/80">
|
||||||
فصلِ {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
|
فصلِ {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
|
||||||
{isCurrentUnit && <span className="mr-1.5 rounded-full bg-white/25 px-2 py-0.5">الان اینجایی</span>}
|
{unit.requiresSubscription && <span className="mr-1.5 inline-flex h-5 items-center gap-1 rounded-full bg-white/25 px-2 align-middle leading-none"><Crown size={11} /> با اشتراک</span>}
|
||||||
|
{isCurrentUnit && <span className="mr-1.5 inline-flex h-5 items-center rounded-full bg-white/25 px-2 align-middle leading-none">الان اینجایی</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div>
|
<div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,13 +129,14 @@ export function HomeScreen() {
|
|||||||
nodes={unit.nodes}
|
nodes={unit.nodes}
|
||||||
color={color}
|
color={color}
|
||||||
currentId={isCurrentUnit ? cont!.node.id : null}
|
currentId={isCurrentUnit ? cont!.node.id : null}
|
||||||
onOpen={(node) => setOpenNode({ node, color })}
|
onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode({ node, color }))}
|
||||||
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
|
onStart={(node) => (unit.requiresSubscription ? paywall() : nav.push({ name: 'quiz', nodeId: node.id }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<BrandFooter />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
251
src/screens/LessonsScreen.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { Brain, Check, ChevronDown, Crown, Lock, PartyPopper, Play, Sparkles } from 'lucide-react';
|
||||||
|
import { api, type HomeNode, type HomeUnit } from '@/api';
|
||||||
|
import { useAppData } from '@/state/AppData';
|
||||||
|
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||||||
|
import { onWhite } from '@/lib/color';
|
||||||
|
import { useNav } from '@/nav/Navigator';
|
||||||
|
import { useAsync } from '@/lib/useAsync';
|
||||||
|
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui';
|
||||||
|
import { iconFor } from '@/components/NodeIcon';
|
||||||
|
import { toFa } from '@/lib/format';
|
||||||
|
import { MathText } from '@/lib/MathText';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تبِ «درسها»: فصل ← جزیره ← درس. فصلها اول بستهاند و بالای صفحه یک کارتِ «ادامه بده» هست.
|
||||||
|
* بازبودنِ فصل/جزیره در متغیرِ ماژول نگه داشته میشود، چون با رفتن به درس و برگشتن صفحه از نو ساخته
|
||||||
|
* میشود (کلیدِ مسیر در MainApp) و کاربر جایش را گم میکرد. با بستنِ اپ پاک میشود، عمداً.
|
||||||
|
*/
|
||||||
|
const remembered: { unit: string | null; node: string | null } = { unit: null, node: null };
|
||||||
|
|
||||||
|
export function LessonsScreen() {
|
||||||
|
const { home, homeError, refreshHome, gradeTitle } = useAppData();
|
||||||
|
const nav = useNav();
|
||||||
|
const units = home?.units ?? [];
|
||||||
|
const current = findContinue(home);
|
||||||
|
const [openUnit, setOpenUnitState] = useState<string | null>(remembered.unit);
|
||||||
|
const [openNode, setOpenNodeState] = useState<string | null>(remembered.node);
|
||||||
|
const setOpenUnit = (id: string | null) => { remembered.unit = id; setOpenUnitState(id); };
|
||||||
|
const setOpenNode = (id: string | null) => { remembered.node = id; setOpenNodeState(id); };
|
||||||
|
|
||||||
|
const totalNodes = units.reduce((n, u) => n + u.nodes.length, 0);
|
||||||
|
const doneNodes = units.reduce((n, u) => n + unitProgress(u).done, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen className="bg-page">
|
||||||
|
<ScreenHeader
|
||||||
|
title="درسها"
|
||||||
|
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>}
|
||||||
|
color="#2FB36B"
|
||||||
|
exact
|
||||||
|
mascot={{ char: 'sigma', mood: 'happy' }}
|
||||||
|
>
|
||||||
|
{home && totalNodes > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="mb-1.5 flex justify-between text-[11px] text-white/85">
|
||||||
|
<span>جزیرههایی که تموم کردی</span>
|
||||||
|
<span>{toFa(doneNodes)} از {toFa(totalNodes)}</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar percent={Math.round((doneNodes / totalNodes) * 100)} color="#fff" track="rgba(255,255,255,.25)" height={10} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScreenHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-4">
|
||||||
|
{!home ? (
|
||||||
|
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم درسها رو میارم…" />
|
||||||
|
) : units.length === 0 ? (
|
||||||
|
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{current && current.node.nextLessonId != null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => nav.push({ name: 'lesson', lessonId: current.node.nextLessonId as number, nodeId: current.node.id })}
|
||||||
|
className="flex items-center gap-3 rounded-2xl px-4 py-3.5 text-right text-white"
|
||||||
|
style={{ backgroundColor: onWhite(unitColor(current.unitIndex)), boxShadow: `0 4px 0 ${onWhite(unitColor(current.unitIndex))}99` }}
|
||||||
|
>
|
||||||
|
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-white/25">
|
||||||
|
<Play size={20} fill="white" />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-[11px] font-bold text-white/85">ادامه بده از همینجا</span>
|
||||||
|
<span className="block text-sm font-black leading-6"><MathText text={current.node.title} /></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{units.map((unit, i) => (
|
||||||
|
<UnitCard
|
||||||
|
key={unit.id}
|
||||||
|
index={i}
|
||||||
|
unit={unit}
|
||||||
|
color={unitColor(i)}
|
||||||
|
currentNodeId={current?.node.id ?? null}
|
||||||
|
open={openUnit === unit.id}
|
||||||
|
onToggle={() => setOpenUnit(openUnit === unit.id ? null : unit.id)}
|
||||||
|
openNode={openNode}
|
||||||
|
onToggleNode={(id) => setOpenNode(openNode === id ? null : id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => nav.switchTab('ask')}
|
||||||
|
className="mt-1 flex items-center gap-3 rounded-2xl border border-line bg-white px-4 py-3 text-right"
|
||||||
|
>
|
||||||
|
<Brain size={22} className="flex-shrink-0 text-violet" />
|
||||||
|
<span className="text-sm leading-6 text-body">یه جایی از درس رو نفهمیدی؟ <b className="text-ink">از دستیار بپرس</b></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnitCard({
|
||||||
|
index, unit, color, currentNodeId, open, onToggle, openNode, onToggleNode,
|
||||||
|
}: {
|
||||||
|
index: number; unit: HomeUnit; color: string; currentNodeId: string | null; open: boolean; onToggle: () => void;
|
||||||
|
openNode: string | null; onToggleNode: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const p = unitProgress(unit);
|
||||||
|
const complete = p.total > 0 && p.done === p.total;
|
||||||
|
const untouched = unit.nodes.every((n) => n.status === 'locked');
|
||||||
|
const Icon = iconFor(unit.nodes[0]?.icon, index);
|
||||||
|
const nav = useNav();
|
||||||
|
const premium = unit.requiresSubscription;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: Math.min(index, 8) * 0.04 }}
|
||||||
|
className="overflow-hidden rounded-2xl bg-white"
|
||||||
|
style={{ border: `2px solid ${open ? color : '#e9e6f7'}`, boxShadow: open ? `0 4px 0 ${color}40` : '0 2px 0 #e9e6f7' }}
|
||||||
|
>
|
||||||
|
<button type="button" onClick={premium ? () => nav.push({ name: 'subscription', view: 'subscription' }) : onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
|
||||||
|
<span
|
||||||
|
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-2xl"
|
||||||
|
style={{ backgroundColor: complete ? color : `${color}22`, color: complete ? '#fff' : color, opacity: untouched ? 0.55 : 1 }}
|
||||||
|
>
|
||||||
|
{complete ? <Check size={26} strokeWidth={3.5} /> : untouched ? <Lock size={22} /> : <Icon size={26} strokeWidth={2.2} />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-[11px] font-bold" style={{ color }}>
|
||||||
|
{complete ? <><PartyPopper size={12} className="mb-0.5 ml-1 inline" />تموم شد</> : `${toFa(p.total)} جزیره`}
|
||||||
|
{premium && <span className="mr-1.5 inline-flex items-center gap-0.5 rounded-full bg-[#FFF4D6] px-1.5 text-[10px] text-[#A86A00]"><Crown size={10} /> با اشتراک</span>}
|
||||||
|
</span>
|
||||||
|
<span className="block text-sm font-black leading-6 text-ink"><MathText text={unit.title} /></span>
|
||||||
|
<span className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="flex-1"><ProgressBar percent={p.percent} color={color} height={6} /></span>
|
||||||
|
<span className="text-[11px] text-muted">{toFa(p.done)}/{toFa(p.total)}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{premium ? <Crown size={18} className="flex-shrink-0 text-[#E0A200]" /> : <ChevronDown size={18} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />}
|
||||||
|
</button>
|
||||||
|
{open && !premium && (
|
||||||
|
<ul className="flex flex-col gap-1.5 border-t border-line bg-[#fbfaff] px-3 py-3">
|
||||||
|
{unit.nodes.map((node, ni) => (
|
||||||
|
<NodeRow
|
||||||
|
key={node.id}
|
||||||
|
num={ni + 1}
|
||||||
|
node={node}
|
||||||
|
color={color}
|
||||||
|
isCurrent={node.id === currentNodeId}
|
||||||
|
open={openNode === node.id}
|
||||||
|
onToggle={() => onToggleNode(node.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeRow({
|
||||||
|
num, node, color, isCurrent, open, onToggle,
|
||||||
|
}: { num: number; node: HomeNode; color: string; isCurrent: boolean; open: boolean; onToggle: () => void }) {
|
||||||
|
const nav = useNav();
|
||||||
|
const locked = node.status === 'locked';
|
||||||
|
const done = node.status === 'completed';
|
||||||
|
const lessons = useAsync(() => (open && !locked ? api.nodeLessons(node.id) : Promise.resolve(null)), [node.id, open, locked]);
|
||||||
|
const playable = new Set(node.lessonIds);
|
||||||
|
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
|
||||||
|
const loaded = !lessons.loading && !lessons.error && lessons.data !== null && lessons.data !== undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="rounded-xl border bg-white" style={{ borderColor: isCurrent ? color : '#e9e6f7' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={locked}
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-right disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-xs font-black"
|
||||||
|
style={done ? { backgroundColor: '#3E9E73', color: '#fff' } : { backgroundColor: locked ? '#f0eeff' : `${color}22`, color: locked ? '#727299' : color }}
|
||||||
|
>
|
||||||
|
{done ? <Check size={15} strokeWidth={3.5} /> : locked ? <Lock size={13} /> : toFa(num)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-sm font-bold leading-6 text-ink"><MathText text={node.title} /></span>
|
||||||
|
<span className="block text-[11px] text-muted">
|
||||||
|
{locked ? 'جزیرهی قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{isCurrent && !open && (
|
||||||
|
<span className="flex-shrink-0 rounded-full px-2 py-0.5 text-[10px] font-black text-white" style={{ backgroundColor: color }}>
|
||||||
|
بعدی
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!locked && <ChevronDown size={16} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && !locked && (
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
{lessons.error ? (
|
||||||
|
<ErrorState error={lessons.error} onRetry={lessons.reload} />
|
||||||
|
) : !loaded ? (
|
||||||
|
<div className="flex flex-col gap-1.5" aria-busy="true" aria-label="دارم درسها رو میارم">
|
||||||
|
{[0, 1, 2].map((k) => <div key={k} className="h-10 animate-pulse rounded-xl bg-soft" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{list.map((l, li) => (
|
||||||
|
<li key={l.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => nav.push({ name: 'lesson', lessonId: Number(l.id), nodeId: node.id })}
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-xl bg-[#f7f8ff] px-3 py-2 text-right text-sm active:scale-[.99]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-black"
|
||||||
|
style={l.done ? { backgroundColor: '#3E9E73', color: '#fff' } : { backgroundColor: '#fff', color: '#727299', border: '1px solid #e9e6f7' }}
|
||||||
|
>
|
||||||
|
{l.done ? <Check size={13} strokeWidth={3.5} /> : toFa(li + 1)}
|
||||||
|
</span>
|
||||||
|
<span className={`flex-1 ${l.done ? 'text-muted' : 'text-ink'}`}><MathText text={l.title} /></span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => nav.push({ name: 'quiz', nodeId: node.id })}
|
||||||
|
className="mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-black text-white active:translate-y-[2px]"
|
||||||
|
style={{ backgroundColor: onWhite(color), boxShadow: `0 3px 0 ${onWhite(color)}99` }}
|
||||||
|
>
|
||||||
|
{done ? <Sparkles size={15} /> : <Play size={15} fill="white" />}
|
||||||
|
{done ? 'دوباره تمرین کن' : 'درسها رو خوندی؟ برو آزمون'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { motion } from 'motion/react';
|
|
||||||
import { Check } from 'lucide-react';
|
|
||||||
import { useAppData } from '@/state/AppData';
|
|
||||||
import { unitColor, unitProgress } from '@/state/curriculum';
|
|
||||||
import { useNav } from '@/nav/Navigator';
|
|
||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
|
||||||
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui';
|
|
||||||
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
|
||||||
import { iconFor } from '@/components/NodeIcon';
|
|
||||||
import { toFa } from '@/lib/format';
|
|
||||||
|
|
||||||
/** نقشهی فصلهای پایه: هر خانه یک فصل است. */
|
|
||||||
export function MapScreen() {
|
|
||||||
const { home, homeError, refreshHome, gradeTitle } = useAppData();
|
|
||||||
const nav = useNav();
|
|
||||||
const units = home?.units ?? [];
|
|
||||||
const points = zigzagPositions(units.length, 150);
|
|
||||||
const height = points.length ? points[points.length - 1].y + 200 : 400;
|
|
||||||
const overall = Math.round(home?.stats.progressPercent ?? 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen className="bg-[#f7f8ff]">
|
|
||||||
<ScreenHeader
|
|
||||||
title="نقشهی فصلها"
|
|
||||||
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}{toFa(overall)}٪ از کلِ مسیر</>}
|
|
||||||
color="#91ADC6"
|
|
||||||
mascot={{ char: 'sigma', mood: overall > 0 ? 'happy' : 'idle' }}
|
|
||||||
>
|
|
||||||
<div className="mt-4">
|
|
||||||
<ProgressBar percent={overall} color="#fff" track="rgba(255,255,255,.25)" />
|
|
||||||
</div>
|
|
||||||
</ScreenHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{!home ? (
|
|
||||||
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم فصلها رو میارم…" />
|
|
||||||
) : units.length === 0 ? (
|
|
||||||
<EmptyState text="هنوز فصلی برای این پایه ثبت نشده" />
|
|
||||||
) : (
|
|
||||||
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
|
|
||||||
<ZigzagPath
|
|
||||||
points={points}
|
|
||||||
height={height}
|
|
||||||
colorOf={(i) => (units[i].nodes.some((n) => n.status !== 'locked') ? unitColor(i) : null)}
|
|
||||||
/>
|
|
||||||
{units.map((unit, i) => {
|
|
||||||
const p = unitProgress(unit);
|
|
||||||
const color = unitColor(i);
|
|
||||||
const Icon = iconFor(unit.nodes[0]?.icon, i);
|
|
||||||
const complete = p.total > 0 && p.done === p.total;
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={unit.id}
|
|
||||||
initial={{ opacity: 0, scale: 0.7 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ delay: i * 0.05, type: 'spring', stiffness: 220, damping: 16 }}
|
|
||||||
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1.5"
|
|
||||||
style={{ left: `${points[i].x}%`, top: points[i].y }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={unit.title}
|
|
||||||
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
|
||||||
className="relative flex h-[84px] w-[84px] items-center justify-center rounded-[26px] transition-transform active:translate-y-1"
|
|
||||||
style={{
|
|
||||||
backgroundColor: complete ? color : '#fff',
|
|
||||||
border: complete ? 'none' : `4px solid ${color}`,
|
|
||||||
boxShadow: complete ? `0 6px 0 ${color}99` : `0 6px 0 ${color}40, 0 0 0 5px white`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{complete ? <Check size={30} color="white" strokeWidth={3.5} /> : <Icon size={30} style={{ color }} strokeWidth={2.2} />}
|
|
||||||
{!complete && p.done > 0 && (
|
|
||||||
<span className="absolute -left-2 -top-2 rounded-full border bg-white px-1.5 py-0.5 text-[10px] font-black shadow" style={{ color, borderColor: color }}>
|
|
||||||
{toFa(p.percent)}٪
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
|
|
||||||
<div className="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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 150 }}>
|
|
||||||
<SeyarMascot char="pi" mood={overall >= 100 ? 'excited' : 'happy'} size={72} bob />
|
|
||||||
<div className="mt-1 whitespace-nowrap rounded-full border border-line bg-white px-3 py-1 text-[10px] font-bold text-[#7857E0] shadow-sm">
|
|
||||||
{overall >= 100 ? 'همه رو تموم کردی! 🎉' : 'همینجا منتظرتم'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title
|
|||||||
char: 'tedi', mood: 'excited', bg: '#D9820B',
|
char: 'tedi', mood: 'excited', bg: '#D9820B',
|
||||||
sub: 'یکی از ما مالِ توئه',
|
sub: 'یکی از ما مالِ توئه',
|
||||||
title: 'همراهت رو انتخاب کن',
|
title: 'همراهت رو انتخاب کن',
|
||||||
desc: 'منم تدی! هر چی امتیازت بیشتر بشه، همراههای تازه باز میشن. از صفحهی «من» هر کدوممون رو خواستی کنارت میذاری.',
|
desc: 'منم امگا! هر چی امتیازت بیشتر بشه، همراههای تازه باز میشن. از صفحهی «من» هر کدوممون رو خواستی کنارت میذاری.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
|
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Crown, Heart, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
|
||||||
FileText, Pencil, Repeat, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX,
|
FileText, Pencil, Repeat, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
@@ -15,7 +15,10 @@ import { BirthDatePicker, ageOf, formatBirthDate } from '@/components/BirthDateP
|
|||||||
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
|
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
|
||||||
import { friendlyError } from '@/lib/errors';
|
import { friendlyError } from '@/lib/errors';
|
||||||
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
|
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
|
||||||
import { digitsOnly, toFa } from '@/lib/format';
|
import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
|
||||||
|
import { useAsync } from '@/lib/useAsync';
|
||||||
|
import { daysLeft } from '@/lib/subscription';
|
||||||
|
import { BrandFooter } from '@/components/BrandFooter';
|
||||||
|
|
||||||
type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null;
|
type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null;
|
||||||
|
|
||||||
@@ -68,6 +71,8 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 px-4 py-5">
|
<div className="flex flex-col gap-4 px-4 py-5">
|
||||||
|
<SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
|
||||||
|
|
||||||
<Menu>
|
<Menu>
|
||||||
<Row icon={GraduationCap} label="پایهی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
<Row icon={GraduationCap} label="پایهی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
||||||
<Row
|
<Row
|
||||||
@@ -108,6 +113,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
|||||||
<button type="button" onClick={() => setEditing('delete')} className="flex items-center justify-center gap-2 py-2 text-xs font-bold text-red-500">
|
<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} /> حذفِ حساب
|
<Trash2 size={14} /> حذفِ حساب
|
||||||
</button>
|
</button>
|
||||||
|
<BrandFooter />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,6 +126,74 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اشتراک بهصورتِ نوار: چند روز از کلِ دوره مانده. مخرج طولِ پلن است (planDurationDays)؛ تمدیدِ زودتر از
|
||||||
|
* موعد روزها را روی هم میگذارد، پس نوار در ۱۰۰٪ بسته میشود. زدنِ کارت فروشگاه را باز میکند.
|
||||||
|
*/
|
||||||
|
function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||||
|
const status = useAsync(() => api.subscription.status(), []);
|
||||||
|
const s = status.data;
|
||||||
|
if (!s) return <div className="h-[132px] animate-pulse rounded-3xl bg-soft" />;
|
||||||
|
const days = s.active ? daysLeft(s.expiresAt) : 0;
|
||||||
|
const total = s.planDurationDays ?? 30;
|
||||||
|
// اشتراکِ بیتاریخ (هدیه) پر نشان داده میشود.
|
||||||
|
const percent = s.active ? (days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)))) : 0;
|
||||||
|
const low = s.active && days !== null && days <= 5;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className={`relative overflow-hidden rounded-3xl p-4 text-right active:scale-[0.99] ${s.active ? 'bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] text-white shadow-lg shadow-orange/25' : 'border-2 border-dashed border-orange/50 bg-white text-ink'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`glyph flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl ${s.active ? 'bg-white/20' : 'bg-[#FFF4D6] text-[#E0A200]'}`}><Crown size={20} /></span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراکِ تدریسینو</span>
|
||||||
|
<span className="block text-base font-black">{s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-left">
|
||||||
|
{s.active && days !== null ? (
|
||||||
|
<><b className="block text-2xl font-black leading-none">{toFa(days)}</b><span className="text-[10px] text-white/80">روز مونده</span></>
|
||||||
|
) : (
|
||||||
|
<ChevronLeft size={18} className={s.active ? 'text-white/80' : 'text-faint'} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`relative mt-3 h-3 overflow-hidden rounded-full ${s.active ? 'bg-black/15' : 'bg-soft'}`}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={total}
|
||||||
|
aria-valuenow={days ?? total}
|
||||||
|
aria-label="روزهای ماندهٔ اشتراک"
|
||||||
|
>
|
||||||
|
{percent > 0 && (
|
||||||
|
<div className="bar-fill absolute inset-y-0 right-0 overflow-hidden rounded-full" style={{ width: `${percent}%`, backgroundColor: low ? '#FFD23F' : '#fff' }}>
|
||||||
|
<span className="shine absolute inset-y-0 w-1/3 bg-gradient-to-l from-transparent via-white/70 to-transparent" style={{ mixBlendMode: 'overlay' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`mt-2 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[11px] ${s.active ? 'text-white/85' : 'text-muted'}`}>
|
||||||
|
{s.active ? (
|
||||||
|
<>
|
||||||
|
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقتِ تمدیده'}</span>}
|
||||||
|
<span className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلبِ نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همهی فصلها و قلبِ نامحدود باز میشه.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{s.questionCredits > 0 && (
|
||||||
|
<div className={`mt-1.5 flex items-center gap-1 text-[11px] font-bold ${s.active ? 'text-white/90' : 'text-violet'}`}>
|
||||||
|
<Sparkles size={11} /> {toFa(s.questionCredits)} سوالِ خریدهشده در اعتبارت
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Menu({ children }: { children: ReactNode }) {
|
function Menu({ children }: { children: ReactNode }) {
|
||||||
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
|
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
|
||||||
}
|
}
|
||||||
@@ -273,6 +347,7 @@ function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open:
|
|||||||
{switching && 'اول از این حساب خارج میشی، بعد با شمارهی دیگه وارد شو. '}
|
{switching && 'اول از این حساب خارج میشی، بعد با شمارهی دیگه وارد شو. '}
|
||||||
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
|
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mb-5 -mt-3 text-xs text-muted">ممنون که با تدریسینو یاد میگیری 💛</p>
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
<SecondaryButton onClick={onClose}>نه، میمونم</SecondaryButton>
|
<SecondaryButton onClick={onClose}>نه، میمونم</SecondaryButton>
|
||||||
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>
|
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import confetti from 'canvas-confetti';
|
import { Brain, Check, ChevronLeft, Coins, Compass, Flag, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react';
|
||||||
import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react';
|
|
||||||
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
|
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
|
||||||
import { useAppData } from '@/state/AppData';
|
import { useAppData } from '@/state/AppData';
|
||||||
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
||||||
@@ -96,15 +95,26 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
|
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
|
||||||
else setPhase({ kind: 'playing' });
|
else setPhase({ kind: 'playing' });
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => alive && setPhase({ kind: 'error', error }));
|
.catch((error: unknown) => {
|
||||||
|
if (!alive) return;
|
||||||
|
// فصلِ غیرِ اول بیاشتراک (۴۰۲)؛ صفحهی خطا معنا ندارد، کاربر یکراست به اشتراکها میرود.
|
||||||
|
if (error instanceof ApiError && error.code === 'subscription_required') nav.replace({ name: 'subscription', view: 'subscription' });
|
||||||
|
else setPhase({ kind: 'error', error });
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [lessonId, nodeId, nodeMode, reload]);
|
}, [lessonId, nodeId, nodeMode, reload]);
|
||||||
|
|
||||||
async function choose(optionId: string) {
|
/** انتخاب فقط گزینه را آبی میکند؛ تا «بررسی» نزده، چیزی ثبت یا رو نمیشود و میشود عوضش کرد. */
|
||||||
if (!question || selected !== null || submitting) return;
|
function choose(optionId: string) {
|
||||||
|
if (!question || answer || submitting) return;
|
||||||
setSelected(optionId);
|
setSelected(optionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
if (!question || selected === null || answer || submitting) return;
|
||||||
|
const optionId = selected;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
let key = answerKeys.current.get(question.id);
|
let key = answerKeys.current.get(question.id);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -122,7 +132,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
sounds.wrong();
|
sounds.wrong();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSelected(null);
|
// گزینه انتخابشده میماند تا کاربر دوباره «بررسی» بزند.
|
||||||
if (err instanceof ApiError && err.code === 'no_hearts') {
|
if (err instanceof ApiError && err.code === 'no_hearts') {
|
||||||
setPhase({
|
setPhase({
|
||||||
kind: 'noHearts',
|
kind: 'noHearts',
|
||||||
@@ -165,7 +175,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
: await api.lesson.complete(lessonId, payload, completeKey.current);
|
: await api.lesson.complete(lessonId, payload, completeKey.current);
|
||||||
setPhase({ kind: 'result', result });
|
setPhase({ kind: 'result', result });
|
||||||
sounds.finish();
|
sounds.finish();
|
||||||
if (result.stars >= 2) void confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } });
|
if (result.stars >= 2) void import('canvas-confetti').then(({ default: confetti }) => confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } }));
|
||||||
void refreshHome();
|
void refreshHome();
|
||||||
void refreshSummary();
|
void refreshSummary();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -240,16 +250,16 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
|
|
||||||
const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad';
|
const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad';
|
||||||
const bubble = !answer
|
const bubble = !answer
|
||||||
? 'جوابِ درست رو انتخاب کن!'
|
? selected === null ? 'جوابِ درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
|
||||||
: answer.correct
|
: answer.correct
|
||||||
? 'آفرین! درست بود ⚡'
|
? 'آفرین! درست بود'
|
||||||
: outOfHearts
|
: outOfHearts
|
||||||
? 'قلبهات تموم شد؛ یه کم استراحت کنیم.'
|
? 'قلبهات تموم شد؛ یه کم استراحت کنیم.'
|
||||||
: 'اشکالی نداره، جوابِ درست رو ببین.';
|
: 'اشکالی نداره، جوابِ درست رو ببین.';
|
||||||
const shortOptions = question.options.every((o) => o.value.length <= 14);
|
const shortOptions = question.options.every((o) => o.value.length <= 14);
|
||||||
|
|
||||||
const optionClass = (id: string) => {
|
const optionClass = (id: string) => {
|
||||||
if (!answer) return selected === id ? 'border-coral bg-[#fff4f2] text-ink' : 'border-line bg-white text-ink';
|
if (!answer) return selected === id ? 'border-[#84D8FF] bg-[#DDF4FF] text-[#1581BA]' : 'border-line bg-white text-ink';
|
||||||
if (id === answer.correctOptionId) return 'border-green-400 bg-[#e5fdf0] text-green-700';
|
if (id === answer.correctOptionId) return 'border-green-400 bg-[#e5fdf0] text-green-700';
|
||||||
if (id === selected) return 'border-red-300 bg-[#fff0f0] text-red-500';
|
if (id === selected) return 'border-red-300 bg-[#fff0f0] text-red-500';
|
||||||
return 'border-line bg-white text-faint';
|
return 'border-line bg-white text-faint';
|
||||||
@@ -283,6 +293,18 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
|
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
|
||||||
}`}>
|
}`}>
|
||||||
{bubble}
|
{bubble}
|
||||||
|
{answer && <Reaction key={question.id} correct={answer.correct} seed={index} />}
|
||||||
|
{answer?.correct && answer.xpEarned > 0 && (
|
||||||
|
<motion.span
|
||||||
|
key={question.id}
|
||||||
|
initial={{ opacity: 0, y: 8, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 400, damping: 14 }}
|
||||||
|
className="mr-2 inline-block rounded-full bg-[#FFF3C4] px-2 py-0.5 text-[11px] font-black text-[#8A5A00]"
|
||||||
|
>
|
||||||
|
+{toFa(answer.xpEarned)} <Star size={11} className="mb-0.5 inline" fill="#E8A400" color="#E8A400" />
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -301,9 +323,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
<motion.button
|
<motion.button
|
||||||
key={option.id}
|
key={option.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void choose(option.id)}
|
onClick={() => choose(option.id)}
|
||||||
disabled={selected !== null && !answer}
|
disabled={submitting || answer !== null}
|
||||||
whileTap={{ scale: selected === null ? 0.95 : 1 }}
|
aria-pressed={selected === option.id}
|
||||||
|
whileTap={{ scale: answer ? 1 : 0.95 }}
|
||||||
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 border-b-4 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`}
|
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><MathText text={option.value} /></span>
|
<span><MathText text={option.value} /></span>
|
||||||
@@ -313,17 +336,24 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{answer && (
|
</div>
|
||||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="mt-1 flex flex-col gap-2.5">
|
|
||||||
|
<div className="flex flex-shrink-0 flex-col gap-2.5 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
|
||||||
|
{answer ? (
|
||||||
|
<>
|
||||||
{!answer.correct && (
|
{!answer.correct && (
|
||||||
<button type="button" onClick={askWhy} className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]">
|
<button type="button" onClick={askWhy} className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]">
|
||||||
<Brain size={16} /> از پی بپرس چرا غلط شد
|
<Brain size={16} /> از پی بپرس چرا غلط شد
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
|
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
|
||||||
{isLast ? 'پایانِ تمرین' : 'سوالِ بعدی'} <ChevronLeft size={18} />
|
{isLast ? 'پایانِ تمرین' : 'ادامه'} <ChevronLeft size={18} />
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</motion.div>
|
</>
|
||||||
|
) : (
|
||||||
|
<PrimaryButton color={color} disabled={selected === null} loading={submitting} onClick={() => void check()}>
|
||||||
|
بررسی
|
||||||
|
</PrimaryButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -419,7 +449,7 @@ function Result({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
|
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
|
||||||
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی 🏝️</p>}
|
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی</p>}
|
||||||
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
|
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full gap-3">
|
<div className="flex w-full gap-3">
|
||||||
@@ -439,7 +469,7 @@ function Result({
|
|||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
{nodeMode && nextNode && (
|
{nodeMode && nextNode && (
|
||||||
<button type="button" onClick={nav.back} className="-mt-2 py-2 text-sm font-bold text-muted">
|
<button type="button" onClick={nav.back} className="-mt-2 py-2 text-sm font-bold text-muted">
|
||||||
فعلاً بسه، برگردیم به نقشه
|
فعلاً بسه، برگردیم
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -508,3 +538,22 @@ function ReportSheet({ open, questionId, onClose }: { open: boolean; questionId:
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** آیکنِ کوچکِ بعد از جواب (همان خانوادهی آیکنِ اپ، نه ایموجیِ کیبورد)؛ هر سوال یکی از مجموعه، با یک تکانِ کوتاه. */
|
||||||
|
const WIN: [LucideIcon, string][] = [[Brain, '#9B7BFF'], [Zap, '#E8A400'], [Target, '#ED6956'], [ThumbsUp, '#3E9E73'], [Sparkles, '#4C5FD5']];
|
||||||
|
const MISS: [LucideIcon, string][] = [[Lightbulb, '#E8A400'], [Ruler, '#4C5FD5'], [Compass, '#9B7BFF']];
|
||||||
|
function Reaction({ correct, seed }: { correct: boolean; seed: number }) {
|
||||||
|
const set = correct ? WIN : MISS;
|
||||||
|
const [Icon, color] = set[seed % set.length];
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
aria-hidden
|
||||||
|
className="mr-1.5 inline-block align-middle"
|
||||||
|
initial={{ scale: 0.4, rotate: 0 }}
|
||||||
|
animate={{ scale: 1, rotate: [0, -14, 14, -8, 8, 0] }}
|
||||||
|
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<Icon size={18} color={color} strokeWidth={2.4} />
|
||||||
|
</motion.span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||||
|
import { BrandMark } from '@/components/BrandMark';
|
||||||
|
|
||||||
/** تا وقتی نشستِ قبلی بررسی میشود نمایش داده میشود. */
|
/** نمادهای ریاضی که آرام از پایینِ صفحه بالا میروند؛ فقط CSS (transform/opacity)، بیبارِ اضافه. */
|
||||||
|
const GLYPHS = [
|
||||||
|
{ c: 'π', x: 12, d: 0 }, { c: '+', x: 28, d: 2.6 }, { c: '√', x: 46, d: 1.2 },
|
||||||
|
{ c: '÷', x: 64, d: 3.8 }, { c: '∑', x: 80, d: 0.6 }, { c: '×', x: 90, d: 4.6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** تا وقتی نشستِ قبلی بررسی میشود نمایش داده میشود؛ عمداً تأخیرِ ساختگی ندارد. */
|
||||||
export function SplashScreen() {
|
export function SplashScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center bg-rose">
|
<div className="relative flex h-full flex-col items-center justify-center overflow-hidden bg-rose">
|
||||||
|
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2">
|
||||||
|
{GLYPHS.map((g) => (
|
||||||
|
<span key={g.c} className="rise absolute bottom-0 text-3xl font-black text-white/60" style={{ left: `${g.x}%`, animationDelay: `${g.d}s` }}>
|
||||||
|
{g.c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.5, opacity: 0 }}
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
|
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
|
||||||
className="flex flex-col items-center gap-4"
|
className="relative flex flex-col items-center gap-4"
|
||||||
>
|
>
|
||||||
<div className="rounded-[32px] bg-white/15 p-6 backdrop-blur-sm">
|
<div className="rounded-[32px] bg-white/15 p-6 backdrop-blur-sm">
|
||||||
<SeyarMascot char="sigma" mood="happy" size={130} />
|
<SeyarMascot char="sigma" mood="happy" size={130} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-black text-white">تدریسینو</h1>
|
<BrandMark size={44} />
|
||||||
<p className="text-sm text-white/75">ریاضی، قدمبهقدم، با همراههات</p>
|
<p className="text-sm text-white/80">ریاضی، قدمبهقدم، از روی کتابِ درسی</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export function StreakScreen() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StreakGoal current={calendar.data.currentStreak} />
|
||||||
|
|
||||||
<div className="rounded-3xl border border-line bg-white p-4">
|
<div className="rounded-3xl border border-line bg-white p-4">
|
||||||
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
|
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
|
||||||
{WEEKDAYS.map((d) => <span key={d}>{d}</span>)}
|
{WEEKDAYS.map((d) => <span key={d}>{d}</span>)}
|
||||||
@@ -90,3 +92,24 @@ export function StreakScreen() {
|
|||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** نقطهعطفهای زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعیای پشتش نیست (پاداش تصمیمِ محصولی است). */
|
||||||
|
const MILESTONES = [3, 7, 14, 30, 60, 100];
|
||||||
|
|
||||||
|
function StreakGoal({ current }: { current: number }) {
|
||||||
|
const next = MILESTONES.find((m) => m > current);
|
||||||
|
const reached = MILESTONES.filter((m) => m <= current).pop();
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-line bg-[#FFF3E8] px-4 py-3">
|
||||||
|
<p className="text-sm font-black leading-7 text-ink">
|
||||||
|
{reached ? <><Flame size={16} fill="#FF7A3D" className="mb-0.5 ml-1 inline text-orange" />{toFa(current)} روزهای! </> : null}
|
||||||
|
{next ? `${toFa(next - current)} روز دیگه میشه زنجیرهی ${toFa(next)} روزه.` : 'از همهی نقطهعطفها رد شدی، افسانهای!'}
|
||||||
|
</p>
|
||||||
|
{next && (
|
||||||
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white">
|
||||||
|
<div className="h-full rounded-full bg-orange transition-[width]" style={{ width: `${Math.round((current / next) * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { Check, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
|
import { Check, ChevronLeft, ChevronRight, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
|
||||||
import { api, type Plan } from '@/api';
|
import { api, type Plan, type SubscriptionStatus, type TutorQuota } from '@/api';
|
||||||
|
import { daysLeft } from '@/lib/subscription';
|
||||||
import { newIdempotencyKey } from '@/api/http';
|
import { newIdempotencyKey } from '@/api/http';
|
||||||
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
|
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
|
||||||
import { useAsync } from '@/lib/useAsync';
|
import { useAsync } from '@/lib/useAsync';
|
||||||
@@ -13,72 +14,96 @@ function durationLabel(days: number): string {
|
|||||||
if (days === 30 || days === 31) return 'یکماهه';
|
if (days === 30 || days === 31) return 'یکماهه';
|
||||||
if (days >= 360 && days <= 370) return 'یکساله';
|
if (days >= 360 && days <= 370) return 'یکساله';
|
||||||
if (days === 90) return 'سهماهه';
|
if (days === 90) return 'سهماهه';
|
||||||
|
if (days === 180) return 'ششماهه';
|
||||||
return days ? `${toFa(days)} روزه` : '';
|
return days ? `${toFa(days)} روزه` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type View = 'subscription' | 'pack';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* «بستهی سوال» (از ۱۴۰۵-۰۷ بهجای اشتراکِ ماهانه/سالانه). هر کس به اندازهی مصرفش سوال از
|
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/ششماهه؛ همهی فصلها، قلبِ نامحدود، سقفِ روزانهی دستیار)
|
||||||
* دستیار میخرد؛ اعتبار منقضی نمیشود و هر بسته چند روز قلبِ نامحدود هم هدیه دارد. هر روز
|
* و «بستهی سوال» (سوالِ بیانقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
|
||||||
* اول سوالهای رایگانِ همان روز خرج میشوند، بعد اعتبار.
|
* یکجا کاربر را گیج نکند. از فصلِ قفل یکراست به فهرستِ اشتراک میآید (`initialView`).
|
||||||
*/
|
*/
|
||||||
export function SubscriptionScreen() {
|
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
||||||
const plans = useAsync(() => api.subscription.plans(), []);
|
const plans = useAsync(() => api.subscription.plans(), []);
|
||||||
const status = useAsync(() => api.subscription.status(), []);
|
const status = useAsync(() => api.subscription.status(), []);
|
||||||
const quota = useAsync(() => api.ai.quota(), []);
|
const quota = useAsync(() => api.ai.quota(), []);
|
||||||
|
const [view, setView] = useState<View | null>(initialView ?? null);
|
||||||
const [buying, setBuying] = useState<Plan | null>(null);
|
const [buying, setBuying] = useState<Plan | null>(null);
|
||||||
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درونبرنامهایِ خودش است؛ بستهای که
|
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درونبرنامهایِ خودش است؛ محصولی که
|
||||||
// SKU ندارد آنجا خریدنی نیست (به زرینپال فرستاده نمیشود).
|
// SKU ندارد آنجا خریدنی نیست (به زرینپال فرستاده نمیشود).
|
||||||
const bazaar = inBazaarApp();
|
const bazaar = inBazaarApp();
|
||||||
|
|
||||||
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
|
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
|
||||||
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
|
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
|
||||||
const reload = () => { status.reload(); quota.reload(); };
|
const reload = () => { status.reload(); quota.reload(); };
|
||||||
|
const list = plans.data?.plans.filter((p) => p.kind === view) ?? [];
|
||||||
|
const monthly = plans.data?.plans.find((p) => p.kind === 'subscription' && p.durationDays <= 31);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ScreenHeader title="بستهی سوال" subtitle="هر وقت لازم داشتی، سوال بخر" color="#ED6956" mascot={{ char: 'pi', mood: 'excited', size: 60 }} />
|
<ScreenHeader
|
||||||
|
title={view === 'pack' ? 'بستهی سوال' : view === 'subscription' ? 'اشتراکِ تدریسینو' : 'فروشگاه'}
|
||||||
|
subtitle={view === 'pack' ? 'سوالِ بیشتر از دستیار، بدونِ انقضا' : 'همهی فصلها، قلبِ نامحدود'}
|
||||||
|
color="#ED6956"
|
||||||
|
exact
|
||||||
|
mascot={{ char: 'pi', mood: 'excited', size: 60 }}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 px-4 py-5">
|
<div className="flex flex-col gap-4 px-4 py-5">
|
||||||
<div className="rounded-3xl border border-line bg-white p-4">
|
<StatusCard status={status.data ?? null} credits={credits} heartsUntil={heartsUntil} quota={quota.data ?? null} />
|
||||||
<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>
|
|
||||||
<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 ? (
|
{view === null ? (
|
||||||
<LoadingState text="دارم بستهها رو میارم…" />
|
<div className="flex flex-col gap-3">
|
||||||
) : plans.error ? (
|
<ChoiceCard
|
||||||
<ErrorState error={plans.error} onRetry={plans.reload} />
|
icon={<Crown size={24} />}
|
||||||
) : !plans.data?.plans.length ? (
|
tone="#FF7A3D"
|
||||||
<EmptyState text="فعلاً بستهای برای خرید تعریف نشده" />
|
title="اشتراکِ ماهانه"
|
||||||
|
text="همهی فصلها و جزیرهها باز، با قلبِ نامحدود"
|
||||||
|
hint={monthly ? `از ${formatToman(monthly.price)}` : undefined}
|
||||||
|
onClick={() => setView('subscription')}
|
||||||
|
/>
|
||||||
|
<ChoiceCard
|
||||||
|
icon={<Sparkles size={24} />}
|
||||||
|
tone="#9B7BFF"
|
||||||
|
title="بستهی سوال"
|
||||||
|
text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچوقت منقضی نمیشه"
|
||||||
|
onClick={() => setView('pack')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
plans.data.plans.map((plan) => (
|
<>
|
||||||
<PackCard key={plan.id} plan={plan} available={!bazaar || !!plan.bazaarSku} onBuy={() => setBuying(plan)} />
|
{!initialView && (
|
||||||
))
|
<button type="button" onClick={() => setView(null)} className="flex items-center gap-1 self-start text-xs font-bold text-coral">
|
||||||
|
<ChevronRight size={14} /> برگشت به انتخاب
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{plans.loading ? (
|
||||||
|
<LoadingState text="دارم فهرست رو میارم…" />
|
||||||
|
) : plans.error ? (
|
||||||
|
<ErrorState error={plans.error} onRetry={plans.reload} />
|
||||||
|
) : !list.length ? (
|
||||||
|
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
|
||||||
|
) : (
|
||||||
|
list.map((plan) => (
|
||||||
|
<PackCard
|
||||||
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
monthlyPrice={monthly?.price}
|
||||||
|
// اشتراک در بازار هنوز SKU و تمدیدِ آزمودهشده ندارد؛ آنجا فقط بسته خریدنی است.
|
||||||
|
available={!bazaar || (plan.kind === 'pack' && !!plan.bazaarSku)}
|
||||||
|
onBuy={() => setBuying(plan)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{view === 'pack' && (
|
||||||
|
<p className="text-center text-xs leading-6 text-muted">
|
||||||
|
هر روز {toFa(status.data?.aiDailyQuota ?? plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول از سقفِ روزانهته؛ بعدش از اعتبارِ بسته کم میشه.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
|
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
|
||||||
@@ -92,7 +117,58 @@ export function SubscriptionScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean; onBuy: () => void }) {
|
function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} className="flex items-center gap-3 rounded-3xl border-2 border-line bg-white p-4 text-right active:scale-[0.99]">
|
||||||
|
<span className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl text-white" style={{ backgroundColor: tone }}>{icon}</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-base font-black text-ink">{title}</span>
|
||||||
|
<span className="mt-0.5 block text-xs leading-6 text-muted">{text}</span>
|
||||||
|
{hint && <span className="mt-1 block text-xs font-black" style={{ color: tone }}>{hint}</span>}
|
||||||
|
</span>
|
||||||
|
<ChevronLeft size={18} className="flex-shrink-0 text-faint" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */
|
||||||
|
function StatusCard({ status, credits, heartsUntil, quota }: {
|
||||||
|
status: SubscriptionStatus | null; credits: number; heartsUntil: string | null; quota: TutorQuota | null;
|
||||||
|
}) {
|
||||||
|
const days = status?.active ? daysLeft(status.expiresAt) : null;
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-line bg-white p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl ${status?.active ? 'bg-[#FFF4D6] text-[#E0A200]' : 'bg-soft text-muted'}`}><Crown size={22} /></span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xs font-bold text-muted">اشتراکِ تو</div>
|
||||||
|
<div className="text-base font-black text-ink">
|
||||||
|
{status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</span>}</> : 'نداری'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{quota && (
|
||||||
|
<div className="text-left text-xs leading-6 text-muted">
|
||||||
|
سوالِ امروز<br />
|
||||||
|
<b className="text-base text-ink">{toFa(quota.remainingQuota)}</b> از {toFa(status?.aiDailyQuota ?? quota.freeDaily)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-xs font-bold">
|
||||||
|
<span className="flex items-center gap-1.5 text-violet"><Sparkles size={13} /> اعتبار: {toFa(credits)} سوال</span>
|
||||||
|
{(status?.active || heartsUntil) && (
|
||||||
|
<span className="flex items-center gap-1.5 text-coral">
|
||||||
|
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}</>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status?.active && status.expiresAt && <span className="text-muted">تا {formatJalaliDate(status.expiresAt)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthlyPrice?: number; available: boolean; onBuy: () => void }) {
|
||||||
|
const months = Math.round(plan.durationDays / 30);
|
||||||
|
const saving = plan.kind === 'subscription' && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
|
||||||
const featured = plan.popular;
|
const featured = plan.popular;
|
||||||
const isPack = plan.kind === 'pack';
|
const isPack = plan.kind === 'pack';
|
||||||
const tone = featured ? 'text-white/80' : 'text-muted';
|
const tone = featured ? 'text-white/80' : 'text-muted';
|
||||||
@@ -105,10 +181,12 @@ function PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean;
|
|||||||
<div className="mb-3 flex items-start justify-between gap-3">
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-black">{isPack ? <>{toFa(plan.questionCredits)} <span className="text-base">سوال</span></> : plan.title}</h3>
|
<h3 className="text-2xl font-black">{isPack ? <>{toFa(plan.questionCredits)} <span className="text-base">سوال</span></> : plan.title}</h3>
|
||||||
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ «پی»، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
|
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ هوشمند، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="text-base font-black">{formatToman(plan.price)}</p>
|
<p className="text-base font-black">{formatToman(plan.price)}</p>
|
||||||
|
{months > 1 && !isPack && <p className={`text-[11px] ${tone}`}>ماهی {formatToman(Math.round(plan.price / months / 1000) * 1000)}</p>}
|
||||||
|
{saving > 0 && <p className={`mt-0.5 text-[11px] font-black ${featured ? 'text-white' : 'text-mint'}`}>{toFa(saving)}٪ ارزانتر</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul className="mb-4 flex flex-col gap-2 text-sm">
|
<ul className="mb-4 flex flex-col gap-2 text-sm">
|
||||||
@@ -233,7 +311,7 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
|||||||
{done ? (
|
{done ? (
|
||||||
<>
|
<>
|
||||||
<p className="mb-5 text-sm leading-7 text-muted">
|
<p className="mb-5 text-sm leading-7 text-muted">
|
||||||
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و سوالها به اعتبارت اضافه شدن.'}
|
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
|
||||||
</p>
|
</p>
|
||||||
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Sparkles } from 'lucide-react';
|
import { Crown, Sparkles } from 'lucide-react';
|
||||||
import type { HomeNode } from '@/api';
|
import type { HomeNode } from '@/api';
|
||||||
import { useAppData } from '@/state/AppData';
|
import { useAppData } from '@/state/AppData';
|
||||||
import { findUnit, unitColor, unitProgress } from '@/state/curriculum';
|
import { findUnit, unitColor, unitProgress } from '@/state/curriculum';
|
||||||
@@ -14,6 +14,7 @@ import { MathText } from '@/lib/MathText';
|
|||||||
export function UnitScreen({ unitId }: { unitId: string }) {
|
export function UnitScreen({ unitId }: { unitId: string }) {
|
||||||
const { home, homeError, refreshHome, companion } = useAppData();
|
const { home, homeError, refreshHome, companion } = useAppData();
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
|
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
|
||||||
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
|
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
|
||||||
const found = findUnit(home, unitId);
|
const found = findUnit(home, unitId);
|
||||||
|
|
||||||
@@ -58,9 +59,14 @@ export function UnitScreen({ unitId }: { unitId: string }) {
|
|||||||
nodes={unit.nodes}
|
nodes={unit.nodes}
|
||||||
color={color}
|
color={color}
|
||||||
currentId={current?.id ?? null}
|
currentId={current?.id ?? null}
|
||||||
onOpen={setOpenNode}
|
onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode(node))}
|
||||||
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
|
onStart={(node) => (unit.requiresSubscription ? paywall() : nav.push({ name: 'quiz', nodeId: node.id }))}
|
||||||
/>
|
/>
|
||||||
|
{unit.requiresSubscription && (
|
||||||
|
<button type="button" onClick={paywall} className="mx-auto mt-6 flex items-center gap-2 rounded-2xl bg-orange px-5 py-3 text-sm font-black text-white shadow-lg shadow-orange/30">
|
||||||
|
<Crown size={16} /> این فصل با اشتراک باز میشه
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="mt-6 flex flex-col items-center">
|
<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="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` }}>
|
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface NodeRef {
|
|||||||
export function findContinue(home: HomeResponse | null): NodeRef | null {
|
export function findContinue(home: HomeResponse | null): NodeRef | null {
|
||||||
if (!home) return null;
|
if (!home) return null;
|
||||||
for (let i = 0; i < home.units.length; i++) {
|
for (let i = 0; i < home.units.length; i++) {
|
||||||
|
if (home.units[i].requiresSubscription) continue;
|
||||||
const node = home.units[i].nodes.find((n) => n.status !== 'completed' && n.status !== 'locked');
|
const node = home.units[i].nodes.find((n) => n.status !== 'completed' && n.status !== 'locked');
|
||||||
if (node) return { unit: home.units[i], unitIndex: i, node };
|
if (node) return { unit: home.units[i], unitIndex: i, node };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@
|
|||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
/* بیتأخیرِ دابلتپ و بیزوم با دو ضربه روی دکمهها */
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -93,3 +95,27 @@
|
|||||||
@utility pb-safe {
|
@utility pb-safe {
|
||||||
padding-bottom: max(env(safe-area-inset-bottom), 0px);
|
padding-bottom: max(env(safe-area-inset-bottom), 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* نمادهای شناورِ سربرگ (ui.tsx). فقط transform، پس روی GPU میماند. */
|
||||||
|
@keyframes glyph-float {
|
||||||
|
0%, 100% { transform: translateY(0) rotate(-6deg); }
|
||||||
|
50% { transform: translateY(-7px) rotate(6deg); }
|
||||||
|
}
|
||||||
|
.glyph {
|
||||||
|
animation: glyph-float 6s ease-in-out infinite;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ناحیهی لمسِ بزرگتر بدونِ تغییرِ ظاهر: دکمههای کوچکِ سربرگ (۲۸–۳۲ پیکسل) برای انگشت کوچکاند. */
|
||||||
|
.hit { position: relative; }
|
||||||
|
.hit::after { content: ''; position: absolute; inset: -7px -3px; }
|
||||||
|
|
||||||
|
/* نوارِ اشتراک و نشانِ تدریسینو: فقط transform/opacity تا روی GPU بماند و روانی حفظ شود. */
|
||||||
|
@keyframes bar-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||||
|
.bar-fill { transform-origin: right; animation: bar-fill 0.9s cubic-bezier(.2,.8,.2,1) both; }
|
||||||
|
@keyframes shine { 0% { transform: translateX(120%); } 60%, 100% { transform: translateX(-220%); } }
|
||||||
|
.shine { animation: shine 3.2s ease-in-out infinite; will-change: transform; }
|
||||||
|
@keyframes spark { 0%, 100% { opacity: .25; transform: scale(.7); } 50% { opacity: 1; transform: scale(1); } }
|
||||||
|
.spark { animation: spark 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||||
|
@keyframes rise { 0% { transform: translateY(16px); opacity: 0; } 15%, 85% { opacity: .55; } 100% { transform: translateY(-110px); opacity: 0; } }
|
||||||
|
.rise { animation: rise 7s linear infinite; will-change: transform, opacity; }
|
||||||
|
|||||||