forked from Tadrisino/MathApp_frontend
rebuild the app on a typed, modular structure wired to the real API
The whole app lived in one 3,071-line App.tsx on a Figma scaffold (60 unused packages, 45 unused UI components, no type-check). The live site could not reach the backend, Persian-keyboard digits were erased from the phone field, guests were signed out after an hour, "mark read" hit a method the server does not accept (405), and iOS Safari blocked the payment window. What changed: - src/ split into api/ (one typed client: timeouts, GET retry, single-flight refresh, SSE), screens/ (one file each), components/, state/, nav/, lib/. - The API address is configuration only: public/config.js at runtime, then VITE_API_BASE_URL at build time, then same-origin /api. No domain in code. - Responsive shell instead of a fixed 390x844 phone frame; self-hosted Vazirmatn instead of Google Fonts; strict TypeScript, `npm run build` type-checks first. - Everything on the handoff list: tutor answers keep their line breaks, have thumbs up/down, follow-ups, "from the book" badge, retry, history that survives tab switches and reloads, "why was I wrong" from a wrong answer with questionId; question reports; hearts with maxHearts/unlimited and a no-hearts screen with the refill time; result screen and a visible error when lesson completion fails; subscription status, badge, discount code before the gateway and the payment-return message; guest refresh token; 6-digit OTP with resend timer; age saved as a Jalali birthYear; PATCH read-all; leaderboard refetched on open with "you" highlighted; selected companion shown across the app; logout that revokes the refresh token. - New screens for endpoints that had none: profile, streak calendar, support tickets and bug reports, parents, invite-a-friend (with ?ref= tracking), guest-to-phone upgrade. - Browser/phone back button walks the screen stack. Checked with a headless-Chrome walkthrough against a local backend at 390x844 and 360x740 (fresh guest: onboarding, setup, a full lesson, map, island sheet, every tab and sub-screen, one real tutor question, reload, logout): no console errors, no failed API calls. Real SMS login and a real Zarinpal payment were not exercised. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
291
src/screens/AskScreen.tsx
Normal file
291
src/screens/AskScreen.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
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 { useAppData } from '@/state/AppData';
|
||||
import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { Screen, ScreenHeader } from '@/components/ui';
|
||||
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 })),
|
||||
),
|
||||
)
|
||||
.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,
|
||||
});
|
||||
askStore.setConversation(done.conversationId);
|
||||
setQuota(done.remainingQuota);
|
||||
} catch (err) {
|
||||
askStore.update(replyId, {
|
||||
text: friendlyError(err, 'جوابی نرسید'),
|
||||
failedQuestion: question,
|
||||
});
|
||||
} 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}
|
||||
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 }: { role: 'user' | 'assistant'; children: 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}
|
||||
</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();
|
||||
|
||||
if (message.role === 'user') return <Bubble role="user">{message.text}</Bubble>;
|
||||
|
||||
if (!message.text) {
|
||||
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">
|
||||
پی داره فکر میکنه
|
||||
{[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>
|
||||
);
|
||||
}
|
||||
|
||||
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">{message.text}</Bubble>
|
||||
<div className="flex flex-wrap items-center gap-2 pr-10">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user