forked from Tadrisino/MathApp_frontend
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:
@@ -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 }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user