mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
797 lines
34 KiB
React
797 lines
34 KiB
React
import { useNavigate } from "react-router-dom";
|
|
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, ArrowRight, Plus, Trash2, BadgeCheck,
|
|
Check, X, ImagePlus, Palette, BookOpen,
|
|
} from "lucide-react";
|
|
|
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
import api from "@/utils/api.util";
|
|
import { PageMeta } from "@/contexts/MetadataContext";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
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 {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from "@/components/ui/select";
|
|
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 CoursePrerequisiteBuilder from "@/modules/admin/components/courses/CoursePrerequisiteBuilder";
|
|
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
|
|
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
|
|
|
// ─── Schema ───────────────────────────────────────────────────────────────────
|
|
|
|
const schema = z.object({
|
|
title: z.string().min(1, "Title is required."),
|
|
description: z.string().optional(),
|
|
course_code: z.string().min(1, "Course Code is required."),
|
|
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."),
|
|
roles: z.array(z.object({ text: z.string().min(1, "Role cannot be empty.") })).default([]),
|
|
achievement_keys: z.array(z.string()).max(1).default([]),
|
|
});
|
|
|
|
// ─── Steps config ─────────────────────────────────────────────────────────────
|
|
|
|
const STEPS = [
|
|
{ label: "Basic Info", description: "Title, level & objectives" },
|
|
{ label: "Roadmap", description: "Units, lessons & prerequisites" },
|
|
{ label: "Rewards", description: "Badge & achievements" },
|
|
{ label: "Review", description: "Confirm & create" },
|
|
];
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function FieldError({ message }) {
|
|
if (!message) return null;
|
|
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
|
}
|
|
|
|
function SectionCard({ title, description, children }) {
|
|
return (
|
|
<div className="rounded-lg border bg-card p-6 space-y-5">
|
|
{(title || description) && (
|
|
<div className="space-y-0.5 pb-1 border-b">
|
|
{title && <h2 className="text-sm font-semibold">{title}</h2>}
|
|
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
|
</div>
|
|
)}
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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() {
|
|
const navigate = useNavigate();
|
|
const { createCourseFull, loading, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat } = useCourses();
|
|
const { user } = useAuth();
|
|
|
|
const [currentStep, setCurrentStep] = useState(0);
|
|
const [roadmapUnits, setRoadmapUnits] = useState([]);
|
|
const [prerequisites, setPrerequisites] = useState([]);
|
|
const [tierCategories, setTierCategories] = useState([]);
|
|
useEffect(() => {
|
|
api.get("/admin/tiers/categories")
|
|
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const [flatCourses, setFlatCourses] = useState([]);
|
|
const [flatUnits, setFlatUnits] = useState([]);
|
|
const [flatLessons, setFlatLessons] = useState([]);
|
|
useEffect(() => {
|
|
(async () => {
|
|
const [c, u, l] = await Promise.all([fetchCoursesFlat(), fetchUnitsFlat(), fetchLessonsFlat()]);
|
|
setFlatCourses(c ?? []);
|
|
setFlatUnits(u ?? []);
|
|
setFlatLessons(l ?? []);
|
|
})();
|
|
}, []);
|
|
|
|
const [badgeColor, setBadgeColor] = useState("purple");
|
|
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
|
|
const [badgeAssetId, setBadgeAssetId] = useState(null);
|
|
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
control,
|
|
setValue,
|
|
getValues,
|
|
trigger,
|
|
formState: { errors, isDirty },
|
|
} = useForm({
|
|
resolver: zodResolver(schema),
|
|
defaultValues: {
|
|
title: "",
|
|
description: "",
|
|
course_code: "",
|
|
level: "beginner",
|
|
subscription: "free",
|
|
status: "draft",
|
|
objectives: [],
|
|
roles: [],
|
|
achievement_keys: [],
|
|
},
|
|
});
|
|
|
|
const { fields: objectiveFields, append: appendObjective, remove: removeObjective, insert: insertObjective } =
|
|
useFieldArray({ control, name: "objectives" });
|
|
|
|
const handleObjectivePaste = (e, index) => {
|
|
const text = e.clipboardData.getData("text");
|
|
const lines = text.split(/\r\n|\r|\n/).map((l) => l.trim()).filter(Boolean);
|
|
if (lines.length <= 1) return;
|
|
e.preventDefault();
|
|
setValue(`objectives.${index}.text`, lines[0], { shouldDirty: true, shouldValidate: true });
|
|
lines.slice(1).forEach((line, i) => {
|
|
insertObjective(index + 1 + i, { text: line });
|
|
});
|
|
};
|
|
const { fields: roleFields, append: appendRole, remove: removeRole } =
|
|
useFieldArray({ control, name: "roles" });
|
|
|
|
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 watchedLevel = useWatch({ control, name: "level" });
|
|
const watchedSubscr = useWatch({ control, name: "subscription" });
|
|
const watchedStatus = useWatch({ control, name: "status" });
|
|
const watchedObjectives = useWatch({ control, name: "objectives" });
|
|
const watchedRoles = useWatch({ control, name: "roles" });
|
|
|
|
const [achievementRegistry, setAchievementRegistry] = useState([]);
|
|
useEffect(() => {
|
|
api.get("/admin/achievements")
|
|
.then(({ data }) => setAchievementRegistry((data.data ?? []).filter((a) => a.is_active)))
|
|
.catch(() => setAchievementRegistry([]));
|
|
}, []);
|
|
|
|
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 ||
|
|
prerequisites.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),
|
|
// and Finish fires the whole course + roadmap + rewards in one request.
|
|
const onBasicInfoSubmit = () => {
|
|
setCurrentStep((s) => s + 1);
|
|
};
|
|
|
|
// 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,
|
|
level: values.level || null,
|
|
subscription: values.subscription,
|
|
status: values.status,
|
|
objectives: values.objectives.map((o) => o.text),
|
|
roles: values.roles.map((r) => r.text),
|
|
// Drop rows where a type was picked but no item was actually selected —
|
|
// sending an empty ref_id fails at the DB level.
|
|
prerequisites: prerequisites
|
|
.filter((p) => p.ref_id !== "" && p.ref_id != null)
|
|
.map(({ ref_type, ref_id }) => ({ ref_type, ref_id })),
|
|
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,
|
|
requirements: u.requirements ?? [],
|
|
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 newCourse = result?.data?.data ?? null;
|
|
if (!newCourse) return;
|
|
bypassOnce();
|
|
navigate(`/admin/courses/${newCourse.course_id}/view`);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col min-h-screen bg-muted/60">
|
|
<PageMeta title="Add Course - STARR" description="Create a new training course." />
|
|
|
|
{/* ── 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("/admin/courses")}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
<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={goToStep} />
|
|
|
|
<form onSubmit={handleSubmit(onBasicInfoSubmit)} className="space-y-5">
|
|
|
|
{/* ── 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>
|
|
|
|
<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="space-y-1.5">
|
|
<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>
|
|
</SectionCard>
|
|
|
|
<SectionCard title="Settings">
|
|
<div className="grid grid-cols-3 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 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>
|
|
|
|
<SectionCard
|
|
title="Learning Objectives"
|
|
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">
|
|
<Input
|
|
placeholder={`Objective ${index + 1}`}
|
|
{...register(`objectives.${index}.text`)}
|
|
onPaste={(e) => handleObjectivePaste(e, index)}
|
|
/>
|
|
<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>
|
|
</div>
|
|
))}
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full mt-1"
|
|
onClick={() => appendObjective({ text: "" })}
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add Objective
|
|
</Button>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard
|
|
title="Course Roles"
|
|
description="Who is this course intended for? e.g. Sales Agent, Property Manager."
|
|
>
|
|
<div className="space-y-2">
|
|
{roleFields.map((field, index) => (
|
|
<div key={field.id} className="flex items-start gap-2">
|
|
<div className="flex-1 space-y-1">
|
|
<Input
|
|
placeholder={`Role ${index + 1}`}
|
|
{...register(`roles.${index}.text`)}
|
|
/>
|
|
<FieldError message={errors.roles?.[index]?.text?.message} />
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
|
onClick={() => removeRole(index)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full mt-1"
|
|
onClick={() => appendRole({ text: "" })}
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add Role
|
|
</Button>
|
|
</div>
|
|
</SectionCard>
|
|
</>
|
|
)}
|
|
|
|
{/* ── Step 1: Roadmap ── */}
|
|
{currentStep === 1 && (
|
|
<>
|
|
<RoadmapBuilder units={roadmapUnits} onUnitsChange={setRoadmapUnits} />
|
|
|
|
<SectionCard
|
|
title="Prerequisites"
|
|
description="What a learner must complete before starting this course."
|
|
>
|
|
<CoursePrerequisiteBuilder
|
|
value={prerequisites}
|
|
onChange={setPrerequisites}
|
|
courses={flatCourses}
|
|
units={flatUnits}
|
|
lessons={flatLessons}
|
|
/>
|
|
</SectionCard>
|
|
</>
|
|
)}
|
|
|
|
{/* ── Step 2: Rewards ── */}
|
|
{currentStep === 2 && (
|
|
<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>
|
|
|
|
<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">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>
|
|
|
|
<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>
|
|
|
|
<div className="border-t pt-4">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
|
Course Roles
|
|
</p>
|
|
{!watchedRoles?.length ? (
|
|
<p className="text-sm text-muted-foreground">None added.</p>
|
|
) : (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{watchedRoles.map((r, i) => (
|
|
<Badge key={i} variant="secondary">{r.text}</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard
|
|
title="Roadmap"
|
|
description={`${roadmapUnits.length} unit${roadmapUnits.length === 1 ? "" : "s"} · ${totalLessons} lesson${totalLessons === 1 ? "" : "s"} added · ${prerequisites.length} prerequisite${prerequisites.length === 1 ? "" : "s"}.`}
|
|
>
|
|
{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 (
|
|
<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>
|
|
)}
|
|
|
|
{prerequisites.length > 0 && (
|
|
<div className="border-t pt-3 mt-1">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
|
Prerequisites
|
|
</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{prerequisites.map((p, i) => (
|
|
<Badge key={i} variant="outline" className="capitalize gap-1">
|
|
{p.ref_type}: {p.title || `#${p.ref_id}`}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</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) */}
|
|
<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={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate("/admin/courses")}
|
|
>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
{currentStep === 0 ? "Cancel" : "Back"}
|
|
</Button>
|
|
|
|
{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="button" disabled={loading} onClick={handleFinish}>
|
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
|
Finish
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{unsavedChangesDialog}
|
|
</div>
|
|
);
|
|
}
|