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:
@@ -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`ِ بکاند.
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 یعنی بازار قیمتِ پیشخان را میگیرد. */
|
||||||
|
|||||||
@@ -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. اگر بازار پول را گرفت ولی تأییدِ سرور نرسید (قطعِ
|
||||||
* اینترنت)، تلاشِ بعدی باید همان سفارش را بگیرد؛ سفارشِ تازه با توکنِ قبلی تأیید نمیشد چون هر
|
* اینترنت)، تلاشِ بعدی باید همان سفارش را بگیرد؛ سفارشِ تازه با توکنِ قبلی تأیید نمیشد چون هر
|
||||||
|
|||||||
@@ -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}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user