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:
12
README.md
12
README.md
@@ -45,6 +45,18 @@ VITE_DEV_PROXY_TARGET=https://mathapi.tadrisino2.ir
|
||||
- **سمتِ بکاند** هم دامنهی تازهی فرانت باید مجاز باشد (`FRONTEND_URL` در `.env`
|
||||
بکاند). جزئیات در `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/`ش سرو میکند):
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#ED6956" />
|
||||
<meta name="description" content="تدریسینو — یادگیریِ ریاضیِ پایهی سوم تا دوازدهم، قدمبهقدم و با دستیارِ هوشمند." />
|
||||
<meta name="description" content="تدریسینو — یادگیری ریاضی پایه سوم تا دوازدهم، قدمبهقدم و با دستیار هوشمند." />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>تدریسینو</title>
|
||||
<!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار میشود. -->
|
||||
@@ -24,9 +24,9 @@
|
||||
.boot-splash { position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 20px; background: #fff; }
|
||||
.boot-splash img { width: 144px; height: 144px; display: block; }
|
||||
/* داخلِ اپِ اندروید اسپلشِ بومی روی صفحه است و سایت زیرش بار میشود؛ اسپلشِ وب نباید یک بار دیگر
|
||||
«عوض شدن» نشان دهد. APKِ تازه (appReady دارد): اسپلشِ وب نامرئی است. APKِ قدیمی: فقط آیکون،
|
||||
بینام، تا کمتر از اسپلشِ سیستم فرق کند. */
|
||||
/* داخل اپ اندروید اسپلش بومی روی صفحه است و سایت زیرش بار میشود؛ اسپلش وب نباید یک بار دیگر
|
||||
«عوض شدن» نشان دهد. APK تازه (appReady دارد): اسپلش وب نامرئی است. APK قدیمی: فقط آیکون،
|
||||
بینام، تا کمتر از اسپلش سیستم فرق کند. */
|
||||
.in-app .boot-splash > * { visibility: hidden; }
|
||||
.in-old-app .boot-splash span { display: none; }
|
||||
.in-old-app .boot-splash img { width: 160px; height: 160px; }
|
||||
|
||||
2
pay.html
2
pay.html
@@ -20,7 +20,7 @@
|
||||
<body>
|
||||
<main>
|
||||
<div class="spin" id="spin"></div>
|
||||
<p id="msg">داریم میریم به درگاهِ پرداخت…</p>
|
||||
<p id="msg">داریم میریم به درگاه پرداخت…</p>
|
||||
<a id="go" href="#">رفتن به درگاه</a>
|
||||
</main>
|
||||
<script type="module" src="/src/pay.ts"></script>
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
// برای جابهجا کردنِ بکاند بدونِ build دوباره، آدرسِ کامل را اینجا بنویسید:
|
||||
// apiBaseUrl: "https://api.new-domain.ir/api",
|
||||
// اگر CDN (آروان) جلوی سایت است، بعد از ویرایش کشِ این فایل را پاک کنید.
|
||||
//
|
||||
// paymentMethods: روشهای پرداختی که برگهی پرداخت نشان میدهد، به همین ترتیب؛ «zarinpal» و «bazaar».
|
||||
// web = سایت در مرورگر، app = داخلِ اپِ اندروید. نسخهی کافهبازار: app: ["bazaar"].
|
||||
// نبودنِ این کلید یعنی پیشفرضِ src/lib/paymentMethods.ts.
|
||||
window.__TADRISINO_CONFIG__ = {
|
||||
apiBaseUrl: "",
|
||||
};
|
||||
|
||||
@@ -254,7 +254,7 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
} catch (err) {
|
||||
if (opts.signal?.aborted) throw err;
|
||||
const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
||||
throw new ApiError(0, timedOut ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد', {}, timedOut);
|
||||
throw new ApiError(0, timedOut ? 'زمان درخواست تمام شد' : 'ارتباط با سرور برقرار نشد', {}, timedOut);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -332,14 +332,14 @@ function PaymentResult({ payment }: { payment: PaymentReturn }) {
|
||||
<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" />}
|
||||
<h2 className="text-lg font-black text-ink">
|
||||
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیتِ پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
|
||||
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیت پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
|
||||
</h2>
|
||||
<p className="text-sm leading-7 text-muted">
|
||||
{paid
|
||||
? 'اشتراکت فعال شد؛ از الان قلبهات نامحدوده.'
|
||||
: state === 'unknown'
|
||||
? 'چند دقیقهی دیگه از «پرداختها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال میشه یا پول خودکار برمیگرده.'
|
||||
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمیگرده یا اشتراکت فعال میشه.'}
|
||||
? 'چند دقیقه دیگه از «پرداختها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال میشه یا پول خودکار برمیگرده.'
|
||||
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طی چند ساعت خودکار برمیگرده یا اشتراکت فعال میشه.'}
|
||||
</p>
|
||||
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
|
||||
</div>
|
||||
|
||||
BIN
src/assets/pay/bazaar.png
Normal file
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
BIN
src/assets/pay/zarinpal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -102,15 +102,15 @@ export function BirthDatePicker({
|
||||
|
||||
return (
|
||||
<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>
|
||||
{Array.from({ length: days }, (_, i) => i + 1).map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
|
||||
</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>
|
||||
{JALALI_MONTHS.map((name, i) => <option key={name} value={i + 1}>{name}</option>)}
|
||||
</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>
|
||||
{years.map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -6,8 +6,8 @@ export function BrandFooter() {
|
||||
return (
|
||||
<footer className="flex flex-col items-center gap-1 pb-4 pt-3 text-center">
|
||||
<BrandMark size={24} tone="dark" />
|
||||
<span className="text-[10px] text-muted">ریاضی، قدمبهقدم، از روی کتابِ درسی</span>
|
||||
<span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو · همهی حقوق محفوظ است</span>
|
||||
<span className="text-[10px] text-muted">ریاضی، قدمبهقدم، از روی کتاب درسی</span>
|
||||
<span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو · همه حقوق محفوظ است</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,18 +10,18 @@ type Kind = 'question' | 'lesson';
|
||||
|
||||
const REASONS: Record<Kind, { id: string; label: string }[]> = {
|
||||
question: [
|
||||
{ id: 'wrong_answer', label: 'جوابِ درست اشتباهه' },
|
||||
{ id: 'unclear', label: 'صورتِ سوال گنگه' },
|
||||
{ id: 'typo', label: 'غلطِ تایپی داره' },
|
||||
{ id: 'wrong_answer', label: 'جواب درست اشتباهه' },
|
||||
{ id: 'unclear', label: 'صورت سوال گنگه' },
|
||||
{ id: 'typo', label: 'غلط تایپی داره' },
|
||||
{ id: 'too_hard', label: 'برای این درس خیلی سخته' },
|
||||
{ id: 'other', label: 'یه چیزِ دیگه' },
|
||||
{ id: 'other', label: 'یه چیز دیگه' },
|
||||
],
|
||||
lesson: [
|
||||
{ id: 'wrong_content', label: 'مطلبِ درس اشتباهه' },
|
||||
{ id: 'wrong_content', label: 'مطلب درس اشتباهه' },
|
||||
{ id: 'unclear', label: 'توضیح گنگه' },
|
||||
{ id: 'typo', label: 'غلطِ تایپی داره' },
|
||||
{ id: 'typo', 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} /> خوب نبود
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(true)} className={`${base} ${idle}`}>
|
||||
<Flag size={16} strokeWidth={2.4} /> گزارشِ ایراد
|
||||
<Flag size={16} strokeWidth={2.4} /> گزارش ایراد
|
||||
</button>
|
||||
</div>
|
||||
<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
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value.slice(0, 500))}
|
||||
placeholder="توضیحِ بیشتر (اختیاری)"
|
||||
placeholder="توضیح بیشتر (اختیاری)"
|
||||
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"
|
||||
/>
|
||||
<PrimaryButton disabled={!reason} loading={sending} onClick={() => void send()}>ارسالِ گزارش</PrimaryButton>
|
||||
<PrimaryButton disabled={!reason} loading={sending} onClick={() => void send()}>ارسال گزارش</PrimaryButton>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { forwardRef, memo, useMemo } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Check, Lock, Play, Star } from 'lucide-react';
|
||||
import type { HomeNode } from '@/api';
|
||||
@@ -7,6 +7,8 @@ import { iconFor } from '@/components/NodeIcon';
|
||||
import { shade } from '@/lib/color';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
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 SPACING = 128;
|
||||
@@ -26,13 +28,18 @@ export const IslandPath = forwardRef<HTMLDivElement, {
|
||||
onOpen: (node: HomeNode) => void;
|
||||
onStart: (node: HomeNode) => void;
|
||||
}>(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 height = points.length ? points[points.length - 1].y + 80 : 120;
|
||||
const { char } = useCompanion();
|
||||
const buddies = useMemo(() => buddySpots(nodes, points, currentId), [nodes, points, currentId]);
|
||||
|
||||
return (
|
||||
<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)} />
|
||||
{buddies.map((b) => (
|
||||
<PathBuddy key={b.segment} char={char} {...b} />
|
||||
))}
|
||||
{nodes.map((node, i) => {
|
||||
const locked = node.status === 'locked';
|
||||
const done = node.status === 'completed';
|
||||
@@ -108,3 +115,75 @@ export const IslandPath = forwardRef<HTMLDivElement, {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<Lock size={24} className="text-muted" />
|
||||
</span>
|
||||
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیرهی قبلی رو تموم کن تا باز بشه.</p>
|
||||
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره قبلی رو تموم کن تا باز بشه.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-sm leading-7 text-body">
|
||||
{done
|
||||
? 'این جزیره رو تموم کردی! میتونی دوباره تمرینش کنی (امتیازِ تازه نداره).'
|
||||
: 'اگه مطمئن نیستی، اول درسها رو بخون؛ بعد آزمونِ کوتاهِ جزیره رو بده. هر جوابی بدی، آخرش جزیرهی بعدی باز میشه.'}
|
||||
? 'این جزیره رو تموم کردی! میتونی دوباره تمرینش کنی (امتیاز تازه نداره).'
|
||||
: 'اگه مطمئن نیستی، اول درسها رو بخون؛ بعد آزمون کوتاه جزیره رو بده. هر جوابی بدی، آخرش جزیره بعدی باز میشه.'}
|
||||
</p>
|
||||
|
||||
<PrimaryButton color={color} onClick={start}>
|
||||
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروعِ آزمونِ جزیره</>}
|
||||
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروع آزمون جزیره</>}
|
||||
</PrimaryButton>
|
||||
<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' }}
|
||||
aria-expanded={showLessons}
|
||||
>
|
||||
<BookOpen size={18} /> {done ? 'مرورِ درسها' : 'اول درسها رو بخون'}
|
||||
<BookOpen size={18} /> {done ? 'مرور درسها' : 'اول درسها رو بخون'}
|
||||
<ChevronDown size={16} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from '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 { useAppData } from '@/state/AppData';
|
||||
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 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);
|
||||
return `ساعتِ ${time} ${day}`;
|
||||
return `ساعت ${time} ${day}`;
|
||||
}
|
||||
|
||||
export function NoHeartsScreen({
|
||||
@@ -113,7 +113,7 @@ export function NoHeartsScreen({
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-ink">{back ? 'یه قلب برگشت!' : 'قلبهات تموم شد'}</h1>
|
||||
<p className="mt-1.5 text-sm leading-7 text-muted">
|
||||
{back ? 'بریم ادامه بدیم؛ این بار با دقتِ بیشتر' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'}
|
||||
{back ? 'بریم ادامه بدیم؛ این بار با دقت بیشتر' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function NoHeartsScreen({
|
||||
</div>
|
||||
) : (
|
||||
<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 ? (
|
||||
<>
|
||||
<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>
|
||||
{full && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
@@ -160,10 +160,10 @@ export function NoHeartsScreen({
|
||||
<Heart size={18} fill="#fff" /> <InfinityIcon size={18} /> نمیخوای منتظر بمونی؟
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-6 text-white/90">
|
||||
هر بستهی سوال چند روز قلبِ نامحدود هدیه داره؛ هر چقدر بخوای تمرین کن.
|
||||
با اشتراک، قلبهات نامحدود میشه؛ هر چقدر بخوای تمرین کن.
|
||||
</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">
|
||||
<ShoppingBag size={15} /> دیدنِ بستهها
|
||||
<Crown size={15} /> دیدن اشتراکها
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -194,7 +194,7 @@ export function NoHeartsScreen({
|
||||
/** پنج جای قلب؛ اولی همراهِ زمان از پایین پر میشود. */
|
||||
function HeartSlots({ progress }: { progress: number }) {
|
||||
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) => (
|
||||
<span key={i} className="relative h-8 w-8">
|
||||
<Heart size={32} className="absolute inset-0 text-[#F2DCD8]" fill="#FBEDEA" />
|
||||
|
||||
@@ -33,7 +33,7 @@ export function Sheet({
|
||||
{open && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col justify-end">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-[#14142a]/45"
|
||||
className="absolute inset-0 bg-[#14142a]/55"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
|
||||
@@ -68,7 +68,7 @@ export function StreakCelebration({ from, to, onDone }: { from: number; to: numb
|
||||
|
||||
const today = (new Date().getDay() + 1) % 7; // شنبه = ۰
|
||||
const message = to === 1
|
||||
? 'اولین روزِ زنجیرهت! فردا هم بیا تا آتیشش بزرگتر بشه.'
|
||||
? 'اولین روز زنجیرهت! فردا هم بیا تا آتیشش بزرگتر بشه.'
|
||||
: `${toFa(to)} روزه که هر روز ریاضی تمرین میکنی. فردا هم بیا تا خاموش نشه!`;
|
||||
|
||||
return (
|
||||
@@ -97,7 +97,7 @@ export function StreakCelebration({ from, to, onDone }: { from: number; to: numb
|
||||
>
|
||||
{toFa(shown)}
|
||||
</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>
|
||||
</div>
|
||||
<div className="flex gap-2" aria-label="روزهای این هفته">
|
||||
|
||||
@@ -122,7 +122,7 @@ export function Spinner({ size = 20, className = '' }: { size?: number; classNam
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label="در حالِ بارگذاری"
|
||||
aria-label="در حال بارگذاری"
|
||||
className={`inline-block animate-spin rounded-full border-2 border-current border-t-transparent ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ const TEXTS = [
|
||||
'✅ جواب: رأس سهمی در (1, -1) است.',
|
||||
'۴) ۲ - ۳ = -۱.',
|
||||
'مجموع زاویههای داخلی = (5 - 2) × 180 = 3 × 180 = 540 درجه',
|
||||
'بازهی [-2, 3) یعنی از −۲ تا ۳، خودِ ۳ نه.',
|
||||
'بازه [-2, 3) یعنی از −۲ تا ۳، خود ۳ نه.',
|
||||
'sin 30° = 0.5 و cos 30° ≈ 0.87 است.',
|
||||
'در کتاب ۲/۵ یعنی دو و نیم (اختیاری).',
|
||||
];
|
||||
|
||||
@@ -3,14 +3,14 @@ import type { DiagramSpec } from '@/diagram/types';
|
||||
/** نمونههای دستنویس از هر نوع شکل، از روی مسئلههای واقعیِ کتابها. فقط برای گالری. */
|
||||
export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
{
|
||||
title: 'یازدهم — سهمی با ریشه، رأس، محورِ تقارن و عرض از مبدأ',
|
||||
title: 'یازدهم — سهمی با ریشه، رأس، محور تقارن و عرض از مبدأ',
|
||||
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'] },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دهم — دایرهی مثلثاتی، سینوس و کسینوسِ ۳۰°',
|
||||
spec: { kind: 'plane', title: 'زاویهی ۳۰ درجه', axes: true, points: { O: [0, 0], A: [1, 0], P: { polar: [1, 30] }, H: { foot: ['P', 'O', 'A'] } }, items: [
|
||||
title: 'دهم — دایره مثلثاتی، سینوس و کسینوس ۳۰°',
|
||||
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: 'segment', from: 'O', to: 'P' },
|
||||
{ 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: [
|
||||
{ type: 'polygon', points: ['A', 'B', '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: [
|
||||
{ type: 'line', through: ['_a1', '_a2'], label: 'a' },
|
||||
{ type: 'line', through: ['_b1', '_b2'], label: 'b' },
|
||||
@@ -40,8 +40,8 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'هشتم — زاویهی محاطی و مرکزیِ روبهروی یک کمان',
|
||||
spec: { kind: 'plane', title: 'زاویهی محاطی نصفِ مرکزی است', axes: false, points: { O: [0, 0], A: { polar: [2, 200] }, B: { polar: [2, -20] }, C: { polar: [2, 100] } }, items: [
|
||||
title: 'هشتم — زاویه محاطی و مرکزی روبهروی یک کمان',
|
||||
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: 'arc', center: 'O', r: 2, start: 200, end: 340, label: 'AB' },
|
||||
{ type: 'segment', from: 'C', to: 'A' }, { type: 'segment', from: 'C', to: 'B' },
|
||||
@@ -51,8 +51,8 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
] },
|
||||
},
|
||||
{
|
||||
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: [
|
||||
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: [
|
||||
{ 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: '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 در جهتِ عقربهها، روی شبکه',
|
||||
spec: { kind: 'plane', title: 'دورانِ ۹۰°', axes: false, grid: true, points: { A: [1, 1], B: [4, 1], C: [1, 3] }, items: [
|
||||
title: 'هفتم — دوران ۹۰° مثلث حول C در جهت عقربهها، روی شبکه',
|
||||
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: '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: [
|
||||
{ type: 'vector', from: 'A', to: 'B', label: 'a' },
|
||||
{ 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: [
|
||||
{ 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' },
|
||||
@@ -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: [
|
||||
{ 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 = ½' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'یازدهم — تابعِ چندضابطهای و حد (نقطهی توخالی)',
|
||||
spec: { kind: 'plane', title: 'حدِ چپ و راست در x = 1', axes: true, points: {}, items: [
|
||||
title: 'یازدهم — تابع چندضابطهای و حد (نقطه توخالی)',
|
||||
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: 'vline', x: 1, label: 'x = 1' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دوازدهم — خطِ مماس بر نمودار',
|
||||
title: 'دوازدهم — خط مماس بر نمودار',
|
||||
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: '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',
|
||||
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: 'vline', x: 2, label: 'x = 2', color: 'red' },
|
||||
{ type: 'hline', y: 1, label: 'y = 1', color: 'red' },
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دوازدهم — اکسترممهای نسبیِ تابعِ درجهی سوم',
|
||||
title: 'دوازدهم — اکسترممهای نسبی تابع درجه سوم',
|
||||
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'] },
|
||||
] },
|
||||
@@ -126,20 +126,20 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
] },
|
||||
},
|
||||
{
|
||||
title: 'دوازدهم — مساحتِ زیرِ نمودار',
|
||||
spec: { kind: 'plane', title: 'ناحیهی بینِ y = √x و محورِ x', axes: true, points: {}, items: [
|
||||
title: 'دوازدهم — مساحت زیر نمودار',
|
||||
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: '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: [
|
||||
{ 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: '۳ واحد به چپ' }] },
|
||||
},
|
||||
{
|
||||
@@ -162,7 +162,7 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
|
||||
spec: { kind: 'grid', title: '۲۰ درصد', rows: 5, cols: 5, shaded: 5 },
|
||||
},
|
||||
{
|
||||
title: 'سوم — مدلِ ضرب: ۳ گروهِ ۴تایی',
|
||||
title: 'سوم — مدل ضرب: ۳ گروه ۴تایی',
|
||||
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 },
|
||||
},
|
||||
{
|
||||
title: 'سوم — نمودارِ ستونیِ آبمیوهها',
|
||||
spec: { kind: 'bar', title: 'آبمیوهی موردِ علاقه', labels: ['پرتقال', 'سیب', 'انگور', 'هلو'], values: [8, 5, 3, 4], unit: 'نفر' },
|
||||
title: 'سوم — نمودار ستونی آبمیوهها',
|
||||
spec: { kind: 'bar', title: 'آبمیوه مورد علاقه', labels: ['پرتقال', 'سیب', 'انگور', 'هلو'], values: [8, 5, 3, 4], unit: 'نفر' },
|
||||
},
|
||||
{
|
||||
title: 'هشتم — نمودارِ دایرهای',
|
||||
spec: { kind: 'pie', title: 'ورزشِ موردِ علاقه', labels: ['فوتبال', 'والیبال', 'شنا', 'بقیه'], values: [12, 6, 4, 2] },
|
||||
title: 'هشتم — نمودار دایرهای',
|
||||
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' },
|
||||
},
|
||||
{
|
||||
title: 'دهم — جدولِ تعیینِ علامت',
|
||||
title: 'دهم — جدول تعیین علامت',
|
||||
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'] },
|
||||
},
|
||||
{
|
||||
@@ -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'] },
|
||||
},
|
||||
{
|
||||
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: 'برد' },
|
||||
},
|
||||
{
|
||||
title: 'نهم — نمودارِ درختیِ دو بار پرتابِ سکه',
|
||||
title: 'نهم — نمودار درختی دو بار پرتاب سکه',
|
||||
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' }] },
|
||||
@@ -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: '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: 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: 'قاعدهی 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: 'cone', dims: { r: 4, h: 12 }, labels: { r: '4 cm', h: '12 cm' } } },
|
||||
{ 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: '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: 'لیوان استوانهای', 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: 'sphere', dims: { r: 1 }, labels: { r: 'r' } } },
|
||||
{ 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: '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: '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' },
|
||||
] } },
|
||||
{ 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°' },
|
||||
] } },
|
||||
{ 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: 'سوم — ضرب روی محور اعداد (تیکهای زیاد)', 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: [
|
||||
{ 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' } },
|
||||
] } },
|
||||
{ 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: '?' } },
|
||||
] } },
|
||||
{ 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' },
|
||||
] } },
|
||||
{ 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: '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: '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: 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: '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: 'چوبکبریت: 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] } },
|
||||
];
|
||||
|
||||
@@ -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; }
|
||||
// «xsin» یا «pix»: نامِ چسبیده را تکهتکه کن
|
||||
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;
|
||||
}
|
||||
if ('+-*/^()'.includes(s[i])) { out.push(s[i]); i++; continue; }
|
||||
throw new Error(`نویسهی ناشناخته: ${s[i]}`);
|
||||
throw new Error(`نویسه ناشناخته: ${s[i]}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export function parseExpr(src: string): (x: number) => number {
|
||||
const peek = () => t[p];
|
||||
const eat = (tok?: string) => {
|
||||
const v = t[p++];
|
||||
if (tok !== undefined && v !== tok) throw new Error(`انتظارِ ${tok}`);
|
||||
if (tok !== undefined && v !== tok) throw new Error(`انتظار ${tok}`);
|
||||
return v;
|
||||
};
|
||||
const startsAtom = (tok: string | undefined) => tok !== undefined && (/^[\d.]/.test(tok) || tok === 'x' || tok === 'pi' || tok === 'e' || tok === '(' || tok in FUNCS);
|
||||
|
||||
@@ -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} />))}
|
||||
<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' }}>
|
||||
{`${toFa(wholes)} واحدِ کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`}
|
||||
{`${toFa(wholes)} واحد کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`}
|
||||
</text>
|
||||
</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} />
|
||||
: <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>}
|
||||
</g>
|
||||
);
|
||||
@@ -281,7 +281,7 @@ export function Pattern({ spec }: { spec: PatternSpec }) {
|
||||
<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" />
|
||||
<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>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function NumberLine({ spec }: { spec: NumberLineSpec }) {
|
||||
};
|
||||
|
||||
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]]))]} />
|
||||
<line x1={PAD - 16} y1={base} x2={W - PAD + 16} y2={base} stroke={INK} strokeWidth={2}
|
||||
markerEnd={`url(#${arrowId(INK)})`} markerStart={`url(#${arrowId(INK)})`} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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ِ واقعیِ آیفون دیده نشده.
|
||||
@@ -34,7 +34,7 @@ const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
|
||||
const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const;
|
||||
|
||||
export function MathText({ text: raw }: { text: string }) {
|
||||
const text = plainMath(raw);
|
||||
const text = plainPersian(plainMath(raw));
|
||||
const out: React.ReactNode[] = [];
|
||||
const depth = bracketDepth(text);
|
||||
let last = 0, i = 0;
|
||||
|
||||
30
src/lib/paymentMethods.ts
Normal file
30
src/lib/paymentMethods.ts
Normal 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[];
|
||||
}
|
||||
@@ -43,7 +43,7 @@ const script = (body: string, table: Record<string, string>, marker: string) =>
|
||||
const mapped = tryMap(body, table);
|
||||
if (mapped) return mapped;
|
||||
const b = body.trim();
|
||||
if (marker === '^') return ` به توانِ (${b}) `;
|
||||
if (marker === '^') return ` به توان (${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(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(/\^\(((?:[^()]|\([^()]*\))*)\)/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(/(?<=[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α-ωΑ-Ω□])(?![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(/\^([+\-−])/g, (_m, a: string) => SUP[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\S]+?)\*\*/g, '$1').replace(/(?<![_\w])__(.+?)__(?![_\w])/g, '$1').replace(/`/g, '');
|
||||
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, 'ه');
|
||||
}
|
||||
|
||||
@@ -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 }> = {
|
||||
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
|
||||
delta: { name: "دلتا", symbol: "Δ", role: "همتیمیِ تمرینهای سریع" },
|
||||
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
|
||||
tedi: { name: "امگا", symbol: "ω", role: "رفیقِ همیشگیِ تدریسینو" },
|
||||
delta: { name: "دلتا", symbol: "Δ", role: "همتیمی تمرینهای سریع" },
|
||||
pi: { name: "پی", symbol: "π", role: "همراه دلگرمی" },
|
||||
tedi: { name: "امگا", symbol: "ω", role: "رفیق همیشگی تدریسینو" },
|
||||
};
|
||||
|
||||
/** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراهها نگاشت میکند. */
|
||||
|
||||
@@ -37,14 +37,14 @@ const ok =
|
||||
new RegExp(`^${api.pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}payment/orders/\\d+/pay$`).test(target.pathname);
|
||||
|
||||
if (!ok || !target) {
|
||||
fail('این لینکِ پرداخت معتبر نیست. به اپ برگرد و دوباره «پرداخت» رو بزن.');
|
||||
fail('این لینک پرداخت معتبر نیست. به اپ برگرد و دوباره «پرداخت» رو بزن.');
|
||||
} else {
|
||||
go.href = target.href;
|
||||
location.replace(target.href);
|
||||
// اگر مرورگر جابهجا نشد (مثلاً جلوی ریدایرکت را گرفت)، دکمهی دستی.
|
||||
setTimeout(() => {
|
||||
spin.style.display = 'none';
|
||||
msg.textContent = 'اگه صفحهی پرداخت باز نشد، این دکمه رو بزن:';
|
||||
msg.textContent = 'اگه صفحه پرداخت باز نشد، این دکمه رو بزن:';
|
||||
go.style.display = 'inline-block';
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { toFa } from '@/lib/format';
|
||||
import { useGateHold } from '@/lib/useAsync';
|
||||
import { iAm, useCompanion } from '@/state/companion';
|
||||
|
||||
const STARTERS = ['این فصل دربارهی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
|
||||
const STARTERS = ['این فصل درباره چیه؟', 'یه مثال ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
|
||||
|
||||
/** «از {همراه} بپرس»: دستیارِ ریاضی که از روی کتابِ پایهی کاربر جواب میدهد؛ چهره و نامش همراهِ انتخابیِ کاربر است. */
|
||||
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>
|
||||
<ScreenHeader
|
||||
title={`از ${companion.name} بپرس`}
|
||||
subtitle={chat.lessonId ? 'از روی کتابِ همین درس جواب میدم' : 'دستیارِ ریاضی، از روی کتابِ خودت'}
|
||||
subtitle={chat.lessonId ? 'از روی کتاب همین درس جواب میدم' : 'دستیار ریاضی، از روی کتاب خودت'}
|
||||
color={companion.color}
|
||||
back={lessonId !== undefined || prompt !== undefined}
|
||||
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">
|
||||
<Bubble role="assistant">
|
||||
سلام {profile.name || 'دوستِ من'}! {`من ${iAm(companion.name)}`} 🙂 هر سوالی از ریاضیِ کتابت داری بپرس؛ قدمبهقدم با هم حلش میکنیم.
|
||||
سلام {profile.name || 'دوست من'}! {`من ${iAm(companion.name)}`} 🙂 هر سوالی از ریاضی کتابت داری بپرس؛ قدمبهقدم با هم حلش میکنیم.
|
||||
</Bubble>
|
||||
|
||||
{empty && chat.historyLoaded && (
|
||||
@@ -252,7 +252,7 @@ const THINKING_STEPS: [number, string][] = [
|
||||
[0, '{name} داره تو کتابت میگرده'],
|
||||
[4, '{name} داره حلش میکنه'],
|
||||
[15, 'داره قدمها رو مرتب مینویسه'],
|
||||
[28, 'جوابِ کامل یه کم طول میکشه، الان میرسه'],
|
||||
[28, 'جواب کامل یه کم طول میکشه، الان میرسه'],
|
||||
];
|
||||
|
||||
function Thinking() {
|
||||
@@ -363,11 +363,11 @@ function QuotaPills({ free }: { free: number }) {
|
||||
return (
|
||||
<>
|
||||
<span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}>
|
||||
{free > 0 ? <>{toFa(free)} سوالِ امروز</> : 'سوالهای امروز تموم شد'}
|
||||
{free > 0 ? <>{toFa(free)} سوال امروز</> : 'سوالهای امروز تموم شد'}
|
||||
</span>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function CharactersScreen() {
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title="همراهت رو انتخاب کن"
|
||||
subtitle={<>امتیازِ تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیازِ بیشتر همراههای بیشتری باز میشن</>}
|
||||
subtitle={<>امتیاز تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیاز بیشتر همراههای بیشتری باز میشن</>}
|
||||
color="#9B7BFF"
|
||||
back
|
||||
/>
|
||||
@@ -94,7 +94,7 @@ export function CharactersScreen() {
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
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 { useAppData } from '@/state/AppData';
|
||||
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { IslandPath } from '@/components/IslandPath';
|
||||
import { IslandSheet } from '@/components/IslandSheet';
|
||||
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
|
||||
import { onWhite, shade } from '@/lib/color';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { BrandFooter } from '@/components/BrandFooter';
|
||||
import { BrandMark } from '@/components/BrandMark';
|
||||
import { StreakFlame, flameState } from '@/components/StreakFlame';
|
||||
@@ -22,7 +20,7 @@ import { SubjectBar } from '@/components/SubjectBar';
|
||||
* دستیار، فصلها، چالش) و بچه نمیدانست کدام را بزند؛ حالا یک کارِ واضح دارد.
|
||||
*/
|
||||
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 paywall = () => nav.push({ name: 'subscription' });
|
||||
@@ -39,37 +37,18 @@ export function HomeScreen() {
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [home]);
|
||||
|
||||
const name = profile.name || 'دوستِ من';
|
||||
const flame = flameState(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 = [
|
||||
{ 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: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<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">
|
||||
<BrandMark size={26} withName={false} />
|
||||
{gradeTitle && (
|
||||
@@ -101,12 +80,6 @@ export function HomeScreen() {
|
||||
</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>
|
||||
|
||||
<SubjectBar />
|
||||
@@ -132,7 +105,7 @@ export function HomeScreen() {
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] font-bold text-white/80">
|
||||
فصلِ {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
|
||||
فصل {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
|
||||
{unit.requiresSubscription && <span className="mr-1.5 inline-flex h-5 items-center gap-1 rounded-full bg-white/25 px-2 align-middle leading-none"><Crown size={11} /> با اشتراک</span>}
|
||||
{isCurrentUnit && <span className="mr-1.5 inline-flex h-5 items-center rounded-full bg-white/25 px-2 align-middle leading-none">الان اینجایی</span>}
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
<Gift size={28} className="text-mint" />
|
||||
<span className="text-xs text-muted">کدِ معرفیِ تو</span>
|
||||
<span className="text-xs text-muted">کد معرفی تو</span>
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
<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>
|
||||
|
||||
<PrimaryButton color="#5FBF8F" onClick={() => void share(me.data!.referralCode)}>
|
||||
<Share2 size={17} /> فرستادنِ لینکِ دعوت
|
||||
<Share2 size={17} /> فرستادن لینک دعوت
|
||||
</PrimaryButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -15,13 +15,13 @@ import { formatJalaliDate, formatToman } from '@/lib/format';
|
||||
*/
|
||||
const STATUS: Record<Invoice['status'], { label: string; className: string }> = {
|
||||
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' },
|
||||
canceled: { label: 'لغوشده', className: 'bg-soft text-muted' },
|
||||
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);
|
||||
|
||||
@@ -32,7 +32,7 @@ export function InvoicesScreen() {
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader title="پرداختها و فاکتورها" subtitle="همهی خریدهات، با شمارهی فاکتور و کدِ پیگیری" color="#4C5FD5" back />
|
||||
<ScreenHeader title="پرداختها و فاکتورها" subtitle="همه خریدهات، با شماره فاکتور و کد پیگیری" color="#4C5FD5" back />
|
||||
<ScreenBody className="flex flex-col gap-3">
|
||||
{list.loading ? (
|
||||
<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">
|
||||
<Receipt size={32} className="text-faint" />
|
||||
<p className="text-sm text-body">هنوز خریدی نکردی.</p>
|
||||
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ اشتراکها</PrimaryButton>
|
||||
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدن اشتراکها</PrimaryButton>
|
||||
</div>
|
||||
) : (
|
||||
list.data.map((invoice) => (
|
||||
@@ -88,17 +88,17 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
|
||||
|
||||
const rows: [string, React.ReactNode][] = invoice
|
||||
? [
|
||||
['شمارهی فاکتور', <span dir="ltr">{invoice.invoiceNumber}</span>],
|
||||
['شماره فاکتور', <span dir="ltr">{invoice.invoiceNumber}</span>],
|
||||
['تاریخ', dateOf(invoice)],
|
||||
['وضعیت', STATUS[invoice.status].label],
|
||||
['اشتراک', invoice.planTitle || '—'],
|
||||
...(invoice.listPrice !== null ? [['قیمت', formatToman(invoice.listPrice)] as [string, React.ReactNode]] : []),
|
||||
...(invoice.discountAmount ? [[
|
||||
invoice.discountCode ? `تخفیف (کدِ ${invoice.discountCode})` : 'تخفیف',
|
||||
invoice.discountCode ? `تخفیف (کد ${invoice.discountCode})` : 'تخفیف',
|
||||
formatToman(invoice.discountAmount),
|
||||
] as [string, React.ReactNode]] : []),
|
||||
['مبلغِ پرداختی', <b>{formatToman(invoice.amount)}</b>],
|
||||
['روشِ پرداخت', PROVIDER[invoice.provider] ?? invoice.provider],
|
||||
['مبلغ پرداختی', <b>{formatToman(invoice.amount)}</b>],
|
||||
['روش پرداخت', PROVIDER[invoice.provider] ?? invoice.provider],
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -122,7 +122,7 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
|
||||
onClick={() => void copy(invoice.refId)}
|
||||
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">
|
||||
{/* کد همانطور که کپی میشود: با رقمِ فارسی صفرها نقطه دیده میشدند. */}
|
||||
{invoice.refId} <Copy size={14} />
|
||||
@@ -131,7 +131,7 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
|
||||
) : (
|
||||
(invoice.status === 'pending' || invoice.status === 'failed') && (
|
||||
<p className="text-xs leading-6 text-muted">
|
||||
اگه پولی از حسابت کم شده ولی این خرید «پرداختشده» نیست، شمارهی فاکتور رو به پشتیبانی بده.
|
||||
اگه پولی از حسابت کم شده ولی این خرید «پرداختشده» نیست، شماره فاکتور رو به پشتیبانی بده.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -22,9 +22,9 @@ export function LeaderboardScreen() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{([['weekly', 'این هفته'], ['all_time', 'همهی زمانها']] as const).map(([p, label]) => (
|
||||
{([['weekly', 'این هفته'], ['all_time', 'همه زمانها']] as const).map(([p, label]) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
|
||||
@@ -92,11 +92,11 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
<FeedbackBar kind="lesson" id={String(lessonId)} />
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
<button
|
||||
@@ -104,7 +104,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
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]"
|
||||
>
|
||||
<Brain size={16} /> دربارهی این درس از {companionName} بپرس
|
||||
<Brain size={16} /> درباره این درس از {companionName} بپرس
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function LessonsScreen() {
|
||||
<Screen className="bg-page">
|
||||
<ScreenHeader
|
||||
title="درسها"
|
||||
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>}
|
||||
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمون جزیره رو بده</>}
|
||||
color="#2FB36B"
|
||||
exact
|
||||
mascot={{ mood: 'happy' }}
|
||||
@@ -198,7 +198,7 @@ function NodeRow({
|
||||
<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-[11px] text-muted">
|
||||
{locked ? 'جزیرهی قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`}
|
||||
{locked ? 'جزیره قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`}
|
||||
</span>
|
||||
</span>
|
||||
{isCurrent && !open && (
|
||||
|
||||
@@ -61,7 +61,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
setDevOtp(data.devOtp ?? null);
|
||||
} catch (err) {
|
||||
if (mine !== attempt.current) return;
|
||||
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن'));
|
||||
setError(friendlyError(err, 'ارسال کد انجام نشد، دوباره امتحان کن'));
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند
|
||||
} 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>
|
||||
<p className="mt-1 text-sm text-white/75">
|
||||
{step === 'phone'
|
||||
? 'شمارهی موبایلت رو وارد کن'
|
||||
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامکشده رو وارد کن`}
|
||||
? 'شماره موبایلت رو وارد کن'
|
||||
: `کد ${toFa(OTP_LENGTH)} رقمی پیامکشده رو وارد کن`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/15 p-2">
|
||||
@@ -146,7 +146,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
) : (
|
||||
<div>
|
||||
<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
|
||||
ref={otpInput}
|
||||
value={otp}
|
||||
@@ -165,7 +165,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
)}
|
||||
{devOtp && (
|
||||
<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>
|
||||
)}
|
||||
<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> · اصلاح
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,7 +187,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
loading={loading === 'login'}
|
||||
disabled={step === 'phone' ? !phoneValid : !otpValid}
|
||||
>
|
||||
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'}
|
||||
{step === 'phone' ? 'دریافت کد تأیید' : 'ورود'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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 }[] = [
|
||||
{
|
||||
char: 'sigma', mood: 'happy', bg: '#4C5FD5',
|
||||
sub: 'از پایهی سوم تا دوازدهم',
|
||||
sub: 'از پایه سوم تا دوازدهم',
|
||||
title: 'به تدریسینو خوش اومدی!',
|
||||
desc: 'من سیگمام. ریاضیِ کتابِ خودت رو جزیره به جزیره با هم جلو میبریم؛ هر جزیره چند درسِ کوتاه و یه عالمه تمرین.',
|
||||
desc: 'من سیگمام. با هم ریاضی کتاب درسیت رو جزیره به جزیره جلو میبریم؛ هر جزیره چند درس کوتاه داره و کلی تمرین.',
|
||||
},
|
||||
{
|
||||
char: 'delta', mood: 'excited', bg: '#FF7A3D',
|
||||
sub: 'هر روز یه کم',
|
||||
title: 'امتیاز بگیر، زنجیره بساز',
|
||||
desc: 'منم دلتا! با هر جوابِ درست امتیاز و سکه میگیری، روزهای پیاپیت شمرده میشه و تو جدولِ امتیازها بالا میری.',
|
||||
desc: 'منم دلتا! با هر جواب درست امتیاز و سکه میگیری، روزهایی که پشت هم تمرین میکنی شمرده میشه و تو جدول امتیازها بالا میری.',
|
||||
},
|
||||
{
|
||||
char: 'pi', mood: 'happy', bg: '#9B7BFF',
|
||||
sub: 'هر جا گیر کردی',
|
||||
title: 'از پی بپرس',
|
||||
desc: 'من پیام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدمبهقدم توضیح میدم.',
|
||||
desc: 'من پیام. هر جا سوالی داشتی یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتاب درسیت قدمبهقدم توضیح میدم.',
|
||||
},
|
||||
{
|
||||
char: 'tedi', mood: 'excited', bg: '#D9820B',
|
||||
sub: 'یکی از ما مالِ توئه',
|
||||
sub: 'یکی از ما مال توئه',
|
||||
title: 'همراهت رو انتخاب کن',
|
||||
desc: 'منم امگا! هر چی امتیازت بیشتر بشه، همراههای تازه باز میشن. از صفحهی «من» هر کدوممون رو خواستی کنارت میذاری.',
|
||||
desc: 'منم امگا! هر چی امتیازت بیشتر بشه، همراههای تازهتری باز میشن. از صفحه «من» هر کدوممون رو بخوای انتخاب میکنی.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
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';
|
||||
import { api } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
@@ -20,7 +20,7 @@ import { useAsync } from '@/lib/useAsync';
|
||||
import { daysLeft } from '@/lib/subscription';
|
||||
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 }) {
|
||||
@@ -46,7 +46,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
{profile.name || 'بینام'} <Pencil size={14} className="text-white/70" />
|
||||
</button>
|
||||
<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 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>}
|
||||
@@ -74,27 +74,27 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
<SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
|
||||
|
||||
<Menu>
|
||||
<Row icon={GraduationCap} label="پایهی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
||||
<Row icon={GraduationCap} label="پایه تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
||||
<Row
|
||||
icon={CalendarDays}
|
||||
label="تاریخِ تولد"
|
||||
label="تاریخ تولد"
|
||||
value={formatBirthDate(profile.birthDate) || (age ? `${toFa(age)} سال` : 'وارد نشده')}
|
||||
onClick={() => setEditing('birth')}
|
||||
/>
|
||||
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} />
|
||||
<Row icon={Sparkles} label="همراه من" onClick={go({ name: 'characters' })} />
|
||||
</Menu>
|
||||
|
||||
<Menu>
|
||||
<Row icon={BarChart3} label="گزارشِ والدین" onClick={go({ name: 'report' })} />
|
||||
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} />
|
||||
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} />
|
||||
<Row icon={BarChart3} label="گزارش والدین" onClick={go({ name: 'report' })} />
|
||||
<Row icon={Flame} label="تقویم زنجیره" onClick={go({ name: 'streak' })} />
|
||||
<Row icon={Trophy} label="جدول امتیازها" onClick={go({ name: 'leaderboard' })} />
|
||||
<Row icon={Bell} label="اعلانها" onClick={go({ name: 'notifications' })} />
|
||||
</Menu>
|
||||
|
||||
<Menu>
|
||||
<Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />
|
||||
<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
|
||||
icon={sound ? Volume2 : VolumeX}
|
||||
label="صداها"
|
||||
@@ -106,13 +106,12 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
/>
|
||||
</Menu>
|
||||
|
||||
{/* حذفِ حساب یک لایه پایینتر، در «حساب کاربری»، مثلِ اپهای بزرگ (دیجیکالا، اسنپ، دولینگو): پیدا میشود
|
||||
(Google Play و App Store «در دسترس و پیداشدنی» میخواهند) ولی با یک لمسِ اشتباه زیرِ انگشت نیست. */}
|
||||
<Menu>
|
||||
<Row icon={Repeat} label="ورود با حسابِ دیگر" onClick={() => setEditing('switch')} />
|
||||
<Row icon={LogOut} label="خروج از حساب" onClick={() => setEditing('logout')} />
|
||||
<Row icon={UserRound} label="حساب کاربری" value={profile.phone ? toFa(profile.phone) : undefined} onClick={() => setEditing('account')} />
|
||||
<Row icon={LogOut} label="خروج از حساب" danger onClick={() => setEditing('logout')} />
|
||||
</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 />
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,7 +119,8 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
|
||||
<GradeSheet open={editing === 'grade'} 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} />
|
||||
</Screen>
|
||||
);
|
||||
@@ -148,7 +148,7 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`glyph flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl ${s.active ? 'bg-white/20' : 'bg-[#FFF4D6] text-[#E0A200]'}`}><Crown size={20} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراکِ تدریسینو</span>
|
||||
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراک تدریسینو</span>
|
||||
<span className="block text-base font-black">{s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}</span>
|
||||
</span>
|
||||
<span className="text-left">
|
||||
@@ -166,7 +166,7 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={total}
|
||||
aria-valuenow={days ?? total}
|
||||
aria-label="روزهای ماندهٔ اشتراک"
|
||||
aria-label="روزهای مانده اشتراک"
|
||||
>
|
||||
{percent > 0 && (
|
||||
<div className="bar-fill absolute inset-y-0 right-0 overflow-hidden rounded-full" style={{ width: `${percent}%`, backgroundColor: low ? '#FFD23F' : '#fff' }}>
|
||||
@@ -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'}`}>
|
||||
{s.active ? (
|
||||
<>
|
||||
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقتِ تمدیده'}</span>}
|
||||
<span className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلبِ نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
|
||||
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقت تمدیده'}</span>}
|
||||
<span className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلب نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
|
||||
</>
|
||||
) : (
|
||||
<span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همهی فصلها و قلبِ نامحدود باز میشه.</span>
|
||||
<span>فصل اول هر پایه رایگانه؛ با اشتراک همه فصلها و قلب نامحدود باز میشه.</span>
|
||||
)}
|
||||
</div>
|
||||
</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>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<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" />
|
||||
<span className="flex-1 text-sm font-bold text-ink">{label}</span>
|
||||
<Icon size={18} className={`flex-shrink-0 ${danger ? 'text-red-500' : 'text-indigo'}`} />
|
||||
<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>}
|
||||
<ChevronLeft size={16} className="text-faint" />
|
||||
</button>
|
||||
@@ -262,8 +262,8 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="پایهی تحصیلی">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">پیشرفتِ هر پایه جدا نگه داشته میشه؛ با برگشتن، سرِ جاشه.</p>
|
||||
<Sheet open={open} onClose={onClose} title="پایه تحصیلی">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">پیشرفت هر پایه جدا نگه داشته میشه؛ با برگشتن، سر جاشه.</p>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{grades.map((g) => {
|
||||
const on = g.id === profile.gradeId;
|
||||
@@ -307,8 +307,8 @@ function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="تاریخِ تولد">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سالِ تولدت به شمسی.</p>
|
||||
<Sheet open={open} onClose={onClose} title="تاریخ تولد">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سال تولدت به شمسی.</p>
|
||||
<div className="mb-5">
|
||||
<BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
|
||||
</div>
|
||||
@@ -318,11 +318,11 @@ function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* خروج، و «ورود با حسابِ دیگر» که همان خروج است با متنِ دیگر. عمداً چند حسابِ واردشده روی
|
||||
* یک گوشی نگه داشته نمیشود: گوشیِ خانوادگی دستبهدست میشود و هر کس بیکد واردِ حسابِ
|
||||
* دیگری میشد، و چند refresh tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر میکرد.
|
||||
* خروج. «ورود با حسابِ دیگر» (۲۰۲۶-۱۰-۱۴) برداشته شد: همان خروج بود با متنِ دیگر. عمداً چند حسابِ واردشده روی یک
|
||||
* گوشی نگه داشته نمیشود: گوشیِ خانوادگی دستبهدست میشود و هر کس بیکد واردِ حسابِ دیگری میشد، و چند refresh
|
||||
* 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);
|
||||
|
||||
async function logout() {
|
||||
@@ -337,22 +337,39 @@ function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open:
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title={switching ? 'ورود با حسابِ دیگر' : 'از حسابت خارج بشی؟'}>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
{switching && 'اول از این حساب خارج میشی، بعد با شمارهی دیگه وارد شو. '}
|
||||
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
|
||||
</p>
|
||||
<p className="mb-5 -mt-3 text-xs text-muted">ممنون که با تدریسینو یاد میگیری 💛</p>
|
||||
<Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟">
|
||||
<p className="mb-5 text-sm leading-7 text-muted">هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سر جاشه.</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<SecondaryButton onClick={onClose}>نه، میمونم</SecondaryButton>
|
||||
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>
|
||||
{switching ? 'خروج و ورود با حسابِ دیگر' : 'خروج'}
|
||||
</PrimaryButton>
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
|
||||
</div>
|
||||
</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). شمارهدار با کدِ تازهی پیامکی تأیید میکند تا گوشیِ
|
||||
* بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند.
|
||||
@@ -393,28 +410,28 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="حذفِ همیشگیِ حساب">
|
||||
<Sheet open={open} onClose={onClose} title="حذف همیشگی حساب">
|
||||
<p className="mb-3 text-sm leading-7 text-body">
|
||||
امتیاز، زنجیره، پیشرفتِ جزیرهها، گفتگوها با دستیار، سوالهای خریدهشده و قلبِ نامحدودت پاک میشن و برنمیگردن.
|
||||
سوابقِ پرداخت فقط برای حسابداری میمونه.
|
||||
امتیاز، زنجیره، پیشرفت جزیرهها، گفتگوها با دستیار و اشتراکت پاک میشن و برنمیگردن.
|
||||
سوابق پرداخت فقط برای حسابداری میمونه. اگه اشتراکت رو از کافهبازار خریدی، تمدید خودکارش رو از بخش «اشتراکها»ی بازار لغو کن.
|
||||
</p>
|
||||
{sent ? (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(digitsOnly(e.target.value, 6))}
|
||||
placeholder="کدِ پیامکشده"
|
||||
placeholder="کد پیامکشده"
|
||||
inputMode="numeric"
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<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>}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton>
|
||||
{!sent ? (
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادنِ کد</PrimaryButton>
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادن کد</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
color="#ef4444"
|
||||
@@ -422,7 +439,7 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
|
||||
disabled={value.length < 6}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
حذفِ همیشگی
|
||||
حذف همیشگی
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -257,7 +257,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
<p className="text-sm text-muted">دارم نتیجهت رو ثبت میکنم…</p>
|
||||
) : (
|
||||
<>
|
||||
<InlineError>نتیجهی این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
|
||||
<InlineError>نتیجه این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
|
||||
<PrimaryButton color={color} onClick={() => void finish()}>دوباره ثبت کن</PrimaryButton>
|
||||
<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 bubble = !answer
|
||||
? selected === null ? 'جوابِ درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
|
||||
? selected === null ? 'جواب درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
|
||||
: answer.correct
|
||||
? combo >= 2 ? `${toFa(combo)} تا پشتِ هم درست! داری عالی پیش میری` : PRAISE[index % PRAISE.length]
|
||||
? combo >= 2 ? `${toFa(combo)} تا پشت هم درست! داری عالی پیش میری` : PRAISE[index % PRAISE.length]
|
||||
: outOfHearts
|
||||
? 'قلبهات تموم شد؛ یه کم استراحت کنیم.'
|
||||
: 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="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>
|
||||
<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}
|
||||
@@ -389,7 +389,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
</button>
|
||||
)}
|
||||
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
|
||||
{isLast ? 'پایانِ تمرین' : 'ادامه'} <ChevronLeft size={18} />
|
||||
{isLast ? 'پایان تمرین' : 'ادامه'} <ChevronLeft size={18} />
|
||||
</PrimaryButton>
|
||||
</>
|
||||
) : (
|
||||
@@ -417,7 +417,7 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
|
||||
if (hearts?.isUnlimited) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -495,7 +495,7 @@ function Result({
|
||||
<div>
|
||||
<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>}
|
||||
<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 && (
|
||||
<p className="mt-2 text-xs leading-6 text-body">
|
||||
@@ -517,7 +517,7 @@ function Result({
|
||||
</div>
|
||||
</div>}
|
||||
<PrimaryButton color={color} loading={loadingNext} onClick={() => then(() => void goNext())}>
|
||||
{nodeMode && nextNode ? 'بریم جزیرهی بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
||||
{nodeMode && nextNode ? 'بریم جزیره بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
||||
</PrimaryButton>
|
||||
{nodeMode && nextNode && (
|
||||
<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 NUDGE = ['نزدیک بودی! جوابِ درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد میگیریم.', 'این یکی سخت بود؛ جوابِ درست سبز شده.'];
|
||||
const NUDGE = ['نزدیک بودی! جواب درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد میگیریم.', 'این یکی سخت بود؛ جواب درست سبز شده.'];
|
||||
|
||||
/** «راهنمای مهربون»ِ پی: نکتهی عمومیِ کوتاه بعد از جوابِ غلط؛ نه جوابِ سوال، فقط عادتِ خوبِ حل کردن. */
|
||||
const PI_TIPS = [
|
||||
'صورتِ سوال رو یه بار دیگه آروم بخون؛ کلمهی «نیست» یا «بیشترین» زیاد جا میمونه.',
|
||||
'علامتِ منفیها رو یه بار دیگه چک کن.',
|
||||
'اول داخلِ پرانتز، بعد ضرب و تقسیم، آخرش جمع و تفریق.',
|
||||
'صورت سوال رو یه بار دیگه آروم بخون؛ کلمه «نیست» یا «بیشترین» زیاد جا میمونه.',
|
||||
'علامت منفیها رو یه بار دیگه چک کن.',
|
||||
'اول داخل پرانتز، بعد ضرب و تقسیم، آخرش جمع و تفریق.',
|
||||
'جوابت رو بذار توی سوال؛ اگه درست درنیومد یه جا اشتباه شده.',
|
||||
];
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ export function ReportScreen() {
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title="گزارشِ والدین"
|
||||
subtitle={`پیشرفتِ ${profile.name || 'دانشآموز'} در ۷ روزِ اخیر`}
|
||||
title="گزارش والدین"
|
||||
subtitle={`پیشرفت ${profile.name || 'دانشآموز'} در ۷ روز اخیر`}
|
||||
color={THIS_WEEK}
|
||||
back
|
||||
mascot={{ mood: 'happy', size: 56 }}
|
||||
@@ -69,43 +69,43 @@ function Report({ data, name }: { data: WeeklyReport; name: string }) {
|
||||
<Verdict now={now} before={before} name={name} />
|
||||
|
||||
<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={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
|
||||
icon={CheckCircle2}
|
||||
label="درصدِ جوابِ درست"
|
||||
label="درصد جواب درست"
|
||||
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}
|
||||
/>
|
||||
{/* آزمونِ جزیره و آزمونِ تکدرس هر دو «تمام کردن» اند؛ جدا شمردنشان برای والد فرقی نمیکند. */}
|
||||
<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, '')} />
|
||||
</div>
|
||||
|
||||
{data.streak.longest > 0 && (
|
||||
<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" />
|
||||
زنجیرهی فعلی <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>
|
||||
)}
|
||||
|
||||
<BarChart
|
||||
title="زمانِ تمرین در هر روز"
|
||||
description="ستونِ آبی هر روزِ این هفته است و ستونِ نارنجیِ کنارش همان روز در هفتهی پیش؛ آبیِ بلندتر یعنی بیشتر از هفتهی پیش."
|
||||
title="زمان تمرین در هر روز"
|
||||
description="ستون آبی هر روز این هفته است و ستون نارنجی کنارش همان روز در هفته پیش؛ آبی بلندتر یعنی بیشتر از هفته پیش."
|
||||
unit="دقیقه"
|
||||
days={data.days}
|
||||
mode="grouped"
|
||||
series={[
|
||||
{ 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
|
||||
title="سوالهای حلشده در هر روز"
|
||||
description="کلِ ستون تعدادِ سوالهایی است که آن روز جواب داده؛ بخشِ سبز جوابهای درست است و بخشِ طلایی نادرست."
|
||||
description="کل ستون تعداد سوالهایی است که آن روز جواب داده؛ بخش سبز جوابهای درست است و بخش طلایی نادرست."
|
||||
unit="سوال"
|
||||
days={data.days}
|
||||
mode="stacked"
|
||||
@@ -128,18 +128,18 @@ function verdict(now: ReportTotals, before: ReportTotals, name: string): { mood:
|
||||
const diff = now.minutes - before.minutes;
|
||||
const meaningful = Math.abs(diff) >= 3 && Math.abs(diff) >= before.minutes * 0.1;
|
||||
if (!now.minutes && !before.minutes) {
|
||||
return { mood: 'idle', text: 'این هفته هنوز تمرینی ثبت نشده. یه جزیرهی کوتاه شروعِ خوبیه!' };
|
||||
return { mood: 'idle', text: 'این هفته هنوز تمرینی ثبت نشده. یه جزیره کوتاه شروع خوبیه!' };
|
||||
}
|
||||
if (!before.minutes) {
|
||||
return { mood: 'excited', text: `آفرین! ${who}این هفته شروع کرده و ${toFa(now.activeDays)} روز تمرین کرده.` };
|
||||
}
|
||||
if (meaningful && diff > 0) {
|
||||
return { mood: 'excited', text: `آفرین! ${who}این هفته حدودِ ${toFa(diff)} دقیقه بیشتر از هفتهی پیش تمرین کرده.` };
|
||||
return { mood: 'excited', text: `آفرین! ${who}این هفته حدود ${toFa(diff)} دقیقه بیشتر از هفته پیش تمرین کرده.` };
|
||||
}
|
||||
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 }) {
|
||||
@@ -162,8 +162,8 @@ function change(now: number, before: number, unit: string): Delta {
|
||||
const u = unit ? ` ${unit}` : '';
|
||||
// صفر در برابرِ صفر خبری ندارد؛ زیرِ همهی کاشیهای حسابِ تازه «مثلِ هفتهی پیش» مینشست.
|
||||
if (!now && !before) return null;
|
||||
if (diff === 0) return { text: 'مثلِ هفتهی پیش', up: null };
|
||||
return { text: `${toFa(Math.abs(diff))}${u} ${diff > 0 ? 'بیشتر' : 'کمتر'} از هفتهی پیش`, up: diff > 0 };
|
||||
if (diff === 0) return { text: 'مثل هفته پیش', up: null };
|
||||
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 }) {
|
||||
@@ -326,7 +326,7 @@ function DayTable({ days, previous }: { days: ReportDay[]; previous: ReportDay[]
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
@@ -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="relative flex items-center justify-between pt-8">
|
||||
<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>
|
||||
</div>
|
||||
<div className="rounded-3xl bg-white/15 p-2">
|
||||
@@ -84,14 +84,14 @@ export function SetupScreen({
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<BirthDatePicker value={birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -129,7 +129,7 @@ export function SetupScreen({
|
||||
{error && <InlineError>{error}</InlineError>}
|
||||
|
||||
<PrimaryButton onClick={() => void submit()} loading={saving} className="mt-auto">
|
||||
شروعِ یادگیری <ChevronLeft size={19} strokeWidth={3} />
|
||||
شروع یادگیری <ChevronLeft size={19} strokeWidth={3} />
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function StreakScreen() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{calendar.loading ? (
|
||||
<LoadingState />
|
||||
@@ -50,7 +50,7 @@ export function StreakScreen() {
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: 'زنجیرهی فعلی', value: calendar.data.currentStreak },
|
||||
{ label: 'زنجیره فعلی', value: calendar.data.currentStreak },
|
||||
{ label: 'بیشترین زنجیره', value: calendar.data.longestStreak },
|
||||
].map((s, i) => (
|
||||
<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
|
||||
? `زنجیرهی ${toFa(lost)} روزهت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.`
|
||||
? `زنجیره ${toFa(lost)} روزهت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.`
|
||||
: current === 0
|
||||
? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیرهت روشن بشه.'
|
||||
: !doneToday
|
||||
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیرهی ${toFa(current)} روزهت نسوزه.`
|
||||
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیره ${toFa(current)} روزهت نسوزه.`
|
||||
: null;
|
||||
return (
|
||||
<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>}
|
||||
<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}
|
||||
{next ? `${toFa(next - current)} روز دیگه میشه زنجیرهی ${toFa(next)} روزه.` : 'از همهی نقطهعطفها رد شدی، افسانهای!'}
|
||||
{next ? `${toFa(next - current)} روز دیگه میشه زنجیره ${toFa(next)} روزه.` : 'از همه نقطهعطفها رد شدی، افسانهای!'}
|
||||
</p>
|
||||
{next && (
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { Check, Crown, CreditCard, ShieldCheck, ShoppingBag, Ticket } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, ChevronDown, Crown, Lock, Ticket } from 'lucide-react';
|
||||
import { api, type Plan, type SubscriptionStatus } from '@/api';
|
||||
import { daysLeft } from '@/lib/subscription';
|
||||
import { newIdempotencyKey } from '@/api/http';
|
||||
@@ -10,6 +10,9 @@ import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Scree
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { formatNumber, formatToman, toFa } from '@/lib/format';
|
||||
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 {
|
||||
if (days === 30 || days === 31) return 'یکماهه';
|
||||
@@ -62,8 +65,8 @@ export function SubscriptionScreen() {
|
||||
<Screen>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ScreenHeader
|
||||
title={active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو'}
|
||||
subtitle="همهی فصلها، قلبِ نامحدود"
|
||||
title={active ? 'تمدید اشتراک' : 'اشتراک تدریسینو'}
|
||||
subtitle="همه فصلها، قلب نامحدود"
|
||||
color="#ED6956"
|
||||
exact
|
||||
back
|
||||
@@ -79,7 +82,7 @@ export function SubscriptionScreen() {
|
||||
) : !list.length ? (
|
||||
<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) => (
|
||||
<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 && (
|
||||
<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)}>
|
||||
{`خریدِ اشتراکِ ${durationLabel(selected.durationDays) || selected.title}`}
|
||||
{`خرید اشتراک ${durationLabel(selected.durationDays) || selected.title}`}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -178,12 +181,10 @@ function PlanRow({ plan, assistant, monthlyPrice, selected, onSelect }: { plan:
|
||||
);
|
||||
}
|
||||
|
||||
type Method = 'gateway' | 'bazaar';
|
||||
|
||||
/** چرا بازار برای این پلن و اینجا ممکن نیست؛ null یعنی ممکن است. */
|
||||
function bazaarBlocker(plan: Plan | null): string | null {
|
||||
if (!plan?.bazaarSku) return 'این مورد در بازار نیست';
|
||||
if (!inAndroidApp()) return 'فقط در اپِ اندروید';
|
||||
if (!inAndroidApp()) return 'فقط در اپ اندروید';
|
||||
if (!canBuyFromBazaar()) return 'اپ رو از بازار بهروز کن';
|
||||
return null;
|
||||
}
|
||||
@@ -193,24 +194,28 @@ function bazaarBlocker(plan: Plan | null): string | null {
|
||||
* صفحهی خریدِ بازار میرود. 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 }) {
|
||||
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 [showCode, setShowCode] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
|
||||
|
||||
// هر بار که برگه برای پلنی باز میشود، زرینپال انتخاب است (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ بازار یک لمس آنطرفتر.
|
||||
// هر بار که برگه برای پلنی باز میشود، اولین روشِ ممکن انتخاب است (ترتیب از config.js؛ پیشفرض زرینپال اول).
|
||||
useEffect(() => {
|
||||
if (plan) setMethod('gateway');
|
||||
}, [plan]);
|
||||
if (plan) setMethod(firstUsable);
|
||||
}, [plan, firstUsable]);
|
||||
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') {
|
||||
// کارِ کاربر است، نه خطای خرید: پولی جابهجا نشده.
|
||||
setError(err.message === 'old_bazaar'
|
||||
? 'کافهبازارِ گوشیت قدیمیه؛ از خودِ بازار بهروزش کن و دوباره بزن، یا با کارتِ بانکی پرداخت کن.'
|
||||
: 'کافهبازار روی گوشیت پیدا نشد یا وصل نشد. مطمئن شو نصبه و وارد حسابت شدی، بعد دوباره بزن؛ یا با کارتِ بانکی پرداخت کن.');
|
||||
? 'کافهبازار گوشیت قدیمیه؛ از خود بازار بهروزش کن و دوباره بزن، یا با کارت بانکی پرداخت کن.'
|
||||
: 'کافهبازار روی گوشیت پیدا نشد یا وصل نشد. مطمئن شو نصبه و وارد حسابت شدی، بعد دوباره بزن؛ یا با کارت بانکی پرداخت کن.');
|
||||
} else {
|
||||
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
|
||||
}
|
||||
@@ -319,12 +324,12 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
};
|
||||
|
||||
const doneText =
|
||||
done === 'gateway' ? 'صفحهی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همینجا؛ خریدت خودش فعال میشه.'
|
||||
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
|
||||
: 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش میکنه؛ لغوِ تمدید از بخشِ «اشتراکها»ی خودِ بازاره.';
|
||||
done === 'gateway' ? 'صفحه پرداخت در مرورگر گوشی باز شد. بعد از پرداخت برگرد همینجا؛ خریدت خودش فعال میشه.'
|
||||
: done === 'code' ? 'کد تخفیفت کل مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
|
||||
: 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخر هر دوره خودش تمدیدش میکنه؛ لغو تمدید از بخش «اشتراکها»ی خود بازاره.';
|
||||
|
||||
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 ? (
|
||||
<>
|
||||
<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 && (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3.5">
|
||||
<span className="text-sm font-bold text-body">مبلغ</span>
|
||||
<span className="text-lg font-black text-ink">{method === 'bazaar' && storePrice ? storePrice : price(plan.price)}</span>
|
||||
<div className="mb-5 flex items-center gap-3 rounded-2xl bg-soft px-4 py-3.5">
|
||||
<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="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 role="radiogroup" aria-label="روشِ پرداخت" className="mb-4">
|
||||
<span className="mb-2 block text-sm font-black text-ink">با چی پرداخت میکنی؟</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MethodTile selected={method === 'gateway'} onSelect={() => setMethod('gateway')} icon={<CreditCard size={22} />} title="کارتِ بانکی" note="زرینپال" />
|
||||
<MethodTile selected={method === 'bazaar'} onSelect={() => setMethod('bazaar')} icon={<ShoppingBag size={22} />} title="کافهبازار" note={blocker ?? storePrice ?? 'با حسابِ بازارت'} disabled={!!blocker} />
|
||||
<div role="radiogroup" aria-label="روش پرداخت" className="mb-4">
|
||||
<span className="mb-2 block text-sm font-black text-ink">روش پرداخت</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{methods.map((m) =>
|
||||
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>
|
||||
{showCode ? (
|
||||
<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
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.trim().slice(0, 40))}
|
||||
@@ -356,19 +370,19 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex items-center gap-1.5 text-sm font-bold text-coral">
|
||||
<Ticket size={15} /> کدِ تخفیف دارم
|
||||
<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={16} className="text-coral" /> <span className="flex-1 text-right">کد تخفیف دارم</span> <ChevronDown size={16} className="text-faint" />
|
||||
</button>
|
||||
)}
|
||||
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
|
||||
<PrimaryButton loading={busy} onClick={() => void pay()}>
|
||||
{method === 'bazaar' ? 'پرداخت با کافهبازار' : 'پرداخت با کارتِ بانکی'}
|
||||
<PrimaryButton loading={busy} disabled={method === 'bazaar' && !!blocker} onClick={() => void pay()}>
|
||||
{`پرداخت ${method === 'bazaar' && storePrice ? storePrice : price(plan.price)}`}
|
||||
</PrimaryButton>
|
||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
|
||||
<ShieldCheck size={14} />
|
||||
<Lock size={13} />
|
||||
{method === 'bazaar'
|
||||
? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید میشه.'
|
||||
: 'پرداخت در درگاهِ امنِ زرینپال انجام میشه.'}
|
||||
? 'پرداخت امن در خود کافهبازار؛ آخر هر دوره خودکار تمدید میشه.'
|
||||
: 'پرداخت امن در درگاه زرینپال'}
|
||||
</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;
|
||||
return (
|
||||
<button
|
||||
@@ -386,18 +401,23 @@ function MethodTile({ selected, onSelect, icon, title, note, disabled = false }:
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
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 ${
|
||||
disabled ? 'cursor-not-allowed border-line bg-soft opacity-70' : on ? 'border-coral bg-[#fff4f2]' : 'border-line bg-white active:translate-y-0.5'
|
||||
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-60' : on ? 'border-coral bg-[#fff6f4]' : 'border-line bg-white active:bg-soft'
|
||||
}`}
|
||||
>
|
||||
{on && (
|
||||
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-coral text-white">
|
||||
<Check size={12} strokeWidth={4} />
|
||||
</span>
|
||||
)}
|
||||
<span className={on ? 'text-coral' : 'text-muted'}>{icon}</span>
|
||||
<span className="text-[15px] font-black text-ink">{title}</span>
|
||||
<span className="text-xs leading-5 text-muted">{note}</span>
|
||||
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl border border-line bg-white">
|
||||
<img src={logo} alt="" width={30} height={30} className={disabled ? 'grayscale' : ''} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[15px] font-black text-ink">{title}</span>
|
||||
<span className="block truncate text-xs text-muted">{note}</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ const CATEGORIES = [
|
||||
{ id: 'learning', label: 'درس و سوالها' },
|
||||
{ id: 'payment', label: 'پرداخت و اشتراک' },
|
||||
{ id: 'account', label: 'حساب و ورود' },
|
||||
{ id: 'other', label: 'چیزِ دیگه' },
|
||||
{ id: 'other', label: 'چیز دیگه' },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
open: 'باز',
|
||||
in_progress: 'در حالِ بررسی',
|
||||
in_progress: 'در حال بررسی',
|
||||
answered: 'پاسخ داده شد',
|
||||
closed: 'بسته',
|
||||
};
|
||||
@@ -61,7 +61,7 @@ export function SupportScreen() {
|
||||
<ScreenHeader title="پشتیبانی" subtitle="سوال، مشکل یا پیشنهاد داری؟ بنویس." color="#4C5FD5" back mascot={{ mood: 'happy', size: 56 }} />
|
||||
<ScreenBody className="flex flex-col gap-4">
|
||||
<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
|
||||
key={id}
|
||||
type="button"
|
||||
@@ -108,7 +108,7 @@ export function SupportScreen() {
|
||||
|
||||
{!!tickets.data?.length && (
|
||||
<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">
|
||||
{tickets.data.map((t) => <TicketCard key={t.ticketId} summary={t} />)}
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function UnitScreen({ unitId }: { unitId: string }) {
|
||||
>
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 flex justify-between text-[10px] text-white/75">
|
||||
<span>پیشرفتِ تو</span>
|
||||
<span>پیشرفت تو</span>
|
||||
<span>{toFa(progress.percent)}٪</span>
|
||||
</div>
|
||||
<ProgressBar percent={progress.percent} color="#fff" track="rgba(0,0,0,.1)" height={10} />
|
||||
|
||||
@@ -30,15 +30,15 @@ export const COMPANIONS: Record<SeyarChar, CompanionLook> = {
|
||||
},
|
||||
delta: {
|
||||
char: 'delta', name: SEYAR_INFO.delta.name, symbol: 'Δ', color: '#FF7A3D', light: '#FFB25E', pitch: 2,
|
||||
trait: { title: 'شعلهی سرعت', text: 'جوابای درستِ پشتِ هم رو آتیشی میکنه و تشویقت میکنه.' },
|
||||
trait: { title: 'شعله سرعت', text: 'جوابای درست پشت هم رو آتیشی میکنه و تشویقت میکنه.' },
|
||||
},
|
||||
pi: {
|
||||
char: 'pi', name: SEYAR_INFO.pi.name, symbol: 'π', color: '#9B7BFF', light: '#C6B3FF', pitch: 4,
|
||||
trait: { title: 'راهنمای مهربون', text: 'وقتی غلط میزنی یه نکتهی کوچیک میگه که دفعهی بعد گیر نکنی.' },
|
||||
trait: { title: 'راهنمای مهربون', text: 'وقتی غلط میزنی یه نکته کوچیک میگه که دفعه بعد گیر نکنی.' },
|
||||
},
|
||||
tedi: {
|
||||
char: 'tedi', name: SEYAR_INFO.tedi.name, symbol: 'ω', color: '#E8A400', light: '#FFC63D', pitch: 7,
|
||||
trait: { title: 'جشنساز', text: 'آخرِ هر جزیره، هر نمرهای بگیری، برات جشنِ طلایی میگیره.' },
|
||||
trait: { title: 'جشنساز', text: 'آخر هر جزیره، هر نمرهای بگیری، برات جشن طلایی میگیره.' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -131,3 +131,9 @@
|
||||
.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; } }
|
||||
.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
6
src/vite-env.d.ts
vendored
@@ -5,6 +5,10 @@ interface ImportMetaEnv {
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__TADRISINO_CONFIG__?: { apiBaseUrl?: string };
|
||||
__TADRISINO_CONFIG__?: {
|
||||
apiBaseUrl?: string;
|
||||
/** روشهای پرداختِ فعال در مرورگر (web) و داخلِ اپِ اندروید (app)؛ src/lib/paymentMethods.ts. */
|
||||
paymentMethods?: { web?: string[]; app?: string[] };
|
||||
};
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user