diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 7502414..b93e3ba 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -22,6 +22,13 @@ import { CourseDetailPage } from "../pages/content-management/CourseDetailPage"; import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage"; import { AddVideoFlow } from "../pages/content-management/AddVideoFlow"; import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow"; +import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage"; +import { AttachPracticeFlow } from "../pages/content-management/AttachPracticeFlow"; +import { AttachProgramPracticeFlow } from "../pages/content-management/AttachProgramPracticeFlow"; +import { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTypeSelectionPage"; +import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage"; +import { CourseManagementPage } from "../pages/content-management/CourseManagementPage"; +import { UnitManagementPage } from "../pages/content-management/UnitManagementPage"; import { NotFoundPage } from "../pages/NotFoundPage"; import { NotificationsPage } from "../pages/notifications/NotificationsPage"; import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage"; @@ -154,6 +161,34 @@ export function AppRoutes() { } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 5095b8b..37ed927 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,12 +1,12 @@ -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { X } from "lucide-react" -import { cn } from "../../lib/utils" +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "../../lib/utils"; -const Dialog = DialogPrimitive.Root -const DialogTrigger = DialogPrimitive.Trigger -const DialogPortal = DialogPrimitive.Portal -const DialogClose = DialogPrimitive.Close +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< React.ElementRef, @@ -20,8 +20,8 @@ const DialogOverlay = React.forwardRef< )} {...props} /> -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, @@ -38,27 +38,42 @@ const DialogContent = React.forwardRef< {...props} > {children} - - + + Close -)) -DialogContent.displayName = DialogPrimitive.Content.displayName +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; -const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
-) -DialogHeader.displayName = "DialogHeader" - -const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => (
-) -DialogFooter.displayName = "DialogFooter" +); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< React.ElementRef, @@ -66,11 +81,14 @@ const DialogTitle = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< React.ElementRef, @@ -81,8 +99,8 @@ const DialogDescription = React.forwardRef< className={cn("text-sm text-muted-foreground", className)} {...props} /> -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; export { Dialog, @@ -95,5 +113,4 @@ export { DialogFooter, DialogTitle, DialogDescription, -} - +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 83cec90..ba422f1 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -9,7 +9,7 @@ export const Input = React.forwardRef( {} @@ -18,10 +18,9 @@ export const Select = React.forwardRef( > {children} - +
- ) + ); }, -) -Select.displayName = "Select" - +); +Select.displayName = "Select"; diff --git a/src/components/ui/stepper.tsx b/src/components/ui/stepper.tsx index a0c87a2..0fe69de 100644 --- a/src/components/ui/stepper.tsx +++ b/src/components/ui/stepper.tsx @@ -18,15 +18,18 @@ export function Stepper({ steps, currentStep, className }: StepperProps) { key={step} className="flex-1 relative flex flex-col items-center group" > - {/* Connector Line (Behind) */} + {/* Connector Line - floats between circles with gap on both sides */} {index < steps.length - 1 && ( -
+
)} {/* Circle */}
diff --git a/src/pages/content-management/AddNewPracticePage.tsx b/src/pages/content-management/AddNewPracticePage.tsx index b6f01fa..ad370db 100644 --- a/src/pages/content-management/AddNewPracticePage.tsx +++ b/src/pages/content-management/AddNewPracticePage.tsx @@ -1,99 +1,156 @@ -import { useMemo, useRef, useState, type ChangeEvent } from "react" -import { Link, useLocation, useParams, useNavigate } from "react-router-dom" -import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, Edit, Rocket, Loader2, Upload } from "lucide-react" -import { toast } from "sonner" -import { Card } from "../../components/ui/card" -import { Button } from "../../components/ui/button" -import { Input } from "../../components/ui/input" -import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields" -import { createQuestionSet, createQuestion, addQuestionToSet } from "../../api/courses.api" -import { uploadVideoFile } from "../../api/files.api" -import { Select } from "../../components/ui/select" -import type { QuestionOption } from "../../types/course.types" +import { useMemo, useRef, useState, type ChangeEvent } from "react"; +import { Link, useLocation, useParams, useNavigate } from "react-router-dom"; +import { + ArrowLeft, + ArrowRight, + ChevronDown, + Grid3X3, + Check, + Plus, + Trash2, + GripVertical, + Edit, + Rocket, + Loader2, + Upload, +} from "lucide-react"; +import { toast } from "sonner"; +import { Card } from "../../components/ui/card"; +import { Button } from "../../components/ui/button"; +import { Input } from "../../components/ui/input"; +import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"; +import { + createQuestionSet, + createQuestion, + addQuestionToSet, +} from "../../api/courses.api"; +import { uploadVideoFile } from "../../api/files.api"; +import { Select } from "../../components/ui/select"; +import type { QuestionOption } from "../../types/course.types"; -type Step = 1 | 2 | 3 | 4 | 5 -type ResultStatus = "success" | "error" -type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" -type DifficultyLevel = "EASY" | "MEDIUM" | "HARD" +type Step = 1 | 2 | 3 | 4 | 5; +type ResultStatus = "success" | "error"; +type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"; +type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"; interface Persona { - id: string - name: string - avatar: string + id: string; + name: string; + avatar: string; } interface MCQOption { - text: string - isCorrect: boolean + text: string; + isCorrect: boolean; } interface Question { - id: string - questionText: string - questionType: QuestionType - difficultyLevel: DifficultyLevel - points: number - tips: string - explanation: string - options: MCQOption[] - voicePrompt: string - sampleAnswerVoicePrompt: string - audioCorrectAnswerText: string - shortAnswers: string[] - imageUrl: string + id: string; + questionText: string; + questionType: QuestionType; + difficultyLevel: DifficultyLevel; + points: number; + tips: string; + explanation: string; + options: MCQOption[]; + voicePrompt: string; + sampleAnswerVoicePrompt: string; + audioCorrectAnswerText: string; + shortAnswers: string[]; + imageUrl: string; } const PERSONAS: Persona[] = [ - { id: "1", name: "Dawit", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit" }, - { id: "2", name: "Mahlet", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet" }, - { id: "3", name: "Amanuel", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel" }, - { id: "4", name: "Bethel", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel" }, - { id: "5", name: "Liya", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya" }, - { id: "6", name: "Aseffa", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa" }, - { id: "7", name: "Hana", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana" }, - { id: "8", name: "Nahom", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom" }, -] + { + id: "1", + name: "Dawit", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit", + }, + { + id: "2", + name: "Mahlet", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet", + }, + { + id: "3", + name: "Amanuel", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel", + }, + { + id: "4", + name: "Bethel", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel", + }, + { + id: "5", + name: "Liya", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya", + }, + { + id: "6", + name: "Aseffa", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa", + }, + { + id: "7", + name: "Hana", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana", + }, + { + id: "8", + name: "Nahom", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom", + }, +]; const STEPS = [ { number: 1, label: "Context" }, { number: 2, label: "Persona" }, { number: 3, label: "Questions" }, { number: 4, label: "Review" }, -] +]; /** Prefer direct storage URL; for Vimeo pipeline match SubCourseContentPage player URL shape. */ -function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: string } | undefined): string | null { - if (!data) return null - const pageUrl = data.url?.trim() - const embedUrl = data.embed_url?.trim() +function introVideoUrlFromUploadResponse( + data: { url?: string; embed_url?: string } | undefined, +): string | null { + if (!data) return null; + const pageUrl = data.url?.trim(); + const embedUrl = data.embed_url?.trim(); if (embedUrl) { - const hashFromUrl = pageUrl ? pageUrl.split("/").filter(Boolean).at(-1) : undefined - return hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl + const hashFromUrl = pageUrl + ? pageUrl.split("/").filter(Boolean).at(-1) + : undefined; + return hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl; } - return pageUrl || null + return pageUrl || null; } function toVimeoEmbedUrl(rawUrl: string): string | null { try { - const parsed = new URL(rawUrl.trim()) - const host = parsed.hostname.toLowerCase() - if (!host.includes("vimeo.com")) return null - if (host.includes("player.vimeo.com") && parsed.pathname.includes("/video/")) return parsed.toString() - const segments = parsed.pathname.split("/").filter(Boolean) - const videoId = segments.find((segment) => /^\d+$/.test(segment)) - if (!videoId) return null - const hash = parsed.searchParams.get("h") + const parsed = new URL(rawUrl.trim()); + const host = parsed.hostname.toLowerCase(); + if (!host.includes("vimeo.com")) return null; + if ( + host.includes("player.vimeo.com") && + parsed.pathname.includes("/video/") + ) + return parsed.toString(); + const segments = parsed.pathname.split("/").filter(Boolean); + const videoId = segments.find((segment) => /^\d+$/.test(segment)); + if (!videoId) return null; + const hash = parsed.searchParams.get("h"); return hash ? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}` - : `https://player.vimeo.com/video/${videoId}` + : `https://player.vimeo.com/video/${videoId}`; } catch { - return null + return null; } } function isDirectVideoFile(url: string): boolean { - const clean = url.split("?")[0].toLowerCase() - return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean) + const clean = url.split("?")[0].toLowerCase(); + return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean); } function escapeHtml(raw: string): string { @@ -102,45 +159,56 @@ function escapeHtml(raw: string): string { .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) - .replaceAll("'", "'") + .replaceAll("'", "'"); } function sanitizeAdminRichTextHtml(input: string): string { - if (!input.trim()) return "" + if (!input.trim()) return ""; try { - const parser = new DOMParser() - const doc = parser.parseFromString(input, "text/html") - const blockedTags = new Set(["script", "style", "iframe", "object", "embed", "link", "meta"]) + const parser = new DOMParser(); + const doc = parser.parseFromString(input, "text/html"); + const blockedTags = new Set([ + "script", + "style", + "iframe", + "object", + "embed", + "link", + "meta", + ]); doc.body.querySelectorAll("*").forEach((el) => { - const tagName = el.tagName.toLowerCase() + const tagName = el.tagName.toLowerCase(); if (blockedTags.has(tagName)) { - el.remove() - return + el.remove(); + return; } - const attrs = [...el.attributes] + const attrs = [...el.attributes]; attrs.forEach((attr) => { - const name = attr.name.toLowerCase() - const value = attr.value.trim().toLowerCase() + const name = attr.name.toLowerCase(); + const value = attr.value.trim().toLowerCase(); if (name.startsWith("on")) { - el.removeAttribute(attr.name) - return + el.removeAttribute(attr.name); + return; } - if ((name === "href" || name === "src") && value.startsWith("javascript:")) { - el.removeAttribute(attr.name) + if ( + (name === "href" || name === "src") && + value.startsWith("javascript:") + ) { + el.removeAttribute(attr.name); } - }) - }) - return doc.body.innerHTML + }); + }); + return doc.body.innerHTML; } catch { - return escapeHtml(input).replace(/\r?\n/g, "
") + return escapeHtml(input).replace(/\r?\n/g, "
"); } } function formatDescriptionForPreview(raw: string): string { - if (!raw.trim()) return "" - const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw) - if (hasHtml) return sanitizeAdminRichTextHtml(raw) - return escapeHtml(raw).replace(/\r?\n/g, "
") + if (!raw.trim()) return ""; + const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw); + if (hasHtml) return sanitizeAdminRichTextHtml(raw); + return escapeHtml(raw).replace(/\r?\n/g, "
"); } function createEmptyQuestion(id: string): Question { @@ -163,178 +231,197 @@ function createEmptyQuestion(id: string): Question { audioCorrectAnswerText: "", shortAnswers: [], imageUrl: "", - } + }; } export function AddNewPracticePage() { - const { categoryId, courseId, subModuleId } = useParams() - const location = useLocation() - const navigate = useNavigate() - const searchParams = new URLSearchParams(location.search) - const source = searchParams.get("source") + const { categoryId, courseId, subModuleId } = useParams(); + const location = useLocation(); + const navigate = useNavigate(); + const searchParams = new URLSearchParams(location.search); + const source = searchParams.get("source"); const backTo = useMemo(() => { - if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) { - return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}` + if ( + location.pathname.includes("/content/human-language/") && + location.pathname.includes("/sub-module/") + ) { + return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`; } - if (source === "human-language") return "/content/human-language" - return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}` - }, [location.pathname, source, categoryId, courseId, subModuleId]) - - const [currentStep, setCurrentStep] = useState(1) - const [saving, setSaving] = useState(false) + if (source === "human-language") return "/content/human-language"; + return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`; + }, [location.pathname, source, categoryId, courseId, subModuleId]); + + const [currentStep, setCurrentStep] = useState(1); + const [saving, setSaving] = useState(false); // Step 1: Context - const [selectedProgram] = useState("Intermediate") - const [selectedCourse] = useState("B2") - const [practiceTitle, setPracticeTitle] = useState("") - const [practiceDescription, setPracticeDescription] = useState("") - const [introVideoUrl, setIntroVideoUrl] = useState("") - const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false) - const [importingIntroVideoUrl, setImportingIntroVideoUrl] = useState(false) - const introVideoFileInputRef = useRef(null) - const [shuffleQuestions, setShuffleQuestions] = useState(false) - const [passingScore, setPassingScore] = useState(50) - const [timeLimitMinutes, setTimeLimitMinutes] = useState(60) - const [saveError, setSaveError] = useState(null) - const [resultStatus, setResultStatus] = useState(null) - const [resultMessage, setResultMessage] = useState("") + const [selectedProgram] = useState("Intermediate"); + const [selectedCourse] = useState("B2"); + const [practiceTitle, setPracticeTitle] = useState(""); + const [practiceDescription, setPracticeDescription] = useState(""); + const [introVideoUrl, setIntroVideoUrl] = useState(""); + const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false); + const [importingIntroVideoUrl, setImportingIntroVideoUrl] = useState(false); + const introVideoFileInputRef = useRef(null); + const [shuffleQuestions, setShuffleQuestions] = useState(false); + const [passingScore, setPassingScore] = useState(50); + const [timeLimitMinutes, setTimeLimitMinutes] = useState(60); + const [saveError, setSaveError] = useState(null); + const [resultStatus, setResultStatus] = useState(null); + const [resultMessage, setResultMessage] = useState(""); // Step 2: Persona - const [selectedPersona, setSelectedPersona] = useState(null) + const [selectedPersona, setSelectedPersona] = useState(null); // Step 3: Questions const [questions, setQuestions] = useState([ createEmptyQuestion("1"), - ]) + ]); const handleNext = () => { if (currentStep < 4) { - setCurrentStep((currentStep + 1) as Step) + setCurrentStep((currentStep + 1) as Step); } - } + }; const handleBack = () => { if (currentStep > 1) { - setCurrentStep((currentStep - 1) as Step) + setCurrentStep((currentStep - 1) as Step); } - } + }; const handleCancel = () => { - navigate(backTo) - } + navigate(backTo); + }; - const handleIntroVideoFileChange = async (event: ChangeEvent) => { - const file = event.target.files?.[0] - event.target.value = "" - if (!file) return + const handleIntroVideoFileChange = async ( + event: ChangeEvent, + ) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; - setUploadingIntroVideo(true) + setUploadingIntroVideo(true); try { const uploadRes = await uploadVideoFile(file, { - title: practiceTitle.trim() || file.name.replace(/\.[^.]+$/, "") || "Practice intro", + title: + practiceTitle.trim() || + file.name.replace(/\.[^.]+$/, "") || + "Practice intro", description: practiceDescription.trim() || undefined, - }) - const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data) - if (!finalUrl) throw new Error("Missing uploaded video url") - setIntroVideoUrl(finalUrl) - toast.success("Intro video uploaded", { description: "The URL has been filled in for you." }) + }); + const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data); + if (!finalUrl) throw new Error("Missing uploaded video url"); + setIntroVideoUrl(finalUrl); + toast.success("Intro video uploaded", { + description: "The URL has been filled in for you.", + }); } catch (error) { - console.error("Failed to upload intro video:", error) - toast.error("Failed to upload intro video") + console.error("Failed to upload intro video:", error); + toast.error("Failed to upload intro video"); } finally { - setUploadingIntroVideo(false) + setUploadingIntroVideo(false); } - } + }; const handleImportIntroVideoFromUrl = async () => { - const source = introVideoUrl.trim() - if (!source || !/^https?:\/\//i.test(source)) return - const vimeoEmbed = toVimeoEmbedUrl(source) + const source = introVideoUrl.trim(); + if (!source || !/^https?:\/\//i.test(source)) return; + const vimeoEmbed = toVimeoEmbedUrl(source); // Vimeo page URLs can be protected by anti-bot checks when server-side fetched. // For those links, prefer local normalization to player URL instead of failing import. if (vimeoEmbed) { - setIntroVideoUrl(vimeoEmbed) - return + setIntroVideoUrl(vimeoEmbed); + return; } - setImportingIntroVideoUrl(true) + setImportingIntroVideoUrl(true); try { const uploadRes = await uploadVideoFile(source, { title: practiceTitle.trim() || "Practice intro", description: practiceDescription.trim() || undefined, - }) - const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data) - if (!finalUrl) throw new Error("Missing uploaded video url") - setIntroVideoUrl(finalUrl) - toast.success("Intro video URL imported", { description: "Processed via /files/upload." }) + }); + const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data); + if (!finalUrl) throw new Error("Missing uploaded video url"); + setIntroVideoUrl(finalUrl); + toast.success("Intro video URL imported", { + description: "Processed via /files/upload.", + }); } catch (error) { - console.error("Failed to import intro video URL:", error) - toast.error("Failed to import intro video URL") + console.error("Failed to import intro video URL:", error); + toast.error("Failed to import intro video URL"); } finally { - setImportingIntroVideoUrl(false) + setImportingIntroVideoUrl(false); } - } + }; const introVideoPreview = useMemo(() => { - const raw = introVideoUrl.trim() - if (!raw) return null - const vimeoEmbedUrl = toVimeoEmbedUrl(raw) - if (vimeoEmbedUrl) return { kind: "vimeo" as const, url: vimeoEmbedUrl } - if (isDirectVideoFile(raw)) return { kind: "video" as const, url: raw } - return null - }, [introVideoUrl]) + const raw = introVideoUrl.trim(); + if (!raw) return null; + const vimeoEmbedUrl = toVimeoEmbedUrl(raw); + if (vimeoEmbedUrl) return { kind: "vimeo" as const, url: vimeoEmbedUrl }; + if (isDirectVideoFile(raw)) return { kind: "video" as const, url: raw }; + return null; + }, [introVideoUrl]); const descriptionPreviewHtml = useMemo( () => formatDescriptionForPreview(practiceDescription), [practiceDescription], - ) + ); const addQuestion = () => { - setQuestions([...questions, createEmptyQuestion(String(Date.now()))]) - } + setQuestions([...questions, createEmptyQuestion(String(Date.now()))]); + }; const removeQuestion = (id: string) => { if (questions.length > 1) { - setQuestions(questions.filter(q => q.id !== id)) + setQuestions(questions.filter((q) => q.id !== id)); } - } + }; const updateQuestion = (id: string, updates: Partial) => { - setQuestions(questions.map(q => q.id === id ? { ...q, ...updates } : q)) - } + setQuestions( + questions.map((q) => (q.id === id ? { ...q, ...updates } : q)), + ); + }; const saveQuestionSet = async (status: "DRAFT" | "PUBLISHED") => { - setSaving(true) - setSaveError(null) + setSaving(true); + setSaveError(null); try { - const persona = PERSONAS.find(p => p.id === selectedPersona) + const persona = PERSONAS.find((p) => p.id === selectedPersona); const setRes = await createQuestionSet({ title: practiceTitle || "Untitled Practice", set_type: "PRACTICE", owner_type: "SUB_MODULE", owner_id: Number(subModuleId), - ...(practiceDescription.trim() ? { description: practiceDescription.trim() } : {}), + ...(practiceDescription.trim() + ? { description: practiceDescription.trim() } + : {}), ...(persona?.name ? { persona: persona.name } : {}), shuffle_questions: shuffleQuestions, status, passing_score: passingScore, time_limit_minutes: timeLimitMinutes, - ...(introVideoUrl.trim() ? { intro_video_url: introVideoUrl.trim() } : {}), - }) + ...(introVideoUrl.trim() + ? { intro_video_url: introVideoUrl.trim() } + : {}), + }); - const questionSetId = setRes.data?.data?.id + const questionSetId = setRes.data?.data?.id; if (questionSetId) { for (let i = 0; i < questions.length; i++) { - const q = questions[i] - if (!q.questionText.trim()) continue + const q = questions[i]; + if (!q.questionText.trim()) continue; - const options: QuestionOption[] = q.questionType === "MCQ" - ? q.options.map((opt, idx) => ({ - option_order: idx + 1, - option_text: opt.text, - is_correct: opt.isCorrect, - })) - : [] + const options: QuestionOption[] = + q.questionType === "MCQ" + ? q.options.map((opt, idx) => ({ + option_order: idx + 1, + option_text: opt.text, + is_correct: opt.isCorrect, + })) + : []; const qRes = await createQuestion({ question_text: q.questionText, @@ -349,734 +436,926 @@ export function AddNewPracticePage() { sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined, audio_correct_answer_text: q.audioCorrectAnswerText || undefined, image_url: q.imageUrl.trim() || undefined, - short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined, - }) + short_answers: + q.shortAnswers.length > 0 ? q.shortAnswers : undefined, + }); - const questionId = qRes.data?.data?.id + const questionId = qRes.data?.data?.id; if (questionId) { await addQuestionToSet(questionSetId, { display_order: i + 1, question_id: questionId, - }) + }); } } } - setResultStatus("success") + setResultStatus("success"); setResultMessage( status === "PUBLISHED" ? "Your speaking practice is now active." - : "Your practice has been saved as a draft." - ) - setCurrentStep(5) + : "Your practice has been saved as a draft.", + ); + setCurrentStep(5); } catch (err: unknown) { - console.error("Failed to save practice:", err) - const errorMsg = err instanceof Error ? err.message : "An unexpected error occurred." - setResultStatus("error") - setResultMessage(errorMsg) - setCurrentStep(5) + console.error("Failed to save practice:", err); + const errorMsg = + err instanceof Error ? err.message : "An unexpected error occurred."; + setResultStatus("error"); + setResultMessage(errorMsg); + setCurrentStep(5); } finally { - setSaving(false) + setSaving(false); } - } + }; - const handleSaveAsDraft = () => saveQuestionSet("DRAFT") - const handlePublish = () => saveQuestionSet("PUBLISHED") + const handleSaveAsDraft = () => saveQuestionSet("DRAFT"); + const handlePublish = () => saveQuestionSet("PUBLISHED"); const getNextButtonLabel = () => { switch (currentStep) { - case 1: return "Next: Persona" - case 2: return "Next: Questions" - case 3: return "Next: Review" - default: return "Next" + case 1: + return "Next: Persona"; + case 2: + return "Next: Questions"; + case 3: + return "Next: Review"; + default: + return "Next"; } - } + }; return (
- {currentStep !== 5 && ( - <> - {/* Back Link */} - - - Back to Sub-course - - - {/* Header */} -
-

Add New Practice

-

- Create a new immersive practice session for students. -

-
- - )} - - {/* Step Tracker */} - {currentStep !== 5 && ( -
- {STEPS.map((step, index) => ( -
-
-
step.number - ? "bg-brand-500 text-white" - : "border-2 border-grayScale-300 bg-white text-grayScale-400" - }`} - > - {currentStep > step.number ? : step.number} -
- step.number - ? "text-brand-500" - : "text-grayScale-400" - }`} - > - {step.label} - -
- {index < STEPS.length - 1 && ( -
step.number ? "bg-brand-500" : "bg-grayScale-200" - }`} - /> - )} -
- ))} -
- )} - - {/* Step Content */} - {currentStep === 1 && ( - -
-

Step 1: Context

-

- Define details and rules for this practice. Curriculum context is shown on the right. -

-
- -
-
-
-
- - setPracticeTitle(e.target.value)} - placeholder="Enter practice title" - className="h-11" - /> -
- -
- -