From a8a53518626c966028c3da49b131deb839811ef6 Mon Sep 17 00:00:00 2001
From: AliReza
Date: Sat, 10 Oct 2026 01:19:09 +0330
Subject: [PATCH] Subscription store, paywall on locked units, profile
subscription bar, Tadrisino branding
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
src/api/types.ts | 12 +-
src/app/MainApp.tsx | 2 +-
src/components/BrandFooter.tsx | 13 +++
src/components/BrandMark.tsx | 23 ++++
src/lib/subscription.ts | 5 +
src/nav/routes.ts | 3 +-
src/screens/HomeScreen.tsx | 13 ++-
src/screens/LessonsScreen.tsx | 11 +-
src/screens/ProfileScreen.tsx | 79 ++++++++++++-
src/screens/QuizScreen.tsx | 7 +-
src/screens/SplashScreen.tsx | 24 +++-
src/screens/SubscriptionScreen.tsx | 176 +++++++++++++++++++++--------
src/screens/UnitScreen.tsx | 12 +-
src/state/curriculum.ts | 1 +
src/styles/index.css | 10 ++
15 files changed, 320 insertions(+), 71 deletions(-)
create mode 100644 src/components/BrandFooter.tsx
create mode 100644 src/components/BrandMark.tsx
create mode 100644 src/lib/subscription.ts
diff --git a/src/api/types.ts b/src/api/types.ts
index 5580e08..378c430 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -68,6 +68,8 @@ export interface HomeUnit {
id: string;
title: string;
blurb: string;
+ /** این کاربر برای بازیِ این فصل اشتراک لازم دارد (فقط فصلِ اولِ هر پایه رایگان است). */
+ requiresSubscription: boolean;
nodes: HomeNode[];
}
@@ -259,7 +261,7 @@ export interface Plan {
badge: string;
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
aiDailyQuota: number;
- /** «pack» بستهی سوال است (از ۱۴۰۵-۰۷)؛ «subscription» اشتراکِ زمانیِ قدیمی. */
+ /** «pack» بستهی سوال؛ «subscription» اشتراکِ زمانی که همهی فصلها را باز میکند. */
kind: 'pack' | 'subscription';
/** بسته: سوالهایی که به اعتبار اضافه میشود. */
questionCredits: number;
@@ -276,8 +278,14 @@ export interface PlansResponse {
}
export interface SubscriptionStatus {
- /** اشتراکِ زمانیِ قدیمی (ماهانه/سالانه)، نه بسته. */
+ /** اشتراکِ زمانیِ فعال (یک/سه/ششماهه)، نه بسته. */
active: boolean;
+ /** نامِ پلنِ فعال؛ null بیاشتراک. */
+ planTitle: string | null;
+ /** طولِ دورهی پلنِ فعال به روز؛ مخرجِ نوارِ «چقدر مانده». */
+ planDurationDays: number | null;
+ /** سقفِ سوالِ روزانهی همین کاربر از دستیار. */
+ aiDailyQuota: number;
planId: string | null;
expiresAt: string | null;
/** سوالهای خریدهشدهی باقیمانده. */
diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx
index 48e7b54..311dfea 100644
--- a/src/app/MainApp.tsx
+++ b/src/app/MainApp.tsx
@@ -110,7 +110,7 @@ function RouteView({ route, onSignedOut }: { route: Route } & Pick ;
case 'lessons': return ;
case 'ask': return ;
- case 'subscription': return ;
+ case 'subscription': return ;
case 'profile': return ;
case 'unit': return ;
case 'lesson': return ;
diff --git a/src/components/BrandFooter.tsx b/src/components/BrandFooter.tsx
new file mode 100644
index 0000000..375f0c3
--- /dev/null
+++ b/src/components/BrandFooter.tsx
@@ -0,0 +1,13 @@
+import { toFa } from '@/lib/format';
+import { BrandMark } from '@/components/BrandMark';
+
+/** امضای تدریسینو پایینِ صفحهها: نشان، نام و حقِ نشر، کوچک و کمرنگ. */
+export function BrandFooter() {
+ return (
+
+
+ ریاضی، قدمبهقدم، از روی کتابِ درسی
+ © {toFa(1405)} تدریسینو · همهی حقوق محفوظ است
+
+ );
+}
diff --git a/src/components/BrandMark.tsx b/src/components/BrandMark.tsx
new file mode 100644
index 0000000..ad7474c
--- /dev/null
+++ b/src/components/BrandMark.tsx
@@ -0,0 +1,23 @@
+/**
+ * نشانِ تدریسینو: یک کاشیِ گرد با «π» و جرقهی کوچکِ چشمکزن، کنارِ نامِ تدریسینو.
+ * خودِ SVG است (بیفایلِ تصویر)، پس در هر اندازه تیز است و چیزی دانلود نمیشود.
+ */
+export function BrandMark({ size = 28, tone = 'light', withName = true }: { size?: number; tone?: 'light' | 'dark'; withName?: boolean }) {
+ const ink = tone === 'light' ? '#fff' : '#3C3F8F';
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {withName && تدریسینو }
+
+ );
+}
diff --git a/src/lib/subscription.ts b/src/lib/subscription.ts
new file mode 100644
index 0000000..d393a9a
--- /dev/null
+++ b/src/lib/subscription.ts
@@ -0,0 +1,5 @@
+/** چند روز تا پایانِ اشتراک مانده (رو به بالا)؛ null برای اشتراکِ بیتاریخ (هدیهی نامحدود). */
+export function daysLeft(expiresAt: string | null): number | null {
+ if (!expiresAt) return null;
+ return Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000));
+}
diff --git a/src/nav/routes.ts b/src/nav/routes.ts
index aae6b2d..d81cfd4 100644
--- a/src/nav/routes.ts
+++ b/src/nav/routes.ts
@@ -3,7 +3,8 @@ export type Route =
| { name: 'home' }
| { name: 'lessons' }
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
- | { name: 'subscription' }
+ /** view: مستقیم به فهرستِ اشتراکها یا بستهها (مثلاً از فصلِ قفل)؛ بیآن انتخابِ دوگزینهای. */
+ | { name: 'subscription'; view?: 'subscription' | 'pack' }
| { name: 'profile' }
| { name: 'unit'; unitId: string }
| { name: 'lesson'; lessonId: number; nodeId?: string }
diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx
index 6122f46..aaabbdc 100644
--- a/src/screens/HomeScreen.tsx
+++ b/src/screens/HomeScreen.tsx
@@ -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(null);
const scrolled = useRef(false);
@@ -52,6 +56,7 @@ export function HomeScreen() {
+
{gradeTitle && (
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() {
فصلِ {toFa(i + 1)} {toFa(p.done)} از {toFa(p.total)} جزیره
+ {unit.requiresSubscription && با اشتراک }
{isCurrentUnit && الان اینجایی }
{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}
@@ -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 }))}
/>
);
})}
+
)}
diff --git a/src/screens/LessonsScreen.tsx b/src/screens/LessonsScreen.tsx
index 958ca2d..332fd4a 100644
--- a/src/screens/LessonsScreen.tsx
+++ b/src/screens/LessonsScreen.tsx
@@ -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 (
-
+ 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">
{complete ? <> تموم شد> : `${toFa(p.total)} جزیره`}
+ {premium && با اشتراک }
@@ -140,9 +143,9 @@ function UnitCard({
{toFa(p.done)}/{toFa(p.total)}
-
+ {premium ? : }
- {open && (
+ {open && !premium && (
{unit.nodes.map((node, ni) => (
void }) {
+ nav.switchTab('subscription')} />
+
setEditing('grade')} />
void }) {
setEditing('delete')} className="flex items-center justify-center gap-2 py-2 text-xs font-bold text-red-500">
حذفِ حساب
+
@@ -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
;
+ 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 (
+
+
+
+
+ اشتراکِ تدریسینو
+ {s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}
+
+
+ {s.active && days !== null ? (
+ <>{toFa(days)} روز مونده >
+ ) : (
+
+ )}
+
+
+
+
+ {percent > 0 && (
+
+
+
+ )}
+
+
+
+ {s.active ? (
+ <>
+ {s.expiresAt && تا {formatJalaliDate(s.expiresAt)}{low && ' · وقتِ تمدیده'} }
+ قلبِ نامحدود · {toFa(s.aiDailyQuota)} سوال در روز
+ >
+ ) : (
+ فصلِ اولِ هر پایه رایگانه؛ با اشتراک همهی فصلها و قلبِ نامحدود باز میشه.
+ )}
+
+ {s.questionCredits > 0 && (
+
+ {toFa(s.questionCredits)} سوالِ خریدهشده در اعتبارت
+
+ )}
+
+ );
+}
+
function Menu({ children }: { children: ReactNode }) {
return {children}
;
}
@@ -273,6 +347,7 @@ function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open:
{switching && 'اول از این حساب خارج میشی، بعد با شمارهی دیگه وارد شو. '}
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
+ ممنون که با تدریسینو یاد میگیری 💛
نه، میمونم
void logout()}>
diff --git a/src/screens/QuizScreen.tsx b/src/screens/QuizScreen.tsx
index 7a35d6c..09bfec2 100644
--- a/src/screens/QuizScreen.tsx
+++ b/src/screens/QuizScreen.tsx
@@ -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;
};
diff --git a/src/screens/SplashScreen.tsx b/src/screens/SplashScreen.tsx
index 10e04dd..f5a48b9 100644
--- a/src/screens/SplashScreen.tsx
+++ b/src/screens/SplashScreen.tsx
@@ -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 (
-
+
+
+ {GLYPHS.map((g) => (
+
+ {g.c}
+
+ ))}
+
- تدریسینو
- ریاضی، قدمبهقدم، با همراههات
+
+ ریاضی، قدمبهقدم، از روی کتابِ درسی
= 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(initialView ?? null);
const [buying, setBuying] = useState(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 (
-
+
-
-
-
-
-
اعتبارِ تو
-
{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 ? (
-
+ {view === null ? (
+
+ }
+ tone="#FF7A3D"
+ title="اشتراکِ ماهانه"
+ text="همهی فصلها و جزیرهها باز، با قلبِ نامحدود"
+ hint={monthly ? `از ${formatToman(monthly.price)}` : undefined}
+ onClick={() => setView('subscription')}
+ />
+ }
+ tone="#9B7BFF"
+ title="بستهی سوال"
+ text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچوقت منقضی نمیشه"
+ onClick={() => setView('pack')}
+ />
+
) : (
- plans.data.plans.map((plan) => (
-
setBuying(plan)} />
- ))
+ <>
+ {!initialView && (
+ setView(null)} className="flex items-center gap-1 self-start text-xs font-bold text-coral">
+ برگشت به انتخاب
+
+ )}
+ {plans.loading ? (
+
+ ) : plans.error ? (
+
+ ) : !list.length ? (
+
+ ) : (
+ list.map((plan) => (
+ setBuying(plan)}
+ />
+ ))
+ )}
+ {view === 'pack' && (
+
+ هر روز {toFa(status.data?.aiDailyQuota ?? plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول از سقفِ روزانهته؛ بعدش از اعتبارِ بسته کم میشه.
+
+ )}
+ >
)}
@@ -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 (
+
+ {icon}
+
+ {title}
+ {text}
+ {hint && {hint} }
+
+
+
+ );
+}
+
+/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */
+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 (
+
+
+
+
+
اشتراکِ تو
+
+ {status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && · {toFa(days)} روز مونده }> : 'نداری'}
+
+
+ {quota && (
+
+ سوالِ امروز
+ {toFa(quota.remainingQuota)} از {toFa(status?.aiDailyQuota ?? quota.freeDaily)}
+
+ )}
+
+
+ اعتبار: {toFa(credits)} سوال
+ {(status?.active || heartsUntil) && (
+
+ قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}>}
+
+ )}
+ {status?.active && status.expiresAt && تا {formatJalaliDate(status.expiresAt)} }
+
+
+ );
+}
+
+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;
{isPack ? <>{toFa(plan.questionCredits)} سوال > : plan.title}
-
{isPack ? 'از دستیارِ «پی»، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}
+
{isPack ? 'از دستیارِ هوشمند، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}
{formatToman(plan.price)}
+ {months > 1 && !isPack &&
ماهی {formatToman(Math.round(plan.price / months / 1000) * 1000)}
}
+ {saving > 0 &&
{toFa(saving)}٪ ارزانتر
}
@@ -233,7 +311,7 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
{done ? (
<>
- {done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و سوالها به اعتبارت اضافه شدن.'}
+ {done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
عالیه!
>
diff --git a/src/screens/UnitScreen.tsx b/src/screens/UnitScreen.tsx
index c332943..bbd4bb2 100644
--- a/src/screens/UnitScreen.tsx
+++ b/src/screens/UnitScreen.tsx
@@ -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(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 && (
+
+ این فصل با اشتراک باز میشه
+
+ )}
{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}
diff --git a/src/state/curriculum.ts b/src/state/curriculum.ts
index 7a5d71d..0987980 100644
--- a/src/state/curriculum.ts
+++ b/src/state/curriculum.ts
@@ -23,6 +23,7 @@ export interface NodeRef {
export function findContinue(home: HomeResponse | null): NodeRef | null {
if (!home) return null;
for (let i = 0; i < home.units.length; i++) {
+ if (home.units[i].requiresSubscription) continue;
const node = home.units[i].nodes.find((n) => n.status !== 'completed' && n.status !== 'locked');
if (node) return { unit: home.units[i], unitIndex: i, node };
}
diff --git a/src/styles/index.css b/src/styles/index.css
index 8ed7c04..bd084e8 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -109,3 +109,13 @@
/* ناحیهی لمسِ بزرگتر بدونِ تغییرِ ظاهر: دکمههای کوچکِ سربرگ (۲۸–۳۲ پیکسل) برای انگشت کوچکاند. */
.hit { position: relative; }
.hit::after { content: ''; position: absolute; inset: -7px -3px; }
+
+/* نوارِ اشتراک و نشانِ تدریسینو: فقط transform/opacity تا روی GPU بماند و روانی حفظ شود. */
+@keyframes bar-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
+.bar-fill { transform-origin: right; animation: bar-fill 0.9s cubic-bezier(.2,.8,.2,1) both; }
+@keyframes shine { 0% { transform: translateX(120%); } 60%, 100% { transform: translateX(-220%); } }
+.shine { animation: shine 3.2s ease-in-out infinite; will-change: transform; }
+@keyframes spark { 0%, 100% { opacity: .25; transform: scale(.7); } 50% { opacity: 1; transform: scale(1); } }
+.spark { animation: spark 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
+@keyframes rise { 0% { transform: translateY(16px); opacity: 0; } 15%, 85% { opacity: .55; } 100% { transform: translateY(-110px); opacity: 0; } }
+.rise { animation: rise 7s linear infinite; will-change: transform, opacity; }