import { useState, type ReactNode } from 'react'; import { Check, ChevronLeft, ChevronRight, Crown, Heart, Sparkles, Ticket } from 'lucide-react'; import { api, type Plan, type SubscriptionStatus, type TutorQuota } from '@/api'; import { daysLeft } from '@/lib/subscription'; 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 'سه‌ماهه'; if (days === 180) return 'شش‌ماهه'; return days ? `${toFa(days)} روزه` : ''; } 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 quota = useAsync(() => api.ai.quota(), [], { key: 'ai-quota' }); const [view, setView] = useState(initialView ?? null); 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(); }; const list = plans.data?.plans.filter((p) => p.kind === view) ?? []; const monthly = plans.data?.plans.find((p) => p.kind === 'subscription' && p.durationDays <= 31); return (
{view === null ? (
} tone="#FF7A3D" title="اشتراکِ ماهانه" text="همه‌ی فصل‌ها و جزیره‌ها باز، با قلبِ نامحدود" hint={monthly ? `از ${formatToman(monthly.price)}` : undefined} onClick={() => setView('subscription')} /> } tone="#9B7BFF" title="بسته‌ی سوال" text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچ‌وقت منقضی نمی‌شه" onClick={() => setView('pack')} />
) : ( <> {!initialView && ( )} {plans.loading ? ( ) : plans.error ? ( ) : !list.length ? ( ) : ( list.map((plan) => ( setBuying(plan)} /> )) )} {view === 'pack' && (

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

)} )}

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

setBuying(null)} onPaid={() => { setBuying(null); reload(); }} />
); } function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) { return ( ); } /** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */ /** تا وضعیت نرسیده جای خالیِ هم‌اندازه نشان داده می‌شود؛ قبلاً «نداری» می‌آمد و برای مشترک غلط بود. */ const Placeholder = ({ className }: { className: string }) => ; function StatusCard({ status, statusLoading, credits, heartsUntil, quota, quotaLoading }: { status: SubscriptionStatus | null; statusLoading: boolean; credits: number; heartsUntil: string | null; quota: TutorQuota | null; quotaLoading: boolean; }) { const days = status?.active ? daysLeft(status.expiresAt) : null; return (
اشتراکِ تو
{statusLoading ? : status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && · {toFa(days)} روز مونده} : 'نداری'}
{quotaLoading && } {quota && (
سوالِ امروز
{toFa(quota.remainingQuota)} از {toFa(status?.aiDailyQuota ?? quota.freeDaily)}
)}
اعتبار: {statusLoading && quotaLoading ? : <>{toFa(credits)} سوال} {(status?.active || heartsUntil) && ( قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}} )} {status?.active && status.expiresAt && تا {formatJalaliDate(status.expiresAt)}}
); } function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthlyPrice?: number; available: boolean; onBuy: () => void }) { const months = Math.round(plan.durationDays / 30); const saving = plan.kind === 'subscription' && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0; 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)}

{months > 1 && !isPack &&

ماهی {formatToman(Math.round(plan.price / months / 1000) * 1000)}

} {saving > 0 &&

{toFa(saving)}٪ ارزان‌تر

}
    {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()}>پرداخت )}
); }