16 Commits

Author SHA1 Message Date
19ca3d7de1 Merge personal-fork Android build commit; keep MH-W90's signing flow from upstream (owner of Android signing)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:41:20 +03:30
f74b6ad279 Companions: each wears its own Greek letter badge (Δ, π, ω); Tedi renamed Omega (ω)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:40:52 +03:30
a8a5351862 Subscription store, paywall on locked units, profile subscription bar, Tadrisino branding
- Store opens with two choices (subscription / question packs); 3- and 6-month cards
  show per-month price and saving.
- Units that need a subscription show a crown badge and open the store; a 402 from
  the quiz goes there too. «ادامه بده» skips locked units.
- Profile: subscription card with an animated days-left bar (planDurationDays).
- BrandMark (SVG) on splash, home header and footer; copyright footer; floating math
  glyphs on splash. CSS transform/opacity only; honours reduced motion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:19:09 +03:30
8879b42b1c Fix: first screen's looping animations (mascot bob, island ring, start button) never ran until a tab switch; AnimatePresence initial={false} leaked into children 2026-10-08 18:12:35 +03:30
6930c83e07 Quiz: selecting an option only highlights it blue; the answer is submitted by a «بررسی» button and green/red is shown after, then «ادامه» 2026-10-08 18:04:02 +03:30
d0a2822658 Fine-tune: bigger invisible tap areas on header chips, press feedback on tabs and chips, touch-action manipulation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:59:12 +03:30
50f6ade9f8 Replace keyboard emojis with the app's own lucide icons (quiz reactions, +XP, welcome hand, party popper, streak flame)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:47:05 +03:30
22fc870176 Smoother tab switch (no dark flash), full welcome sentence, chapter banner sticky and chip centered, floating math glyphs in headers, quiz reaction emojis
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:38:28 +03:30
54b872026d Lessons and packages headers: back to the owner's preferred colors (green, coral), exactly as before
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:31:01 +03:30
54bd865835 Colors keep their role: headers/banners darken to readable contrast (same hue), lessons uses the teal token, packages use indigo; +XP pop, streak milestone card, lazy confetti
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:26:07 +03:30
dc1fb15a55 Lessons tab: chapters closed by default, no quiz-button flash while lessons load, state kept on back, continue card and progress
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:00:49 +03:30
09ca1a270a Lessons tab replaces the map tab: chapter > island > lesson list with the island quiz beside it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 15:46:38 +03:30
838049c469 Island sheet: quiz button stays primary, lessons button gets a neon-green outline and clearer wording
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 14:37:01 +03:30
2fbcf6e6e0 fixes 2026-10-07 19:04:45 +03:30
5b98c054ea Merge branch 'main' of https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend 2026-10-07 18:30:25 +03:30
c64b3939ba changes 2026-10-07 18:29:03 +03:30
46 changed files with 831 additions and 691 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
}

1
android/.gitignore vendored
View File

@@ -6,4 +6,3 @@ local.properties
*.iml *.iml
*.jks *.jks
*.keystore *.keystore
build-out/

View File

@@ -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`.
## جریانِ خرید (برای عیب‌یابی) ## جریانِ خرید (برای عیب‌یابی)

View File

@@ -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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -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>

View File

@@ -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"

Binary file not shown.

View File

@@ -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
View File

@@ -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
View File

@@ -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%

View File

@@ -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;
/** سوال‌های خریده‌شده‌ی باقی‌مانده. */ /** سوال‌های خریده‌شده‌ی باقی‌مانده. */

View File

@@ -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} />;

View File

@@ -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} />

View 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>
);
}

View 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>
);
}

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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">

View File

@@ -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
View 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));
}

View File

@@ -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, '&#916;', '#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, '&#960;', '#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, '&#969;', '#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) را به یکی از همراه‌ها نگاشت می‌کند. */

View File

@@ -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,

View File

@@ -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);

View File

@@ -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>

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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: 'منم امگا! هر چی امتیازت بیشتر بشه، همراه‌های تازه باز می‌شن. از صفحه‌ی «من» هر کدوممون رو خواستی کنارت می‌ذاری.',
}, },
]; ];

View File

@@ -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()}>

View File

@@ -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>
);
}

View File

@@ -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 }}

View File

@@ -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>
);
}

View File

@@ -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>
</> </>

View File

@@ -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` }}>

View File

@@ -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 };
} }

View File

@@ -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; }