From d440986b88c0ee17a3e102ccbb1f5d3d23a2c465 Mon Sep 17 00:00:00 2001 From: AliReza Date: Mon, 12 Oct 2026 18:21:48 +0330 Subject: [PATCH] Checkout: Zarinpal is the default; Bazaar tile shows the price Bazaar itself has (Poolakey getSkuDetails bridge) Co-Authored-By: Claude Opus 5.5 --- android/README.md | 5 +- .../java/com/tadrisino/app/MainActivity.kt | 47 +++++++++++++++++++ src/lib/bazaar.ts | 44 +++++++++++++++++ src/screens/SubscriptionScreen.tsx | 30 ++++++++---- 4 files changed, 115 insertions(+), 11 deletions(-) diff --git a/android/README.md b/android/README.md index 0787373..f0fd601 100644 --- a/android/README.md +++ b/android/README.md @@ -21,7 +21,7 @@ | `mediaPlaybackRequiresUserGesture = false` | صداهای رابط بعد از پاسخِ سرور پخش می‌شوند، نه در لحظه‌ی لمس | صدای جوابِ درست/غلط و پایانِ تمرین | | `isPaymentPage` + `paysInApp()` | درگاهِ زرین‌پال داخلِ خودِ اپ باز می‌شود. اپِ فعلی آن را به مرورگرِ گوشی می‌دهد که Referer نمی‌فرستد و زرین‌پال بی‌Referer «دسترسی از این دامنه مجاز نمی‌باشد» می‌دهد؛ فعلاً فرانت از راهِ `pay.html` (با `http://`) دورش می‌زند | «پرداخت» با کارتِ بانکی: صفحه‌ی زرین‌پال و بانک داخلِ اپ، و بعد از پرداخت برگشت به اپ با پیامِ «پرداخت موفق بود» (PaymentResult). دکمه‌ی برگشت وسطِ صفحه‌ی بانک چه می‌کند را هم ببین | | اسپلشِ یک‌مرحله‌ای: `installSplashScreen()` + `setKeepOnScreenCondition` + `TadrisinoAndroid.appReady()` (`446cf36`، `ab02bb4`) | اپِ فعلی سه تکه می‌شود: اسپلشِ اندروید ← WebViewِ سفیدِ خالی ← اسپلشِ وب (آیکون + نام) ← خانه. با APKِ تازه اسپلشِ بومی تا اولین صفحه‌ی واقعیِ وب می‌ماند و مستقیم می‌رود روی همان صفحه؛ اسپلشِ وب داخلِ اپ نامرئی است (در APKِ قدیمی فقط آیکونِ بی‌نام). فرانت `appReady` را بعد از اولین صفحه‌ی غیر‌boot صدا می‌زند و APKِ قدیمی آن را ندارد، پس بی‌اثر است | روی گوشی: باز کردنِ اپ با اینترنت باید آیکون ← خانه باشد، بی‌سفیدی و بی‌تصویرِ دوم؛ اندازه‌ی آیکون روی اندروید ۱۲+ (`drawable/splash_icon.xml`: ۲۸۸dp، حاشیه ۲۲٪) | -| خریدِ کافه‌بازار: کلیدِ RSA، `bazaarBuy` با اشتراک (`subscribeProduct`) و بسته، «تخفیف پویا»؛ `versionCode` ۲ | بی‌این‌ها از بازار هیچ خریدی ممکن نیست (APKهای قبلی کلید نداشتند) | داخلِ اپ ← فروشگاه ← اشتراکِ سه‌ماهه: کاشیِ «کافه‌بازار» فعال و پیش‌فرض باشد؛ خرید با حسابِ تست، برگشت با «اشتراکت فعاله»؛ در `/admin/payment/order/` سفارشِ بازار «پرداخت‌شده». یک بار هم «انصراف» در صفحه‌ی بازار | +| خریدِ کافه‌بازار: کلیدِ RSA، `bazaarBuy` با اشتراک (`subscribeProduct`) و بسته، «تخفیف پویا»؛ `versionCode` ۲ | بی‌این‌ها از بازار هیچ خریدی ممکن نیست (APKهای قبلی کلید نداشتند) | داخلِ اپ ← فروشگاه ← اشتراکِ سه‌ماهه: «کارتِ بانکی» پیش‌فرض، کاشیِ «کافه‌بازار» فعال با قیمتِ بازار زیرِ آن؛ خرید با حسابِ تست، برگشت با «اشتراکت فعاله»؛ در `/admin/payment/order/` سفارشِ بازار «پرداخت‌شده». یک بار هم «انصراف» در صفحه‌ی بازار | | صفحه‌ی بی‌اتصال: `onReceivedError`/`onReceivedHttpError` ← همان تصویر + «اینترنت قطع است / کند است؛ در حال اتصال…»، تلاشِ دوباره هر ۵ ثانیه، و مجوزِ `ACCESS_NETWORK_STATE` | کاربر هرگز صفحه‌ی خطای WebView یا متنِ فنی نمی‌بیند. اتصالِ کند (بی‌خطا) عمداً هیچ متنِ اضافه نمی‌آورد و روی اسپلش می‌ماند | هواپیما روشن ← باز کردنِ اپ ← خاموش کردن: باید خودش وصل شود. سرورِ ۵۰۲ (مثلاً nginx خاموش) هم همین صفحه. اگر صفحه لود شد ولی ۱۵ ثانیه `appReady` نیامد، لاگِ ERROR و رهاشدنِ اسپلش (باگِ JS) | **اسپلش و بی‌اینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصب‌شده امتحان شد).** چیزی که روی گوشی دیده می‌شود @@ -54,9 +54,10 @@ |---|---|---| | اسمِ پکیج | `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` در بازار هست ولی معلوم نیست چیست | +| SKUِ هر پلن | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | یک‌ماهه `10342`، سه‌ماهه `10343`، شش‌ماهه `10344`؛ بسته‌ها هنوز در پیشخان تعریف نشده‌اند | | کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور | +قیمت: فرانت `TadrisinoAndroid.bazaarPrices(skusJson, productType)` را صدا می‌زند و قیمتِ پیشخان را روی کاشیِ بازار نشان می‌دهد. خرید: فرانت `TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)` را صدا می‌زند؛ اشتراک با `subscribeProduct` (بی‌consume، بازار خودش تمدید می‌کند)، بسته با `purchaseProduct` و بعد `bazaarConsume`. قیمتِ هر محصول در پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف خورده، سرور قیمتِ کم‌شده را امضا می‌کند. diff --git a/android/app/src/main/java/com/tadrisino/app/MainActivity.kt b/android/app/src/main/java/com/tadrisino/app/MainActivity.kt index d735f68..e21e172 100644 --- a/android/app/src/main/java/com/tadrisino/app/MainActivity.kt +++ b/android/app/src/main/java/com/tadrisino/app/MainActivity.kt @@ -42,6 +42,7 @@ import org.json.JSONObject * * قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت: * JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken) + * TadrisinoAndroid.bazaarPrices(skusJson, productType) → رویدادِ «tadrisino:bazaar-prices» * TadrisinoAndroid.bazaarConsume(purchaseToken) * اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا * { orderId, error: "canceled" | "failed", message } @@ -261,6 +262,36 @@ class MainActivity : AppCompatActivity() { if (subscription) p.getSubscribedProducts(query) else p.getPurchasedProducts(query) } + /** + * قیمتِ ثبت‌شده در پیشخانِ بازار برای هر SKU (متنِ آماده‌ی نمایش، مثلِ «۷۹۸,۰۰۰ تومان»)، تا فرانت روی کاشیِ + * بازار همان را نشان دهد. API سرور-به-سرورِ بازار قیمت نمی‌دهد؛ فقط Poolakey روی گوشی. + * نتیجه: رویدادِ «tadrisino:bazaar-prices» با { productType, prices: { sku: price } } یا { productType, error }. + */ + private fun prices(skus: List, productType: String) { + val p = payment ?: return sendPrices(productType, error = "bazaar not connected") + val callback: ir.cafebazaar.poolakey.callback.GetSkuDetailsCallback.() -> Unit = { + getSkuDetailsSucceed { list -> + val map = JSONObject() + list.forEach { map.put(it.sku, it.price) } + sendPrices(productType, prices = map) + } + getSkuDetailsFailed { sendPrices(productType, error = it.message ?: "failed") } + } + if (productType == "subscription") p.getSubscriptionSkuDetails(skus, callback) + else p.getInAppSkuDetails(skus, callback) + } + + private fun sendPrices(productType: String, prices: JSONObject? = null, error: String? = null) { + val detail = JSONObject().put("productType", productType) + if (prices != null) detail.put("prices", prices) + if (error != null) detail.put("error", error) + runOnUiThread { + webView.evaluateJavascript( + "window.dispatchEvent(new CustomEvent('tadrisino:bazaar-prices', { detail: $detail }));", null, + ) + } + } + private fun consume(purchaseToken: String) { payment?.consumeProduct(purchaseToken) { consumeSucceed { Log.i(TAG, "consumed") } @@ -324,6 +355,22 @@ class MainActivity : AppCompatActivity() { runOnUiThread { hideOffline() } } + /** skusJson آرایه‌ی JSON از SKUها؛ productType «inapp» یا «subscription». */ + @JavascriptInterface + fun bazaarPrices(skusJson: String, productType: String) { + val skus = try { + val arr = JSONArray(skusJson) + List(arr.length()) { i -> arr.getString(i) } + } catch (e: Exception) { + Log.w(TAG, "bad skus", e) + return + } + runOnUiThread { + if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread + prices(skus, productType) + } + } + /** فرانت با این می‌فهمد درگاه داخلِ اپ باز می‌شود (isPaymentPage)، نه در مرورگرِ بیرونی. */ @JavascriptInterface fun paysInApp(): Boolean = true diff --git a/src/lib/bazaar.ts b/src/lib/bazaar.ts index fe5402a..f88cf6c 100644 --- a/src/lib/bazaar.ts +++ b/src/lib/bazaar.ts @@ -12,6 +12,8 @@ import { store } from './storage'; * productType «inapp» (بسته، purchaseProduct) یا «subscription» (subscribeProduct)؛ * orderId همان payloadِ خرید است؛ dynamicPriceToken رشته‌ی خالی یعنی قیمتِ پیشخان. * JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) فقط بسته، بعد از تأییدِ سرور. + * JS → اندروید TadrisinoAndroid.bazaarPrices(skusJson, productType) قیمتِ پیشخان؛ جواب با رویدادِ + * «tadrisino:bazaar-prices» و detail‌ِ { productType, prices: { sku: «۷۹۸,۰۰۰ تومان» } } یا { productType, error } * اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail: * { orderId, purchaseToken } خرید انجام شد * { orderId, error: 'canceled' | 'failed', message? } @@ -28,6 +30,7 @@ interface AndroidBridge { /** APKِ قدیمی؛ فقط برای این‌که بدانیم داخلِ اپیم ولی خرید از بازار در آن ممکن نیست. */ bazaarPurchase?: (sku: string, orderId: string) => void; bazaarConsume?: (purchaseToken: string) => void; + bazaarPrices?: (skusJson: string, productType: BazaarProductType) => void; /** اولین صفحه‌ی واقعی کشیده شد؛ اسپلشِ سیستم می‌تواند برود (APKهای قدیمی‌تر این را ندارند). */ appReady?: () => void; } @@ -87,6 +90,47 @@ export function bazaarBuy(sku: string, orderId: string, productType: BazaarProdu }); } +type PricesResult = { productType: BazaarProductType; prices?: Record; error?: string }; + +function askPrices(skus: string[], productType: BazaarProductType): Promise> { + const bridge = window.TadrisinoAndroid; + if (!bridge?.bazaarPrices || !skus.length) return Promise.resolve({}); + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => done(() => reject(new Error('timeout'))), 8000); + function done(settle: () => void) { + window.clearTimeout(timer); + window.removeEventListener('tadrisino:bazaar-prices', onResult); + settle(); + } + function onResult(event: Event) { + const detail = (event as CustomEvent).detail; + if (!detail || detail.productType !== productType) return; + if (detail.prices) done(() => resolve(detail.prices!)); + else done(() => reject(new Error(detail.error || 'failed'))); + } + window.addEventListener('tadrisino:bazaar-prices', onResult); + bridge.bazaarPrices!(JSON.stringify(skus), productType); + }); +} + +/** + * قیمتِ ثبت‌شده در پیشخانِ بازار برای هر SKU، از خودِ بازار روی گوشی (API سرور قیمت نمی‌دهد). اتصالِ Poolakey + * چند لحظه بعد از باز شدنِ اپ برقرار می‌شود، پس یک بار دوباره امتحان می‌شود. خطا یعنی قیمتی نشان داده نمی‌شود. + */ +export async function bazaarPrices(skus: string[], productType: BazaarProductType): Promise> { + try { + return await askPrices(skus, productType); + } catch { + await new Promise((r) => setTimeout(r, 1500)); + try { + return await askPrices(skus, productType); + } catch (err) { + console.error('bazaarPrices failed', err); + return {}; + } + } +} + export function bazaarConsume(purchaseToken: string) { try { window.TadrisinoAndroid?.bazaarConsume?.(purchaseToken); diff --git a/src/screens/SubscriptionScreen.tsx b/src/screens/SubscriptionScreen.tsx index 09eb0b7..4c5647b 100644 --- a/src/screens/SubscriptionScreen.tsx +++ b/src/screens/SubscriptionScreen.tsx @@ -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, bazaarBuy, bazaarConsume, bazaarPending, canBuyFromBazaar, inAndroidApp } from '@/lib/bazaar'; +import { BazaarPurchaseError, bazaarBuy, bazaarConsume, bazaarPending, bazaarPrices, canBuyFromBazaar, inAndroidApp } from '@/lib/bazaar'; import { useAsync } from '@/lib/useAsync'; import { Sheet } from '@/components/Sheet'; import { useBackStep } from '@/nav/Navigator'; @@ -42,6 +42,8 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) { const [view, setView] = useState(initialView ?? null); const [picked, setPicked] = useState(null); const [buying, setBuying] = useState(null); + // داخلِ اپی که از بازار می‌خرد: قیمتِ پیشخانِ هر SKU، تا کاشیِ بازار همان را بگوید که بازار می‌گیرد. + const [storePrices, setStorePrices] = useState>({}); const look = useCompanion(); // از فهرست، «برگشت» اول به دو گزینه برمی‌گردد. useBackStep(view !== null && !initialView, () => setView(null)); @@ -54,6 +56,15 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) { }, [status.reload]); const all = plans.data?.plans ?? []; + useEffect(() => { + if (!canBuyFromBazaar() || !plans.data) return; + const skus = (kind: Plan['kind']) => plans.data!.plans.filter((p) => p.kind === kind && p.bazaarSku).map((p) => p.bazaarSku); + let alive = true; + void Promise.all([bazaarPrices(skus('subscription'), 'subscription'), bazaarPrices(skus('pack'), 'inapp')]).then( + ([subs, packs]) => alive && setStorePrices({ ...subs, ...packs }), + ); + return () => { alive = false; }; + }, [plans.data]); const list = all.filter((p) => p.kind === view); const monthly = all.find((p) => p.kind === 'subscription' && p.durationDays <= 31); const cheapestSub = all.filter((p) => p.kind === 'subscription').sort((x, y) => x.price - y.price)[0]; @@ -126,7 +137,7 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) { )} - setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} /> + setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} /> ); } @@ -274,11 +285,11 @@ function bazaarBlocker(plan: Plan | null): string | null { * جابه‌جاییِ همین صفحه است. * * دو روشِ پرداخت همیشه کنارِ هم دیده می‌شوند (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ اگر بازار این‌جا ممکن نیست، کاشیِ - * آن غیرفعال است و دلیلش را زیرِ نامش می‌گوید (نه پنهان، تا کاربر بداند راهِ دیگری هم هست). داخلِ اپی که - * از بازار می‌خرد پیش‌فرض بازار است. ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ + * آن غیرفعال است و دلیلش را زیرِ نامش می‌گوید (نه پنهان، تا کاربر بداند راهِ دیگری هم هست). پیش‌فرض زرین‌پال + * است؛ داخلِ اپ، زیرِ «کافه‌بازار» قیمتی می‌آید که خودِ بازار برای آن SKU دارد. ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ * درون‌برنامه‌ای را محدود می‌کند؛ نگه داشتنِ زرین‌پال داخلِ اپ تصمیمِ مالک است و با بازار هماهنگ نشده. */ -function CheckoutSheet({ plan, onClose, onPaid }: { plan: Plan | null; onClose: () => void; onPaid: () => void }) { +function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | null; storePrices: Record; onClose: () => void; onPaid: () => void }) { const blocker = bazaarBlocker(plan); const [method, setMethod] = useState('gateway'); const [code, setCode] = useState(''); @@ -288,10 +299,11 @@ function CheckoutSheet({ plan, onClose, onPaid }: { plan: Plan | null; onClose: const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null); const isSubscription = plan?.kind === 'subscription'; - // هر بار که برگه برای پلنی باز می‌شود: داخلِ اپِ بازار، بازار پیش‌فرض است. + // هر بار که برگه برای پلنی باز می‌شود، زرین‌پال انتخاب است (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ بازار یک لمس آن‌طرف‌تر. useEffect(() => { - if (plan) setMethod(bazaarBlocker(plan) ? 'gateway' : 'bazaar'); + if (plan) setMethod('gateway'); }, [plan]); + const storePrice = plan?.bazaarSku ? storePrices[plan.bazaarSku] : undefined; /** * بازار: سفارش (با کلیدِ ثابتِ همین SKU) → کدِ تخفیف → شروع (SKU، نوع، قیمتِ امضاشده) → خرید در بازار @@ -409,13 +421,13 @@ function CheckoutSheet({ plan, onClose, onPaid }: { plan: Plan | null; onClose: <>
مبلغ - {price(plan.price)} + {method === 'bazaar' && storePrice ? storePrice : price(plan.price)}
با چی پرداخت می‌کنی؟
setMethod('gateway')} icon={} title="کارتِ بانکی" note="زرین‌پال" /> - setMethod('bazaar')} icon={} title="کافه‌بازار" note={blocker ?? 'با حسابِ بازارت'} disabled={!!blocker} /> + setMethod('bazaar')} icon={} title="کافه‌بازار" note={blocker ?? storePrice ?? 'با حسابِ بازارت'} disabled={!!blocker} />
{showCode ? (