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 ── */}
{unsavedChangesDialog}
);
}