Files
MathApp_frontend/src/screens/AskScreen.tsx
AliReza de7cd0bfa8 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>
2026-10-02 02:38:43 +03:30

334 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react';
import { BookOpen, MessageSquarePlus, RotateCw, Send, ThumbsDown, ThumbsUp } from 'lucide-react';
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';
import { Diagram } from '@/diagram';
import { MathText } from '@/lib/MathText';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
const STARTERS = ['این فصل درباره‌ی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
/** «از پی بپرس»: دستیارِ ریاضی که از روی کتابِ پایه‌ی کاربر جواب می‌دهد. */
export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) {
const { profile } = useAppData();
const chat = useAskState();
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
const [quota, setQuota] = useState<number | null>(null);
const listEnd = useRef<HTMLDivElement>(null);
const autoSent = useRef(false);
// درسِ تازه یعنی گفتگوی تازه؛ وگرنه اگر گفتگوی قبلی هست، تاریخچه‌اش را بیاور.
useEffect(() => {
if (lessonId !== undefined && lessonId !== chat.lessonId) {
askStore.reset(lessonId);
return;
}
if (chat.historyLoaded) return;
if (!chat.conversationId) {
askStore.markHistoryLoaded();
return;
}
api.ai
.history(chat.conversationId)
.then((r) =>
askStore.setHistory(
r.messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId, diagram: m.diagram ?? null })),
),
)
.catch(() => askStore.reset(null));
// فقط بارِ اولِ باز شدنِ صفحه؛ عمداً به chat وابسته نیست.
}, []);
useEffect(() => {
if (prompt && !autoSent.current) {
autoSent.current = true;
void send(prompt, questionId);
}
}, [prompt, questionId]);
// بعد از رندر اسکرول کن، تا پیشنهادهای ادامه که آخر از همه می‌آیند هم دیده شوند.
useEffect(() => {
const frame = requestAnimationFrame(() => listEnd.current?.scrollIntoView({ block: 'end' }));
return () => cancelAnimationFrame(frame);
}, [chat.messages]);
async function send(text: string, forQuestionId?: string) {
const question = text.trim();
if (!question || busy) return;
setBusy(true);
setInput('');
askStore.add({ role: 'user', text: question });
const replyId = askStore.add({ role: 'assistant', text: '' });
let answer = '';
try {
const done = await api.ai.ask(
{
userQuestion: question,
conversationId: askStore.get().conversationId ?? undefined,
lessonId: askStore.get().lessonId ?? undefined,
questionId: forQuestionId,
},
(delta) => {
answer += delta;
askStore.update(replyId, { text: answer });
},
);
askStore.update(replyId, {
text: answer,
messageId: done.messageId,
usedChunks: done.usedChunks,
followUps: done.suggestedFollowUps,
diagram: done.diagram ?? null,
});
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, 'جوابی نرسید'),
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
});
if (quotaReached) setQuota(0);
} finally {
setBusy(false);
}
}
function retry(message: ChatMessage) {
if (!message.failedQuestion) return;
const all = askStore.get().messages;
const index = all.findIndex((m) => m.localId === message.localId);
const question = all[index - 1];
askStore.remove(message.localId);
if (question?.role === 'user') askStore.remove(question.localId);
void send(message.failedQuestion);
}
const empty = chat.messages.length === 0;
return (
<Screen>
<ScreenHeader
title="از پی بپرس"
subtitle={chat.lessonId ? 'از روی کتابِ همین درس جواب می‌دم' : 'دستیارِ ریاضی، از روی کتابِ خودت'}
color="#9B7BFF"
back={lessonId !== undefined || prompt !== undefined}
mascot={{ char: 'pi', mood: busy ? 'excited' : 'idle', size: 60 }}
>
{!empty && (
<button
type="button"
onClick={() => askStore.reset(null)}
disabled={busy}
className="mt-3 flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold disabled:opacity-50"
>
<MessageSquarePlus size={14} /> گفتگوی تازه
</button>
)}
</ScreenHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
<Bubble role="assistant">
سلام {profile.name || 'دوستِ من'}! من پی‌ام 🙂 هر سوالی از ریاضیِ کتابت داری بپرس؛ قدم‌به‌قدم با هم حلش می‌کنیم.
</Bubble>
{empty && chat.historyLoaded && (
<div className="flex flex-wrap gap-2 pr-10">
{STARTERS.map((s) => (
<button key={s} type="button" onClick={() => void send(s)} className="rounded-full border border-[#dcd2ff] bg-white px-3 py-1.5 text-xs font-bold text-[#7857E0]">
{s}
</button>
))}
</div>
)}
{chat.messages.map((m, i) => (
<Message key={m.localId} message={m} last={i === chat.messages.length - 1} busy={busy} onRetry={retry} onFollowUp={(q) => void send(q)} />
))}
<div ref={listEnd} />
</div>
<form
className="flex-shrink-0 border-t border-line bg-white px-4 py-3"
onSubmit={(e) => {
e.preventDefault();
void send(input);
}}
>
<div className="flex items-end gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value.slice(0, 1000))}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
void send(input);
}
}}
rows={1}
// جهتِ هر خط از اولین حرفش: «-3 + 5» هنگامِ تایپ چپ‌به‌راست می‌ماند. خطی که با
// فارسی شروع شود راست‌به‌چپ است و ریاضیِ وسطش در کادرِ تایپ ممکن است جابه‌جا دیده شود؛
// در textarea نمی‌شود تکه‌ای را قفل کرد. پیامِ فرستاده‌شده از MathText رد می‌شود.
dir={input ? 'auto' : 'rtl'}
style={{ unicodeBidi: 'plaintext', textAlign: 'right' }}
placeholder="سوالت رو بنویس…"
className="max-h-28 min-h-10 flex-1 resize-none rounded-3xl bg-soft px-4 py-2.5 text-sm leading-6 text-ink outline-none placeholder:text-faint"
/>
<button
type="submit"
aria-label="ارسال"
disabled={!input.trim() || busy}
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-violet text-white transition-opacity disabled:opacity-40"
>
<Send size={16} className="-scale-x-100" />
</button>
</div>
{quota !== null && <p className="mt-1.5 text-center text-[10px] text-muted">امروز {toFa(quota)} سوالِ دیگه می‌تونی بپرسی</p>}
</form>
</Screen>
);
}
function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
const mine = role === 'user';
return (
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine && (
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
<SeyarMascot char="pi" mood="happy" size={32} />
</span>
)}
<div
className={`max-w-[82%] whitespace-pre-line break-words rounded-2xl px-4 py-3 text-sm leading-[1.95] ${
mine ? 'rounded-tl-sm bg-coral text-white' : 'rounded-tr-sm border border-line bg-white text-ink shadow-sm'
}`}
>
{children}
{extra}
</div>
</div>
);
}
/**
* منتظرِ جوابِ دستیار. سرویسِ مدل کلِ جواب را نگه می‌دارد و یک‌جا می‌فرستد؛ جوابِ
* کامل با شکل در ۲۰۲۶-۰۹-۲۹ حدودِ ۳۰ ثانیه طول کشید. پیامِ ثابت در آن مدت شبیهِ
* گیر کردن بود، پس متن با زمان جلو می‌رود. زمان‌ها تقریبی‌اند و به مرحله‌ی واقعیِ
* سرور وصل نیستند.
*/
const THINKING_STEPS: [number, string][] = [
[0, 'پی داره تو کتابت می‌گرده'],
[4, 'پی داره حلش می‌کنه'],
[15, 'داره قدم‌ها رو مرتب می‌نویسه'],
[28, 'جوابِ کامل یه کم طول می‌کشه، الان می‌رسه'],
];
function Thinking() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const started = Date.now();
const timer = setInterval(() => setSeconds((Date.now() - started) / 1000), 1000);
return () => clearInterval(timer);
}, []);
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1];
return (
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
<SeyarMascot char="pi" mood="idle" size={32} />
</span>
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted" aria-live="polite">
{text}
{[0, 1, 2].map((i) => (
<motion.span key={i} className="h-1.5 w-1.5 rounded-full bg-violet/60" animate={{ y: [0, -4, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }} />
))}
</div>
</div>
);
}
function Message({
message, last, busy, onRetry, onFollowUp,
}: {
message: ChatMessage;
last: boolean;
busy: boolean;
onRetry: (m: ChatMessage) => void;
onFollowUp: (q: string) => void;
}) {
const toast = useToast();
const nav = useNav();
if (message.role === 'user') return <Bubble role="user"><MathText text={message.text} /></Bubble>;
if (!message.text) return <Thinking />;
async function rate(helpful: boolean) {
if (!message.messageId || message.feedback) return;
askStore.update(message.localId, { feedback: helpful ? 'up' : 'down' });
try {
await api.ai.feedback(message.messageId, helpful);
toast(helpful ? 'ممنون! 🙂' : 'ممنون که گفتی؛ بهترش می‌کنیم.', 'success');
} catch {
askStore.update(message.localId, { feedback: undefined });
toast('نظرت ثبت نشد', 'error');
}
}
return (
<div className="flex flex-col gap-1.5">
<Bubble role="assistant" extra={message.diagram ? <div className="whitespace-normal"><Diagram spec={message.diagram} /></div> : undefined}>
<MathText text={message.text} />
</Bubble>
<div className="flex flex-wrap items-center gap-2 pr-10">
{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>
) : (
<>
{message.usedChunks && message.usedChunks.length > 0 && (
<span className="flex items-center gap-1 text-[10px] font-bold text-mint" title={message.usedChunks.map((c) => c.title).join('، ')}>
<BookOpen size={11} /> از روی کتاب
</span>
)}
{message.messageId && (
<span className="mr-auto flex gap-1">
<button type="button" aria-label="مفید بود" onClick={() => void rate(true)} disabled={!!message.feedback} className={`rounded-full p-1.5 ${message.feedback === 'up' ? 'bg-green-100 text-green-600' : 'text-faint'}`}>
<ThumbsUp size={13} />
</button>
<button type="button" aria-label="مفید نبود" onClick={() => void rate(false)} disabled={!!message.feedback} className={`rounded-full p-1.5 ${message.feedback === 'down' ? 'bg-red-100 text-red-500' : 'text-faint'}`}>
<ThumbsDown size={13} />
</button>
</span>
)}
</>
)}
</div>
{last && !busy && message.followUps && message.followUps.length > 0 && (
<div className="flex flex-wrap gap-2 pr-10">
{message.followUps.map((f) => (
<button key={f} type="button" onClick={() => onFollowUp(f)} className="rounded-full border border-[#dcd2ff] bg-white px-3 py-1.5 text-xs font-bold text-[#7857E0]">
{f}
</button>
))}
</div>
)}
</div>
);
}