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` | صداهای رابط بعد از پاسخِ سرور پخش میشوند، نه در لحظهی لمس | صدای جوابِ درست/غلط و پایانِ تمرین |
|
||||
| `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، حاشیه ۲۲٪) |
|
||||
| خریدِ کافهبازار: کلیدِ 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) |
|
||||
|
||||
**اسپلش و بیاینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصبشده امتحان شد).** چیزی که روی گوشی دیده میشود
|
||||
@@ -57,10 +57,10 @@
|
||||
| SKUِ هر پلن | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | یکماهه `10342`، سهماهه `10343`، ششماهه `10344`؛ بستهی سوال فروخته نمیشود |
|
||||
| کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور |
|
||||
|
||||
قیمت: فرانت `TadrisinoAndroid.bazaarPrices(skusJson, productType)` را صدا میزند و قیمتِ پیشخان را روی کاشیِ بازار نشان میدهد.
|
||||
خرید: فرانت `TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)` را صدا میزند؛ اشتراک با
|
||||
`subscribeProduct` (بیconsume، بازار خودش تمدید میکند)، بسته با `purchaseProduct` و بعد `bazaarConsume`. قیمتِ هر
|
||||
محصول در پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف خورده، سرور قیمتِ کمشده را امضا میکند.
|
||||
فقط اشتراک فروخته میشود. قیمت: فرانت `TadrisinoAndroid.bazaarPrices(skusJson)` را صدا میزند و قیمتِ پیشخان را روی
|
||||
کاشیِ بازار نشان میدهد. خرید: `TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)` با `subscribeProduct`
|
||||
(مصرف نمیشود، بازار خودش تمدید میکند). قیمتِ پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف
|
||||
خورده، سرور قیمتِ کمشده را امضا میکند.
|
||||
`compileDebugKotlin` سبز است؛ هیچ خریدی روی گوشی آزموده نشده.
|
||||
|
||||
## build
|
||||
@@ -74,14 +74,15 @@
|
||||
|
||||
```
|
||||
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
|
||||
فرانت TadrisinoAndroid.bazaarPurchase(sku, orderId)
|
||||
اپ Poolakey.purchaseProduct(sku, payload=orderId) → purchaseToken
|
||||
فرانت (اختیاری) POST /api/discount/redeem {code, orderId}
|
||||
فرانت POST /api/payment/bazaar/orders/{orderId}/start → sku، dynamicPriceToken (اگر تخفیف خورده)
|
||||
فرانت TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
|
||||
اپ Poolakey.subscribeProduct(sku, payload=orderId) → purchaseToken
|
||||
اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken}
|
||||
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار میپرسد
|
||||
فرانت TadrisinoAndroid.bazaarConsume(token) ← تا همان بسته دوباره خریدنی باشد
|
||||
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار میپرسد و اشتراک را فعال میکند
|
||||
```
|
||||
|
||||
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» میزند: فرانت همان سفارش را میفرستد
|
||||
و اپ توکنِ ماندهٔ همان سفارش را از `getPurchasedProducts` پیدا میکند.
|
||||
و اپ توکنِ همان سفارش را از `getSubscribedProducts` پیدا میکند.
|
||||
|
||||
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بکاند.
|
||||
|
||||
@@ -41,17 +41,16 @@ import org.json.JSONObject
|
||||
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درونبرنامهایِ بازار است.
|
||||
*
|
||||
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
|
||||
* JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)
|
||||
* TadrisinoAndroid.bazaarPrices(skusJson, productType) → رویدادِ «tadrisino:bazaar-prices»
|
||||
* TadrisinoAndroid.bazaarConsume(purchaseToken)
|
||||
* JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken) (اشتراک)
|
||||
* TadrisinoAndroid.bazaarPrices(skusJson) → رویدادِ «tadrisino:bazaar-prices»
|
||||
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
|
||||
* { orderId, error: "canceled" | "failed", message }
|
||||
*
|
||||
* سمتِ سرور توکن را از خودِ بازار میسنجد (`POST /api/payment/bazaar/verify`)، پس اینجا
|
||||
* فقط واسطه است و به هیچ نتیجهای اعتماد نمیکند.
|
||||
*
|
||||
* ⚠️ نوشته شده بیآنکه build شود (ماشینِ نویسنده Android SDK نداشت، ۲۰۲۶-۱۰-۰۳)؛ APIِ
|
||||
* Poolakey از README خودش است. پیش از انتشار با Android Studio بسازید و یک خریدِ واقعی بزنید.
|
||||
* ⚠️ کامپایل میشود (compileDebugKotlin، ۲۰۲۶-۱۰-۱۲) و امضای توابعِ Poolakey 2.2.0 از خودِ کتابخانه خوانده شد،
|
||||
* ولی روی گوشی اجرا نشده؛ پیش از انتشار یک خریدِ واقعی بزنید.
|
||||
*/
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
@@ -220,38 +219,34 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* productType «inapp» (بسته: purchaseProduct، بعد از تأییدِ سرور consume) یا «subscription» (اشتراکِ
|
||||
* بازار: subscribeProduct، مصرف نمیشود و بازار خودش تمدید میکند). dynamicPriceToken خالی یعنی قیمتِ
|
||||
* پیشخان؛ پر یعنی قیمتی که سرور با کلیدِ «تخفیف پویا» امضا کرده (بازارِ ۱۳.۳ به بعد).
|
||||
* خریدِ اشتراکِ بازار (subscribeProduct؛ فقط اشتراک فروخته میشود و مصرف نمیشود، بازار خودش تمدید میکند).
|
||||
* dynamicPriceToken خالی یعنی قیمتِ پیشخان؛ پر یعنی قیمتی که سرور با کلیدِ «تخفیف پویا» امضا کرده
|
||||
* (بازارِ ۱۳.۳ به بعد).
|
||||
*/
|
||||
private fun buy(sku: String, orderId: String, productType: String, dynamicPriceToken: String) {
|
||||
private fun buy(sku: String, orderId: String, dynamicPriceToken: String) {
|
||||
val p = payment
|
||||
if (p == null) {
|
||||
send(orderId, error = "failed", message = "bazaar not connected")
|
||||
return
|
||||
}
|
||||
val subscription = productType == "subscription"
|
||||
val request = PurchaseRequest(
|
||||
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) }
|
||||
purchaseCanceled { send(orderId, error = "canceled") }
|
||||
purchaseFailed { recoverOwned(sku, orderId, subscription, it) }
|
||||
failedToBeginFlow { recoverOwned(sku, orderId, subscription, it) }
|
||||
purchaseFailed { recoverOwned(sku, orderId, 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 query: ir.cafebazaar.poolakey.callback.PurchaseQueryCallback.() -> Unit = {
|
||||
p.getSubscribedProducts {
|
||||
querySucceed { owned ->
|
||||
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
|
||||
if (match != null) send(orderId, token = match.purchaseToken)
|
||||
@@ -259,30 +254,27 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
queryFailed { send(orderId, error = "failed", message = cause.message) }
|
||||
}
|
||||
if (subscription) p.getSubscribedProducts(query) else p.getPurchasedProducts(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* قیمتِ ثبتشده در پیشخانِ بازار برای هر SKU (متنِ آمادهی نمایش، مثلِ «۷۹۸,۰۰۰ تومان»)، تا فرانت روی کاشیِ
|
||||
* قیمتِ ثبتشده در پیشخانِ بازار برای هر SKU (متنِ آمادهی نمایش، مثلِ «۸۹۸,۰۰۰ تومان»)، تا فرانت روی کاشیِ
|
||||
* بازار همان را نشان دهد. 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) {
|
||||
val p = payment ?: return sendPrices(productType, error = "bazaar not connected")
|
||||
val callback: ir.cafebazaar.poolakey.callback.GetSkuDetailsCallback.() -> Unit = {
|
||||
private fun prices(skus: List<String>) {
|
||||
val p = payment ?: return sendPrices(error = "bazaar not connected")
|
||||
p.getSubscriptionSkuDetails(skus) {
|
||||
getSkuDetailsSucceed { list ->
|
||||
val map = JSONObject()
|
||||
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) {
|
||||
val detail = JSONObject().put("productType", productType)
|
||||
private fun sendPrices(prices: JSONObject? = null, error: String? = null) {
|
||||
val detail = JSONObject()
|
||||
if (prices != null) detail.put("prices", prices)
|
||||
if (error != null) detail.put("error", error)
|
||||
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) {
|
||||
val detail = JSONObject().put("orderId", orderId)
|
||||
if (token != null) detail.put("purchaseToken", token)
|
||||
@@ -315,10 +300,10 @@ class MainActivity : AppCompatActivity() {
|
||||
private inner class Bridge {
|
||||
/** قراردادش در src/lib/bazaar.tsِ فرانت است. */
|
||||
@JavascriptInterface
|
||||
fun bazaarBuy(sku: String, orderId: String, productType: String, dynamicPriceToken: String) {
|
||||
fun bazaarBuy(sku: String, orderId: String, dynamicPriceToken: String) {
|
||||
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() }
|
||||
}
|
||||
|
||||
/** skusJson آرایهی JSON از SKUها؛ productType «inapp» یا «subscription». */
|
||||
/** skusJson آرایهی JSON از SKUهای اشتراک. */
|
||||
@JavascriptInterface
|
||||
fun bazaarPrices(skusJson: String, productType: String) {
|
||||
fun bazaarPrices(skusJson: String) {
|
||||
val skus = try {
|
||||
val arr = JSONArray(skusJson)
|
||||
List(arr.length()) { i -> arr.getString(i) }
|
||||
@@ -367,7 +352,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
runOnUiThread {
|
||||
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
|
||||
prices(skus, productType)
|
||||
prices(skus)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,13 +360,6 @@ class MainActivity : AppCompatActivity() {
|
||||
@JavascriptInterface
|
||||
fun paysInApp(): Boolean = true
|
||||
|
||||
@JavascriptInterface
|
||||
fun bazaarConsume(purchaseToken: String) {
|
||||
runOnUiThread {
|
||||
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
|
||||
consume(purchaseToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
||||
@@ -241,8 +241,6 @@ export interface AskDone {
|
||||
suggestedFollowUps: string[];
|
||||
/** سوالهای رایگانِ باقیِ امروز. */
|
||||
remainingQuota: number;
|
||||
/** اعتبارِ بستههای سوال؛ منقضی نمیشود. */
|
||||
credits?: number;
|
||||
usedChunks: UsedChunk[];
|
||||
diagram?: Diagram | null;
|
||||
}
|
||||
@@ -281,13 +279,7 @@ export interface Plan {
|
||||
badge: string;
|
||||
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
|
||||
aiDailyQuota: number;
|
||||
/** «pack» بستهی سوال؛ «subscription» اشتراکِ زمانی که همهی فصلها را باز میکند. */
|
||||
kind: 'pack' | 'subscription';
|
||||
/** بسته: سوالهایی که به اعتبار اضافه میشود. */
|
||||
questionCredits: number;
|
||||
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
|
||||
heartsDays: number;
|
||||
/** SKUِ همین پلن (بسته یا اشتراک) در کافهبازار؛ خالی یعنی از بازار خریدنی نیست. */
|
||||
/** SKUِ همین اشتراک در کافهبازار؛ خالی یعنی از بازار خریدنی نیست. */
|
||||
bazaarSku: string;
|
||||
}
|
||||
|
||||
@@ -298,7 +290,7 @@ export interface PlansResponse {
|
||||
}
|
||||
|
||||
export interface SubscriptionStatus {
|
||||
/** اشتراکِ زمانیِ فعال (یک/سه/ششماهه)، نه بسته. */
|
||||
/** اشتراکِ زمانیِ فعال (یک/سه/ششماهه). */
|
||||
active: boolean;
|
||||
/** نامِ پلنِ فعال؛ null بیاشتراک. */
|
||||
planTitle: string | null;
|
||||
@@ -308,17 +300,12 @@ export interface SubscriptionStatus {
|
||||
aiDailyQuota: number;
|
||||
planId: string | null;
|
||||
expiresAt: string | null;
|
||||
/** سوالهای خریدهشدهی باقیمانده. */
|
||||
questionCredits: number;
|
||||
/** پایانِ قلبِ نامحدودِ هدیهی بستهها؛ null یعنی ندارد. */
|
||||
unlimitedHeartsUntil: string | null;
|
||||
}
|
||||
|
||||
/** GET /ai/quota */
|
||||
export interface TutorQuota {
|
||||
freeDaily: number;
|
||||
remainingQuota: number;
|
||||
credits: number;
|
||||
}
|
||||
|
||||
export interface PurchaseResponse {
|
||||
@@ -327,15 +314,12 @@ export interface PurchaseResponse {
|
||||
paymentUrl: string | null;
|
||||
/** فقط بازار: SKUی که اپِ اندروید میخرد. */
|
||||
bazaarSku?: string;
|
||||
/** فقط بازار: «inapp» بسته (بعد از تأیید consume)، «subscription» اشتراک. */
|
||||
bazaarProductType?: 'inapp' | 'subscription';
|
||||
}
|
||||
|
||||
/** POST /payment/bazaar/orders/{id}/start — بعد از کدِ تخفیف، درست پیش از صفحهی خریدِ بازار. */
|
||||
export interface BazaarStartResponse {
|
||||
orderId: string;
|
||||
sku: string;
|
||||
productType: 'inapp' | 'subscription';
|
||||
/** مبلغِ سفارش (تومان)، پس از کدِ تخفیف. */
|
||||
amount: number;
|
||||
/** JWTِ «تخفیف پویا»؛ null یعنی بازار قیمتِ پیشخان را میگیرد. */
|
||||
|
||||
@@ -8,12 +8,11 @@ import { store } from './storage';
|
||||
* جایی است که با آن حرف میزند. در مرورگرِ معمولی آن شیء نیست و خرید با زرینپال است.
|
||||
*
|
||||
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
|
||||
* JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)
|
||||
* productType «inapp» (بسته، purchaseProduct) یا «subscription» (subscribeProduct)؛
|
||||
* orderId همان payloadِ خرید است؛ dynamicPriceToken رشتهی خالی یعنی قیمتِ پیشخان.
|
||||
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) فقط بسته، بعد از تأییدِ سرور.
|
||||
* JS → اندروید TadrisinoAndroid.bazaarPrices(skusJson, productType) قیمتِ پیشخان؛ جواب با رویدادِ
|
||||
* «tadrisino:bazaar-prices» و detailِ { productType, prices: { sku: «۷۹۸,۰۰۰ تومان» } } یا { productType, error }
|
||||
* JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
|
||||
* خریدِ اشتراک با subscribeProduct (فقط اشتراک فروخته میشود)؛ orderId همان payloadِ خرید است؛
|
||||
* dynamicPriceToken رشتهی خالی یعنی قیمتِ پیشخان.
|
||||
* JS → اندروید TadrisinoAndroid.bazaarPrices(skusJson) قیمتِ پیشخان؛ جواب با رویدادِ «tadrisino:bazaar-prices»
|
||||
* و detailِ { prices: { sku: «۸۹۸,۰۰۰ تومان» } } یا { error }
|
||||
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
|
||||
* { orderId, purchaseToken } خرید انجام شد
|
||||
* { orderId, error: 'canceled' | 'failed', message? }
|
||||
@@ -23,14 +22,11 @@ import { store } from './storage';
|
||||
* هیچکدام از اینها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۱۲).
|
||||
*/
|
||||
|
||||
export type BazaarProductType = 'inapp' | 'subscription';
|
||||
|
||||
interface AndroidBridge {
|
||||
bazaarBuy?: (sku: string, orderId: string, productType: BazaarProductType, dynamicPriceToken: string) => void;
|
||||
bazaarBuy?: (sku: string, orderId: string, dynamicPriceToken: string) => void;
|
||||
/** APKِ قدیمی؛ فقط برای اینکه بدانیم داخلِ اپیم ولی خرید از بازار در آن ممکن نیست. */
|
||||
bazaarPurchase?: (sku: string, orderId: string) => void;
|
||||
bazaarConsume?: (purchaseToken: string) => void;
|
||||
bazaarPrices?: (skusJson: string, productType: BazaarProductType) => void;
|
||||
bazaarPrices?: (skusJson: string) => void;
|
||||
/** اولین صفحهی واقعی کشیده شد؛ اسپلشِ سیستم میتواند برود (APKهای قدیمیتر این را ندارند). */
|
||||
appReady?: () => void;
|
||||
}
|
||||
@@ -56,7 +52,7 @@ export function inAndroidApp(): boolean {
|
||||
return typeof window.TadrisinoAndroid === 'object' && window.TadrisinoAndroid !== null;
|
||||
}
|
||||
|
||||
/** اپی که میتواند از بازار بخرد (بسته و اشتراک). */
|
||||
/** اپی که میتواند از بازار اشتراک بخرد. */
|
||||
export function canBuyFromBazaar(): boolean {
|
||||
return typeof window.TadrisinoAndroid?.bazaarBuy === 'function';
|
||||
}
|
||||
@@ -65,7 +61,7 @@ export function canBuyFromBazaar(): boolean {
|
||||
const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
/** خرید را به اپِ اندروید میسپارد و 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;
|
||||
if (!bridge?.bazaarBuy) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app'));
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -83,16 +79,16 @@ export function bazaarBuy(sku: string, orderId: string, productType: BazaarProdu
|
||||
}
|
||||
window.addEventListener('tadrisino:bazaar', onResult);
|
||||
try {
|
||||
bridge.bazaarBuy!(sku, orderId, productType, dynamicPriceToken ?? '');
|
||||
bridge.bazaarBuy!(sku, orderId, dynamicPriceToken ?? '');
|
||||
} catch (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;
|
||||
if (!bridge?.bazaarPrices || !skus.length) return Promise.resolve({});
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -104,12 +100,12 @@ function askPrices(skus: string[], productType: BazaarProductType): Promise<Reco
|
||||
}
|
||||
function onResult(event: Event) {
|
||||
const detail = (event as CustomEvent<PricesResult>).detail;
|
||||
if (!detail || detail.productType !== productType) return;
|
||||
if (!detail) return;
|
||||
if (detail.prices) done(() => resolve(detail.prices!));
|
||||
else done(() => reject(new Error(detail.error || 'failed')));
|
||||
}
|
||||
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
|
||||
* چند لحظه بعد از باز شدنِ اپ برقرار میشود، پس یک بار دوباره امتحان میشود. خطا یعنی قیمتی نشان داده نمیشود.
|
||||
*/
|
||||
export async function bazaarPrices(skus: string[], productType: BazaarProductType): Promise<Record<string, string>> {
|
||||
export async function bazaarPrices(skus: string[]): Promise<Record<string, string>> {
|
||||
try {
|
||||
return await askPrices(skus, productType);
|
||||
return await askPrices(skus);
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
try {
|
||||
return await askPrices(skus, productType);
|
||||
return await askPrices(skus);
|
||||
} catch (err) {
|
||||
console.error('bazaarPrices failed', err);
|
||||
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. اگر بازار پول را گرفت ولی تأییدِ سرور نرسید (قطعِ
|
||||
* اینترنت)، تلاشِ بعدی باید همان سفارش را بگیرد؛ سفارشِ تازه با توکنِ قبلی تأیید نمیشد چون هر
|
||||
|
||||
@@ -25,13 +25,13 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
const chat = useAskState();
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
// «X سوالِ رایگانِ امروز + Y سوالِ خریدهشده»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
|
||||
const [quota, setQuota] = useState<{ free: number; credits: number } | null>(null);
|
||||
// «X سوالِ امروز»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
|
||||
const [quota, setQuota] = useState<number | null>(null);
|
||||
const [quotaDone, setQuotaDone] = useState(false);
|
||||
useEffect(() => {
|
||||
api.ai
|
||||
.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))
|
||||
.finally(() => setQuotaDone(true));
|
||||
}, []);
|
||||
@@ -109,7 +109,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
diagram: done.diagram ?? null,
|
||||
});
|
||||
askStore.setConversation(done.conversationId);
|
||||
setQuota((q) => ({ free: done.remainingQuota, credits: done.credits ?? q?.credits ?? 0 }));
|
||||
setQuota(done.remainingQuota);
|
||||
} catch (err) {
|
||||
// سقفِ روزانه (نه throttleِ چندثانیهای): «دوباره» تا فردا بیفایده است.
|
||||
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, 'جوابی نرسید'),
|
||||
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
|
||||
});
|
||||
if (quotaReached) setQuota({ free: 0, credits: 0 });
|
||||
if (quotaReached) setQuota(0);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
mascot={{ mood: busy ? 'excited' : 'idle', size: 60 }}
|
||||
>
|
||||
<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 && (
|
||||
<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();
|
||||
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'}`}>
|
||||
{free > 0 ? <>{toFa(free)} سوالِ امروز</> : 'سوالهای امروز تموم شد'}
|
||||
</span>
|
||||
{credits > 0 ? (
|
||||
<span className="rounded-full bg-white px-3 py-1.5 text-xs font-black text-violet">{toFa(credits)} سوالِ خریدهشده</span>
|
||||
) : free === 0 ? (
|
||||
{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>
|
||||
) : null}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,11 +185,6 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||
<span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همهی فصلها و قلبِ نامحدود باز میشه.</span>
|
||||
)}
|
||||
</div>
|
||||
{s.questionCredits > 0 && (
|
||||
<div className={`mt-1.5 flex items-center gap-1 text-[11px] font-bold ${s.active ? 'text-white/90' : 'text-violet'}`}>
|
||||
<Sparkles size={11} /> {toFa(s.questionCredits)} سوالِ خریدهشده در اعتبارت
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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 { daysLeft } from '@/lib/subscription';
|
||||
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 { Sheet } from '@/components/Sheet';
|
||||
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
|
||||
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';
|
||||
|
||||
function durationLabel(days: number): string {
|
||||
@@ -45,12 +45,12 @@ export function SubscriptionScreen() {
|
||||
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||
}, [status.reload]);
|
||||
|
||||
const list = (plans.data?.plans ?? []).filter((p) => p.kind === 'subscription');
|
||||
const list = plans.data?.plans ?? [];
|
||||
useEffect(() => {
|
||||
const skus = list.filter((p) => p.bazaarSku).map((p) => p.bazaarSku);
|
||||
if (!canBuyFromBazaar() || !skus.length) return;
|
||||
let alive = true;
|
||||
void bazaarPrices(skus, 'subscription').then((prices) => alive && setStorePrices(prices));
|
||||
void bazaarPrices(skus).then((prices) => alive && setStorePrices(prices));
|
||||
return () => { alive = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [plans.data]);
|
||||
@@ -102,59 +102,37 @@ export function SubscriptionScreen() {
|
||||
}
|
||||
|
||||
/**
|
||||
* وضعیتِ کاربر بالای فروشگاه. مشترک: یک کارتِ واضح با روزهای مانده. بیاشتراک: هیچ کارتی (دو گزینهی زیر
|
||||
* خودشان حرف را میزنند)، مگر سوالِ خریدهشده یا قلبِ هدیه داشته باشد. تا وضعیت نرسیده جای خالیِ هماندازه
|
||||
* میآید، نه «نداری» که برای مشترک غلط بود.
|
||||
* وضعیتِ کاربر بالای فروشگاه: مشترک یک کارتِ واضح با روزهای مانده میبیند؛ بیاشتراک هیچ کارتی (کارتهای
|
||||
* اشتراکِ زیر خودشان حرف را میزنند). تا وضعیت نرسیده جای خالیِ هماندازه میآید، نه «نداری» که برای مشترک غلط بود.
|
||||
*/
|
||||
function StatusCard({ status, loading }: { status: SubscriptionStatus | null; loading: boolean }) {
|
||||
if (loading) return <div className="h-[104px] animate-pulse rounded-3xl bg-soft" />;
|
||||
if (!status) return null;
|
||||
const days = status.active ? daysLeft(status.expiresAt) : null;
|
||||
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;
|
||||
}
|
||||
if (!status?.active) return null;
|
||||
const days = daysLeft(status.expiresAt);
|
||||
const total = status.planDurationDays ?? 30;
|
||||
const percent = days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)));
|
||||
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="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>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-black">اشتراکت فعاله</div>
|
||||
<div className="text-sm text-white/85">{status.planTitle ?? 'اشتراک'}</div>
|
||||
<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">
|
||||
<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="text-base font-black">اشتراکت فعاله</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>
|
||||
{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>
|
||||
<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 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>
|
||||
{(status.questionCredits > 0) && <div className="flex flex-wrap gap-2">{extras}</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 }) {
|
||||
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 [error, setError] = useState('');
|
||||
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
|
||||
const isSubscription = plan?.kind === 'subscription';
|
||||
|
||||
// هر بار که برگه برای پلنی باز میشود، زرینپال انتخاب است (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ بازار یک لمس آنطرفتر.
|
||||
useEffect(() => {
|
||||
@@ -238,7 +215,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
|
||||
/**
|
||||
* بازار: سفارش (با کلیدِ ثابتِ همین SKU) → کدِ تخفیف → شروع (SKU، نوع، قیمتِ امضاشده) → خرید در بازار
|
||||
* → سنجشِ سرور → مصرفِ خرید (فقط بسته؛ اشتراک مصرف نمیشود).
|
||||
* → سنجشِ سرور. اشتراکِ بازار مصرف (consume) نمیشود؛ بازار خودش آخرِ هر دوره تمدیدش میکند.
|
||||
*/
|
||||
async function payWithBazaar(plan: Plan): Promise<'bazaar' | 'code' | null> {
|
||||
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);
|
||||
let token: string;
|
||||
try {
|
||||
token = await bazaarBuy(start.sku, order.orderId, start.productType, start.dynamicPriceToken);
|
||||
token = await bazaarBuy(start.sku, order.orderId, start.dynamicPriceToken);
|
||||
} catch (err) {
|
||||
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return null;
|
||||
throw err;
|
||||
@@ -277,7 +254,6 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
const result = await api.subscription.verifyBazaar(order.orderId, token);
|
||||
if (result.status !== 'paid') throw new Error('bazaar purchase not confirmed');
|
||||
bazaarPending.clear(sku);
|
||||
if (start.productType === 'inapp') bazaarConsume(token);
|
||||
return 'bazaar';
|
||||
}
|
||||
|
||||
@@ -338,8 +314,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
const doneText =
|
||||
done === 'gateway' ? 'صفحهی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همینجا؛ خریدت خودش فعال میشه.'
|
||||
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
|
||||
: isSubscription ? 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش میکنه؛ لغوِ تمدید از بخشِ «اشتراکها»ی خودِ بازاره.'
|
||||
: 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.';
|
||||
: 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش میکنه؛ لغوِ تمدید از بخشِ «اشتراکها»ی خودِ بازاره.';
|
||||
|
||||
return (
|
||||
<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">
|
||||
<ShieldCheck size={14} />
|
||||
{method === 'bazaar'
|
||||
? isSubscription ? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید میشه.' : 'پرداخت در خودِ بازار انجام میشه.'
|
||||
? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید میشه.'
|
||||
: 'پرداخت در درگاهِ امنِ زرینپال انجام میشه.'}
|
||||
</p>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user