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'; import { friendlyError } from '@/lib/errors'; import { formatJalaliDate, formatToman, toFa } from '@/lib/format'; function durationLabel(days: number): string { if (days === 30 || days === 31) return 'یک‌ماهه'; if (days >= 360 && days <= 370) return 'یک‌ساله'; if (days === 90) return 'سه‌ماهه'; return days ? `${toFa(days)} روزه` : ''; } /** * «بسته‌ی سوال» (از ۱۴۰۵-۰۷ به‌جای اشتراکِ ماهانه/سالانه). هر کس به اندازه‌ی مصرفش سوال از * دستیار می‌خرد؛ اعتبار منقضی نمی‌شود و هر بسته چند روز قلبِ نامحدود هم هدیه دارد. هر روز * اول سوال‌های رایگانِ همان روز خرج می‌شوند، بعد اعتبار. */ export function SubscriptionScreen() { const plans = useAsync(() => api.subscription.plans(), []); 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; const reload = () => { status.reload(); quota.reload(); }; return (
اعتبارِ تو
{toFa(credits)} سوال
{quota.data && (
رایگانِ امروز
{toFa(quota.data.remainingQuota)} از {toFa(quota.data.freeDaily)}
)}

هر روز {toFa(plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول رایگانه؛ بعدش از اعتبارت کم می‌شه. اعتبار هیچ‌وقت منقضی نمی‌شه.

{heartsUntil && (

قلبِ نامحدود تا {formatJalaliDate(heartsUntil)}

)} {status.data?.active && (

اشتراکِ قبلیت هم فعاله{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}}.

)}
{plans.loading ? ( ) : plans.error ? ( ) : !plans.data?.plans.length ? ( ) : ( plans.data.plans.map((plan) => ( setBuying(plan)} /> )) )}

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

setBuying(null)} onPaid={() => { setBuying(null); reload(); }} />
); } 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'; return (
{plan.badge && ( {plan.badge} )}

{isPack ? <>{toFa(plan.questionCredits)} سوال : plan.title}

{isPack ? 'از دستیارِ «پی»، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}

{formatToman(plan.price)}

    {isPack && plan.heartsDays > 0 && (
  • هدیه: {toFa(plan.heartsDays)} روز قلبِ نامحدود
  • )} {!isPack && plan.aiDailyQuota > 0 && (
  • {toFa(plan.aiDailyQuota)} سوال در روز از دستیار
  • )} {plan.features.map((f) => (
  • {f}
  • ))}
); } /** * خرید: سفارش ساخته می‌شود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت می‌شود، * و بعد کاربر در همین برگه به درگاه می‌رود. window.open بعد از await را * Safariِ آیفون مسدود می‌کند؛ برای همین جابه‌جاییِ همین صفحه است. */ 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 [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') { 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)'); 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); }; return ( {done ? ( <>

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

عالیه! ) : plan && ( <>
مبلغ {formatToman(plan.price)}
{bazaar ? null : showCode ? ( ) : ( )} {error &&
{error}
} void pay()}>پرداخت )}
); }