Subscription store, paywall on locked units, profile subscription bar, Tadrisino branding

- Store opens with two choices (subscription / question packs); 3- and 6-month cards
  show per-month price and saving.
- Units that need a subscription show a crown badge and open the store; a 402 from
  the quiz goes there too. «ادامه بده» skips locked units.
- Profile: subscription card with an animated days-left bar (planDurationDays).
- BrandMark (SVG) on splash, home header and footer; copyright footer; floating math
  glyphs on splash. CSS transform/opacity only; honours reduced motion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 01:19:09 +03:30
parent 8879b42b1c
commit a8a5351862
15 changed files with 320 additions and 71 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Bell, ChevronLeft, Coins, Flame, Hand, PartyPopper, Star } from 'lucide-react';
import { Bell, ChevronLeft, Coins, Crown, Flame, Hand, PartyPopper, Star } from 'lucide-react';
import type { HomeNode } from '@/api';
import { useAppData } from '@/state/AppData';
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
@@ -11,6 +11,8 @@ import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/
import { onWhite, shade } from '@/lib/color';
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
import { BrandFooter } from '@/components/BrandFooter';
import { BrandMark } from '@/components/BrandMark';
/**
* خانه = خودِ مسیر، مثلِ دولینگو: همه‌ی فصل‌های پایه پشتِ هم، و جزیره‌ی فعلی با
@@ -20,6 +22,8 @@ import { MathText } from '@/lib/MathText';
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 [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
const currentRef = useRef<HTMLDivElement>(null);
const scrolled = useRef(false);
@@ -52,6 +56,7 @@ export function HomeScreen() {
<Screen>
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-4 pt-safe text-white shadow-md shadow-coral/20">
<div className="flex items-center gap-2 pt-4">
<BrandMark size={26} withName={false} />
{gradeTitle && (
<button type="button" onClick={() => nav.switchTab('profile')} className="hit rounded-full bg-white/20 px-3 py-1.5 text-xs font-black active:scale-95">
{gradeTitle}
@@ -111,6 +116,7 @@ export function HomeScreen() {
<div className="min-w-0 flex-1">
<div className="text-[11px] font-bold text-white/80">
فصلِ {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
{unit.requiresSubscription && <span className="mr-1.5 inline-flex h-5 items-center gap-1 rounded-full bg-white/25 px-2 align-middle leading-none"><Crown size={11} /> با اشتراک</span>}
{isCurrentUnit && <span className="mr-1.5 inline-flex h-5 items-center rounded-full bg-white/25 px-2 align-middle leading-none">الان اینجایی</span>}
</div>
<div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div>
@@ -123,13 +129,14 @@ export function HomeScreen() {
nodes={unit.nodes}
color={color}
currentId={isCurrentUnit ? cont!.node.id : null}
onOpen={(node) => setOpenNode({ node, color })}
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode({ node, color }))}
onStart={(node) => (unit.requiresSubscription ? paywall() : nav.push({ name: 'quiz', nodeId: node.id }))}
/>
</div>
</section>
);
})}
<BrandFooter />
</div>
)}
</div>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import { Brain, Check, ChevronDown, Lock, PartyPopper, Play, Sparkles } from 'lucide-react';
import { Brain, Check, ChevronDown, Crown, Lock, PartyPopper, Play, Sparkles } from 'lucide-react';
import { api, type HomeNode, type HomeUnit } from '@/api';
import { useAppData } from '@/state/AppData';
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
@@ -115,6 +115,8 @@ function UnitCard({
const complete = p.total > 0 && p.done === p.total;
const untouched = unit.nodes.every((n) => n.status === 'locked');
const Icon = iconFor(unit.nodes[0]?.icon, index);
const nav = useNav();
const premium = unit.requiresSubscription;
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
@@ -123,7 +125,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={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', view: '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 }}
@@ -133,6 +135,7 @@ function UnitCard({
<span className="min-w-0 flex-1">
<span className="block text-[11px] font-bold" style={{ color }}>
{complete ? <><PartyPopper size={12} className="mb-0.5 ml-1 inline" />تموم شد</> : `${toFa(p.total)} جزیره`}
{premium && <span className="mr-1.5 inline-flex items-center gap-0.5 rounded-full bg-[#FFF4D6] px-1.5 text-[10px] text-[#A86A00]"><Crown size={10} /> با اشتراک</span>}
</span>
<span className="block text-sm font-black leading-6 text-ink"><MathText text={unit.title} /></span>
<span className="mt-1 flex items-center gap-2">
@@ -140,9 +143,9 @@ function UnitCard({
<span className="text-[11px] text-muted">{toFa(p.done)}/{toFa(p.total)}</span>
</span>
</span>
<ChevronDown size={18} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />
{premium ? <Crown size={18} className="flex-shrink-0 text-[#E0A200]" /> : <ChevronDown size={18} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />}
</button>
{open && (
{open && !premium && (
<ul className="flex flex-col gap-1.5 border-t border-line bg-[#fbfaff] px-3 py-3">
{unit.nodes.map((node, ni) => (
<NodeRow

View File

@@ -1,6 +1,6 @@
import { useState, type ReactNode } from 'react';
import {
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Crown, Heart, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
FileText, Pencil, Repeat, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX,
} from 'lucide-react';
import { api } from '@/api';
@@ -15,7 +15,10 @@ import { BirthDatePicker, ageOf, formatBirthDate } from '@/components/BirthDateP
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
import { digitsOnly, toFa } from '@/lib/format';
import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
import { useAsync } from '@/lib/useAsync';
import { daysLeft } from '@/lib/subscription';
import { BrandFooter } from '@/components/BrandFooter';
type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null;
@@ -68,6 +71,8 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
</header>
<div className="flex flex-col gap-4 px-4 py-5">
<SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
<Menu>
<Row icon={GraduationCap} label="پایه‌ی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
<Row
@@ -108,6 +113,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<button type="button" onClick={() => setEditing('delete')} className="flex items-center justify-center gap-2 py-2 text-xs font-bold text-red-500">
<Trash2 size={14} /> حذفِ حساب
</button>
<BrandFooter />
</div>
</div>
@@ -120,6 +126,74 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
);
}
/**
* اشتراک به‌صورتِ نوار: چند روز از کلِ دوره مانده. مخرج طولِ پلن است (planDurationDays)؛ تمدیدِ زودتر از
* موعد روزها را روی هم می‌گذارد، پس نوار در ۱۰۰٪ بسته می‌شود. زدنِ کارت فروشگاه را باز می‌کند.
*/
function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
const status = useAsync(() => api.subscription.status(), []);
const s = status.data;
if (!s) return <div className="h-[132px] animate-pulse rounded-3xl bg-soft" />;
const days = s.active ? daysLeft(s.expiresAt) : 0;
const total = s.planDurationDays ?? 30;
// اشتراکِ بی‌تاریخ (هدیه) پر نشان داده می‌شود.
const percent = s.active ? (days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)))) : 0;
const low = s.active && days !== null && days <= 5;
return (
<button
type="button"
onClick={onOpen}
className={`relative overflow-hidden rounded-3xl p-4 text-right active:scale-[0.99] ${s.active ? 'bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] text-white shadow-lg shadow-orange/25' : 'border-2 border-dashed border-orange/50 bg-white text-ink'}`}
>
<div className="flex items-center gap-3">
<span className={`glyph flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl ${s.active ? 'bg-white/20' : 'bg-[#FFF4D6] text-[#E0A200]'}`}><Crown size={20} /></span>
<span className="min-w-0 flex-1">
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراکِ تدریسینو</span>
<span className="block text-base font-black">{s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}</span>
</span>
<span className="text-left">
{s.active && days !== null ? (
<><b className="block text-2xl font-black leading-none">{toFa(days)}</b><span className="text-[10px] text-white/80">روز مونده</span></>
) : (
<ChevronLeft size={18} className={s.active ? 'text-white/80' : 'text-faint'} />
)}
</span>
</div>
<div
className={`relative mt-3 h-3 overflow-hidden rounded-full ${s.active ? 'bg-black/15' : 'bg-soft'}`}
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={days ?? total}
aria-label="روزهای ماندهٔ اشتراک"
>
{percent > 0 && (
<div className="bar-fill absolute inset-y-0 right-0 overflow-hidden rounded-full" style={{ width: `${percent}%`, backgroundColor: low ? '#FFD23F' : '#fff' }}>
<span className="shine absolute inset-y-0 w-1/3 bg-gradient-to-l from-transparent via-white/70 to-transparent" style={{ mixBlendMode: 'overlay' }} />
</div>
)}
</div>
<div className={`mt-2 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[11px] ${s.active ? 'text-white/85' : 'text-muted'}`}>
{s.active ? (
<>
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقتِ تمدیده'}</span>}
<span className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلبِ نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
</>
) : (
<span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همه‌ی فصل‌ها و قلبِ نامحدود باز می‌شه.</span>
)}
</div>
{s.questionCredits > 0 && (
<div className={`mt-1.5 flex items-center gap-1 text-[11px] font-bold ${s.active ? 'text-white/90' : 'text-violet'}`}>
<Sparkles size={11} /> {toFa(s.questionCredits)} سوالِ خریده‌شده در اعتبارت
</div>
)}
</button>
);
}
function Menu({ children }: { children: ReactNode }) {
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
}
@@ -273,6 +347,7 @@ function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open:
{switching && 'اول از این حساب خارج می‌شی، بعد با شماره‌ی دیگه وارد شو. '}
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
</p>
<p className="mb-5 -mt-3 text-xs text-muted">ممنون که با تدریسینو یاد می‌گیری 💛</p>
<div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، می‌مونم</SecondaryButton>
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>

