practice creation UI fix

This commit is contained in:
Yared Yemane 2026-06-05 10:31:53 -07:00
parent 1014f4a72f
commit 095e690a68
12 changed files with 851 additions and 238 deletions

View File

@ -97,6 +97,9 @@ import type {
CreateExamPrepModuleLessonResponse, CreateExamPrepModuleLessonResponse,
UpdateExamPrepModuleLessonRequest, UpdateExamPrepModuleLessonRequest,
UpdateExamPrepModuleLessonResponse, UpdateExamPrepModuleLessonResponse,
PublishExamPrepModuleLessonRequest,
CreateExamPrepLessonPracticeRequest,
CreateExamPrepLessonPracticeResponse,
GetExamPrepModuleLessonsResponse, GetExamPrepModuleLessonsResponse,
GetTopLevelModuleLessonsResponse, GetTopLevelModuleLessonsResponse,
GetPracticesByParentContextResponse, GetPracticesByParentContextResponse,
@ -587,10 +590,26 @@ export const updateExamPrepModuleLesson = (
data, data,
) )
/** PUT /exam-prep/lessons/:lessonId — set publish_status only (draft or published). */
export const publishExamPrepModuleLesson = (
lessonId: number,
data: PublishExamPrepModuleLessonRequest,
) => http.put(`/exam-prep/lessons/${lessonId}`, data)
/** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */ /** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */
export const deleteExamPrepModuleLesson = (lessonId: number) => export const deleteExamPrepModuleLesson = (lessonId: number) =>
http.delete(`/exam-prep/lessons/${lessonId}`) http.delete(`/exam-prep/lessons/${lessonId}`)
/** POST /exam-prep/lessons/:lessonId/practices */
export const createExamPrepLessonPractice = (
lessonId: number,
data: CreateExamPrepLessonPracticeRequest,
) =>
http.post<CreateExamPrepLessonPracticeResponse>(
`/exam-prep/lessons/${lessonId}/practices`,
data,
)
/** Top-level course resource (Learn English track) — PUT /courses/:id */ /** Top-level course resource (Learn English track) — PUT /courses/:id */
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) => export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
http.put(`/courses/${courseId}`, data) http.put(`/courses/${courseId}`, data)

View File

