No diacritics or written ezafe ی in the UI; map without the speech banner, companions cheering along the path; familiar payment sheet with gateway logos and per-channel methods; account deletion moved under «حساب کاربری»

- Text: kasra/fatha/damma/shadda/sukun/hamza-above and «ه‌ی» ezafe removed from every user-visible string (comments untouched); tanwin kept. Stale copy fixed (question packs are no longer sold), awkward onboarding sentences rewritten, سؤال → سوال. plainPersian() in MathText cleans the live tutor stream.
- Home: the mascot + speech bubble header is gone; the selected companion hops beside the current island and sits at every third bend (CSS transform animations only).
- Checkout: order summary, payment-method rows with Zarinpal/Bazaar icons and radio, amount on the pay button, lock trust line, darker overlay. paymentMethods in config.js per channel (web: zarinpal, app: zarinpal+bazaar by default).
- Profile: «ورود با حساب دیگر» removed (it was logout); new «حساب کاربری» sheet shows phone/ID and holds «حذف حساب کاربری»; logout is a red row. Delete sheet tells Bazaar subscribers to cancel renewal in Bazaar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 16:55:17 +03:30
parent 4a661f504f
commit ca96a91dee
48 changed files with 492 additions and 337 deletions

View File

@@ -45,6 +45,18 @@ VITE_DEV_PROXY_TARGET=https://mathapi.tadrisino2.ir
- **سمتِ بک‌اند** هم دامنه‌ی تازه‌ی فرانت باید مجاز باشد (`FRONTEND_URL` در `.env` - **سمتِ بک‌اند** هم دامنه‌ی تازه‌ی فرانت باید مجاز باشد (`FRONTEND_URL` در `.env`
بک‌اند). جزئیات در `docs/operations.md` همان ریپو. بک‌اند). جزئیات در `docs/operations.md` همان ریپو.
## روش‌های پرداخت
برگه‌ی پرداخت روش‌ها را از `paymentMethods` در `dist/config.js` می‌خواند (`src/lib/paymentMethods.ts`)، جدا برای
مرورگر (`web`) و داخلِ اپِ اندروید (`app`)؛ مقدارها `"zarinpal"` و `"bazaar"`. پیش‌فرض: مرورگر فقط زرین‌پال، اپ هر دو.
نسخه‌ای که فقط در کافه‌بازار است: `paymentMethods: { app: ["bazaar"] }`؛ build لازم نیست، فقط کشِ CDNِ این فایل را پاک کنید.
## متنِ فارسی
هیچ اعرابی (کسره، فتحه، تشدید، …) و هیچ «ی»ِ اضافه‌ی نوشته‌شده («پایه‌ی سوم») به کاربر نمی‌رسد (تصمیمِ شرکت،
۲۰۲۶-۱۰-۱۴)؛ تنوین («قبلاً») می‌ماند. متنِ تازه را همین‌طور بنویسید. متنِ سرور را بک‌اند هنگامِ ساختنِ پاسخ پاک می‌کند
و `plainPersian` در `MathText` همان کار را برای پاسخِ زنده‌ی دستیار.
## دیپلوی ## دیپلوی
روی سرور (`~/mathapp_frontend_new`، که nginxِ `math.tadrisino2.ir` از `dist/`ش سرو می‌کند): روی سرور (`~/mathapp_frontend_new`، که nginxِ `math.tadrisino2.ir` از `dist/`ش سرو می‌کند):

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ED6956" /> <meta name="theme-color" content="#ED6956" />
<meta name="description" content="تدریسینو — یادگیریِ ریاضیِ پایه‌ی سوم تا دوازدهم، قدم‌به‌قدم و با دستیارِ هوشمند." /> <meta name="description" content="تدریسینو — یادگیری ریاضی پایه سوم تا دوازدهم، قدم‌به‌قدم و با دستیار هوشمند." />
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<title>تدریسینو</title> <title>تدریسینو</title>
<!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار می‌شود. --> <!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار می‌شود. -->
@@ -24,9 +24,9 @@
.boot-splash { position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column; .boot-splash { position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 20px; background: #fff; } align-items: center; justify-content: center; gap: 20px; background: #fff; }
.boot-splash img { width: 144px; height: 144px; display: block; } .boot-splash img { width: 144px; height: 144px; display: block; }
/* داخلِ اپِ اندروید اسپلشِ بومی روی صفحه است و سایت زیرش بار می‌شود؛ اسپلشِ وب نباید یک بار دیگر /* داخل اپ اندروید اسپلش بومی روی صفحه است و سایت زیرش بار می‌شود؛ اسپلش وب نباید یک بار دیگر
«عوض شدن» نشان دهد. APKِ تازه (appReady دارد): اسپلشِ وب نامرئی است. APKِ قدیمی: فقط آیکون، «عوض شدن» نشان دهد. APK تازه (appReady دارد): اسپلش وب نامرئی است. APK قدیمی: فقط آیکون،
بی‌نام، تا کمتر از اسپلشِ سیستم فرق کند. */ بی‌نام، تا کمتر از اسپلش سیستم فرق کند. */
.in-app .boot-splash > * { visibility: hidden; } .in-app .boot-splash > * { visibility: hidden; }
.in-old-app .boot-splash span { display: none; } .in-old-app .boot-splash span { display: none; }
.in-old-app .boot-splash img { width: 160px; height: 160px; } .in-old-app .boot-splash img { width: 160px; height: 160px; }

View File

@@ -20,7 +20,7 @@
<body> <body>
<main> <main>
<div class="spin" id="spin"></div> <div class="spin" id="spin"></div>
<p id="msg">داریم می‌ریم به درگاهِ پرداخت…</p> <p id="msg">داریم می‌ریم به درگاه پرداخت…</p>
<a id="go" href="#">رفتن به درگاه</a> <a id="go" href="#">رفتن به درگاه</a>
</main> </main>
<script type="module" src="/src/pay.ts"></script> <script type="module" src="/src/pay.ts"></script>

View File

@@ -4,6 +4,10 @@
// برای جابه‌جا کردنِ بک‌اند بدونِ build دوباره، آدرسِ کامل را اینجا بنویسید: // برای جابه‌جا کردنِ بک‌اند بدونِ build دوباره، آدرسِ کامل را اینجا بنویسید:
// apiBaseUrl: "https://api.new-domain.ir/api", // apiBaseUrl: "https://api.new-domain.ir/api",
// اگر CDN (آروان) جلوی سایت است، بعد از ویرایش کشِ این فایل را پاک کنید. // اگر CDN (آروان) جلوی سایت است، بعد از ویرایش کشِ این فایل را پاک کنید.
//
// paymentMethods: روش‌های پرداختی که برگه‌ی پرداخت نشان می‌دهد، به همین ترتیب؛ «zarinpal» و «bazaar».
// web = سایت در مرورگر، app = داخلِ اپِ اندروید. نسخه‌ی کافه‌بازار: app: ["bazaar"].
// نبودنِ این کلید یعنی پیش‌فرضِ src/lib/paymentMethods.ts.
window.__TADRISINO_CONFIG__ = { window.__TADRISINO_CONFIG__ = {
apiBaseUrl: "", apiBaseUrl: "",
}; };

View File

@@ -254,7 +254,7 @@ export function createHttpClient(options: HttpClientOptions) {
} catch (err) { } catch (err) {
if (opts.signal?.aborted) throw err; if (opts.signal?.aborted) throw err;
const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError'); const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError');
throw new ApiError(0, timedOut ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد', {}, timedOut); throw new ApiError(0, timedOut ? 'زمان درخواست تمام شد' : 'ارتباط با سرور برقرار نشد', {}, timedOut);
} }
} }

View File

