import { useNavigate } from "react-router-dom"; import { useEffect, useState } from "react"; import { useForm, useFieldArray, useWatch } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowLeft, ArrowRight, Plus, Trash2, BadgeCheck, Check, X, ImagePlus, Palette, BookOpen, } from "lucide-react"; import { useCourses } from "@/contexts/AdminCoursesContext"; import { useAuth } from "@/contexts/AuthContext"; import api from "@/utils/api.util"; import { PageMeta } from "@/contexts/MetadataContext"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Spinner } from "@/components/ui/spinner"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import CourseBadge from "@/modules/admin/components/courses/CourseBadge"; import RoadmapBuilder from "@/modules/admin/components/courses/RoadmapBuilder"; import AchievementsBuilder from "@/modules/admin/components/courses/AchievementsBuilder"; import CoursePrerequisiteBuilder from "@/modules/admin/components/courses/CoursePrerequisiteBuilder"; import { TIER_COLOR_OPTIONS } from "@/utils/tierColors"; import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; // ─── Schema ─────────────────────────────────────────────────────────────────── const schema = z.object({ title: z.string().min(1, "Title is required."), description: z.string().optional(), course_code: z.string().min(1, "Course Code is required."), level: z.enum(["beginner", "intermediate", "advanced"]).optional(), subscription: z.string().min(1, "Subscription is required.").default("free"), status: z.enum(["draft", "published", "unpublished"]).default("draft"), objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })) .min(1, "At least one learning objective is required."), roles: z.array(z.object({ text: z.string().min(1, "Role cannot be empty.") })).default([]), achievement_keys: z.array(z.string()).max(1).default([]), }); // ─── Steps config ───────────────────────────────────────────────────────────── const STEPS = [ { label: "Basic Info", description: "Title, level & objectives" }, { label: "Roadmap", description: "Units, lessons & prerequisites" }, { label: "Rewards", description: "Badge & achievements" }, { label: "Review", description: "Confirm & create" }, ]; // ─── Helpers ────────────────────────────────────────────────────────────────── function FieldError({ message }) { if (!message) return null; return

{message}

