From 71f758fe0b4ecf1df9262e70442b4ada9b32660b Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Sat, 11 Jul 2026 12:12:40 +0800 Subject: [PATCH] merged Signed-off-by: Kenneth Obsequio --- .../generic/AdminStickyAnnouncementBar.jsx | 116 ++++ .../generic/Dialogs/UnsavedChangesDialog.jsx | 72 +++ .../generic/StickyAnnouncementBar.jsx | 158 +++--- .../generic/notificationDisplay.jsx | 20 + src/contexts/AdminLibraryContext.jsx | 26 +- src/contexts/AdminNotificationContext.jsx | 23 +- .../AdminNotificationTemplateContext.jsx | 18 +- src/contexts/AdminTaskContext.jsx | 23 +- src/data/adminTiles.data.js | 27 +- src/data/advertisement.data.js | 6 + src/data/placement.data.js | 4 + src/hooks/useAssetPreviewSrc.js | 20 +- src/hooks/useUnsavedChangesGuard.jsx | 73 +++ .../courses/AchievementsBuilder.jsx | 133 +++++ .../courses/AttachAchievementDialog.jsx | 107 ++++ .../courses/CreateAchievementDialog.jsx | 179 ++++++ .../components/courses/RoadmapBuilder.jsx | 9 +- .../components/library/AttachUnitsDialog.jsx | 38 +- .../advertisements/archive/columns.config.jsx | 4 + .../config/library/lessons/columns.config.jsx | 25 +- .../library/lessons/rowActions.config.jsx | 12 +- .../config/library/units/columns.config.jsx | 17 +- src/modules/admin/layouts/AdminLayout.jsx | 4 +- .../pages/advertisements/AddAdvertisement.jsx | 28 +- .../advertisements/AdvertisementList.jsx | 41 +- .../advertisements/EditAdvertisement.jsx | 9 +- src/modules/admin/pages/assets/AddAsset.jsx | 11 +- src/modules/admin/pages/assets/EditAsset.jsx | 6 + .../admin/pages/categories/AddCategory.jsx | 8 +- .../admin/pages/categories/EditCategory.jsx | 8 +- src/modules/admin/pages/courses/AddCourse.jsx | 265 +++++---- .../admin/pages/courses/CourseList.jsx | 4 +- .../admin/pages/courses/EditCourse.jsx | 43 +- .../admin/pages/courses/lessons/AddLesson.jsx | 8 +- .../pages/courses/lessons/EditLesson.jsx | 8 +- .../courses/lessons/LessonPageBuilder.jsx | 29 +- .../admin/pages/courses/units/AddUnit.jsx | 8 +- .../admin/pages/courses/units/EditUnit.jsx | 8 +- .../Jobs.jsx} | 15 +- .../library/lessons/AddLibraryLesson.jsx | 300 +++++++++- .../library/lessons/EditLibraryLesson.jsx | 8 +- .../library/lessons/LessonLibraryList.jsx | 4 +- .../pages/library/units/AddLibraryUnit.jsx | 523 ++++++++++++++++-- .../pages/library/units/EditLibraryUnit.jsx | 8 +- .../pages/library/units/UnitLibraryList.jsx | 4 +- .../AddNotificationBroadcast.jsx | 108 +++- .../notifications/AddNotificationTemplate.jsx | 136 +++++ .../EditNotificationBroadcast.jsx | 111 +++- .../EditNotificationTemplate.jsx | 111 +++- .../NotificationBroadcastList.jsx | 29 +- .../notifications/NotificationTemplates.jsx | 73 ++- .../admin/pages/resources/ResourceList.jsx | 85 --- .../task_list/task/RequirementBuilder.jsx | 211 +++---- src/modules/admin/pages/tiers/AddPlan.jsx | 12 +- src/modules/admin/pages/tiers/EditPlan.jsx | 8 +- .../admin/pages/users/AddStaffUser.jsx | 8 +- src/modules/admin/pages/users/EditUser.jsx | 5 + src/modules/admin/routes/AdminRoutes.jsx | 22 +- src/modules/client/components/LessonCard.jsx | 18 +- src/modules/client/components/UnitCard.jsx | 14 +- src/modules/client/layout/ClientLayout.jsx | 40 +- src/modules/client/pages/CourseList.jsx | 50 +- src/modules/client/pages/LessonDetails.jsx | 193 ++++--- src/modules/client/pages/LessonsList.jsx | 23 +- src/modules/client/pages/UnitDetails.jsx | 254 ++++----- src/modules/client/pages/UnitsList.jsx | 23 +- 66 files changed, 3055 insertions(+), 939 deletions(-) create mode 100644 src/components/generic/AdminStickyAnnouncementBar.jsx create mode 100644 src/components/generic/Dialogs/UnsavedChangesDialog.jsx create mode 100644 src/hooks/useUnsavedChangesGuard.jsx create mode 100644 src/modules/admin/components/courses/AchievementsBuilder.jsx create mode 100644 src/modules/admin/components/courses/AttachAchievementDialog.jsx create mode 100644 src/modules/admin/components/courses/CreateAchievementDialog.jsx rename src/modules/admin/pages/{notifications/NotificationSettings.jsx => jobs/Jobs.jsx} (95%) create mode 100644 src/modules/admin/pages/notifications/AddNotificationTemplate.jsx delete mode 100644 src/modules/admin/pages/resources/ResourceList.jsx diff --git a/src/components/generic/AdminStickyAnnouncementBar.jsx b/src/components/generic/AdminStickyAnnouncementBar.jsx new file mode 100644 index 0000000..4b72274 --- /dev/null +++ b/src/components/generic/AdminStickyAnnouncementBar.jsx @@ -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 ( + <> +
+
+
+
+ +
+ +
+

+ {stickyAnnouncement.title || "Announcement"} +

+

+ {stickyAnnouncement.message || ""} +

+
+
+ + +
+
+ + + + + +
+ +
+ {stickyAnnouncement.title || "Announcement"} +
+ +

+ {stickyAnnouncement.message || ""} +

+
+
+ + Close + {linkUrl && ( + + Open Link + + )} + +
+
+ + ); +} diff --git a/src/components/generic/Dialogs/UnsavedChangesDialog.jsx b/src/components/generic/Dialogs/UnsavedChangesDialog.jsx new file mode 100644 index 0000000..fcca2d3 --- /dev/null +++ b/src/components/generic/Dialogs/UnsavedChangesDialog.jsx @@ -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 ( + + + + + + {title} + + {description} + + + {cancelLabel} + + {confirmLabel} + + + + + ); +} diff --git a/src/components/generic/StickyAnnouncementBar.jsx b/src/components/generic/StickyAnnouncementBar.jsx index 5c605da..09e9a5e 100644 --- a/src/components/generic/StickyAnnouncementBar.jsx +++ b/src/components/generic/StickyAnnouncementBar.jsx @@ -1,95 +1,125 @@ -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { X } from "lucide-react"; 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 { 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; - - return { - ...(background ? { backgroundColor: background } : null), - ...(text ? { color: text } : null), - ...(border ? { borderColor: border } : 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 { + label: "Open Link", + go: (navigate) => (explicitUrl.startsWith("/") + ? navigate(explicitUrl) + : window.open(explicitUrl, "_blank", "noopener,noreferrer")), + }; + } + return resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data); } export default function StickyAnnouncementBar() { const navigate = useNavigate(); const { stickyAnnouncement, markSeen } = useClientNotifications(); + const [detailsOpen, setDetailsOpen] = useState(false); const onDismiss = useCallback(async () => { if (!stickyAnnouncement) return; await markSeen(stickyAnnouncement.notification_id); }, [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; - - const id = stickyAnnouncement.notification_id; - await markSeen(id); - - const link = resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data); - if (link) await link.go(navigate); - }, [stickyAnnouncement, markSeen, navigate]); + setDetailsOpen(true); + }, [stickyAnnouncement]); if (!stickyAnnouncement) return null; const accentClass = getTypeAccent(stickyAnnouncement.type); const inlineStyle = resolveStickyStyle(stickyAnnouncement.data); + const clickAction = resolveClickAction(stickyAnnouncement); return ( -
+ <>
-
-
- -
- -
-

- {stickyAnnouncement.title || "Announcement"} -

-

- {stickyAnnouncement.message || ""} -

-
-
- - +
+
+ +
+ +
+

+ {stickyAnnouncement.title || "Announcement"} +

+

+ {stickyAnnouncement.message || ""} +

+
+
+ + +
- + + {/* 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). */} + + + + +
+ +
+ {stickyAnnouncement.title || "Announcement"} +
+ +

+ {stickyAnnouncement.message || ""} +

+
+
+ + Close + {clickAction && ( + clickAction.go(navigate)}> + {clickAction.label} + + )} + +
+
+ ); } diff --git a/src/components/generic/notificationDisplay.jsx b/src/components/generic/notificationDisplay.jsx index f691b0a..a74f86a 100644 --- a/src/components/generic/notificationDisplay.jsx +++ b/src/components/generic/notificationDisplay.jsx @@ -22,6 +22,26 @@ const TYPE_ACCENT = { 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 }) { const Icon = TYPE_ICON[type] ?? Bell; return ; diff --git a/src/contexts/AdminLibraryContext.jsx b/src/contexts/AdminLibraryContext.jsx index 5551fb1..f29abde 100644 --- a/src/contexts/AdminLibraryContext.jsx +++ b/src/contexts/AdminLibraryContext.jsx @@ -118,6 +118,19 @@ export function LibraryProvider({ children }) { [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( (unitId, payload) => request(async () => { @@ -311,6 +324,15 @@ export function LibraryProvider({ children }) { [request], ); + const saveLessonPage = useCallback( + (lessonId, payload) => + request(async () => { + const { data } = await api.put(`${LESSONS_BASE}/${lessonId}/page`, payload); + return data; + }), + [request], + ); + const archiveLesson = useCallback( (lessonId) => request(async () => { @@ -401,7 +423,7 @@ export function LibraryProvider({ children }) { // unit library units, unit, unitsFlat, - fetchUnits, fetchUnitsFlat, fetchUnit, createUnit, updateUnit, + fetchUnits, fetchUnitsFlat, fetchUnit, createUnit, createUnitFull, updateUnit, archiveUnit, archiveUnits, fetchArchivedUnits, restoreUnit, restoreUnits, permanentlyDeleteUnit, permanentlyDeleteUnits, @@ -411,7 +433,7 @@ export function LibraryProvider({ children }) { // lesson library lessons, lesson, lessonsFlat, - fetchLessons, fetchLessonsFlat, fetchLesson, createLesson, updateLesson, + fetchLessons, fetchLessonsFlat, fetchLesson, createLesson, updateLesson, saveLessonPage, archiveLesson, archiveLessons, fetchArchivedLessons, restoreLesson, restoreLessons, permanentlyDeleteLesson, permanentlyDeleteLessons, diff --git a/src/contexts/AdminNotificationContext.jsx b/src/contexts/AdminNotificationContext.jsx index 8351e63..0c47a01 100644 --- a/src/contexts/AdminNotificationContext.jsx +++ b/src/contexts/AdminNotificationContext.jsx @@ -14,6 +14,7 @@ export function useAdminNotifications() { export function AdminNotificationProvider({ children }) { const [notifications, setNotifications] = useState([]); const [unseenCount, setUnseenCount] = useState(0); + const [stickyAnnouncement, setStickyAnnouncement] = useState(null); const [loading, setLoading] = useState(false); 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 () => { setLoading(true); try { @@ -47,6 +57,7 @@ export function AdminNotificationProvider({ children }) { prev.map(n => n.notification_id === id ? { ...n, seen: true } : n) ); setUnseenCount(prev => Math.max(0, prev - 1)); + setStickyAnnouncement(prev => (prev?.notification_id === id ? null : prev)); } catch { // silent } @@ -57,22 +68,28 @@ export function AdminNotificationProvider({ children }) { await api.patch("/admin/notifications/seen-all"); setNotifications(prev => prev.map(n => ({ ...n, seen: true }))); setUnseenCount(0); + setStickyAnnouncement(null); } catch { // silent } }, []); - // Initial load + start polling unseen count + // Initial load + start polling unseen count + sticky announcement useEffect(() => { fetchUnseen(); - intervalRef.current = setInterval(fetchUnseen, POLL_INTERVAL); + fetchStickyAnnouncement(); + intervalRef.current = setInterval(() => { + fetchUnseen(); + fetchStickyAnnouncement(); + }, POLL_INTERVAL); return () => clearInterval(intervalRef.current); - }, [fetchUnseen]); + }, [fetchUnseen, fetchStickyAnnouncement]); return ( + 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 ( {children} diff --git a/src/contexts/AdminTaskContext.jsx b/src/contexts/AdminTaskContext.jsx index 5db28b5..39d3ff7 100644 --- a/src/contexts/AdminTaskContext.jsx +++ b/src/contexts/AdminTaskContext.jsx @@ -437,14 +437,15 @@ export function AdminTaskProvider({ children }) { [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( () => request(async () => { const res = await api.get('/admin/courses/units-flat'); - const raw = res.data?.data ?? []; - return raw.map((u) => ({ - ...u, - _search: `${u.course_title} unit ${u.order_index + 1} ${u.title}`.toLowerCase(), - })); + return res.data?.data ?? []; }), [request] ); @@ -452,11 +453,7 @@ export function AdminTaskProvider({ children }) { const fetchLessonsFlat = useCallback( () => request(async () => { const res = await api.get('/admin/courses/lessons-flat'); - const raw = 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(), - })); + return res.data?.data ?? []; }), [request] ); @@ -464,11 +461,7 @@ export function AdminTaskProvider({ children }) { const fetchQuizzesFlat = useCallback( () => request(async () => { const res = await api.get('/admin/courses/quizzes-flat'); - const raw = res.data?.data ?? []; - return raw.map((q) => ({ - ...q, - _search: `${q.course_title} ${q.unit_title} ${q.title}`.toLowerCase(), - })); + return res.data?.data ?? []; }), [request] ); diff --git a/src/data/adminTiles.data.js b/src/data/adminTiles.data.js index 0f66506..557934f 100644 --- a/src/data/adminTiles.data.js +++ b/src/data/adminTiles.data.js @@ -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 = [ { @@ -18,18 +18,10 @@ export const ADMIN_SECTIONS = [ title: "Resource Management", description: "It includes assets management and tier plans.", 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", tab: "Content Management", @@ -40,7 +32,7 @@ export const ADMIN_SECTIONS = [ { key: "units", label: "Units", icon: BookCheck, link: "/admin/units" }, { key: "lessons", label: "Lessons", icon: FileText, link: "/admin/lessons" }, { 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", tiles: [ { 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" }, ], }, + { + id: "section-system", + tab: "System", + title: "System", + description: "Background jobs and automation", + tiles: [ + { key: "jobs", label: "Jobs", icon: Cog, link: "/admin/jobs" }, + ], + }, ]; \ No newline at end of file diff --git a/src/data/advertisement.data.js b/src/data/advertisement.data.js index d084a80..0a01d0b 100644 --- a/src/data/advertisement.data.js +++ b/src/data/advertisement.data.js @@ -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, // 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 = [ { 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" }, @@ -23,6 +26,9 @@ export const RICH_CONTENT_TYPES = ["hero"]; // ─── Statuses ─────────────────────────────────────────────────────────────── // 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 = [ { 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" }, diff --git a/src/data/placement.data.js b/src/data/placement.data.js index 003cd6d..55f3921 100644 --- a/src/data/placement.data.js +++ b/src/data/placement.data.js @@ -6,6 +6,10 @@ // backend registry when adding a new placement — same pattern as ADVERTISEMENT_TYPES already // 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 = [ { 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)" }, diff --git a/src/hooks/useAssetPreviewSrc.js b/src/hooks/useAssetPreviewSrc.js index 84d5b56..a0dab43 100644 --- a/src/hooks/useAssetPreviewSrc.js +++ b/src/hooks/useAssetPreviewSrc.js @@ -16,22 +16,32 @@ export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) { const [thumbnailUrl, setThumbnailUrl] = useState(null); 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(() => { setSrc(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) { setSrc(fastSrc); - setThumbnailUrl(asset.thumbnail_url ?? null); + setThumbnailUrl(thumbnail_url ?? null); return; } let cancelled = false; setLoading(true); - fetchAssetPreviewSrc(asset, { scope }).then((result) => { + fetchAssetPreviewSrc(currentAsset, { scope }).then((result) => { if (cancelled) return; setSrc(result.src); setThumbnailUrl(result.thumbnailUrl); @@ -40,7 +50,7 @@ export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) { }); return () => { cancelled = true; }; - }, [asset, scope]); + }, [asset_id, storage_provider, file_url, thumbnail_url, scope]); return { src, thumbnailUrl, loading }; } diff --git a/src/hooks/useUnsavedChangesGuard.jsx b/src/hooks/useUnsavedChangesGuard.jsx new file mode 100644 index 0000000..2c37726 --- /dev/null +++ b/src/hooks/useUnsavedChangesGuard.jsx @@ -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 /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
...{unsavedChangesDialog}
; +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: ( + + ), + }; +} diff --git a/src/modules/admin/components/courses/AchievementsBuilder.jsx b/src/modules/admin/components/courses/AchievementsBuilder.jsx new file mode 100644 index 0000000..6d550a4 --- /dev/null +++ b/src/modules/admin/components/courses/AchievementsBuilder.jsx @@ -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 ( +
+
+
+

+ + Award an achievement for completing this course? +

+

+ Optional — learners can earn a badge or milestone for finishing this course. +

+
+
+ + +
+
+
+ ); + } + + return ( +
+
+
+

Achievements

+

+ Attach an existing achievement from the registry, or define a new one from scratch. +

+
+
+ + +
+
+ + {!selected ? ( +
+ +

No achievement selected

+ +
+ ) : ( +
+ {(() => { + const Icon = LucideIcons[selected.icon] ?? Trophy; + return ; + })()} +
+

{selected.label}

+ {selected.description && ( +

{selected.description}

+ )} +
+ + {selected.type === "badge" ? : } + {selected.type} + + +
+ )} + + + +
+ ); +} diff --git a/src/modules/admin/components/courses/AttachAchievementDialog.jsx b/src/modules/admin/components/courses/AttachAchievementDialog.jsx new file mode 100644 index 0000000..e435946 --- /dev/null +++ b/src/modules/admin/components/courses/AttachAchievementDialog.jsx @@ -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 ( + + + + + Attach Achievement + + + Pick one achievement from the registry to award learners who complete this course. + + + +
+ + setQuery(e.target.value)} + /> +
+ + + {candidates.length === 0 ? ( +

+ {query ? "No achievements match your search." : "No achievements in the registry yet."} +

+ ) : ( + + {candidates.map((a) => { + const Icon = LucideIcons[a.icon] ?? Trophy; + return ( + + ); + })} + + )} +
+ + + + + +
+
+ ); +} diff --git a/src/modules/admin/components/courses/CreateAchievementDialog.jsx b/src/modules/admin/components/courses/CreateAchievementDialog.jsx new file mode 100644 index 0000000..1f43a03 --- /dev/null +++ b/src/modules/admin/components/courses/CreateAchievementDialog.jsx @@ -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 ( + + + + New Achievement + + +
+
+ + set("key")(e.target.value.toLowerCase())} + placeholder="e.g. course_marathon" + /> +

Lowercase, no spaces. Cannot be changed after creation.

+ {errors.key &&

{errors.key}

} +
+ +
+ + set("label")(e.target.value)} placeholder="e.g. Course Marathon" /> + {errors.label &&

{errors.label}

} +
+ +
+ +