@@ -332,14 +332,14 @@ function PaymentResult({ payment }: { payment: PaymentReturn }) {
<div className="flex flex-col items-center gap-3 pb-2 text-center"> <div className="flex flex-col items-center gap-3 pb-2 text-center">
{paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />} {paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />}
<h2 className="text-lg font-black text-ink"> <h2 className="text-lg font-black text-ink">
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیتِ پرداخت معلوم نشد' : 'پرداخت انجام نشد'} {paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیت پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
</h2> </h2>
<p className="text-sm leading-7 text-muted"> <p className="text-sm leading-7 text-muted">
{paid {paid
? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.' ? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.'
: state === 'unknown' : state === 'unknown'
? 'چند دقیقه‌ی دیگه از «پرداخت‌ها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال می‌شه یا پول خودکار برمی‌گرده.' ? 'چند دقیقه دیگه از «پرداخت‌ها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال می‌شه یا پول خودکار برمی‌گرده.'
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'} : 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طی چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'}
</p> </p>
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton> <PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
</div> </div>

BIN
src/assets/pay/bazaar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src/assets/pay/zarinpal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -102,15 +102,15 @@ export function BirthDatePicker({
return ( return (
<div className="grid grid-cols-[1fr_1.5fr_1.3fr] gap-2"> <div className="grid grid-cols-[1fr_1.5fr_1.3fr] gap-2">
<select aria-label="روزِ تولد" value={d} onChange={(e) => setD(e.target.value ? +e.target.value : '')} className={`${select} ${tone(d)}`}> <select aria-label="روز تولد" value={d} onChange={(e) => setD(e.target.value ? +e.target.value : '')} className={`${select} ${tone(d)}`}>
<option value="">روز</option> <option value="">روز</option>
{Array.from({ length: days }, (_, i) => i + 1).map((n) => <option key={n} value={n}>{toFa(n)}</option>)} {Array.from({ length: days }, (_, i) => i + 1).map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
</select> </select>
<select aria-label="ماهِ تولد" value={m} onChange={(e) => setM(e.target.value ? +e.target.value : '')} className={`${select} ${tone(m)}`}> <select aria-label="ماه تولد" value={m} onChange={(e) => setM(e.target.value ? +e.target.value : '')} className={`${select} ${tone(m)}`}>
<option value="">ماه</option> <option value="">ماه</option>
{JALALI_MONTHS.map((name, i) => <option key={name} value={i + 1}>{name}</option>)} {JALALI_MONTHS.map((name, i) => <option key={name} value={i + 1}>{name}</option>)}
</select> </select>
<select aria-label="سالِ تولد" value={y} onChange={(e) => setY(e.target.value ? +e.target.value : '')} className={`${select} ${tone(y)}`}> <select aria-label="سال تولد" value={y} onChange={(e) => setY(e.target.value ? +e.target.value : '')} className={`${select} ${tone(y)}`}>
<option value="">سال</option> <option value="">سال</option>
{years.map((n) => <option key={n} value={n}>{toFa(n)}</option>)} {years.map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
</select> </select>

View File

@@ -6,8 +6,8 @@ export function BrandFooter() {
return ( return (
<footer className="flex flex-col items-center gap-1 pb-4 pt-3 text-center"> <footer className="flex flex-col items-center gap-1 pb-4 pt-3 text-center">
<BrandMark size={24} tone="dark" /> <BrandMark size={24} tone="dark" />
<span className="text-[10px] text-muted">ریاضی، قدم‌به‌قدم، از روی کتابِ درسی</span> <span className="text-[10px] text-muted">ریاضی، قدم‌به‌قدم، از روی کتاب درسی</span>
<span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو · همه‌ی حقوق محفوظ است</span> <span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو · همه حقوق محفوظ است</span>
</footer> </footer>
); );
} }

View File

@@ -10,18 +10,18 @@ type Kind = 'question' | 'lesson';
const REASONS: Record<Kind, { id: string; label: string }[]> = { const REASONS: Record<Kind, { id: string; label: string }[]> = {
question: [ question: [
{ id: 'wrong_answer', label: 'جوابِ درست اشتباهه' }, { id: 'wrong_answer', label: 'جواب درست اشتباهه' },
{ id: 'unclear', label: 'صورتِ سوال گنگه' }, { id: 'unclear', label: 'صورت سوال گنگه' },
{ id: 'typo', label: 'غلطِ تایپی داره' }, { id: 'typo', label: 'غلط تایپی داره' },
{ id: 'too_hard', label: 'برای این درس خیلی سخته' }, { id: 'too_hard', label: 'برای این درس خیلی سخته' },
{ id: 'other', label: 'یه چیزِ دیگه' }, { id: 'other', label: 'یه چیز دیگه' },
], ],
lesson: [ lesson: [
{ id: 'wrong_content', label: 'مطلبِ درس اشتباهه' }, { id: 'wrong_content', label: 'مطلب درس اشتباهه' },
{ id: 'unclear', label: 'توضیح گنگه' }, { id: 'unclear', label: 'توضیح گنگه' },
{ id: 'typo', label: 'غلطِ تایپی داره' }, { id: 'typo', label: 'غلط تایپی داره' },
{ id: 'bad_figure', label: 'شکل ایراد داره' }, { id: 'bad_figure', label: 'شکل ایراد داره' },
{ id: 'other', label: 'یه چیزِ دیگه' }, { id: 'other', label: 'یه چیز دیگه' },
], ],
}; };
@@ -72,7 +72,7 @@ export function FeedbackBar({ kind, id }: { kind: Kind; id: string }) {
<ThumbsDown size={16} strokeWidth={2.4} /> خوب نبود <ThumbsDown size={16} strokeWidth={2.4} /> خوب نبود
</button> </button>
<button type="button" onClick={() => setOpen(true)} className={`${base} ${idle}`}> <button type="button" onClick={() => setOpen(true)} className={`${base} ${idle}`}>
<Flag size={16} strokeWidth={2.4} /> گزارشِ ایراد <Flag size={16} strokeWidth={2.4} /> گزارش ایراد
</button> </button>
</div> </div>
<ReportSheet open={open} kind={kind} id={id} onClose={() => setOpen(false)} /> <ReportSheet open={open} kind={kind} id={id} onClose={() => setOpen(false)} />
@@ -121,11 +121,11 @@ function ReportSheet({ open, kind, id, onClose }: { open: boolean; kind: Kind; i
<textarea <textarea
value={note} value={note}
onChange={(e) => setNote(e.target.value.slice(0, 500))} onChange={(e) => setNote(e.target.value.slice(0, 500))}
placeholder="توضیحِ بیشتر (اختیاری)" placeholder="توضیح بیشتر (اختیاری)"
rows={2} rows={2}
className="mb-4 w-full resize-none rounded-2xl border-2 border-line px-4 py-3 text-sm outline-none focus:border-coral" className="mb-4 w-full resize-none rounded-2xl border-2 border-line px-4 py-3 text-sm outline-none focus:border-coral"
/> />
<PrimaryButton disabled={!reason} loading={sending} onClick={() => void send()}>ارسالِ گزارش</PrimaryButton> <PrimaryButton disabled={!reason} loading={sending} onClick={() => void send()}>ارسال گزارش</PrimaryButton>
</Sheet> </Sheet>
); );
} }

View File

@@ -1,4 +1,4 @@
import { forwardRef } from 'react'; import { forwardRef, memo, useMemo } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Check, Lock, Play, Star } from 'lucide-react'; import { Check, Lock, Play, Star } from 'lucide-react';
import type { HomeNode } from '@/api'; import type { HomeNode } from '@/api';
@@ -7,6 +7,8 @@ import { iconFor } from '@/components/NodeIcon';
import { shade } from '@/lib/color'; import { shade } from '@/lib/color';
import { MathText } from '@/lib/MathText'; import { MathText } from '@/lib/MathText';
import { useFirstVisit } from '@/lib/firstVisit'; import { useFirstVisit } from '@/lib/firstVisit';
import { SEYAR_SVG, type SeyarChar, type SeyarMood } from '@/mascots/seyar';
import { useCompanion } from '@/state/companion';
const NODE_SIZE = 76; const NODE_SIZE = 76;
const SPACING = 128; const SPACING = 128;
@@ -26,13 +28,18 @@ export const IslandPath = forwardRef<HTMLDivElement, {
onOpen: (node: HomeNode) => void; onOpen: (node: HomeNode) => void;
onStart: (node: HomeNode) => void; onStart: (node: HomeNode) => void;
}>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) { }>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) {
const points = zigzagPositions(nodes.length, SPACING, TOP); const points = useMemo(() => zigzagPositions(nodes.length, SPACING, TOP), [nodes.length]);
const intro = useFirstVisit(`islands:${nodes[0]?.id ?? ''}`); const intro = useFirstVisit(`islands:${nodes[0]?.id ?? ''}`);
const height = points.length ? points[points.length - 1].y + 80 : 120; const height = points.length ? points[points.length - 1].y + 80 : 120;
const { char } = useCompanion();
const buddies = useMemo(() => buddySpots(nodes, points, currentId), [nodes, points, currentId]);
return ( return (
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}> <div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
<ZigzagPath points={points} height={height} colorOf={(i) => (nodes[i].status === 'completed' ? color : null)} /> <ZigzagPath points={points} height={height} colorOf={(i) => (nodes[i].status === 'completed' ? color : null)} />
{buddies.map((b) => (
<PathBuddy key={b.segment} char={char} {...b} />
))}
{nodes.map((node, i) => { {nodes.map((node, i) => {
const locked = node.status === 'locked'; const locked = node.status === 'locked';
const done = node.status === 'completed'; const done = node.status === 'completed';
@@ -108,3 +115,75 @@ export const IslandPath = forwardRef<HTMLDivElement, {
</div> </div>
); );
}); });
interface BuddySpot {
segment: number;
x: number;
y: number;
mood: SeyarMood;
current: boolean;
dim: boolean;
}
/**
* جای همراه‌های کنارِ مسیر: وسطِ یک پیچ (بینِ دو جزیره)، در سمتی که مسیر از آن دور است. در آن ارتفاع نه اسمِ جزیره‌ای
* هست (اسم‌ها هم‌ترازِ خودِ جزیره‌اند، ±۲۴ پیکسل) نه دکمه‌ی «شروع» (بالای جزیره‌ی فعلی). همراهِ اصلی زیرِ جزیره‌ی فعلی
* تشویق می‌کند؛ بقیه هر سه پیچ یکی، تا مسیر شلوغ نشود. فاصله‌ها با SPACING = ۱۲۸ و اندازه‌ی ≤ ۶۴ سنجیده شده‌اند، نه با
* عرضِ صفحه‌ی کمتر از ۳۲۰ پیکسل.
*/
function buddySpots(nodes: HomeNode[], points: { x: number; y: number }[], currentId: string | null): BuddySpot[] {
if (points.length < 2) return [];
const currentIndex = nodes.findIndex((n) => n.id === currentId);
const mainSegment = currentIndex < 0 ? -1 : Math.min(currentIndex, points.length - 2);
const spots: BuddySpot[] = [];
for (let i = 0; i < points.length - 1; i++) {
const main = i === mainSegment;
if (!main && (i % 3 !== 1 || Math.abs(i - mainSegment) <= 1)) continue;
const midX = (points[i].x + points[i + 1].x) / 2;
const x = Math.min(84, Math.max(16, midX >= 50 ? midX - 34 : midX + 34));
const locked = nodes[i + 1].status === 'locked' && !main;
spots.push({
segment: i,
x,
y: (points[i].y + points[i + 1].y) / 2,
mood: main ? 'excited' : locked ? 'idle' : 'happy',
current: main,
dim: locked,
});
}
return spots;
}
/** همراهِ انتخابیِ کاربر کنارِ مسیر؛ فقط تزئین است و لمس را به جزیره‌ها رد می‌کند. */
const PathBuddy = memo(function PathBuddy({ char, segment, x, y, mood, current, dim }: BuddySpot & { char: SeyarChar }) {
const size = current ? 62 : 46;
const svg = useMemo(() => SEYAR_SVG[char](mood), [char, mood]);
return (
<div
aria-hidden
className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: `${x}%`, top: y, opacity: dim ? 0.5 : 1 }}
>
{current && (
<>
<Sparkle className="spark absolute -right-2 top-0" />
<Sparkle className="spark absolute -left-3 top-5" style={{ animationDelay: '0.8s' }} />
</>
)}
<div
className={current ? 'buddy-hop' : 'buddy-sway'}
style={{ width: size, height: size, lineHeight: 0, animationDelay: `${(segment % 4) * 0.35}s` }}
// SVG از seyar.ts ساخته می‌شود و هیچ ورودیِ کاربر در آن نیست.
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
);
});
function Sparkle({ className, style }: { className: string; style?: React.CSSProperties }) {
return (
<svg width="14" height="14" viewBox="0 0 24 24" className={className} style={style}>
<path d="M12 0 L14.5 9.5 L24 12 L14.5 14.5 L12 24 L9.5 14.5 L0 12 L9.5 9.5 Z" fill="#FFC53D" />
</svg>
);
}

View File

@@ -48,18 +48,18 @@ export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; c
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft"> <span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
<Lock size={24} className="text-muted" /> <Lock size={24} className="text-muted" />
</span> </span>
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره‌ی قبلی رو تموم کن تا باز بشه.</p> <p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره قبلی رو تموم کن تا باز بشه.</p>
</div> </div>
) : ( ) : (
<> <>
<p className="mb-4 text-sm leading-7 text-body"> <p className="mb-4 text-sm leading-7 text-body">
{done {done
? 'این جزیره رو تموم کردی! می‌تونی دوباره تمرینش کنی (امتیازِ تازه نداره).' ? 'این جزیره رو تموم کردی! می‌تونی دوباره تمرینش کنی (امتیاز تازه نداره).'
: 'اگه مطمئن نیستی، اول درس‌ها رو بخون؛ بعد آزمونِ کوتاهِ جزیره رو بده. هر جوابی بدی، آخرش جزیره‌ی بعدی باز می‌شه.'} : 'اگه مطمئن نیستی، اول درس‌ها رو بخون؛ بعد آزمون کوتاه جزیره رو بده. هر جوابی بدی، آخرش جزیره بعدی باز می‌شه.'}
</p> </p>
<PrimaryButton color={color} onClick={start}> <PrimaryButton color={color} onClick={start}>
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروعِ آزمونِ جزیره</>} {done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروع آزمون جزیره</>}
</PrimaryButton> </PrimaryButton>
<button <button
type="button" type="button"
@@ -68,7 +68,7 @@ export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; c
style={{ borderColor: '#39FF14', color: '#15803d', backgroundColor: showLessons ? '#39FF1422' : 'white' }} style={{ borderColor: '#39FF14', color: '#15803d', backgroundColor: showLessons ? '#39FF1422' : 'white' }}
aria-expanded={showLessons} aria-expanded={showLessons}
> >
<BookOpen size={18} /> {done ? 'مرورِ درس‌ها' : 'اول درس‌ها رو بخون'} <BookOpen size={18} /> {done ? 'مرور درس‌ها' : 'اول درس‌ها رو بخون'}
<ChevronDown size={16} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} /> <ChevronDown size={16} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} />
</button> </button>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Brain, Heart, HeartCrack, Infinity as InfinityIcon, ShoppingBag, X } from 'lucide-react'; import { Brain, Heart, HeartCrack, Infinity as InfinityIcon, Crown, X } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
@@ -44,7 +44,7 @@ function whenLabel(iso: string, now: number): string {
const dayOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); const dayOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const days = Math.round((dayOf(at) - dayOf(new Date(now))) / 86_400_000); const days = Math.round((dayOf(at) - dayOf(new Date(now))) / 86_400_000);
const day = days <= 0 ? 'امروز' : days === 1 ? 'فردا' : new Intl.DateTimeFormat('fa-IR', { weekday: 'long' }).format(at); const day = days <= 0 ? 'امروز' : days === 1 ? 'فردا' : new Intl.DateTimeFormat('fa-IR', { weekday: 'long' }).format(at);
return `ساعتِ ${time} ${day}`; return `ساعت ${time} ${day}`;
} }
export function NoHeartsScreen({ export function NoHeartsScreen({
@@ -113,7 +113,7 @@ export function NoHeartsScreen({
<div> <div>
<h1 className="text-2xl font-black text-ink">{back ? 'یه قلب برگشت!' : 'قلب‌هات تموم شد'}</h1> <h1 className="text-2xl font-black text-ink">{back ? 'یه قلب برگشت!' : 'قلب‌هات تموم شد'}</h1>
<p className="mt-1.5 text-sm leading-7 text-muted"> <p className="mt-1.5 text-sm leading-7 text-muted">
{back ? 'بریم ادامه بدیم؛ این بار با دقتِ بیشتر' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'} {back ? 'بریم ادامه بدیم؛ این بار با دقت بیشتر' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'}
</p> </p>
</div> </div>
@@ -125,7 +125,7 @@ export function NoHeartsScreen({
</div> </div>
) : ( ) : (
<div className="w-full rounded-3xl border border-line bg-white p-5 shadow-sm"> <div className="w-full rounded-3xl border border-line bg-white p-5 shadow-sm">
<div className="text-xs font-bold text-muted">قلبِ بعدی</div> <div className="text-xs font-bold text-muted">قلب بعدی</div>
{left !== null ? ( {left !== null ? (
<> <>
<div dir="ltr" className="mt-1 text-4xl font-black tracking-wide text-coral tabular-nums">{clock(left)}</div> <div dir="ltr" className="mt-1 text-4xl font-black tracking-wide text-coral tabular-nums">{clock(left)}</div>
@@ -138,7 +138,7 @@ export function NoHeartsScreen({
</div> </div>
{full && ( {full && (
<p className="mt-3 text-xs leading-6 text-muted"> <p className="mt-3 text-xs leading-6 text-muted">
همه‌ی {toFa(MAX_HEARTS)} قلب: <b className="text-body">{whenLabel(full, now)}</b> همه {toFa(MAX_HEARTS)} قلب: <b className="text-body">{whenLabel(full, now)}</b>
</p> </p>
)} )}
</> </>
@@ -160,10 +160,10 @@ export function NoHeartsScreen({
<Heart size={18} fill="#fff" /> <InfinityIcon size={18} /> نمی‌خوای منتظر بمونی؟ <Heart size={18} fill="#fff" /> <InfinityIcon size={18} /> نمی‌خوای منتظر بمونی؟
</span> </span>
<span className="mt-1 block text-xs leading-6 text-white/90"> <span className="mt-1 block text-xs leading-6 text-white/90">
هر بسته‌ی سوال چند روز قلبِ نامحدود هدیه داره؛ هر چقدر بخوای تمرین کن. با اشتراک، قلب‌هات نامحدود می‌شه؛ هر چقدر بخوای تمرین کن.
</span> </span>
<span className="mt-3 inline-flex items-center gap-1.5 rounded-2xl bg-white px-4 py-2 text-sm font-black text-coral"> <span className="mt-3 inline-flex items-center gap-1.5 rounded-2xl bg-white px-4 py-2 text-sm font-black text-coral">
<ShoppingBag size={15} /> دیدنِ بسته‌ها <Crown size={15} /> دیدن اشتراک‌ها
</span> </span>
</button> </button>
@@ -194,7 +194,7 @@ export function NoHeartsScreen({
/** پنج جای قلب؛ اولی همراهِ زمان از پایین پر می‌شود. */ /** پنج جای قلب؛ اولی همراهِ زمان از پایین پر می‌شود. */
function HeartSlots({ progress }: { progress: number }) { function HeartSlots({ progress }: { progress: number }) {
return ( return (
<div className="flex items-center gap-2.5" aria-label={`قلبِ بعدی ${toFa(Math.round(progress * 100))} درصد پر شده`}> <div className="flex items-center gap-2.5" aria-label={`قلب بعدی ${toFa(Math.round(progress * 100))} درصد پر شده`}>
{Array.from({ length: MAX_HEARTS }, (_, i) => ( {Array.from({ length: MAX_HEARTS }, (_, i) => (
<span key={i} className="relative h-8 w-8"> <span key={i} className="relative h-8 w-8">
<Heart size={32} className="absolute inset-0 text-[#F2DCD8]" fill="#FBEDEA" /> <Heart size={32} className="absolute inset-0 text-[#F2DCD8]" fill="#FBEDEA" />

View File

@@ -33,7 +33,7 @@ export function Sheet({
{open && ( {open && (
<div className="absolute inset-0 z-50 flex flex-col justify-end"> <div className="absolute inset-0 z-50 flex flex-col justify-end">
<motion.div <motion.div
className="absolute inset-0 bg-[#14142a]/45" className="absolute inset-0 bg-[#14142a]/55"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}

View File

@@ -68,7 +68,7 @@ export function StreakCelebration({ from, to, onDone }: { from: number; to: numb
const today = (new Date().getDay() + 1) % 7; // شنبه = ۰ const today = (new Date().getDay() + 1) % 7; // شنبه = ۰
const message = to === 1 const message = to === 1
? 'اولین روزِ زنجیره‌ت! فردا هم بیا تا آتیشش بزرگ‌تر بشه.' ? 'اولین روز زنجیره‌ت! فردا هم بیا تا آتیشش بزرگ‌تر بشه.'
: `${toFa(to)} روزه که هر روز ریاضی تمرین می‌کنی. فردا هم بیا تا خاموش نشه!`; : `${toFa(to)} روزه که هر روز ریاضی تمرین می‌کنی. فردا هم بیا تا خاموش نشه!`;
return ( return (
@@ -97,7 +97,7 @@ export function StreakCelebration({ from, to, onDone }: { from: number; to: numb
> >
{toFa(shown)} {toFa(shown)}
</motion.div> </motion.div>
<h2 className="mt-1 text-2xl font-black text-ink">روزِ پیاپی!</h2> <h2 className="mt-1 text-2xl font-black text-ink">روز پیاپی!</h2>
<p className="mt-2 text-sm leading-7 text-body">{message}</p> <p className="mt-2 text-sm leading-7 text-body">{message}</p>
</div> </div>
<div className="flex gap-2" aria-label="روزهای این هفته"> <div className="flex gap-2" aria-label="روزهای این هفته">

View File

@@ -122,7 +122,7 @@ export function Spinner({ size = 20, className = '' }: { size?: number; classNam
return ( return (
<span <span
role="status" role="status"
aria-label="در حالِ بارگذاری" aria-label="در حال بارگذاری"
className={`inline-block animate-spin rounded-full border-2 border-current border-t-transparent ${className}`} className={`inline-block animate-spin rounded-full border-2 border-current border-t-transparent ${className}`}
style={{ width: size, height: size }} style={{ width: size, height: size }}
/> />

View File

@@ -8,7 +8,7 @@ const TEXTS = [
'✅ جواب: رأس سهمی در (1, -1) است.', '✅ جواب: رأس سهمی در (1, -1) است.',
'۴) ۲ - ۳ = -۱.', '۴) ۲ - ۳ = -۱.',
'مجموع زاویه‌های داخلی = (5 - 2) × 180 = 3 × 180 = 540 درجه', 'مجموع زاویه‌های داخلی = (5 - 2) × 180 = 3 × 180 = 540 درجه',
'بازه‌ی [-2, 3) یعنی از −۲ تا ۳، خودِ ۳ نه.', 'بازه [-2, 3) یعنی از −۲ تا ۳، خود ۳ نه.',
'sin 30° = 0.5 و cos 30° ≈ 0.87 است.', 'sin 30° = 0.5 و cos 30° ≈ 0.87 است.',
'در کتاب ۲/۵ یعنی دو و نیم (اختیاری).', 'در کتاب ۲/۵ یعنی دو و نیم (اختیاری).',
]; ];

View File

@@ -3,14 +3,14 @@ import type { DiagramSpec } from '@/diagram/types';
/** نمونه‌های دست‌نویس از هر نوع شکل، از روی مسئله‌های واقعیِ کتاب‌ها. فقط برای گالری. */ /** نمونه‌های دست‌نویس از هر نوع شکل، از روی مسئله‌های واقعیِ کتاب‌ها. فقط برای گالری. */
export const SAMPLES: { title: string; spec: DiagramSpec }[] = [ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
{ {
title: 'یازدهم — سهمی با ریشه، رأس، محورِ تقارن و عرض از مبدأ', title: 'یازدهم — سهمی با ریشه، رأس، محور تقارن و عرض از مبدأ',
spec: { kind: 'plane', title: 'y = x² − 2x − 3', axes: true, points: {}, items: [ spec: { kind: 'plane', title: 'y = x² − 2x − 3', axes: true, points: {}, items: [
{ type: 'function', expr: 'x^2-2x-3', from: -2.5, to: 4.5, label: 'y = x² − 2x − 3', marks: ['roots', 'vertex', 'axis', 'yIntercept'] }, { type: 'function', expr: 'x^2-2x-3', from: -2.5, to: 4.5, label: 'y = x² − 2x − 3', marks: ['roots', 'vertex', 'axis', 'yIntercept'] },
] }, ] },
}, },
{ {
title: 'دهم — دایره‌ی مثلثاتی، سینوس و کسینوسِ ۳۰°', title: 'دهم — دایره مثلثاتی، سینوس و کسینوس ۳۰°',
spec: { kind: 'plane', title: 'زاویه‌ی ۳۰ درجه', axes: true, points: { O: [0, 0], A: [1, 0], P: { polar: [1, 30] }, H: { foot: ['P', 'O', 'A'] } }, items: [ spec: { kind: 'plane', title: 'زاویه ۳۰ درجه', axes: true, points: { O: [0, 0], A: [1, 0], P: { polar: [1, 30] }, H: { foot: ['P', 'O', 'A'] } }, items: [
{ type: 'circle', center: 'O', r: 1 }, { type: 'circle', center: 'O', r: 1 },
{ type: 'segment', from: 'O', to: 'P' }, { type: 'segment', from: 'O', to: 'P' },
{ type: 'segment', from: 'P', to: 'H', dashed: true, color: 'green', label: 'sin 30° = 0.5' }, { type: 'segment', from: 'P', to: 'H', dashed: true, color: 'green', label: 'sin 30° = 0.5' },
@@ -20,7 +20,7 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] }, ] },
}, },
{ {
title: 'نهم — مثلثِ قائم‌الزاویه، فیثاغورس', title: 'نهم — مثلث قائم‌الزاویه، فیثاغورس',
spec: { kind: 'plane', title: 'وتر = ۵', axes: false, points: { A: [0, 0], B: [4, 0], C: [0, 3] }, items: [ spec: { kind: 'plane', title: 'وتر = ۵', axes: false, points: { A: [0, 0], B: [4, 0], C: [0, 3] }, items: [
{ type: 'polygon', points: ['A', 'B', 'C'] }, { type: 'polygon', points: ['A', 'B', 'C'] },
{ type: 'rightAngle', vertex: 'A', from: 'B', to: 'C' }, { type: 'rightAngle', vertex: 'A', from: 'B', to: 'C' },
@@ -30,7 +30,7 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] }, ] },
}, },
{ {
title: 'هشتم — خطوطِ موازی و مورب، زاویه‌های مساوی', title: 'هشتم — خطوط موازی و مورب، زاویه‌های مساوی',
spec: { kind: 'plane', title: 'a ∥ b و d مورب', axes: false, points: { _a1: [0, 2], _a2: [6, 2], _b1: [0, 0], _b2: [6, 0], _d1: [1.2, -1], _d2: [4.8, 3], P: { intersect: [['_a1', '_a2'], ['_d1', '_d2']] }, Q: { intersect: [['_b1', '_b2'], ['_d1', '_d2']] } }, items: [ spec: { kind: 'plane', title: 'a ∥ b و d مورب', axes: false, points: { _a1: [0, 2], _a2: [6, 2], _b1: [0, 0], _b2: [6, 0], _d1: [1.2, -1], _d2: [4.8, 3], P: { intersect: [['_a1', '_a2'], ['_d1', '_d2']] }, Q: { intersect: [['_b1', '_b2'], ['_d1', '_d2']] } }, items: [
{ type: 'line', through: ['_a1', '_a2'], label: 'a' }, { type: 'line', through: ['_a1', '_a2'], label: 'a' },
{ type: 'line', through: ['_b1', '_b2'], label: 'b' }, { type: 'line', through: ['_b1', '_b2'], label: 'b' },
@@ -40,8 +40,8 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] }, ] },
}, },
{ {
title: 'هشتم — زاویه‌ی محاطی و مرکزیِ روبه‌روی یک کمان', title: 'هشتم — زاویه محاطی و مرکزی روبه‌روی یک کمان',
spec: { kind: 'plane', title: 'زاویه‌ی محاطی نصفِ مرکزی است', axes: false, points: { O: [0, 0], A: { polar: [2, 200] }, B: { polar: [2, -20] }, C: { polar: [2, 100] } }, items: [ spec: { kind: 'plane', title: 'زاویه محاطی نصف مرکزی است', axes: false, points: { O: [0, 0], A: { polar: [2, 200] }, B: { polar: [2, -20] }, C: { polar: [2, 100] } }, items: [
{ type: 'circle', center: 'O', r: 2 }, { type: 'circle', center: 'O', r: 2 },
{ type: 'arc', center: 'O', r: 2, start: 200, end: 340, label: 'AB' }, { type: 'arc', center: 'O', r: 2, start: 200, end: 340, label: 'AB' },
{ type: 'segment', from: 'C', to: 'A' }, { type: 'segment', from: 'C', to: 'B' }, { type: 'segment', from: 'C', to: 'A' }, { type: 'segment', from: 'C', to: 'B' },
@@ -51,8 +51,8 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] }, ] },
}, },
{ {
title: 'نهم — متوازی‌الاضلاع و وسطِ ضلع‌ها، ضلع‌های مساوی', title: 'نهم — متوازی‌الاضلاع و وسط ضلع‌ها، ضلع‌های مساوی',
spec: { kind: 'plane', title: 'M و N وسطِ ضلع‌ها', axes: false, points: { A: [0, 0], B: [4, 0], C: [5, 2.5], D: [1, 2.5], M: { mid: ['A', 'B'] }, N: { mid: ['C', 'D'] } }, items: [ spec: { kind: 'plane', title: 'M و N وسط ضلع‌ها', axes: false, points: { A: [0, 0], B: [4, 0], C: [5, 2.5], D: [1, 2.5], M: { mid: ['A', 'B'] }, N: { mid: ['C', 'D'] } }, items: [
{ type: 'polygon', points: ['A', 'B', 'C', 'D'] }, { type: 'polygon', points: ['A', 'B', 'C', 'D'] },
{ type: 'segment', from: 'A', to: 'M', ticks: 1 }, { type: 'segment', from: 'M', to: 'B', ticks: 1 }, { type: 'segment', from: 'A', to: 'M', ticks: 1 }, { type: 'segment', from: 'M', to: 'B', ticks: 1 },
{ type: 'segment', from: 'D', to: 'N', ticks: 2 }, { type: 'segment', from: 'N', to: 'C', ticks: 2 }, { type: 'segment', from: 'D', to: 'N', ticks: 2 }, { type: 'segment', from: 'N', to: 'C', ticks: 2 },
@@ -60,14 +60,14 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] }, ] },
}, },
{ {
title: 'هفتم — دورانِ ۹۰° مثلث حولِ C در جهتِ عقربه‌ها، روی شبکه', title: 'هفتم — دوران ۹۰° مثلث حول C در جهت عقربه‌ها، روی شبکه',
spec: { kind: 'plane', title: 'دورانِ ۹۰°', axes: false, grid: true, points: { A: [1, 1], B: [4, 1], C: [1, 3] }, items: [ spec: { kind: 'plane', title: 'دوران ۹۰°', axes: false, grid: true, points: { A: [1, 1], B: [4, 1], C: [1, 3] }, items: [
{ type: 'polygon', points: ['A', 'B', 'C'] }, { type: 'polygon', points: ['A', 'B', 'C'] },
{ type: 'image', of: ['A', 'B', 'C'], transform: { rotate: { center: 'C', deg: -90 } } }, { type: 'image', of: ['A', 'B', 'C'], transform: { rotate: { center: 'C', deg: -90 } } },
] }, ] },
}, },
{ {
title: 'هشتم — بردار و جمعِ بردارها روی شبکه', title: 'هشتم — بردار و جمع بردارها روی شبکه',
spec: { kind: 'plane', title: 'AB + BC = AC', axes: false, grid: true, points: { A: [0, 0], B: [3, 1], C: [4, 4] }, items: [ spec: { kind: 'plane', title: 'AB + BC = AC', axes: false, grid: true, points: { A: [0, 0], B: [3, 1], C: [4, 4] }, items: [
{ type: 'vector', from: 'A', to: 'B', label: 'a' }, { type: 'vector', from: 'A', to: 'B', label: 'a' },
{ type: 'vector', from: 'B', to: 'C', label: 'b', color: 'blue' }, { type: 'vector', from: 'B', to: 'C', label: 'b', color: 'blue' },
@@ -75,7 +75,7 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] }, ] },
}, },
{ {
title: 'نهم — دو خط و محلِ برخورد', title: 'نهم — دو خط و محل برخورد',
spec: { kind: 'plane', title: 'y = 2x − 1 و y = −x + 5', axes: true, points: { P: { intersect: [[[0, -1], [1, 1]], [[0, 5], [1, 4]]] } }, items: [ spec: { kind: 'plane', title: 'y = 2x − 1 و y = −x + 5', axes: true, points: { P: { intersect: [[[0, -1], [1, 1]], [[0, 5], [1, 4]]] } }, items: [
{ type: 'function', expr: '2x-1', from: -1, to: 4, label: 'y = 2x − 1' }, { type: 'function', expr: '2x-1', from: -1, to: 4, label: 'y = 2x − 1' },
{ type: 'function', expr: '-x+5', from: -1, to: 6, label: 'y = −x + 5' }, { type: 'function', expr: '-x+5', from: -1, to: 6, label: 'y = −x + 5' },
@@ -83,21 +83,21 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] }, ] },
}, },
{ {
title: 'یازدهم — نمودارِ سینوس با مضرب‌های π', title: 'یازدهم — نمودار سینوس با مضرب‌های π',
spec: { kind: 'plane', title: 'y = sin x', axes: true, trig: true, points: {}, items: [ spec: { kind: 'plane', title: 'y = sin x', axes: true, trig: true, points: {}, items: [
{ type: 'function', expr: 'sin(x)', from: -6.2832, to: 6.2832, label: 'y = sin x', marks: ['roots', 'extrema'] }, { type: 'function', expr: 'sin(x)', from: -6.2832, to: 6.2832, label: 'y = sin x', marks: ['roots', 'extrema'] },
{ type: 'hline', y: 0.5, color: 'orange', label: 'y = ½' }, { type: 'hline', y: 0.5, color: 'orange', label: 'y = ½' },
] }, ] },
}, },
{ {
title: 'یازدهم — تابعِ چندضابطه‌ای و حد (نقطه‌ی توخالی)', title: 'یازدهم — تابع چندضابطه‌ای و حد (نقطه توخالی)',
spec: { kind: 'plane', title: 'حدِ چپ و راست در x = 1', axes: true, points: {}, items: [ spec: { kind: 'plane', title: 'حد چپ و راست در x = 1', axes: true, points: {}, items: [
{ type: 'piecewise', pieces: [{ expr: 'x+1', from: -2, to: 1, closedFrom: true, closedTo: false }, { expr: '-x+4', from: 1, to: 4, closedFrom: true, closedTo: true }] }, { type: 'piecewise', pieces: [{ expr: 'x+1', from: -2, to: 1, closedFrom: true, closedTo: false }, { expr: '-x+4', from: 1, to: 4, closedFrom: true, closedTo: true }] },
{ type: 'vline', x: 1, label: 'x = 1' }, { type: 'vline', x: 1, label: 'x = 1' },
] }, ] },
}, },
{ {
title: 'دوازدهم — خطِ مماس بر نمودار', title: 'دوازدهم — خط مماس بر نمودار',
spec: { kind: 'plane', title: 'مماس بر y = x² در x = 1', axes: true, points: {}, items: [ spec: { kind: 'plane', title: 'مماس بر y = x² در x = 1', axes: true, points: {}, items: [
{ type: 'function', expr: 'x^2', from: -2, to: 2.5, label: 'y = x²' }, { type: 'function', expr: 'x^2', from: -2, to: 2.5, label: 'y = x²' },
{ type: 'tangent', expr: 'x^2', at: 1, label: 'y = 2x − 1' }, { type: 'tangent', expr: 'x^2', at: 1, label: 'y = 2x − 1' },
@@ -105,14 +105,14 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
}, },
{ {
title: 'دوازدهم — مجانب‌ها: y = 1/(x − 2) + 1', title: 'دوازدهم — مجانب‌ها: y = 1/(x − 2) + 1',
spec: { kind: 'plane', title: 'مجانبِ قائم و افقی', axes: true, xRange: [-2, 6], yRange: [-4, 6], points: {}, items: [ spec: { kind: 'plane', title: 'مجانب قائم و افقی', axes: true, xRange: [-2, 6], yRange: [-4, 6], points: {}, items: [
{ type: 'function', expr: '1/(x-2)+1', from: -2, to: 6, label: 'y = 1/(x−2) + 1' }, { type: 'function', expr: '1/(x-2)+1', from: -2, to: 6, label: 'y = 1/(x−2) + 1' },
{ type: 'vline', x: 2, label: 'x = 2', color: 'red' }, { type: 'vline', x: 2, label: 'x = 2', color: 'red' },
{ type: 'hline', y: 1, label: 'y = 1', color: 'red' }, { type: 'hline', y: 1, label: 'y = 1', color: 'red' },
] }, ] },
}, },
{ {
title: 'دوازدهم — اکسترمم‌های نسبیِ تابعِ درجه‌ی سوم', title: 'دوازدهم — اکسترمم‌های نسبی تابع درجه سوم',
spec: { kind: 'plane', title: 'y = x³ − 3x', axes: true, points: {}, items: [ spec: { kind: 'plane', title: 'y = x³ − 3x', axes: true, points: {}, items: [
{ type: 'function', expr: 'x^3-3x', from: -2.3, to: 2.3, label: 'y = x³ − 3x', marks: ['extrema', 'roots'] }, { type: 'function', expr: 'x^3-3x', from: -2.3, to: 2.3, label: 'y = x³ − 3x', marks: ['extrema', 'roots'] },
] }, ] },
@@ -126,20 +126,20 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
] }, ] },
}, },
{ {
title: 'دوازدهم — مساحتِ زیرِ نمودار', title: 'دوازدهم — مساحت زیر نمودار',
spec: { kind: 'plane', title: 'ناحیه‌ی بینِ y = √x و محورِ x', axes: true, points: {}, items: [ spec: { kind: 'plane', title: 'ناحیه بین y = √x و محور x', axes: true, points: {}, items: [
{ type: 'area', expr: 'sqrt(x)', from: 0, to: 4, color: 'green', label: 'S' }, { type: 'area', expr: 'sqrt(x)', from: 0, to: 4, color: 'green', label: 'S' },
{ type: 'function', expr: 'sqrt(x)', from: 0, to: 5, label: 'y = √x', color: 'green' }, { type: 'function', expr: 'sqrt(x)', from: 0, to: 5, label: 'y = √x', color: 'green' },
] }, ] },
}, },
{ {
title: 'دهم — ناحیه‌ی جوابِ نامعادله', title: 'دهم — ناحیه جواب نامعادله',
spec: { kind: 'plane', title: 'y > 2x − 1', axes: true, xRange: [-3, 3], yRange: [-4, 4], points: {}, items: [ spec: { kind: 'plane', title: 'y > 2x − 1', axes: true, xRange: [-3, 3], yRange: [-4, 4], points: {}, items: [
{ type: 'region', expr: '2x-1', side: 'above', strict: true, label: 'y > 2x − 1' }, { type: 'region', expr: '2x-1', side: 'above', strict: true, label: 'y > 2x − 1' },
] }, ] },
}, },
{ {
title: 'هشتم — محورِ اعداد: از ۲ سه واحد به چپ', title: 'هشتم — محور اعداد: از ۲ سه واحد به چپ',
spec: { kind: 'numberline', title: '۲ − ۳ = −۱', min: -4, max: 4, points: [{ at: 2, label: 'شروع' }, { at: -1, label: 'پایان' }], jumps: [{ from: 2, to: -1, label: '۳ واحد به چپ' }] }, spec: { kind: 'numberline', title: '۲ − ۳ = −۱', min: -4, max: 4, points: [{ at: 2, label: 'شروع' }, { at: -1, label: 'پایان' }], jumps: [{ from: 2, to: -1, label: '۳ واحد به چپ' }] },
}, },
{ {
@@ -162,7 +162,7 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
spec: { kind: 'grid', title: '۲۰ درصد', rows: 5, cols: 5, shaded: 5 }, spec: { kind: 'grid', title: '۲۰ درصد', rows: 5, cols: 5, shaded: 5 },
}, },
{ {
title: 'سوم — مدلِ ضرب: ۳ گروهِ ۴تایی', title: 'سوم — مدل ضرب: ۳ گروه ۴تایی',
spec: { kind: 'groups', title: '۳ × ۴', groups: 3, each: 4, item: 'circle' }, spec: { kind: 'groups', title: '۳ × ۴', groups: 3, each: 4, item: 'circle' },
}, },
{ {
@@ -170,23 +170,23 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
spec: { kind: 'clock', title: 'ساعت ۳:۲۵', hour: 3, minute: 25 }, spec: { kind: 'clock', title: 'ساعت ۳:۲۵', hour: 3, minute: 25 },
}, },
{ {
title: 'سوم — نمودارِ ستونیِ آب‌میوه‌ها', title: 'سوم — نمودار ستونی آب‌میوه‌ها',
spec: { kind: 'bar', title: 'آب‌میوه‌ی موردِ علاقه', labels: ['پرتقال', 'سیب', 'انگور', 'هلو'], values: [8, 5, 3, 4], unit: 'نفر' }, spec: { kind: 'bar', title: 'آب‌میوه مورد علاقه', labels: ['پرتقال', 'سیب', 'انگور', 'هلو'], values: [8, 5, 3, 4], unit: 'نفر' },
}, },
{ {
title: 'هشتم — نمودارِ دایره‌ای', title: 'هشتم — نمودار دایره‌ای',
spec: { kind: 'pie', title: 'ورزشِ موردِ علاقه', labels: ['فوتبال', 'والیبال', 'شنا', 'بقیه'], values: [12, 6, 4, 2] }, spec: { kind: 'pie', title: 'ورزش مورد علاقه', labels: ['فوتبال', 'والیبال', 'شنا', 'بقیه'], values: [12, 6, 4, 2] },
}, },
{ {
title: 'دهم — دمای زمین بر حسبِ عمق (خط‌شکسته)', title: 'دهم — دمای زمین بر حسب عمق (خط‌شکسته)',
spec: { kind: 'linechart', title: 'دما (°C)', labels: ['1', '2', '3', '4', '5', '6'], values: [55, 90, 125, 160, 195, 230], unit: '°C' }, spec: { kind: 'linechart', title: 'دما (°C)', labels: ['1', '2', '3', '4', '5', '6'], values: [55, 90, 125, 160, 195, 230], unit: '°C' },
}, },
{ {
title: 'دهم — جدولِ تعیینِ علامت', title: 'دهم — جدول تعیین علامت',
spec: { kind: 'table', title: 'y = (x + 1)(x − 2)', headers: ['x', '−∞', '−1', '', '2', '+∞'], rows: [['y', '+', '0', '−', '0', '+']] }, spec: { kind: 'table', title: 'y = (x + 1)(x − 2)', headers: ['x', '−∞', '−1', '', '2', '+∞'], rows: [['y', '+', '0', '−', '0', '+']] },
}, },
{ {
title: 'دهم — نمودارِ ون: A ∩ B', title: 'دهم — نمودار ون: A ∩ B',
spec: { kind: 'venn', title: 'A = {1,2,3,4} و B = {3,4,5}', sets: ['A', 'B'], regions: { A: ['1', '2'], AB: ['3', '4'], B: ['5'], out: ['6'] }, shade: ['AB'] }, spec: { kind: 'venn', title: 'A = {1,2,3,4} و B = {3,4,5}', sets: ['A', 'B'], regions: { A: ['1', '2'], AB: ['3', '4'], B: ['5'], out: ['6'] }, shade: ['AB'] },
}, },
{ {
@@ -194,11 +194,11 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
spec: { kind: 'venn', title: 'A − (B ∪ C)', sets: ['A', 'B', 'C'], regions: { A: ['1'], B: ['2'], C: ['3'], AB: ['4'], ABC: ['5'] }, shade: ['A'] }, spec: { kind: 'venn', title: 'A − (B ∪ C)', sets: ['A', 'B', 'C'], regions: { A: ['1'], B: ['2'], C: ['3'], AB: ['4'], ABC: ['5'] }, shade: ['A'] },
}, },
{ {
title: 'دهم — نمودارِ پیکانیِ تابع', title: 'دهم — نمودار پیکانی تابع',
spec: { kind: 'mapping', title: 'f(x) = 3(x − 1)²', left: ['0', '0.5', '1'], right: ['3', '0.75', '0'], arrows: [[0, 0], [1, 1], [2, 2]], leftLabel: 'دامنه', rightLabel: 'برد' }, spec: { kind: 'mapping', title: 'f(x) = 3(x − 1)²', left: ['0', '0.5', '1'], right: ['3', '0.75', '0'], arrows: [[0, 0], [1, 1], [2, 2]], leftLabel: 'دامنه', rightLabel: 'برد' },
}, },
{ {
title: 'نهم — نمودارِ درختیِ دو بار پرتابِ سکه', title: 'نهم — نمودار درختی دو بار پرتاب سکه',
spec: { kind: 'tree', title: '۴ حالت', root: { label: 'شروع', children: [ spec: { kind: 'tree', title: '۴ حالت', root: { label: 'شروع', children: [
{ label: 'رو', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] }, { label: 'رو', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] },
{ label: 'پشت', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] }, { label: 'پشت', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] },
@@ -206,42 +206,42 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
}, },
{ title: 'هفتم — مکعب‌مستطیل', spec: { kind: 'solid', title: 'حجم = ۵ × ۳ × ۴', shape: 'cuboid', dims: { a: 5, b: 3, c: 4 }, labels: { a: '5 cm', b: '3 cm', c: '4 cm' } } }, { title: 'هفتم — مکعب‌مستطیل', spec: { kind: 'solid', title: 'حجم = ۵ × ۳ × ۴', shape: 'cuboid', dims: { a: 5, b: 3, c: 4 }, labels: { a: '5 cm', b: '3 cm', c: '4 cm' } } },
{ title: 'هفتم — مکعب', spec: { kind: 'solid', title: 'مکعب', shape: 'cube', dims: { a: 3 }, labels: { a: 'a' } } }, { title: 'هفتم — مکعب', spec: { kind: 'solid', title: 'مکعب', shape: 'cube', dims: { a: 3 }, labels: { a: 'a' } } },
{ title: 'هفتم — منشورِ سه‌پهلو', spec: { kind: 'solid', title: 'منشورِ مثلثی', shape: 'prism', dims: { n: 3, r: 1.2, h: 2 }, labels: { h: 'h' } } }, { title: 'هفتم — منشور سه‌پهلو', spec: { kind: 'solid', title: 'منشور مثلثی', shape: 'prism', dims: { n: 3, r: 1.2, h: 2 }, labels: { h: 'h' } } },
{ title: 'نهم — منشورِ شش‌پهلو', spec: { kind: 'solid', title: 'منشورِ شش‌ضلعی', shape: 'prism', dims: { n: 6, r: 1.2, h: 1.6 }, labels: { h: '8 cm' } } }, { title: 'نهم — منشور شش‌پهلو', spec: { kind: 'solid', title: 'منشور شش‌ضلعی', shape: 'prism', dims: { n: 6, r: 1.2, h: 1.6 }, labels: { h: '8 cm' } } },
{ title: 'نهم — هرمِ مربع‌القاعده با ارتفاع', spec: { kind: 'solid', title: 'هرم', shape: 'pyramid', dims: { n: 4, r: 1.3, h: 2 }, labels: { h: 'h', a: 'a' } } }, { title: 'نهم — هرم مربع‌القاعده با ارتفاع', spec: { kind: 'solid', title: 'هرم', shape: 'pyramid', dims: { n: 4, r: 1.3, h: 2 }, labels: { h: 'h', a: 'a' } } },
{ title: 'نهم — هرمِ پنج‌پهلو', spec: { kind: 'solid', title: 'قاعده‌ی ABCDE', shape: 'pyramid', dims: { n: 5, r: 1.3, h: 2 }, labels: { h: 'OH' } } }, { title: 'نهم — هرم پنج‌پهلو', spec: { kind: 'solid', title: 'قاعده ABCDE', shape: 'pyramid', dims: { n: 5, r: 1.3, h: 2 }, labels: { h: 'OH' } } },
{ title: 'نهم — استوانه', spec: { kind: 'solid', title: 'لیوانِ استوانه‌ای', shape: 'cylinder', dims: { r: 6, h: 10 }, labels: { r: '6 cm', h: 'h' } } }, { title: 'نهم — استوانه', spec: { kind: 'solid', title: 'لیوان استوانه‌ای', shape: 'cylinder', dims: { r: 6, h: 10 }, labels: { r: '6 cm', h: 'h' } } },
{ title: 'نهم — مخروط', spec: { kind: 'solid', title: 'ظرفِ مخروطی', shape: 'cone', dims: { r: 4, h: 12 }, labels: { r: '4 cm', h: '12 cm' } } }, { title: 'نهم — مخروط', spec: { kind: 'solid', title: 'ظرف مخروطی', shape: 'cone', dims: { r: 4, h: 12 }, labels: { r: '4 cm', h: '12 cm' } } },
{ title: 'نهم — کره', spec: { kind: 'solid', title: 'کره', shape: 'sphere', dims: { r: 1 }, labels: { r: 'r' } } }, { title: 'نهم — کره', spec: { kind: 'solid', title: 'کره', shape: 'sphere', dims: { r: 1 }, labels: { r: 'r' } } },
{ title: 'نهم — نیم‌کره', spec: { kind: 'solid', title: 'پیمانه‌ی نیم‌کره', shape: 'hemisphere', dims: { r: 12 }, labels: { r: '12 cm' } } }, { title: 'نهم — نیم‌کره', spec: { kind: 'solid', title: 'پیمانه نیم‌کره', shape: 'hemisphere', dims: { r: 12 }, labels: { r: '12 cm' } } },
{ title: 'ششم — مقایسه‌ی یک‌پنجم و نُه‌دهم', spec: { kind: 'fraction', title: 'کدام بزرگ‌تر است؟', shape: 'rect', parts: 5, shaded: 1, compare: [{ parts: 5, shaded: 1, label: 'یک‌پنجم' }, { parts: 10, shaded: 9, label: 'نُه‌دهم' }] } }, { title: 'ششم — مقایسه یک‌پنجم و نه‌دهم', spec: { kind: 'fraction', title: 'کدام بزرگ‌تر است؟', shape: 'rect', parts: 5, shaded: 1, compare: [{ parts: 5, shaded: 1, label: 'یک‌پنجم' }, { parts: 10, shaded: 9, label: 'نه‌دهم' }] } },
{ title: 'ششم — دو و هفت‌دهم', spec: { kind: 'fraction', title: '۲٫۷', shape: 'circle', parts: 10, shaded: 7, wholes: 2 } }, { title: 'ششم — دو و هفت‌دهم', spec: { kind: 'fraction', title: '۲٫۷', shape: 'circle', parts: 10, shaded: 7, wholes: 2 } },
{ title: 'دهم — دایره و خطِ مماس از معادله', spec: { kind: 'plane', title: 'فاصله‌ی مرکز تا خط', axes: true, points: { O: [-1, 2] }, items: [ { title: 'دهم — دایره و خط مماس از معادله', spec: { kind: 'plane', title: 'فاصله مرکز تا خط', axes: true, points: { O: [-1, 2] }, items: [
{ type: 'line', eq: [3, 5, 4], label: '3x + 5y = 4' }, { type: 'circle', center: 'O', r: 0.5145, color: 'green' }, { type: 'line', eq: [3, 5, 4], label: '3x + 5y = 4' }, { type: 'circle', center: 'O', r: 0.5145, color: 'green' },
] } }, ] } },
{ title: 'هشتم — زاویه‌ی خارجیِ پنج‌ضلعی با امتدادِ ضلع', spec: { kind: 'plane', title: 'زاویه‌ی خارجی = ۷۲°', axes: false, points: { A: { polar: [2, 234] }, B: { polar: [2, 306] }, C: { polar: [2, 18] }, D: { polar: [2, 90] }, E: { polar: [2, 162] }, X: { along: ['A', 'B', 1.6] } }, items: [ { title: 'هشتم — زاویه خارجی پنج‌ضلعی با امتداد ضلع', spec: { kind: 'plane', title: 'زاویه خارجی = ۷۲°', axes: false, points: { A: { polar: [2, 234] }, B: { polar: [2, 306] }, C: { polar: [2, 18] }, D: { polar: [2, 90] }, E: { polar: [2, 162] }, X: { along: ['A', 'B', 1.6] } }, items: [
{ type: 'polygon', points: ['A', 'B', 'C', 'D', 'E'] }, { type: 'segment', from: 'B', to: 'X', dashed: true }, { type: 'angle', vertex: 'B', from: 'X', to: 'C', label: '72°' }, { type: 'polygon', points: ['A', 'B', 'C', 'D', 'E'] }, { type: 'segment', from: 'B', to: 'X', dashed: true }, { type: 'angle', vertex: 'B', from: 'X', to: 'C', label: '72°' },
] } }, ] } },
{ title: 'سوم — ضرب روی محورِ اعداد (تیک‌های زیاد)', spec: { kind: 'numberline', title: '۲ × ۱۱', min: 0, max: 25, points: [{ at: 11, label: '' }, { at: 22, label: '' }], jumps: [{ from: 0, to: 11, label: '۱۱' }, { from: 11, to: 22, label: '۱۱' }] } }, { title: 'سوم — ضرب روی محور اعداد (تیک‌های زیاد)', spec: { kind: 'numberline', title: '۲ × ۱۱', min: 0, max: 25, points: [{ at: 11, label: '' }, { at: 22, label: '' }], jumps: [{ from: 0, to: 11, label: '۱۱' }, { from: 11, to: 22, label: '۱۱' }] } },
{ title: 'نهم — مثلث از روی داده: AB = 6، AC = 8، زاویه‌ی A قائمه', spec: { kind: 'plane', title: 'فیثاغورس', axes: false, points: {}, items: [ { title: 'نهم — مثلث از روی داده: AB = 6، AC = 8، زاویه A قائمه', spec: { kind: 'plane', title: 'فیثاغورس', axes: false, points: {}, items: [
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 6, AC: 8 }, angles: { A: 90 }, labels: { BC: 'x' } }, { type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 6, AC: 8 }, angles: { A: 90 }, labels: { BC: 'x' } },
] } }, ] } },
{ title: 'هشتم — مثلث با سه ضلع ۵، ۷، ۹ و ارتفاع از C', spec: { kind: 'plane', title: 'ارتفاعِ وارد بر AB', axes: false, points: {}, items: [ { title: 'هشتم — مثلث با سه ضلع ۵، ۷، ۹ و ارتفاع از C', spec: { kind: 'plane', title: 'ارتفاع وارد بر AB', axes: false, points: {}, items: [
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 9, BC: 7, CA: 5 }, altitude: 'C', labels: { h: 'h' } }, { type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 9, BC: 7, CA: 5 }, altitude: 'C', labels: { h: 'h' } },
] } }, ] } },
{ title: 'هفتم — مثلث با دو زاویه (۴۰° و ۷۵°)', spec: { kind: 'plane', title: 'زاویه‌ی سوم؟', axes: false, points: {}, items: [ { title: 'هفتم — مثلث با دو زاویه (۴۰° و ۷۵°)', spec: { kind: 'plane', title: 'زاویه سوم؟', axes: false, points: {}, items: [
{ type: 'triangle', names: ['A', 'B', 'C'], angles: { A: 40, B: 75 }, labels: { C: '?' } }, { type: 'triangle', names: ['A', 'B', 'C'], angles: { A: 40, B: 75 }, labels: { C: '?' } },
] } }, ] } },
{ title: 'منفرجه — ارتفاع بیرونِ مثلث', spec: { kind: 'plane', title: 'زاویه‌ی B = ۱۲۰°', axes: false, points: {}, items: [ { title: 'منفرجه — ارتفاع بیرون مثلث', spec: { kind: 'plane', title: 'زاویه B = ۱۲۰°', axes: false, points: {}, items: [
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 4, BC: 3 }, angles: { B: 120 }, altitude: 'C' }, { type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 4, BC: 3 }, angles: { B: 120 }, altitude: 'C' },
] } }, ] } },
{ title: 'ششم — مستطیلِ باغچه ۸ × ۵', spec: { kind: 'plane', title: 'باغچه', axes: false, points: {}, items: [{ type: 'quad', shape: 'rectangle', names: ['A', 'B', 'C', 'D'], w: 8, h: 5 }] } }, { title: 'ششم — مستطیل باغچه ۸ × ۵', spec: { kind: 'plane', title: 'باغچه', axes: false, points: {}, items: [{ type: 'quad', shape: 'rectangle', names: ['A', 'B', 'C', 'D'], w: 8, h: 5 }] } },
{ title: 'مربع با ضلعِ ۴', spec: { kind: 'plane', title: 'مربع', axes: false, points: {}, items: [{ type: 'quad', shape: 'square', names: ['A', 'B', 'C', 'D'], side: 4 }] } }, { title: 'مربع با ضلع ۴', spec: { kind: 'plane', title: 'مربع', axes: false, points: {}, items: [{ type: 'quad', shape: 'square', names: ['A', 'B', 'C', 'D'], side: 4 }] } },
{ title: 'ششم — متوازی‌الاضلاع: قاعده ۷، ارتفاع ۴', spec: { kind: 'plane', title: 'مساحت = قاعده × ارتفاع', axes: false, points: {}, items: [{ type: 'quad', shape: 'parallelogram', names: ['A', 'B', 'C', 'D'], w: 7, h: 4 }] } }, { title: 'ششم — متوازی‌الاضلاع: قاعده ۷، ارتفاع ۴', spec: { kind: 'plane', title: 'مساحت = قاعده × ارتفاع', axes: false, points: {}, items: [{ type: 'quad', shape: 'parallelogram', names: ['A', 'B', 'C', 'D'], w: 7, h: 4 }] } },
{ title: 'هفتم — لوزی با قطرهای ۸ و ۶', spec: { kind: 'plane', title: 'مساحت = نصفِ حاصل‌ضربِ قطرها', axes: false, points: {}, items: [{ type: 'quad', shape: 'rhombus', names: ['A', 'B', 'C', 'D'], d1: 8, d2: 6 }] } }, { title: 'هفتم — لوزی با قطرهای ۸ و ۶', spec: { kind: 'plane', title: 'مساحت = نصف حاصل‌ضرب قطرها', axes: false, points: {}, items: [{ type: 'quad', shape: 'rhombus', names: ['A', 'B', 'C', 'D'], d1: 8, d2: 6 }] } },
{ title: 'هشتم — ذوزنقه: قاعده‌های ۱۰ و ۶، ارتفاع ۴', spec: { kind: 'plane', title: 'ذوزنقه', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 10, top: 6, h: 4 }] } }, { title: 'هشتم — ذوزنقه: قاعده‌های ۱۰ و ۶، ارتفاع ۴', spec: { kind: 'plane', title: 'ذوزنقه', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 10, top: 6, h: 4 }] } },
{ title: 'ذوزنقه‌ی قائم‌الزاویه', spec: { kind: 'plane', title: 'ذوزنقه‌ی قائم', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 8, top: 5, h: 4, right: true }] } }, { title: 'ذوزنقه قائم‌الزاویه', spec: { kind: 'plane', title: 'ذوزنقه قائم', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 8, top: 5, h: 4, right: true }] } },
{ title: 'هفتم — الگوی مربعی ۱، ۴، ۹ و شکلِ بعدی', spec: { kind: 'pattern', title: 'الگوی n²', layout: 'square', unit: 'square', stages: [1, 2, 3], next: true } }, { title: 'هفتم — الگوی مربعی ۱، ۴، ۹ و شکل بعدی', spec: { kind: 'pattern', title: 'الگوی n²', layout: 'square', unit: 'square', stages: [1, 2, 3], next: true } },
{ title: 'هفتم — الگوی چوب‌کبریت', spec: { kind: 'pattern', title: 'چوب‌کبریت: 3n + 1', layout: 'row', unit: 'stick', stages: [1, 2, 3, 4], next: true } }, { title: 'هفتم — الگوی چوب‌کبریت', spec: { kind: 'pattern', title: 'چوب‌کبریت: 3n + 1', layout: 'row', unit: 'stick', stages: [1, 2, 3, 4], next: true } },
{ title: 'دهم — عددهای مثلثی با نقطه', spec: { kind: 'pattern', title: '۱، ۳، ۶، ۱۰', layout: 'triangle', unit: 'dot', stages: [1, 2, 3, 4] } }, { title: 'دهم — عددهای مثلثی با نقطه', spec: { kind: 'pattern', title: '۱، ۳، ۶، ۱۰', layout: 'triangle', unit: 'dot', stages: [1, 2, 3, 4] } },
]; ];

View File

@@ -32,11 +32,11 @@ function tokenize(src: string): string[] {
if (w === 'x' || w === 'pi' || w === 'e' || w in FUNCS) { out.push(w); i += w.length; continue; } if (w === 'x' || w === 'pi' || w === 'e' || w in FUNCS) { out.push(w); i += w.length; continue; }
// «xsin» یا «pix»: نامِ چسبیده را تکه‌تکه کن // «xsin» یا «pix»: نامِ چسبیده را تکه‌تکه کن
const known = ['sqrt', 'sin', 'cos', 'tan', 'abs', 'log', 'ln', 'pi', 'x', 'e'].find((k) => w.startsWith(k)); const known = ['sqrt', 'sin', 'cos', 'tan', 'abs', 'log', 'ln', 'pi', 'x', 'e'].find((k) => w.startsWith(k));
if (!known) throw new Error(`نامِ ناشناخته: ${w}`); if (!known) throw new Error(`نام ناشناخته: ${w}`);
out.push(known); i += known.length; continue; out.push(known); i += known.length; continue;
} }
if ('+-*/^()'.includes(s[i])) { out.push(s[i]); i++; continue; } if ('+-*/^()'.includes(s[i])) { out.push(s[i]); i++; continue; }
throw new Error(`نویسه‌ی ناشناخته: ${s[i]}`); throw new Error(`نویسه ناشناخته: ${s[i]}`);
} }
return out; return out;
} }
@@ -48,7 +48,7 @@ export function parseExpr(src: string): (x: number) => number {
const peek = () => t[p]; const peek = () => t[p];
const eat = (tok?: string) => { const eat = (tok?: string) => {
const v = t[p++]; const v = t[p++];
if (tok !== undefined && v !== tok) throw new Error(`انتظارِ ${tok}`); if (tok !== undefined && v !== tok) throw new Error(`انتظار ${tok}`);
return v; return v;
}; };
const startsAtom = (tok: string | undefined) => tok !== undefined && (/^[\d.]/.test(tok) || tok === 'x' || tok === 'pi' || tok === 'e' || tok === '(' || tok in FUNCS); const startsAtom = (tok: string | undefined) => tok !== undefined && (/^[\d.]/.test(tok) || tok === 'x' || tok === 'pi' || tok === 'e' || tok === '(' || tok in FUNCS);

View File

@@ -74,7 +74,7 @@ function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts:
: <circle key={w} cx={cx(w)} cy={cy} r={r} fill={SHADE} stroke={INK} strokeWidth={2} />))} : <circle key={w} cx={cx(w)} cy={cy} r={r} fill={SHADE} stroke={INK} strokeWidth={2} />))}
<g>{parts === 1 ? <circle cx={cx(wholes)} cy={cy} r={r} fill={shaded ? SHADE : '#fff'} stroke={INK} strokeWidth={2} /> : Array.from({ length: parts }, (_, i) => slice(cx(wholes), i, false))}</g> <g>{parts === 1 ? <circle cx={cx(wholes)} cy={cy} r={r} fill={shaded ? SHADE : '#fff'} stroke={INK} strokeWidth={2} /> : Array.from({ length: parts }, (_, i) => slice(cx(wholes), i, false))}</g>
<text x={W / 2} y={112} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}> <text x={W / 2} y={112} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
{`${toFa(wholes)} واحدِ کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`} {`${toFa(wholes)} واحد کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`}
</text> </text>
</svg> </svg>
); );
@@ -268,7 +268,7 @@ export function Pattern({ spec }: { spec: PatternSpec }) {
? <circle key={k} cx={cx + u / 2} cy={cy + u / 2} r={u * 0.32} fill={col} /> ? <circle key={k} cx={cx + u / 2} cy={cy + u / 2} r={u * 0.32} fill={col} />
: <rect key={k} x={cx + 1} y={cy + 1} width={u - 2} height={u - 2} rx={2} fill={`${col}33`} stroke={col} strokeWidth={1.5} />; : <rect key={k} x={cx + 1} y={cy + 1} width={u - 2} height={u - 2} rx={2} fill={`${col}33`} stroke={col} strokeWidth={1.5} />;
})} })}
<text x={x0 + (s.w * u) / 2} y={base + 16} fontSize={11} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکلِ ${toFa(i + 1)}`}</text> <text x={x0 + (s.w * u) / 2} y={base + 16} fontSize={11} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکل ${toFa(i + 1)}`}</text>
{spec.counts !== false && <text x={x0 + (s.w * u) / 2} y={base + 30} fontSize={10.5} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(count)} ${sticks ? 'چوب' : 'تا'}`}</text>} {spec.counts !== false && <text x={x0 + (s.w * u) / 2} y={base + 30} fontSize={10.5} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(count)} ${sticks ? 'چوب' : 'تا'}`}</text>}
</g> </g>
); );
@@ -281,7 +281,7 @@ export function Pattern({ spec }: { spec: PatternSpec }) {
<g> <g>
<rect x={nx} y={base - 1.4 * u} width={1.4 * u} height={1.4 * u} rx={6} fill="#fff" stroke={COLORS.red} strokeWidth={2} strokeDasharray="4 3" /> <rect x={nx} y={base - 1.4 * u} width={1.4 * u} height={1.4 * u} rx={6} fill="#fff" stroke={COLORS.red} strokeWidth={2} strokeDasharray="4 3" />
<text x={nx + 0.7 * u} y={base - 0.45 * u} fontSize={u * 0.8} fontWeight={900} fill={COLORS.red} textAnchor="middle">?</text> <text x={nx + 0.7 * u} y={base - 0.45 * u} fontSize={u * 0.8} fontWeight={900} fill={COLORS.red} textAnchor="middle">?</text>
<text x={nx + 0.7 * u} y={base + 16} fontSize={11} fontWeight={800} fill={COLORS.red} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکلِ ${toFa(stages.length + 1)}`}</text> <text x={nx + 0.7 * u} y={base + 16} fontSize={11} fontWeight={800} fill={COLORS.red} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکل ${toFa(stages.length + 1)}`}</text>
</g> </g>
)} )}
</svg> </svg>

