import { store } from './storage'; /** * پلِ پرداختِ کافه‌بازار، وقتی همین سایت داخلِ اپِ اندرویدِ تدریسینو (WebView) باز است. * * خریدِ درون‌برنامه‌ایِ بازار فقط از کدِ بومیِ اندروید (کتابخانه‌ی Poolakey) شدنی است، پس * اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه می‌گذارد و این فایل تنها * جایی است که با آن حرف می‌زند. در مرورگرِ معمولی آن شیء نیست و خرید با زرین‌پال است. * * قرارداد (سمتِ اندروید: `android/` در همین ریپو): * JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken) * 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? } * * `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; bazaarPrices?: (skusJson: string, productType: BazaarProductType) => void; /** اولین صفحه‌ی واقعی کشیده شد؛ اسپلشِ سیستم می‌تواند برود (APKهای قدیمی‌تر این را ندارند). */ appReady?: () => void; } declare global { interface Window { TadrisinoAndroid?: AndroidBridge; } } type BridgeResult = | { orderId: string; purchaseToken: string } | { orderId: string; error: 'canceled' | 'failed'; message?: string }; export class BazaarPurchaseError extends Error { constructor(readonly kind: 'canceled' | 'failed' | 'timeout', message = '') { super(message || kind); } } /** داخلِ اپِ اندرویدِ تدریسینوییم (هر نسخه‌ای). */ 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 bazaarBuy(sku: string, orderId: string, productType: BazaarProductType, dynamicPriceToken: string | null): Promise { const bridge = window.TadrisinoAndroid; 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) { window.clearTimeout(timer); window.removeEventListener('tadrisino:bazaar', onResult); settle(); } function onResult(event: Event) { const detail = (event as CustomEvent).detail; if (!detail || detail.orderId !== orderId) return; if ('purchaseToken' in detail && detail.purchaseToken) finish(() => resolve(detail.purchaseToken)); else if ('error' in detail) finish(() => reject(new BazaarPurchaseError(detail.error, detail.message))); } window.addEventListener('tadrisino:bazaar', onResult); try { bridge.bazaarBuy!(sku, orderId, productType, dynamicPriceToken ?? ''); } catch (err) { finish(() => reject(new BazaarPurchaseError('failed', String(err)))); } }); } 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); } catch (err) { // مصرف نشدن یعنی خریدِ دوباره‌ی همین بسته تا مصرفِ بعدی گیر می‌کند؛ اعتبارِ کاربر سالم است. console.error('bazaarConsume failed', err); } } /** * Idempotency-Keyِ خریدِ نیمه‌تمامِ هر SKU. اگر بازار پول را گرفت ولی تأییدِ سرور نرسید (قطعِ * اینترنت)، تلاشِ بعدی باید همان سفارش را بگیرد؛ سفارشِ تازه با توکنِ قبلی تأیید نمی‌شد چون هر * توکن فقط به یک سفارش می‌خورد. */ const pendingKey = (sku: string) => `tadrisino.bazaarPending.${sku}`; export const bazaarPending = { get: (sku: string) => store.get(pendingKey(sku)), set: (sku: string, key: string) => store.set(pendingKey(sku), key), clear: (sku: string) => store.remove(pendingKey(sku)), };