diff --git a/src/api/index.ts b/src/api/index.ts index 790701a..6583fba 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -61,7 +61,7 @@ export const api = { profile: { get: () => http.get('/user/profile'), - update: (patch: Partial>) => + update: (patch: Partial>) => http.patch('/user/profile', patch), setGrade: (gradeId: number) => http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }), @@ -70,6 +70,8 @@ export const api = { summary: () => http.get('/user/summary'), hearts: () => http.get('/user/hearts'), streakCalendar: () => http.get('/user/streak/calendar'), + /** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */ + weeklyReport: () => http.get('/user/weekly-report'), /** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */ home: () => http.get('/home'), @@ -167,6 +169,8 @@ export const api = { redeem: (code: string, orderId: string) => http.post('/discount/redeem', { code, orderId }), order: (orderId: string) => http.get(`/payment/orders/${orderId}`), + /** پرداخت‌ها و فاکتورهای همین کاربر، تازه‌ترین اول. */ + invoices: () => http.get<{ invoices: T.Invoice[] }>('/payment/invoices').then((r) => r.invoices), }, support: { @@ -181,17 +185,6 @@ export const api = { http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }), }, - family: { - link: () => http.get('/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('/family/weekly-report'), - settings: () => http.get('/family/settings'), - updateSettings: (patch: Partial) => http.patch('/family/settings', patch), - }, - affiliate: { me: () => http.get('/affiliate/me'), /** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل می‌کند. */ diff --git a/src/api/types.ts b/src/api/types.ts index 0f77b83..3303cc5 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -34,7 +34,8 @@ export interface UserProfile { isGuest: boolean; gradeId: number | null; birthYear: number | null; - parentPhone: string; + /** تاریخِ تولدِ شمسی «1385-04-12»؛ null وقتی فقط سال داده شده یا هیچ. */ + birthDate: string | null; bio: string; } @@ -288,24 +289,57 @@ export interface Ticket { messages: { id: string; sender: string; text: string }[]; } -export interface FamilyLink { - familyLinkId: string; - status: string; - childName: string; +/** یک سفارش به شکلِ فاکتور (GET /payment/invoices). مبلغ‌ها تومان‌اند. */ +export interface Invoice { + orderId: 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 { - weekStart: string; - xpEarned: number; - lessonsCompleted: number; - islandsCompleted: number; - currentStreak: number; +/** یک روز از گزارشِ هفتگی. `minutes` تخمینِ سرور است (از فاصله‌ی جواب‌ها)، نه زمانِ باز بودنِ اپ. */ +export interface ReportDay { + date: string; + minutes: number; + questions: number; + correct: number; + xp: number; + lessons: number; + islands: number; + aiQuestions: number; } -export interface FamilySettings { - dailyLimitMinutes: number; - quietHoursStart: string; - quietHoursEnd: string; +export interface ReportTotals { + minutes: number; + activeDays: number; + 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 { diff --git a/src/app/App.tsx b/src/app/App.tsx index 6073195..dbf45fa 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { api, ApiError, http, type Grade, type UserProfile } from '@/api'; -import type { Route } from '@/nav/routes'; import { AppShell } from '@/components/AppShell'; import { ToastProvider } from '@/components/Toast'; import { ErrorState } from '@/components/ui'; @@ -20,7 +19,7 @@ type Flow = | { kind: 'onboarding' } | { kind: 'login' } | { 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; @@ -37,8 +36,6 @@ export function App() { const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]); await trackReferral(profile); 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 }); }, []); @@ -117,7 +114,6 @@ export function App() { profile={flow.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' && ( @@ -125,7 +121,6 @@ export function App() { profile={flow.profile} grades={flow.grades} payment={launchPayment} - start={flow.start} onSignedOut={() => setFlow({ kind: 'login' })} onRegistered={trackReferral} /> @@ -137,21 +132,6 @@ export function App() { ); } -/** - * آیا این حساب والد است؟ سرور نقش را صریح نمی‌دهد: پیوندِ «در انتظار» فقط برای - * والدی که دعوت ساخته دیده می‌شود، و گزارشِ هفتگی فقط به والد جواب می‌دهد. - */ -async function isParent(): Promise { - try { - const link = await api.family.link(); - if (link.status === 'pending') return true; - await api.family.weeklyReport(); - return true; - } catch { - return false; - } -} - /** * اگر کاربر با لینکِ دعوت آمده بود و حالا با شماره وارد شده، معرفی را ثبت کن. * مهمان را سرور نمی‌پذیرد، پس کد تا ثبتِ شماره نگه داشته می‌شود. diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index fa92347..3ded5d2 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -26,13 +26,14 @@ function screen>, K extends keyof M>(load } const AskScreen = screen(() => import('@/screens/AskScreen'), 'AskScreen'); const CharactersScreen = screen(() => import('@/screens/CharactersScreen'), 'CharactersScreen'); -const FamilyScreen = screen(() => import('@/screens/FamilyScreen'), 'FamilyScreen'); const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScreen'); +const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen'); const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen'); const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen'); const MapScreen = screen(() => import('@/screens/MapScreen'), 'MapScreen'); const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen'); const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen'); +const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen'); const QuizScreen = screen(() => import('@/screens/QuizScreen'), 'QuizScreen'); const StreakScreen = screen(() => import('@/screens/StreakScreen'), 'StreakScreen'); const SubscriptionScreen = screen(() => import('@/screens/SubscriptionScreen'), 'SubscriptionScreen'); @@ -52,18 +53,16 @@ interface Props { profile: UserProfile; grades: Grade[]; payment: PaymentReturn | null; - /** صفحه‌ی شروع؛ برای والد بخشِ والدین است. */ - start?: Route; onSignedOut: () => void; onRegistered: (profile: UserProfile) => Promise; } /** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */ -export function MainApp({ profile, grades, payment, start, onSignedOut, onRegistered }: Props) { +export function MainApp({ profile, grades, payment, onSignedOut, onRegistered }: Props) { useEffect(preloadScreens, []); return ( - + {payment && } @@ -114,7 +113,8 @@ function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick case 'characters': return ; case 'streak': return ; case 'support': return ; - case 'family': return ; + case 'report': return ; + case 'invoices': return ; case 'invite': return ; case 'register': return ; } diff --git a/src/components/AgePicker.tsx b/src/components/AgePicker.tsx deleted file mode 100644 index 98f7374..0000000 --- a/src/components/AgePicker.tsx +++ /dev/null @@ -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 ( -
- -
- {value === null ? ( - - ) : ( - <> - {toFa(value)} - سال - - )} -
- -
- ); -} diff --git a/src/components/BirthDatePicker.tsx b/src/components/BirthDatePicker.tsx new file mode 100644 index 0000000..9f83cd3 --- /dev/null +++ b/src/components/BirthDatePicker.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef, useState } from 'react'; +import { toEnDigits, toFa } from '@/lib/format'; + +/** + * تاریخِ تولدِ شمسی با سه فهرستِ روز/ماه/سال (از ۱۴۰۵-۰۷ به جای «چند سالته؟»). + * setD(e.target.value ? +e.target.value : '')} className={`${select} ${tone(d)}`}> + + {Array.from({ length: days }, (_, i) => i + 1).map((n) => )} + + + + + ); +} diff --git a/src/nav/routes.ts b/src/nav/routes.ts index c7cdc22..58557d7 100644 --- a/src/nav/routes.ts +++ b/src/nav/routes.ts @@ -14,7 +14,10 @@ export type Route = | { name: 'characters' } | { name: 'streak' } | { name: 'support' } - | { name: 'family' } + /** «گزارشِ والدین»: والد پیشرفتِ هفته را در حسابِ خودِ دانش‌آموز می‌بیند. */ + | { name: 'report' } + /** پرداخت‌ها و فاکتورهای خودِ کاربر. */ + | { name: 'invoices' } | { name: 'invite' } | { name: 'register' }; diff --git a/src/screens/AskScreen.tsx b/src/screens/AskScreen.tsx index 0172e7f..ffd73c6 100644 --- a/src/screens/AskScreen.tsx +++ b/src/screens/AskScreen.tsx @@ -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 ; @@ -286,7 +291,11 @@ function Message({
- {message.failedQuestion ? ( + {message.quotaReached ? ( + + ) : message.failedQuestion ? ( diff --git a/src/screens/FamilyScreen.tsx b/src/screens/FamilyScreen.tsx deleted file mode 100644 index 2f8ec1d..0000000 --- a/src/screens/FamilyScreen.tsx +++ /dev/null @@ -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 ( - - - - {state.loading ? ( - - ) : state.error ? ( - - ) : ( - <> - {data?.link && } - {data?.report && } - {!data?.role && isStudent && } - {data?.role !== 'child' && (!isStudent || showParentForm || data?.role === 'parent' || data?.link?.status === 'pending') && } - {!data?.role && !isStudent && } - {!data?.role && isStudent && !showParentForm && data?.link?.status !== 'pending' && ( - - )} - - )} - {/* والد بدونِ پایه نوارِ پایین را ندارد؛ خروج باید همین‌جا باشد. */} - {!nav.canGoBack && ( - - )} - - - ); -} - -function LinkStatus({ link, role }: { link: FamilyLink; role: 'parent' | 'child' | null }) { - const accepted = link.status === 'accepted'; - return ( -
- -

- {!accepted - ? 'دعوت فرستاده شده و منتظرِ تأییدِ فرزندته.' - : role === 'child' - ? 'حسابت به پدر یا مادرت وصله؛ اون‌ها هر هفته پیشرفتت رو می‌بینن.' - : <>به {link.childName || 'فرزندت'} وصلی.} -

-
- ); -} - -function WeeklyReport({ report: r }: { report: FamilyWeeklyReport }) { - return ( -
-

گزارشِ این هفته (از {formatJalaliDate(r.weekStart)})

-
- {[ - { 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 }) => ( -
- - {label} - {toFa(value)} -
- ))} -
-
- ); -} - -function InviteChild({ onDone }: { onDone: () => void }) { - const [phone, setPhone] = useState(''); - const [code, setCode] = useState(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 ( -
-

من والدم

-

شماره‌ای که فرزندت باهاش وارد تدریسینو شده رو بنویس.

- {code ? ( -
-

این کد رو به فرزندت بده تا تو بخشِ والدینِ حسابِ خودش وارد کنه:

-

{code}

-
- ) : ( - <> - 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 &&
{error}
} - void invite()}> - ساختِ کدِ دعوت - - - )} -
- ); -} - -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 ( -
-

من دانش‌آموزم

-

اگه پدر یا مادرت کدِ دعوت بهت داده، اینجا واردش کن.

- 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 &&
{error}
} - void accept()}> - وصل شو - -
- ); -} diff --git a/src/screens/InvoicesScreen.tsx b/src/screens/InvoicesScreen.tsx new file mode 100644 index 0000000..1d1bc84 --- /dev/null +++ b/src/screens/InvoicesScreen.tsx @@ -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 = { + paid: { label: 'پرداخت‌شده', className: 'bg-[#EAF8F0] text-[#2E7D5B]' }, + pending: { label: 'در انتظارِ پرداخت', className: 'bg-[#FFF8E1] text-[#8A6100]' }, + failed: { label: 'ناموفق', className: 'bg-soft text-muted' }, +}; + +const PROVIDER: Record = { 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(null); + + return ( + + + + {list.loading ? ( + + ) : list.error || !list.data ? ( + + ) : list.data.length === 0 ? ( +
+ +

هنوز خریدی نکردی.

+ nav.switchTab('subscription')}>دیدنِ اشتراک‌ها +
+ ) : ( + list.data.map((invoice) => ( + + )) + )} +
+ setOpen(null)} /> +
+ ); +} + +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 + ? [ + ['شماره‌ی فاکتور', {invoice.invoiceNumber}], + ['تاریخ', 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]] : []), + ['مبلغِ پرداختی', {formatToman(invoice.amount)}], + ['روشِ پرداخت', PROVIDER[invoice.provider] ?? invoice.provider], + ] + : []; + + return ( + + {invoice && ( +
+ + + {rows.map(([label, value]) => ( + + + + + ))} + +
{label}{value}
+ {invoice.refId ? ( + + ) : ( + invoice.status !== 'paid' && ( +

+ اگه پولی از حسابت کم شده ولی این خرید «پرداخت‌شده» نیست، شماره‌ی فاکتور رو به پشتیبانی بده. +

+ ) + )} +
+ )} +
+ ); +} diff --git a/src/screens/NotificationsScreen.tsx b/src/screens/NotificationsScreen.tsx index 2dd1751..0c1aebc 100644 --- a/src/screens/NotificationsScreen.tsx +++ b/src/screens/NotificationsScreen.tsx @@ -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 = { 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' }; diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index 4e41d0d..38a94ec 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -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(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 }) { +
{gradeTitle && {gradeTitle}} {age && {toFa(age)} ساله} @@ -86,11 +100,17 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) { setEditing('grade')} /> - setEditing('age')} /> + setEditing('birth')} + /> + @@ -98,7 +118,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) { {!profile.isGuest && } - {!profile.isGuest && } + void }) { setEditing(null)} /> setEditing(null)} /> - setEditing(null)} /> + setEditing(null)} /> setEditing(null)} onSignedOut={onSignedOut} /> ); @@ -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(ageFromBirthYear(profile.birthYear)); + const [birthDate, setBirthDate] = useState(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 ( - + +

روز، ماه و سالِ تولدت به شمسی.

- +
- void save()}>ذخیره + void save()}>ذخیره
); } diff --git a/src/screens/ReportScreen.tsx b/src/screens/ReportScreen.tsx new file mode 100644 index 0000000..6bdcd78 --- /dev/null +++ b/src/screens/ReportScreen.tsx @@ -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 ( + + + + {report.loading ? ( + + ) : report.error || !report.data ? ( + + ) : ( + + )} + + + ); +} + +function Report({ data, name }: { data: WeeklyReport; name: string }) { + const { thisWeek: now, lastWeek: before } = data; + return ( + <> + + +
+ + + + + {/* آزمونِ جزیره و آزمونِ تک‌درس هر دو «تمام کردن» اند؛ جدا شمردنشان برای والد فرقی نمی‌کند. */} + + +
+ + {data.streak.longest > 0 && ( +
+ + زنجیره‌ی فعلی {toFa(data.streak.current)} روز؛ بیشترین {toFa(data.streak.longest)} روز +
+ )} + + d.minutes) }, + { label: 'هفته‌ی پیش', color: LAST_WEEK, values: data.previousDays.map((d) => d.minutes) }, + ]} + note="زمان تقریبی است: از فاصله‌ی جواب‌ها و پرسش‌ها حساب می‌شود، پس خواندنِ درس بدونِ جواب دادن در آن نیست." + /> + + d.correct) }, + { label: 'نادرست', color: WRONG, values: data.days.map((d) => d.questions - d.correct) }, + ]} + /> + + + + ); +} + +/* ─── جمله‌ی بالای صفحه ─────────────────────────────────────────────────────── */ + +/** مقایسه با زمانِ تمرین؛ تغییرِ کمتر از ۱۰٪ یا ۳ دقیقه «مثلِ هفته‌ی پیش» است، نه بالا و پایینِ اتفاقی. */ +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 ( +
+ +

{text}

+
+ ); +} + +/* ─── کاشی‌های عدد ─────────────────────────────────────────────────────────── */ + +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 ( +
+ + {label} + + {value} + {delta && ( + + {delta.up === null ? '' : delta.up ? '▲ ' : '▼ '} + {delta.text} + + )} +
+ ); +} + +/* ─── نمودارِ ستونی ─────────────────────────────────────────────────────────── */ + +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 ( +
+

{title}

+

{description}

+ +
+ {series.map((s) => ( + + + {s.label} + + ))} +
+ + {/* خواندنِ روزِ انتخاب‌شده؛ همان کارِ tooltip را روی گوشی می‌کند. */} +

+ {longDay(days[selected].date)}:{' '} + {series.map((s) => `${s.label} ${toFa(s.values[selected])} ${unit}`).join('، ')} +

+ +
+ {/* خطِ راهنمای سقف و نیمه؛ کم‌رنگ تا با ستون‌ها رقابت نکند. */} + {[1, 0.5].map((f) => ( +
+ {/* نیمه‌ی سقفِ ۱ یا ۵ اعشاری است («۰.۵»)؛ فقط عددِ صحیح نوشته می‌شود. */} + {!empty && Number.isInteger(max * f) && ( + {toFa(max * f)} + )} +
+ ))} +
+ + {empty && ( +

+ هنوز چیزی ثبت نشده +

+ )} + +
+ {days.map((day, i) => { + const isToday = i === days.length - 1; + return ( + + ); + })} +
+
+ + {note &&

{note}

} +
+ ); +} + +/* ─── جدولِ روزها ───────────────────────────────────────────────────────────── */ + +/** همان عددهای نمودارها، برای کسی که رنگ‌ها را از هم جدا نمی‌بیند یا عددِ دقیق می‌خواهد. */ +function DayTable({ days, previous }: { days: ReportDay[]; previous: ReportDay[] }) { + return ( +
+ عددهای هر روز + + + + + + + + + + + + {days.map((d, i) => ( + + + + + + + + ))} + +
روزدقیقههفته‌ی پیشسوالدرست
{longDay(d.date)}{toFa(d.minutes)}{toFa(previous[i]?.minutes ?? 0)}{toFa(d.questions)}{toFa(d.correct)}
+
+ ); +} diff --git a/src/screens/SetupScreen.tsx b/src/screens/SetupScreen.tsx index 4f583a0..3a0396e 100644 --- a/src/screens/SetupScreen.tsx +++ b/src/screens/SetupScreen.tsx @@ -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(ageFromBirthYear(profile.birthYear)); + const [birthDate, setBirthDate] = useState(profile.birthDate); const [gradeId, setGradeId] = useState(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 (
@@ -95,13 +84,16 @@ export function SetupScreen({
- چند سالته؟ (اختیاری) + تاریخِ تولدت (به شمسی، اختیاری) - +
- کدوم پایه‌ای؟ + + {adult ? 'ریاضیِ کدوم پایه رو می‌خوای یاد بگیری؟' : 'کدوم پایه‌ای؟'} + {adult && هر وقت خواستی از پروفایل عوضش کن.} +
{grades.map((g) => { const selected = gradeId === g.id; @@ -131,7 +123,7 @@ export function SetupScreen({ ); })}
- {gradeMissing && پایه‌ت رو انتخاب کن} + {gradeMissing && {adult ? 'یه پایه رو انتخاب کن' : 'پایه‌ت رو انتخاب کن'}}
{error && {error}} @@ -139,9 +131,6 @@ export function SetupScreen({ void submit()} loading={saving} className="mt-auto"> شروعِ یادگیری -
); diff --git a/src/state/askStore.ts b/src/state/askStore.ts index ea3f342..751067e 100644 --- a/src/state/askStore.ts +++ b/src/state/askStore.ts @@ -18,6 +18,8 @@ export interface ChatMessage { feedback?: 'up' | 'down'; /** پیامِ خطا به‌جای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده می‌شود. */ failedQuestion?: string; + /** سهمیه‌ی رایگانِ امروز تمام شده؛ به‌جای «دوباره»، راهِ اشتراک نشان داده می‌شود. */ + quotaReached?: boolean; } interface AskState {