; } function SectionCard({ title, description, children }) { return (
{(title || description) && (
{title &&

{title}

} {description &&

{description}

}
)} {children}
); } function StepIndicator({ steps, current, onStepClick }) { return (
{steps.flatMap((step, i) => { const items = [ , ]; if (i < steps.length - 1) { items.push(
); } return items; })}
); } // ─── Page ───────────────────────────────────────────────────────────────────── export default function AddCourse() { const navigate = useNavigate(); const { createCourseFull, loading, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat } = useCourses(); const { user } = useAuth(); const [currentStep, setCurrentStep] = useState(0); const [roadmapUnits, setRoadmapUnits] = useState([]); const [prerequisites, setPrerequisites] = useState([]); const [tierCategories, setTierCategories] = useState([]); useEffect(() => { api.get("/admin/tiers/categories") .then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active))) .catch(() => {}); }, []); const [flatCourses, setFlatCourses] = useState([]); const [flatUnits, setFlatUnits] = useState([]); const [flatLessons, setFlatLessons] = useState([]); useEffect(() => { (async () => { const [c, u, l] = await Promise.all([fetchCoursesFlat(), fetchUnitsFlat(), fetchLessonsFlat()]); setFlatCourses(c ?? []); setFlatUnits(u ?? []); setFlatLessons(l ?? []); })(); }, []); const [badgeColor, setBadgeColor] = useState("purple"); const [badgeImageUrl, setBadgeImageUrl] = useState(null); const [badgeAssetId, setBadgeAssetId] = useState(null); const [assetPickerOpen, setAssetPickerOpen] = useState(false); const { register, handleSubmit, control, setValue, getValues, trigger, formState: { errors, isDirty }, } = useForm({ resolver: zodResolver(schema), defaultValues: { title: "", description: "", course_code: "", level: "beginner", subscription: "free", status: "draft", objectives: [], roles: [], achievement_keys: [], }, }); const { fields: objectiveFields, append: appendObjective, remove: removeObjective, insert: insertObjective } = useFieldArray({ control, name: "objectives" }); const handleObjectivePaste = (e, index) => { const text = e.clipboardData.getData("text"); const lines = text.split(/\r\n|\r|\n/).map((l) => l.trim()).filter(Boolean); if (lines.length <= 1) return; e.preventDefault(); setValue(`objectives.${index}.text`, lines[0], { shouldDirty: true, shouldValidate: true }); lines.slice(1).forEach((line, i) => { insertObjective(index + 1 + i, { text: line }); }); }; const { fields: roleFields, append: appendRole, remove: removeRole } = useFieldArray({ control, name: "roles" }); const currentAchKeys = useWatch({ control, name: "achievement_keys" }); const watchedTitle = useWatch({ control, name: "title" }); const watchedDescription = useWatch({ control, name: "description" }); const watchedCourseCode = useWatch({ control, name: "course_code" }); const watchedLevel = useWatch({ control, name: "level" }); const watchedSubscr = useWatch({ control, name: "subscription" }); const watchedStatus = useWatch({ control, name: "status" }); const watchedObjectives = useWatch({ control, name: "objectives" }); const watchedRoles = useWatch({ control, name: "roles" }); const [achievementRegistry, setAchievementRegistry] = useState([]); useEffect(() => { api.get("/admin/achievements") .then(({ data }) => setAchievementRegistry((data.data ?? []).filter((a) => a.is_active))) .catch(() => setAchievementRegistry([])); }, []); const selectedAchievement = achievementRegistry.find((a) => a.key === currentAchKeys[0]) ?? null; const totalLessons = roadmapUnits.reduce( (sum, u) => sum + u.lessons.length + (u.existing_lesson_count ?? 0), 0 ); // Roadmap/badge/achievement selections live outside react-hook-form, so // isDirty alone won't catch them — fold them in by hand. const hasUnsavedChanges = isDirty || roadmapUnits.length > 0 || prerequisites.length > 0 || currentAchKeys.length > 0 || !!badgeImageUrl || badgeColor !== "purple"; const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(hasUnsavedChanges); // Nothing here writes to the API until Finish — Basic Info just validates // and advances, Roadmap is held as local draft state (see roadmapUnits), // and Finish fires the whole course + roadmap + rewards in one request. const onBasicInfoSubmit = () => { setCurrentStep((s) => s + 1); }; // Guards the step indicator: jumping straight to Roadmap/Rewards must not // bypass the Basic Info required fields, so re-validate before honoring // any click that leaves step 0. const goToStep = async (target) => { if (target > 0) { const valid = await trigger(["title", "course_code", "objectives"]); if (!valid) { setCurrentStep(0); return; } } setCurrentStep(target); }; const handleFinish = async () => { const values = getValues(); const result = await createCourseFull({ title: values.title, description: values.description, course_code: values.course_code || null, level: values.level || null, subscription: values.subscription, status: values.status, objectives: values.objectives.map((o) => o.text), roles: values.roles.map((r) => r.text), // Drop rows where a type was picked but no item was actually selected — // sending an empty ref_id fails at the DB level. prerequisites: prerequisites .filter((p) => p.ref_id !== "" && p.ref_id != null) .map(({ ref_type, ref_id }) => ({ ref_type, ref_id })), achievement_keys: currentAchKeys, badge_color: badgeColor, badge_asset_id: badgeAssetId ?? null, badge_image_url: badgeImageUrl ?? null, units: roadmapUnits.map((u) => ({ unit_id: u.unit_id, title: u.title, description: u.description, requirements: u.requirements ?? [], lessons: u.lessons.map((l) => ({ lesson_id: l.lesson_id, title: l.title, description: l.description, objectives: l.objectives ?? [], })), })), createdBy: user?.user_id ?? null, }); const newCourse = result?.data?.data ?? null; if (!newCourse) return; bypassOnce(); navigate(`/admin/courses/${newCourse.course_id}/view`); }; return (
{/* ── Sticky header ── */}

Add Course

Create a new training course.

{/* ── Content ── */}
{/* ── Step 0: Basic Info ── */} {currentStep === 0 && ( <>