View File

@@ -43,7 +43,7 @@ export function NumberLine({ spec }: { spec: NumberLineSpec }) {
}; };
return ( return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img" aria-label={spec.title || 'محورِ اعداد'}> <svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img" aria-label={spec.title || 'محور اعداد'}>
<Arrowheads ids={[...arrows, ...intervals.map((iv, i) => color(iv.color, COLORS[SERIES[i % SERIES.length]]))]} /> <Arrowheads ids={[...arrows, ...intervals.map((iv, i) => color(iv.color, COLORS[SERIES[i % SERIES.length]]))]} />
<line x1={PAD - 16} y1={base} x2={W - PAD + 16} y2={base} stroke={INK} strokeWidth={2} <line x1={PAD - 16} y1={base} x2={W - PAD + 16} y2={base} stroke={INK} strokeWidth={2}
markerEnd={`url(#${arrowId(INK)})`} markerStart={`url(#${arrowId(INK)})`} /> markerEnd={`url(#${arrowId(INK)})`} markerStart={`url(#${arrowId(INK)})`} />

View File

@@ -1,5 +1,5 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { plainMath } from './plainMath'; import { plainMath, plainPersian } from './plainMath';
/** /**
* متنِ فارسی که ریاضی لایش است («عدد −۱ می‌شود»، «رأس (۱، −۴) است»). * متنِ فارسی که ریاضی لایش است («عدد −۱ می‌شود»، «رأس (۱، −۴) است»).
@@ -12,7 +12,7 @@ import { plainMath } from './plainMath';
* فهرستِ داخلِ جمله («از −۳ به −۲، −۱، ۰ می‌رسیم»، «الگویِ ۲، ۵، ۸، …») مالِ جمله است و * فهرستِ داخلِ جمله («از −۳ به −۲، −۱، ۰ می‌رسیم»، «الگویِ ۲، ۵، ۸، …») مالِ جمله است و
* از راست خوانده می‌شود؛ پس «،» و «؛» بیرونِ پرانتز و آکولاد تکه را می‌شکنند و هر عضو * از راست خوانده می‌شود؛ پس «،» و «؛» بیرونِ پرانتز و آکولاد تکه را می‌شکنند و هر عضو
* جدا قفل می‌شود. داخلِ `{۱، ۲، ۳}` و `(۱، −۴)` ریاضی است و یک‌جا قفل می‌ماند. سنجیده * جدا قفل می‌شود. داخلِ `{۱، ۲، ۳}` و `(۱، −۴)` ریاضی است و یک‌جا قفل می‌ماند. سنجیده
* با مختصاتِ رسم‌شده در Chrome روی همه‌ی سؤال‌ها و گزینه‌های بانک و چند جوابِ دستیار * با مختصاتِ رسم‌شده در Chrome روی همه‌ی سوال‌ها و گزینه‌های بانک و چند جوابِ دستیار
* (۲۰۲۶-۰۹-۲۹)؛ پیش از آن هر ۴۸۲ فهرستِ داخلِ جمله برعکس دیده می‌شد. * (۲۰۲۶-۰۹-۲۹)؛ پیش از آن هر ۴۸۲ فهرستِ داخلِ جمله برعکس دیده می‌شد.
* *
* آزموده روی Chrome (دسکتاپ و موبایلِ شبیه‌سازی‌شده)؛ روی Safariِ واقعیِ آیفون دیده نشده. * آزموده روی Chrome (دسکتاپ و موبایلِ شبیه‌سازی‌شده)؛ روی Safariِ واقعیِ آیفون دیده نشده.
@@ -34,7 +34,7 @@ const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const; const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const;
export function MathText({ text: raw }: { text: string }) { export function MathText({ text: raw }: { text: string }) {
const text = plainMath(raw); const text = plainPersian(plainMath(raw));
const out: React.ReactNode[] = []; const out: React.ReactNode[] = [];
const depth = bracketDepth(text); const depth = bracketDepth(text);
let last = 0, i = 0; let last = 0, i = 0;

30
src/lib/paymentMethods.ts Normal file
View File

@@ -0,0 +1,30 @@
import { inAndroidApp } from './bazaar';
export type PaymentMethod = 'zarinpal' | 'bazaar';
const KNOWN: PaymentMethod[] = ['zarinpal', 'bazaar'];
/**
* پیش‌فرض: در مرورگر فقط زرین‌پال (خریدِ بازار بیرون از اپِ اندروید ممکن نیست)، داخلِ اپ هر دو، زرین‌پال اول.
* برای نسخه‌ای که فقط در کافه‌بازار منتشر می‌شود، `app: ["bazaar"]` در dist/config.js؛ build لازم نیست.
*/
const DEFAULTS: Record<'web' | 'app', PaymentMethod[]> = {
web: ['zarinpal'],
app: ['zarinpal', 'bazaar'],
};
/**
* روش‌های پرداختِ این‌جا (مرورگر یا اپ)، به ترتیبِ نمایش. نامِ ناشناخته در config.js نادیده گرفته نمی‌شود که
* بی‌صدا یک روش گم شود: در کنسول خطا می‌دهد و فهرستِ پیش‌فرض به کار می‌رود.
*/
export function enabledPaymentMethods(): PaymentMethod[] {
const channel = inAndroidApp() ? 'app' : 'web';
const configured = window.__TADRISINO_CONFIG__?.paymentMethods?.[channel];
if (!configured) return DEFAULTS[channel];
const unknown = configured.filter((m) => !KNOWN.includes(m as PaymentMethod));
if (unknown.length || !configured.length) {
console.error(`config.js: paymentMethods.${channel} نامعتبر است`, configured);
return DEFAULTS[channel];
}
return configured as PaymentMethod[];
}

View File

@@ -43,7 +43,7 @@ const script = (body: string, table: Record<string, string>, marker: string) =>
const mapped = tryMap(body, table); const mapped = tryMap(body, table);
if (mapped) return mapped; if (mapped) return mapped;
const b = body.trim(); const b = body.trim();
if (marker === '^') return ` به توانِ (${b}) `; if (marker === '^') return ` به توان (${b}) `;
return /^[\w۰-۹]{1,4}$/.test(b) ? b : `_(${b})`; return /^[\w۰-۹]{1,4}$/.test(b) ? b : `_(${b})`;
}; };
@@ -64,14 +64,24 @@ export function plainMath(text: string): string {
s = s.replace(SYMBOL_RE, (_m, n: string) => SYMBOLS[n]); s = s.replace(SYMBOL_RE, (_m, n: string) => SYMBOLS[n]);
s = s.replace(new RegExp(`\\^${G}`, 'g'), (_m, a: string) => script(a, SUP, '^')); s = s.replace(new RegExp(`\\^${G}`, 'g'), (_m, a: string) => script(a, SUP, '^'));
s = s.replace(new RegExp(`_${G}`, 'g'), (_m, a: string) => script(a, SUB, '_')); s = s.replace(new RegExp(`_${G}`, 'g'), (_m, a: string) => script(a, SUB, '_'));
s = s.replace(/\^\(((?:[^()]|\([^()]*\))*)\)/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توانِ (${a.trim()}) `); s = s.replace(/\^\(((?:[^()]|\([^()]*\))*)\)/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توان (${a.trim()}) `);
s = s.replace(/\^([+\-−]?[0-9۰-۹]+)/g, (m, a: string) => tryMap(a, SUP) ?? m); s = s.replace(/\^([+\-−]?[0-9۰-۹]+)/g, (m, a: string) => tryMap(a, SUP) ?? m);
s = s.replace(/(?<=[A-Za-z0-9۰-۹)₀-₉])\^([A-Za-zα-ωΑ-Ω□])(?![A-Za-z0-9۰-۹])/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توانِ ${a} `); s = s.replace(/(?<=[A-Za-z0-9۰-۹)₀-₉])\^([A-Za-zα-ωΑ-Ω□])(?![A-Za-z0-9۰-۹])/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توان ${a} `);
s = s.replace(/((?<![A-Za-z0-9_])[A-Za-z]|log|ln|lim|[)∫Σ∏])_([0-9۰-۹]+[A-Za-z]?|[A-Za-z]{1,4}[0-9۰-۹]?)(?![A-Za-z0-9_])/g, (_m, p: string, a: string) => (p === 'log' && [...a].some((c) => !(c in SUB)) ? `log پایه‌ی ${a}` : p + [...a].map((c) => SUB[c] ?? c).join(''))); s = s.replace(/((?<![A-Za-z0-9_])[A-Za-z]|log|ln|lim|[)∫Σ∏])_([0-9۰-۹]+[A-Za-z]?|[A-Za-z]{1,4}[0-9۰-۹]?)(?![A-Za-z0-9_])/g, (_m, p: string, a: string) => (p === 'log' && [...a].some((c) => !(c in SUB)) ? `log پایه ${a}` : p + [...a].map((c) => SUB[c] ?? c).join('')));
s = s.replace(/\^([+\-−])/g, (_m, a: string) => SUP[a]); s = s.replace(/\^([+\-−])/g, (_m, a: string) => SUP[a]);
s = s.replace(/(?<=[A-Za-z)'])_([+\-−])/g, (_m, a: string) => SUB[a]); s = s.replace(/(?<=[A-Za-z)'])_([+\-−])/g, (_m, a: string) => SUB[a]);
s = s.replace(/\^(√[0-9۰-۹A-Za-z]+)/g, ' به توانِ $1 ').replace(/\^([A-Za-z]{2,3})(?![A-Za-z0-9])/g, ' به توانِ $1 '); s = s.replace(/\^(√[0-9۰-۹A-Za-z]+)/g, ' به توان $1 ').replace(/\^([A-Za-z]{2,3})(?![A-Za-z0-9])/g, ' به توان $1 ');
s = s.replace(/(?<=\S) {2,}(?=\S)/g, ' '); s = s.replace(/(?<=\S) {2,}(?=\S)/g, ' ');
s = s.replace(/\*\*([\s\S]+?)\*\*/g, '$1').replace(/(?<![_\w])__(.+?)__(?![_\w])/g, '$1').replace(/`/g, ''); s = s.replace(/\*\*([\s\S]+?)\*\*/g, '$1').replace(/(?<![_\w])__(.+?)__(?![_\w])/g, '$1').replace(/`/g, '');
return s.replace(/^\s{0,3}#{1,6}\s+/gm, ''); return s.replace(/^\s{0,3}#{1,6}\s+/gm, '');
} }
/**
* بی‌اعراب و بی «ی»ِ اضافه («پایه‌ی سوم» → «پایه سوم»)؛ تنوین («قبلاً») می‌ماند. همان قاعده‌ی بک‌اند
* (`content/plain_math.py::plain_persian`، روی هر پاسخِ API). این‌جا برای پاسخِ استریمیِ دستیار است که پیش از
* پاک‌سازیِ نهاییِ سرور تکه‌تکه می‌رسد.
*/
export function plainPersian(text: string): string {
if (!text) return text;
return text.replace(/[ٌ-ْٔ]/g, '').replace(/ه‌ی(?![؀-ۿ‌])/g, 'ه');
}

