mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
units,lesson as standalone
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user