forked from Tadrisino/MathApp_frontend
UI readability pass and Bazaar in-app purchase bridge
- muted text #9e9ec0 -> #727299 (contrast 2.6 -> 4.5 on white); tab labels 11px - map: chapter titles wrap to two lines instead of being cut off - reduced-motion respected (MotionConfig + CSS), focus-visible ring, no pull-to-refresh, short vibration on correct/wrong answers - src/lib/bazaar.ts: bridge to the Android WebView wrapper; checkout uses Bazaar IAP only inside that app, Zarinpal everywhere else (unchanged) - android/: WebView wrapper with Poolakey; written but never built Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
8
android/.gitignore
vendored
Normal file
8
android/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
app/build/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
44
android/README.md
Normal file
44
android/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# اپِ اندرویدِ تدریسینو (پوستهی WebView + پرداختِ بازار)
|
||||||
|
|
||||||
|
همان سایتِ `math.tadrisino2.ir` داخلِ یک WebView. تنها کدِ بومی پرداختِ درونبرنامهایِ
|
||||||
|
کافهبازار است (کتابخانهی Poolakey)، چون بازار برای اپی که در آن منتشر میشود پرداختِ
|
||||||
|
محصولِ دیجیتال را فقط از راهِ خودش میپذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
|
||||||
|
فرانت بهروز میشود، بیآنکه نسخهی تازهی اپ لازم باشد.
|
||||||
|
|
||||||
|
> ⚠️ **این پروژه هنوز یک بار هم build نشده.** روی ماشینی نوشته شد که Android SDK نداشت
|
||||||
|
> (۲۰۲۶-۱۰-۰۳). APIِ Poolakey از README خودِ کتابخانه است. اولین کار: باز کردن در Android
|
||||||
|
> Studio، build، و یک خریدِ واقعی روی گوشی.
|
||||||
|
|
||||||
|
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
|
||||||
|
|
||||||
|
| چه | کجا گذاشته میشود | وضعیت |
|
||||||
|
|---|---|---|
|
||||||
|
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.app` حدس است؛ تأیید نشده |
|
||||||
|
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | ندارد؛ بدونِ آن خرید شروع نمیشود |
|
||||||
|
| SKUِ هر بسته (محصولِ **مصرفشدنی**) | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچکدام تعریف نشده |
|
||||||
|
|
||||||
|
قیمتِ هر محصول در پیشخانِ بازار باید با قیمتِ همان بسته در ادمین یکی باشد؛ سرور مبلغ را از
|
||||||
|
بازار نمیخواند.
|
||||||
|
|
||||||
|
## build
|
||||||
|
|
||||||
|
1. Android Studio ← Open ← همین پوشهی `android/`. Studio خودش Gradle wrapper را میسازد.
|
||||||
|
2. `Build ← Generate Signed App Bundle / APK`، با کلیدِ امضای شرکت. **کلیدِ امضا را گم نکنید**:
|
||||||
|
بدونِ همان کلید هیچ نسخهی بعدی روی بازار بهروزرسانی نمیشود.
|
||||||
|
3. نصب روی گوشیای که کافهبازار دارد و با حسابِ تست وارد شده.
|
||||||
|
|
||||||
|
## جریانِ خرید (برای عیبیابی)
|
||||||
|
|
||||||
|
```
|
||||||
|
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
|
||||||
|
فرانت TadrisinoAndroid.bazaarPurchase(sku, orderId)
|
||||||
|
اپ Poolakey.purchaseProduct(sku, payload=orderId) → purchaseToken
|
||||||
|
اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken}
|
||||||
|
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار میپرسد
|
||||||
|
فرانت TadrisinoAndroid.bazaarConsume(token) ← تا همان بسته دوباره خریدنی باشد
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» میزند: فرانت همان سفارش را میفرستد
|
||||||
|
و اپ توکنِ ماندهٔ همان سفارش را از `getPurchasedProducts` پیدا میکند.
|
||||||
|
|
||||||
|
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بکاند.
|
||||||
50
android/app/build.gradle.kts
Normal file
50
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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.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")
|
||||||
|
}
|
||||||
29
android/app/src/main/AndroidManifest.xml
Normal file
29
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- برای لرزشِ کوتاهِ جوابِ درست/غلط (navigator.vibrate در WebView). -->
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
|
<!-- اندروید ۱۱+: بدونِ این، اپ نمیتواند سرویسِ پرداختِ بازار را پیدا کند. -->
|
||||||
|
<queries>
|
||||||
|
<package android:name="com.farsitel.bazaar" />
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Tadrisino">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
167
android/app/src/main/java/com/tadrisino/app/MainActivity.kt
Normal file
167
android/app/src/main/java/com/tadrisino/app/MainActivity.kt
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package com.tadrisino.app
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import ir.cafebazaar.poolakey.Connection
|
||||||
|
import ir.cafebazaar.poolakey.Payment
|
||||||
|
import ir.cafebazaar.poolakey.config.PaymentConfiguration
|
||||||
|
import ir.cafebazaar.poolakey.config.SecurityCheck
|
||||||
|
import ir.cafebazaar.poolakey.request.PurchaseRequest
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درونبرنامهایِ بازار است.
|
||||||
|
*
|
||||||
|
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
|
||||||
|
* JS → اینجا TadrisinoAndroid.bazaarPurchase(sku, orderId)
|
||||||
|
* TadrisinoAndroid.bazaarConsume(purchaseToken)
|
||||||
|
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
|
||||||
|
* { orderId, error: "canceled" | "failed", message }
|
||||||
|
*
|
||||||
|
* سمتِ سرور توکن را از خودِ بازار میسنجد (`POST /api/payment/bazaar/verify`)، پس اینجا
|
||||||
|
* فقط واسطه است و به هیچ نتیجهای اعتماد نمیکند.
|
||||||
|
*
|
||||||
|
* ⚠️ نوشته شده بیآنکه build شود (ماشینِ نویسنده Android SDK نداشت، ۲۰۲۶-۱۰-۰۳)؛ APIِ
|
||||||
|
* Poolakey از README خودش است. پیش از انتشار با Android Studio بسازید و یک خریدِ واقعی بزنید.
|
||||||
|
*/
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private lateinit var webView: WebView
|
||||||
|
private var payment: Payment? = null
|
||||||
|
private var connection: Connection? = null
|
||||||
|
private val webHost: String = Uri.parse(BuildConfig.WEB_URL).host ?: ""
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
webView = WebView(this)
|
||||||
|
setContentView(webView)
|
||||||
|
|
||||||
|
webView.settings.javaScriptEnabled = true
|
||||||
|
webView.settings.domStorageEnabled = true // توکنِ ورود در localStorage است
|
||||||
|
webView.webViewClient = object : WebViewClient() {
|
||||||
|
// هر آدرسی بیرونِ دامنهی خودمان در مرورگرِ گوشی باز میشود، نه داخلِ اپ؛ پس پل فقط
|
||||||
|
// در صفحههای خودمان در دسترس است.
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||||
|
if (request.url.host == webHost) return false
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, request.url))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webView.addJavascriptInterface(Bridge(), "TadrisinoAndroid")
|
||||||
|
|
||||||
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
|
// اپِ وب برای هر صفحه history.pushState میزند؛ دکمهی back همان را برمیگرداند.
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
if (webView.canGoBack()) webView.goBack() else finish()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
connectToBazaar()
|
||||||
|
if (savedInstanceState == null) webView.loadUrl(BuildConfig.WEB_URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectToBazaar() {
|
||||||
|
if (BuildConfig.BAZAAR_RSA_KEY.isEmpty()) {
|
||||||
|
Log.e(TAG, "BAZAAR_RSA_KEY is empty; in-app purchase disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val config = PaymentConfiguration(
|
||||||
|
localSecurityCheck = SecurityCheck.Enable(rsaPublicKey = BuildConfig.BAZAAR_RSA_KEY),
|
||||||
|
)
|
||||||
|
payment = Payment(context = this, config = config).also { p ->
|
||||||
|
connection = p.connect {
|
||||||
|
connectionSucceed { Log.i(TAG, "bazaar connected") }
|
||||||
|
connectionFailed { Log.e(TAG, "bazaar connection failed", it) }
|
||||||
|
disconnected { Log.w(TAG, "bazaar disconnected") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun purchase(sku: String, orderId: String) {
|
||||||
|
val p = payment
|
||||||
|
if (p == null) {
|
||||||
|
send(orderId, error = "failed", message = "bazaar not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.purchaseProduct(
|
||||||
|
registry = activityResultRegistry,
|
||||||
|
request = PurchaseRequest(productId = sku, payload = orderId),
|
||||||
|
) {
|
||||||
|
purchaseSucceed { send(orderId, token = it.purchaseToken) }
|
||||||
|
purchaseCanceled { send(orderId, error = "canceled") }
|
||||||
|
purchaseFailed { recoverOwned(sku, orderId, it) }
|
||||||
|
failedToBeginFlow { recoverOwned(sku, orderId, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* خریدِ قبلیِ همین محصول که مصرف نشده (پول رفته ولی تأییدِ سرور نرسیده بود) جلوی خریدِ
|
||||||
|
* دوباره را میگیرد. فرانت در تلاشِ بعدی همان سفارش را میفرستد؛ اگر توکنِ ماندهٔ همان
|
||||||
|
* سفارش (payload) پیدا شد، همان برمیگردد تا سرور ثبتش کند.
|
||||||
|
*/
|
||||||
|
private fun recoverOwned(sku: String, orderId: String, cause: Throwable) {
|
||||||
|
val p = payment ?: return send(orderId, error = "failed", message = cause.message)
|
||||||
|
p.getPurchasedProducts {
|
||||||
|
querySucceed { owned ->
|
||||||
|
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
|
||||||
|
if (match != null) send(orderId, token = match.purchaseToken)
|
||||||
|
else send(orderId, error = "failed", message = cause.message)
|
||||||
|
}
|
||||||
|
queryFailed { send(orderId, error = "failed", message = cause.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun consume(purchaseToken: String) {
|
||||||
|
payment?.consumeProduct(purchaseToken) {
|
||||||
|
consumeSucceed { Log.i(TAG, "consumed") }
|
||||||
|
consumeFailed { Log.e(TAG, "consume failed", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun send(orderId: String, token: String? = null, error: String? = null, message: String? = null) {
|
||||||
|
val detail = JSONObject().put("orderId", orderId)
|
||||||
|
if (token != null) detail.put("purchaseToken", token)
|
||||||
|
if (error != null) detail.put("error", error)
|
||||||
|
if (message != null) detail.put("message", message)
|
||||||
|
runOnUiThread {
|
||||||
|
webView.evaluateJavascript(
|
||||||
|
"window.dispatchEvent(new CustomEvent('tadrisino:bazaar', { detail: $detail }));", null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** متدهای این کلاس روی threadِ WebView صدا زده میشوند، نه UI. */
|
||||||
|
private inner class Bridge {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun bazaarPurchase(sku: String, orderId: String) {
|
||||||
|
runOnUiThread {
|
||||||
|
if (Uri.parse(webView.url ?: "").host != webHost) return@runOnUiThread
|
||||||
|
purchase(sku, orderId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun bazaarConsume(purchaseToken: String) {
|
||||||
|
runOnUiThread { consume(purchaseToken) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
connection?.disconnect()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "Tadrisino"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">تدریسینو</string>
|
||||||
|
</resources>
|
||||||
7
android/app/src/main/res/values/themes.xml
Normal file
7
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Tadrisino" parent="Theme.AppCompat.Light.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">#ED6956</item>
|
||||||
|
<item name="android:windowBackground">@android:color/white</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
4
android/build.gradle.kts
Normal file
4
android/build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.5.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||||
|
}
|
||||||
2
android/gradle.properties
Normal file
2
android/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
org.gradle.jvmargs=-Xmx2g
|
||||||
18
android/settings.gradle.kts
Normal file
18
android/settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
// Poolakey (کتابخانهی پرداختِ کافهبازار) فقط روی jitpack منتشر میشود.
|
||||||
|
maven { url = uri("https://jitpack.io") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "Tadrisino"
|
||||||
|
include(":app")
|
||||||
@@ -178,6 +178,15 @@ export const api = {
|
|||||||
status: () => http.get<T.SubscriptionStatus>('/subscription/status'),
|
status: () => http.get<T.SubscriptionStatus>('/subscription/status'),
|
||||||
/** سفارش میسازد؛ paymentUrl خودش سراغِ درگاه میرود. */
|
/** سفارش میسازد؛ paymentUrl خودش سراغِ درگاه میرود. */
|
||||||
purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }),
|
purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }),
|
||||||
|
/**
|
||||||
|
* خرید داخلِ اپِ اندرویدِ بازار. idempotencyKey ثابت است تا تلاشِ دوباره بعد از قطعِ اینترنت
|
||||||
|
* همان سفارش را برگرداند (src/lib/bazaar.ts).
|
||||||
|
*/
|
||||||
|
purchaseBazaar: (planId: string, idempotencyKey: string) =>
|
||||||
|
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }),
|
||||||
|
/** توکنِ خریدِ Poolakey را سرور از خودِ بازار میسنجد؛ گفتهی اپ کافی نیست. */
|
||||||
|
verifyBazaar: (orderId: string, purchaseToken: string) =>
|
||||||
|
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),
|
||||||
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
|
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
|
||||||
redeem: (code: string, orderId: string) =>
|
redeem: (code: string, orderId: string) =>
|
||||||
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
|
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
|
||||||
|
|||||||
@@ -258,6 +258,8 @@ export interface Plan {
|
|||||||
questionCredits: number;
|
questionCredits: number;
|
||||||
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
|
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
|
||||||
heartsDays: number;
|
heartsDays: number;
|
||||||
|
/** SKUِ همین بسته در کافهبازار؛ خالی یعنی داخلِ اپِ بازار خریدنی نیست. */
|
||||||
|
bazaarSku: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlansResponse {
|
export interface PlansResponse {
|
||||||
@@ -286,7 +288,16 @@ export interface TutorQuota {
|
|||||||
|
|
||||||
export interface PurchaseResponse {
|
export interface PurchaseResponse {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
paymentUrl: string;
|
/** زرینپال: آدرسِ درگاه. بازار: null. */
|
||||||
|
paymentUrl: string | null;
|
||||||
|
/** فقط بازار: SKUی که اپِ اندروید میخرد. */
|
||||||
|
bazaarSku?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BazaarVerifyResponse {
|
||||||
|
orderId: string;
|
||||||
|
status: string;
|
||||||
|
valid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RedeemResponse {
|
export interface RedeemResponse {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, MotionConfig, motion } from 'motion/react';
|
||||||
import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
|
import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
|
||||||
import { AppShell } from '@/components/AppShell';
|
import { AppShell } from '@/components/AppShell';
|
||||||
import { ToastProvider } from '@/components/Toast';
|
import { ToastProvider } from '@/components/Toast';
|
||||||
@@ -83,6 +83,8 @@ export function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// reducedMotion="user": اگر «کاهشِ حرکت» در گوشی روشن باشد، motion جابهجاییها را حذف میکند.
|
||||||
|
<MotionConfig reducedMotion="user">
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
@@ -129,6 +131,7 @@ export function App() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
</MotionConfig>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ export function BottomNav({ active, onChange }: { active: TabName | null; onChan
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(id)}
|
onClick={() => onChange(id)}
|
||||||
aria-current={on ? 'page' : undefined}
|
aria-current={on ? 'page' : undefined}
|
||||||
className={`flex flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-colors ${on ? 'text-coral' : 'text-muted'}`}
|
className={`flex min-w-14 flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-colors ${on ? 'text-coral' : 'text-muted'}`}
|
||||||
>
|
>
|
||||||
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
|
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
|
||||||
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
|
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-bold">{label}</span>
|
<span className="text-[11px] font-bold">{label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
96
src/lib/bazaar.ts
Normal file
96
src/lib/bazaar.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { store } from './storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پلِ پرداختِ کافهبازار، وقتی همین سایت داخلِ اپِ اندرویدِ تدریسینو (WebView) باز است.
|
||||||
|
*
|
||||||
|
* خریدِ درونبرنامهایِ بازار فقط از کدِ بومیِ اندروید (کتابخانهی Poolakey) شدنی است، پس
|
||||||
|
* اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه میگذارد و این فایل تنها
|
||||||
|
* جایی است که با آن حرف میزند. در مرورگرِ معمولی آن شیء نیست، `inBazaarApp()` false است و
|
||||||
|
* خرید مثلِ قبل به زرینپال میرود.
|
||||||
|
*
|
||||||
|
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
|
||||||
|
* JS → اندروید TadrisinoAndroid.bazaarPurchase(sku, orderId) خرید را شروع کن؛ orderId همان
|
||||||
|
* developerPayloadِ خرید است.
|
||||||
|
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) بعد از تأییدِ سرور؛ بدونِ آن
|
||||||
|
* همان بسته دوباره خریدنی نیست.
|
||||||
|
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
|
||||||
|
* { orderId, purchaseToken } خرید انجام شد
|
||||||
|
* { orderId, error: 'canceled' | 'failed', message? }
|
||||||
|
*
|
||||||
|
* هیچکدام از اینها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۰۳): اپِ اندروید ساخته نشده.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface AndroidBridge {
|
||||||
|
bazaarPurchase?: (sku: string, orderId: string) => void;
|
||||||
|
bazaarConsume?: (purchaseToken: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
TadrisinoAndroid?: AndroidBridge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BridgeResult =
|
||||||
|
| { orderId: string; purchaseToken: string }
|
||||||
|
| { orderId: string; error: 'canceled' | 'failed'; message?: string };
|
||||||
|
|
||||||
|
export class BazaarPurchaseError extends Error {
|
||||||
|
constructor(readonly kind: 'canceled' | 'failed' | 'timeout', message = '') {
|
||||||
|
super(message || kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inBazaarApp(): boolean {
|
||||||
|
return typeof window.TadrisinoAndroid?.bazaarPurchase === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** صفحهی خریدِ بازار ممکن است طول بکشد (ورود به بازار، کارتِ بانکی)؛ این سقف فقط جلوی گیر کردنِ همیشگی را میگیرد. */
|
||||||
|
const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
/** خرید را به اپِ اندروید میسپارد و purchaseToken را برمیگرداند. */
|
||||||
|
export function bazaarPurchase(sku: string, orderId: string): Promise<string> {
|
||||||
|
const bridge = window.TadrisinoAndroid;
|
||||||
|
if (!bridge?.bazaarPurchase) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app'));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = window.setTimeout(() => finish(() => reject(new BazaarPurchaseError('timeout'))), PURCHASE_TIMEOUT_MS);
|
||||||
|
function finish(settle: () => void) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
window.removeEventListener('tadrisino:bazaar', onResult);
|
||||||
|
settle();
|
||||||
|
}
|
||||||
|
function onResult(event: Event) {
|
||||||
|
const detail = (event as CustomEvent<BridgeResult>).detail;
|
||||||
|
if (!detail || detail.orderId !== orderId) return;
|
||||||
|
if ('purchaseToken' in detail && detail.purchaseToken) finish(() => resolve(detail.purchaseToken));
|
||||||
|
else if ('error' in detail) finish(() => reject(new BazaarPurchaseError(detail.error, detail.message)));
|
||||||
|
}
|
||||||
|
window.addEventListener('tadrisino:bazaar', onResult);
|
||||||
|
try {
|
||||||
|
bridge.bazaarPurchase!(sku, orderId);
|
||||||
|
} catch (err) {
|
||||||
|
finish(() => reject(new BazaarPurchaseError('failed', String(err))));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bazaarConsume(purchaseToken: string) {
|
||||||
|
try {
|
||||||
|
window.TadrisinoAndroid?.bazaarConsume?.(purchaseToken);
|
||||||
|
} catch (err) {
|
||||||
|
// مصرف نشدن یعنی خریدِ دوبارهی همین بسته تا مصرفِ بعدی گیر میکند؛ اعتبارِ کاربر سالم است.
|
||||||
|
console.error('bazaarConsume failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotency-Keyِ خریدِ نیمهتمامِ هر SKU. اگر بازار پول را گرفت ولی تأییدِ سرور نرسید (قطعِ
|
||||||
|
* اینترنت)، تلاشِ بعدی باید همان سفارش را بگیرد؛ سفارشِ تازه با توکنِ قبلی تأیید نمیشد چون هر
|
||||||
|
* توکن فقط به یک سفارش میخورد.
|
||||||
|
*/
|
||||||
|
const pendingKey = (sku: string) => `tadrisino.bazaarPending.${sku}`;
|
||||||
|
export const bazaarPending = {
|
||||||
|
get: (sku: string) => store.get(pendingKey(sku)),
|
||||||
|
set: (sku: string, key: string) => store.set(pendingKey(sku), key),
|
||||||
|
clear: (sku: string) => store.remove(pendingKey(sku)),
|
||||||
|
};
|
||||||
@@ -43,12 +43,29 @@ function tone(frequency: number, start: number, duration: number, volume = 0.05)
|
|||||||
osc.stop(t + duration + 0.02);
|
osc.stop(t + duration + 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* لرزشِ کوتاه کنارِ صدا، با همان کلیدِ «صدا» خاموش میشود. فقط اندروید/کروم دارد؛
|
||||||
|
* Safariِ آیفون `vibrate` ندارد و اینجا هیچ کاری نمیکند.
|
||||||
|
*/
|
||||||
|
function buzz(pattern: number | number[]) {
|
||||||
|
if (!soundEnabled() || typeof navigator.vibrate !== 'function') return;
|
||||||
|
try {
|
||||||
|
navigator.vibrate(pattern);
|
||||||
|
} catch {
|
||||||
|
/* بعضی WebViewها بیاجازه throw میکنند؛ لرزش فقط تزئینی است */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const sounds = {
|
export const sounds = {
|
||||||
tick: () => tone(720, 0, 0.045, 0.04),
|
tick: () => tone(720, 0, 0.045, 0.04),
|
||||||
correct: () => {
|
correct: () => {
|
||||||
tone(660, 0, 0.12);
|
tone(660, 0, 0.12);
|
||||||
tone(990, 0.1, 0.18);
|
tone(990, 0.1, 0.18);
|
||||||
|
buzz(30);
|
||||||
|
},
|
||||||
|
wrong: () => {
|
||||||
|
tone(220, 0, 0.25, 0.06);
|
||||||
|
buzz([40, 60, 40]);
|
||||||
},
|
},
|
||||||
wrong: () => tone(220, 0, 0.25, 0.06),
|
|
||||||
finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)),
|
finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function MapScreen() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
|
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
|
||||||
<div className="max-w-[130px] truncate text-xs font-black text-ink">{unit.title}</div>
|
<div className="line-clamp-2 max-w-[170px] text-xs font-black leading-5 text-ink">{unit.title}</div>
|
||||||
<div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
|
<div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Check, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
|
import { Check, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
|
||||||
import { api, type Plan } from '@/api';
|
import { api, type Plan } from '@/api';
|
||||||
|
import { newIdempotencyKey } from '@/api/http';
|
||||||
|
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
|
||||||
import { useAsync } from '@/lib/useAsync';
|
import { useAsync } from '@/lib/useAsync';
|
||||||
import { Sheet } from '@/components/Sheet';
|
import { Sheet } from '@/components/Sheet';
|
||||||
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
|
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
|
||||||
@@ -24,6 +26,9 @@ export function SubscriptionScreen() {
|
|||||||
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 [buying, setBuying] = useState<Plan | null>(null);
|
const [buying, setBuying] = useState<Plan | null>(null);
|
||||||
|
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درونبرنامهایِ خودش است؛ بستهای که
|
||||||
|
// SKU ندارد آنجا خریدنی نیست (به زرینپال فرستاده نمیشود).
|
||||||
|
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;
|
||||||
@@ -71,19 +76,23 @@ export function SubscriptionScreen() {
|
|||||||
) : !plans.data?.plans.length ? (
|
) : !plans.data?.plans.length ? (
|
||||||
<EmptyState text="فعلاً بستهای برای خرید تعریف نشده" />
|
<EmptyState text="فعلاً بستهای برای خرید تعریف نشده" />
|
||||||
) : (
|
) : (
|
||||||
plans.data.plans.map((plan) => <PackCard key={plan.id} plan={plan} onBuy={() => setBuying(plan)} />)
|
plans.data.plans.map((plan) => (
|
||||||
|
<PackCard key={plan.id} plan={plan} available={!bazaar || !!plan.bazaarSku} onBuy={() => setBuying(plan)} />
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="pb-2 text-center text-[11px] leading-6 text-faint">پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.</p>
|
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
|
||||||
|
{bazaar ? 'پرداخت از طریقِ کافهبازار انجام میشه.' : 'پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaidWithCode={() => { setBuying(null); reload(); }} />
|
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); reload(); }} />
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) {
|
function PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean; onBuy: () => void }) {
|
||||||
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';
|
||||||
@@ -122,9 +131,10 @@ function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBuy}
|
onClick={onBuy}
|
||||||
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] ${featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'}`}
|
disabled={!available}
|
||||||
|
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-50 ${featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'}`}
|
||||||
>
|
>
|
||||||
خرید
|
{available ? 'خرید' : 'بهزودی در بازار'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,27 +146,70 @@ function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) {
|
|||||||
* و بعد کاربر در همین برگه به درگاه میرود. window.open بعد از await را
|
* و بعد کاربر در همین برگه به درگاه میرود. window.open بعد از await را
|
||||||
* Safariِ آیفون مسدود میکند؛ برای همین جابهجاییِ همین صفحه است.
|
* Safariِ آیفون مسدود میکند؛ برای همین جابهجاییِ همین صفحه است.
|
||||||
*/
|
*/
|
||||||
function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; onClose: () => void; onPaidWithCode: () => void }) {
|
function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) {
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [showCode, setShowCode] = useState(false);
|
const [showCode, setShowCode] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [freeDone, setFreeDone] = useState(false);
|
const [done, setDone] = useState<'code' | 'bazaar' | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید.
|
||||||
|
* کدِ تخفیف اینجا نیست: بازار قیمتِ ثابتِ محصول را میگیرد و سرور تخفیف را رد میکند.
|
||||||
|
*/
|
||||||
|
async function payWithBazaar(plan: Plan) {
|
||||||
|
const sku = plan.bazaarSku;
|
||||||
|
let key = bazaarPending.get(sku);
|
||||||
|
if (key) {
|
||||||
|
// کلیدِ مانده از خریدِ قبلی فقط وقتی به کار میآید که سفارشش هنوز در انتظار است؛ سفارشِ
|
||||||
|
// پرداختشده یا ناموفق با خریدِ تازه قاطی نشود.
|
||||||
|
const previous = await api.subscription.purchaseBazaar(plan.id, key).catch(() => null);
|
||||||
|
const status = previous ? await api.subscription.order(previous.orderId).then((o) => o.status).catch(() => null) : null;
|
||||||
|
if (status !== 'pending') key = null;
|
||||||
|
}
|
||||||
|
if (!key) {
|
||||||
|
key = newIdempotencyKey();
|
||||||
|
bazaarPending.set(sku, key);
|
||||||
|
}
|
||||||
|
const order = await api.subscription.purchaseBazaar(plan.id, key);
|
||||||
|
let token: string;
|
||||||
|
try {
|
||||||
|
token = await bazaarPurchase(order.bazaarSku || sku, order.orderId);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const result = await api.subscription.verifyBazaar(order.orderId, token);
|
||||||
|
if (result.status !== 'paid') throw new Error('bazaar purchase not confirmed');
|
||||||
|
bazaarPending.clear(sku);
|
||||||
|
bazaarConsume(token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function pay() {
|
async function pay() {
|
||||||
if (!plan || busy) return;
|
if (!plan || busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
if (bazaar) {
|
||||||
|
try {
|
||||||
|
if (await payWithBazaar(plan)) setDone('bazaar');
|
||||||
|
} catch (err) {
|
||||||
|
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const order = await api.subscription.purchase(plan.id);
|
const order = await api.subscription.purchase(plan.id);
|
||||||
if (code.trim()) {
|
if (code.trim()) {
|
||||||
const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
|
const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
|
||||||
if (redeemed.orderStatus === 'paid') {
|
if (redeemed.orderStatus === 'paid') {
|
||||||
setFreeDone(true);
|
setDone('code');
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!order.paymentUrl) throw new Error('order without paymentUrl');
|
||||||
window.location.assign(order.paymentUrl);
|
window.location.assign(order.paymentUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
|
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
|
||||||
@@ -165,19 +218,21 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
|
|||||||
}
|
}
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
if (freeDone) onPaidWithCode();
|
if (done) onPaid();
|
||||||
else onClose();
|
else onClose();
|
||||||
setCode('');
|
setCode('');
|
||||||
setShowCode(false);
|
setShowCode(false);
|
||||||
setError('');
|
setError('');
|
||||||
setFreeDone(false);
|
setDone(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={plan !== null} onClose={close} title={freeDone ? 'خریدت انجام شد! 🎉' : plan?.title}>
|
<Sheet open={plan !== null} onClose={close} title={done ? 'خریدت انجام شد! 🎉' : plan?.title}>
|
||||||
{freeDone ? (
|
{done ? (
|
||||||
<>
|
<>
|
||||||
<p className="mb-5 text-sm leading-7 text-muted">کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.</p>
|
<p className="mb-5 text-sm leading-7 text-muted">
|
||||||
|
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و سوالها به اعتبارت اضافه شدن.'}
|
||||||
|
</p>
|
||||||
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
||||||
</>
|
</>
|
||||||
) : plan && (
|
) : plan && (
|
||||||
@@ -186,7 +241,7 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
|
|||||||
<span className="text-sm text-body">مبلغ</span>
|
<span className="text-sm text-body">مبلغ</span>
|
||||||
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
|
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
|
||||||
</div>
|
</div>
|
||||||
{showCode ? (
|
{bazaar ? null : showCode ? (
|
||||||
<label className="mb-4 block">
|
<label className="mb-4 block">
|
||||||
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
|
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -21,7 +21,10 @@
|
|||||||
|
|
||||||
--color-ink: #1a1a2e; /* متنِ اصلی */
|
--color-ink: #1a1a2e; /* متنِ اصلی */
|
||||||
--color-body: #3a3a5c; /* متنِ بدنه */
|
--color-body: #3a3a5c; /* متنِ بدنه */
|
||||||
--color-muted: #9e9ec0; /* متنِ کمرنگ */
|
/* متنِ کمرنگ. تا ۲۰۲۶-۱۰-۰۳ #9e9ec0 بود: کنتراست ۲٫۶ روی سفید، زیرِ حدِ ۴٫۵ِ WCAG، و
|
||||||
|
همین رنگ زیرنویسِ ۱۰–۱۲ پیکسلیِ همهی صفحهها و آیکونِ تبهای غیرفعال است. این یکی
|
||||||
|
روی سفید ۴٫۵ و روی پسزمینهی صفحه ۴٫۲ است. */
|
||||||
|
--color-muted: #727299;
|
||||||
--color-faint: #b0b0c8;
|
--color-faint: #b0b0c8;
|
||||||
--color-line: #e9e6f7; /* حاشیهی کارت */
|
--color-line: #e9e6f7; /* حاشیهی کارت */
|
||||||
--color-soft: #f0eeff; /* پسزمینهی ملایم */
|
--color-soft: #f0eeff; /* پسزمینهی ملایم */
|
||||||
@@ -47,6 +50,30 @@
|
|||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
/* کشیدنِ صفحه به پایین در کروم اندروید کلِ اپ را رفرش میکرد (pull-to-refresh)
|
||||||
|
و وسطِ آزمون جوابِ جاری را میپراند؛ صفحههای داخلی خودشان اسکرول دارند. */
|
||||||
|
html, body {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
/* حلقهی فوکوس فقط برای صفحهکلید (focus-visible)، نه برای لمس. */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-indigo);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* کاربری که در تنظیماتِ گوشی «کاهشِ حرکت» را زده: انیمیشنها و transitionهای CSS
|
||||||
|
تقریباً آنی میشوند. انیمیشنهای motion را MotionConfig در App خاموش میکند. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
input, textarea {
|
input, textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user