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

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

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

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -48,18 +48,18 @@ export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; c
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
<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>

View File

@@ -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" />

View File

@@ -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 }}

View File

@@ -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="روزهای این هفته">

View File

@@ -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 }}
/>