good morning

This commit is contained in:
rgrgogu
2026-08-06 08:11:18 +08:00
parent a37a4e9a97
commit 2be8547a52
10 changed files with 114 additions and 355 deletions
+2 -10
View File
@@ -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,
+2 -2
View File
@@ -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) {
+18 -4
View File
@@ -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">
+79 -9
View File
@@ -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;
+1 -28
View File
@@ -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}
/>
)}
</> </>
)} )}