Files
MathApp_frontend/src/screens/SubscriptionScreen.tsx

491 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, type ReactNode } from 'react';
import { Check, ChevronLeft, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag, Sparkles, Ticket } from 'lucide-react';
import { api, type Plan, type SubscriptionStatus } from '@/api';
import { daysLeft } from '@/lib/subscription';
import { newIdempotencyKey } from '@/api/http';
import { BazaarPurchaseError, bazaarBuy, bazaarConsume, bazaarPending, bazaarPrices, canBuyFromBazaar, inAndroidApp } from '@/lib/bazaar';
import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet';
import { useBackStep } from '@/nav/Navigator';
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { formatJalaliDate, formatNumber, formatToman, toFa } from '@/lib/format';
import { useCompanion } from '@/state/companion';
function durationLabel(days: number): string {
if (days === 30 || days === 31) return 'یک‌ماهه';
if (days >= 360 && days <= 370) return 'یک‌ساله';
if (days === 90) return 'سه‌ماهه';
if (days === 180) return 'شش‌ماهه';
return days ? `${toFa(days)} روزه` : '';
}
/** «۷۹۸ هزار تومان» به‌جای «۷۹۸٬۰۰۰ تومان»؛ صفرهای پشتِ هم برای بچه خواندنِ قیمت را سخت می‌کرد. */
function price(value: number): string {
return value >= 1000 && value % 1000 === 0 ? `${formatNumber(value / 1000)} هزار تومان` : formatToman(value);
}
type View = 'subscription' | 'pack';
/**
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/شش‌ماهه؛ همه‌ی فصل‌ها، قلبِ نامحدود، سقفِ روزانه‌ی دستیار)
* و «بسته‌ی سوال» (سوالِ بی‌انقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
* یک‌جا کاربر را گیج نکند. از فصلِ قفل یک‌راست به فهرستِ اشتراک می‌آید (`initialView`).
*
* چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیج‌کننده»): مزیت‌های اشتراک یک بار بالای فهرست
* می‌آیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ
* انتخاب است با نام و قیمت، و فقط یک دکمه‌ی بزرگ پایینِ صفحه هست. روشِ پرداخت (زرین‌پال یا کافه‌بازار) در برگه‌ی پرداخت انتخاب می‌شود.
*/
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
const [view, setView] = useState<View | null>(initialView ?? null);
const [picked, setPicked] = useState<string | null>(null);
const [buying, setBuying] = useState<Plan | null>(null);
// داخلِ اپی که از بازار می‌خرد: قیمتِ پیشخانِ هر SKU، تا کاشیِ بازار همان را بگوید که بازار می‌گیرد.
const [storePrices, setStorePrices] = useState<Record<string, string>>({});
const look = useCompanion();
// از فهرست، «برگشت» اول به دو گزینه برمی‌گردد.
useBackStep(view !== null && !initialView, () => setView(null));
// پرداخت با درگاه داخلِ اپ در مرورگرِ گوشی باز می‌شود؛ برگشت به اپ یعنی وضعیت را دوباره بپرس.
useEffect(() => {
const onVisible = () => document.visibilityState === 'visible' && status.reload();
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [status.reload]);
const all = plans.data?.plans ?? [];
useEffect(() => {
if (!canBuyFromBazaar() || !plans.data) return;
const skus = (kind: Plan['kind']) => plans.data!.plans.filter((p) => p.kind === kind && p.bazaarSku).map((p) => p.bazaarSku);
let alive = true;
void Promise.all([bazaarPrices(skus('subscription'), 'subscription'), bazaarPrices(skus('pack'), 'inapp')]).then(
([subs, packs]) => alive && setStorePrices({ ...subs, ...packs }),
);
return () => { alive = false; };
}, [plans.data]);
const list = all.filter((p) => p.kind === view);
const monthly = all.find((p) => p.kind === 'subscription' && p.durationDays <= 31);
const cheapestSub = all.filter((p) => p.kind === 'subscription').sort((x, y) => x.price - y.price)[0];
const selected = list.find((p) => p.id === picked) ?? list.find((p) => p.popular) ?? list[0];
const active = status.data?.active ?? false;
return (
<Screen>
<div className="flex-1 overflow-y-auto">
<ScreenHeader
title={view === 'pack' ? 'بسته‌ی سوال' : view === 'subscription' ? (active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو') : 'فروشگاه'}
subtitle={view === 'pack' ? `سوالِ بیشتر از ${look.name}، بدونِ تاریخِ انقضا` : 'همه‌ی فصل‌ها، قلبِ نامحدود'}
color="#ED6956"
exact
back={view !== null && !initialView ? () => setView(null) : true}
mascot={{ mood: 'excited', size: 60 }}
/>
<div className="flex flex-col gap-4 px-4 py-5">
{view === null && <StatusCard status={status.data ?? null} loading={status.loading && !status.data} />}
{view === null ? (
<div className="flex flex-col gap-3">
<ChoiceCard
icon={<Crown size={26} />}
tone="#FF7A3D"
title={active ? 'تمدیدِ اشتراک' : 'اشتراک'}
text="همه‌ی فصل‌ها باز، با قلبِ نامحدود"
hint={cheapestSub ? `از ${price(cheapestSub.price)}` : undefined}
onClick={() => setView('subscription')}
/>
<ChoiceCard
icon={<Sparkles size={26} />}
tone="#9B7BFF"
title="بسته‌ی سوال"
text={`سوالِ بیشتر از ${look.name}، هیچ‌وقت تموم نمی‌شه`}
onClick={() => setView('pack')}
/>
</div>
) : plans.loading && !plans.data ? (
<LoadingState text="دارم فهرست رو میارم…" />
) : plans.error && !plans.data ? (
<ErrorState error={plans.error} onRetry={plans.reload} />
) : !list.length ? (
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
) : (
<>
{view === 'subscription' ? (
<Perks items={selected?.features.length ? selected.features : ['همه‌ی فصل‌ها و جزیره‌ها باز', 'قلبِ نامحدود']} />
) : (
<p className="px-1 text-center text-sm leading-7 text-body">
هر روز اول از سوال‌های رایگانِ همون روز خرج می‌شه، بعد از بسته.
</p>
)}
<div role="radiogroup" aria-label={view === 'pack' ? 'بسته‌ها' : 'مدتِ اشتراک'} className="flex flex-col gap-3">
{list.map((plan) => (
<PlanRow key={plan.id} plan={plan} monthlyPrice={monthly?.price} selected={plan.id === selected?.id} onSelect={() => setPicked(plan.id)} />
))}
</div>
</>
)}
</div>
</div>
{view !== null && selected && (
<div className="flex-shrink-0 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
<PrimaryButton color={view === 'pack' ? '#9B7BFF' : '#FF7A3D'} onClick={() => setBuying(selected)}>
{view === 'pack' ? `خریدِ ${toFa(selected.questionCredits)} سوال` : `خریدِ اشتراکِ ${durationLabel(selected.durationDays) || selected.title}`}
</PrimaryButton>
</div>
)}
<CheckoutSheet plan={buying} storePrices={storePrices} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
</Screen>
);
}
function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-4 rounded-3xl border-2 border-b-4 bg-white p-4 text-right transition-transform active:translate-y-0.5"
style={{ borderColor: `${tone}40` }}
>
<span className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl text-white" style={{ backgroundColor: tone }}>{icon}</span>
<span className="min-w-0 flex-1">
<span className="block text-lg font-black text-ink">{title}</span>
<span className="mt-0.5 block text-sm leading-6 text-body">{text}</span>
{hint && <span className="mt-1 block text-sm font-black" style={{ color: tone }}>{hint}</span>}
</span>
<ChevronLeft size={20} className="flex-shrink-0" style={{ color: tone }} />
</button>
);
}
/**
* وضعیتِ کاربر بالای فروشگاه. مشترک: یک کارتِ واضح با روزهای مانده. بی‌اشتراک: هیچ کارتی (دو گزینه‌ی زیر
* خودشان حرف را می‌زنند)، مگر سوالِ خریده‌شده یا قلبِ هدیه داشته باشد. تا وضعیت نرسیده جای خالیِ هم‌اندازه
* می‌آید، نه «نداری» که برای مشترک غلط بود.
*/
function StatusCard({ status, loading }: { status: SubscriptionStatus | null; loading: boolean }) {
if (loading) return <div className="h-[104px] animate-pulse rounded-3xl bg-soft" />;
if (!status) return null;
const days = status.active ? daysLeft(status.expiresAt) : null;
const hearts = !status.active && status.unlimitedHeartsUntil;
const extras = (
<>
{status.questionCredits > 0 && <Pill icon={<Sparkles size={14} />} text={`${toFa(status.questionCredits)} سوالِ خریده‌شده`} />}
{hearts && <Pill icon={<Heart size={14} fill="currentColor" />} text={`قلبِ نامحدود تا ${formatJalaliDate(status.unlimitedHeartsUntil!)}`} />}
</>
);
if (!status.active) {
return status.questionCredits > 0 || hearts ? <div className="flex flex-wrap gap-2">{extras}</div> : null;
}
const total = status.planDurationDays ?? 30;
const percent = days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)));
return (
<div className="flex flex-col gap-2">
<div className="rounded-3xl bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] p-4 text-white shadow-lg shadow-orange/25">
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-white/20"><Crown size={24} /></span>
<div className="min-w-0 flex-1">
<div className="text-base font-black">اشتراکت فعاله</div>
<div className="text-sm text-white/85">{status.planTitle ?? 'اشتراک'}</div>
</div>
{days !== null && (
<div className="text-center">
<div className="text-3xl font-black leading-none">{toFa(days)}</div>
<div className="mt-1 text-xs text-white/85">روز مونده</div>
</div>
)}
</div>
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-black/15">
<div className="h-full rounded-full bg-white" style={{ width: `${percent}%` }} />
</div>
</div>
{(status.questionCredits > 0) && <div className="flex flex-wrap gap-2">{extras}</div>}
</div>
);
}
function Pill({ icon, text }: { icon: ReactNode; text: string }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white px-3 py-1.5 text-sm font-bold text-violet ring-1 ring-line">
{icon} {text}
</span>
);
}
/** مزیت‌های اشتراک، یک بار. */
function Perks({ items }: { items: string[] }) {
return (
<ul className="flex flex-col gap-2.5 rounded-3xl bg-[#FFF4EC] p-4">
{items.map((f) => (
<li key={f} className="flex items-center gap-2.5 text-[15px] font-bold text-ink">
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-orange text-white"><Check size={14} strokeWidth={3.5} /></span>
{f}
</li>
))}
</ul>
);
}
/** یک ردیفِ انتخابی: نام و قیمت درشت؛ فقط یک نکته‌ی کوچک (تخفیف یا هدیه)، نه پنج خطِ ریز. */
function PlanRow({ plan, monthlyPrice, selected, onSelect }: { plan: Plan; monthlyPrice?: number; selected: boolean; onSelect: () => void }) {
const isPack = plan.kind === 'pack';
const months = Math.round(plan.durationDays / 30);
const saving = !isPack && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
const tone = isPack ? '#9B7BFF' : '#FF7A3D';
const note = isPack
? plan.heartsDays > 0 ? `+ ${toFa(plan.heartsDays)} روز قلبِ نامحدود هدیه` : ''
: saving > 0 ? `${toFa(saving)}٪ ارزان‌تر از ماهانه` : '';
return (
<button
type="button"
role="radio"
aria-checked={selected}
onClick={onSelect}
className="relative flex items-center gap-3 rounded-3xl border-2 border-b-4 bg-white px-4 py-4 text-right transition-colors active:translate-y-0.5"
style={{ borderColor: selected ? tone : '#e9e6f7', backgroundColor: selected ? `${tone}0F` : '#fff' }}
>
{plan.badge && (
<span className="absolute -top-3 left-4 rounded-full px-3 py-0.5 text-xs font-black text-white shadow-sm" style={{ backgroundColor: tone }}>
{plan.badge}
</span>
)}
<span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2"
style={{ borderColor: selected ? tone : '#d6d3ea', backgroundColor: selected ? tone : '#fff' }}
>
{selected && <Check size={14} strokeWidth={3.5} className="text-white" />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-lg font-black text-ink">
{isPack ? <>{toFa(plan.questionCredits)} سوال</> : durationLabel(plan.durationDays) || plan.title}
</span>
{note && <span className="mt-0.5 block text-sm font-bold" style={{ color: isPack ? '#7857E0' : '#2E9E6A' }}>{note}</span>}
</span>
<span className="text-left text-base font-black text-ink">{price(plan.price)}</span>
</button>
);
}
type Method = 'gateway' | 'bazaar';
/** چرا بازار برای این پلن و این‌جا ممکن نیست؛ null یعنی ممکن است. */
function bazaarBlocker(plan: Plan | null): string | null {
if (!plan?.bazaarSku) return 'این مورد در بازار نیست';
if (!inAndroidApp()) return 'فقط در اپِ اندروید';
if (!canBuyFromBazaar()) return 'اپ رو از بازار به‌روز کن';
return null;
}
/**
* خرید: سفارش ساخته می‌شود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت می‌شود، و بعد کاربر به درگاه یا به
* صفحه‌ی خریدِ بازار می‌رود. window.open بعد از await را Safariِ آیفون مسدود می‌کند؛ برای همین درگاه
* جابه‌جاییِ همین صفحه است.
*
* دو روشِ پرداخت همیشه کنارِ هم دیده می‌شوند (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ اگر بازار این‌جا ممکن نیست، کاشیِ
* آن غیرفعال است و دلیلش را زیرِ نامش می‌گوید (نه پنهان، تا کاربر بداند راهِ دیگری هم هست). پیش‌فرض زرین‌پال
* است؛ داخلِ اپ، زیرِ «کافه‌بازار» قیمتی می‌آید که خودِ بازار برای آن SKU دارد. ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ
* درون‌برنامه‌ای را محدود می‌کند؛ نگه داشتنِ زرین‌پال داخلِ اپ تصمیمِ مالک است و با بازار هماهنگ نشده.
*/
function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | null; storePrices: Record<string, string>; onClose: () => void; onPaid: () => void }) {
const blocker = bazaarBlocker(plan);
const [method, setMethod] = useState<Method>('gateway');
const [code, setCode] = useState('');
const [showCode, setShowCode] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
const isSubscription = plan?.kind === 'subscription';
// هر بار که برگه برای پلنی باز می‌شود، زرین‌پال انتخاب است (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ بازار یک لمس آن‌طرف‌تر.
useEffect(() => {
if (plan) setMethod('gateway');
}, [plan]);
const storePrice = plan?.bazaarSku ? storePrices[plan.bazaarSku] : undefined;
/**
* بازار: سفارش (با کلیدِ ثابتِ همین SKU) → کدِ تخفیف → شروع (SKU، نوع، قیمتِ امضاشده) → خرید در بازار
* → سنجشِ سرور → مصرفِ خرید (فقط بسته؛ اشتراک مصرف نمی‌شود).
*/
async function payWithBazaar(plan: Plan): Promise<'bazaar' | 'code' | null> {
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);
if (code.trim()) {
const current = await api.subscription.order(order.orderId);
// سفارشِ مانده از تلاشِ قبلی ممکن است همین کد را از قبل گرفته باشد.
if (current.amount === plan.price) {
const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
if (redeemed.orderStatus === 'paid') {
bazaarPending.clear(sku);
return 'code';
}
}
}
const start = await api.subscription.startBazaar(order.orderId);
let token: string;
try {
token = await bazaarBuy(start.sku, order.orderId, start.productType, start.dynamicPriceToken);
} catch (err) {
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return null;
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);
if (start.productType === 'inapp') bazaarConsume(token);
return 'bazaar';
}
async function pay() {
if (!plan || busy) return;
setBusy(true);
setError('');
if (method === 'bazaar') {
try {
const outcome = await payWithBazaar(plan);
if (outcome) setDone(outcome);
} 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') {
setDone('code');
setBusy(false);
return;
}
}
if (!order.paymentUrl) throw new Error('order without paymentUrl');
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
const shell = (window as unknown as { TadrisinoAndroid?: { paysInApp?: () => boolean } }).TadrisinoAndroid;
if (inAndroidApp() && !shell?.paysInApp?.()) {
// اپِ اندرویدِ قدیمی هر آدرسِ بیرونی را در مرورگرِ گوشی باز می‌کند و آن مرورگر Referer نمی‌فرستد؛ زرین‌پال
// بی‌Referer می‌گوید «دسترسی از این دامنه مجاز نمی‌باشد». پس اول pay.htmlِ خودمان در مرورگر باز می‌شود
// (src/pay.ts) و آن به درگاه می‌رود. «http://» عمداً است: WebView فقط https‌ِ همین دامنه را در خودش نگه
// می‌دارد؛ آروان بلافاصله به https می‌برد و لینک در fragment است، پس به سرور نمی‌رسد.
window.location.assign(`http://${window.location.host}/pay.html#u=${encodeURIComponent(order.paymentUrl)}`);
setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500);
} else {
// مرورگر، یا اپی که پرداخت را در خودش باز می‌کند (paysInApp): همین صفحه به درگاه می‌رود و Referer دارد.
window.location.assign(order.paymentUrl);
}
} catch (err) {
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
setBusy(false);
}
}
const close = () => {
if (done) onPaid();
else onClose();
setCode('');
setShowCode(false);
setError('');
setDone(null);
};
const doneText =
done === 'gateway' ? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همین‌جا؛ خریدت خودش فعال می‌شه.'
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
: isSubscription ? 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش می‌کنه؛ لغوِ تمدید از بخشِ «اشتراک‌ها»ی خودِ بازاره.'
: 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.';
return (
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? (plan.kind === 'pack' ? `${toFa(plan.questionCredits)} سوال` : `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}`) : ''}>
{done ? (
<>
<p className="mb-5 text-sm leading-7 text-muted">{doneText}</p>
<PrimaryButton onClick={close}>{done === 'gateway' ? 'باشه' : 'عالیه!'}</PrimaryButton>
</>
) : plan && (
<>
<div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3.5">
<span className="text-sm font-bold text-body">مبلغ</span>
<span className="text-lg font-black text-ink">{method === 'bazaar' && storePrice ? storePrice : price(plan.price)}</span>
</div>
<div role="radiogroup" aria-label="روشِ پرداخت" className="mb-4">
<span className="mb-2 block text-sm font-black text-ink">با چی پرداخت می‌کنی؟</span>
<div className="grid grid-cols-2 gap-2">
<MethodTile selected={method === 'gateway'} onSelect={() => setMethod('gateway')} icon={<CreditCard size={22} />} title="کارتِ بانکی" note="زرین‌پال" />
<MethodTile selected={method === 'bazaar'} onSelect={() => setMethod('bazaar')} icon={<ShoppingBag size={22} />} title="کافه‌بازار" note={blocker ?? storePrice ?? 'با حسابِ بازارت'} disabled={!!blocker} />
</div>
</div>
{showCode ? (
<label className="mb-4 block">
<span className="mb-2 block text-sm font-bold text-body">کدِ تخفیف</span>
<input
value={code}
onChange={(e) => setCode(e.target.value.trim().slice(0, 40))}
dir="ltr"
autoCapitalize="characters"
placeholder="CODE"
className="w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-coral"
/>
</label>
) : (
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex items-center gap-1.5 text-sm font-bold text-coral">
<Ticket size={15} /> کدِ تخفیف دارم
</button>
)}
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
<PrimaryButton loading={busy} onClick={() => void pay()}>
{method === 'bazaar' ? 'پرداخت با کافه‌بازار' : 'پرداخت با کارتِ بانکی'}
</PrimaryButton>
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
<ShieldCheck size={14} />
{method === 'bazaar'
? isSubscription ? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید می‌شه.' : 'پرداخت در خودِ بازار انجام می‌شه.'
: 'پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.'}
</p>
</>
)}
</Sheet>
);
}
function MethodTile({ selected, onSelect, icon, title, note, disabled = false }: { selected: boolean; onSelect: () => void; icon: ReactNode; title: string; note: string; disabled?: boolean }) {
const on = selected && !disabled;
return (
<button
type="button"
role="radio"
aria-checked={on}
aria-disabled={disabled}
disabled={disabled}
onClick={onSelect}
className={`relative flex flex-col items-center gap-1 rounded-2xl border-2 border-b-4 px-2 py-3 text-center transition-colors ${
disabled ? 'cursor-not-allowed border-line bg-soft opacity-70' : on ? 'border-coral bg-[#fff4f2]' : 'border-line bg-white active:translate-y-0.5'
}`}
>
{on && (
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-coral text-white">
<Check size={12} strokeWidth={4} />
</span>
)}
<span className={on ? 'text-coral' : 'text-muted'}>{icon}</span>
<span className="text-[15px] font-black text-ink">{title}</span>
<span className="text-xs leading-5 text-muted">{note}</span>
</button>
);
}