forked from Tadrisino/MathApp_frontend
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:
@@ -130,6 +130,9 @@ export const api = {
|
||||
},
|
||||
|
||||
ai: {
|
||||
/** «امروز X سوالِ رایگان + Y سوالِ خریدهشده»، پیش از اولین سوال. */
|
||||
quota: () => http.get<T.TutorQuota>('/ai/quota'),
|
||||
|
||||
/**
|
||||
* پرسش با پاسخِ تکهتکه. `lessonId` پاسخ را به کتابِ همان درس مستند میکند؛
|
||||
* `questionId` به دستیار میگوید دانشآموز کدام سوال را غلط زده.
|
||||
|
||||
@@ -210,7 +210,10 @@ export interface AskDone {
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
suggestedFollowUps: string[];
|
||||
/** سوالهای رایگانِ باقیِ امروز. */
|
||||
remainingQuota: number;
|
||||
/** اعتبارِ بستههای سوال؛ منقضی نمیشود. */
|
||||
credits?: number;
|
||||
usedChunks: UsedChunk[];
|
||||
diagram?: Diagram | null;
|
||||
}
|
||||
@@ -249,6 +252,12 @@ export interface Plan {
|
||||
badge: string;
|
||||
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
|
||||
aiDailyQuota: number;
|
||||
/** «pack» بستهی سوال است (از ۱۴۰۵-۰۷)؛ «subscription» اشتراکِ زمانیِ قدیمی. */
|
||||
kind: 'pack' | 'subscription';
|
||||
/** بسته: سوالهایی که به اعتبار اضافه میشود. */
|
||||
questionCredits: number;
|
||||
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
|
||||
heartsDays: number;
|
||||
}
|
||||
|
||||
export interface PlansResponse {
|
||||
@@ -258,9 +267,21 @@ export interface PlansResponse {
|
||||
}
|
||||
|
||||
export interface SubscriptionStatus {
|
||||
/** اشتراکِ زمانیِ قدیمی (ماهانه/سالانه)، نه بسته. */
|
||||
active: boolean;
|
||||
planId: 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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
|
||||
{ id: 'home', icon: Home, label: 'خانه' },
|
||||
{ id: 'map', icon: Map, label: 'نقشه' },
|
||||
{ id: 'ask', icon: Brain, label: 'دستیار' },
|
||||
{ id: 'subscription', icon: Crown, label: 'اشتراک' },
|
||||
{ id: 'subscription', icon: ShoppingBag, label: 'بستهها' },
|
||||
{ id: 'profile', icon: UserRound, label: 'من' },
|
||||
];
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
const chat = useAskState();
|
||||
const [input, setInput] = useState('');
|
||||
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 autoSent = useRef(false);
|
||||
|
||||
@@ -91,7 +95,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
diagram: done.diagram ?? null,
|
||||
});
|
||||
askStore.setConversation(done.conversationId);
|
||||
setQuota(done.remainingQuota);
|
||||
setQuota((q) => ({ free: done.remainingQuota, credits: done.credits ?? q?.credits ?? 0 }));
|
||||
} catch (err) {
|
||||
// سقفِ روزانه (نه throttleِ چندثانیهای): «دوباره» تا فردا بیفایده است.
|
||||
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, 'جوابی نرسید'),
|
||||
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
|
||||
});
|
||||
if (quotaReached) setQuota(0);
|
||||
if (quotaReached) setQuota({ free: 0, credits: 0 });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -194,7 +198,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
<Send size={16} className="-scale-x-100" />
|
||||
</button>
|
||||
</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>
|
||||
</Screen>
|
||||
);
|
||||
@@ -293,7 +297,7 @@ function Message({
|
||||
<div className="flex flex-wrap items-center gap-2 pr-10">
|
||||
{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>
|
||||
) : 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">
|
||||
@@ -331,3 +335,22 @@ function Message({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="حذفِ همیشگیِ حساب">
|
||||
<p className="mb-3 text-sm leading-7 text-body">
|
||||
امتیاز، زنجیره، پیشرفتِ جزیرهها، گفتگوها با دستیار و اشتراکِ فعالت پاک میشن و برنمیگردن.
|
||||
امتیاز، زنجیره، پیشرفتِ جزیرهها، گفتگوها با دستیار، سوالهای خریدهشده و قلبِ نامحدودت پاک میشن و برنمیگردن.
|
||||
سوابقِ پرداخت فقط برای حسابداری میمونه.
|
||||
</p>
|
||||
{needsCode ? (
|
||||
|
||||
@@ -364,9 +364,9 @@ function NoHearts({ nextRefillAt }: { nextRefillAt: string | null }) {
|
||||
<p className="text-sm leading-7 text-muted">
|
||||
{nextRefillAt ? <>قلبِ بعدی تا <b className="text-coral">{timeUntil(nextRefillAt)}</b> دیگه پر میشه.</> : 'کمی صبر کن تا قلبهات دوباره پر بشن.'}
|
||||
<br />
|
||||
با اشتراکِ ویژه قلبها نامحدودن.
|
||||
هر بستهی سوال چند روز قلبِ نامحدود هم هدیه داره.
|
||||
</p>
|
||||
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ اشتراک</PrimaryButton>
|
||||
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ بستهها</PrimaryButton>
|
||||
<SecondaryButton onClick={nav.back}>باشه، بعداً</SecondaryButton>
|
||||
</div>
|
||||
</Screen>
|
||||
|
||||
@@ -14,85 +14,108 @@ 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 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">
|
||||
با اشتراک هر روز سوالِ بیشتری از دستیار میپرسی و قلبهات هیچوقت تموم نمیشن.
|
||||
</div>
|
||||
<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) => {
|
||||
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 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 (
|
||||
<div key={plan.id} className="relative pt-3">
|
||||
<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>
|
||||
<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 className="mb-3 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>
|
||||
<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">{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>
|
||||
)}
|
||||
<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">
|
||||
{/* سقفِ دستیار از خودِ پلن ساخته میشود (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>
|
||||
<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 text-sm">
|
||||
<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>
|
||||
@@ -100,27 +123,14 @@ export function SubscriptionScreen() {
|
||||
</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'
|
||||
}`}
|
||||
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'}`}
|
||||
>
|
||||
{isCurrent ? 'اشتراکِ فعلیِ تو' : plan.price === 0 ? 'رایگان' : status.data?.active ? 'تمدید با این پلن' : 'انتخابِ این پلن'}
|
||||
خرید
|
||||
</button>
|
||||
</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 (
|
||||
<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