forked from Tadrisino/MathApp_frontend
FeedbackBar: like/dislike/report under every quiz question and lesson
This commit is contained in:
@@ -121,6 +121,17 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
/** لایک/دیسلایک و گزارشِ ایراد روی سوال و درس (۰ = پسگرفتنِ رأی). */
|
||||
feedback: {
|
||||
vote: (kind: 'question' | 'lesson', id: string, value: 1 | -1 | 0) =>
|
||||
http.post<{ value: number }>(`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/vote`, { value }),
|
||||
report: (kind: 'question' | 'lesson', id: string, reason: string, note?: string) =>
|
||||
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
|
||||
`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/report`,
|
||||
{ reason, ...(note ? { note } : {}) },
|
||||
),
|
||||
},
|
||||
|
||||
leaderboard: (period: T.LeaderboardPeriod) =>
|
||||
http.get<{ period: string; entries: T.LeaderboardEntry[] }>(`/leaderboard${q({ period })}`).then((r) => r.entries),
|
||||
|
||||
|
||||
131
src/components/FeedbackBar.tsx
Normal file
131
src/components/FeedbackBar.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Flag, ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { PrimaryButton } from '@/components/ui';
|
||||
|
||||
type Kind = 'question' | 'lesson';
|
||||
|
||||
const REASONS: Record<Kind, { id: string; label: string }[]> = {
|
||||
question: [
|
||||
{ id: 'wrong_answer', label: 'جوابِ درست اشتباهه' },
|
||||
{ id: 'unclear', label: 'صورتِ سوال گنگه' },
|
||||
{ id: 'typo', label: 'غلطِ تایپی داره' },
|
||||
{ id: 'too_hard', label: 'برای این درس خیلی سخته' },
|
||||
{ id: 'other', label: 'یه چیزِ دیگه' },
|
||||
],
|
||||
lesson: [
|
||||
{ id: 'wrong_content', label: 'مطلبِ درس اشتباهه' },
|
||||
{ id: 'unclear', label: 'توضیح گنگه' },
|
||||
{ id: 'typo', label: 'غلطِ تایپی داره' },
|
||||
{ id: 'bad_figure', label: 'شکل ایراد داره' },
|
||||
{ id: 'other', label: 'یه چیزِ دیگه' },
|
||||
],
|
||||
};
|
||||
|
||||
const memoKey = (kind: Kind, id: string) => `tadrisino.vote.${kind}.${id}`;
|
||||
function readVote(kind: Kind, id: string): 1 | -1 | 0 {
|
||||
try {
|
||||
const v = Number(sessionStorage.getItem(memoKey(kind, id)));
|
||||
return v === 1 || v === -1 ? v : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* زیرِ هر سوال و هر درس: لایک، دیسلایک و گزارشِ ایراد. به ادمین میرسد
|
||||
* (ستونِ 👍/👎 و صفِ گزارشها) تا ایرادِ محتوا را خودِ دانشآموزها خبر بدهند.
|
||||
*/
|
||||
export function FeedbackBar({ kind, id }: { kind: Kind; id: string }) {
|
||||
const toast = useToast();
|
||||
const [vote, setVote] = useState<1 | -1 | 0>(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => setVote(readVote(kind, id)), [kind, id]);
|
||||
|
||||
async function cast(next: 1 | -1) {
|
||||
const value = vote === next ? 0 : next;
|
||||
const before = vote;
|
||||
setVote(value);
|
||||
try {
|
||||
await api.feedback.vote(kind, id, value);
|
||||
try { sessionStorage.setItem(memoKey(kind, id), String(value)); } catch { /* حافظهی مرورگر بسته است؛ فقط یادآوریِ همین نشست */ }
|
||||
if (value !== 0) toast('ممنون از نظرت!', 'success');
|
||||
} catch (err) {
|
||||
setVote(before);
|
||||
toast(friendlyError(err, 'نظرت ثبت نشد'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const base = 'flex min-h-11 flex-1 items-center justify-center gap-1.5 rounded-2xl border-2 px-2 text-xs font-black transition-colors';
|
||||
const idle = 'border-line bg-white text-muted';
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2" role="group" aria-label="نظر و گزارش">
|
||||
<button type="button" aria-pressed={vote === 1} onClick={() => void cast(1)} className={`${base} ${vote === 1 ? 'border-[#5FBF8F] bg-[#EEF9F3] text-[#2f8a5f]' : idle}`}>
|
||||
<ThumbsUp size={16} strokeWidth={2.4} /> خوب بود
|
||||
</button>
|
||||
<button type="button" aria-pressed={vote === -1} onClick={() => void cast(-1)} className={`${base} ${vote === -1 ? 'border-[#ED6956] bg-[#fff4f2] text-coral' : idle}`}>
|
||||
<ThumbsDown size={16} strokeWidth={2.4} /> خوب نبود
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(true)} className={`${base} ${idle}`}>
|
||||
<Flag size={16} strokeWidth={2.4} /> گزارشِ ایراد
|
||||
</button>
|
||||
</div>
|
||||
<ReportSheet open={open} kind={kind} id={id} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportSheet({ open, kind, id, onClose }: { open: boolean; kind: Kind; id: string; onClose: () => void }) {
|
||||
const toast = useToast();
|
||||
const [reason, setReason] = useState<string | 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.feedback.report(kind, id, reason, note.trim());
|
||||
toast(r.alreadyReported ? 'قبلاً گزارشش رو داده بودی؛ ممنون!' : 'ممنون! گزارشت رسید و بررسی میشه.', 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast(friendlyError(err, 'گزارش ارسال نشد'), 'error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title={kind === 'question' ? 'این سوال چه ایرادی داره؟' : 'این درس چه ایرادی داره؟'}>
|
||||
<div className="mb-4 flex flex-col gap-2">
|
||||
{REASONS[kind].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>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { Diagram } from '@/diagram';
|
||||
import { StepText } from '@/lib/StepText';
|
||||
import { FeedbackBar } from '@/components/FeedbackBar';
|
||||
|
||||
/** معرفیِ درس، پیش از تمرین. */
|
||||
export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
|
||||
@@ -88,6 +89,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-3 pt-2">
|
||||
<FeedbackBar kind="lesson" id={String(lessonId)} />
|
||||
{data.questionCount === 0 ? (
|
||||
<div className="rounded-2xl border-2 border-dashed border-[#dedced] bg-white py-4 text-center text-sm font-bold text-muted">
|
||||
تمرینِ این درس بهزودی اضافه میشه
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Brain, Check, ChevronLeft, Coins, Compass, Flag, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react';
|
||||
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
|
||||
import { Brain, Check, ChevronLeft, Coins, Compass, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react';
|
||||
import { api, ApiError, type AnswerResult, type Hearts, type Question } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { fetchHome } from '@/state/subject';
|
||||
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
||||
@@ -17,6 +17,7 @@ import { friendlyError } from '@/lib/errors';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { Diagram } from '@/diagram';
|
||||
import { FeedbackBar } from '@/components/FeedbackBar';
|
||||
import { sounds } from '@/lib/sound';
|
||||
import { useGateHold } from '@/lib/useAsync';
|
||||
import { StreakCelebration, StreakFlame } from '@/components/StreakFlame';
|
||||
@@ -69,7 +70,6 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
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 [combo, setCombo] = useState(0);
|
||||
@@ -344,9 +344,6 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
<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"><MathText text={question.prompt} /></h2>
|
||||
{question.figure ? <div className="mt-3"><Diagram spec={question.figure} /></div> : null}
|
||||
@@ -370,6 +367,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FeedbackBar kind="question" id={question.id} />
|
||||
</div>
|
||||
|
||||
{answer && !answer.correct && look.char === 'pi' && (
|
||||
@@ -411,7 +409,6 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
<ReportSheet open={reporting} questionId={question.id} onClose={() => setReporting(false)} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -569,68 +566,6 @@ function TraitChip({ char, color, correct, combo }: { char: string; color: strin
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** آیکنِ کوچکِ بعد از جواب (همان خانوادهی آیکنِ اپ، نه ایموجیِ کیبورد)؛ هر سوال یکی از مجموعه، با یک تکانِ کوتاه. */
|
||||
const WIN: [LucideIcon, string][] = [[Brain, '#9B7BFF'], [Zap, '#E8A400'], [Target, '#ED6956'], [ThumbsUp, '#3E9E73'], [Sparkles, '#4C5FD5']];
|
||||
const MISS: [LucideIcon, string][] = [[Lightbulb, '#E8A400'], [Ruler, '#4C5FD5'], [Compass, '#9B7BFF']];
|
||||
|
||||
Reference in New Issue
Block a user