@@ -1,12 +1,11 @@
import { useEffect , useState , type ReactNode } from 'react' ;
import { Check , ChevronLeft , Crown , CreditCard , Heart , ShieldCheck , ShoppingBag , Sparkles , Ticket } from 'lucide-react' ;
import { Check , 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' ;
@@ -25,28 +24,19 @@ function price(value: number): string {
return value >= 1000 && value % 1000 === 0 ? ` ${ formatNumber ( value / 1000 ) } هزار تومان ` : formatToman ( value ) ;
}
type View = 'subscription' | 'pack' ;
/**
* فروشگاه دو محصول دارد: « اشتراک» (یک/سه/ششماهه؛ همهی فصلها، قلبِ نامحدود، سقفِ روزانهی دستی ار)
* و «بستهی سوال» (سوالِ بیانقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
* یکجا کاربر را گیج نکند. از فصلِ قفل یکراست به فهرستِ اشتراک میآید (`initialView`) .
*
* چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیجکننده»): مزیتهای اشتراک یک بار بالای فهرست
* میآیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ
* انتخاب است با نام و قیمت، و فقط یک دکمهی بزرگ پایینِ صفحه هست. روشِ پرداخت (زرینپال یا کافهبازار) در برگهی پرداخت انتخاب میشود.
* فروشگاه فقط اشتراک دارد (یک/سه/ششماهه؛ تصمیمِ سینیور ۲۰۲۶-۱ ۰ -۱۲: بستهی سوال دیگر فروخته نمیشود). هر ک ارت
* خودش میگوید چه میگیری ( خواستِ سینیور) : مزیتهای پلن (`features`، از ادمین) و سقفِ روزانهی دستیار از `aiDailyQuota`،
* تا عدد با سقفِ واقعی جدا نیفتد. یک دکمهی بزرگ پایینِ صفحه؛ روشِ پرداخت (زرینپال یا کافهبازار) در برگهی پرداخت .
*/
export function SubscriptionScreen( { initialView } : { initialView? : View } ) {
export function SubscriptionScreen() {
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 ( ( ) = > {
@@ -55,19 +45,16 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
return ( ) = > document . removeEventListener ( 'visibilitychange' , onVisible ) ;
} , [ status . reload ] ) ;
const all = plans . data ? . plans ? ? [ ] ;
const list = ( plans . data ? . plans ? ? [ ] ) . filter ( ( p ) = > p . kind === 'subscription' ) ;
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 ) ;
const skus = list . filter ( ( p ) = > p . bazaarSku ) . map ( ( p ) = > p . bazaarSku ) ;
if ( ! canBuyFromBazaar ( ) || ! skus . length ) return ;
let alive = true ;
void Promise . all ( [ bazaarPrices( skus ( 'subscription' ) , 'subscription' ) , bazaarPrices ( skus ( 'pack' ) , 'inapp' ) ] ) . then (
( [ subs , packs ] ) = > alive && setStorePrices ( { . . . subs , . . . packs } ) ,
) ;
void bazaarPrices ( skus , 'subscription' ) . then ( ( prices ) = > alive && setStorePrices ( prices ) ) ;
return ( ) = > { alive = false ; } ;
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ 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 monthly = list . find ( ( p ) = > p . durationDays < = 31 ) ;
const selected = list . find ( ( p ) = > p . id === picked ) ? ? list . find ( ( p ) = > p . popular ) ? ? list [ 0 ] ;
const active = status . data ? . active ? ? false ;
@@ -75,64 +62,36 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
< Screen >
< div className = "flex-1 overflow-y-auto" >
< ScreenHeader
title = { view === 'pack' ? 'بستهی سوال' : view === 'subscription' ? ( active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو' ) : 'فروشگاه' }
subtitle = { view === 'pack' ? ` سوالِ بیشتر از ${ look . name } ، بدونِ تاریخِ انقضا ` : ' همهی فصلها، قلبِ نامحدود' }
title = { active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو' }
subtitle = " همهی فصلها، قلبِ نامحدود"
color = "#ED6956"
exact
back = { view !== null && ! initialView ? ( ) = > setView ( null ) : true }
back
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 ? (
< StatusCard status = { status . data ? ? null } loading = { status . loading && ! status . data } / >
{ 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 role = "radiogroup" aria-label = "مدتِ اشتراک" className = "flex flex-col gap-3" >
{ list . map ( ( plan ) = > (
< PlanRow key = { plan . id } plan = { plan } assistant = { look . name } monthlyPrice = { monthly ? . price } selected = { plan . id === selected ? . id } onSelect = { ( ) = > setPicked ( plan . id ) } / >
) ) }
< / div >
) }
< / div >
< / div >
{ view !== null && selected && (
{ 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 color = " #FF7A3D" onClick = { ( ) = > setBuying ( selected ) } >
{ ` خریدِ اشتراکِ ${ durationLabel ( selected . durationDays ) || selected . title } ` }
< / PrimaryButton >
< / div >
) }
@@ -142,25 +101,6 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
) ;
}
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 >
) ;
}
/**
* وضعیتِ کاربر بالای فروشگاه. مشترک: یک کارتِ واضح با روزهای مانده. بیاشتراک: هیچ کارتی (دو گزینهی زیر
* خودشان حرف را میزنند)، مگر سوالِ خریدهشده یا قلبِ هدیه داشته باشد. تا وضعیت نرسیده جای خالیِ هماندازه
@@ -215,36 +155,19 @@ function Pill({ icon, text }: { icon: ReactNode; text: string }) {
) ;
}
/** مزیته ای اشتراک، یک بار . */
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' ;
/** یک کارتِ انتخابی: نام و قیمت درشت، و زیرش آنچه این اشتراک میدهد (خواستِ سینیور: روی هر کارت) . */
function PlanRow ( { plan , assistant , monthlyPrice , selected , onSelect } : { plan : Plan ; assistant : string ; monthlyPrice? : number ; selected : boolean ; onSelect : ( ) = > void } ) {
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 ) } ٪ ارزانتر از ماهانه ` : '' ;
const saving = monthlyPrice && months > 1 ? Math . round ( ( 1 - plan . price / ( monthlyPrice * months ) ) * 100 ) : 0 ;
const tone = '#FF7A3D' ;
const perks = [ . . . plan . features , ` ${ toFa ( plan . aiDailyQuota ) } سوال در روز از ${ assistant } ` ] ;
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"
className = "relative flex flex-col 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 && (
@@ -252,19 +175,27 @@ function PlanRow({ plan, monthlyPrice, selected, onSelect }: { plan: Plan; month
{ 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 className = "flex items-center gap-3" >
< 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 >
{ note && <span className = "mt-0.5 block text-sm font-bold" style = { { color : isPack ? '#7857E0' : '#2E9E6A' } } > { note } < / span > }
< span className = "min-w-0 flex-1" >
< span className = "block text-lg font-black text-ink" > { durationLabel ( plan . durationDays ) || plan . title } < / span >
{ saving > 0 && < span className = "mt-0.5 block text-sm font-bold text-[#2E9E6A]" > { toFa ( saving ) } ٪ ا ر ز ا ن ت ر ا ز م ا ه ا ن ه < / span > }
< / span >
< span className = "text-left text-base font-black text-ink" > { price ( plan . price ) } < / span >
< / span >
< span className = "text-left text-base font-black text-ink" > { price ( plan . price ) } < / span >
< ul className = "flex flex-col gap-1.5 border-t border-line pt-3" >
{ perks . map ( ( f ) = > (
< li key = { f } className = "flex items-center gap-2 text-sm font-bold text-body" >
< span className = "flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-orange text-white" > < Check size = { 12 } strokeWidth = { 3.5 } / > < / span >
{ f }
< / li >
) ) }
< / ul >
< / button >
) ;
}
@@ -411,7 +342,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
: 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.' ;
return (
< Sheet open = { plan !== null } onClose = { close } title = { done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? ( plan . kind === 'pack' ? ` ${ toFa ( plan . questionCredits ) } سوال ` : ` اشتراکِ ${ durationLabel ( plan . durationDays ) || plan . title } ` ) : '' } >
< Sheet open = { plan !== null } onClose = { close } title = { done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? ` اشتراکِ ${ durationLabel ( plan . durationDays ) || plan . title } ` : '' } >
{ done ? (
< >
< p className = "mb-5 text-sm leading-7 text-muted" > { doneText } < / p >