Subscriptions only: no question credits anywhere; Bazaar bridge is subscribeProduct only (bazaarBuy(sku, orderId, token), bazaarPrices(skusJson)), no consume

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 20:33:47 +03:30
parent e0a9605b8e
commit 854149bb5d
7 changed files with 98 additions and 180 deletions

View File

@@ -21,7 +21,7 @@
| `mediaPlaybackRequiresUserGesture = false` | صداهای رابط بعد از پاسخِ سرور پخش می‌شوند، نه در لحظه‌ی لمس | صدای جوابِ درست/غلط و پایانِ تمرین | | `mediaPlaybackRequiresUserGesture = false` | صداهای رابط بعد از پاسخِ سرور پخش می‌شوند، نه در لحظه‌ی لمس | صدای جوابِ درست/غلط و پایانِ تمرین |
| `isPaymentPage` + `paysInApp()` | درگاهِ زرین‌پال داخلِ خودِ اپ باز می‌شود. اپِ فعلی آن را به مرورگرِ گوشی می‌دهد که Referer نمی‌فرستد و زرین‌پال بی‌Referer «دسترسی از این دامنه مجاز نمی‌باشد» می‌دهد؛ فعلاً فرانت از راهِ `pay.html` (با `http://`) دورش می‌زند | «پرداخت» با کارتِ بانکی: صفحه‌ی زرین‌پال و بانک داخلِ اپ، و بعد از پرداخت برگشت به اپ با پیامِ «پرداخت موفق بود» (PaymentResult). دکمه‌ی برگشت وسطِ صفحه‌ی بانک چه می‌کند را هم ببین | | `isPaymentPage` + `paysInApp()` | درگاهِ زرین‌پال داخلِ خودِ اپ باز می‌شود. اپِ فعلی آن را به مرورگرِ گوشی می‌دهد که Referer نمی‌فرستد و زرین‌پال بی‌Referer «دسترسی از این دامنه مجاز نمی‌باشد» می‌دهد؛ فعلاً فرانت از راهِ `pay.html` (با `http://`) دورش می‌زند | «پرداخت» با کارتِ بانکی: صفحه‌ی زرین‌پال و بانک داخلِ اپ، و بعد از پرداخت برگشت به اپ با پیامِ «پرداخت موفق بود» (PaymentResult). دکمه‌ی برگشت وسطِ صفحه‌ی بانک چه می‌کند را هم ببین |
| اسپلشِ یک‌مرحله‌ای: `installSplashScreen()` + `setKeepOnScreenCondition` + `TadrisinoAndroid.appReady()` (`446cf36`، `ab02bb4`) | اپِ فعلی سه تکه می‌شود: اسپلشِ اندروید ← WebViewِ سفیدِ خالی ← اسپلشِ وب (آیکون + نام) ← خانه. با APKِ تازه اسپلشِ بومی تا اولین صفحه‌ی واقعیِ وب می‌ماند و مستقیم می‌رود روی همان صفحه؛ اسپلشِ وب داخلِ اپ نامرئی است (در APKِ قدیمی فقط آیکونِ بی‌نام). فرانت `appReady` را بعد از اولین صفحه‌ی غیر‌boot صدا می‌زند و APKِ قدیمی آن را ندارد، پس بی‌اثر است | روی گوشی: باز کردنِ اپ با اینترنت باید آیکون ← خانه باشد، بی‌سفیدی و بی‌تصویرِ دوم؛ اندازه‌ی آیکون روی اندروید ۱۲+ (`drawable/splash_icon.xml`: ۲۸۸dp، حاشیه ۲۲٪) | | اسپلشِ یک‌مرحله‌ای: `installSplashScreen()` + `setKeepOnScreenCondition` + `TadrisinoAndroid.appReady()` (`446cf36`، `ab02bb4`) | اپِ فعلی سه تکه می‌شود: اسپلشِ اندروید ← WebViewِ سفیدِ خالی ← اسپلشِ وب (آیکون + نام) ← خانه. با APKِ تازه اسپلشِ بومی تا اولین صفحه‌ی واقعیِ وب می‌ماند و مستقیم می‌رود روی همان صفحه؛ اسپلشِ وب داخلِ اپ نامرئی است (در APKِ قدیمی فقط آیکونِ بی‌نام). فرانت `appReady` را بعد از اولین صفحه‌ی غیر‌boot صدا می‌زند و APKِ قدیمی آن را ندارد، پس بی‌اثر است | روی گوشی: باز کردنِ اپ با اینترنت باید آیکون ← خانه باشد، بی‌سفیدی و بی‌تصویرِ دوم؛ اندازه‌ی آیکون روی اندروید ۱۲+ (`drawable/splash_icon.xml`: ۲۸۸dp، حاشیه ۲۲٪) |
| خریدِ کافه‌بازار: کلیدِ RSA، `bazaarBuy` با اشتراک (`subscribeProduct`) و بسته، «تخفیف پویا»؛ `versionCode` ۲ | بی‌این‌ها از بازار هیچ خریدی ممکن نیست (APKهای قبلی کلید نداشتند) | داخلِ اپ ← فروشگاه ← اشتراکِ سه‌ماهه: «کارتِ بانکی» پیش‌فرض، کاشیِ «کافه‌بازار» فعال با قیمتِ بازار زیرِ آن؛ خرید با حسابِ تست، برگشت با «اشتراکت فعاله»؛ در `/admin/payment/order/` سفارشِ بازار «پرداخت‌شده». یک بار هم «انصراف» در صفحه‌ی بازار | | خریدِ اشتراک از کافه‌بازار: کلیدِ RSA، `bazaarBuy` (`subscribeProduct`)، قیمتِ پیشخان (`bazaarPrices`)، «تخفیف پویا»؛ `versionCode` ۲ | بی‌این‌ها از بازار هیچ خریدی ممکن نیست (APKهای قبلی کلید نداشتند) | داخلِ اپ ← فروشگاه ← اشتراکِ سه‌ماهه: «کارتِ بانکی» پیش‌فرض، کاشیِ «کافه‌بازار» فعال با قیمتِ بازار زیرِ آن؛ خرید با حسابِ تست، برگشت با «اشتراکت فعاله»؛ در `/admin/payment/order/` سفارشِ بازار «پرداخت‌شده». یک بار هم «انصراف» در صفحه‌ی بازار |
| صفحه‌ی بی‌اتصال: `onReceivedError`/`onReceivedHttpError` ← همان تصویر + «اینترنت قطع است / کند است؛ در حال اتصال…»، تلاشِ دوباره هر ۵ ثانیه، و مجوزِ `ACCESS_NETWORK_STATE` | کاربر هرگز صفحه‌ی خطای WebView یا متنِ فنی نمی‌بیند. اتصالِ کند (بی‌خطا) عمداً هیچ متنِ اضافه نمی‌آورد و روی اسپلش می‌ماند | هواپیما روشن ← باز کردنِ اپ ← خاموش کردن: باید خودش وصل شود. سرورِ ۵۰۲ (مثلاً nginx خاموش) هم همین صفحه. اگر صفحه لود شد ولی ۱۵ ثانیه `appReady` نیامد، لاگِ ERROR و رهاشدنِ اسپلش (باگِ JS) | | صفحه‌ی بی‌اتصال: `onReceivedError`/`onReceivedHttpError` ← همان تصویر + «اینترنت قطع است / کند است؛ در حال اتصال…»، تلاشِ دوباره هر ۵ ثانیه، و مجوزِ `ACCESS_NETWORK_STATE` | کاربر هرگز صفحه‌ی خطای WebView یا متنِ فنی نمی‌بیند. اتصالِ کند (بی‌خطا) عمداً هیچ متنِ اضافه نمی‌آورد و روی اسپلش می‌ماند | هواپیما روشن ← باز کردنِ اپ ← خاموش کردن: باید خودش وصل شود. سرورِ ۵۰۲ (مثلاً nginx خاموش) هم همین صفحه. اگر صفحه لود شد ولی ۱۵ ثانیه `appReady` نیامد، لاگِ ERROR و رهاشدنِ اسپلش (باگِ JS) |
**اسپلش و بی‌اینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصب‌شده امتحان شد).** چیزی که روی گوشی دیده می‌شود **اسپلش و بی‌اینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصب‌شده امتحان شد).** چیزی که روی گوشی دیده می‌شود
@@ -57,10 +57,10 @@
| SKUِ هر پلن | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | یک‌ماهه `10342`، سه‌ماهه `10343`، شش‌ماهه `10344`؛ بسته‌ی سوال فروخته نمی‌شود | | SKUِ هر پلن | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | یک‌ماهه `10342`، سه‌ماهه `10343`، شش‌ماهه `10344`؛ بسته‌ی سوال فروخته نمی‌شود |
| کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور | | کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور |
قیمت: فرانت `TadrisinoAndroid.bazaarPrices(skusJson, productType)` را صدا می‌زند و قیمتِ پیشخان را روی کاشیِ بازار نشان می‌دهد. فقط اشتراک فروخته می‌شود. قیمت: فرانت `TadrisinoAndroid.bazaarPrices(skusJson)` را صدا می‌زند و قیمتِ پیشخان را روی
خرید: فرانت `TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)` را صدا می‌زند؛ اشتراک با کاشیِ بازار نشان می‌دهد. خرید: `TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)` با `subscribeProduct`
`subscribeProduct` (بی‌consume، بازار خودش تمدید می‌کند)، بسته با `purchaseProduct` و بعد `bazaarConsume`. قیمتِ هر (مصرف نمی‌شود، بازار خودش تمدید می‌کند). قیمتِ پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف
محصول در پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف خورده، سرور قیمتِ کم‌شده را امضا می‌کند. خورده، سرور قیمتِ کم‌شده را امضا می‌کند.
`compileDebugKotlin` سبز است؛ هیچ خریدی روی گوشی آزموده نشده. `compileDebugKotlin` سبز است؛ هیچ خریدی روی گوشی آزموده نشده.
## build ## build
@@ -74,14 +74,15 @@
``` ```
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
فرانت TadrisinoAndroid.bazaarPurchase(sku, orderId) فرانت (اختیاری) POST /api/discount/redeem {code, orderId}
اپ Poolakey.purchaseProduct(sku, payload=orderId) → purchaseToken فرانت POST /api/payment/bazaar/orders/{orderId}/start → sku، dynamicPriceToken (اگر تخفیف خورده)
فرانت TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
اپ Poolakey.subscribeProduct(sku, payload=orderId) → purchaseToken
اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken} اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken}
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار می‌پرسد فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار می‌پرسد و اشتراک را فعال می‌کند
فرانت TadrisinoAndroid.bazaarConsume(token) ← تا همان بسته دوباره خریدنی باشد
``` ```
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» می‌زند: فرانت همان سفارش را می‌فرستد اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» می‌زند: فرانت همان سفارش را می‌فرستد
و اپ توکنِ ماندهٔ همان سفارش را از `getPurchasedProducts` پیدا می‌کند. و اپ توکنِ همان سفارش را از `getSubscribedProducts` پیدا می‌کند.
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بک‌اند. قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بک‌اند.

