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

@@ -1,7 +1,7 @@
import { useState, type ReactNode } from 'react';
import {
Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
Pencil, Smartphone, Sparkles, Star, Trophy, Users, Volume2, VolumeX,
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Copy, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
FileText, Pencil, Smartphone, Sparkles, Star, Trophy, Volume2, VolumeX,
} from 'lucide-react';
import { api } from '@/api';
import { useAppData } from '@/state/AppData';
@@ -11,13 +11,13 @@ import type { Route } from '@/nav/routes';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { Sheet } from '@/components/Sheet';
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 { friendlyError } from '@/lib/errors';
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
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 }) {
@@ -25,7 +25,18 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
const nav = useNav();
const [editing, setEditing] = useState<Editing>(null);
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);
@@ -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">
{profile.name || 'بی‌نام'} <Pencil size={14} className="text-white/70" />
</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">
{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>}
@@ -86,11 +100,17 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<Menu>
<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' })} />
</Menu>
<Menu>
<Row icon={BarChart3} label="گزارشِ والدین" onClick={go({ name: 'report' })} />
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} />
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} />
<Row icon={Bell} label="اعلان‌ها" onClick={go({ name: 'notifications' })} />
@@ -98,7 +118,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<Menu>
{!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={sound ? Volume2 : VolumeX}
@@ -119,7 +139,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<NameSheet open={editing === 'name'} 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} />
</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 toast = useToast();
const [age, setAge] = useState<number | null>(ageFromBirthYear(profile.birthYear));
const [birthDate, setBirthDate] = useState<string | null>(profile.birthDate);
const [saving, setSaving] = useState(false);
async function save() {
if (!age) return;
if (!birthDate) return;
setSaving(true);
try {
setProfile(await api.profile.update({ birthYear: birthYearFromAge(age) }));
setProfile(await api.profile.update({ birthDate }));
toast('ذخیره شد', 'success');
onClose();
} catch (err) {
@@ -243,11 +263,12 @@ function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
}
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">
<AgePicker value={age} onChange={setAge} />
<BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
</div>
<PrimaryButton loading={saving} disabled={!age} onClick={() => void save()}>ذخیره</PrimaryButton>
<PrimaryButton loading={saving} disabled={!birthDate} onClick={() => void save()}>ذخیره</PrimaryButton>
</Sheet>
);
}