@ -24,8 +24,6 @@ import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow"; import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow"; import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage"; 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 { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTypeSelectionPage";
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage"; import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
import { CourseManagementPage } from "../pages/content-management/CourseManagementPage"; import { CourseManagementPage } from "../pages/content-management/CourseManagementPage";
@ -191,12 +189,16 @@ export function AppRoutes() {
element={<ProgramDetailPage />} element={<ProgramDetailPage />}
/> />
<Route <Route
path="/new-content/courses/:programType/attach-practice" path="/new-content/courses/:programType/add-practice"
element={<AttachProgramPracticeFlow />} element={<AddPracticeFlow />}
/> />
<Route <Route
path="/new-content/courses/:programType/:courseId/unit/:unitId/module/:moduleId/attach-practice" path="/new-content/courses/:programType/:courseId/add-practice"
element={<AttachPracticeFlow />} element={<AddPracticeFlow />}
/>
<Route
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/add-practice"
element={<AddPracticeFlow />}
/> />
<Route <Route
path="/new-content/courses/:programType/:courseId" path="/new-content/courses/:programType/:courseId"
@ -210,6 +212,10 @@ export function AppRoutes() {
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId" path="/new-content/courses/:programType/:courseId/:unitId/:moduleId"
element={<CourseModuleDetailPage />} element={<CourseModuleDetailPage />}
/> />
<Route
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/lessons/:lessonId/practices"
element={<LessonPracticesPage />}
/>
<Route <Route
path="/new-content/learn-english" path="/new-content/learn-english"
element={<LearnEnglishPage />} element={<LearnEnglishPage />}

View File

@ -1,6 +1,7 @@
import type { AxiosError } from "axios" import type { AxiosError } from "axios"
import { import {
addQuestionToSet, addQuestionToSet,
createExamPrepLessonPractice,
createParentLinkedPractice, createParentLinkedPractice,
createQuestion, createQuestion,
createQuestionSet, createQuestionSet,
@ -72,6 +73,8 @@ export async function executeLearnEnglishPracticeCreation(opts: {
personaId: number personaId: number
questions: LearnEnglishDefinitionQuestionInput[] questions: LearnEnglishDefinitionQuestionInput[]
definitions: QuestionTypeDefinition[] definitions: QuestionTypeDefinition[]
/** When set, links practice via POST /exam-prep/lessons/:id/practices instead of POST /practices. */
examPrepLessonId?: number
}): Promise<{ questionSetId: number; practiceId: number }> { }): Promise<{ questionSetId: number; practiceId: number }> {
const err = validateLearnEnglishQuestionsWithDefinitions( const err = validateLearnEnglishQuestionsWithDefinitions(
opts.questions, opts.questions,
@ -128,7 +131,16 @@ export async function executeLearnEnglishPracticeCreation(opts: {
}) })
} }
const practiceRes = await createParentLinkedPractice({ const practiceRes = opts.examPrepLessonId
? await createExamPrepLessonPractice(opts.examPrepLessonId, {
title: opts.practiceTitle.trim(),
story_description: opts.storyDescription.trim(),
story_image: opts.storyImage.trim(),
persona_id: opts.personaId,
question_set_id: setId,
quick_tips: opts.quickTips.trim(),
})
: await createParentLinkedPractice({
parent_kind: opts.parentKind, parent_kind: opts.parentKind,
parent_id: opts.parentId, parent_id: opts.parentId,
title: opts.practiceTitle.trim(), title: opts.practiceTitle.trim(),

View File

@ -0,0 +1,124 @@
export interface MultipleChoiceOptionValue {
id: string
text: string
is_correct: boolean
}
export interface MultipleChoiceSlotValue {
options: MultipleChoiceOptionValue[]
}
const DEFAULT_OPTION_IDS = ["a", "b", "c", "d", "e", "f", "g", "h"] as const
export function defaultMultipleChoiceSlotValue(
count = 4,
): MultipleChoiceSlotValue {
return {
options: Array.from({ length: count }, (_, index) => ({
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
text: "",
is_correct: index === 0,
})),
}
}
export function serializeMultipleChoiceSlotValue(
value: MultipleChoiceSlotValue,
): string {
return JSON.stringify(value)
}
export function parseMultipleChoiceSlotValue(
raw: string | undefined,
): MultipleChoiceSlotValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) return defaultMultipleChoiceSlotValue()
try {
const parsed = JSON.parse(trimmed) as unknown
return normalizeMultipleChoiceValue(parsed)
} catch {
return defaultMultipleChoiceSlotValue()
}
}
export function normalizeMultipleChoiceValue(
raw: unknown,
mcqOptions?: { option_text?: string; text?: string; is_correct?: boolean; isCorrect?: boolean }[],
): MultipleChoiceSlotValue {
if (mcqOptions?.some((o) => (o.option_text ?? o.text ?? "").trim())) {
return {
options: mcqOptions
.map((option, index) => ({
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
text: (option.option_text ?? option.text ?? "").trim(),
is_correct: Boolean(option.is_correct ?? option.isCorrect),
}))
.filter((option) => option.text),
}
}
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (Array.isArray(record.options)) {
return {
options: record.options.map((option, index) =>
normalizeMultipleChoiceOption(option, index),
),
}
}
}
if (Array.isArray(raw)) {
return {
options: raw.map((option, index) =>
normalizeMultipleChoiceOption(option, index),
),
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeMultipleChoiceValue(JSON.parse(raw) as unknown)
} catch {
return defaultMultipleChoiceSlotValue()
}
}
return defaultMultipleChoiceSlotValue()
}
function normalizeMultipleChoiceOption(
raw: unknown,
index: number,
): MultipleChoiceOptionValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
id: String(record.id ?? DEFAULT_OPTION_IDS[index] ?? index + 1),
text: String(record.text ?? record.option_text ?? "").trim(),
is_correct: Boolean(record.is_correct ?? record.isCorrect),
}
}
return {
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
text: String(raw ?? "").trim(),
is_correct: false,
}
}
export function multipleChoiceSlotHasContent(
value: MultipleChoiceSlotValue,
): boolean {
return value.options.some((option) => option.text.trim())
}
export function validateMultipleChoiceSlotValue(
value: MultipleChoiceSlotValue,
): string | null {
const filled = value.options.filter((option) => option.text.trim())
if (filled.length < 2) return "Add at least two choices with text."
if (!filled.some((option) => option.is_correct)) {
return "Mark one choice as correct."
}
return null
}

View File

