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:
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'motion/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 { askStore, useAskState, type ChatMessage } from '@/state/askStore';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { Screen, ScreenHeader } from '@/components/ui';
|
||||
@@ -92,10 +93,13 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
askStore.setConversation(done.conversationId);
|
||||
setQuota(done.remainingQuota);
|
||||
} catch (err) {
|
||||
// سقفِ روزانه (نه throttleِ چندثانیهای): «دوباره» تا فردا بیفایده است.
|
||||
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
|
||||
askStore.update(replyId, {
|
||||
text: friendlyError(err, 'جوابی نرسید'),
|
||||
failedQuestion: question,
|
||||
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
|
||||
});
|
||||
if (quotaReached) setQuota(0);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -263,6 +267,7 @@ function Message({
|
||||
onFollowUp: (q: string) => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const nav = useNav();
|
||||
|
||||
if (message.role === 'user') return <Bubble role="user"><MathText text={message.text} /></Bubble>;
|
||||
|
||||
@@ -286,7 +291,11 @@ function Message({
|
||||
<MathText text={message.text} />
|
||||
</Bubble>
|
||||
<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">
|
||||
<RotateCw size={12} /> دوباره امتحان کن
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
140
src/screens/InvoicesScreen.tsx
Normal file
140
src/screens/InvoicesScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 { useAppData } from '@/state/AppData';
|
||||
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' },
|
||||
order_paid: { icon: Crown, color: '#9B7BFF', bg: '#F5F1FF' },
|
||||
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' };
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
346
src/screens/ReportScreen.tsx
Normal file
346
src/screens/ReportScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,26 +2,27 @@ import { useState } from 'react';
|
||||
import { Check, ChevronLeft } from 'lucide-react';
|
||||
import { api, type Grade, type UserProfile } from '@/api';
|
||||
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 { friendlyError } from '@/lib/errors';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
/** «بیا با هم آشنا بشیم»: اسم، سن و پایه، بارِ اولِ ورود. */
|
||||
/**
|
||||
* «بیا با هم آشنا بشیم»: اسم، تاریخِ تولد و پایه، بارِ اولِ ورود.
|
||||
* بزرگسال هم میآید (کسی که فقط یک مبحث را میخواهد یاد بگیرد)؛ برای او «پایه»
|
||||
* یعنی ریاضیِ کدام پایه، نه کلاسی که در آن است.
|
||||
*/
|
||||
export function SetupScreen({
|
||||
profile,
|
||||
grades,
|
||||
onDone,
|
||||
onParent,
|
||||
}: {
|
||||
profile: UserProfile;
|
||||
grades: Grade[];
|
||||
onDone: (profile: UserProfile) => void;
|
||||
/** «من والدم»: بدونِ پایه، مستقیم به بخشِ والدین. */
|
||||
onParent: (profile: UserProfile) => void;
|
||||
}) {
|
||||
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 [touched, setTouched] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -29,6 +30,8 @@ export function SetupScreen({
|
||||
|
||||
const nameMissing = touched && !name.trim();
|
||||
const gradeMissing = touched && !gradeId;
|
||||
const age = ageOf(birthDate, null);
|
||||
const adult = age !== null && age >= ADULT_AGE;
|
||||
|
||||
async function submit() {
|
||||
setTouched(true);
|
||||
@@ -39,7 +42,7 @@ export function SetupScreen({
|
||||
await api.profile.setGrade(gradeId);
|
||||
const updated = await api.profile.update({
|
||||
name: name.trim(),
|
||||
...(age ? { birthYear: birthYearFromAge(age) } : {}),
|
||||
...(birthDate ? { birthDate } : {}),
|
||||
});
|
||||
onDone({ ...updated, gradeId });
|
||||
} 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 (
|
||||
<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">
|
||||
@@ -95,13 +84,16 @@ export function SetupScreen({
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-sm font-black text-ink">
|
||||
چند سالته؟ <span className="text-xs font-medium text-muted">(اختیاری)</span>
|
||||
تاریخِ تولدت <span className="text-xs font-medium text-muted">(به شمسی، اختیاری)</span>
|
||||
</span>
|
||||
<AgePicker value={age} onChange={setAge} />
|
||||
<BirthDatePicker value={birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
|
||||
</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">
|
||||
{grades.map((g) => {
|
||||
const selected = gradeId === g.id;
|
||||
@@ -131,7 +123,7 @@ export function SetupScreen({
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
|
||||
{error && <InlineError>{error}</InlineError>}
|
||||
@@ -139,9 +131,6 @@ export function SetupScreen({
|
||||
<PrimaryButton onClick={() => void submit()} loading={saving} className="mt-auto">
|
||||
شروعِ یادگیری <ChevronLeft size={19} strokeWidth={3} />
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user