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:
@@ -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 }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
96
src/lib/bazaar.ts
Normal 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)),
|
||||
};
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user