Yimaru-Admin/src/pages/content-management/AddPracticeFlow.tsx

634 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string | null>(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<string, string>,
mcqOptions: [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
],
trueFalseCorrect: true,
shortAnswers: [""],
},
],
});
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>(
[],
);
const [definitionsLoading, setDefinitionsLoading] = useState(true);
const [definitionsError, setDefinitionsError] = useState<string | null>(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 (
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-500">
<div className="mb-10 relative">
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
<img
src={successIcon}
alt="Success"
className="h-[128px] w-[128px] relative"
/>
</div>
<h1 className="text-[28px] font-bold text-grayScale-900 mb-2">
Practice Published Successfully!
</h1>
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
{lessonId
? "Your speaking practice is saved and linked to this lessons question set."
: "Your speaking practice is saved for the linked course or module."}
</p>
<div className="flex flex-col gap-4 w-full max-w-[400px]">
<Button
onClick={() => navigate(backPath)}
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
>
{backLabel}
</Button>
<Button
onClick={() => {
setIsPublished(false);
setCurrentStep(1);
setSelectedPersona(null);
setFormData({
title: "",
description: "",
storyImageUrl: "",
shuffleQuestions: false,
tips: "",
questions: [
{
id: "q1",
questionTypeDefinitionId:
typeDefinitions[0]?.id ?? (null as number | null),
text: "",
dynamicFieldValues: typeDefinitions[0]
? emptyDynamicFieldValuesForDefinition(typeDefinitions[0])
: {},
mcqOptions: [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
],
trueFalseCorrect: true,
shortAnswers: [""],
},
],
});
}}
variant="outline"
className="h-14 rounded-[6px] border-[#9E2891] text-[#9E2891] font-semibold text-[16px] bg-white "
>
Add Another Practice
</Button>
</div>
</div>
);
}
const renderStep = () => {
if (isModuleContext) {
switch (currentStep) {
case 1:
return (
<ContextStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
onCancel={() => navigate(backPath)}
isLessonPractice={isLearnEnglishLessonPractice}
lessonTitle={lessonTitleDisplay}
parentSummary={parentSummary}
/>
);
case 2:
return (
<PersonaStep
personas={personas}
loading={personasLoading}
error={personasError}
onRetry={() => void reloadPersonas()}
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 3:
return (
<QuestionsStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
typeDefinitions={typeDefinitions}
definitionsLoading={definitionsLoading}
definitionsError={definitionsError}
/>
);
case 4:
return (
<ReviewStep
formData={formData}
selectedPersona={selectedPersona}
personas={personas}
isLessonPractice={isLearnEnglishLessonPractice}
lessonTitle={lessonTitleDisplay}
programLabel={programLabel}
courseLabel={courseId ? `Course ${courseId}` : null}
moduleLabel={moduleId ? `Module ${moduleId}` : null}
prevStep={prevStep}
onEditContext={() => 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 (
<ScenarioStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
cancelHref={backPath}
/>
);
case 2:
return (
<PersonaStep
personas={personas}
loading={personasLoading}
error={personasError}
onRetry={() => void reloadPersonas()}
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 3:
return (
<QuestionsStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
typeDefinitions={typeDefinitions}
definitionsLoading={definitionsLoading}
definitionsError={definitionsError}
/>
);
case 4:
return (
<ReviewStep
formData={formData}
selectedPersona={selectedPersona}
personas={personas}
isLessonPractice={isLearnEnglishLessonPractice}
lessonTitle={lessonTitleDisplay}
programLabel={programLabel}
courseLabel={courseId ? `Course ${courseId}` : null}
moduleLabel={moduleId ? `Module ${moduleId}` : null}
prevStep={prevStep}
onEditContext={() => 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 (
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
<div className="mx-auto max-w-7xl w-full">
<div className="flex items-center justify-between mb-8">
<Link
to={backPath}
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-600 transition-colors hover:text-brand-500 decoration-none"
>
<ArrowLeft className="h-4 w-4" />
{backLabel}
</Link>
</div>
<div className=" mb-10">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-[#0F172A]">
Add New Practice
</h1>
<Button
variant="outline"
className="rounded-[8px] border-grayScale-200 text-grayScale-600 h-10 px-6 font-bold bg-white hover:bg-grayScale-50"
onClick={() => navigate(backPath)}
>
Cancel
</Button>
</div>
<p className="text-grayScale-400 text-base">
Create a practice with story details, a persona, and questions from your question type library.
</p>
{lessonId ? (
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950">
<p className="font-semibold text-violet-900">Lesson practice</p>
<p className="mt-1 text-violet-800/90">
Linked to lesson{" "}
<span className="font-mono font-bold text-violet-950">
#{lessonId}
</span>
{lessonTitleDisplay ? (
<>
{" "}
<span className="font-medium">{lessonTitleDisplay}</span>
</>
) : null}
.
</p>
</div>
) : null}
</div>
<div className="mx-auto w-[70%] mb-12">
<Stepper steps={[...STEP_LABELS]} currentStep={currentStep} />
</div>
<div
className={`mx-auto ${currentStep === 3 || currentStep === 4 ? "max-w-6xl" : "max-w-4xl"}`}
>
{renderStep()}
</div>
</div>
</div>
);
}