@@ -96,12 +100,14 @@ export function StreakScreen() {
/** نقطهعطفهای زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعیای پشتش نیست (پاداش تصمیمِ محصولی است). */
const MILESTONES = [3, 7, 14, 30, 60, 100];
-function StreakGoal({ current, doneToday }: { current: number; doneToday: boolean }) {
+function StreakGoal({ current, doneToday, lost }: { current: number; doneToday: boolean; lost: number }) {
const next = MILESTONES.find((m) => m > current);
const reached = MILESTONES.filter((m) => m <= current).pop();
// زنجیرهی صفر: «۳ روز دیگه میشه زنجیرهی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته میشود.
// زنجیرهی روشن و امروزِ خالی: یادآوریِ اینکه امروز چیزی مانده تا زنجیره نسوزد.
- const today = current === 0
+ const today = current === 0 && lost > 0
+ ? `زنجیرهی ${toFa(lost)} روزهت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.`
+ : current === 0
? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیرهت روشن بشه.'
: !doneToday
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیرهی ${toFa(current)} روزهت نسوزه.`
diff --git a/src/screens/SubscriptionScreen.tsx b/src/screens/SubscriptionScreen.tsx
index 7129e68..a1eba6b 100644
--- a/src/screens/SubscriptionScreen.tsx
+++ b/src/screens/SubscriptionScreen.tsx
@@ -1,14 +1,15 @@
-import { useState, type ReactNode } from 'react';
-import { Check, ChevronLeft, ChevronRight, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
-import { api, type Plan, type SubscriptionStatus, type TutorQuota } from '@/api';
+import { useEffect, useState, type ReactNode } from 'react';
+import { Check, ChevronLeft, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag, Sparkles, Ticket } from 'lucide-react';
+import { api, type Plan, type SubscriptionStatus } from '@/api';
import { daysLeft } from '@/lib/subscription';
import { newIdempotencyKey } from '@/api/http';
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet';
+import { useBackStep } from '@/nav/Navigator';
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
-import { formatJalaliDate, formatToman, toFa } from '@/lib/format';
+import { formatJalaliDate, formatNumber, formatToman, toFa } from '@/lib/format';
function durationLabel(days: number): string {
if (days === 30 || days === 31) return 'یکماهه';
@@ -18,208 +19,241 @@ function durationLabel(days: number): string {
return days ? `${toFa(days)} روزه` : '';
}
+/** «۷۹۸ هزار تومان» بهجای «۷۹۸٬۰۰۰ تومان»؛ صفرهای پشتِ هم برای بچه خواندنِ قیمت را سخت میکرد. */
+function price(value: number): string {
+ return value >= 1000 && value % 1000 === 0 ? `${formatNumber(value / 1000)} هزار تومان` : formatToman(value);
+}
+
type View = 'subscription' | 'pack';
/**
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/ششماهه؛ همهی فصلها، قلبِ نامحدود، سقفِ روزانهی دستیار)
* و «بستهی سوال» (سوالِ بیانقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
* یکجا کاربر را گیج نکند. از فصلِ قفل یکراست به فهرستِ اشتراک میآید (`initialView`).
+ *
+ * چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیجکننده»): مزیتهای اشتراک یک بار بالای فهرست
+ * میآیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ
+ * انتخاب است با نام و قیمت، و فقط یک دکمهی بزرگ پایینِ صفحه هست. روشِ پرداخت در برگهی پرداخت انتخاب میشود.
*/
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
- const quota = useAsync(() => api.ai.quota(), [], { key: 'ai-quota' });
const [view, setView] = useState
(initialView ?? null);
+ const [picked, setPicked] = useState(null);
const [buying, setBuying] = useState(null);
- // داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درونبرنامهایِ خودش است؛ محصولی که
- // SKU ندارد آنجا خریدنی نیست (به زرینپال فرستاده نمیشود).
const bazaar = inBazaarApp();
+ // از فهرست، «برگشت» اول به دو گزینه برمیگردد.
+ useBackStep(view !== null && !initialView, () => setView(null));
- const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
- const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
- const reload = () => { status.reload(); quota.reload(); };
- const list = plans.data?.plans.filter((p) => p.kind === view) ?? [];
- const monthly = plans.data?.plans.find((p) => p.kind === 'subscription' && p.durationDays <= 31);
+ // پرداخت با درگاه داخلِ اپ در مرورگرِ گوشی باز میشود؛ برگشت به اپ یعنی وضعیت را دوباره بپرس.
+ useEffect(() => {
+ const onVisible = () => document.visibilityState === 'visible' && status.reload();
+ document.addEventListener('visibilitychange', onVisible);
+ return () => document.removeEventListener('visibilitychange', onVisible);
+ }, [status.reload]);
+
+ const all = plans.data?.plans ?? [];
+ const list = all.filter((p) => p.kind === view);
+ const monthly = all.find((p) => p.kind === 'subscription' && p.durationDays <= 31);
+ const cheapestSub = all.filter((p) => p.kind === 'subscription').sort((x, y) => x.price - y.price)[0];
+ const selected = list.find((p) => p.id === picked) ?? list.find((p) => p.popular) ?? list[0];
+ const active = status.data?.active ?? false;
return (
setView(null) : true}
mascot={{ char: 'pi', mood: 'excited', size: 60 }}
/>
-
+ {view === null &&
}
{view === null ? (
}
+ icon={}
tone="#FF7A3D"
- title="اشتراکِ ماهانه"
- text="همهی فصلها و جزیرهها باز، با قلبِ نامحدود"
- hint={monthly ? `از ${formatToman(monthly.price)}` : undefined}
+ title={active ? 'تمدیدِ اشتراک' : 'اشتراک'}
+ text="همهی فصلها باز، با قلبِ نامحدود"
+ hint={cheapestSub ? `از ${price(cheapestSub.price)}` : undefined}
onClick={() => setView('subscription')}
/>
}
+ icon={}
tone="#9B7BFF"
title="بستهی سوال"
- text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچوقت منقضی نمیشه"
+ text="سوالِ بیشتر از پی، هیچوقت تموم نمیشه"
onClick={() => setView('pack')}
/>
+ ) : plans.loading && !plans.data ? (
+
+ ) : plans.error && !plans.data ? (
+
+ ) : !list.length ? (
+
) : (
<>
- {!initialView && (
-
- )}
- {plans.loading ? (
-
- ) : plans.error ? (
-
- ) : !list.length ? (
-
+ {view === 'subscription' ? (
+
) : (
- list.map((plan) => (
-
setBuying(plan)}
- />
- ))
- )}
- {view === 'pack' && (
-
- هر روز {toFa(status.data?.aiDailyQuota ?? plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول از سقفِ روزانهته؛ بعدش از اعتبارِ بسته کم میشه.
+
+ هر روز اول از سوالهای رایگانِ همون روز خرج میشه، بعد از بسته.
)}
+
+ {list.map((plan) => (
+
setPicked(plan.id)} />
+ ))}
+
>
)}
-
-
- {bazaar ? 'پرداخت از طریقِ کافهبازار انجام میشه.' : 'پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.'}
-
- setBuying(null)} onPaid={() => { setBuying(null); reload(); }} />
+ {view !== null && selected && (
+
+
setBuying(selected)}>
+ {view === 'pack' ? `خریدِ ${toFa(selected.questionCredits)} سوال` : `خریدِ اشتراکِ ${durationLabel(selected.durationDays) || selected.title}`}
+
+
+ )}
+
+ setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
);
}
function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) {
return (
-