diff --git a/src/pages/content-management/AddNewPracticePage.tsx b/src/pages/content-management/AddNewPracticePage.tsx
index b6f01fa..ad370db 100644
--- a/src/pages/content-management/AddNewPracticePage.tsx
+++ b/src/pages/content-management/AddNewPracticePage.tsx
@@ -1,99 +1,156 @@
-import { useMemo, useRef, useState, type ChangeEvent } from "react"
-import { Link, useLocation, useParams, useNavigate } from "react-router-dom"
-import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, Edit, Rocket, Loader2, Upload } from "lucide-react"
-import { toast } from "sonner"
-import { Card } from "../../components/ui/card"
-import { Button } from "../../components/ui/button"
-import { Input } from "../../components/ui/input"
-import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"
-import { createQuestionSet, createQuestion, addQuestionToSet } from "../../api/courses.api"
-import { uploadVideoFile } from "../../api/files.api"
-import { Select } from "../../components/ui/select"
-import type { QuestionOption } from "../../types/course.types"
+import { useMemo, useRef, useState, type ChangeEvent } from "react";
+import { Link, useLocation, useParams, useNavigate } from "react-router-dom";
+import {
+ ArrowLeft,
+ ArrowRight,
+ ChevronDown,
+ Grid3X3,
+ Check,
+ Plus,
+ Trash2,
+ GripVertical,
+ Edit,
+ Rocket,
+ Loader2,
+ Upload,
+} from "lucide-react";
+import { toast } from "sonner";
+import { Card } from "../../components/ui/card";
+import { Button } from "../../components/ui/button";
+import { Input } from "../../components/ui/input";
+import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields";
+import {
+ createQuestionSet,
+ createQuestion,
+ addQuestionToSet,
+} from "../../api/courses.api";
+import { uploadVideoFile } from "../../api/files.api";
+import { Select } from "../../components/ui/select";
+import type { QuestionOption } from "../../types/course.types";
-type Step = 1 | 2 | 3 | 4 | 5
-type ResultStatus = "success" | "error"
-type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
-type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
+type Step = 1 | 2 | 3 | 4 | 5;
+type ResultStatus = "success" | "error";
+type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO";
+type DifficultyLevel = "EASY" | "MEDIUM" | "HARD";
interface Persona {
- id: string
- name: string
- avatar: string
+ id: string;
+ name: string;
+ avatar: string;
}
interface MCQOption {
- text: string
- isCorrect: boolean
+ text: string;
+ isCorrect: boolean;
}
interface Question {
- id: string
- questionText: string
- questionType: QuestionType
- difficultyLevel: DifficultyLevel
- points: number
- tips: string
- explanation: string
- options: MCQOption[]
- voicePrompt: string
- sampleAnswerVoicePrompt: string
- audioCorrectAnswerText: string
- shortAnswers: string[]
- imageUrl: string
+ id: string;
+ questionText: string;
+ questionType: QuestionType;
+ difficultyLevel: DifficultyLevel;
+ points: number;
+ tips: string;
+ explanation: string;
+ options: MCQOption[];
+ voicePrompt: string;
+ sampleAnswerVoicePrompt: string;
+ audioCorrectAnswerText: string;
+ shortAnswers: string[];
+ imageUrl: string;
}
const PERSONAS: Persona[] = [
- { id: "1", name: "Dawit", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit" },
- { id: "2", name: "Mahlet", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet" },
- { id: "3", name: "Amanuel", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel" },
- { id: "4", name: "Bethel", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel" },
- { id: "5", name: "Liya", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya" },
- { id: "6", name: "Aseffa", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa" },
- { id: "7", name: "Hana", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana" },
- { id: "8", name: "Nahom", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom" },
-]
+ {
+ id: "1",
+ name: "Dawit",
+ avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit",
+ },
+ {
+ id: "2",
+ name: "Mahlet",
+ avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet",
+ },
+ {
+ id: "3",
+ name: "Amanuel",
+ avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel",
+ },
+ {
+ id: "4",
+ name: "Bethel",
+ avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel",
+ },
+ {
+ id: "5",
+ name: "Liya",
+ avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya",
+ },
+ {
+ id: "6",
+ name: "Aseffa",
+ avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa",
+ },
+ {
+ id: "7",
+ name: "Hana",
+ avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana",
+ },
+ {
+ id: "8",
+ name: "Nahom",
+ avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom",
+ },
+];
const STEPS = [
{ number: 1, label: "Context" },
{ number: 2, label: "Persona" },
{ number: 3, label: "Questions" },
{ number: 4, label: "Review" },
-]
+];
/** Prefer direct storage URL; for Vimeo pipeline match SubCourseContentPage player URL shape. */
-function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: string } | undefined): string | null {
- if (!data) return null
- const pageUrl = data.url?.trim()
- const embedUrl = data.embed_url?.trim()
+function introVideoUrlFromUploadResponse(
+ data: { url?: string; embed_url?: string } | undefined,
+): string | null {
+ if (!data) return null;
+ const pageUrl = data.url?.trim();
+ const embedUrl = data.embed_url?.trim();
if (embedUrl) {
- const hashFromUrl = pageUrl ? pageUrl.split("/").filter(Boolean).at(-1) : undefined
- return hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl
+ const hashFromUrl = pageUrl
+ ? pageUrl.split("/").filter(Boolean).at(-1)
+ : undefined;
+ return hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl;
}
- return pageUrl || null
+ return pageUrl || null;
}
function toVimeoEmbedUrl(rawUrl: string): string | null {
try {
- const parsed = new URL(rawUrl.trim())
- const host = parsed.hostname.toLowerCase()
- if (!host.includes("vimeo.com")) return null
- if (host.includes("player.vimeo.com") && parsed.pathname.includes("/video/")) return parsed.toString()
- const segments = parsed.pathname.split("/").filter(Boolean)
- const videoId = segments.find((segment) => /^\d+$/.test(segment))
- if (!videoId) return null
- const hash = parsed.searchParams.get("h")
+ const parsed = new URL(rawUrl.trim());
+ const host = parsed.hostname.toLowerCase();
+ if (!host.includes("vimeo.com")) return null;
+ if (
+ host.includes("player.vimeo.com") &&
+ parsed.pathname.includes("/video/")
+ )
+ return parsed.toString();
+ const segments = parsed.pathname.split("/").filter(Boolean);
+ const videoId = segments.find((segment) => /^\d+$/.test(segment));
+ if (!videoId) return null;
+ const hash = parsed.searchParams.get("h");
return hash
? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}`
- : `https://player.vimeo.com/video/${videoId}`
+ : `https://player.vimeo.com/video/${videoId}`;
} catch {
- return null
+ return null;
}
}
function isDirectVideoFile(url: string): boolean {
- const clean = url.split("?")[0].toLowerCase()
- return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean)
+ const clean = url.split("?")[0].toLowerCase();
+ return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
}
function escapeHtml(raw: string): string {
@@ -102,45 +159,56 @@ function escapeHtml(raw: string): string {
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
- .replaceAll("'", "'")
+ .replaceAll("'", "'");
}
function sanitizeAdminRichTextHtml(input: string): string {
- if (!input.trim()) return ""
+ if (!input.trim()) return "";
try {
- const parser = new DOMParser()
- const doc = parser.parseFromString(input, "text/html")
- const blockedTags = new Set(["script", "style", "iframe", "object", "embed", "link", "meta"])
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(input, "text/html");
+ const blockedTags = new Set([
+ "script",
+ "style",
+ "iframe",
+ "object",
+ "embed",
+ "link",
+ "meta",
+ ]);
doc.body.querySelectorAll("*").forEach((el) => {
- const tagName = el.tagName.toLowerCase()
+ const tagName = el.tagName.toLowerCase();
if (blockedTags.has(tagName)) {
- el.remove()
- return
+ el.remove();
+ return;
}
- const attrs = [...el.attributes]
+ const attrs = [...el.attributes];
attrs.forEach((attr) => {
- const name = attr.name.toLowerCase()
- const value = attr.value.trim().toLowerCase()
+ const name = attr.name.toLowerCase();
+ const value = attr.value.trim().toLowerCase();
if (name.startsWith("on")) {
- el.removeAttribute(attr.name)
- return
+ el.removeAttribute(attr.name);
+ return;
}
- if ((name === "href" || name === "src") && value.startsWith("javascript:")) {
- el.removeAttribute(attr.name)
+ if (
+ (name === "href" || name === "src") &&
+ value.startsWith("javascript:")
+ ) {
+ el.removeAttribute(attr.name);
}
- })
- })
- return doc.body.innerHTML
+ });
+ });
+ return doc.body.innerHTML;
} catch {
- return escapeHtml(input).replace(/\r?\n/g, "
")
+ return escapeHtml(input).replace(/\r?\n/g, "
");
}
}
function formatDescriptionForPreview(raw: string): string {
- if (!raw.trim()) return ""
- const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw)
- if (hasHtml) return sanitizeAdminRichTextHtml(raw)
- return escapeHtml(raw).replace(/\r?\n/g, "
")
+ if (!raw.trim()) return "";
+ const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw);
+ if (hasHtml) return sanitizeAdminRichTextHtml(raw);
+ return escapeHtml(raw).replace(/\r?\n/g, "
");
}
function createEmptyQuestion(id: string): Question {
@@ -163,178 +231,197 @@ function createEmptyQuestion(id: string): Question {
audioCorrectAnswerText: "",
shortAnswers: [],
imageUrl: "",
- }
+ };
}
export function AddNewPracticePage() {
- const { categoryId, courseId, subModuleId } = useParams()
- const location = useLocation()
- const navigate = useNavigate()
- const searchParams = new URLSearchParams(location.search)
- const source = searchParams.get("source")
+ const { categoryId, courseId, subModuleId } = useParams();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const searchParams = new URLSearchParams(location.search);
+ const source = searchParams.get("source");
const backTo = useMemo(() => {
- if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
- return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
+ if (
+ location.pathname.includes("/content/human-language/") &&
+ location.pathname.includes("/sub-module/")
+ ) {
+ return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`;
}
- if (source === "human-language") return "/content/human-language"
- return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
- }, [location.pathname, source, categoryId, courseId, subModuleId])
-
- const [currentStep, setCurrentStep] = useState
(1)
- const [saving, setSaving] = useState(false)
+ if (source === "human-language") return "/content/human-language";
+ return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`;
+ }, [location.pathname, source, categoryId, courseId, subModuleId]);
+
+ const [currentStep, setCurrentStep] = useState(1);
+ const [saving, setSaving] = useState(false);
// Step 1: Context
- const [selectedProgram] = useState("Intermediate")
- const [selectedCourse] = useState("B2")
- const [practiceTitle, setPracticeTitle] = useState("")
- const [practiceDescription, setPracticeDescription] = useState("")
- const [introVideoUrl, setIntroVideoUrl] = useState("")
- const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false)
- const [importingIntroVideoUrl, setImportingIntroVideoUrl] = useState(false)
- const introVideoFileInputRef = useRef(null)
- const [shuffleQuestions, setShuffleQuestions] = useState(false)
- const [passingScore, setPassingScore] = useState(50)
- const [timeLimitMinutes, setTimeLimitMinutes] = useState(60)
- const [saveError, setSaveError] = useState(null)
- const [resultStatus, setResultStatus] = useState(null)
- const [resultMessage, setResultMessage] = useState("")
+ const [selectedProgram] = useState("Intermediate");
+ const [selectedCourse] = useState("B2");
+ const [practiceTitle, setPracticeTitle] = useState("");
+ const [practiceDescription, setPracticeDescription] = useState("");
+ const [introVideoUrl, setIntroVideoUrl] = useState("");
+ const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false);
+ const [importingIntroVideoUrl, setImportingIntroVideoUrl] = useState(false);
+ const introVideoFileInputRef = useRef(null);
+ const [shuffleQuestions, setShuffleQuestions] = useState(false);
+ const [passingScore, setPassingScore] = useState(50);
+ const [timeLimitMinutes, setTimeLimitMinutes] = useState(60);
+ const [saveError, setSaveError] = useState(null);
+ const [resultStatus, setResultStatus] = useState(null);
+ const [resultMessage, setResultMessage] = useState("");
// Step 2: Persona
- const [selectedPersona, setSelectedPersona] = useState(null)
+ const [selectedPersona, setSelectedPersona] = useState(null);
// Step 3: Questions
const [questions, setQuestions] = useState([
createEmptyQuestion("1"),
- ])
+ ]);
const handleNext = () => {
if (currentStep < 4) {
- setCurrentStep((currentStep + 1) as Step)
+ setCurrentStep((currentStep + 1) as Step);
}
- }
+ };
const handleBack = () => {
if (currentStep > 1) {
- setCurrentStep((currentStep - 1) as Step)
+ setCurrentStep((currentStep - 1) as Step);
}
- }
+ };
const handleCancel = () => {
- navigate(backTo)
- }
+ navigate(backTo);
+ };
- const handleIntroVideoFileChange = async (event: ChangeEvent) => {
- const file = event.target.files?.[0]
- event.target.value = ""
- if (!file) return
+ const handleIntroVideoFileChange = async (
+ event: ChangeEvent,
+ ) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (!file) return;
- setUploadingIntroVideo(true)
+ setUploadingIntroVideo(true);
try {
const uploadRes = await uploadVideoFile(file, {
- title: practiceTitle.trim() || file.name.replace(/\.[^.]+$/, "") || "Practice intro",
+ title:
+ practiceTitle.trim() ||
+ file.name.replace(/\.[^.]+$/, "") ||
+ "Practice intro",
description: practiceDescription.trim() || undefined,
- })
- const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data)
- if (!finalUrl) throw new Error("Missing uploaded video url")
- setIntroVideoUrl(finalUrl)
- toast.success("Intro video uploaded", { description: "The URL has been filled in for you." })
+ });
+ const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data);
+ if (!finalUrl) throw new Error("Missing uploaded video url");
+ setIntroVideoUrl(finalUrl);
+ toast.success("Intro video uploaded", {
+ description: "The URL has been filled in for you.",
+ });
} catch (error) {
- console.error("Failed to upload intro video:", error)
- toast.error("Failed to upload intro video")
+ console.error("Failed to upload intro video:", error);
+ toast.error("Failed to upload intro video");
} finally {
- setUploadingIntroVideo(false)
+ setUploadingIntroVideo(false);
}
- }
+ };
const handleImportIntroVideoFromUrl = async () => {
- const source = introVideoUrl.trim()
- if (!source || !/^https?:\/\//i.test(source)) return
- const vimeoEmbed = toVimeoEmbedUrl(source)
+ const source = introVideoUrl.trim();
+ if (!source || !/^https?:\/\//i.test(source)) return;
+ const vimeoEmbed = toVimeoEmbedUrl(source);
// Vimeo page URLs can be protected by anti-bot checks when server-side fetched.
// For those links, prefer local normalization to player URL instead of failing import.
if (vimeoEmbed) {
- setIntroVideoUrl(vimeoEmbed)
- return
+ setIntroVideoUrl(vimeoEmbed);
+ return;
}
- setImportingIntroVideoUrl(true)
+ setImportingIntroVideoUrl(true);
try {
const uploadRes = await uploadVideoFile(source, {
title: practiceTitle.trim() || "Practice intro",
description: practiceDescription.trim() || undefined,
- })
- const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data)
- if (!finalUrl) throw new Error("Missing uploaded video url")
- setIntroVideoUrl(finalUrl)
- toast.success("Intro video URL imported", { description: "Processed via /files/upload." })
+ });
+ const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data);
+ if (!finalUrl) throw new Error("Missing uploaded video url");
+ setIntroVideoUrl(finalUrl);
+ toast.success("Intro video URL imported", {
+ description: "Processed via /files/upload.",
+ });
} catch (error) {
- console.error("Failed to import intro video URL:", error)
- toast.error("Failed to import intro video URL")
+ console.error("Failed to import intro video URL:", error);
+ toast.error("Failed to import intro video URL");
} finally {
- setImportingIntroVideoUrl(false)
+ setImportingIntroVideoUrl(false);
}
- }
+ };
const introVideoPreview = useMemo(() => {
- const raw = introVideoUrl.trim()
- if (!raw) return null
- const vimeoEmbedUrl = toVimeoEmbedUrl(raw)
- if (vimeoEmbedUrl) return { kind: "vimeo" as const, url: vimeoEmbedUrl }
- if (isDirectVideoFile(raw)) return { kind: "video" as const, url: raw }
- return null
- }, [introVideoUrl])
+ const raw = introVideoUrl.trim();
+ if (!raw) return null;
+ const vimeoEmbedUrl = toVimeoEmbedUrl(raw);
+ if (vimeoEmbedUrl) return { kind: "vimeo" as const, url: vimeoEmbedUrl };
+ if (isDirectVideoFile(raw)) return { kind: "video" as const, url: raw };
+ return null;
+ }, [introVideoUrl]);
const descriptionPreviewHtml = useMemo(
() => formatDescriptionForPreview(practiceDescription),
[practiceDescription],
- )
+ );
const addQuestion = () => {
- setQuestions([...questions, createEmptyQuestion(String(Date.now()))])
- }
+ setQuestions([...questions, createEmptyQuestion(String(Date.now()))]);
+ };
const removeQuestion = (id: string) => {
if (questions.length > 1) {
- setQuestions(questions.filter(q => q.id !== id))
+ setQuestions(questions.filter((q) => q.id !== id));
}
- }
+ };
const updateQuestion = (id: string, updates: Partial) => {
- setQuestions(questions.map(q => q.id === id ? { ...q, ...updates } : q))
- }
+ setQuestions(
+ questions.map((q) => (q.id === id ? { ...q, ...updates } : q)),
+ );
+ };
const saveQuestionSet = async (status: "DRAFT" | "PUBLISHED") => {
- setSaving(true)
- setSaveError(null)
+ setSaving(true);
+ setSaveError(null);
try {
- const persona = PERSONAS.find(p => p.id === selectedPersona)
+ const persona = PERSONAS.find((p) => p.id === selectedPersona);
const setRes = await createQuestionSet({
title: practiceTitle || "Untitled Practice",
set_type: "PRACTICE",
owner_type: "SUB_MODULE",
owner_id: Number(subModuleId),
- ...(practiceDescription.trim() ? { description: practiceDescription.trim() } : {}),
+ ...(practiceDescription.trim()
+ ? { description: practiceDescription.trim() }
+ : {}),
...(persona?.name ? { persona: persona.name } : {}),
shuffle_questions: shuffleQuestions,
status,
passing_score: passingScore,
time_limit_minutes: timeLimitMinutes,
- ...(introVideoUrl.trim() ? { intro_video_url: introVideoUrl.trim() } : {}),
- })
+ ...(introVideoUrl.trim()
+ ? { intro_video_url: introVideoUrl.trim() }
+ : {}),
+ });
- const questionSetId = setRes.data?.data?.id
+ const questionSetId = setRes.data?.data?.id;
if (questionSetId) {
for (let i = 0; i < questions.length; i++) {
- const q = questions[i]
- if (!q.questionText.trim()) continue
+ const q = questions[i];
+ if (!q.questionText.trim()) continue;
- const options: QuestionOption[] = q.questionType === "MCQ"
- ? q.options.map((opt, idx) => ({
- option_order: idx + 1,
- option_text: opt.text,
- is_correct: opt.isCorrect,
- }))
- : []
+ const options: QuestionOption[] =
+ q.questionType === "MCQ"
+ ? q.options.map((opt, idx) => ({
+ option_order: idx + 1,
+ option_text: opt.text,
+ is_correct: opt.isCorrect,
+ }))
+ : [];
const qRes = await createQuestion({
question_text: q.questionText,
@@ -349,734 +436,926 @@ export function AddNewPracticePage() {
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
image_url: q.imageUrl.trim() || undefined,
- short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
- })
+ short_answers:
+ q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
+ });
- const questionId = qRes.data?.data?.id
+ const questionId = qRes.data?.data?.id;
if (questionId) {
await addQuestionToSet(questionSetId, {
display_order: i + 1,
question_id: questionId,
- })
+ });
}
}
}
- setResultStatus("success")
+ setResultStatus("success");
setResultMessage(
status === "PUBLISHED"
? "Your speaking practice is now active."
- : "Your practice has been saved as a draft."
- )
- setCurrentStep(5)
+ : "Your practice has been saved as a draft.",
+ );
+ setCurrentStep(5);
} catch (err: unknown) {
- console.error("Failed to save practice:", err)
- const errorMsg = err instanceof Error ? err.message : "An unexpected error occurred."
- setResultStatus("error")
- setResultMessage(errorMsg)
- setCurrentStep(5)
+ console.error("Failed to save practice:", err);
+ const errorMsg =
+ err instanceof Error ? err.message : "An unexpected error occurred.";
+ setResultStatus("error");
+ setResultMessage(errorMsg);
+ setCurrentStep(5);
} finally {
- setSaving(false)
+ setSaving(false);
}
- }
+ };
- const handleSaveAsDraft = () => saveQuestionSet("DRAFT")
- const handlePublish = () => saveQuestionSet("PUBLISHED")
+ const handleSaveAsDraft = () => saveQuestionSet("DRAFT");
+ const handlePublish = () => saveQuestionSet("PUBLISHED");
const getNextButtonLabel = () => {
switch (currentStep) {
- case 1: return "Next: Persona"
- case 2: return "Next: Questions"
- case 3: return "Next: Review"
- default: return "Next"
+ case 1:
+ return "Next: Persona";
+ case 2:
+ return "Next: Questions";
+ case 3:
+ return "Next: Review";
+ default:
+ return "Next";
}
- }
+ };
return (
- {currentStep !== 5 && (
- <>
- {/* Back Link */}
-
-
- Back to Sub-course
-
-
- {/* Header */}
-
-
Add New Practice
-
- Create a new immersive practice session for students.
-
-
- >
- )}
-
- {/* Step Tracker */}
- {currentStep !== 5 && (
-
- {STEPS.map((step, index) => (
-
-
-
step.number
- ? "bg-brand-500 text-white"
- : "border-2 border-grayScale-300 bg-white text-grayScale-400"
- }`}
- >
- {currentStep > step.number ? : step.number}
-
-
step.number
- ? "text-brand-500"
- : "text-grayScale-400"
- }`}
- >
- {step.label}
-
-
- {index < STEPS.length - 1 && (
-
step.number ? "bg-brand-500" : "bg-grayScale-200"
- }`}
- />
- )}
-
- ))}
-
- )}
-
- {/* Step Content */}
- {currentStep === 1 && (
-
-
-
Step 1: Context
-
- Define details and rules for this practice. Curriculum context is shown on the right.
-
-
-
-
-
-
-
-
- setPracticeTitle(e.target.value)}
- placeholder="Enter practice title"
- className="h-11"
- />
-
-
-
-
-
-
-
setIntroVideoUrl(e.target.value)}
- onBlur={() => void handleImportIntroVideoFromUrl()}
- placeholder="https://…"
- type="url"
- inputMode="url"
- autoComplete="off"
- className="h-11 font-mono text-[13px]"
- />
-
-
-
-
- {introVideoUrl.trim() ? (
-
- ) : null}
-
- {introVideoPreview ? (
-
-
Preview
- {introVideoPreview.kind === "vimeo" ? (
-
-
-
- ) : (
-
- )}
-
- ) : null}
-
- Paste a link or upload from your computer; uploads go through the file service (optional, not tied to sub-course video rows).
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- {currentStep === 2 && (
-
-
-
Step 2: Persona
-
- Choose the character students will interact with in this practice.
-
-
-
-
-
- {PERSONAS.map((persona) => (
-
- ))}
-
-
-
-
-
-
-
-
- )}
-
- {currentStep === 3 && (
-
-
-
Step 3: Questions
-
- Add MCQ, True/False, Short Answer, or Audio items. Use the full width for stems and options.
-
-
-
-
- {questions.map((question, index) => (
-
-
-
-
- Question {index + 1}
-
-
-
-
- {
- updateQuestion(question.id, {
- questionText: next.questionText,
- questionType: next.questionType as QuestionType,
- difficultyLevel: next.difficultyLevel as DifficultyLevel,
- points: next.points,
- tips: next.tips,
- explanation: next.explanation,
- options: next.options,
- voicePrompt: next.voicePrompt,
- sampleAnswerVoicePrompt: next.sampleAnswerVoicePrompt,
- audioCorrectAnswerText: next.audioCorrectAnswerText,
- shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
- imageUrl: next.imageUrl,
- })
- }}
- mediaBusy={saving}
- />
-
- ))}
-
-
-
-
-
+
+ Back to Sub-course
+
-
-
-
-
-
- )}
-
- {currentStep === 4 && (
-
-
-
Step 4: Review & publish
-
- Confirm context, persona, and questions before saving or publishing.
-
-
-
-
- {/* Basic Information Card */}
-
-
-
Basic Information
-
+ {/* Header */}
+
+
+ Add New Practice
+
+
+ Create a new immersive practice session for students.
+
-
-
- Title
- {practiceTitle || "Untitled Practice"}
-
-
-
Description
- {descriptionPreviewHtml ? (
+ >
+ )}
+
+ {/* Step Tracker */}
+ {currentStep !== 5 && (
+
+ {STEPS.map((step, index) => (
+
+
-
- Intro video URL
-
- {introVideoUrl.trim() || "—"}
-
-
- {introVideoPreview ? (
-
-
Intro video preview
-
- {introVideoPreview.kind === "vimeo" ? (
-
-
-
+ className={`flex h-9 w-9 items-center justify-center rounded-full text-xs font-semibold shadow-sm transition-all duration-300 sm:h-10 sm:w-10 sm:text-sm ${
+ currentStep === step.number
+ ? "bg-brand-500 text-white ring-4 ring-brand-100"
+ : currentStep > step.number
+ ? "bg-brand-500 text-white"
+ : "border-2 border-grayScale-300 bg-white text-grayScale-400"
+ }`}
+ >
+ {currentStep > step.number ? (
+
) : (
-
+ step.number
)}
+
step.number
+ ? "text-brand-500"
+ : "text-grayScale-400"
+ }`}
+ >
+ {step.label}
+
- ) : null}
-
-
Passing Score
-
{passingScore}%
+ {index < STEPS.length - 1 && (
+
step.number
+ ? "bg-brand-500"
+ : "bg-grayScale-200"
+ }`}
+ />
+ )}
-
- Time Limit
- {timeLimitMinutes} minutes
+ ))}
+
+ )}
+
+ {/* Step Content */}
+ {currentStep === 1 && (
+
+
+
+ Step 1: Context
+
+
+ Define details and rules for this practice. Curriculum context
+ is shown on the right.
+
+
+
+
+
+
+
+
+ setPracticeTitle(e.target.value)}
+ placeholder="Enter practice title"
+ className="h-11"
+ />
+
+
+
+
+
+
+
setIntroVideoUrl(e.target.value)}
+ onBlur={() => void handleImportIntroVideoFromUrl()}
+ placeholder="https://…"
+ type="url"
+ inputMode="url"
+ autoComplete="off"
+ className="h-11 font-mono text-[13px]"
+ />
+
+
+
+
+ {introVideoUrl.trim() ? (
+
+ ) : null}
+
+ {introVideoPreview ? (
+
+
+ Preview
+
+ {introVideoPreview.kind === "vimeo" ? (
+
+
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+ Paste a link or upload from your computer; uploads go
+ through the file service (optional, not tied to sub-course
+ video rows).
+
+
+
+
+
-
- Shuffle Questions
- {shuffleQuestions ? "Yes" : "No"}
-
-
-
Persona
-
- {selectedPersona && (
-
+
+
+
+
+
+
+
+ )}
+
+ {currentStep === 2 && (
+
+
+
+ Step 2: Persona
+
+
+ Choose the character students will interact with in this
+ practice.
+
+
+
+
+
+ {PERSONAS.map((persona) => (
+
+
+ {persona.name}
+
+
+ ))}
+
+
+
+
+
+ )}
- {/* Questions Review */}
-
-
-
-
Questions
-
- {questions.length}
-
-
+ {currentStep === 3 && (
+
+
+
+ Step 3: Questions
+
+
+ Add MCQ, True/False, Short Answer, or Audio items. Use the full
+ width for stems and options.
+
+
+
+
+ {questions.map((question, index) => (
+
+
+
+
+
+ Question {index + 1}
+
+
+
+
+
+ {
+ updateQuestion(question.id, {
+ questionText: next.questionText,
+ questionType: next.questionType as QuestionType,
+ difficultyLevel:
+ next.difficultyLevel as DifficultyLevel,
+ points: next.points,
+ tips: next.tips,
+ explanation: next.explanation,
+ options: next.options,
+ voicePrompt: next.voicePrompt,
+ sampleAnswerVoicePrompt: next.sampleAnswerVoicePrompt,
+ audioCorrectAnswerText: next.audioCorrectAnswerText,
+ shortAnswers: next.shortAnswer.trim()
+ ? [next.shortAnswer.trim()]
+ : [],
+ imageUrl: next.imageUrl,
+ });
+ }}
+ mediaBusy={saving}
+ />
+
+ ))}
+
+
+
-
- {questions.map((question, index) => (
-
-
-
- {index + 1}
+
+
+
+
+
+
+ )}
+
+ {currentStep === 4 && (
+
+
+
+ Step 4: Review & publish
+
+
+ Confirm context, persona, and questions before saving or
+ publishing.
+
+
+
+
+ {/* Basic Information Card */}
+
+
+
+ Basic Information
+
+
+
+
+
+
Title
+
+ {practiceTitle || "Untitled Practice"}
-
-
{question.questionText}
-
-
- {question.questionType === "MCQ"
- ? "Multiple Choice"
- : question.questionType === "TRUE_FALSE"
- ? "True/False"
- : question.questionType === "AUDIO"
- ? "Audio"
- : "Short Answer"}
-
-
- {question.difficultyLevel}
-
- {question.points} pt{question.points !== 1 ? "s" : ""}
+
+
+
+ Description
+
+ {descriptionPreviewHtml ? (
+
+ ) : (
+
—
+ )}
+
+
+
+ Intro video URL
+
+
+ {introVideoUrl.trim() || "—"}
+
+
+ {introVideoPreview ? (
+
+
+ Intro video preview
+
+
+ {introVideoPreview.kind === "vimeo" ? (
+
+
+
+ ) : (
+
+ )}
- {question.questionType === "MCQ" && question.options.length > 0 && (
-
- {question.options.map((opt, i) => (
-
- {opt.isCorrect && }
- {opt.text || `Option ${i + 1}`}
-
- ))}
+
+ ) : null}
+
+
+ Passing Score
+
+
+ {passingScore}%
+
+
+
+
+ Time Limit
+
+
+ {timeLimitMinutes} minutes
+
+
+
+
+ Shuffle Questions
+
+
+ {shuffleQuestions ? "Yes" : "No"}
+
+
+
+
Persona
+
+ {selectedPersona && (
+
+

p.id === selectedPersona)
+ ?.avatar
+ }
+ alt="Persona"
+ className="h-full w-full object-cover"
+ />
)}
- {question.tips && (
-
💡 Tip: {question.tips}
- )}
- {question.explanation && (
-
Explanation: {question.explanation}
- )}
+
+ {PERSONAS.find((p) => p.id === selectedPersona)?.name ||
+ "None selected"}
+
- ))}
-
-
-
+
- {saveError && (
-
-
{saveError}
+ {/* Questions Review */}
+
+
+
+
+ Questions
+
+
+ {questions.length}
+
+
+
+
+
+ {questions.map((question, index) => (
+
+
+
+ {index + 1}
+
+
+
+ {question.questionText}
+
+
+
+ {question.questionType === "MCQ"
+ ? "Multiple Choice"
+ : question.questionType === "TRUE_FALSE"
+ ? "True/False"
+ : question.questionType === "AUDIO"
+ ? "Audio"
+ : "Short Answer"}
+
+
+ {question.difficultyLevel}
+
+
+ {question.points} pt
+ {question.points !== 1 ? "s" : ""}
+
+
+ {question.questionType === "MCQ" &&
+ question.options.length > 0 && (
+
+ {question.options.map((opt, i) => (
+
+ {opt.isCorrect && (
+
+ )}
+ {opt.text || `Option ${i + 1}`}
+
+ ))}
+
+ )}
+ {question.tips && (
+
+ 💡 Tip: {question.tips}
+
+ )}
+ {question.explanation && (
+
+ Explanation: {question.explanation}
+
+ )}
+
+
+
+ ))}
+
+
- )}
-
-
-
-
-
-
-
-
- )}
-
- {/* Step 5: Result */}
- {currentStep === 5 && resultStatus && (
-
- {resultStatus === "success" ? (
- <>
-
-
+ {saveError && (
+
-
- Practice Published Successfully!
-
-
{resultMessage}
-
-
+ )}
+
+
+
+
-
- >
- ) : (
- <>
-
-
- Publish Error!
-
-
{resultMessage}
-
- >
- )}
-
- )}
+
+
+ )}
+
+ {/* Step 5: Result */}
+ {currentStep === 5 && resultStatus && (
+
+ {resultStatus === "success" ? (
+ <>
+
+
+ Practice Published Successfully!
+
+
+ {resultMessage}
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+ Publish Error!
+
+
+ {resultMessage}
+
+
+
+
+ >
+ )}
+
+ )}
- )
+ );
}
diff --git a/src/pages/content-management/AddPracticeFlow.tsx b/src/pages/content-management/AddPracticeFlow.tsx
index 7e3b742..360d16d 100644
--- a/src/pages/content-management/AddPracticeFlow.tsx
+++ b/src/pages/content-management/AddPracticeFlow.tsx
@@ -25,6 +25,7 @@ export function AddPracticeFlow() {
const moduleId = searchParams.get("moduleId");
const isModuleContext = backTo === "module";
+ const isCourseContext = backTo === "modules";
const backLabel =
backTo === "module"
@@ -73,7 +74,7 @@ export function AddPracticeFlow() {
if (isPublished) {
return (
-
+
-
+
Practice Published Successfully!
-
+
Your speaking practice is now active and available inside the module.
@@ -106,7 +107,7 @@ export function AddPracticeFlow() {
});
}}
variant="outline"
- className="h-14 rounded-2xl border-brand-200 text-brand-500 font-bold hover:bg-brand-50 transition-all text-[17px] bg-white"
+ className="h-14 rounded-[6px] border-[#9E2891] text-[#9E2891] font-semibold text-[16px] bg-white "
>
Add Another Practice
@@ -128,6 +129,7 @@ export function AddPracticeFlow() {
navigate={navigate}
level={level!}
isModuleContext={isModuleContext}
+ isCourseContext={isCourseContext}
/>
);
case 2:
@@ -182,6 +184,7 @@ export function AddPracticeFlow() {
navigate={navigate}
level={level!}
isModuleContext={isModuleContext}
+ isCourseContext={isCourseContext}
/>
);
case 2:
@@ -219,40 +222,46 @@ export function AddPracticeFlow() {
};
return (
-
+
{/* Header */}
{backLabel}
-
-
-
- Add New Practice
-
-
+
+
+
+ Add New Practice
+
+
+
+
Create a new immersive practice session for students.
-
+
-
{renderStep()}
+
+ {renderStep()}
+
);
diff --git a/src/pages/content-management/AddVideoFlow.tsx b/src/pages/content-management/AddVideoFlow.tsx
index 7086bd4..9278496 100644
--- a/src/pages/content-management/AddVideoFlow.tsx
+++ b/src/pages/content-management/AddVideoFlow.tsx
@@ -6,6 +6,7 @@ import { Stepper } from "../../components/ui/stepper";
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
+import successIcon from "../../assets/success.svg";
const STEPS = [
{ id: 1, label: "Video Detail" },
@@ -37,43 +38,31 @@ export function AddVideoFlow() {
if (isPublished) {
return (
-
+
{/* Success Icon Wrapper (Jagged Circle Style) */}
-
-
-
- {/* Sub-Jagged layer for depth if needed */}
-
+
-
+
Video Published Successfully!
-
+
Your video is now live and available inside the selected module.
@@ -90,7 +79,7 @@ export function AddVideoFlow() {
setCurrentStep(1);
}}
variant="outline"
- className="h-14 rounded-2xl border-brand-200 text-brand-500 font-bold hover:bg-brand-50 transition-all text-[17px] active:scale-95 bg-white"
+ className="h-12 rounded-[6px] border-brand-200 text-brand-500 font-bold text-[17px] active:scale-95 bg-white"
>
Add Another Video
@@ -100,7 +89,7 @@ export function AddVideoFlow() {
}
return (
-
+
{/* Header */}
@@ -120,7 +109,7 @@ export function AddVideoFlow() {
-
+
Add New Video
diff --git a/src/pages/content-management/AttachPracticeFlow.tsx b/src/pages/content-management/AttachPracticeFlow.tsx
new file mode 100644
index 0000000..948f51c
--- /dev/null
+++ b/src/pages/content-management/AttachPracticeFlow.tsx
@@ -0,0 +1,193 @@
+import { useState } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+import { ArrowLeft, Clock, FileVideo, Check } from "lucide-react";
+import { Button } from "../../components/ui/button";
+import { Stepper } from "../../components/ui/stepper";
+import successIcon from "../../assets/success.svg";
+
+import { AttachPracticeStep1 } from "./components/practice-steps/AttachPracticeStep1";
+import { AttachPracticeReviewStep } from "./components/practice-steps/AttachPracticeReviewStep";
+
+export function AttachPracticeFlow() {
+ const navigate = useNavigate();
+ const { programType, courseId, unitId, moduleId } = useParams<{
+ programType: string;
+ courseId: string;
+ unitId: string;
+ moduleId: string;
+ }>();
+
+ const backPath = `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`;
+ const [currentStep, setCurrentStep] = useState(1);
+ const [isPublished, setIsPublished] = useState(false);
+
+ const [formData, setFormData] = useState({
+ program:
+ programType === "skill"
+ ? "Skill-Based Courses"
+ : "English Proficiency Exams",
+ module: "Module 4: Interactive Speaking",
+ video: "Intro to Interactive Speaking",
+ questionType: "speaking",
+ version: "v1",
+ });
+
+ const steps = ["Set Video", "Review & Publish"];
+
+ const nextStep = () =>
+ setCurrentStep((prev) => Math.min(prev + 1, steps.length));
+ const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
+
+ if (isPublished) {
+ return (
+
+ {/* Scalloped Success Icon */}
+
+
+

+
+
+
+ Practice Attached Successfully!
+
+
+ The practice has been successfully linked to a video{" "}
+ “{formData.video}”
+
+
+ {/* Video Info Card */}
+
+
+
+

+
+
+
+ Intro to IELTS Speaking Part 1
+
+
+
+
+ 10:42 mins
+
+
•
+
+ MP4
+
+
+
+
+
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+ );
+ }
+
+ const renderStep = () => {
+ switch (currentStep) {
+ case 1:
+ return (
+ navigate(backPath)}
+ />
+ );
+ case 2:
+ return (
+ setIsPublished(true)}
+ />
+ );
+ default:
+ return null;
+ }
+ };
+
+ const title =
+ currentStep === 1 ? "Attach Practice to a Video" : "Review & Publish";
+ const description =
+ currentStep === 1
+ ? "Create a new immersive practice session for a video."
+ : "Verify practice details before publishing it.";
+
+ return (
+
+
+ {/* Navigation Breadcrumb */}
+
+
+ {/* Stepper Area */}
+
+
+
+
+ {/* Page Title & Header Actions */}
+
+
+
{title}
+
+ {description}
+
+
+
+
+
+
+
+
+ {/* Form Content */}
+
{renderStep()}
+
+
+ );
+}
diff --git a/src/pages/content-management/AttachProgramPracticeFlow.tsx b/src/pages/content-management/AttachProgramPracticeFlow.tsx
new file mode 100644
index 0000000..106fd63
--- /dev/null
+++ b/src/pages/content-management/AttachProgramPracticeFlow.tsx
@@ -0,0 +1,157 @@
+import { useState } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+import { ArrowLeft, Check } from "lucide-react";
+import { Button } from "../../components/ui/button";
+import { Stepper } from "../../components/ui/stepper";
+import successIcon from "../../assets/success.svg";
+
+import { ProgramAttachStep1 } from "./components/practice-steps/ProgramAttachStep1";
+import { ProgramAttachReviewStep } from "./components/practice-steps/ProgramAttachReviewStep";
+
+export function AttachProgramPracticeFlow() {
+ const navigate = useNavigate();
+ const { programType } = useParams<{ programType: string }>();
+
+ const backPath = `/new-content/courses/${programType}`;
+ const [currentStep, setCurrentStep] = useState(1);
+ const [isPublished, setIsPublished] = useState(false);
+
+ const [formData, setFormData] = useState({
+ program: "English Proficiency Exams",
+ test: "Mock Exam 1",
+ questionType: "Speaking Practice",
+ version: "V 1.0",
+ });
+
+ const steps = ["Set Program", "Review & Publish"];
+
+ const nextStep = () =>
+ setCurrentStep((prev) => Math.min(prev + 1, steps.length));
+ const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
+
+ if (isPublished) {
+ return (
+
+ {/* Scalloped Success Icon */}
+
+
+

+
+
+
+ Practice Attached Successfully!
+
+
+ The practice has been successfully linked to the program{" "}
+ “{formData.program}”
+
+
+
+
+
+
+
+ );
+ }
+
+ const renderStep = () => {
+ switch (currentStep) {
+ case 1:
+ return (
+ navigate(backPath)}
+ />
+ );
+ case 2:
+ return (
+ setIsPublished(true)}
+ onCancel={() => navigate(backPath)}
+ />
+ );
+ default:
+ return null;
+ }
+ };
+
+ const title =
+ currentStep === 1 ? "Attach Practice to a program" : "Review & Publish";
+ const description =
+ currentStep === 1
+ ? "Create a new immersive practice session for a video."
+ : "Verify practice details before publishing it.";
+
+ return (
+
+
+ {/* Navigation Breadcrumb */}
+
+
+
+ Back to Program
+
+
+
+ {/* Stepper Area */}
+
+
+
+
+ {/* Page Title & Header Actions */}
+
+
+
{title}
+
{description}
+
+
+
+
+
+
+
+ {/* Form Content */}
+
+ {renderStep()}
+
+
+
+ );
+}
diff --git a/src/pages/content-management/CourseDetailPage.tsx b/src/pages/content-management/CourseDetailPage.tsx
index 52896fd..1493eae 100644
--- a/src/pages/content-management/CourseDetailPage.tsx
+++ b/src/pages/content-management/CourseDetailPage.tsx
@@ -41,7 +41,7 @@ export function CourseDetailPage() {
const [isAddModuleOpen, setIsAddModuleOpen] = useState(false);
return (
-
+
{/* Header Navigation */}
-
-
+
+
{courseId?.toUpperCase() || "A1"}
-
+
Learn basic English words, phrases, and simple sentences for daily
situations.
@@ -67,37 +67,51 @@ export function CourseDetailPage() {
+
setIsAddModuleOpen(false)}
/>
+ {/* Gradient Divider */}
{/* Gradient Grid */}
-
+
{MODULES.map((module) => (
{/* Gradient Banner */}
-
+
{/* Icon Circle */}
-
-
+
+
{/* Content */}
-
+
{module.title}
-
+
{module.description}
@@ -129,7 +147,7 @@ export function CourseDetailPage() {
-
-
+
Description
-
+
Program Order
-
+
Thumbnail
@@ -148,12 +148,12 @@ export function LearnEnglishPage() {
Cancel
-
+
Create Program
@@ -165,7 +165,7 @@ export function LearnEnglishPage() {
{/* Gradient Divider */}
{/* Cards Grid */}
-
+
{levels.map((level) => (
{/* Gradient Header */}
-
-
- {level.title}
-
-
- {level.description}
-
+
+
+
+ {level.title}
+
+
+ {level.description}
+
+
-
+
View Courses
diff --git a/src/pages/content-management/ModuleDetailPage.tsx b/src/pages/content-management/ModuleDetailPage.tsx
index 834a4b9..4acbb94 100644
--- a/src/pages/content-management/ModuleDetailPage.tsx
+++ b/src/pages/content-management/ModuleDetailPage.tsx
@@ -94,7 +94,7 @@ export function ModuleDetailPage() {
.join(" ") || "Business English Fundamentals";
return (
-
+
{/* Header Navigation */}
-
-
+
+
Module 3: {moduleTitle}
-
+
This module covers essential vocabulary and phrases used in modern
business environments, including email etiquette and meeting
protocols.
@@ -121,7 +121,7 @@ export function ModuleDetailPage() {
navigate(
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}`,
@@ -132,7 +132,7 @@ export function ModuleDetailPage() {
Add Practice
navigate(
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/add-video`,
@@ -148,12 +148,12 @@ export function ModuleDetailPage() {
{/* Tabs */}
-
+
setActiveTab("video")}
className={cn(
- "pb-4 text-[17px] font-bold transition-all relative",
+ "pb-4 text-[16px] font-medium transition-all relative",
activeTab === "video"
? "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",
@@ -164,7 +164,7 @@ export function ModuleDetailPage() {
setActiveTab("practice")}
className={cn(
- "pb-4 text-[17px] font-bold transition-all relative",
+ "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",
@@ -270,12 +270,12 @@ function PracticeCard({
-
+
{title}
-
+
{level}
@@ -285,21 +285,21 @@ function PracticeCard({
-
+
{variations} Variations
-
-
+
+
{status}
-
-
+
+
-
-
+
+
diff --git a/src/pages/content-management/NewContentPage.tsx b/src/pages/content-management/NewContentPage.tsx
index d382ebd..094e453 100644
--- a/src/pages/content-management/NewContentPage.tsx
+++ b/src/pages/content-management/NewContentPage.tsx
@@ -41,7 +41,7 @@ export function NewContentPage() {
-
+
Learn English
@@ -50,7 +50,7 @@ export function NewContentPage() {
modules.
-
+
Manage Learn English
@@ -64,15 +64,17 @@ export function NewContentPage() {
-
+
Courses
Manage skill-based and exam preparation courses such as Duolingo
and IELTS.
-
- Manage Courses
-
+
+
+ Manage Courses
+
+
diff --git a/src/pages/content-management/ProgramCoursesPage.tsx b/src/pages/content-management/ProgramCoursesPage.tsx
index 68586b8..2195cb7 100644
--- a/src/pages/content-management/ProgramCoursesPage.tsx
+++ b/src/pages/content-management/ProgramCoursesPage.tsx
@@ -45,7 +45,7 @@ export function ProgramCoursesPage() {
];
return (
-
+
{/* Navigation */}
+ {/* Gradient Divider */}
+
+
{/* Cards Grid */}
-
+
{courses.map((course) => (
{/* Gradient Header */}
();
+
+ // Mock data for "proficiency" program type
+ const programs: Record
= {
+ proficiency: {
+ title: "English Proficiency Exams",
+ description:
+ "Manage exam-based learning programs such as Duolingo and IELTS.",
+ courses: [
+ {
+ id: "duolingo",
+ name: "Duolingo English Test",
+ description:
+ "Adaptive exam-style practice for speaking, writing, reading, and listening.",
+ coursesCount: 6,
+ questionTypesCount: 13,
+ logo: (
+
+ {/* Simple Duolingo-like representation if image not available */}
+
+
+
+ ),
+ buttonText: "Manage Detail",
+ },
+ {
+ id: "ielts",
+ name: "IELTS Academic",
+ description:
+ "Full preparation for IELTS speaking, writing, listening, and reading.",
+ coursesCount: 4,
+ questionTypesCount: 18,
+ logo: (
+
+
+ IELTS
+
+
+ ™
+
+
+ ),
+ buttonText: "View Detail",
+ },
+ ],
+ },
+ "skill-based": {
+ title: "Skill-Based Courses",
+ description:
+ "Practice-focused communication and skills training for real-world scenarios.",
+ courses: [], // To be implemented or shown if needed
+ },
+ };
+
+ const currentProgram =
+ programs[programType || "proficiency"] || programs.proficiency;
+
+ return (
+
+ {/* Navigation */}
+
+
+ Back
+
+
+ {/* Header section */}
+
+
+
+ {currentProgram.title}
+
+
+ {currentProgram.description}
+
+
+
+
+
+
+
+ navigate(`/new-content/courses/${programType}/attach-practice`)
+ }
+ >
+
+ Attach Practice
+
+
+
+
+ {/* Gradient Divider */}
+
+
+ {/* Cards Grid */}
+
+ {currentProgram.courses.map((course: any) => (
+
+ {/* Logo */}
+ {course.logo}
+
+ {/* Content */}
+
+
+ {course.name}
+
+
+ {course.description}
+
+
+
+ {/* Badges/Stats */}
+
+
+
+
+ {course.coursesCount} Courses
+
+
+
+
+
+ {course.questionTypesCount} Question Types
+
+
+
+
+ {/* Action Button */}
+
+ navigate(`/new-content/courses/${programType}/${course.id}`)
+ }
+ >
+ {course.buttonText}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/content-management/ProgramTypeSelectionPage.tsx b/src/pages/content-management/ProgramTypeSelectionPage.tsx
new file mode 100644
index 0000000..090018c
--- /dev/null
+++ b/src/pages/content-management/ProgramTypeSelectionPage.tsx
@@ -0,0 +1,82 @@
+import { Link } from "react-router-dom";
+import { GraduationCap, Brain } from "lucide-react";
+import { Button } from "../../components/ui/button";
+
+export function ProgramTypeSelectionPage() {
+ return (
+
+ {/* Header section */}
+
+
+
+ Courses
+
+
+ Organize courses under skill-based learning or English proficiency
+ exams. Select a program type to manage curriculum and modules.
+
+
+
+ Manage Question Types
+
+
+
+ {/* Gradient Divider */}
+
+
+ {/* Selection Cards Grid */}
+
+ {/* Skill-Based Courses Card */}
+
+
+
+
+
+
+
+
+ Skill-Based Courses
+
+
+ Practice-focused communication and skills training. Create
+ modules for vocabulary, grammar, and real-world conversation
+ scenarios.
+
+
+
+
+
+ {/* English Proficiency Exams Card */}
+
+
+
+
+
+
+
+
+ English Proficiency Exams
+
+
+ Exam preparation courses such as IELTS, and Duolingo. Structure
+ content by band scores, sections, and mock tests.
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/content-management/UnitManagementPage.tsx b/src/pages/content-management/UnitManagementPage.tsx
new file mode 100644
index 0000000..9284d1e
--- /dev/null
+++ b/src/pages/content-management/UnitManagementPage.tsx
@@ -0,0 +1,254 @@
+import { Link, useParams, useNavigate } from "react-router-dom";
+import {
+ ArrowLeft,
+ Plus,
+ MessageCircle,
+ PlayCircle,
+ ClipboardCheck,
+ ArrowRight,
+ X,
+} from "lucide-react";
+import { Button } from "../../components/ui/button";
+import { Card } from "../../components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+ DialogClose,
+} from "../../components/ui/dialog";
+import { Input } from "../../components/ui/input";
+import { Select } from "../../components/ui/select";
+import uploadIcon from "../../assets/icons/upload.png";
+
+export function UnitManagementPage() {
+ const navigate = useNavigate();
+ const { programType, courseId, unitId } = useParams<{
+ programType: string;
+ courseId: string;
+ unitId: string;
+ }>();
+
+ // Mock titles
+ const unitTitles: Record = {
+ unit1: "Greetings & Introductions",
+ unit2: "Speaking",
+ unit3: "Reading",
+ };
+
+ const unitDisplayName =
+ unitTitles[unitId || ""] || "Greetings & Introductions";
+
+ const modules = [
+ {
+ id: "mod1",
+ name: "Module 1: Basic Phrases",
+ description: "Learn essential phrases for daily conversations.",
+ videos: 3,
+ practices: 3,
+ gradient:
+ "linear-gradient(135deg, rgba(158, 40, 145, 0.4) 0%, rgba(158, 40, 145, 0.7) 100%)",
+ },
+ {
+ id: "mod2",
+ name: "Module 1: Basic Phrases", // Matching Image 2092-1 labels
+ description: "Learn essential phrases for daily conversations.",
+ videos: 3,
+ practices: 3,
+ gradient:
+ "linear-gradient(135deg, rgba(79, 70, 229, 0.4) 0%, rgba(79, 70, 229, 0.7) 100%)",
+ },
+ {
+ id: "mod3",
+ name: "Module 1: Basic Phrases",
+ description: "Learn essential phrases for daily conversations.",
+ videos: 3,
+ practices: 3,
+ gradient:
+ "linear-gradient(135deg, rgba(124, 58, 237, 0.4) 0%, rgba(124, 58, 237, 0.7) 100%)",
+ },
+ ];
+
+ return (
+
+ {/* Navigation */}
+
+
+ Back to Courses
+
+
+ {/* Header section */}
+
+
+ {unitDisplayName}
+
+
+
+
+
+ {/* Gradient Divider */}
+
+
+ {/* Grid of Modules */}
+
+ {modules.map((module, index) => (
+
+ {/* Gradient Header */}
+
+
+
+
+ {/* Chat Icon */}
+
+
+
+
+
+
+ {module.name}
+
+
+ {module.description}
+
+
+
+
+ {/* Stats Pills */}
+
+
+
+
+ {module.videos} Videos
+
+
+
+
+
+ {module.practices} Practices
+
+
+
+
+ {/* Action Button */}
+
+ navigate(
+ `/new-content/courses/${programType}/${courseId}/${unitId}/${module.id}`,
+ )
+ }
+ >
+ View Detail
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/content-management/components/AddModuleModal.tsx b/src/pages/content-management/components/AddModuleModal.tsx
index 7cc989f..8c92dbb 100644
--- a/src/pages/content-management/components/AddModuleModal.tsx
+++ b/src/pages/content-management/components/AddModuleModal.tsx
@@ -28,10 +28,6 @@ export function AddModuleModal({ isOpen, onClose }: AddModuleModalProps) {
Create a module to organize videos and practices.
-
-
- Close
-
{/* Gradient Divider */}
diff --git a/src/pages/content-management/components/VideoCard.tsx b/src/pages/content-management/components/VideoCard.tsx
index 63946f3..bd271a7 100644
--- a/src/pages/content-management/components/VideoCard.tsx
+++ b/src/pages/content-management/components/VideoCard.tsx
@@ -67,7 +67,7 @@ export function VideoCard({
-
+
{title}
@@ -76,7 +76,7 @@ export function VideoCard({
Edit
@@ -85,7 +85,7 @@ export function VideoCard({
disabled={status === "Published"}
onClick={onPublish}
className={cn(
- "w-full h-11 rounded-xl font-bold transition-all shadow-sm",
+ "w-full h-10 rounded-xl font-bold transition-all shadow-sm",
status === "Published"
? "bg-[#E9D5E5] text-white opacity-100 cursor-default"
: "bg-brand-500 text-white hover:bg-brand-600 shadow-brand-500/10",
diff --git a/src/pages/content-management/components/practice-steps/AttachPracticeReviewStep.tsx b/src/pages/content-management/components/practice-steps/AttachPracticeReviewStep.tsx
new file mode 100644
index 0000000..f700406
--- /dev/null
+++ b/src/pages/content-management/components/practice-steps/AttachPracticeReviewStep.tsx
@@ -0,0 +1,232 @@
+import {
+ Rocket,
+ GraduationCap,
+ Folder,
+ ChevronUp,
+ ChevronDown,
+ Eye,
+ Video as VideoIcon,
+ ClipboardList,
+} from "lucide-react";
+import { useState } from "react";
+import { Button } from "../../../../components/ui/button";
+import { Card } from "../../../../components/ui/card";
+import { cn } from "../../../../lib/utils";
+
+interface AttachPracticeReviewStepProps {
+ formData: any;
+ prevStep: () => void;
+ onPublish: () => void;
+}
+
+export function AttachPracticeReviewStep({
+ formData,
+ prevStep,
+ onPublish,
+}: AttachPracticeReviewStepProps) {
+ const [isExpanded, setIsExpanded] = useState(true);
+ const [isConfirmed, setIsConfirmed] = useState(false);
+
+ const questions = [
+ { order: "01", text: "What is the main idea of the passage?" },
+ { order: "02", text: "What does the speaker mainly talk about in the..." },
+ { order: "03", text: "Which option best completes the sentence?" },
+ ];
+
+ return (
+
+ {/* 1. Video Summary Card */}
+
+
+ {/* Thumbnail */}
+
+

+
+
+ 12:30
+
+
+
+ {/* Details */}
+
+
+ Intro to Interactive Speaking
+
+
+
+
+ IELTS
+
+
+
+ Unit 2: Speaking
+
+
+
+ Module 4: Interactive Speaking
+
+
+
+
+
+
+ {/* 2. Attached Practices Section */}
+
+
+
+ Attached Practices
+
+
+ Total Items: 3
+
+
+
+
+ {/* Header */}
+ setIsExpanded(!isExpanded)}
+ >
+
+
+
+
+
+
+ Multiple Choice
+
+
+ 3 Questions • ~4 min to complete
+
+
+
+
+
+
+ Preview
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Expanded Content (Table) */}
+ {isExpanded && (
+
+
+
+
+
+ |
+
+ Order
+ |
+
+ Versions
+ |
+
+
+
+ {questions.map((q, i) => (
+
+ |
+
+ |
+
+
+ {q.order}
+
+ |
+
+
+ {q.text}
+
+ |
+
+ ))}
+
+
+
+
+ )}
+
+
+
+ {/* 3. Confirmation Checkbox */}
+
+
setIsConfirmed(e.target.checked)}
+ className="mt-1 h-5 w-5 rounded border-grayScale-300 text-brand-500 focus:ring-brand-500 cursor-pointer"
+ />
+
+
+ I confirm these details are correct
+
+
+ This action cannot be undone immediately. Rollback requires manual
+ intervention.
+
+
+
+
+ {/* 4. Action Footer */}
+
+
+ Back
+
+
+
+ Save as Draft
+
+
+
+ Publish Now
+
+
+
+
+ );
+}
+
+// Add GripVertical helper since it might not be imported from lucide-react if I missed it
+function GripVertical({ className }: { className?: string }) {
+ return (
+
+ {[...Array(6)].map((_, i) => (
+
+ ))}
+
+ );
+}
diff --git a/src/pages/content-management/components/practice-steps/AttachPracticeStep1.tsx b/src/pages/content-management/components/practice-steps/AttachPracticeStep1.tsx
new file mode 100644
index 0000000..9cdfe38
--- /dev/null
+++ b/src/pages/content-management/components/practice-steps/AttachPracticeStep1.tsx
@@ -0,0 +1,168 @@
+import { LayoutGrid, Video, ArrowRight } from "lucide-react";
+import { Button } from "../../../../components/ui/button";
+import { Card } from "../../../../components/ui/card";
+import { Select } from "../../../../components/ui/select";
+import { cn } from "../../../../lib/utils";
+
+interface AttachPracticeStep1Props {
+ formData: any;
+ setFormData: (data: any) => void;
+ nextStep: () => void;
+ onCancel: () => void;
+}
+
+export function AttachPracticeStep1({
+ formData,
+ setFormData,
+ nextStep,
+ onCancel,
+}: AttachPracticeStep1Props) {
+ return (
+
+
+ {/* Select Program */}
+
+
+ Select Program
+
+
+
+
+
+
+
+
+
+ {/* Select Module */}
+
+
+ Select Module
+
+
+
+
+
+
+
+
+ Select the specific learning module this practice will reinforce.
+
+
+
+ {/* Select Video */}
+
+
+ Select Video
+
+
+
+
+
+
+
+
+ Select the specific video this practice will reinforce.
+
+
+
+ {/* Select Question Type */}
+
+
+ Select Question Type
+
+
+
+
+
+
+
+
+ Select one question type that associates with th selected video
+
+
+
+ {/* Set Version */}
+
+
+ Set Version
+
+
+
+
+
+
+
+
+ Select one or more versions
+
+
+
+
+
+
+ Cancel
+
+
+ Next: Review
+
+
+
+
+ );
+}
diff --git a/src/pages/content-management/components/practice-steps/ContextStep.tsx b/src/pages/content-management/components/practice-steps/ContextStep.tsx
index 165bfff..11898c5 100644
--- a/src/pages/content-management/components/practice-steps/ContextStep.tsx
+++ b/src/pages/content-management/components/practice-steps/ContextStep.tsx
@@ -10,6 +10,7 @@ interface ContextStepProps {
navigate: (path: string) => void;
level: string;
isModuleContext?: boolean;
+ isCourseContext?: boolean;
}
export function ContextStep({
@@ -19,22 +20,37 @@ export function ContextStep({
navigate,
level,
isModuleContext,
+ isCourseContext,
}: ContextStepProps) {
return (
-
-
-
+
+
+
Step 1: Context Definition
-
+
Define the educational level and curriculum module for this practice.
+
+
{/* Program Field */}
-
+
Program{" "}
(Auto-selected)
@@ -42,10 +58,10 @@ export function ContextStep({
-
+