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

@@ -130,6 +130,9 @@ export const api = {
}, },
ai: { ai: {
/** «امروز X سوالِ رایگان + Y سوالِ خریده‌شده»، پیش از اولین سوال. */
quota: () => http.get<T.TutorQuota>('/ai/quota'),
/** /**
* پرسش با پاسخِ تکه‌تکه. `lessonId` پاسخ را به کتابِ همان درس مستند می‌کند؛ * پرسش با پاسخِ تکه‌تکه. `lessonId` پاسخ را به کتابِ همان درس مستند می‌کند؛
* `questionId` به دستیار می‌گوید دانش‌آموز کدام سوال را غلط زده. * `questionId` به دستیار می‌گوید دانش‌آموز کدام سوال را غلط زده.

View File

@@ -210,7 +210,10 @@ export interface AskDone {
conversationId: string; conversationId: string;
messageId: string; messageId: string;
suggestedFollowUps: string[]; suggestedFollowUps: string[];
/** سوال‌های رایگانِ باقیِ امروز. */
remainingQuota: number; remainingQuota: number;
/** اعتبارِ بسته‌های سوال؛ منقضی نمی‌شود. */
credits?: number;
usedChunks: UsedChunk[]; usedChunks: UsedChunk[];
diagram?: Diagram | null; diagram?: Diagram | null;
} }
@@ -249,6 +252,12 @@ export interface Plan {
badge: string; badge: string;
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */ /** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
aiDailyQuota: number; aiDailyQuota: number;
/** «pack» بسته‌ی سوال است (از ۱۴۰۵-۰۷)؛ «subscription» اشتراکِ زمانیِ قدیمی. */
kind: 'pack' | 'subscription';
/** بسته: سوال‌هایی که به اعتبار اضافه می‌شود. */
questionCredits: number;
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
heartsDays: number;
} }
export interface PlansResponse { export interface PlansResponse {
@@ -258,9 +267,21 @@ export interface PlansResponse {
} }
export interface SubscriptionStatus { export interface SubscriptionStatus {
/** اشتراکِ زمانیِ قدیمی (ماهانه/سالانه)، نه بسته. */
active: boolean; active: boolean;
planId: string | null; planId: string | null;
expiresAt: string | null; expiresAt: string | null;
/** سوال‌های خریده‌شده‌ی باقی‌مانده. */
questionCredits: number;
/** پایانِ قلبِ نامحدودِ هدیه‌ی بسته‌ها؛ null یعنی ندارد. */
unlimitedHeartsUntil: string | null;
}
/** GET /ai/quota */
export interface TutorQuota {
freeDaily: number;
remainingQuota: number;
credits: number;
} }
export interface PurchaseResponse { export interface PurchaseResponse {

View File

@@ -1,11 +1,11 @@
import { Brain, Crown, Home, Map, UserRound, type LucideIcon } from 'lucide-react'; import { Brain, ShoppingBag, Home, Map, UserRound, type LucideIcon } from 'lucide-react';
import type { TabName } from '@/nav/routes'; import type { TabName } from '@/nav/routes';
const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [ const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
{ id: 'home', icon: Home, label: 'خانه' }, { id: 'home', icon: Home, label: 'خانه' },
{ id: 'map', icon: Map, label: 'نقشه' }, { id: 'map', icon: Map, label: 'نقشه' },
{ id: 'ask', icon: Brain, label: 'دستیار' }, { id: 'ask', icon: Brain, label: 'دستیار' },
{ id: 'subscription', icon: Crown, label: 'اشتراک' }, { id: 'subscription', icon: ShoppingBag, label: 'بسته‌ها' },
{ id: 'profile', icon: UserRound, label: 'من' }, { id: 'profile', icon: UserRound, label: 'من' },
]; ];

View File

@@ -21,7 +21,11 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
const chat = useAskState(); const chat = useAskState();
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [quota, setQuota] = useState<number | null>(null); // «X سوالِ رایگانِ امروز + Y سوالِ خریده‌شده»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
const [quota, setQuota] = useState<{ free: number; credits: number } | null>(null);
useEffect(() => {
api.ai.quota().then((q) => setQuota({ free: q.remainingQuota, credits: q.credits })).catch(() => {});
}, []);
const listEnd = useRef<HTMLDivElement>(null); const listEnd = useRef<HTMLDivElement>(null);
const autoSent = useRef(false); const autoSent = useRef(false);
@@ -91,7 +95,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
diagram: done.diagram ?? null, diagram: done.diagram ?? null,
}); });
askStore.setConversation(done.conversationId); askStore.setConversation(done.conversationId);
setQuota(done.remainingQuota); setQuota((q) => ({ free: done.remainingQuota, credits: done.credits ?? q?.credits ?? 0 }));
} catch (err) { } catch (err) {
// سقفِ روزانه (نه throttleِ چندثانیه‌ای): «دوباره» تا فردا بی‌فایده است. // سقفِ روزانه (نه throttleِ چندثانیه‌ای): «دوباره» تا فردا بی‌فایده است.
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال'); const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
@@ -99,7 +103,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
text: friendlyError(err, 'جوابی نرسید'), text: friendlyError(err, 'جوابی نرسید'),
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }), ...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
}); });
if (quotaReached) setQuota(0); if (quotaReached) setQuota({ free: 0, credits: 0 });
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -194,7 +198,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
<Send size={16} className="-scale-x-100" /> <Send size={16} className="-scale-x-100" />
</button> </button>
</div> </div>
{quota !== null && <p className="mt-1.5 text-center text-[10px] text-muted">امروز {toFa(quota)} سوالِ دیگه می‌تونی بپرسی</p>} {quota !== null && <QuotaLine free={quota.free} credits={quota.credits} />}
</form> </form>
</Screen> </Screen>
); );
@@ -293,7 +297,7 @@ function Message({
<div className="flex flex-wrap items-center gap-2 pr-10"> <div className="flex flex-wrap items-center gap-2 pr-10">
{message.quotaReached ? ( {message.quotaReached ? (
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-violet px-3 py-1 text-xs font-bold text-white"> <button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-violet px-3 py-1 text-xs font-bold text-white">
دیدنِ اشتراک‌ها خریدِ بسته‌ی سوال
</button> </button>
) : message.failedQuestion ? ( ) : message.failedQuestion ? (
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50"> <button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">
@@ -331,3 +335,22 @@ function Message({
</div> </div>
); );
} }
/** شمارنده‌ی زیرِ جعبه‌ی پرسش: رایگانِ امروز و اعتبارِ خریده‌شده، هر دو جدا و روشن. */
function QuotaLine({ free, credits }: { free: number; credits: number }) {
const nav = useNav();
return (
<div className="mt-1.5 flex items-center justify-center gap-2 text-[11px]">
<span className={`rounded-full px-2.5 py-0.5 font-bold ${free > 0 ? 'bg-[#EAF8F0] text-[#2E7D5B]' : 'bg-soft text-muted'}`}>
رایگانِ امروز: {toFa(free)}
</span>
<button
type="button"
onClick={() => nav.switchTab('subscription')}
className={`rounded-full px-2.5 py-0.5 font-bold ${credits > 0 ? 'bg-[#F5F1FF] text-violet' : 'bg-soft text-muted'}`}
>
سوالِ خریده‌شده: {toFa(credits)}{credits === 0 && ' · خرید'}
</button>
</div>
);
}

View File

@@ -348,7 +348,7 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
return ( return (
<Sheet open={open} onClose={onClose} title="حذفِ همیشگیِ حساب"> <Sheet open={open} onClose={onClose} title="حذفِ همیشگیِ حساب">
<p className="mb-3 text-sm leading-7 text-body"> <p className="mb-3 text-sm leading-7 text-body">
امتیاز، زنجیره، پیشرفتِ جزیره‌ها، گفتگوها با دستیار و اشتراکِ فعالت پاک می‌شن و برنمی‌گردن. امتیاز، زنجیره، پیشرفتِ جزیره‌ها، گفتگوها با دستیار، سوال‌های خریده‌شده و قلبِ نامحدودت پاک می‌شن و برنمی‌گردن.
سوابقِ پرداخت فقط برای حسابداری می‌مونه. سوابقِ پرداخت فقط برای حسابداری می‌مونه.
</p> </p>
{needsCode ? ( {needsCode ? (

View File

@@ -364,9 +364,9 @@ function NoHearts({ nextRefillAt }: { nextRefillAt: string | null }) {
<p className="text-sm leading-7 text-muted"> <p className="text-sm leading-7 text-muted">
{nextRefillAt ? <>قلبِ بعدی تا <b className="text-coral">{timeUntil(nextRefillAt)}</b> دیگه پر می‌شه.</> : 'کمی صبر کن تا قلب‌هات دوباره پر بشن.'} {nextRefillAt ? <>قلبِ بعدی تا <b className="text-coral">{timeUntil(nextRefillAt)}</b> دیگه پر می‌شه.</> : 'کمی صبر کن تا قلب‌هات دوباره پر بشن.'}
<br /> <br />
با اشتراکِ ویژه قلب‌ها نامحدودن. هر بسته‌ی سوال چند روز قلبِ نامحدود هم هدیه داره.
</p> </p>
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ اشتراک</PrimaryButton> <PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ بسته‌ها</PrimaryButton>
<SecondaryButton onClick={nav.back}>باشه، بعداً</SecondaryButton> <SecondaryButton onClick={nav.back}>باشه، بعداً</SecondaryButton>
</div> </div>
</Screen> </Screen>

View File

@@ -14,85 +14,108 @@ function durationLabel(days: number): string {
return days ? `${toFa(days)} روزه` : ''; return days ? `${toFa(days)} روزه` : '';
} }
/**
* «بسته‌ی سوال» (از ۱۴۰۵-۰۷ به‌جای اشتراکِ ماهانه/سالانه). هر کس به اندازه‌ی مصرفش سوال از
* دستیار می‌خرد؛ اعتبار منقضی نمی‌شود و هر بسته چند روز قلبِ نامحدود هم هدیه دارد. هر روز
* اول سوال‌های رایگانِ همان روز خرج می‌شوند، بعد اعتبار.
*/
export function SubscriptionScreen() { export function SubscriptionScreen() {
const plans = useAsync(() => api.subscription.plans(), []); const plans = useAsync(() => api.subscription.plans(), []);
const status = useAsync(() => api.subscription.status(), []); const status = useAsync(() => api.subscription.status(), []);
const quota = useAsync(() => api.ai.quota(), []);
const [buying, setBuying] = useState<Plan | null>(null); 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 ( return (
<Screen> <Screen>
<div className="flex-1 overflow-y-auto"> <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"> <div className="flex flex-col gap-4 px-4 py-5">
{status.data?.active && ( <div className="rounded-3xl border border-line bg-white p-4">
<div className="flex items-center gap-3 rounded-3xl border-2 border-mint bg-[#EAF8F0] p-4"> <div className="flex items-center gap-3">
<Crown size={22} className="flex-shrink-0 text-mint" /> <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="text-sm leading-7 text-ink"> <div className="flex-1">
<b>اشتراکت فعاله.</b> <div className="text-xs font-bold text-muted">اعتبارِ تو</div>
{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}.</>} <div className="text-2xl font-black text-ink">{toFa(credits)} <span className="text-sm font-bold text-body">سوال</span></div>
<div className="text-xs text-muted">اگه دوباره بخری، از روزِ پایانِ همین اشتراک تمدید می‌شه.</div>
</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>
<div className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4"> <p className="mt-3 border-t border-line pt-3 text-xs leading-6 text-muted">
<Heart size={22} className="flex-shrink-0 text-coral" fill="#ED6956" /> هر روز {toFa(plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول رایگانه؛ بعدش از اعتبارت کم می‌شه. اعتبار هیچ‌وقت منقضی نمی‌شه.
<p className="text-sm leading-7 text-body">
با اشتراک هر روز سوالِ بیشتری از دستیار می‌پرسی و قلب‌هات هیچ‌وقت تموم نمی‌شن.
</p> </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> </div>
{plans.loading ? ( {plans.loading ? (
<LoadingState text="دارم پلن‌ها رو میارم…" /> <LoadingState text="دارم بسته‌ها رو میارم…" />
) : plans.error ? ( ) : plans.error ? (
<ErrorState error={plans.error} onRetry={plans.reload} /> <ErrorState error={plans.error} onRetry={plans.reload} />
) : !plans.data?.plans.length ? ( ) : !plans.data?.plans.length ? (
<EmptyState text="فعلاً پلنی برای خرید تعریف نشده" /> <EmptyState text="فعلاً بسته‌ای برای خرید تعریف نشده" />
) : ( ) : (
plans.data.plans.map((plan) => { 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); reload(); }} />
</Screen>
);
}
function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) {
const featured = plan.popular; const featured = plan.popular;
const isCurrent = plan.id === activePlanId; 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 ( return (
<div key={plan.id} className="relative pt-3"> <div className="relative pt-3">
{plan.badge && ( {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"> <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>
{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={`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 className="mb-3 flex items-start justify-between gap-3">
<div> <div>
<h3 className="text-lg font-black">{plan.title}</h3> <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 ${featured ? 'text-white/75' : 'text-muted'}`}>{durationLabel(plan.durationDays)}</p> <p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ «پی»، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
</div> </div>
<div className="text-left"> <div className="text-left">
<p className="text-base font-black">{plan.price === 0 ? 'رایگان' : formatToman(plan.price)}</p> <p className="text-base font-black">{formatToman(plan.price)}</p>
{plan.discountPercent > 0 && ( {perQuestion > 0 && <p className={`mt-0.5 text-[11px] ${tone}`}>هر سوال {formatToman(perQuestion)}</p>}
<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>
</div> </div>
<ul className="mb-4 flex flex-col gap-2"> <ul className="mb-4 flex flex-col gap-2 text-sm">
{/* سقفِ دستیار از خودِ پلن ساخته می‌شود (Plan.ai_daily_quota)، نه از متنِ ویژگی‌ها، {isPack && plan.heartsDays > 0 && (
تا عوض کردنِ عدد در ادمین کارت را هم درست عوض کند. */} <li className="flex items-center gap-2 font-bold">
{plan.aiDailyQuota > 0 && ( <Heart size={14} fill={featured ? '#fff' : '#ED6956'} className={featured ? 'text-white' : 'text-coral'} />
<li className="flex items-center gap-2 text-sm font-bold"> هدیه: {toFa(plan.heartsDays)} روز قلبِ نامحدود
<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> </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) => ( {plan.features.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm"> <li key={f} className="flex items-center gap-2">
<Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} /> <Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
<span className={featured ? 'text-white/90' : 'text-body'}>{f}</span> <span className={featured ? 'text-white/90' : 'text-body'}>{f}</span>
</li> </li>
@@ -100,27 +123,14 @@ export function SubscriptionScreen() {
</ul> </ul>
<button <button
type="button" type="button"
disabled={plan.price === 0 || isCurrent} onClick={onBuy}
onClick={() => setBuying(plan)} 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'}`}
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> </button>
</div> </div>
</div> </div>
); );
})
)}
<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(); }} />
</Screen>
);
} }
/** /**
@@ -166,10 +176,10 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
}; };
return ( return (
<Sheet open={plan !== null} onClose={close} title={freeDone ? 'اشتراکت فعال شد! 🎉' : plan?.title}> <Sheet open={plan !== null} onClose={close} title={freeDone ? 'خریدت انجام شد! 🎉' : plan?.title}>
{freeDone ? ( {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> <PrimaryButton onClick={close}>عالیه!</PrimaryButton>
</> </>
) : plan && ( ) : plan && (