forked from Tadrisino/MathApp_frontend
491 lines
27 KiB
TypeScript
491 lines
27 KiB
TypeScript
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>
|
||
);
|
||
}
|