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` | صداهای رابط بعد از پاسخِ سرور پخش میشوند، نه در لحظهی لمس | صدای جوابِ درست/غلط و پایانِ تمرین |
|
||||
| `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/` سفارشِ بازار «پرداختشده». یک بار هم «انصراف» در صفحهی بازار |
|
||||
| صفحهی بیاتصال: `onReceivedError`/`onReceivedHttpError` ← همان تصویر + «اینترنت قطع است / کند است؛ در حال اتصال…»، تلاشِ دوباره هر ۵ ثانیه، و مجوزِ `ACCESS_NETWORK_STATE` | کاربر هرگز صفحهی خطای WebView یا متنِ فنی نمیبیند. اتصالِ کند (بیخطا) عمداً هیچ متنِ اضافه نمیآورد و روی اسپلش میماند | هواپیما روشن ← باز کردنِ اپ ← خاموش کردن: باید خودش وصل شود. سرورِ ۵۰۲ (مثلاً nginx خاموش) هم همین صفحه. اگر صفحه لود شد ولی ۱۵ ثانیه `appReady` نیامد، لاگِ ERROR و رهاشدنِ اسپلش (باگِ JS) |
|
||||
|
||||
**اسپلش و بیاینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصبشده امتحان شد).** چیزی که روی گوشی دیده میشود
|
||||
@@ -47,16 +48,19 @@
|
||||
ساخت روی مکِ مالک: SDK در `~/Library/Android/sdk`، JDK 17 (`/opt/homebrew/opt/openjdk@17`)، و چون Google Maven از
|
||||
ایران بیVPN ۴۰۴ میدهد، `~/.gradle/init.d/google-mirror.gradle` آینهی Aliyun را اضافه میکند؛ ساختِ اول ~۹ دقیقه.
|
||||
|
||||
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
|
||||
## پیش از build — آنچه از پیشخانِ بازار لازم است
|
||||
|
||||
| چه | کجا گذاشته میشود | وضعیت |
|
||||
| چه | کجا گذاشته میشود | وضعیت (۲۰۲۶-۱۰-۱۲) |
|
||||
|---|---|---|
|
||||
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app` (APKِ ساختهشده)؛ ثبتش در پیشخان تأیید نشده |
|
||||
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | ندارد؛ بدونِ آن خرید شروع نمیشود |
|
||||
| SKUِ هر بسته (محصولِ **مصرفشدنی**) | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچکدام تعریف نشده |
|
||||
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app`؛ بازار محصولهای ۱۰۳۴۲–۱۰۳۴۴ را زیرِ همین نام شناخت (API، توکنِ ساختگی) |
|
||||
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | گذاشته شد |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -64,13 +64,15 @@ android {
|
||||
applicationId = "com.tadrisino.math.app"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
// بازار نسخهی تازه را فقط با versionCodeِ بزرگتر میپذیرد؛ هر build که منتشر میشود یکی بالا.
|
||||
versionCode = 2
|
||||
versionName = "1.1.0"
|
||||
|
||||
// URL of the web frontend.
|
||||
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.
|
||||
buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
|
||||
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (پرداخت درونبرنامهای)؛ سینیور، ۲۰۲۶-۱۰-۱۲. عمومی است و در APK
|
||||
// میرود؛ Poolakey با آن امضای خرید را روی گوشی میسنجد. سنجشِ اصلی سمتِ سرور است.
|
||||
buildConfigField("String", "BAZAAR_RSA_KEY", "\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwC9EBw0RW6jeTdHkrJwfB/mX+klpjpVXVb2stpGDcixeqHiLE+/Iow+PiMbZEQfxZDuED6ELvZlzQX+ALOlGRPxaa1ctl6/Uy6wGaPXhGPDpZL6jPrXV88mjJzMcEoB4sf3yNb+6YNKrvKhRyXXR0JdMUmPMqMe8igmQZTv/UF+QEMNqv7lg7qw9Yl+fG802nqjasft7qUPQK+YHJYPS4PJ5Pj/YpTkGDdDmVi7AIMCAwEAAQ==\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
|
||||
@@ -41,7 +41,7 @@ import org.json.JSONObject
|
||||
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درونبرنامهایِ بازار است.
|
||||
*
|
||||
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
|
||||
* JS → اینجا TadrisinoAndroid.bazaarPurchase(sku, orderId)
|
||||
* JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)
|
||||
* TadrisinoAndroid.bazaarConsume(purchaseToken)
|
||||
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
|
||||
* { 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
|
||||
if (p == null) {
|
||||
send(orderId, error = "failed", message = "bazaar not connected")
|
||||
return
|
||||
}
|
||||
p.purchaseProduct(
|
||||
registry = activityResultRegistry,
|
||||
request = PurchaseRequest(productId = sku, payload = orderId),
|
||||
) {
|
||||
val subscription = productType == "subscription"
|
||||
val request = PurchaseRequest(
|
||||
productId = sku, payload = orderId, dynamicPriceToken = dynamicPriceToken.ifEmpty { null },
|
||||
)
|
||||
val callback: ir.cafebazaar.poolakey.callback.PurchaseCallback.() -> Unit = {
|
||||
purchaseSucceed { send(orderId, token = it.purchaseToken) }
|
||||
purchaseCanceled { send(orderId, error = "canceled") }
|
||||
purchaseFailed { recoverOwned(sku, orderId, it) }
|
||||
failedToBeginFlow { recoverOwned(sku, orderId, it) }
|
||||
purchaseFailed { recoverOwned(sku, orderId, subscription, it) }
|
||||
failedToBeginFlow { recoverOwned(sku, orderId, subscription, it) }
|
||||
}
|
||||
if (subscription) p.subscribeProduct(activityResultRegistry, request, callback)
|
||||
else p.purchaseProduct(activityResultRegistry, request, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* خریدِ قبلیِ همین محصول که مصرف نشده (پول رفته ولی تأییدِ سرور نرسیده بود) جلوی خریدِ
|
||||
* دوباره را میگیرد. فرانت در تلاشِ بعدی همان سفارش را میفرستد؛ اگر توکنِ ماندهٔ همان
|
||||
* خریدِ قبلیِ همین محصول (بستهی مصرفنشده، یا اشتراکِ فعال) که پولش رفته ولی تأییدِ سرور نرسیده بود
|
||||
* جلوی خریدِ دوباره را میگیرد. فرانت در تلاشِ بعدی همان سفارش را میفرستد؛ اگر توکنِ ماندهٔ همان
|
||||
* سفارش (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)
|
||||
p.getPurchasedProducts {
|
||||
val query: ir.cafebazaar.poolakey.callback.PurchaseQueryCallback.() -> Unit = {
|
||||
querySucceed { owned ->
|
||||
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
|
||||
if (match != null) send(orderId, token = match.purchaseToken)
|
||||
@@ -250,6 +258,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
queryFailed { send(orderId, error = "failed", message = cause.message) }
|
||||
}
|
||||
if (subscription) p.getSubscribedProducts(query) else p.getPurchasedProducts(query)
|
||||
}
|
||||
|
||||
private fun consume(purchaseToken: String) {
|
||||
@@ -273,11 +282,12 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
/** متدهای این کلاس روی threadِ WebView صدا زده میشوند، نه UI. */
|
||||
private inner class Bridge {
|
||||
/** قراردادش در src/lib/bazaar.tsِ فرانت است. */
|
||||
@JavascriptInterface
|
||||
fun bazaarPurchase(sku: String, orderId: String) {
|
||||
fun bazaarBuy(sku: String, orderId: String, productType: String, dynamicPriceToken: String) {
|
||||
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) =>
|
||||
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 را سرور از خودِ بازار میسنجد؛ گفتهی اپ کافی نیست. */
|
||||
verifyBazaar: (orderId: string, purchaseToken: string) =>
|
||||
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),
|
||||
|
||||
@@ -287,7 +287,7 @@ export interface Plan {
|
||||
questionCredits: number;
|
||||
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
|
||||
heartsDays: number;
|
||||
/** SKUِ همین بسته در کافهبازار؛ خالی یعنی داخلِ اپِ بازار خریدنی نیست. */
|
||||
/** SKUِ همین پلن (بسته یا اشتراک) در کافهبازار؛ خالی یعنی از بازار خریدنی نیست. */
|
||||
bazaarSku: string;
|
||||
}
|
||||
|
||||
@@ -327,6 +327,19 @@ 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 یعنی بازار قیمتِ پیشخان را میگیرد. */
|
||||
dynamicPriceToken: string | null;
|
||||
}
|
||||
|
||||
export interface BazaarVerifyResponse {
|
||||
|
||||
@@ -5,22 +5,27 @@ import { store } from './storage';
|
||||
*
|
||||
* خریدِ درونبرنامهایِ بازار فقط از کدِ بومیِ اندروید (کتابخانهی Poolakey) شدنی است، پس
|
||||
* اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه میگذارد و این فایل تنها
|
||||
* جایی است که با آن حرف میزند. در مرورگرِ معمولی آن شیء نیست، `inBazaarApp()` false است و
|
||||
* خرید مثلِ قبل به زرینپال میرود.
|
||||
* جایی است که با آن حرف میزند. در مرورگرِ معمولی آن شیء نیست و خرید با زرینپال است.
|
||||
*
|
||||
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
|
||||
* JS → اندروید TadrisinoAndroid.bazaarPurchase(sku, orderId) خرید را شروع کن؛ orderId همان
|
||||
* developerPayloadِ خرید است.
|
||||
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) بعد از تأییدِ سرور؛ بدونِ آن
|
||||
* همان بسته دوباره خریدنی نیست.
|
||||
* JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)
|
||||
* productType «inapp» (بسته، purchaseProduct) یا «subscription» (subscribeProduct)؛
|
||||
* orderId همان payloadِ خرید است؛ dynamicPriceToken رشتهی خالی یعنی قیمتِ پیشخان.
|
||||
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) فقط بسته، بعد از تأییدِ سرور.
|
||||
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
|
||||
* { orderId, purchaseToken } خرید انجام شد
|
||||
* { orderId, error: 'canceled' | 'failed', message? }
|
||||
*
|
||||
* هیچکدام از اینها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۰۳): اپِ اندروید ساخته نشده.
|
||||
* `bazaarBuy` از APKهای پس از ۲۰۲۶-۱۰-۱۲ است. APKهای قبلی کلیدِ RSA نداشتند و Poolakey در آنها خاموش بود، پس از
|
||||
* بازار هیچ خریدی نکردهاند؛ در آنها کاشیِ بازار غیرفعال است و «اپ را بهروز کن» میگوید.
|
||||
* هیچکدام از اینها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۱۲).
|
||||
*/
|
||||
|
||||
export type BazaarProductType = 'inapp' | 'subscription';
|
||||
|
||||
interface AndroidBridge {
|
||||
bazaarBuy?: (sku: string, orderId: string, productType: BazaarProductType, dynamicPriceToken: string) => void;
|
||||
/** APKِ قدیمی؛ فقط برای اینکه بدانیم داخلِ اپیم ولی خرید از بازار در آن ممکن نیست. */
|
||||
bazaarPurchase?: (sku: string, orderId: string) => void;
|
||||
bazaarConsume?: (purchaseToken: string) => void;
|
||||
/** اولین صفحهی واقعی کشیده شد؛ اسپلشِ سیستم میتواند برود (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;
|
||||
|
||||
/** خرید را به اپِ اندروید میسپارد و 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;
|
||||
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) => {
|
||||
const timer = window.setTimeout(() => finish(() => reject(new BazaarPurchaseError('timeout'))), PURCHASE_TIMEOUT_MS);
|
||||
function finish(settle: () => void) {
|
||||
@@ -69,7 +80,7 @@ export function bazaarPurchase(sku: string, orderId: string): Promise<string> {
|
||||
}
|
||||
window.addEventListener('tadrisino:bazaar', onResult);
|
||||
try {
|
||||
bridge.bazaarPurchase!(sku, orderId);
|
||||
bridge.bazaarBuy!(sku, orderId, productType, dynamicPriceToken ?? '');
|
||||
} catch (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 { daysLeft } from '@/lib/subscription';
|
||||
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 { Sheet } from '@/components/Sheet';
|
||||
import { useBackStep } from '@/nav/Navigator';
|
||||
@@ -34,7 +34,7 @@ type View = 'subscription' | 'pack';
|
||||
*
|
||||
* چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیجکننده»): مزیتهای اشتراک یک بار بالای فهرست
|
||||
* میآیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ
|
||||
* انتخاب است با نام و قیمت، و فقط یک دکمهی بزرگ پایینِ صفحه هست. روشِ پرداخت در برگهی پرداخت انتخاب میشود.
|
||||
* انتخاب است با نام و قیمت، و فقط یک دکمهی بزرگ پایینِ صفحه هست. روشِ پرداخت (زرینپال یا کافهبازار) در برگهی پرداخت انتخاب میشود.
|
||||
*/
|
||||
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
||||
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 [picked, setPicked] = useState<string | null>(null);
|
||||
const [buying, setBuying] = useState<Plan | null>(null);
|
||||
const bazaar = inBazaarApp();
|
||||
const look = useCompanion();
|
||||
// از فهرست، «برگشت» اول به دو گزینه برمیگردد.
|
||||
useBackStep(view !== null && !initialView, () => setView(null));
|
||||
@@ -127,7 +126,7 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 را
|
||||
* Safariِ آیفون مسدود میکند؛ برای همین جابهجاییِ همین صفحه است.
|
||||
* خرید: سفارش ساخته میشود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت میشود، و بعد کاربر به درگاه یا به
|
||||
* صفحهی خریدِ بازار میرود. window.open بعد از await را Safariِ آیفون مسدود میکند؛ برای همین درگاه
|
||||
* جابهجاییِ همین صفحه است.
|
||||
*
|
||||
* دو روشِ پرداخت همیشه کنارِ هم دیده میشوند (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ اگر بازار اینجا ممکن نیست، کاشیِ
|
||||
* آن غیرفعال است و دلیلش را زیرِ نامش میگوید (نه پنهان، تا کاربر بداند راهِ دیگری هم هست). داخلِ اپی که
|
||||
* از بازار میخرد پیشفرض بازار است. ⚠️ قانونِ کافهبازار پرداختِ محتوای دیجیتال بیرون از پرداختِ
|
||||
* درونبرنامهای را محدود میکند؛ نگه داشتنِ زرینپال داخلِ اپ تصمیمِ مالک است و با بازار هماهنگ نشده.
|
||||
*/
|
||||
function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) {
|
||||
// داخلِ اپِ بازار دو راه هست (خواستِ مالک، ۱۴۰۵-۰۷): درگاهِ بانکی پیشفرض است، بازار فقط برای بستهای که SKU دارد.
|
||||
// ⚠️ قانونِ کافهبازار پرداختِ محتوای دیجیتال بیرون از پرداختِ درونبرنامهای را محدود میکند؛ این انتخاب تصمیمِ مالک است
|
||||
// و با بازار هماهنگ نشده.
|
||||
const canBazaar = bazaar && !!plan?.bazaarSku;
|
||||
const [method, setMethod] = useState<'gateway' | 'bazaar'>('gateway');
|
||||
function CheckoutSheet({ plan, onClose, onPaid }: { plan: Plan | null; onClose: () => void; onPaid: () => void }) {
|
||||
const blocker = bazaarBlocker(plan);
|
||||
const [method, setMethod] = useState<Method>('gateway');
|
||||
const [code, setCode] = useState('');
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
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;
|
||||
let key = bazaarPending.get(sku);
|
||||
if (key) {
|
||||
@@ -295,27 +312,40 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
||||
bazaarPending.set(sku, 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;
|
||||
try {
|
||||
token = await bazaarPurchase(order.bazaarSku || sku, order.orderId);
|
||||
token = await bazaarBuy(start.sku, order.orderId, start.productType, start.dynamicPriceToken);
|
||||
} catch (err) {
|
||||
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return false;
|
||||
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return null;
|
||||
throw err;
|
||||
}
|
||||
const result = await api.subscription.verifyBazaar(order.orderId, token);
|
||||
if (result.status !== 'paid') throw new Error('bazaar purchase not confirmed');
|
||||
bazaarPending.clear(sku);
|
||||
bazaarConsume(token);
|
||||
return true;
|
||||
if (start.productType === 'inapp') bazaarConsume(token);
|
||||
return 'bazaar';
|
||||
}
|
||||
|
||||
async function pay() {
|
||||
if (!plan || busy) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
if (canBazaar && method === 'bazaar') {
|
||||
if (method === 'bazaar') {
|
||||
try {
|
||||
if (await payWithBazaar(plan)) setDone('bazaar');
|
||||
const outcome = await payWithBazaar(plan);
|
||||
if (outcome) setDone(outcome);
|
||||
} catch (err) {
|
||||
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
|
||||
}
|
||||
@@ -336,8 +366,8 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
||||
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
|
||||
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
|
||||
const shell = (window as unknown as { TadrisinoAndroid?: { paysInApp?: () => boolean } }).TadrisinoAndroid;
|
||||
if (bazaar && !shell?.paysInApp?.()) {
|
||||
// اپِ اندرویدِ فعلی هر آدرسِ بیرونی را در مرورگرِ گوشی باز میکند و آن مرورگر Referer نمیفرستد؛ زرینپال
|
||||
if (inAndroidApp() && !shell?.paysInApp?.()) {
|
||||
// اپِ اندرویدِ قدیمی هر آدرسِ بیرونی را در مرورگرِ گوشی باز میکند و آن مرورگر Referer نمیفرستد؛ زرینپال
|
||||
// بیReferer میگوید «دسترسی از این دامنه مجاز نمیباشد». پس اول pay.htmlِ خودمان در مرورگر باز میشود
|
||||
// (src/pay.ts) و آن به درگاه میرود. «http://» عمداً است: WebView فقط httpsِ همین دامنه را در خودش نگه
|
||||
// میدارد؛ آروان بلافاصله به https میبرد و لینک در fragment است، پس به سرور نمیرسد.
|
||||
@@ -360,18 +390,19 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
||||
setShowCode(false);
|
||||
setError('');
|
||||
setDone(null);
|
||||
setMethod('gateway');
|
||||
};
|
||||
|
||||
const doneText =
|
||||
done === 'gateway' ? 'صفحهی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همینجا؛ خریدت خودش فعال میشه.'
|
||||
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
|
||||
: isSubscription ? 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش میکنه؛ لغوِ تمدید از بخشِ «اشتراکها»ی خودِ بازاره.'
|
||||
: 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.';
|
||||
|
||||
return (
|
||||
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? (plan.kind === 'pack' ? `${toFa(plan.questionCredits)} سوال` : `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}`) : ''}>
|
||||
{done ? (
|
||||
<>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
{done === 'gateway'
|
||||
? 'صفحهی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همینجا؛ خریدت خودش فعال میشه.'
|
||||
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
|
||||
</p>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">{doneText}</p>
|
||||
<PrimaryButton onClick={close}>{done === 'gateway' ? 'باشه' : 'عالیه!'}</PrimaryButton>
|
||||
</>
|
||||
) : 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-lg font-black text-ink">{price(plan.price)}</span>
|
||||
</div>
|
||||
{canBazaar && (
|
||||
<div role="radiogroup" aria-label="روشِ پرداخت" className="mb-4 flex flex-col gap-2">
|
||||
<span className="text-sm font-black text-ink">با چی پرداخت میکنی؟</span>
|
||||
<MethodRow selected={method === 'gateway'} onSelect={() => setMethod('gateway')} icon={<CreditCard size={20} />} title="کارتِ بانکی" note="درگاهِ امنِ زرینپال" />
|
||||
<MethodRow selected={method === 'bazaar'} onSelect={() => setMethod('bazaar')} icon={<ShoppingBag size={20} />} title="کافهبازار" note="با حسابِ بازارت" />
|
||||
<div role="radiogroup" aria-label="روشِ پرداخت" className="mb-4">
|
||||
<span className="mb-2 block text-sm font-black text-ink">با چی پرداخت میکنی؟</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MethodTile selected={method === 'gateway'} onSelect={() => setMethod('gateway')} icon={<CreditCard size={22} />} title="کارتِ بانکی" note="زرینپال" />
|
||||
<MethodTile selected={method === 'bazaar'} onSelect={() => setMethod('bazaar')} icon={<ShoppingBag size={22} />} title="کافهبازار" note={blocker ?? 'با حسابِ بازارت'} disabled={!!blocker} />
|
||||
</div>
|
||||
)}
|
||||
{method === 'gateway' && (showCode ? (
|
||||
</div>
|
||||
{showCode ? (
|
||||
<label className="mb-4 block">
|
||||
<span className="mb-2 block text-sm font-bold text-body">کدِ تخفیف</span>
|
||||
<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">
|
||||
<Ticket size={15} /> کدِ تخفیف دارم
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-checked={on}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
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]'}`}>
|
||||
{selected && <Check size={12} strokeWidth={4} className="text-white" />}
|
||||
</span>
|
||||
<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>
|
||||
{on && (
|
||||
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-coral text-white">
|
||||
<Check size={12} strokeWidth={4} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user