units,lesson as standalone

This commit is contained in:
2026-07-10 11:45:02 +08:00
parent 182bd93d10
commit 01a2c63b06
60 changed files with 3217 additions and 606 deletions
+67 -165
View File
@@ -30,6 +30,7 @@ import {
} from "@/components/ui/command";
import { ScrollArea } from "@/components/ui/scroll-area";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import RoadmapBuilder from "@/modules/admin/components/courses/RoadmapBuilder";
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
@@ -38,30 +39,21 @@ import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
course_code: z.string().optional(),
course_code: z.string().min(1, "Course Code is required."),
order_index: z.coerce.number().min(0).default(0),
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.string().min(1, "Subscription is required.").default("free"),
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
.min(1, "At least one learning objective is required."),
achievement_keys: z.array(z.string()).max(1).default([]),
unit_title: z.string().min(1, "Unit title is required."),
unit_description: z.string().optional(),
unit_order: z.coerce.number().min(0).default(0),
lesson_title: z.string().min(1, "Lesson title is required."),
lesson_description: z.string().optional(),
lesson_order: z.coerce.number().min(0).default(0),
lesson_objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
});
// ─── Steps config ─────────────────────────────────────────────────────────────
const STEPS = [
{ label: "Basic Info", description: "Title, level & objectives" },
{ label: "First Unit", description: "The course's first unit" },
{ label: "First Lesson", description: "A lesson inside that unit" },
{ label: "Rewards", description: "Badge & achievements" },
{ label: "Basic Info", description: "Title, level & objectives" },
{ label: "Roadmap", description: "Units & lessons" },
{ label: "Rewards", description: "Badge & achievements" },
];
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -139,10 +131,11 @@ function StepIndicator({ steps, current, onStepClick }) {
export default function AddCourse() {
const navigate = useNavigate();
const { createCourse, createUnit, createLesson, loading } = useCourses();
const { createCourseFull, loading } = useCourses();
const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(0);
const [roadmapUnits, setRoadmapUnits] = useState([]);
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
@@ -159,9 +152,10 @@ export default function AddCourse() {
const {
register,
handleSubmit,
trigger,
control,
setValue,
getValues,
trigger,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
@@ -174,22 +168,12 @@ export default function AddCourse() {
subscription: "free",
objectives: [],
achievement_keys: [],
unit_title: "",
unit_description: "",
unit_order: 0,
lesson_title: "",
lesson_description: "",
lesson_order: 0,
lesson_objectives: [],
},
});
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
useFieldArray({ control, name: "objectives" });
const { fields: lessonObjectiveFields, append: appendLessonObjective, remove: removeLessonObjective } =
useFieldArray({ control, name: "lesson_objectives" });
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
const watchedTitle = useWatch({ control, name: "title" });
const watchedLevel = useWatch({ control, name: "level" });
@@ -210,29 +194,31 @@ export default function AddCourse() {
}
};
const STEP_FIELDS = [
["title", "subscription", "objectives"],
["unit_title", "unit_order"],
["lesson_title", "lesson_order", "lesson_objectives"],
[],
];
const handleNext = async (e) => {
// The Next button occupies the same DOM position as the eventual
// type="submit" Create Course button. Advancing the step re-renders
// that node's type attribute in place *before* the browser evaluates
// this click's default action, which would otherwise submit the form.
e.preventDefault();
const fields = STEP_FIELDS[currentStep];
if (fields?.length) {
const valid = await trigger(fields);
if (!valid) return;
}
// 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);
};
const onSubmit = async (values) => {
const coursePayload = {
// 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,
@@ -240,37 +226,26 @@ export default function AddCourse() {
level: values.level || null,
subscription: values.subscription,
objectives: values.objectives.map((o) => o.text),
achievement_keys: values.achievement_keys,
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,
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 courseResult = await createCourse(coursePayload);
const newCourse = courseResult?.data?.data ?? null;
const newCourse = result?.data?.data ?? null;
if (!newCourse) return;
const unitResult = await createUnit(newCourse.course_id, {
title: values.unit_title,
description: values.unit_description,
order: values.unit_order,
createdBy: user?.user_id,
});
const newUnit = unitResult?.data?.data ?? null;
if (!newUnit) {
navigate(`/admin/courses/${newCourse.course_id}/view`);
return;
}
await createLesson(newCourse.course_id, newUnit.unit_id, {
title: values.lesson_title,
description: values.lesson_description,
order: values.lesson_order,
objectives: values.lesson_objectives.map((o) => o.text),
createdBy: user?.user_id,
});
navigate(`/admin/courses/${newCourse.course_id}/view`);
};
@@ -303,9 +278,9 @@ export default function AddCourse() {
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className="max-w-2xl mx-auto">
<StepIndicator steps={STEPS} current={currentStep} onStepClick={setCurrentStep} />
<StepIndicator steps={STEPS} current={currentStep} onStepClick={goToStep} />
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<form onSubmit={handleSubmit(onBasicInfoSubmit)} className="space-y-5">
{/* ── Step 0: Basic Info ── */}
{currentStep === 0 && (
@@ -326,7 +301,9 @@ export default function AddCourse() {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="course_code">Course Code</Label>
<Label htmlFor="course_code">
Course Code <span className="text-destructive">*</span>
</Label>
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
<FieldError message={errors.course_code?.message} />
</div>
@@ -382,9 +359,10 @@ export default function AddCourse() {
<SectionCard
title="Learning Objectives"
description="What will learners be able to do after completing this course?"
description="What will learners be able to do after completing this course? At least one is required."
>
<div className="space-y-2">
<FieldError message={errors.objectives?.message} />
{objectiveFields.map((field, index) => (
<div key={field.id} className="flex items-start gap-2">
<div className="flex-1 space-y-1">
@@ -421,95 +399,13 @@ export default function AddCourse() {
</>
)}
{/* ── Step 1: First Unit ── */}
{/* ── Step 1: Roadmap ── */}
{currentStep === 1 && (
<SectionCard title="First Unit" description="Every course needs at least one unit to hold its lessons.">
<div className="space-y-1.5">
<Label htmlFor="unit_title">
Title <span className="text-destructive">*</span>
</Label>
<Input id="unit_title" placeholder="e.g. Getting Started" {...register("unit_title")} />
<FieldError message={errors.unit_title?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="unit_description">Description</Label>
<Textarea id="unit_description" placeholder="Optional unit description" rows={3} {...register("unit_description")} />
</div>
<div className="space-y-1.5 max-w-[120px]">
<Label htmlFor="unit_order">Order</Label>
<Input id="unit_order" type="number" min={0} {...register("unit_order")} />
</div>
</SectionCard>
<RoadmapBuilder units={roadmapUnits} onUnitsChange={setRoadmapUnits} />
)}
{/* ── Step 2: First Lesson ── */}
{/* ── Step 2: Rewards ── */}
{currentStep === 2 && (
<>
<SectionCard title="First Lesson" description="Add the first lesson inside that unit.">
<div className="space-y-1.5">
<Label htmlFor="lesson_title">
Title <span className="text-destructive">*</span>
</Label>
<Input id="lesson_title" placeholder="e.g. Welcome to the Course" {...register("lesson_title")} />
<FieldError message={errors.lesson_title?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="lesson_description">Description</Label>
<Textarea id="lesson_description" placeholder="Optional lesson description" rows={3} {...register("lesson_description")} />
</div>
<div className="space-y-1.5 max-w-[120px]">
<Label htmlFor="lesson_order">Order</Label>
<Input id="lesson_order" type="number" min={0} {...register("lesson_order")} />
</div>
</SectionCard>
<SectionCard
title="Lesson Objectives"
description="What will learners be able to do after this lesson?"
>
<div className="space-y-2">
{lessonObjectiveFields.map((field, index) => (
<div key={field.id} className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<Input
placeholder={`Objective ${index + 1}`}
{...register(`lesson_objectives.${index}.text`)}
/>
<FieldError message={errors.lesson_objectives?.[index]?.text?.message} />
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-0.5 text-muted-foreground hover:text-destructive"
onClick={() => removeLessonObjective(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full mt-1"
onClick={() => appendLessonObjective({ text: "" })}
>
<Plus className="h-4 w-4 mr-2" />
Add Objective
</Button>
</div>
</SectionCard>
</>
)}
{/* ── Step 3: Rewards ── */}
{currentStep === 3 && (
<SectionCard
title="Rewards"
description="Badge and achievements awarded to learners who complete this course."
@@ -716,15 +612,21 @@ export default function AddCourse() {
{currentStep === 0 ? "Cancel" : "Back"}
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
{currentStep === 0 ? (
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Next
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
) : currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={() => setCurrentStep((s) => s + 1)}>
Next
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
) : (
<Button type="submit" disabled={loading}>
<Button type="button" disabled={loading} onClick={handleFinish}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Course
Finish
</Button>
)}
</div>