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:
2026-10-12 17:51:03 +03:30
parent 3a2a685ed3
commit fffdeac3ed
7 changed files with 174 additions and 93 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
} }
} }

View File

@@ -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 }),

View File

@@ -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 {

View File

@@ -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))));
} }

View File

@@ -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>
<span className="flex-1"> )}
<span className="block text-[15px] font-black text-ink">{title}</span> <span className={on ? 'text-coral' : 'text-muted'}>{icon}</span>
<span className="block text-xs text-muted">{note}</span> <span className="text-[15px] font-black text-ink">{title}</span>
</span> <span className="text-xs leading-5 text-muted">{note}</span>
</button> </button>
); );
} }