From b860c77b91575cb00f52813b6464fcbc2fd86ebb Mon Sep 17 00:00:00 2001 From: AliReza Date: Fri, 2 Oct 2026 11:41:43 +0330 Subject: [PATCH] question packs page, free/purchased counters in the tutor, packs from the no-hearts page Co-Authored-By: Claude Opus 5.5 --- src/api/index.ts | 3 + src/api/types.ts | 21 ++++ src/components/BottomNav.tsx | 4 +- src/screens/AskScreen.tsx | 33 +++++- src/screens/ProfileScreen.tsx | 2 +- src/screens/QuizScreen.tsx | 4 +- src/screens/SubscriptionScreen.tsx | 170 +++++++++++++++-------------- 7 files changed, 147 insertions(+), 90 deletions(-) diff --git a/src/api/index.ts b/src/api/index.ts index aed8efc..d81e300 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -130,6 +130,9 @@ export const api = { }, ai: { + /** «امروز X سوالِ رایگان + Y سوالِ خریده‌شده»، پیش از اولین سوال. */ + quota: () => http.get('/ai/quota'), + /** * پرسش با پاسخِ تکه‌تکه. `lessonId` پاسخ را به کتابِ همان درس مستند می‌کند؛ * `questionId` به دستیار می‌گوید دانش‌آموز کدام سوال را غلط زده. diff --git a/src/api/types.ts b/src/api/types.ts index 50454b2..68ce253 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 { diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index 14a0004..a56a6a4 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -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: 'من' }, ]; diff --git a/src/screens/AskScreen.tsx b/src/screens/AskScreen.tsx index ffd73c6..535395e 100644 --- a/src/screens/AskScreen.tsx +++ b/src/screens/AskScreen.tsx @@ -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(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(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; - {quota !== null &&

امروز {toFa(quota)} سوالِ دیگه می‌تونی بپرسی

} + {quota !== null && } ); @@ -293,7 +297,7 @@ function Message({
{message.quotaReached ? ( ) : message.failedQuestion ? (
); } + +/** شمارنده‌ی زیرِ جعبه‌ی پرسش: رایگانِ امروز و اعتبارِ خریده‌شده، هر دو جدا و روشن. */ +function QuotaLine({ free, credits }: { free: number; credits: number }) { + const nav = useNav(); + return ( +
+ 0 ? 'bg-[#EAF8F0] text-[#2E7D5B]' : 'bg-soft text-muted'}`}> + رایگانِ امروز: {toFa(free)} + + +
+ ); +} diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index b464a4b..1288595 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -348,7 +348,7 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC return (

- امتیاز، زنجیره، پیشرفتِ جزیره‌ها، گفتگوها با دستیار و اشتراکِ فعالت پاک می‌شن و برنمی‌گردن. + امتیاز، زنجیره، پیشرفتِ جزیره‌ها، گفتگوها با دستیار، سوال‌های خریده‌شده و قلبِ نامحدودت پاک می‌شن و برنمی‌گردن. سوابقِ پرداخت فقط برای حسابداری می‌مونه.

{needsCode ? ( diff --git a/src/screens/QuizScreen.tsx b/src/screens/QuizScreen.tsx index 12ab4b8..7544237 100644 --- a/src/screens/QuizScreen.tsx +++ b/src/screens/QuizScreen.tsx @@ -364,9 +364,9 @@ function NoHearts({ nextRefillAt }: { nextRefillAt: string | null }) {

{nextRefillAt ? <>قلبِ بعدی تا {timeUntil(nextRefillAt)} دیگه پر می‌شه. : 'کمی صبر کن تا قلب‌هات دوباره پر بشن.'}
- با اشتراکِ ویژه قلب‌ها نامحدودن. + هر بسته‌ی سوال چند روز قلبِ نامحدود هم هدیه داره.

- nav.switchTab('subscription')}>دیدنِ اشتراک + nav.switchTab('subscription')}>دیدنِ بسته‌ها باشه، بعداً diff --git a/src/screens/SubscriptionScreen.tsx b/src/screens/SubscriptionScreen.tsx index 2fd3944..b2016ad 100644 --- a/src/screens/SubscriptionScreen.tsx +++ b/src/screens/SubscriptionScreen.tsx @@ -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(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 (
- +
- {status.data?.active && ( -
- -
- اشتراکت فعاله. - {status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}.} -
اگه دوباره بخری، از روزِ پایانِ همین اشتراک تمدید می‌شه.
+
+
+ +
+
اعتبارِ تو
+
{toFa(credits)} سوال
+ {quota.data && ( +
+ رایگانِ امروز
+ {toFa(quota.data.remainingQuota)} از {toFa(quota.data.freeDaily)} +
+ )}
- )} - -
- -

- با اشتراک هر روز سوالِ بیشتری از دستیار می‌پرسی و قلب‌هات هیچ‌وقت تموم نمی‌شن. +

+ هر روز {toFa(plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول رایگانه؛ بعدش از اعتبارت کم می‌شه. اعتبار هیچ‌وقت منقضی نمی‌شه.

+ {heartsUntil && ( +

+ قلبِ نامحدود تا {formatJalaliDate(heartsUntil)} +

+ )} + {status.data?.active && ( +

+ اشتراکِ قبلیت هم فعاله{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}}. +

+ )}
{plans.loading ? ( - + ) : plans.error ? ( ) : !plans.data?.plans.length ? ( - + ) : ( - plans.data.plans.map((plan) => { - const featured = plan.popular; - const isCurrent = plan.id === activePlanId; - return ( -
- {plan.badge && ( - - {plan.badge} - - )} -
-
-
-

{plan.title}

-

{durationLabel(plan.durationDays)}

-
-
-

{plan.price === 0 ? 'رایگان' : formatToman(plan.price)}

- {plan.discountPercent > 0 && ( - - {toFa(plan.discountPercent)}٪ تخفیف - - )} -
-
-
    - {/* سقفِ دستیار از خودِ پلن ساخته می‌شود (Plan.ai_daily_quota)، نه از متنِ ویژگی‌ها، - تا عوض کردنِ عدد در ادمین کارت را هم درست عوض کند. */} - {plan.aiDailyQuota > 0 && ( -
  • - - - {toFa(plan.aiDailyQuota)} سوال در روز از دستیارِ «پی» - - {' '}(رایگان: {toFa(plans.data!.freeAiDailyQuota)}) - - -
  • - )} - {plan.features.map((f) => ( -
  • - - {f} -
  • - ))} -
- -
-
- ); - }) + plans.data.plans.map((plan) => setBuying(plan)} />) )}

پرداخت از طریقِ درگاهِ امنِ زرین‌پال انجام می‌شه.

- setBuying(null)} onPaidWithCode={() => { setBuying(null); status.reload(); }} /> + setBuying(null)} onPaidWithCode={() => { setBuying(null); reload(); }} /> ); } +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 ( +
+ {plan.badge && ( + {plan.badge} + )} +
+
+
+

{isPack ? <>{toFa(plan.questionCredits)} سوال : plan.title}

+

{isPack ? 'از دستیارِ «پی»، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}

+
+
+

{formatToman(plan.price)}

+ {perQuestion > 0 &&

هر سوال {formatToman(perQuestion)}

} +
+
+
    + {isPack && plan.heartsDays > 0 && ( +
  • + + هدیه: {toFa(plan.heartsDays)} روز قلبِ نامحدود +
  • + )} + {!isPack && plan.aiDailyQuota > 0 && ( +
  • {toFa(plan.aiDailyQuota)} سوال در روز از دستیار
  • + )} + {plan.features.map((f) => ( +
  • + + {f} +
  • + ))} +
+ +
+
+ ); +} + /** * خرید: سفارش ساخته می‌شود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت می‌شود، * و بعد کاربر در همین برگه به درگاه می‌رود. window.open بعد از await را @@ -166,10 +176,10 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o }; return ( - + {freeDone ? ( <> -

کدِ تخفیفت کلِ مبلغ رو پوشوند و اشتراکت همین الان فعال شد.

+

کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.

عالیه! ) : plan && (