View File

@@ -41,17 +41,16 @@ import org.json.JSONObject
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درون‌برنامه‌ایِ بازار است. * همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درون‌برنامه‌ایِ بازار است.
* *
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت: * قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
* JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken) * JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken) (اشتراک)
* TadrisinoAndroid.bazaarPrices(skusJson, productType) → رویدادِ «tadrisino:bazaar-prices» * TadrisinoAndroid.bazaarPrices(skusJson) → رویدادِ «tadrisino:bazaar-prices»
* TadrisinoAndroid.bazaarConsume(purchaseToken)
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا * اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
* { orderId, error: "canceled" | "failed", message } * { orderId, error: "canceled" | "failed", message }
* *
* سمتِ سرور توکن را از خودِ بازار می‌سنجد (`POST /api/payment/bazaar/verify`)، پس این‌جا * سمتِ سرور توکن را از خودِ بازار می‌سنجد (`POST /api/payment/bazaar/verify`)، پس این‌جا
* فقط واسطه است و به هیچ نتیجه‌ای اعتماد نمی‌کند. * فقط واسطه است و به هیچ نتیجه‌ای اعتماد نمی‌کند.
* *
* ⚠️ نوشته شده بی‌آن‌که build شود (ماشینِ نویسنده Android SDK نداشت، ۲۰۲۶-۱۰-۰۳)؛ APIِ * ⚠️ کامپایل می‌شود (compileDebugKotlin، ۲۰۲۶-۱۰-۱۲) و امضای توابعِ Poolakey 2.2.0 از خودِ کتابخانه خوانده شد،
* Poolakey از README خودش است. پیش از انتشار با Android Studio بسازید و یک خریدِ واقعی بزنید. * ولی روی گوشی اجرا نشده؛ پیش از انتشار یک خریدِ واقعی بزنید.
*/ */
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -220,38 +219,34 @@ class MainActivity : AppCompatActivity() {
} }
/** /**
* productType «inapp» (بسته: purchaseProduct، بعد از تأییدِ سرور consume) یا «subscription» (اشتراکِ * خریدِ اشتراکِ بازار (subscribeProduct؛ فقط اشتراک فروخته می‌شود و مصرف نمی‌شود، بازار خودش تمدید می‌کند).
* بازار: subscribeProduct، مصرف نمی‌شود و بازار خودش تمدید می‌کند). dynamicPriceToken خالی یعنی قیمتِ * dynamicPriceToken خالی یعنی قیمتِ پیشخان؛ پر یعنی قیمتی که سرور با کلیدِ «تخفیف پویا» امضا کرده
* پیشخان؛ پر یعنی قیمتی که سرور با کلیدِ «تخفیف پویا» امضا کرده (بازارِ ۱۳.۳ به بعد). * (بازارِ ۱۳.۳ به بعد).
*/ */
private fun buy(sku: String, orderId: String, productType: String, dynamicPriceToken: String) { private fun buy(sku: String, orderId: String, dynamicPriceToken: String) {
val p = payment val p = payment
if (p == null) { if (p == null) {
send(orderId, error = "failed", message = "bazaar not connected") send(orderId, error = "failed", message = "bazaar not connected")
return return
} }
val subscription = productType == "subscription"
val request = PurchaseRequest( val request = PurchaseRequest(
productId = sku, payload = orderId, dynamicPriceToken = dynamicPriceToken.ifEmpty { null }, productId = sku, payload = orderId, dynamicPriceToken = dynamicPriceToken.ifEmpty { null },
) )
val callback: ir.cafebazaar.poolakey.callback.PurchaseCallback.() -> Unit = { p.subscribeProduct(activityResultRegistry, request) {
purchaseSucceed { send(orderId, token = it.purchaseToken) } purchaseSucceed { send(orderId, token = it.purchaseToken) }
purchaseCanceled { send(orderId, error = "canceled") } purchaseCanceled { send(orderId, error = "canceled") }
purchaseFailed { recoverOwned(sku, orderId, subscription, it) } purchaseFailed { recoverOwned(sku, orderId, it) }
failedToBeginFlow { recoverOwned(sku, orderId, subscription, it) } failedToBeginFlow { recoverOwned(sku, orderId, it) }
} }
if (subscription) p.subscribeProduct(activityResultRegistry, request, callback)
else p.purchaseProduct(activityResultRegistry, request, callback)
} }
/** /**
* خریدِ قبلیِ همین محصول (بسته‌ی مصرف‌نشده، یا اشتراکِ فعال) که پولش رفته ولی تأییدِ سرور نرسیده بود * اشتراکِ همین محصول که پولش رفته ولی تأییدِ سرور نرسیده بود جلوی خریدِ دوباره را می‌گیرد. فرانت در تلاشِ
* جلوی خریدِ دوباره را می‌گیرد. فرانت در تلاشِ بعدی همان سفارش را می‌فرستد؛ اگر توکنِ ماندهٔ همان * بعدی همان سفارش را می‌فرستد؛ اگر اشتراکِ همان سفارش (payload) پیدا شد، توکنش برمی‌گردد تا سرور ثبتش کند.
* سفارش (payload) پیدا شد، همان برمی‌گردد تا سرور ثبتش کند.
*/ */
private fun recoverOwned(sku: String, orderId: String, subscription: Boolean, cause: Throwable) { private fun recoverOwned(sku: String, orderId: String, cause: Throwable) {
val p = payment ?: return send(orderId, error = "failed", message = cause.message) val p = payment ?: return send(orderId, error = "failed", message = cause.message)
val query: ir.cafebazaar.poolakey.callback.PurchaseQueryCallback.() -> Unit = { p.getSubscribedProducts {
querySucceed { owned -> querySucceed { owned ->
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId } val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
if (match != null) send(orderId, token = match.purchaseToken) if (match != null) send(orderId, token = match.purchaseToken)
@@ -259,30 +254,27 @@ class MainActivity : AppCompatActivity() {
} }
queryFailed { send(orderId, error = "failed", message = cause.message) } queryFailed { send(orderId, error = "failed", message = cause.message) }
} }
if (subscription) p.getSubscribedProducts(query) else p.getPurchasedProducts(query)
} }
/** /**
* قیمتِ ثبت‌شده در پیشخانِ بازار برای هر SKU (متنِ آماده‌ی نمایش، مثلِ «۷۹۸,۰۰۰ تومان»)، تا فرانت روی کاشیِ * قیمتِ ثبت‌شده در پیشخانِ بازار برای هر SKU (متنِ آماده‌ی نمایش، مثلِ «۸۹۸,۰۰۰ تومان»)، تا فرانت روی کاشیِ
* بازار همان را نشان دهد. API سرور-به-سرورِ بازار قیمت نمی‌دهد؛ فقط Poolakey روی گوشی. * بازار همان را نشان دهد. API سرور-به-سرورِ بازار قیمت نمی‌دهد؛ فقط Poolakey روی گوشی.
* نتیجه: رویدادِ «tadrisino:bazaar-prices» با { productType, prices: { sku: price } } یا { productType, error }. * نتیجه: رویدادِ «tadrisino:bazaar-prices» با { prices: { sku: price } } یا { error }.
*/ */
private fun prices(skus: List<String>, productType: String) { private fun prices(skus: List<String>) {
val p = payment ?: return sendPrices(productType, error = "bazaar not connected") val p = payment ?: return sendPrices(error = "bazaar not connected")
val callback: ir.cafebazaar.poolakey.callback.GetSkuDetailsCallback.() -> Unit = { p.getSubscriptionSkuDetails(skus) {
getSkuDetailsSucceed { list -> getSkuDetailsSucceed { list ->
val map = JSONObject() val map = JSONObject()
list.forEach { map.put(it.sku, it.price) } list.forEach { map.put(it.sku, it.price) }
sendPrices(productType, prices = map) sendPrices(prices = map)
} }
getSkuDetailsFailed { sendPrices(productType, error = it.message ?: "failed") } getSkuDetailsFailed { sendPrices(error = it.message ?: "failed") }
} }
if (productType == "subscription") p.getSubscriptionSkuDetails(skus, callback)
else p.getInAppSkuDetails(skus, callback)
} }
private fun sendPrices(productType: String, prices: JSONObject? = null, error: String? = null) { private fun sendPrices(prices: JSONObject? = null, error: String? = null) {
val detail = JSONObject().put("productType", productType) val detail = JSONObject()
if (prices != null) detail.put("prices", prices) if (prices != null) detail.put("prices", prices)
if (error != null) detail.put("error", error) if (error != null) detail.put("error", error)
runOnUiThread { runOnUiThread {
@@ -292,13 +284,6 @@ class MainActivity : AppCompatActivity() {
} }
} }
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) { private fun send(orderId: String, token: String? = null, error: String? = null, message: String? = null) {
val detail = JSONObject().put("orderId", orderId) val detail = JSONObject().put("orderId", orderId)
if (token != null) detail.put("purchaseToken", token) if (token != null) detail.put("purchaseToken", token)
@@ -315,10 +300,10 @@ class MainActivity : AppCompatActivity() {
private inner class Bridge { private inner class Bridge {
/** قراردادش در src/lib/bazaar.tsِ فرانت است. */ /** قراردادش در src/lib/bazaar.tsِ فرانت است. */
@JavascriptInterface @JavascriptInterface
fun bazaarBuy(sku: String, orderId: String, productType: String, dynamicPriceToken: String) { fun bazaarBuy(sku: String, orderId: String, dynamicPriceToken: String) {
runOnUiThread { runOnUiThread {
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
buy(sku, orderId, productType, dynamicPriceToken) buy(sku, orderId, dynamicPriceToken)
} }
} }
@@ -355,9 +340,9 @@ class MainActivity : AppCompatActivity() {
runOnUiThread { hideOffline() } runOnUiThread { hideOffline() }
} }
/** skusJson آرایه‌ی JSON از SKUها؛ productType «inapp» یا «subscription». */ /** skusJson آرایه‌ی JSON از SKUهای اشتراک. */
@JavascriptInterface @JavascriptInterface
fun bazaarPrices(skusJson: String, productType: String) { fun bazaarPrices(skusJson: String) {
val skus = try { val skus = try {
val arr = JSONArray(skusJson) val arr = JSONArray(skusJson)
List(arr.length()) { i -> arr.getString(i) } List(arr.length()) { i -> arr.getString(i) }
@@ -367,7 +352,7 @@ class MainActivity : AppCompatActivity() {
} }
runOnUiThread { runOnUiThread {
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
prices(skus, productType) prices(skus)
} }
} }
@@ -375,13 +360,6 @@ class MainActivity : AppCompatActivity() {
@JavascriptInterface @JavascriptInterface
fun paysInApp(): Boolean = true fun paysInApp(): Boolean = true
@JavascriptInterface
fun bazaarConsume(purchaseToken: String) {
runOnUiThread {
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
consume(purchaseToken)
}
}
} }
override fun onDestroy() { override fun onDestroy() {

View File

@@ -241,8 +241,6 @@ export interface AskDone {
suggestedFollowUps: string[]; suggestedFollowUps: string[];
/** سوال‌های رایگانِ باقیِ امروز. */ /** سوال‌های رایگانِ باقیِ امروز. */
remainingQuota: number; remainingQuota: number;
/** اعتبارِ بسته‌های سوال؛ منقضی نمی‌شود. */
credits?: number;
usedChunks: UsedChunk[]; usedChunks: UsedChunk[];
diagram?: Diagram | null; diagram?: Diagram | null;
} }
@@ -281,13 +279,7 @@ export interface Plan {
badge: string; badge: string;
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */ /** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
aiDailyQuota: number; aiDailyQuota: number;
/** «pack» بسته‌ی سوال؛ «subscription» اشتراکِ زمانی که همه‌ی فصل‌ها را باز می‌کند. */ /** SKUِ همین اشتراک در کافه‌بازار؛ خالی یعنی از بازار خریدنی نیست. */
kind: 'pack' | 'subscription';
/** بسته: سوال‌هایی که به اعتبار اضافه می‌شود. */
questionCredits: number;
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
heartsDays: number;
/** SKUِ همین پلن (بسته یا اشتراک) در کافه‌بازار؛ خالی یعنی از بازار خریدنی نیست. */
bazaarSku: string; bazaarSku: string;
} }
@@ -298,7 +290,7 @@ export interface PlansResponse {
} }
export interface SubscriptionStatus { export interface SubscriptionStatus {
/** اشتراکِ زمانیِ فعال (یک/سه/شش‌ماهه)، نه بسته. */ /** اشتراکِ زمانیِ فعال (یک/سه/شش‌ماهه). */
active: boolean; active: boolean;
/** نامِ پلنِ فعال؛ null بی‌اشتراک. */ /** نامِ پلنِ فعال؛ null بی‌اشتراک. */
planTitle: string | null; planTitle: string | null;
@@ -308,17 +300,12 @@ export interface SubscriptionStatus {
aiDailyQuota: number; aiDailyQuota: number;
planId: string | null; planId: string | null;
expiresAt: string | null; expiresAt: string | null;
/** سوال‌های خریده‌شده‌ی باقی‌مانده. */
questionCredits: number;
/** پایانِ قلبِ نامحدودِ هدیه‌ی بسته‌ها؛ null یعنی ندارد. */
unlimitedHeartsUntil: string | null;
} }
/** GET /ai/quota */ /** GET /ai/quota */
export interface TutorQuota { export interface TutorQuota {
freeDaily: number; freeDaily: number;
remainingQuota: number; remainingQuota: number;
credits: number;
} }
export interface PurchaseResponse { export interface PurchaseResponse {
@@ -327,15 +314,12 @@ export interface PurchaseResponse {
paymentUrl: string | null; paymentUrl: string | null;
/** فقط بازار: SKUی که اپِ اندروید می‌خرد. */ /** فقط بازار: SKUی که اپِ اندروید می‌خرد. */
bazaarSku?: string; bazaarSku?: string;
/** فقط بازار: «inapp» بسته (بعد از تأیید consume)، «subscription» اشتراک. */
bazaarProductType?: 'inapp' | 'subscription';
} }
/** POST /payment/bazaar/orders/{id}/start — بعد از کدِ تخفیف، درست پیش از صفحه‌ی خریدِ بازار. */ /** POST /payment/bazaar/orders/{id}/start — بعد از کدِ تخفیف، درست پیش از صفحه‌ی خریدِ بازار. */
export interface BazaarStartResponse { export interface BazaarStartResponse {
orderId: string; orderId: string;
sku: string; sku: string;
productType: 'inapp' | 'subscription';
/** مبلغِ سفارش (تومان)، پس از کدِ تخفیف. */ /** مبلغِ سفارش (تومان)، پس از کدِ تخفیف. */
amount: number; amount: number;
/** JWTِ «تخفیف پویا»؛ null یعنی بازار قیمتِ پیشخان را می‌گیرد. */ /** JWTِ «تخفیف پویا»؛ null یعنی بازار قیمتِ پیشخان را می‌گیرد. */

View File

@@ -8,12 +8,11 @@ import { store } from './storage';
* جایی است که با آن حرف می‌زند. در مرورگرِ معمولی آن شیء نیست و خرید با زرین‌پال است. * جایی است که با آن حرف می‌زند. در مرورگرِ معمولی آن شیء نیست و خرید با زرین‌پال است.
* *
* قرارداد (سمتِ اندروید: `android/` در همین ریپو): * قرارداد (سمتِ اندروید: `android/` در همین ریپو):
* JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken) * JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
* productType «inapp» (بسته، purchaseProduct) یا «subscription» (subscribeProduct)؛ * خریدِ اشتراک با subscribeProduct (فقط اشتراک فروخته می‌شود)؛ orderId همان payloadِ خرید است؛
* orderId همان payloadِ خرید است؛ dynamicPriceToken رشته‌ی خالی یعنی قیمتِ پیشخان. * dynamicPriceToken رشته‌ی خالی یعنی قیمتِ پیشخان.
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) فقط بسته، بعد از تأییدِ سرور. * JS → اندروید TadrisinoAndroid.bazaarPrices(skusJson) قیمتِ پیشخان؛ جواب با رویدادِ «tadrisino:bazaar-prices»
* JS → اندروید TadrisinoAndroid.bazaarPrices(skusJson, productType) قیمتِ پیشخان؛ جواب با رویدادِ * و detail‌ِ { prices: { sku: «۸۹۸,۰۰۰ تومان» } } یا { error }
* «tadrisino:bazaar-prices» و detail‌ِ { productType, prices: { sku: «۷۹۸,۰۰۰ تومان» } } یا { productType, error }
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail: * اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
* { orderId, purchaseToken } خرید انجام شد * { orderId, purchaseToken } خرید انجام شد
* { orderId, error: 'canceled' | 'failed', message? } * { orderId, error: 'canceled' | 'failed', message? }
@@ -23,14 +22,11 @@ import { store } from './storage';
* هیچ‌کدام از این‌ها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۱۲). * هیچ‌کدام از این‌ها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۱۲).
*/ */
export type BazaarProductType = 'inapp' | 'subscription';
interface AndroidBridge { interface AndroidBridge {
bazaarBuy?: (sku: string, orderId: string, productType: BazaarProductType, dynamicPriceToken: string) => void; bazaarBuy?: (sku: string, orderId: string, dynamicPriceToken: string) => void;
/** APKِ قدیمی؛ فقط برای این‌که بدانیم داخلِ اپیم ولی خرید از بازار در آن ممکن نیست. */ /** APKِ قدیمی؛ فقط برای این‌که بدانیم داخلِ اپیم ولی خرید از بازار در آن ممکن نیست. */
bazaarPurchase?: (sku: string, orderId: string) => void; bazaarPurchase?: (sku: string, orderId: string) => void;
bazaarConsume?: (purchaseToken: string) => void; bazaarPrices?: (skusJson: string) => void;
bazaarPrices?: (skusJson: string, productType: BazaarProductType) => void;
/** اولین صفحه‌ی واقعی کشیده شد؛ اسپلشِ سیستم می‌تواند برود (APKهای قدیمی‌تر این را ندارند). */ /** اولین صفحه‌ی واقعی کشیده شد؛ اسپلشِ سیستم می‌تواند برود (APKهای قدیمی‌تر این را ندارند). */
appReady?: () => void; appReady?: () => void;
} }
@@ -56,7 +52,7 @@ export function inAndroidApp(): boolean {
return typeof window.TadrisinoAndroid === 'object' && window.TadrisinoAndroid !== null; return typeof window.TadrisinoAndroid === 'object' && window.TadrisinoAndroid !== null;
} }
/** اپی که می‌تواند از بازار بخرد (بسته و اشتراک). */ /** اپی که می‌تواند از بازار اشتراک بخرد. */
export function canBuyFromBazaar(): boolean { export function canBuyFromBazaar(): boolean {
return typeof window.TadrisinoAndroid?.bazaarBuy === 'function'; return typeof window.TadrisinoAndroid?.bazaarBuy === 'function';
} }
@@ -65,7 +61,7 @@ export function canBuyFromBazaar(): boolean {
const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000; const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000;
/** خرید را به اپِ اندروید می‌سپارد و purchaseToken را برمی‌گرداند. */ /** خرید را به اپِ اندروید می‌سپارد و purchaseToken را برمی‌گرداند. */
export function bazaarBuy(sku: string, orderId: string, productType: BazaarProductType, dynamicPriceToken: string | null): Promise<string> { export function bazaarBuy(sku: string, orderId: string, dynamicPriceToken: string | null): Promise<string> {
const bridge = window.TadrisinoAndroid; const bridge = window.TadrisinoAndroid;
if (!bridge?.bazaarBuy) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app')); if (!bridge?.bazaarBuy) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app'));
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -83,16 +79,16 @@ export function bazaarBuy(sku: string, orderId: string, productType: BazaarProdu
} }
window.addEventListener('tadrisino:bazaar', onResult); window.addEventListener('tadrisino:bazaar', onResult);
try { try {
bridge.bazaarBuy!(sku, orderId, productType, dynamicPriceToken ?? ''); bridge.bazaarBuy!(sku, orderId, dynamicPriceToken ?? '');
} catch (err) { } catch (err) {
finish(() => reject(new BazaarPurchaseError('failed', String(err)))); finish(() => reject(new BazaarPurchaseError('failed', String(err))));
} }
}); });
} }
type PricesResult = { productType: BazaarProductType; prices?: Record<string, string>; error?: string }; type PricesResult = { prices?: Record<string, string>; error?: string };
function askPrices(skus: string[], productType: BazaarProductType): Promise<Record<string, string>> { function askPrices(skus: string[]): Promise<Record<string, string>> {
const bridge = window.TadrisinoAndroid; const bridge = window.TadrisinoAndroid;
if (!bridge?.bazaarPrices || !skus.length) return Promise.resolve({}); if (!bridge?.bazaarPrices || !skus.length) return Promise.resolve({});
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -104,12 +100,12 @@ function askPrices(skus: string[], productType: BazaarProductType): Promise<Reco
} }
function onResult(event: Event) { function onResult(event: Event) {
const detail = (event as CustomEvent<PricesResult>).detail; const detail = (event as CustomEvent<PricesResult>).detail;
if (!detail || detail.productType !== productType) return; if (!detail) return;
if (detail.prices) done(() => resolve(detail.prices!)); if (detail.prices) done(() => resolve(detail.prices!));
else done(() => reject(new Error(detail.error || 'failed'))); else done(() => reject(new Error(detail.error || 'failed')));
} }
window.addEventListener('tadrisino:bazaar-prices', onResult); window.addEventListener('tadrisino:bazaar-prices', onResult);
bridge.bazaarPrices!(JSON.stringify(skus), productType); bridge.bazaarPrices!(JSON.stringify(skus));
}); });
} }
@@ -117,13 +113,13 @@ function askPrices(skus: string[], productType: BazaarProductType): Promise<Reco
* قیمتِ ثبت‌شده در پیشخانِ بازار برای هر SKU، از خودِ بازار روی گوشی (API سرور قیمت نمی‌دهد). اتصالِ Poolakey * قیمتِ ثبت‌شده در پیشخانِ بازار برای هر SKU، از خودِ بازار روی گوشی (API سرور قیمت نمی‌دهد). اتصالِ Poolakey
* چند لحظه بعد از باز شدنِ اپ برقرار می‌شود، پس یک بار دوباره امتحان می‌شود. خطا یعنی قیمتی نشان داده نمی‌شود. * چند لحظه بعد از باز شدنِ اپ برقرار می‌شود، پس یک بار دوباره امتحان می‌شود. خطا یعنی قیمتی نشان داده نمی‌شود.
*/ */
export async function bazaarPrices(skus: string[], productType: BazaarProductType): Promise<Record<string, string>> { export async function bazaarPrices(skus: string[]): Promise<Record<string, string>> {
try { try {
return await askPrices(skus, productType); return await askPrices(skus);
} catch { } catch {
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500));
try { try {
return await askPrices(skus, productType); return await askPrices(skus);
} catch (err) { } catch (err) {
console.error('bazaarPrices failed', err); console.error('bazaarPrices failed', err);
return {}; return {};
@@ -131,15 +127,6 @@ export async function bazaarPrices(skus: string[], productType: BazaarProductTyp
} }
} }
export function bazaarConsume(purchaseToken: string) {
try {
window.TadrisinoAndroid?.bazaarConsume?.(purchaseToken);
} catch (err) {
// مصرف نشدن یعنی خریدِ دوباره‌ی همین بسته تا مصرفِ بعدی گیر می‌کند؛ اعتبارِ کاربر سالم است.
console.error('bazaarConsume failed', err);
}
}
/** /**
* Idempotency-Keyِ خریدِ نیمه‌تمامِ هر SKU. اگر بازار پول را گرفت ولی تأییدِ سرور نرسید (قطعِ * Idempotency-Keyِ خریدِ نیمه‌تمامِ هر SKU. اگر بازار پول را گرفت ولی تأییدِ سرور نرسید (قطعِ
* اینترنت)، تلاشِ بعدی باید همان سفارش را بگیرد؛ سفارشِ تازه با توکنِ قبلی تأیید نمی‌شد چون هر * اینترنت)، تلاشِ بعدی باید همان سفارش را بگیرد؛ سفارشِ تازه با توکنِ قبلی تأیید نمی‌شد چون هر

View File

@@ -25,13 +25,13 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
const chat = useAskState(); const chat = useAskState();
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// «X سوالِ رایگانِ امروز + Y سوالِ خریده‌شده»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب. // «X سوالِ امروز»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
const [quota, setQuota] = useState<{ free: number; credits: number } | null>(null); const [quota, setQuota] = useState<number | null>(null);
const [quotaDone, setQuotaDone] = useState(false); const [quotaDone, setQuotaDone] = useState(false);
useEffect(() => { useEffect(() => {
api.ai api.ai
.quota() .quota()
.then((q) => setQuota({ free: q.remainingQuota, credits: q.credits })) .then((q) => setQuota(q.remainingQuota))
.catch((err: unknown) => console.warn('ai quota lookup failed', err)) .catch((err: unknown) => console.warn('ai quota lookup failed', err))
.finally(() => setQuotaDone(true)); .finally(() => setQuotaDone(true));
}, []); }, []);
@@ -109,7 +109,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
diagram: done.diagram ?? null, diagram: done.diagram ?? null,
}); });
askStore.setConversation(done.conversationId); askStore.setConversation(done.conversationId);
setQuota((q) => ({ free: done.remainingQuota, credits: done.credits ?? q?.credits ?? 0 })); setQuota(done.remainingQuota);
} catch (err) { } catch (err) {
// سقفِ روزانه (نه throttleِ چندثانیه‌ای): «دوباره» تا فردا بی‌فایده است. // سقفِ روزانه (نه throttleِ چندثانیه‌ای): «دوباره» تا فردا بی‌فایده است.
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال'); const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
@@ -117,7 +117,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
text: friendlyError(err, 'جوابی نرسید'), text: friendlyError(err, 'جوابی نرسید'),
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }), ...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
}); });
if (quotaReached) setQuota({ free: 0, credits: 0 }); if (quotaReached) setQuota(0);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -145,7 +145,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
mascot={{ mood: busy ? 'excited' : 'idle', size: 60 }} mascot={{ mood: busy ? 'excited' : 'idle', size: 60 }}
> >
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="mt-3 flex flex-wrap items-center gap-2">
{quota !== null && <QuotaPills free={quota.free} credits={quota.credits} />} {quota !== null && <QuotaPills free={quota} />}
{!empty && ( {!empty && (
<button <button
type="button" type="button"
@@ -355,23 +355,21 @@ function Message({
} }
/** /**
* شمارنده در سربرگِ دستیار: سوال‌های رایگانِ باقیِ امروز، و اگر از بسته‌های قدیمی سوالی خریده و مانده، آن هم جدا. * شمارنده در سربرگِ دستیار: سوال‌های باقیِ امروز. وقتی تمام شد، دکمه‌ی دوم به فروشگاهِ اشتراک می‌برد
* بسته از ۲۰۲۶-۱۰-۱۲ فروخته نمی‌شود؛ وقتی رایگانِ امروز تمام شد، دکمه‌ی دوم به فروشگاهِ اشتراک می‌برد. * (سوال جدا فروخته نمی‌شود؛ مشترک فردا دوباره سهم دارد).
*/ */
function QuotaPills({ free, credits }: { free: number; credits: number }) { function QuotaPills({ free }: { free: number }) {
const nav = useNav(); const nav = useNav();
return ( return (
<> <>
<span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}> <span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}>
{free > 0 ? <>{toFa(free)} سوالِ امروز</> : 'سوال‌های امروز تموم شد'} {free > 0 ? <>{toFa(free)} سوالِ امروز</> : 'سوال‌های امروز تموم شد'}
</span> </span>
{credits > 0 ? ( {free === 0 && (
<span className="rounded-full bg-white px-3 py-1.5 text-xs font-black text-violet">{toFa(credits)} سوالِ خریده‌شده</span>
) : free === 0 ? (
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-white/15 px-3 py-1.5 text-xs font-black text-white"> <button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-white/15 px-3 py-1.5 text-xs font-black text-white">
سوالِ بیشتر با اشتراک سوالِ بیشتر با اشتراک
</button> </button>
) : null} )}
</> </>
); );
} }

