add: more commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:21:27 +08:00
parent 17326b2c2e
commit 7e964f2432
112 changed files with 9160 additions and 3461 deletions
+421 -333
View File
@@ -3,7 +3,10 @@ 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, Plus, Trash2, BadgeCheck, Trophy, Check, ChevronsUpDown, X, ImagePlus, Palette } from "lucide-react";
import {
ArrowLeft, ArrowRight, Plus, Trash2, BadgeCheck, Trophy,
Check, ChevronsUpDown, X, ImagePlus, Palette, BookOpen,
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
@@ -17,28 +20,16 @@ import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover, PopoverContent, PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
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 { ACHIEVEMENT_REGISTRY } from "@/utils/achievements.data";
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
@@ -52,9 +43,16 @@ const schema = z.object({
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([]),
achievement_keys: z.array(z.string()).max(3).default([]),
achievement_keys: z.array(z.string()).max(1).default([]),
});
// ─── Steps config ─────────────────────────────────────────────────────────────
const STEPS = [
{ label: "Basic Info", description: "Title, level & objectives" },
{ label: "Rewards", description: "Badge & achievements" },
];
// ─── Helpers ──────────────────────────────────────────────────────────────────
function FieldError({ message }) {
@@ -76,6 +74,56 @@ function SectionCard({ title, description, children }) {
);
}
function StepIndicator({ steps, current, onStepClick }) {
return (
<div className="flex items-start w-full mb-8">
{steps.flatMap((step, i) => {
const items = [
<button
key={`step-${i}`}
type="button"
onClick={() => onStepClick(i)}
className="flex flex-col items-center gap-1.5 shrink-0 group"
>
<div
className={[
"w-8 h-8 rounded-full border-2 flex items-center justify-center text-sm font-semibold transition-all group-hover:opacity-80",
i < current
? "bg-primary border-primary text-primary-foreground"
: i === current
? "border-primary text-primary"
: "border-border text-muted-foreground",
].join(" ")}
>
{i < current ? <Check className="h-4 w-4" /> : i + 1}
</div>
<p
className={[
"text-[11px] font-medium text-center leading-tight whitespace-nowrap",
i === current ? "text-foreground" : "text-muted-foreground",
].join(" ")}
>
{step.label}
</p>
</button>,
];
if (i < steps.length - 1) {
items.push(
<div
key={`line-${i}`}
className={[
"flex-1 h-px mt-4 mx-2 shrink",
i < current ? "bg-primary" : "bg-border",
].join(" ")}
/>
);
}
return items;
})}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AddCourse() {
@@ -83,6 +131,7 @@ export default function AddCourse() {
const { createCourse, loading } = useCourses();
const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(0);
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
@@ -90,7 +139,6 @@ export default function AddCourse() {
.catch(() => {});
}, []);
// ─── Badge config state ─────────────────────────────────────────────────
const [badgeColor, setBadgeColor] = useState("purple");
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
const [badgeAssetId, setBadgeAssetId] = useState(null);
@@ -100,6 +148,7 @@ export default function AddCourse() {
const {
register,
handleSubmit,
trigger,
control,
setValue,
formState: { errors },
@@ -120,19 +169,34 @@ 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 watchedLevel = useWatch({ control, name: "level" });
const watchedSubscr = useWatch({ control, name: "subscription" });
const [achievementRegistry, setAchievementRegistry] = useState([]);
useEffect(() => {
api.get("/admin/achievements")
.then(({ data }) => setAchievementRegistry((data.data ?? []).filter((a) => a.is_active)))
.catch(() => setAchievementRegistry([]));
}, []);
const toggleAchievement = (key) => {
if (currentAchKeys.includes(key)) {
setValue("achievement_keys", currentAchKeys.filter((k) => k !== key), { shouldDirty: true });
} else if (currentAchKeys.length < 3) {
setValue("achievement_keys", [...currentAchKeys, key], { shouldDirty: true });
} else {
setValue("achievement_keys", [key], { shouldDirty: true });
}
};
const handleNext = async () => {
if (currentStep === 0) {
const valid = await trigger(["title", "subscription", "objectives"]);
if (!valid) return;
}
setCurrentStep((s) => s + 1);
};
const onSubmit = async (values) => {
const payload = {
...values,
@@ -151,352 +215,376 @@ export default function AddCourse() {
};
return (
<section className="bg-muted/60 min-h-full">
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title="Add Course - STARR" description="Create a new training course." />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
{/* ── Sticky header ── */}
<div
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Add Course</h1>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<BookOpen className="h-5 w-5 text-muted-foreground" />
Add Course
</h1>
<p className="text-sm text-muted-foreground">Create a new training course.</p>
</div>
</div>
</div>
</div>
{/* ── Content ── */}
<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} />
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{/* ── Basic Info ── */}
<SectionCard title="Basic Information">
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="e.g. Introduction to Real Estate" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="course_code">Course Code</Label>
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
<FieldError message={errors.course_code?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="order_index">Order</Label>
<Input id="order_index" type="number" min={0} {...register("order_index")} />
<FieldError message={errors.order_index?.message} />
</div>
</div>
</SectionCard>
{/* ── Settings ── */}
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Level</Label>
<Select
value={watchedLevel ?? ""}
onValueChange={(val) => setValue("level", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="beginner">Beginner</SelectItem>
<SelectItem value="intermediate">Intermediate</SelectItem>
<SelectItem value="advanced">Advanced</SelectItem>
</SelectContent>
</Select>
<FieldError message={errors.level?.message} />
</div>
<div className="space-y-1.5">
<Label>Subscription</Label>
<Select
value={watchedSubscr ?? "free"}
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select subscription" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.subscription?.message} />
</div>
</div>
</SectionCard>
{/* ── Objectives ── */}
<SectionCard
title="Learning Objectives"
description="What will learners be able to do after completing this course?"
>
<div className="space-y-2">
{objectiveFields.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(`objectives.${index}.text`)}
/>
<FieldError message={errors.objectives?.[index]?.text?.message} />
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-0.5 text-muted-foreground hover:text-destructive"
onClick={() => removeObjective(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
{/* ── Step 0: Basic Info ── */}
{currentStep === 0 && (
<>
<SectionCard title="Basic Information">
<div className="space-y-1.5">
<Label htmlFor="title">
Title <span className="text-destructive">*</span>
</Label>
<Input id="title" placeholder="e.g. Introduction to Real Estate" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full mt-1"
onClick={() => appendObjective({ text: "" })}
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="course_code">Course Code</Label>
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
<FieldError message={errors.course_code?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="order_index">Order</Label>
<Input id="order_index" type="number" min={0} {...register("order_index")} />
<FieldError message={errors.order_index?.message} />
</div>
</div>
</SectionCard>
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Level</Label>
<Select
value={watchedLevel ?? ""}
onValueChange={(val) => setValue("level", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="beginner">Beginner</SelectItem>
<SelectItem value="intermediate">Intermediate</SelectItem>
<SelectItem value="advanced">Advanced</SelectItem>
</SelectContent>
</Select>
<FieldError message={errors.level?.message} />
</div>
<div className="space-y-1.5">
<Label>Subscription</Label>
<Select
value={watchedSubscr ?? "free"}
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select subscription" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.subscription?.message} />
</div>
</div>
</SectionCard>
<SectionCard
title="Learning Objectives"
description="What will learners be able to do after completing this course?"
>
<Plus className="h-4 w-4 mr-2" />
Add Objective
</Button>
</div>
</SectionCard>
{/* ── Rewards ── */}
<SectionCard
title="Rewards"
description="Badge and achievements awarded to learners who complete this course."
>
{/* ── Completion Badge ── */}
<div>
<p className="text-xs font-medium mb-3 text-muted-foreground uppercase tracking-wide">Completion Badge</p>
<div className="flex items-start gap-4">
<CourseBadge
title={watchedTitle || "Course Title"}
level={watchedLevel}
color={badgeColor}
imageUrl={badgeImageUrl}
/>
<div className="flex-1 flex flex-col gap-3">
{/* Metadata */}
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Label:</span> Course Completion
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Trigger:</span> Pass course assessment
</div>
<div className="flex items-center gap-1.5 pb-0.5">
<span className="font-medium text-foreground">Type:</span> Milestone achievement
</div>
<Badge className="self-start bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div>
{/* Color picker */}
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<Palette className="h-3 w-3" /> Color
</p>
<div className="flex flex-wrap gap-1.5">
{TIER_COLOR_OPTIONS.map((opt) => (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => setBadgeColor(opt.key)}
className={[
"w-5 h-5 rounded-full border-2 transition-all",
badgeColor === opt.key
? "border-foreground scale-110 shadow-sm"
: "border-transparent hover:border-muted-foreground/50",
].join(" ")}
style={{ backgroundColor: opt.swatch }}
<div className="space-y-2">
{objectiveFields.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(`objectives.${index}.text`)}
/>
))}
</div>
</div>
{/* Image picker */}
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<ImagePlus className="h-3 w-3" /> Image <span className="normal-case font-normal">(optional)</span>
</p>
<div className="flex items-center gap-2">
{badgeImageUrl && (
<div className="w-8 h-8 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
<img src={badgeImageUrl} alt="" className="w-6 h-6 object-contain" />
</div>
)}
<FieldError message={errors.objectives?.[index]?.text?.message} />
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAssetPickerOpen(true)}
className="h-7 text-xs"
variant="ghost"
size="icon"
className="mt-0.5 text-muted-foreground hover:text-destructive"
onClick={() => removeObjective(index)}
>
<ImagePlus className="h-3 w-3 mr-1" />
{badgeImageUrl ? "Change" : "Pick from assets"}
<Trash2 className="h-4 w-4" />
</Button>
{badgeImageUrl && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => { setBadgeImageUrl(null); setBadgeAssetId(null); }}
>
<X className="h-3 w-3 mr-1" /> Remove
</Button>
)}
</div>
</div>
</div>
</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}/3 selected</span>
</div>
{/* Selected badges */}
{currentAchKeys.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{currentAchKeys.map((key) => {
const ach = ACHIEVEMENT_REGISTRY.find((a) => a.key === key);
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>
)}
{/* Popover picker */}
<Popover open={achOpen} onOpenChange={setAchOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-between"
disabled={currentAchKeys.length >= 3}
className="w-full mt-1"
onClick={() => appendObjective({ text: "" })}
>
<span className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" />
{currentAchKeys.length > 0
? `${currentAchKeys.length} selected — add more`
: "Select achievements"}
</span>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
<Plus className="h-4 w-4 mr-2" />
Add Objective
</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">
{ACHIEVEMENT_REGISTRY.map((ach) => {
const checked = currentAchKeys.includes(ach.key);
const disabled = !checked && currentAchKeys.length >= 3;
return (
<CommandItem
key={ach.key}
value={ach.label}
disabled={disabled}
onSelect={() => !disabled && toggleAchievement(ach.key)}
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>
</div>
</SectionCard>
</>
)}
{/* ── Actions ── */}
<div className="flex justify-end gap-3 pt-1">
{/* ── Step 1: Rewards ── */}
{currentStep === 1 && (
<SectionCard
title="Rewards"
description="Badge and achievements awarded to learners who complete this course."
>
{/* Completion Badge */}
<div>
<p className="text-xs font-medium mb-3 text-muted-foreground uppercase tracking-wide">Completion Badge</p>
<div className="flex items-start gap-4">
<CourseBadge
title={watchedTitle || "Course Title"}
level={watchedLevel}
color={badgeColor}
imageUrl={badgeImageUrl}
/>
<div className="flex-1 flex flex-col gap-3">
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Label:</span> Course Completion
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Trigger:</span> Pass course assessment
</div>
<div className="flex items-center gap-1.5 pb-0.5">
<span className="font-medium text-foreground">Type:</span> Milestone achievement
</div>
<Badge className="self-start bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div>
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<Palette className="h-3 w-3" /> Color
</p>
<div className="flex flex-wrap gap-1.5">
{TIER_COLOR_OPTIONS.map((opt) => (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => setBadgeColor(opt.key)}
className={[
"w-5 h-5 rounded-full border-2 transition-all",
badgeColor === opt.key
? "border-foreground scale-110 shadow-sm"
: "border-transparent hover:border-muted-foreground/50",
].join(" ")}
style={{ backgroundColor: opt.swatch }}
/>
))}
</div>
</div>
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<ImagePlus className="h-3 w-3" /> Image <span className="normal-case font-normal">(optional)</span>
</p>
<div className="flex items-center gap-2">
{badgeImageUrl && (
<div className="w-8 h-8 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
<img src={badgeImageUrl} alt="" className="w-6 h-6 object-contain" />
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAssetPickerOpen(true)}
className="h-7 text-xs"
>
<ImagePlus className="h-3 w-3 mr-1" />
{badgeImageUrl ? "Change" : "Pick from assets"}
</Button>
{badgeImageUrl && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => { setBadgeImageUrl(null); setBadgeAssetId(null); }}
>
<X className="h-3 w-3 mr-1" /> Remove
</Button>
)}
</div>
</div>
</div>
</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>
</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);
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>
)}
<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>
)}
{/* Asset picker (always mounted) */}
<AssetPickerSheet
open={assetPickerOpen}
onOpenChange={setAssetPickerOpen}
fileType="image"
onSelect={(asset, resolvedUrl) => {
setBadgeImageUrl(resolvedUrl ?? null);
setBadgeAssetId(asset.asset_id);
}}
/>
{/* ── Step navigation ── */}
<div className="flex items-center justify-between pt-2 pb-6">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
disabled={loading}
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Course
<ArrowLeft className="h-4 w-4 mr-2" />
{currentStep === 0 ? "Cancel" : "Back"}
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
Next
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
) : (
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Course
</Button>
)}
</div>
</form>
</div>
</div>
{/* Asset picker for badge image */}
<AssetPickerSheet
open={assetPickerOpen}
onOpenChange={setAssetPickerOpen}
fileType="image"
onSelect={(asset, resolvedUrl) => {
setBadgeImageUrl(resolvedUrl ?? null);
setBadgeAssetId(asset.asset_id);
}}
/>
</section>
</div>
);
}