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(initialView ?? null); const [picked, setPicked] = useState(null); const [buying, setBuying] = useState(null); // داخلِ اپی که از بازار می‌خرد: قیمتِ پیشخانِ هر SKU، تا کاشیِ بازار همان را بگوید که بازار می‌گیرد. const [storePrices, setStorePrices] = useState>({}); 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 (
setView(null) : true} mascot={{ mood: 'excited', size: 60 }} />
{view === null && } {view === null ? (
} tone="#FF7A3D" title={active ? 'تمدیدِ اشتراک' : 'اشتراک'} text="همه‌ی فصل‌ها باز، با قلبِ نامحدود" hint={cheapestSub ? `از ${price(cheapestSub.price)}` : undefined} onClick={() => setView('subscription')} /> } tone="#9B7BFF" title="بسته‌ی سوال" text={`سوالِ بیشتر از ${look.name}، هیچ‌وقت تموم نمی‌شه`} onClick={() => setView('pack')} />
) : plans.loading && !plans.data ? ( ) : plans.error && !plans.data ? ( ) : !list.length ? ( ) : ( <> {view === 'subscription' ? ( ) : (

هر روز اول از سوال‌های رایگانِ همون روز خرج می‌شه، بعد از بسته.

)}
{list.map((plan) => ( setPicked(plan.id)} /> ))}
)}
{view !== null && selected && (
setBuying(selected)}> {view === 'pack' ? `خریدِ ${toFa(selected.questionCredits)} سوال` : `خریدِ اشتراکِ ${durationLabel(selected.durationDays) || selected.title}`}
)} setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
); } function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) { return ( ); } /** * وضعیتِ کاربر بالای فروشگاه. مشترک: یک کارتِ واضح با روزهای مانده. بی‌اشتراک: هیچ کارتی (دو گزینه‌ی زیر * خودشان حرف را می‌زنند)، مگر سوالِ خریده‌شده یا قلبِ هدیه داشته باشد. تا وضعیت نرسیده جای خالیِ هم‌اندازه * می‌آید، نه «نداری» که برای مشترک غلط بود. */ function StatusCard({ status, loading }: { status: SubscriptionStatus | null; loading: boolean }) { if (loading) return
; if (!status) return null; const days = status.active ? daysLeft(status.expiresAt) : null; const hearts = !status.active && status.unlimitedHeartsUntil; const extras = ( <> {status.questionCredits > 0 && } text={`${toFa(status.questionCredits)} سوالِ خریده‌شده`} />} {hearts && } text={`قلبِ نامحدود تا ${formatJalaliDate(status.unlimitedHeartsUntil!)}`} />} ); if (!status.active) { return status.questionCredits > 0 || hearts ?
{extras}
: null; } const total = status.planDurationDays ?? 30; const percent = days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100))); return (
اشتراکت فعاله
{status.planTitle ?? 'اشتراک'}
{days !== null && (
{toFa(days)}
روز مونده
)}
{(status.questionCredits > 0) &&
{extras}
}
); } function Pill({ icon, text }: { icon: ReactNode; text: string }) { return ( {icon} {text} ); } /** مزیت‌های اشتراک، یک بار. */ function Perks({ items }: { items: string[] }) { return (
    {items.map((f) => (
  • {f}
  • ))}
); } /** یک ردیفِ انتخابی: نام و قیمت درشت؛ فقط یک نکته‌ی کوچک (تخفیف یا هدیه)، نه پنج خطِ ریز. */ 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 ( ); } 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; onClose: () => void; onPaid: () => void }) { const blocker = bazaarBlocker(plan); const [method, setMethod] = useState('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 ( {done ? ( <>

{doneText}

{done === 'gateway' ? 'باشه' : 'عالیه!'} ) : plan && ( <>
مبلغ {method === 'bazaar' && storePrice ? storePrice : price(plan.price)}
با چی پرداخت می‌کنی؟
setMethod('gateway')} icon={} title="کارتِ بانکی" note="زرین‌پال" /> setMethod('bazaar')} icon={} title="کافه‌بازار" note={blocker ?? storePrice ?? 'با حسابِ بازارت'} disabled={!!blocker} />
{showCode ? ( ) : ( )} {error &&
{error}
} void pay()}> {method === 'bazaar' ? 'پرداخت با کافه‌بازار' : 'پرداخت با کارتِ بانکی'}

{method === 'bazaar' ? isSubscription ? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید می‌شه.' : 'پرداخت در خودِ بازار انجام می‌شه.' : 'پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.'}

)}
); } 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 ( ); }