@ -1,4 +1,9 @@
import type { DynamicQuestionPayload } from "../types/questionTypeDefinition.types" import type { DynamicQuestionPayload } from "../types/questionTypeDefinition.types"
import {
multipleChoiceSlotHasContent,
normalizeMultipleChoiceValue,
parseMultipleChoiceSlotValue,
} from "./multipleChoiceSlotValue"
/** Parse a single slot value: plain string/URL, or JSON object/array when input looks like JSON. */ /** Parse a single slot value: plain string/URL, or JSON object/array when input looks like JSON. */
export function parseDynamicSlotValue(raw: string | undefined): unknown { export function parseDynamicSlotValue(raw: string | undefined): unknown {
@ -14,21 +19,78 @@ export function parseDynamicSlotValue(raw: string | undefined): unknown {
return t return t
} }
const PLAIN_TEXT_STIMULUS_KINDS = new Set([
"INSTRUCTION",
"QUESTION_TEXT",
"TEXT_PASSAGE",
"TEXT",
"TEXT_INPUT",
])
function isMultipleChoiceKind(kind: string): boolean {
const upper = kind.trim().toUpperCase()
return upper === "MULTIPLE_CHOICE" || upper === "OPTION"
}
function slotValueForRow(
row: { id: string; kind: string },
side: "stimulus" | "response",
fieldValues: Record<string, string>,
mcqOptions: { option_text: string; is_correct: boolean }[] | undefined,
mcqOptionsConsumed: { current: boolean },
): unknown {
const fieldKey = `${side}:${row.id}`
const rawField = fieldValues[fieldKey]
if (isMultipleChoiceKind(row.kind)) {
const fromField = parseMultipleChoiceSlotValue(rawField)
if (multipleChoiceSlotHasContent(fromField)) {
return normalizeMultipleChoiceValue(fromField)
}
if (mcqOptions && !mcqOptionsConsumed.current) {
mcqOptionsConsumed.current = true
return normalizeMultipleChoiceValue(undefined, mcqOptions)
}
return normalizeMultipleChoiceValue(fromField)
}
if (side === "stimulus" && PLAIN_TEXT_STIMULUS_KINDS.has(row.kind.trim().toUpperCase())) {
return (rawField ?? "").trim()
}
return parseDynamicSlotValue(rawField)
}
export function buildDynamicQuestionPayload(input: { export function buildDynamicQuestionPayload(input: {
stimulusRows: { id: string; kind: string }[] stimulusRows: { id: string; kind: string }[]
responseRows: { id: string; kind: string }[] responseRows: { id: string; kind: string }[]
fieldValues: Record<string, string> fieldValues: Record<string, string>
mcqOptions?: { option_text: string; is_correct: boolean }[]
}): DynamicQuestionPayload { }): DynamicQuestionPayload {
const mcqOptionsConsumed = { current: false }
return { return {
stimulus: input.stimulusRows.map((row) => ({ stimulus: input.stimulusRows.map((row) => ({
id: row.id, id: row.id,
kind: row.kind, kind: row.kind,
value: parseDynamicSlotValue(input.fieldValues[`stimulus:${row.id}`]), value: slotValueForRow(
row,
"stimulus",
input.fieldValues,
input.mcqOptions,
mcqOptionsConsumed,
),
})), })),
response: input.responseRows.map((row) => ({ response: input.responseRows.map((row) => ({
id: row.id, id: row.id,
kind: row.kind, kind: row.kind,
value: parseDynamicSlotValue(input.fieldValues[`response:${row.id}`]), value: slotValueForRow(
row,
"response",
input.fieldValues,
input.mcqOptions,
mcqOptionsConsumed,
),
})), })),
} }
} }

View File

@ -35,13 +35,41 @@ const STEP_LABELS = ["Practice", "Persona", "Questions", "Review"] as const;
export function AddPracticeFlow() { export function AddPracticeFlow() {
const navigate = useNavigate(); const navigate = useNavigate();
const { level } = useParams<{ level: string }>(); const {
level,
programType,
courseId: routeCourseId,
unitId: routeUnitId,
moduleId: routeModuleId,
} = useParams<{
level?: string;
programType?: string;
courseId?: string;
unitId?: string;
moduleId?: string;
}>();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const backTo = searchParams.get("backTo"); const backToParam = searchParams.get("backTo");
const courseId = searchParams.get("courseId");
const moduleId = searchParams.get("moduleId");
const lessonId = searchParams.get("lessonId"); const lessonId = searchParams.get("lessonId");
const lessonTitleRaw = searchParams.get("lessonTitle"); 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 lessonTitleDisplay = (() => {
const raw = lessonTitleRaw?.trim(); const raw = lessonTitleRaw?.trim();
if (!raw) return null; if (!raw) return null;
@ -52,12 +80,15 @@ export function AddPracticeFlow() {
} }
})(); })();
const isModuleContext = backTo === "module"; const isModuleContext = effectiveBackTo === "module";
const isCourseContext = backTo === "modules"; const isCourseContext =
effectiveBackTo === "modules" || effectiveBackTo === "courses";
const isLessonPractice = useMemo(() => { const isLessonPractice = useMemo(() => {
const lid = lessonId ? Number(lessonId) : NaN; const lid = lessonId ? Number(lessonId) : NaN;
return Number.isFinite(lid) && lid > 0; return Number.isFinite(lid) && lid > 0;
}, [lessonId]); }, [lessonId]);
/** Learn English lesson practices skip story fields; exam prep lessons use the full form. */
const isLearnEnglishLessonPractice = isLessonPractice && !isExamPrep;
const parentContext = useMemo((): { const parentContext = useMemo((): {
kind: PracticeParentKind; kind: PracticeParentKind;
@ -89,18 +120,60 @@ export function AddPracticeFlow() {
courseId, courseId,
]); ]);
const programLabel = isExamPrep
? programType === "skill"
? "Skill-Based Courses"
: "English Proficiency Exams"
: level
? `Program ${level}`
: null;
const backLabel = const backLabel =
backTo === "module" effectiveBackTo === "module"
? "Back to Module" ? "Back to Module"
: backTo === "modules" : effectiveBackTo === "modules"
? "Back to Modules" ? "Back to Modules"
: effectiveBackTo === "courses"
? "Back to Course"
: isExamPrep
? "Back to Program"
: "Back to Courses"; : "Back to Courses";
const backPath =
backTo === "module" && courseId && moduleId const backPath = useMemo(() => {
? `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}` if (isExamPrep) {
: backTo === "modules" && courseId if (
? `/new-content/learn-english/${level}/courses/${courseId}` effectiveBackTo === "module" &&
: `/new-content/learn-english/${level}/courses`; 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 [currentStep, setCurrentStep] = useState(1);
const [isPublished, setIsPublished] = useState(false); const [isPublished, setIsPublished] = useState(false);
@ -194,7 +267,7 @@ export function AddPracticeFlow() {
return; return;
} }
if ( if (
!isLessonPractice && !isLearnEnglishLessonPractice &&
(!formData.title.trim() || !formData.description.trim()) (!formData.title.trim() || !formData.description.trim())
) { ) {
toast.error("Title and story description are required", { toast.error("Title and story description are required", {
@ -243,26 +316,35 @@ export function AddPracticeFlow() {
lessonTitleDisplay?.trim() || lessonTitleDisplay?.trim() ||
(lessonId ? `Lesson ${lessonId} practice` : "Lesson practice"); (lessonId ? `Lesson ${lessonId} practice` : "Lesson practice");
const useExamPrepLessonApi =
isExamPrep &&
isLessonPractice &&
parentContext.kind === "LESSON" &&
Number.isFinite(parentContext.id);
setSubmitting(true); setSubmitting(true);
try { try {
await executeLearnEnglishPracticeCreation({ await executeLearnEnglishPracticeCreation({
parentKind: parentContext.kind, parentKind: parentContext.kind,
parentId: parentContext.id, parentId: parentContext.id,
examPrepLessonId: useExamPrepLessonApi ? parentContext.id : undefined,
status, status,
questionSetTitle: isLessonPractice questionSetTitle: isLearnEnglishLessonPractice
? lessonDefaultTitle ? lessonDefaultTitle
: formData.title.trim() || "Practice set", : formData.title.trim() || "Practice set",
questionSetDescription: isLessonPractice questionSetDescription: isLearnEnglishLessonPractice
? null ? null
: formData.description.trim() || null, : formData.description.trim() || null,
shuffleQuestions: formData.shuffleQuestions, shuffleQuestions: formData.shuffleQuestions,
practiceTitle: isLessonPractice practiceTitle: isLearnEnglishLessonPractice
? lessonDefaultTitle ? lessonDefaultTitle
: formData.title.trim() || "Untitled practice", : formData.title.trim() || "Untitled practice",
storyDescription: isLessonPractice storyDescription: isLearnEnglishLessonPractice
? "" ? ""
: formData.description.trim(), : formData.description.trim(),
storyImage: isLessonPractice ? "" : formData.storyImageUrl.trim(), storyImage: isLearnEnglishLessonPractice
? ""
: formData.storyImageUrl.trim(),
quickTips: formData.tips.trim(), quickTips: formData.tips.trim(),
personaName: persona?.name ?? null, personaName: persona?.name ?? null,
personaId, personaId,
@ -368,7 +450,7 @@ export function AddPracticeFlow() {
setFormData={setFormData} setFormData={setFormData}
nextStep={nextStep} nextStep={nextStep}
onCancel={() => navigate(backPath)} onCancel={() => navigate(backPath)}
isLessonPractice={isLessonPractice} isLessonPractice={isLearnEnglishLessonPractice}
lessonTitle={lessonTitleDisplay} lessonTitle={lessonTitleDisplay}
parentSummary={parentSummary} parentSummary={parentSummary}
/> />
@ -404,9 +486,9 @@ export function AddPracticeFlow() {
formData={formData} formData={formData}
selectedPersona={selectedPersona} selectedPersona={selectedPersona}
personas={personas} personas={personas}
isLessonPractice={isLessonPractice} isLessonPractice={isLearnEnglishLessonPractice}
lessonTitle={lessonTitleDisplay} lessonTitle={lessonTitleDisplay}
programLabel={level ? `Program ${level}` : null} programLabel={programLabel}
courseLabel={courseId ? `Course ${courseId}` : null} courseLabel={courseId ? `Course ${courseId}` : null}
moduleLabel={moduleId ? `Module ${moduleId}` : null} moduleLabel={moduleId ? `Module ${moduleId}` : null}
prevStep={prevStep} prevStep={prevStep}
@ -466,9 +548,9 @@ export function AddPracticeFlow() {
formData={formData} formData={formData}
selectedPersona={selectedPersona} selectedPersona={selectedPersona}
personas={personas} personas={personas}
isLessonPractice={isLessonPractice} isLessonPractice={isLearnEnglishLessonPractice}
lessonTitle={lessonTitleDisplay} lessonTitle={lessonTitleDisplay}
programLabel={level ? `Program ${level}` : null} programLabel={programLabel}
courseLabel={courseId ? `Course ${courseId}` : null} courseLabel={courseId ? `Course ${courseId}` : null}
moduleLabel={moduleId ? `Module ${moduleId}` : null} moduleLabel={moduleId ? `Module ${moduleId}` : null}
prevStep={prevStep} prevStep={prevStep}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
ArrowLeft, ArrowLeft,
Plus, Plus,
@ -26,17 +26,27 @@ import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.s
import alertSrc from "../../assets/Alert.svg"; import alertSrc from "../../assets/Alert.svg";
import { import {
deleteTopLevelCourseModule, deleteTopLevelCourseModule,
getPracticesByParentCourse,
getProgramCourses, getProgramCourses,
getTopLevelCourseModules, getTopLevelCourseModules,
publishParentLinkedPractice,
updateParentLinkedPractice,
updateTopLevelCourseModule, updateTopLevelCourseModule,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { refreshFileUrl, resolveFileUrl } from "../../api/files.api"; import { refreshFileUrl, resolveFileUrl } from "../../api/files.api";
import type { import type {
ParentContextPractice,
ProgramCourseListItem, ProgramCourseListItem,
TopLevelCourseModuleItem, TopLevelCourseModuleItem,
} from "../../types/course.types"; } from "../../types/course.types";
import {
isPracticeDraft,
isPracticePublished,
unwrapPracticesList,
} from "../../lib/parentContextPractice";
import { AddModuleModal } from "./components/AddModuleModal"; import { AddModuleModal } from "./components/AddModuleModal";
import { ModuleIconUploadField } from "./components/ModuleIconUploadField"; import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
import { ModulePracticeCard } from "./components/ModulePracticeCard";
import { PublishPracticeButton } from "./components/PublishPracticeButton"; import { PublishPracticeButton } from "./components/PublishPracticeButton";
const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const; const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const;
@ -155,6 +165,17 @@ export function CourseDetailPage() {
useState<TopLevelCourseModuleItem | null>(null); useState<TopLevelCourseModuleItem | null>(null);
const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false); const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false);
const [activeTab, setActiveTab] = useState<"modules" | "practice">("modules");
const [practiceFilter, setPracticeFilter] = useState("All");
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [practicesLoading, setPracticesLoading] = useState(false);
const [practicesLoadError, setPracticesLoadError] = useState<string | null>(
null,
);
const [publishStatusPracticeId, setPublishStatusPracticeId] = useState<
number | null
>(null);
const openEditModule = (module: TopLevelCourseModuleItem) => { const openEditModule = (module: TopLevelCourseModuleItem) => {
setEditingModule(module); setEditingModule(module);
setEditModuleName(module.name ?? ""); setEditModuleName(module.name ?? "");
@ -260,6 +281,91 @@ export function CourseDetailPage() {
void loadPage(); void loadPage();
}, [loadPage]); }, [loadPage]);
const loadCoursePractices = useCallback(async () => {
if (!Number.isFinite(courseIdNum) || courseIdNum < 1) {
setPractices([]);
setPracticesLoadError(null);
setPracticesLoading(false);
return;
}
setPracticesLoading(true);
setPracticesLoadError(null);
try {
const res = await getPracticesByParentCourse(courseIdNum, {
limit: 100,
offset: 0,
});
setPractices(unwrapPracticesList(res));
} catch {
setPractices([]);
setPracticesLoadError("Failed to load practices. Please try again.");
} finally {
setPracticesLoading(false);
}
}, [courseIdNum]);
useEffect(() => {
if (activeTab !== "practice") return;
void loadCoursePractices();
}, [activeTab, loadCoursePractices]);
const filteredPractices = useMemo(() => {
if (practiceFilter === "Published") {
return practices.filter(isPracticePublished);
}
if (practiceFilter === "Draft") {
return practices.filter(isPracticeDraft);
}
if (practiceFilter === "Archived") {
return [];
}
return practices;
}, [practices, practiceFilter]);
const handlePublishPractice = async (practiceId: number) => {
setPublishStatusPracticeId(practiceId);
try {
await publishParentLinkedPractice(practiceId);
setPractices((prev) =>
prev.map((p) =>
p.id === practiceId ? { ...p, publish_status: "PUBLISHED" } : p,
),
);
toast.success("Practice published");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to publish practice";
toast.error(msg);
} finally {
setPublishStatusPracticeId(null);
}
};
const handleSavePracticeAsDraft = async (practiceId: number) => {
setPublishStatusPracticeId(practiceId);
try {
await updateParentLinkedPractice(practiceId, {
publish_status: "DRAFT",
});
setPractices((prev) =>
prev.map((p) =>
p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p,
),
);
toast.success("Practice saved as draft");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to save practice as draft";
toast.error(msg);
} finally {
setPublishStatusPracticeId(null);
}
};
const handleSaveModuleEdit = async () => { const handleSaveModuleEdit = async () => {
if (!editingModule) return; if (!editingModule) return;
const name = editModuleName.trim(); const name = editModuleName.trim();
@ -391,20 +497,32 @@ export function CourseDetailPage() {
</Button> </Button>
</div> </div>
</div> </div>
<div className="relative"> <div className="border-b border-grayScale-200">
<div <div className="flex gap-10">
className="absolute inset-0 flex items-center" <button
aria-hidden="true" type="button"
onClick={() => setActiveTab("modules")}
className={cn(
"pb-4 text-[16px] font-medium transition-all relative",
activeTab === "modules"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600",
)}
> >
<div className="w-full border-t border-grayScale-200" /> Modules
</div> </button>
<div className="relative flex justify-center"> <button
<div type="button"
className="h-[0.5px] w-full rounded-full opacity-20" onClick={() => setActiveTab("practice")}
style={{ className={cn(
background: "gray", "pb-4 text-[16px] font-medium transition-all relative",
}} activeTab === "practice"
/> ? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Practice
</button>
</div> </div>
</div> </div>
@ -496,14 +614,14 @@ export function CourseDetailPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{modules.length === 0 ? ( {activeTab === "modules" ? (
modules.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center"> <div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600"> <p className="text-sm font-medium text-grayScale-600">
No modules in this course yet No modules in this course yet
</p> </p>
<p className="mt-1 text-sm text-grayScale-400"> <p className="mt-1 text-sm text-grayScale-400">
Add modules when your workflow is connected, or create them via Add a module to organize lessons and practices for this course.
the API.
</p> </p>
</div> </div>
) : ( ) : (
@ -590,6 +708,91 @@ export function CourseDetailPage() {
); );
})} })}
</div> </div>
)
) : (
<div className="space-y-8">
<div className="flex items-center gap-10 overflow-x-auto whitespace-nowrap rounded-2xl border border-grayScale-100 bg-white px-8 py-4 shadow-sm">
<div className="mr-2 flex items-center gap-2 text-[12px] font-bold uppercase tracking-widest text-grayScale-300">
STATUS:
</div>
<div className="flex items-center gap-3">
{["All", "Published", "Draft", "Archived"].map((label) => (
<button
key={label}
type="button"
onClick={() => setPracticeFilter(label)}
className={cn(
"h-9 rounded-full px-5 text-[13px] font-bold transition-all",
practiceFilter === label
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
)}
>
{label}
</button>
))}
</div>
</div>
{practicesLoading ? (
<div className="flex flex-col items-center justify-center py-24 text-[15px] font-medium text-grayScale-500">
Loading practices
</div>
) : practicesLoadError ? (
<div className="mx-auto max-w-lg rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900">
{practicesLoadError}
</div>
) : filteredPractices.length > 0 ? (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{filteredPractices.map((practice) => (
<ModulePracticeCard
key={practice.id}
practice={practice}
statusUpdating={publishStatusPracticeId === practice.id}
onEdit={() =>
navigate(`/content/practices?type=course&id=${courseIdNum}`)
}
onPublish={() => void handlePublishPractice(practice.id)}
onSaveAsDraft={() =>
void handleSavePracticeAsDraft(practice.id)
}
/>
))}
</div>
) : (
<div className="mx-auto flex max-w-4xl flex-col items-center justify-center rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white px-4 py-32 shadow-sm">
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-[#FAF5FF]">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-[#F5EBFF]">
<Calendar className="h-7 w-7 text-brand-500" />
</div>
</div>
<h2 className="mb-3 text-2xl font-extrabold text-grayScale-900">
{practices.length === 0
? "No practices for this course yet"
: "No practices match this filter"}
</h2>
<p className="mb-10 max-w-sm text-center text-[15px] font-medium leading-relaxed text-grayScale-400">
{practices.length === 0
? "Add a course-level practice to give learners exercises attached to this course."
: "Try another status filter or add a new practice."}
</p>
{practices.length === 0 ? (
<Button
variant="outline"
className="flex h-12 items-center gap-2 rounded-xl border-brand-500 px-8 font-bold text-brand-500 transition-all hover:bg-brand-50"
onClick={() =>
navigate(
`/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
)
}
>
<Calendar className="h-5 w-5" />
Add Practice
</Button>
) : null}
</div>
)}
</div>
)} )}
{deletingModule && ( {deletingModule && (

View File

@ -3,7 +3,6 @@ import { Link, useParams, useNavigate } from "react-router-dom";
import { import {
ArrowLeft, ArrowLeft,
Plus, Plus,
FileText,
LayoutGrid, LayoutGrid,
PlayCircle, PlayCircle,
ClipboardCheck, ClipboardCheck,
@ -556,17 +555,6 @@ export function CourseManagementPage() {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Button
variant="outline"
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
onClick={() =>
navigate(`/new-content/courses/${programType}/attach-practice`)
}
>
<FileText className="h-5 w-5" />
Attach Practice
</Button>
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowLeft, Plus, FileText, Pencil, Trash2 } from "lucide-react"; import { ArrowLeft, Plus, FileText, Video } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
@ -23,10 +23,19 @@ import {
updateExamPrepModuleLesson, updateExamPrepModuleLesson,
deleteExamPrepModuleLesson, deleteExamPrepModuleLesson,
getExamPrepModuleLessons, getExamPrepModuleLessons,
publishExamPrepModuleLesson,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { uploadImageFile, uploadVideoFile } from "../../api/files.api"; import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import type { PracticePublishStatus } from "../../types/course.types"; import type { PracticePublishStatus } from "../../types/course.types";
const LESSON_THUMB_GRADIENTS = [
"from-[#CBD5E1] to-[#94A3B8]",
"from-[#DBEAFE] to-[#93C5FD]",
"from-[#FEF3C7] to-[#FCD34D]",
"from-[#FCE7F3] to-[#F9A8D4]",
] as const;
const MOCK_PRACTICES = [ const MOCK_PRACTICES = [
{ {
id: "p1", id: "p1",
@ -61,13 +70,17 @@ export function CourseModuleDetailPage() {
id: number; id: number;
title: string; title: string;
videoUrl: string; videoUrl: string;
description: string; description: string | null;
thumbnail: string; thumbnail: string;
sortOrder: number; sortOrder: number;
gradient: string; publishStatus: PracticePublishStatus | string | null;
durationSeconds: number | null; durationSeconds: number | null;
}> }>
>([]); >([]);
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
const [publishStatusLessonId, setPublishStatusLessonId] = useState<
number | null
>(null);
const [createLessonOpen, setCreateLessonOpen] = useState(false); const [createLessonOpen, setCreateLessonOpen] = useState(false);
const [createTitle, setCreateTitle] = useState(""); const [createTitle, setCreateTitle] = useState("");
const [createVideoUrl, setCreateVideoUrl] = useState(""); const [createVideoUrl, setCreateVideoUrl] = useState("");
@ -123,6 +136,7 @@ export function CourseModuleDetailPage() {
return; return;
} }
setLessonsLoading(true); setLessonsLoading(true);
setLessonsLoadError(null);
try { try {
const response = await getExamPrepModuleLessons(parsedModuleId, { const response = await getExamPrepModuleLessons(parsedModuleId, {
limit: 20, limit: 20,
@ -131,7 +145,7 @@ export function CourseModuleDetailPage() {
const rows = response.data?.data?.lessons; const rows = response.data?.data?.lessons;
const list = Array.isArray(rows) ? rows : []; const list = Array.isArray(rows) ? rows : [];
setLessons( setLessons(
list.map((row, index) => { list.map((row) => {
const raw = row.duration_seconds ?? row.duration ?? null; const raw = row.duration_seconds ?? row.duration ?? null;
const n = const n =
raw == null ? NaN : typeof raw === "number" ? raw : Number(raw); raw == null ? NaN : typeof raw === "number" ? raw : Number(raw);
@ -141,22 +155,17 @@ export function CourseModuleDetailPage() {
id: Number(row.id), id: Number(row.id),
title: row.title?.trim() || `Lesson ${row.id}`, title: row.title?.trim() || `Lesson ${row.id}`,
videoUrl: row.video_url?.trim() || "", videoUrl: row.video_url?.trim() || "",
description: row.description?.trim() || "—", description: row.description?.trim() || null,
thumbnail: row.thumbnail?.trim() || "", thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0), sortOrder: Number(row.sort_order ?? 0),
publishStatus: row.publish_status ?? null,
durationSeconds, durationSeconds,
gradient:
index % 3 === 1
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)"
: index % 3 === 2
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)"
: "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)",
}; };
}), }),
); );
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error("Failed to load lessons"); setLessonsLoadError("Failed to load lessons. Please try again.");
setLessons([]); setLessons([]);
} finally { } finally {
setLessonsLoading(false); setLessonsLoading(false);
@ -463,6 +472,45 @@ export function CourseModuleDetailPage() {
} }
}; };
const handleToggleLessonPublishStatus = async (
lessonId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusLessonId(lessonId);
try {
await publishExamPrepModuleLesson(lessonId, {
publish_status: nextStatus,
});
setLessons((prev) =>
prev.map((l) =>
l.id === lessonId ? { ...l, publishStatus: nextStatus } : l,
),
);
toast.success(
nextStatus === "PUBLISHED"
? "Lesson published"
: "Lesson saved as draft",
);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(nextStatus === "PUBLISHED"
? "Failed to publish lesson"
: "Failed to save lesson as draft");
toast.error(message);
} finally {
setPublishStatusLessonId(null);
}
};
const lessonAttachPracticePath = (lesson: (typeof lessons)[number]) =>
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`;
const lessonPracticesPath = (lesson: (typeof lessons)[number]) =>
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/lessons/${lesson.id}/practices?lessonTitle=${encodeURIComponent(lesson.title)}`;
return ( return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10"> <div className="space-y-8 animate-in fade-in duration-500 pb-10">
{/* Navigation */} {/* Navigation */}
@ -491,12 +539,12 @@ export function CourseModuleDetailPage() {
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2 shadow-sm" className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2 shadow-sm"
onClick={() => onClick={() =>
navigate( navigate(
`/new-content/courses/${programType}/${courseId}/unit/${unitId}/module/${moduleId}/attach-practice`, `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice`,
) )
} }
> >
<FileText className="h-5 w-5" /> <FileText className="h-5 w-5" />
Attach Practice Add Practice
</Button> </Button>
<Dialog <Dialog
open={createLessonOpen} open={createLessonOpen}
@ -693,13 +741,14 @@ export function CourseModuleDetailPage() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-10 border-b border-grayScale-100"> <div className="border-b border-grayScale-200">
<div className="flex gap-10">
<button <button
onClick={() => setActiveTab("video")} onClick={() => setActiveTab("video")}
className={cn( className={cn(
"pb-4 text-[16px] font-bold transition-all relative px-2", "pb-4 text-[16px] font-medium transition-all relative",
activeTab === "video" activeTab === "video"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500" ? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600", : "text-grayScale-400 hover:text-grayScale-600",
)} )}
> >
@ -708,48 +757,85 @@ export function CourseModuleDetailPage() {
<button <button
onClick={() => setActiveTab("practice")} onClick={() => setActiveTab("practice")}
className={cn( className={cn(
"pb-4 text-[16px] font-bold transition-all relative px-2", "pb-4 text-[16px] font-medium transition-all relative",
activeTab === "practice" activeTab === "practice"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500" ? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600", : "text-grayScale-400 hover:text-grayScale-600",
)} )}
> >
Practice Practice
</button> </button>
</div> </div>
</div>
{/* Grid of Content */} {/* Content */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4"> <div className="mt-8">
{activeTab === "video" ? ( {activeTab === "video" ? (
lessonsLoading ? ( lessonsLoading ? (
<p className="text-sm text-grayScale-500">Loading lessons...</p> <div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
) : lessons.length === 0 ? ( Loading lessons
<div className="col-span-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No lessons for this module yet
</p>
<p className="mt-1 text-sm text-grayScale-400">
Create your first lesson to start building this module.
</p>
</div> </div>
) : ( ) : lessonsLoadError ? (
lessons.map((lesson) => ( <div className="rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900 max-w-lg mx-auto">
{lessonsLoadError}
</div>
) : lessons.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{lessons.map((lesson, i) => (
<VideoCard <VideoCard
key={lesson.id} key={lesson.id}
id={lesson.id}
title={lesson.title} title={lesson.title}
thumbnailUrl={lesson.thumbnail}
videoUrl={lesson.videoUrl} videoUrl={lesson.videoUrl}
thumbnailGradient={lesson.gradient} publishStatus={lesson.publishStatus}
durationSeconds={lesson.durationSeconds}
hoverModuleActions hoverModuleActions
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
thumbnailGradient={
LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]
}
durationSeconds={lesson.durationSeconds}
onEdit={() => openEditLesson(lesson)} onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLessonId(lesson.id)} onDelete={() => setDeletingLessonId(lesson.id)}
description={lesson.description} description={lesson.description}
onAddPractice={() => navigate(lessonAttachPracticePath(lesson))}
onViewPractices={() => navigate(lessonPracticesPath(lesson))}
onTogglePublishStatus={(nextStatus) =>
void handleToggleLessonPublishStatus(lesson.id, nextStatus)
}
publishStatusUpdating={publishStatusLessonId === lesson.id}
/> />
)) ))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
<div className="h-14 w-14 rounded-full bg-[#F5EBFF] flex items-center justify-center">
<Video className="h-7 w-7 text-brand-500 fill-brand-500/10" />
</div>
</div>
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
No lessons in this module yet
</h2>
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
Lessons are a great way to engage students. Add your first
lesson to get started.
</p>
<Button
variant="outline"
className="h-12 px-8 rounded-xl border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
onClick={() => setCreateLessonOpen(true)}
>
<Video className="h-5 w-5" />
Add Lesson
</Button>
</div>
) )
) : ( ) : (
MOCK_PRACTICES.map((item) => <PracticeCard key={item.id} {...item} />) <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{MOCK_PRACTICES.map((item) => (
<PracticeCard key={item.id} {...item} />
))}
</div>
)} )}
</div> </div>

View File

@ -145,16 +145,21 @@ function PracticeCard({
export function LessonPracticesPage() { export function LessonPracticesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { level, courseId, moduleId, lessonId } = useParams<{ const { level, programType, courseId, unitId, moduleId, lessonId } = useParams<{
level: string; level?: string;
courseId: string; programType?: string;
moduleId: string; courseId?: string;
lessonId: string; unitId?: string;
moduleId?: string;
lessonId?: string;
}>(); }>();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const lessonTitle = searchParams.get("lessonTitle")?.trim() || ""; const lessonTitle = searchParams.get("lessonTitle")?.trim() || "";
const backHref = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`; const isExamPrep = Boolean(programType?.trim());
const backHref = isExamPrep
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`
: `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
const [practices, setPractices] = useState<ParentContextPractice[]>([]); const [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
@ -200,7 +205,9 @@ export function LessonPracticesPage() {
const displayTitle = const displayTitle =
lessonTitle || (validLesson ? `Lesson #${lid}` : "Lesson practices"); lessonTitle || (validLesson ? `Lesson #${lid}` : "Lesson practices");
const addPracticeHref = `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`; const addPracticeHref = isExamPrep
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`
: `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
return ( return (
<div className="min-h-screen bg-gradient-to-b from-[#F4F6FB] via-white to-[#F8FAFC]"> <div className="min-h-screen bg-gradient-to-b from-[#F4F6FB] via-white to-[#F8FAFC]">

View File

@ -557,11 +557,11 @@ export function ProgramDetailPage() {
variant="outline" variant="outline"
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold flex items-center gap-2" className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold flex items-center gap-2"
onClick={() => onClick={() =>
navigate(`/new-content/courses/${programType}/attach-practice`) navigate(`/new-content/courses/${programType}/add-practice`)
} }
> >
<FileText className="h-5 w-5" /> <FileText className="h-5 w-5" />
Attach Practice Add Practice
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -330,6 +330,7 @@ export interface ExamPrepModuleLessonItem {
thumbnail?: string | null thumbnail?: string | null
description?: string | null description?: string | null
sort_order?: number sort_order?: number
publish_status?: PracticePublishStatus | string | null
/** Total length in seconds when the API provides it. */ /** Total length in seconds when the API provides it. */
duration?: number | null duration?: number | null
duration_seconds?: number | null duration_seconds?: number | null
@ -361,6 +362,29 @@ export interface UpdateExamPrepModuleLessonRequest {
sort_order: number sort_order: number
} }
/** Publish-only patch: PUT /exam-prep/lessons/:lessonId with { publish_status }. */
export interface PublishExamPrepModuleLessonRequest {
publish_status: PracticePublishStatus
}
/** POST /exam-prep/lessons/:lessonId/practices */
export interface CreateExamPrepLessonPracticeRequest {
title: string
story_description: string
story_image: string
persona_id: number
question_set_id: number
quick_tips: string
}
export interface CreateExamPrepLessonPracticeResponse {
message: string
data: ParentContextPractice
success: boolean
status_code: number
metadata: unknown | null
}
export interface UpdateExamPrepModuleLessonResponse { export interface UpdateExamPrepModuleLessonResponse {
message: string message: string
data: ExamPrepModuleLessonItem data: ExamPrepModuleLessonItem