import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate, useParams, useSearchParams, } from "react-router-dom"; import { ArrowLeft } from "lucide-react"; import { toast } from "sonner"; import { Button } from "../../components/ui/button"; import { Stepper } from "../../components/ui/stepper"; import successIcon from "../../assets/success.svg"; import type { PracticeParentKind } from "../../types/course.types"; import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"; import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api"; import { emptyDynamicFieldValuesForDefinition } from "../../lib/learnEnglishDefinitionQuestion"; import { executeLearnEnglishPracticeCreation, learnEnglishPracticeApiErrorMessage, validateLearnEnglishQuestionsWithDefinitions, } from "../../lib/learnEnglishPracticePublish"; import { ContextStep } from "./components/practice-steps/ContextStep"; import { ScenarioStep } from "./components/practice-steps/ScenarioStep"; import { PersonaStep } from "./components/practice-steps/PersonaStep"; import { QuestionsStep } from "./components/practice-steps/QuestionsStep"; import { ReviewStep } from "./components/practice-steps/ReviewStep"; import { personaFromId, personaIdNumber, } from "./components/practice-steps/constants"; import { useActivePersonas } from "../../hooks/useActivePersonas"; const STEP_LABELS = ["Practice", "Persona", "Questions", "Review"] as const; export function AddPracticeFlow() { const navigate = useNavigate(); const { level, programType, courseId: routeCourseId, unitId: routeUnitId, moduleId: routeModuleId, } = useParams<{ level?: string; programType?: string; courseId?: string; unitId?: string; moduleId?: string; }>(); const [searchParams] = useSearchParams(); const backToParam = searchParams.get("backTo"); const lessonId = searchParams.get("lessonId"); const lessonTitleRaw = searchParams.get("lessonTitle"); const isExamPrep = Boolean(programType?.trim()); const effectiveBackTo = useMemo(() => { if (backToParam?.trim()) return backToParam.trim(); if (isExamPrep && routeModuleId) return "module"; if (isExamPrep && routeCourseId) return "courses"; return null; }, [backToParam, isExamPrep, routeModuleId, routeCourseId]); const courseId = isExamPrep ? routeCourseId ?? searchParams.get("courseId") : searchParams.get("courseId"); const moduleId = isExamPrep ? routeModuleId ?? searchParams.get("moduleId") : searchParams.get("moduleId"); const unitId = isExamPrep ? routeUnitId : null; const lessonTitleDisplay = (() => { const raw = lessonTitleRaw?.trim(); if (!raw) return null; try { return decodeURIComponent(raw); } catch { return raw; } })(); const isModuleContext = effectiveBackTo === "module"; const isCourseContext = effectiveBackTo === "modules" || effectiveBackTo === "courses"; const isLessonPractice = useMemo(() => { const lid = lessonId ? Number(lessonId) : NaN; return Number.isFinite(lid) && lid > 0; }, [lessonId]); /** Learn English lesson practices skip story fields; exam prep lessons use the full form. */ const isLearnEnglishLessonPractice = isLessonPractice && !isExamPrep; const parentContext = useMemo((): { kind: PracticeParentKind; id: number; } | null => { const lid = lessonId ? Number(lessonId) : NaN; if (Number.isFinite(lid) && lid > 0) return { kind: "LESSON", id: lid }; const mid = moduleId ? Number(moduleId) : NaN; if (isModuleContext && Number.isFinite(mid) && mid > 0) return { kind: "MODULE", id: mid }; const cid = courseId ? Number(courseId) : NaN; if (isCourseContext && Number.isFinite(cid) && cid > 0) return { kind: "COURSE", id: cid }; return null; }, [lessonId, moduleId, courseId, isModuleContext, isCourseContext]); const parentSummary = useMemo(() => { if (lessonId) return `Lesson #${lessonId}${lessonTitleDisplay ? ` — ${lessonTitleDisplay}` : ""}`; if (isModuleContext && moduleId) return `Module #${moduleId}`; if (isCourseContext && courseId) return `Course #${courseId}`; return null; }, [ lessonId, lessonTitleDisplay, isModuleContext, isCourseContext, moduleId, courseId, ]); const programLabel = isExamPrep ? programType === "skill" ? "Skill-Based Courses" : "English Proficiency Exams" : level ? `Program ${level}` : null; const backLabel = effectiveBackTo === "module" ? "Back to Module" : effectiveBackTo === "modules" ? "Back to Modules" : effectiveBackTo === "courses" ? "Back to Course" : isExamPrep ? "Back to Program" : "Back to Courses"; const backPath = useMemo(() => { if (isExamPrep) { if ( effectiveBackTo === "module" && programType && courseId && unitId && moduleId ) { return `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`; } if (effectiveBackTo === "courses" && programType && courseId) { return `/new-content/courses/${programType}/${courseId}`; } if (programType) { return `/new-content/courses/${programType}`; } return "/new-content"; } if (effectiveBackTo === "module" && level && courseId && moduleId) { return `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`; } if (effectiveBackTo === "modules" && level && courseId) { return `/new-content/learn-english/${level}/courses/${courseId}`; } return `/new-content/learn-english/${level}/courses`; }, [ isExamPrep, effectiveBackTo, programType, courseId, unitId, moduleId, level, ]); const [currentStep, setCurrentStep] = useState(1); const [isPublished, setIsPublished] = useState(false); const [submitting, setSubmitting] = useState(false); const [selectedPersona, setSelectedPersona] = useState(null); const { personas, loading: personasLoading, error: personasError, reload: reloadPersonas, } = useActivePersonas(); const [formData, setFormData] = useState({ title: "", description: "", storyImageUrl: "", shuffleQuestions: false, tips: "", questions: [ { id: "q1", questionTypeDefinitionId: null as number | null, text: "", dynamicFieldValues: {} as Record, mcqOptions: [ { text: "", isCorrect: true }, { text: "", isCorrect: false }, { text: "", isCorrect: false }, { text: "", isCorrect: false }, ], trueFalseCorrect: true, shortAnswers: [""], }, ], }); const [typeDefinitions, setTypeDefinitions] = useState( [], ); const [definitionsLoading, setDefinitionsLoading] = useState(true); const [definitionsError, setDefinitionsError] = useState(null); useEffect(() => { let cancelled = false; (async () => { setDefinitionsLoading(true); setDefinitionsError(null); try { const { definitions: list } = await getQuestionTypeDefinitions({ include_system: true, status: "ACTIVE", }); if (!cancelled) setTypeDefinitions(list); } catch (e) { if (!cancelled) { setDefinitionsError(learnEnglishPracticeApiErrorMessage(e)); setTypeDefinitions([]); } } finally { if (!cancelled) setDefinitionsLoading(false); } })(); return () => { cancelled = true; }; }, []); useEffect(() => { if (typeDefinitions.length === 0) return; setFormData((fd) => ({ ...fd, questions: fd.questions.map((q) => { if (q.questionTypeDefinitionId != null) return q; const def = typeDefinitions[0]; return { ...q, questionTypeDefinitionId: def.id, dynamicFieldValues: emptyDynamicFieldValuesForDefinition(def), }; }), })); }, [typeDefinitions]); const submitPractice = async (status: "DRAFT" | "PUBLISHED") => { if (!parentContext) { toast.error("Missing practice parent", { description: "Open this screen from a course, module, or lesson so the API receives parent_kind and parent_id.", }); return; } if ( !isLearnEnglishLessonPractice && (!formData.title.trim() || !formData.description.trim()) ) { toast.error("Title and story description are required", { description: "Complete the first step before publishing.", }); return; } if (!selectedPersona) { toast.error("Select a persona", { description: "Choose a character on the Persona step before publishing.", }); return; } const personaId = personaIdNumber(selectedPersona); if (!personaId) { toast.error("Invalid persona", { description: "Re-select a persona from the list and try again.", }); return; } const persona = personaFromId(selectedPersona, personas); const mappedQuestions = formData.questions.map((q) => ({ questionText: String(q.text ?? "").trim(), questionTypeDefinitionId: Number(q.questionTypeDefinitionId), dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) }, mcqOptions: (q.mcqOptions ?? []).map( (o: { text?: string; isCorrect?: boolean }) => ({ option_text: String(o.text ?? ""), is_correct: Boolean(o.isCorrect), }), ), trueFalseAnswerIsTrue: q.trueFalseCorrect !== false, shortAnswers: (q.shortAnswers ?? []).map((s: string) => String(s)), })); const validationMsg = validateLearnEnglishQuestionsWithDefinitions( mappedQuestions, typeDefinitions, ); if (validationMsg) { toast.error("Check your questions", { description: validationMsg }); return; } const lessonDefaultTitle = lessonTitleDisplay?.trim() || (lessonId ? `Lesson ${lessonId} practice` : "Lesson practice"); const useExamPrepLessonApi = isExamPrep && isLessonPractice && parentContext.kind === "LESSON" && Number.isFinite(parentContext.id); setSubmitting(true); try { await executeLearnEnglishPracticeCreation({ parentKind: parentContext.kind, parentId: parentContext.id, examPrepLessonId: useExamPrepLessonApi ? parentContext.id : undefined, status, questionSetTitle: isLearnEnglishLessonPractice ? lessonDefaultTitle : formData.title.trim() || "Practice set", questionSetDescription: isLearnEnglishLessonPractice ? null : formData.description.trim() || null, shuffleQuestions: formData.shuffleQuestions, practiceTitle: isLearnEnglishLessonPractice ? lessonDefaultTitle : formData.title.trim() || "Untitled practice", storyDescription: isLearnEnglishLessonPractice ? "" : formData.description.trim(), storyImage: isLearnEnglishLessonPractice ? "" : formData.storyImageUrl.trim(), quickTips: formData.tips.trim(), personaName: persona?.name ?? null, personaId, questions: mappedQuestions, definitions: typeDefinitions, }); toast.success( status === "PUBLISHED" ? "Practice published" : "Draft saved", { description: "Question set, questions, and parent-linked practice were created.", }, ); setIsPublished(true); } catch (e) { toast.error("Could not save practice", { description: learnEnglishPracticeApiErrorMessage(e), }); } finally { setSubmitting(false); } }; const nextStep = () => setCurrentStep((prev) => Math.min(prev + 1, STEP_LABELS.length)); const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1)); if (isPublished) { return (
Success

Practice Published Successfully!

{lessonId ? "Your speaking practice is saved and linked to this lesson’s question set." : "Your speaking practice is saved for the linked course or module."}

); } const renderStep = () => { if (isModuleContext) { switch (currentStep) { case 1: return ( navigate(backPath)} isLessonPractice={isLearnEnglishLessonPractice} lessonTitle={lessonTitleDisplay} parentSummary={parentSummary} /> ); case 2: return ( void reloadPersonas()} selectedPersona={selectedPersona} setSelectedPersona={setSelectedPersona} nextStep={nextStep} prevStep={prevStep} /> ); case 3: return ( ); case 4: return ( setCurrentStep(1)} onEditQuestions={() => setCurrentStep(3)} parentSummary={parentSummary} typeDefinitions={typeDefinitions} canPublish={parentContext !== null} submitting={submitting} onSaveDraft={() => void submitPractice("DRAFT")} onPublish={() => void submitPractice("PUBLISHED")} /> ); default: return null; } } switch (currentStep) { case 1: return ( ); case 2: return ( void reloadPersonas()} selectedPersona={selectedPersona} setSelectedPersona={setSelectedPersona} nextStep={nextStep} prevStep={prevStep} /> ); case 3: return ( ); case 4: return ( setCurrentStep(1)} onEditQuestions={() => setCurrentStep(3)} parentSummary={parentSummary} typeDefinitions={typeDefinitions} canPublish={parentContext !== null} submitting={submitting} onSaveDraft={() => void submitPractice("DRAFT")} onPublish={() => void submitPractice("PUBLISHED")} /> ); default: return null; } }; return (
{backLabel}

Add New Practice

Create a practice with story details, a persona, and questions from your question type library.

{lessonId ? (

Lesson practice

Linked to lesson{" "} #{lessonId} {lessonTitleDisplay ? ( <> {" "} — {lessonTitleDisplay} ) : null} .

) : null}
{renderStep()}
); }