import { useEffect, useMemo, useState } from "react" import { GripVertical, RefreshCw } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Button } from "../../components/ui/button" import { Input } from "../../components/ui/input" import { Select } from "../../components/ui/select" import { getCourseCategories } from "../../api/courses.api" import type { CourseCategory } from "../../types/course.types" import { cn } from "../../lib/utils" type StepType = | "lesson" | "practice" | "exam" | "feedback" | "course" | "speaking" | "new_course" type FlowStep = { id: string type: StepType title: string description?: string } const STEP_LABELS: Record = { lesson: "Lesson", practice: "Practice", exam: "Exam", feedback: "Feedback loop", course: "Course", speaking: "Speaking section", new_course: "New course (category)", } const STEP_BADGE: Record = { lesson: "bg-sky-50 text-sky-700 ring-1 ring-inset ring-sky-200", practice: "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200", exam: "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200", feedback: "bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-200", course: "bg-violet-50 text-violet-700 ring-1 ring-inset ring-violet-200", speaking: "bg-teal-50 text-teal-700 ring-1 ring-inset ring-teal-200", new_course: "bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-200", } const PARENT_ORDER_KEY = "parent_categories_order" const SUB_ORDER_KEY_PREFIX = "sub_categories_order_" export function CourseFlowBuilderPage() { const [categories, setCategories] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [scope, setScope] = useState<"sub" | "parent">("sub") const [selectedSubCategoryId, setSelectedSubCategoryId] = useState("") const [selectedParentCategoryId, setSelectedParentCategoryId] = useState("") const [steps, setSteps] = useState([]) const [dragStepId, setDragStepId] = useState(null) // Order of parent category ids (scope = parent) const [parentCategoryOrder, setParentCategoryOrder] = useState([]) // Order of sub category ids for the selected parent (scope = sub) const [subCategoryOrder, setSubCategoryOrder] = useState([]) const [dragCategoryId, setDragCategoryId] = useState(null) const [parentOrderDirty, setParentOrderDirty] = useState(false) const [subOrderDirty, setSubOrderDirty] = useState(false) const [stepsDirty, setStepsDirty] = useState(false) const parentCategories = useMemo( () => categories.filter((c) => !c.parent_id), [categories], ) const selectedParentCategory = useMemo( () => (scope === "sub" ? categories.find((c) => String(c.id) === selectedParentCategoryId) : undefined), [categories, selectedParentCategoryId, scope], ) const subCategoriesForParent = useMemo(() => { if (!selectedParentCategoryId) return [] return categories.filter((c) => String(c.parent_id) === selectedParentCategoryId) }, [categories, selectedParentCategoryId]) const selectedSubCategory = useMemo( () => (scope === "sub" ? categories.find((c) => String(c.id) === selectedSubCategoryId) : undefined), [categories, selectedSubCategoryId, scope], ) // Ordered parent list: use saved order, merge in any new parents from API const orderedParentCategories = useMemo(() => { const byId = new Map(parentCategories.map((c) => [String(c.id), c])) const ordered: CourseCategory[] = [] const seen = new Set() for (const id of parentCategoryOrder) { const cat = byId.get(id) if (cat) { ordered.push(cat) seen.add(id) } } for (const c of parentCategories) { if (!seen.has(String(c.id))) ordered.push(c) } return ordered }, [parentCategories, parentCategoryOrder]) // Ordered sub list for selected parent const orderedSubCategories = useMemo(() => { const byId = new Map(subCategoriesForParent.map((c) => [String(c.id), c])) const ordered: CourseCategory[] = [] const seen = new Set() for (const id of subCategoryOrder) { const cat = byId.get(id) if (cat) { ordered.push(cat) seen.add(id) } } for (const c of subCategoriesForParent) { if (!seen.has(String(c.id))) ordered.push(c) } return ordered }, [subCategoriesForParent, subCategoryOrder]) // Load categories useEffect(() => { const fetchAll = async () => { setLoading(true) setError(null) try { const catRes = await getCourseCategories() const cats = catRes.data.data.categories ?? [] setCategories(cats) } catch (err) { console.error("Failed to load course flows data:", err) setError("Failed to load categories. Please try again.") } finally { setLoading(false) } } fetchAll() }, []) // Load parent category order from localStorage (after we have categories) useEffect(() => { if (parentCategories.length === 0) return try { const raw = window.localStorage.getItem(PARENT_ORDER_KEY) if (raw) { const parsed: string[] = JSON.parse(raw) if (Array.isArray(parsed) && parsed.length > 0) { setParentCategoryOrder(parsed) setParentOrderDirty(false) return } } } catch { // ignore } setParentCategoryOrder(parentCategories.map((c) => String(c.id))) setParentOrderDirty(false) }, [parentCategories.length]) // Load sub category order for selected parent useEffect(() => { if (!selectedParentCategoryId || subCategoriesForParent.length === 0) { setSubCategoryOrder([]) return } const key = `${SUB_ORDER_KEY_PREFIX}${selectedParentCategoryId}` try { const raw = window.localStorage.getItem(key) if (raw) { const parsed: string[] = JSON.parse(raw) if (Array.isArray(parsed) && parsed.length > 0) { setSubCategoryOrder(parsed) setSubOrderDirty(false) return } } } catch { // ignore } setSubCategoryOrder(subCategoriesForParent.map((c) => String(c.id))) setSubOrderDirty(false) }, [selectedParentCategoryId, subCategoriesForParent.length]) // Load flow steps for selected sub category only (sub category structure) useEffect(() => { if (scope !== "sub" || !selectedSubCategoryId) { setSteps([]) setStepsDirty(false) return } const key = `subcategory_flow_${selectedSubCategoryId}` try { const raw = window.localStorage.getItem(key) if (raw) { const parsed: FlowStep[] = JSON.parse(raw) setSteps(parsed) setStepsDirty(false) return } } catch { // ignore and fall through to default } const defaults: FlowStep[] = [ { id: `${selectedSubCategoryId}-lesson`, type: "lesson", title: "Core lessons", description: "Main learning content for this sub category.", }, { id: `${selectedSubCategoryId}-practice`, type: "practice", title: "Practice sessions", description: "Speaking or practice activities to reinforce learning.", }, { id: `${selectedSubCategoryId}-exam`, type: "exam", title: "Exam / Assessment", description: "Formal evaluation of student understanding.", }, { id: `${selectedSubCategoryId}-feedback`, type: "feedback", title: "Feedback loop", description: "Collect feedback and share results with learners.", }, ] setSteps(defaults) setStepsDirty(true) }, [scope, selectedSubCategoryId]) const handleReorder = (targetId: string) => { if (!dragStepId || dragStepId === targetId) return setSteps((prev) => { const currentIndex = prev.findIndex((s) => s.id === dragStepId) const targetIndex = prev.findIndex((s) => s.id === targetId) if (currentIndex === -1 || targetIndex === -1) return prev const copy = [...prev] const [moved] = copy.splice(currentIndex, 1) copy.splice(targetIndex, 0, moved) return copy }) setDragStepId(null) setStepsDirty(true) } const handleReorderParentCategory = (targetId: string) => { if (!dragCategoryId || dragCategoryId === targetId) return setParentCategoryOrder((prev) => { const currentIndex = prev.indexOf(dragCategoryId) const targetIndex = prev.indexOf(targetId) if (currentIndex === -1 || targetIndex === -1) return prev const copy = [...prev] const [moved] = copy.splice(currentIndex, 1) copy.splice(targetIndex, 0, moved) return copy }) setDragCategoryId(null) setParentOrderDirty(true) } const handleReorderSubCategory = (targetId: string) => { if (!dragCategoryId || dragCategoryId === targetId) return setSubCategoryOrder((prev) => { const currentIndex = prev.indexOf(dragCategoryId) const targetIndex = prev.indexOf(targetId) if (currentIndex === -1 || targetIndex === -1) return prev const copy = [...prev] const [moved] = copy.splice(currentIndex, 1) copy.splice(targetIndex, 0, moved) return copy }) setDragCategoryId(null) setSubOrderDirty(true) } const handleSaveParentOrder = () => { if (orderedParentCategories.length === 0 || parentCategoryOrder.length === 0) return window.localStorage.setItem(PARENT_ORDER_KEY, JSON.stringify(parentCategoryOrder)) setParentOrderDirty(false) } const handleSaveSubOrder = () => { if (!selectedParentCategoryId || subCategoryOrder.length === 0) return window.localStorage.setItem( `${SUB_ORDER_KEY_PREFIX}${selectedParentCategoryId}`, JSON.stringify(subCategoryOrder), ) setSubOrderDirty(false) } const handleSaveSteps = () => { if (scope !== "sub" || !selectedSubCategoryId) return window.localStorage.setItem(`subcategory_flow_${selectedSubCategoryId}`, JSON.stringify(steps)) setStepsDirty(false) } const getDefaultDescription = (type: StepType): string => { switch (type) { case "lesson": return "Add the lessons or modules that introduce key concepts." case "practice": return "Connect speaking or practice activities after lessons." case "exam": return "Place exams or quizzes where you want to assess learners." case "feedback": return "Ask for feedback, NPS, or reflection after the exam or final lesson." case "course": return "Link or add an existing course to this flow." case "speaking": return "Speaking or oral practice section for this flow." case "new_course": return "Add a new course within this category." default: return "" } } const handleAddStep = (type: StepType) => { const activeId = scope === "sub" ? selectedSubCategoryId : selectedParentCategoryId if (!activeId) return const newStep: FlowStep = { id: `${activeId}-${type}-${Date.now()}`, type, title: STEP_LABELS[type], description: getDefaultDescription(type), } setSteps((prev) => [...prev, newStep]) setStepsDirty(true) } const handleUpdateStep = (id: string, changes: Partial) => { setSteps((prev) => prev.map((s) => (s.id === id ? { ...s, ...changes } : s))) setStepsDirty(true) } const handleRemoveStep = (id: string) => { setSteps((prev) => prev.filter((s) => s.id !== id)) setStepsDirty(true) } if (loading) { return (

Loading course flows…

) } if (error) { return (

{error}

) } return (
{/* Header */}

Course Flows

Arrange parent categories and sub categories into flows, including lessons, practice, exams, and feedback steps.

{/* Scope & selector */}
{scope === "sub" && ( <>

Parent category

Sub category (structure)

)}
{/* Parent scope: sequence of parent categories only */} {scope === "parent" && (
Parent category sequence

Drag to reorder the sequence in which parent categories appear. No courses or steps—order only.

{orderedParentCategories.length === 0 ? (
No parent categories. Add categories in Content Management first.
) : ( orderedParentCategories.map((cat, index) => (
setDragCategoryId(String(cat.id))} onDragOver={(e) => e.preventDefault()} onDrop={() => handleReorderParentCategory(String(cat.id))} className={cn( "flex items-center gap-3 rounded-xl border border-grayScale-100 bg-white p-3 shadow-sm transition-colors", dragCategoryId === String(cat.id) && "ring-2 ring-brand-300", )} > #{index + 1} {cat.name}
)) )}
)} {/* Sub scope: sub category sequence then structure */} {scope === "sub" && selectedParentCategoryId && ( <>
Sub category sequence

Drag to reorder sub categories under this parent.

{orderedSubCategories.length === 0 ? (
No sub categories under this parent.
) : ( orderedSubCategories.map((sub, index) => (
setDragCategoryId(String(sub.id))} onDragOver={(e) => e.preventDefault()} onDrop={() => handleReorderSubCategory(String(sub.id))} className={cn( "flex items-center gap-3 rounded-xl border border-grayScale-100 bg-white p-3 shadow-sm transition-colors", dragCategoryId === String(sub.id) && "ring-2 ring-brand-300", )} > #{index + 1} {sub.name}
)) )}
{/* Sub category structure: flow steps (only when a sub category is selected) */} {selectedSubCategory && (
Sub category structure

Courses, questions, and feedback steps for “{selectedSubCategory.name}”.

{steps.length === 0 && (
No steps yet. Use the buttons on the right to add lessons, practice, exams, and feedback loops.
)}
{steps.map((step, index) => (
setDragStepId(step.id)} onDragOver={(e) => e.preventDefault()} onDrop={() => handleReorder(step.id)} className={cn( "flex flex-col gap-2 rounded-xl border border-grayScale-100 bg-white p-3.5 shadow-sm transition-colors md:flex-row md:items-start", dragStepId === step.id && "ring-2 ring-brand-300", )} >
{STEP_LABELS[step.type]} #{index + 1}
handleUpdateStep(step.id, { title: e.target.value })} className="h-8 text-sm" /> handleUpdateStep(step.id, { description: e.target.value }) } placeholder="Optional description for this step" className="h-8 text-xs text-grayScale-500" />
{step.type !== "feedback" && ( )}
))}
{/* Palette / What this controls */}
Add steps

Drag steps in the sequence on the left to change their order. Add feedback loops after exams or any important milestone to keep learners engaged.

How this is used

This builder helps you map out the ideal learner journey. You can later connect each step to actual lessons, speaking practices, exams, or surveys in your backend. For now, flows are saved locally in your browser.

)} )} {scope === "sub" && !selectedParentCategoryId && (

Select a parent category to reorder sub categories and edit a sub category’s structure.

Use the dropdowns above to choose a parent, then reorder its sub categories and pick a sub category to define courses, questions, and feedback steps.

)}
) }