From f8cf86e4e89bce3f6e2f16c82e3b5f7e066eb771 Mon Sep 17 00:00:00 2001 From: AliReza Date: Sat, 3 Oct 2026 19:13:12 +0330 Subject: [PATCH] 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 --- android/.gitignore | 8 + android/README.md | 44 +++++ android/app/build.gradle.kts | 50 ++++++ android/app/src/main/AndroidManifest.xml | 29 +++ .../java/com/tadrisino/app/MainActivity.kt | 167 ++++++++++++++++++ android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/themes.xml | 7 + android/build.gradle.kts | 4 + android/gradle.properties | 2 + android/settings.gradle.kts | 18 ++ src/api/index.ts | 9 + src/api/types.ts | 13 +- src/app/App.tsx | 97 +++++----- src/components/BottomNav.tsx | 4 +- src/lib/bazaar.ts | 96 ++++++++++ src/lib/sound.ts | 19 +- src/screens/MapScreen.tsx | 2 +- src/screens/SubscriptionScreen.tsx | 85 +++++++-- src/styles/index.css | 29 ++- 19 files changed, 619 insertions(+), 68 deletions(-) create mode 100644 android/.gitignore create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/tadrisino/app/MainActivity.kt create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/settings.gradle.kts create mode 100644 src/lib/bazaar.ts diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..5004337 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +build/ +app/build/ +local.properties +.idea/ +*.iml +*.jks +*.keystore diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..07fff84 --- /dev/null +++ b/android/README.md @@ -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`ِ بک‌اند. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..0305a39 --- /dev/null +++ b/android/app/build.gradle.kts @@ -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") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..29be4f0 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/tadrisino/app/MainActivity.kt b/android/app/src/main/java/com/tadrisino/app/MainActivity.kt new file mode 100644 index 0000000..83e00d9 --- /dev/null +++ b/android/app/src/main/java/com/tadrisino/app/MainActivity.kt @@ -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" + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..02afa6f --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + تدریسینو + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..449d7ba --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..017d909 --- /dev/null +++ b/android/build.gradle.kts @@ -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 +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..c335529 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2g diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..47346a2 --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/src/api/index.ts b/src/api/index.ts index d81e300..d414b26 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -178,6 +178,15 @@ export const api = { status: () => http.get('/subscription/status'), /** سفارش می‌سازد؛ paymentUrl خودش سراغِ درگاه می‌رود. */ purchase: (planId: string) => http.create('/subscription/purchase', { planId }), + /** + * خرید داخلِ اپِ اندرویدِ بازار. idempotencyKey ثابت است تا تلاشِ دوباره بعد از قطعِ اینترنت + * همان سفارش را برگرداند (src/lib/bazaar.ts). + */ + purchaseBazaar: (planId: string, idempotencyKey: string) => + http.create('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }), + /** توکنِ خریدِ Poolakey را سرور از خودِ بازار می‌سنجد؛ گفته‌ی اپ کافی نیست. */ + verifyBazaar: (orderId: string, purchaseToken: string) => + http.post('/payment/bazaar/verify', { orderId, purchaseToken }), /** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */ redeem: (code: string, orderId: string) => http.post('/discount/redeem', { code, orderId }), diff --git a/src/api/types.ts b/src/api/types.ts index dedbb2e..7aad36f 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -258,6 +258,8 @@ export interface Plan { questionCredits: number; /** بسته: روزهای قلبِ نامحدودِ هدیه. */ heartsDays: number; + /** SKUِ همین بسته در کافه‌بازار؛ خالی یعنی داخلِ اپِ بازار خریدنی نیست. */ + bazaarSku: string; } export interface PlansResponse { @@ -286,7 +288,16 @@ export interface TutorQuota { export interface PurchaseResponse { orderId: string; - paymentUrl: string; + /** زرین‌پال: آدرسِ درگاه. بازار: null. */ + paymentUrl: string | null; + /** فقط بازار: SKUی که اپِ اندروید می‌خرد. */ + bazaarSku?: string; +} + +export interface BazaarVerifyResponse { + orderId: string; + status: string; + valid: boolean; } export interface RedeemResponse { diff --git a/src/app/App.tsx b/src/app/App.tsx index dbf45fa..9758df0 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,5 +1,5 @@ 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 { AppShell } from '@/components/AppShell'; import { ToastProvider } from '@/components/Toast'; @@ -83,52 +83,55 @@ export function App() { }; return ( - - - - - {flow.kind === 'boot' && } - {flow.kind === 'bootError' && ( -
- void boot()} /> -
- )} - {flow.kind === 'onboarding' && ( - { - store.set(KEYS.onboarded, '1'); - setFlow({ kind: 'login' }); - }} - /> - )} - {flow.kind === 'login' && void afterLogin()} />} - {flow.kind === 'setup' && ( - setFlow({ kind: 'main', profile, grades: flow.grades })} - /> - )} - {flow.kind === 'main' && ( - setFlow({ kind: 'login' })} - onRegistered={trackReferral} - /> - )} -
-
-
-
+ // reducedMotion="user": اگر «کاهشِ حرکت» در گوشی روشن باشد، motion جابه‌جایی‌ها را حذف می‌کند. + + + + + + {flow.kind === 'boot' && } + {flow.kind === 'bootError' && ( +
+ void boot()} /> +
+ )} + {flow.kind === 'onboarding' && ( + { + store.set(KEYS.onboarded, '1'); + setFlow({ kind: 'login' }); + }} + /> + )} + {flow.kind === 'login' && void afterLogin()} />} + {flow.kind === 'setup' && ( + setFlow({ kind: 'main', profile, grades: flow.grades })} + /> + )} + {flow.kind === 'main' && ( + setFlow({ kind: 'login' })} + onRegistered={trackReferral} + /> + )} +
+
+
+
+
); } diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index a56a6a4..f568b08 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -20,12 +20,12 @@ export function BottomNav({ active, onChange }: { active: TabName | null; onChan type="button" onClick={() => onChange(id)} 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'}`} > - {label} + {label} ); })} diff --git a/src/lib/bazaar.ts b/src/lib/bazaar.ts new file mode 100644 index 0000000..d72ce04 --- /dev/null +++ b/src/lib/bazaar.ts @@ -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 { + 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).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)), +}; diff --git a/src/lib/sound.ts b/src/lib/sound.ts index 81e81f9..1c533c1 100644 --- a/src/lib/sound.ts +++ b/src/lib/sound.ts @@ -43,12 +43,29 @@ function tone(frequency: number, start: number, duration: number, volume = 0.05) 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 = { tick: () => tone(720, 0, 0.045, 0.04), correct: () => { tone(660, 0, 0.12); 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)), }; diff --git a/src/screens/MapScreen.tsx b/src/screens/MapScreen.tsx index 195558e..53b64ad 100644 --- a/src/screens/MapScreen.tsx +++ b/src/screens/MapScreen.tsx @@ -76,7 +76,7 @@ export function MapScreen() { )}
-
{unit.title}
+
{unit.title}
{toFa(p.done)} از {toFa(p.total)} جزیره
diff --git a/src/screens/SubscriptionScreen.tsx b/src/screens/SubscriptionScreen.tsx index 1c3133d..a809fde 100644 --- a/src/screens/SubscriptionScreen.tsx +++ b/src/screens/SubscriptionScreen.tsx @@ -1,6 +1,8 @@ import { useState } from 'react'; import { Check, Crown, Heart, Sparkles, Ticket } from 'lucide-react'; 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 { Sheet } from '@/components/Sheet'; 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 quota = useAsync(() => api.ai.quota(), []); const [buying, setBuying] = useState(null); + // داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درون‌برنامه‌ایِ خودش است؛ بسته‌ای که + // SKU ندارد آن‌جا خریدنی نیست (به زرین‌پال فرستاده نمی‌شود). + const bazaar = inBazaarApp(); const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0; const heartsUntil = status.data?.unlimitedHeartsUntil ?? null; @@ -71,19 +76,23 @@ export function SubscriptionScreen() { ) : !plans.data?.plans.length ? ( ) : ( - plans.data.plans.map((plan) => setBuying(plan)} />) + plans.data.plans.map((plan) => ( + setBuying(plan)} /> + )) )} -

