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" />