parent report page with two charts, Jalali birth date picker, user ID and invoices in profile

- the parents section is gone: no parent login, invite codes or "I'm a parent" button;
  "گزارشِ والدین" in the profile shows the week against the previous one (praise line,
  six tiles, minutes-per-day and right/wrong-per-day charts, a table of the numbers)
- birth date as day / month / year selects (Jalali, leap Esfand handled) instead of age;
  adults (19+) are asked which grade's math they want to learn
- user ID under the name, copyable
- "پرداخت‌ها و فاکتورها": every order with invoice number, discount, amount and reference code
- tutor: when the free daily quota runs out, a button to the subscription page instead of "retry"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 02:38:43 +03:30
parent 0e56d9c8aa
commit de7cd0bfa8
15 changed files with 738 additions and 373 deletions

View File

@@ -61,7 +61,7 @@ export const api = {
profile: { profile: {
get: () => http.get<T.UserProfile>('/user/profile'), get: () => http.get<T.UserProfile>('/user/profile'),
update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) => update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'birthDate' | 'bio'>>) =>
http.patch<T.UserProfile>('/user/profile', patch), http.patch<T.UserProfile>('/user/profile', patch),
setGrade: (gradeId: number) => setGrade: (gradeId: number) =>
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }), http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
@@ -70,6 +70,8 @@ export const api = {
summary: () => http.get<T.UserSummary>('/user/summary'), summary: () => http.get<T.UserSummary>('/user/summary'),
hearts: () => http.get<T.Hearts>('/user/hearts'), hearts: () => http.get<T.Hearts>('/user/hearts'),
streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'), streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'),
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
/** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */ /** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */
home: () => http.get<T.HomeResponse>('/home'), home: () => http.get<T.HomeResponse>('/home'),
@@ -167,6 +169,8 @@ export const api = {
redeem: (code: string, orderId: string) => redeem: (code: string, orderId: string) =>
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }), http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${orderId}`), order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${orderId}`),
/** پرداخت‌ها و فاکتورهای همین کاربر، تازه‌ترین اول. */
invoices: () => http.get<{ invoices: T.Invoice[] }>('/payment/invoices').then((r) => r.invoices),
}, },
support: { support: {
@@ -181,17 +185,6 @@ export const api = {
http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }), http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }),
}, },
family: {
link: () => http.get<T.FamilyLink>('/family/link'),
invite: (childPhone: string) =>
http.post<{ familyLinkId: string; inviteCode: string; status: string }>('/family/link/invite', { childPhone }),
accept: (inviteCode: string) =>
http.post<{ familyLinkId: string; status: string }>('/family/link/accept', { inviteCode }),
weeklyReport: () => http.get<T.FamilyWeeklyReport>('/family/weekly-report'),
settings: () => http.get<T.FamilySettings>('/family/settings'),
updateSettings: (patch: Partial<T.FamilySettings>) => http.patch('/family/settings', patch),
},
affiliate: { affiliate: {
me: () => http.get<T.AffiliateMe>('/affiliate/me'), me: () => http.get<T.AffiliateMe>('/affiliate/me'),
/** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل می‌کند. */ /** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل می‌کند. */

View File

@@ -34,7 +34,8 @@ export interface UserProfile {
isGuest: boolean; isGuest: boolean;
gradeId: number | null; gradeId: number | null;
birthYear: number | null; birthYear: number | null;
parentPhone: string; /** تاریخِ تولدِ شمسی «1385-04-12»؛ null وقتی فقط سال داده شده یا هیچ. */
birthDate: string | null;
bio: string; bio: string;
} }
@@ -288,24 +289,57 @@ export interface Ticket {
messages: { id: string; sender: string; text: string }[]; messages: { id: string; sender: string; text: string }[];
} }
export interface FamilyLink { /** یک سفارش به شکلِ فاکتور (GET /payment/invoices). مبلغ‌ها تومان‌اند. */
familyLinkId: string; export interface Invoice {
status: string; orderId: string;
childName: string; invoiceNumber: string;
status: 'pending' | 'paid' | 'failed';
provider: 'gateway' | 'bazaar' | string;
planTitle: string;
/** قیمتِ پایه پیش از تخفیف؛ null برای سفارش‌های قدیمی‌تر از ثبتِ فاکتور. */
listPrice: number | null;
discountAmount: number | null;
discountCode: string;
amount: number;
currency: string;
createdAt: string;
paidAt: string | null;
/** کدِ پیگیریِ بانکی/بازار؛ خالی برای پرداخت‌نشده یا کدِ تخفیفِ ۱۰۰٪. */
refId: string;
} }
export interface FamilyWeeklyReport { /** یک روز از گزارشِ هفتگی. `minutes` تخمینِ سرور است (از فاصله‌ی جواب‌ها)، نه زمانِ باز بودنِ اپ. */
weekStart: string; export interface ReportDay {
xpEarned: number; date: string;
lessonsCompleted: number; minutes: number;
islandsCompleted: number; questions: number;
currentStreak: number; correct: number;
xp: number;
lessons: number;
islands: number;
aiQuestions: number;
} }
export interface FamilySettings { export interface ReportTotals {
dailyLimitMinutes: number; minutes: number;
quietHoursStart: string; activeDays: number;
quietHoursEnd: string; questions: number;
correct: number;
xp: number;
/** یکتا: درس یا جزیره‌ی تکراری یک بار شمرده می‌شود. */
lessons: number;
islands: number;
aiQuestions: number;
}
/** GET /user/weekly-report: هفت روزِ اخیر و هفت روزِ قبلش، هر دو قدیمی به جدید. */
export interface WeeklyReport {
today: string;
days: ReportDay[];
previousDays: ReportDay[];
thisWeek: ReportTotals;
lastWeek: ReportTotals;
streak: { current: number; longest: number };
} }
export interface AffiliateMe { export interface AffiliateMe {

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { api, ApiError, http, type Grade, type UserProfile } from '@/api'; import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
import type { Route } from '@/nav/routes';
import { AppShell } from '@/components/AppShell'; import { AppShell } from '@/components/AppShell';
import { ToastProvider } from '@/components/Toast'; import { ToastProvider } from '@/components/Toast';
import { ErrorState } from '@/components/ui'; import { ErrorState } from '@/components/ui';
@@ -20,7 +19,7 @@ type Flow =
| { kind: 'onboarding' } | { kind: 'onboarding' }
| { kind: 'login' } | { kind: 'login' }
| { kind: 'setup'; profile: UserProfile; grades: Grade[] } | { kind: 'setup'; profile: UserProfile; grades: Grade[] }
| { kind: 'main'; profile: UserProfile; grades: Grade[]; start?: Route }; | { kind: 'main'; profile: UserProfile; grades: Grade[] };
/** کمترین زمانِ نمایشِ اسپلش، تا صفحه چشمک نزند. */ /** کمترین زمانِ نمایشِ اسپلش، تا صفحه چشمک نزند. */
const SPLASH_MIN_MS = 700; const SPLASH_MIN_MS = 700;
@@ -37,8 +36,6 @@ export function App() {
const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]); const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]);
await trackReferral(profile); await trackReferral(profile);
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades }); if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades });
// والد پایه ندارد؛ به‌جای فرمِ دانش‌آموز، مستقیم به بخشِ والدین.
else if (profile.name && (await isParent())) setFlow({ kind: 'main', profile, grades, start: { name: 'family' } });
else setFlow({ kind: 'setup', profile, grades }); else setFlow({ kind: 'setup', profile, grades });
}, []); }, []);
@@ -117,7 +114,6 @@ export function App() {
profile={flow.profile} profile={flow.profile}
grades={flow.grades} grades={flow.grades}
onDone={(profile) => setFlow({ kind: 'main', profile, grades: flow.grades })} onDone={(profile) => setFlow({ kind: 'main', profile, grades: flow.grades })}
onParent={(profile) => setFlow({ kind: 'main', profile, grades: flow.grades, start: { name: 'family' } })}
/> />
)} )}
{flow.kind === 'main' && ( {flow.kind === 'main' && (
@@ -125,7 +121,6 @@ export function App() {
profile={flow.profile} profile={flow.profile}
grades={flow.grades} grades={flow.grades}
payment={launchPayment} payment={launchPayment}
start={flow.start}
onSignedOut={() => setFlow({ kind: 'login' })} onSignedOut={() => setFlow({ kind: 'login' })}
onRegistered={trackReferral} onRegistered={trackReferral}
/> />
@@ -137,21 +132,6 @@ export function App() {
); );
} }
/**
* آیا این حساب والد است؟ سرور نقش را صریح نمی‌دهد: پیوندِ «در انتظار» فقط برای
* والدی که دعوت ساخته دیده می‌شود، و گزارشِ هفتگی فقط به والد جواب می‌دهد.
*/
async function isParent(): Promise<boolean> {
try {
const link = await api.family.link();
if (link.status === 'pending') return true;
await api.family.weeklyReport();
return true;
} catch {
return false;
}
}
/** /**
* اگر کاربر با لینکِ دعوت آمده بود و حالا با شماره وارد شده، معرفی را ثبت کن. * اگر کاربر با لینکِ دعوت آمده بود و حالا با شماره وارد شده، معرفی را ثبت کن.
* مهمان را سرور نمی‌پذیرد، پس کد تا ثبتِ شماره نگه داشته می‌شود. * مهمان را سرور نمی‌پذیرد، پس کد تا ثبتِ شماره نگه داشته می‌شود.

View File

@@ -26,13 +26,14 @@ function screen<M extends Record<K, ComponentType<any>>, K extends keyof M>(load
} }
const AskScreen = screen(() => import('@/screens/AskScreen'), 'AskScreen'); const AskScreen = screen(() => import('@/screens/AskScreen'), 'AskScreen');
const CharactersScreen = screen(() => import('@/screens/CharactersScreen'), 'CharactersScreen'); const CharactersScreen = screen(() => import('@/screens/CharactersScreen'), 'CharactersScreen');
const FamilyScreen = screen(() => import('@/screens/FamilyScreen'), 'FamilyScreen');
const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScreen'); const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScreen');
const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen');
const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen'); const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen');
const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen'); const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen');
const MapScreen = screen(() => import('@/screens/MapScreen'), 'MapScreen'); const MapScreen = screen(() => import('@/screens/MapScreen'), 'MapScreen');
const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen'); const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen');
const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen'); const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen');
const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen');
const QuizScreen = screen(() => import('@/screens/QuizScreen'), 'QuizScreen'); const QuizScreen = screen(() => import('@/screens/QuizScreen'), 'QuizScreen');
const StreakScreen = screen(() => import('@/screens/StreakScreen'), 'StreakScreen'); const StreakScreen = screen(() => import('@/screens/StreakScreen'), 'StreakScreen');
const SubscriptionScreen = screen(() => import('@/screens/SubscriptionScreen'), 'SubscriptionScreen'); const SubscriptionScreen = screen(() => import('@/screens/SubscriptionScreen'), 'SubscriptionScreen');
@@ -52,18 +53,16 @@ interface Props {
profile: UserProfile; profile: UserProfile;
grades: Grade[]; grades: Grade[];
payment: PaymentReturn | null; payment: PaymentReturn | null;
/** صفحه‌ی شروع؛ برای والد بخشِ والدین است. */
start?: Route;
onSignedOut: () => void; onSignedOut: () => void;
onRegistered: (profile: UserProfile) => Promise<void>; onRegistered: (profile: UserProfile) => Promise<void>;
} }
/** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */ /** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */
export function MainApp({ profile, grades, payment, start, onSignedOut, onRegistered }: Props) { export function MainApp({ profile, grades, payment, onSignedOut, onRegistered }: Props) {
useEffect(preloadScreens, []); useEffect(preloadScreens, []);
return ( return (
<AppDataProvider initialProfile={profile} grades={grades}> <AppDataProvider initialProfile={profile} grades={grades}>
<NavigatorProvider initial={start ?? { name: payment ? 'subscription' : 'home' }}> <NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
<Screens onSignedOut={onSignedOut} onRegistered={onRegistered} /> <Screens onSignedOut={onSignedOut} onRegistered={onRegistered} />
{payment && <PaymentResult payment={payment} />} {payment && <PaymentResult payment={payment} />}
</NavigatorProvider> </NavigatorProvider>
@@ -114,7 +113,8 @@ function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick
case 'characters': return <CharactersScreen />; case 'characters': return <CharactersScreen />;
case 'streak': return <StreakScreen />; case 'streak': return <StreakScreen />;
case 'support': return <SupportScreen />; case 'support': return <SupportScreen />;
case 'family': return <FamilyScreen onSignedOut={onSignedOut} />; case 'report': return <ReportScreen />;
case 'invoices': return <InvoicesScreen />;
case 'invite': return <InviteScreen />; case 'invite': return <InviteScreen />;
case 'register': return <RegisterView onRegistered={onRegistered} />; case 'register': return <RegisterView onRegistered={onRegistered} />;
} }

View File

@@ -1,66 +0,0 @@
import { Minus, Plus } from 'lucide-react';
import { currentJalaliYear, toFa } from '@/lib/format';
import { sounds } from '@/lib/sound';
const MIN_AGE = 7;
const MAX_AGE = 19;
const DEFAULT_AGE = 13;
/** سرور سالِ تولدِ شمسی می‌خواهد، نه سن. */
export const birthYearFromAge = (age: number) => currentJalaliYear() - age;
export function ageFromBirthYear(year: number | null | undefined): number | null {
if (!year) return null;
const age = currentJalaliYear() - year;
return age >= MIN_AGE && age <= MAX_AGE ? age : null;
}
/**
* انتخابِ سن با دو دکمه‌ی − و +. جای «چرخِ» قبلی نشست که عددِ وسطش با مقدارِ
* واقعی نمی‌خواند و کلیک روی عددهایش کار نمی‌کرد.
*/
export function AgePicker({ value, onChange }: { value: number | null; onChange: (age: number) => void }) {
const current = value ?? DEFAULT_AGE;
const step = (delta: number) => {
const next = Math.max(MIN_AGE, Math.min(MAX_AGE, current + delta));
if (next !== current || value === null) {
sounds.tick();
onChange(next);
}
};
return (
<div className="flex items-center justify-between rounded-2xl border-2 border-line bg-white p-2">
<button
type="button"
aria-label="کمتر"
onClick={() => step(-1)}
disabled={value !== null && current <= MIN_AGE}
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
>
<Minus size={20} strokeWidth={3} />
</button>
<div className="text-center" aria-live="polite">
{value === null ? (
<button type="button" onClick={() => onChange(DEFAULT_AGE)} className="text-sm font-bold text-muted">
بزن تا انتخاب کنی
</button>
) : (
<>
<span className="text-3xl font-black text-coral">{toFa(value)}</span>
<span className="mr-1 text-sm font-bold text-muted">سال</span>
</>
)}
</div>
<button
type="button"
aria-label="بیشتر"
onClick={() => step(1)}
disabled={value !== null && current >= MAX_AGE}
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
>
<Plus size={20} strokeWidth={3} />
</button>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react';
import { toEnDigits, toFa } from '@/lib/format';
/**
* تاریخِ تولدِ شمسی با سه فهرستِ روز/ماه/سال (از ۱۴۰۵-۰۷ به جای «چند سالته؟»).
* <select> عمداً بومی است: روی گوشی همان چرخِ آشنای خودِ گوشی باز می‌شود و تقویمِ
* ماهانه‌ای که برای تاریخِ سی سال پیش باید ده‌ها بار ورق بخورد لازم نیست.
*
* سرور تاریخ را شمسی می‌گیرد («1385-04-12») و خودش به میلادی تبدیل می‌کند
* (accounts/jalali.py)؛ این‌جا هیچ تبدیلی انجام نمی‌شود، فقط تعدادِ روزهای ماه.
*/
export const JALALI_MONTHS = [
'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور',
'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند',
];
/** همان بازه‌ی سرور (accounts/serializers.py: MIN_AGE / MAX_AGE). */
const MIN_AGE = 5;
const MAX_AGE = 99;
/** از این سن به بعد فرمِ آشنایی به‌جای «کدوم پایه‌ای؟» می‌پرسد «ریاضیِ کدوم پایه؟». */
export const ADULT_AGE = 19;
type Parts = { y: number; m: number; d: number };
function jalaliParts(date: Date): Parts {
const parts = new Intl.DateTimeFormat('en-US-u-ca-persian', { year: 'numeric', month: 'numeric', day: 'numeric' }).formatToParts(date);
const get = (type: string) => parseInt(toEnDigits(parts.find((p) => p.type === type)?.value ?? '0'), 10);
return { y: get('year'), m: get('month'), d: get('day') };
}
export const todayJalali = () => jalaliParts(new Date());
/** کبیسه یعنی سی‌امِ اسفند وجود دارد؛ آن روز همیشه یکی از ۱۹ تا ۲۱ مارسِ سالِ بعدِ میلادی است. */
function isLeap(y: number): boolean {
return [19, 20, 21].some((day) => {
const p = jalaliParts(new Date(y + 622, 2, day, 12));
return p.y === y && p.m === 12 && p.d === 30;
});
}
function monthLength(y: number, m: number): number {
if (m <= 6) return 31;
if (m <= 11) return 30;
return isLeap(y) ? 30 : 29;
}
function parse(value: string | null | undefined): Parts | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
return match ? { y: +match[1], m: +match[2], d: +match[3] } : null;
}
const pad = (n: number) => String(n).padStart(2, '0');
/** «1385-04-12» → «۱۲ تیر ۱۳۸۵». */
export function formatBirthDate(value: string | null): string {
const p = parse(value);
return p ? `${toFa(p.d)} ${JALALI_MONTHS[p.m - 1]} ${toFa(p.y)}` : '';
}
/** سنِ کامل از تاریخِ تولد؛ برای کاربرِ قدیمی که فقط سال داده، تقریبی از سال. */
export function ageOf(birthDate: string | null, birthYear: number | null): number | null {
const today = todayJalali();
const born = parse(birthDate);
if (born) return today.y - born.y - (today.m < born.m || (today.m === born.m && today.d < born.d) ? 1 : 0);
return birthYear ? today.y - birthYear : null;
}
export function BirthDatePicker({
value,
fallbackYear = null,
onChange,
}: {
value: string | null;
/** سالِ تولدِ کاربرِ قدیمی (بی‌تاریخ) تا فهرستِ سال خالی باز نشود. */
fallbackYear?: number | null;
/** فقط وقتی هر سه انتخاب شده‌اند تاریخ می‌دهد؛ تا آن موقع null. */
onChange: (value: string | null) => void;
}) {
const initial = parse(value);
const [y, setY] = useState<number | ''>(initial?.y ?? fallbackYear ?? '');
const [m, setM] = useState<number | ''>(initial?.m ?? '');
const [d, setD] = useState<number | ''>(initial?.d ?? '');
// والد هر بار تابعِ تازه می‌سازد؛ با ref، تغییرِ تابع خودش onChange را دوباره صدا نمی‌زند.
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const thisYear = todayJalali().y;
const years = Array.from({ length: MAX_AGE - MIN_AGE + 1 }, (_, i) => thisYear - MIN_AGE - i);
const days = monthLength(typeof y === 'number' ? y : thisYear, typeof m === 'number' ? m : 1);
// سی‌ویکِ مهر یا سی‌امِ اسفندِ غیرکبیسه با عوض شدنِ ماه/سال معتبر نمی‌ماند.
useEffect(() => {
if (typeof d === 'number' && d > days) setD(days);
}, [d, days]);
useEffect(() => {
const complete = y !== '' && m !== '' && d !== '' && d <= days;
onChangeRef.current(complete ? `${y}-${pad(m)}-${pad(d)}` : null);
}, [y, m, d, days]);
const select = 'w-full appearance-none rounded-2xl border-2 bg-white px-3 py-3.5 text-center text-sm font-bold outline-none focus:border-coral';
const tone = (v: number | '') => (v === '' ? 'border-line text-muted' : 'border-coral text-ink');
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)}`}>
<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)}`}>
<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)}`}>
<option value="">سال</option>
{years.map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
</select>
</div>
);
}

View File

@@ -14,7 +14,10 @@ export type Route =
| { name: 'characters' } | { name: 'characters' }
| { name: 'streak' } | { name: 'streak' }
| { name: 'support' } | { name: 'support' }
| { name: 'family' } /** «گزارشِ والدین»: والد پیشرفتِ هفته را در حسابِ خودِ دانش‌آموز می‌بیند. */
| { name: 'report' }
/** پرداخت‌ها و فاکتورهای خودِ کاربر. */
| { name: 'invoices' }
| { name: 'invite' } | { name: 'invite' }
| { name: 'register' }; | { name: 'register' };

View File

@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { BookOpen, MessageSquarePlus, RotateCw, Send, ThumbsDown, ThumbsUp } from 'lucide-react'; import { BookOpen, MessageSquarePlus, RotateCw, Send, ThumbsDown, ThumbsUp } from 'lucide-react';
import { api } from '@/api'; import { api, ApiError } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { askStore, useAskState, type ChatMessage } from '@/state/askStore'; import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
import { Screen, ScreenHeader } from '@/components/ui'; import { Screen, ScreenHeader } from '@/components/ui';
@@ -92,10 +93,13 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
askStore.setConversation(done.conversationId); askStore.setConversation(done.conversationId);
setQuota(done.remainingQuota); setQuota(done.remainingQuota);
} catch (err) { } catch (err) {
// سقفِ روزانه (نه throttleِ چندثانیه‌ای): «دوباره» تا فردا بی‌فایده است.
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
askStore.update(replyId, { askStore.update(replyId, {
text: friendlyError(err, 'جوابی نرسید'), text: friendlyError(err, 'جوابی نرسید'),
failedQuestion: question, ...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
}); });
if (quotaReached) setQuota(0);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -263,6 +267,7 @@ function Message({
onFollowUp: (q: string) => void; onFollowUp: (q: string) => void;
}) { }) {
const toast = useToast(); const toast = useToast();
const nav = useNav();
if (message.role === 'user') return <Bubble role="user"><MathText text={message.text} /></Bubble>; if (message.role === 'user') return <Bubble role="user"><MathText text={message.text} /></Bubble>;
@@ -286,7 +291,11 @@ function Message({
<MathText text={message.text} /> <MathText text={message.text} />
</Bubble> </Bubble>
<div className="flex flex-wrap items-center gap-2 pr-10"> <div className="flex flex-wrap items-center gap-2 pr-10">
{message.failedQuestion ? ( {message.quotaReached ? (
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-violet px-3 py-1 text-xs font-bold text-white">
دیدنِ اشتراک‌ها
</button>
) : message.failedQuestion ? (
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50"> <button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">
<RotateCw size={12} /> دوباره امتحان کن <RotateCw size={12} /> دوباره امتحان کن
</button> </button>

View File

@@ -1,203 +0,0 @@
import { useState } from 'react';
import { BookOpen, Flame, Link2, LogOut, Star, Trophy, UserPlus } from 'lucide-react';
import { useNav } from '@/nav/Navigator';
import { useAppData } from '@/state/AppData';
import { api, ApiError, type FamilyLink, type FamilyWeeklyReport } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
/**
* بخشِ والدین. والد با شماره‌ی فرزند دعوت می‌سازد و یک کد می‌گیرد؛ فرزند
* (با همان شماره وارد شده) کد را اینجا وارد می‌کند. بعد از اتصال، والد
* گزارشِ هفتگی را می‌بیند.
*/
export function FamilyScreen({ onSignedOut }: { onSignedOut: () => void }) {
const nav = useNav();
const { profile } = useAppData();
// دانش‌آموز (پایه دارد) پیش از اتصال فقط «کدِ دعوت» را می‌بیند؛ فرمِ والد برای او پشتِ یک
// پیوند است. قبلاً هر دو فرم با هم بود و بچه نمی‌دانست کدام مالِ اوست. والد پایه ندارد.
const isStudent = profile.gradeId !== null;
const [showParentForm, setShowParentForm] = useState(false);
// GET /family/link نمی‌گوید کاربر در این اتصال والد است یا فرزند؛ گزارشِ
// هفتگی فقط برای والد جواب می‌دهد و برای فرزند ۴۰۴ است، پس نقش از همان درمی‌آید.
const state = useAsync(async () => {
const link = await api.family.link().catch((err) => {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
});
if (!link || link.status !== 'accepted') return { link, role: null, report: null };
try {
return { link, role: 'parent' as const, report: await api.family.weeklyReport() };
} catch (err) {
if (err instanceof ApiError && (err.status === 404 || err.status === 403)) return { link, role: 'child' as const, report: null };
throw err;
}
}, []);
const data = state.data;
return (
<Screen>
<ScreenHeader title="بخشِ والدین" subtitle={isStudent ? 'به پدر یا مادرت وصل شو تا پیشرفتت رو ببینن' : 'پیشرفتِ فرزندت رو هر هفته ببین'} color="#FF7A3D" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
{state.loading ? (
<LoadingState />
) : state.error ? (
<ErrorState error={state.error} onRetry={state.reload} />
) : (
<>
{data?.link && <LinkStatus link={data.link} role={data.role} />}
{data?.report && <WeeklyReport report={data.report} />}
{!data?.role && isStudent && <AcceptInvite onDone={state.reload} />}
{data?.role !== 'child' && (!isStudent || showParentForm || data?.role === 'parent' || data?.link?.status === 'pending') && <InviteChild onDone={state.reload} />}
{!data?.role && !isStudent && <AcceptInvite onDone={state.reload} />}
{!data?.role && isStudent && !showParentForm && data?.link?.status !== 'pending' && (
<button type="button" onClick={() => setShowParentForm(true)} className="py-1 text-center text-xs font-bold text-muted">
پدر یا مادرِ یک دانش‌آموز هستی؟ از اینجا دعوت بساز
</button>
)}
</>
)}
{/* والد بدونِ پایه نوارِ پایین را ندارد؛ خروج باید همین‌جا باشد. */}
{!nav.canGoBack && (
<button
type="button"
onClick={() => void api.auth.logout().catch(() => {}).finally(onSignedOut)}
className="flex items-center justify-center gap-2 py-3 text-sm font-bold text-red-500"
>
<LogOut size={16} /> خروج از حساب
</button>
)}
</ScreenBody>
</Screen>
);
}
function LinkStatus({ link, role }: { link: FamilyLink; role: 'parent' | 'child' | null }) {
const accepted = link.status === 'accepted';
return (
<div className={`flex items-center gap-3 rounded-3xl p-4 ${accepted ? 'bg-[#EAF8F0]' : 'bg-[#FFF8E1]'}`}>
<Link2 size={20} className={accepted ? 'text-mint' : 'text-gold'} />
<p className="text-sm leading-7 text-ink">
{!accepted
? 'دعوت فرستاده شده و منتظرِ تأییدِ فرزندته.'
: role === 'child'
? 'حسابت به پدر یا مادرت وصله؛ اون‌ها هر هفته پیشرفتت رو می‌بینن.'
: <>به <b>{link.childName || 'فرزندت'}</b> وصلی.</>}
</p>
</div>
);
}
function WeeklyReport({ report: r }: { report: FamilyWeeklyReport }) {
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-3 text-sm font-black text-ink">گزارشِ این هفته <span className="text-xs font-medium text-muted">(از {formatJalaliDate(r.weekStart)})</span></h2>
<div className="grid grid-cols-2 gap-2">
{[
{ icon: Star, label: 'امتیاز', value: r.xpEarned },
{ icon: BookOpen, label: 'درسِ تمام‌شده', value: r.lessonsCompleted },
{ icon: Trophy, label: 'جزیره‌ی تمام‌شده', value: r.islandsCompleted },
{ icon: Flame, label: 'زنجیره (روز)', value: r.currentStreak },
].map(({ icon: Icon, label, value }) => (
<div key={label} className="flex items-center gap-2 rounded-2xl bg-soft px-3 py-2.5">
<Icon size={16} className="text-orange" />
<span className="flex-1 text-xs text-body">{label}</span>
<span className="text-sm font-black text-ink">{toFa(value)}</span>
</div>
))}
</div>
</section>
);
}
function InviteChild({ onDone }: { onDone: () => void }) {
const [phone, setPhone] = useState('');
const [code, setCode] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function invite() {
setBusy(true);
setError('');
try {
const r = await api.family.invite(phone);
setCode(r.inviteCode);
onDone();
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
}
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-1 flex items-center gap-2 text-sm font-black text-ink"><UserPlus size={16} className="text-orange" /> من والدم</h2>
<p className="mb-3 text-xs leading-6 text-muted">شماره‌ای که فرزندت باهاش وارد تدریسینو شده رو بنویس.</p>
{code ? (
<div className="rounded-2xl bg-soft p-4 text-center">
<p className="text-xs text-muted">این کد رو به فرزندت بده تا تو بخشِ والدینِ حسابِ خودش وارد کنه:</p>
<p dir="ltr" className="mt-2 text-2xl font-black tracking-widest text-ink">{code}</p>
</div>
) : (
<>
<input
value={phone}
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))}
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
dir="ltr"
inputMode="numeric"
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-left outline-none focus:border-orange"
/>
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<PrimaryButton color="#FF7A3D" loading={busy} disabled={!/^09\d{9}$/.test(phone)} onClick={() => void invite()}>
ساختِ کدِ دعوت
</PrimaryButton>
</>
)}
</section>
);
}
function AcceptInvite({ onDone }: { onDone: () => void }) {
const toast = useToast();
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function accept() {
setBusy(true);
setError('');
try {
await api.family.accept(code.trim());
toast('به والدت وصل شدی', 'success');
setCode('');
onDone();
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
}
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-1 text-sm font-black text-ink">من دانش‌آموزم</h2>
<p className="mb-3 text-xs leading-6 text-muted">اگه پدر یا مادرت کدِ دعوت بهت داده، اینجا واردش کن.</p>
<input
value={code}
onChange={(e) => setCode(e.target.value.slice(0, 32))}
placeholder="کدِ دعوت"
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-orange"
/>
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<PrimaryButton color="#FF7A3D" loading={busy} disabled={!code.trim()} onClick={() => void accept()}>
وصل شو
</PrimaryButton>
</section>
);
}

View File

@@ -0,0 +1,140 @@
import { useState } from 'react';
import { ChevronLeft, Copy, Receipt } from 'lucide-react';
import { api, type Invoice } from '@/api';
import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { Sheet } from '@/components/Sheet';
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader, Sep } from '@/components/ui';
import { formatJalaliDate, formatToman } from '@/lib/format';
/**
* «پرداخت‌ها و فاکتورها» (۱۴۰۵-۰۷): همه‌ی سفارش‌های کاربر، با فاکتورِ هر خرید.
* سفارشِ «در انتظار» و «ناموفق» هم نشان داده می‌شود تا کسی که پولش کم شده ولی
* اشتراکش فعال نشده، شماره‌ی فاکتور را به پشتیبانی بدهد. عددها از GET /payment/invoices.
*/
const STATUS: Record<Invoice['status'], { label: string; className: string }> = {
paid: { label: 'پرداخت‌شده', className: 'bg-[#EAF8F0] text-[#2E7D5B]' },
pending: { label: 'در انتظارِ پرداخت', className: 'bg-[#FFF8E1] text-[#8A6100]' },
failed: { label: 'ناموفق', className: 'bg-soft text-muted' },
};
const PROVIDER: Record<string, string> = { gateway: 'درگاهِ زرین‌پال', bazaar: 'کافه‌بازار' };
const dateOf = (invoice: Invoice) => formatJalaliDate(invoice.paidAt ?? invoice.createdAt);
export function InvoicesScreen() {
const nav = useNav();
const list = useAsync(() => api.subscription.invoices(), []);
const [open, setOpen] = useState<Invoice | null>(null);
return (
<Screen>
<ScreenHeader title="پرداخت‌ها و فاکتورها" subtitle="همه‌ی خریدهات، با شماره‌ی فاکتور و کدِ پیگیری" color="#4C5FD5" back />
<ScreenBody className="flex flex-col gap-3">
{list.loading ? (
<LoadingState />
) : list.error || !list.data ? (
<ErrorState error={list.error} onRetry={list.reload} />
) : list.data.length === 0 ? (
<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>
</div>
) : (
list.data.map((invoice) => (
<button
key={invoice.orderId}
type="button"
onClick={() => setOpen(invoice)}
className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4 text-right active:scale-[0.99]"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-black text-ink">{invoice.planTitle || 'اشتراک'}</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold ${STATUS[invoice.status].className}`}>
{STATUS[invoice.status].label}
</span>
</div>
<div className="mt-1 text-xs text-muted">
{dateOf(invoice)}<Sep /><span dir="ltr">{invoice.invoiceNumber}</span>
</div>
</div>
<span className="text-sm font-black text-ink">{formatToman(invoice.amount)}</span>
<ChevronLeft size={16} className="text-faint" />
</button>
))
)}
</ScreenBody>
<InvoiceSheet invoice={open} onClose={() => setOpen(null)} />
</Screen>
);
}
function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose: () => void }) {
const toast = useToast();
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text);
toast('کپی شد', 'success');
} catch {
toast(text, 'success');
}
}
const rows: [string, React.ReactNode][] = invoice
? [
['شماره‌ی فاکتور', <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})` : 'تخفیف',
formatToman(invoice.discountAmount),
] as [string, React.ReactNode]] : []),
['مبلغِ پرداختی', <b>{formatToman(invoice.amount)}</b>],
['روشِ پرداخت', PROVIDER[invoice.provider] ?? invoice.provider],
]
: [];
return (
<Sheet open={invoice !== null} onClose={onClose} title="فاکتور">
{invoice && (
<div className="flex flex-col gap-4">
<table className="w-full text-sm">
<tbody>
{rows.map(([label, value]) => (
<tr key={label} className="border-b border-line last:border-0">
<td className="py-2.5 text-muted">{label}</td>
<td className="py-2.5 text-left text-ink">{value}</td>
</tr>
))}
</tbody>
</table>
{invoice.refId ? (
<button
type="button"
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="flex items-center gap-2 font-bold text-ink" dir="ltr">
{/* کد همان‌طور که کپی می‌شود: با رقمِ فارسی صفرها نقطه دیده می‌شدند. */}
{invoice.refId} <Copy size={14} />
</span>
</button>
) : (
invoice.status !== 'paid' && (
<p className="text-xs leading-6 text-muted">
اگه پولی از حسابت کم شده ولی این خرید «پرداخت‌شده» نیست، شماره‌ی فاکتور رو به پشتیبانی بده.
</p>
)
)}
</div>
)}
</Sheet>
);
}

View File

@@ -1,4 +1,4 @@
import { Bell, Check, Crown, FileText, MessageCircle, Star, Trophy, Users, type LucideIcon } from 'lucide-react'; import { Bell, Check, Crown, MessageCircle, Star, Trophy, type LucideIcon } from 'lucide-react';
import { api, type NotificationItem } from '@/api'; import { api, type NotificationItem } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync'; import { useAsync } from '@/lib/useAsync';
@@ -13,8 +13,6 @@ const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = {
node_completed: { icon: Trophy, color: '#FF7A3D', bg: '#FFF1E6' }, node_completed: { icon: Trophy, color: '#FF7A3D', bg: '#FFF1E6' },
order_paid: { icon: Crown, color: '#9B7BFF', bg: '#F5F1FF' }, order_paid: { icon: Crown, color: '#9B7BFF', bg: '#F5F1FF' },
ticket_replied: { icon: MessageCircle, color: '#4C5FD5', bg: '#EEF1FF' }, ticket_replied: { icon: MessageCircle, color: '#4C5FD5', bg: '#EEF1FF' },
family_link_accepted: { icon: Users, color: '#FF7A3D', bg: '#FFF1E6' },
weekly_report_ready: { icon: FileText, color: '#91ADC6', bg: '#EAF2F6' },
}; };
const styleOf = (type: string) => STYLE[type] ?? { icon: Bell, color: '#9e9ec0', bg: '#f0eeff' }; const styleOf = (type: string) => STYLE[type] ?? { icon: Bell, color: '#9e9ec0', bg: '#f0eeff' };

View File

@@ -1,7 +1,7 @@
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { import {
Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut, BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Copy, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
Pencil, Smartphone, Sparkles, Star, Trophy, Users, Volume2, VolumeX, FileText, Pencil, Smartphone, Sparkles, Star, Trophy, Volume2, VolumeX,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
@@ -11,13 +11,13 @@ import type { Route } from '@/nav/routes';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import { Sheet } from '@/components/Sheet'; import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
import { AgePicker, ageFromBirthYear, birthYearFromAge } from '@/components/AgePicker'; import { BirthDatePicker, ageOf, formatBirthDate } from '@/components/BirthDatePicker';
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui'; import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { setSoundEnabled, soundEnabled } from '@/lib/sound'; import { setSoundEnabled, soundEnabled } from '@/lib/sound';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
type Editing = 'name' | 'grade' | 'age' | 'logout' | null; type Editing = 'name' | 'grade' | 'birth' | 'logout' | null;
/** «من»: پروفایل، تنظیمات و میان‌بُر به بخش‌های فرعی. */ /** «من»: پروفایل، تنظیمات و میان‌بُر به بخش‌های فرعی. */
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) { export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
@@ -25,7 +25,18 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
const nav = useNav(); const nav = useNav();
const [editing, setEditing] = useState<Editing>(null); const [editing, setEditing] = useState<Editing>(null);
const [sound, setSound] = useState(soundEnabled()); const [sound, setSound] = useState(soundEnabled());
const age = ageFromBirthYear(profile.birthYear); const toast = useToast();
const age = ageOf(profile.birthDate, profile.birthYear);
// شناسه همان شماره‌ی ثبتِ کاربر است؛ برای پشتیبانی، بی‌آن‌که شماره‌ی تلفن گفته شود.
async function copyId() {
try {
await navigator.clipboard.writeText(profile.userId);
toast('شناسه کپی شد', 'success');
} catch {
toast(`شناسه‌ی تو: ${toFa(profile.userId)}`, 'success');
}
}
const go = (route: Route) => () => nav.push(route); const go = (route: Route) => () => nav.push(route);
@@ -42,6 +53,9 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<button type="button" onClick={() => setEditing('name')} className="flex items-center gap-2 text-xl font-black"> <button type="button" onClick={() => setEditing('name')} className="flex items-center gap-2 text-xl font-black">
{profile.name || 'بی‌نام'} <Pencil size={14} className="text-white/70" /> {profile.name || 'بی‌نام'} <Pencil size={14} className="text-white/70" />
</button> </button>
<button type="button" onClick={() => void copyId()} className="mt-0.5 flex items-center gap-1 text-xs text-white/75" aria-label="کپیِ شناسه‌ی کاربری">
شناسه‌ی کاربری: <span className="font-bold text-white">{toFa(profile.userId)}</span> <Copy size={11} />
</button>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80"> <div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80">
{gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>} {gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
{age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>} {age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>}
@@ -86,11 +100,17 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<Menu> <Menu>
<Row icon={GraduationCap} label="پایه‌ی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} /> <Row icon={GraduationCap} label="پایه‌ی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
<Row icon={CalendarDays} label="سن" value={age ? `${toFa(age)} سال` : 'وارد نشده'} onClick={() => setEditing('age')} /> <Row
icon={CalendarDays}
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>
<Menu> <Menu>
<Row icon={BarChart3} label="گزارشِ والدین" onClick={go({ name: 'report' })} />
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} /> <Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} />
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} /> <Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} />
<Row icon={Bell} label="اعلان‌ها" onClick={go({ name: 'notifications' })} /> <Row icon={Bell} label="اعلان‌ها" onClick={go({ name: 'notifications' })} />
@@ -98,7 +118,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<Menu> <Menu>
{!profile.isGuest && <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />} {!profile.isGuest && <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />}
{!profile.isGuest && <Row icon={Users} label="بخشِ والدین" onClick={go({ name: 'family' })} />} <Row icon={FileText} label="پرداخت‌ها و فاکتورها" onClick={go({ name: 'invoices' })} />
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} /> <Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
<Row <Row
icon={sound ? Volume2 : VolumeX} icon={sound ? Volume2 : VolumeX}
@@ -119,7 +139,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} /> <NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} /> <GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
<AgeSheet open={editing === 'age'} onClose={() => setEditing(null)} /> <BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} /> <LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
</Screen> </Screen>
); );
@@ -222,17 +242,17 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
); );
} }
function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) { function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const { profile, setProfile } = useAppData(); const { profile, setProfile } = useAppData();
const toast = useToast(); const toast = useToast();
const [age, setAge] = useState<number | null>(ageFromBirthYear(profile.birthYear)); const [birthDate, setBirthDate] = useState<string | null>(profile.birthDate);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
async function save() { async function save() {
if (!age) return; if (!birthDate) return;
setSaving(true); setSaving(true);
try { try {
setProfile(await api.profile.update({ birthYear: birthYearFromAge(age) })); setProfile(await api.profile.update({ birthDate }));
toast('ذخیره شد', 'success'); toast('ذخیره شد', 'success');
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -243,11 +263,12 @@ function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
} }
return ( return (
<Sheet open={open} onClose={onClose} title="چند سالته؟"> <Sheet open={open} onClose={onClose} title="تاریخِ تولد">
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سالِ تولدت به شمسی.</p>
<div className="mb-5"> <div className="mb-5">
<AgePicker value={age} onChange={setAge} /> <BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
</div> </div>
<PrimaryButton loading={saving} disabled={!age} onClick={() => void save()}>ذخیره</PrimaryButton> <PrimaryButton loading={saving} disabled={!birthDate} onClick={() => void save()}>ذخیره</PrimaryButton>
</Sheet> </Sheet>
); );
} }

View File

@@ -0,0 +1,346 @@
import { useState, type ReactNode } from 'react';
import { BookOpen, CalendarCheck, CheckCircle2, Clock, Flame, MessageCircle, Target, type LucideIcon } from 'lucide-react';
import { api, type ReportDay, type ReportTotals, type WeeklyReport } from '@/api';
import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarMood } from '@/mascots/seyar';
import { ErrorState, LoadingState, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { toFa } from '@/lib/format';
/*
* «گزارشِ والدین». تا ۱۴۰۵-۰۷ والد حسابِ جدا و کدِ دعوت داشت؛ به خواستِ سینیور
* حذف شد و والد همین صفحه را در حسابِ خودِ دانش‌آموز می‌بیند. همه‌ی عددها از
* GET /user/weekly-report می‌آیند: ۷ روزِ اخیر در کنارِ ۷ روزِ قبلش.
*
* رنگِ نمودارها با validate_palette (مهارتِ dataviz) سنجیده شده: هر جفت برای
* کوررنگیِ قرمز-سبز هم جداست. نارنجی و طلایی کنتراستِ کمتر از ۳:۱ با سفید دارند،
* برای همین عددِ هر روز با زدن روی ستون و در جدولِ پایین هم نوشته می‌شود.
*/
const THIS_WEEK = '#4C5FD5';
const LAST_WEEK = '#FF7A3D';
const CORRECT = '#2E9E73';
const WRONG = '#E8A400';
const WEEKDAY_SHORT = ['ی', 'د', 'س', 'چ', 'پ', 'ج', 'ش'];
/** «2026-10-01» به تاریخِ محلی؛ new Date(iso) آن را UTC می‌خواند و روز جابه‌جا می‌شد. */
function localDate(iso: string): Date {
const [y, m, d] = iso.split('-').map(Number);
return new Date(y, m - 1, d);
}
const longDay = (iso: string) =>
new Intl.DateTimeFormat('fa-IR-u-ca-persian', { weekday: 'long', day: 'numeric', month: 'long' }).format(localDate(iso));
const percent = (part: number, whole: number) => (whole ? Math.round((part / whole) * 100) : 0);
export function ReportScreen() {
const { profile } = useAppData();
const report = useAsync(() => api.weeklyReport(), []);
return (
<Screen>
<ScreenHeader
title="گزارشِ والدین"
subtitle={`پیشرفتِ ${profile.name || 'دانش‌آموز'} در ۷ روزِ اخیر`}
color={THIS_WEEK}
back
mascot={{ char: 'sigma', mood: 'happy', size: 56 }}
/>
<ScreenBody className="flex flex-col gap-4">
{report.loading ? (
<LoadingState />
) : report.error || !report.data ? (
<ErrorState error={report.error} onRetry={report.reload} />
) : (
<Report data={report.data} name={profile.name} />
)}
</ScreenBody>
</Screen>
);
}
function Report({ data, name }: { data: WeeklyReport; name: string }) {
const { thisWeek: now, lastWeek: before } = data;
return (
<>
<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={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={CheckCircle2}
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={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> روز
</div>
)}
<BarChart
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) },
]}
note="زمان تقریبی است: از فاصله‌ی جواب‌ها و پرسش‌ها حساب می‌شود، پس خواندنِ درس بدونِ جواب دادن در آن نیست."
/>
<BarChart
title="سوال‌های حل‌شده در هر روز"
description="کلِ ستون تعدادِ سوال‌هایی است که آن روز جواب داده؛ بخشِ سبز جواب‌های درست است و بخشِ طلایی نادرست."
unit="سوال"
days={data.days}
mode="stacked"
series={[
{ label: 'درست', color: CORRECT, values: data.days.map((d) => d.correct) },
{ label: 'نادرست', color: WRONG, values: data.days.map((d) => d.questions - d.correct) },
]}
/>
<DayTable days={data.days} previous={data.previousDays} />
</>
);
}
/* ─── جمله‌ی بالای صفحه ─────────────────────────────────────────────────────── */
/** مقایسه با زمانِ تمرین؛ تغییرِ کمتر از ۱۰٪ یا ۳ دقیقه «مثلِ هفته‌ی پیش» است، نه بالا و پایینِ اتفاقی. */
function verdict(now: ReportTotals, before: ReportTotals, name: string): { mood: SeyarMood; text: string } {
const who = name ? `${name} ` : '';
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: 'این هفته هنوز تمرینی ثبت نشده. یه جزیره‌ی کوتاه شروعِ خوبیه!' };
}
if (!before.minutes) {
return { mood: 'excited', text: `آفرین! ${who}این هفته شروع کرده و ${toFa(now.activeDays)} روز تمرین کرده.` };
}
if (meaningful && diff > 0) {
return { mood: 'excited', text: `آفرین! ${who}این هفته حدودِ ${toFa(diff)} دقیقه بیشتر از هفته‌ی پیش تمرین کرده.` };
}
if (meaningful && diff < 0) {
return { mood: 'idle', text: `این هفته حدودِ ${toFa(-diff)} دقیقه کمتر از هفته‌ی پیش تمرین شده. یه تشویقِ کوچیک کمک می‌کنه!` };
}
return { mood: 'happy', text: `${who}مثلِ هفته‌ی پیش منظم تمرین کرده؛ همین‌طور ادامه بده!` };
}
function Verdict({ now, before, name }: { now: ReportTotals; before: ReportTotals; name: string }) {
const { mood, text } = verdict(now, before, name);
return (
<div className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4">
<SeyarMascot char="sigma" mood={mood} size={52} />
<p className="flex-1 text-sm font-bold leading-7 text-ink">{text}</p>
</div>
);
}
/* ─── کاشی‌های عدد ─────────────────────────────────────────────────────────── */
type Delta = { text: string; up: boolean | null } | null;
function change(now: number, before: number, unit: string): Delta {
const diff = now - before;
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 };
}
function Stat({ icon: Icon, label, value, delta }: { icon: LucideIcon; label: string; value: string; delta: Delta }) {
return (
<div className="flex flex-col gap-1 rounded-2xl border border-line bg-white px-3 py-3">
<span className="flex items-center gap-1.5 text-[11px] font-bold text-muted">
<Icon size={13} /> {label}
</span>
<span className="text-lg font-black text-ink">{value}</span>
{delta && (
<span className={`text-[11px] font-bold ${delta.up ? 'text-[#2E7D5B]' : 'text-muted'}`}>
{delta.up === null ? '' : delta.up ? '▲ ' : '▼ '}
{delta.text}
</span>
)}
</div>
);
}
/* ─── نمودارِ ستونی ─────────────────────────────────────────────────────────── */
interface Series {
label: string;
color: string;
values: number[];
}
/** سقفِ گرد برای محورِ عمودی: ۱، ۲، ۵ × ۱۰ⁿ. */
function niceMax(value: number): number {
if (value <= 0) return 1;
const pow = 10 ** Math.floor(Math.log10(value));
return [1, 2, 5, 10].map((m) => m * pow).find((m) => m >= value) ?? value;
}
const PLOT_HEIGHT = 128;
function BarChart({
title,
description,
unit,
days,
series,
mode,
note,
}: {
title: string;
/** یک جمله برای والد: این نمودار چه می‌گوید. */
description: string;
unit: string;
days: ReportDay[];
series: Series[];
/** grouped: ستون‌های کنارِ هم (مقایسه)؛ stacked: روی هم (بخش‌های یک کل). */
mode: 'grouped' | 'stacked';
note?: ReactNode;
}) {
const [selected, setSelected] = useState(days.length - 1);
const totals = days.map((_, i) => series.reduce((sum, s) => sum + s.values[i], 0));
const peak = mode === 'stacked' ? Math.max(...totals) : Math.max(...series.flatMap((s) => s.values));
const max = niceMax(peak);
const empty = peak === 0;
const height = (v: number) => (v / max) * PLOT_HEIGHT;
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="text-sm font-black text-ink">{title}</h2>
<p className="mt-1 text-[11px] leading-5 text-muted">{description}</p>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-body">
{series.map((s) => (
<span key={s.label} className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: s.color }} />
{s.label}
</span>
))}
</div>
{/* خواندنِ روزِ انتخاب‌شده؛ همان کارِ tooltip را روی گوشی می‌کند. */}
<p className="mt-2 min-h-5 text-xs text-body" aria-live="polite">
<b className="text-ink">{longDay(days[selected].date)}:</b>{' '}
{series.map((s) => `${s.label} ${toFa(s.values[selected])} ${unit}`).join('، ')}
</p>
<div className="relative mt-2" style={{ height: PLOT_HEIGHT + 22 }}>
{/* خطِ راهنمای سقف و نیمه؛ کم‌رنگ تا با ستون‌ها رقابت نکند. */}
{[1, 0.5].map((f) => (
<div key={f} className="pointer-events-none absolute inset-x-0 border-t border-dashed border-line" style={{ bottom: 22 + PLOT_HEIGHT * f }}>
{/* نیمه‌ی سقفِ ۱ یا ۵ اعشاری است («۰.۵»)؛ فقط عددِ صحیح نوشته می‌شود. */}
{!empty && Number.isInteger(max * f) && (
<span className="absolute -top-2 left-0 bg-white pr-1 text-[10px] text-muted">{toFa(max * f)}</span>
)}
</div>
))}
<div className="absolute inset-x-0 border-t border-faint" style={{ bottom: 22 }} />
{empty && (
<p className="absolute inset-x-0 text-center text-xs text-muted" style={{ bottom: 22 + PLOT_HEIGHT / 2 - 8 }}>
هنوز چیزی ثبت نشده
</p>
)}
<div className="absolute inset-0 flex gap-1 pl-6">
{days.map((day, i) => {
const isToday = i === days.length - 1;
return (
<button
key={day.date}
type="button"
onClick={() => setSelected(i)}
aria-label={`${longDay(day.date)}: ${series.map((s) => `${s.label} ${toFa(s.values[i])} ${unit}`).join('، ')}`}
aria-pressed={selected === i}
className={`flex flex-1 flex-col items-center justify-end rounded-xl ${selected === i ? 'bg-soft' : ''}`}
>
<div className="flex items-end justify-center gap-0.5" style={{ height: PLOT_HEIGHT }}>
{mode === 'grouped' ? (
series.map((s) => (
<span
key={s.label}
className="w-2.5 rounded-t"
style={{ height: Math.max(height(s.values[i]), s.values[i] ? 3 : 0), backgroundColor: s.color }}
/>
))
) : (
// بخش‌ها از پایین روی هم؛ فاصله‌ی سفیدِ ۲ پیکسلی جدایشان می‌کند.
<span className="flex w-4 flex-col-reverse gap-0.5">
{series.map((s, k) =>
s.values[i] ? (
<span
key={s.label}
className={k === series.length - 1 || series.slice(k + 1).every((n) => !n.values[i]) ? 'rounded-t' : ''}
style={{ height: Math.max(height(s.values[i]) - 2, 3), backgroundColor: s.color }}
/>
) : null,
)}
</span>
)}
</div>
<span className={`mt-1 h-4 text-[10px] ${isToday ? 'font-black text-ink' : 'text-muted'}`}>
{isToday ? 'امروز' : WEEKDAY_SHORT[localDate(day.date).getDay()]}
</span>
</button>
);
})}
</div>
</div>
{note && <p className="mt-3 text-[11px] leading-5 text-muted">{note}</p>}
</section>
);
}
/* ─── جدولِ روزها ───────────────────────────────────────────────────────────── */
/** همان عددهای نمودارها، برای کسی که رنگ‌ها را از هم جدا نمی‌بیند یا عددِ دقیق می‌خواهد. */
function DayTable({ days, previous }: { days: ReportDay[]; previous: ReportDay[] }) {
return (
<details className="rounded-3xl border border-line bg-white p-4 text-xs">
<summary className="cursor-pointer font-black text-ink">عددهای هر روز</summary>
<table className="mt-3 w-full text-center">
<thead className="text-[11px] text-muted">
<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>
</tr>
</thead>
<tbody className="text-body">
{days.map((d, i) => (
<tr key={d.date} className="border-t border-line">
<td className="py-1.5 text-right">{longDay(d.date)}</td>
<td>{toFa(d.minutes)}</td>
<td>{toFa(previous[i]?.minutes ?? 0)}</td>
<td>{toFa(d.questions)}</td>
<td>{toFa(d.correct)}</td>
</tr>
))}
</tbody>
</table>
</details>
);
}

View File

@@ -2,26 +2,27 @@ import { useState } from 'react';
import { Check, ChevronLeft } from 'lucide-react'; import { Check, ChevronLeft } from 'lucide-react';
import { api, type Grade, type UserProfile } from '@/api'; import { api, type Grade, type UserProfile } from '@/api';
import { InlineError, PrimaryButton } from '@/components/ui'; import { InlineError, PrimaryButton } from '@/components/ui';
import { AgePicker, ageFromBirthYear, birthYearFromAge } from '@/components/AgePicker'; import { ADULT_AGE, BirthDatePicker, ageOf } from '@/components/BirthDatePicker';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
/** «بیا با هم آشنا بشیم»: اسم، سن و پایه، بارِ اولِ ورود. */ /**
* «بیا با هم آشنا بشیم»: اسم، تاریخِ تولد و پایه، بارِ اولِ ورود.
* بزرگسال هم می‌آید (کسی که فقط یک مبحث را می‌خواهد یاد بگیرد)؛ برای او «پایه»
* یعنی ریاضیِ کدام پایه، نه کلاسی که در آن است.
*/
export function SetupScreen({ export function SetupScreen({
profile, profile,
grades, grades,
onDone, onDone,
onParent,
}: { }: {
profile: UserProfile; profile: UserProfile;
grades: Grade[]; grades: Grade[];
onDone: (profile: UserProfile) => void; onDone: (profile: UserProfile) => void;
/** «من والدم»: بدونِ پایه، مستقیم به بخشِ والدین. */
onParent: (profile: UserProfile) => void;
}) { }) {
const [name, setName] = useState(profile.name); const [name, setName] = useState(profile.name);
const [age, setAge] = useState<number | null>(ageFromBirthYear(profile.birthYear)); const [birthDate, setBirthDate] = useState<string | null>(profile.birthDate);
const [gradeId, setGradeId] = useState<number | null>(profile.gradeId); const [gradeId, setGradeId] = useState<number | null>(profile.gradeId);
const [touched, setTouched] = useState(false); const [touched, setTouched] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -29,6 +30,8 @@ export function SetupScreen({
const nameMissing = touched && !name.trim(); const nameMissing = touched && !name.trim();
const gradeMissing = touched && !gradeId; const gradeMissing = touched && !gradeId;
const age = ageOf(birthDate, null);
const adult = age !== null && age >= ADULT_AGE;
async function submit() { async function submit() {
setTouched(true); setTouched(true);
@@ -39,7 +42,7 @@ export function SetupScreen({
await api.profile.setGrade(gradeId); await api.profile.setGrade(gradeId);
const updated = await api.profile.update({ const updated = await api.profile.update({
name: name.trim(), name: name.trim(),
...(age ? { birthYear: birthYearFromAge(age) } : {}), ...(birthDate ? { birthDate } : {}),
}); });
onDone({ ...updated, gradeId }); onDone({ ...updated, gradeId });
} catch (err) { } catch (err) {
@@ -49,20 +52,6 @@ export function SetupScreen({
} }
} }
async function submitAsParent() {
setTouched(true);
if (!name.trim() || saving) return;
setSaving(true);
setError('');
try {
onParent(await api.profile.update({ name: name.trim() }));
} catch (err) {
setError(friendlyError(err, 'ذخیره نشد، دوباره امتحان کن'));
} finally {
setSaving(false);
}
}
return ( return (
<div className="flex h-full flex-col bg-page"> <div className="flex h-full flex-col bg-page">
<header className="relative overflow-hidden rounded-b-[32px] bg-coral px-5 pb-7 pt-safe text-white"> <header className="relative overflow-hidden rounded-b-[32px] bg-coral px-5 pb-7 pt-safe text-white">
@@ -95,13 +84,16 @@ export function SetupScreen({
<div> <div>
<span className="mb-2 block text-sm font-black text-ink"> <span className="mb-2 block text-sm font-black text-ink">
چند سالته؟ <span className="text-xs font-medium text-muted">(اختیاری)</span> تاریخِ تولدت <span className="text-xs font-medium text-muted">(به شمسی، اختیاری)</span>
</span> </span>
<AgePicker value={age} onChange={setAge} /> <BirthDatePicker value={birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
</div> </div>
<div> <div>
<span className="mb-3 block text-sm font-black text-ink">کدوم پایه‌ای؟</span> <span className="mb-3 block text-sm font-black text-ink">
{adult ? 'ریاضیِ کدوم پایه رو می‌خوای یاد بگیری؟' : 'کدوم پایه‌ای؟'}
{adult && <span className="mt-1 block text-xs font-medium text-muted">هر وقت خواستی از پروفایل عوضش کن.</span>}
</span>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{grades.map((g) => { {grades.map((g) => {
const selected = gradeId === g.id; const selected = gradeId === g.id;
@@ -131,7 +123,7 @@ export function SetupScreen({
); );
})} })}
</div> </div>
{gradeMissing && <span className="mt-2 block text-xs text-red-500">پایه‌ت رو انتخاب کن</span>} {gradeMissing && <span className="mt-2 block text-xs text-red-500">{adult ? 'یه پایه رو انتخاب کن' : 'پایه‌ت رو انتخاب کن'}</span>}
</div> </div>
{error && <InlineError>{error}</InlineError>} {error && <InlineError>{error}</InlineError>}
@@ -139,9 +131,6 @@ export function SetupScreen({
<PrimaryButton onClick={() => void submit()} loading={saving} className="mt-auto"> <PrimaryButton onClick={() => void submit()} loading={saving} className="mt-auto">
شروعِ یادگیری <ChevronLeft size={19} strokeWidth={3} /> شروعِ یادگیری <ChevronLeft size={19} strokeWidth={3} />
</PrimaryButton> </PrimaryButton>
<button type="button" onClick={() => void submitAsParent()} disabled={saving} className="-mt-2 py-2 text-sm font-bold text-muted disabled:opacity-40">
دانش‌آموز نیستم؛ والدم
</button>
</div> </div>
</div> </div>
); );

View File

@@ -18,6 +18,8 @@ export interface ChatMessage {
feedback?: 'up' | 'down'; feedback?: 'up' | 'down';
/** پیامِ خطا به‌جای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده می‌شود. */ /** پیامِ خطا به‌جای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده می‌شود. */
failedQuestion?: string; failedQuestion?: string;
/** سهمیه‌ی رایگانِ امروز تمام شده؛ به‌جای «دوباره»، راهِ اشتراک نشان داده می‌شود. */
quotaReached?: boolean;
} }
interface AskState { interface AskState {