mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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) {
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+7
-8
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 /> },
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user