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

@@ -178,6 +178,15 @@ export const api = {
status: () => http.get<T.SubscriptionStatus>('/subscription/status'),
/** سفارش می‌سازد؛ paymentUrl خودش سراغِ درگاه می‌رود. */
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) =>
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),

View File

@@ -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 {

View File

@@ -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 (
<AppShell>
<ToastProvider>
<AnimatePresence mode="wait">
<motion.div
key={flow.kind}
className="flex h-full flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{flow.kind === 'boot' && <SplashScreen />}
{flow.kind === 'bootError' && (
<div className="flex h-full flex-col justify-center">
<ErrorState error={flow.error} onRetry={() => void boot()} />
</div>
)}
{flow.kind === 'onboarding' && (
<OnboardingScreen
onDone={() => {
store.set(KEYS.onboarded, '1');
setFlow({ kind: 'login' });
}}
/>
)}
{flow.kind === 'login' && <LoginScreen onDone={() => void afterLogin()} />}
{flow.kind === 'setup' && (
<SetupScreen
profile={flow.profile}
grades={flow.grades}
onDone={(profile) => setFlow({ kind: 'main', profile, grades: flow.grades })}
/>
)}
{flow.kind === 'main' && (
<MainApp
profile={flow.profile}
grades={flow.grades}
payment={launchPayment}
onSignedOut={() => setFlow({ kind: 'login' })}
onRegistered={trackReferral}
/>
)}
</motion.div>
</AnimatePresence>
</ToastProvider>
</AppShell>
// reducedMotion="user": اگر «کاهشِ حرکت» در گوشی روشن باشد، motion جابه‌جایی‌ها را حذف می‌کند.
<MotionConfig reducedMotion="user">
<AppShell>
<ToastProvider>
<AnimatePresence mode="wait">
<motion.div
key={flow.kind}
className="flex h-full flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{flow.kind === 'boot' && <SplashScreen />}
{flow.kind === 'bootError' && (
<div className="flex h-full flex-col justify-center">
<ErrorState error={flow.error} onRetry={() => void boot()} />
</div>
)}
{flow.kind === 'onboarding' && (
<OnboardingScreen
onDone={() => {
store.set(KEYS.onboarded, '1');
setFlow({ kind: 'login' });
}}
/>
)}
{flow.kind === 'login' && <LoginScreen onDone={() => void afterLogin()} />}
{flow.kind === 'setup' && (
<SetupScreen
profile={flow.profile}
grades={flow.grades}
onDone={(profile) => setFlow({ kind: 'main', profile, grades: flow.grades })}
/>
)}
{flow.kind === 'main' && (
<MainApp
profile={flow.profile}
grades={flow.grades}
payment={launchPayment}
onSignedOut={() => setFlow({ kind: 'login' })}
onRegistered={trackReferral}
/>
)}
</motion.div>
</AnimatePresence>
</ToastProvider>
</AppShell>
</MotionConfig>
);
}

View File

@@ -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'}`}
>
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
</span>
<span className="text-[10px] font-bold">{label}</span>
<span className="text-[11px] font-bold">{label}</span>
</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);
}
/**
* لرزشِ کوتاه کنارِ صدا، با همان کلیدِ «صدا» خاموش می‌شود. فقط اندروید/کروم دارد؛
* 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)),
};

View File

@@ -76,7 +76,7 @@ export function MapScreen() {
)}
</button>
<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>
</motion.div>

View File

@@ -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<Plan | null>(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 ? (
<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>
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaidWithCode={() => { setBuying(null); reload(); }} />
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); reload(); }} />
</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 isPack = plan.kind === 'pack';
const tone = featured ? 'text-white/80' : 'text-muted';
@@ -122,9 +131,10 @@ function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) {
<button
type="button"
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>
</div>
</div>
@@ -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 (
<Sheet open={plan !== null} onClose={close} title={freeDone ? 'خریدت انجام شد! 🎉' : plan?.title}>
{freeDone ? (
<Sheet open={plan !== null} onClose={close} title={done ? 'خریدت انجام شد! 🎉' : plan?.title}>
{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>
</>
) : plan && (
@@ -186,7 +241,7 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
<span className="text-sm text-body">مبلغ</span>
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
</div>
{showCode ? (
{bazaar ? null : showCode ? (
<label className="mb-4 block">
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
<input

View File

@@ -21,7 +21,10 @@
--color-ink: #1a1a2e; /* متنِ اصلی */
--color-body: #3a3a5c; /* متنِ بدنه */
--color-muted: #9e9ec0; /* متنِ کم‌رنگ */
/* متنِ کم‌رنگ. تا ۲۰۲۶-۱۰-۰۳ ‎#9e9ec0 بود: کنتراست ۲٫۶ روی سفید، زیرِ حدِ ۴٫۵ِ WCAG، و
همین رنگ زیرنویسِ ۱۰–۱۲ پیکسلیِ همه‌ی صفحه‌ها و آیکونِ تب‌های غیرفعال است. این یکی
روی سفید ۴٫۵ و روی پس‌زمینه‌ی صفحه ۴٫۲ است. */
--color-muted: #727299;
--color-faint: #b0b0c8;
--color-line: #e9e6f7; /* حاشیه‌ی کارت */
--color-soft: #f0eeff; /* پس‌زمینه‌ی ملایم */
@@ -47,6 +50,30 @@
button:disabled {
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 {
font: inherit;
}