mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
240 lines
14 KiB
React
240 lines
14 KiB
React
import { useState, useEffect } from "react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { ClipboardCheck, CheckCheck, SendHorizonal, Lock, Zap, Info, Tag } from "lucide-react";
|
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
|
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useNavigate } from "react-router-dom";
|
|
import api from "@/utils/api.util";
|
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
|
|
|
function TierBadge({ tier }) {
|
|
const { tierMap } = useClientTiers();
|
|
const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
|
|
return (
|
|
<Badge className={`gap-1 ${cls} w-fit shrink-0`}>
|
|
{rank > 0 ? <Lock className="size-3" /> : <Tag className="size-3" />}
|
|
{label}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
// ─── PassQuiz — task requirement block for pass_quiz type ─────────────────────
|
|
// Mirrors ReadUnit.jsx's fetch-detail-and-navigate pattern. A quiz is always
|
|
// unit-scoped; navigation goes into the course reader if the unit is attached
|
|
// to a course, otherwise the standalone unit reader.
|
|
const PassQuiz = ({ title = "Pass Quizzes", quizzes = [], groupId, taskListId, taskId }) => {
|
|
const navigate = useNavigate();
|
|
const [details, setDetails] = useState({});
|
|
const [locked, setLocked] = useState({});
|
|
const [lockedInfo, setLockedInfo] = useState({});
|
|
const [unavailable, setUnavailable] = useState({});
|
|
const [fetching, setFetching] = useState({});
|
|
const [selected, setSelected] = useState(null);
|
|
|
|
useEffect(() => {
|
|
quizzes.forEach(async (q) => {
|
|
if (!q.reference_id) return;
|
|
setFetching((prev) => ({ ...prev, [q.reference_id]: true }));
|
|
try {
|
|
const res = await api.get(`/client/courses/quiz/uuid/${q.reference_id}`);
|
|
const d = res.data?.data;
|
|
if (d) setDetails((prev) => ({ ...prev, [q.reference_id]: d }));
|
|
} catch (err) {
|
|
if (err?.response?.status === 403) {
|
|
setLocked((prev) => ({ ...prev, [q.reference_id]: true }));
|
|
const course = err.response?.data?.course;
|
|
if (course) setLockedInfo((prev) => ({ ...prev, [q.reference_id]: course }));
|
|
} else {
|
|
setUnavailable((prev) => ({ ...prev, [q.reference_id]: true }));
|
|
}
|
|
} finally {
|
|
setFetching((prev) => ({ ...prev, [q.reference_id]: false }));
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
const goToQuiz = (info) => {
|
|
if (!info) return;
|
|
const taskCtx = taskId ? { has_task: true, groupId, taskListId, taskId } : undefined;
|
|
if (info.unit?.course?.course_id) {
|
|
navigate(`/course/${info.unit.course.course_id}/unit`, {
|
|
state: { quizUnitId: info.unit.unit_id, ...(taskCtx ? { taskCtx } : {}) },
|
|
});
|
|
} else if (info.unit?.uuid) {
|
|
navigate(`/units/${info.unit.uuid}/read`, { state: { quizId: true } });
|
|
}
|
|
};
|
|
|
|
if (!quizzes.length) return null;
|
|
|
|
const hasLocked = Object.values(locked).some(Boolean);
|
|
|
|
return (
|
|
<>
|
|
<div className="border rounded-lg bg-card overflow-hidden">
|
|
<div className="px-6 py-4 border-b flex items-center gap-2">
|
|
<ClipboardCheck className="size-4 text-muted-foreground" />
|
|
<h2 className="font-semibold text-sm">{title}</h2>
|
|
<Badge variant="secondary" className="ml-auto">{quizzes.length}</Badge>
|
|
</div>
|
|
|
|
{hasLocked && (
|
|
<div className="flex items-start gap-3 px-5 py-3.5 border-b bg-amber-50 dark:bg-amber-950/30 text-amber-800 dark:text-amber-300">
|
|
<Info className="size-4 mt-0.5 shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium leading-snug">Subscription Required</p>
|
|
<p className="text-xs mt-0.5 text-amber-700 dark:text-amber-400 leading-relaxed">
|
|
To complete this activity, subscribe to one of our available tier plans.
|
|
</p>
|
|
</div>
|
|
<Button size="sm" variant="outline" className="shrink-0 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40" onClick={() => navigate('/plans')}>
|
|
<Zap className="size-3.5" /> View Plans
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<ScrollArea className="w-full bg-muted overflow-hidden">
|
|
<div className="flex gap-4 p-4">
|
|
{quizzes.map((q) => {
|
|
const info = details[q.reference_id];
|
|
const courseInfo = lockedInfo[q.reference_id];
|
|
const isLocked = locked[q.reference_id];
|
|
const isUnavailable = unavailable[q.reference_id];
|
|
const isFetching = fetching[q.reference_id];
|
|
const passed = info?.has_passed || q.completed;
|
|
|
|
if (isLocked) {
|
|
return (
|
|
<div
|
|
key={q.id}
|
|
onClick={() => navigate('/plans')}
|
|
className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-80 shrink-0 opacity-80"
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
<ClipboardCheck className="size-3.5 text-muted-foreground shrink-0" />
|
|
<span className="text-xs text-muted-foreground font-medium">Quiz</span>
|
|
<div className="ml-auto"><TierBadge tier={courseInfo?.subscription} /></div>
|
|
</div>
|
|
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">{q.title}</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Subscribe to <span className="font-medium">{courseInfo?.title ?? 'this course'}</span> to unlock this quiz.
|
|
</p>
|
|
<div className="mt-auto">
|
|
<Button size="sm" className="w-full gap-1.5" onClick={(e) => { e.stopPropagation(); navigate('/plans'); }}>
|
|
<Zap className="size-3.5" /> Upgrade to unlock
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isUnavailable) {
|
|
return (
|
|
<div key={q.id} className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 w-80 shrink-0 opacity-50 cursor-not-allowed">
|
|
<div className="flex items-center gap-1.5">
|
|
<ClipboardCheck className="size-3.5 text-muted-foreground shrink-0" />
|
|
<span className="text-xs text-muted-foreground font-medium">Quiz</span>
|
|
<Badge variant="secondary" className="ml-auto gap-1 text-muted-foreground text-xs">
|
|
<Lock className="size-3" /> Unavailable
|
|
</Badge>
|
|
</div>
|
|
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">{q.title}</h1>
|
|
<p className="text-sm text-muted-foreground">This quiz is no longer available.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={q.id}
|
|
onClick={() => !isFetching && setSelected(q)}
|
|
className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-80 shrink-0 ${
|
|
isFetching ? 'opacity-60 cursor-wait' : 'hover:bg-muted/60 hover:shadow-sm cursor-pointer group'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
<ClipboardCheck className="size-3.5 text-muted-foreground shrink-0" />
|
|
<span className="text-xs text-muted-foreground font-medium">Quiz</span>
|
|
<div className="ml-auto">
|
|
{info?.unit?.course?.subscription
|
|
? <TierBadge tier={info.unit.course.subscription} />
|
|
: isFetching && <Badge variant="secondary" className="opacity-40 text-xs">Loading…</Badge>
|
|
}
|
|
</div>
|
|
</div>
|
|
{info?.unit?.title && (
|
|
<p className="text-xs text-muted-foreground truncate -mt-1">
|
|
in <span className="text-foreground/70 font-medium">{info.unit.title}</span>
|
|
</p>
|
|
)}
|
|
<h1 className="text-base font-semibold leading-snug line-clamp-2">{q.title}</h1>
|
|
<div className="flex items-center justify-between text-sm mt-auto pt-2 border-t">
|
|
{passed ? (
|
|
<span className="flex items-center gap-1.5 text-green-600 dark:text-green-400 font-medium">
|
|
<CheckCheck className="size-4" /> Passed
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground font-medium">Not Attempted</span>
|
|
)}
|
|
{info?.passing_score && (
|
|
<span className="text-xs text-muted-foreground">{info.passing_score}% to pass</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<ScrollBar orientation="horizontal" />
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
<ResponsiveModal
|
|
open={!!selected}
|
|
onOpenChange={(v) => !v && setSelected(null)}
|
|
title={selected?.title}
|
|
description="Quiz Info"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
|
|
<Button
|
|
onClick={() => goToQuiz(details[selected?.reference_id])}
|
|
disabled={!details[selected?.reference_id]}
|
|
>
|
|
<SendHorizonal /> Take Quiz
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
{selected && (() => {
|
|
const info = details[selected.reference_id];
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{(info?.has_passed || selected.completed) && (
|
|
<div className="flex items-center gap-2 bg-green-50 dark:bg-green-950/40 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg px-4 py-3">
|
|
<CheckCheck className="size-4 shrink-0" /> Already Passed
|
|
</div>
|
|
)}
|
|
{info?.unit?.title && (
|
|
<p className="text-sm">
|
|
<span className="text-muted-foreground">Unit: </span>
|
|
<span className="font-medium">{info.unit.title}</span>
|
|
</p>
|
|
)}
|
|
{info?.passing_score && (
|
|
<p className="text-sm">
|
|
<span className="text-muted-foreground">Passing score: </span>
|
|
<span className="font-medium">{info.passing_score}%</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
</ResponsiveModal>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default PassQuiz;
|