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 3cf064b..86d3cc6 100644
--- a/android/app/src/main/java/com/tadrisino/app/MainActivity.kt
+++ b/android/app/src/main/java/com/tadrisino/app/MainActivity.kt
@@ -48,6 +48,15 @@ class MainActivity : AppCompatActivity() {
/** فقط خودِ سایت و فقط با https؛ «http://دامنهی ما» قابلِ دستکاریِ شبکه است و نباید پل داشته باشد. */
private fun isOurPage(uri: Uri?): Boolean = uri != null && uri.scheme == "https" && uri.host == webHost
+ /** API و درگاه: زیردامنههای دامنهی خودمان (mathapi…)، زرینپال و صفحههای بانکیِ شاپرک؛ فقط https. پل اینجا کار نمیکند. */
+ private fun isPaymentPage(uri: Uri?): Boolean {
+ val host = uri?.host ?: return false
+ if (uri.scheme != "https") return false
+ val ours = webHost.substringAfter('.')
+ return host == ours || host.endsWith(".$ours") ||
+ host == "zarinpal.com" || host.endsWith(".zarinpal.com") || host.endsWith(".shaparak.ir")
+ }
+
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -66,6 +75,9 @@ class MainActivity : AppCompatActivity() {
// در صفحههای خودمان در دسترس است.
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
if (isOurPage(request.url)) return false
+ // پرداخت با درگاه داخلِ خودِ اپ میماند: زرینپال بیRefererِ دامنهی ما صفحه را باز نمیکند و مرورگرِ
+ // بیرونی Referer نمیفرستد. بعد از پرداخت، زرینپال به سایتِ ما برمیگرداند و نتیجه همینجا دیده میشود.
+ if (isPaymentPage(request.url)) return false
// فقط http(s) به مرورگرِ گوشی میرود؛ intent:، file: و schemeهای سفارشی بیصدا بسته میشوند
// (پیشتر هر آدرسی بیرونِ دامنه به ACTION_VIEW میرفت و صفحه میتوانست اپِ دلخواه باز کند).
if (request.url.scheme == "https" || request.url.scheme == "http") {
@@ -193,6 +205,10 @@ class MainActivity : AppCompatActivity() {
else @Suppress("DEPRECATION") vibrator.vibrate(timings, -1)
}
+ /** فرانت با این میفهمد درگاه داخلِ اپ باز میشود (isPaymentPage)، نه در مرورگرِ بیرونی. */
+ @JavascriptInterface
+ fun paysInApp(): Boolean = true
+
@JavascriptInterface
fun bazaarConsume(purchaseToken: String) {
runOnUiThread {
diff --git a/pay.html b/pay.html
new file mode 100644
index 0000000..07a2463
--- /dev/null
+++ b/pay.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+ رفتن به درگاه — تدریسینو
+
+
+
+
+
+
+ داریم میریم به درگاهِ پرداخت…
+ رفتن به درگاه
+
+
+
+
diff --git a/src/pay.ts b/src/pay.ts
new file mode 100644
index 0000000..562c1d0
--- /dev/null
+++ b/src/pay.ts
@@ -0,0 +1,50 @@
+import { resolveApiBaseUrl } from '@/config';
+
+/**
+ * صفحهی واسطهی پرداخت (pay.html)، برای اپِ اندرویدی که درگاه را در مرورگرِ گوشی باز میکند.
+ *
+ * زرینپال صفحهی StartPay را فقط وقتی باز میکند که Referer از دامنهی ثبتشده باشد؛ بیآن میگوید «دسترسی از این
+ * دامنه مجاز نمیباشد» (روی سرور با درخواستِ بیReferer دیده شد، ۱۴۰۵-۰۷). مرورگری که اپِ دیگری بازش کرده Referer
+ * نمیفرستد. پس اپ اول همین صفحه را در مرورگر باز میکند و این صفحه کاربر را به لینکِ پرداخت میفرستد؛ حالا Referer
+ * از math.tadrisino2.ir است، درست مثلِ پرداخت از سایت.
+ *
+ * فقط لینکِ پرداختِ API خودمان پذیرفته میشود (پیشوندِ آدرسِ API + /payment/orders//pay)، تا این صفحه
+ * ریدایرکتِ باز به هر جایی نشود.
+ */
+const msg = document.getElementById('msg')!;
+const spin = document.getElementById('spin')!;
+const go = document.getElementById('go') as HTMLAnchorElement;
+
+function fail(text: string) {
+ spin.style.display = 'none';
+ msg.textContent = text;
+}
+
+// در fragment میآید، نه query: اپ این صفحه را با http:// باز میکند (پایینِ SubscriptionScreen) و fragment هرگز
+// به سرور فرستاده نمیشود، پس امضای لینکِ پرداخت روی آن یک گامِ بیرمز دیده نمیشود.
+const raw = new URLSearchParams(location.hash.slice(1)).get('u') ?? '';
+let target: URL | null = null;
+try {
+ target = new URL(raw);
+} catch {
+ target = null;
+}
+const api = new URL(resolveApiBaseUrl() + '/', location.origin);
+const ok =
+ target !== null &&
+ target.protocol === 'https:' &&
+ target.origin === api.origin &&
+ new RegExp(`^${api.pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}payment/orders/\\d+/pay$`).test(target.pathname);
+
+if (!ok || !target) {
+ fail('این لینکِ پرداخت معتبر نیست. به اپ برگرد و دوباره «پرداخت» رو بزن.');
+} else {
+ go.href = target.href;
+ location.replace(target.href);
+ // اگر مرورگر جابهجا نشد (مثلاً جلوی ریدایرکت را گرفت)، دکمهی دستی.
+ setTimeout(() => {
+ spin.style.display = 'none';
+ msg.textContent = 'اگه صفحهی پرداخت باز نشد، این دکمه رو بزن:';
+ go.style.display = 'inline-block';
+ }, 2500);
+}
diff --git a/src/screens/SubscriptionScreen.tsx b/src/screens/SubscriptionScreen.tsx
index a1eba6b..2c6b3cc 100644
--- a/src/screens/SubscriptionScreen.tsx
+++ b/src/screens/SubscriptionScreen.tsx
@@ -333,9 +333,18 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
if (!order.paymentUrl) throw new Error('order without paymentUrl');
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
- window.location.assign(order.paymentUrl);
- // داخلِ اپ آدرسِ درگاه در مرورگرِ گوشی باز میشود و این صفحه همینجا میماند؛ دکمه نباید تا ابد بچرخد.
- if (bazaar) setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500);
+ const shell = (window as unknown as { TadrisinoAndroid?: { paysInApp?: () => boolean } }).TadrisinoAndroid;
+ if (bazaar && !shell?.paysInApp?.()) {
+ // اپِ اندرویدِ فعلی هر آدرسِ بیرونی را در مرورگرِ گوشی باز میکند و آن مرورگر Referer نمیفرستد؛ زرینپال
+ // بیReferer میگوید «دسترسی از این دامنه مجاز نمیباشد». پس اول pay.htmlِ خودمان در مرورگر باز میشود
+ // (src/pay.ts) و آن به درگاه میرود. «http://» عمداً است: WebView فقط httpsِ همین دامنه را در خودش نگه
+ // میدارد؛ آروان بلافاصله به https میبرد و لینک در fragment است، پس به سرور نمیرسد.
+ window.location.assign(`http://${window.location.host}/pay.html#u=${encodeURIComponent(order.paymentUrl)}`);
+ setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500);
+ } else {
+ // مرورگر، یا اپی که پرداخت را در خودش باز میکند (paysInApp): همین صفحه به درگاه میرود و Referer دارد.
+ window.location.assign(order.paymentUrl);
+ }
} catch (err) {
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
setBusy(false);
@@ -397,7 +406,7 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
void pay()}>پرداخت
{method === 'gateway' && (
- {bazaar ? 'صفحهی پرداخت در مرورگرِ گوشی باز میشه؛ بعدش برگرد به اپ.' : 'پرداخت در درگاهِ امنِ زرینپال انجام میشه.'}
+ پرداخت در درگاهِ امنِ زرینپال انجام میشه.
)}
>
diff --git a/vite.config.ts b/vite.config.ts
index 61997ac..b3bc678 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -14,6 +14,8 @@ export default defineConfig(({ mode }) => {
},
build: {
rollupOptions: {
+ // pay.html: صفحهی واسطهی درگاه برای اپِ اندروید (src/pay.ts).
+ input: { main: path.resolve(__dirname, 'index.html'), pay: path.resolve(__dirname, 'pay.html') },
output: {
// کتابخانهها با هر انتشار عوض نمیشوند؛ فایلِ جدا یعنی بعد از هر دیپلوی
// از کشِ مرورگر میآیند و فقط کدِ خودِ اپ دوباره دانلود میشود.