mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
good morning
This commit is contained in:
@@ -489,7 +489,7 @@ export function AdminTaskProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Units/lessons/quizzes are standalone entities that may sit under 0..N
|
// Units/lessons are standalone entities that may sit under 0..N
|
||||||
// courses (junction revamp) — each row now carries a `courses[]` binding
|
// courses (junction revamp) — each row now carries a `courses[]` binding
|
||||||
// array instead of a single course_title/order_index pair, and
|
// array instead of a single course_title/order_index pair, and
|
||||||
// RequirementBuilder's ContentPicker builds its own search string from it,
|
// RequirementBuilder's ContentPicker builds its own search string from it,
|
||||||
@@ -510,14 +510,6 @@ export function AdminTaskProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchQuizzesFlat = useCallback(
|
|
||||||
() => request(async () => {
|
|
||||||
const res = await api.get('/admin/courses/quizzes-flat');
|
|
||||||
return res.data?.data ?? [];
|
|
||||||
}),
|
|
||||||
[request]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
// TASK COMPLETIONS
|
// TASK COMPLETIONS
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -702,7 +694,7 @@ export function AdminTaskProvider({ children }) {
|
|||||||
bulkArchiveCompletions, bulkRestoreCompletions,
|
bulkArchiveCompletions, bulkRestoreCompletions,
|
||||||
|
|
||||||
// ── Flat lists for RequirementBuilder ────────────────────────────
|
// ── Flat lists for RequirementBuilder ────────────────────────────
|
||||||
fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat,
|
fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat,
|
||||||
|
|
||||||
// ── Review workflow ───────────────────────────────────────────────
|
// ── Review workflow ───────────────────────────────────────────────
|
||||||
reviewSubmission,
|
reviewSubmission,
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ export function AuthProvider({ children }) {
|
|||||||
// recently and its trust window is still valid, in which case the backend
|
// recently and its trust window is still valid, in which case the backend
|
||||||
// returns otpRequired:false along with a full session, same shape as
|
// returns otpRequired:false along with a full session, same shape as
|
||||||
// verifyOTP's response.
|
// verifyOTP's response.
|
||||||
const login = useCallback(async ({ email, password }) => {
|
const login = useCallback(async ({ email, password, group_code }) => {
|
||||||
setAuthError(null)
|
setAuthError(null)
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('/auth/login', { email, password })
|
const { data } = await api.post('/auth/login', { email, password, ...(group_code ? { group_code } : {}) })
|
||||||
if (data.data.otpRequired === false) {
|
if (data.data.otpRequired === false) {
|
||||||
applySession(data.data)
|
applySession(data.data)
|
||||||
return { success: true, otpRequired: false, user: data.data.user }
|
return { success: true, otpRequired: false, user: data.data.user }
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function SummaryRow({ label, value }) {
|
|||||||
export default function CreateTask() {
|
export default function CreateTask() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { taskListId } = useParams();
|
const { taskListId } = useParams();
|
||||||
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, fetchTasksFlat, loading } = useAdminTask();
|
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchTasksFlat, loading } = useAdminTask();
|
||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
@@ -53,14 +53,12 @@ export default function CreateTask() {
|
|||||||
const [courses, setCourses] = useState([]);
|
const [courses, setCourses] = useState([]);
|
||||||
const [units, setUnits] = useState([]);
|
const [units, setUnits] = useState([]);
|
||||||
const [lessons, setLessons] = useState([]);
|
const [lessons, setLessons] = useState([]);
|
||||||
const [quizzes, setQuizzes] = useState([]);
|
|
||||||
const [siblingTasks, setSiblingTasks] = useState([]);
|
const [siblingTasks, setSiblingTasks] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCoursesFlat().then((d) => d && setCourses(d));
|
fetchCoursesFlat().then((d) => d && setCourses(d));
|
||||||
fetchUnitsFlat().then((d) => d && setUnits(d));
|
fetchUnitsFlat().then((d) => d && setUnits(d));
|
||||||
fetchLessonsFlat().then((d) => d && setLessons(d));
|
fetchLessonsFlat().then((d) => d && setLessons(d));
|
||||||
fetchQuizzesFlat().then((d) => d && setQuizzes(d));
|
|
||||||
fetchTasksFlat(taskListId).then((d) => d && setSiblingTasks(d));
|
fetchTasksFlat(taskListId).then((d) => d && setSiblingTasks(d));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -249,7 +247,6 @@ export default function CreateTask() {
|
|||||||
courses={courses}
|
courses={courses}
|
||||||
units={units}
|
units={units}
|
||||||
lessons={lessons}
|
lessons={lessons}
|
||||||
quizzes={quizzes}
|
|
||||||
taskListId={taskListId}
|
taskListId={taskListId}
|
||||||
/>
|
/>
|
||||||
{errors.requirements && (
|
{errors.requirements && (
|
||||||
|
|||||||
@@ -33,14 +33,13 @@ const STATUS_OPTIONS = [
|
|||||||
export default function EditTask() {
|
export default function EditTask() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { taskListId, taskId } = useParams();
|
const { taskListId, taskId } = useParams();
|
||||||
const { fetchTask, updateTask, fetchTasksFlat, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, loading } = useAdminTask();
|
const { fetchTask, updateTask, fetchTasksFlat, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading } = useAdminTask();
|
||||||
|
|
||||||
const [form, setForm] = useState(null);
|
const [form, setForm] = useState(null);
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [courses, setCourses] = useState([]);
|
const [courses, setCourses] = useState([]);
|
||||||
const [units, setUnits] = useState([]);
|
const [units, setUnits] = useState([]);
|
||||||
const [lessons, setLessons] = useState([]);
|
const [lessons, setLessons] = useState([]);
|
||||||
const [quizzes, setQuizzes] = useState([]);
|
|
||||||
const [siblingTasks, setSiblingTasks] = useState([]);
|
const [siblingTasks, setSiblingTasks] = useState([]);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const initialRequirementsRef = useRef(null);
|
const initialRequirementsRef = useRef(null);
|
||||||
@@ -62,7 +61,6 @@ export default function EditTask() {
|
|||||||
fetchCoursesFlat().then((d) => d && setCourses(d));
|
fetchCoursesFlat().then((d) => d && setCourses(d));
|
||||||
fetchUnitsFlat().then((d) => d && setUnits(d));
|
fetchUnitsFlat().then((d) => d && setUnits(d));
|
||||||
fetchLessonsFlat().then((d) => d && setLessons(d));
|
fetchLessonsFlat().then((d) => d && setLessons(d));
|
||||||
fetchQuizzesFlat().then((d) => d && setQuizzes(d));
|
|
||||||
fetchTasksFlat(taskListId).then((d) => d && setSiblingTasks(d.filter((t) => t.task_id !== taskId)));
|
fetchTasksFlat(taskListId).then((d) => d && setSiblingTasks(d.filter((t) => t.task_id !== taskId)));
|
||||||
}, [taskListId, taskId]);
|
}, [taskListId, taskId]);
|
||||||
|
|
||||||
@@ -245,7 +243,6 @@ export default function EditTask() {
|
|||||||
courses={courses}
|
courses={courses}
|
||||||
units={units}
|
units={units}
|
||||||
lessons={lessons}
|
lessons={lessons}
|
||||||
quizzes={quizzes}
|
|
||||||
taskListId={taskListId}
|
taskListId={taskListId}
|
||||||
/>
|
/>
|
||||||
{errors.requirements && (
|
{errors.requirements && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Tag, Lock, Clock, AlertTriangle, PenLine, ClipboardCheck, ShieldCheck, Link2, Unlink, History } from 'lucide-react';
|
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Tag, Lock, Clock, AlertTriangle, PenLine, ShieldCheck, Link2, Unlink, History } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -22,7 +22,6 @@ const REQUIREMENT_TYPES = [
|
|||||||
{ value: 'read_course', label: 'Read a Course', icon: BookOpen, category: 'Content' },
|
{ value: 'read_course', label: 'Read a Course', icon: BookOpen, category: 'Content' },
|
||||||
{ value: 'read_unit', label: 'Read a Unit', icon: Layers, category: 'Content' },
|
{ value: 'read_unit', label: 'Read a Unit', icon: Layers, category: 'Content' },
|
||||||
{ value: 'read_lesson', label: 'Read a Lesson', icon: FileText, category: 'Content' },
|
{ value: 'read_lesson', label: 'Read a Lesson', icon: FileText, category: 'Content' },
|
||||||
{ value: 'pass_quiz', label: 'Pass a Quiz', icon: ClipboardCheck, category: 'Content' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// No longer a choosable option (removed from Add Task) — kept here only so
|
// No longer a choosable option (removed from Add Task) — kept here only so
|
||||||
@@ -103,7 +102,7 @@ function BindingLine({ courses = [] }) {
|
|||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground min-w-0">
|
<span className="flex items-center gap-1 text-xs text-muted-foreground min-w-0">
|
||||||
<Link2 className="size-3 shrink-0" />
|
<Link2 className="size-3 shrink-0" />
|
||||||
<span className="flex-1 min-w-0 truncate">{courses.map((c) => c.title).join(', ')}</span>
|
<span className="flex-1 w-[10rem] truncate">{courses.map((c) => c.title).join(', ')}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -142,7 +141,7 @@ function ContentPicker({ value, options, idKey, searchKey, labelKey, placeholder
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="top-[15%] translate-y-0 flex flex-col max-h-[70vh] overflow-hidden rounded-xl! p-0 gap-0 sm:max-w-md">
|
<DialogContent className="top-[15%] translate-y-0 flex flex-col max-h-[70vh] overflow-hidden rounded-xl! p-0 gap-0 sm:max-w-lg">
|
||||||
<DialogHeader className="px-4 pt-4 pb-3 border-b pr-10">
|
<DialogHeader className="px-4 pt-4 pb-3 border-b pr-10">
|
||||||
<DialogTitle className="text-sm">{dialogTitle ?? placeholder}</DialogTitle>
|
<DialogTitle className="text-sm">{dialogTitle ?? placeholder}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -187,7 +186,7 @@ function createRequirement(type = 'visit_link') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── RequirementBuilder ───────────────────────────────────────────────────────
|
// ─── RequirementBuilder ───────────────────────────────────────────────────────
|
||||||
export default function RequirementBuilder({ value = [], onChange, courses = [], units = [], lessons = [], quizzes = [], taskListId }) {
|
export default function RequirementBuilder({ value = [], onChange, courses = [], units = [], lessons = [], taskListId }) {
|
||||||
const [items, setItems] = useState(
|
const [items, setItems] = useState(
|
||||||
value.length > 0
|
value.length > 0
|
||||||
? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
|
? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
|
||||||
@@ -229,7 +228,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
updateItem(key, { allowed_file_types: next });
|
updateItem(key, { allowed_file_types: next });
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONTENT_TYPE_TO_REQUIREMENT_TYPE = { course: 'read_course', unit: 'read_unit', lesson: 'read_lesson', quiz: 'pass_quiz' };
|
const CONTENT_TYPE_TO_REQUIREMENT_TYPE = { course: 'read_course', unit: 'read_unit', lesson: 'read_lesson' };
|
||||||
|
|
||||||
const handleContentSelect = (key, content, contentType) => {
|
const handleContentSelect = (key, content, contentType) => {
|
||||||
updateItem(key, {
|
updateItem(key, {
|
||||||
@@ -437,7 +436,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
renderTrigger={(c) => (
|
renderTrigger={(c) => (
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<TierBadge subscription={c.subscription} tierMap={tierMap} />
|
<TierBadge subscription={c.subscription} tierMap={tierMap} />
|
||||||
<span className="flex-1 truncate text-sm">{c.title}</span>
|
<span className="flex-1 w-[5rem] truncate text-sm">{c.title}</span>
|
||||||
{fmtDuration(c.duration_seconds) && (
|
{fmtDuration(c.duration_seconds) && (
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||||
<Clock className="size-3" />{fmtDuration(c.duration_seconds)}
|
<Clock className="size-3" />{fmtDuration(c.duration_seconds)}
|
||||||
@@ -474,7 +473,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
<TierBadge subscription={u.subscription} tierMap={tierMap} />
|
<TierBadge subscription={u.subscription} tierMap={tierMap} />
|
||||||
<div className="flex flex-col items-start flex-1 min-w-0">
|
<div className="flex flex-col items-start flex-1 min-w-0">
|
||||||
<BindingLine courses={u.courses} />
|
<BindingLine courses={u.courses} />
|
||||||
<span className="text-sm leading-tight truncate">{u.title}</span>
|
<span className="flex-1 w-[15rem] text-sm leading-tight truncate">{u.title}</span>
|
||||||
</div>
|
</div>
|
||||||
{fmtDuration(u.duration_seconds) && (
|
{fmtDuration(u.duration_seconds) && (
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||||
@@ -515,7 +514,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<div className="flex flex-col items-start flex-1 min-w-0">
|
<div className="flex flex-col items-start flex-1 min-w-0">
|
||||||
<BindingLine courses={l.courses} />
|
<BindingLine courses={l.courses} />
|
||||||
<span className="text-sm leading-tight truncate">{l.title}</span>
|
<span className="flex-1 w-[15rem] text-sm leading-tight truncate">{l.title}</span>
|
||||||
</div>
|
</div>
|
||||||
{fmtDuration(l.duration_seconds) && (
|
{fmtDuration(l.duration_seconds) && (
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||||
@@ -550,47 +549,6 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── pass_quiz fields ── */}
|
|
||||||
{item.type === 'pass_quiz' && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Quiz</Label>
|
|
||||||
<ContentPicker
|
|
||||||
value={item.reference_id}
|
|
||||||
options={quizzes}
|
|
||||||
idKey="uuid"
|
|
||||||
labelKey="title"
|
|
||||||
placeholder="Select a quiz"
|
|
||||||
dialogTitle="Select a quiz"
|
|
||||||
onSelect={(q) => handleContentSelect(item._key, q, 'quiz')}
|
|
||||||
renderTrigger={(q) => (
|
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
||||||
<div className="flex flex-col items-start flex-1 min-w-0">
|
|
||||||
<span className="text-xs leading-tight text-muted-foreground truncate">{q.unit_title}</span>
|
|
||||||
<span className="text-sm leading-tight truncate">{q.title}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
renderItem={(q) => (
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 w-full">
|
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
|
||||||
<span className="text-sm leading-tight truncate">{q.title}</span>
|
|
||||||
<span className="text-xs leading-tight text-muted-foreground truncate">Unit: {q.unit_title}</span>
|
|
||||||
</div>
|
|
||||||
<BindingChip courses={q.courses} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── inline no-content error ── */}
|
|
||||||
{item.reference_id && (item.duration_seconds ?? -1) === 0 && (
|
|
||||||
<p className="flex items-center gap-1.5 text-xs text-destructive pl-7 pt-1">
|
|
||||||
<AlertTriangle className="size-3 shrink-0" />
|
|
||||||
This quiz has no questions yet.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { FileText, Link as LinkIcon, Upload, BookOpen, Layers, PenLine, ClipboardCheck } from 'lucide-react';
|
import { FileText, Link as LinkIcon, Upload, BookOpen, Layers, PenLine } from 'lucide-react';
|
||||||
|
|
||||||
// ── Requirement validation ────────────────────────────────────────────────────
|
// ── Requirement validation ────────────────────────────────────────────────────
|
||||||
// pass_quiz reuses the same "picked a reference, and it has content" check as
|
const READ_TYPES = ['read_course', 'read_unit', 'read_lesson'];
|
||||||
// the read_* types — its duration_seconds slot carries question_count instead.
|
|
||||||
const READ_TYPES = ['read_course', 'read_unit', 'read_lesson', 'pass_quiz'];
|
|
||||||
|
|
||||||
// Users commonly type bare domains ("google.com") — default the scheme to https
|
// Users commonly type bare domains ("google.com") — default the scheme to https
|
||||||
// so the link is actually clickable/navigable once the task is saved.
|
// so the link is actually clickable/navigable once the task is saved.
|
||||||
@@ -44,7 +42,6 @@ export const REQUIREMENT_TYPE_META = {
|
|||||||
read_course: { label: 'Read a Course', icon: BookOpen },
|
read_course: { label: 'Read a Course', icon: BookOpen },
|
||||||
read_unit: { label: 'Read a Unit', icon: Layers },
|
read_unit: { label: 'Read a Unit', icon: Layers },
|
||||||
read_lesson: { label: 'Read a Lesson', icon: FileText },
|
read_lesson: { label: 'Read a Lesson', icon: FileText },
|
||||||
pass_quiz: { label: 'Pass a Quiz', icon: ClipboardCheck },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function requirementSummaryText(req) {
|
export function requirementSummaryText(req) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* May 23, 2026 lash0000 002 Migrated to Zod + zodResolver; removed shadcn Form wrapper
|
* May 23, 2026 lash0000 002 Migrated to Zod + zodResolver; removed shadcn Form wrapper
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link, useSearchParams } from 'react-router-dom'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Eye, EyeOff, LoaderCircle } from 'lucide-react'
|
import { Eye, EyeOff, LoaderCircle, Users } from 'lucide-react'
|
||||||
|
|
||||||
// ─── Schema ──────────────────────────────────────────────────────────────────
|
// ─── Schema ──────────────────────────────────────────────────────────────────
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
@@ -46,6 +46,8 @@ const loginSchema = z.object({
|
|||||||
export function LoginForm({ className, ...props }) {
|
export function LoginForm({ className, ...props }) {
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const groupCode = searchParams.get('group_code') || ''
|
||||||
|
|
||||||
const [passwordVisible, setPasswordVisible] = useState(false)
|
const [passwordVisible, setPasswordVisible] = useState(false)
|
||||||
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
||||||
@@ -66,7 +68,7 @@ export function LoginForm({ className, ...props }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async ({ email, password }) => {
|
const onSubmit = async ({ email, password }) => {
|
||||||
const result = await login({ email, password })
|
const result = await login({ email, password, group_code: groupCode || undefined })
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.otpRequired === false) {
|
if (result.otpRequired === false) {
|
||||||
@@ -95,7 +97,8 @@ export function LoginForm({ className, ...props }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGoogle = () => {
|
const handleGoogle = () => {
|
||||||
window.location.href = `${import.meta.env.VITE_API_URL}/auth/google`
|
const params = groupCode ? `?group_code=${encodeURIComponent(groupCode)}` : ''
|
||||||
|
window.location.href = `${import.meta.env.VITE_API_URL}/auth/google${params}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (otpEmail) {
|
if (otpEmail) {
|
||||||
@@ -127,6 +130,17 @@ export function LoginForm({ className, ...props }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Group code notice */}
|
||||||
|
{groupCode && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-2 bg-muted/40">
|
||||||
|
<Users className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Signing in will join group{' '}
|
||||||
|
<span className="font-semibold text-foreground">{groupCode}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Google OAuth */}
|
{/* Google OAuth */}
|
||||||
<Button variant="outline" type="button" onClick={handleGoogle} className="w-full">
|
<Button variant="outline" type="button" onClick={handleGoogle} className="w-full">
|
||||||
<svg viewBox="0 0 128 128" className="size-4 mr-2">
|
<svg viewBox="0 0 128 128" className="size-4 mr-2">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { PhoneInput } from '@/components/ui/phone-input'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Calendar } from '@/components/ui/calendar'
|
import { Calendar } from '@/components/ui/calendar'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -148,8 +149,8 @@ export function RegisterForm({ className, ...props }) {
|
|||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const groupCode = searchParams.get('group_code') || ''
|
const groupCode = searchParams.get('group_code') || ''
|
||||||
|
|
||||||
// 0 = personal, 1 = credentials, 2 = otp
|
// 'choice' = Google vs. manual signup entry screen, 0 = personal, 1 = credentials, 2 = otp
|
||||||
const [step, setStep] = useState(0)
|
const [step, setStep] = useState('choice')
|
||||||
const [pendingEmail, setPendingEmail] = useState('')
|
const [pendingEmail, setPendingEmail] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [showConfirm, setShowConfirm] = useState(false)
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
@@ -177,7 +178,7 @@ export function RegisterForm({ className, ...props }) {
|
|||||||
// server-side account + send an OTP — losing the tab here (slow internet,
|
// server-side account + send an OTP — losing the tab here (slow internet,
|
||||||
// accidental reload) is what leaves an orphaned unverified account behind.
|
// accidental reload) is what leaves an orphaned unverified account behind.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step === 0) return
|
if (step === 'choice' || step === 0) return
|
||||||
|
|
||||||
const handleBeforeUnload = (e) => {
|
const handleBeforeUnload = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -204,6 +205,11 @@ export function RegisterForm({ className, ...props }) {
|
|||||||
setStep(1)
|
setStep(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleGoogle = () => {
|
||||||
|
const params = groupCode ? `?group_code=${encodeURIComponent(groupCode)}` : ''
|
||||||
|
window.location.href = `${import.meta.env.VITE_API_URL}/auth/google${params}`
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 2: Credentials ───────────────────────────────────────────────────
|
// ── Step 2: Credentials ───────────────────────────────────────────────────
|
||||||
const {
|
const {
|
||||||
register: regCreds,
|
register: regCreds,
|
||||||
@@ -306,7 +312,60 @@ export function RegisterForm({ className, ...props }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn('flex flex-col', className)} {...props}>
|
<div className={cn('flex flex-col', className)} {...props}>
|
||||||
<StepIndicator current={step} />
|
{step !== 'choice' && <StepIndicator current={step} />}
|
||||||
|
|
||||||
|
{/* ── Entry: Google vs. manual signup ── */}
|
||||||
|
{step === 'choice' && (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tighter">Create your account.</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">Join Philproperties to start learning.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group code notice */}
|
||||||
|
{groupCode && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-2 bg-muted/40">
|
||||||
|
<Users className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Joining group{' '}
|
||||||
|
<span className="font-semibold text-foreground">{groupCode}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Google OAuth */}
|
||||||
|
<Button variant="outline" type="button" onClick={handleGoogle} className="w-full">
|
||||||
|
<svg viewBox="0 0 128 128" className="size-4 mr-2">
|
||||||
|
<path fill="#fff" d="M44.59 4.21a63.28 63.28 0 004.33 120.9 67.6 67.6 0 0032.36.35 57.13 57.13 0 0025.9-13.46 57.44 57.44 0 0016-26.26 74.33 74.33 0 001.61-33.58H65.27v24.69h34.47a29.72 29.72 0 01-12.66 19.52 36.16 36.16 0 01-13.93 5.5 41.29 41.29 0 01-15.1 0A37.16 37.16 0 0144 95.74a39.3 39.3 0 01-14.5-19.42 38.31 38.31 0 010-24.63 39.25 39.25 0 019.18-14.91A37.17 37.17 0 0176.13 27a34.28 34.28 0 0113.64 8q5.83-5.8 11.64-11.63c2-2.09 4.18-4.08 6.15-6.22A61.22 61.22 0 0087.2 4.59a64 64 0 00-42.61-.38z" />
|
||||||
|
<path fill="#e33629" d="M44.59 4.21a64 64 0 0142.61.37 61.22 61.22 0 0120.35 12.62c-2 2.14-4.11 4.14-6.15 6.22Q95.58 29.23 89.77 35a34.28 34.28 0 00-13.64-8 37.17 37.17 0 00-37.46 9.74 39.25 39.25 0 00-9.18 14.91L8.76 35.6A63.53 63.53 0 0144.59 4.21z" />
|
||||||
|
<path fill="#f8bd00" d="M3.26 51.5a62.93 62.93 0 015.5-15.9l20.73 16.09a38.31 38.31 0 000 24.63q-10.36 8-20.73 16.08a63.33 63.33 0 01-5.5-40.9z" />
|
||||||
|
<path fill="#587dbd" d="M65.27 52.15h59.52a74.33 74.33 0 01-1.61 33.58 57.44 57.44 0 01-16 26.26c-6.69-5.22-13.41-10.4-20.1-15.62a29.72 29.72 0 0012.66-19.54H65.27c-.01-8.22 0-16.45 0-24.68z" />
|
||||||
|
<path fill="#319f43" d="M8.75 92.4q10.37-8 20.73-16.08A39.3 39.3 0 0044 95.74a37.16 37.16 0 0014.08 6.08 41.29 41.29 0 0015.1 0 36.16 36.16 0 0013.93-5.5c6.69 5.22 13.41 10.4 20.1 15.62a57.13 57.13 0 01-25.9 13.47 67.6 67.6 0 01-32.36-.35 63 63 0 01-23-11.59A63.73 63.73 0 018.75 92.4z" />
|
||||||
|
</svg>
|
||||||
|
Login with Google
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
<span>Or continue with</span>
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" className="w-full" onClick={() => setStep(0)}>
|
||||||
|
Proceed
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link
|
||||||
|
to={groupCode ? `/login?group_code=${groupCode}` : '/login'}
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Step 1: Personal info ── */}
|
{/* ── Step 1: Personal info ── */}
|
||||||
{step === 0 && (
|
{step === 0 && (
|
||||||
@@ -472,13 +531,21 @@ export function RegisterForm({ className, ...props }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full mt-1">
|
<div className="flex gap-2 mt-1">
|
||||||
Proceed
|
<Button type="button" variant="outline" className="flex-1" onClick={() => setStep('choice')}>
|
||||||
</Button>
|
← Back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="flex-1">
|
||||||
|
Proceed
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<Link to="/login" className="font-medium underline underline-offset-4">
|
<Link
|
||||||
|
to={groupCode ? `/login?group_code=${groupCode}` : '/login'}
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
@@ -621,7 +688,10 @@ export function RegisterForm({ className, ...props }) {
|
|||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<Link to="/login" className="font-medium underline underline-offset-4">
|
<Link
|
||||||
|
to={groupCode ? `/login?group_code=${groupCode}` : '/login'}
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -11,7 +11,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Trophy, Clock, Paperclip, Plus,
|
Trophy, Clock, Paperclip, Plus,
|
||||||
Link, BookOpen, LayoutList, FileText, House,
|
Link, BookOpen, LayoutList, FileText, House,
|
||||||
Image, Video, Music, PenLine, ClipboardCheck, Hourglass, XCircle,
|
Image, Video, Music, PenLine, Hourglass, XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -29,7 +29,6 @@ import VisitLink from '../components/blocks/VisitLink';
|
|||||||
import ReadCourse from '../components/blocks/ReadCourse';
|
import ReadCourse from '../components/blocks/ReadCourse';
|
||||||
import ReadUnit from '../components/blocks/ReadUnit';
|
import ReadUnit from '../components/blocks/ReadUnit';
|
||||||
import ReadLesson from '../components/blocks/ReadLesson';
|
import ReadLesson from '../components/blocks/ReadLesson';
|
||||||
import PassQuiz from '../components/blocks/PassQuiz';
|
|
||||||
|
|
||||||
import { useTask } from '@/contexts/ClientTaskContext';
|
import { useTask } from '@/contexts/ClientTaskContext';
|
||||||
import { PageMeta } from '@/contexts/MetadataContext';
|
import { PageMeta } from '@/contexts/MetadataContext';
|
||||||
@@ -86,16 +85,6 @@ const RequirementsStatusPanel = ({ requirements = [], latestCompletion, isVisite
|
|||||||
return { done: done ? 1 : 0, total: 1, binary: true };
|
return { done: done ? 1 : 0, total: 1, binary: true };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'pass_quiz',
|
|
||||||
label: 'Pass quizzes',
|
|
||||||
icon: <ClipboardCheck className="size-4 shrink-0 text-muted-foreground" />,
|
|
||||||
getValue: () => {
|
|
||||||
const reqs = requirements.filter((r) => r.type === 'pass_quiz');
|
|
||||||
const done = reqs.filter((r) => isCompleted(r.requirement_id, r.reference_id)).length;
|
|
||||||
return { done, total: reqs.length, binary: false };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'visit_link',
|
key: 'visit_link',
|
||||||
label: 'Visit links',
|
label: 'Visit links',
|
||||||
@@ -402,7 +391,6 @@ const ViewTask = () => {
|
|||||||
const readCourseReqs = requirements.filter((r) => r.type === 'read_course');
|
const readCourseReqs = requirements.filter((r) => r.type === 'read_course');
|
||||||
const readUnitReqs = requirements.filter((r) => r.type === 'read_unit');
|
const readUnitReqs = requirements.filter((r) => r.type === 'read_unit');
|
||||||
const readLessonReqs = requirements.filter((r) => r.type === 'read_lesson');
|
const readLessonReqs = requirements.filter((r) => r.type === 'read_lesson');
|
||||||
const passQuizReqs = requirements.filter((r) => r.type === 'pass_quiz');
|
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: 'Home', icon: <House className="size-4" />, to: '/dashboard' },
|
{ label: 'Home', icon: <House className="size-4" />, to: '/dashboard' },
|
||||||
@@ -601,21 +589,6 @@ const ViewTask = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* pass_quiz */}
|
|
||||||
{passQuizReqs.length > 0 && (
|
|
||||||
<PassQuiz
|
|
||||||
quizzes={passQuizReqs.map((r) => ({
|
|
||||||
id: r.requirement_id,
|
|
||||||
requirement_id: r.requirement_id,
|
|
||||||
reference_id: r.reference_id,
|
|
||||||
title: r.reference_label ?? 'Quiz',
|
|
||||||
completed: isCompleted(r.requirement_id, r.reference_id),
|
|
||||||
}))}
|
|
||||||
groupId={groupId}
|
|
||||||
taskListId={taskListId}
|
|
||||||
taskId={taskId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user