View File

@@ -185,11 +185,6 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
<span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همه‌ی فصل‌ها و قلبِ نامحدود باز می‌شه.</span> <span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همه‌ی فصل‌ها و قلبِ نامحدود باز می‌شه.</span>
)} )}
</div> </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> </button>
); );
} }

View File

@@ -1,14 +1,14 @@
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { Check, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag, Sparkles, Ticket } from 'lucide-react'; import { Check, Crown, CreditCard, ShieldCheck, ShoppingBag, Ticket } from 'lucide-react';
import { api, type Plan, type SubscriptionStatus } from '@/api'; import { api, type Plan, type SubscriptionStatus } from '@/api';
import { daysLeft } from '@/lib/subscription'; import { daysLeft } from '@/lib/subscription';
import { newIdempotencyKey } from '@/api/http'; import { newIdempotencyKey } from '@/api/http';
import { BazaarPurchaseError, bazaarBuy, bazaarConsume, bazaarPending, bazaarPrices, canBuyFromBazaar, inAndroidApp } from '@/lib/bazaar'; import { BazaarPurchaseError, bazaarBuy, bazaarPending, bazaarPrices, canBuyFromBazaar, inAndroidApp } 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';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { formatJalaliDate, formatNumber, formatToman, toFa } from '@/lib/format'; import { formatNumber, formatToman, toFa } from '@/lib/format';
import { useCompanion } from '@/state/companion'; import { useCompanion } from '@/state/companion';
function durationLabel(days: number): string { function durationLabel(days: number): string {
@@ -45,12 +45,12 @@ export function SubscriptionScreen() {
return () => document.removeEventListener('visibilitychange', onVisible); return () => document.removeEventListener('visibilitychange', onVisible);
}, [status.reload]); }, [status.reload]);
const list = (plans.data?.plans ?? []).filter((p) => p.kind === 'subscription'); const list = plans.data?.plans ?? [];
useEffect(() => { useEffect(() => {
const skus = list.filter((p) => p.bazaarSku).map((p) => p.bazaarSku); const skus = list.filter((p) => p.bazaarSku).map((p) => p.bazaarSku);
if (!canBuyFromBazaar() || !skus.length) return; if (!canBuyFromBazaar() || !skus.length) return;
let alive = true; let alive = true;
void bazaarPrices(skus, 'subscription').then((prices) => alive && setStorePrices(prices)); void bazaarPrices(skus).then((prices) => alive && setStorePrices(prices));
return () => { alive = false; }; return () => { alive = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [plans.data]); }, [plans.data]);
@@ -102,59 +102,37 @@ export function SubscriptionScreen() {
} }
/** /**
* وضعیتِ کاربر بالای فروشگاه. مشترک: یک کارتِ واضح با روزهای مانده. بی‌اشتراک: هیچ کارتی (دو گزینه‌ی زیر * وضعیتِ کاربر بالای فروشگاه: مشترک یک کارتِ واضح با روزهای مانده می‌بیند؛ بی‌اشتراک هیچ کارتی (کارت‌های
* خودشان حرف را می‌زنند)، مگر سوالِ خریده‌شده یا قلبِ هدیه داشته باشد. تا وضعیت نرسیده جای خالیِ هم‌اندازه * اشتراکِ زیر خودشان حرف را می‌زنند). تا وضعیت نرسیده جای خالیِ هم‌اندازه می‌آید، نه «نداری» که برای مشترک غلط بود.
* می‌آید، نه «نداری» که برای مشترک غلط بود.
*/ */
function StatusCard({ status, loading }: { status: SubscriptionStatus | null; loading: boolean }) { function StatusCard({ status, loading }: { status: SubscriptionStatus | null; loading: boolean }) {
if (loading) return <div className="h-[104px] animate-pulse rounded-3xl bg-soft" />; if (loading) return <div className="h-[104px] animate-pulse rounded-3xl bg-soft" />;
if (!status) return null; if (!status?.active) return null;
const days = status.active ? daysLeft(status.expiresAt) : null; const days = daysLeft(status.expiresAt);
const hearts = !status.active && status.unlimitedHeartsUntil;
const extras = (
<>
{status.questionCredits > 0 && <Pill icon={<Sparkles size={14} />} text={`${toFa(status.questionCredits)} سوالِ خریده‌شده`} />}
{hearts && <Pill icon={<Heart size={14} fill="currentColor" />} text={`قلبِ نامحدود تا ${formatJalaliDate(status.unlimitedHeartsUntil!)}`} />}
</>
);
if (!status.active) {
return status.questionCredits > 0 || hearts ? <div className="flex flex-wrap gap-2">{extras}</div> : null;
}
const total = status.planDurationDays ?? 30; const total = status.planDurationDays ?? 30;
const percent = days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100))); const percent = days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)));
return ( return (
<div className="flex flex-col gap-2"> <div className="rounded-3xl bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] p-4 text-white shadow-lg shadow-orange/25">
<div className="rounded-3xl bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] p-4 text-white shadow-lg shadow-orange/25"> <div className="flex items-center gap-3">
<div className="flex items-center gap-3"> <span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-white/20"><Crown size={24} /></span>
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-white/20"><Crown size={24} /></span> <div className="min-w-0 flex-1">
<div className="min-w-0 flex-1"> <div className="text-base font-black">اشتراکت فعاله</div>
<div className="text-base font-black">اشتراکت فعاله</div> <div className="text-sm text-white/85">{status.planTitle ?? 'اشتراک'}</div>
<div className="text-sm text-white/85">{status.planTitle ?? 'اشتراک'}</div> </div>
{days !== null && (
<div className="text-center">
<div className="text-3xl font-black leading-none">{toFa(days)}</div>
<div className="mt-1 text-xs text-white/85">روز مونده</div>
</div> </div>
{days !== null && ( )}
<div className="text-center"> </div>
<div className="text-3xl font-black leading-none">{toFa(days)}</div> <div className="mt-3 h-2.5 overflow-hidden rounded-full bg-black/15">
<div className="mt-1 text-xs text-white/85">روز مونده</div> <div className="h-full rounded-full bg-white" style={{ width: `${percent}%` }} />
</div>
)}
</div>
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-black/15">
<div className="h-full rounded-full bg-white" style={{ width: `${percent}%` }} />
</div>
</div> </div>
{(status.questionCredits > 0) && <div className="flex flex-wrap gap-2">{extras}</div>}
</div> </div>
); );
} }
function Pill({ icon, text }: { icon: ReactNode; text: string }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white px-3 py-1.5 text-sm font-bold text-violet ring-1 ring-line">
{icon} {text}
</span>
);
}
/** یک کارتِ انتخابی: نام و قیمت درشت، و زیرش آنچه این اشتراک می‌دهد (خواستِ سینیور: روی هر کارت). */ /** یک کارتِ انتخابی: نام و قیمت درشت، و زیرش آنچه این اشتراک می‌دهد (خواستِ سینیور: روی هر کارت). */
function PlanRow({ plan, assistant, monthlyPrice, selected, onSelect }: { plan: Plan; assistant: string; monthlyPrice?: number; selected: boolean; onSelect: () => void }) { function PlanRow({ plan, assistant, monthlyPrice, selected, onSelect }: { plan: Plan; assistant: string; monthlyPrice?: number; selected: boolean; onSelect: () => void }) {
const months = Math.round(plan.durationDays / 30); const months = Math.round(plan.durationDays / 30);
@@ -228,7 +206,6 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null); const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
const isSubscription = plan?.kind === 'subscription';
// هر بار که برگه برای پلنی باز می‌شود، زرین‌پال انتخاب است (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ بازار یک لمس آن‌طرف‌تر. // هر بار که برگه برای پلنی باز می‌شود، زرین‌پال انتخاب است (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ بازار یک لمس آن‌طرف‌تر.
useEffect(() => { useEffect(() => {
@@ -238,7 +215,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
/** /**
* بازار: سفارش (با کلیدِ ثابتِ همین SKU) → کدِ تخفیف → شروع (SKU، نوع، قیمتِ امضاشده) → خرید در بازار * بازار: سفارش (با کلیدِ ثابتِ همین SKU) → کدِ تخفیف → شروع (SKU، نوع، قیمتِ امضاشده) → خرید در بازار
* → سنجشِ سرور → مصرفِ خرید (فقط بسته؛ اشتراک مصرف نمی‌شود). * → سنجشِ سرور. اشتراکِ بازار مصرف (consume) نمی‌شود؛ بازار خودش آخرِ هر دوره تمدیدش می‌کند.
*/ */
async function payWithBazaar(plan: Plan): Promise<'bazaar' | 'code' | null> { async function payWithBazaar(plan: Plan): Promise<'bazaar' | 'code' | null> {
const sku = plan.bazaarSku; const sku = plan.bazaarSku;
@@ -269,7 +246,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
const start = await api.subscription.startBazaar(order.orderId); const start = await api.subscription.startBazaar(order.orderId);
let token: string; let token: string;
try { try {
token = await bazaarBuy(start.sku, order.orderId, start.productType, start.dynamicPriceToken); token = await bazaarBuy(start.sku, order.orderId, start.dynamicPriceToken);
} catch (err) { } catch (err) {
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return null; if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return null;
throw err; throw err;
@@ -277,7 +254,6 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
const result = await api.subscription.verifyBazaar(order.orderId, token); const result = await api.subscription.verifyBazaar(order.orderId, token);
if (result.status !== 'paid') throw new Error('bazaar purchase not confirmed'); if (result.status !== 'paid') throw new Error('bazaar purchase not confirmed');
bazaarPending.clear(sku); bazaarPending.clear(sku);
if (start.productType === 'inapp') bazaarConsume(token);
return 'bazaar'; return 'bazaar';
} }
@@ -338,8 +314,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
const doneText = const doneText =
done === 'gateway' ? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همین‌جا؛ خریدت خودش فعال می‌شه.' done === 'gateway' ? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همین‌جا؛ خریدت خودش فعال می‌شه.'
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
: isSubscription ? 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش می‌کنه؛ لغوِ تمدید از بخشِ «اشتراک‌ها»ی خودِ بازاره.' : 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش می‌کنه؛ لغوِ تمدید از بخشِ «اشتراک‌ها»ی خودِ بازاره.';
: 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.';
return ( return (
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}` : ''}> <Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}` : ''}>
@@ -385,7 +360,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted"> <p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
<ShieldCheck size={14} /> <ShieldCheck size={14} />
{method === 'bazaar' {method === 'bazaar'
? isSubscription ? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید می‌شه.' : 'پرداخت در خودِ بازار انجام می‌شه.' ? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید می‌شه.'
: 'پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.'} : 'پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.'}
</p> </p>
</> </>