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

8
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.gradle/
build/
app/build/
local.properties
.idea/
*.iml
*.jks
*.keystore

44
android/README.md Normal file
View File

@@ -0,0 +1,44 @@
# اپِ اندرویدِ تدریسینو (پوسته‌ی WebView + پرداختِ بازار)
همان سایتِ `math.tadrisino2.ir` داخلِ یک WebView. تنها کدِ بومی پرداختِ درون‌برنامه‌ایِ
کافه‌بازار است (کتابخانه‌ی Poolakey)، چون بازار برای اپی که در آن منتشر می‌شود پرداختِ
محصولِ دیجیتال را فقط از راهِ خودش می‌پذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
فرانت به‌روز می‌شود، بی‌آن‌که نسخه‌ی تازه‌ی اپ لازم باشد.
> ⚠️ **این پروژه هنوز یک بار هم build نشده.** روی ماشینی نوشته شد که Android SDK نداشت
> (۲۰۲۶-۱۰-۰۳). APIِ Poolakey از README خودِ کتابخانه است. اولین کار: باز کردن در Android
> Studio، build، و یک خریدِ واقعی روی گوشی.
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
| چه | کجا گذاشته می‌شود | وضعیت |
|---|---|---|
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.app` حدس است؛ تأیید نشده |
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | ندارد؛ بدونِ آن خرید شروع نمی‌شود |
| SKUِ هر بسته (محصولِ **مصرف‌شدنی**) | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچ‌کدام تعریف نشده |
قیمتِ هر محصول در پیشخانِ بازار باید با قیمتِ همان بسته در ادمین یکی باشد؛ سرور مبلغ را از
بازار نمی‌خواند.
## build
1. Android Studio ← Open ← همین پوشه‌ی `android/`. Studio خودش Gradle wrapper را می‌سازد.
2. `Build ← Generate Signed App Bundle / APK`، با کلیدِ امضای شرکت. **کلیدِ امضا را گم نکنید**:
بدونِ همان کلید هیچ نسخه‌ی بعدی روی بازار به‌روزرسانی نمی‌شود.
3. نصب روی گوشی‌ای که کافه‌بازار دارد و با حسابِ تست وارد شده.
## جریانِ خرید (برای عیب‌یابی)
```
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
فرانت TadrisinoAndroid.bazaarPurchase(sku, orderId)
اپ Poolakey.purchaseProduct(sku, payload=orderId) → purchaseToken
اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken}
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار می‌پرسد
فرانت TadrisinoAndroid.bazaarConsume(token) ← تا همان بسته دوباره خریدنی باشد
```
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» می‌زند: فرانت همان سفارش را می‌فرستد
و اپ توکنِ ماندهٔ همان سفارش را از `getPurchasedProducts` پیدا می‌کند.
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بک‌اند.

View File

@@ -0,0 +1,50 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
// ⚠️ اسمِ پکیج باید همانی باشد که در پیشخانِ بازار ثبت است. com.tadrisino.app را بازار
// برای حسابِ شرکت «Product is not found» جواب داد (یعنی هست)، ولی سنیور تأیید نکرده
// که همان اپِ ریاضی است (CLAUDE.mdِ بک‌اند، ۲۰۲۶-۰۹-۳۰).
namespace = "com.tadrisino.app"
compileSdk = 34
defaultConfig {
applicationId = "com.tadrisino.app"
minSdk = 23
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
// آدرسِ همان فرانتِ وب. دامنه عوض شود، فقط همین خط.
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (بخشِ «پرداخت درون‌برنامه‌ای»). کلیدِ عمومی
// است و در اپ می‌نشیند؛ رمز نیست. تا پر نشود، اپ خرید را شروع نمی‌کند.
buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
}
buildFeatures {
buildConfig = true
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-ktx:1.9.2")
implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
}

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>

4
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}

View File

@@ -0,0 +1,2 @@
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2g

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
// Poolakey (کتابخانه‌ی پرداختِ کافه‌بازار) فقط روی jitpack منتشر می‌شود.
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "Tadrisino"
include(":app")

View File

@@ -178,6 +178,15 @@ export const api = {
status: () => http.get<T.SubscriptionStatus>('/subscription/status'), status: () => http.get<T.SubscriptionStatus>('/subscription/status'),
/** سفارش می‌سازد؛ paymentUrl خودش سراغِ درگاه می‌رود. */ /** سفارش می‌سازد؛ paymentUrl خودش سراغِ درگاه می‌رود. */
purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }), purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }),
/**
* خرید داخلِ اپِ اندرویدِ بازار. idempotencyKey ثابت است تا تلاشِ دوباره بعد از قطعِ اینترنت
* همان سفارش را برگرداند (src/lib/bazaar.ts).
*/
purchaseBazaar: (planId: string, idempotencyKey: string) =>
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }),
/** توکنِ خریدِ Poolakey را سرور از خودِ بازار می‌سنجد؛ گفته‌ی اپ کافی نیست. */
verifyBazaar: (orderId: string, purchaseToken: string) =>
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */ /** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
redeem: (code: string, orderId: string) => redeem: (code: string, orderId: string) =>
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }), http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),

View File

@@ -258,6 +258,8 @@ export interface Plan {
questionCredits: number; questionCredits: number;
/** بسته: روزهای قلبِ نامحدودِ هدیه. */ /** بسته: روزهای قلبِ نامحدودِ هدیه. */
heartsDays: number; heartsDays: number;
/** SKUِ همین بسته در کافه‌بازار؛ خالی یعنی داخلِ اپِ بازار خریدنی نیست. */
bazaarSku: string;
} }
export interface PlansResponse { export interface PlansResponse {
@@ -286,7 +288,16 @@ export interface TutorQuota {
export interface PurchaseResponse { export interface PurchaseResponse {
orderId: string; orderId: string;
paymentUrl: string; /** زرین‌پال: آدرسِ درگاه. بازار: null. */
paymentUrl: string | null;
/** فقط بازار: SKUی که اپِ اندروید می‌خرد. */
bazaarSku?: string;
}
export interface BazaarVerifyResponse {
orderId: string;
status: string;
valid: boolean;
} }
export interface RedeemResponse { export interface RedeemResponse {

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, MotionConfig, motion } from 'motion/react';
import { api, ApiError, http, type Grade, type UserProfile } from '@/api'; import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
import { AppShell } from '@/components/AppShell'; import { AppShell } from '@/components/AppShell';
import { ToastProvider } from '@/components/Toast'; import { ToastProvider } from '@/components/Toast';
@@ -83,52 +83,55 @@ export function App() {
}; };
return ( return (
<AppShell> // reducedMotion="user": اگر «کاهشِ حرکت» در گوشی روشن باشد، motion جابه‌جایی‌ها را حذف می‌کند.
<ToastProvider> <MotionConfig reducedMotion="user">
<AnimatePresence mode="wait"> <AppShell>
<motion.div <ToastProvider>
key={flow.kind} <AnimatePresence mode="wait">
className="flex h-full flex-col" <motion.div
initial={{ opacity: 0 }} key={flow.kind}
animate={{ opacity: 1 }} className="flex h-full flex-col"
exit={{ opacity: 0 }} initial={{ opacity: 0 }}
transition={{ duration: 0.2 }} animate={{ opacity: 1 }}
> exit={{ opacity: 0 }}
{flow.kind === 'boot' && <SplashScreen />} transition={{ duration: 0.2 }}
{flow.kind === 'bootError' && ( >
<div className="flex h-full flex-col justify-center"> {flow.kind === 'boot' && <SplashScreen />}
<ErrorState error={flow.error} onRetry={() => void boot()} /> {flow.kind === 'bootError' && (
</div> <div className="flex h-full flex-col justify-center">
)} <ErrorState error={flow.error} onRetry={() => void boot()} />
{flow.kind === 'onboarding' && ( </div>
<OnboardingScreen )}
onDone={() => { {flow.kind === 'onboarding' && (
store.set(KEYS.onboarded, '1'); <OnboardingScreen
setFlow({ kind: 'login' }); onDone={() => {
}} store.set(KEYS.onboarded, '1');
/> setFlow({ kind: 'login' });
)} }}
{flow.kind === 'login' && <LoginScreen onDone={() => void afterLogin()} />} />
{flow.kind === 'setup' && ( )}
<SetupScreen {flow.kind === 'login' && <LoginScreen onDone={() => void afterLogin()} />}
profile={flow.profile} {flow.kind === 'setup' && (
grades={flow.grades} <SetupScreen
onDone={(profile) => setFlow({ kind: 'main', profile, grades: flow.grades })} profile={flow.profile}
/> grades={flow.grades}
)} onDone={(profile) => setFlow({ kind: 'main', profile, grades: flow.grades })}
{flow.kind === 'main' && ( />
<MainApp )}
profile={flow.profile} {flow.kind === 'main' && (
grades={flow.grades} <MainApp
payment={launchPayment} profile={flow.profile}
onSignedOut={() => setFlow({ kind: 'login' })} grades={flow.grades}
onRegistered={trackReferral} payment={launchPayment}
/> onSignedOut={() => setFlow({ kind: 'login' })}
)} onRegistered={trackReferral}
</motion.div> />
</AnimatePresence> )}
</ToastProvider> </motion.div>
</AppShell> </AnimatePresence>
</ToastProvider>
</AppShell>
</MotionConfig>
); );
} }

View File

@@ -20,12 +20,12 @@ export function BottomNav({ active, onChange }: { active: TabName | null; onChan
type="button" type="button"
onClick={() => onChange(id)} onClick={() => onChange(id)}
aria-current={on ? 'page' : undefined} aria-current={on ? 'page' : undefined}
className={`flex flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-colors ${on ? 'text-coral' : 'text-muted'}`} className={`flex min-w-14 flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-colors ${on ? 'text-coral' : 'text-muted'}`}
> >
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}> <span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} /> <Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
</span> </span>
<span className="text-[10px] font-bold">{label}</span> <span className="text-[11px] font-bold">{label}</span>
</button> </button>
); );
})} })}

96
src/lib/bazaar.ts Normal file
View File

@@ -0,0 +1,96 @@
import { store } from './storage';
/**
* پلِ پرداختِ کافه‌بازار، وقتی همین سایت داخلِ اپِ اندرویدِ تدریسینو (WebView) باز است.
*
* خریدِ درون‌برنامه‌ایِ بازار فقط از کدِ بومیِ اندروید (کتابخانه‌ی Poolakey) شدنی است، پس
* اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه می‌گذارد و این فایل تنها
* جایی است که با آن حرف می‌زند. در مرورگرِ معمولی آن شیء نیست، `inBazaarApp()` false است و
* خرید مثلِ قبل به زرین‌پال می‌رود.
*
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
* JS → اندروید TadrisinoAndroid.bazaarPurchase(sku, orderId) خرید را شروع کن؛ orderId همان
* developerPayloadِ خرید است.
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) بعد از تأییدِ سرور؛ بدونِ آن
* همان بسته دوباره خریدنی نیست.
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
* { orderId, purchaseToken } خرید انجام شد
* { orderId, error: 'canceled' | 'failed', message? }
*
* هیچ‌کدام از این‌ها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۰۳): اپِ اندروید ساخته نشده.
*/
interface AndroidBridge {
bazaarPurchase?: (sku: string, orderId: string) => void;
bazaarConsume?: (purchaseToken: string) => 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 inBazaarApp(): boolean {
return typeof window.TadrisinoAndroid?.bazaarPurchase === 'function';
}
/** صفحه‌ی خریدِ بازار ممکن است طول بکشد (ورود به بازار، کارتِ بانکی)؛ این سقف فقط جلوی گیر کردنِ همیشگی را می‌گیرد. */
const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000;
/** خرید را به اپِ اندروید می‌سپارد و purchaseToken را برمی‌گرداند. */
export function bazaarPurchase(sku: string, orderId: string): Promise<string> {
const bridge = window.TadrisinoAndroid;
if (!bridge?.bazaarPurchase) 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<BridgeResult>).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.bazaarPurchase!(sku, orderId);
} catch (err) {
finish(() => reject(new BazaarPurchaseError('failed', String(err))));
}
});
}
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)),
};

View File

@@ -43,12 +43,29 @@ function tone(frequency: number, start: number, duration: number, volume = 0.05)
osc.stop(t + duration + 0.02); osc.stop(t + duration + 0.02);
} }
/**
* لرزشِ کوتاه کنارِ صدا، با همان کلیدِ «صدا» خاموش می‌شود. فقط اندروید/کروم دارد؛
* Safariِ آیفون `vibrate` ندارد و این‌جا هیچ کاری نمی‌کند.
*/
function buzz(pattern: number | number[]) {
if (!soundEnabled() || typeof navigator.vibrate !== 'function') return;
try {
navigator.vibrate(pattern);
} catch {
/* بعضی WebViewها بی‌اجازه throw می‌کنند؛ لرزش فقط تزئینی است */
}
}
export const sounds = { export const sounds = {
tick: () => tone(720, 0, 0.045, 0.04), tick: () => tone(720, 0, 0.045, 0.04),
correct: () => { correct: () => {
tone(660, 0, 0.12); tone(660, 0, 0.12);
tone(990, 0.1, 0.18); tone(990, 0.1, 0.18);
buzz(30);
},
wrong: () => {
tone(220, 0, 0.25, 0.06);
buzz([40, 60, 40]);
}, },
wrong: () => tone(220, 0, 0.25, 0.06),
finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)), finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)),
}; };

View File

@@ -76,7 +76,7 @@ export function MapScreen() {
)} )}
</button> </button>
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center"> <div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
<div className="max-w-[130px] truncate text-xs font-black text-ink">{unit.title}</div> <div className="line-clamp-2 max-w-[170px] text-xs font-black leading-5 text-ink">{unit.title}</div>
<div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div> <div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
</div> </div>
</motion.div> </motion.div>

View File

@@ -1,6 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { Check, Crown, Heart, Sparkles, Ticket } from 'lucide-react'; import { Check, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
import { api, type Plan } from '@/api'; import { api, type Plan } from '@/api';
import { newIdempotencyKey } from '@/api/http';
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
import { useAsync } from '@/lib/useAsync'; import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet'; import { Sheet } from '@/components/Sheet';
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui'; import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
@@ -24,6 +26,9 @@ export function SubscriptionScreen() {
const status = useAsync(() => api.subscription.status(), []); const status = useAsync(() => api.subscription.status(), []);
const quota = useAsync(() => api.ai.quota(), []); const quota = useAsync(() => api.ai.quota(), []);
const [buying, setBuying] = useState<Plan | null>(null); const [buying, setBuying] = useState<Plan | null>(null);
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درون‌برنامه‌ایِ خودش است؛ بسته‌ای که
// SKU ندارد آن‌جا خریدنی نیست (به زرین‌پال فرستاده نمی‌شود).
const bazaar = inBazaarApp();
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0; const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null; const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
@@ -71,19 +76,23 @@ export function SubscriptionScreen() {
) : !plans.data?.plans.length ? ( ) : !plans.data?.plans.length ? (
<EmptyState text="فعلاً بسته‌ای برای خرید تعریف نشده" /> <EmptyState text="فعلاً بسته‌ای برای خرید تعریف نشده" />
) : ( ) : (
plans.data.plans.map((plan) => <PackCard key={plan.id} plan={plan} onBuy={() => setBuying(plan)} />) plans.data.plans.map((plan) => (
<PackCard key={plan.id} plan={plan} available={!bazaar || !!plan.bazaarSku} onBuy={() => setBuying(plan)} />
))
)} )}
<p className="pb-2 text-center text-[11px] leading-6 text-faint">پرداخت از طریقِ درگاهِ امنِ زرین‌پال انجام می‌شه.</p> <p className="pb-2 text-center text-[11px] leading-6 text-muted">
{bazaar ? 'پرداخت از طریقِ کافه‌بازار انجام می‌شه.' : 'پرداخت از طریقِ درگاهِ امنِ زرین‌پال انجام می‌شه.'}
</p>
</div> </div>
</div> </div>
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaidWithCode={() => { setBuying(null); reload(); }} /> <CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); reload(); }} />
</Screen> </Screen>
); );
} }
function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) { function PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean; onBuy: () => void }) {
const featured = plan.popular; const featured = plan.popular;
const isPack = plan.kind === 'pack'; const isPack = plan.kind === 'pack';
const tone = featured ? 'text-white/80' : 'text-muted'; const tone = featured ? 'text-white/80' : 'text-muted';
@@ -122,9 +131,10 @@ function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) {
<button <button
type="button" type="button"
onClick={onBuy} onClick={onBuy}
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] ${featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'}`} disabled={!available}
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-50 ${featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'}`}
> >
خرید {available ? 'خرید' : 'به‌زودی در بازار'}
</button> </button>
</div> </div>
</div> </div>
@@ -136,27 +146,70 @@ function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) {
* و بعد کاربر در همین برگه به درگاه می‌رود. window.open بعد از await را * و بعد کاربر در همین برگه به درگاه می‌رود. window.open بعد از await را
* Safariِ آیفون مسدود می‌کند؛ برای همین جابه‌جاییِ همین صفحه است. * Safariِ آیفون مسدود می‌کند؛ برای همین جابه‌جاییِ همین صفحه است.
*/ */
function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; onClose: () => void; onPaidWithCode: () => void }) { function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) {
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [showCode, setShowCode] = useState(false); const [showCode, setShowCode] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [freeDone, setFreeDone] = useState(false); const [done, setDone] = useState<'code' | 'bazaar' | null>(null);
/**
* داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید.
* کدِ تخفیف این‌جا نیست: بازار قیمتِ ثابتِ محصول را می‌گیرد و سرور تخفیف را رد می‌کند.
*/
async function payWithBazaar(plan: Plan) {
const sku = plan.bazaarSku;
let key = bazaarPending.get(sku);
if (key) {
// کلیدِ مانده از خریدِ قبلی فقط وقتی به کار می‌آید که سفارشش هنوز در انتظار است؛ سفارشِ
// پرداخت‌شده یا ناموفق با خریدِ تازه قاطی نشود.
const previous = await api.subscription.purchaseBazaar(plan.id, key).catch(() => null);
const status = previous ? await api.subscription.order(previous.orderId).then((o) => o.status).catch(() => null) : null;
if (status !== 'pending') key = null;
}
if (!key) {
key = newIdempotencyKey();
bazaarPending.set(sku, key);
}
const order = await api.subscription.purchaseBazaar(plan.id, key);
let token: string;
try {
token = await bazaarPurchase(order.bazaarSku || sku, order.orderId);
} catch (err) {
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return false;
throw err;
}
const result = await api.subscription.verifyBazaar(order.orderId, token);
if (result.status !== 'paid') throw new Error('bazaar purchase not confirmed');
bazaarPending.clear(sku);
bazaarConsume(token);
return true;
}
async function pay() { async function pay() {
if (!plan || busy) return; if (!plan || busy) return;
setBusy(true); setBusy(true);
setError(''); setError('');
if (bazaar) {
try {
if (await payWithBazaar(plan)) setDone('bazaar');
} catch (err) {
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
}
setBusy(false);
return;
}
try { try {
const order = await api.subscription.purchase(plan.id); const order = await api.subscription.purchase(plan.id);
if (code.trim()) { if (code.trim()) {
const redeemed = await api.subscription.redeem(code.trim(), order.orderId); const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
if (redeemed.orderStatus === 'paid') { if (redeemed.orderStatus === 'paid') {
setFreeDone(true); setDone('code');
setBusy(false); setBusy(false);
return; return;
} }
} }
if (!order.paymentUrl) throw new Error('order without paymentUrl');
window.location.assign(order.paymentUrl); window.location.assign(order.paymentUrl);
} catch (err) { } catch (err) {
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن')); setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
@@ -165,19 +218,21 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
} }
const close = () => { const close = () => {
if (freeDone) onPaidWithCode(); if (done) onPaid();
else onClose(); else onClose();
setCode(''); setCode('');
setShowCode(false); setShowCode(false);
setError(''); setError('');
setFreeDone(false); setDone(null);
}; };
return ( return (
<Sheet open={plan !== null} onClose={close} title={freeDone ? 'خریدت انجام شد! 🎉' : plan?.title}> <Sheet open={plan !== null} onClose={close} title={done ? 'خریدت انجام شد! 🎉' : plan?.title}>
{freeDone ? ( {done ? (
<> <>
<p className="mb-5 text-sm leading-7 text-muted">کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.</p> <p className="mb-5 text-sm leading-7 text-muted">
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و سوال‌ها به اعتبارت اضافه شدن.'}
</p>
<PrimaryButton onClick={close}>عالیه!</PrimaryButton> <PrimaryButton onClick={close}>عالیه!</PrimaryButton>
</> </>
) : plan && ( ) : plan && (
@@ -186,7 +241,7 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
<span className="text-sm text-body">مبلغ</span> <span className="text-sm text-body">مبلغ</span>
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span> <span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
</div> </div>
{showCode ? ( {bazaar ? null : showCode ? (
<label className="mb-4 block"> <label className="mb-4 block">
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span> <span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
<input <input

View File

@@ -21,7 +21,10 @@
--color-ink: #1a1a2e; /* متنِ اصلی */ --color-ink: #1a1a2e; /* متنِ اصلی */
--color-body: #3a3a5c; /* متنِ بدنه */ --color-body: #3a3a5c; /* متنِ بدنه */
--color-muted: #9e9ec0; /* متنِ کم‌رنگ */ /* متنِ کم‌رنگ. تا ۲۰۲۶-۱۰-۰۳ ‎#9e9ec0 بود: کنتراست ۲٫۶ روی سفید، زیرِ حدِ ۴٫۵ِ WCAG، و
همین رنگ زیرنویسِ ۱۰–۱۲ پیکسلیِ همه‌ی صفحه‌ها و آیکونِ تب‌های غیرفعال است. این یکی
روی سفید ۴٫۵ و روی پس‌زمینه‌ی صفحه ۴٫۲ است. */
--color-muted: #727299;
--color-faint: #b0b0c8; --color-faint: #b0b0c8;
--color-line: #e9e6f7; /* حاشیه‌ی کارت */ --color-line: #e9e6f7; /* حاشیه‌ی کارت */
--color-soft: #f0eeff; /* پس‌زمینه‌ی ملایم */ --color-soft: #f0eeff; /* پس‌زمینه‌ی ملایم */
@@ -47,6 +50,30 @@
button:disabled { button:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
/* کشیدنِ صفحه به پایین در کروم اندروید کلِ اپ را رفرش می‌کرد (pull-to-refresh)
و وسطِ آزمون جوابِ جاری را می‌پراند؛ صفحه‌های داخلی خودشان اسکرول دارند. */
html, body {
overscroll-behavior: none;
}
/* حلقه‌ی فوکوس فقط برای صفحه‌کلید (focus-visible)، نه برای لمس. */
:focus-visible {
outline: 2px solid var(--color-indigo);
outline-offset: 2px;
}
}
/* کاربری که در تنظیماتِ گوشی «کاهشِ حرکت» را زده: انیمیشن‌ها و transitionهای CSS
تقریباً آنی می‌شوند. انیمیشن‌های motion را MotionConfig در App خاموش می‌کند. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@layer base {
input, textarea { input, textarea {
font: inherit; font: inherit;
} }