mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
278 lines
12 KiB
React
278 lines
12 KiB
React
import { useEffect, useState } from "react";
|
||
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||
import { useForm, useWatch } from "react-hook-form";
|
||
import { z } from "zod";
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { ChevronLeft, ChevronRight, Check, FileText, ListChecks, Link2, Plus } from "lucide-react";
|
||
|
||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||
import { useAuth } from "@/contexts/AuthContext";
|
||
import { PageMeta } from "@/contexts/MetadataContext";
|
||
import { cn } from "@/lib/utils";
|
||
import api from "@/utils/api.util";
|
||
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 {
|
||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||
import DraftRequirementsEditor from "@/modules/admin/components/courses/DraftRequirementsEditor";
|
||
import AttachUnitsDialog from "@/modules/admin/components/library/AttachUnitsDialog";
|
||
|
||
const schema = z.object({
|
||
title: z.string().min(1, "Title is required."),
|
||
description: z.string().optional(),
|
||
order: z.coerce.number().min(0).default(0),
|
||
subscription: z.string().optional(),
|
||
});
|
||
|
||
const STEPS = [
|
||
{ id: 0, label: "Details", icon: FileText },
|
||
{ id: 1, label: "Completion Requirements", icon: ListChecks },
|
||
];
|
||
|
||
function FieldError({ message }) {
|
||
if (!message) return null;
|
||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||
}
|
||
|
||
export default function AddUnit() {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const { courseId } = useParams();
|
||
const { createUnit, fetchCourse, course, loading, syncUnitRequirements } = useCourses();
|
||
const { user } = useAuth();
|
||
|
||
const backTarget = location.state?.returnTo ?? `/admin/courses/${courseId}/units`;
|
||
|
||
const [mode, setMode] = useState(null); // null | "create" — the Details/Requirements form only shows once "Create" is chosen
|
||
const [step, setStep] = useState(0);
|
||
const [requirements, setRequirements] = useState([]);
|
||
const [attachOpen, setAttachOpen] = useState(false);
|
||
|
||
const [tierCategories, setTierCategories] = useState([]);
|
||
useEffect(() => {
|
||
api.get("/admin/tiers/categories")
|
||
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
const { register, trigger, getValues, control, setValue, formState: { errors, isDirty } } = useForm({
|
||
resolver: zodResolver(schema),
|
||
defaultValues: { title: "", description: "", order: 0, subscription: "free" },
|
||
});
|
||
|
||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||
const defaultTierSlug = tierCategories.find((c) => c.is_default)?.slug || "free";
|
||
|
||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || requirements.length > 0);
|
||
|
||
useEffect(() => {
|
||
fetchCourse(courseId);
|
||
}, [courseId]);
|
||
|
||
const handleNext = async () => {
|
||
const valid = await trigger();
|
||
if (valid) setStep(1);
|
||
};
|
||
|
||
// The one and only persistence point — nothing is created until this final
|
||
// click at Requirements (the last step), which is purely a draft form
|
||
// until now.
|
||
const handleCreate = async () => {
|
||
const values = getValues();
|
||
const result = await createUnit(courseId, { ...values, subscription: values.subscription || defaultTierSlug, createdBy: user?.user_id });
|
||
const newUnitId = result?.data?.data?.unit_id;
|
||
if (!newUnitId) return;
|
||
|
||
if (requirements.length > 0) {
|
||
const clean = requirements.map(({ _key, ...r }) => r);
|
||
await syncUnitRequirements(courseId, newUnitId, clean);
|
||
}
|
||
|
||
bypassOnce();
|
||
navigate(backTarget);
|
||
};
|
||
|
||
return (
|
||
<section className="bg-muted h-full">
|
||
<PageMeta title={course ? `Add Unit – ${course.title} - STARR` : undefined} />
|
||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||
|
||
<div className="w-full max-w-2xl mx-auto space-y-6">
|
||
<div className="flex items-center gap-3">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => (mode === "create" && step > 0 ? setStep(0) : navigate(backTarget))}
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
<div>
|
||
<h1 className="text-xl font-semibold">Create Unit</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
Creates a new unit in the shared Units Library and attaches it to this course.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Select existing vs. create new */}
|
||
<div className="flex items-start justify-between gap-3 pb-3 border-b border-border">
|
||
<p className="text-xs text-muted-foreground">
|
||
Attach an existing library unit to reuse its content, or create a new one from scratch.
|
||
</p>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<Button type="button" variant="outline" size="sm" onClick={() => setAttachOpen(true)}>
|
||
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Select
|
||
</Button>
|
||
<Button type="button" variant="outline" size="sm" onClick={() => setMode("create")}>
|
||
<Plus className="h-3.5 w-3.5 mr-1.5" /> Create
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{mode === "create" && (
|
||
<>
|
||
{/* Stepper */}
|
||
<div className="flex items-center gap-0">
|
||
{STEPS.map((s, i) => {
|
||
const Icon = s.icon;
|
||
const isActive = step === i;
|
||
const isDone = step > i;
|
||
|
||
return (
|
||
<div key={s.id} className="flex items-center flex-1 last:flex-none">
|
||
<div className="flex flex-col items-center gap-1">
|
||
<div className={cn(
|
||
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors",
|
||
isDone && "bg-emerald-600 border-emerald-600 text-white",
|
||
isActive && "border-primary bg-primary text-primary-foreground",
|
||
!isActive && !isDone && "border-border bg-background text-muted-foreground"
|
||
)}>
|
||
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
|
||
</div>
|
||
<span className={cn(
|
||
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
|
||
isActive ? "text-foreground" : "text-muted-foreground",
|
||
isDone ? "text-emerald-600" : ""
|
||
)}>
|
||
{s.label}
|
||
</span>
|
||
</div>
|
||
{i < STEPS.length - 1 && (
|
||
<div className={cn(
|
||
"flex-1 h-px mx-2 mb-4 transition-colors",
|
||
step > i ? "bg-emerald-600" : "bg-border"
|
||
)} />
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Step content */}
|
||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||
{step === 0 && (
|
||
<div className="space-y-5">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||
<Input id="title" placeholder="Unit title" {...register("title")} />
|
||
<FieldError message={errors.title?.message} />
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="description">Description</Label>
|
||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||
</div>
|
||
|
||
<div className="space-y-1.5 max-w-[120px]">
|
||
<Label htmlFor="order">Order</Label>
|
||
<Input id="order" type="number" min={0} {...register("order")} />
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label>Subscription</Label>
|
||
<Select
|
||
value={watchedSubscr || defaultTierSlug}
|
||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="No tier gate" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{tierCategories.map((c) => (
|
||
<SelectItem key={c.slug} value={c.slug}>
|
||
{c.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-xs text-muted-foreground">
|
||
Optional. Gates this unit directly, independent of the course it's attached to.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{step === 1 && (
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-muted-foreground">
|
||
Configure how learners complete this unit — optional, sensible defaults apply automatically. This is created together with the rest of the unit when you finish.
|
||
</p>
|
||
<DraftRequirementsEditor entityType="unit" items={requirements} onChange={setRequirements} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex justify-between gap-3">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => (step === 0 ? navigate(backTarget) : setStep(0))}
|
||
disabled={loading}
|
||
>
|
||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||
{step === 0 ? "Cancel" : "Back"}
|
||
</Button>
|
||
|
||
{step === 0 ? (
|
||
<Button type="button" onClick={handleNext}>
|
||
Next
|
||
<ChevronRight className="h-4 w-4 ml-1" />
|
||
</Button>
|
||
) : (
|
||
// Only the true final step (Requirements) actually persists
|
||
// anything — the unit and any draft requirements are created
|
||
// together in one shot here.
|
||
<Button type="button" onClick={handleCreate} disabled={loading}>
|
||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||
Create Unit
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{unsavedChangesDialog}
|
||
|
||
<AttachUnitsDialog
|
||
open={attachOpen}
|
||
onOpenChange={setAttachOpen}
|
||
attachedUnitIds={course?.units?.map((u) => u.unit_id) ?? []}
|
||
onAttach={async (unitIds) => {
|
||
await api.post(`/admin/courses/${courseId}/units/attach`, { unit_ids: unitIds });
|
||
bypassOnce();
|
||
navigate(backTarget);
|
||
}}
|
||
loading={loading}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|