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:
2026-10-02 11:41:43 +03:30
parent 44871894e0
commit b860c77b91
7 changed files with 147 additions and 90 deletions

View File

@@ -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 && (