question packs page, free/purchased counters in the tutor, packs from the no-hearts page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,115 +14,125 @@ function durationLabel(days: number): string {
|
||||
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<Plan | null>(null);
|
||||
|
||||
const activePlanId = status.data?.active ? status.data.planId : null;
|
||||
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
|
||||
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
|
||||
const reload = () => { status.reload(); quota.reload(); };
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ScreenHeader title="اشتراکِ ویژه" subtitle="یادگیری بدونِ وقفه" color="#ED6956" mascot={{ char: 'sigma', mood: 'excited', size: 60 }} />
|
||||
<ScreenHeader title="بستهی سوال" subtitle="هر وقت لازم داشتی، سوال بخر" color="#ED6956" mascot={{ char: 'pi', mood: 'excited', size: 60 }} />
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 py-5">
|
||||
{status.data?.active && (
|
||||
<div className="flex items-center gap-3 rounded-3xl border-2 border-mint bg-[#EAF8F0] p-4">
|
||||
<Crown size={22} className="flex-shrink-0 text-mint" />
|
||||
<div className="text-sm leading-7 text-ink">
|
||||
<b>اشتراکت فعاله.</b>
|
||||
{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}.</>}
|
||||
<div className="text-xs text-muted">اگه دوباره بخری، از روزِ پایانِ همین اشتراک تمدید میشه.</div>
|
||||
<div className="rounded-3xl border border-line bg-white p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-[#F5F1FF] text-violet"><Sparkles size={22} /></span>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-bold text-muted">اعتبارِ تو</div>
|
||||
<div className="text-2xl font-black text-ink">{toFa(credits)} <span className="text-sm font-bold text-body">سوال</span></div>
|
||||
</div>
|
||||
{quota.data && (
|
||||
<div className="text-left text-xs leading-6 text-muted">
|
||||
رایگانِ امروز<br />
|
||||
<b className="text-base text-ink">{toFa(quota.data.remainingQuota)}</b> از {toFa(quota.data.freeDaily)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4">
|
||||
<Heart size={22} className="flex-shrink-0 text-coral" fill="#ED6956" />
|
||||
<p className="text-sm leading-7 text-body">
|
||||
با اشتراک هر روز سوالِ بیشتری از دستیار میپرسی و قلبهات هیچوقت تموم نمیشن.
|
||||
<p className="mt-3 border-t border-line pt-3 text-xs leading-6 text-muted">
|
||||
هر روز {toFa(plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول رایگانه؛ بعدش از اعتبارت کم میشه. اعتبار هیچوقت منقضی نمیشه.
|
||||
</p>
|
||||
{heartsUntil && (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-coral">
|
||||
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود تا {formatJalaliDate(heartsUntil)}
|
||||
</p>
|
||||
)}
|
||||
{status.data?.active && (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-mint">
|
||||
<Crown size={13} /> اشتراکِ قبلیت هم فعاله{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}</>}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plans.loading ? (
|
||||
<LoadingState text="دارم پلنها رو میارم…" />
|
||||
<LoadingState text="دارم بستهها رو میارم…" />
|
||||
) : plans.error ? (
|
||||
<ErrorState error={plans.error} onRetry={plans.reload} />
|
||||
) : !plans.data?.plans.length ? (
|
||||
<EmptyState text="فعلاً پلنی برای خرید تعریف نشده" />
|
||||
<EmptyState text="فعلاً بستهای برای خرید تعریف نشده" />
|
||||
) : (
|
||||
plans.data.plans.map((plan) => {
|
||||
const featured = plan.popular;
|
||||
const isCurrent = plan.id === activePlanId;
|
||||
return (
|
||||
<div key={plan.id} className="relative pt-3">
|
||||
{plan.badge && (
|
||||
<span className="absolute right-5 top-0 z-10 rounded-full bg-[#FFD23F] px-3 py-1 text-[11px] font-black text-ink shadow">
|
||||
{plan.badge}
|
||||
</span>
|
||||
)}
|
||||
<div className={`rounded-3xl p-5 ${featured ? 'bg-orange text-white shadow-lg shadow-orange/30' : 'border border-line bg-white text-ink'}`}>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-black">{plan.title}</h3>
|
||||
<p className={`mt-0.5 text-xs ${featured ? 'text-white/75' : 'text-muted'}`}>{durationLabel(plan.durationDays)}</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-base font-black">{plan.price === 0 ? 'رایگان' : formatToman(plan.price)}</p>
|
||||
{plan.discountPercent > 0 && (
|
||||
<span className={`mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-black ${featured ? 'bg-white/20' : 'bg-[#fde9e5] text-coral'}`}>
|
||||
{toFa(plan.discountPercent)}٪ تخفیف
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="mb-4 flex flex-col gap-2">
|
||||
{/* سقفِ دستیار از خودِ پلن ساخته میشود (Plan.ai_daily_quota)، نه از متنِ ویژگیها،
|
||||
تا عوض کردنِ عدد در ادمین کارت را هم درست عوض کند. */}
|
||||
{plan.aiDailyQuota > 0 && (
|
||||
<li className="flex items-center gap-2 text-sm font-bold">
|
||||
<Sparkles size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
|
||||
<span>
|
||||
{toFa(plan.aiDailyQuota)} سوال در روز از دستیارِ «پی»
|
||||
<span className={`font-medium ${featured ? 'text-white/75' : 'text-muted'}`}>
|
||||
{' '}(رایگان: {toFa(plans.data!.freeAiDailyQuota)})
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-sm">
|
||||
<Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
|
||||
<span className={featured ? 'text-white/90' : 'text-body'}>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
disabled={plan.price === 0 || isCurrent}
|
||||
onClick={() => setBuying(plan)}
|
||||
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-60 ${
|
||||
featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'
|
||||
}`}
|
||||
>
|
||||
{isCurrent ? 'اشتراکِ فعلیِ تو' : plan.price === 0 ? 'رایگان' : status.data?.active ? 'تمدید با این پلن' : 'انتخابِ این پلن'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
plans.data.plans.map((plan) => <PackCard key={plan.id} plan={plan} onBuy={() => setBuying(plan)} />)
|
||||
)}
|
||||
|
||||
<p className="pb-2 text-center text-[11px] leading-6 text-faint">پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaidWithCode={() => { setBuying(null); status.reload(); }} />
|
||||
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaidWithCode={() => { setBuying(null); reload(); }} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) {
|
||||
const featured = plan.popular;
|
||||
const isPack = plan.kind === 'pack';
|
||||
const perQuestion = isPack && plan.questionCredits ? Math.round(plan.price / plan.questionCredits / 10) * 10 : 0;
|
||||
const tone = featured ? 'text-white/80' : 'text-muted';
|
||||
return (
|
||||
<div className="relative pt-3">
|
||||
{plan.badge && (
|
||||
<span className="absolute right-5 top-0 z-10 rounded-full bg-[#FFD23F] px-3 py-1 text-[11px] font-black text-ink shadow">{plan.badge}</span>
|
||||
)}
|
||||
<div className={`rounded-3xl p-5 ${featured ? 'bg-orange text-white shadow-lg shadow-orange/30' : 'border border-line bg-white text-ink'}`}>
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black">{isPack ? <>{toFa(plan.questionCredits)} <span className="text-base">سوال</span></> : plan.title}</h3>
|
||||
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ «پی»، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-base font-black">{formatToman(plan.price)}</p>
|
||||
{perQuestion > 0 && <p className={`mt-0.5 text-[11px] ${tone}`}>هر سوال {formatToman(perQuestion)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="mb-4 flex flex-col gap-2 text-sm">
|
||||
{isPack && plan.heartsDays > 0 && (
|
||||
<li className="flex items-center gap-2 font-bold">
|
||||
<Heart size={14} fill={featured ? '#fff' : '#ED6956'} className={featured ? 'text-white' : 'text-coral'} />
|
||||
هدیه: {toFa(plan.heartsDays)} روز قلبِ نامحدود
|
||||
</li>
|
||||
)}
|
||||
{!isPack && plan.aiDailyQuota > 0 && (
|
||||
<li className="flex items-center gap-2 font-bold"><Sparkles size={14} /> {toFa(plan.aiDailyQuota)} سوال در روز از دستیار</li>
|
||||
)}
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-center gap-2">
|
||||
<Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
|
||||
<span className={featured ? 'text-white/90' : 'text-body'}>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBuy}
|
||||
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] ${featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'}`}
|
||||
>
|
||||
خرید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* خرید: سفارش ساخته میشود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت میشود،
|
||||
* و بعد کاربر در همین برگه به درگاه میرود. window.open بعد از await را
|
||||
@@ -166,10 +176,10 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={plan !== null} onClose={close} title={freeDone ? 'اشتراکت فعال شد! 🎉' : plan?.title}>
|
||||
<Sheet open={plan !== null} onClose={close} title={freeDone ? 'خریدت انجام شد! 🎉' : plan?.title}>
|
||||
{freeDone ? (
|
||||
<>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">کدِ تخفیفت کلِ مبلغ رو پوشوند و اشتراکت همین الان فعال شد.</p>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.</p>
|
||||
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
||||
</>
|
||||
) : plan && (
|
||||
|
||||
Reference in New Issue
Block a user