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:
466
src/screens/QuizScreen.tsx
Normal file
466
src/screens/QuizScreen.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react';
|
||||
import { api, ApiError, type AnswerResult, type Hearts, type LessonComplete, type Question, type ReportReason } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { newIdempotencyKey } from '@/api/http';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import type { SeyarMood } from '@/mascots/seyar';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton, Screen } from '@/components/ui';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { timeUntil, toFa } from '@/lib/format';
|
||||
import { sounds } from '@/lib/sound';
|
||||
|
||||
type Phase =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'error'; error: unknown }
|
||||
| { kind: 'playing' }
|
||||
| { kind: 'noHearts'; nextRefillAt: string | null }
|
||||
| { kind: 'finishing' }
|
||||
| { kind: 'finishError'; error: unknown }
|
||||
| { kind: 'result'; result: LessonComplete };
|
||||
|
||||
/** تمرینِ یک درس: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه. */
|
||||
export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
|
||||
const { home, companion, refreshHome, refreshSummary } = useAppData();
|
||||
const nav = useNav();
|
||||
const toast = useToast();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [hearts, setHearts] = useState<Hearts | null>(null);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [answer, setAnswer] = useState<AnswerResult | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [correctCount, setCorrectCount] = useState(0);
|
||||
const [confirmExit, setConfirmExit] = useState(false);
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
// یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمیشود.
|
||||
const answerKeys = useRef(new Map<string, string>());
|
||||
const completeKey = useRef(newIdempotencyKey());
|
||||
|
||||
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
|
||||
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
|
||||
const question = questions[index];
|
||||
const isLast = index === questions.length - 1;
|
||||
const unlimited = hearts?.isUnlimited ?? false;
|
||||
const heartsLeft = hearts?.currentHearts ?? 5;
|
||||
const outOfHearts = !unlimited && hearts !== null && heartsLeft <= 0;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setPhase({ kind: 'loading' });
|
||||
Promise.all([api.lesson.questions(lessonId), api.hearts().catch(() => null)])
|
||||
.then(([qs, h]) => {
|
||||
if (!alive) return;
|
||||
setQuestions(qs);
|
||||
setHearts(h);
|
||||
setIndex(0);
|
||||
setSelected(null);
|
||||
setAnswer(null);
|
||||
setCorrectCount(0);
|
||||
answerKeys.current.clear();
|
||||
completeKey.current = newIdempotencyKey();
|
||||
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt });
|
||||
else setPhase({ kind: 'playing' });
|
||||
})
|
||||
.catch((error: unknown) => alive && setPhase({ kind: 'error', error }));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [lessonId, reload]);
|
||||
|
||||
async function choose(optionId: string) {
|
||||
if (!question || selected !== null || submitting) return;
|
||||
setSelected(optionId);
|
||||
setSubmitting(true);
|
||||
let key = answerKeys.current.get(question.id);
|
||||
if (!key) {
|
||||
key = newIdempotencyKey();
|
||||
answerKeys.current.set(question.id, key);
|
||||
}
|
||||
try {
|
||||
const result = await api.lesson.answer(question.id, optionId, key);
|
||||
setAnswer(result);
|
||||
setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h));
|
||||
if (result.correct) {
|
||||
setCorrectCount((c) => c + 1);
|
||||
sounds.correct();
|
||||
} else {
|
||||
sounds.wrong();
|
||||
}
|
||||
} catch (err) {
|
||||
setSelected(null);
|
||||
if (err instanceof ApiError && err.code === 'no_hearts') {
|
||||
setPhase({ kind: 'noHearts', nextRefillAt: (err.body.nextRefillAt as string | null) ?? null });
|
||||
} else {
|
||||
toast(friendlyError(err, 'جوابت ثبت نشد، دوباره بزن'), 'error');
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function next() {
|
||||
if (outOfHearts && !isLast) {
|
||||
const h = await api.hearts().catch(() => null);
|
||||
setPhase({ kind: 'noHearts', nextRefillAt: h?.nextRefillAt ?? hearts?.nextRefillAt ?? null });
|
||||
return;
|
||||
}
|
||||
if (!isLast) {
|
||||
setIndex((i) => i + 1);
|
||||
setSelected(null);
|
||||
setAnswer(null);
|
||||
return;
|
||||
}
|
||||
await finish();
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
setPhase({ kind: 'finishing' });
|
||||
try {
|
||||
const result = await api.lesson.complete(
|
||||
lessonId,
|
||||
{ correctCount, totalQuestions: questions.length },
|
||||
completeKey.current,
|
||||
);
|
||||
setPhase({ kind: 'result', result });
|
||||
sounds.finish();
|
||||
if (result.stars >= 2) void confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } });
|
||||
void refreshHome();
|
||||
void refreshSummary();
|
||||
} catch (error) {
|
||||
setPhase({ kind: 'finishError', error });
|
||||
}
|
||||
}
|
||||
|
||||
const askWhy = () =>
|
||||
nav.push({ name: 'ask', lessonId, questionId: question?.id, prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟' });
|
||||
|
||||
/* ─── حالتهای غیرِ بازی ─────────────────────────────────────────────── */
|
||||
|
||||
if (phase.kind === 'loading') return <Screen><LoadingState text="دارم سوالها رو میارم…" /></Screen>;
|
||||
if (phase.kind === 'error') {
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex flex-1 flex-col justify-center">
|
||||
<ErrorState error={phase.error} onRetry={() => setReload((n) => n + 1)} />
|
||||
<div className="px-8"><SecondaryButton onClick={nav.back}>برگشت</SecondaryButton></div>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
if (phase.kind === 'noHearts') return <NoHearts nextRefillAt={phase.nextRefillAt} />;
|
||||
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id} color={color} />;
|
||||
if (phase.kind === 'finishing' || phase.kind === 'finishError') {
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||
<SeyarMascot char={companion} mood={phase.kind === 'finishing' ? 'happy' : 'sad'} size={96} />
|
||||
{phase.kind === 'finishing' ? (
|
||||
<p className="text-sm text-muted">دارم نتیجهت رو ثبت میکنم…</p>
|
||||
) : (
|
||||
<>
|
||||
<InlineError>نتیجهی این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
|
||||
<PrimaryButton color={color} onClick={() => void finish()}>دوباره ثبت کن</PrimaryButton>
|
||||
<SecondaryButton onClick={nav.back}>بیخیال، برگرد</SecondaryButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
if (!question) {
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex flex-1 flex-col justify-center gap-4 px-8">
|
||||
<ErrorState text="این درس هنوز سوالی نداره" />
|
||||
<SecondaryButton onClick={nav.back}>برگشت</SecondaryButton>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── خودِ بازی ───────────────────────────────────────────────────────── */
|
||||
|
||||
const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad';
|
||||
const bubble = !answer
|
||||
? 'جوابِ درست رو انتخاب کن!'
|
||||
: answer.correct
|
||||
? 'آفرین! درست بود ⚡'
|
||||
: outOfHearts
|
||||
? 'قلبهات تموم شد؛ یه کم استراحت کنیم.'
|
||||
: 'اشکالی نداره، جوابِ درست رو ببین.';
|
||||
const shortOptions = question.options.every((o) => o.value.length <= 14);
|
||||
|
||||
const optionClass = (id: string) => {
|
||||
if (!answer) return selected === id ? 'border-coral bg-[#fff4f2] text-ink' : 'border-line bg-white text-ink';
|
||||
if (id === answer.correctOptionId) return 'border-green-400 bg-[#e5fdf0] text-green-700';
|
||||
if (id === selected) return 'border-red-300 bg-[#fff0f0] text-red-500';
|
||||
return 'border-line bg-white text-faint';
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex-shrink-0 border-b border-line bg-white px-5 pb-4 pt-safe">
|
||||
<div className="flex items-center gap-3 pt-5">
|
||||
<button type="button" aria-label="خروج از تمرین" onClick={() => setConfirmExit(true)} className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-soft">
|
||||
<X size={15} className="text-coral" />
|
||||
</button>
|
||||
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-soft">
|
||||
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${((index + (answer ? 1 : 0)) / questions.length) * 100}%`, backgroundColor: color }} />
|
||||
</div>
|
||||
<span className="w-10 flex-shrink-0 text-left text-xs font-bold text-muted">
|
||||
{toFa(index + 1)}/{toFa(questions.length)}
|
||||
</span>
|
||||
</div>
|
||||
<HeartsRow hearts={hearts} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div key={mood} initial={{ scale: 0.7, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.7, opacity: 0 }} className="rounded-3xl border-2 border-line bg-white p-2">
|
||||
<SeyarMascot char={companion} mood={mood} size={64} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<div className={`flex-1 rounded-2xl border px-4 py-3 text-xs font-bold leading-6 ${
|
||||
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
|
||||
}`}>
|
||||
{bubble}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-line bg-white p-5 shadow-sm">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted">سوالِ {toFa(index + 1)}</span>
|
||||
<button type="button" onClick={() => setReporting(true)} className="flex items-center gap-1 text-[11px] font-bold text-faint">
|
||||
<Flag size={12} /> گزارشِ ایراد
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink">{question.prompt}</h2>
|
||||
</div>
|
||||
|
||||
<div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
|
||||
{question.options.map((option) => (
|
||||
<motion.button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => void choose(option.id)}
|
||||
disabled={selected !== null && !answer}
|
||||
whileTap={{ scale: selected === null ? 0.95 : 1 }}
|
||||
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`}
|
||||
>
|
||||
<span dir="auto">{option.value}</span>
|
||||
{answer && option.id === answer.correctOptionId && <Check size={16} className="text-green-500" strokeWidth={3} />}
|
||||
{answer && option.id === selected && !answer.correct && <X size={16} className="text-red-400" strokeWidth={3} />}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{answer && (
|
||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="mt-1 flex flex-col gap-2.5">
|
||||
{!answer.correct && (
|
||||
<button type="button" onClick={askWhy} className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]">
|
||||
<Brain size={16} /> از پی بپرس چرا غلط شد
|
||||
</button>
|
||||
)}
|
||||
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
|
||||
{isLast ? 'پایانِ تمرین' : 'سوالِ بعدی'} <ChevronLeft size={18} />
|
||||
</PrimaryButton>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Sheet open={confirmExit} onClose={() => setConfirmExit(false)} title="از تمرین بیرون بری؟">
|
||||
<p className="mb-5 text-sm leading-7 text-muted">جوابهایی که دادی ثبت شده، ولی این درس تا آخرش رو نزنی تمومشده حساب نمیشه.</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه میدم</PrimaryButton>
|
||||
<SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton>
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
<ReportSheet open={reporting} questionId={question.id} onClose={() => setReporting(false)} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function HeartsRow({ hearts }: { hearts: Hearts | null }) {
|
||||
if (hearts?.isUnlimited) {
|
||||
return (
|
||||
<div className="mt-3 flex items-center justify-center gap-1.5 text-xs font-bold text-coral">
|
||||
<Heart size={18} fill="#ED6956" /> <InfinityIcon size={18} /> قلبِ نامحدود
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const max = hearts?.maxHearts ?? 5;
|
||||
const current = hearts?.currentHearts ?? max;
|
||||
return (
|
||||
<div className="mt-3 flex items-center justify-center gap-2" aria-label={`${toFa(current)} قلب از ${toFa(max)}`}>
|
||||
{Array.from({ length: max }, (_, i) => (
|
||||
<span key={i} className="relative h-5 w-5">
|
||||
<Heart size={20} className="absolute inset-0 text-[#e5e1f7]" />
|
||||
<AnimatePresence>
|
||||
{i < current && (
|
||||
<motion.span
|
||||
className="absolute inset-0"
|
||||
exit={{ scale: [1, 1.35, 0], rotate: [0, -22, 18, 0], y: [0, -4, 10], opacity: [1, 1, 0] }}
|
||||
transition={{ duration: 0.55 }}
|
||||
>
|
||||
<Heart size={20} className="text-coral" fill="#ED6956" />
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoHearts({ nextRefillAt }: { nextRefillAt: string | null }) {
|
||||
const nav = useNav();
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||
<SeyarMascot char="pi" mood="depressed" size={110} />
|
||||
<h2 className="text-xl font-black text-ink">قلبهات تموم شد</h2>
|
||||
<p className="text-sm leading-7 text-muted">
|
||||
{nextRefillAt ? <>قلبِ بعدی تا <b className="text-coral">{timeUntil(nextRefillAt)}</b> دیگه پر میشه.</> : 'کمی صبر کن تا قلبهات دوباره پر بشن.'}
|
||||
<br />
|
||||
با اشتراکِ ویژه قلبها نامحدودن.
|
||||
</p>
|
||||
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ اشتراک</PrimaryButton>
|
||||
<SecondaryButton onClick={nav.back}>باشه، بعداً</SecondaryButton>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function Result({
|
||||
result, total, correct, lessonId, nodeId, color,
|
||||
}: { result: LessonComplete; total: number; correct: number; lessonId: number; nodeId?: string; color: string }) {
|
||||
const nav = useNav();
|
||||
const { companion } = useAppData();
|
||||
const [loadingNext, setLoadingNext] = useState(false);
|
||||
|
||||
// بعد از ثبت، نقشه عوض شده؛ درسِ بعدی را از نسخهی تازهی نقشه میخوانیم.
|
||||
async function goNext() {
|
||||
setLoadingNext(true);
|
||||
try {
|
||||
const fresh = await api.home();
|
||||
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId);
|
||||
const next = ref ? nextLessonOf(ref.node) : null;
|
||||
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
|
||||
nav.replace({ name: 'lesson', lessonId: next, nodeId: ref.node.id });
|
||||
} else {
|
||||
nav.back();
|
||||
}
|
||||
} catch {
|
||||
nav.back();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-8 text-center">
|
||||
<SeyarMascot char={companion} mood={result.stars >= 2 ? 'excited' : 'happy'} size={120} />
|
||||
<div className="flex gap-1.5" aria-label={`${toFa(result.stars)} ستاره`}>
|
||||
{[1, 2, 3].map((s) => (
|
||||
<motion.span key={s} initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.2 + s * 0.15, type: 'spring' }}>
|
||||
<Star size={40} className={s <= result.stars ? 'text-[#FFC53D]' : 'text-[#e5e1f7]'} fill={s <= result.stars ? '#FFC53D' : '#e5e1f7'} />
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
|
||||
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
|
||||
</div>
|
||||
<div className="flex w-full gap-3">
|
||||
<div className="flex flex-1 flex-col items-center rounded-2xl border border-line bg-white py-3">
|
||||
<Star size={18} className="text-gold" />
|
||||
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.xpEarned)}</span>
|
||||
<span className="text-[10px] text-muted">امتیاز</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center rounded-2xl border border-line bg-white py-3">
|
||||
<Coins size={18} className="text-violet" />
|
||||
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.coinsEarned)}</span>
|
||||
<span className="text-[10px] text-muted">سکه</span>
|
||||
</div>
|
||||
</div>
|
||||
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
|
||||
ادامه <ChevronLeft size={18} />
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const REASONS: { id: ReportReason; label: string }[] = [
|
||||
{ id: 'wrong_answer', label: 'جوابِ درست اشتباهه' },
|
||||
{ id: 'unclear', label: 'صورتِ سوال گنگه' },
|
||||
{ id: 'typo', label: 'غلطِ تایپی داره' },
|
||||
{ id: 'too_hard', label: 'برای این درس خیلی سخته' },
|
||||
{ id: 'other', label: 'یه چیزِ دیگه' },
|
||||
];
|
||||
|
||||
/** گزارشِ سوالِ ایراددار؛ مستقیم به صفِ بررسیِ محتوا میرود. */
|
||||
function ReportSheet({ open, questionId, onClose }: { open: boolean; questionId: string; onClose: () => void }) {
|
||||
const toast = useToast();
|
||||
const [reason, setReason] = useState<ReportReason | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setReason(null);
|
||||
setNote('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function send() {
|
||||
if (!reason) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const r = await api.lesson.report(questionId, reason, note.trim());
|
||||
toast(r.alreadyReported ? 'قبلاً گزارشش رو داده بودی؛ ممنون!' : 'ممنون! گزارشت رسید و بررسی میشه.', 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast(friendlyError(err, 'گزارش ارسال نشد'), 'error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="این سوال چه ایرادی داره؟">
|
||||
<div className="mb-4 flex flex-col gap-2">
|
||||
{REASONS.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => setReason(r.id)}
|
||||
className={`rounded-2xl border-2 px-4 py-3 text-right text-sm font-bold ${reason === r.id ? 'border-coral bg-[#fff4f2] text-coral' : 'border-line text-body'}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value.slice(0, 500))}
|
||||
placeholder="توضیحِ بیشتر (اختیاری)"
|
||||
rows={2}
|
||||
className="mb-4 w-full resize-none rounded-2xl border-2 border-line px-4 py-3 text-sm outline-none focus:border-coral"
|
||||
/>
|
||||
<PrimaryButton disabled={!reason} loading={sending} onClick={() => void send()}>ارسالِ گزارش</PrimaryButton>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user