Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-11 12:12:40 +08:00
parent 01a2c63b06
commit 71f758fe0b
66 changed files with 3055 additions and 939 deletions
@@ -0,0 +1,116 @@
import { useCallback, useState } from "react";
import { X } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
import { NotificationIcon, getTypeAccent, resolveStickyStyle } from "@/components/generic/notificationDisplay";
// Admin counterpart to StickyAnnouncementBar (client). Only supports the
// explicit link_url from the "On Open" section — the type-based fallback
// resolver in notificationDisplay.jsx points at client-only routes
// (/course/:id, /plans, /group/:id), which don't exist in the admin app.
export default function AdminStickyAnnouncementBar() {
const navigate = useNavigate();
const { stickyAnnouncement, markSeen } = useAdminNotifications();
const [detailsOpen, setDetailsOpen] = useState(false);
const onDismiss = useCallback(async () => {
if (!stickyAnnouncement) return;
await markSeen(stickyAnnouncement.notification_id);
}, [stickyAnnouncement, markSeen]);
// Opening the dialog must NOT mark it seen — markSeen clears
// stickyAnnouncement, which would unmount this component (dialog included)
// before it ever shows. Only the X button dismisses/marks seen.
const onClickBanner = useCallback(() => {
if (!stickyAnnouncement) return;
setDetailsOpen(true);
}, [stickyAnnouncement]);
if (!stickyAnnouncement) return null;
const accentClass = getTypeAccent(stickyAnnouncement.type);
const inlineStyle = resolveStickyStyle(stickyAnnouncement.data);
const linkUrl = stickyAnnouncement.data?.linkUrl || null;
const openLink = () => {
if (!linkUrl) return;
if (linkUrl.startsWith("/")) navigate(linkUrl);
else window.open(linkUrl, "_blank", "noopener,noreferrer");
};
return (
<>
<div
role="status"
className="w-full border-b shadow-sm px-4 md:px-6"
>
<div
onClick={onClickBanner}
className="w-full cursor-pointer bg-card rounded-none py-3 flex items-center justify-between gap-4"
style={inlineStyle ?? undefined}
>
<div className="flex items-start gap-3 min-w-0">
<div className={`flex h-9 w-9 items-center justify-center rounded ${accentClass}`}>
<NotificationIcon type={stickyAnnouncement.type} className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold leading-snug truncate">
{stickyAnnouncement.title || "Announcement"}
</p>
<p className="text-sm text-muted-foreground leading-snug line-clamp-2">
{stickyAnnouncement.message || ""}
</p>
</div>
</div>
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void onDismiss();
}}
aria-label="Dismiss sticky announcement"
title="Dismiss"
>
<X className="size-4" />
</Button>
</div>
</div>
<AlertDialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<div className={`flex h-8 w-8 items-center justify-center rounded shrink-0 ${accentClass}`}>
<NotificationIcon type={stickyAnnouncement.type} className="h-4 w-4" />
</div>
{stickyAnnouncement.title || "Announcement"}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<p className="whitespace-pre-wrap text-left pt-1 text-foreground">
{stickyAnnouncement.message || ""}
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Close</AlertDialogCancel>
{linkUrl && (
<AlertDialogAction onClick={openLink}>
Open Link
</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,72 @@
// ─── components/generic/Dialogs/UnsavedChangesDialog.jsx ─────────────────────
import { useRef } from "react";
import { AlertTriangle } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
/**
* Generic "leave without saving?" prompt. Rendered by useUnsavedChangesGuard —
* see that hook for the intended integration (drop its returned `dialog`
* anywhere in the page JSX, no direct usage of this component needed).
*/
export function UnsavedChangesDialog({
open,
onConfirm,
onCancel,
title = "Unsaved changes",
description = "You have unsaved changes. If you leave this page now, they will be lost.",
confirmLabel = "Leave without saving",
cancelLabel = "Stay on this page",
}) {
// Radix's AlertDialogAction/Cancel both auto-dismiss on click (firing
// onOpenChange(false)) *in addition to* their own onClick. Without this
// flag, clicking Action fires onConfirm() then onOpenChange(false) fires
// onCancel() right behind it — the reset immediately undoes the confirm,
// so "Leave without saving" silently does nothing.
const confirmedRef = useRef(false);
const handleConfirm = () => {
confirmedRef.current = true;
onConfirm?.();
};
const handleOpenChange = (next) => {
if (next) return;
if (confirmedRef.current) {
confirmedRef.current = false;
return;
}
onCancel?.();
};
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500" />
{title}
</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -1,61 +1,63 @@
import { useCallback } from "react"; import { useCallback, useState } from "react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useClientNotifications } from "@/contexts/ClientNotificationContext"; import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { NotificationIcon, getTypeAccent, resolveNotificationLink } from "@/components/generic/notificationDisplay"; import { NotificationIcon, getTypeAccent, resolveNotificationLink, resolveStickyStyle } from "@/components/generic/notificationDisplay";
function resolveStickyStyle(data) {
// Supports multiple possible shapes without tightly coupling to one admin UI.
const style = data?.sticky_style ?? data?.stickyStyle ?? data?.stickyColors ?? data?.colors ?? null;
if (!style) return null;
const background = style.background ?? style.bg ?? style.backgroundColor ?? null;
const text = style.text ?? style.color ?? style.foreground ?? null;
const border = style.border ?? style.borderColor ?? null;
if (!background && !text && !border) return null;
// Explicit link_url (from the admin "On Open" section) always wins. Falls back
// to the type-based resolver for broadcasts sent before that field existed.
function resolveClickAction(stickyAnnouncement) {
const explicitUrl = stickyAnnouncement.data?.linkUrl || null;
if (explicitUrl) {
return { return {
...(background ? { backgroundColor: background } : null), label: "Open Link",
...(text ? { color: text } : null), go: (navigate) => (explicitUrl.startsWith("/")
...(border ? { borderColor: border } : null), ? navigate(explicitUrl)
: window.open(explicitUrl, "_blank", "noopener,noreferrer")),
}; };
}
return resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data);
} }
export default function StickyAnnouncementBar() { export default function StickyAnnouncementBar() {
const navigate = useNavigate(); const navigate = useNavigate();
const { stickyAnnouncement, markSeen } = useClientNotifications(); const { stickyAnnouncement, markSeen } = useClientNotifications();
const [detailsOpen, setDetailsOpen] = useState(false);
const onDismiss = useCallback(async () => { const onDismiss = useCallback(async () => {
if (!stickyAnnouncement) return; if (!stickyAnnouncement) return;
await markSeen(stickyAnnouncement.notification_id); await markSeen(stickyAnnouncement.notification_id);
}, [stickyAnnouncement, markSeen]); }, [stickyAnnouncement, markSeen]);
const onClickBanner = useCallback(async () => { // Opening the dialog must NOT mark it seen — markSeen clears
// stickyAnnouncement, which would unmount this component (dialog included)
// before it ever shows. Only the X button dismisses/marks seen.
const onClickBanner = useCallback(() => {
if (!stickyAnnouncement) return; if (!stickyAnnouncement) return;
setDetailsOpen(true);
const id = stickyAnnouncement.notification_id; }, [stickyAnnouncement]);
await markSeen(id);
const link = resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data);
if (link) await link.go(navigate);
}, [stickyAnnouncement, markSeen, navigate]);
if (!stickyAnnouncement) return null; if (!stickyAnnouncement) return null;
const accentClass = getTypeAccent(stickyAnnouncement.type); const accentClass = getTypeAccent(stickyAnnouncement.type);
const inlineStyle = resolveStickyStyle(stickyAnnouncement.data); const inlineStyle = resolveStickyStyle(stickyAnnouncement.data);
const clickAction = resolveClickAction(stickyAnnouncement);
return ( return (
<>
<div <div
role="status" role="status"
className="fixed left-0 right-0 z-60 border-b shadow-sm px-4 md:px-6" className="w-full border-b shadow-sm px-4 md:px-6"
style={{ top: 0 }}
> >
<div <div
onClick={onClickBanner} onClick={onClickBanner}
className="w-full cursor-pointer bg-card border rounded-none px-4 py-3 flex items-start justify-between gap-4" className="w-full cursor-pointer bg-card rounded-none py-3 flex items-center justify-between gap-4"
style={inlineStyle ?? undefined} style={inlineStyle ?? undefined}
> >
<div className="flex items-start gap-3 min-w-0"> <div className="flex items-start gap-3 min-w-0">
@@ -74,10 +76,8 @@ export default function StickyAnnouncementBar() {
</div> </div>
<Button <Button
type="button" variant="outline"
variant="ghost"
size="icon" size="icon"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -90,6 +90,36 @@ export default function StickyAnnouncementBar() {
</Button> </Button>
</div> </div>
</div> </div>
{/* Full-content view — plain text info, or with an Open Link action
when the announcement was created with a link (see AddNotificationBroadcast's
"On Open" section). */}
<AlertDialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<div className={`flex h-8 w-8 items-center justify-center rounded shrink-0 ${accentClass}`}>
<NotificationIcon type={stickyAnnouncement.type} className="h-4 w-4" />
</div>
{stickyAnnouncement.title || "Announcement"}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<p className="whitespace-pre-wrap text-left pt-1 text-foreground">
{stickyAnnouncement.message || ""}
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Close</AlertDialogCancel>
{clickAction && (
<AlertDialogAction onClick={() => clickAction.go(navigate)}>
{clickAction.label}
</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
); );
} }
@@ -22,6 +22,26 @@ const TYPE_ACCENT = {
assessment: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400", assessment: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400",
}; };
// Shared by StickyAnnouncementBar (client) and AdminStickyAnnouncementBar —
// supports multiple possible admin-authored shapes without tightly coupling
// to one admin UI.
export function resolveStickyStyle(data) {
const style = data?.sticky_style ?? data?.stickyStyle ?? data?.stickyColors ?? data?.colors ?? null;
if (!style) return null;
const background = style.background ?? style.bg ?? style.backgroundColor ?? null;
const text = style.text ?? style.color ?? style.foreground ?? null;
const border = style.border ?? style.borderColor ?? null;
if (!background && !text && !border) return null;
return {
...(background ? { backgroundColor: background } : null),
...(text ? { color: text } : null),
...(border ? { borderColor: border } : null),
};
}
export function NotificationIcon({ type, className }) { export function NotificationIcon({ type, className }) {
const Icon = TYPE_ICON[type] ?? Bell; const Icon = TYPE_ICON[type] ?? Bell;
return <Icon className={cn("shrink-0", className)} />; return <Icon className={cn("shrink-0", className)} />;
+24 -2
View File
@@ -118,6 +118,19 @@ export function LibraryProvider({ children }) {
[request], [request],
); );
// One consolidated call — creates the unit, its lessons, their objectives
// and page-builder blocks, and attaches them, in a single request.
const createUnitFull = useCallback(
(payload) =>
request(async () => {
const { data } = await api.post(`${UNITS_BASE}/full`, payload);
const created = data?.data?.data ?? null;
if (created) toast("Unit created successfully.");
return data;
}),
[request],
);
const updateUnit = useCallback( const updateUnit = useCallback(
(unitId, payload) => (unitId, payload) =>
request(async () => { request(async () => {
@@ -311,6 +324,15 @@ export function LibraryProvider({ children }) {
[request], [request],
); );
const saveLessonPage = useCallback(
(lessonId, payload) =>
request(async () => {
const { data } = await api.put(`${LESSONS_BASE}/${lessonId}/page`, payload);
return data;
}),
[request],
);
const archiveLesson = useCallback( const archiveLesson = useCallback(
(lessonId) => (lessonId) =>
request(async () => { request(async () => {
@@ -401,7 +423,7 @@ export function LibraryProvider({ children }) {
// unit library // unit library
units, unit, unitsFlat, units, unit, unitsFlat,
fetchUnits, fetchUnitsFlat, fetchUnit, createUnit, updateUnit, fetchUnits, fetchUnitsFlat, fetchUnit, createUnit, createUnitFull, updateUnit,
archiveUnit, archiveUnits, fetchArchivedUnits, archiveUnit, archiveUnits, fetchArchivedUnits,
restoreUnit, restoreUnits, restoreUnit, restoreUnits,
permanentlyDeleteUnit, permanentlyDeleteUnits, permanentlyDeleteUnit, permanentlyDeleteUnits,
@@ -411,7 +433,7 @@ export function LibraryProvider({ children }) {
// lesson library // lesson library
lessons, lesson, lessonsFlat, lessons, lesson, lessonsFlat,
fetchLessons, fetchLessonsFlat, fetchLesson, createLesson, updateLesson, fetchLessons, fetchLessonsFlat, fetchLesson, createLesson, updateLesson, saveLessonPage,
archiveLesson, archiveLessons, fetchArchivedLessons, archiveLesson, archiveLessons, fetchArchivedLessons,
restoreLesson, restoreLessons, restoreLesson, restoreLessons,
permanentlyDeleteLesson, permanentlyDeleteLessons, permanentlyDeleteLesson, permanentlyDeleteLessons,
+20 -3
View File
@@ -14,6 +14,7 @@ export function useAdminNotifications() {
export function AdminNotificationProvider({ children }) { export function AdminNotificationProvider({ children }) {
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [unseenCount, setUnseenCount] = useState(0); const [unseenCount, setUnseenCount] = useState(0);
const [stickyAnnouncement, setStickyAnnouncement] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const intervalRef = useRef(null); const intervalRef = useRef(null);
@@ -26,6 +27,15 @@ export function AdminNotificationProvider({ children }) {
} }
}, []); }, []);
const fetchStickyAnnouncement = useCallback(async () => {
try {
const res = await api.get("/admin/notifications/sticky");
setStickyAnnouncement(res.data?.data?.announcement ?? null);
} catch {
// silent
}
}, []);
const fetchNotifications = useCallback(async () => { const fetchNotifications = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -47,6 +57,7 @@ export function AdminNotificationProvider({ children }) {
prev.map(n => n.notification_id === id ? { ...n, seen: true } : n) prev.map(n => n.notification_id === id ? { ...n, seen: true } : n)
); );
setUnseenCount(prev => Math.max(0, prev - 1)); setUnseenCount(prev => Math.max(0, prev - 1));
setStickyAnnouncement(prev => (prev?.notification_id === id ? null : prev));
} catch { } catch {
// silent // silent
} }
@@ -57,22 +68,28 @@ export function AdminNotificationProvider({ children }) {
await api.patch("/admin/notifications/seen-all"); await api.patch("/admin/notifications/seen-all");
setNotifications(prev => prev.map(n => ({ ...n, seen: true }))); setNotifications(prev => prev.map(n => ({ ...n, seen: true })));
setUnseenCount(0); setUnseenCount(0);
setStickyAnnouncement(null);
} catch { } catch {
// silent // silent
} }
}, []); }, []);
// Initial load + start polling unseen count // Initial load + start polling unseen count + sticky announcement
useEffect(() => { useEffect(() => {
fetchUnseen(); fetchUnseen();
intervalRef.current = setInterval(fetchUnseen, POLL_INTERVAL); fetchStickyAnnouncement();
intervalRef.current = setInterval(() => {
fetchUnseen();
fetchStickyAnnouncement();
}, POLL_INTERVAL);
return () => clearInterval(intervalRef.current); return () => clearInterval(intervalRef.current);
}, [fetchUnseen]); }, [fetchUnseen, fetchStickyAnnouncement]);
return ( return (
<AdminNotificationContext.Provider value={{ <AdminNotificationContext.Provider value={{
notifications, notifications,
unseenCount, unseenCount,
stickyAnnouncement,
loading, loading,
fetchNotifications, fetchNotifications,
markSeen, markSeen,
@@ -49,10 +49,26 @@ export function AdminNotificationTemplateProvider({ children }) {
return data.data; return data.data;
}), [request, template]); }), [request, template]);
const createTemplate = useCallback((payload) =>
request(async () => {
const { data } = await api.post("/admin/announcement-templates", payload);
setTemplates((prev) => [...prev, data.data]);
toast("Announcement template created.");
return data.data;
}), [request]);
const deleteTemplate = useCallback((id) =>
request(async () => {
await api.delete(`/admin/announcement-templates/${id}`);
setTemplates((prev) => prev.filter((t) => String(t.notification_template_id) !== String(id)));
toast("Announcement template deleted.");
return true;
}), [request]);
return ( return (
<AdminNotificationTemplateContext.Provider value={{ <AdminNotificationTemplateContext.Provider value={{
templates, template, loading, templates, template, loading,
fetchTemplates, fetchTemplate, updateTemplate, fetchTemplates, fetchTemplate, updateTemplate, createTemplate, deleteTemplate,
}}> }}>
{children} {children}
</AdminNotificationTemplateContext.Provider> </AdminNotificationTemplateContext.Provider>
+8 -15
View File
@@ -437,14 +437,15 @@ export function AdminTaskProvider({ children }) {
[request] [request]
); );
// Units/lessons/quizzes are standalone entities that may sit under 0..N
// courses (junction revamp) — each row now carries a `courses[]` binding
// array instead of a single course_title/order_index pair, and
// RequirementBuilder's ContentPicker builds its own search string from it,
// so no client-side `_search` precomputation is needed here anymore.
const fetchUnitsFlat = useCallback( const fetchUnitsFlat = useCallback(
() => request(async () => { () => request(async () => {
const res = await api.get('/admin/courses/units-flat'); const res = await api.get('/admin/courses/units-flat');
const raw = res.data?.data ?? []; return res.data?.data ?? [];
return raw.map((u) => ({
...u,
_search: `${u.course_title} unit ${u.order_index + 1} ${u.title}`.toLowerCase(),
}));
}), }),
[request] [request]
); );
@@ -452,11 +453,7 @@ export function AdminTaskProvider({ children }) {
const fetchLessonsFlat = useCallback( const fetchLessonsFlat = useCallback(
() => request(async () => { () => request(async () => {
const res = await api.get('/admin/courses/lessons-flat'); const res = await api.get('/admin/courses/lessons-flat');
const raw = res.data?.data ?? []; return res.data?.data ?? [];
return raw.map((l) => ({
...l,
_search: `${l.course_title} unit ${l.unit_order + 1} ${l.unit_title} lesson ${l.order_index + 1} ${l.title}`.toLowerCase(),
}));
}), }),
[request] [request]
); );
@@ -464,11 +461,7 @@ export function AdminTaskProvider({ children }) {
const fetchQuizzesFlat = useCallback( const fetchQuizzesFlat = useCallback(
() => request(async () => { () => request(async () => {
const res = await api.get('/admin/courses/quizzes-flat'); const res = await api.get('/admin/courses/quizzes-flat');
const raw = res.data?.data ?? []; return res.data?.data ?? [];
return raw.map((q) => ({
...q,
_search: `${q.course_title} ${q.unit_title} ${q.title}`.toLowerCase(),
}));
}), }),
[request] [request]
); );
+14 -13
View File
@@ -1,4 +1,4 @@
import { Users, GitFork, FolderOpen, BookText, BookCheck, FileText, ListCheck, ShieldCheck, Megaphone, Bell, Trophy } from "lucide-react"; import { Users, GitFork, FolderOpen, BookText, BookCheck, FileText, ListCheck, ShieldCheck, Megaphone, Bell, Trophy, Cog } from "lucide-react";
export const ADMIN_SECTIONS = [ export const ADMIN_SECTIONS = [
{ {
@@ -18,18 +18,10 @@ export const ADMIN_SECTIONS = [
title: "Resource Management", title: "Resource Management",
description: "It includes assets management and tier plans.", description: "It includes assets management and tier plans.",
tiles: [ tiles: [
{ key: "resources", label: "Resources", icon: FolderOpen, link: "/admin/resources" }, { key: "assets", label: "Assets", icon: FolderOpen, link: "/admin/assets" },
{ key: "tiers", label: "Tier Plans", icon: ShieldCheck, link: "/admin/tiers/plans" },
], ],
}, },
// {
// id: "section-assets",
// tab: "Resource Management",
// title: "Resource Management",
// description: "It includes assets management and tier plans.",
// tiles: [
// { key: "assets", label: "Resources", icon: FolderOpen, link: "/admin/assets" },
// ],
// },
{ {
id: "section-courses", id: "section-courses",
tab: "Content Management", tab: "Content Management",
@@ -40,7 +32,7 @@ export const ADMIN_SECTIONS = [
{ key: "units", label: "Units", icon: BookCheck, link: "/admin/units" }, { key: "units", label: "Units", icon: BookCheck, link: "/admin/units" },
{ key: "lessons", label: "Lessons", icon: FileText, link: "/admin/lessons" }, { key: "lessons", label: "Lessons", icon: FileText, link: "/admin/lessons" },
{ key: "tasks", label: "Tasks", icon: ListCheck, link: "/admin/taskList" }, { key: "tasks", label: "Tasks", icon: ListCheck, link: "/admin/taskList" },
// { key: "tiers", label: "Tier Plans", icon: ShieldCheck, link: "/admin/tiers/plans" },
], ],
}, },
{ {
@@ -50,8 +42,17 @@ export const ADMIN_SECTIONS = [
description: "Manage public-facing content", description: "Manage public-facing content",
tiles: [ tiles: [
{ key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" }, { key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" },
{ key: "notifications", label: "Announcements", icon: Bell, link: "/admin/notifications" }, { key: "notifications", label: "Announcements", icon: Bell, link: "/admin/announcements" },
{ key: "achievements", label: "Achievements", icon: Trophy, link: "/admin/achievements" }, { key: "achievements", label: "Achievements", icon: Trophy, link: "/admin/achievements" },
], ],
}, },
{
id: "section-system",
tab: "System",
title: "System",
description: "Background jobs and automation",
tiles: [
{ key: "jobs", label: "Jobs", icon: Cog, link: "/admin/jobs" },
],
},
]; ];
+6
View File
@@ -6,6 +6,9 @@ import { Megaphone, Image, BellRing, PanelRight } from "lucide-react";
// and which fields the Add/Edit form shows (hero needs headline/description/ctas, // and which fields the Add/Edit form shows (hero needs headline/description/ctas,
// banner/popup/sidebar are closer to image-only). // banner/popup/sidebar are closer to image-only).
// TODO(ads-1): Once placements are re-categorized (Hero -> Dashboard, Banner ->
// Tier Plans) and popup/sidebar placements are removed, drop the "popup" and
// "sidebar" entries here too — see data/placement.data.js.
export const ADVERTISEMENT_TYPES = [ export const ADVERTISEMENT_TYPES = [
{ value: "hero", label: "Hero", icon: Megaphone, description: "Large featured banner with headline, description, and CTAs" }, { value: "hero", label: "Hero", icon: Megaphone, description: "Large featured banner with headline, description, and CTAs" },
{ value: "banner", label: "Banner", icon: Image, description: "Simple image banner" }, { value: "banner", label: "Banner", icon: Image, description: "Simple image banner" },
@@ -23,6 +26,9 @@ export const RICH_CONTENT_TYPES = ["hero"];
// ─── Statuses ─────────────────────────────────────────────────────────────── // ─── Statuses ───────────────────────────────────────────────────────────────
// Drives: filter dropdown options, status badge color/label on each card. // Drives: filter dropdown options, status badge color/label on each card.
// TODO(ads-4): Remove "archived" from this list — it should no longer show up
// in the "All statuses" filter on AdvertisementList.jsx (archived ads live in
// their own separate Archived list/table, not mixed into the active filter).
export const ADVERTISEMENT_STATUSES = [ export const ADVERTISEMENT_STATUSES = [
{ value: "draft", label: "Draft", badgeClass: "bg-muted text-muted-foreground" }, { value: "draft", label: "Draft", badgeClass: "bg-muted text-muted-foreground" },
{ value: "active", label: "Active", badgeClass: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400" }, { value: "active", label: "Active", badgeClass: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400" },
+4
View File
@@ -6,6 +6,10 @@
// backend registry when adding a new placement — same pattern as ADVERTISEMENT_TYPES already // backend registry when adding a new placement — same pattern as ADVERTISEMENT_TYPES already
// mirroring the backend type ENUM. // mirroring the backend type ENUM.
// TODO(ads-1): Re-categorize placements — Hero -> Dashboard, Banner -> Tier Plans.
// Remove the "popup" and "sidebar" formats entirely (dashboard.popup,
// course_details.sidebar). Keep in sync with the backend registry at
// new_starr/models/advertisements/advertisements.placements.js.
export const PLACEMENTS = [ export const PLACEMENTS = [
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" }, { key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
{ key: "dashboard.popup", format: "popup", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Popup (on load)" }, { key: "dashboard.popup", format: "popup", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Popup (on load)" },
+15 -5
View File
@@ -16,22 +16,32 @@ export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) {
const [thumbnailUrl, setThumbnailUrl] = useState(null); const [thumbnailUrl, setThumbnailUrl] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Callers pass a fresh object literal on every render (built from a
// content prop), so the effect below keys off these primitive fields
// instead of `asset` itself — depending on the object reference would
// re-fire the effect (and its setState calls) on every render, looping
// forever since each firing produces a new src ("" vs null) that never
// stabilizes.
const { asset_id, storage_provider, file_url, thumbnail_url } = asset ?? {};
useEffect(() => { useEffect(() => {
setSrc(null); setSrc(null);
setThumbnailUrl(null); setThumbnailUrl(null);
if (!asset) return; if (!asset_id && !file_url && !thumbnail_url) return;
const fastSrc = resolveAssetSrc(asset); const currentAsset = { asset_id, storage_provider, file_url, thumbnail_url };
const fastSrc = resolveAssetSrc(currentAsset);
if (fastSrc) { if (fastSrc) {
setSrc(fastSrc); setSrc(fastSrc);
setThumbnailUrl(asset.thumbnail_url ?? null); setThumbnailUrl(thumbnail_url ?? null);
return; return;
} }
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
fetchAssetPreviewSrc(asset, { scope }).then((result) => { fetchAssetPreviewSrc(currentAsset, { scope }).then((result) => {
if (cancelled) return; if (cancelled) return;
setSrc(result.src); setSrc(result.src);
setThumbnailUrl(result.thumbnailUrl); setThumbnailUrl(result.thumbnailUrl);
@@ -40,7 +50,7 @@ export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) {
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [asset, scope]); }, [asset_id, storage_provider, file_url, thumbnail_url, scope]);
return { src, thumbnailUrl, loading }; return { src, thumbnailUrl, loading };
} }
+73
View File
@@ -0,0 +1,73 @@
// hooks/useUnsavedChangesGuard.js
//
// One-call gate for "leave this page?" confirmations across the admin side.
// Covers every way a user can leave: in-app <Link>/navigate() calls, the
// browser's own Back/Forward buttons (both go through the router as POP
// navigations since router.jsx uses createBrowserRouter), and hard navigation
// (refresh/close tab/typed URL) via beforeunload.
//
// Usage:
// const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
// ...
// const handleFinish = async () => {
// const ok = await save();
// if (!ok) return;
// bypassOnce(); // don't gate the navigate we're about to do ourselves
// navigate("/somewhere");
// };
// ...
// return <div>...{unsavedChangesDialog}</div>;
import { useCallback, useEffect, useRef } from "react";
import { useBlocker } from "react-router-dom";
import { UnsavedChangesDialog } from "@/components/generic/Dialogs/UnsavedChangesDialog";
export function useUnsavedChangesGuard(hasUnsavedChanges, dialogProps = {}) {
const bypassRef = useRef(false);
const blocker = useBlocker(
useCallback(
({ currentLocation, nextLocation }) =>
hasUnsavedChanges &&
!bypassRef.current &&
currentLocation.pathname !== nextLocation.pathname,
[hasUnsavedChanges]
)
);
useEffect(() => {
if (!hasUnsavedChanges) return;
const handler = (e) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [hasUnsavedChanges]);
const confirmLeave = useCallback(() => {
if (blocker.state === "blocked") blocker.proceed();
}, [blocker]);
const cancelLeave = useCallback(() => {
if (blocker.state === "blocked") blocker.reset();
}, [blocker]);
// Call right before an intentional programmatic navigate() (e.g. after a
// successful save) so that navigation isn't gated by its own guard.
const bypassOnce = useCallback(() => { bypassRef.current = true; }, []);
return {
isBlocked: blocker.state === "blocked",
confirmLeave,
cancelLeave,
bypassOnce,
dialog: (
<UnsavedChangesDialog
open={blocker.state === "blocked"}
onConfirm={confirmLeave}
onCancel={cancelLeave}
{...dialogProps}
/>
),
};
}
@@ -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"> <div className="flex flex-col items-center justify-center gap-3 py-10 text-center">
<BookOpen className="h-8 w-8 text-muted-foreground" /> <BookOpen className="h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium">No units yet</p> <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>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@@ -12,7 +12,6 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { formatDuration } from "@/utils/timestamp.util"; 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)); .filter((u) => !q || u.title?.toLowerCase().includes(q));
}, [unitsFlat, attachedSet, query]); }, [unitsFlat, attachedSet, query]);
const toggle = (unitId, blocked) => { const toggle = (unitId) => {
if (blocked) return;
setSelected((prev) => setSelected((prev) =>
prev.includes(unitId) ? prev.filter((id) => id !== unitId) : [...prev, unitId] 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 () => { const handleAttach = async () => {
if (!selected.length) return; if (!selected.length) return;
await onAttach(selected); await onAttach(selected);
@@ -90,19 +93,16 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
<div className="divide-y"> <div className="divide-y">
{candidates.map((u) => { {candidates.map((u) => {
const blocked = Number(u.course_count) > 0; const blocked = Number(u.course_count) > 0;
const isSelected = selected.includes(u.unit_id);
return ( return (
<label <label
key={u.unit_id} key={u.unit_id}
title={blocked ? "Already attached to another course — a unit can only belong to one course at a time." : undefined} title={blocked ? `Currently in "${u.course_title}" — selecting it will attach a copy to this course.` : undefined}
className={[ className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/60 cursor-pointer"
"flex items-center gap-3 px-3 py-2.5",
blocked ? "opacity-60 cursor-not-allowed" : "hover:bg-muted/60 cursor-pointer",
].join(" ")}
> >
<Checkbox <Checkbox
checked={selected.includes(u.unit_id)} checked={isSelected}
disabled={blocked} onCheckedChange={() => toggle(u.unit_id)}
onCheckedChange={() => toggle(u.unit_id, blocked)}
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{u.title}</p> <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)} {u.lesson_count ?? 0} lesson{(u.lesson_count ?? 0) === 1 ? "" : "s"} · {formatDuration(u.duration_seconds ?? 0)}
</p> </p>
</div> </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> </label>
); );
})} })}
@@ -124,13 +117,20 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
)} )}
</ScrollArea> </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> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}> <Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleAttach} disabled={loading || !selected.length}> <Button onClick={handleAttach} disabled={loading || !selected.length}>
{loading && <Spinner className="h-4 w-4 mr-2" />} {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> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -11,6 +11,10 @@ export const columnPinning = {
const cellOverrides = {}; 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. * Builds the full column array for the Archived Advertisements table.
* *
@@ -13,6 +13,12 @@ export const columnPinning = {
left: [], left: [],
}; };
const COURSE_STATUS_BADGE = {
published: "default",
draft: "secondary",
standalone: "outline",
};
const cellOverrides = { const cellOverrides = {
duration_seconds: (info) => { duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10); const seconds = parseInt(info.getValue() ?? 0, 10);
@@ -25,21 +31,18 @@ const cellOverrides = {
</div> </div>
); );
}, },
unit_count: (info) => { course_count: (info) => {
const n = parseInt(info.getValue() ?? 0, 10); const n = parseInt(info.getValue() ?? 0, 10);
return n > 0 ? ( return (
<Badge variant="secondary" className="text-xs tabular-nums"> <Badge variant="secondary" className="text-xs tabular-nums">
in {n} unit{n === 1 ? "" : "s"} {n} course{n === 1 ? "" : "s"}
</Badge> </Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">standalone</Badge>
); );
}, },
course_bound: (info) => course_status: (info) => (
info.getValue() ? ( <Badge variant={COURSE_STATUS_BADGE[info.getValue()] ?? "outline"} className="text-xs capitalize">
<Badge variant="default" className="text-xs">In a course</Badge> {info.getValue()}
) : ( </Badge>
<Badge variant="outline" className="text-xs text-muted-foreground">Not in a course</Badge>
), ),
}; };
@@ -14,12 +14,12 @@ export function buildRowActions({ onView, onEdit, onBuildPage, onViewPage, onArc
icon: <Eye className="h-3.5 w-3.5" />, icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row), onClick: (row) => onView(row),
}, },
{ // {
key: "edit", // key: "edit",
label: "Edit Lesson", // label: "Edit Lesson",
icon: <Pencil className="h-3.5 w-3.5" />, // icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => onEdit(row), // onClick: (row) => onEdit(row),
}, // },
{ {
key: "build_page", key: "build_page",
label: "Page Builder", label: "Page Builder",
@@ -13,6 +13,12 @@ export const columnPinning = {
left: [], left: [],
}; };
const COURSE_STATUS_BADGE = {
published: "default",
draft: "secondary",
standalone: "outline",
};
const cellOverrides = { const cellOverrides = {
duration_seconds: (info) => { duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10); const seconds = parseInt(info.getValue() ?? 0, 10);
@@ -32,14 +38,17 @@ const cellOverrides = {
), ),
course_count: (info) => { course_count: (info) => {
const n = parseInt(info.getValue() ?? 0, 10); const n = parseInt(info.getValue() ?? 0, 10);
return n > 0 ? ( return (
<Badge variant="secondary" className="text-xs tabular-nums"> <Badge variant="secondary" className="text-xs tabular-nums">
in {n} course{n === 1 ? "" : "s"} {n} course{n === 1 ? "" : "s"}
</Badge> </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) { export function buildDataColumns(attributes, rowActions) {
+3 -1
View File
@@ -22,6 +22,7 @@ import { cn } from "@/lib/utils"
import UserMenu from "@/components/generic/UserMenu" import UserMenu from "@/components/generic/UserMenu"
import NotificationBell from "@/components/generic/NotificationBell" import NotificationBell from "@/components/generic/NotificationBell"
import AdminStickyAnnouncementBar from "@/components/generic/AdminStickyAnnouncementBar"
import { ROLE_CONFIG } from "@/data/profile.data" import { ROLE_CONFIG } from "@/data/profile.data"
const AdminLayout = () => { const AdminLayout = () => {
@@ -43,11 +44,12 @@ const AdminLayout = () => {
}, []) }, [])
return ( 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> <TooltipProvider>
{/* AdminProvider wraps header + body so UserMenu can access ProfileProvider */} {/* AdminProvider wraps header + body so UserMenu can access ProfileProvider */}
<AdminProvider> <AdminProvider>
<div ref={headerRef} className={cn('fixed top-0 z-50 w-full bg-background border-b')} > <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="w-full flex items-center justify-between xs:px-4 lg:px-5 py-3">
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="w-40 cursor-pointer" onClick={() => navigate("/")}> <div className="w-40 cursor-pointer" onClick={() => navigate("/")}>
@@ -12,6 +12,7 @@ import {
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext"; import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { resolveAssetSrc } from "@/utils/media.util"; import { resolveAssetSrc } from "@/utils/media.util";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; 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 ──────────────────────────────────────────────────────────────────── // ─── Steps ────────────────────────────────────────────────────────────────────
// richOnly steps are skipped entirely for placements whose format isn't a // richOnly steps are skipped entirely for placements whose format isn't a
// RICH_CONTENT_TYPES format (banner/popup/sidebar never had CTAs). // RICH_CONTENT_TYPES format (banner/popup/sidebar never had CTAs).
@@ -457,7 +473,7 @@ export default function AddAdvertisement() {
getValues, getValues,
watch, watch,
setValue, setValue,
formState: { errors }, formState: { errors, isDirty },
} = useForm({ } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
@@ -477,6 +493,12 @@ export default function AddAdvertisement() {
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" }); 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 placement = watch("placement");
const description = watch("description"); const description = watch("description");
const format = PLACEMENT_MAP[placement]?.format; const format = PLACEMENT_MAP[placement]?.format;
@@ -522,7 +544,7 @@ export default function AddAdvertisement() {
}; };
const res = await createAdvertisement(payload); const res = await createAdvertisement(payload);
if (res) navigate("/admin/advertisements"); if (res) { bypassOnce(); navigate("/admin/advertisements"); }
}); });
return ( return (
@@ -626,6 +648,8 @@ export default function AddAdvertisement() {
setValue("image_asset_id", asset.asset_id, { shouldValidate: true }); setValue("image_asset_id", asset.asset_id, { shouldValidate: true });
}} }}
/> />
{unsavedChangesDialog}
</section> </section>
); );
} }
@@ -27,8 +27,18 @@ export default function AdvertisementList() {
const [typeFilter, setTypeFilter] = useState("all"); const [typeFilter, setTypeFilter] = useState("all");
const [placementFilter, setPlacementFilter] = useState("all"); const [placementFilter, setPlacementFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = 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(() => { useEffect(() => {
const filters = []; const filters = [];
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter }); if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
@@ -103,6 +113,7 @@ export default function AdvertisementList() {
</SelectContent> </SelectContent>
</Select> </Select>
{/* TODO(ads-2): Remove this "All placements" dropdown entirely. */}
<Select value={placementFilter} onValueChange={setPlacementFilter}> <Select value={placementFilter} onValueChange={setPlacementFilter}>
<SelectTrigger className="w-[220px] bg-background"> <SelectTrigger className="w-[220px] bg-background">
<SelectValue placeholder="All placements" /> <SelectValue placeholder="All placements" />
@@ -127,17 +138,31 @@ export default function AdvertisementList() {
</SelectContent> </SelectContent>
</Select> </Select>
<div className="relative flex-1 min-w-[160px]"> {/* 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" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input <Input
placeholder="Search advertisements..." placeholder="Search advertisements..."
className="pl-8 bg-background" className="pl-8 bg-background"
value={search} value={searchInput}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") setSearch(searchInput); }}
/> />
</div> </div>
<Button variant="outline" size="icon" className="shrink-0 bg-background" onClick={() => setSearch(searchInput)} aria-label="Search">
<Search className="size-4" />
</Button>
</div>
</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 ───────────────────────────────────────────────────── */} {/* ── Grid ───────────────────────────────────────────────────── */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-20"> <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 { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { resolveAssetSrc } from "@/utils/media.util"; import { resolveAssetSrc } from "@/utils/media.util";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button"; 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 { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const placement = watch("placement"); const placement = watch("placement");
const description = watch("description"); const description = watch("description");
const format = PLACEMENT_MAP[placement]?.format; const format = PLACEMENT_MAP[placement]?.format;
@@ -197,7 +200,7 @@ export default function EditAdvertisement() {
}, [advertisementId]); }, [advertisementId]);
const onSubmit = async (values) => { const onSubmit = async (values) => {
if (!isDirty) return navigate(-1); if (!isDirty) { bypassOnce(); return navigate(-1); }
const payload = { const payload = {
...values, ...values,
@@ -209,7 +212,7 @@ export default function EditAdvertisement() {
}; };
const res = await updateAdvertisement(advertisementId, payload); const res = await updateAdvertisement(advertisementId, payload);
if (res) navigate("/admin/advertisements"); if (res) { bypassOnce(); navigate("/admin/advertisements"); }
}; };
if (!ready) { if (!ready) {
@@ -466,6 +469,8 @@ export default function EditAdvertisement() {
setValue("image_asset_id", asset.asset_id, { shouldValidate: true, shouldDirty: true }); setValue("image_asset_id", asset.asset_id, { shouldValidate: true, shouldDirty: true });
}} }}
/> />
{unsavedChangesDialog}
</section> </section>
); );
} }
+9 -2
View File
@@ -9,6 +9,7 @@ import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from
import { useAssets } from "@/contexts/AdminAssetsContext"; import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -130,7 +131,7 @@ export default function AddAsset() {
watch, watch,
setError, setError,
clearErrors, clearErrors,
formState: { errors }, formState: { errors, isDirty },
} = useForm({ } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
@@ -145,6 +146,10 @@ export default function AddAsset() {
const isVideo = file?.type?.startsWith("video/"); const isVideo = file?.type?.startsWith("video/");
const isAudio = file?.type?.startsWith("audio/"); 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 ─────────────────────────────────────── // ── Auto-derive file_type from MIME ───────────────────────────────────────
const fileType = file ? resolveFileType(file.type) : null; const fileType = file ? resolveFileType(file.type) : null;
@@ -188,7 +193,7 @@ export default function AddAsset() {
createdBy: user?.user_id, createdBy: user?.user_id,
}); });
if (result) navigate(-1); if (result) { bypassOnce(); navigate(-1); }
}; };
return ( return (
@@ -357,6 +362,8 @@ export default function AddAsset() {
</Button> </Button>
</div> </div>
</form> </form>
{unsavedChangesDialog}
</div> </div>
); );
} }
@@ -9,6 +9,7 @@ import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, RefreshCw } from
import { useAssets } from "@/contexts/AdminAssetsContext"; import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -177,6 +178,8 @@ export default function EditAsset() {
const isVideo = asset?.file_type === "video"; const isVideo = asset?.file_type === "video";
const hasThumbnailChange = !!thumbnailRef.current; const hasThumbnailChange = !!thumbnailRef.current;
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || hasThumbnailChange);
const onSubmit = async (data) => { const onSubmit = async (data) => {
const result = await updateAsset( const result = await updateAsset(
assetId, assetId,
@@ -191,6 +194,7 @@ export default function EditAsset() {
); );
if (!result) return; if (!result) return;
bypassOnce();
navigate(-1); navigate(-1);
}; };
@@ -340,6 +344,8 @@ export default function EditAsset() {
</div> </div>
</form> </form>
{unsavedChangesDialog}
</div> </div>
); );
} }
@@ -11,6 +11,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useCategories } from "@/contexts/AdminCategoriesContext"; import { useCategories } from "@/contexts/AdminCategoriesContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({ const schema = z.object({
name: z.string().min(1, "Name is required."), name: z.string().min(1, "Name is required."),
@@ -36,14 +37,17 @@ export default function AddCategory() {
const navigate = useNavigate(); const navigate = useNavigate();
const { createCategory, loading } = useCategories(); const { createCategory, loading } = useCategories();
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ const { register, handleSubmit, watch, setValue, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { name: "", description: "", is_active: true }, defaultValues: { name: "", description: "", is_active: true },
}); });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const onSubmit = async (values) => { const onSubmit = async (values) => {
const result = await createCategory(values); const result = await createCategory(values);
if (!result) return; if (!result) return;
bypassOnce();
navigate("/admin/courses/categories"); navigate("/admin/courses/categories");
}; };
@@ -106,6 +110,8 @@ export default function AddCategory() {
</div> </div>
</div> </div>
{unsavedChangesDialog}
</section> </section>
); );
} }
@@ -12,6 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useCategories } from "@/contexts/AdminCategoriesContext"; import { useCategories } from "@/contexts/AdminCategoriesContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({ const schema = z.object({
name: z.string().min(1, "Name is required."), name: z.string().min(1, "Name is required."),
@@ -51,10 +52,13 @@ export default function EditCategory() {
})(); })();
}, [id]); }, [id]);
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const onSubmit = async (values) => { const onSubmit = async (values) => {
if (!isDirty) return navigate(-1); if (!isDirty) { bypassOnce(); return navigate(-1); }
const result = await updateCategory(id, values); const result = await updateCategory(id, values);
if (!result) return; if (!result) return;
bypassOnce();
navigate("/admin/courses/categories"); navigate("/admin/courses/categories");
}; };
@@ -117,6 +121,8 @@ export default function EditCategory() {
</div> </div>
</div> </div>
{unsavedChangesDialog}
</section> </section>
); );
} }
+148 -99
View File
@@ -4,8 +4,8 @@ import { useForm, useFieldArray, useWatch } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { import {
ArrowLeft, ArrowRight, Plus, Trash2, BadgeCheck, Trophy, ArrowLeft, ArrowRight, Plus, Trash2, BadgeCheck,
Check, ChevronsUpDown, X, ImagePlus, Palette, BookOpen, Check, X, ImagePlus, Palette, BookOpen,
} from "lucide-react"; } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
@@ -18,21 +18,15 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"; } 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 CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import RoadmapBuilder from "@/modules/admin/components/courses/RoadmapBuilder"; import RoadmapBuilder from "@/modules/admin/components/courses/RoadmapBuilder";
import AchievementsBuilder from "@/modules/admin/components/courses/AchievementsBuilder";
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors"; import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet"; import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// ─── Schema ─────────────────────────────────────────────────────────────────── // ─── Schema ───────────────────────────────────────────────────────────────────
@@ -43,6 +37,7 @@ const schema = z.object({
order_index: z.coerce.number().min(0).default(0), order_index: z.coerce.number().min(0).default(0),
level: z.enum(["beginner", "intermediate", "advanced"]).optional(), level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.string().min(1, "Subscription is required.").default("free"), 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.") })) objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
.min(1, "At least one learning objective is required."), .min(1, "At least one learning objective is required."),
achievement_keys: z.array(z.string()).max(1).default([]), achievement_keys: z.array(z.string()).max(1).default([]),
@@ -54,6 +49,7 @@ const STEPS = [
{ label: "Basic Info", description: "Title, level & objectives" }, { label: "Basic Info", description: "Title, level & objectives" },
{ label: "Roadmap", description: "Units & lessons" }, { label: "Roadmap", description: "Units & lessons" },
{ label: "Rewards", description: "Badge & achievements" }, { label: "Rewards", description: "Badge & achievements" },
{ label: "Review", description: "Confirm & create" },
]; ];
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -147,7 +143,6 @@ export default function AddCourse() {
const [badgeImageUrl, setBadgeImageUrl] = useState(null); const [badgeImageUrl, setBadgeImageUrl] = useState(null);
const [badgeAssetId, setBadgeAssetId] = useState(null); const [badgeAssetId, setBadgeAssetId] = useState(null);
const [assetPickerOpen, setAssetPickerOpen] = useState(false); const [assetPickerOpen, setAssetPickerOpen] = useState(false);
const [achOpen, setAchOpen] = useState(false);
const { const {
register, register,
@@ -156,7 +151,7 @@ export default function AddCourse() {
setValue, setValue,
getValues, getValues,
trigger, trigger,
formState: { errors }, formState: { errors, isDirty },
} = useForm({ } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
@@ -166,6 +161,7 @@ export default function AddCourse() {
order_index: 0, order_index: 0,
level: "beginner", level: "beginner",
subscription: "free", subscription: "free",
status: "draft",
objectives: [], objectives: [],
achievement_keys: [], achievement_keys: [],
}, },
@@ -176,8 +172,13 @@ export default function AddCourse() {
const currentAchKeys = useWatch({ control, name: "achievement_keys" }); const currentAchKeys = useWatch({ control, name: "achievement_keys" });
const watchedTitle = useWatch({ control, name: "title" }); 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 watchedLevel = useWatch({ control, name: "level" });
const watchedSubscr = useWatch({ control, name: "subscription" }); const watchedSubscr = useWatch({ control, name: "subscription" });
const watchedStatus = useWatch({ control, name: "status" });
const watchedObjectives = useWatch({ control, name: "objectives" });
const [achievementRegistry, setAchievementRegistry] = useState([]); const [achievementRegistry, setAchievementRegistry] = useState([]);
useEffect(() => { useEffect(() => {
@@ -186,13 +187,22 @@ export default function AddCourse() {
.catch(() => setAchievementRegistry([])); .catch(() => setAchievementRegistry([]));
}, []); }, []);
const toggleAchievement = (key) => { const selectedAchievement = achievementRegistry.find((a) => a.key === currentAchKeys[0]) ?? null;
if (currentAchKeys.includes(key)) { const totalLessons = roadmapUnits.reduce(
setValue("achievement_keys", currentAchKeys.filter((k) => k !== key), { shouldDirty: true }); (sum, u) => sum + u.lessons.length + (u.existing_lesson_count ?? 0),
} else { 0
setValue("achievement_keys", [key], { shouldDirty: true }); );
}
}; // 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 // Nothing here writes to the API until Finish — Basic Info just validates
// and advances, Roadmap is held as local draft state (see roadmapUnits), // and advances, Roadmap is held as local draft state (see roadmapUnits),
@@ -225,6 +235,7 @@ export default function AddCourse() {
order_index: values.order_index, order_index: values.order_index,
level: values.level || null, level: values.level || null,
subscription: values.subscription, subscription: values.subscription,
status: values.status,
objectives: values.objectives.map((o) => o.text), objectives: values.objectives.map((o) => o.text),
achievement_keys: currentAchKeys, achievement_keys: currentAchKeys,
badge_color: badgeColor, badge_color: badgeColor,
@@ -246,6 +257,7 @@ export default function AddCourse() {
const newCourse = result?.data?.data ?? null; const newCourse = result?.data?.data ?? null;
if (!newCourse) return; if (!newCourse) return;
bypassOnce();
navigate(`/admin/courses/${newCourse.course_id}/view`); navigate(`/admin/courses/${newCourse.course_id}/view`);
}; };
@@ -316,7 +328,7 @@ export default function AddCourse() {
</SectionCard> </SectionCard>
<SectionCard title="Settings"> <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"> <div className="space-y-1.5">
<Label>Level</Label> <Label>Level</Label>
<Select <Select
@@ -354,6 +366,24 @@ export default function AddCourse() {
</Select> </Select>
<FieldError message={errors.subscription?.message} /> <FieldError message={errors.subscription?.message} />
</div> </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> </div>
</SectionCard> </SectionCard>
@@ -496,98 +526,115 @@ export default function AddCourse() {
</div> </div>
</div> </div>
{/* Achievements */} <AchievementsBuilder
<div className="border-t pt-4"> achievementKeys={currentAchKeys}
<div className="flex items-center justify-between mb-2"> onAchievementKeysChange={(keys) => setValue("achievement_keys", keys, { shouldDirty: true })}
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Achievements</p> registry={achievementRegistry}
<span className="text-[10px] text-muted-foreground">{currentAchKeys.length > 0 ? "1 selected" : "none selected"}</span> 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> </div>
{currentAchKeys.length > 0 && ( <div className="border-t pt-4">
<div className="flex flex-wrap gap-1.5 mb-2"> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
{currentAchKeys.map((key) => { Learning Objectives
const ach = achievementRegistry.find((a) => a.key === key); </p>
return ( {!watchedObjectives?.length ? (
<Badge key={key} variant="secondary" className="gap-1 pr-1"> <p className="text-sm text-muted-foreground">None added.</p>
{ach?.label ?? key} ) : (
<button <ul className="list-disc list-inside space-y-1 text-sm">
type="button" {watchedObjectives.map((o, i) => (
className="ml-0.5 rounded-full hover:bg-muted" <li key={i}>{o.text}</li>
onClick={() => toggleAchievement(key)} ))}
</ul>
)}
</div>
</SectionCard>
<SectionCard
title="Roadmap"
description={`${roadmapUnits.length} unit${roadmapUnits.length === 1 ? "" : "s"} · ${totalLessons} lesson${totalLessons === 1 ? "" : "s"} added.`}
> >
<X className="h-3 w-3" /> {roadmapUnits.length === 0 ? (
</button> <p className="text-sm text-muted-foreground">No units added.</p>
) : (
<div className="space-y-1.5">
{roadmapUnits.map((u, index) => {
const lessonCount = u.lessons.length + (u.existing_lesson_count ?? 0);
return (
<div key={u.key} className="rounded-md border px-3 py-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground shrink-0">{index + 1}</span>
<span className="text-sm font-medium truncate flex-1 min-w-0">{u.title}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
{lessonCount} lesson{lessonCount === 1 ? "" : "s"}
</Badge> </Badge>
{!u.unit_id && (
<Badge variant="secondary" className="text-[10px] shrink-0">new</Badge>
)}
</div>
); );
})} })}
</div> </div>
)} )}
</SectionCard>
<Popover open={achOpen} onOpenChange={setAchOpen}> <SectionCard title="Rewards">
<PopoverTrigger asChild> <div className="flex items-center gap-4">
<Button <CourseBadge
type="button" title={watchedTitle || "Course Title"}
variant="outline" level={watchedLevel}
size="sm" color={badgeColor}
className="w-full justify-between" imageUrl={badgeImageUrl}
>
<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="text-sm">
<div className="flex items-center gap-1.5 flex-wrap"> <p className="text-xs text-muted-foreground mb-1">Achievement</p>
<span className="text-xs font-medium">{ach.label}</span> {selectedAchievement ? (
<Badge variant="outline" className="text-[9px] px-1 py-0 gap-0.5 capitalize"> <Badge variant="outline">{selectedAchievement.label}</Badge>
{ach.type === "badge" ) : (
? <Trophy className="h-2.5 w-2.5" /> <p className="text-muted-foreground">None selected</p>
: <BadgeCheck className="h-2.5 w-2.5" /> )}
}
{ach.type}
</Badge>
</div> </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> </div>
</SectionCard> </SectionCard>
</>
)} )}
{/* Asset picker (always mounted) */} {/* Asset picker (always mounted) */}
@@ -634,6 +681,8 @@ export default function AddCourse() {
</form> </form>
</div> </div>
</div> </div>
{unsavedChangesDialog}
</div> </div>
); );
} }
@@ -18,7 +18,7 @@ export default function CourseList() {
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
</div> </div>
<div className="w-full space-y-4"> <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" /> <Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Courses are organized under <span className="font-medium text-foreground">Tier Plans</span> — each course's{" "} 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> </Link>
, then attach them to any course. , then attach them to any course.
</p> </p>
</div> </div> */}
<CoursesTable /> <CoursesTable />
</div> </div>
</div> </div>
+41 -2
View File
@@ -17,6 +17,7 @@ import CourseInstructorPicker from "@/modules/admin/components/courses/CourseIns
import { useCategories } from "@/contexts/AdminCategoriesContext"; import { useCategories } from "@/contexts/AdminCategoriesContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -45,6 +46,7 @@ const schema = z.object({
order_index: z.coerce.number().min(0).default(0), order_index: z.coerce.number().min(0).default(0),
level: z.enum(["beginner", "intermediate", "advanced"]).optional(), level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.string().min(1, "Subscription is required.").default("free"), subscription: z.string().min(1, "Subscription is required.").default("free"),
status: z.enum(["draft", "published", "unpublished"]).default("draft"),
objectives: z objectives: z
.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })) .array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
.default([]), .default([]),
@@ -202,16 +204,31 @@ export default function EditCourse() {
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
title: "", description: "", course_code: "", 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 } = const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
useFieldArray({ control, name: "objectives" }); 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 watchedTitle = useWatch({ control, name: "title" });
const watchedLevel = useWatch({ control, name: "level" }); const watchedLevel = useWatch({ control, name: "level" });
const watchedSubscription = useWatch({ control, name: "subscription" }); const watchedSubscription = useWatch({ control, name: "subscription" });
const watchedStatus = useWatch({ control, name: "status" });
// ─── Load data ──────────────────────────────────────────────────────────── // ─── Load data ────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
@@ -227,6 +244,7 @@ export default function EditCourse() {
order_index: c.order_index ?? 0, order_index: c.order_index ?? 0,
level: c.level ?? undefined, level: c.level ?? undefined,
subscription: c.subscription ?? "free", subscription: c.subscription ?? "free",
status: c.status ?? "draft",
objectives: (c.objectives ?? []).map((o) => ({ objectives: (c.objectives ?? []).map((o) => ({
objective_id: o.objective_id ?? null, objective_id: o.objective_id ?? null,
text: o.text ?? "", text: o.text ?? "",
@@ -442,6 +460,7 @@ export default function EditCourse() {
if (achievementsDirty) await handleSaveAchievements(); if (achievementsDirty) await handleSaveAchievements();
if (productDirty && productForm.price) await handleSaveProduct(); if (productDirty && productForm.price) await handleSaveProduct();
bypassOnce();
navigate(`/admin/courses/${courseId}/view`); navigate(`/admin/courses/${courseId}/view`);
} finally { } finally {
setDoneLoading(false); setDoneLoading(false);
@@ -535,7 +554,7 @@ export default function EditCourse() {
</SectionCard> </SectionCard>
<SectionCard title="Settings"> <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"> <div className="space-y-1.5">
<Label>Level</Label> <Label>Level</Label>
<Select <Select
@@ -573,6 +592,24 @@ export default function EditCourse() {
</Select> </Select>
<FieldError message={errors.subscription?.message} /> <FieldError message={errors.subscription?.message} />
</div> </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> </div>
</SectionCard> </SectionCard>
@@ -1121,6 +1158,8 @@ export default function EditCourse() {
</form> </form>
</div> </div>
</div> </div>
{unsavedChangesDialog}
</div> </div>
); );
} }
@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
@@ -34,13 +35,15 @@ export default function AddLesson() {
const { createLesson, fetchUnit, course, unit, loading } = useCourses(); const { createLesson, fetchUnit, course, unit, loading } = useCourses();
const { user } = useAuth(); const { user } = useAuth();
const { register, handleSubmit, control, formState: { errors } } = useForm({ const { register, handleSubmit, control, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { title: "", description: "", order: 0, objectives: [] }, defaultValues: { title: "", description: "", order: 0, objectives: [] },
}); });
const { fields, append, remove } = useFieldArray({ control, name: "objectives" }); const { fields, append, remove } = useFieldArray({ control, name: "objectives" });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
useEffect(() => { useEffect(() => {
fetchUnit(courseId, unitId); fetchUnit(courseId, unitId);
}, [courseId, unitId]); }, [courseId, unitId]);
@@ -53,6 +56,7 @@ export default function AddLesson() {
}; };
const result = await createLesson(courseId, unitId, payload); const result = await createLesson(courseId, unitId, payload);
if (!result) return; if (!result) return;
bypassOnce();
navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`); navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`);
}; };
@@ -153,6 +157,8 @@ export default function AddLesson() {
</form> </form>
</div> </div>
</div> </div>
{unsavedChangesDialog}
</section> </section>
); );
} }
@@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({ const schema = z.object({
title: z.string().min(1, "Title is required."), 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 { fields, append, remove } = useFieldArray({ control, name: "objectives" });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const res = await fetchLesson(courseId, unitId, lessonId); const res = await fetchLesson(courseId, unitId, lessonId);
@@ -59,7 +62,7 @@ export default function EditLesson() {
}, [courseId, unitId, lessonId]); }, [courseId, unitId, lessonId]);
const onSubmit = async (data) => { const onSubmit = async (data) => {
if (!isDirty) return navigate(-1); if (!isDirty) { bypassOnce(); return navigate(-1); }
const result = await updateLesson(courseId, unitId, lessonId, { const result = await updateLesson(courseId, unitId, lessonId, {
...data, ...data,
objectives: data.objectives?.map((o, i) => ({ objectives: data.objectives?.map((o, i) => ({
@@ -70,6 +73,7 @@ export default function EditLesson() {
updatedBy: user?.user_id, updatedBy: user?.user_id,
}); });
if (!result) return; if (!result) return;
bypassOnce();
navigate(-1); navigate(-1);
}; };
@@ -170,6 +174,8 @@ export default function EditLesson() {
</form> </form>
</div> </div>
</div> </div>
{unsavedChangesDialog}
</section> </section>
); );
} }
@@ -34,6 +34,7 @@ export default function LessonPageBuilder() {
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const headerRef = useRef(null); const headerRef = useRef(null);
const editorPaneRef = useRef(null);
const blocksSeeded = useRef(false); const blocksSeeded = useRef(false);
useEffect(() => { useEffect(() => {
@@ -69,6 +70,21 @@ export default function LessonPageBuilder() {
return () => window.removeEventListener("resize", update); 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 addBlock = (type) => setBlocks((prev) => [...prev, makeBlock(type)]);
const updateBlock = (id, content) => setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, content } : b)); 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 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", "flex flex-col gap-3 p-4 pb-10",
previewVisible && "lg:overflow-y-auto" previewVisible && "lg:overflow-y-auto"
)} )}
ref={(el) => { ref={editorPaneRef}
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);
}}
> >
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground pt-1"> <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground pt-1">
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
@@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
@@ -32,11 +33,13 @@ export default function AddUnit() {
const { createUnit, fetchCourse, course, loading } = useCourses(); const { createUnit, fetchCourse, course, loading } = useCourses();
const { user } = useAuth(); const { user } = useAuth();
const { register, handleSubmit, formState: { errors } } = useForm({ const { register, handleSubmit, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { title: "", description: "", order: 0 }, defaultValues: { title: "", description: "", order: 0 },
}); });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
useEffect(() => { useEffect(() => {
fetchCourse(courseId); fetchCourse(courseId);
}, [courseId]); }, [courseId]);
@@ -44,6 +47,7 @@ export default function AddUnit() {
const onSubmit = async (data) => { const onSubmit = async (data) => {
const result = await createUnit(courseId, { ...data, createdBy: user?.user_id }); const result = await createUnit(courseId, { ...data, createdBy: user?.user_id });
if (!result) return; if (!result) return;
bypassOnce();
navigate(`/admin/courses/${courseId}/units`); navigate(`/admin/courses/${courseId}/units`);
}; };
@@ -96,6 +100,8 @@ export default function AddUnit() {
</form> </form>
</div> </div>
</div> </div>
{unsavedChangesDialog}
</section> </section>
); );
} }
@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
@@ -52,10 +53,13 @@ export default function EditUnit() {
}, [courseId, unitId]); }, [courseId, unitId]);
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const onSubmit = async (data) => { 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 }); const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id });
if (!result) return; if (!result) return;
bypassOnce();
navigate(`/admin/courses/${courseId}/units/${unitId}/view`); navigate(`/admin/courses/${courseId}/units/${unitId}/view`);
}; };
@@ -108,6 +112,8 @@ export default function EditUnit() {
</form> </form>
</div> </div>
</div> </div>
{unsavedChangesDialog}
</section> </section>
); );
} }
@@ -1,4 +1,4 @@
// modules/admin/pages/notifications/NotificationSettings.jsx // modules/admin/pages/jobs/Jobs.jsx
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; 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>; return <div className="rounded-lg border bg-card p-4">{children}</div>;
} }
export default function NotificationSettings() { export default function Jobs() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const { fmtDateTime } = useDateFormat(); const { fmtDateTime } = useDateFormat();
@@ -31,8 +31,7 @@ export default function NotificationSettings() {
const breadcrumbItems = [ const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Announcements", to: "/admin/announcements" }, { label: "Jobs" },
{ label: "Settings" },
]; ];
async function fetchSettings() { async function fetchSettings() {
@@ -41,7 +40,7 @@ export default function NotificationSettings() {
const { data } = await api.get("/admin/announcement-settings"); const { data } = await api.get("/admin/announcement-settings");
setSettings(data?.data ?? []); setSettings(data?.data ?? []);
} catch (err) { } catch (err) {
toast(err?.response?.data?.message ?? "Failed to load notification settings."); toast(err?.response?.data?.message ?? "Failed to load jobs.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -94,13 +93,13 @@ export default function NotificationSettings() {
<div className="w-full max-w-2xl pb-10 space-y-5"> <div className="w-full max-w-2xl pb-10 space-y-5">
<div className="flex items-center gap-2"> <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" /> <ArrowLeft className="size-4" />
</Button> </Button>
<div> <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"> <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> </p>
</div> </div>
</div> </div>
@@ -1,59 +1,255 @@
import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; 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 { z } from "zod";
import { zodResolver } from "@hookform/resolvers/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 { useLibrary } from "@/contexts/AdminLibraryContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; 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({ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
description: z.string().optional(), 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 }) { function FieldError({ message }) {
if (!message) return null; if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>; 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() { export default function AddLibraryLesson() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { createLesson, loading } = useLibrary(); const { createLesson, saveLessonPage, loading } = useLibrary();
const { user } = useAuth(); const { user } = useAuth();
// ?unit_id=… → create-and-attach in one call (from the unit lessons manager) // ?unit_id=… → create-and-attach in one call (from the unit lessons manager)
const attachUnitId = searchParams.get("unit_id"); 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), 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({ const result = await createLesson({
...data, title: data.title,
description: data.description || null,
...(attachUnitId ? { unit_id: attachUnitId } : {}), ...(attachUnitId ? { unit_id: attachUnitId } : {}),
createdBy: user?.user_id, createdBy: user?.user_id,
}); });
if (!result) return; 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"); navigate(attachUnitId ? `/admin/units/${attachUnitId}/view` : "/admin/lessons");
}; };
return ( return (
<section className="bg-muted h-full"> <section className="bg-muted min-h-full">
<PageMeta title="Add Lesson - STARR" /> <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="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">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}> <Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
@@ -67,34 +263,82 @@ export default function AddLibraryLesson() {
</div> </div>
</div> </div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> {/* Stepper */}
<div className="rounded-lg border bg-card p-6 space-y-5"> <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"> return (
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label> <div key={s.id} className="flex items-center flex-1 last:flex-none">
<Input id="title" placeholder="Lesson title" {...register("title")} /> <div className="flex flex-col items-center gap-1">
<FieldError message={errors.title?.message} /> <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>
<div className="space-y-1.5"> {/* Step content */}
<Label htmlFor="description">Description</Label> <div className="rounded-lg border bg-card p-6 min-h-[320px]">
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} /> <h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
{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>
</div> {/* Navigation */}
<div className="flex items-center justify-between gap-3">
<div className="flex justify-end gap-3"> <Button type="button" variant="outline" onClick={handleBack} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}> <ChevronLeft className="h-4 w-4 mr-1" />
Cancel {step === 0 ? "Cancel" : "Back"}
</Button> </Button>
<Button type="submit" disabled={loading}>
{step < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button type="button" onClick={handleCreate} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />} {loading && <Spinner className="h-4 w-4 mr-2" />}
Create Lesson Create Lesson
</Button> </Button>
)}
</div> </div>
</form>
</div> </div>
</div> </div>
{unsavedChangesDialog}
</section> </section>
); );
} }
@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
@@ -30,11 +31,13 @@ export default function EditLibraryLesson() {
const { fetchLesson, updateLesson, lesson, loading } = useLibrary(); const { fetchLesson, updateLesson, lesson, loading } = useLibrary();
const { user } = useAuth(); const { user } = useAuth();
const { register, handleSubmit, reset, formState: { errors } } = useForm({ const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { title: "", description: "" }, defaultValues: { title: "", description: "" },
}); });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
useEffect(() => { useEffect(() => {
fetchLesson(lessonId); fetchLesson(lessonId);
}, [lessonId]); }, [lessonId]);
@@ -48,6 +51,7 @@ export default function EditLibraryLesson() {
const onSubmit = async (data) => { const onSubmit = async (data) => {
const result = await updateLesson(lessonId, { ...data, updatedBy: user?.user_id }); const result = await updateLesson(lessonId, { ...data, updatedBy: user?.user_id });
if (!result) return; if (!result) return;
bypassOnce();
navigate(`/admin/lessons/${lessonId}/view`); navigate(`/admin/lessons/${lessonId}/view`);
}; };
@@ -97,6 +101,8 @@ export default function EditLibraryLesson() {
</form> </form>
</div> </div>
</div> </div>
{unsavedChangesDialog}
</section> </section>
); );
} }
@@ -18,7 +18,7 @@ export default function LessonLibraryList() {
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
</div> </div>
<div className="w-full space-y-4"> <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" /> <Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Lessons run <span className="font-medium text-foreground">independently</span> — build content here once, Lessons run <span className="font-medium text-foreground">independently</span> — build content here once,
@@ -28,7 +28,7 @@ export default function LessonLibraryList() {
</Link> </Link>
. Removing a lesson from a unit only detaches it; the lesson stays in this library. . Removing a lesson from a unit only detaches it; the lesson stays in this library.
</p> </p>
</div> </div> */}
<LessonLibraryTable /> <LessonLibraryTable />
</div> </div>
</div> </div>
@@ -1,80 +1,89 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useForm, useWatch } from "react-hook-form"; import { useForm, useFieldArray, useWatch } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; 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 { useLibrary } from "@/contexts/AdminLibraryContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"; } 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({ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
description: z.string().optional(), description: z.string().optional(),
subscription: 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 }) { function FieldError({ message }) {
if (!message) return null; if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>; return <p className="text-xs text-destructive mt-1">{message}</p>;
} }
export default function AddLibraryUnit() { // ─── Step 1 — Create Unit ─────────────────────────────────────────────────────
const navigate = useNavigate(); function StepUnit({ register, errors, control, setValue, tierCategories }) {
const { createUnit, loading } = useLibrary();
const { user } = useAuth();
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({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "", subscription: "" },
});
const watchedSubscr = useWatch({ control, name: "subscription" }); const watchedSubscr = useWatch({ control, name: "subscription" });
const onSubmit = async (data) => {
const result = await createUnit({ ...data, subscription: data.subscription || null, createdBy: user?.user_id });
if (!result) return;
navigate("/admin/units");
};
return ( return (
<section className="bg-muted h-full"> <div className="space-y-5">
<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-2xl mx-auto">
<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>
<h1 className="text-xl font-semibold">Create Unit</h1>
<p className="text-sm text-muted-foreground">
Units are standalone — attach this one to any course later, or run it on its own.
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="rounded-lg border bg-card p-6 space-y-5">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label> <Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="Unit title" {...register("title")} /> <Input id="title" placeholder="Unit title" {...register("title")} />
@@ -108,21 +117,431 @@ export default function AddLibraryUnit() {
Optional. Gates this unit directly, independent of any course it may later be attached to. Optional. Gates this unit directly, independent of any course it may later be attached to.
</p> </p>
</div> </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>
<div className="flex justify-end gap-3"> <div className="space-y-1.5">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}> <Label>Title <span className="text-destructive">*</span></Label>
Cancel <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> </Button>
<Button type="submit" disabled={loading}> </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 { 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, control, trigger, getValues, setValue,
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: DEFAULT_VALUES,
mode: "onTouched",
});
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 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 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="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Create Unit</h1>
<p className="text-sm text-muted-foreground">
Units are standalone — attach this one to any course later, or run it on its own.
</p>
</div>
</div>
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors",
isDone && "bg-emerald-600 border-emerald-600 text-white",
isActive && "border-primary bg-primary text-primary-foreground",
!isActive && !isDone && "border-border bg-background text-muted-foreground"
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
isActive ? "text-foreground" : "text-muted-foreground",
isDone ? "text-emerald-600" : ""
)}>
{s.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div className={cn(
"flex-1 h-px mx-2 mb-4 transition-colors",
step > i ? "bg-emerald-600" : "bg-border"
)} />
)}
</div>
);
})}
</div>
{/* Step content */}
<div className="rounded-lg border bg-card p-6 min-h-[320px]">
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
{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>
{/* 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="button" onClick={handleCreate} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />} {loading && <Spinner className="h-4 w-4 mr-2" />}
Create Unit Create Unit
</Button> </Button>
)}
</div> </div>
</form>
</div> </div>
</div> </div>
{unsavedChangesDialog}
</section> </section>
); );
} }
@@ -17,6 +17,7 @@ import { Spinner } from "@/components/ui/spinner";
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const schema = z.object({ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
@@ -42,11 +43,13 @@ export default function EditLibraryUnit() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
const { register, handleSubmit, reset, control, setValue, formState: { errors } } = useForm({ const { register, handleSubmit, reset, control, setValue, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { title: "", description: "", subscription: "" }, defaultValues: { title: "", description: "", subscription: "" },
}); });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const watchedSubscr = useWatch({ control, name: "subscription" }); const watchedSubscr = useWatch({ control, name: "subscription" });
useEffect(() => { useEffect(() => {
@@ -62,6 +65,7 @@ export default function EditLibraryUnit() {
const onSubmit = async (data) => { const onSubmit = async (data) => {
const result = await updateUnit(unitId, { ...data, subscription: data.subscription || null, updatedBy: user?.user_id }); const result = await updateUnit(unitId, { ...data, subscription: data.subscription || null, updatedBy: user?.user_id });
if (!result) return; if (!result) return;
bypassOnce();
navigate(`/admin/units/${unitId}/view`); navigate(`/admin/units/${unitId}/view`);
}; };
@@ -134,6 +138,8 @@ export default function EditLibraryUnit() {
</form> </form>
</div> </div>
</div> </div>
{unsavedChangesDialog}
</section> </section>
); );
} }
@@ -18,7 +18,7 @@ export default function UnitLibraryList() {
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
</div> </div>
<div className="w-full space-y-4"> <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" /> <Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Units run <span className="font-medium text-foreground">independently</span> — build them here once, Units run <span className="font-medium text-foreground">independently</span> — build them here once,
@@ -28,7 +28,7 @@ export default function UnitLibraryList() {
</Link> </Link>
. Removing a unit from a course only detaches it; the unit stays in this library. . Removing a unit from a course only detaches it; the unit stays in this library.
</p> </p>
</div> </div> */}
<UnitLibraryTable /> <UnitLibraryTable />
</div> </div>
</div> </div>
@@ -1,11 +1,13 @@
// modules/admin/pages/notifications/AddNotificationBroadcast.jsx // modules/admin/pages/notifications/AddNotificationBroadcast.jsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { House } from "lucide-react"; import { House } from "lucide-react";
import api from "@/utils/api.util";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext"; import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data"; import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// ─── Schema ───────────────────────────────────────────────────────────────── // ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
message: z.string().min(1, "Message 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(), target_id: z.string().nullable().optional(),
show_in_sticky: z.boolean().optional(), show_in_sticky: z.boolean().optional(),
show_in_notifications: 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) => { }).superRefine((data, ctx) => {
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) { if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
ctx.addIssue({ ctx.addIssue({
@@ -45,6 +50,14 @@ const schema = z.object({
path: ["show_in_sticky"], 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 ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
@@ -80,7 +93,7 @@ export default function AddNotificationBroadcast() {
handleSubmit, handleSubmit,
watch, watch,
setValue, setValue,
formState: { errors }, formState: { errors, isDirty },
} = useForm({ } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
@@ -90,14 +103,33 @@ export default function AddNotificationBroadcast() {
target_id: null, target_id: null,
show_in_sticky: false, show_in_sticky: false,
show_in_notifications: true, 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 targetType = watch("target_type");
const targetId = watch("target_id"); const targetId = watch("target_id");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget; const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const showInSticky = watch("show_in_sticky"); const showInSticky = watch("show_in_sticky");
const showInNotifications = watch("show_in_notifications"); const showInNotifications = watch("show_in_notifications");
const linkMode = watch("link_mode");
const breadcrumbItems = [ const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -106,16 +138,18 @@ export default function AddNotificationBroadcast() {
]; ];
const onSubmit = async (values) => { const onSubmit = async (values) => {
const { link_mode, ...rest } = values;
const payload = { const payload = {
...values, ...rest,
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null, target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
show_in_sticky: values.show_in_sticky ?? false, show_in_sticky: values.show_in_sticky ?? false,
show_in_notifications: values.show_in_notifications ?? true, 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, createdBy: user?.user_id ?? null,
}; };
const res = await createBroadcast(payload); const res = await createBroadcast(payload);
if (res) navigate("/admin/announcements"); if (res) { bypassOnce(); navigate("/admin/announcements"); }
}; };
return ( return (
@@ -132,6 +166,22 @@ export default function AddNotificationBroadcast() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Content" description="What admins and/or users will see."> <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> <div>
<Label className="mb-1.5 block">Title</Label> <Label className="mb-1.5 block">Title</Label>
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} /> <Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
@@ -149,8 +199,8 @@ export default function AddNotificationBroadcast() {
<Select <Select
value={targetType} value={targetType}
onValueChange={(v) => { onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true }); setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
setValue("target_id", null); setValue("target_id", null, { shouldDirty: true });
}} }}
> >
<SelectTrigger> <SelectTrigger>
@@ -175,7 +225,7 @@ export default function AddNotificationBroadcast() {
<BroadcastTargetPicker <BroadcastTargetPicker
targetType={targetType} targetType={targetType}
value={targetId} 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} /> <FieldError message={errors.target_id?.message} />
</div> </div>
@@ -188,7 +238,7 @@ export default function AddNotificationBroadcast() {
<Checkbox <Checkbox
id="show_in_sticky" id="show_in_sticky"
checked={showInSticky === true} 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"> <Label htmlFor="show_in_sticky" className="cursor-pointer">
Show in Sticky Announcements Show in Sticky Announcements
@@ -199,7 +249,7 @@ export default function AddNotificationBroadcast() {
<Checkbox <Checkbox
id="show_in_notifications" id="show_in_notifications"
checked={showInNotifications === true} 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"> <Label htmlFor="show_in_notifications" className="cursor-pointer">
Show in Notifications Show in Notifications
@@ -208,6 +258,44 @@ export default function AddNotificationBroadcast() {
</div> </div>
</SectionCard> </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"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}> <Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel Cancel
@@ -220,6 +308,8 @@ export default function AddNotificationBroadcast() {
</form> </form>
</div> </div>
</div> </div>
{unsavedChangesDialog}
</section> </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 // modules/admin/pages/notifications/EditNotificationBroadcast.jsx
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { House } from "lucide-react"; import { House } from "lucide-react";
import api from "@/utils/api.util";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext"; import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data"; import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// ─── Schema ───────────────────────────────────────────────────────────────── // ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
message: z.string().min(1, "Message 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(), target_id: z.string().nullable().optional(),
show_in_sticky: z.boolean().optional(), show_in_sticky: z.boolean().optional(),
show_in_notifications: 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) => { }).superRefine((data, ctx) => {
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) { if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
ctx.addIssue({ ctx.addIssue({
@@ -46,6 +50,14 @@ const schema = z.object({
path: ["show_in_sticky"], 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 ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
@@ -83,7 +95,7 @@ export default function EditNotificationBroadcast() {
reset, reset,
watch, watch,
setValue, setValue,
formState: { errors }, formState: { errors, isDirty },
} = useForm({ } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
@@ -93,14 +105,33 @@ export default function EditNotificationBroadcast() {
target_id: null, target_id: null,
show_in_sticky: false, show_in_sticky: false,
show_in_notifications: true, 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 targetType = watch("target_type");
const targetId = watch("target_id"); const targetId = watch("target_id");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget; const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const showInSticky = watch("show_in_sticky"); const showInSticky = watch("show_in_sticky");
const showInNotifications = watch("show_in_notifications"); const showInNotifications = watch("show_in_notifications");
const linkMode = watch("link_mode");
const breadcrumbItems = [ const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -122,22 +153,26 @@ export default function EditNotificationBroadcast() {
target_id: b.target_id ?? null, target_id: b.target_id ?? null,
show_in_sticky: b.show_in_sticky ?? false, show_in_sticky: b.show_in_sticky ?? false,
show_in_notifications: b.show_in_notifications ?? true, 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [broadcastId]); }, [broadcastId]);
const onSubmit = async (values) => { const onSubmit = async (values) => {
const { link_mode, ...rest } = values;
const payload = { const payload = {
...values, ...rest,
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null, target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
show_in_sticky: values.show_in_sticky ?? false, show_in_sticky: values.show_in_sticky ?? false,
show_in_notifications: values.show_in_notifications ?? true, 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, updatedBy: user?.user_id ?? null,
}; };
const res = await updateBroadcast(broadcastId, payload); const res = await updateBroadcast(broadcastId, payload);
if (res) navigate("/admin/announcements"); if (res) { bypassOnce(); navigate("/admin/announcements"); }
}; };
return ( return (
@@ -154,6 +189,22 @@ export default function EditNotificationBroadcast() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Content" description="What admins and/or users will see."> <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> <div>
<Label className="mb-1.5 block">Title</Label> <Label className="mb-1.5 block">Title</Label>
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} /> <Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
@@ -171,8 +222,8 @@ export default function EditNotificationBroadcast() {
<Select <Select
value={targetType} value={targetType}
onValueChange={(v) => { onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true }); setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
setValue("target_id", null); setValue("target_id", null, { shouldDirty: true });
}} }}
> >
<SelectTrigger> <SelectTrigger>
@@ -197,7 +248,7 @@ export default function EditNotificationBroadcast() {
<BroadcastTargetPicker <BroadcastTargetPicker
targetType={targetType} targetType={targetType}
value={targetId} 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} /> <FieldError message={errors.target_id?.message} />
</div> </div>
@@ -210,7 +261,7 @@ export default function EditNotificationBroadcast() {
<Checkbox <Checkbox
id="show_in_sticky" id="show_in_sticky"
checked={showInSticky === true} 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"> <Label htmlFor="show_in_sticky" className="cursor-pointer">
Show in Sticky Announcements Show in Sticky Announcements
@@ -221,7 +272,7 @@ export default function EditNotificationBroadcast() {
<Checkbox <Checkbox
id="show_in_notifications" id="show_in_notifications"
checked={showInNotifications === true} 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"> <Label htmlFor="show_in_notifications" className="cursor-pointer">
Show in Notifications Show in Notifications
@@ -230,6 +281,44 @@ export default function EditNotificationBroadcast() {
</div> </div>
</SectionCard> </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"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}> <Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel Cancel
@@ -242,6 +331,8 @@ export default function EditNotificationBroadcast() {
</form> </form>
</div> </div>
</div> </div>
{unsavedChangesDialog}
</section> </section>
); );
} }
@@ -1,12 +1,17 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge"; 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 AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { import {
@@ -34,12 +39,15 @@ function FieldError({ message }) {
function EditNotificationTemplateInner() { function EditNotificationTemplateInner() {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const { template, loading, fetchTemplate, updateTemplate } = useAdminNotificationTemplates(); const { template, loading, fetchTemplate, updateTemplate, deleteTemplate } = useAdminNotificationTemplates();
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [confirmDelete, setConfirmDelete] = useState(false);
const isCustom = template && !template.is_system;
useEffect(() => { useEffect(() => {
if (id) fetchTemplate(id); if (id) fetchTemplate(id);
@@ -80,6 +88,12 @@ function EditNotificationTemplateInner() {
if (result) navigate("/admin/announcement-templates"); if (result) navigate("/admin/announcement-templates");
}; };
const handleDelete = async () => {
const result = await deleteTemplate(id);
setConfirmDelete(false);
if (result) navigate("/admin/announcement-templates");
};
return ( return (
<section className="bg-muted/60 min-h-full"> <section className="bg-muted/60 min-h-full">
<PageMeta title="Edit Announcement Template - STARR" /> <PageMeta title="Edit Announcement Template - STARR" />
@@ -102,7 +116,7 @@ function EditNotificationTemplateInner() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-semibold">Edit Announcement Template</h1> <h1 className="text-xl font-semibold">Edit Announcement Template</h1>
{template && ( {template && !isCustom && (
<Badge variant="outline" className={cn("gap-1", status.badgeClass)}> <Badge variant="outline" className={cn("gap-1", status.badgeClass)}>
<Send className="h-3 w-3" /> {status.label} <Send className="h-3 w-3" /> {status.label}
</Badge> </Badge>
@@ -112,7 +126,7 @@ function EditNotificationTemplateInner() {
</div> </div>
</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"> <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" /> <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"> <p className="text-xs text-amber-800 dark:text-amber-300">
@@ -123,6 +137,7 @@ function EditNotificationTemplateInner() {
</div> </div>
)} )}
{!isCustom && (
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5"> <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" /> <Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -130,15 +145,18 @@ function EditNotificationTemplateInner() {
so the type is locked. Label, title and message are still fully editable. so the type is locked. Label, title and message are still fully editable.
</p> </p>
</div> </div>
)}
<div className="space-y-5"> <div className="space-y-5">
<SectionCard title="Template Details"> <SectionCard title="Template Details">
{!isCustom && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Type</Label> <Label>Type</Label>
<Input value={template?.type ?? ""} disabled /> <Input value={template?.type ?? ""} disabled />
<p className="text-xs text-muted-foreground">Cannot be changed — this is what code looks up.</p> <p className="text-xs text-muted-foreground">Cannot be changed — this is what code looks up.</p>
</div> </div>
)}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label> <Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
@@ -159,11 +177,13 @@ function EditNotificationTemplateInner() {
</div> </div>
<p className="text-xs text-muted-foreground -mt-1"> <p className="text-xs text-muted-foreground -mt-1">
Plain text only — no HTML, no conditional logic, just straight{" "} {isCustom
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens. ? "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> </p>
{(knownPlaceholders !== null) && ( {!isCustom && (knownPlaceholders !== null) && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Available placeholders</p> <p className="text-xs font-medium text-muted-foreground">Available placeholders</p>
{knownPlaceholders.length ? ( {knownPlaceholders.length ? (
@@ -191,8 +211,22 @@ function EditNotificationTemplateInner() {
<FieldError message={errors.message} /> <FieldError message={errors.message} />
</SectionCard> </SectionCard>
<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"> <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={() => 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}> <Button type="button" variant="outline" onClick={() => handleSave(false)} disabled={loading}>
Save as Draft Save as Draft
</Button> </Button>
@@ -200,10 +234,31 @@ function EditNotificationTemplateInner() {
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />} {loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
Publish Publish
</Button> </Button>
</>
)}
</div> </div>
</div> </div>
</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> </section>
); );
} }
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; 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 { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -24,6 +24,7 @@ export default function NotificationBroadcastList() {
const { broadcasts, pagination, loading, fetchBroadcasts, sendBroadcast, archiveBroadcast } = useNotificationBroadcasts(); const { broadcasts, pagination, loading, fetchBroadcasts, sendBroadcast, archiveBroadcast } = useNotificationBroadcasts();
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [limit, setLimit] = useState(12); const [limit, setLimit] = useState(12);
@@ -76,10 +77,6 @@ export default function NotificationBroadcastList() {
<FileText className="size-4" /> <FileText className="size-4" />
Templates Templates
</Button> </Button>
<Button variant="outline" onClick={() => navigate("/admin/announcements/settings")}>
<Settings className="size-4" />
Settings
</Button>
<Button variant="outline" onClick={() => navigate("/admin/announcements/archived")}> <Button variant="outline" onClick={() => navigate("/admin/announcements/archived")}>
<Archive className="size-4" /> <Archive className="size-4" />
Archived Archived
@@ -112,15 +109,21 @@ export default function NotificationBroadcastList() {
</SelectContent> </SelectContent>
</Select> </Select>
<div className="relative flex-1 min-w-[160px]"> <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" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input <Input
placeholder="Search announcements..." placeholder="Search announcements..."
className="pl-8 bg-background" className="pl-8 bg-background"
value={search} value={searchInput}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") setSearch(searchInput); }}
/> />
</div> </div>
<Button variant="outline" size="icon" className="shrink-0 bg-background" onClick={() => setSearch(searchInput)} aria-label="Search">
<Search className="size-4" />
</Button>
</div>
</div> </div>
{/* ── Grid ───────────────────────────────────────────────────── */} {/* ── Grid ───────────────────────────────────────────────────── */}
@@ -1,10 +1,15 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; 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 { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Separator } from "@/components/ui/separator"; 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 AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { import {
@@ -15,7 +20,7 @@ import { NOTIFICATION_TEMPLATE_TYPES, getNotificationTemplateType } from "@/data
import { STATUS_META, hasPendingChanges } from "@/data/notificationTemplateStatus.data"; import { STATUS_META, hasPendingChanges } from "@/data/notificationTemplateStatus.data";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function TemplateCard({ item, onEdit }) { function TemplateCard({ item, onEdit, onDelete }) {
const typeMeta = getNotificationTemplateType(item.notify_type); const typeMeta = getNotificationTemplateType(item.notify_type);
const TypeIcon = typeMeta?.icon ?? Bell; const TypeIcon = typeMeta?.icon ?? Bell;
const status = STATUS_META[item.status] ?? STATUS_META.draft; const status = STATUS_META[item.status] ?? STATUS_META.draft;
@@ -27,10 +32,17 @@ function TemplateCard({ item, onEdit }) {
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center shrink-0"> <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" /> <TypeIcon className="h-4.5 w-4.5 text-muted-foreground" />
</div> </div>
<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)}> <Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1"> <div className="flex items-center gap-2 flex-wrap mb-1">
@@ -68,11 +80,18 @@ function TemplateCard({ item, onEdit }) {
function NotificationTemplatesInner() { function NotificationTemplatesInner() {
const navigate = useNavigate(); const navigate = useNavigate();
const { templates, loading, fetchTemplates } = useAdminNotificationTemplates(); const { templates, loading, fetchTemplates, deleteTemplate } = useAdminNotificationTemplates();
const [activeType, setActiveType] = useState("all"); const [activeType, setActiveType] = useState("all");
const [deleteTarget, setDeleteTarget] = useState(null);
useEffect(() => { fetchTemplates(); }, []); useEffect(() => { fetchTemplates(); }, []);
const handleDelete = async () => {
if (!deleteTarget) return;
await deleteTemplate(deleteTarget.notification_template_id);
setDeleteTarget(null);
};
const filtered = useMemo( const filtered = useMemo(
() => activeType === "all" ? templates : templates.filter((t) => t.notify_type === activeType), () => activeType === "all" ? templates : templates.filter((t) => t.notify_type === activeType),
[templates, activeType] [templates, activeType]
@@ -120,25 +139,30 @@ function NotificationTemplatesInner() {
)} )}
</div> </div>
</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>
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5"> <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" /> <Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="text-xs text-muted-foreground space-y-1"> <div className="text-xs text-muted-foreground space-y-1">
<p> <p>
Every template here is <strong>system</strong>-triggered — code fires it by referencing its <strong>System</strong> templates are locked — code fires them by referencing their exact
exact type, so no template can be added or removed from this screen. Only the title and type, so only the title and message wording is editable, never the type itself, and they
message wording is editable. can't be deleted.
</p> </p>
<p> <p>
<strong>Draft vs. Sent:</strong> a <strong>Sent</strong> template is the version actually <strong>Custom</strong> templates (no lock icon) are reusable title/message presets you
used for real notifications right now. Editing a Sent template doesn't change what goes out create — pick one from the "Load from template" dropdown when composing a new announcement
immediately — it's held as a pending change until you press <strong>Publish</strong> again. to skip retyping recurring wording. You can freely create, edit, and delete these.
</p> </p>
<p> <p>
Only plain text is supported — no HTML, no conditional logic, just straight{" "} <strong>Draft vs. Sent</strong> (system templates only): a <strong>Sent</strong> template
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens that get swapped is the version actually used for real notifications right now. Editing a Sent template
for real values when the notification fires. doesn't change what goes out immediately — it's held as a pending change until you press{" "}
<strong>Publish</strong> again.
</p> </p>
</div> </div>
</div> </div>
@@ -180,12 +204,31 @@ function NotificationTemplatesInner() {
key={item.notification_template_id} key={item.notification_template_id}
item={item} item={item}
onEdit={(t) => navigate(`/admin/announcement-templates/${t.notification_template_id}/edit`)} onEdit={(t) => navigate(`/admin/announcement-templates/${t.notification_template_id}/edit`)}
onDelete={setDeleteTarget}
/> />
))} ))}
</div> </div>
)} )}
</div> </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> </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 { 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; 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 { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { resolveTierBadge } from '@/utils/tierBadge.util'; import { resolveTierBadge } from '@/utils/tierBadge.util';
import api from '@/utils/api.util'; import api from '@/utils/api.util';
@@ -59,71 +60,109 @@ function TierBadge({ subscription, tierMap }) {
); );
} }
// ─── Custom content picker ──────────────────────────────────────────────────── // ─── Course binding indicator ──────────────────────────────────────────────────
function ContentPicker({ value, options, idKey, searchKey, labelKey, placeholder = 'Select…', onSelect, renderTrigger, renderItem, listHeight = 'max-h-48' }) { // Units/lessons are standalone entities that may sit under 0..N courses
const [open, setOpen] = useState(false); // (junction revamp) — these surface that binding in the picker instead of the
const [query, setQuery] = useState(''); // old single "course_title | Unit N" prefix, which broke once a unit/lesson
// could belong to several courses or none at all.
const filtered = useMemo(() => { function BindingChip({ courses = [] }) {
const q = query.trim().toLowerCase(); if (!courses.length) {
if (!q) return options; return (
return options.filter((o) => <Badge variant="secondary" className="text-xs gap-1 shrink-0 text-muted-foreground">
String(o[searchKey] ?? o[labelKey] ?? '').toLowerCase().includes(q) <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 selected = options.find((o) => String(o[idKey]) === String(value));
const handleOpenChange = (v) => { const searchValue = (o) => {
setOpen(v); const base = String(o[searchKey] ?? o[labelKey] ?? o[idKey] ?? '');
if (!v) setQuery(''); const courseNames = (o.courses ?? []).map((c) => c.title).join(' ');
return `${base} ${courseNames}`.trim() || String(o[idKey]);
}; };
return ( return (
<Popover open={open} onOpenChange={handleOpenChange}> <>
<PopoverTrigger asChild>
<Button <Button
type="button"
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="h-auto min-h-8 w-full justify-between text-sm font-normal py-1.5 px-3" className="h-auto min-h-8 w-full justify-between text-sm font-normal py-1.5 px-3"
onClick={() => setOpen(true)}
> >
{selected {selected
? renderTrigger(selected) ? renderTrigger(selected)
: <span className="text-muted-foreground">{placeholder}</span>} : <span className="text-muted-foreground">{placeholder}</span>}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: 'var(--radix-popover-trigger-width)' }} align="start"> <Dialog open={open} onOpenChange={setOpen}>
<div className="flex items-center gap-2 border-b px-3"> <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">
<Search className="size-4 shrink-0 text-muted-foreground" /> <DialogHeader className="px-4 pt-4 pb-3 border-b pr-10">
<input <DialogTitle className="text-sm">{dialogTitle ?? placeholder}</DialogTitle>
autoFocus </DialogHeader>
value={query} <Command className="flex-1 min-h-0 rounded-none! bg-transparent p-0" loop>
onChange={(e) => setQuery(e.target.value)} <CommandInput placeholder="Search…" />
placeholder="Search…" <CommandList className="max-h-[50vh]">
className="flex-1 bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground" <CommandEmpty>No results found.</CommandEmpty>
/> <CommandGroup>
</div> {options.map((o) => (
<div className={`overflow-y-auto ${listHeight}`}> <CommandItem
{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]} key={o[idKey]}
role="option" value={searchValue(o)}
aria-selected={String(value) === String(o[idKey])} onSelect={() => { onSelect(o); setOpen(false); }}
className={`cursor-pointer select-none transition-colors hover:bg-accent hover:text-accent-foreground${String(value) === String(o[idKey]) ? ' bg-accent/50' : ''}`} className="p-0"
onClick={() => { onSelect(o); setOpen(false); setQuery(''); }}
> >
{renderItem(o)} {renderItem(o)}
</div> </CommandItem>
)) ))}
)} </CommandGroup>
</div> </CommandList>
</PopoverContent> </Command>
</Popover> </DialogContent>
</Dialog>
</>
); );
} }
@@ -354,6 +393,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
idKey="uuid" idKey="uuid"
labelKey="title" labelKey="title"
placeholder="Select a course" placeholder="Select a course"
dialogTitle="Select a course"
onSelect={(c) => handleContentSelect(item._key, c, 'course')} onSelect={(c) => handleContentSelect(item._key, c, 'course')}
renderTrigger={(c) => ( renderTrigger={(c) => (
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
@@ -367,7 +407,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
</div> </div>
)} )}
renderItem={(c) => ( 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} /> <TierBadge subscription={c.subscription} tierMap={tierMap} />
<span className="flex-1 text-sm truncate">{c.title}</span> <span className="flex-1 text-sm truncate">{c.title}</span>
{fmtDuration(c.duration_seconds) && ( {fmtDuration(c.duration_seconds) && (
@@ -387,16 +427,14 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
options={units} options={units}
idKey="uuid" idKey="uuid"
labelKey="title" labelKey="title"
searchKey="_search"
placeholder="Select a unit" placeholder="Select a unit"
dialogTitle="Select a unit"
onSelect={(u) => handleContentSelect(item._key, u, 'unit')} onSelect={(u) => handleContentSelect(item._key, u, 'unit')}
renderTrigger={(u) => ( renderTrigger={(u) => (
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<TierBadge subscription={u.subscription} tierMap={tierMap} /> <TierBadge subscription={u.subscription} tierMap={tierMap} />
<div className="flex flex-col items-start flex-1"> <div className="flex flex-col items-start flex-1 min-w-0">
<span className="text-xs leading-tight text-muted-foreground truncate"> <BindingLine courses={u.courses} />
{u.course_title} | Unit {u.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{u.title}</span> <span className="text-sm leading-tight truncate">{u.title}</span>
</div> </div>
{fmtDuration(u.duration_seconds) && ( {fmtDuration(u.duration_seconds) && (
@@ -407,15 +445,13 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
</div> </div>
)} )}
renderItem={(u) => ( 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} /> <TierBadge subscription={u.subscription} tierMap={tierMap} />
<div className="flex flex-col flex-1 min-w-0"> <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> <span className="text-sm leading-tight truncate">{u.title}</span>
<BindingLine courses={u.courses} />
</div> </div>
<BindingChip courses={u.courses} />
{fmtDuration(u.duration_seconds) && ( {fmtDuration(u.duration_seconds) && (
<span className="flex items-center gap-1 text-sm text-muted-foreground shrink-0"> <span className="flex items-center gap-1 text-sm text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(u.duration_seconds)} <Clock className="size-3" />{fmtDuration(u.duration_seconds)}
@@ -433,17 +469,13 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
options={lessons} options={lessons}
idKey="uuid" idKey="uuid"
labelKey="title" labelKey="title"
searchKey="_search"
placeholder="Select a lesson" placeholder="Select a lesson"
listHeight="max-h-64" dialogTitle="Select a lesson"
onSelect={(l) => handleContentSelect(item._key, l, 'lesson')} onSelect={(l) => handleContentSelect(item._key, l, 'lesson')}
renderTrigger={(l) => ( renderTrigger={(l) => (
<div className="flex items-center gap-2 min-w-0 flex-1"> <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 min-w-0">
<div className="flex flex-col items-start flex-1"> <BindingLine courses={l.courses} />
<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>
<span className="text-sm leading-tight truncate">{l.title}</span> <span className="text-sm leading-tight truncate">{l.title}</span>
</div> </div>
{fmtDuration(l.duration_seconds) && ( {fmtDuration(l.duration_seconds) && (
@@ -454,14 +486,12 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
</div> </div>
)} )}
renderItem={(l) => ( renderItem={(l) => (
<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={l.subscription} tierMap={tierMap} />
<div className="flex flex-col flex-1 min-w-0"> <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> <span className="text-sm leading-tight truncate">{l.title}</span>
<BindingLine courses={l.courses} />
</div> </div>
<BindingChip courses={l.courses} />
{fmtDuration(l.duration_seconds) && ( {fmtDuration(l.duration_seconds) && (
<span className="flex items-center gap-1 text-sm text-muted-foreground shrink-0"> <span className="flex items-center gap-1 text-sm text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(l.duration_seconds)} <Clock className="size-3" />{fmtDuration(l.duration_seconds)}
@@ -491,29 +521,24 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
options={quizzes} options={quizzes}
idKey="uuid" idKey="uuid"
labelKey="title" labelKey="title"
searchKey="_search"
placeholder="Select a quiz" placeholder="Select a quiz"
dialogTitle="Select a quiz"
onSelect={(q) => handleContentSelect(item._key, q, 'quiz')} onSelect={(q) => handleContentSelect(item._key, q, 'quiz')}
renderTrigger={(q) => ( renderTrigger={(q) => (
<div className="flex items-center gap-2 min-w-0 flex-1"> <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"> <div className="flex flex-col items-start flex-1 min-w-0">
<span className="text-xs leading-tight text-muted-foreground truncate"> <span className="text-xs leading-tight text-muted-foreground truncate">{q.unit_title}</span>
{q.course_title ? `${q.course_title} | ` : ''}{q.unit_title}
</span>
<span className="text-sm leading-tight truncate">{q.title}</span> <span className="text-sm leading-tight truncate">{q.title}</span>
</div> </div>
</div> </div>
)} )}
renderItem={(q) => ( renderItem={(q) => (
<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={q.subscription} tierMap={tierMap} />
<div className="flex flex-col flex-1 min-w-0"> <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-sm leading-tight truncate">{q.title}</span>
<span className="text-xs leading-tight text-muted-foreground truncate">Unit: {q.unit_title}</span>
</div> </div>
<BindingChip courses={q.courses} />
</div> </div>
)} )}
/> />
+11 -1
View File
@@ -16,6 +16,7 @@ import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker"; import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker"; import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// ─── Schema ─────────────────────────────────────────────────────────────────── // ─── Schema ───────────────────────────────────────────────────────────────────
@@ -154,7 +155,7 @@ export default function AddPlan() {
.catch(() => {}); .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), resolver: zodResolver(schema),
defaultValues: { tier_category_id: "", label: "", description: "", features: [], duration_value: 30, duration_unit: "day", price: "", currency: "USD" }, 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 } = const { fields: featureFields, append: appendFeature, remove: removeFeature } =
useFieldArray({ control, name: "features" }); 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"); const selectedCategoryId = watch("tier_category_id");
// Derive the subscription slug from the chosen category // Derive the subscription slug from the chosen category
@@ -207,6 +214,7 @@ export default function AddPlan() {
}).catch(() => {}); }).catch(() => {});
} }
bypassOnce();
navigate(`/admin/tiers/plans`); navigate(`/admin/tiers/plans`);
}; };
@@ -421,6 +429,8 @@ export default function AddPlan() {
</form> </form>
</div> </div>
</div> </div>
{unsavedChangesDialog}
</section> </section>
); );
} }
+7 -1
View File
@@ -19,6 +19,7 @@ import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker"; import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker"; import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
const DURATION_UNITS = [ const DURATION_UNITS = [
{ value: "minute", label: "Minute(s)" }, { value: "minute", label: "Minute(s)" },
@@ -92,13 +93,15 @@ export default function EditPlan() {
const [impactLoading, setImpactLoading] = useState(false); const [impactLoading, setImpactLoading] = useState(false);
const [pendingValues, setPendingValues] = useState(null); 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), resolver: zodResolver(schema),
}); });
const { fields: featureFields, append: appendFeature, remove: removeFeature } = const { fields: featureFields, append: appendFeature, remove: removeFeature } =
useFieldArray({ control, name: "features" }); useFieldArray({ control, name: "features" });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
useEffect(() => { useEffect(() => {
fetchPlan(planId); fetchPlan(planId);
api.get("/admin/tiers/currencies") api.get("/admin/tiers/currencies")
@@ -150,6 +153,7 @@ export default function EditPlan() {
await api.post(`/admin/tiers/${planId}/courses`, { await api.post(`/admin/tiers/${planId}/courses`, {
course_ids: [...selectedCourseIds], course_ids: [...selectedCourseIds],
}).catch(() => {}); }).catch(() => {});
bypassOnce();
navigate("/admin/tiers/plans"); navigate("/admin/tiers/plans");
}; };
@@ -405,6 +409,8 @@ export default function EditPlan() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{unsavedChangesDialog}
</section> </section>
); );
} }
@@ -13,6 +13,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// ─── Zod schema ─────────────────────────────────────────────────────────────── // ─── Zod schema ───────────────────────────────────────────────────────────────
const phoneSchema = z.object({ const phoneSchema = z.object({
@@ -356,13 +357,15 @@ export default function AddStaffUserPage() {
trigger, trigger,
getValues, getValues,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors, isDirty },
} = useForm({ } = useForm({
resolver: zodResolver(staffUserSchema), resolver: zodResolver(staffUserSchema),
defaultValues: DEFAULT_VALUES, defaultValues: DEFAULT_VALUES,
mode: "onTouched", mode: "onTouched",
}); });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
// Validate all fields then advance to summary // Validate all fields then advance to summary
const handleNext = async () => { const handleNext = async () => {
const valid = await trigger(); const valid = await trigger();
@@ -388,7 +391,7 @@ export default function AddStaffUserPage() {
}; };
const res = await addStaffUser(payload); const res = await addStaffUser(payload);
if (res) navigate("/admin/users/all"); if (res) { bypassOnce(); navigate("/admin/users/all"); }
}); });
return ( return (
@@ -479,6 +482,7 @@ export default function AddStaffUserPage() {
)} )}
</div> </div>
{unsavedChangesDialog}
</div> </div>
); );
} }
@@ -17,6 +17,7 @@ import {
SelectTrigger, SelectValue, SelectTrigger, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ArrowLeft, Save, Plus, Trash2, UserCircle2 } from "lucide-react"; import { ArrowLeft, Save, Plus, Trash2, UserCircle2 } from "lucide-react";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// ─── Schema ─────────────────────────────────────────────────────────────────── // ─── Schema ───────────────────────────────────────────────────────────────────
const addressSchema = z.object({ const addressSchema = z.object({
@@ -81,6 +82,8 @@ export default function EditUser() {
}, },
}); });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
// ─── Field arrays ────────────────────────────────────────────────────────── // ─── Field arrays ──────────────────────────────────────────────────────────
const { const {
fields: addressFields, fields: addressFields,
@@ -152,6 +155,7 @@ export default function EditUser() {
const res = await updateUser(id, payload); const res = await updateUser(id, payload);
if (res) { if (res) {
toast("User updated successfully."); toast("User updated successfully.");
bypassOnce();
navigate(`../view/${id}`); navigate(`../view/${id}`);
} else { } else {
toast("Failed to update user."); toast("Failed to update user.");
@@ -418,6 +422,7 @@ export default function EditUser() {
))} ))}
</div> </div>
{unsavedChangesDialog}
</form> </form>
); );
} }
+9 -13
View File
@@ -124,16 +124,15 @@ import NotificationBroadcastList from '../pages/notifications/NotificationBroadc
import AddNotificationBroadcast from '../pages/notifications/AddNotificationBroadcast' import AddNotificationBroadcast from '../pages/notifications/AddNotificationBroadcast'
import EditNotificationBroadcast from '../pages/notifications/EditNotificationBroadcast' import EditNotificationBroadcast from '../pages/notifications/EditNotificationBroadcast'
import ViewNotificationBroadcast from '../pages/notifications/ViewNotificationBroadcast' 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 NotificationTemplates from '../pages/notifications/NotificationTemplates'
import AddNotificationTemplate from '../pages/notifications/AddNotificationTemplate'
import EditNotificationTemplate from '../pages/notifications/EditNotificationTemplate' import EditNotificationTemplate from '../pages/notifications/EditNotificationTemplate'
import ArchivedNotificationBroadcastList from '../pages/notifications/ArchivedNotificationBroadcastList' import ArchivedNotificationBroadcastList from '../pages/notifications/ArchivedNotificationBroadcastList'
// Activity // Activity
import ActivityFeed from '../pages/activity/ActivityFeed' import ActivityFeed from '../pages/activity/ActivityFeed'
import UserActivityPage from '../pages/activity/UserActivityPage' import UserActivityPage from '../pages/activity/UserActivityPage'
import ResourceList from '../pages/resources/ResourceList'
export const AdminRoutes = { export const AdminRoutes = {
@@ -187,14 +186,6 @@ export const AdminRoutes = {
], ],
}, },
{
path: 'resources',
element: <Outlet />,
children: [
{ index: true, element: <ResourceList /> },
],
},
// Courses // Courses
{ {
path: '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) // Announcements (admin-authored broadcasts)
{ {
path: 'announcements', path: 'announcements',
@@ -387,7 +381,7 @@ export const AdminRoutes = {
{ index: true, element: <NotificationBroadcastList /> }, { index: true, element: <NotificationBroadcastList /> },
{ path: 'archived', element: <ArchivedNotificationBroadcastList /> }, { path: 'archived', element: <ArchivedNotificationBroadcastList /> },
{ path: 'add', element: <AddNotificationBroadcast /> }, { path: 'add', element: <AddNotificationBroadcast /> },
{ path: 'settings', element: <NotificationSettings /> }, { path: 'settings', element: <Jobs /> },
{ path: ':broadcastId/view', element: <ViewNotificationBroadcast /> }, { path: ':broadcastId/view', element: <ViewNotificationBroadcast /> },
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> }, { path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
] ]
@@ -397,6 +391,7 @@ export const AdminRoutes = {
element: <Outlet />, element: <Outlet />,
children: [ children: [
{ index: true, element: <NotificationTemplates /> }, { index: true, element: <NotificationTemplates /> },
{ path: 'add', element: <AddNotificationTemplate /> },
{ path: ':id/edit', element: <EditNotificationTemplate /> }, { path: ':id/edit', element: <EditNotificationTemplate /> },
] ]
}, },
@@ -409,7 +404,7 @@ export const AdminRoutes = {
{ index: true, element: <NotificationBroadcastList /> }, { index: true, element: <NotificationBroadcastList /> },
{ path: 'archived', element: <ArchivedNotificationBroadcastList /> }, { path: 'archived', element: <ArchivedNotificationBroadcastList /> },
{ path: 'add', element: <AddNotificationBroadcast /> }, { path: 'add', element: <AddNotificationBroadcast /> },
{ path: 'settings', element: <NotificationSettings /> }, { path: 'settings', element: <Jobs /> },
{ path: ':broadcastId/view', element: <ViewNotificationBroadcast /> }, { path: ':broadcastId/view', element: <ViewNotificationBroadcast /> },
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> }, { path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
] ]
@@ -419,6 +414,7 @@ export const AdminRoutes = {
element: <Outlet />, element: <Outlet />,
children: [ children: [
{ index: true, element: <NotificationTemplates /> }, { index: true, element: <NotificationTemplates /> },
{ path: 'add', element: <AddNotificationTemplate /> },
{ path: ':id/edit', element: <EditNotificationTemplate /> }, { path: ':id/edit', element: <EditNotificationTemplate /> },
] ]
}, },
+14 -4
View File
@@ -21,6 +21,7 @@ export const LessonCard = ({ lesson, onViewDetails }) => {
const locked = lesson.is_locked; const locked = lesson.is_locked;
const duration = formatDuration(lesson.duration_seconds); const duration = formatDuration(lesson.duration_seconds);
const unitCount = Number(lesson.unit_count ?? 0); const unitCount = Number(lesson.unit_count ?? 0);
const courses = lesson.courses ?? [];
return ( return (
<div <div
@@ -34,14 +35,23 @@ export const LessonCard = ({ lesson, onViewDetails }) => {
onClick={() => onViewDetails(lesson)} onClick={() => onViewDetails(lesson)}
> >
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{locked ? ( {locked && (
<Badge variant="secondary"> <Badge variant="secondary">
<LockIcon className="size-3" /> Locked <LockIcon className="size-3" /> Locked
</Badge> </Badge>
) : unitCount > 0 ? ( )}
<Badge variant="outline"><Layers className="size-3" /> In {unitCount} unit{unitCount === 1 ? "" : "s"}</Badge> {unitCount > 0 ? (
<Badge variant="outline">
<Layers className="size-3" /> In {unitCount} unit{unitCount === 1 ? "" : "s"}
{courses[0] && (
<>
{" "}· <span className="truncate max-w-[120px] inline-block align-bottom">{courses[0].title}</span>
{courses.length > 1 ? ` +${courses.length - 1}` : ""}
</>
)}
</Badge>
) : ( ) : (
<Badge variant="outline" className="text-muted-foreground">Standalone</Badge> !locked && <Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
)} )}
</div> </div>
+10 -4
View File
@@ -20,6 +20,7 @@ export const UnitCard = ({ unit, onViewDetails }) => {
const duration = formatDuration(unit.duration_seconds); const duration = formatDuration(unit.duration_seconds);
const lessonCount = Number(unit.lesson_count ?? 0); const lessonCount = Number(unit.lesson_count ?? 0);
const courseCount = Number(unit.course_count ?? 0); const courseCount = Number(unit.course_count ?? 0);
const courses = unit.courses ?? [];
return ( return (
<div <div
@@ -33,14 +34,19 @@ export const UnitCard = ({ unit, onViewDetails }) => {
onClick={() => onViewDetails(unit)} onClick={() => onViewDetails(unit)}
> >
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{locked ? ( {locked && (
<Badge variant="secondary"> <Badge variant="secondary">
<LockIcon className="size-3" /> Locked <LockIcon className="size-3" /> Locked
</Badge> </Badge>
) : courseCount > 0 ? ( )}
<Badge variant="outline"><BookOpen className="size-3" /> In {courseCount} course{courseCount === 1 ? "" : "s"}</Badge> {courseCount > 0 ? (
<Badge variant="outline">
<BookOpen className="size-3" />
<span className="truncate max-w-[140px] inline-block align-bottom">{courses[0]?.title ?? "Course"}</span>
{courseCount > 1 ? ` +${courseCount - 1}` : ""}
</Badge>
) : ( ) : (
<Badge variant="outline" className="text-muted-foreground">Standalone</Badge> !locked && <Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
)} )}
{unit.quiz_id && ( {unit.quiz_id && (
<Badge variant="outline"><ClipboardList className="size-3" /> Quiz</Badge> <Badge variant="outline"><ClipboardList className="size-3" /> Quiz</Badge>
+21 -17
View File
@@ -143,8 +143,6 @@ function ClientNav() {
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout } = useAuth() const { user, logout } = useAuth()
const navRef = useRef(null)
// Background fetches only — nav rendering never waits on these // Background fetches only — nav rendering never waits on these
const { achievements, getAchievements } = useProfile() const { achievements, getAchievements } = useProfile()
const { myTier, tierMap, tierCategories, getMyTier, getTierCategories } = useClientTiers() const { myTier, tierMap, tierCategories, getMyTier, getTierCategories } = useClientTiers()
@@ -162,19 +160,6 @@ function ClientNav() {
if (tierCategories.length === 0) getTierCategories(); if (tierCategories.length === 0) getTierCategories();
}, [user]); }, [user]);
// Used by sticky overlays (e.g. StickyAnnouncementBar) to avoid overlapping the fixed header.
useEffect(() => {
if (!navRef.current) return
const update = () => {
document.documentElement.style.setProperty('--navbar-h', `${navRef.current.offsetHeight}px`)
}
update()
const ro = new ResizeObserver(update)
ro.observe(navRef.current)
return () => ro.disconnect()
}, [])
// ── Derive directly from auth user — same pattern as admin UserMenu ────── // ── Derive directly from auth user — same pattern as admin UserMenu ──────
// No extra fetch, no loading state, no flicker on reload. // No extra fetch, no loading state, no flicker on reload.
const given = user?.personal_info?.name?.given_name ?? "" const given = user?.personal_info?.name?.given_name ?? ""
@@ -235,7 +220,7 @@ function ClientNav() {
return ( return (
<> <>
<nav ref={navRef} className="bg-card fixed w-full z-50 top-0 border-b border-default"> <nav className="bg-card w-full border-b border-default">
<div className="flex flex-wrap items-center justify-between mx-auto py-3 px-6"> <div className="flex flex-wrap items-center justify-between mx-auto py-3 px-6">
{/* Logo */} {/* Logo */}
@@ -357,11 +342,30 @@ const ClientLayout = () => {
const currentHandle = matches.at(-1)?.handle ?? {} const currentHandle = matches.at(-1)?.handle ?? {}
const showFooter = currentHandle.showFooter ?? true const showFooter = currentHandle.showFooter ?? true
// Sticky announcement bar and nav stack inside one fixed header instead of
// each being independently `fixed top-0` (which made them overlap). Height
// is measured off this wrapper so --navbar-h always reflects the combined
// space, whether or not an announcement is currently showing.
const headerRef = useRef(null)
useEffect(() => {
if (!headerRef.current) return
const update = () => {
document.documentElement.style.setProperty('--navbar-h', `${headerRef.current.offsetHeight}px`)
}
update()
const ro = new ResizeObserver(update)
ro.observe(headerRef.current)
return () => ro.disconnect()
}, [])
return ( return (
<ClientProvider> <ClientProvider>
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<ClientNav /> <header ref={headerRef} className="fixed top-0 left-0 right-0 z-50 flex flex-col">
<StickyAnnouncementBar /> <StickyAnnouncementBar />
<ClientNav />
</header>
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<Outlet /> <Outlet />
</div> </div>
+27 -17
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { House, Timer, ChevronLeft, ChevronRight, LockIcon, Tag, Tags, Check, ShoppingCart } from "lucide-react"; import { House, Timer, ChevronLeft, ChevronRight, LockIcon, Tag, Tags, Check, ShoppingCart, Search } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@@ -170,6 +170,7 @@ const CoursesList = () => {
const [tierCategories, setTierCategories] = useState([]); const [tierCategories, setTierCategories] = useState([]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [subFilter, setSubFilter] = useState("All"); const [subFilter, setSubFilter] = useState("All");
const [categoryFilter, setCategoryFilter] = useState("All"); const [categoryFilter, setCategoryFilter] = useState("All");
@@ -215,6 +216,8 @@ const CoursesList = () => {
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)); const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const runSearch = () => { setSearch(searchInput); setCurrentPage(1); };
const handleViewDetails = (course) => { const handleViewDetails = (course) => {
if (course.is_locked) { if (course.is_locked) {
setSelectedCourse(course); setSelectedCourse(course);
@@ -242,26 +245,20 @@ const CoursesList = () => {
{/* Search & Filters */} {/* Search & Filters */}
<div className="fixed top-[67px] left-0 right-0 z-10 bg-card border-b lg:static lg:bg-transparent lg:border-none py-3 xs:px-4 md:px-6 lg:px-0"> <div className="fixed top-[67px] left-0 right-0 z-10 bg-card border-b lg:static lg:bg-transparent lg:border-none py-3 xs:px-4 md:px-6 lg:px-0">
<div className="flex items-center xs:flex-col lg:flex-row gap-4"> <div className="flex items-center xs:flex-col lg:flex-row gap-4">
<div className="flex items-center gap-2 w-full lg:max-w-64">
<Input <Input
placeholder="Search courses..." placeholder="Search courses..."
className="w-full bg-card lg:max-w-64 text-sm" className="w-full bg-card text-sm"
value={search} value={searchInput}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }} onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") runSearch(); }}
/> />
<Button size="icon" variant="outline" className="shrink-0 bg-card" onClick={runSearch} aria-label="Search">
<Search />
</Button>
</div>
<div className="flex gap-4 items-start w-full"> <div className="flex gap-4 items-start w-full">
<Select value="courses" onValueChange={(v) => {
if (v === "units") navigate("/units");
if (v === "lessons") navigate("/lessons");
}}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Browse" />
</SelectTrigger>
<SelectContent>
<SelectItem value="courses">Courses</SelectItem>
<SelectItem value="units">Units</SelectItem>
<SelectItem value="lessons">Lessons</SelectItem>
</SelectContent>
</Select>
<Select value={subFilter} onValueChange={(v) => { setSubFilter(v); setCurrentPage(1); }}> <Select value={subFilter} onValueChange={(v) => { setSubFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-full lg:w-48 bg-card"> <SelectTrigger className="w-full lg:w-48 bg-card">
@@ -276,6 +273,19 @@ const CoursesList = () => {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value="courses" onValueChange={(v) => {
if (v === "units") navigate("/units");
if (v === "lessons") navigate("/lessons");
}}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Browse" />
</SelectTrigger>
<SelectContent>
<SelectItem value="courses">Courses</SelectItem>
<SelectItem value="units">Units</SelectItem>
<SelectItem value="lessons">Lessons</SelectItem>
</SelectContent>
</Select>
{allCategories.length > 0 && ( {allCategories.length > 0 && (
<Select value={categoryFilter} onValueChange={(v) => { setCategoryFilter(v); setCurrentPage(1); }}> <Select value={categoryFilter} onValueChange={(v) => { setCategoryFilter(v); setCurrentPage(1); }}>
+95 -68
View File
@@ -1,8 +1,9 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { import {
House, Timer, SendHorizonal, CheckCheck, ListChecks, ArrowRight, Layers, Hourglass, House, SendHorizonal, CheckCheck, Check, Hourglass,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -21,13 +22,64 @@ function formatDuration(seconds = 0) {
return `${m}min`; return `${m}min`;
} }
// ─── Sibling lessons sidebar (unit context) ────────────────────────────────────
const UnitLessonsSidebar = ({ unitDetail, currentLessonUuid, onSelect }) => {
if (!unitDetail) return null;
const lessons = unitDetail.lessons ?? [];
return (
<aside className="lg:w-80 shrink-0 bg-muted xs:px-4 xs:py-8 lg:px-4 lg:py-8 flex flex-col gap-3">
<span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">{unitDetail.title}</span>
<div className="flex flex-col gap-1">
{lessons.map((l) => {
const isCurrent = l.uuid === currentLessonUuid;
const completed = l.status === "completed";
return (
<div
key={l.lesson_id}
onClick={() => onSelect(l)}
className={cn(
"flex items-center justify-between gap-2 py-2.5 px-3 rounded-lg cursor-pointer text-sm transition-colors",
isCurrent
? "bg-background border shadow-sm font-medium text-card-foreground"
: "hover:bg-background/60"
)}
>
<span className={cn(
"truncate",
!isCurrent && !completed && "text-muted-foreground"
)}>
{l.title}
</span>
{completed ? (
<Check className="size-4 text-emerald-500 shrink-0" />
) : formatDuration(l.duration_seconds) && (
<span className={cn(
"text-xs shrink-0",
isCurrent ? "text-blue-600 dark:text-blue-400" : "text-muted-foreground"
)}>
{formatDuration(l.duration_seconds)}
</span>
)}
</div>
);
})}
</div>
</aside>
);
};
// ─── Lesson Details ───────────────────────────────────────────────────────── // ─── Lesson Details ─────────────────────────────────────────────────────────
const LessonDetails = () => { const LessonDetails = () => {
const { uuid } = useParams(); const { uuid } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson } = useLibrary(); const {
getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson,
getUnitDetail, unitDetail, resetUnitDetail,
} = useLibrary();
const { tierMap, getTierCategories } = useClientTiers(); const { tierMap, getTierCategories } = useClientTiers();
const hasCompleted = lesson?.status === "completed"; const hasCompleted = lesson?.status === "completed";
@@ -41,6 +93,13 @@ const LessonDetails = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [uuid]); }, [uuid]);
useEffect(() => {
if (!hasUnit) return;
getUnitDetail(unit.uuid);
return () => resetUnitDetail();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [unit?.uuid]);
const items = [ const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` }, { label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
{ label: "Lessons", to: `/lessons` }, { label: "Lessons", to: `/lessons` },
@@ -67,31 +126,40 @@ const LessonDetails = () => {
if (!hasUnit) return; if (!hasUnit) return;
navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } }); navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } });
}; };
const handleSelectSibling = (sibling) => {
if (sibling.uuid === uuid) return;
navigate(`/lessons/${sibling.uuid}`);
};
return ( return (
<div> <div className="flex-1 flex flex-col">
<PageMeta title={`${lesson.title} - STARR`} description={lesson.description} /> <PageMeta title={`${lesson.title} - STARR`} description={lesson.description} />
<div className="my-17"> <div
<div className="flex flex-col gap-8"> className="flex-1 flex flex-col lg:flex-row items-stretch"
<div className="flex flex-col gap-6"> style={{ paddingTop: "var(--navbar-h)" }}
>
<div className="flex flex-col gap-6 flex-1 min-w-0 xs:px-4 xs:py-8 lg:px-16 lg:py-10">
<AppBreadcrumb items={items} />
{/* Hero */} <div className="flex flex-col gap-3 max-w-2xl">
<div className="bg-primary dark:bg-accent/50"> <h1 className="font-bold xs:text-2xl lg:text-3xl">{lesson.title}</h1>
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-6 lg:px-4 lg:py-16"> <p className="text-muted-foreground">{lesson.description ?? ""}</p>
<AppBreadcrumb </div>
color={{ link: { color: "text-white" }, page: { color: "text-white" } }}
items={items} {lesson.objectives?.length > 0 && (
/> <div className="bg-background border rounded-lg p-4 flex flex-col gap-3 max-w-2xl">
<div className="flex lg:flex-row items-start justify-between w-full text-white"> <span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">What you'll learn</span>
<div className="flex flex-col gap-4"> <ul className="flex flex-col gap-2">
<h1 className="font-bold xs:text-2xl lg:text-4xl">{lesson.title}</h1> {lesson.objectives.map((obj) => (
<p className="max-w-2xl xs:text-sm lg:text-lg">{lesson.description ?? ""}</p> <li key={obj.objective_id} className="flex items-start gap-2 text-card-foreground">
{lesson.duration_seconds > 0 && ( <Check className="size-4 text-emerald-500 shrink-0 mt-0.5" />
<div className="[&_svg]:size-4 flex gap-1.5 items-center"> {obj.text}
<Timer /> </li>
{formatDuration(lesson.duration_seconds)} ))}
</ul>
</div> </div>
)} )}
<div className="w-fit"> <div className="w-fit">
{!hasUnit ? ( {!hasUnit ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground"> <div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
@@ -99,10 +167,7 @@ const LessonDetails = () => {
This lesson isn't part of a unit yet. Check back later. This lesson isn't part of a unit yet. Check back later.
</div> </div>
) : ( ) : (
<Button <Button className="w-fit bg-blue-500" onClick={handleStart}>
className="w-fit bg-blue-500"
onClick={handleStart}
>
{hasCompleted {hasCompleted
? <><CheckCheck /> Start Again</> ? <><CheckCheck /> Start Again</>
: <><SendHorizonal /> Start Lesson</> : <><SendHorizonal /> Start Lesson</>
@@ -111,54 +176,16 @@ const LessonDetails = () => {
)} )}
</div> </div>
</div> </div>
</div>
</div>
</div>
{/* Body */}
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
<div className="flex flex-col gap-8 max-w-3xl">
<div className="space-y-4">
<div className="font-bold text-2xl">About this lesson</div>
<div className="space-y-4 text-muted-foreground lg:text-lg">
<p>{lesson.description ?? ""}</p>
</div>
</div>
{lesson.objectives?.length > 0 && (
<div className="space-y-4">
<div className="font-bold text-2xl flex items-center gap-2">
<ListChecks className="size-5 text-muted-foreground" />
Objectives
</div>
<ul className="space-y-2">
{lesson.objectives.map((obj) => (
<li key={obj.objective_id} className="flex items-start gap-2 text-muted-foreground lg:text-lg">
<span className="mt-2.5 size-1.5 rounded-full bg-muted-foreground/60 shrink-0" />
{obj.text}
</li>
))}
</ul>
</div>
)}
{hasUnit && ( {hasUnit && (
<button <UnitLessonsSidebar
type="button" unitDetail={unitDetail}
onClick={() => navigate(`/units/${unit.uuid}`)} currentLessonUuid={uuid}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-fit" onSelect={handleSelectSibling}
> />
<Layers className="size-4" />
Part of unit: <span className="font-medium text-foreground">{unit.title}</span>
<ArrowRight className="size-3.5" />
</button>
)} )}
</div> </div>
</div> </div>
</div>
</div>
</div>
</div>
); );
}; };
+13 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { House, ChevronLeft, ChevronRight, Layers } from "lucide-react"; import { House, ChevronLeft, ChevronRight, Layers, Search } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@@ -70,6 +70,7 @@ const LessonsList = () => {
const { tierMap, getTierCategories } = useClientTiers(); const { tierMap, getTierCategories } = useClientTiers();
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [lockFilter, setLockFilter] = useState("All"); const [lockFilter, setLockFilter] = useState("All");
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
@@ -98,6 +99,8 @@ const LessonsList = () => {
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)); const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const runSearch = () => { setSearch(searchInput); setCurrentPage(1); };
const handleViewDetails = (lesson) => { const handleViewDetails = (lesson) => {
if (lesson.is_locked) { if (lesson.is_locked) {
setSelectedLesson(lesson); setSelectedLesson(lesson);
@@ -122,12 +125,18 @@ const LessonsList = () => {
{/* Search & Filters */} {/* Search & Filters */}
<div className="fixed top-[67px] left-0 right-0 z-10 bg-card border-b lg:static lg:bg-transparent lg:border-none py-3 xs:px-4 md:px-6 lg:px-0"> <div className="fixed top-[67px] left-0 right-0 z-10 bg-card border-b lg:static lg:bg-transparent lg:border-none py-3 xs:px-4 md:px-6 lg:px-0">
<div className="flex items-center xs:flex-col lg:flex-row gap-4"> <div className="flex items-center xs:flex-col lg:flex-row gap-4">
<div className="flex items-center gap-2 w-full lg:max-w-64">
<Input <Input
placeholder="Search lessons..." placeholder="Search lessons..."
className="w-full bg-card lg:max-w-64 text-sm" className="w-full bg-card text-sm"
value={search} value={searchInput}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }} onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") runSearch(); }}
/> />
<Button size="icon" variant="outline" className="shrink-0 bg-card" onClick={runSearch} aria-label="Search">
<Search />
</Button>
</div>
<Select value={lockFilter} onValueChange={(v) => { setLockFilter(v); setCurrentPage(1); }}> <Select value={lockFilter} onValueChange={(v) => { setLockFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-full lg:w-48 bg-card"> <SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Access" /> <SelectValue placeholder="Access" />
+86 -104
View File
@@ -1,12 +1,12 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { import {
House, Timer, Layers, SendHorizonal, CheckCheck, CheckCircle2, Circle, House, Timer, CheckCircle2, Check, ClipboardList, Hourglass,
FileQuestion, Hourglass, ClipboardList,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -25,74 +25,79 @@ function formatDuration(seconds = 0) {
return `${m}min`; return `${m}min`;
} }
// ─── Content card (single unit — lessons + quiz, no multi-unit spine) ───────── // ─── Lessons roadmap (single unit — lessons in order + trailing quiz row) ─────
const UnitContentCard = ({ unitDetail, onLessonClick, onQuizClick }) => { const LessonsRoadmap = ({ unitDetail, currentLessonId, onLessonClick, onContinue, onQuizClick }) => {
const lessons = unitDetail.lessons ?? []; const lessons = unitDetail.lessons ?? [];
const quiz = unitDetail.quiz ?? null; const quiz = unitDetail.quiz ?? null;
return (
<div className="flex flex-col gap-1">
{lessons.map((lesson, index) => {
const completed = lesson.status === "completed";
const isCurrent = lesson.lesson_id === currentLessonId;
return ( return (
<motion.div <motion.div
className="rounded-xl border bg-card"
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
>
<div className="px-4 py-4 border-b flex flex-col gap-2">
<div className="[&_svg]:size-4 text-md text-muted-foreground flex items-center gap-4">
{lessons.length > 0 && (
<div className="flex items-center gap-1.5">
<Layers /> {lessons.length} {lessons.length === 1 ? "Lesson" : "Lessons"}
</div>
)}
{unitDetail.duration_seconds > 0 && (
<div className="flex items-center gap-1.5">
<Timer /> {formatDuration(unitDetail.duration_seconds)}
</div>
)}
{quiz && (
<div className="flex items-center gap-1.5">
<FileQuestion /> Quiz
</div>
)}
</div>
</div>
<div className="flex flex-col gap-1 p-1.5">
{lessons.map((lesson) => (
<div
key={lesson.lesson_id} key={lesson.lesson_id}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-200 dark:hover:bg-blue-500 transition-colors cursor-pointer" initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className={cn(
"flex items-center justify-between gap-3 py-3 px-3 rounded-lg cursor-pointer transition-colors",
isCurrent ? "bg-blue-50 dark:bg-blue-950/40" : "hover:bg-muted/60"
)}
onClick={() => onLessonClick(lesson)} onClick={() => onLessonClick(lesson)}
> >
<div className="flex items-start gap-3 select-none min-w-0"> <div className="flex items-start gap-3 min-w-0">
{lesson.status === "completed" <div className={cn(
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0 mt-0.5" /> "w-7 h-7 rounded-full border bg-background flex items-center justify-center text-xs font-medium shrink-0",
: <Circle className="size-4 text-muted-foreground/40 shrink-0 mt-0.5" /> completed
} ? "border-emerald-500 text-emerald-500"
: isCurrent
? "border-blue-500 text-blue-500"
: "border-border text-muted-foreground"
)}>
{completed ? <Check className="size-3.5" /> : index + 1}
</div>
<div className="min-w-0"> <div className="min-w-0">
<span className="text-md text-card-foreground truncate block">{lesson.title}</span> <span className={cn(
{lesson.description && ( "block truncate",
<span className="text-sm text-muted-foreground line-clamp-1 block">{lesson.description}</span> completed || isCurrent ? "font-medium text-card-foreground" : "text-muted-foreground"
)}>
{lesson.title}
</span>
<span className="text-sm text-muted-foreground">
{formatDuration(lesson.duration_seconds) ?? "—"}
{completed && " · Completed"}
{isCurrent && !completed && " · In progress"}
</span>
</div>
</div>
{isCurrent && !completed && (
<Button
size="sm"
className="bg-blue-500 shrink-0"
onClick={(e) => { e.stopPropagation(); onContinue(lesson); }}
>
Continue
</Button>
)} )}
</div> </motion.div>
</div> );
{lesson.duration_seconds > 0 && ( })}
<span className="text-sm shrink-0 ml-2">{formatDuration(lesson.duration_seconds)}</span>
)}
</div>
))}
{quiz && ( {quiz && (
<div <div
className="flex items-center justify-between py-2 px-3 mt-1 rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/60 dark:bg-blue-950/30 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors cursor-pointer" className="flex items-center justify-between py-3 px-3 mt-1 rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/60 dark:bg-blue-950/30 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors cursor-pointer"
onClick={() => onQuizClick()} onClick={() => onQuizClick()}
> >
<div className="flex items-center gap-3 select-none min-w-0"> <div className="flex items-center gap-3 select-none min-w-0">
{quiz.has_passed {quiz.has_passed
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" /> ? <CheckCircle2 className="size-5 text-emerald-500 shrink-0" />
: <ClipboardList className="size-4 text-blue-500 shrink-0" /> : <ClipboardList className="size-5 text-blue-500 shrink-0" />
} }
<span className="text-sm font-medium text-blue-700 dark:text-blue-300 truncate">{quiz.title || "Quiz"}</span> <span className="font-medium text-blue-700 dark:text-blue-300 truncate">{quiz.title || "Quiz"}</span>
</div> </div>
<Badge className={cn( <Badge className={cn(
"shrink-0 ml-2 text-[10px]", "shrink-0 ml-2 text-[10px]",
@@ -105,7 +110,6 @@ const UnitContentCard = ({ unitDetail, onLessonClick, onQuizClick }) => {
</div> </div>
)} )}
</div> </div>
</motion.div>
); );
}; };
@@ -118,7 +122,6 @@ const UnitDetails = () => {
const { getUnitDetail, unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo, resetUnitDetail } = useLibrary(); const { getUnitDetail, unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo, resetUnitDetail } = useLibrary();
const { tierMap, getTierCategories } = useClientTiers(); const { tierMap, getTierCategories } = useClientTiers();
const hasCompleted = !!unitDetail?.is_completed;
const contentNotReady = !unitDetail?.duration_seconds; const contentNotReady = !unitDetail?.duration_seconds;
useEffect(() => { useEffect(() => {
@@ -149,9 +152,17 @@ const UnitDetails = () => {
); );
} }
const lessons = unitDetail?.lessons ?? [];
const completedCount = lessons.filter((l) => l.status === "completed").length;
const currentLesson = lessons.find((l) => l.status !== "completed") ?? null;
const progressPct = lessons.length > 0 ? Math.round((completedCount / lessons.length) * 100) : 0;
const handleLessonClick = (lesson) => { const handleLessonClick = (lesson) => {
navigate(`/lessons/${lesson.uuid}`); navigate(`/lessons/${lesson.uuid}`);
}; };
const handleContinue = (lesson) => {
navigate(`/units/${uuid}/read`, { state: { lessonId: lesson.lesson_id } });
};
const handleQuizClick = () => { const handleQuizClick = () => {
navigate(`/units/${uuid}/read`, { state: { quizId: true } }); navigate(`/units/${uuid}/read`, { state: { quizId: true } });
}; };
@@ -159,76 +170,47 @@ const UnitDetails = () => {
return ( return (
<div> <div>
<PageMeta title={unitDetail ? `${unitDetail.title} - STARR` : undefined} description={unitDetail?.description} /> <PageMeta title={unitDetail ? `${unitDetail.title} - STARR` : undefined} description={unitDetail?.description} />
<div className="my-17"> <div className="xs:my-20 lg:my-28 lg:container lg:mx-auto xs:px-4 lg:px-4 flex flex-col gap-6 max-w-3xl">
<div className="flex flex-col gap-8"> <AppBreadcrumb items={items} />
<div className="flex flex-col gap-6">
{/* Hero */} <div className="flex flex-col gap-3">
<div className="bg-primary dark:bg-accent/50"> <span className="text-xs font-semibold tracking-wide text-blue-600 dark:text-blue-400 uppercase">Unit</span>
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-6 lg:px-4 lg:py-16">
<AppBreadcrumb
color={{ link: { color: "text-white" }, page: { color: "text-white" } }}
items={items}
/>
<div className="flex lg:flex-row items-start justify-between w-full text-white">
<div className="flex flex-col gap-4">
<h1 className="font-bold xs:text-2xl lg:text-4xl">{unitDetail?.title ?? "Unit"}</h1> <h1 className="font-bold xs:text-2xl lg:text-4xl">{unitDetail?.title ?? "Unit"}</h1>
<p className="max-w-2xl xs:text-sm lg:text-lg">{unitDetail?.description ?? ""}</p> <p className="text-muted-foreground lg:text-lg">{unitDetail?.description ?? ""}</p>
{unitDetail?.duration_seconds > 0 && (
<div className="[&_svg]:size-4 flex gap-1.5 items-center">
<Timer />
{formatDuration(unitDetail.duration_seconds)}
</div> </div>
)}
<div className="w-fit">
{contentNotReady ? ( {contentNotReady ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground"> <div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground w-fit">
<Hourglass className="size-4 shrink-0" /> <Hourglass className="size-4 shrink-0" />
This unit is currently being prepared. Please check back later. This unit is currently being prepared. Please check back later.
</div> </div>
) : ( ) : (
<Button <>
className="w-fit bg-blue-500" <div className="flex flex-col gap-2">
onClick={() => navigate(`/units/${uuid}/read`)} <div className="flex items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4">
> <span>{lessons.length} {lessons.length === 1 ? "lesson" : "lessons"}</span>
{hasCompleted <span>·</span>
? <><CheckCheck /> Start Again</> <span className="flex items-center gap-1"><Timer /> {formatDuration(unitDetail.duration_seconds) ?? "—"} total</span>
: <><SendHorizonal /> Start Learning</> <span>·</span>
} <span className="text-blue-600 dark:text-blue-400 font-medium">{completedCount} of {lessons.length} complete</span>
</Button>
)}
</div>
</div>
</div>
</div> </div>
<Progress value={progressPct} />
</div> </div>
{/* Body */} <div className="flex flex-col gap-3">
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0"> <h2 className="font-bold text-xl">Lessons in this unit</h2>
<div className="flex flex-col gap-8 max-w-3xl"> <LessonsRoadmap
<div className="space-y-4">
<div className="font-bold text-2xl">About this unit</div>
<div className="space-y-4 text-muted-foreground lg:text-lg">
<p>{unitDetail?.description ?? ""}</p>
</div>
</div>
{unitDetail && !contentNotReady && (
<div className="space-y-4">
<div className="font-bold text-2xl">Unit content</div>
<UnitContentCard
unitDetail={unitDetail} unitDetail={unitDetail}
currentLessonId={currentLesson?.lesson_id}
onLessonClick={handleLessonClick} onLessonClick={handleLessonClick}
onContinue={handleContinue}
onQuizClick={handleQuizClick} onQuizClick={handleQuizClick}
/> />
</div> </div>
</>
)} )}
</div> </div>
</div> </div>
</div>
</div>
</div>
</div>
); );
}; };
+13 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { House, ChevronLeft, ChevronRight, Layers } from "lucide-react"; import { House, ChevronLeft, ChevronRight, Layers, Search } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@@ -70,6 +70,7 @@ const UnitsList = () => {
const { tierMap, getTierCategories } = useClientTiers(); const { tierMap, getTierCategories } = useClientTiers();
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [lockFilter, setLockFilter] = useState("All"); const [lockFilter, setLockFilter] = useState("All");
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
@@ -98,6 +99,8 @@ const UnitsList = () => {
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)); const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const runSearch = () => { setSearch(searchInput); setCurrentPage(1); };
const handleViewDetails = (unit) => { const handleViewDetails = (unit) => {
if (unit.is_locked) { if (unit.is_locked) {
setSelectedUnit(unit); setSelectedUnit(unit);
@@ -122,12 +125,18 @@ const UnitsList = () => {
{/* Search & Filters */} {/* Search & Filters */}
<div className="fixed top-[67px] left-0 right-0 z-10 bg-card border-b lg:static lg:bg-transparent lg:border-none py-3 xs:px-4 md:px-6 lg:px-0"> <div className="fixed top-[67px] left-0 right-0 z-10 bg-card border-b lg:static lg:bg-transparent lg:border-none py-3 xs:px-4 md:px-6 lg:px-0">
<div className="flex items-center xs:flex-col lg:flex-row gap-4"> <div className="flex items-center xs:flex-col lg:flex-row gap-4">
<div className="flex items-center gap-2 w-full lg:max-w-64">
<Input <Input
placeholder="Search units..." placeholder="Search units..."
className="w-full bg-card lg:max-w-64 text-sm" className="w-full bg-card text-sm"
value={search} value={searchInput}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }} onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") runSearch(); }}
/> />
<Button size="icon" variant="outline" className="shrink-0 bg-card" onClick={runSearch} aria-label="Search">
<Search />
</Button>
</div>
<Select value={lockFilter} onValueChange={(v) => { setLockFilter(v); setCurrentPage(1); }}> <Select value={lockFilter} onValueChange={(v) => { setLockFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-full lg:w-48 bg-card"> <SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Access" /> <SelectValue placeholder="Access" />