mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user