View File

@@ -199,9 +199,9 @@ export const SEYAR_SVG: Record<SeyarChar, (mood: SeyarMood) => string> = {
}; };
export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = { export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = {
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" }, sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
delta: { name: "دلتا", symbol: "Δ", role: "هم‌تیمیِ تمرین‌های سریع" }, delta: { name: "دلتا", symbol: "Δ", role: "هم‌تیمی تمرین‌های سریع" },
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" }, pi: { name: "پی", symbol: "π", role: "همراه دلگرمی" },
tedi: { name: "امگا", symbol: "ω", role: "رفیقِ همیشگیِ تدریسینو" }, tedi: { name: "امگا", symbol: "ω", role: "رفیق همیشگی تدریسینو" },
}; };
/** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراه‌ها نگاشت می‌کند. */ /** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراه‌ها نگاشت می‌کند. */

View File

@@ -37,14 +37,14 @@ const ok =
new RegExp(`^${api.pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}payment/orders/\\d+/pay$`).test(target.pathname); new RegExp(`^${api.pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}payment/orders/\\d+/pay$`).test(target.pathname);
if (!ok || !target) { if (!ok || !target) {
fail('این لینکِ پرداخت معتبر نیست. به اپ برگرد و دوباره «پرداخت» رو بزن.'); fail('این لینک پرداخت معتبر نیست. به اپ برگرد و دوباره «پرداخت» رو بزن.');
} else { } else {
go.href = target.href; go.href = target.href;
location.replace(target.href); location.replace(target.href);
// اگر مرورگر جابه‌جا نشد (مثلاً جلوی ریدایرکت را گرفت)، دکمه‌ی دستی. // اگر مرورگر جابه‌جا نشد (مثلاً جلوی ریدایرکت را گرفت)، دکمه‌ی دستی.
setTimeout(() => { setTimeout(() => {
spin.style.display = 'none'; spin.style.display = 'none';
msg.textContent = 'اگه صفحه‌ی پرداخت باز نشد، این دکمه رو بزن:'; msg.textContent = 'اگه صفحه پرداخت باز نشد، این دکمه رو بزن:';
go.style.display = 'inline-block'; go.style.display = 'inline-block';
}, 2500); }, 2500);
} }

View File

