Store sells subscriptions only: each card lists what you get (features + assistant questions/day), tab renamed, assistant points to subscription

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 19:57:18 +03:30
parent d440986b88
commit e0a9605b8e
10 changed files with 69 additions and 138 deletions

View File

@@ -54,7 +54,7 @@
|---|---|---|
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app`؛ بازار محصول‌های ۱۰۳۴۲–۱۰۳۴۴ را زیرِ همین نام شناخت (API، توکنِ ساختگی) |
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | گذاشته شد |
| SKUِ هر پلن | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | یک‌ماهه `10342`، سه‌ماهه `10343`، شش‌ماهه `10344`؛ بسته‌ها هنوز در پیشخان تعریف نشده‌اند |
| SKUِ هر پلن | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | یک‌ماهه `10342`، سه‌ماهه `10343`، شش‌ماهه `10344`؛ بسته‌ی سوال فروخته نمی‌شود |
| کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور |
قیمت: فرانت `TadrisinoAndroid.bazaarPrices(skusJson, productType)` را صدا می‌زند و قیمتِ پیشخان را روی کاشیِ بازار نشان می‌دهد.

View File

@@ -283,7 +283,7 @@ function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSig
case 'home': return <HomeScreen />;
case 'lessons': return <LessonsScreen />;
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
case 'subscription': return <SubscriptionScreen key={route.view ?? ''} initialView={route.view} />;
case 'subscription': return <SubscriptionScreen />;
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
case 'unit': return <UnitScreen unitId={route.unitId} />;
case 'lesson': return <LessonScreen lessonId={route.lessonId} nodeId={route.nodeId} />;

View File

@@ -8,7 +8,7 @@ const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
{ id: 'home', icon: Home, label: 'خانه' },
{ id: 'lessons', icon: BookOpen, label: 'درس‌ها' },
{ id: 'ask', icon: Brain, label: 'دستیار' },
{ id: 'subscription', icon: ShoppingBag, label: 'بسته‌ها' },
{ id: 'subscription', icon: ShoppingBag, label: 'اشتراک' },
{ id: 'profile', icon: UserRound, label: 'من' },
];

View File

@@ -4,7 +4,7 @@ export type Route =
| { name: 'lessons' }
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
/** view: مستقیم به فهرستِ اشتراک‌ها یا بسته‌ها (مثلاً از فصلِ قفل)؛ بی‌آن انتخابِ دوگزینه‌ای. */
| { name: 'subscription'; view?: 'subscription' | 'pack' }
| { name: 'subscription' }
| { name: 'profile' }
| { name: 'unit'; unitId: string }
| { name: 'lesson'; lessonId: number; nodeId?: string }

View File

@@ -315,7 +315,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">
@@ -355,23 +355,23 @@ function Message({
}
/**
* شمارنده در سربرگِ دستیار: سوال‌های رایگانِ باقیِ امروز و سوال‌های خریده‌شده، هر کدام جدا و
* درشت، تا بچه پیش از پرسیدن بداند چقدر دارد. زدن روی دومی صفحه‌ی بسته‌ها را باز می‌کند.
* شمارنده در سربرگِ دستیار: سوال‌های رایگانِ باقیِ امروز، و اگر از بسته‌های قدیمی سوالی خریده و مانده، آن هم جدا.
* بسته از ۲۰۲۶-۱۰-۱۲ فروخته نمی‌شود؛ وقتی رایگانِ امروز تمام شد، دکمه‌ی دوم به فروشگاهِ اشتراک می‌برد.
*/
function QuotaPills({ free, credits }: { free: number; credits: number }) {
const nav = useNav();
return (
<>
<span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}>
{free > 0 ? <>{toFa(free)} سوالِ رایگانِ امروز</> : 'رایگانِ امروز تموم شد'}
{free > 0 ? <>{toFa(free)} سوالِ امروز</> : 'سوال‌های امروز تموم شد'}
</span>
<button
type="button"
onClick={() => nav.switchTab('subscription')}
className={`rounded-full px-3 py-1.5 text-xs font-black ${credits > 0 ? 'bg-white text-violet' : 'bg-white/15 text-white'}`}
>
{credits > 0 ? <>{toFa(credits)} سوالِ خریده‌شده</> : '+ خریدِ سوال'}
</button>
{credits > 0 ? (
<span className="rounded-full bg-white px-3 py-1.5 text-xs font-black text-violet">{toFa(credits)} سوالِ خریده‌شده</span>
) : free === 0 ? (
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-white/15 px-3 py-1.5 text-xs font-black text-white">
سوالِ بیشتر با اشتراک
</button>
) : null}
</>
);
}

View File

@@ -25,7 +25,7 @@ export function HomeScreen() {
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
const nav = useNav();
// فصلِ غیرِ اول بی‌اشتراک: زدنِ جزیره یک‌راست فهرستِ اشتراک‌ها را باز می‌کند.
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
const paywall = () => nav.push({ name: 'subscription' });
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
const currentRef = useRef<HTMLDivElement>(null);
const scrolled = useRef(false);

View File

@@ -130,7 +130,7 @@ function UnitCard({
className="overflow-hidden rounded-2xl bg-white"
style={{ border: `2px solid ${open ? color : '#e9e6f7'}`, boxShadow: open ? `0 4px 0 ${color}40` : '0 2px 0 #e9e6f7' }}
>
<button type="button" onClick={premium ? () => nav.push({ name: 'subscription', view: 'subscription' }) : onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
<button type="button" onClick={premium ? () => nav.push({ name: 'subscription' }) : onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
<span
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-2xl"
style={{ backgroundColor: complete ? color : `${color}22`, color: complete ? '#fff' : color, opacity: untouched ? 0.55 : 1 }}

View File

@@ -111,7 +111,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
.catch((error: unknown) => {
if (!alive) return;
// فصلِ غیرِ اول بی‌اشتراک (۴۰۲)؛ صفحه‌ی خطا معنا ندارد، کاربر یک‌راست به اشتراک‌ها می‌رود.
if (error instanceof ApiError && error.code === 'subscription_required') nav.replace({ name: 'subscription', view: 'subscription' });
if (error instanceof ApiError && error.code === 'subscription_required') nav.replace({ name: 'subscription' });
else setPhase({ kind: 'error', error });
});
return () => {

View File

@@ -1,12 +1,11 @@
import { useEffect, useState, type ReactNode } from 'react';
import { Check, ChevronLeft, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag, Sparkles, Ticket } from 'lucide-react';
import { Check, 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, bazaarBuy, bazaarConsume, bazaarPending, bazaarPrices, canBuyFromBazaar, inAndroidApp } 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, formatNumber, formatToman, toFa } from '@/lib/format';
@@ -25,28 +24,19 @@ function price(value: number): string {
return value >= 1000 && value % 1000 === 0 ? `${formatNumber(value / 1000)} هزار تومان` : formatToman(value);
}
type View = 'subscription' | 'pack';
/**
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/شش‌ماهه؛ همه‌ی فصل‌ها، قلبِ نامحدود، سقفِ روزانه‌ی دستیار)
* و «بسته‌ی سوال» (سوالِ بی‌انقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
* یک‌جا کاربر را گیج نکند. از فصلِ قفل یک‌راست به فهرستِ اشتراک می‌آید (`initialView`).
*
* چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیج‌کننده»): مزیت‌های اشتراک یک بار بالای فهرست
* می‌آیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ
* انتخاب است با نام و قیمت، و فقط یک دکمه‌ی بزرگ پایینِ صفحه هست. روشِ پرداخت (زرین‌پال یا کافه‌بازار) در برگه‌ی پرداخت انتخاب می‌شود.
* فروشگاه فقط اشتراک دارد (یک/سه/شش‌ماهه؛ تصمیمِ سینیور ۲۰۲۶-۱۰-۱۲: بسته‌ی سوال دیگر فروخته نمی‌شود). هر کارت
* خودش می‌گوید چه می‌گیری (خواستِ سینیور): مزیت‌های پلن (`features`، از ادمین) و سقفِ روزانه‌ی دستیار از `aiDailyQuota`،
* تا عدد با سقفِ واقعی جدا نیفتد. یک دکمه‌ی بزرگ پایینِ صفحه؛ روشِ پرداخت (زرین‌پال یا کافه‌بازار) در برگه‌ی پرداخت.
*/
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
export function SubscriptionScreen() {
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
const [view, setView] = useState<View | null>(initialView ?? null);
const [picked, setPicked] = useState<string | null>(null);
const [buying, setBuying] = useState<Plan | null>(null);
// داخلِ اپی که از بازار می‌خرد: قیمتِ پیشخانِ هر SKU، تا کاشیِ بازار همان را بگوید که بازار می‌گیرد.
const [storePrices, setStorePrices] = useState<Record<string, string>>({});
const look = useCompanion();
// از فهرست، «برگشت» اول به دو گزینه برمی‌گردد.
useBackStep(view !== null && !initialView, () => setView(null));
// پرداخت با درگاه داخلِ اپ در مرورگرِ گوشی باز می‌شود؛ برگشت به اپ یعنی وضعیت را دوباره بپرس.
useEffect(() => {
@@ -55,19 +45,16 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
return () => document.removeEventListener('visibilitychange', onVisible);
}, [status.reload]);
const all = plans.data?.plans ?? [];
const list = (plans.data?.plans ?? []).filter((p) => p.kind === 'subscription');
useEffect(() => {
if (!canBuyFromBazaar() || !plans.data) return;
const skus = (kind: Plan['kind']) => plans.data!.plans.filter((p) => p.kind === kind && p.bazaarSku).map((p) => p.bazaarSku);
const skus = list.filter((p) => p.bazaarSku).map((p) => p.bazaarSku);
if (!canBuyFromBazaar() || !skus.length) return;
let alive = true;
void Promise.all([bazaarPrices(skus('subscription'), 'subscription'), bazaarPrices(skus('pack'), 'inapp')]).then(
([subs, packs]) => alive && setStorePrices({ ...subs, ...packs }),
);
void bazaarPrices(skus, 'subscription').then((prices) => alive && setStorePrices(prices));
return () => { alive = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plans.data]);
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 monthly = list.find((p) => p.durationDays <= 31);
const selected = list.find((p) => p.id === picked) ?? list.find((p) => p.popular) ?? list[0];
const active = status.data?.active ?? false;
@@ -75,64 +62,36 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
<Screen>
<div className="flex-1 overflow-y-auto">
<ScreenHeader
title={view === 'pack' ? 'بسته‌ی سوال' : view === 'subscription' ? (active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو') : 'فروشگاه'}
subtitle={view === 'pack' ? `سوالِ بیشتر از ${look.name}، بدونِ تاریخِ انقضا` : 'همه‌ی فصل‌ها، قلبِ نامحدود'}
title={active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو'}
subtitle="همه‌ی فصل‌ها، قلبِ نامحدود"
color="#ED6956"
exact
back={view !== null && !initialView ? () => setView(null) : true}
back
mascot={{ mood: 'excited', size: 60 }}
/>
<div className="flex flex-col gap-4 px-4 py-5">
{view === null && <StatusCard status={status.data ?? null} loading={status.loading && !status.data} />}
{view === null ? (
<div className="flex flex-col gap-3">
<ChoiceCard
icon={<Crown size={26} />}
tone="#FF7A3D"
title={active ? 'تمدیدِ اشتراک' : 'اشتراک'}
text="همه‌ی فصل‌ها باز، با قلبِ نامحدود"
hint={cheapestSub ? `از ${price(cheapestSub.price)}` : undefined}
onClick={() => setView('subscription')}
/>
<ChoiceCard
icon={<Sparkles size={26} />}
tone="#9B7BFF"
title="بسته‌ی سوال"
text={`سوالِ بیشتر از ${look.name}، هیچ‌وقت تموم نمی‌شه`}
onClick={() => setView('pack')}
/>
</div>
) : plans.loading && !plans.data ? (
<StatusCard status={status.data ?? null} loading={status.loading && !status.data} />
{plans.loading && !plans.data ? (
<LoadingState text="دارم فهرست رو میارم…" />
) : plans.error && !plans.data ? (
<ErrorState error={plans.error} onRetry={plans.reload} />
) : !list.length ? (
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
) : (
<>
{view === 'subscription' ? (
<Perks items={selected?.features.length ? selected.features : ['همه‌ی فصل‌ها و جزیره‌ها باز', 'قلبِ نامحدود']} />
) : (
<p className="px-1 text-center text-sm leading-7 text-body">
هر روز اول از سوال‌های رایگانِ همون روز خرج می‌شه، بعد از بسته.
</p>
)}
<div role="radiogroup" aria-label={view === 'pack' ? 'بسته‌ها' : 'مدتِ اشتراک'} className="flex flex-col gap-3">
{list.map((plan) => (
<PlanRow key={plan.id} plan={plan} monthlyPrice={monthly?.price} selected={plan.id === selected?.id} onSelect={() => setPicked(plan.id)} />
))}
</div>
</>
<div role="radiogroup" aria-label="مدتِ اشتراک" className="flex flex-col gap-3">
{list.map((plan) => (
<PlanRow key={plan.id} plan={plan} assistant={look.name} monthlyPrice={monthly?.price} selected={plan.id === selected?.id} onSelect={() => setPicked(plan.id)} />
))}
</div>
)}
</div>
</div>
{view !== null && selected && (
{selected && (
<div className="flex-shrink-0 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
<PrimaryButton color={view === 'pack' ? '#9B7BFF' : '#FF7A3D'} onClick={() => setBuying(selected)}>
{view === 'pack' ? `خریدِ ${toFa(selected.questionCredits)} سوال` : `خریدِ اشتراکِ ${durationLabel(selected.durationDays) || selected.title}`}
<PrimaryButton color="#FF7A3D" onClick={() => setBuying(selected)}>
{`خریدِ اشتراکِ ${durationLabel(selected.durationDays) || selected.title}`}
</PrimaryButton>
</div>
)}
@@ -142,25 +101,6 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
);
}
function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-4 rounded-3xl border-2 border-b-4 bg-white p-4 text-right transition-transform active:translate-y-0.5"
style={{ borderColor: `${tone}40` }}
>
<span className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl text-white" style={{ backgroundColor: tone }}>{icon}</span>
<span className="min-w-0 flex-1">
<span className="block text-lg font-black text-ink">{title}</span>
<span className="mt-0.5 block text-sm leading-6 text-body">{text}</span>
{hint && <span className="mt-1 block text-sm font-black" style={{ color: tone }}>{hint}</span>}
</span>
<ChevronLeft size={20} className="flex-shrink-0" style={{ color: tone }} />
</button>
);
}
/**
* وضعیتِ کاربر بالای فروشگاه. مشترک: یک کارتِ واضح با روزهای مانده. بی‌اشتراک: هیچ کارتی (دو گزینه‌ی زیر
* خودشان حرف را می‌زنند)، مگر سوالِ خریده‌شده یا قلبِ هدیه داشته باشد. تا وضعیت نرسیده جای خالیِ هم‌اندازه
@@ -215,36 +155,19 @@ function Pill({ icon, text }: { icon: ReactNode; text: string }) {
);
}
/** مزیت‌های اشتراک، یک بار. */
function Perks({ items }: { items: string[] }) {
return (
<ul className="flex flex-col gap-2.5 rounded-3xl bg-[#FFF4EC] p-4">
{items.map((f) => (
<li key={f} className="flex items-center gap-2.5 text-[15px] font-bold text-ink">
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-orange text-white"><Check size={14} strokeWidth={3.5} /></span>
{f}
</li>
))}
</ul>
);
}
/** یک ردیفِ انتخابی: نام و قیمت درشت؛ فقط یک نکته‌ی کوچک (تخفیف یا هدیه)، نه پنج خطِ ریز. */
function PlanRow({ plan, monthlyPrice, selected, onSelect }: { plan: Plan; monthlyPrice?: number; selected: boolean; onSelect: () => void }) {
const isPack = plan.kind === 'pack';
/** یک کارتِ انتخابی: نام و قیمت درشت، و زیرش آنچه این اشتراک می‌دهد (خواستِ سینیور: روی هر کارت). */
function PlanRow({ plan, assistant, monthlyPrice, selected, onSelect }: { plan: Plan; assistant: string; monthlyPrice?: number; selected: boolean; onSelect: () => void }) {
const months = Math.round(plan.durationDays / 30);
const saving = !isPack && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
const tone = isPack ? '#9B7BFF' : '#FF7A3D';
const note = isPack
? plan.heartsDays > 0 ? `+ ${toFa(plan.heartsDays)} روز قلبِ نامحدود هدیه` : ''
: saving > 0 ? `${toFa(saving)}٪ ارزان‌تر از ماهانه` : '';
const saving = monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
const tone = '#FF7A3D';
const perks = [...plan.features, `${toFa(plan.aiDailyQuota)} سوال در روز از ${assistant}`];
return (
<button
type="button"
role="radio"
aria-checked={selected}
onClick={onSelect}
className="relative flex items-center gap-3 rounded-3xl border-2 border-b-4 bg-white px-4 py-4 text-right transition-colors active:translate-y-0.5"
className="relative flex flex-col gap-3 rounded-3xl border-2 border-b-4 bg-white px-4 py-4 text-right transition-colors active:translate-y-0.5"
style={{ borderColor: selected ? tone : '#e9e6f7', backgroundColor: selected ? `${tone}0F` : '#fff' }}
>
{plan.badge && (
@@ -252,19 +175,27 @@ function PlanRow({ plan, monthlyPrice, selected, onSelect }: { plan: Plan; month
{plan.badge}
</span>
)}
<span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2"
style={{ borderColor: selected ? tone : '#d6d3ea', backgroundColor: selected ? tone : '#fff' }}
>
{selected && <Check size={14} strokeWidth={3.5} className="text-white" />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-lg font-black text-ink">
{isPack ? <>{toFa(plan.questionCredits)} سوال</> : durationLabel(plan.durationDays) || plan.title}
<span className="flex items-center gap-3">
<span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2"
style={{ borderColor: selected ? tone : '#d6d3ea', backgroundColor: selected ? tone : '#fff' }}
>
{selected && <Check size={14} strokeWidth={3.5} className="text-white" />}
</span>
{note && <span className="mt-0.5 block text-sm font-bold" style={{ color: isPack ? '#7857E0' : '#2E9E6A' }}>{note}</span>}
<span className="min-w-0 flex-1">
<span className="block text-lg font-black text-ink">{durationLabel(plan.durationDays) || plan.title}</span>
{saving > 0 && <span className="mt-0.5 block text-sm font-bold text-[#2E9E6A]">{toFa(saving)}٪ ارزان‌تر از ماهانه</span>}
</span>
<span className="text-left text-base font-black text-ink">{price(plan.price)}</span>
</span>
<span className="text-left text-base font-black text-ink">{price(plan.price)}</span>
<ul className="flex flex-col gap-1.5 border-t border-line pt-3">
{perks.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm font-bold text-body">
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-orange text-white"><Check size={12} strokeWidth={3.5} /></span>
{f}
</li>
))}
</ul>
</button>
);
}
@@ -411,7 +342,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
: 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.';
return (
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? (plan.kind === 'pack' ? `${toFa(plan.questionCredits)} سوال` : `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}`) : ''}>
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}` : ''}>
{done ? (
<>
<p className="mb-5 text-sm leading-7 text-muted">{doneText}</p>

View File

@@ -14,7 +14,7 @@ import { MathText } from '@/lib/MathText';
export function UnitScreen({ unitId }: { unitId: string }) {
const { home, homeError, refreshHome, companion } = useAppData();
const nav = useNav();
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
const paywall = () => nav.push({ name: 'subscription' });
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
const found = findUnit(home, unitId);