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 && (
)} 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 ( - - {open && ( + {open && !premium && (
    {unit.nodes.map((node, ni) => ( void }) {
+ nav.switchTab('subscription')} /> + setEditing('grade')} /> void }) { +
@@ -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 ( + + ); +} + 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 ( -
+
+
-

تدریسینو

-

ریاضی، قدم‌به‌قدم، با همراه‌هات

+ +

ریاضی، قدم‌به‌قدم، از روی کتابِ درسی

= 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 && ( + + )} + {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 ( + + ); +} + +/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */ +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; }