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" interface Persona { id: string name: string avatar: string } interface MCQOption { 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 } 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" }, ] 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() if (embedUrl) { const hashFromUrl = pageUrl ? pageUrl.split("/").filter(Boolean).at(-1) : undefined return hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl } 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") return hash ? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}` : `https://player.vimeo.com/video/${videoId}` } catch { return null } } function isDirectVideoFile(url: string): boolean { const clean = url.split("?")[0].toLowerCase() return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean) } function escapeHtml(raw: string): string { return raw .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'") } function sanitizeAdminRichTextHtml(input: string): string { 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"]) doc.body.querySelectorAll("*").forEach((el) => { const tagName = el.tagName.toLowerCase() if (blockedTags.has(tagName)) { el.remove() return } const attrs = [...el.attributes] attrs.forEach((attr) => { const name = attr.name.toLowerCase() const value = attr.value.trim().toLowerCase() if (name.startsWith("on")) { el.removeAttribute(attr.name) return } if ((name === "href" || name === "src") && value.startsWith("javascript:")) { el.removeAttribute(attr.name) } }) }) return doc.body.innerHTML } catch { 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, "
") } function createEmptyQuestion(id: string): Question { return { id, questionText: "", questionType: "MCQ", difficultyLevel: "EASY", points: 1, tips: "", explanation: "", options: [ { text: "", isCorrect: true }, { text: "", isCorrect: false }, { text: "", isCorrect: false }, { text: "", isCorrect: false }, ], voicePrompt: "", sampleAnswerVoicePrompt: "", 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 backTo = useMemo(() => { 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) // 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("") // Step 2: Persona const [selectedPersona, setSelectedPersona] = useState(null) // Step 3: Questions const [questions, setQuestions] = useState([ createEmptyQuestion("1"), ]) const handleNext = () => { if (currentStep < 4) { setCurrentStep((currentStep + 1) as Step) } } const handleBack = () => { if (currentStep > 1) { setCurrentStep((currentStep - 1) as Step) } } const handleCancel = () => { navigate(backTo) } const handleIntroVideoFileChange = async (event: ChangeEvent) => { const file = event.target.files?.[0] event.target.value = "" if (!file) return setUploadingIntroVideo(true) try { const uploadRes = await uploadVideoFile(file, { 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." }) } catch (error) { console.error("Failed to upload intro video:", error) toast.error("Failed to upload intro video") } finally { setUploadingIntroVideo(false) } } const handleImportIntroVideoFromUrl = async () => { 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 } 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." }) } catch (error) { console.error("Failed to import intro video URL:", error) toast.error("Failed to import intro video URL") } finally { 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 descriptionPreviewHtml = useMemo( () => formatDescriptionForPreview(practiceDescription), [practiceDescription], ) const addQuestion = () => { setQuestions([...questions, createEmptyQuestion(String(Date.now()))]) } const removeQuestion = (id: string) => { if (questions.length > 1) { setQuestions(questions.filter(q => q.id !== id)) } } const updateQuestion = (id: string, updates: Partial) => { setQuestions(questions.map(q => q.id === id ? { ...q, ...updates } : q)) } const saveQuestionSet = async (status: "DRAFT" | "PUBLISHED") => { setSaving(true) setSaveError(null) try { 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() } : {}), ...(persona?.name ? { persona: persona.name } : {}), shuffle_questions: shuffleQuestions, status, passing_score: passingScore, time_limit_minutes: timeLimitMinutes, ...(introVideoUrl.trim() ? { intro_video_url: introVideoUrl.trim() } : {}), }) 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 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, question_type: q.questionType, difficulty_level: q.difficultyLevel, points: q.points, tips: q.tips || undefined, explanation: q.explanation || undefined, status: "PUBLISHED", options: options.length > 0 ? options : undefined, voice_prompt: q.voicePrompt || undefined, 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, }) const questionId = qRes.data?.data?.id if (questionId) { await addQuestionToSet(questionSetId, { display_order: i + 1, question_id: questionId, }) } } } setResultStatus("success") setResultMessage( status === "PUBLISHED" ? "Your speaking practice is now active." : "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) } finally { setSaving(false) } } 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" } } 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" />