View File

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

View File

@@ -1,21 +1,35 @@
import { motion } from 'motion/react';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { BrandMark } from '@/components/BrandMark';
/** تا وقتی نشستِ قبلی بررسی می‌شود نمایش داده می‌شود. */
/** نمادهای ریاضی که آرام از پایینِ صفحه بالا می‌روند؛ فقط CSS (transform/opacity)، بی‌بارِ اضافه. */
const GLYPHS = [
{ c: 'π', x: 12, d: 0 }, { c: '+', x: 28, d: 2.6 }, { c: '√', x: 46, d: 1.2 },
{ c: '÷', x: 64, d: 3.8 }, { c: '∑', x: 80, d: 0.6 }, { c: '×', x: 90, d: 4.6 },
];
/** تا وقتی نشستِ قبلی بررسی می‌شود نمایش داده می‌شود؛ عمداً تأخیرِ ساختگی ندارد. */
export function SplashScreen() {
return (
<div className="flex h-full flex-col items-center justify-center bg-rose">
<div className="relative flex h-full flex-col items-center justify-center overflow-hidden bg-rose">
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2">
{GLYPHS.map((g) => (
<span key={g.c} className="rise absolute bottom-0 text-3xl font-black text-white/60" style={{ left: `${g.x}%`, animationDelay: `${g.d}s` }}>
{g.c}
</span>
))}
</div>
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
className="flex flex-col items-center gap-4"
className="relative flex flex-col items-center gap-4"
>
<div className="rounded-[32px] bg-white/15 p-6 backdrop-blur-sm">
<SeyarMascot char="sigma" mood="happy" size={130} />
</div>
<h1 className="text-3xl font-black text-white">تدریسینو</h1>
<p className="text-sm text-white/75">ریاضی، قدم‌به‌قدم، با همراه‌هات</p>
<BrandMark size={44} />
<p className="text-sm text-white/80">ریاضی، قدم‌به‌قدم، از روی کتابِ درسی</p>
</motion.div>
<motion.div
animate={{ rotate: 360 }}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Check, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
import { api, type Plan } from '@/api';
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 { daysLeft } from '@/lib/subscription';
import { newIdempotencyKey } from '@/api/http';
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
import { useAsync } from '@/lib/useAsync';
@@ -13,72 +14,96 @@ function durationLabel(days: number): string {
if (days === 30 || days === 31) return 'یک‌ماهه';
if (days >= 360 && days <= 370) return 'یک‌ساله';
if (days === 90) return 'سه‌ماهه';
if (days === 180) return 'شش‌ماهه';
return days ? `${toFa(days)} روزه` : '';
}
type View = 'subscription' | 'pack';
/**
* «بسته‌ی سوال» (از ۱۴۰۵-۰۷ به‌جای اشتراکِ ماهانه/سالانه). هر کس به اندازه‌ی مصرفش سوال از
* دستیار می‌خرد؛ اعتبار منقضی نمی‌شود و هر بسته چند روز قلبِ نامحدود هم هدیه دارد. هر روز
* اول سوال‌های رایگانِ همان روز خرج می‌شوند، بعد اعتبار.
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/شش‌ماهه؛ همه‌ی فصل‌ها، قلبِ نامحدود، سقفِ روزانه‌ی دستیار)
* و «بسته‌ی سوال» (سوالِ بی‌انقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
* یک‌جا کاربر را گیج نکند. از فصلِ قفل یک‌راست به فهرستِ اشتراک می‌آید (`initialView`).
*/
export function SubscriptionScreen() {
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
const plans = useAsync(() => api.subscription.plans(), []);
const status = useAsync(() => api.subscription.status(), []);
const quota = useAsync(() => api.ai.quota(), []);
const [view, setView] = useState<View | null>(initialView ?? null);
const [buying, setBuying] = useState<Plan | null>(null);
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درون‌برنامه‌ایِ خودش است؛ بسته‌ای که
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درون‌برنامه‌ایِ خودش است؛ محصولی که
// SKU ندارد آن‌جا خریدنی نیست (به زرین‌پال فرستاده نمی‌شود).
const bazaar = inBazaarApp();
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);
return (
<Screen>
<div className="flex-1 overflow-y-auto">
<ScreenHeader title="بسته‌ی سوال" subtitle="هر وقت لازم داشتی، سوال بخر" color="#ED6956" exact mascot={{ char: 'pi', mood: 'excited', size: 60 }} />
<ScreenHeader
title={view === 'pack' ? 'بسته‌ی سوال' : view === 'subscription' ? 'اشتراکِ تدریسینو' : 'فروشگاه'}
subtitle={view === 'pack' ? 'سوالِ بیشتر از دستیار، بدونِ انقضا' : 'همه‌ی فصل‌ها، قلبِ نامحدود'}
color="#ED6956"
exact
mascot={{ char: 'pi', mood: 'excited', size: 60 }}
/>
<div className="flex flex-col gap-4 px-4 py-5">
<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>
<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>
<StatusCard status={status.data ?? null} credits={credits} heartsUntil={heartsUntil} quota={quota.data ?? null} />
{plans.loading ? (
<LoadingState text="دارم بسته‌ها رو میارم…" />
) : plans.error ? (
<ErrorState error={plans.error} onRetry={plans.reload} />
) : !plans.data?.plans.length ? (
<EmptyState text="فعلاً بسته‌ای برای خرید تعریف نشده" />
{view === null ? (
<div className="flex flex-col gap-3">
<ChoiceCard
icon={<Crown size={24} />}
tone="#FF7A3D"
title="اشتراکِ ماهانه"
text="همه‌ی فصل‌ها و جزیره‌ها باز، با قلبِ نامحدود"
hint={monthly ? `از ${formatToman(monthly.price)}` : undefined}
onClick={() => setView('subscription')}
/>
<ChoiceCard
icon={<Sparkles size={24} />}
tone="#9B7BFF"
title="بسته‌ی سوال"
text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچ‌وقت منقضی نمی‌شه"
onClick={() => setView('pack')}
/>
</div>
) : (
plans.data.plans.map((plan) => (
<PackCard key={plan.id} plan={plan} available={!bazaar || !!plan.bazaarSku} onBuy={() => setBuying(plan)} />
))
<>
{!initialView && (
<button type="button" onClick={() => setView(null)} className="flex items-center gap-1 self-start text-xs font-bold text-coral">
<ChevronRight size={14} /> برگشت به انتخاب
</button>
)}
{plans.loading ? (
<LoadingState text="دارم فهرست رو میارم…" />
) : plans.error ? (
<ErrorState error={plans.error} onRetry={plans.reload} />
) : !list.length ? (
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
) : (
list.map((plan) => (
<PackCard
key={plan.id}
plan={plan}
monthlyPrice={monthly?.price}
// اشتراک در بازار هنوز SKU و تمدیدِ آزموده‌شده ندارد؛ آن‌جا فقط بسته خریدنی است.
available={!bazaar || (plan.kind === 'pack' && !!plan.bazaarSku)}
onBuy={() => setBuying(plan)}
/>
))
)}
{view === 'pack' && (
<p className="text-center text-xs leading-6 text-muted">
هر روز {toFa(status.data?.aiDailyQuota ?? plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول از سقفِ روزانه‌ته؛ بعدش از اعتبارِ بسته کم می‌شه.
</p>
)}
</>
)}
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
@@ -92,7 +117,58 @@ export function SubscriptionScreen() {
);
}
function PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean; onBuy: () => void }) {
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-3 rounded-3xl border-2 border-line bg-white p-4 text-right active:scale-[0.99]">
<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-base font-black text-ink">{title}</span>
<span className="mt-0.5 block text-xs leading-6 text-muted">{text}</span>
{hint && <span className="mt-1 block text-xs font-black" style={{ color: tone }}>{hint}</span>}
</span>
<ChevronLeft size={18} className="flex-shrink-0 text-faint" />
</button>
);
}
/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */
function StatusCard({ status, credits, heartsUntil, quota }: {
status: SubscriptionStatus | null; credits: number; heartsUntil: string | null; quota: TutorQuota | null;
}) {
const days = status?.active ? daysLeft(status.expiresAt) : null;
return (
<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 ${status?.active ? 'bg-[#FFF4D6] text-[#E0A200]' : 'bg-soft text-muted'}`}><Crown size={22} /></span>
<div className="flex-1">
<div className="text-xs font-bold text-muted">اشتراکِ تو</div>
<div className="text-base font-black text-ink">
{status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</span>}</> : 'نداری'}
</div>
</div>
{quota && (
<div className="text-left text-xs leading-6 text-muted">
سوالِ امروز<br />
<b className="text-base text-ink">{toFa(quota.remainingQuota)}</b> از {toFa(status?.aiDailyQuota ?? quota.freeDaily)}
</div>
)}
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-xs font-bold">
<span className="flex items-center gap-1.5 text-violet"><Sparkles size={13} /> اعتبار: {toFa(credits)} سوال</span>
{(status?.active || heartsUntil) && (
<span className="flex items-center gap-1.5 text-coral">
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}</>}
</span>
)}
{status?.active && status.expiresAt && <span className="text-muted">تا {formatJalaliDate(status.expiresAt)}</span>}
</div>
</div>
);
}
function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthlyPrice?: number; available: boolean; onBuy: () => void }) {
const months = Math.round(plan.durationDays / 30);
const saving = plan.kind === 'subscription' && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
const featured = plan.popular;
const isPack = plan.kind === 'pack';
const tone = featured ? 'text-white/80' : 'text-muted';
@@ -105,10 +181,12 @@ function PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean;
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<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>
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ هوشمند، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
</div>
<div className="text-left">
<p className="text-base font-black">{formatToman(plan.price)}</p>
{months > 1 && !isPack && <p className={`text-[11px] ${tone}`}>ماهی {formatToman(Math.round(plan.price / months / 1000) * 1000)}</p>}
{saving > 0 && <p className={`mt-0.5 text-[11px] font-black ${featured ? 'text-white' : 'text-mint'}`}>{toFa(saving)}٪ ارزان‌تر</p>}
</div>
</div>
<ul className="mb-4 flex flex-col gap-2 text-sm">
@@ -233,7 +311,7 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
{done ? (
<>
<p className="mb-5 text-sm leading-7 text-muted">
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و سوال‌ها به اعتبارت اضافه شدن.'}
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
</p>
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
</>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Sparkles } from 'lucide-react';
import { Crown, Sparkles } from 'lucide-react';
import type { HomeNode } from '@/api';
import { useAppData } from '@/state/AppData';
import { findUnit, unitColor, unitProgress } from '@/state/curriculum';
@@ -14,6 +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 [openNode, setOpenNode] = useState<HomeNode | null>(null);
const found = findUnit(home, unitId);
@@ -58,9 +59,14 @@ export function UnitScreen({ unitId }: { unitId: string }) {
nodes={unit.nodes}
color={color}
currentId={current?.id ?? null}
onOpen={setOpenNode}
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode(node))}
onStart={(node) => (unit.requiresSubscription ? paywall() : nav.push({ name: 'quiz', nodeId: node.id }))}
/>
{unit.requiresSubscription && (
<button type="button" onClick={paywall} className="mx-auto mt-6 flex items-center gap-2 rounded-2xl bg-orange px-5 py-3 text-sm font-black text-white shadow-lg shadow-orange/30">
<Crown size={16} /> این فصل با اشتراک باز می‌شه
</button>
)}
<div className="mt-6 flex flex-col items-center">
<span className="mb-1 text-[11px] font-bold text-muted">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>