added new requirements and fix UI bugs

This commit is contained in:
rgrgogu
2026-07-15 16:26:59 +08:00
parent 95987edd22
commit 5a9c0390e6
27 changed files with 1423 additions and 232 deletions
@@ -6,11 +6,13 @@ import { z } from "zod";
import { nanoid } from "nanoid";
import {
ArrowLeft, ChevronLeft, ChevronRight, Check,
FileText, BookOpen, LayoutTemplate, ClipboardCheck,
FileText, BookOpen, LayoutTemplate, ClipboardCheck, ListChecks,
Plus, Trash2,
} from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import { useCourses } from "@/contexts/AdminCoursesContext";
import DraftRequirementsEditor, { DraftRequirementsSummary } from "@/modules/admin/components/courses/DraftRequirementsEditor";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util";
@@ -46,6 +48,7 @@ const lessonSchema = z.object({
z.object({ value: z.string().min(1, "Objective cannot be empty.") })
).optional(),
blocks: z.array(z.any()).optional(),
requirements: z.array(z.any()).optional(),
});
const schema = z.object({
@@ -66,7 +69,8 @@ const STEPS = [
{ id: 0, label: "Create Unit", icon: FileText },
{ id: 1, label: "Lessons", icon: BookOpen },
{ id: 2, label: "Page Builder", icon: LayoutTemplate },
{ id: 3, label: "Review", icon: ClipboardCheck },
{ id: 3, label: "Requirements", icon: ListChecks },
{ id: 4, label: "Review", icon: ClipboardCheck },
];
// Fields validated with trigger() before advancing past each step.
@@ -194,7 +198,7 @@ function StepLessons({ control, register, errors }) {
type="button"
variant="outline"
className="w-full"
onClick={() => append({ title: "", description: "", objectives: [], blocks: [] })}
onClick={() => append({ title: "", description: "", objectives: [], blocks: [], requirements: [] })}
>
<Plus className="h-4 w-4 mr-1" /> Add Lesson
</Button>
@@ -239,6 +243,9 @@ function StepPageBuilder({ control, setValue }) {
return next;
});
const requirements = activeLesson?.requirements ?? [];
const setRequirements = (next) => setValue(`lessons.${activeIndex}.requirements`, next, { shouldDirty: true });
return (
<div className="space-y-3">
{lessons.map((l, i) => (
@@ -247,6 +254,7 @@ function StepPageBuilder({ control, setValue }) {
<p className="text-sm font-medium">{l.title || `Lesson ${i + 1}`}</p>
<p className="text-xs text-muted-foreground">
{(l.blocks?.length ?? 0)} block{(l.blocks?.length ?? 0) !== 1 ? "s" : ""}
{(l.requirements?.length ?? 0) > 0 && ` · ${l.requirements.length} requirement${l.requirements.length !== 1 ? "s" : ""}`}
</p>
</div>
<Button
@@ -313,6 +321,21 @@ function StepPageBuilder({ control, setValue }) {
</PreviewChrome>
</div>
</div>
<div className="mt-6 pt-4 border-t space-y-3">
<div>
<p className="text-sm font-medium">Completion Requirements — {activeLesson?.title || `Lesson ${activeIndex + 1}`}</p>
<p className="text-xs text-muted-foreground">
What this specific lesson's blocks unlock — e.g. a video block above makes "Finish Watching the Full Video" available.
</p>
</div>
<DraftRequirementsEditor
entityType="lesson"
items={requirements}
onChange={setRequirements}
blockTypes={blocks.map((b) => b.type)}
/>
</div>
</div>
<DrawerFooter className="border-t flex-row justify-end">
@@ -337,7 +360,7 @@ function SummaryRow({ label, value }) {
);
}
function StepReview({ data, tierCategories }) {
function StepReview({ data, tierCategories, requirements }) {
const tierName = tierCategories.find((c) => c.slug === data.subscription)?.name;
return (
@@ -364,12 +387,26 @@ function StepReview({ data, tierCategories }) {
<div key={i} className="flex items-center justify-between text-sm border-t border-border pt-2 first:border-t-0 first:pt-0">
<span>{i + 1}. {l.title}</span>
<span className="text-muted-foreground text-xs">
{(l.objectives ?? []).filter((o) => o.value).length} objective(s) · {(l.blocks ?? []).length} block(s)
{(l.objectives ?? []).filter((o) => o.value).length} objective(s) · {(l.blocks ?? []).length} block(s) · {(l.requirements ?? []).length} requirement(s)
</span>
</div>
))}
</div>
)}
<DraftRequirementsSummary entityType="unit" items={requirements} />
</div>
);
}
// ─── Step 5 — Requirements ──────────────────────────────────────────────────────
function StepRequirements({ requirements, setRequirements }) {
return (
<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>
);
}
@@ -380,7 +417,10 @@ export default function AddLibraryUnit() {
const { createUnitFull, loading } = useLibrary();
const { user } = useAuth();
const { syncUnitRequirements } = useCourses();
const [step, setStep] = useState(0);
const [requirements, setRequirements] = useState([]);
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
@@ -398,7 +438,7 @@ export default function AddLibraryUnit() {
mode: "onTouched",
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || requirements.length > 0);
const handleNext = async () => {
const fields = STEP_FIELDS[step];
@@ -411,8 +451,9 @@ export default function AddLibraryUnit() {
else setStep((s) => s - 1);
};
// Called manually on click — no <form> tag, so no accidental submit from
// Enter or another button while stepping through the wizard.
// The one and only persistence point — nothing is created until this final
// click at Review, so Requirements (the step before it) is purely a draft
// form. No <form> tag wraps the wizard, so this is invoked manually.
const handleCreate = async () => {
const valid = await trigger();
if (!valid) return;
@@ -427,6 +468,7 @@ export default function AddLibraryUnit() {
description: l.description || null,
objectives: (l.objectives ?? []).map((o) => o.value).filter(Boolean),
blocks: l.blocks ?? [],
requirements: (l.requirements ?? []).map(({ _key, ...r }) => r),
})),
createdBy: user?.user_id,
};
@@ -434,7 +476,14 @@ export default function AddLibraryUnit() {
// Single request creates the unit, its lessons, objectives, and page
// content in one transaction — no per-lesson/per-page follow-up calls.
const result = await createUnitFull(payload);
if (!result) return;
const newUnitId = result?.data?.data?.unit_id;
if (!newUnitId) return;
if (requirements.length > 0) {
const clean = requirements.map(({ _key, ...r }) => r);
await syncUnitRequirements(null, newUnitId, clean);
}
bypassOnce();
navigate("/admin/units");
};
@@ -514,7 +563,10 @@ export default function AddLibraryUnit() {
<StepPageBuilder control={control} setValue={setValue} />
)}
{step === 3 && (
<StepReview data={getValues()} tierCategories={tierCategories} />
<StepRequirements requirements={requirements} setRequirements={setRequirements} />
)}
{step === 4 && (
<StepReview data={getValues()} tierCategories={tierCategories} requirements={requirements} />
)}
</div>
@@ -531,6 +583,9 @@ export default function AddLibraryUnit() {
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
// Only the true final step (Review) actually persists anything —
// the unit, its lessons/page content, and any draft requirements
// are all created together in one shot here.
<Button type="button" onClick={handleCreate} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Unit