practice creation UI fix
This commit is contained in:
parent
1014f4a72f
commit
095e690a68
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 />}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
124
src/lib/multipleChoiceSlotValue.ts
Normal file
124
src/lib/multipleChoiceSlotValue.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user