Bazaar: subscriptions (subscribeProduct), dynamic price token, RSA key; Zarinpal and Bazaar side by side at checkout
- Checkout sheet always shows both methods as two tiles; Bazaar tile disabled with the reason
(not in Bazaar / Android app only / update the app) instead of hidden; default is Bazaar inside an app that can buy.
- Bridge v2: TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken); inAndroidApp() split
from canBuyFromBazaar() so the pay.html workaround still applies to the installed APK.
- Bazaar flow: order -> optional discount code -> POST /payment/bazaar/orders/{id}/start -> Poolakey -> verify;
consume only packs.
- Android: RSA key, subscribeProduct/getSubscribedProducts for subscriptions, dynamicPriceToken, versionCode 2.
compileDebugKotlin green; no purchase tested on a phone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +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/` سفارشِ بازار «پرداختشده». یک بار هم «انصراف» در صفحهی بازار |
|
||||||
| صفحهی بیاتصال: `onReceivedError`/`onReceivedHttpError` ← همان تصویر + «اینترنت قطع است / کند است؛ در حال اتصال…»، تلاشِ دوباره هر ۵ ثانیه، و مجوزِ `ACCESS_NETWORK_STATE` | کاربر هرگز صفحهی خطای WebView یا متنِ فنی نمیبیند. اتصالِ کند (بیخطا) عمداً هیچ متنِ اضافه نمیآورد و روی اسپلش میماند | هواپیما روشن ← باز کردنِ اپ ← خاموش کردن: باید خودش وصل شود. سرورِ ۵۰۲ (مثلاً nginx خاموش) هم همین صفحه. اگر صفحه لود شد ولی ۱۵ ثانیه `appReady` نیامد، لاگِ ERROR و رهاشدنِ اسپلش (باگِ JS) |
|
| صفحهی بیاتصال: `onReceivedError`/`onReceivedHttpError` ← همان تصویر + «اینترنت قطع است / کند است؛ در حال اتصال…»، تلاشِ دوباره هر ۵ ثانیه، و مجوزِ `ACCESS_NETWORK_STATE` | کاربر هرگز صفحهی خطای WebView یا متنِ فنی نمیبیند. اتصالِ کند (بیخطا) عمداً هیچ متنِ اضافه نمیآورد و روی اسپلش میماند | هواپیما روشن ← باز کردنِ اپ ← خاموش کردن: باید خودش وصل شود. سرورِ ۵۰۲ (مثلاً nginx خاموش) هم همین صفحه. اگر صفحه لود شد ولی ۱۵ ثانیه `appReady` نیامد، لاگِ ERROR و رهاشدنِ اسپلش (باگِ JS) |
|
||||||
|
|
||||||
**اسپلش و بیاینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصبشده امتحان شد).** چیزی که روی گوشی دیده میشود
|
**اسپلش و بیاینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصبشده امتحان شد).** چیزی که روی گوشی دیده میشود
|
||||||
@@ -47,16 +48,19 @@
|
|||||||
ساخت روی مکِ مالک: SDK در `~/Library/Android/sdk`، JDK 17 (`/opt/homebrew/opt/openjdk@17`)، و چون Google Maven از
|
ساخت روی مکِ مالک: SDK در `~/Library/Android/sdk`، JDK 17 (`/opt/homebrew/opt/openjdk@17`)، و چون Google Maven از
|
||||||
ایران بیVPN ۴۰۴ میدهد، `~/.gradle/init.d/google-mirror.gradle` آینهی Aliyun را اضافه میکند؛ ساختِ اول ~۹ دقیقه.
|
ایران بیVPN ۴۰۴ میدهد، `~/.gradle/init.d/google-mirror.gradle` آینهی Aliyun را اضافه میکند؛ ساختِ اول ~۹ دقیقه.
|
||||||
|
|
||||||
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
|
## پیش از build — آنچه از پیشخانِ بازار لازم است
|
||||||
|
|
||||||
| چه | کجا گذاشته میشود | وضعیت |
|
| چه | کجا گذاشته میشود | وضعیت (۲۰۲۶-۱۰-۱۲) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app` (APKِ ساختهشده)؛ ثبتش در پیشخان تأیید نشده |
|
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app`؛ بازار محصولهای ۱۰۳۴۲–۱۰۳۴۴ را زیرِ همین نام شناخت (API، توکنِ ساختگی) |
|
||||||
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | ندارد؛ بدونِ آن خرید شروع نمیشود |
|
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | گذاشته شد |
|
||||||
| SKUِ هر بسته (محصولِ **مصرفشدنی**) | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچکدام تعریف نشده |
|
| SKUِ هر پلن | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | سهماهه `10343`، ششماهه `10344` (migrationِ `subscription.0014`)؛ یکماهه و بستهها ندارند؛ `10342` در بازار هست ولی معلوم نیست چیست |
|
||||||
|
| کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور |
|
||||||
|
|
||||||
قیمتِ هر محصول در پیشخانِ بازار باید با قیمتِ همان بسته در ادمین یکی باشد؛ سرور مبلغ را از
|
خرید: فرانت `TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)` را صدا میزند؛ اشتراک با
|
||||||
بازار نمیخواند.
|
`subscribeProduct` (بیconsume، بازار خودش تمدید میکند)، بسته با `purchaseProduct` و بعد `bazaarConsume`. قیمتِ هر
|
||||||
|
محصول در پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف خورده، سرور قیمتِ کمشده را امضا میکند.
|
||||||
|
`compileDebugKotlin` سبز است؛ هیچ خریدی روی گوشی آزموده نشده.
|
||||||
|
|
||||||
## build
|
## build
|
||||||
|
|
||||||
|
|||||||
@@ -64,13 +64,15 @@ android {
|
|||||||
applicationId = "com.tadrisino.math.app"
|
applicationId = "com.tadrisino.math.app"
|
||||||
minSdk = 23
|
minSdk = 23
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
// بازار نسخهی تازه را فقط با versionCodeِ بزرگتر میپذیرد؛ هر build که منتشر میشود یکی بالا.
|
||||||
versionName = "1.0.0"
|
versionCode = 2
|
||||||
|
versionName = "1.1.0"
|
||||||
|
|
||||||
// URL of the web frontend.
|
// URL of the web frontend.
|
||||||
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
|
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
|
||||||
// Public RSA key from the Cafe Bazaar panel. Purchases won't start until it is filled in.
|
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (پرداخت درونبرنامهای)؛ سینیور، ۲۰۲۶-۱۰-۱۲. عمومی است و در APK
|
||||||
buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
|
// میرود؛ Poolakey با آن امضای خرید را روی گوشی میسنجد. سنجشِ اصلی سمتِ سرور است.
|
||||||
|
buildConfigField("String", "BAZAAR_RSA_KEY", "\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwC9EBw0RW6jeTdHkrJwfB/mX+klpjpVXVb2stpGDcixeqHiLE+/Iow+PiMbZEQfxZDuED6ELvZlzQX+ALOlGRPxaa1ctl6/Uy6wGaPXhGPDpZL6jPrXV88mjJzMcEoB4sf3yNb+6YNKrvKhRyXXR0JdMUmPMqMe8igmQZTv/UF+QEMNqv7lg7qw9Yl+fG802nqjasft7qUPQK+YHJYPS4PJ5Pj/YpTkGDdDmVi7AIMCAwEAAQ==\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import org.json.JSONObject
|
|||||||
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درونبرنامهایِ بازار است.
|
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درونبرنامهایِ بازار است.
|
||||||
*
|
*
|
||||||
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
|
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
|
||||||
* JS → اینجا TadrisinoAndroid.bazaarPurchase(sku, orderId)
|
* JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)
|
||||||
* TadrisinoAndroid.bazaarConsume(purchaseToken)
|
* 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 }
|
||||||
@@ -218,31 +218,39 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun purchase(sku: String, orderId: String) {
|
/**
|
||||||
|
* productType «inapp» (بسته: purchaseProduct، بعد از تأییدِ سرور consume) یا «subscription» (اشتراکِ
|
||||||
|
* بازار: subscribeProduct، مصرف نمیشود و بازار خودش تمدید میکند). dynamicPriceToken خالی یعنی قیمتِ
|
||||||
|
* پیشخان؛ پر یعنی قیمتی که سرور با کلیدِ «تخفیف پویا» امضا کرده (بازارِ ۱۳.۳ به بعد).
|
||||||
|
*/
|
||||||
|
private fun buy(sku: String, orderId: String, productType: 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
|
||||||
}
|
}
|
||||||
p.purchaseProduct(
|
val subscription = productType == "subscription"
|
||||||
registry = activityResultRegistry,
|
val request = PurchaseRequest(
|
||||||
request = PurchaseRequest(productId = sku, payload = orderId),
|
productId = sku, payload = orderId, dynamicPriceToken = dynamicPriceToken.ifEmpty { null },
|
||||||
) {
|
)
|
||||||
|
val callback: ir.cafebazaar.poolakey.callback.PurchaseCallback.() -> Unit = {
|
||||||
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, it) }
|
purchaseFailed { recoverOwned(sku, orderId, subscription, it) }
|
||||||
failedToBeginFlow { recoverOwned(sku, orderId, it) }
|
failedToBeginFlow { recoverOwned(sku, orderId, subscription, it) }
|
||||||
}
|
}
|
||||||
|
if (subscription) p.subscribeProduct(activityResultRegistry, request, callback)
|
||||||
|
else p.purchaseProduct(activityResultRegistry, request, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* خریدِ قبلیِ همین محصول که مصرف نشده (پول رفته ولی تأییدِ سرور نرسیده بود) جلوی خریدِ
|
* خریدِ قبلیِ همین محصول (بستهی مصرفنشده، یا اشتراکِ فعال) که پولش رفته ولی تأییدِ سرور نرسیده بود
|
||||||
* دوباره را میگیرد. فرانت در تلاشِ بعدی همان سفارش را میفرستد؛ اگر توکنِ ماندهٔ همان
|
* جلوی خریدِ دوباره را میگیرد. فرانت در تلاشِ بعدی همان سفارش را میفرستد؛ اگر توکنِ ماندهٔ همان
|
||||||
* سفارش (payload) پیدا شد، همان برمیگردد تا سرور ثبتش کند.
|
* سفارش (payload) پیدا شد، همان برمیگردد تا سرور ثبتش کند.
|
||||||
*/
|
*/
|
||||||
private fun recoverOwned(sku: String, orderId: String, cause: Throwable) {
|
private fun recoverOwned(sku: String, orderId: String, subscription: Boolean, cause: Throwable) {
|
||||||
val p = payment ?: return send(orderId, error = "failed", message = cause.message)
|
val p = payment ?: return send(orderId, error = "failed", message = cause.message)
|
||||||
p.getPurchasedProducts {
|
val query: ir.cafebazaar.poolakey.callback.PurchaseQueryCallback.() -> Unit = {
|
||||||
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)
|
||||||
@@ -250,6 +258,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun consume(purchaseToken: String) {
|
private fun consume(purchaseToken: String) {
|
||||||
@@ -273,11 +282,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
/** متدهای این کلاس روی threadِ WebView صدا زده میشوند، نه UI. */
|
/** متدهای این کلاس روی threadِ WebView صدا زده میشوند، نه UI. */
|
||||||
private inner class Bridge {
|
private inner class Bridge {
|
||||||
|
/** قراردادش در src/lib/bazaar.tsِ فرانت است. */
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun bazaarPurchase(sku: String, orderId: String) {
|
fun bazaarBuy(sku: String, orderId: String, productType: String, dynamicPriceToken: String) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
|
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
|
||||||
purchase(sku, orderId)
|
buy(sku, orderId, productType, dynamicPriceToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
purchaseBazaar: (planId: string, idempotencyKey: string) =>
|
purchaseBazaar: (planId: string, idempotencyKey: string) =>
|
||||||
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }),
|
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }),
|
||||||
|
/** SKU، نوعِ محصول و (اگر تخفیف خورده) قیمتِ امضاشده برای بازار؛ بعد از آن کدِ تخفیف نمینشیند. */
|
||||||
|
startBazaar: (orderId: string) => http.post<T.BazaarStartResponse>(`/payment/bazaar/orders/${seg(orderId)}/start`),
|
||||||
/** توکنِ خریدِ Poolakey را سرور از خودِ بازار میسنجد؛ گفتهی اپ کافی نیست. */
|
/** توکنِ خریدِ Poolakey را سرور از خودِ بازار میسنجد؛ گفتهی اپ کافی نیست. */
|
||||||
verifyBazaar: (orderId: string, purchaseToken: string) =>
|
verifyBazaar: (orderId: string, purchaseToken: string) =>
|
||||||
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),
|
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ export interface Plan {
|
|||||||
questionCredits: number;
|
questionCredits: number;
|
||||||
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
|
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
|
||||||
heartsDays: number;
|
heartsDays: number;
|
||||||
/** SKUِ همین بسته در کافهبازار؛ خالی یعنی داخلِ اپِ بازار خریدنی نیست. */
|
/** SKUِ همین پلن (بسته یا اشتراک) در کافهبازار؛ خالی یعنی از بازار خریدنی نیست. */
|
||||||
bazaarSku: string;
|
bazaarSku: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +327,19 @@ 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 — بعد از کدِ تخفیف، درست پیش از صفحهی خریدِ بازار. */
|
||||||
|
export interface BazaarStartResponse {
|
||||||
|
orderId: string;
|
||||||
|
sku: string;
|
||||||
|
productType: 'inapp' | 'subscription';
|
||||||
|
/** مبلغِ سفارش (تومان)، پس از کدِ تخفیف. */
|
||||||
|
amount: number;
|
||||||
|
/** JWTِ «تخفیف پویا»؛ null یعنی بازار قیمتِ پیشخان را میگیرد. */
|
||||||
|
dynamicPriceToken: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BazaarVerifyResponse {
|
export interface BazaarVerifyResponse {
|
||||||
|
|||||||
@@ -5,22 +5,27 @@ import { store } from './storage';
|
|||||||
*
|
*
|
||||||
* خریدِ درونبرنامهایِ بازار فقط از کدِ بومیِ اندروید (کتابخانهی Poolakey) شدنی است، پس
|
* خریدِ درونبرنامهایِ بازار فقط از کدِ بومیِ اندروید (کتابخانهی Poolakey) شدنی است، پس
|
||||||
* اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه میگذارد و این فایل تنها
|
* اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه میگذارد و این فایل تنها
|
||||||
* جایی است که با آن حرف میزند. در مرورگرِ معمولی آن شیء نیست، `inBazaarApp()` false است و
|
* جایی است که با آن حرف میزند. در مرورگرِ معمولی آن شیء نیست و خرید با زرینپال است.
|
||||||
* خرید مثلِ قبل به زرینپال میرود.
|
|
||||||
*
|
*
|
||||||
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
|
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
|
||||||
* JS → اندروید TadrisinoAndroid.bazaarPurchase(sku, orderId) خرید را شروع کن؛ orderId همان
|
* JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)
|
||||||
* developerPayloadِ خرید است.
|
* productType «inapp» (بسته، purchaseProduct) یا «subscription» (subscribeProduct)؛
|
||||||
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) بعد از تأییدِ سرور؛ بدونِ آن
|
* orderId همان payloadِ خرید است؛ dynamicPriceToken رشتهی خالی یعنی قیمتِ پیشخان.
|
||||||
* همان بسته دوباره خریدنی نیست.
|
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) فقط بسته، بعد از تأییدِ سرور.
|
||||||
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
|
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
|
||||||
* { orderId, purchaseToken } خرید انجام شد
|
* { orderId, purchaseToken } خرید انجام شد
|
||||||
* { orderId, error: 'canceled' | 'failed', message? }
|
* { orderId, error: 'canceled' | 'failed', message? }
|
||||||
*
|
*
|
||||||
* هیچکدام از اینها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۰۳): اپِ اندروید ساخته نشده.
|
* `bazaarBuy` از APKهای پس از ۲۰۲۶-۱۰-۱۲ است. APKهای قبلی کلیدِ RSA نداشتند و Poolakey در آنها خاموش بود، پس از
|
||||||
|
* بازار هیچ خریدی نکردهاند؛ در آنها کاشیِ بازار غیرفعال است و «اپ را بهروز کن» میگوید.
|
||||||
|
* هیچکدام از اینها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۱۲).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type BazaarProductType = 'inapp' | 'subscription';
|
||||||
|
|
||||||
interface AndroidBridge {
|
interface AndroidBridge {
|
||||||
|
bazaarBuy?: (sku: string, orderId: string, productType: BazaarProductType, dynamicPriceToken: string) => void;
|
||||||
|
/** APKِ قدیمی؛ فقط برای اینکه بدانیم داخلِ اپیم ولی خرید از بازار در آن ممکن نیست. */
|
||||||
bazaarPurchase?: (sku: string, orderId: string) => void;
|
bazaarPurchase?: (sku: string, orderId: string) => void;
|
||||||
bazaarConsume?: (purchaseToken: string) => void;
|
bazaarConsume?: (purchaseToken: string) => void;
|
||||||
/** اولین صفحهی واقعی کشیده شد؛ اسپلشِ سیستم میتواند برود (APKهای قدیمیتر این را ندارند). */
|
/** اولین صفحهی واقعی کشیده شد؛ اسپلشِ سیستم میتواند برود (APKهای قدیمیتر این را ندارند). */
|
||||||
@@ -43,17 +48,23 @@ export class BazaarPurchaseError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inBazaarApp(): boolean {
|
/** داخلِ اپِ اندرویدِ تدریسینوییم (هر نسخهای). */
|
||||||
return typeof window.TadrisinoAndroid?.bazaarPurchase === 'function';
|
export function inAndroidApp(): boolean {
|
||||||
|
return typeof window.TadrisinoAndroid === 'object' && window.TadrisinoAndroid !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** اپی که میتواند از بازار بخرد (بسته و اشتراک). */
|
||||||
|
export function canBuyFromBazaar(): boolean {
|
||||||
|
return typeof window.TadrisinoAndroid?.bazaarBuy === 'function';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** صفحهی خریدِ بازار ممکن است طول بکشد (ورود به بازار، کارتِ بانکی)؛ این سقف فقط جلوی گیر کردنِ همیشگی را میگیرد. */
|
/** صفحهی خریدِ بازار ممکن است طول بکشد (ورود به بازار، کارتِ بانکی)؛ این سقف فقط جلوی گیر کردنِ همیشگی را میگیرد. */
|
||||||
const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000;
|
const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
/** خرید را به اپِ اندروید میسپارد و purchaseToken را برمیگرداند. */
|
/** خرید را به اپِ اندروید میسپارد و purchaseToken را برمیگرداند. */
|
||||||
export function bazaarPurchase(sku: string, orderId: string): Promise<string> {
|
export function bazaarBuy(sku: string, orderId: string, productType: BazaarProductType, dynamicPriceToken: string | null): Promise<string> {
|
||||||
const bridge = window.TadrisinoAndroid;
|
const bridge = window.TadrisinoAndroid;
|
||||||
if (!bridge?.bazaarPurchase) 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) => {
|
||||||
const timer = window.setTimeout(() => finish(() => reject(new BazaarPurchaseError('timeout'))), PURCHASE_TIMEOUT_MS);
|
const timer = window.setTimeout(() => finish(() => reject(new BazaarPurchaseError('timeout'))), PURCHASE_TIMEOUT_MS);
|
||||||
function finish(settle: () => void) {
|
function finish(settle: () => void) {
|
||||||
@@ -69,7 +80,7 @@ export function bazaarPurchase(sku: string, orderId: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
window.addEventListener('tadrisino:bazaar', onResult);
|
window.addEventListener('tadrisino:bazaar', onResult);
|
||||||
try {
|
try {
|
||||||
bridge.bazaarPurchase!(sku, orderId);
|
bridge.bazaarBuy!(sku, orderId, productType, dynamicPriceToken ?? '');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
finish(() => reject(new BazaarPurchaseError('failed', String(err))));
|
finish(() => reject(new BazaarPurchaseError('failed', String(err))));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Check, ChevronLeft, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag,
|
|||||||
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, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
|
import { BazaarPurchaseError, bazaarBuy, bazaarConsume, bazaarPending, 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 { useBackStep } from '@/nav/Navigator';
|
import { useBackStep } from '@/nav/Navigator';
|
||||||
@@ -34,7 +34,7 @@ type View = 'subscription' | 'pack';
|
|||||||
*
|
*
|
||||||
* چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیجکننده»): مزیتهای اشتراک یک بار بالای فهرست
|
* چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیجکننده»): مزیتهای اشتراک یک بار بالای فهرست
|
||||||
* میآیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ
|
* میآیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ
|
||||||
* انتخاب است با نام و قیمت، و فقط یک دکمهی بزرگ پایینِ صفحه هست. روشِ پرداخت در برگهی پرداخت انتخاب میشود.
|
* انتخاب است با نام و قیمت، و فقط یک دکمهی بزرگ پایینِ صفحه هست. روشِ پرداخت (زرینپال یا کافهبازار) در برگهی پرداخت انتخاب میشود.
|
||||||
*/
|
*/
|
||||||
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
||||||
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
|
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
|
||||||
@@ -42,7 +42,6 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
|||||||
const [view, setView] = useState<View | null>(initialView ?? null);
|
const [view, setView] = useState<View | null>(initialView ?? null);
|
||||||
const [picked, setPicked] = useState<string | null>(null);
|
const [picked, setPicked] = useState<string | null>(null);
|
||||||
const [buying, setBuying] = useState<Plan | null>(null);
|
const [buying, setBuying] = useState<Plan | null>(null);
|
||||||
const bazaar = inBazaarApp();
|
|
||||||
const look = useCompanion();
|
const look = useCompanion();
|
||||||
// از فهرست، «برگشت» اول به دو گزینه برمیگردد.
|
// از فهرست، «برگشت» اول به دو گزینه برمیگردد.
|
||||||
useBackStep(view !== null && !initialView, () => setView(null));
|
useBackStep(view !== null && !initialView, () => setView(null));
|
||||||
@@ -127,7 +126,7 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
|
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -259,28 +258,46 @@ function PlanRow({ plan, monthlyPrice, selected, onSelect }: { plan: Plan; month
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Method = 'gateway' | 'bazaar';
|
||||||
|
|
||||||
|
/** چرا بازار برای این پلن و اینجا ممکن نیست؛ null یعنی ممکن است. */
|
||||||
|
function bazaarBlocker(plan: Plan | null): string | null {
|
||||||
|
if (!plan?.bazaarSku) return 'این مورد در بازار نیست';
|
||||||
|
if (!inAndroidApp()) return 'فقط در اپِ اندروید';
|
||||||
|
if (!canBuyFromBazaar()) return 'اپ رو از بازار بهروز کن';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* خرید: سفارش ساخته میشود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت میشود،
|
* خرید: سفارش ساخته میشود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت میشود، و بعد کاربر به درگاه یا به
|
||||||
* و بعد کاربر در همین برگه به درگاه میرود. window.open بعد از await را
|
* صفحهی خریدِ بازار میرود. window.open بعد از await را Safariِ آیفون مسدود میکند؛ برای همین درگاه
|
||||||
* Safariِ آیفون مسدود میکند؛ برای همین جابهجاییِ همین صفحه است.
|
* جابهجاییِ همین صفحه است.
|
||||||
|
*
|
||||||
|
* دو روشِ پرداخت همیشه کنارِ هم دیده میشوند (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ اگر بازار اینجا ممکن نیست، کاشیِ
|
||||||
|
* آن غیرفعال است و دلیلش را زیرِ نامش میگوید (نه پنهان، تا کاربر بداند راهِ دیگری هم هست). داخلِ اپی که
|
||||||
|
* از بازار میخرد پیشفرض بازار است. ⚠️ قانونِ کافهبازار پرداختِ محتوای دیجیتال بیرون از پرداختِ
|
||||||
|
* درونبرنامهای را محدود میکند؛ نگه داشتنِ زرینپال داخلِ اپ تصمیمِ مالک است و با بازار هماهنگ نشده.
|
||||||
*/
|
*/
|
||||||
function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) {
|
function CheckoutSheet({ plan, onClose, onPaid }: { plan: Plan | null; onClose: () => void; onPaid: () => void }) {
|
||||||
// داخلِ اپِ بازار دو راه هست (خواستِ مالک، ۱۴۰۵-۰۷): درگاهِ بانکی پیشفرض است، بازار فقط برای بستهای که SKU دارد.
|
const blocker = bazaarBlocker(plan);
|
||||||
// ⚠️ قانونِ کافهبازار پرداختِ محتوای دیجیتال بیرون از پرداختِ درونبرنامهای را محدود میکند؛ این انتخاب تصمیمِ مالک است
|
const [method, setMethod] = useState<Method>('gateway');
|
||||||
// و با بازار هماهنگ نشده.
|
|
||||||
const canBazaar = bazaar && !!plan?.bazaarSku;
|
|
||||||
const [method, setMethod] = useState<'gateway' | 'bazaar'>('gateway');
|
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [showCode, setShowCode] = useState(false);
|
const [showCode, setShowCode] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
|
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
|
||||||
|
const isSubscription = plan?.kind === 'subscription';
|
||||||
|
|
||||||
|
// هر بار که برگه برای پلنی باز میشود: داخلِ اپِ بازار، بازار پیشفرض است.
|
||||||
|
useEffect(() => {
|
||||||
|
if (plan) setMethod(bazaarBlocker(plan) ? 'gateway' : 'bazaar');
|
||||||
|
}, [plan]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید.
|
* بازار: سفارش (با کلیدِ ثابتِ همین SKU) → کدِ تخفیف → شروع (SKU، نوع، قیمتِ امضاشده) → خرید در بازار
|
||||||
* کدِ تخفیف اینجا نیست: بازار قیمتِ ثابتِ محصول را میگیرد و سرور تخفیف را رد میکند.
|
* → سنجشِ سرور → مصرفِ خرید (فقط بسته؛ اشتراک مصرف نمیشود).
|
||||||
*/
|
*/
|
||||||
async function payWithBazaar(plan: Plan) {
|
async function payWithBazaar(plan: Plan): Promise<'bazaar' | 'code' | null> {
|
||||||
const sku = plan.bazaarSku;
|
const sku = plan.bazaarSku;
|
||||||
let key = bazaarPending.get(sku);
|
let key = bazaarPending.get(sku);
|
||||||
if (key) {
|
if (key) {
|
||||||
@@ -295,27 +312,40 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
|||||||
bazaarPending.set(sku, key);
|
bazaarPending.set(sku, key);
|
||||||
}
|
}
|
||||||
const order = await api.subscription.purchaseBazaar(plan.id, key);
|
const order = await api.subscription.purchaseBazaar(plan.id, key);
|
||||||
|
if (code.trim()) {
|
||||||
|
const current = await api.subscription.order(order.orderId);
|
||||||
|
// سفارشِ مانده از تلاشِ قبلی ممکن است همین کد را از قبل گرفته باشد.
|
||||||
|
if (current.amount === plan.price) {
|
||||||
|
const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
|
||||||
|
if (redeemed.orderStatus === 'paid') {
|
||||||
|
bazaarPending.clear(sku);
|
||||||
|
return 'code';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const start = await api.subscription.startBazaar(order.orderId);
|
||||||
let token: string;
|
let token: string;
|
||||||
try {
|
try {
|
||||||
token = await bazaarPurchase(order.bazaarSku || sku, order.orderId);
|
token = await bazaarBuy(start.sku, order.orderId, start.productType, start.dynamicPriceToken);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return false;
|
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return null;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
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);
|
||||||
bazaarConsume(token);
|
if (start.productType === 'inapp') bazaarConsume(token);
|
||||||
return true;
|
return 'bazaar';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pay() {
|
async function pay() {
|
||||||
if (!plan || busy) return;
|
if (!plan || busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
if (canBazaar && method === 'bazaar') {
|
if (method === 'bazaar') {
|
||||||
try {
|
try {
|
||||||
if (await payWithBazaar(plan)) setDone('bazaar');
|
const outcome = await payWithBazaar(plan);
|
||||||
|
if (outcome) setDone(outcome);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
|
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
|
||||||
}
|
}
|
||||||
@@ -336,8 +366,8 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
|||||||
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
|
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
|
||||||
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
|
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
|
||||||
const shell = (window as unknown as { TadrisinoAndroid?: { paysInApp?: () => boolean } }).TadrisinoAndroid;
|
const shell = (window as unknown as { TadrisinoAndroid?: { paysInApp?: () => boolean } }).TadrisinoAndroid;
|
||||||
if (bazaar && !shell?.paysInApp?.()) {
|
if (inAndroidApp() && !shell?.paysInApp?.()) {
|
||||||
// اپِ اندرویدِ فعلی هر آدرسِ بیرونی را در مرورگرِ گوشی باز میکند و آن مرورگر Referer نمیفرستد؛ زرینپال
|
// اپِ اندرویدِ قدیمی هر آدرسِ بیرونی را در مرورگرِ گوشی باز میکند و آن مرورگر Referer نمیفرستد؛ زرینپال
|
||||||
// بیReferer میگوید «دسترسی از این دامنه مجاز نمیباشد». پس اول pay.htmlِ خودمان در مرورگر باز میشود
|
// بیReferer میگوید «دسترسی از این دامنه مجاز نمیباشد». پس اول pay.htmlِ خودمان در مرورگر باز میشود
|
||||||
// (src/pay.ts) و آن به درگاه میرود. «http://» عمداً است: WebView فقط httpsِ همین دامنه را در خودش نگه
|
// (src/pay.ts) و آن به درگاه میرود. «http://» عمداً است: WebView فقط httpsِ همین دامنه را در خودش نگه
|
||||||
// میدارد؛ آروان بلافاصله به https میبرد و لینک در fragment است، پس به سرور نمیرسد.
|
// میدارد؛ آروان بلافاصله به https میبرد و لینک در fragment است، پس به سرور نمیرسد.
|
||||||
@@ -360,18 +390,19 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
|||||||
setShowCode(false);
|
setShowCode(false);
|
||||||
setError('');
|
setError('');
|
||||||
setDone(null);
|
setDone(null);
|
||||||
setMethod('gateway');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const doneText =
|
||||||
|
done === 'gateway' ? 'صفحهی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همینجا؛ خریدت خودش فعال میشه.'
|
||||||
|
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
|
||||||
|
: isSubscription ? 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش میکنه؛ لغوِ تمدید از بخشِ «اشتراکها»ی خودِ بازاره.'
|
||||||
|
: 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? (plan.kind === 'pack' ? `${toFa(plan.questionCredits)} سوال` : `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}`) : ''}>
|
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? (plan.kind === 'pack' ? `${toFa(plan.questionCredits)} سوال` : `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}`) : ''}>
|
||||||
{done ? (
|
{done ? (
|
||||||
<>
|
<>
|
||||||
<p className="mb-5 text-sm leading-7 text-muted">
|
<p className="mb-5 text-sm leading-7 text-muted">{doneText}</p>
|
||||||
{done === 'gateway'
|
|
||||||
? 'صفحهی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همینجا؛ خریدت خودش فعال میشه.'
|
|
||||||
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
|
|
||||||
</p>
|
|
||||||
<PrimaryButton onClick={close}>{done === 'gateway' ? 'باشه' : 'عالیه!'}</PrimaryButton>
|
<PrimaryButton onClick={close}>{done === 'gateway' ? 'باشه' : 'عالیه!'}</PrimaryButton>
|
||||||
</>
|
</>
|
||||||
) : plan && (
|
) : plan && (
|
||||||
@@ -380,14 +411,14 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
|||||||
<span className="text-sm font-bold text-body">مبلغ</span>
|
<span className="text-sm font-bold text-body">مبلغ</span>
|
||||||
<span className="text-lg font-black text-ink">{price(plan.price)}</span>
|
<span className="text-lg font-black text-ink">{price(plan.price)}</span>
|
||||||
</div>
|
</div>
|
||||||
{canBazaar && (
|
<div role="radiogroup" aria-label="روشِ پرداخت" className="mb-4">
|
||||||
<div role="radiogroup" aria-label="روشِ پرداخت" className="mb-4 flex flex-col gap-2">
|
<span className="mb-2 block text-sm font-black text-ink">با چی پرداخت میکنی؟</span>
|
||||||
<span className="text-sm font-black text-ink">با چی پرداخت میکنی؟</span>
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<MethodRow selected={method === 'gateway'} onSelect={() => setMethod('gateway')} icon={<CreditCard size={20} />} title="کارتِ بانکی" note="درگاهِ امنِ زرینپال" />
|
<MethodTile selected={method === 'gateway'} onSelect={() => setMethod('gateway')} icon={<CreditCard size={22} />} title="کارتِ بانکی" note="زرینپال" />
|
||||||
<MethodRow selected={method === 'bazaar'} onSelect={() => setMethod('bazaar')} icon={<ShoppingBag size={20} />} title="کافهبازار" note="با حسابِ بازارت" />
|
<MethodTile selected={method === 'bazaar'} onSelect={() => setMethod('bazaar')} icon={<ShoppingBag size={22} />} title="کافهبازار" note={blocker ?? 'با حسابِ بازارت'} disabled={!!blocker} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{method === 'gateway' && (showCode ? (
|
{showCode ? (
|
||||||
<label className="mb-4 block">
|
<label className="mb-4 block">
|
||||||
<span className="mb-2 block text-sm font-bold text-body">کدِ تخفیف</span>
|
<span className="mb-2 block text-sm font-bold text-body">کدِ تخفیف</span>
|
||||||
<input
|
<input
|
||||||
@@ -403,37 +434,45 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
|||||||
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex items-center gap-1.5 text-sm font-bold text-coral">
|
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex items-center gap-1.5 text-sm font-bold text-coral">
|
||||||
<Ticket size={15} /> کدِ تخفیف دارم
|
<Ticket size={15} /> کدِ تخفیف دارم
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
|
|
||||||
<PrimaryButton loading={busy} onClick={() => void pay()}>پرداخت</PrimaryButton>
|
|
||||||
{method === 'gateway' && (
|
|
||||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
|
|
||||||
<ShieldCheck size={14} /> پرداخت در درگاهِ امنِ زرینپال انجام میشه.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
|
||||||
|
<PrimaryButton loading={busy} onClick={() => void pay()}>
|
||||||
|
{method === 'bazaar' ? 'پرداخت با کافهبازار' : 'پرداخت با کارتِ بانکی'}
|
||||||
|
</PrimaryButton>
|
||||||
|
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
|
||||||
|
<ShieldCheck size={14} />
|
||||||
|
{method === 'bazaar'
|
||||||
|
? isSubscription ? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید میشه.' : 'پرداخت در خودِ بازار انجام میشه.'
|
||||||
|
: 'پرداخت در درگاهِ امنِ زرینپال انجام میشه.'}
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MethodRow({ selected, onSelect, icon, title, note }: { selected: boolean; onSelect: () => void; icon: ReactNode; title: string; note: string }) {
|
function MethodTile({ selected, onSelect, icon, title, note, disabled = false }: { selected: boolean; onSelect: () => void; icon: ReactNode; title: string; note: string; disabled?: boolean }) {
|
||||||
|
const on = selected && !disabled;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={selected}
|
aria-checked={on}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
disabled={disabled}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
className={`flex items-center gap-3 rounded-2xl border-2 px-4 py-3 text-right ${selected ? 'border-coral bg-[#fff4f2]' : 'border-line bg-white'}`}
|
className={`relative flex flex-col items-center gap-1 rounded-2xl border-2 border-b-4 px-2 py-3 text-center transition-colors ${
|
||||||
|
disabled ? 'cursor-not-allowed border-line bg-soft opacity-70' : on ? 'border-coral bg-[#fff4f2]' : 'border-line bg-white active:translate-y-0.5'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border-2 ${selected ? 'border-coral bg-coral' : 'border-[#d6d3ea]'}`}>
|
{on && (
|
||||||
{selected && <Check size={12} strokeWidth={4} className="text-white" />}
|
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-coral text-white">
|
||||||
</span>
|
<Check size={12} strokeWidth={4} />
|
||||||
<span className={selected ? 'text-coral' : 'text-muted'}>{icon}</span>
|
|
||||||
<span className="flex-1">
|
|
||||||
<span className="block text-[15px] font-black text-ink">{title}</span>
|
|
||||||
<span className="block text-xs text-muted">{note}</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={on ? 'text-coral' : 'text-muted'}>{icon}</span>
|
||||||
|
<span className="text-[15px] font-black text-ink">{title}</span>
|
||||||
|
<span className="text-xs leading-5 text-muted">{note}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user