@@ -15,7 +15,7 @@ import { toFa } from '@/lib/format';
import { useGateHold } from '@/lib/useAsync'; import { useGateHold } from '@/lib/useAsync';
import { iAm, useCompanion } from '@/state/companion'; import { iAm, useCompanion } from '@/state/companion';
const STARTERS = ['این فصل درباره‌ی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟']; const STARTERS = ['این فصل درباره چیه؟', 'یه مثال ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
/** «از {همراه} بپرس»: دستیارِ ریاضی که از روی کتابِ پایه‌ی کاربر جواب می‌دهد؛ چهره و نامش همراهِ انتخابیِ کاربر است. */ /** «از {همراه} بپرس»: دستیارِ ریاضی که از روی کتابِ پایه‌ی کاربر جواب می‌دهد؛ چهره و نامش همراهِ انتخابیِ کاربر است. */
export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) { export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) {
@@ -139,7 +139,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
<Screen> <Screen>
<ScreenHeader <ScreenHeader
title={`از ${companion.name} بپرس`} title={`از ${companion.name} بپرس`}
subtitle={chat.lessonId ? 'از روی کتابِ همین درس جواب می‌دم' : 'دستیارِ ریاضی، از روی کتابِ خودت'} subtitle={chat.lessonId ? 'از روی کتاب همین درس جواب می‌دم' : 'دستیار ریاضی، از روی کتاب خودت'}
color={companion.color} color={companion.color}
back={lessonId !== undefined || prompt !== undefined} back={lessonId !== undefined || prompt !== undefined}
mascot={{ mood: busy ? 'excited' : 'idle', size: 60 }} mascot={{ mood: busy ? 'excited' : 'idle', size: 60 }}
@@ -161,7 +161,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4"> <div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
<Bubble role="assistant"> <Bubble role="assistant">
سلام {profile.name || 'دوستِ من'}! {`من ${iAm(companion.name)}`} 🙂 هر سوالی از ریاضیِ کتابت داری بپرس؛ قدم‌به‌قدم با هم حلش می‌کنیم. سلام {profile.name || 'دوست من'}! {`من ${iAm(companion.name)}`} 🙂 هر سوالی از ریاضی کتابت داری بپرس؛ قدم‌به‌قدم با هم حلش می‌کنیم.
</Bubble> </Bubble>
{empty && chat.historyLoaded && ( {empty && chat.historyLoaded && (
@@ -252,7 +252,7 @@ const THINKING_STEPS: [number, string][] = [
[0, '{name} داره تو کتابت می‌گرده'], [0, '{name} داره تو کتابت می‌گرده'],
[4, '{name} داره حلش می‌کنه'], [4, '{name} داره حلش می‌کنه'],
[15, 'داره قدم‌ها رو مرتب می‌نویسه'], [15, 'داره قدم‌ها رو مرتب می‌نویسه'],
[28, 'جوابِ کامل یه کم طول می‌کشه، الان می‌رسه'], [28, 'جواب کامل یه کم طول می‌کشه، الان می‌رسه'],
]; ];
function Thinking() { function Thinking() {
@@ -363,11 +363,11 @@ function QuotaPills({ free }: { free: number }) {
return ( 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'}`}> <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> </span>
{free === 0 && ( {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 type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-white/15 px-3 py-1.5 text-xs font-black text-white">
سوالِ بیشتر با اشتراک سوال بیشتر با اشتراک
</button> </button>
)} )}
</> </>

View File

@@ -40,7 +40,7 @@ export function CharactersScreen() {
<Screen> <Screen>
<ScreenHeader <ScreenHeader
title="همراهت رو انتخاب کن" title="همراهت رو انتخاب کن"
subtitle={<>امتیازِ تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیازِ بیشتر همراه‌های بیشتری باز می‌شن</>} subtitle={<>امتیاز تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیاز بیشتر همراه‌های بیشتری باز می‌شن</>}
color="#9B7BFF" color="#9B7BFF"
back back
/> />
@@ -94,7 +94,7 @@ export function CharactersScreen() {
</span> </span>
)} )}
<span className="text-center text-[10px] leading-5 text-muted"> <span className="text-center text-[10px] leading-5 text-muted">
{!c.unlocked ? `با ${toFa(c.unlockXp)} امتیاز باز می‌شه` : c.selected ? 'همراهِ فعلی' : 'بزن تا همراهت بشه'} {!c.unlocked ? `با ${toFa(c.unlockXp)} امتیاز باز می‌شه` : c.selected ? 'همراه فعلی' : 'بزن تا همراهت بشه'}
</span> </span>
</button> </button>
); );

View File

@@ -1,16 +1,14 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Bell, ChevronLeft, Coins, Crown, Flame, Hand, PartyPopper, Star } from 'lucide-react'; import { Bell, ChevronLeft, Coins, Crown, Flame, Star } from 'lucide-react';
import type { HomeNode } from '@/api'; import type { HomeNode } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { findContinue, unitColor, unitProgress } from '@/state/curriculum'; import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { IslandPath } from '@/components/IslandPath'; import { IslandPath } from '@/components/IslandPath';
import { IslandSheet } from '@/components/IslandSheet'; import { IslandSheet } from '@/components/IslandSheet';
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui'; import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
import { onWhite, shade } from '@/lib/color'; import { onWhite, shade } from '@/lib/color';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
import { BrandFooter } from '@/components/BrandFooter'; import { BrandFooter } from '@/components/BrandFooter';
import { BrandMark } from '@/components/BrandMark'; import { BrandMark } from '@/components/BrandMark';
import { StreakFlame, flameState } from '@/components/StreakFlame'; import { StreakFlame, flameState } from '@/components/StreakFlame';
@@ -22,7 +20,7 @@ import { SubjectBar } from '@/components/SubjectBar';
* دستیار، فصل‌ها، چالش) و بچه نمی‌دانست کدام را بزند؛ حالا یک کارِ واضح دارد. * دستیار، فصل‌ها، چالش) و بچه نمی‌دانست کدام را بزند؛ حالا یک کارِ واضح دارد.
*/ */
export function HomeScreen() { export function HomeScreen() {
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData(); const { gradeTitle, summary, home, homeError, unreadCount, refreshHome } = useAppData();
const nav = useNav(); const nav = useNav();
// فصلِ غیرِ اول بی‌اشتراک: زدنِ جزیره یک‌راست فهرستِ اشتراک‌ها را باز می‌کند. // فصلِ غیرِ اول بی‌اشتراک: زدنِ جزیره یک‌راست فهرستِ اشتراک‌ها را باز می‌کند.
const paywall = () => nav.push({ name: 'subscription' }); const paywall = () => nav.push({ name: 'subscription' });
@@ -39,37 +37,18 @@ export function HomeScreen() {
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [home]); }, [home]);
const name = profile.name || 'دوستِ من';
const flame = flameState(summary?.streak); const flame = flameState(summary?.streak);
const streak = summary?.streak; const streak = summary?.streak;
// زنجیره‌ی در خطر یا یخ‌زده جلوتر از پیامِ معمول می‌آید: از دست دادن انگیزه‌ی قوی‌تری از به‌دست آوردن است،
// ولی لحن مهربان می‌ماند و قدمِ بعدی را می‌گوید.
const streakNote = cont && streak
? flame === 'frozen'
? <>{name}، زنجیره‌ی {toFa(streak.lost)} روزه‌ت یخ زد <StreakFlame state="frozen" size={14} className="mb-0.5 inline" /> یه جزیره بزن تا دوباره آتیشش روشن بشه!</>
: flame === 'risk'
? <>{name}، امروز هنوز تمرین نکردی! زنجیره‌ی {toFa(streak.current)} روزه‌ت منتظرته <StreakFlame state="lit" size={14} className="mb-0.5 inline" /></>
: null
: null;
const bubble = !home
? 'دارم مسیرت رو میارم…'
: !home.units.length
? 'هنوز درسی برای این پایه ثبت نشده.'
: streakNote
? streakNote
: cont
? <>سلام {name}! <Hand size={15} className="mb-0.5 inline text-[#E8A400]" /> وقتشه جزیره‌ی <b>«<MathText text={cont.node.title} />»</b> رو شروع کنی؛ دکمه‌ی <b>شروع</b> رو بزن.</>
: <>آفرین {name}! همه‌ی جزیره‌های این پایه رو تموم کردی <PartyPopper size={15} className="mb-0.5 inline text-[#E8A400]" /></>;
const stats = [ const stats = [
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const }, { icon: Flame, value: summary?.streak.current ?? 0, label: 'روز پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', color: '#E8A400', fill: true, to: 'leaderboard' as const }, { icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', color: '#E8A400', fill: true, to: 'leaderboard' as const },
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const }, { icon: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const },
]; ];
return ( return (
<Screen> <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"> <header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-3 pt-safe text-white shadow-md shadow-coral/20">
<div className="flex items-center gap-2 pt-4"> <div className="flex items-center gap-2 pt-4">
<BrandMark size={26} withName={false} /> <BrandMark size={26} withName={false} />
{gradeTitle && ( {gradeTitle && (
@@ -101,12 +80,6 @@ export function HomeScreen() {
</div> </div>
</div> </div>
<div className="mt-3 flex items-end gap-2">
<SeyarMascot char={companion} mood={cont ? 'happy' : home ? 'excited' : 'idle'} size={64} bob />
<div className="relative mb-2 flex-1 rounded-2xl rounded-br-sm bg-white px-3.5 py-2.5 text-[13px] leading-6 text-ink shadow-sm">
{bubble}
</div>
</div>
</header> </header>
<SubjectBar /> <SubjectBar />
@@ -132,7 +105,7 @@ export function HomeScreen() {
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[11px] font-bold text-white/80"> <div className="text-[11px] font-bold text-white/80">
فصلِ {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره فصل {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>} {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>} {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>

View File

@@ -45,7 +45,7 @@ export function InviteScreen() {
<> <>
<div className="flex flex-col items-center gap-2 rounded-3xl border border-line bg-white p-5 text-center"> <div className="flex flex-col items-center gap-2 rounded-3xl border border-line bg-white p-5 text-center">
<Gift size={28} className="text-mint" /> <Gift size={28} className="text-mint" />
<span className="text-xs text-muted">کدِ معرفیِ تو</span> <span className="text-xs text-muted">کد معرفی تو</span>
<button <button
type="button" type="button"
onClick={() => void navigator.clipboard?.writeText(me.data!.referralCode).then(() => toast('کد کپی شد', 'success'))} onClick={() => void navigator.clipboard?.writeText(me.data!.referralCode).then(() => toast('کد کپی شد', 'success'))}
@@ -71,11 +71,11 @@ export function InviteScreen() {
<p className="flex items-start gap-2 rounded-2xl bg-[#EAF8F0] p-3.5 text-xs leading-6 text-body"> <p className="flex items-start gap-2 rounded-2xl bg-[#EAF8F0] p-3.5 text-xs leading-6 text-body">
<Users size={16} className="mt-0.5 flex-shrink-0 text-mint" /> <Users size={16} className="mt-0.5 flex-shrink-0 text-mint" />
دوستت باید با لینکِ تو وارد بشه و شماره‌ش رو ثبت کنه. اگه اشتراک خرید، {toFa(Math.round(me.data.commissionRate * 100))}٪ مبلغ پورسانتِ توئه. دوستت باید با لینک تو وارد بشه و شماره‌ش رو ثبت کنه. اگه اشتراک خرید، {toFa(Math.round(me.data.commissionRate * 100))}٪ مبلغ پورسانت توئه.
</p> </p>
<PrimaryButton color="#5FBF8F" onClick={() => void share(me.data!.referralCode)}> <PrimaryButton color="#5FBF8F" onClick={() => void share(me.data!.referralCode)}>
<Share2 size={17} /> فرستادنِ لینکِ دعوت <Share2 size={17} /> فرستادن لینک دعوت
</PrimaryButton> </PrimaryButton>
</> </>
)} )}

View File

@@ -15,13 +15,13 @@ import { formatJalaliDate, formatToman } from '@/lib/format';
*/ */
const STATUS: Record<Invoice['status'], { label: string; className: string }> = { const STATUS: Record<Invoice['status'], { label: string; className: string }> = {
paid: { label: 'پرداخت‌شده', className: 'bg-[#EAF8F0] text-[#2E7D5B]' }, paid: { label: 'پرداخت‌شده', className: 'bg-[#EAF8F0] text-[#2E7D5B]' },
pending: { label: 'در انتظارِ پرداخت', className: 'bg-[#FFF8E1] text-[#8A6100]' }, pending: { label: 'در انتظار پرداخت', className: 'bg-[#FFF8E1] text-[#8A6100]' },
failed: { label: 'ناموفق', className: 'bg-soft text-muted' }, failed: { label: 'ناموفق', className: 'bg-soft text-muted' },
canceled: { label: 'لغوشده', className: 'bg-soft text-muted' }, canceled: { label: 'لغوشده', className: 'bg-soft text-muted' },
refunded: { label: 'بازپرداخت‌شده', className: 'bg-[#EEF1FF] text-indigo' }, refunded: { label: 'بازپرداخت‌شده', className: 'bg-[#EEF1FF] text-indigo' },
}; };
const PROVIDER: Record<string, string> = { gateway: 'درگاهِ زرین‌پال', bazaar: 'کافه‌بازار' }; const PROVIDER: Record<string, string> = { gateway: 'درگاه زرین‌پال', bazaar: 'کافه‌بازار' };
const dateOf = (invoice: Invoice) => formatJalaliDate(invoice.paidAt ?? invoice.createdAt); const dateOf = (invoice: Invoice) => formatJalaliDate(invoice.paidAt ?? invoice.createdAt);
@@ -32,7 +32,7 @@ export function InvoicesScreen() {
return ( return (
<Screen> <Screen>
<ScreenHeader title="پرداخت‌ها و فاکتورها" subtitle="همه‌ی خریدهات، با شماره‌ی فاکتور و کدِ پیگیری" color="#4C5FD5" back /> <ScreenHeader title="پرداخت‌ها و فاکتورها" subtitle="همه خریدهات، با شماره فاکتور و کد پیگیری" color="#4C5FD5" back />
<ScreenBody className="flex flex-col gap-3"> <ScreenBody className="flex flex-col gap-3">
{list.loading ? ( {list.loading ? (
<LoadingState /> <LoadingState />
@@ -42,7 +42,7 @@ export function InvoicesScreen() {
<div className="flex flex-col items-center gap-4 rounded-3xl border border-line bg-white px-5 py-8 text-center"> <div className="flex flex-col items-center gap-4 rounded-3xl border border-line bg-white px-5 py-8 text-center">
<Receipt size={32} className="text-faint" /> <Receipt size={32} className="text-faint" />
<p className="text-sm text-body">هنوز خریدی نکردی.</p> <p className="text-sm text-body">هنوز خریدی نکردی.</p>
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ اشتراک‌ها</PrimaryButton> <PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدن اشتراک‌ها</PrimaryButton>
</div> </div>
) : ( ) : (
list.data.map((invoice) => ( list.data.map((invoice) => (
@@ -88,17 +88,17 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
const rows: [string, React.ReactNode][] = invoice const rows: [string, React.ReactNode][] = invoice
? [ ? [
['شماره‌ی فاکتور', <span dir="ltr">{invoice.invoiceNumber}</span>], ['شماره فاکتور', <span dir="ltr">{invoice.invoiceNumber}</span>],
['تاریخ', dateOf(invoice)], ['تاریخ', dateOf(invoice)],
['وضعیت', STATUS[invoice.status].label], ['وضعیت', STATUS[invoice.status].label],
['اشتراک', invoice.planTitle || '—'], ['اشتراک', invoice.planTitle || '—'],
...(invoice.listPrice !== null ? [['قیمت', formatToman(invoice.listPrice)] as [string, React.ReactNode]] : []), ...(invoice.listPrice !== null ? [['قیمت', formatToman(invoice.listPrice)] as [string, React.ReactNode]] : []),
...(invoice.discountAmount ? [[ ...(invoice.discountAmount ? [[
invoice.discountCode ? `تخفیف (کدِ ${invoice.discountCode})` : 'تخفیف', invoice.discountCode ? `تخفیف (کد ${invoice.discountCode})` : 'تخفیف',
formatToman(invoice.discountAmount), formatToman(invoice.discountAmount),
] as [string, React.ReactNode]] : []), ] as [string, React.ReactNode]] : []),
['مبلغِ پرداختی', <b>{formatToman(invoice.amount)}</b>], ['مبلغ پرداختی', <b>{formatToman(invoice.amount)}</b>],
['روشِ پرداخت', PROVIDER[invoice.provider] ?? invoice.provider], ['روش پرداخت', PROVIDER[invoice.provider] ?? invoice.provider],
] ]
: []; : [];
@@ -122,7 +122,7 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
onClick={() => void copy(invoice.refId)} onClick={() => void copy(invoice.refId)}
className="flex items-center justify-between rounded-2xl bg-soft px-4 py-3 text-sm" className="flex items-center justify-between rounded-2xl bg-soft px-4 py-3 text-sm"
> >
<span className="text-muted">کدِ پیگیری</span> <span className="text-muted">کد پیگیری</span>
<span className="flex items-center gap-2 font-bold text-ink" dir="ltr"> <span className="flex items-center gap-2 font-bold text-ink" dir="ltr">
{/* کد همان‌طور که کپی می‌شود: با رقمِ فارسی صفرها نقطه دیده می‌شدند. */} {/* کد همان‌طور که کپی می‌شود: با رقمِ فارسی صفرها نقطه دیده می‌شدند. */}
{invoice.refId} <Copy size={14} /> {invoice.refId} <Copy size={14} />
@@ -131,7 +131,7 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
) : ( ) : (
(invoice.status === 'pending' || invoice.status === 'failed') && ( (invoice.status === 'pending' || invoice.status === 'failed') && (
<p className="text-xs leading-6 text-muted"> <p className="text-xs leading-6 text-muted">
اگه پولی از حسابت کم شده ولی این خرید «پرداخت‌شده» نیست، شماره‌ی فاکتور رو به پشتیبانی بده. اگه پولی از حسابت کم شده ولی این خرید «پرداخت‌شده» نیست، شماره فاکتور رو به پشتیبانی بده.
</p> </p>
) )
)} )}

View File

@@ -22,9 +22,9 @@ export function LeaderboardScreen() {
return ( return (
<Screen> <Screen>
<ScreenHeader title="جدولِ امتیازها" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }}> <ScreenHeader title="جدول امتیازها" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }}>
<div className="mt-4 flex gap-1 rounded-2xl bg-white/15 p-1" role="tablist"> <div className="mt-4 flex gap-1 rounded-2xl bg-white/15 p-1" role="tablist">
{([['weekly', 'این هفته'], ['all_time', 'همه‌ی زمان‌ها']] as const).map(([p, label]) => ( {([['weekly', 'این هفته'], ['all_time', 'همه زمان‌ها']] as const).map(([p, label]) => (
<button <button
key={p} key={p}
type="button" type="button"

View File

@@ -92,11 +92,11 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
<FeedbackBar kind="lesson" id={String(lessonId)} /> <FeedbackBar kind="lesson" id={String(lessonId)} />
{data.questionCount === 0 ? ( {data.questionCount === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-[#dedced] bg-white py-4 text-center text-sm font-bold text-muted"> <div className="rounded-2xl border-2 border-dashed border-[#dedced] bg-white py-4 text-center text-sm font-bold text-muted">
تمرینِ این درس به‌زودی اضافه می‌شه تمرین این درس به‌زودی اضافه می‌شه
</div> </div>
) : ( ) : (
<PrimaryButton color={color} onClick={() => nav.replace({ name: 'quiz', lessonId, nodeId: ref?.node.id ?? nodeId })}> <PrimaryButton color={color} onClick={() => nav.replace({ name: 'quiz', lessonId, nodeId: ref?.node.id ?? nodeId })}>
<Play size={18} fill="white" /> شروعِ تمرین ({toFa(data.questionCount)} سوال) <Play size={18} fill="white" /> شروع تمرین ({toFa(data.questionCount)} سوال)
</PrimaryButton> </PrimaryButton>
)} )}
<button <button
@@ -104,7 +104,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
onClick={() => nav.push({ name: 'ask', lessonId })} onClick={() => nav.push({ name: 'ask', lessonId })}
className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]" className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]"
> >
<Brain size={16} /> درباره‌ی این درس از {companionName} بپرس <Brain size={16} /> درباره این درس از {companionName} بپرس
</button> </button>
</div> </div>
</> </>

View File

@@ -38,7 +38,7 @@ export function LessonsScreen() {
<Screen className="bg-page"> <Screen className="bg-page">
<ScreenHeader <ScreenHeader
title="درس‌ها" title="درس‌ها"
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>} subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمون جزیره رو بده</>}
color="#2FB36B" color="#2FB36B"
exact exact
mascot={{ mood: 'happy' }} mascot={{ mood: 'happy' }}
@@ -198,7 +198,7 @@ function NodeRow({
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-sm font-bold leading-6 text-ink"><MathText text={node.title} /></span> <span className="block text-sm font-bold leading-6 text-ink"><MathText text={node.title} /></span>
<span className="block text-[11px] text-muted"> <span className="block text-[11px] text-muted">
{locked ? 'جزیره‌ی قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`} {locked ? 'جزیره قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`}
</span> </span>
</span> </span>
{isCurrent && !open && ( {isCurrent && !open && (

View File

@@ -61,7 +61,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
setDevOtp(data.devOtp ?? null); setDevOtp(data.devOtp ?? null);
} catch (err) { } catch (err) {
if (mine !== attempt.current) return; if (mine !== attempt.current) return;
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن')); setError(friendlyError(err, 'ارسال کد انجام نشد، دوباره امتحان کن'));
if (err instanceof ApiError && err.status === 400) { if (err instanceof ApiError && err.status === 400) {
setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند
} else if (err instanceof ApiError && err.isRateLimited && err.retryAfterMs) { } else if (err instanceof ApiError && err.isRateLimited && err.retryAfterMs) {
@@ -109,8 +109,8 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
<h1 className="text-xl font-black">ورود به تدریسینو</h1> <h1 className="text-xl font-black">ورود به تدریسینو</h1>
<p className="mt-1 text-sm text-white/75"> <p className="mt-1 text-sm text-white/75">
{step === 'phone' {step === 'phone'
? 'شماره‌ی موبایلت رو وارد کن' ? 'شماره موبایلت رو وارد کن'
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامک‌شده رو وارد کن`} : `کد ${toFa(OTP_LENGTH)} رقمی پیامک‌شده رو وارد کن`}
</p> </p>
</div> </div>
<div className="rounded-2xl bg-white/15 p-2"> <div className="rounded-2xl bg-white/15 p-2">
@@ -146,7 +146,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
) : ( ) : (
<div> <div>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-bold text-ink">کدِ تأیید</span> <span className="mb-2 block text-sm font-bold text-ink">کد تأیید</span>
<input <input
ref={otpInput} ref={otpInput}
value={otp} value={otp}
@@ -165,7 +165,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
)} )}
{devOtp && ( {devOtp && (
<p className="mt-2 text-xs text-muted"> <p className="mt-2 text-xs text-muted">
کدِ آزمایشیِ حالتِ توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span> کد آزمایشی حالت توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
</p> </p>
)} )}
<div className="mt-3 flex items-center justify-between text-xs font-bold"> <div className="mt-3 flex items-center justify-between text-xs font-bold">
@@ -173,7 +173,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
<Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح <Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح
</button> </button>
<button type="button" disabled={resendIn > 0 || sending || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted"> <button type="button" disabled={resendIn > 0 || sending || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted">
{resendIn > 0 ? `ارسالِ دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسالِ دوباره'} {resendIn > 0 ? `ارسال دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسال دوباره'}
</button> </button>
</div> </div>
</div> </div>
@@ -187,7 +187,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
loading={loading === 'login'} loading={loading === 'login'}
disabled={step === 'phone' ? !phoneValid : !otpValid} disabled={step === 'phone' ? !phoneValid : !otpValid}
> >
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'} {step === 'phone' ? 'دریافت کد تأیید' : 'ورود'}
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>

View File

@@ -7,27 +7,27 @@ import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title: string; desc: string }[] = [ const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title: string; desc: string }[] = [
{ {
char: 'sigma', mood: 'happy', bg: '#4C5FD5', char: 'sigma', mood: 'happy', bg: '#4C5FD5',
sub: 'از پایه‌ی سوم تا دوازدهم', sub: 'از پایه سوم تا دوازدهم',
title: 'به تدریسینو خوش اومدی!', title: 'به تدریسینو خوش اومدی!',
desc: 'من سیگمام. ریاضیِ کتابِ خودت رو جزیره به جزیره با هم جلو می‌بریم؛ هر جزیره چند درسِ کوتاه و یه عالمه تمرین.', desc: 'من سیگمام. با هم ریاضی کتاب درسیت رو جزیره به جزیره جلو می‌بریم؛ هر جزیره چند درس کوتاه داره و کلی تمرین.',
}, },
{ {
char: 'delta', mood: 'excited', bg: '#FF7A3D', char: 'delta', mood: 'excited', bg: '#FF7A3D',
sub: 'هر روز یه کم', sub: 'هر روز یه کم',
title: 'امتیاز بگیر، زنجیره بساز', title: 'امتیاز بگیر، زنجیره بساز',
desc: 'منم دلتا! با هر جوابِ درست امتیاز و سکه می‌گیری، روزهای پیاپی‌ت شمرده می‌شه و تو جدولِ امتیازها بالا می‌ری.', desc: 'منم دلتا! با هر جواب درست امتیاز و سکه می‌گیری، روزهایی که پشت هم تمرین می‌کنی شمرده می‌شه و تو جدول امتیازها بالا می‌ری.',
}, },
{ {
char: 'pi', mood: 'happy', bg: '#9B7BFF', char: 'pi', mood: 'happy', bg: '#9B7BFF',
sub: 'هر جا گیر کردی', sub: 'هر جا گیر کردی',
title: 'از پی بپرس', title: 'از پی بپرس',
desc: 'من پی‌ام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدم‌به‌قدم توضیح می‌دم.', desc: 'من پی‌ام. هر جا سوالی داشتی یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتاب درسیت قدم‌به‌قدم توضیح می‌دم.',
}, },
{ {
char: 'tedi', mood: 'excited', bg: '#D9820B', char: 'tedi', mood: 'excited', bg: '#D9820B',
sub: 'یکی از ما مالِ توئه', sub: 'یکی از ما مال توئه',
title: 'همراهت رو انتخاب کن', title: 'همراهت رو انتخاب کن',
desc: 'منم امگا! هر چی امتیازت بیشتر بشه، همراه‌های تازه باز می‌شن. از صفحه‌ی «من» هر کدوممون رو خواستی کنارت می‌ذاری.', desc: 'منم امگا! هر چی امتیازت بیشتر بشه، همراه‌های تازه‌تری باز می‌شن. از صفحه «من» هر کدوممون رو بخوای انتخاب می‌کنی.',
}, },
]; ];

View File

@@ -1,7 +1,7 @@
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { import {
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Crown, Heart, 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, FileText, Pencil, Sparkles, Star, Trash2, Trophy, UserRound, Volume2, VolumeX,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
@@ -20,7 +20,7 @@ import { useAsync } from '@/lib/useAsync';
import { daysLeft } from '@/lib/subscription'; import { daysLeft } from '@/lib/subscription';
import { BrandFooter } from '@/components/BrandFooter'; import { BrandFooter } from '@/components/BrandFooter';
type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null; type Editing = 'name' | 'grade' | 'birth' | 'account' | 'logout' | 'delete' | null;
/** «من»: پروفایل، تنظیمات و میان‌بُر به بخش‌های فرعی. */ /** «من»: پروفایل، تنظیمات و میان‌بُر به بخش‌های فرعی. */
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) { export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
@@ -46,7 +46,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
{profile.name || 'بی‌نام'} <Pencil size={14} className="text-white/70" /> {profile.name || 'بی‌نام'} <Pencil size={14} className="text-white/70" />
</button> </button>
<div className="mt-0.5 text-xs text-white/75"> <div className="mt-0.5 text-xs text-white/75">
شناسه‌ی کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span> شناسه کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span>
</div> </div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80"> <div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80">
{gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>} {gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
@@ -74,27 +74,27 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<SubscriptionCard onOpen={() => nav.switchTab('subscription')} /> <SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
<Menu> <Menu>
<Row icon={GraduationCap} label="پایه‌ی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} /> <Row icon={GraduationCap} label="پایه تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
<Row <Row
icon={CalendarDays} icon={CalendarDays}
label="تاریخِ تولد" label="تاریخ تولد"
value={formatBirthDate(profile.birthDate) || (age ? `${toFa(age)} سال` : 'وارد نشده')} value={formatBirthDate(profile.birthDate) || (age ? `${toFa(age)} سال` : 'وارد نشده')}
onClick={() => setEditing('birth')} onClick={() => setEditing('birth')}
/> />
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} /> <Row icon={Sparkles} label="همراه من" onClick={go({ name: 'characters' })} />
</Menu> </Menu>
<Menu> <Menu>
<Row icon={BarChart3} label="گزارشِ والدین" onClick={go({ name: 'report' })} /> <Row icon={BarChart3} label="گزارش والدین" onClick={go({ name: 'report' })} />
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} /> <Row icon={Flame} label="تقویم زنجیره" onClick={go({ name: 'streak' })} />
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} /> <Row icon={Trophy} label="جدول امتیازها" onClick={go({ name: 'leaderboard' })} />
<Row icon={Bell} label="اعلان‌ها" onClick={go({ name: 'notifications' })} /> <Row icon={Bell} label="اعلان‌ها" onClick={go({ name: 'notifications' })} />
</Menu> </Menu>
<Menu> <Menu>
<Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} /> <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />
<Row icon={FileText} label="پرداخت‌ها و فاکتورها" onClick={go({ name: 'invoices' })} /> <Row icon={FileText} label="پرداخت‌ها و فاکتورها" onClick={go({ name: 'invoices' })} />
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} /> <Row icon={LifeBuoy} label="پشتیبانی و گزارش مشکل" onClick={go({ name: 'support' })} />
<Row <Row
icon={sound ? Volume2 : VolumeX} icon={sound ? Volume2 : VolumeX}
label="صداها" label="صداها"
@@ -106,13 +106,12 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
/> />
</Menu> </Menu>
{/* حذفِ حساب یک لایه پایین‌تر، در «حساب کاربری»، مثلِ اپ‌های بزرگ (دیجی‌کالا، اسنپ، دولینگو): پیدا می‌شود
(Google Play و App Store «در دسترس و پیداشدنی» می‌خواهند) ولی با یک لمسِ اشتباه زیرِ انگشت نیست. */}
<Menu> <Menu>
<Row icon={Repeat} label="ورود با حسابِ دیگر" onClick={() => setEditing('switch')} /> <Row icon={UserRound} label="حساب کاربری" value={profile.phone ? toFa(profile.phone) : undefined} onClick={() => setEditing('account')} />
<Row icon={LogOut} label="خروج از حساب" onClick={() => setEditing('logout')} /> <Row icon={LogOut} label="خروج از حساب" danger onClick={() => setEditing('logout')} />
</Menu> </Menu>
<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 /> <BrandFooter />
</div> </div>
</div> </div>
@@ -120,7 +119,8 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} /> <NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} /> <GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
<BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} /> <BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
<LogoutSheet open={editing === 'logout' || editing === 'switch'} switching={editing === 'switch'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} /> <AccountSheet open={editing === 'account'} onClose={() => setEditing(null)} onDelete={() => setEditing('delete')} />
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
<DeleteAccountSheet open={editing === 'delete'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} /> <DeleteAccountSheet open={editing === 'delete'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
</Screen> </Screen>
); );
@@ -148,7 +148,7 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
<div className="flex items-center gap-3"> <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={`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="min-w-0 flex-1">
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراکِ تدریسینو</span> <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 className="block text-base font-black">{s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}</span>
</span> </span>
<span className="text-left"> <span className="text-left">
@@ -166,7 +166,7 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={total} aria-valuemax={total}
aria-valuenow={days ?? total} aria-valuenow={days ?? total}
aria-label="روزهای ماندهٔ اشتراک" aria-label="روزهای مانده اشتراک"
> >
{percent > 0 && ( {percent > 0 && (
<div className="bar-fill absolute inset-y-0 right-0 overflow-hidden rounded-full" style={{ width: `${percent}%`, backgroundColor: low ? '#FFD23F' : '#fff' }}> <div className="bar-fill absolute inset-y-0 right-0 overflow-hidden rounded-full" style={{ width: `${percent}%`, backgroundColor: low ? '#FFD23F' : '#fff' }}>
@@ -178,11 +178,11 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
<div className={`mt-2 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[11px] ${s.active ? 'text-white/85' : 'text-muted'}`}> <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.active ? (
<> <>
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقتِ تمدیده'}</span>} {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 className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلب نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
</> </>
) : ( ) : (
<span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همه‌ی فصل‌ها و قلبِ نامحدود باز می‌شه.</span> <span>فصل اول هر پایه رایگانه؛ با اشتراک همه فصل‌ها و قلب نامحدود باز می‌شه.</span>
)} )}
</div> </div>
</button> </button>
@@ -193,11 +193,11 @@ function Menu({ children }: { children: ReactNode }) {
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>; return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
} }
function Row({ icon: Icon, label, value, onClick }: { icon: typeof Bell; label: string; value?: string; onClick: () => void }) { function Row({ icon: Icon, label, value, danger = false, onClick }: { icon: typeof Bell; label: string; value?: string; danger?: boolean; onClick: () => void }) {
return ( return (
<button type="button" onClick={onClick} className="flex w-full items-center gap-3 px-4 py-3.5 text-right active:bg-soft"> <button type="button" onClick={onClick} className="flex w-full items-center gap-3 px-4 py-3.5 text-right active:bg-soft">
<Icon size={18} className="flex-shrink-0 text-indigo" /> <Icon size={18} className={`flex-shrink-0 ${danger ? 'text-red-500' : 'text-indigo'}`} />
<span className="flex-1 text-sm font-bold text-ink">{label}</span> <span className={`flex-1 text-sm font-bold ${danger ? 'text-red-500' : 'text-ink'}`}>{label}</span>
{value && <span className="text-xs text-muted">{value}</span>} {value && <span className="text-xs text-muted">{value}</span>}
<ChevronLeft size={16} className="text-faint" /> <ChevronLeft size={16} className="text-faint" />
</button> </button>
@@ -262,8 +262,8 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
} }
return ( return (
<Sheet open={open} onClose={onClose} title="پایه‌ی تحصیلی"> <Sheet open={open} onClose={onClose} title="پایه تحصیلی">
<p className="mb-3 text-xs leading-6 text-muted">پیشرفتِ هر پایه جدا نگه داشته می‌شه؛ با برگشتن، سرِ جاشه.</p> <p className="mb-3 text-xs leading-6 text-muted">پیشرفت هر پایه جدا نگه داشته می‌شه؛ با برگشتن، سر جاشه.</p>
<div className="grid grid-cols-2 gap-2.5"> <div className="grid grid-cols-2 gap-2.5">
{grades.map((g) => { {grades.map((g) => {
const on = g.id === profile.gradeId; const on = g.id === profile.gradeId;
@@ -307,8 +307,8 @@ function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void
} }
return ( return (
<Sheet open={open} onClose={onClose} title="تاریخِ تولد"> <Sheet open={open} onClose={onClose} title="تاریخ تولد">
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سالِ تولدت به شمسی.</p> <p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سال تولدت به شمسی.</p>
<div className="mb-5"> <div className="mb-5">
<BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} /> <BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
</div> </div>
@@ -318,11 +318,11 @@ function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void
} }
/** /**
* خروج، و «ورود با حسابِ دیگر» که همان خروج است با متنِ دیگر. عمداً چند حسابِ واردشده روی * خروج. «ورود با حسابِ دیگر» (۲۰۲۶-۱۰-۱۴) برداشته شد: همان خروج بود با متنِ دیگر. عمداً چند حسابِ واردشده روی یک
* یک گوشی نگه داشته نمی‌شود: گوشیِ خانوادگی دست‌به‌دست می‌شود و هر کس بی‌کد واردِ حسابِ * گوشی نگه داشته نمی‌شود: گوشیِ خانوادگی دست‌به‌دست می‌شود و هر کس بی‌کد واردِ حسابِ دیگری می‌شد، و چند refresh
* دیگری می‌شد، و چند refresh tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر می‌کرد. * tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر می‌کرد. خروج و ورودِ دوباره با پیامک همان کار را می‌کند.
*/ */
function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open: boolean; switching?: boolean; onClose: () => void; onSignedOut: () => void }) { function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
async function logout() { async function logout() {
@@ -337,22 +337,39 @@ function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open:
} }
return ( return (
<Sheet open={open} onClose={onClose} title={switching ? 'ورود با حسابِ دیگر' : 'از حسابت خارج بشی؟'}> <Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟">
<p className="mb-5 text-sm leading-7 text-muted"> <p className="mb-5 text-sm leading-7 text-muted">هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سر جاشه.</p>
{switching && 'اول از این حساب خارج می‌شی، بعد با شماره‌ی دیگه وارد شو. '}
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
</p>
<p className="mb-5 -mt-3 text-xs text-muted">ممنون که با تدریسینو یاد می‌گیری 💛</p>
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، می‌مونم</SecondaryButton> <SecondaryButton onClick={onClose}>نه، می‌مونم</SecondaryButton>
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}> <PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
{switching ? 'خروج و ورود با حسابِ دیگر' : 'خروج'}
</PrimaryButton>
</div> </div>
</Sheet> </Sheet>
); );
} }
/** «حساب کاربری»: شماره و شناسه، و پایینش حذفِ حساب (جای استانداردش؛ نه دمِ دستِ صفحه‌ی اصلی). */
function AccountSheet({ open, onClose, onDelete }: { open: boolean; onClose: () => void; onDelete: () => void }) {
const { profile } = useAppData();
return (
<Sheet open={open} onClose={onClose} title="حساب کاربری">
<div className="mb-6 divide-y divide-line overflow-hidden rounded-2xl border border-line">
<div className="flex items-center justify-between px-4 py-3.5 text-sm">
<span className="font-bold text-body">شماره موبایل</span>
<span dir="ltr" className="font-bold text-ink">{profile.phone ? toFa(profile.phone) : '—'}</span>
</div>
<div className="flex items-center justify-between px-4 py-3.5 text-sm">
<span className="font-bold text-body">شناسه کاربری</span>
<span dir="ltr" className="font-bold tracking-wide text-ink">{profile.publicId}</span>
</div>
</div>
<p className="mb-2 text-xs leading-6 text-muted">با حذف حساب، همه پیشرفت و اطلاعاتت برای همیشه پاک می‌شه.</p>
<button type="button" onClick={onDelete} className="flex w-full items-center justify-center gap-2 rounded-2xl border border-red-200 py-3 text-sm font-bold text-red-500 active:bg-red-50">
<Trash2 size={16} /> حذف حساب کاربری
</button>
</Sheet>
);
}
/** /**
* حذفِ همیشگیِ حساب (POST /user/delete). شماره‌دار با کدِ تازه‌ی پیامکی تأیید می‌کند تا گوشیِ * حذفِ همیشگیِ حساب (POST /user/delete). شماره‌دار با کدِ تازه‌ی پیامکی تأیید می‌کند تا گوشیِ
* بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند. * بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند.
@@ -393,28 +410,28 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
} }
return ( return (
<Sheet open={open} onClose={onClose} title="حذفِ همیشگیِ حساب"> <Sheet open={open} onClose={onClose} title="حذف همیشگی حساب">
<p className="mb-3 text-sm leading-7 text-body"> <p className="mb-3 text-sm leading-7 text-body">
امتیاز، زنجیره، پیشرفتِ جزیره‌ها، گفتگوها با دستیار، سوال‌های خریده‌شده و قلبِ نامحدودت پاک می‌شن و برنمی‌گردن. امتیاز، زنجیره، پیشرفت جزیره‌ها، گفتگوها با دستیار و اشتراکت پاک می‌شن و برنمی‌گردن.
سوابقِ پرداخت فقط برای حسابداری می‌مونه. سوابق پرداخت فقط برای حسابداری می‌مونه. اگه اشتراکت رو از کافه‌بازار خریدی، تمدید خودکارش رو از بخش «اشتراک‌ها»ی بازار لغو کن.
</p> </p>
{sent ? ( {sent ? (
<input <input
value={value} value={value}
onChange={(e) => setValue(digitsOnly(e.target.value, 6))} onChange={(e) => setValue(digitsOnly(e.target.value, 6))}
placeholder="کدِ پیامک‌شده" placeholder="کد پیامک‌شده"
inputMode="numeric" inputMode="numeric"
dir="ltr" dir="ltr"
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-red-400" className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-red-400"
/> />
) : ( ) : (
<p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شماره‌ی خودت پیامک می‌شه.</p> <p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شماره خودت پیامک می‌شه.</p>
)} )}
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>} {error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton> <SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton>
{!sent ? ( {!sent ? (
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادنِ کد</PrimaryButton> <PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادن کد</PrimaryButton>
) : ( ) : (
<PrimaryButton <PrimaryButton
color="#ef4444" color="#ef4444"
@@ -422,7 +439,7 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
disabled={value.length < 6} disabled={value.length < 6}
onClick={() => void remove()} onClick={() => void remove()}
> >
حذفِ همیشگی حذف همیشگی
</PrimaryButton> </PrimaryButton>
)} )}
</div> </div>

View File

@@ -257,7 +257,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
<p className="text-sm text-muted">دارم نتیجه‌ت رو ثبت می‌کنم…</p> <p className="text-sm text-muted">دارم نتیجه‌ت رو ثبت می‌کنم…</p>
) : ( ) : (
<> <>
<InlineError>نتیجه‌ی این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError> <InlineError>نتیجه این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
<PrimaryButton color={color} onClick={() => void finish()}>دوباره ثبت کن</PrimaryButton> <PrimaryButton color={color} onClick={() => void finish()}>دوباره ثبت کن</PrimaryButton>
<SecondaryButton onClick={nav.back}>بی‌خیال، برگرد</SecondaryButton> <SecondaryButton onClick={nav.back}>بی‌خیال، برگرد</SecondaryButton>
</> </>
@@ -281,9 +281,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad'; const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad';
const bubble = !answer const bubble = !answer
? selected === null ? 'جوابِ درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.' ? selected === null ? 'جواب درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
: answer.correct : answer.correct
? combo >= 2 ? `${toFa(combo)} تا پشتِ هم درست! داری عالی پیش می‌ری` : PRAISE[index % PRAISE.length] ? combo >= 2 ? `${toFa(combo)} تا پشت هم درست! داری عالی پیش می‌ری` : PRAISE[index % PRAISE.length]
: outOfHearts : outOfHearts
? 'قلب‌هات تموم شد؛ یه کم استراحت کنیم.' ? 'قلب‌هات تموم شد؛ یه کم استراحت کنیم.'
: NUDGE[index % NUDGE.length]; : NUDGE[index % NUDGE.length];
@@ -343,7 +343,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
<div className="rounded-3xl border border-line bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-line bg-white p-5 shadow-sm">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted">سوالِ {toFa(index + 1)}</span> <span className="text-xs font-medium text-muted">سوال {toFa(index + 1)}</span>
</div> </div>
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} /></h2> <h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} /></h2>
{question.figure ? <div className="mt-3"><Diagram spec={question.figure} /></div> : null} {question.figure ? <div className="mt-3"><Diagram spec={question.figure} /></div> : null}
@@ -389,7 +389,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
</button> </button>
)} )}
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}> <PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
{isLast ? 'پایانِ تمرین' : 'ادامه'} <ChevronLeft size={18} /> {isLast ? 'پایان تمرین' : 'ادامه'} <ChevronLeft size={18} />
</PrimaryButton> </PrimaryButton>
</> </>
) : ( ) : (
@@ -417,7 +417,7 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
if (hearts?.isUnlimited) { if (hearts?.isUnlimited) {
return ( return (
<div className="mt-3 flex items-center justify-center gap-1.5 text-xs font-bold text-coral"> <div className="mt-3 flex items-center justify-center gap-1.5 text-xs font-bold text-coral">
<Heart size={18} fill="#ED6956" /> <InfinityIcon size={18} /> قلبِ نامحدود <Heart size={18} fill="#ED6956" /> <InfinityIcon size={18} /> قلب نامحدود
</div> </div>
); );
} }
@@ -495,7 +495,7 @@ function Result({
<div> <div>
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2> <h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی</p>} {nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی</p>}
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p> <p className="mt-1 text-sm text-muted">{toFa(correct)} جواب درست از {toFa(total)} سوال</p>
{/* نمره‌ی کم: تلاش و راه را ستایش کن، نه «باهوشی» را (پژوهش‌های ذهنیتِ رشد)؛ و راهِ بعدی را نشان بده. */} {/* نمره‌ی کم: تلاش و راه را ستایش کن، نه «باهوشی» را (پژوهش‌های ذهنیتِ رشد)؛ و راهِ بعدی را نشان بده. */}
{result.stars <= 1 && ( {result.stars <= 1 && (
<p className="mt-2 text-xs leading-6 text-body"> <p className="mt-2 text-xs leading-6 text-body">
@@ -517,7 +517,7 @@ function Result({
</div> </div>
</div>} </div>}
<PrimaryButton color={color} loading={loadingNext} onClick={() => then(() => void goNext())}> <PrimaryButton color={color} loading={loadingNext} onClick={() => then(() => void goNext())}>
{nodeMode && nextNode ? 'بریم جزیره‌ی بعدی' : 'ادامه'} <ChevronLeft size={18} /> {nodeMode && nextNode ? 'بریم جزیره بعدی' : 'ادامه'} <ChevronLeft size={18} />
</PrimaryButton> </PrimaryButton>
{nodeMode && nextNode && ( {nodeMode && nextNode && (
<button type="button" onClick={() => then(nav.back)} className="-mt-2 py-2 text-sm font-bold text-muted"> <button type="button" onClick={() => then(nav.back)} className="-mt-2 py-2 text-sm font-bold text-muted">
@@ -534,13 +534,13 @@ function Result({
* تلاش و راه ستایش می‌شود و قدمِ بعدی گفته می‌شود، نه این‌که «اشکالی نداره» تنها بماند. * تلاش و راه ستایش می‌شود و قدمِ بعدی گفته می‌شود، نه این‌که «اشکالی نداره» تنها بماند.
*/ */
const PRAISE = ['آفرین! درست بود', 'دقیقاً همینه!', 'عالی! خوب فکر کردی', 'درسته! داری راه میفتی']; const PRAISE = ['آفرین! درست بود', 'دقیقاً همینه!', 'عالی! خوب فکر کردی', 'درسته! داری راه میفتی'];
const NUDGE = ['نزدیک بودی! جوابِ درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد می‌گیریم.', 'این یکی سخت بود؛ جوابِ درست سبز شده.']; const NUDGE = ['نزدیک بودی! جواب درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد می‌گیریم.', 'این یکی سخت بود؛ جواب درست سبز شده.'];
/** «راهنمای مهربون»ِ پی: نکته‌ی عمومیِ کوتاه بعد از جوابِ غلط؛ نه جوابِ سوال، فقط عادتِ خوبِ حل کردن. */ /** «راهنمای مهربون»ِ پی: نکته‌ی عمومیِ کوتاه بعد از جوابِ غلط؛ نه جوابِ سوال، فقط عادتِ خوبِ حل کردن. */
const PI_TIPS = [ const PI_TIPS = [
'صورتِ سوال رو یه بار دیگه آروم بخون؛ کلمه‌ی «نیست» یا «بیشترین» زیاد جا می‌مونه.', 'صورت سوال رو یه بار دیگه آروم بخون؛ کلمه «نیست» یا «بیشترین» زیاد جا می‌مونه.',
'علامتِ منفی‌ها رو یه بار دیگه چک کن.', 'علامت منفی‌ها رو یه بار دیگه چک کن.',
'اول داخلِ پرانتز، بعد ضرب و تقسیم، آخرش جمع و تفریق.', 'اول داخل پرانتز، بعد ضرب و تقسیم، آخرش جمع و تفریق.',
'جوابت رو بذار توی سوال؛ اگه درست درنیومد یه جا اشتباه شده.', 'جوابت رو بذار توی سوال؛ اگه درست درنیومد یه جا اشتباه شده.',
]; ];

View File

@@ -43,8 +43,8 @@ export function ReportScreen() {
return ( return (
<Screen> <Screen>
<ScreenHeader <ScreenHeader
title="گزارشِ والدین" title="گزارش والدین"
subtitle={`پیشرفتِ ${profile.name || 'دانش‌آموز'} در ۷ روزِ اخیر`} subtitle={`پیشرفت ${profile.name || 'دانش‌آموز'} در ۷ روز اخیر`}
color={THIS_WEEK} color={THIS_WEEK}
back back
mascot={{ mood: 'happy', size: 56 }} mascot={{ mood: 'happy', size: 56 }}
@@ -69,43 +69,43 @@ function Report({ data, name }: { data: WeeklyReport; name: string }) {
<Verdict now={now} before={before} name={name} /> <Verdict now={now} before={before} name={name} />
<div className="grid grid-cols-2 gap-2.5"> <div className="grid grid-cols-2 gap-2.5">
<Stat icon={Clock} label="زمانِ تمرین" value={`حدود ${toFa(now.minutes)} دقیقه`} delta={change(now.minutes, before.minutes, 'دقیقه')} /> <Stat icon={Clock} label="زمان تمرین" value={`حدود ${toFa(now.minutes)} دقیقه`} delta={change(now.minutes, before.minutes, 'دقیقه')} />
<Stat icon={CalendarCheck} label="روزهای فعال" value={`${toFa(now.activeDays)} از ۷ روز`} delta={change(now.activeDays, before.activeDays, 'روز')} /> <Stat icon={CalendarCheck} label="روزهای فعال" value={`${toFa(now.activeDays)} از ۷ روز`} delta={change(now.activeDays, before.activeDays, 'روز')} />
<Stat icon={Target} label="سوالِ حل‌شده" value={toFa(now.questions)} delta={change(now.questions, before.questions, '')} /> <Stat icon={Target} label="سوال حل‌شده" value={toFa(now.questions)} delta={change(now.questions, before.questions, '')} />
<Stat <Stat
icon={CheckCircle2} icon={CheckCircle2}
label="درصدِ جوابِ درست" label="درصد جواب درست"
value={now.questions ? `${toFa(percent(now.correct, now.questions))}٪` : '—'} value={now.questions ? `${toFa(percent(now.correct, now.questions))}٪` : '—'}
delta={now.questions && before.questions ? change(percent(now.correct, now.questions), percent(before.correct, before.questions), '') : null} delta={now.questions && before.questions ? change(percent(now.correct, now.questions), percent(before.correct, before.questions), '') : null}
/> />
{/* آزمونِ جزیره و آزمونِ تک‌درس هر دو «تمام کردن» اند؛ جدا شمردنشان برای والد فرقی نمی‌کند. */} {/* آزمونِ جزیره و آزمونِ تک‌درس هر دو «تمام کردن» اند؛ جدا شمردنشان برای والد فرقی نمی‌کند. */}
<Stat icon={BookOpen} label="جزیره و درسِ تمام‌شده" value={toFa(now.islands + now.lessons)} delta={change(now.islands + now.lessons, before.islands + before.lessons, '')} /> <Stat icon={BookOpen} label="جزیره و درس تمام‌شده" value={toFa(now.islands + now.lessons)} delta={change(now.islands + now.lessons, before.islands + before.lessons, '')} />
<Stat icon={MessageCircle} label="سوال از دستیار" value={toFa(now.aiQuestions)} delta={change(now.aiQuestions, before.aiQuestions, '')} /> <Stat icon={MessageCircle} label="سوال از دستیار" value={toFa(now.aiQuestions)} delta={change(now.aiQuestions, before.aiQuestions, '')} />
</div> </div>
{data.streak.longest > 0 && ( {data.streak.longest > 0 && (
<div className="flex items-center gap-2 rounded-2xl bg-[#FFF1E6] px-4 py-3 text-xs text-body"> <div className="flex items-center gap-2 rounded-2xl bg-[#FFF1E6] px-4 py-3 text-xs text-body">
<Flame size={16} className="text-orange" fill="#FF7A3D" /> <Flame size={16} className="text-orange" fill="#FF7A3D" />
زنجیره‌ی فعلی <b className="text-ink">{toFa(data.streak.current)}</b> روز؛ بیشترین <b className="text-ink">{toFa(data.streak.longest)}</b> روز زنجیره فعلی <b className="text-ink">{toFa(data.streak.current)}</b> روز؛ بیشترین <b className="text-ink">{toFa(data.streak.longest)}</b> روز
</div> </div>
)} )}
<BarChart <BarChart
title="زمانِ تمرین در هر روز" title="زمان تمرین در هر روز"
description="ستونِ آبی هر روزِ این هفته است و ستونِ نارنجیِ کنارش همان روز در هفته‌ی پیش؛ آبیِ بلندتر یعنی بیشتر از هفته‌ی پیش." description="ستون آبی هر روز این هفته است و ستون نارنجی کنارش همان روز در هفته پیش؛ آبی بلندتر یعنی بیشتر از هفته پیش."
unit="دقیقه" unit="دقیقه"
days={data.days} days={data.days}
mode="grouped" mode="grouped"
series={[ series={[
{ label: 'این هفته', color: THIS_WEEK, values: data.days.map((d) => d.minutes) }, { label: 'این هفته', color: THIS_WEEK, values: data.days.map((d) => d.minutes) },
{ label: 'هفته‌ی پیش', color: LAST_WEEK, values: data.previousDays.map((d) => d.minutes) }, { label: 'هفته پیش', color: LAST_WEEK, values: data.previousDays.map((d) => d.minutes) },
]} ]}
note="زمان تقریبی است: از فاصله‌ی جواب‌ها و پرسش‌ها حساب می‌شود، پس خواندنِ درس بدونِ جواب دادن در آن نیست." note="زمان تقریبی است: از فاصله جواب‌ها و پرسش‌ها حساب می‌شود، پس خواندن درس بدون جواب دادن در آن نیست."
/> />
<BarChart <BarChart
title="سوال‌های حل‌شده در هر روز" title="سوال‌های حل‌شده در هر روز"
description="کلِ ستون تعدادِ سوال‌هایی است که آن روز جواب داده؛ بخشِ سبز جواب‌های درست است و بخشِ طلایی نادرست." description="کل ستون تعداد سوال‌هایی است که آن روز جواب داده؛ بخش سبز جواب‌های درست است و بخش طلایی نادرست."
unit="سوال" unit="سوال"
days={data.days} days={data.days}
mode="stacked" mode="stacked"
@@ -128,18 +128,18 @@ function verdict(now: ReportTotals, before: ReportTotals, name: string): { mood:
const diff = now.minutes - before.minutes; const diff = now.minutes - before.minutes;
const meaningful = Math.abs(diff) >= 3 && Math.abs(diff) >= before.minutes * 0.1; const meaningful = Math.abs(diff) >= 3 && Math.abs(diff) >= before.minutes * 0.1;
if (!now.minutes && !before.minutes) { if (!now.minutes && !before.minutes) {
return { mood: 'idle', text: 'این هفته هنوز تمرینی ثبت نشده. یه جزیره‌ی کوتاه شروعِ خوبیه!' }; return { mood: 'idle', text: 'این هفته هنوز تمرینی ثبت نشده. یه جزیره کوتاه شروع خوبیه!' };
} }
if (!before.minutes) { if (!before.minutes) {
return { mood: 'excited', text: `آفرین! ${who}این هفته شروع کرده و ${toFa(now.activeDays)} روز تمرین کرده.` }; return { mood: 'excited', text: `آفرین! ${who}این هفته شروع کرده و ${toFa(now.activeDays)} روز تمرین کرده.` };
} }
if (meaningful && diff > 0) { if (meaningful && diff > 0) {
return { mood: 'excited', text: `آفرین! ${who}این هفته حدودِ ${toFa(diff)} دقیقه بیشتر از هفته‌ی پیش تمرین کرده.` }; return { mood: 'excited', text: `آفرین! ${who}این هفته حدود ${toFa(diff)} دقیقه بیشتر از هفته پیش تمرین کرده.` };
} }
if (meaningful && diff < 0) { if (meaningful && diff < 0) {
return { mood: 'idle', text: `این هفته حدودِ ${toFa(-diff)} دقیقه کمتر از هفته‌ی پیش تمرین شده. یه تشویقِ کوچیک کمک می‌کنه!` }; return { mood: 'idle', text: `این هفته حدود ${toFa(-diff)} دقیقه کمتر از هفته پیش تمرین شده. یه تشویق کوچیک کمک می‌کنه!` };
} }
return { mood: 'happy', text: `${who}مثلِ هفته‌ی پیش منظم تمرین کرده؛ همین‌طور ادامه بده!` }; return { mood: 'happy', text: `${who}مثل هفته پیش منظم تمرین کرده؛ همین‌طور ادامه بده!` };
} }
function Verdict({ now, before, name }: { now: ReportTotals; before: ReportTotals; name: string }) { function Verdict({ now, before, name }: { now: ReportTotals; before: ReportTotals; name: string }) {
@@ -162,8 +162,8 @@ function change(now: number, before: number, unit: string): Delta {
const u = unit ? ` ${unit}` : ''; const u = unit ? ` ${unit}` : '';
// صفر در برابرِ صفر خبری ندارد؛ زیرِ همه‌ی کاشی‌های حسابِ تازه «مثلِ هفته‌ی پیش» می‌نشست. // صفر در برابرِ صفر خبری ندارد؛ زیرِ همه‌ی کاشی‌های حسابِ تازه «مثلِ هفته‌ی پیش» می‌نشست.
if (!now && !before) return null; if (!now && !before) return null;
if (diff === 0) return { text: 'مثلِ هفته‌ی پیش', up: null }; if (diff === 0) return { text: 'مثل هفته پیش', up: null };
return { text: `${toFa(Math.abs(diff))}${u} ${diff > 0 ? 'بیشتر' : 'کمتر'} از هفته‌ی پیش`, up: diff > 0 }; return { text: `${toFa(Math.abs(diff))}${u} ${diff > 0 ? 'بیشتر' : 'کمتر'} از هفته پیش`, up: diff > 0 };
} }
function Stat({ icon: Icon, label, value, delta }: { icon: LucideIcon; label: string; value: string; delta: Delta }) { function Stat({ icon: Icon, label, value, delta }: { icon: LucideIcon; label: string; value: string; delta: Delta }) {
@@ -326,7 +326,7 @@ function DayTable({ days, previous }: { days: ReportDay[]; previous: ReportDay[]
<tr> <tr>
<th className="py-1 text-right font-bold">روز</th> <th className="py-1 text-right font-bold">روز</th>
<th className="font-bold">دقیقه</th> <th className="font-bold">دقیقه</th>
<th className="font-bold">هفته‌ی پیش</th> <th className="font-bold">هفته پیش</th>
<th className="font-bold">سوال</th> <th className="font-bold">سوال</th>
<th className="font-bold">درست</th> <th className="font-bold">درست</th>
</tr> </tr>

View File

@@ -58,7 +58,7 @@ export function SetupScreen({
<div className="absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" /> <div className="absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
<div className="relative flex items-center justify-between pt-8"> <div className="relative flex items-center justify-between pt-8">
<div> <div>
<div className="mb-1 text-xs font-bold text-white/75">فقط چندتا سؤالِ کوچیک</div> <div className="mb-1 text-xs font-bold text-white/75">فقط چندتا سوال کوچیک</div>
<h1 className="text-2xl font-black">بیا با هم آشنا بشیم!</h1> <h1 className="text-2xl font-black">بیا با هم آشنا بشیم!</h1>
</div> </div>
<div className="rounded-3xl bg-white/15 p-2"> <div className="rounded-3xl bg-white/15 p-2">
@@ -84,14 +84,14 @@ export function SetupScreen({
<div> <div>
<span className="mb-2 block text-sm font-black text-ink"> <span className="mb-2 block text-sm font-black text-ink">
تاریخِ تولدت <span className="text-xs font-medium text-muted">(به شمسی، اختیاری)</span> تاریخ تولدت <span className="text-xs font-medium text-muted">(به شمسی، اختیاری)</span>
</span> </span>
<BirthDatePicker value={birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} /> <BirthDatePicker value={birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
</div> </div>
<div> <div>
<span className="mb-3 block text-sm font-black text-ink"> <span className="mb-3 block text-sm font-black text-ink">
{adult ? 'ریاضیِ کدوم پایه رو می‌خوای یاد بگیری؟' : 'کدوم پایه‌ای؟'} {adult ? 'ریاضی کدوم پایه رو می‌خوای یاد بگیری؟' : 'کدوم پایه‌ای؟'}
{adult && <span className="mt-1 block text-xs font-medium text-muted">هر وقت خواستی از پروفایل عوضش کن.</span>} {adult && <span className="mt-1 block text-xs font-medium text-muted">هر وقت خواستی از پروفایل عوضش کن.</span>}
</span> </span>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -129,7 +129,7 @@ export function SetupScreen({
{error && <InlineError>{error}</InlineError>} {error && <InlineError>{error}</InlineError>}
<PrimaryButton onClick={() => void submit()} loading={saving} className="mt-auto"> <PrimaryButton onClick={() => void submit()} loading={saving} className="mt-auto">
شروعِ یادگیری <ChevronLeft size={19} strokeWidth={3} /> شروع یادگیری <ChevronLeft size={19} strokeWidth={3} />
</PrimaryButton> </PrimaryButton>
</div> </div>
</div> </div>

View File

@@ -40,7 +40,7 @@ export function StreakScreen() {
return ( return (
<Screen> <Screen>
<ScreenHeader title="زنجیره‌ی روزها" subtitle="هر روز حداقل یه درس، زنجیره‌ت رو زنده نگه می‌داره" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }} /> <ScreenHeader title="زنجیره روزها" subtitle="هر روز حداقل یه درس، زنجیره‌ت رو زنده نگه می‌داره" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }} />
<ScreenBody className="flex flex-col gap-4"> <ScreenBody className="flex flex-col gap-4">
{calendar.loading ? ( {calendar.loading ? (
<LoadingState /> <LoadingState />
@@ -50,7 +50,7 @@ export function StreakScreen() {
<> <>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{[ {[
{ label: 'زنجیره‌ی فعلی', value: calendar.data.currentStreak }, { label: 'زنجیره فعلی', value: calendar.data.currentStreak },
{ label: 'بیشترین زنجیره', value: calendar.data.longestStreak }, { label: 'بیشترین زنجیره', value: calendar.data.longestStreak },
].map((s, i) => ( ].map((s, i) => (
<div key={s.label} className="flex flex-col items-center rounded-3xl border border-line bg-white py-4"> <div key={s.label} className="flex flex-col items-center rounded-3xl border border-line bg-white py-4">
@@ -106,18 +106,18 @@ function StreakGoal({ current, doneToday, lost }: { current: number; doneToday:
// زنجیره‌ی صفر: «۳ روز دیگه می‌شه زنجیره‌ی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته می‌شود. // زنجیره‌ی صفر: «۳ روز دیگه می‌شه زنجیره‌ی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته می‌شود.
// زنجیره‌ی روشن و امروزِ خالی: یادآوریِ این‌که امروز چیزی مانده تا زنجیره نسوزد. // زنجیره‌ی روشن و امروزِ خالی: یادآوریِ این‌که امروز چیزی مانده تا زنجیره نسوزد.
const today = current === 0 && lost > 0 const today = current === 0 && lost > 0
? `زنجیره‌ی ${toFa(lost)} روزه‌ت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.` ? `زنجیره ${toFa(lost)} روزه‌ت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.`
: current === 0 : current === 0
? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیره‌ت روشن بشه.' ? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیره‌ت روشن بشه.'
: !doneToday : !doneToday
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیره‌ی ${toFa(current)} روزه‌ت نسوزه.` ? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیره ${toFa(current)} روزه‌ت نسوزه.`
: null; : null;
return ( return (
<div className="rounded-3xl border border-line bg-[#FFF3E8] px-4 py-3"> <div className="rounded-3xl border border-line bg-[#FFF3E8] px-4 py-3">
{today && <p className="mb-1 text-xs font-bold leading-6 text-[#B4501C]">{today}</p>} {today && <p className="mb-1 text-xs font-bold leading-6 text-[#B4501C]">{today}</p>}
<p className="text-sm font-black leading-7 text-ink"> <p className="text-sm font-black leading-7 text-ink">
{reached ? <><Flame size={16} fill="#FF7A3D" className="mb-0.5 ml-1 inline text-orange" />{toFa(current)} روزه‌ای! </> : null} {reached ? <><Flame size={16} fill="#FF7A3D" className="mb-0.5 ml-1 inline text-orange" />{toFa(current)} روزه‌ای! </> : null}
{next ? `${toFa(next - current)} روز دیگه می‌شه زنجیره‌ی ${toFa(next)} روزه.` : 'از همه‌ی نقطه‌عطف‌ها رد شدی، افسانه‌ای!'} {next ? `${toFa(next - current)} روز دیگه می‌شه زنجیره ${toFa(next)} روزه.` : 'از همه نقطه‌عطف‌ها رد شدی، افسانه‌ای!'}
</p> </p>
{next && ( {next && (
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white"> <div className="mt-2 h-2 overflow-hidden rounded-full bg-white">

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Check, Crown, CreditCard, ShieldCheck, ShoppingBag, Ticket } from 'lucide-react'; import { Check, ChevronDown, Crown, Lock, Ticket } from 'lucide-react';
import { api, type Plan, type SubscriptionStatus } from '@/api'; import { api, type Plan, type SubscriptionStatus } from '@/api';
import { daysLeft } from '@/lib/subscription'; import { daysLeft } from '@/lib/subscription';
import { newIdempotencyKey } from '@/api/http'; import { newIdempotencyKey } from '@/api/http';
@@ -10,6 +10,9 @@ import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Scree
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { formatNumber, formatToman, toFa } from '@/lib/format'; import { formatNumber, formatToman, toFa } from '@/lib/format';
import { useCompanion } from '@/state/companion'; import { useCompanion } from '@/state/companion';
import { enabledPaymentMethods, type PaymentMethod } from '@/lib/paymentMethods';
import zarinpalLogo from '@/assets/pay/zarinpal.png';
import bazaarLogo from '@/assets/pay/bazaar.png';
function durationLabel(days: number): string { function durationLabel(days: number): string {
if (days === 30 || days === 31) return 'یک‌ماهه'; if (days === 30 || days === 31) return 'یک‌ماهه';
@@ -62,8 +65,8 @@ export function SubscriptionScreen() {
<Screen> <Screen>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<ScreenHeader <ScreenHeader
title={active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو'} title={active ? 'تمدید اشتراک' : 'اشتراک تدریسینو'}
subtitle="همه‌ی فصل‌ها، قلبِ نامحدود" subtitle="همه فصل‌ها، قلب نامحدود"
color="#ED6956" color="#ED6956"
exact exact
back back
@@ -79,7 +82,7 @@ export function SubscriptionScreen() {
) : !list.length ? ( ) : !list.length ? (
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" /> <EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
) : ( ) : (
<div role="radiogroup" aria-label="مدتِ اشتراک" className="flex flex-col gap-3"> <div role="radiogroup" aria-label="مدت اشتراک" className="flex flex-col gap-3">
{list.map((plan) => ( {list.map((plan) => (
<PlanRow key={plan.id} plan={plan} assistant={look.name} monthlyPrice={monthly?.price} selected={plan.id === selected?.id} onSelect={() => setPicked(plan.id)} /> <PlanRow key={plan.id} plan={plan} assistant={look.name} monthlyPrice={monthly?.price} selected={plan.id === selected?.id} onSelect={() => setPicked(plan.id)} />
))} ))}
@@ -91,7 +94,7 @@ export function SubscriptionScreen() {
{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"> <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="#FF7A3D" onClick={() => setBuying(selected)}> <PrimaryButton color="#FF7A3D" onClick={() => setBuying(selected)}>
{`خریدِ اشتراکِ ${durationLabel(selected.durationDays) || selected.title}`} {`خرید اشتراک ${durationLabel(selected.durationDays) || selected.title}`}
</PrimaryButton> </PrimaryButton>
</div> </div>
)} )}
@@ -178,12 +181,10 @@ function PlanRow({ plan, assistant, monthlyPrice, selected, onSelect }: { plan:
); );
} }
type Method = 'gateway' | 'bazaar';
/** چرا بازار برای این پلن و این‌جا ممکن نیست؛ null یعنی ممکن است. */ /** چرا بازار برای این پلن و این‌جا ممکن نیست؛ null یعنی ممکن است. */
function bazaarBlocker(plan: Plan | null): string | null { function bazaarBlocker(plan: Plan | null): string | null {
if (!plan?.bazaarSku) return 'این مورد در بازار نیست'; if (!plan?.bazaarSku) return 'این مورد در بازار نیست';
if (!inAndroidApp()) return 'فقط در اپِ اندروید'; if (!inAndroidApp()) return 'فقط در اپ اندروید';
if (!canBuyFromBazaar()) return 'اپ رو از بازار به‌روز کن'; if (!canBuyFromBazaar()) return 'اپ رو از بازار به‌روز کن';
return null; return null;
} }
@@ -193,24 +194,28 @@ function bazaarBlocker(plan: Plan | null): string | null {
* صفحه‌ی خریدِ بازار می‌رود. window.open بعد از await را Safariِ آیفون مسدود می‌کند؛ برای همین درگاه * صفحه‌ی خریدِ بازار می‌رود. window.open بعد از await را Safariِ آیفون مسدود می‌کند؛ برای همین درگاه
* جابه‌جاییِ همین صفحه است. * جابه‌جاییِ همین صفحه است.
* *
* دو روشِ پرداخت همیشه کنارِ هم دیده می‌شوند (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ اگر بازار این‌جا ممکن نیست، کاشیِ * روش‌های پرداخت به شکلِ آشنای برگه‌ی پرداختِ اپ‌ها می‌آیند: هر روش یک ردیف با نشانِ خودِ درگاه، نام و یک
* آن غیرفعال است و دلیلش را زیرِ نامش می‌گوید (نه پنهان، تا کاربر بداند راهِ دیگری هم هست). پیش‌فرض زرین‌پال * دایره‌ی انتخاب، و دکمه‌ی پایین مبلغ را می‌گوید (خواستِ مالک، ۲۰۲۶-۱۰-۱۴: «حسِ اطمینان»). کدام روش‌ها در مرورگر و
* است؛ داخلِ اپ، زیرِ «کافه‌بازار» قیمتی می‌آید که خودِ بازار برای آن SKU دارد. ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ * کدام داخلِ اپ دیده شوند از config.js می‌آید (src/lib/paymentMethods.ts)، تا نسخه‌ی کافه‌بازار فقط بازار را نشان دهد
* درون‌برنامه‌ای را محدود می‌کند؛ نگه داشتنِ زرین‌پال داخلِ اپ تصمیمِ مالک است و با بازار هماهنگ نشده. * و سایت فقط زرین‌پال را، بی build. روشی که فهرست شده ولی این‌جا ممکن نیست (APKِ قدیمی) غیرفعال با دلیل می‌آید.
* ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ درون‌برنامه‌ای را محدود می‌کند؛ زرین‌پال داخلِ اپ
* تصمیمِ مالک است و با بازار هماهنگ نشده.
*/ */
function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | null; storePrices: Record<string, string>; onClose: () => void; onPaid: () => void }) { function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | null; storePrices: Record<string, string>; onClose: () => void; onPaid: () => void }) {
const blocker = bazaarBlocker(plan); const blocker = bazaarBlocker(plan);
const [method, setMethod] = useState<Method>('gateway'); const methods = useMemo(enabledPaymentMethods, []);
const firstUsable = methods.find((m) => m !== 'bazaar' || !blocker) ?? methods[0];
const [method, setMethod] = useState<PaymentMethod>(firstUsable);
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [showCode, setShowCode] = useState(false); const [showCode, setShowCode] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null); const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
// هر بار که برگه برای پلنی باز می‌شود، زرین‌پال انتخاب است (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ بازار یک لمس آن‌طرف‌تر. // هر بار که برگه برای پلنی باز می‌شود، اولین روشِ ممکن انتخاب است (ترتیب از config.js؛ پیش‌فرض زرین‌پال اول).
useEffect(() => { useEffect(() => {
if (plan) setMethod('gateway'); if (plan) setMethod(firstUsable);
}, [plan]); }, [plan, firstUsable]);
const storePrice = plan?.bazaarSku ? storePrices[plan.bazaarSku] : undefined; const storePrice = plan?.bazaarSku ? storePrices[plan.bazaarSku] : undefined;
/** /**
@@ -269,8 +274,8 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
if (err instanceof BazaarPurchaseError && err.kind === 'unavailable') { if (err instanceof BazaarPurchaseError && err.kind === 'unavailable') {
// کارِ کاربر است، نه خطای خرید: پولی جابه‌جا نشده. // کارِ کاربر است، نه خطای خرید: پولی جابه‌جا نشده.
setError(err.message === 'old_bazaar' setError(err.message === 'old_bazaar'
? 'کافه‌بازارِ گوشیت قدیمیه؛ از خودِ بازار به‌روزش کن و دوباره بزن، یا با کارتِ بانکی پرداخت کن.' ? 'کافه‌بازار گوشیت قدیمیه؛ از خود بازار به‌روزش کن و دوباره بزن، یا با کارت بانکی پرداخت کن.'
: 'کافه‌بازار روی گوشیت پیدا نشد یا وصل نشد. مطمئن شو نصبه و وارد حسابت شدی، بعد دوباره بزن؛ یا با کارتِ بانکی پرداخت کن.'); : 'کافه‌بازار روی گوشیت پیدا نشد یا وصل نشد. مطمئن شو نصبه و وارد حسابت شدی، بعد دوباره بزن؛ یا با کارت بانکی پرداخت کن.');
} else { } else {
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.')); setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
} }
@@ -319,12 +324,12 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
}; };
const doneText = const doneText =
done === 'gateway' ? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همین‌جا؛ خریدت خودش فعال می‌شه.' done === 'gateway' ? 'صفحه پرداخت در مرورگر گوشی باز شد. بعد از پرداخت برگرد همین‌جا؛ خریدت خودش فعال می‌شه.'
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : done === 'code' ? 'کد تخفیفت کل مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
: 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش می‌کنه؛ لغوِ تمدید از بخشِ «اشتراک‌ها»ی خودِ بازاره.'; : 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخر هر دوره خودش تمدیدش می‌کنه؛ لغو تمدید از بخش «اشتراک‌ها»ی خود بازاره.';
return ( return (
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}` : ''}> <Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? 'پرداخت' : ''}>
{done ? ( {done ? (
<> <>
<p className="mb-5 text-sm leading-7 text-muted">{doneText}</p> <p className="mb-5 text-sm leading-7 text-muted">{doneText}</p>
@@ -332,20 +337,29 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
</> </>
) : plan && ( ) : plan && (
<> <>
<div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3.5"> <div className="mb-5 flex items-center gap-3 rounded-2xl bg-soft px-4 py-3.5">
<span className="text-sm font-bold text-body">مبلغ</span> <span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-[#FFF4D6] text-[#E0A200]"><Crown size={20} /></span>
<span className="text-lg font-black text-ink">{method === 'bazaar' && storePrice ? storePrice : price(plan.price)}</span> <span className="min-w-0 flex-1">
<span className="block text-sm font-black text-ink">اشتراک {durationLabel(plan.durationDays) || plan.title}</span>
<span className="block text-xs text-muted">همه فصل‌ها، قلب نامحدود</span>
</span>
<span className="text-left text-base font-black text-ink">{method === 'bazaar' && storePrice ? storePrice : price(plan.price)}</span>
</div> </div>
<div role="radiogroup" aria-label="روشِ پرداخت" className="mb-4"> <div role="radiogroup" aria-label="روش پرداخت" className="mb-4">
<span className="mb-2 block text-sm font-black text-ink">با چی پرداخت می‌کنی؟</span> <span className="mb-2 block text-sm font-black text-ink">روش پرداخت</span>
<div className="grid grid-cols-2 gap-2"> <div className="flex flex-col gap-2">
<MethodTile selected={method === 'gateway'} onSelect={() => setMethod('gateway')} icon={<CreditCard size={22} />} title="کارتِ بانکی" note="زرین‌پال" /> {methods.map((m) =>
<MethodTile selected={method === 'bazaar'} onSelect={() => setMethod('bazaar')} icon={<ShoppingBag size={22} />} title="کافه‌بازار" note={blocker ?? storePrice ?? 'با حسابِ بازارت'} disabled={!!blocker} /> m === 'zarinpal' ? (
<MethodRow key={m} selected={method === m} onSelect={() => setMethod(m)} logo={zarinpalLogo} title="زرین‌پال" note="کارت بانکی، همه بانک‌های عضو شتاب" />
) : (
<MethodRow key={m} selected={method === m} onSelect={() => setMethod(m)} logo={bazaarLogo} title="کافه‌بازار" note={blocker ?? (storePrice ? `${storePrice} · با حساب بازارت` : 'با حساب بازارت')} disabled={!!blocker} />
),
)}
</div> </div>
</div> </div>
{showCode ? ( {showCode ? (
<label className="mb-4 block"> <label className="mb-4 block">
<span className="mb-2 block text-sm font-bold text-body">کدِ تخفیف</span> <span className="mb-2 block text-sm font-bold text-body">کد تخفیف</span>
<input <input
value={code} value={code}
onChange={(e) => setCode(e.target.value.trim().slice(0, 40))} onChange={(e) => setCode(e.target.value.trim().slice(0, 40))}
@@ -356,19 +370,19 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
/> />
</label> </label>
) : ( ) : (
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex items-center gap-1.5 text-sm font-bold text-coral"> <button type="button" onClick={() => setShowCode(true)} className="mb-4 flex w-full items-center gap-2 rounded-2xl border border-dashed border-line px-4 py-3 text-sm font-bold text-body active:bg-soft">
<Ticket size={15} /> کدِ تخفیف دارم <Ticket size={16} className="text-coral" /> <span className="flex-1 text-right">کد تخفیف دارم</span> <ChevronDown size={16} className="text-faint" />
</button> </button>
)} )}
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>} {error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
<PrimaryButton loading={busy} onClick={() => void pay()}> <PrimaryButton loading={busy} disabled={method === 'bazaar' && !!blocker} onClick={() => void pay()}>
{method === 'bazaar' ? 'پرداخت با کافه‌بازار' : 'پرداخت با کارتِ بانکی'} {`پرداخت ${method === 'bazaar' && storePrice ? storePrice : price(plan.price)}`}
</PrimaryButton> </PrimaryButton>
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted"> <p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
<ShieldCheck size={14} /> <Lock size={13} />
{method === 'bazaar' {method === 'bazaar'
? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید می‌شه.' ? 'پرداخت امن در خود کافه‌بازار؛ آخر هر دوره خودکار تمدید می‌شه.'
: 'پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.'} : 'پرداخت امن در درگاه زرین‌پال'}
</p> </p>
</> </>
)} )}
@@ -376,7 +390,8 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
); );
} }
function MethodTile({ selected, onSelect, icon, title, note, disabled = false }: { selected: boolean; onSelect: () => void; icon: ReactNode; title: string; note: string; disabled?: boolean }) { /** یک روشِ پرداخت به شکلِ ردیفِ آشنای برگه‌های پرداخت: نشانِ درگاه، نام و توضیح، و دایره‌ی انتخاب. */
function MethodRow({ selected, onSelect, logo, title, note, disabled = false }: { selected: boolean; onSelect: () => void; logo: string; title: string; note: string; disabled?: boolean }) {
const on = selected && !disabled; const on = selected && !disabled;
return ( return (
<button <button
@@ -386,18 +401,23 @@ function MethodTile({ selected, onSelect, icon, title, note, disabled = false }:
aria-disabled={disabled} aria-disabled={disabled}
disabled={disabled} disabled={disabled}
onClick={onSelect} onClick={onSelect}
className={`relative flex flex-col items-center gap-1 rounded-2xl border-2 border-b-4 px-2 py-3 text-center transition-colors ${ className={`flex w-full items-center gap-3 rounded-2xl border-2 px-3 py-3 text-right transition-colors ${
disabled ? 'cursor-not-allowed border-line bg-soft opacity-70' : on ? 'border-coral bg-[#fff4f2]' : 'border-line bg-white active:translate-y-0.5' disabled ? 'cursor-not-allowed border-line bg-soft opacity-60' : on ? 'border-coral bg-[#fff6f4]' : 'border-line bg-white active:bg-soft'
}`} }`}
> >
{on && ( <span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl border border-line bg-white">
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-coral text-white"> <img src={logo} alt="" width={30} height={30} className={disabled ? 'grayscale' : ''} />
<Check size={12} strokeWidth={4} /> </span>
</span> <span className="min-w-0 flex-1">
)} <span className="block text-[15px] font-black text-ink">{title}</span>
<span className={on ? 'text-coral' : 'text-muted'}>{icon}</span> <span className="block truncate text-xs text-muted">{note}</span>
<span className="text-[15px] font-black text-ink">{title}</span> </span>
<span className="text-xs leading-5 text-muted">{note}</span> <span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2"
style={{ borderColor: on ? '#ED6956' : '#d6d3ea', backgroundColor: on ? '#ED6956' : '#fff' }}
>
{on && <Check size={14} strokeWidth={3.5} className="text-white" />}
</span>
</button> </button>
); );
} }

View File

@@ -12,12 +12,12 @@ const CATEGORIES = [
{ id: 'learning', label: 'درس و سوال‌ها' }, { id: 'learning', label: 'درس و سوال‌ها' },
{ id: 'payment', label: 'پرداخت و اشتراک' }, { id: 'payment', label: 'پرداخت و اشتراک' },
{ id: 'account', label: 'حساب و ورود' }, { id: 'account', label: 'حساب و ورود' },
{ id: 'other', label: 'چیزِ دیگه' }, { id: 'other', label: 'چیز دیگه' },
]; ];
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
open: 'باز', open: 'باز',
in_progress: 'در حالِ بررسی', in_progress: 'در حال بررسی',
answered: 'پاسخ داده شد', answered: 'پاسخ داده شد',
closed: 'بسته', closed: 'بسته',
}; };
@@ -61,7 +61,7 @@ export function SupportScreen() {
<ScreenHeader title="پشتیبانی" subtitle="سوال، مشکل یا پیشنهاد داری؟ بنویس." color="#4C5FD5" back mascot={{ mood: 'happy', size: 56 }} /> <ScreenHeader title="پشتیبانی" subtitle="سوال، مشکل یا پیشنهاد داری؟ بنویس." color="#4C5FD5" back mascot={{ mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4"> <ScreenBody className="flex flex-col gap-4">
<div className="flex gap-1 rounded-2xl bg-soft p-1"> <div className="flex gap-1 rounded-2xl bg-soft p-1">
{([['ticket', 'پیام به پشتیبانی', MessageCircle], ['bug', 'گزارشِ خطای اپ', Bug]] as const).map(([id, label, Icon]) => ( {([['ticket', 'پیام به پشتیبانی', MessageCircle], ['bug', 'گزارش خطای اپ', Bug]] as const).map(([id, label, Icon]) => (
<button <button
key={id} key={id}
type="button" type="button"
@@ -108,7 +108,7 @@ export function SupportScreen() {
{!!tickets.data?.length && ( {!!tickets.data?.length && (
<section> <section>
<h2 className="mb-2 mt-2 text-sm font-black text-ink">پیام‌های قبلیِ تو</h2> <h2 className="mb-2 mt-2 text-sm font-black text-ink">پیام‌های قبلی تو</h2>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{tickets.data.map((t) => <TicketCard key={t.ticketId} summary={t} />)} {tickets.data.map((t) => <TicketCard key={t.ticketId} summary={t} />)}
</div> </div>

View File

@@ -43,7 +43,7 @@ export function UnitScreen({ unitId }: { unitId: string }) {
> >
<div className="mt-4"> <div className="mt-4">
<div className="mb-1.5 flex justify-between text-[10px] text-white/75"> <div className="mb-1.5 flex justify-between text-[10px] text-white/75">
<span>پیشرفتِ تو</span> <span>پیشرفت تو</span>
<span>{toFa(progress.percent)}٪</span> <span>{toFa(progress.percent)}٪</span>
</div> </div>
<ProgressBar percent={progress.percent} color="#fff" track="rgba(0,0,0,.1)" height={10} /> <ProgressBar percent={progress.percent} color="#fff" track="rgba(0,0,0,.1)" height={10} />

View File

@@ -30,15 +30,15 @@ export const COMPANIONS: Record<SeyarChar, CompanionLook> = {
}, },
delta: { delta: {
char: 'delta', name: SEYAR_INFO.delta.name, symbol: 'Δ', color: '#FF7A3D', light: '#FFB25E', pitch: 2, char: 'delta', name: SEYAR_INFO.delta.name, symbol: 'Δ', color: '#FF7A3D', light: '#FFB25E', pitch: 2,
trait: { title: 'شعله‌ی سرعت', text: 'جوابای درستِ پشتِ هم رو آتیشی می‌کنه و تشویقت می‌کنه.' }, trait: { title: 'شعله سرعت', text: 'جوابای درست پشت هم رو آتیشی می‌کنه و تشویقت می‌کنه.' },
}, },
pi: { pi: {
char: 'pi', name: SEYAR_INFO.pi.name, symbol: 'π', color: '#9B7BFF', light: '#C6B3FF', pitch: 4, char: 'pi', name: SEYAR_INFO.pi.name, symbol: 'π', color: '#9B7BFF', light: '#C6B3FF', pitch: 4,
trait: { title: 'راهنمای مهربون', text: 'وقتی غلط می‌زنی یه نکته‌ی کوچیک می‌گه که دفعه‌ی بعد گیر نکنی.' }, trait: { title: 'راهنمای مهربون', text: 'وقتی غلط می‌زنی یه نکته کوچیک می‌گه که دفعه بعد گیر نکنی.' },
}, },
tedi: { tedi: {
char: 'tedi', name: SEYAR_INFO.tedi.name, symbol: 'ω', color: '#E8A400', light: '#FFC63D', pitch: 7, char: 'tedi', name: SEYAR_INFO.tedi.name, symbol: 'ω', color: '#E8A400', light: '#FFC63D', pitch: 7,
trait: { title: 'جشن‌ساز', text: 'آخرِ هر جزیره، هر نمره‌ای بگیری، برات جشنِ طلایی می‌گیره.' }, trait: { title: 'جشن‌ساز', text: 'آخر هر جزیره، هر نمره‌ای بگیری، برات جشن طلایی می‌گیره.' },
}, },
}; };

View File

@@ -131,3 +131,9 @@
.ice-glint { animation: ice-glint 2.8s ease-in-out infinite; } .ice-glint { animation: ice-glint 2.8s ease-in-out infinite; }
@keyframes spark-rise { 0% { transform: translateY(0) scale(1); opacity: 0; } 15% { opacity: 1; } 100% { transform: translateY(-180px) scale(.3); opacity: 0; } } @keyframes spark-rise { 0% { transform: translateY(0) scale(1); opacity: 0; } 15% { opacity: 1; } 100% { transform: translateY(-180px) scale(.3); opacity: 0; } }
.spark-rise { animation: spark-rise 1.8s ease-out infinite; } .spark-rise { animation: spark-rise 1.8s ease-out infinite; }
/* همراه‌های کنارِ مسیرِ جزیره‌ها (IslandPath): فقط transform، تا روی گوشیِ ضعیف هم روی GPU بماند. */
@keyframes buddy-hop { 0%, 55%, 100% { transform: translateY(0) scale(1, 1); } 20% { transform: translateY(-12px) scale(.96, 1.05); } 40% { transform: translateY(0) scale(1.06, .94); } }
@keyframes buddy-sway { 0%, 100% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } }
.buddy-hop { animation: buddy-hop 1.6s ease-in-out infinite; transform-origin: 50% 100%; }
.buddy-sway { animation: buddy-sway 3.2s ease-in-out infinite; transform-origin: 50% 100%; }

6
src/vite-env.d.ts vendored
View File

@@ -5,6 +5,10 @@ interface ImportMetaEnv {
} }
interface Window { interface Window {
__TADRISINO_CONFIG__?: { apiBaseUrl?: string }; __TADRISINO_CONFIG__?: {
apiBaseUrl?: string;
/** روش‌های پرداختِ فعال در مرورگر (web) و داخلِ اپِ اندروید (app)؛ src/lib/paymentMethods.ts. */
paymentMethods?: { web?: string[]; app?: string[] };
};
webkitAudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext;
} }