From 854149bb5d513d2d41b5f558f1652d0a354c9155 Mon Sep 17 00:00:00 2001 From: AliReza Date: Mon, 12 Oct 2026 20:33:47 +0330 Subject: [PATCH] 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 --- android/README.md | 21 ++--- .../java/com/tadrisino/app/MainActivity.kt | 80 +++++++----------- src/api/types.ts | 20 +---- src/lib/bazaar.ts | 47 ++++------- src/screens/AskScreen.tsx | 24 +++--- src/screens/ProfileScreen.tsx | 5 -- src/screens/SubscriptionScreen.tsx | 81 +++++++------------ 7 files changed, 98 insertions(+), 180 deletions(-) diff --git a/android/README.md b/android/README.md index 8709348..8baf0f9 100644 --- a/android/README.md +++ b/android/README.md @@ -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`ِ بک‌اند. diff --git a/android/app/src/main/java/com/tadrisino/app/MainActivity.kt b/android/app/src/main/java/com/tadrisino/app/MainActivity.kt index e21e172..a4ecdd4 100644 --- a/android/app/src/main/java/com/tadrisino/app/MainActivity.kt +++ b/android/app/src/main/java/com/tadrisino/app/MainActivity.kt @@ -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, 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) { + 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() { diff --git a/src/api/types.ts b/src/api/types.ts index 1d1fbc2..f753741 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 یعنی بازار قیمتِ پیشخان را می‌گیرد. */ diff --git a/src/lib/bazaar.ts b/src/lib/bazaar.ts index f88cf6c..8530d62 100644 --- a/src/lib/bazaar.ts +++ b/src/lib/bazaar.ts @@ -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 { +export function bazaarBuy(sku: string, orderId: string, dynamicPriceToken: string | null): Promise { 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; error?: string }; +type PricesResult = { prices?: Record; error?: string }; -function askPrices(skus: string[], productType: BazaarProductType): Promise> { +function askPrices(skus: string[]): Promise> { 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).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> { +export async function bazaarPrices(skus: string[]): Promise> { 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. اگر بازار پول را گرفت ولی تأییدِ سرور نرسید (قطعِ * اینترنت)، تلاشِ بعدی باید همان سفارش را بگیرد؛ سفارشِ تازه با توکنِ قبلی تأیید نمی‌شد چون هر diff --git a/src/screens/AskScreen.tsx b/src/screens/AskScreen.tsx index 76a9c21..1694866 100644 --- a/src/screens/AskScreen.tsx +++ b/src/screens/AskScreen.tsx @@ -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(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 }} >
- {quota !== null && } + {quota !== null && } {!empty && ( - ) : null} + )} ); } diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index c22f260..5f0134e 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -185,11 +185,6 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) { فصلِ اولِ هر پایه رایگانه؛ با اشتراک همه‌ی فصل‌ها و قلبِ نامحدود باز می‌شه. )}
- {s.questionCredits > 0 && ( -
- {toFa(s.questionCredits)} سوالِ خریده‌شده در اعتبارت -
- )} ); } diff --git a/src/screens/SubscriptionScreen.tsx b/src/screens/SubscriptionScreen.tsx index e66ae8f..4ecd1be 100644 --- a/src/screens/SubscriptionScreen.tsx +++ b/src/screens/SubscriptionScreen.tsx @@ -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
; - if (!status) return null; - const days = status.active ? daysLeft(status.expiresAt) : null; - const hearts = !status.active && status.unlimitedHeartsUntil; - const extras = ( - <> - {status.questionCredits > 0 && } text={`${toFa(status.questionCredits)} سوالِ خریده‌شده`} />} - {hearts && } text={`قلبِ نامحدود تا ${formatJalaliDate(status.unlimitedHeartsUntil!)}`} />} - - ); - if (!status.active) { - return status.questionCredits > 0 || hearts ?
{extras}
: 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 ( -
-
-
- -
-
اشتراکت فعاله
-
{status.planTitle ?? 'اشتراک'}
+
+
+ +
+
اشتراکت فعاله
+
{status.planTitle ?? 'اشتراک'}
+
+ {days !== null && ( +
+
{toFa(days)}
+
روز مونده
- {days !== null && ( -
-
{toFa(days)}
-
روز مونده
-
- )} -
-
-
-
+ )} +
+
+
- {(status.questionCredits > 0) &&
{extras}
}
); } -function Pill({ icon, text }: { icon: ReactNode; text: string }) { - return ( - - {icon} {text} - - ); -} - /** یک کارتِ انتخابی: نام و قیمت درشت، و زیرش آنچه این اشتراک می‌دهد (خواستِ سینیور: روی هر کارت). */ 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 ( @@ -385,7 +360,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu

{method === 'bazaar' - ? isSubscription ? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید می‌شه.' : 'پرداخت در خودِ بازار انجام می‌شه.' + ? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید می‌شه.' : 'پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.'}