پرداخت از طریقِ درگاهِ امنِ زرین‌پال انجام می‌شه.

+

+ {bazaar ? 'پرداخت از طریقِ کافه‌بازار انجام می‌شه.' : 'پرداخت از طریقِ درگاهِ امنِ زرین‌پال انجام می‌شه.'} +

- setBuying(null)} onPaidWithCode={() => { setBuying(null); reload(); }} /> + setBuying(null)} onPaid={() => { setBuying(null); reload(); }} /> ); } -function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) { +function PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean; onBuy: () => void }) { const featured = plan.popular; const isPack = plan.kind === 'pack'; const tone = featured ? 'text-white/80' : 'text-muted'; @@ -122,9 +131,10 @@ function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) { @@ -136,27 +146,70 @@ function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) { * و بعد کاربر در همین برگه به درگاه می‌رود. window.open بعد از await را * 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 [showCode, setShowCode] = useState(false); const [busy, setBusy] = useState(false); 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() { if (!plan || busy) return; setBusy(true); setError(''); + if (bazaar) { + try { + if (await payWithBazaar(plan)) setDone('bazaar'); + } catch (err) { + setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.')); + } + setBusy(false); + return; + } try { const order = await api.subscription.purchase(plan.id); if (code.trim()) { const redeemed = await api.subscription.redeem(code.trim(), order.orderId); if (redeemed.orderStatus === 'paid') { - setFreeDone(true); + setDone('code'); setBusy(false); return; } } + if (!order.paymentUrl) throw new Error('order without paymentUrl'); window.location.assign(order.paymentUrl); } catch (err) { setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن')); @@ -165,19 +218,21 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o } const close = () => { - if (freeDone) onPaidWithCode(); + if (done) onPaid(); else onClose(); setCode(''); setShowCode(false); setError(''); - setFreeDone(false); + setDone(null); }; return ( - - {freeDone ? ( + + {done ? ( <> -

کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.

+

+ {done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و سوال‌ها به اعتبارت اضافه شدن.'} +

عالیه! ) : plan && ( @@ -186,7 +241,7 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o مبلغ {formatToman(plan.price)} - {showCode ? ( + {bazaar ? null : showCode ? (