Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-11 12:12:40 +08:00
parent 01a2c63b06
commit 71f758fe0b
66 changed files with 3055 additions and 939 deletions
+157 -108
View File
@@ -4,8 +4,8 @@ import { useForm, useFieldArray, useWatch } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import {
ArrowLeft, ArrowRight, Plus, Trash2, BadgeCheck, Trophy,
Check, ChevronsUpDown, X, ImagePlus, Palette, BookOpen,
ArrowLeft, ArrowRight, Plus, Trash2, BadgeCheck,
Check, X, ImagePlus, Palette, BookOpen,
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
@@ -18,21 +18,15 @@ 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 { Checkbox } from "@/components/ui/checkbox";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Popover, PopoverContent, PopoverTrigger,
} from "@/components/ui/popover";
import {
Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,
} 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 AchievementsBuilder from "@/modules/admin/components/courses/AchievementsBuilder";
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// ─── Schema ───────────────────────────────────────────────────────────────────
@@ -43,6 +37,7 @@ const schema = z.object({
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"),
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."),
achievement_keys: z.array(z.string()).max(1).default([]),
@@ -54,6 +49,7 @@ const STEPS = [
{ label: "Basic Info", description: "Title, level & objectives" },
{ label: "Roadmap", description: "Units & lessons" },
{ label: "Rewards", description: "Badge & achievements" },
{ label: "Review", description: "Confirm & create" },
];
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -147,7 +143,6 @@ export default function AddCourse() {
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
const [badgeAssetId, setBadgeAssetId] = useState(null);
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
const [achOpen, setAchOpen] = useState(false);
const {
register,
@@ -156,7 +151,7 @@ export default function AddCourse() {
setValue,
getValues,
trigger,
formState: { errors },
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
@@ -166,6 +161,7 @@ export default function AddCourse() {
order_index: 0,
level: "beginner",
subscription: "free",
status: "draft",
objectives: [],
achievement_keys: [],
},
@@ -174,10 +170,15 @@ export default function AddCourse() {
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
useFieldArray({ control, name: "objectives" });
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
const watchedTitle = useWatch({ control, name: "title" });
const watchedLevel = useWatch({ control, name: "level" });
const watchedSubscr = useWatch({ control, name: "subscription" });
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 watchedOrderIndex = useWatch({ control, name: "order_index" });
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 [achievementRegistry, setAchievementRegistry] = useState([]);
useEffect(() => {
@@ -186,13 +187,22 @@ export default function AddCourse() {
.catch(() => setAchievementRegistry([]));
}, []);
const toggleAchievement = (key) => {
if (currentAchKeys.includes(key)) {
setValue("achievement_keys", currentAchKeys.filter((k) => k !== key), { shouldDirty: true });
} else {
setValue("achievement_keys", [key], { shouldDirty: true });
}
};
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 ||
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),
@@ -225,6 +235,7 @@ export default function AddCourse() {
order_index: values.order_index,
level: values.level || null,
subscription: values.subscription,
status: values.status,
objectives: values.objectives.map((o) => o.text),
achievement_keys: currentAchKeys,
badge_color: badgeColor,
@@ -246,6 +257,7 @@ export default function AddCourse() {
const newCourse = result?.data?.data ?? null;
if (!newCourse) return;
bypassOnce();
navigate(`/admin/courses/${newCourse.course_id}/view`);
};
@@ -316,7 +328,7 @@ export default function AddCourse() {
</SectionCard>
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label>Level</Label>
<Select
@@ -354,6 +366,24 @@ export default function AddCourse() {
</Select>
<FieldError message={errors.subscription?.message} />
</div>
<div className="space-y-1.5">
<Label>Status</Label>
<Select
value={watchedStatus ?? "draft"}
onValueChange={(val) => setValue("status", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="unpublished">Unpublished</SelectItem>
</SelectContent>
</Select>
<FieldError message={errors.status?.message} />
</div>
</div>
</SectionCard>
@@ -496,98 +526,115 @@ export default function AddCourse() {
</div>
</div>
{/* Achievements */}
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Achievements</p>
<span className="text-[10px] text-muted-foreground">{currentAchKeys.length > 0 ? "1 selected" : "none selected"}</span>
<AchievementsBuilder
achievementKeys={currentAchKeys}
onAchievementKeysChange={(keys) => setValue("achievement_keys", keys, { shouldDirty: true })}
registry={achievementRegistry}
onRegistryChange={setAchievementRegistry}
/>
</SectionCard>
)}
{/* ── Step 3: Review ── */}
{currentStep === 3 && (
<>
<SectionCard
title="Basic Info"
description="Confirm everything looks right before creating the course."
>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Title</p>
<p className="font-medium">{watchedTitle || "—"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Course Code</p>
<p className="font-medium">{watchedCourseCode || "—"}</p>
</div>
<div className="col-span-2">
<p className="text-xs text-muted-foreground">Description</p>
<p className="font-medium">{watchedDescription || "—"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Level</p>
<p className="font-medium capitalize">{watchedLevel || "—"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Order</p>
<p className="font-medium">{watchedOrderIndex ?? 0}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Subscription</p>
<p className="font-medium capitalize">{watchedSubscr || "—"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Status</p>
<Badge variant="outline" className="capitalize">{watchedStatus}</Badge>
</div>
</div>
{currentAchKeys.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{currentAchKeys.map((key) => {
const ach = achievementRegistry.find((a) => a.key === key);
<div className="border-t pt-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
Learning Objectives
</p>
{!watchedObjectives?.length ? (
<p className="text-sm text-muted-foreground">None added.</p>
) : (
<ul className="list-disc list-inside space-y-1 text-sm">
{watchedObjectives.map((o, i) => (
<li key={i}>{o.text}</li>
))}
</ul>
)}
</div>
</SectionCard>
<SectionCard
title="Roadmap"
description={`${roadmapUnits.length} unit${roadmapUnits.length === 1 ? "" : "s"} · ${totalLessons} lesson${totalLessons === 1 ? "" : "s"} added.`}
>
{roadmapUnits.length === 0 ? (
<p className="text-sm text-muted-foreground">No units added.</p>
) : (
<div className="space-y-1.5">
{roadmapUnits.map((u, index) => {
const lessonCount = u.lessons.length + (u.existing_lesson_count ?? 0);
return (
<Badge key={key} variant="secondary" className="gap-1 pr-1">
{ach?.label ?? key}
<button
type="button"
className="ml-0.5 rounded-full hover:bg-muted"
onClick={() => toggleAchievement(key)}
>
<X className="h-3 w-3" />
</button>
</Badge>
<div key={u.key} className="rounded-md border px-3 py-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground shrink-0">{index + 1}</span>
<span className="text-sm font-medium truncate flex-1 min-w-0">{u.title}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
{lessonCount} lesson{lessonCount === 1 ? "" : "s"}
</Badge>
{!u.unit_id && (
<Badge variant="secondary" className="text-[10px] shrink-0">new</Badge>
)}
</div>
);
})}
</div>
)}
</SectionCard>
<Popover open={achOpen} onOpenChange={setAchOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-between"
>
<span className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" />
{currentAchKeys.length > 0
? "Change achievement"
: "Select achievement"}
</span>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder="Search achievements…" />
<CommandList className="max-h-none">
<CommandEmpty>No achievements found.</CommandEmpty>
<CommandGroup>
<ScrollArea className="h-64">
{achievementRegistry.map((ach) => {
const checked = currentAchKeys.includes(ach.key);
return (
<CommandItem
key={ach.key}
value={ach.label}
onSelect={() => {
toggleAchievement(ach.key);
setAchOpen(false);
}}
className="gap-2 items-start py-2"
>
<Checkbox
checked={checked}
className="pointer-events-none mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-medium">{ach.label}</span>
<Badge variant="outline" className="text-[9px] px-1 py-0 gap-0.5 capitalize">
{ach.type === "badge"
? <Trophy className="h-2.5 w-2.5" />
: <BadgeCheck className="h-2.5 w-2.5" />
}
{ach.type}
</Badge>
</div>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5">{ach.description}</p>
</div>
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />}
</CommandItem>
);
})}
</ScrollArea>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</SectionCard>
<SectionCard title="Rewards">
<div className="flex items-center gap-4">
<CourseBadge
title={watchedTitle || "Course Title"}
level={watchedLevel}
color={badgeColor}
imageUrl={badgeImageUrl}
/>
<div className="text-sm">
<p className="text-xs text-muted-foreground mb-1">Achievement</p>
{selectedAchievement ? (
<Badge variant="outline">{selectedAchievement.label}</Badge>
) : (
<p className="text-muted-foreground">None selected</p>
)}
</div>
</div>
</SectionCard>
</>
)}
{/* Asset picker (always mounted) */}
@@ -634,6 +681,8 @@ export default function AddCourse() {
</form>
</div>
</div>
{unsavedChangesDialog}
</div>
);
}