Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-11 12:12:40 +08:00
parent 01a2c63b06
commit 71f758fe0b
66 changed files with 3055 additions and 939 deletions
@@ -0,0 +1,133 @@
// modules/admin/components/courses/AchievementsBuilder.jsx
// The "Achievements" sub-section of the Rewards step: pick an existing
// achievement from the registry or define a new one — same New/Attach
// pattern as RoadmapBuilder's Units section. A course carries at most one
// achievement.
import { useState } from "react";
import * as LucideIcons from "lucide-react";
import { Plus, Link2, Trophy, BadgeCheck, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import AttachAchievementDialog from "./AttachAchievementDialog";
import CreateAchievementDialog from "./CreateAchievementDialog";
export default function AchievementsBuilder({ achievementKeys, onAchievementKeysChange, registry, onRegistryChange }) {
const [attachOpen, setAttachOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
// Gate the whole New/Attach builder behind an explicit yes/no — don't
// assume every course wants an achievement. Starts open if a course being
// edited already has one selected.
const [wantsAchievement, setWantsAchievement] = useState(achievementKeys.length > 0);
const selected = registry.find((a) => a.key === achievementKeys[0]) ?? null;
const handleAttach = (key) => onAchievementKeysChange([key]);
const handleCreated = (achievement) => {
onRegistryChange([...registry, achievement]);
onAchievementKeysChange([achievement.key]);
};
const declineAchievement = () => {
onAchievementKeysChange([]);
setWantsAchievement(false);
};
if (!wantsAchievement) {
return (
<div className="border-t pt-4">
<div className="rounded-md border border-dashed px-4 py-5 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5 text-muted-foreground" />
Award an achievement for completing this course?
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Optional — learners can earn a badge or milestone for finishing this course.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button type="button" variant="outline" size="sm" onClick={declineAchievement}>
No
</Button>
<Button type="button" size="sm" onClick={() => setWantsAchievement(true)}>
Yes, add one
</Button>
</div>
</div>
</div>
);
}
return (
<div className="border-t pt-4">
<div className="flex items-start justify-between gap-3 pb-3 mb-3 border-b">
<div className="space-y-0.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Achievements</p>
<p className="text-xs text-muted-foreground">
Attach an existing achievement from the registry, or define a new one from scratch.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button type="button" variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> New Achievement
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setAttachOpen(true)}>
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach
</Button>
</div>
</div>
{!selected ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
<Trophy className="h-7 w-7 text-muted-foreground" />
<p className="text-sm font-medium">No achievement selected</p>
<Button type="button" variant="ghost" size="sm" className="text-muted-foreground" onClick={declineAchievement}>
Actually, skip achievements
</Button>
</div>
) : (
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
{(() => {
const Icon = LucideIcons[selected.icon] ?? Trophy;
return <Icon className="h-4 w-4 text-muted-foreground shrink-0" />;
})()}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{selected.label}</p>
{selected.description && (
<p className="text-xs text-muted-foreground truncate">{selected.description}</p>
)}
</div>
<Badge variant="outline" className="text-[10px] shrink-0 capitalize gap-1">
{selected.type === "badge" ? <Trophy className="h-2.5 w-2.5" /> : <BadgeCheck className="h-2.5 w-2.5" />}
{selected.type}
</Badge>
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive shrink-0 h-7 w-7"
onClick={() => onAchievementKeysChange([])}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
<AttachAchievementDialog
open={attachOpen}
onOpenChange={setAttachOpen}
registry={registry}
selectedKey={achievementKeys[0] ?? null}
onAttach={handleAttach}
/>
<CreateAchievementDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={handleCreated}
/>
</div>
);
}
@@ -0,0 +1,107 @@
// modules/admin/components/courses/AttachAchievementDialog.jsx
// Pick a single achievement from the global registry — the attach-existing
// counterpart to CreateAchievementDialog's create-new flow. A course carries
// at most one achievement, so selection behaves like a radio, not a checklist.
import { useEffect, useMemo, useState } from "react";
import { Search, Link2, Trophy, BadgeCheck } from "lucide-react";
import * as LucideIcons from "lucide-react";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
export default function AttachAchievementDialog({ open, onOpenChange, registry = [], selectedKey, onAttach }) {
const [query, setQuery] = useState("");
const [picked, setPicked] = useState(null);
useEffect(() => {
if (open) {
setQuery("");
setPicked(selectedKey ?? null);
}
}, [open, selectedKey]);
const candidates = useMemo(() => {
const q = query.trim().toLowerCase();
return registry
.filter((a) => a.is_active !== false)
.filter((a) => !q || a.label?.toLowerCase().includes(q));
}, [registry, query]);
const handleAttach = () => {
if (!picked) return;
onAttach(picked);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="h-4 w-4" /> Attach Achievement
</DialogTitle>
<DialogDescription>
Pick one achievement from the registry to award learners who complete this course.
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search achievements..."
className="pl-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<ScrollArea className="h-64 rounded-md border">
{candidates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
{query ? "No achievements match your search." : "No achievements in the registry yet."}
</p>
) : (
<RadioGroup value={picked ?? ""} onValueChange={setPicked} className="divide-y gap-0">
{candidates.map((a) => {
const Icon = LucideIcons[a.icon] ?? Trophy;
return (
<label
key={a.key}
htmlFor={`ach-${a.key}`}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/60 cursor-pointer"
>
<RadioGroupItem value={a.key} id={`ach-${a.key}`} />
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{a.label}</p>
{a.description && (
<p className="text-xs text-muted-foreground truncate">{a.description}</p>
)}
</div>
<Badge variant="outline" className="text-[10px] shrink-0 capitalize gap-1">
{a.type === "badge" ? <Trophy className="h-2.5 w-2.5" /> : <BadgeCheck className="h-2.5 w-2.5" />}
{a.type}
</Badge>
</label>
);
})}
</RadioGroup>
)}
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleAttach} disabled={!picked}>Attach</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,179 @@
// modules/admin/components/courses/CreateAchievementDialog.jsx
// Lightweight "define a brand-new achievement and select it for this course"
// dialog — the create-new counterpart to AttachAchievementDialog's
// attach-existing flow. Achievements are a global registry (not per-course
// drafts), so this posts immediately instead of deferring to wizard finish.
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { BADGE_ICON_OPTIONS } from "@/modules/admin/pages/tiers/EditTierCategory";
import api from "@/utils/api.util";
import { toast } from "sonner";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const TRIGGER_OPTIONS = [
{ value: "auth", label: "Auth (registration / login)" },
{ value: "tier", label: "Tier (subscription purchase)" },
{ value: "course", label: "Course (lessons / quizzes)" },
{ value: "profile", label: "Profile completion" },
{ value: "social", label: "Social (referrals / community)" },
{ value: "manual", label: "Manual (admin-granted only)" },
];
const emptyForm = { key: "", type: "badge", label: "", description: "", icon: null, trigger: "manual", is_active: true };
export default function CreateAchievementDialog({ open, onOpenChange, onCreated }) {
const [form, setForm] = useState(emptyForm);
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) { setForm(emptyForm); setErrors({}); }
}, [open]);
const set = (field) => (value) => setForm((prev) => ({ ...prev, [field]: value }));
const validate = () => {
const e = {};
if (!form.key.trim()) e.key = "Key is required.";
else if (!/^[a-z0-9_]+$/.test(form.key)) e.key = "Key must be lowercase letters, numbers or underscores.";
if (!form.label.trim()) e.label = "Label is required.";
setErrors(e);
return !Object.keys(e).length;
};
const handleCreate = async () => {
if (!validate()) return;
setLoading(true);
try {
const { data } = await api.post("/admin/achievements", {
key: form.key.trim(),
type: form.type,
label: form.label.trim(),
description: form.description.trim() || null,
icon: form.icon || null,
trigger: form.trigger || null,
is_active: form.is_active,
});
toast("Achievement created.");
onCreated?.(data.data);
onOpenChange(false);
} catch (err) {
toast(err?.response?.data?.message ?? "Could not create achievement.");
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New Achievement</DialogTitle>
</DialogHeader>
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
<div className="space-y-1.5">
<Label htmlFor="ach_key">Key <span className="text-destructive">*</span></Label>
<Input
id="ach_key"
value={form.key}
onChange={(e) => set("key")(e.target.value.toLowerCase())}
placeholder="e.g. course_marathon"
/>
<p className="text-xs text-muted-foreground">Lowercase, no spaces. Cannot be changed after creation.</p>
{errors.key && <p className="text-sm text-destructive">{errors.key}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="ach_label">Label <span className="text-destructive">*</span></Label>
<Input id="ach_label" value={form.label} onChange={(e) => set("label")(e.target.value)} placeholder="e.g. Course Marathon" />
{errors.label && <p className="text-sm text-destructive">{errors.label}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="ach_description">Description</Label>
<Textarea id="ach_description" rows={2} value={form.description} onChange={(e) => set("description")(e.target.value)} placeholder="What does a learner do to earn this?" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Type</Label>
<Select value={form.type} onValueChange={set("type")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="badge">Badge</SelectItem>
<SelectItem value="milestone">Milestone</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Trigger</Label>
<Select value={form.trigger} onValueChange={set("trigger")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TRIGGER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label>Icon</Label>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => set("icon")(null)}
className={`flex items-center justify-center w-8 h-8 rounded-lg border-2 text-xs text-muted-foreground transition-all ${!form.icon ? "border-foreground bg-muted scale-105" : "border-border hover:border-muted-foreground"}`}
title="No icon"
>
<X className="size-3.5" />
</button>
{BADGE_ICON_OPTIONS.map(({ name, icon: Icon }) => {
const selected = form.icon === name;
return (
<button
key={name}
type="button"
title={name}
onClick={() => set("icon")(name)}
className={`flex items-center justify-center w-8 h-8 rounded-lg border-2 transition-all ${selected ? "bg-secondary text-secondary-foreground border-foreground scale-105" : "border-border hover:border-muted-foreground"}`}
>
<Icon className="size-4" />
</button>
);
})}
</div>
</div>
<div className="flex items-center gap-3">
<Switch id="ach_is_active" checked={form.is_active} onCheckedChange={set("is_active")} />
<Label htmlFor="ach_is_active">Active</Label>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" disabled={loading}>Cancel</Button>
</DialogClose>
<Button type="button" onClick={handleCreate} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Achievement
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -130,14 +130,7 @@ export default function RoadmapBuilder({ units, onUnitsChange }) {
<div className="flex flex-col items-center justify-center gap-3 py-10 text-center">
<BookOpen className="h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium">No units yet</p>
<div className="flex items-center gap-2">
<Button type="button" size="sm" onClick={() => setCreateUnitOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> New Unit
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setAttachUnitOpen(true)}>
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach Existing
</Button>
</div>
</div>
) : (
<div className="space-y-2">
@@ -12,7 +12,6 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { formatDuration } from "@/utils/timestamp.util";
@@ -42,13 +41,17 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
.filter((u) => !q || u.title?.toLowerCase().includes(q));
}, [unitsFlat, attachedSet, query]);
const toggle = (unitId, blocked) => {
if (blocked) return;
const toggle = (unitId) => {
setSelected((prev) =>
prev.includes(unitId) ? prev.filter((id) => id !== unitId) : [...prev, unitId]
);
};
const duplicatingCount = useMemo(
() => candidates.filter((u) => selected.includes(u.unit_id) && Number(u.course_count) > 0).length,
[candidates, selected]
);
const handleAttach = async () => {
if (!selected.length) return;
await onAttach(selected);
@@ -90,19 +93,16 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
<div className="divide-y">
{candidates.map((u) => {
const blocked = Number(u.course_count) > 0;
const isSelected = selected.includes(u.unit_id);
return (
<label
key={u.unit_id}
title={blocked ? "Already attached to another course — a unit can only belong to one course at a time." : undefined}
className={[
"flex items-center gap-3 px-3 py-2.5",
blocked ? "opacity-60 cursor-not-allowed" : "hover:bg-muted/60 cursor-pointer",
].join(" ")}
title={blocked ? `Currently in "${u.course_title}" — selecting it will attach a copy to this course.` : undefined}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/60 cursor-pointer"
>
<Checkbox
checked={selected.includes(u.unit_id)}
disabled={blocked}
onCheckedChange={() => toggle(u.unit_id, blocked)}
checked={isSelected}
onCheckedChange={() => toggle(u.unit_id)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{u.title}</p>
@@ -110,13 +110,6 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
{u.lesson_count ?? 0} lesson{(u.lesson_count ?? 0) === 1 ? "" : "s"} · {formatDuration(u.duration_seconds ?? 0)}
</p>
</div>
{blocked ? (
<Badge variant="secondary" className="text-xs shrink-0 bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-700">
already in a course
</Badge>
) : (
<Badge variant="outline" className="text-xs shrink-0 text-muted-foreground">standalone</Badge>
)}
</label>
);
})}
@@ -124,13 +117,20 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
)}
</ScrollArea>
{duplicatingCount > 0 && (
<p className="text-xs text-amber-700 dark:text-amber-400">
{duplicatingCount} of the selected unit{duplicatingCount !== 1 ? "s are" : " is"} already in another course
— attaching will create {duplicatingCount !== 1 ? "copies" : "a copy"} of {duplicatingCount !== 1 ? "them" : "it"} here.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button onClick={handleAttach} disabled={loading || !selected.length}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Attach {selected.length > 0 ? `(${selected.length})` : ""}
{duplicatingCount > 0 ? "Attach & Duplicate" : "Attach"} {selected.length > 0 ? `(${selected.length})` : ""}
</Button>
</DialogFooter>
</DialogContent>
@@ -11,6 +11,10 @@ export const columnPinning = {
const cellOverrides = {};
// TODO(ads-9): Fix Sort and Columns on the Archived Advertisements table —
// sorting/column visibility currently misbehaves. Compare against a working
// DataTable usage elsewhere in admin/config to see what's diverging (likely
// an attributes/sort-key mismatch coming out of the paginate() response).
/**
* Builds the full column array for the Archived Advertisements table.
*
@@ -13,6 +13,12 @@ export const columnPinning = {
left: [],
};
const COURSE_STATUS_BADGE = {
published: "default",
draft: "secondary",
standalone: "outline",
};
const cellOverrides = {
duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10);
@@ -25,22 +31,19 @@ const cellOverrides = {
</div>
);
},
unit_count: (info) => {
course_count: (info) => {
const n = parseInt(info.getValue() ?? 0, 10);
return n > 0 ? (
return (
<Badge variant="secondary" className="text-xs tabular-nums">
in {n} unit{n === 1 ? "" : "s"}
{n} course{n === 1 ? "" : "s"}
</Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">standalone</Badge>
);
},
course_bound: (info) =>
info.getValue() ? (
<Badge variant="default" className="text-xs">In a course</Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">Not in a course</Badge>
),
course_status: (info) => (
<Badge variant={COURSE_STATUS_BADGE[info.getValue()] ?? "outline"} className="text-xs capitalize">
{info.getValue()}
</Badge>
),
};
export function buildDataColumns(attributes, rowActions) {
@@ -14,12 +14,12 @@ export function buildRowActions({ onView, onEdit, onBuildPage, onViewPage, onArc
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row),
},
{
key: "edit",
label: "Edit Lesson",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => onEdit(row),
},
// {
// key: "edit",
// label: "Edit Lesson",
// icon: <Pencil className="h-3.5 w-3.5" />,
// onClick: (row) => onEdit(row),
// },
{
key: "build_page",
label: "Page Builder",
@@ -13,6 +13,12 @@ export const columnPinning = {
left: [],
};
const COURSE_STATUS_BADGE = {
published: "default",
draft: "secondary",
standalone: "outline",
};
const cellOverrides = {
duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10);
@@ -32,14 +38,17 @@ const cellOverrides = {
),
course_count: (info) => {
const n = parseInt(info.getValue() ?? 0, 10);
return n > 0 ? (
return (
<Badge variant="secondary" className="text-xs tabular-nums">
in {n} course{n === 1 ? "" : "s"}
{n} course{n === 1 ? "" : "s"}
</Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">standalone</Badge>
);
},
course_status: (info) => (
<Badge variant={COURSE_STATUS_BADGE[info.getValue()] ?? "outline"} className="text-xs capitalize">
{info.getValue()}
</Badge>
),
};
export function buildDataColumns(attributes, rowActions) {
+3 -1
View File
@@ -22,6 +22,7 @@ import { cn } from "@/lib/utils"
import UserMenu from "@/components/generic/UserMenu"
import NotificationBell from "@/components/generic/NotificationBell"
import AdminStickyAnnouncementBar from "@/components/generic/AdminStickyAnnouncementBar"
import { ROLE_CONFIG } from "@/data/profile.data"
const AdminLayout = () => {
@@ -43,11 +44,12 @@ const AdminLayout = () => {
}, [])
return (
<section id="philproperties-admin" className="min-h-screen flex flex-col">
<section id="philproperties-admin" data-vaul-drawer-wrapper className="min-h-screen flex flex-col">
<TooltipProvider>
{/* AdminProvider wraps header + body so UserMenu can access ProfileProvider */}
<AdminProvider>
<div ref={headerRef} className={cn('fixed top-0 z-50 w-full bg-background border-b')} >
<AdminStickyAnnouncementBar />
<div className="w-full flex items-center justify-between xs:px-4 lg:px-5 py-3">
<div className="flex gap-4 items-center">
<div className="w-40 cursor-pointer" onClick={() => navigate("/")}>
@@ -12,6 +12,7 @@ import {
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { resolveAssetSrc } from "@/utils/media.util";
import { cn } from "@/lib/utils";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -63,6 +64,21 @@ const schema = z.object({
}
});
// TODO(ads-6): Rework this wizard to match the target spec:
// Step 1 Placement — choose ONLY Dashboard, Tier Plans, or Course Details
// (depends on ads-1 registry re-categorization).
// Step 2 Content — pick "full image" vs "content + image":
// full image -> image only
// content+img -> badge label, headline, description,
// CTAs, redirect link
// Step 3 Page Builder — only shown when no redirect link was provided;
// builds an internal landing page (title, description,
// body, links, etc.) — new step, doesn't exist yet.
// Step 4 Scheduling & Display — start date, end date, order, and an
// active/draft switch labeled "Draft" when off
// (currently has start/end/order but check the
// on/off switch's Draft/Inactive labeling matches).
// Step 5 Review — display all details.
// ─── Steps ────────────────────────────────────────────────────────────────────
// richOnly steps are skipped entirely for placements whose format isn't a
// RICH_CONTENT_TYPES format (banner/popup/sidebar never had CTAs).
@@ -457,7 +473,7 @@ export default function AddAdvertisement() {
getValues,
watch,
setValue,
formState: { errors },
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
@@ -477,6 +493,12 @@ export default function AddAdvertisement() {
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
// selectedPage/selectedAsset live outside the form and their setValue()
// calls don't pass shouldDirty, so isDirty alone would miss them.
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(
isDirty || !!selectedAsset || !!selectedPage
);
const placement = watch("placement");
const description = watch("description");
const format = PLACEMENT_MAP[placement]?.format;
@@ -522,7 +544,7 @@ export default function AddAdvertisement() {
};
const res = await createAdvertisement(payload);
if (res) navigate("/admin/advertisements");
if (res) { bypassOnce(); navigate("/admin/advertisements"); }
});
return (
@@ -626,6 +648,8 @@ export default function AddAdvertisement() {
setValue("image_asset_id", asset.asset_id, { shouldValidate: true });
}}
/>
{unsavedChangesDialog}
</section>
);
}
@@ -27,8 +27,18 @@ export default function AdvertisementList() {
const [typeFilter, setTypeFilter] = useState("all");
const [placementFilter, setPlacementFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
// TODO(ads-3): Filters are not actually filtering — the "status" filter in
// particular compares against the stored `status` column, but status is only
// recomputed on read (see deriveStatus() in
// controllers/admin/advertisements.controller.js) and never persisted back
// to the DB. An ad that lapsed to "expired" still has status="active" in
// the row, so filtering by status here misses/matches the wrong rows.
// Needs either persisting the derived status on write/read, or filtering
// server-side using the same derivation logic. Also verify type/placement
// filters actually round-trip once ads-1/ads-2 land.
useEffect(() => {
const filters = [];
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
@@ -103,6 +113,7 @@ export default function AdvertisementList() {
</SelectContent>
</Select>
{/* TODO(ads-2): Remove this "All placements" dropdown entirely. */}
<Select value={placementFilter} onValueChange={setPlacementFilter}>
<SelectTrigger className="w-[220px] bg-background">
<SelectValue placeholder="All placements" />
@@ -127,17 +138,31 @@ export default function AdvertisementList() {
</SelectContent>
</Select>
<div className="relative flex-1 min-w-[160px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search advertisements..."
className="pl-8 bg-background"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{/* TODO(ads-5): Verify this already satisfies the spec — search only
fires on button click / Enter (`search` state, not `searchInput`,
drives the fetch effect above), typing alone does not refetch.
Looks done already; double-check then mark complete. */}
<div className="flex items-center gap-2 flex-1 min-w-[160px]">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search advertisements..."
className="pl-8 bg-background"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") setSearch(searchInput); }}
/>
</div>
<Button variant="outline" size="icon" className="shrink-0 bg-background" onClick={() => setSearch(searchInput)} aria-label="Search">
<Search className="size-4" />
</Button>
</div>
</div>
{/* TODO(ads-8): Add pagination controls for this grid — currently
always fetches page 1 / limit 24 with no way to reach further
pages (see `pagination` from useAdvertisements, already returned
by the API but unused here). */}
{/* ── Grid ───────────────────────────────────────────────────── */}
{loading ? (
<div className="flex items-center justify-center py-20">
@@ -9,6 +9,7 @@ import { House, Plus, Trash2, ImagePlus } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { resolveAssetSrc } from "@/utils/media.util";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
@@ -144,6 +145,8 @@ export default function EditAdvertisement() {
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const placement = watch("placement");
const description = watch("description");
const format = PLACEMENT_MAP[placement]?.format;
@@ -197,7 +200,7 @@ export default function EditAdvertisement() {
}, [advertisementId]);
const onSubmit = async (values) => {
if (!isDirty) return navigate(-1);
if (!isDirty) { bypassOnce(); return navigate(-1); }
const payload = {
...values,
@@ -209,7 +212,7 @@ export default function EditAdvertisement() {
};
const res = await updateAdvertisement(advertisementId, payload);
if (res) navigate("/admin/advertisements");
if (res) { bypassOnce(); navigate("/admin/advertisements"); }
};
if (!ready) {
@@ -466,6 +469,8 @@ export default function EditAdvertisement() {
setValue("image_asset_id", asset.asset_id, { shouldValidate: true, shouldDirty: true });
}}
/>
{unsavedChangesDialog}
</section>
);
}
+9 -2
View File
@@ -9,6 +9,7 @@ import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -130,7 +131,7 @@ export default function AddAsset() {
watch,
setError,
clearErrors,
formState: { errors },
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
@@ -145,6 +146,10 @@ export default function AddAsset() {
const isVideo = file?.type?.startsWith("video/");
const isAudio = file?.type?.startsWith("audio/");
// setValue("_file", ...) doesn't mark isDirty (no shouldDirty), so a
// picked-but-unsubmitted file wouldn't otherwise be caught by the guard.
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || !!file);
// ── Auto-derive file_type from MIME ───────────────────────────────────────
const fileType = file ? resolveFileType(file.type) : null;
@@ -188,7 +193,7 @@ export default function AddAsset() {
createdBy: user?.user_id,
});
if (result) navigate(-1);
if (result) { bypassOnce(); navigate(-1); }
};
return (
@@ -357,6 +362,8 @@ export default function AddAsset() {
</Button>
</div>
</form>
{unsavedChangesDialog}
</div>
);
}
@@ -9,6 +9,7 @@ import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, RefreshCw } from
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -177,6 +178,8 @@ export default function EditAsset() {
const isVideo = asset?.file_type === "video";
const hasThumbnailChange = !!thumbnailRef.current;
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || hasThumbnailChange);
const onSubmit = async (data) => {
const result = await updateAsset(
assetId,
@@ -191,6 +194,7 @@ export default function EditAsset() {
);
if (!result) return;
bypassOnce();
navigate(-1);
};
@@ -340,6 +344,8 @@ export default function EditAsset() {
</div>
</form>
{unsavedChangesDialog}
</div>
);
}
@@ -11,6 +11,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Spinner } from "@/components/ui/spinner";
import { useCategories } from "@/contexts/AdminCategoriesContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({
name: z.string().min(1, "Name is required."),
@@ -36,14 +37,17 @@ export default function AddCategory() {
const navigate = useNavigate();
const { createCategory, loading } = useCategories();
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({
const { register, handleSubmit, watch, setValue, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
defaultValues: { name: "", description: "", is_active: true },
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const onSubmit = async (values) => {
const result = await createCategory(values);
if (!result) return;
bypassOnce();
navigate("/admin/courses/categories");
};
@@ -106,6 +110,8 @@ export default function AddCategory() {
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
@@ -12,6 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Spinner } from "@/components/ui/spinner";
import { useCategories } from "@/contexts/AdminCategoriesContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({
name: z.string().min(1, "Name is required."),
@@ -51,10 +52,13 @@ export default function EditCategory() {
})();
}, [id]);
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const onSubmit = async (values) => {
if (!isDirty) return navigate(-1);
if (!isDirty) { bypassOnce(); return navigate(-1); }
const result = await updateCategory(id, values);
if (!result) return;
bypassOnce();
navigate("/admin/courses/categories");
};
@@ -117,6 +121,8 @@ export default function EditCategory() {
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
+157 -108
View File
@@ -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>
);
}
@@ -18,7 +18,7 @@ export default function CourseList() {
<AppBreadcrumb items={items} />
</div>
<div className="w-full space-y-4">
<div className="flex items-start gap-3 rounded-lg border bg-card p-4 text-sm">
{/* <div className="flex items-start gap-3 rounded-lg border bg-card p-4 text-sm">
<Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground">
Courses are organized under <span className="font-medium text-foreground">Tier Plans</span> — each course's{" "}
@@ -36,7 +36,7 @@ export default function CourseList() {
</Link>
, then attach them to any course.
</p>
</div>
</div> */}
<CoursesTable />
</div>
</div>
+41 -2
View File
@@ -17,6 +17,7 @@ import CourseInstructorPicker from "@/modules/admin/components/courses/CourseIns
import { useCategories } from "@/contexts/AdminCategoriesContext";
import { useAuth } from "@/contexts/AuthContext";
import api from "@/utils/api.util";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -45,6 +46,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.") }))
.default([]),
@@ -202,16 +204,31 @@ export default function EditCourse() {
resolver: zodResolver(schema),
defaultValues: {
title: "", description: "", course_code: "",
order_index: 0, level: undefined, subscription: "free", objectives: [],
order_index: 0, level: undefined, subscription: "free", status: "draft", objectives: [],
},
});
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
useFieldArray({ control, name: "objectives" });
// Each section (Categories, Instructors, Badge, Achievements, Product) tracks
// its own dirty flag already (see handleDone below) — fold them in here too
// so leaving the wizard early (Back/Cancel/browser back) is gated the same
// way "Done" already flushes them.
const hasUnsavedChanges =
isDirty ||
categoriesDirty ||
instructorsDirty ||
badgeDirty ||
achievementsDirty ||
productDirty;
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(hasUnsavedChanges);
const watchedTitle = useWatch({ control, name: "title" });
const watchedLevel = useWatch({ control, name: "level" });
const watchedSubscription = useWatch({ control, name: "subscription" });
const watchedStatus = useWatch({ control, name: "status" });
// ─── Load data ────────────────────────────────────────────────────────────
useEffect(() => {
@@ -227,6 +244,7 @@ export default function EditCourse() {
order_index: c.order_index ?? 0,
level: c.level ?? undefined,
subscription: c.subscription ?? "free",
status: c.status ?? "draft",
objectives: (c.objectives ?? []).map((o) => ({
objective_id: o.objective_id ?? null,
text: o.text ?? "",
@@ -442,6 +460,7 @@ export default function EditCourse() {
if (achievementsDirty) await handleSaveAchievements();
if (productDirty && productForm.price) await handleSaveProduct();
bypassOnce();
navigate(`/admin/courses/${courseId}/view`);
} finally {
setDoneLoading(false);
@@ -535,7 +554,7 @@ export default function EditCourse() {
</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
@@ -573,6 +592,24 @@ export default function EditCourse() {
</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>
@@ -1121,6 +1158,8 @@ export default function EditCourse() {
</form>
</div>
</div>
{unsavedChangesDialog}
</div>
);
}
@@ -13,6 +13,7 @@ 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 { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({
title: z.string().min(1, "Title is required."),
@@ -34,13 +35,15 @@ export default function AddLesson() {
const { createLesson, fetchUnit, course, unit, loading } = useCourses();
const { user } = useAuth();
const { register, handleSubmit, control, formState: { errors } } = useForm({
const { register, handleSubmit, control, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "", order: 0, objectives: [] },
});
const { fields, append, remove } = useFieldArray({ control, name: "objectives" });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
useEffect(() => {
fetchUnit(courseId, unitId);
}, [courseId, unitId]);
@@ -53,6 +56,7 @@ export default function AddLesson() {
};
const result = await createLesson(courseId, unitId, payload);
if (!result) return;
bypassOnce();
navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`);
};
@@ -153,6 +157,8 @@ export default function AddLesson() {
</form>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
@@ -14,6 +14,7 @@ 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 { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({
title: z.string().min(1, "Title is required."),
@@ -43,6 +44,8 @@ export default function EditLesson() {
const { fields, append, remove } = useFieldArray({ control, name: "objectives" });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
useEffect(() => {
(async () => {
const res = await fetchLesson(courseId, unitId, lessonId);
@@ -59,7 +62,7 @@ export default function EditLesson() {
}, [courseId, unitId, lessonId]);
const onSubmit = async (data) => {
if (!isDirty) return navigate(-1);
if (!isDirty) { bypassOnce(); return navigate(-1); }
const result = await updateLesson(courseId, unitId, lessonId, {
...data,
objectives: data.objectives?.map((o, i) => ({
@@ -70,6 +73,7 @@ export default function EditLesson() {
updatedBy: user?.user_id,
});
if (!result) return;
bypassOnce();
navigate(-1);
};
@@ -170,6 +174,8 @@ export default function EditLesson() {
</form>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
@@ -34,6 +34,7 @@ export default function LessonPageBuilder() {
const [previewOpen, setPreviewOpen] = useState(false);
const headerRef = useRef(null);
const editorPaneRef = useRef(null);
const blocksSeeded = useRef(false);
useEffect(() => {
@@ -69,6 +70,21 @@ export default function LessonPageBuilder() {
return () => window.removeEventListener("resize", update);
}, []);
useEffect(() => {
const el = editorPaneRef.current;
if (!el) return;
const applyHeight = () => {
if (window.innerWidth >= 1024 && previewVisible) {
el.style.height = `calc(100vh - var(--navbar-h) - var(--builder-h, 0px))`;
} else {
el.style.height = "auto";
}
};
applyHeight();
window.addEventListener("resize", applyHeight);
return () => window.removeEventListener("resize", applyHeight);
}, [previewVisible]);
const addBlock = (type) => setBlocks((prev) => [...prev, makeBlock(type)]);
const updateBlock = (id, content) => setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, content } : b));
const deleteBlock = (id) => setBlocks((prev) => prev.filter((b) => b.id !== id));
@@ -164,18 +180,7 @@ export default function LessonPageBuilder() {
"flex flex-col gap-3 p-4 pb-10",
previewVisible && "lg:overflow-y-auto"
)}
ref={(el) => {
if (!el) return;
const applyHeight = () => {
if (window.innerWidth >= 1024 && previewVisible) {
el.style.height = `calc(100vh - var(--navbar-h) - var(--builder-h, 0px))`;
} else {
el.style.height = "auto";
}
};
applyHeight();
window.addEventListener("resize", applyHeight);
}}
ref={editorPaneRef}
>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground pt-1">
<Pencil className="h-4 w-4" />
@@ -14,6 +14,7 @@ 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 { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({
title: z.string().min(1, "Title is required."),
@@ -32,11 +33,13 @@ export default function AddUnit() {
const { createUnit, fetchCourse, course, loading } = useCourses();
const { user } = useAuth();
const { register, handleSubmit, formState: { errors } } = useForm({
const { register, handleSubmit, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "", order: 0 },
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
useEffect(() => {
fetchCourse(courseId);
}, [courseId]);
@@ -44,6 +47,7 @@ export default function AddUnit() {
const onSubmit = async (data) => {
const result = await createUnit(courseId, { ...data, createdBy: user?.user_id });
if (!result) return;
bypassOnce();
navigate(`/admin/courses/${courseId}/units`);
};
@@ -96,6 +100,8 @@ export default function AddUnit() {
</form>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
@@ -13,6 +13,7 @@ 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 { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({
title: z.string().min(1, "Title is required."),
@@ -52,10 +53,13 @@ export default function EditUnit() {
}, [courseId, unitId]);
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const onSubmit = async (data) => {
if (!isDirty) return navigate(-1);
if (!isDirty) { bypassOnce(); return navigate(-1); }
const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id });
if (!result) return;
bypassOnce();
navigate(`/admin/courses/${courseId}/units/${unitId}/view`);
};
@@ -108,6 +112,8 @@ export default function EditUnit() {
</form>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
@@ -1,4 +1,4 @@
// modules/admin/pages/notifications/NotificationSettings.jsx
// modules/admin/pages/jobs/Jobs.jsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -20,7 +20,7 @@ function SectionCard({ children }) {
return <div className="rounded-lg border bg-card p-4">{children}</div>;
}
export default function NotificationSettings() {
export default function Jobs() {
const navigate = useNavigate();
const { user } = useAuth();
const { fmtDateTime } = useDateFormat();
@@ -31,8 +31,7 @@ export default function NotificationSettings() {
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Announcements", to: "/admin/announcements" },
{ label: "Settings" },
{ label: "Jobs" },
];
async function fetchSettings() {
@@ -41,7 +40,7 @@ export default function NotificationSettings() {
const { data } = await api.get("/admin/announcement-settings");
setSettings(data?.data ?? []);
} catch (err) {
toast(err?.response?.data?.message ?? "Failed to load notification settings.");
toast(err?.response?.data?.message ?? "Failed to load jobs.");
} finally {
setLoading(false);
}
@@ -94,13 +93,13 @@ export default function NotificationSettings() {
<div className="w-full max-w-2xl pb-10 space-y-5">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/announcements")} aria-label="Back">
<Button variant="ghost" size="icon" onClick={() => navigate("/admin")} aria-label="Back">
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="text-xl font-semibold tracking-tight">Announcement Settings</h1>
<h1 className="text-xl font-semibold tracking-tight">Jobs</h1>
<p className="text-sm text-muted-foreground">
Toggle and reschedule automatic notifications without a deploy.
Toggle and reschedule automatic announcement jobs without a deploy.
</p>
</div>
</div>
@@ -1,59 +1,255 @@
import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft } from "lucide-react";
import { nanoid } from "nanoid";
import {
ArrowLeft, ChevronLeft, ChevronRight, Check,
FileText, LayoutTemplate, ClipboardCheck,
} from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { cn } from "@/lib/utils";
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 {
Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription,
DrawerFooter, DrawerClose,
} from "@/components/ui/drawer";
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
import { AddBlockMenu } from "@/components/generic/AddBlockMenu";
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
function makeBlock(type) {
return { id: nanoid(), type, content: { ...DEFAULT_CONTENT[type] } };
}
const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
blocks: z.array(z.any()).optional(),
});
const DEFAULT_VALUES = { title: "", description: "", blocks: [] };
const STEPS = [
{ id: 0, label: "Lesson", icon: FileText },
{ id: 1, label: "Page Builder", icon: LayoutTemplate },
{ id: 2, label: "Review", icon: ClipboardCheck },
];
// Fields validated with trigger() before advancing past each step.
// Empty array means "validate the whole form" (nothing new to check that step).
const STEP_FIELDS = [["title", "description"], [], []];
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
// ─── Step 1 — Lesson ───────────────────────────────────────────────────────────
function StepLesson({ register, errors }) {
return (
<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="Lesson 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>
);
}
// ─── Step 2 — Page Builder ──────────────────────────────────────────────────────
function StepPageBuilder({ control, setValue, getValues }) {
const title = useWatch({ control, name: "title" });
const description = useWatch({ control, name: "description" });
const blocks = useWatch({ control, name: "blocks" }) ?? [];
const [drawerOpen, setDrawerOpen] = useState(false);
const setBlocks = (updater) => {
const next = typeof updater === "function" ? updater(getValues("blocks") ?? []) : updater;
setValue("blocks", next, { shouldDirty: true });
};
const addBlock = (type) => setBlocks((prev) => [...prev, makeBlock(type)]);
const updateBlock = (id, content) => setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, content } : b)));
const deleteBlock = (id) => setBlocks((prev) => prev.filter((b) => b.id !== id));
const moveBlock = (id, direction) => setBlocks((prev) => {
const index = prev.findIndex((b) => b.id === id);
const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= prev.length) return prev;
const next = [...prev];
[next[index], next[swapIndex]] = [next[swapIndex], next[index]];
return next;
});
return (
<div className="space-y-3">
<div className="flex items-center justify-between border border-border rounded-lg p-4">
<div>
<p className="text-sm font-medium">{title || "Untitled lesson"}</p>
<p className="text-xs text-muted-foreground">
{blocks.length} block{blocks.length !== 1 ? "s" : ""}
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setDrawerOpen(true)}>
<LayoutTemplate className="h-4 w-4 mr-1.5" />
Open Page Builder
</Button>
</div>
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} shouldScaleBackground>
<DrawerContent className="data-[vaul-drawer-direction=bottom]:max-h-[90vh]">
<DrawerHeader className="border-b text-left">
<DrawerTitle>Page Builder</DrawerTitle>
<DrawerDescription>{title || "Untitled lesson"}</DrawerDescription>
</DrawerHeader>
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
Editor
{blocks.length > 0 && (
<span className="text-xs font-normal">· {blocks.length} block{blocks.length !== 1 ? "s" : ""}</span>
)}
</div>
<BlockList blocks={blocks} onUpdate={updateBlock} onMove={moveBlock} onDelete={deleteBlock} />
<AddBlockMenu onAdd={addBlock} />
</div>
<div className="space-y-3">
<div className="text-sm font-medium text-muted-foreground">Live Preview</div>
<PreviewChrome title={title}>
<div className="p-6 space-y-5 min-h-[300px]">
<PreviewContent
lesson={{ title, description }}
blocks={blocks}
empty="Your content will appear here as you build."
/>
</div>
</PreviewChrome>
</div>
</div>
</div>
<DrawerFooter className="border-t flex-row justify-end">
<DrawerClose asChild>
<Button type="button">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
);
}
// ─── Step 3 — Review ─────────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm">
<span className="text-muted-foreground min-w-[140px]">{label}</span>
<span className="text-foreground text-right">{value}</span>
</div>
);
}
function StepReview({ data, attachUnitId }) {
return (
<div className="space-y-4">
<div className="border border-border rounded-lg p-4 space-y-1">
<div className="flex items-center gap-2 mb-3">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Lesson</span>
</div>
<SummaryRow label="Title" value={data.title} />
<SummaryRow label="Description" value={data.description} />
<SummaryRow label="Page content" value={`${(data.blocks ?? []).length} block(s)`} />
{attachUnitId && <SummaryRow label="Attaches to" value="The unit you came from" />}
</div>
</div>
);
}
// ─── Main Page ──────────────────────────────────────────────────────────────────
export default function AddLibraryLesson() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { createLesson, loading } = useLibrary();
const { createLesson, saveLessonPage, loading } = useLibrary();
const { user } = useAuth();
// ?unit_id=… → create-and-attach in one call (from the unit lessons manager)
const attachUnitId = searchParams.get("unit_id");
const { register, handleSubmit, formState: { errors } } = useForm({
const [step, setStep] = useState(0);
const {
register, control, trigger, getValues, setValue,
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "" },
defaultValues: DEFAULT_VALUES,
mode: "onTouched",
});
const onSubmit = async (data) => {
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const handleNext = async () => {
const fields = STEP_FIELDS[step];
const valid = await trigger(fields.length ? fields : undefined);
if (valid) setStep((s) => Math.min(s + 1, STEPS.length - 1));
};
const handleBack = () => {
if (step === 0) navigate(-1);
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.
const handleCreate = async () => {
const valid = await trigger();
if (!valid) return;
const data = getValues();
const result = await createLesson({
...data,
title: data.title,
description: data.description || null,
...(attachUnitId ? { unit_id: attachUnitId } : {}),
createdBy: user?.user_id,
});
if (!result) return;
const lessonId = result?.data?.data?.lesson_id;
if (lessonId && (data.blocks ?? []).length > 0) {
await saveLessonPage(lessonId, { blocks: data.blocks, updatedBy: user?.user_id });
}
bypassOnce();
navigate(attachUnitId ? `/admin/units/${attachUnitId}/view` : "/admin/lessons");
};
return (
<section className="bg-muted h-full">
<section className="bg-muted min-h-full">
<PageMeta title="Add Lesson - STARR" />
<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-3xl mx-auto space-y-6">
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
@@ -67,34 +263,82 @@ export default function AddLibraryLesson() {
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="rounded-lg border bg-card p-6 space-y-5">
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="Lesson title" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
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>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
</div>
{/* Step content */}
<div className="rounded-lg border bg-card p-6 min-h-[320px]">
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
</div>
{step === 0 && (
<StepLesson register={register} errors={errors} />
)}
{step === 1 && (
<StepPageBuilder control={control} setValue={setValue} getValues={getValues} />
)}
{step === 2 && (
<StepReview data={getValues()} attachUnitId={attachUnitId} />
)}
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
{/* Navigation */}
<div className="flex items-center justify-between gap-3">
<Button type="button" variant="outline" onClick={handleBack} disabled={loading}>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? "Cancel" : "Back"}
</Button>
{step < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
<Button type="submit" disabled={loading}>
) : (
<Button type="button" onClick={handleCreate} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Lesson
</Button>
</div>
</form>
)}
</div>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
@@ -13,6 +13,7 @@ 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 { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({
title: z.string().min(1, "Title is required."),
@@ -30,11 +31,13 @@ export default function EditLibraryLesson() {
const { fetchLesson, updateLesson, lesson, loading } = useLibrary();
const { user } = useAuth();
const { register, handleSubmit, reset, formState: { errors } } = useForm({
const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "" },
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
useEffect(() => {
fetchLesson(lessonId);
}, [lessonId]);
@@ -48,6 +51,7 @@ export default function EditLibraryLesson() {
const onSubmit = async (data) => {
const result = await updateLesson(lessonId, { ...data, updatedBy: user?.user_id });
if (!result) return;
bypassOnce();
navigate(`/admin/lessons/${lessonId}/view`);
};
@@ -97,6 +101,8 @@ export default function EditLibraryLesson() {
</form>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
@@ -18,7 +18,7 @@ export default function LessonLibraryList() {
<AppBreadcrumb items={items} />
</div>
<div className="w-full space-y-4">
<div className="flex items-start gap-3 rounded-lg border bg-card p-4 text-sm">
{/* <div className="flex items-start gap-3 rounded-lg border bg-card p-4 text-sm">
<Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground">
Lessons run <span className="font-medium text-foreground">independently</span> — build content here once,
@@ -28,7 +28,7 @@ export default function LessonLibraryList() {
</Link>
. Removing a lesson from a unit only detaches it; the lesson stays in this library.
</p>
</div>
</div> */}
<LessonLibraryTable />
</div>
</div>
@@ -1,66 +1,451 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { useForm, useFieldArray, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft } from "lucide-react";
import { z } from "zod";
import { nanoid } from "nanoid";
import {
ArrowLeft, ChevronLeft, ChevronRight, Check,
FileText, BookOpen, LayoutTemplate, ClipboardCheck,
Plus, Trash2,
} from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util";
import { cn } from "@/lib/utils";
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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription,
DrawerFooter, DrawerClose,
} from "@/components/ui/drawer";
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
import { AddBlockMenu } from "@/components/generic/AddBlockMenu";
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
function makeBlock(type) {
return { id: nanoid(), type, content: { ...DEFAULT_CONTENT[type] } };
}
// ─── Schema ───────────────────────────────────────────────────────────────────
const lessonSchema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
objectives: z.array(
z.object({ value: z.string().min(1, "Objective cannot be empty.") })
).optional(),
blocks: z.array(z.any()).optional(),
});
const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
subscription: z.string().optional(),
lessons: z.array(lessonSchema).optional(),
});
const DEFAULT_VALUES = {
title: "",
description: "",
subscription: "",
lessons: [],
};
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 },
];
// Fields validated with trigger() before advancing past each step.
// Empty array means "validate the whole form" (nothing new to check that step).
const STEP_FIELDS = [["title", "description", "subscription"], ["lessons"], [], []];
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
// ─── Step 1 — Create Unit ─────────────────────────────────────────────────────
function StepUnit({ register, errors, control, setValue, tierCategories }) {
const watchedSubscr = useWatch({ control, name: "subscription" });
return (
<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">
<Label>Subscription</Label>
<Select
value={watchedSubscr || "__open"}
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No tier gate" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__open">No tier gate (open)</SelectItem>
{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 any course it may later be attached to.
</p>
</div>
</div>
);
}
// ─── Step 2 — Lessons ──────────────────────────────────────────────────────────
function LessonObjectives({ control, register, lessonIndex }) {
const { fields, append, remove } = useFieldArray({ control, name: `lessons.${lessonIndex}.objectives` });
return (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">Objectives</Label>
{fields.map((f, oi) => (
<div key={f.id} className="flex items-center gap-2">
<Input {...register(`lessons.${lessonIndex}.objectives.${oi}.value`)} placeholder="Learning objective" />
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive shrink-0"
onClick={() => remove(oi)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={() => append({ value: "" })}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add Objective
</Button>
</div>
);
}
function StepLessons({ control, register, errors }) {
const { fields, append, remove } = useFieldArray({ control, name: "lessons" });
return (
<div className="space-y-4">
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No lessons yet. A unit can be created without any, but add one now if you'd like to build its content in this wizard.
</p>
)}
{fields.map((f, i) => (
<div key={f.id} className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Lesson {i + 1}</span>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive h-7 px-2"
onClick={() => remove(i)}
>
Remove
</Button>
</div>
<div className="space-y-1.5">
<Label>Title <span className="text-destructive">*</span></Label>
<Input {...register(`lessons.${i}.title`)} placeholder="Lesson title" />
<FieldError message={errors.lessons?.[i]?.title?.message} />
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Textarea rows={2} {...register(`lessons.${i}.description`)} placeholder="Optional description" />
</div>
<LessonObjectives control={control} register={register} lessonIndex={i} />
</div>
))}
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => append({ title: "", description: "", objectives: [], blocks: [] })}
>
<Plus className="h-4 w-4 mr-1" /> Add Lesson
</Button>
</div>
);
}
// ─── Step 3 — Page Builder ─────────────────────────────────────────────────────
function StepPageBuilder({ control, setValue }) {
const lessons = useWatch({ control, name: "lessons" }) ?? [];
const [rawIndex, setActiveIndex] = useState(0);
const [drawerOpen, setDrawerOpen] = useState(false);
if (lessons.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Add at least one lesson in the previous step to build its page content here.
</p>
);
}
// Clamp instead of syncing via effect — a removed lesson (from the previous
// step) can leave rawIndex pointing past the end of the array.
const activeIndex = Math.min(rawIndex, lessons.length - 1);
const activeLesson = lessons[activeIndex];
const blocks = activeLesson?.blocks ?? [];
const setBlocks = (updater) => {
const next = typeof updater === "function" ? updater(blocks) : updater;
setValue(`lessons.${activeIndex}.blocks`, next, { shouldDirty: true });
};
const addBlock = (type) => setBlocks((prev) => [...prev, makeBlock(type)]);
const updateBlock = (id, content) => setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, content } : b)));
const deleteBlock = (id) => setBlocks((prev) => prev.filter((b) => b.id !== id));
const moveBlock = (id, direction) => setBlocks((prev) => {
const index = prev.findIndex((b) => b.id === id);
const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= prev.length) return prev;
const next = [...prev];
[next[index], next[swapIndex]] = [next[swapIndex], next[index]];
return next;
});
return (
<div className="space-y-3">
{lessons.map((l, i) => (
<div key={i} className="flex items-center justify-between border border-border rounded-lg p-4">
<div>
<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" : ""}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => { setActiveIndex(i); setDrawerOpen(true); }}
>
<LayoutTemplate className="h-4 w-4 mr-1.5" />
Open Page Builder
</Button>
</div>
))}
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} shouldScaleBackground>
<DrawerContent className="data-[vaul-drawer-direction=bottom]:max-h-[90vh]">
<DrawerHeader className="border-b text-left">
<DrawerTitle>Page Builder</DrawerTitle>
<DrawerDescription>{activeLesson?.title || `Lesson ${activeIndex + 1}`}</DrawerDescription>
</DrawerHeader>
<div className="px-4 pt-3">
<Tabs value={String(activeIndex)} onValueChange={(v) => setActiveIndex(Number(v))}>
<TabsList className="flex-wrap h-auto">
{lessons.map((l, i) => (
<TabsTrigger key={i} value={String(i)} className="gap-1.5">
{l.title || `Lesson ${i + 1}`}
{l.blocks?.length > 0 && (
<Badge variant="secondary" className="text-[10px] px-1.5">{l.blocks.length}</Badge>
)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
Editor
{blocks.length > 0 && (
<span className="text-xs font-normal">· {blocks.length} block{blocks.length !== 1 ? "s" : ""}</span>
)}
</div>
<BlockList blocks={blocks} onUpdate={updateBlock} onMove={moveBlock} onDelete={deleteBlock} />
<AddBlockMenu onAdd={addBlock} />
</div>
<div className="space-y-3">
<div className="text-sm font-medium text-muted-foreground">Live Preview</div>
<PreviewChrome title={activeLesson.title}>
<div className="p-6 space-y-5 min-h-[300px]">
<PreviewContent
lesson={{
title: activeLesson.title,
description: activeLesson.description,
objectives: (activeLesson.objectives ?? []).map((o, oi) => ({ objective_id: oi, text: o.value })),
}}
blocks={blocks}
empty="Your content will appear here as you build."
/>
</div>
</PreviewChrome>
</div>
</div>
</div>
<DrawerFooter className="border-t flex-row justify-end">
<DrawerClose asChild>
<Button type="button">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
);
}
// ─── Step 4 — Review ───────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm">
<span className="text-muted-foreground min-w-[140px]">{label}</span>
<span className="text-foreground text-right">{value}</span>
</div>
);
}
function StepReview({ data, tierCategories }) {
const tierName = tierCategories.find((c) => c.slug === data.subscription)?.name;
return (
<div className="space-y-4">
<div className="border border-border rounded-lg p-4 space-y-1">
<div className="flex items-center gap-2 mb-3">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Unit</span>
</div>
<SummaryRow label="Title" value={data.title} />
<SummaryRow label="Description" value={data.description} />
<SummaryRow label="Subscription" value={tierName || "No tier gate (open)"} />
</div>
{(data.lessons ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No lessons will be created with this unit.</p>
) : (
<div className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Lessons ({data.lessons.length})</span>
</div>
{data.lessons.map((l, i) => (
<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)
</span>
</div>
))}
</div>
)}
</div>
);
}
// ─── Main Page ──────────────────────────────────────────────────────────────
export default function AddLibraryUnit() {
const navigate = useNavigate();
const { createUnit, loading } = useLibrary();
const { createUnitFull, loading } = useLibrary();
const { user } = useAuth();
const [step, setStep] = useState(0);
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
.catch(() => {});
}, []);
const { register, handleSubmit, control, setValue, formState: { errors } } = useForm({
const {
register, control, trigger, getValues, setValue,
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "", subscription: "" },
defaultValues: DEFAULT_VALUES,
mode: "onTouched",
});
const watchedSubscr = useWatch({ control, name: "subscription" });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const onSubmit = async (data) => {
const result = await createUnit({ ...data, subscription: data.subscription || null, createdBy: user?.user_id });
const handleNext = async () => {
const fields = STEP_FIELDS[step];
const valid = await trigger(fields.length ? fields : undefined);
if (valid) setStep((s) => Math.min(s + 1, STEPS.length - 1));
};
const handleBack = () => {
if (step === 0) navigate(-1);
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.
const handleCreate = async () => {
const valid = await trigger();
if (!valid) return;
const data = getValues();
const payload = {
title: data.title,
description: data.description || null,
subscription: data.subscription || null,
lessons: (data.lessons ?? []).map((l) => ({
title: l.title,
description: l.description || null,
objectives: (l.objectives ?? []).map((o) => o.value).filter(Boolean),
blocks: l.blocks ?? [],
})),
createdBy: user?.user_id,
};
// 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;
bypassOnce();
navigate("/admin/units");
};
return (
<section className="bg-muted h-full">
<section className="bg-muted min-h-full">
<PageMeta title="Add Unit - STARR" />
<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-3xl mx-auto space-y-6">
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
@@ -72,57 +457,91 @@ export default function AddLibraryUnit() {
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="rounded-lg border bg-card p-6 space-y-5">
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
<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>
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>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
</div>
{/* Step content */}
<div className="rounded-lg border bg-card p-6 min-h-[320px]">
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
<div className="space-y-1.5">
<Label>Subscription</Label>
<Select
value={watchedSubscr || "__open"}
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No tier gate" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__open">No tier gate (open)</SelectItem>
{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 any course it may later be attached to.
</p>
</div>
{step === 0 && (
<StepUnit
register={register}
errors={errors}
control={control}
setValue={setValue}
tierCategories={tierCategories}
/>
)}
{step === 1 && (
<StepLessons control={control} register={register} errors={errors} />
)}
{step === 2 && (
<StepPageBuilder control={control} setValue={setValue} />
)}
{step === 3 && (
<StepReview data={getValues()} tierCategories={tierCategories} />
)}
</div>
</div>
{/* Navigation */}
<div className="flex items-center justify-between gap-3">
<Button type="button" variant="outline" onClick={handleBack} disabled={loading}>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? "Cancel" : "Back"}
</Button>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
{step < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
<Button type="submit" disabled={loading}>
) : (
<Button type="button" onClick={handleCreate} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Unit
</Button>
</div>
</form>
)}
</div>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
@@ -17,6 +17,7 @@ import { Spinner } from "@/components/ui/spinner";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({
title: z.string().min(1, "Title is required."),
@@ -42,11 +43,13 @@ export default function EditLibraryUnit() {
.catch(() => {});
}, []);
const { register, handleSubmit, reset, control, setValue, formState: { errors } } = useForm({
const { register, handleSubmit, reset, control, setValue, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "", subscription: "" },
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const watchedSubscr = useWatch({ control, name: "subscription" });
useEffect(() => {
@@ -62,6 +65,7 @@ export default function EditLibraryUnit() {
const onSubmit = async (data) => {
const result = await updateUnit(unitId, { ...data, subscription: data.subscription || null, updatedBy: user?.user_id });
if (!result) return;
bypassOnce();
navigate(`/admin/units/${unitId}/view`);
};
@@ -134,6 +138,8 @@ export default function EditLibraryUnit() {
</form>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
@@ -18,7 +18,7 @@ export default function UnitLibraryList() {
<AppBreadcrumb items={items} />
</div>
<div className="w-full space-y-4">
<div className="flex items-start gap-3 rounded-lg border bg-card p-4 text-sm">
{/* <div className="flex items-start gap-3 rounded-lg border bg-card p-4 text-sm">
<Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground">
Units run <span className="font-medium text-foreground">independently</span> — build them here once,
@@ -28,7 +28,7 @@ export default function UnitLibraryList() {
</Link>
. Removing a unit from a course only detaches it; the unit stays in this library.
</p>
</div>
</div> */}
<UnitLibraryTable />
</div>
</div>
@@ -1,11 +1,13 @@
// modules/admin/pages/notifications/AddNotificationBroadcast.jsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { House } from "lucide-react";
import api from "@/utils/api.util";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -19,16 +21,19 @@ import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
title: z.string().min(1, "Title is required."),
message: z.string().min(1, "Message is required."),
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { message: "Please select a target." }),
target_id: z.string().nullable().optional(),
show_in_sticky: z.boolean().optional(),
show_in_notifications: z.boolean().optional(),
link_mode: z.enum(["info", "link"]).optional(),
link_url: z.string().trim().optional(),
}).superRefine((data, ctx) => {
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
ctx.addIssue({
@@ -45,6 +50,14 @@ const schema = z.object({
path: ["show_in_sticky"],
});
}
if (data.show_in_sticky && data.link_mode === "link" && !data.link_url) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter a link URL, or switch to text info only.",
path: ["link_url"],
});
}
});
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -80,7 +93,7 @@ export default function AddNotificationBroadcast() {
handleSubmit,
watch,
setValue,
formState: { errors },
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
@@ -90,14 +103,33 @@ export default function AddNotificationBroadcast() {
target_id: null,
show_in_sticky: false,
show_in_notifications: true,
link_mode: "info",
link_url: "",
},
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const [templates, setTemplates] = useState([]);
useEffect(() => {
api.get("/admin/announcement-templates")
.then(({ data }) => setTemplates((data.data ?? []).filter((t) => !t.is_system)))
.catch(() => {});
}, []);
const applyTemplate = (id) => {
const tpl = templates.find((t) => String(t.notification_template_id) === id);
if (!tpl) return;
setValue("title", tpl.title ?? "", { shouldValidate: true, shouldDirty: true });
setValue("message", tpl.message ?? "", { shouldValidate: true, shouldDirty: true });
};
const targetType = watch("target_type");
const targetId = watch("target_id");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const showInSticky = watch("show_in_sticky");
const showInNotifications = watch("show_in_notifications");
const linkMode = watch("link_mode");
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -106,16 +138,18 @@ export default function AddNotificationBroadcast() {
];
const onSubmit = async (values) => {
const { link_mode, ...rest } = values;
const payload = {
...values,
...rest,
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
show_in_sticky: values.show_in_sticky ?? false,
show_in_notifications: values.show_in_notifications ?? true,
link_url: (values.show_in_sticky && link_mode === "link") ? values.link_url.trim() : null,
createdBy: user?.user_id ?? null,
};
const res = await createBroadcast(payload);
if (res) navigate("/admin/announcements");
if (res) { bypassOnce(); navigate("/admin/announcements"); }
};
return (
@@ -132,6 +166,22 @@ export default function AddNotificationBroadcast() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Content" description="What admins and/or users will see.">
{templates.length > 0 && (
<div>
<Label className="mb-1.5 block">Load from template</Label>
<Select onValueChange={applyTemplate}>
<SelectTrigger>
<SelectValue placeholder="Optional — start from a saved preset" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.notification_template_id} value={String(t.notification_template_id)}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">Fills in the title and message below — you can still edit them after.</p>
</div>
)}
<div>
<Label className="mb-1.5 block">Title</Label>
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
@@ -149,8 +199,8 @@ export default function AddNotificationBroadcast() {
<Select
value={targetType}
onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true });
setValue("target_id", null);
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
setValue("target_id", null, { shouldDirty: true });
}}
>
<SelectTrigger>
@@ -175,7 +225,7 @@ export default function AddNotificationBroadcast() {
<BroadcastTargetPicker
targetType={targetType}
value={targetId}
onChange={(id) => setValue("target_id", id, { shouldValidate: true })}
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
/>
<FieldError message={errors.target_id?.message} />
</div>
@@ -188,7 +238,7 @@ export default function AddNotificationBroadcast() {
<Checkbox
id="show_in_sticky"
checked={showInSticky === true}
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true })}
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true, shouldDirty: true })}
/>
<Label htmlFor="show_in_sticky" className="cursor-pointer">
Show in Sticky Announcements
@@ -199,7 +249,7 @@ export default function AddNotificationBroadcast() {
<Checkbox
id="show_in_notifications"
checked={showInNotifications === true}
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true })}
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
/>
<Label htmlFor="show_in_notifications" className="cursor-pointer">
Show in Notifications
@@ -208,6 +258,44 @@ export default function AddNotificationBroadcast() {
</div>
</SectionCard>
{showInSticky && (
<SectionCard title="On Open" description="What happens when someone opens this announcement from the sticky banner.">
<div className="flex gap-2">
<Button
type="button"
variant={linkMode !== "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "info", { shouldDirty: true })}
>
Text info only
</Button>
<Button
type="button"
variant={linkMode === "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "link", { shouldDirty: true })}
>
Include a link
</Button>
</div>
{linkMode === "link" ? (
<div>
<Label className="mb-1.5 block">Link URL</Label>
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
<FieldError message={errors.link_url?.message} />
<p className="text-xs text-muted-foreground mt-1.5">
Shows an "Open Link" button in the full-content view. Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
</p>
</div>
) : (
<p className="text-xs text-muted-foreground">
The full-content view will show just the title and message, with no action button.
</p>
)}
</SectionCard>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
@@ -220,6 +308,8 @@ export default function AddNotificationBroadcast() {
</form>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
@@ -0,0 +1,136 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft, House } from "lucide-react";
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 AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminNotificationTemplateProvider,
useAdminNotificationTemplates,
} from "@/contexts/AdminNotificationTemplateContext";
function SectionCard({ title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
{children}
</div>
);
}
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function AddNotificationTemplateInner() {
const navigate = useNavigate();
const { loading, createTemplate } = useAdminNotificationTemplates();
const [label, setLabel] = useState("");
const [title, setTitle] = useState("");
const [message, setMessage] = useState("");
const [errors, setErrors] = useState({});
const validate = () => {
const e = {};
if (!label.trim()) e.label = "Label is required.";
if (!title.trim()) e.title = "Title is required.";
if (!message.trim()) e.message = "Message is required.";
setErrors(e);
return !Object.keys(e).length;
};
const handleCreate = async () => {
if (!validate()) return;
const result = await createTemplate({
label: label.trim(),
title: title.trim(),
message: message.trim(),
});
if (result) navigate("/admin/announcement-templates");
};
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Add Announcement Template - STARR" />
<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 flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Announcements", to: "/admin/announcements" },
{ label: "Templates", to: "/admin/announcement-templates" },
{ label: "Add" },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold">Add Announcement Template</h1>
<p className="text-sm text-muted-foreground">
Save a reusable title/message preset to load into a new announcement later.
</p>
</div>
</div>
<div className="space-y-5">
<SectionCard title="Template Details">
<div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Scheduled Maintenance" />
<FieldError message={errors.label} />
</div>
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Scheduled maintenance tonight" />
<FieldError message={errors.title} />
</div>
</SectionCard>
<SectionCard title="Message">
<p className="text-xs text-muted-foreground -mt-1">
This is copied straight into the announcement — no placeholders here, this text goes out as-is.
</p>
<Textarea
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={6}
placeholder="Full announcement text"
/>
<FieldError message={errors.message} />
</SectionCard>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" onClick={handleCreate} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Template
</Button>
</div>
</div>
</div>
</div>
</section>
);
}
export default function AddNotificationTemplate() {
return (
<AdminNotificationTemplateProvider>
<AddNotificationTemplateInner />
</AdminNotificationTemplateProvider>
);
}
@@ -1,12 +1,13 @@
// modules/admin/pages/notifications/EditNotificationBroadcast.jsx
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { House } from "lucide-react";
import api from "@/utils/api.util";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -20,16 +21,19 @@ import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
title: z.string().min(1, "Title is required."),
message: z.string().min(1, "Message is required."),
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { message: "Please select a target." }),
target_id: z.string().nullable().optional(),
show_in_sticky: z.boolean().optional(),
show_in_notifications: z.boolean().optional(),
link_mode: z.enum(["info", "link"]).optional(),
link_url: z.string().trim().optional(),
}).superRefine((data, ctx) => {
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
ctx.addIssue({
@@ -46,6 +50,14 @@ const schema = z.object({
path: ["show_in_sticky"],
});
}
if (data.show_in_sticky && data.link_mode === "link" && !data.link_url) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter a link URL, or switch to text info only.",
path: ["link_url"],
});
}
});
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -83,7 +95,7 @@ export default function EditNotificationBroadcast() {
reset,
watch,
setValue,
formState: { errors },
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
@@ -93,14 +105,33 @@ export default function EditNotificationBroadcast() {
target_id: null,
show_in_sticky: false,
show_in_notifications: true,
link_mode: "info",
link_url: "",
},
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const [templates, setTemplates] = useState([]);
useEffect(() => {
api.get("/admin/announcement-templates")
.then(({ data }) => setTemplates((data.data ?? []).filter((t) => !t.is_system)))
.catch(() => {});
}, []);
const applyTemplate = (id) => {
const tpl = templates.find((t) => String(t.notification_template_id) === id);
if (!tpl) return;
setValue("title", tpl.title ?? "", { shouldValidate: true, shouldDirty: true });
setValue("message", tpl.message ?? "", { shouldValidate: true, shouldDirty: true });
};
const targetType = watch("target_type");
const targetId = watch("target_id");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const showInSticky = watch("show_in_sticky");
const showInNotifications = watch("show_in_notifications");
const linkMode = watch("link_mode");
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -122,22 +153,26 @@ export default function EditNotificationBroadcast() {
target_id: b.target_id ?? null,
show_in_sticky: b.show_in_sticky ?? false,
show_in_notifications: b.show_in_notifications ?? true,
link_mode: b.link_url ? "link" : "info",
link_url: b.link_url ?? "",
});
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [broadcastId]);
const onSubmit = async (values) => {
const { link_mode, ...rest } = values;
const payload = {
...values,
...rest,
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
show_in_sticky: values.show_in_sticky ?? false,
show_in_notifications: values.show_in_notifications ?? true,
link_url: (values.show_in_sticky && link_mode === "link") ? values.link_url.trim() : null,
updatedBy: user?.user_id ?? null,
};
const res = await updateBroadcast(broadcastId, payload);
if (res) navigate("/admin/announcements");
if (res) { bypassOnce(); navigate("/admin/announcements"); }
};
return (
@@ -154,6 +189,22 @@ export default function EditNotificationBroadcast() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Content" description="What admins and/or users will see.">
{templates.length > 0 && (
<div>
<Label className="mb-1.5 block">Load from template</Label>
<Select onValueChange={applyTemplate}>
<SelectTrigger>
<SelectValue placeholder="Optional — start from a saved preset" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.notification_template_id} value={String(t.notification_template_id)}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">Fills in the title and message below — you can still edit them after.</p>
</div>
)}
<div>
<Label className="mb-1.5 block">Title</Label>
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
@@ -171,8 +222,8 @@ export default function EditNotificationBroadcast() {
<Select
value={targetType}
onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true });
setValue("target_id", null);
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
setValue("target_id", null, { shouldDirty: true });
}}
>
<SelectTrigger>
@@ -197,7 +248,7 @@ export default function EditNotificationBroadcast() {
<BroadcastTargetPicker
targetType={targetType}
value={targetId}
onChange={(id) => setValue("target_id", id, { shouldValidate: true })}
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
/>
<FieldError message={errors.target_id?.message} />
</div>
@@ -210,7 +261,7 @@ export default function EditNotificationBroadcast() {
<Checkbox
id="show_in_sticky"
checked={showInSticky === true}
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true })}
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true, shouldDirty: true })}
/>
<Label htmlFor="show_in_sticky" className="cursor-pointer">
Show in Sticky Announcements
@@ -221,7 +272,7 @@ export default function EditNotificationBroadcast() {
<Checkbox
id="show_in_notifications"
checked={showInNotifications === true}
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true })}
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
/>
<Label htmlFor="show_in_notifications" className="cursor-pointer">
Show in Notifications
@@ -230,6 +281,44 @@ export default function EditNotificationBroadcast() {
</div>
</SectionCard>
{showInSticky && (
<SectionCard title="On Open" description="What happens when someone opens this announcement from the sticky banner.">
<div className="flex gap-2">
<Button
type="button"
variant={linkMode !== "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "info", { shouldDirty: true })}
>
Text info only
</Button>
<Button
type="button"
variant={linkMode === "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "link", { shouldDirty: true })}
>
Include a link
</Button>
</div>
{linkMode === "link" ? (
<div>
<Label className="mb-1.5 block">Link URL</Label>
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
<FieldError message={errors.link_url?.message} />
<p className="text-xs text-muted-foreground mt-1.5">
Shows an "Open Link" button in the full-content view. Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
</p>
</div>
) : (
<p className="text-xs text-muted-foreground">
The full-content view will show just the title and message, with no action button.
</p>
)}
</SectionCard>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
@@ -242,6 +331,8 @@ export default function EditNotificationBroadcast() {
</form>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
@@ -1,12 +1,17 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Lock, Send, Clock3 } from "lucide-react";
import { ArrowLeft, House, Lock, Send, Clock3, Trash2 } from "lucide-react";
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 {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
@@ -34,12 +39,15 @@ function FieldError({ message }) {
function EditNotificationTemplateInner() {
const navigate = useNavigate();
const { id } = useParams();
const { template, loading, fetchTemplate, updateTemplate } = useAdminNotificationTemplates();
const { template, loading, fetchTemplate, updateTemplate, deleteTemplate } = useAdminNotificationTemplates();
const [label, setLabel] = useState("");
const [title, setTitle] = useState("");
const [message, setMessage] = useState("");
const [errors, setErrors] = useState({});
const [confirmDelete, setConfirmDelete] = useState(false);
const isCustom = template && !template.is_system;
useEffect(() => {
if (id) fetchTemplate(id);
@@ -80,6 +88,12 @@ function EditNotificationTemplateInner() {
if (result) navigate("/admin/announcement-templates");
};
const handleDelete = async () => {
const result = await deleteTemplate(id);
setConfirmDelete(false);
if (result) navigate("/admin/announcement-templates");
};
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Edit Announcement Template - STARR" />
@@ -102,7 +116,7 @@ function EditNotificationTemplateInner() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-semibold">Edit Announcement Template</h1>
{template && (
{template && !isCustom && (
<Badge variant="outline" className={cn("gap-1", status.badgeClass)}>
<Send className="h-3 w-3" /> {status.label}
</Badge>
@@ -112,7 +126,7 @@ function EditNotificationTemplateInner() {
</div>
</div>
{pending && (
{pending && !isCustom && (
<div className="rounded-lg border border-amber-400 bg-amber-50 dark:bg-amber-950/40 dark:border-amber-700 p-4 flex items-start gap-3 mb-5">
<Clock3 className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-xs text-amber-800 dark:text-amber-300">
@@ -123,22 +137,26 @@ function EditNotificationTemplateInner() {
</div>
)}
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-xs text-muted-foreground">
This is a <strong>system</strong> notification — code fires it by referencing this exact type,
so the type is locked. Label, title and message are still fully editable.
</p>
</div>
{!isCustom && (
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-xs text-muted-foreground">
This is a <strong>system</strong> notification — code fires it by referencing this exact type,
so the type is locked. Label, title and message are still fully editable.
</p>
</div>
)}
<div className="space-y-5">
<SectionCard title="Template Details">
<div className="space-y-1.5">
<Label>Type</Label>
<Input value={template?.type ?? ""} disabled />
<p className="text-xs text-muted-foreground">Cannot be changed — this is what code looks up.</p>
</div>
{!isCustom && (
<div className="space-y-1.5">
<Label>Type</Label>
<Input value={template?.type ?? ""} disabled />
<p className="text-xs text-muted-foreground">Cannot be changed — this is what code looks up.</p>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
@@ -159,11 +177,13 @@ function EditNotificationTemplateInner() {
</div>
<p className="text-xs text-muted-foreground -mt-1">
Plain text only — no HTML, no conditional logic, just straight{" "}
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens.
{isCustom
? "Plain text only — this is copied straight into the announcement as-is."
: (<>Plain text only — no HTML, no conditional logic, just straight{" "}
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens.</>)}
</p>
{(knownPlaceholders !== null) && (
{!isCustom && (knownPlaceholders !== null) && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Available placeholders</p>
{knownPlaceholders.length ? (
@@ -191,19 +211,54 @@ function EditNotificationTemplateInner() {
<FieldError message={errors.message} />
</SectionCard>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" variant="outline" onClick={() => handleSave(false)} disabled={loading}>
Save as Draft
</Button>
<Button type="button" onClick={() => handleSave(true)} disabled={loading}>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
Publish
</Button>
<div className="flex items-center justify-between gap-3">
{isCustom ? (
<Button type="button" variant="ghost" className="text-destructive hover:text-destructive" onClick={() => setConfirmDelete(true)} disabled={loading}>
<Trash2 className="h-4 w-4 mr-2" /> Delete
</Button>
) : <span />}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
{isCustom ? (
<Button type="button" onClick={() => handleSave(false)} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save
</Button>
) : (
<>
<Button type="button" variant="outline" onClick={() => handleSave(false)} disabled={loading}>
Save as Draft
</Button>
<Button type="button" onClick={() => handleSave(true)} disabled={loading}>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
Publish
</Button>
</>
)}
</div>
</div>
</div>
</div>
</div>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this template?</AlertDialogTitle>
<AlertDialogDescription>
"{template?.label}" will be permanently removed. It won't affect any announcements already sent.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={loading} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, Settings, FileText, Archive } from "lucide-react";
import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, FileText, Archive } from "lucide-react";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -24,6 +24,7 @@ export default function NotificationBroadcastList() {
const { broadcasts, pagination, loading, fetchBroadcasts, sendBroadcast, archiveBroadcast } = useNotificationBroadcasts();
const [statusFilter, setStatusFilter] = useState("all");
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [limit, setLimit] = useState(12);
@@ -76,10 +77,6 @@ export default function NotificationBroadcastList() {
<FileText className="size-4" />
Templates
</Button>
<Button variant="outline" onClick={() => navigate("/admin/announcements/settings")}>
<Settings className="size-4" />
Settings
</Button>
<Button variant="outline" onClick={() => navigate("/admin/announcements/archived")}>
<Archive className="size-4" />
Archived
@@ -112,14 +109,20 @@ export default function NotificationBroadcastList() {
</SelectContent>
</Select>
<div className="relative flex-1 min-w-[160px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search announcements..."
className="pl-8 bg-background"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex items-center gap-2 flex-1 min-w-[160px]">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search announcements..."
className="pl-8 bg-background"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") setSearch(searchInput); }}
/>
</div>
<Button variant="outline" size="icon" className="shrink-0 bg-background" onClick={() => setSearch(searchInput)} aria-label="Search">
<Search className="size-4" />
</Button>
</div>
</div>
@@ -1,10 +1,15 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Pencil, Bell, Lock, Send, Clock3 } from "lucide-react";
import { House, Pencil, Bell, Lock, Send, Clock3, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Separator } from "@/components/ui/separator";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
@@ -15,7 +20,7 @@ import { NOTIFICATION_TEMPLATE_TYPES, getNotificationTemplateType } from "@/data
import { STATUS_META, hasPendingChanges } from "@/data/notificationTemplateStatus.data";
import { cn } from "@/lib/utils";
function TemplateCard({ item, onEdit }) {
function TemplateCard({ item, onEdit, onDelete }) {
const typeMeta = getNotificationTemplateType(item.notify_type);
const TypeIcon = typeMeta?.icon ?? Bell;
const status = STATUS_META[item.status] ?? STATUS_META.draft;
@@ -27,9 +32,16 @@ function TemplateCard({ item, onEdit }) {
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center shrink-0">
<TypeIcon className="h-4.5 w-4.5 text-muted-foreground" />
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
<div className="flex items-center gap-0.5">
{!item.is_system && (
<Button type="button" variant="ghost" size="icon" className="text-destructive hover:text-destructive" onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4" />
</Button>
)}
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
</div>
</div>
<div className="min-w-0 flex-1">
@@ -68,11 +80,18 @@ function TemplateCard({ item, onEdit }) {
function NotificationTemplatesInner() {
const navigate = useNavigate();
const { templates, loading, fetchTemplates } = useAdminNotificationTemplates();
const { templates, loading, fetchTemplates, deleteTemplate } = useAdminNotificationTemplates();
const [activeType, setActiveType] = useState("all");
const [deleteTarget, setDeleteTarget] = useState(null);
useEffect(() => { fetchTemplates(); }, []);
const handleDelete = async () => {
if (!deleteTarget) return;
await deleteTemplate(deleteTarget.notification_template_id);
setDeleteTarget(null);
};
const filtered = useMemo(
() => activeType === "all" ? templates : templates.filter((t) => t.notify_type === activeType),
[templates, activeType]
@@ -120,25 +139,30 @@ function NotificationTemplatesInner() {
)}
</div>
</div>
<Button type="button" onClick={() => navigate("/admin/announcement-templates/add")} className="gap-1.5">
<Plus className="h-4 w-4" /> Add Template
</Button>
</div>
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="text-xs text-muted-foreground space-y-1">
<p>
Every template here is <strong>system</strong>-triggered — code fires it by referencing its
exact type, so no template can be added or removed from this screen. Only the title and
message wording is editable.
<strong>System</strong> templates are locked — code fires them by referencing their exact
type, so only the title and message wording is editable, never the type itself, and they
can't be deleted.
</p>
<p>
<strong>Draft vs. Sent:</strong> a <strong>Sent</strong> template is the version actually
used for real notifications right now. Editing a Sent template doesn't change what goes out
immediately — it's held as a pending change until you press <strong>Publish</strong> again.
<strong>Custom</strong> templates (no lock icon) are reusable title/message presets you
create — pick one from the "Load from template" dropdown when composing a new announcement
to skip retyping recurring wording. You can freely create, edit, and delete these.
</p>
<p>
Only plain text is supported — no HTML, no conditional logic, just straight{" "}
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens that get swapped
for real values when the notification fires.
<strong>Draft vs. Sent</strong> (system templates only): a <strong>Sent</strong> template
is the version actually used for real notifications right now. Editing a Sent template
doesn't change what goes out immediately — it's held as a pending change until you press{" "}
<strong>Publish</strong> again.
</p>
</div>
</div>
@@ -180,12 +204,31 @@ function NotificationTemplatesInner() {
key={item.notification_template_id}
item={item}
onEdit={(t) => navigate(`/admin/announcement-templates/${t.notification_template_id}/edit`)}
onDelete={setDeleteTarget}
/>
))}
</div>
)}
</div>
</div>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this template?</AlertDialogTitle>
<AlertDialogDescription>
"{deleteTarget?.label}" will be permanently removed. It won't affect any announcements already sent.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={loading} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
@@ -1,85 +0,0 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"
import { House, FileText, List, ShieldCheck } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
export default function ResourceList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
{ label: "Resources" },
]
const handleNavigate = useNavigate();
return (
<div className="flex lg:items-center lg:container lg:mx-auto flex-col xs:px-6 lg:px-0">
<div className="max-w-lg h-full w-full lg:mt-4 space-y-4">
<div className="flex flex-col gap-2 mt-6">
<AppBreadcrumb items={items} />
</div>
{/* This page will have tiles to redirect for Assets and Tier Plans (at the moment) */}
<div className="space-y-2">
<h1 className="text-2xl font-medium">Resources</h1>
<p className="text-sm text-muted-foreground">Manage content..</p>
</div>
<div className="grid xs:grid-cols-1 sm:grid-cols-2 h-fit w-full gap-4">
<motion.div
onClick={(e) => handleNavigate('/admin/assets')}
className="group bg-card hover:bg-primary border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer transition-colors duration-200 ease-out"
whileHover={{ y: -6, scale: 1.02 }}
transition={{
y: { type: "spring", stiffness: 300, damping: 20 },
scale: { type: "spring", stiffness: 300, damping: 20 },
}}
>
<motion.div
className="w-full flex justify-end"
whileHover={{ rotate: -18, scale: 1.05 }}
transition={{ type: "spring", stiffness: 200 }}
>
<FileText className="size-32 opacity-50 -rotate-24 text-blue-500 transition-colors duration-200 group-hover:text-white group-hover:opacity-100" />
</motion.div>
<motion.div
className="p-4 font-medium mt-auto text-foreground transition-colors duration-200 ease-out group-hover:text-white"
whileHover={{ y: -2 }}
>
Assets
</motion.div>
</motion.div>
<motion.div
onClick={(e) => handleNavigate('/admin/tiers/plans')}
className="group bg-card hover:bg-primary border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer transition-colors duration-200 ease-out"
whileHover={{ y: -6, scale: 1.02 }}
transition={{
y: { type: "spring", stiffness: 300, damping: 20 },
scale: { type: "spring", stiffness: 300, damping: 20 },
}}
>
<motion.div
className="w-full flex justify-end"
whileHover={{ rotate: -18, scale: 1.05 }}
transition={{ type: "spring", stiffness: 200 }}
>
<List className="size-32 opacity-50 -rotate-24 text-blue-500 transition-colors duration-200 group-hover:text-white group-hover:opacity-100" />
</motion.div>
<motion.div
className="p-4 font-medium mt-auto text-foreground transition-colors duration-200 ease-out group-hover:text-white"
whileHover={{ y: -2 }}
>
Tier Plans
</motion.div>
</motion.div>
</div>
</div>
</div>
)
}
@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react';
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Tag, Lock, Clock, Search, AlertTriangle, PenLine, ClipboardCheck, ShieldCheck } from 'lucide-react';
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Tag, Lock, Clock, AlertTriangle, PenLine, ClipboardCheck, ShieldCheck, Link2, Unlink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -9,7 +9,8 @@ import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '@/components/ui/command';
import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { resolveTierBadge } from '@/utils/tierBadge.util';
import api from '@/utils/api.util';
@@ -59,71 +60,109 @@ function TierBadge({ subscription, tierMap }) {
);
}
// ─── Custom content picker ────────────────────────────────────────────────────
function ContentPicker({ value, options, idKey, searchKey, labelKey, placeholder = 'Select…', onSelect, renderTrigger, renderItem, listHeight = 'max-h-48' }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) =>
String(o[searchKey] ?? o[labelKey] ?? '').toLowerCase().includes(q)
// ─── Course binding indicator ──────────────────────────────────────────────────
// Units/lessons are standalone entities that may sit under 0..N courses
// (junction revamp) — these surface that binding in the picker instead of the
// old single "course_title | Unit N" prefix, which broke once a unit/lesson
// could belong to several courses or none at all.
function BindingChip({ courses = [] }) {
if (!courses.length) {
return (
<Badge variant="secondary" className="text-xs gap-1 shrink-0 text-muted-foreground">
<Unlink className="size-2.5" />
Standalone
</Badge>
);
}, [options, query, searchKey, labelKey]);
}
return (
<span className="flex items-center gap-1 min-w-0 shrink-0">
<Badge variant="outline" className="text-xs gap-1 shrink-0 max-w-28">
<Link2 className="size-2.5 shrink-0" />
<span className="truncate">{courses[0].title}</span>
</Badge>
{courses.length > 1 && (
<Badge variant="outline" className="text-xs shrink-0">+{courses.length - 1}</Badge>
)}
</span>
);
}
function BindingLine({ courses = [] }) {
if (!courses.length) {
return (
<span className="flex items-center gap-1 text-xs italic text-muted-foreground truncate">
<Unlink className="size-3 shrink-0" />
Standalone — not attached to any course
</span>
);
}
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground truncate">
<Link2 className="size-3 shrink-0" />
<span className="truncate">{courses.map((c) => c.title).join(', ')}</span>
</span>
);
}
// ─── Custom content picker ────────────────────────────────────────────────────
// Opens as its own centered Dialog (cmdk Command inside) rather than an
// anchored Popover — when this builder is used inside TaskQueueStep's "Add
// Task" Dialog, a flip-prone anchored popover overlaps the surrounding form
// fields once there isn't room to open downward. A second, independent
// modal sidesteps that entirely (Radix Dialogs stack cleanly).
function ContentPicker({ value, options, idKey, searchKey, labelKey, placeholder = 'Select…', dialogTitle, onSelect, renderTrigger, renderItem }) {
const [open, setOpen] = useState(false);
const selected = options.find((o) => String(o[idKey]) === String(value));
const handleOpenChange = (v) => {
setOpen(v);
if (!v) setQuery('');
const searchValue = (o) => {
const base = String(o[searchKey] ?? o[labelKey] ?? o[idKey] ?? '');
const courseNames = (o.courses ?? []).map((c) => c.title).join(' ');
return `${base} ${courseNames}`.trim() || String(o[idKey]);
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-auto min-h-8 w-full justify-between text-sm font-normal py-1.5 px-3"
>
{selected
? renderTrigger(selected)
: <span className="text-muted-foreground">{placeholder}</span>}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: 'var(--radix-popover-trigger-width)' }} align="start">
<div className="flex items-center gap-2 border-b px-3">
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
className="flex-1 bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className={`overflow-y-auto ${listHeight}`}>
{filtered.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No results found.</p>
) : (
filtered.map((o) => (
<div
key={o[idKey]}
role="option"
aria-selected={String(value) === String(o[idKey])}
className={`cursor-pointer select-none transition-colors hover:bg-accent hover:text-accent-foreground${String(value) === String(o[idKey]) ? ' bg-accent/50' : ''}`}
onClick={() => { onSelect(o); setOpen(false); setQuery(''); }}
>
{renderItem(o)}
</div>
))
)}
</div>
</PopoverContent>
</Popover>
<>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="h-auto min-h-8 w-full justify-between text-sm font-normal py-1.5 px-3"
onClick={() => setOpen(true)}
>
{selected
? renderTrigger(selected)
: <span className="text-muted-foreground">{placeholder}</span>}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="top-[15%] translate-y-0 flex flex-col max-h-[70vh] overflow-hidden rounded-xl! p-0 gap-0 sm:max-w-md">
<DialogHeader className="px-4 pt-4 pb-3 border-b pr-10">
<DialogTitle className="text-sm">{dialogTitle ?? placeholder}</DialogTitle>
</DialogHeader>
<Command className="flex-1 min-h-0 rounded-none! bg-transparent p-0" loop>
<CommandInput placeholder="Search…" />
<CommandList className="max-h-[50vh]">
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((o) => (
<CommandItem
key={o[idKey]}
value={searchValue(o)}
onSelect={() => { onSelect(o); setOpen(false); }}
className="p-0"
>
{renderItem(o)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
</>
);
}
@@ -354,6 +393,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
idKey="uuid"
labelKey="title"
placeholder="Select a course"
dialogTitle="Select a course"
onSelect={(c) => handleContentSelect(item._key, c, 'course')}
renderTrigger={(c) => (
<div className="flex items-center gap-2 min-w-0 flex-1">
@@ -367,7 +407,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
</div>
)}
renderItem={(c) => (
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex items-center gap-2 px-3 py-2 w-full">
<TierBadge subscription={c.subscription} tierMap={tierMap} />
<span className="flex-1 text-sm truncate">{c.title}</span>
{fmtDuration(c.duration_seconds) && (
@@ -387,16 +427,14 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
options={units}
idKey="uuid"
labelKey="title"
searchKey="_search"
placeholder="Select a unit"
dialogTitle="Select a unit"
onSelect={(u) => handleContentSelect(item._key, u, 'unit')}
renderTrigger={(u) => (
<div className="flex items-center gap-2 min-w-0 flex-1">
<TierBadge subscription={u.subscription} tierMap={tierMap} />
<div className="flex flex-col items-start flex-1">
<span className="text-xs leading-tight text-muted-foreground truncate">
{u.course_title} | Unit {u.order_index + 1}
</span>
<div className="flex flex-col items-start flex-1 min-w-0">
<BindingLine courses={u.courses} />
<span className="text-sm leading-tight truncate">{u.title}</span>
</div>
{fmtDuration(u.duration_seconds) && (
@@ -407,15 +445,13 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
</div>
)}
renderItem={(u) => (
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex items-center gap-2 px-3 py-2 w-full">
<TierBadge subscription={u.subscription} tierMap={tierMap} />
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm leading-tight text-muted-foreground truncate">
{u.course_title} (Course) | Unit {u.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{u.title}</span>
<BindingLine courses={u.courses} />
</div>
<BindingChip courses={u.courses} />
{fmtDuration(u.duration_seconds) && (
<span className="flex items-center gap-1 text-sm text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(u.duration_seconds)}
@@ -433,17 +469,13 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
options={lessons}
idKey="uuid"
labelKey="title"
searchKey="_search"
placeholder="Select a lesson"
listHeight="max-h-64"
dialogTitle="Select a lesson"
onSelect={(l) => handleContentSelect(item._key, l, 'lesson')}
renderTrigger={(l) => (
<div className="flex items-center gap-2 min-w-0 flex-1">
<TierBadge subscription={l.subscription} tierMap={tierMap} />
<div className="flex flex-col items-start flex-1">
<span className="text-xs leading-tight text-muted-foreground truncate">
{l.course_title} (Course) | Unit {l.unit_order + 1} | Lesson {l.order_index + 1}
</span>
<div className="flex flex-col items-start flex-1 min-w-0">
<BindingLine courses={l.courses} />
<span className="text-sm leading-tight truncate">{l.title}</span>
</div>
{fmtDuration(l.duration_seconds) && (
@@ -454,14 +486,12 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
</div>
)}
renderItem={(l) => (
<div className="flex items-center gap-2 px-3 py-2">
<TierBadge subscription={l.subscription} tierMap={tierMap} />
<div className="flex items-center gap-2 px-3 py-2 w-full">
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm leading-tight text-muted-foreground truncate">
{l.course_title} (Course) | Unit {l.unit_order + 1} | Lesson {l.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{l.title}</span>
<BindingLine courses={l.courses} />
</div>
<BindingChip courses={l.courses} />
{fmtDuration(l.duration_seconds) && (
<span className="flex items-center gap-1 text-sm text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(l.duration_seconds)}
@@ -491,29 +521,24 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
options={quizzes}
idKey="uuid"
labelKey="title"
searchKey="_search"
placeholder="Select a quiz"
dialogTitle="Select a quiz"
onSelect={(q) => handleContentSelect(item._key, q, 'quiz')}
renderTrigger={(q) => (
<div className="flex items-center gap-2 min-w-0 flex-1">
<TierBadge subscription={q.subscription} tierMap={tierMap} />
<div className="flex flex-col items-start flex-1 min-w-0">
<span className="text-xs leading-tight text-muted-foreground truncate">
{q.course_title ? `${q.course_title} | ` : ''}{q.unit_title}
</span>
<span className="text-xs leading-tight text-muted-foreground truncate">{q.unit_title}</span>
<span className="text-sm leading-tight truncate">{q.title}</span>
</div>
</div>
)}
renderItem={(q) => (
<div className="flex items-center gap-2 px-3 py-2">
<TierBadge subscription={q.subscription} tierMap={tierMap} />
<div className="flex items-center gap-2 px-3 py-2 w-full">
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm leading-tight text-muted-foreground truncate">
{q.course_title ? `${q.course_title} | ` : ''}{q.unit_title}
</span>
<span className="text-sm leading-tight truncate">{q.title}</span>
<span className="text-xs leading-tight text-muted-foreground truncate">Unit: {q.unit_title}</span>
</div>
<BindingChip courses={q.courses} />
</div>
)}
/>
+11 -1
View File
@@ -16,6 +16,7 @@ import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// ─── Schema ───────────────────────────────────────────────────────────────────
@@ -154,7 +155,7 @@ export default function AddPlan() {
.catch(() => {});
}, []);
const { register, handleSubmit, trigger, setValue, watch, control, formState: { errors } } = useForm({
const { register, handleSubmit, trigger, setValue, watch, control, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
defaultValues: { tier_category_id: "", label: "", description: "", features: [], duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
});
@@ -162,6 +163,12 @@ export default function AddPlan() {
const { fields: featureFields, append: appendFeature, remove: removeFeature } =
useFieldArray({ control, name: "features" });
// selectedCourseIds lives outside the form — this is a create page so it
// always starts empty, meaning any selection is a genuine unsaved change.
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(
isDirty || selectedCourseIds.size > 0
);
const selectedCategoryId = watch("tier_category_id");
// Derive the subscription slug from the chosen category
@@ -207,6 +214,7 @@ export default function AddPlan() {
}).catch(() => {});
}
bypassOnce();
navigate(`/admin/tiers/plans`);
};
@@ -421,6 +429,8 @@ export default function AddPlan() {
</form>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
+7 -1
View File
@@ -19,6 +19,7 @@ import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const DURATION_UNITS = [
{ value: "minute", label: "Minute(s)" },
@@ -92,13 +93,15 @@ export default function EditPlan() {
const [impactLoading, setImpactLoading] = useState(false);
const [pendingValues, setPendingValues] = useState(null);
const { register, handleSubmit, setValue, watch, reset, control, formState: { errors } } = useForm({
const { register, handleSubmit, setValue, watch, reset, control, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
});
const { fields: featureFields, append: appendFeature, remove: removeFeature } =
useFieldArray({ control, name: "features" });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
useEffect(() => {
fetchPlan(planId);
api.get("/admin/tiers/currencies")
@@ -150,6 +153,7 @@ export default function EditPlan() {
await api.post(`/admin/tiers/${planId}/courses`, {
course_ids: [...selectedCourseIds],
}).catch(() => {});
bypassOnce();
navigate("/admin/tiers/plans");
};
@@ -405,6 +409,8 @@ export default function EditPlan() {
</DialogFooter>
</DialogContent>
</Dialog>
{unsavedChangesDialog}
</section>
);
}
@@ -13,6 +13,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// ─── Zod schema ───────────────────────────────────────────────────────────────
const phoneSchema = z.object({
@@ -356,13 +357,15 @@ export default function AddStaffUserPage() {
trigger,
getValues,
handleSubmit,
formState: { errors },
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(staffUserSchema),
defaultValues: DEFAULT_VALUES,
mode: "onTouched",
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
// Validate all fields then advance to summary
const handleNext = async () => {
const valid = await trigger();
@@ -388,7 +391,7 @@ export default function AddStaffUserPage() {
};
const res = await addStaffUser(payload);
if (res) navigate("/admin/users/all");
if (res) { bypassOnce(); navigate("/admin/users/all"); }
});
return (
@@ -479,6 +482,7 @@ export default function AddStaffUserPage() {
)}
</div>
{unsavedChangesDialog}
</div>
);
}
@@ -17,6 +17,7 @@ import {
SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { ArrowLeft, Save, Plus, Trash2, UserCircle2 } from "lucide-react";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// ─── Schema ───────────────────────────────────────────────────────────────────
const addressSchema = z.object({
@@ -81,6 +82,8 @@ export default function EditUser() {
},
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
// ─── Field arrays ──────────────────────────────────────────────────────────
const {
fields: addressFields,
@@ -152,6 +155,7 @@ export default function EditUser() {
const res = await updateUser(id, payload);
if (res) {
toast("User updated successfully.");
bypassOnce();
navigate(`../view/${id}`);
} else {
toast("Failed to update user.");
@@ -418,6 +422,7 @@ export default function EditUser() {
))}
</div>
{unsavedChangesDialog}
</form>
);
}
+9 -13
View File
@@ -124,16 +124,15 @@ import NotificationBroadcastList from '../pages/notifications/NotificationBroadc
import AddNotificationBroadcast from '../pages/notifications/AddNotificationBroadcast'
import EditNotificationBroadcast from '../pages/notifications/EditNotificationBroadcast'
import ViewNotificationBroadcast from '../pages/notifications/ViewNotificationBroadcast'
import NotificationSettings from '../pages/notifications/NotificationSettings'
import Jobs from '../pages/jobs/Jobs'
import NotificationTemplates from '../pages/notifications/NotificationTemplates'
import AddNotificationTemplate from '../pages/notifications/AddNotificationTemplate'
import EditNotificationTemplate from '../pages/notifications/EditNotificationTemplate'
import ArchivedNotificationBroadcastList from '../pages/notifications/ArchivedNotificationBroadcastList'
// Activity
import ActivityFeed from '../pages/activity/ActivityFeed'
import UserActivityPage from '../pages/activity/UserActivityPage'
import ResourceList from '../pages/resources/ResourceList'
export const AdminRoutes = {
@@ -187,14 +186,6 @@ export const AdminRoutes = {
],
},
{
path: 'resources',
element: <Outlet />,
children: [
{ index: true, element: <ResourceList /> },
],
},
// Courses
{
path: 'courses',
@@ -379,6 +370,9 @@ export const AdminRoutes = {
]
},
// Jobs (cron scheduling for announcement/notification jobs)
{ path: 'jobs', element: <Jobs /> },
// Announcements (admin-authored broadcasts)
{
path: 'announcements',
@@ -387,7 +381,7 @@ export const AdminRoutes = {
{ index: true, element: <NotificationBroadcastList /> },
{ path: 'archived', element: <ArchivedNotificationBroadcastList /> },
{ path: 'add', element: <AddNotificationBroadcast /> },
{ path: 'settings', element: <NotificationSettings /> },
{ path: 'settings', element: <Jobs /> },
{ path: ':broadcastId/view', element: <ViewNotificationBroadcast /> },
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
]
@@ -397,6 +391,7 @@ export const AdminRoutes = {
element: <Outlet />,
children: [
{ index: true, element: <NotificationTemplates /> },
{ path: 'add', element: <AddNotificationTemplate /> },
{ path: ':id/edit', element: <EditNotificationTemplate /> },
]
},
@@ -409,7 +404,7 @@ export const AdminRoutes = {
{ index: true, element: <NotificationBroadcastList /> },
{ path: 'archived', element: <ArchivedNotificationBroadcastList /> },
{ path: 'add', element: <AddNotificationBroadcast /> },
{ path: 'settings', element: <NotificationSettings /> },
{ path: 'settings', element: <Jobs /> },
{ path: ':broadcastId/view', element: <ViewNotificationBroadcast /> },
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
]
@@ -419,6 +414,7 @@ export const AdminRoutes = {
element: <Outlet />,
children: [
{ index: true, element: <NotificationTemplates /> },
{ path: 'add', element: <AddNotificationTemplate /> },
{ path: ':id/edit', element: <EditNotificationTemplate /> },
]
},
+14 -4
View File
@@ -21,6 +21,7 @@ export const LessonCard = ({ lesson, onViewDetails }) => {
const locked = lesson.is_locked;
const duration = formatDuration(lesson.duration_seconds);
const unitCount = Number(lesson.unit_count ?? 0);
const courses = lesson.courses ?? [];
return (
<div
@@ -34,14 +35,23 @@ export const LessonCard = ({ lesson, onViewDetails }) => {
onClick={() => onViewDetails(lesson)}
>
<div className="flex flex-wrap gap-1.5">
{locked ? (
{locked && (
<Badge variant="secondary">
<LockIcon className="size-3" /> Locked
</Badge>
) : unitCount > 0 ? (
<Badge variant="outline"><Layers className="size-3" /> In {unitCount} unit{unitCount === 1 ? "" : "s"}</Badge>
)}
{unitCount > 0 ? (
<Badge variant="outline">
<Layers className="size-3" /> In {unitCount} unit{unitCount === 1 ? "" : "s"}
{courses[0] && (
<>
{" "}· <span className="truncate max-w-[120px] inline-block align-bottom">{courses[0].title}</span>
{courses.length > 1 ? ` +${courses.length - 1}` : ""}
</>
)}
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
!locked && <Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
)}
</div>
+10 -4
View File
@@ -20,6 +20,7 @@ export const UnitCard = ({ unit, onViewDetails }) => {
const duration = formatDuration(unit.duration_seconds);
const lessonCount = Number(unit.lesson_count ?? 0);
const courseCount = Number(unit.course_count ?? 0);
const courses = unit.courses ?? [];
return (
<div
@@ -33,14 +34,19 @@ export const UnitCard = ({ unit, onViewDetails }) => {
onClick={() => onViewDetails(unit)}
>
<div className="flex flex-wrap gap-1.5">
{locked ? (
{locked && (
<Badge variant="secondary">
<LockIcon className="size-3" /> Locked
</Badge>
) : courseCount > 0 ? (
<Badge variant="outline"><BookOpen className="size-3" /> In {courseCount} course{courseCount === 1 ? "" : "s"}</Badge>
)}
{courseCount > 0 ? (
<Badge variant="outline">
<BookOpen className="size-3" />
<span className="truncate max-w-[140px] inline-block align-bottom">{courses[0]?.title ?? "Course"}</span>
{courseCount > 1 ? ` +${courseCount - 1}` : ""}
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
!locked && <Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
)}
{unit.quiz_id && (
<Badge variant="outline"><ClipboardList className="size-3" /> Quiz</Badge>
+22 -18
View File
@@ -143,8 +143,6 @@ function ClientNav() {
const navigate = useNavigate()
const { user, logout } = useAuth()
const navRef = useRef(null)
// Background fetches only — nav rendering never waits on these
const { achievements, getAchievements } = useProfile()
const { myTier, tierMap, tierCategories, getMyTier, getTierCategories } = useClientTiers()
@@ -162,19 +160,6 @@ function ClientNav() {
if (tierCategories.length === 0) getTierCategories();
}, [user]);
// Used by sticky overlays (e.g. StickyAnnouncementBar) to avoid overlapping the fixed header.
useEffect(() => {
if (!navRef.current) return
const update = () => {
document.documentElement.style.setProperty('--navbar-h', `${navRef.current.offsetHeight}px`)
}
update()
const ro = new ResizeObserver(update)
ro.observe(navRef.current)
return () => ro.disconnect()
}, [])
// ── Derive directly from auth user — same pattern as admin UserMenu ──────
// No extra fetch, no loading state, no flicker on reload.
const given = user?.personal_info?.name?.given_name ?? ""
@@ -235,7 +220,7 @@ function ClientNav() {
return (
<>
<nav ref={navRef} className="bg-card fixed w-full z-50 top-0 border-b border-default">
<nav className="bg-card w-full border-b border-default">
<div className="flex flex-wrap items-center justify-between mx-auto py-3 px-6">
{/* Logo */}
@@ -357,11 +342,30 @@ const ClientLayout = () => {
const currentHandle = matches.at(-1)?.handle ?? {}
const showFooter = currentHandle.showFooter ?? true
// Sticky announcement bar and nav stack inside one fixed header instead of
// each being independently `fixed top-0` (which made them overlap). Height
// is measured off this wrapper so --navbar-h always reflects the combined
// space, whether or not an announcement is currently showing.
const headerRef = useRef(null)
useEffect(() => {
if (!headerRef.current) return
const update = () => {
document.documentElement.style.setProperty('--navbar-h', `${headerRef.current.offsetHeight}px`)
}
update()
const ro = new ResizeObserver(update)
ro.observe(headerRef.current)
return () => ro.disconnect()
}, [])
return (
<ClientProvider>
<div className="min-h-screen flex flex-col">
<ClientNav />
<StickyAnnouncementBar />
<header ref={headerRef} className="fixed top-0 left-0 right-0 z-50 flex flex-col">
<StickyAnnouncementBar />
<ClientNav />
</header>
<div className="flex-1 flex flex-col">
<Outlet />
</div>
+30 -20
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Timer, ChevronLeft, ChevronRight, LockIcon, Tag, Tags, Check, ShoppingCart } from "lucide-react";
import { House, Timer, ChevronLeft, ChevronRight, LockIcon, Tag, Tags, Check, ShoppingCart, Search } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Input } from "@/components/ui/input";
import {
@@ -170,6 +170,7 @@ const CoursesList = () => {
const [tierCategories, setTierCategories] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [subFilter, setSubFilter] = useState("All");
const [categoryFilter, setCategoryFilter] = useState("All");
@@ -215,6 +216,8 @@ const CoursesList = () => {
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const runSearch = () => { setSearch(searchInput); setCurrentPage(1); };
const handleViewDetails = (course) => {
if (course.is_locked) {
setSelectedCourse(course);
@@ -242,26 +245,20 @@ const CoursesList = () => {
{/* Search & Filters */}
<div className="fixed top-[67px] left-0 right-0 z-10 bg-card border-b lg:static lg:bg-transparent lg:border-none py-3 xs:px-4 md:px-6 lg:px-0">
<div className="flex items-center xs:flex-col lg:flex-row gap-4">
<Input
placeholder="Search courses..."
className="w-full bg-card lg:max-w-64 text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
/>
<div className="flex items-center gap-2 w-full lg:max-w-64">
<Input
placeholder="Search courses..."
className="w-full bg-card text-sm"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") runSearch(); }}
/>
<Button size="icon" variant="outline" className="shrink-0 bg-card" onClick={runSearch} aria-label="Search">
<Search />
</Button>
</div>
<div className="flex gap-4 items-start w-full">
<Select value="courses" onValueChange={(v) => {
if (v === "units") navigate("/units");
if (v === "lessons") navigate("/lessons");
}}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Browse" />
</SelectTrigger>
<SelectContent>
<SelectItem value="courses">Courses</SelectItem>
<SelectItem value="units">Units</SelectItem>
<SelectItem value="lessons">Lessons</SelectItem>
</SelectContent>
</Select>
<Select value={subFilter} onValueChange={(v) => { setSubFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-full lg:w-48 bg-card">
@@ -276,6 +273,19 @@ const CoursesList = () => {
))}
</SelectContent>
</Select>
<Select value="courses" onValueChange={(v) => {
if (v === "units") navigate("/units");
if (v === "lessons") navigate("/lessons");
}}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Browse" />
</SelectTrigger>
<SelectContent>
<SelectItem value="courses">Courses</SelectItem>
<SelectItem value="units">Units</SelectItem>
<SelectItem value="lessons">Lessons</SelectItem>
</SelectContent>
</Select>
{allCategories.length > 0 && (
<Select value={categoryFilter} onValueChange={(v) => { setCategoryFilter(v); setCurrentPage(1); }}>
+110 -83
View File
@@ -1,8 +1,9 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom";
import {
House, Timer, SendHorizonal, CheckCheck, ListChecks, ArrowRight, Layers, Hourglass,
House, SendHorizonal, CheckCheck, Check, Hourglass,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { useEffect } from "react";
@@ -21,13 +22,64 @@ function formatDuration(seconds = 0) {
return `${m}min`;
}
// ─── Sibling lessons sidebar (unit context) ────────────────────────────────────
const UnitLessonsSidebar = ({ unitDetail, currentLessonUuid, onSelect }) => {
if (!unitDetail) return null;
const lessons = unitDetail.lessons ?? [];
return (
<aside className="lg:w-80 shrink-0 bg-muted xs:px-4 xs:py-8 lg:px-4 lg:py-8 flex flex-col gap-3">
<span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">{unitDetail.title}</span>
<div className="flex flex-col gap-1">
{lessons.map((l) => {
const isCurrent = l.uuid === currentLessonUuid;
const completed = l.status === "completed";
return (
<div
key={l.lesson_id}
onClick={() => onSelect(l)}
className={cn(
"flex items-center justify-between gap-2 py-2.5 px-3 rounded-lg cursor-pointer text-sm transition-colors",
isCurrent
? "bg-background border shadow-sm font-medium text-card-foreground"
: "hover:bg-background/60"
)}
>
<span className={cn(
"truncate",
!isCurrent && !completed && "text-muted-foreground"
)}>
{l.title}
</span>
{completed ? (
<Check className="size-4 text-emerald-500 shrink-0" />
) : formatDuration(l.duration_seconds) && (
<span className={cn(
"text-xs shrink-0",
isCurrent ? "text-blue-600 dark:text-blue-400" : "text-muted-foreground"
)}>
{formatDuration(l.duration_seconds)}
</span>
)}
</div>
);
})}
</div>
</aside>
);
};
// ─── Lesson Details ─────────────────────────────────────────────────────────
const LessonDetails = () => {
const { uuid } = useParams();
const navigate = useNavigate();
const { getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson } = useLibrary();
const {
getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson,
getUnitDetail, unitDetail, resetUnitDetail,
} = useLibrary();
const { tierMap, getTierCategories } = useClientTiers();
const hasCompleted = lesson?.status === "completed";
@@ -41,6 +93,13 @@ const LessonDetails = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uuid]);
useEffect(() => {
if (!hasUnit) return;
getUnitDetail(unit.uuid);
return () => resetUnitDetail();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [unit?.uuid]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
{ label: "Lessons", to: `/lessons` },
@@ -67,96 +126,64 @@ const LessonDetails = () => {
if (!hasUnit) return;
navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } });
};
const handleSelectSibling = (sibling) => {
if (sibling.uuid === uuid) return;
navigate(`/lessons/${sibling.uuid}`);
};
return (
<div>
<div className="flex-1 flex flex-col">
<PageMeta title={`${lesson.title} - STARR`} description={lesson.description} />
<div className="my-17">
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-6">
<div
className="flex-1 flex flex-col lg:flex-row items-stretch"
style={{ paddingTop: "var(--navbar-h)" }}
>
<div className="flex flex-col gap-6 flex-1 min-w-0 xs:px-4 xs:py-8 lg:px-16 lg:py-10">
<AppBreadcrumb items={items} />
{/* Hero */}
<div className="bg-primary dark:bg-accent/50">
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-6 lg:px-4 lg:py-16">
<AppBreadcrumb
color={{ link: { color: "text-white" }, page: { color: "text-white" } }}
items={items}
/>
<div className="flex lg:flex-row items-start justify-between w-full text-white">
<div className="flex flex-col gap-4">
<h1 className="font-bold xs:text-2xl lg:text-4xl">{lesson.title}</h1>
<p className="max-w-2xl xs:text-sm lg:text-lg">{lesson.description ?? ""}</p>
{lesson.duration_seconds > 0 && (
<div className="[&_svg]:size-4 flex gap-1.5 items-center">
<Timer />
{formatDuration(lesson.duration_seconds)}
</div>
)}
<div className="w-fit">
{!hasUnit ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
<Hourglass className="size-4 shrink-0" />
This lesson isn't part of a unit yet. Check back later.
</div>
) : (
<Button
className="w-fit bg-blue-500"
onClick={handleStart}
>
{hasCompleted
? <><CheckCheck /> Start Again</>
: <><SendHorizonal /> Start Lesson</>
}
</Button>
)}
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-3 max-w-2xl">
<h1 className="font-bold xs:text-2xl lg:text-3xl">{lesson.title}</h1>
<p className="text-muted-foreground">{lesson.description ?? ""}</p>
</div>
{lesson.objectives?.length > 0 && (
<div className="bg-background border rounded-lg p-4 flex flex-col gap-3 max-w-2xl">
<span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">What you'll learn</span>
<ul className="flex flex-col gap-2">
{lesson.objectives.map((obj) => (
<li key={obj.objective_id} className="flex items-start gap-2 text-card-foreground">
<Check className="size-4 text-emerald-500 shrink-0 mt-0.5" />
{obj.text}
</li>
))}
</ul>
</div>
)}
{/* Body */}
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
<div className="flex flex-col gap-8 max-w-3xl">
<div className="space-y-4">
<div className="font-bold text-2xl">About this lesson</div>
<div className="space-y-4 text-muted-foreground lg:text-lg">
<p>{lesson.description ?? ""}</p>
</div>
</div>
{lesson.objectives?.length > 0 && (
<div className="space-y-4">
<div className="font-bold text-2xl flex items-center gap-2">
<ListChecks className="size-5 text-muted-foreground" />
Objectives
</div>
<ul className="space-y-2">
{lesson.objectives.map((obj) => (
<li key={obj.objective_id} className="flex items-start gap-2 text-muted-foreground lg:text-lg">
<span className="mt-2.5 size-1.5 rounded-full bg-muted-foreground/60 shrink-0" />
{obj.text}
</li>
))}
</ul>
</div>
)}
{hasUnit && (
<button
type="button"
onClick={() => navigate(`/units/${unit.uuid}`)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<Layers className="size-4" />
Part of unit: <span className="font-medium text-foreground">{unit.title}</span>
<ArrowRight className="size-3.5" />
</button>
)}
<div className="w-fit">
{!hasUnit ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
<Hourglass className="size-4 shrink-0" />
This lesson isn't part of a unit yet. Check back later.
</div>
</div>
) : (
<Button className="w-fit bg-blue-500" onClick={handleStart}>
{hasCompleted
? <><CheckCheck /> Start Again</>
: <><SendHorizonal /> Start Lesson</>
}
</Button>
)}
</div>
</div>
{hasUnit && (
<UnitLessonsSidebar
unitDetail={unitDetail}
currentLessonUuid={uuid}
onSelect={handleSelectSibling}
/>
)}
</div>
</div>
);
+16 -7
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, ChevronLeft, ChevronRight, Layers } from "lucide-react";
import { House, ChevronLeft, ChevronRight, Layers, Search } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Input } from "@/components/ui/input";
import {
@@ -70,6 +70,7 @@ const LessonsList = () => {
const { tierMap, getTierCategories } = useClientTiers();
const [currentPage, setCurrentPage] = useState(1);
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [lockFilter, setLockFilter] = useState("All");
const [modalOpen, setModalOpen] = useState(false);
@@ -98,6 +99,8 @@ const LessonsList = () => {
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const runSearch = () => { setSearch(searchInput); setCurrentPage(1); };
const handleViewDetails = (lesson) => {
if (lesson.is_locked) {
setSelectedLesson(lesson);
@@ -122,12 +125,18 @@ const LessonsList = () => {
{/* Search & Filters */}
<div className="fixed top-[67px] left-0 right-0 z-10 bg-card border-b lg:static lg:bg-transparent lg:border-none py-3 xs:px-4 md:px-6 lg:px-0">
<div className="flex items-center xs:flex-col lg:flex-row gap-4">
<Input
placeholder="Search lessons..."
className="w-full bg-card lg:max-w-64 text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
/>
<div className="flex items-center gap-2 w-full lg:max-w-64">
<Input
placeholder="Search lessons..."
className="w-full bg-card text-sm"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") runSearch(); }}
/>
<Button size="icon" variant="outline" className="shrink-0 bg-card" onClick={runSearch} aria-label="Search">
<Search />
</Button>
</div>
<Select value={lockFilter} onValueChange={(v) => { setLockFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Access" />
+118 -136
View File
@@ -1,12 +1,12 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom";
import {
House, Timer, Layers, SendHorizonal, CheckCheck, CheckCircle2, Circle,
FileQuestion, Hourglass, ClipboardList,
House, Timer, CheckCircle2, Check, ClipboardList, Hourglass,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { motion } from "framer-motion";
import { useEffect } from "react";
@@ -25,87 +25,91 @@ function formatDuration(seconds = 0) {
return `${m}min`;
}
// ─── Content card (single unit — lessons + quiz, no multi-unit spine) ─────────
// ─── Lessons roadmap (single unit — lessons in order + trailing quiz row) ─────
const UnitContentCard = ({ unitDetail, onLessonClick, onQuizClick }) => {
const LessonsRoadmap = ({ unitDetail, currentLessonId, onLessonClick, onContinue, onQuizClick }) => {
const lessons = unitDetail.lessons ?? [];
const quiz = unitDetail.quiz ?? null;
return (
<motion.div
className="rounded-xl border bg-card"
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
>
<div className="px-4 py-4 border-b flex flex-col gap-2">
<div className="[&_svg]:size-4 text-md text-muted-foreground flex items-center gap-4">
{lessons.length > 0 && (
<div className="flex items-center gap-1.5">
<Layers /> {lessons.length} {lessons.length === 1 ? "Lesson" : "Lessons"}
</div>
)}
{unitDetail.duration_seconds > 0 && (
<div className="flex items-center gap-1.5">
<Timer /> {formatDuration(unitDetail.duration_seconds)}
</div>
)}
{quiz && (
<div className="flex items-center gap-1.5">
<FileQuestion /> Quiz
</div>
)}
</div>
</div>
<div className="flex flex-col gap-1 p-1.5">
{lessons.map((lesson) => (
<div
<div className="flex flex-col gap-1">
{lessons.map((lesson, index) => {
const completed = lesson.status === "completed";
const isCurrent = lesson.lesson_id === currentLessonId;
return (
<motion.div
key={lesson.lesson_id}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-200 dark:hover:bg-blue-500 transition-colors cursor-pointer"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className={cn(
"flex items-center justify-between gap-3 py-3 px-3 rounded-lg cursor-pointer transition-colors",
isCurrent ? "bg-blue-50 dark:bg-blue-950/40" : "hover:bg-muted/60"
)}
onClick={() => onLessonClick(lesson)}
>
<div className="flex items-start gap-3 select-none min-w-0">
{lesson.status === "completed"
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0 mt-0.5" />
: <Circle className="size-4 text-muted-foreground/40 shrink-0 mt-0.5" />
}
<div className="flex items-start gap-3 min-w-0">
<div className={cn(
"w-7 h-7 rounded-full border bg-background flex items-center justify-center text-xs font-medium shrink-0",
completed
? "border-emerald-500 text-emerald-500"
: isCurrent
? "border-blue-500 text-blue-500"
: "border-border text-muted-foreground"
)}>
{completed ? <Check className="size-3.5" /> : index + 1}
</div>
<div className="min-w-0">
<span className="text-md text-card-foreground truncate block">{lesson.title}</span>
{lesson.description && (
<span className="text-sm text-muted-foreground line-clamp-1 block">{lesson.description}</span>
)}
<span className={cn(
"block truncate",
completed || isCurrent ? "font-medium text-card-foreground" : "text-muted-foreground"
)}>
{lesson.title}
</span>
<span className="text-sm text-muted-foreground">
{formatDuration(lesson.duration_seconds) ?? "—"}
{completed && " · Completed"}
{isCurrent && !completed && " · In progress"}
</span>
</div>
</div>
{lesson.duration_seconds > 0 && (
<span className="text-sm shrink-0 ml-2">{formatDuration(lesson.duration_seconds)}</span>
{isCurrent && !completed && (
<Button
size="sm"
className="bg-blue-500 shrink-0"
onClick={(e) => { e.stopPropagation(); onContinue(lesson); }}
>
Continue
</Button>
)}
</div>
))}
</motion.div>
);
})}
{quiz && (
<div
className="flex items-center justify-between py-2 px-3 mt-1 rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/60 dark:bg-blue-950/30 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors cursor-pointer"
onClick={() => onQuizClick()}
>
<div className="flex items-center gap-3 select-none min-w-0">
{quiz.has_passed
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
: <ClipboardList className="size-4 text-blue-500 shrink-0" />
}
<span className="text-sm font-medium text-blue-700 dark:text-blue-300 truncate">{quiz.title || "Quiz"}</span>
</div>
<Badge className={cn(
"shrink-0 ml-2 text-[10px]",
quiz.has_passed
? "bg-green-100 text-green-700 border border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700"
: "bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700"
)}>
{quiz.has_passed ? "Passed" : "Quiz"}
</Badge>
{quiz && (
<div
className="flex items-center justify-between py-3 px-3 mt-1 rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/60 dark:bg-blue-950/30 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors cursor-pointer"
onClick={() => onQuizClick()}
>
<div className="flex items-center gap-3 select-none min-w-0">
{quiz.has_passed
? <CheckCircle2 className="size-5 text-emerald-500 shrink-0" />
: <ClipboardList className="size-5 text-blue-500 shrink-0" />
}
<span className="font-medium text-blue-700 dark:text-blue-300 truncate">{quiz.title || "Quiz"}</span>
</div>
)}
</div>
</motion.div>
<Badge className={cn(
"shrink-0 ml-2 text-[10px]",
quiz.has_passed
? "bg-green-100 text-green-700 border border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700"
: "bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700"
)}>
{quiz.has_passed ? "Passed" : "Quiz"}
</Badge>
</div>
)}
</div>
);
};
@@ -118,7 +122,6 @@ const UnitDetails = () => {
const { getUnitDetail, unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo, resetUnitDetail } = useLibrary();
const { tierMap, getTierCategories } = useClientTiers();
const hasCompleted = !!unitDetail?.is_completed;
const contentNotReady = !unitDetail?.duration_seconds;
useEffect(() => {
@@ -149,9 +152,17 @@ const UnitDetails = () => {
);
}
const lessons = unitDetail?.lessons ?? [];
const completedCount = lessons.filter((l) => l.status === "completed").length;
const currentLesson = lessons.find((l) => l.status !== "completed") ?? null;
const progressPct = lessons.length > 0 ? Math.round((completedCount / lessons.length) * 100) : 0;
const handleLessonClick = (lesson) => {
navigate(`/lessons/${lesson.uuid}`);
};
const handleContinue = (lesson) => {
navigate(`/units/${uuid}/read`, { state: { lessonId: lesson.lesson_id } });
};
const handleQuizClick = () => {
navigate(`/units/${uuid}/read`, { state: { quizId: true } });
};
@@ -159,74 +170,45 @@ const UnitDetails = () => {
return (
<div>
<PageMeta title={unitDetail ? `${unitDetail.title} - STARR` : undefined} description={unitDetail?.description} />
<div className="my-17">
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-6">
<div className="xs:my-20 lg:my-28 lg:container lg:mx-auto xs:px-4 lg:px-4 flex flex-col gap-6 max-w-3xl">
<AppBreadcrumb items={items} />
{/* Hero */}
<div className="bg-primary dark:bg-accent/50">
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-6 lg:px-4 lg:py-16">
<AppBreadcrumb
color={{ link: { color: "text-white" }, page: { color: "text-white" } }}
items={items}
/>
<div className="flex lg:flex-row items-start justify-between w-full text-white">
<div className="flex flex-col gap-4">
<h1 className="font-bold xs:text-2xl lg:text-4xl">{unitDetail?.title ?? "Unit"}</h1>
<p className="max-w-2xl xs:text-sm lg:text-lg">{unitDetail?.description ?? ""}</p>
{unitDetail?.duration_seconds > 0 && (
<div className="[&_svg]:size-4 flex gap-1.5 items-center">
<Timer />
{formatDuration(unitDetail.duration_seconds)}
</div>
)}
<div className="w-fit">
{contentNotReady ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
<Hourglass className="size-4 shrink-0" />
This unit is currently being prepared. Please check back later.
</div>
) : (
<Button
className="w-fit bg-blue-500"
onClick={() => navigate(`/units/${uuid}/read`)}
>
{hasCompleted
? <><CheckCheck /> Start Again</>
: <><SendHorizonal /> Start Learning</>
}
</Button>
)}
</div>
</div>
</div>
</div>
</div>
{/* Body */}
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
<div className="flex flex-col gap-8 max-w-3xl">
<div className="space-y-4">
<div className="font-bold text-2xl">About this unit</div>
<div className="space-y-4 text-muted-foreground lg:text-lg">
<p>{unitDetail?.description ?? ""}</p>
</div>
</div>
{unitDetail && !contentNotReady && (
<div className="space-y-4">
<div className="font-bold text-2xl">Unit content</div>
<UnitContentCard
unitDetail={unitDetail}
onLessonClick={handleLessonClick}
onQuizClick={handleQuizClick}
/>
</div>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold tracking-wide text-blue-600 dark:text-blue-400 uppercase">Unit</span>
<h1 className="font-bold xs:text-2xl lg:text-4xl">{unitDetail?.title ?? "Unit"}</h1>
<p className="text-muted-foreground lg:text-lg">{unitDetail?.description ?? ""}</p>
</div>
{contentNotReady ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground w-fit">
<Hourglass className="size-4 shrink-0" />
This unit is currently being prepared. Please check back later.
</div>
) : (
<>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4">
<span>{lessons.length} {lessons.length === 1 ? "lesson" : "lessons"}</span>
<span>·</span>
<span className="flex items-center gap-1"><Timer /> {formatDuration(unitDetail.duration_seconds) ?? "—"} total</span>
<span>·</span>
<span className="text-blue-600 dark:text-blue-400 font-medium">{completedCount} of {lessons.length} complete</span>
</div>
<Progress value={progressPct} />
</div>
<div className="flex flex-col gap-3">
<h2 className="font-bold text-xl">Lessons in this unit</h2>
<LessonsRoadmap
unitDetail={unitDetail}
currentLessonId={currentLesson?.lesson_id}
onLessonClick={handleLessonClick}
onContinue={handleContinue}
onQuizClick={handleQuizClick}
/>
</div>
</>
)}
</div>
</div>
);
+16 -7
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, ChevronLeft, ChevronRight, Layers } from "lucide-react";
import { House, ChevronLeft, ChevronRight, Layers, Search } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Input } from "@/components/ui/input";
import {
@@ -70,6 +70,7 @@ const UnitsList = () => {
const { tierMap, getTierCategories } = useClientTiers();
const [currentPage, setCurrentPage] = useState(1);
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [lockFilter, setLockFilter] = useState("All");
const [modalOpen, setModalOpen] = useState(false);
@@ -98,6 +99,8 @@ const UnitsList = () => {
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const runSearch = () => { setSearch(searchInput); setCurrentPage(1); };
const handleViewDetails = (unit) => {
if (unit.is_locked) {
setSelectedUnit(unit);
@@ -122,12 +125,18 @@ const UnitsList = () => {
{/* Search & Filters */}
<div className="fixed top-[67px] left-0 right-0 z-10 bg-card border-b lg:static lg:bg-transparent lg:border-none py-3 xs:px-4 md:px-6 lg:px-0">
<div className="flex items-center xs:flex-col lg:flex-row gap-4">
<Input
placeholder="Search units..."
className="w-full bg-card lg:max-w-64 text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
/>
<div className="flex items-center gap-2 w-full lg:max-w-64">
<Input
placeholder="Search units..."
className="w-full bg-card text-sm"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") runSearch(); }}
/>
<Button size="icon" variant="outline" className="shrink-0 bg-card" onClick={runSearch} aria-label="Search">
<Search />
</Button>
</div>
<Select value={lockFilter} onValueChange={(v) => { setLockFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Access" />