UI readability pass and Bazaar in-app purchase bridge

- muted text #9e9ec0 -> #727299 (contrast 2.6 -> 4.5 on white); tab labels 11px
- map: chapter titles wrap to two lines instead of being cut off
- reduced-motion respected (MotionConfig + CSS), focus-visible ring,
  no pull-to-refresh, short vibration on correct/wrong answers
- src/lib/bazaar.ts: bridge to the Android WebView wrapper; checkout uses
  Bazaar IAP only inside that app, Zarinpal everywhere else (unchanged)
- android/: WebView wrapper with Poolakey; written but never built

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 19:13:12 +03:30
parent adecf25486
commit f8cf86e4e8
19 changed files with 619 additions and 68 deletions

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- برای لرزشِ کوتاهِ جوابِ درست/غلط (navigator.vibrate در WebView). -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- اندروید ۱۱+: بدونِ این، اپ نمی‌تواند سرویسِ پرداختِ بازار را پیدا کند. -->
<queries>
<package android:name="com.farsitel.bazaar" />
</queries>
<application
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Tadrisino">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,167 @@
package com.tadrisino.app
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import ir.cafebazaar.poolakey.Connection
import ir.cafebazaar.poolakey.Payment
import ir.cafebazaar.poolakey.config.PaymentConfiguration
import ir.cafebazaar.poolakey.config.SecurityCheck
import ir.cafebazaar.poolakey.request.PurchaseRequest
import org.json.JSONObject
/**
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درون‌برنامه‌ایِ بازار است.
*
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
* JS → اینجا TadrisinoAndroid.bazaarPurchase(sku, orderId)
* TadrisinoAndroid.bazaarConsume(purchaseToken)
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
* { orderId, error: "canceled" | "failed", message }
*
* سمتِ سرور توکن را از خودِ بازار می‌سنجد (`POST /api/payment/bazaar/verify`)، پس این‌جا
* فقط واسطه است و به هیچ نتیجه‌ای اعتماد نمی‌کند.
*
* ⚠️ نوشته شده بی‌آن‌که build شود (ماشینِ نویسنده Android SDK نداشت، ۲۰۲۶-۱۰-۰۳)؛ APIِ
* Poolakey از README خودش است. پیش از انتشار با Android Studio بسازید و یک خریدِ واقعی بزنید.
*/
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
private var payment: Payment? = null
private var connection: Connection? = null
private val webHost: String = Uri.parse(BuildConfig.WEB_URL).host ?: ""
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
webView = WebView(this)
setContentView(webView)
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true // توکنِ ورود در localStorage است
webView.webViewClient = object : WebViewClient() {
// هر آدرسی بیرونِ دامنه‌ی خودمان در مرورگرِ گوشی باز می‌شود، نه داخلِ اپ؛ پس پل فقط
// در صفحه‌های خودمان در دسترس است.
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
if (request.url.host == webHost) return false
startActivity(Intent(Intent.ACTION_VIEW, request.url))
return true
}
}
webView.addJavascriptInterface(Bridge(), "TadrisinoAndroid")
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
// اپِ وب برای هر صفحه history.pushState می‌زند؛ دکمه‌ی back همان را برمی‌گرداند.
override fun handleOnBackPressed() {
if (webView.canGoBack()) webView.goBack() else finish()
}
})
connectToBazaar()
if (savedInstanceState == null) webView.loadUrl(BuildConfig.WEB_URL)
}
private fun connectToBazaar() {
if (BuildConfig.BAZAAR_RSA_KEY.isEmpty()) {
Log.e(TAG, "BAZAAR_RSA_KEY is empty; in-app purchase disabled")
return
}
val config = PaymentConfiguration(
localSecurityCheck = SecurityCheck.Enable(rsaPublicKey = BuildConfig.BAZAAR_RSA_KEY),
)
payment = Payment(context = this, config = config).also { p ->
connection = p.connect {
connectionSucceed { Log.i(TAG, "bazaar connected") }
connectionFailed { Log.e(TAG, "bazaar connection failed", it) }
disconnected { Log.w(TAG, "bazaar disconnected") }
}
}
}
private fun purchase(sku: String, orderId: 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),
) {
purchaseSucceed { send(orderId, token = it.purchaseToken) }
purchaseCanceled { send(orderId, error = "canceled") }
purchaseFailed { recoverOwned(sku, orderId, it) }
failedToBeginFlow { recoverOwned(sku, orderId, it) }
}
}
/**
* خریدِ قبلیِ همین محصول که مصرف نشده (پول رفته ولی تأییدِ سرور نرسیده بود) جلوی خریدِ
* دوباره را می‌گیرد. فرانت در تلاشِ بعدی همان سفارش را می‌فرستد؛ اگر توکنِ ماندهٔ همان
* سفارش (payload) پیدا شد، همان برمی‌گردد تا سرور ثبتش کند.
*/
private fun recoverOwned(sku: String, orderId: String, cause: Throwable) {
val p = payment ?: return send(orderId, error = "failed", message = cause.message)
p.getPurchasedProducts {
querySucceed { owned ->
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
if (match != null) send(orderId, token = match.purchaseToken)
else send(orderId, error = "failed", message = cause.message)
}
queryFailed { send(orderId, error = "failed", message = cause.message) }
}
}
private fun consume(purchaseToken: String) {
payment?.consumeProduct(purchaseToken) {
consumeSucceed { Log.i(TAG, "consumed") }
consumeFailed { Log.e(TAG, "consume failed", it) }
}
}
private fun send(orderId: String, token: String? = null, error: String? = null, message: String? = null) {
val detail = JSONObject().put("orderId", orderId)
if (token != null) detail.put("purchaseToken", token)
if (error != null) detail.put("error", error)
if (message != null) detail.put("message", message)
runOnUiThread {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('tadrisino:bazaar', { detail: $detail }));", null,
)
}
}
/** متدهای این کلاس روی threadِ WebView صدا زده می‌شوند، نه UI. */
private inner class Bridge {
@JavascriptInterface
fun bazaarPurchase(sku: String, orderId: String) {
runOnUiThread {
if (Uri.parse(webView.url ?: "").host != webHost) return@runOnUiThread
purchase(sku, orderId)
}
}
@JavascriptInterface
fun bazaarConsume(purchaseToken: String) {
runOnUiThread { consume(purchaseToken) }
}
}
override fun onDestroy() {
connection?.disconnect()
super.onDestroy()
}
companion object {
private const val TAG = "Tadrisino"
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">تدریسینو</string>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Tadrisino" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:statusBarColor">#ED6956</item>
<item name="android:windowBackground">@android:color/white</item>
</style>
</resources>