Files
MathApp_frontend/src/screens/AskScreen.tsx
AliReza ca96a91dee No diacritics or written ezafe ی in the UI; map without the speech banner, companions cheering along the path; familiar payment sheet with gateway logos and per-channel methods; account deletion moved under «حساب کاربری»
- Text: kasra/fatha/damma/shadda/sukun/hamza-above and «ه‌ی» ezafe removed from every user-visible string (comments untouched); tanwin kept. Stale copy fixed (question packs are no longer sold), awkward onboarding sentences rewritten, سؤال → سوال. plainPersian() in MathText cleans the live tutor stream.
- Home: the mascot + speech bubble header is gone; the selected companion hops beside the current island and sits at every third bend (CSS transform animations only).
- Checkout: order summary, payment-method rows with Zarinpal/Bazaar icons and radio, amount on the pay button, lock trust line, darker overlay. paymentMethods in config.js per channel (web: zarinpal, app: zarinpal+bazaar by default).
- Profile: «ورود با حساب دیگر» removed (it was logout); new «حساب کاربری» sheet shows phone/ID and holds «حذف حساب کاربری»; logout is a red row. Delete sheet tells Bazaar subscribers to cancel renewal in Bazaar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:55:17 +03:30

376 lines
16 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';
import { useGateHold } from '@/lib/useAsync';
import { iAm, useCompanion } from '@/state/companion';
const STARTERS = ['این فصل درباره چیه؟', 'یه مثال ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
/** «از {همراه} بپرس»: دستیارِ ریاضی که از روی کتابِ پایه‌ی کاربر جواب می‌دهد؛ چهره و نامش همراهِ انتخابیِ کاربر است. */
export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) {
const { profile, home } = useAppData();
const subjectTitle = home?.subject?.title;
const companion = useCompanion();
const chat = useAskState();
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
// «X سوالِ امروز»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
const [quota, setQuota] = useState<number | null>(null);
const [quotaDone, setQuotaDone] = useState(false);
useEffect(() => {
api.ai
.quota()
.then((q) => setQuota(q.remainingQuota))
.catch((err: unknown) => console.warn('ai quota lookup failed', err))
.finally(() => setQuotaDone(true));
}, []);
// صفحه وقتی می‌آید که تاریخچه و سهمیه رسیده باشند؛ وگرنه اول خوش‌آمد و بعد پیام‌های قبلی رویش می‌پرید.
useGateHold(!chat.historyLoaded || !quotaDone);
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,
// بی‌درس، کتابِ انتخابیِ دانش‌آموز (هندسه، گسسته، …) را می‌فرستیم تا دستیار از همان کتاب جواب بدهد؛
// با درس، سرور کتاب را از خودِ درس درمی‌آورد.
subject: askStore.get().lessonId ? undefined : subjectTitle,
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={`از ${companion.name} بپرس`}
subtitle={chat.lessonId ? 'از روی کتاب همین درس جواب می‌دم' : 'دستیار ریاضی، از روی کتاب خودت'}
color={companion.color}
back={lessonId !== undefined || prompt !== undefined}
mascot={{ mood: busy ? 'excited' : 'idle', size: 60 }}
>
<div className="mt-3 flex flex-wrap items-center gap-2">
{quota !== null && <QuotaPills free={quota} />}
{!empty && (
<button
type="button"
onClick={() => askStore.reset(null)}
disabled={busy}
className="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>
)}
</div>
</ScreenHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
<Bubble role="assistant">
سلام {profile.name || 'دوست من'}! {`من ${iAm(companion.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>
</form>
</Screen>
);
}
function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
const mine = role === 'user';
const companion = useCompanion().char;
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={companion} 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, '{name} داره تو کتابت می‌گرده'],
[4, '{name} داره حلش می‌کنه'],
[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 companion = useCompanion();
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1].replace('{name}', companion.name);
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={companion.char} 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>
);
}
/**
* شمارنده در سربرگِ دستیار: سوال‌های باقیِ امروز. وقتی تمام شد، دکمه‌ی دوم به فروشگاهِ اشتراک می‌برد
* (سوال جدا فروخته نمی‌شود؛ مشترک فردا دوباره سهم دارد).
*/
function QuotaPills({ free }: { free: number }) {
const nav = useNav();
return (
<>
<span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}>
{free > 0 ? <>{toFa(free)} سوال امروز</> : 'سوال‌های امروز تموم شد'}
</span>
{free === 0 && (
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-white/15 px-3 py-1.5 text-xs font-black text-white">
سوال بیشتر با اشتراک
</button>
)}
</>
);
}