forked from Tadrisino/MathApp_frontend
Checkout: Zarinpal is the default; Bazaar tile shows the price Bazaar itself has (Poolakey getSkuDetails bridge)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`. قیمتِ هر
|
||||
محصول در پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف خورده، سرور قیمتِ کمشده را امضا میکند.
|
||||
|
||||
@@ -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<String>, 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
|
||||
|
||||
@@ -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<string, string>; error?: string };
|
||||
|
||||
function askPrices(skus: string[], productType: BazaarProductType): Promise<Record<string, string>> {
|
||||
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<PricesResult>).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<Record<string, string>> {
|
||||
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);
|
||||
|
||||
@@ -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<View | null>(initialView ?? null);
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
const [buying, setBuying] = useState<Plan | null>(null);
|
||||
// داخلِ اپی که از بازار میخرد: قیمتِ پیشخانِ هر SKU، تا کاشیِ بازار همان را بگوید که بازار میگیرد.
|
||||
const [storePrices, setStorePrices] = useState<Record<string, string>>({});
|
||||
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 }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
|
||||
<CheckoutSheet plan={buying} storePrices={storePrices} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>; onClose: () => void; onPaid: () => void }) {
|
||||
const blocker = bazaarBlocker(plan);
|
||||
const [method, setMethod] = useState<Method>('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:
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3.5">
|
||||
<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">{method === 'bazaar' && storePrice ? storePrice : price(plan.price)}</span>
|
||||
</div>
|
||||
<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} />
|
||||
<MethodTile selected={method === 'bazaar'} onSelect={() => setMethod('bazaar')} icon={<ShoppingBag size={22} />} title="کافهبازار" note={blocker ?? storePrice ?? 'با حسابِ بازارت'} disabled={!!blocker} />
|
||||
</div>
|
||||
</div>
|
||||
{showCode ? (
|
||||
|
||||
Reference in New Issue
Block a user