From fa92d924f4c79ae840b564b50b4ba2043751d3be Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Sun, 12 Jul 2026 12:39:47 +0800 Subject: [PATCH] added Signed-off-by: Kenneth Obsequio --- .../generic/AdminStickyAnnouncementBar.jsx | 209 +++--- .../generic/AnnouncementCarouselDialog.jsx | 83 +++ .../Blocks/Client/Advertisements/Popup.jsx | 71 --- .../Blocks/Client/Advertisements/Sidebar.jsx | 78 --- .../generic/BroadcastTargetPicker.jsx | 14 +- src/components/generic/Sheet/FilterSheet.jsx | 41 +- .../generic/StickyAnnouncementBar.jsx | 207 +++--- src/components/generic/Table/DataTable.jsx | 9 + .../generic/notificationDisplay.jsx | 20 - src/contexts/AdminAchievementsContext.jsx | 76 --- src/contexts/AdminAssetsContext.jsx | 8 +- .../AdminNotificationBroadcastContext.jsx | 33 + src/contexts/AdminNotificationContext.jsx | 13 +- .../AdminNotificationTemplateContext.jsx | 76 --- src/contexts/ClientAdvertisementContext.jsx | 51 +- src/contexts/ClientNotificationContext.jsx | 19 +- src/data/adminTiles.data.js | 3 +- src/data/advertisement.data.js | 22 +- .../notificationTemplatePlaceholders.data.js | 20 - src/data/notificationTemplateTypes.data.js | 20 - src/data/placement.data.js | 9 +- src/data/placementLayouts.data.js | 35 +- .../advertisements/archive/columns.config.jsx | 4 - .../admin/pages/achievements/Achievements.jsx | 172 ----- .../pages/achievements/EditAchievement.jsx | 255 -------- .../pages/advertisements/AddAdvertisement.jsx | 453 +++++++------ .../advertisements/AdvertisementList.jsx | 105 ++- .../advertisements/EditAdvertisement.jsx | 255 +++++--- src/modules/admin/pages/assets/EditAsset.jsx | 9 +- .../AddNotificationBroadcast.jsx | 573 ++++++++++++----- .../notifications/AddNotificationTemplate.jsx | 136 ---- .../EditNotificationBroadcast.jsx | 599 +++++++++++++----- .../EditNotificationTemplate.jsx | 272 -------- .../NotificationBroadcastList.jsx | 107 +++- .../notifications/NotificationTemplates.jsx | 242 ------- .../ViewNotificationBroadcast.jsx | 16 +- .../admin/pages/tiers/EditTierCategory.jsx | 7 +- src/modules/admin/routes/AdminRoutes.jsx | 38 -- src/modules/client/components/LessonCard.jsx | 28 +- src/modules/client/components/UnitCard.jsx | 143 ++--- .../client/pages/AdvertisementLandingPage.jsx | 104 +++ src/modules/client/pages/CourseDetails.jsx | 103 ++- src/modules/client/pages/CourseList.jsx | 13 - src/modules/client/pages/Dashboard.jsx | 23 +- src/modules/client/pages/LessonsList.jsx | 1 + src/modules/client/pages/PlanList.jsx | 6 +- src/modules/client/pages/UnitsList.jsx | 1 + src/modules/client/routes/ClientRoutes.jsx | 2 + src/utils/tierBadge.util.js | 15 + src/utils/tierColors.js | 26 + 50 files changed, 2202 insertions(+), 2623 deletions(-) create mode 100644 src/components/generic/AnnouncementCarouselDialog.jsx delete mode 100644 src/components/generic/Blocks/Client/Advertisements/Popup.jsx delete mode 100644 src/components/generic/Blocks/Client/Advertisements/Sidebar.jsx delete mode 100644 src/contexts/AdminAchievementsContext.jsx delete mode 100644 src/contexts/AdminNotificationTemplateContext.jsx delete mode 100644 src/data/notificationTemplatePlaceholders.data.js delete mode 100644 src/data/notificationTemplateTypes.data.js delete mode 100644 src/modules/admin/pages/achievements/Achievements.jsx delete mode 100644 src/modules/admin/pages/achievements/EditAchievement.jsx delete mode 100644 src/modules/admin/pages/notifications/AddNotificationTemplate.jsx delete mode 100644 src/modules/admin/pages/notifications/EditNotificationTemplate.jsx delete mode 100644 src/modules/admin/pages/notifications/NotificationTemplates.jsx create mode 100644 src/modules/client/pages/AdvertisementLandingPage.jsx diff --git a/src/components/generic/AdminStickyAnnouncementBar.jsx b/src/components/generic/AdminStickyAnnouncementBar.jsx index 4b72274..8eac8c4 100644 --- a/src/components/generic/AdminStickyAnnouncementBar.jsx +++ b/src/components/generic/AdminStickyAnnouncementBar.jsx @@ -1,116 +1,163 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, 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"; +import { getTierColor, getContrastText } from "@/utils/tierColors"; +import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog"; + +const ROTATE_INTERVAL_MS = 6000; // 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. +function resolveClickAction(stickyAnnouncement) { + const linkUrl = stickyAnnouncement.data?.linkUrl || null; + if (!linkUrl) return null; + return { + label: stickyAnnouncement.data?.linkLabel || "Open Link", + go: (navigate) => (linkUrl.startsWith("/") + ? navigate(linkUrl) + : window.open(linkUrl, "_blank", "noopener,noreferrer")), + }; +} + export default function AdminStickyAnnouncementBar() { const navigate = useNavigate(); - const { stickyAnnouncement, markSeen } = useAdminNotifications(); + const { stickyAnnouncements, bannerImage, markSeen } = useAdminNotifications(); + const [activeIndex, setActiveIndex] = useState(0); const [detailsOpen, setDetailsOpen] = useState(false); + const count = stickyAnnouncements.length; + // Derived rather than clamped via effect — safe the instant the array + // shrinks (e.g. after a dismiss), no render with a stale out-of-range index. + const safeIndex = count ? Math.min(activeIndex, count - 1) : 0; + + useEffect(() => { + if (count <= 1) return; + const id = setInterval(() => { + setActiveIndex((i) => (i + 1) % count); + }, ROTATE_INTERVAL_MS); + return () => clearInterval(id); + }, [count]); + + const current = stickyAnnouncements[safeIndex]; + const onDismiss = useCallback(async () => { - if (!stickyAnnouncement) return; - await markSeen(stickyAnnouncement.notification_id); - }, [stickyAnnouncement, markSeen]); + if (!current) return; + await markSeen(current.notification_id); + }, [current, 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. + // Opening the dialog must NOT mark it seen — markSeen removes the row from + // stickyAnnouncements, which would unmount this component (dialog included) + // before it ever shows. const onClickBanner = useCallback(() => { - if (!stickyAnnouncement) return; + if (!current) return; setDetailsOpen(true); - }, [stickyAnnouncement]); + }, [current]); - if (!stickyAnnouncement) return null; + // Closing the details dialog (X, Escape, overlay click — any reason) + // dismisses whichever announcement was being viewed at the time. This is + // the only dismiss path when multiple are active (no per-item X on the bar + // itself — see the count > 1 branch below). + const onDialogOpenChange = useCallback((open) => { + setDetailsOpen(open); + if (!open) void onDismiss(); + }, [onDismiss]); - const accentClass = getTypeAccent(stickyAnnouncement.type); - const inlineStyle = resolveStickyStyle(stickyAnnouncement.data); - const linkUrl = stickyAnnouncement.data?.linkUrl || null; + if (!current) return null; - const openLink = () => { - if (!linkUrl) return; - if (linkUrl.startsWith("/")) navigate(linkUrl); - else window.open(linkUrl, "_blank", "noopener,noreferrer"); - }; + const swatch = getTierColor(current.color || "indigo").swatch; + const textColor = getContrastText(swatch, current.color || "indigo"); + const clickAction = resolveClickAction(current); return ( <> -
+
-
-
- -
+
+

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

-
-

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

-

- {stickyAnnouncement.message || ""} -

-
+ {clickAction && ( + + )}
- + {count > 1 && ( +
+ {stickyAnnouncements.map((a, i) => ( +
+ )} + + {/* Dismiss-X only makes sense for a single active announcement — + with multiple, the dialog's own close button (shadcn Dialog) + is the way to close/step away, no per-item dismiss from the bar. */} + {count <= 1 && ( +
{ + e.preventDefault(); + e.stopPropagation(); + void onDismiss(); + }} + onKeyDown={(e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + e.stopPropagation(); + void onDismiss(); + }} + aria-label="Dismiss sticky announcement" + title="Dismiss" + className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity" + style={{ color: textColor }} + > + +
+ )}
- - - - -
- -
- {stickyAnnouncement.title || "Announcement"} -
- -

- {stickyAnnouncement.message || ""} -

-
-
- - Close - {linkUrl && ( - - Open Link - - )} - -
-
+ ); } diff --git a/src/components/generic/AnnouncementCarouselDialog.jsx b/src/components/generic/AnnouncementCarouselDialog.jsx new file mode 100644 index 0000000..63921d0 --- /dev/null +++ b/src/components/generic/AnnouncementCarouselDialog.jsx @@ -0,0 +1,83 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { resolveAssetSrc } from "@/utils/media.util"; + +// Shared details view for the sticky announcement bar (client + admin +// variants) — pages through every currently-active sticky announcement (max +// 3, see notificationBroadcasts.controller.js's countActiveSticky) with a +// segmented progress bar. bannerImage is ONE shared image for the whole set +// (not per-announcement) — see NotificationBroadcastList.jsx's banner picker +// and the sticky_banner_settings singleton. +export default function AnnouncementCarouselDialog({ + open, + onOpenChange, + announcements, + activeIndex, + onIndexChange, + resolveClickAction, + navigate, + bannerImage, +}) { + const current = announcements[activeIndex]; + if (!current) return null; + + const clickAction = resolveClickAction(current); + const imageSrc = resolveAssetSrc(bannerImage); + + return ( + + +
+
+ + + {current.title || "Announcement"} + + +

+ {current.message || ""} +

+
+
+ +
+ {clickAction && ( + + )} + + {announcements.length > 1 && ( +
+ {announcements.map((a, i) => ( +
+ )} +
+
+ +
+ {imageSrc ? ( + + ) : ( + No image + )} +
+
+
+
+ ); +} diff --git a/src/components/generic/Blocks/Client/Advertisements/Popup.jsx b/src/components/generic/Blocks/Client/Advertisements/Popup.jsx deleted file mode 100644 index e46680c..0000000 --- a/src/components/generic/Blocks/Client/Advertisements/Popup.jsx +++ /dev/null @@ -1,71 +0,0 @@ -// components/blocks/Popup.jsx - -import { Button } from "@/components/ui/button"; -import ResponsiveModal from "@/components/generic/ResponsiveModal"; -import { resolveAssetSrc } from "@/utils/media.util"; - -// ── Popup ──────────────────────────────────────────────────────────────────── -/** - * Generic popup advertisement block. - * Modal-style placement shown on page load — wraps ResponsiveModal so it gets - * dialog/drawer behavior for free. Caller owns the `open` state (typically set - * to true once an active popup ad resolves from the API). - * - * Props: - * ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id } - * open — boolean, modal visibility - * onOpenChange — (open: boolean) => void - * onCtaClick — (ad, cta) => void, called when a footer CTA button is clicked - * onDismissForever — () => void, called when the user picks "Don't show this ad again" - */ -export function Popup({ ad, open, onOpenChange, onCtaClick, onDismissForever }) { - if (!ad) return null; - - const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null; - const ctas = Array.isArray(ad.ctas) ? ad.ctas : []; - - const handleDismissForever = () => { - onOpenChange?.(false); - onDismissForever?.(); - }; - - return ( - 0 ? ( - <> - {ctas.map((cta, i) => ( - - ))} - - ) : undefined - } - > - {imageSrc && ( -
- {ad.headline -
- )} - - {onDismissForever && ( - - )} -
- ); -} \ No newline at end of file diff --git a/src/components/generic/Blocks/Client/Advertisements/Sidebar.jsx b/src/components/generic/Blocks/Client/Advertisements/Sidebar.jsx deleted file mode 100644 index 2dc5e3c..0000000 --- a/src/components/generic/Blocks/Client/Advertisements/Sidebar.jsx +++ /dev/null @@ -1,78 +0,0 @@ -// components/blocks/Sidebar.jsx - -import { Megaphone } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { resolveAssetSrc } from "@/utils/media.util"; - -// ── Sidebar ────────────────────────────────────────────────────────────────── -/** - * Generic sidebar advertisement block. - * Compact vertical card — image on top, optional short headline/description and - * a single CTA below. Meant to sit in a narrow column (sidebars, rail layouts), - * not stretch full-width like Hero/Banner. - * - * Props: - * ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id } - * onCtaClick — (ad, cta) => void, called when the CTA button (or card, if no CTA) is clicked - */ -export function Sidebar({ ad, onCtaClick }) { - if (!ad) return null; - - const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null; - const ctas = Array.isArray(ad.ctas) ? ad.ctas : []; - const primaryCta = ctas[0]; - - const handleCardClick = () => { - if (!primaryCta) onCtaClick?.(ad, undefined); - }; - - return ( -
-
- {imageSrc ? ( - {ad.headline - ) : ( - - )} -
- - {(ad.headline || ad.description || primaryCta) && ( -
- {ad.headline &&

{ad.headline}

} - {ad.description &&

{ad.description}

} - {primaryCta && ( - - )} -
- )} -
- ); -} - -// ── SidebarSkeleton ────────────────────────────────────────────────────────── - -export function SidebarSkeleton() { - return ( -
- -
- - - -
-
- ); -} \ No newline at end of file diff --git a/src/components/generic/BroadcastTargetPicker.jsx b/src/components/generic/BroadcastTargetPicker.jsx index b5086b1..f6bc9f9 100644 --- a/src/components/generic/BroadcastTargetPicker.jsx +++ b/src/components/generic/BroadcastTargetPicker.jsx @@ -32,7 +32,7 @@ const TARGET_CONFIGS = { }, }; -export function BroadcastTargetPicker({ targetType, value, onChange }) { +export function BroadcastTargetPicker({ targetType, value, onChange, onLabelResolved }) { const config = TARGET_CONFIGS[targetType]; const [items, setItems] = useState([]); @@ -57,9 +57,17 @@ export function BroadcastTargetPicker({ targetType, value, onChange }) { return items.filter((item) => String(item[config.labelKey] ?? "").toLowerCase().includes(q)); }, [items, query, config]); - if (!config) return null; + const selected = config ? items.find((item) => String(item[config.idKey]) === String(value)) : undefined; - const selected = items.find((item) => String(item[config.idKey]) === String(value)); + // Lets the parent (Review step summaries, etc.) show the resolved name + // instead of just the raw id — fires whenever the matched item changes, + // including on initial load once the fetched list resolves `value`. + useEffect(() => { + onLabelResolved?.(selected ? selected[config.labelKey] : null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected]); + + if (!config) return null; return ( { setOpen(v); if (!v) setQuery(""); }}> diff --git a/src/components/generic/Sheet/FilterSheet.jsx b/src/components/generic/Sheet/FilterSheet.jsx index 17f4948..571ae27 100644 --- a/src/components/generic/Sheet/FilterSheet.jsx +++ b/src/components/generic/Sheet/FilterSheet.jsx @@ -15,14 +15,22 @@ const FIELD_DISPLAY_MAP = { const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP); // ─── Generic formatter ──────────────────────────────────────────────────────── +// Audit fields (createdBy/updatedBy/deletedBy) come back from the field-values +// API as { value: user_id, label: full_name } — filtering has to select on the +// id, but the sheet should still display the name. Every other field type +// still hands this plain primitives, which pass through unchanged. +const itemValue = (item) => (item && typeof item === "object" && "value" in item) ? item.value : item; +const itemLabel = (item) => (item && typeof item === "object" && "label" in item) ? item.label : item; + const formatFilterItem = (item, field, type, fmtDate) => { + const label = itemLabel(item); if (FIELD_DISPLAY_MAP[field]) { - return FIELD_DISPLAY_MAP[field][String(item)] ?? item; + return FIELD_DISPLAY_MAP[field][String(label)] ?? label; } - if (type === "date" && item) { - return fmtDate(item); + if (type === "date" && label) { + return fmtDate(label); } - return item; + return label; }; // ─── Reusable empty state ───────────────────────────────────────────────────── @@ -37,17 +45,20 @@ const FilterList = ({ items, field, type, selected, onToggle, inputType = "check const { fmtDate } = useDateFormat(); if (items.length === 0) return ; - return items.map((item) => ( - - )); + return items.map((item) => { + const value = itemValue(item); + return ( + + ); + }); }; export function FilterSheet({ open, onOpenChange, column, attr, data = [], loading }) { diff --git a/src/components/generic/StickyAnnouncementBar.jsx b/src/components/generic/StickyAnnouncementBar.jsx index 09e9a5e..e2950aa 100644 --- a/src/components/generic/StickyAnnouncementBar.jsx +++ b/src/components/generic/StickyAnnouncementBar.jsx @@ -1,22 +1,22 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, 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, resolveStickyStyle } from "@/components/generic/notificationDisplay"; +import { resolveNotificationLink } from "@/components/generic/notificationDisplay"; +import { getTierColor, getContrastText } from "@/utils/tierColors"; +import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog"; -// 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. +const ROTATE_INTERVAL_MS = 6000; + +// Explicit link_url (from the admin "On Open" section) always wins, using the +// admin-authored button label when set. 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", + label: stickyAnnouncement.data?.linkLabel || "Open Link", go: (navigate) => (explicitUrl.startsWith("/") ? navigate(explicitUrl) : window.open(explicitUrl, "_blank", "noopener,noreferrer")), @@ -27,99 +27,140 @@ function resolveClickAction(stickyAnnouncement) { export default function StickyAnnouncementBar() { const navigate = useNavigate(); - const { stickyAnnouncement, markSeen } = useClientNotifications(); + const { stickyAnnouncements, bannerImage, markSeen } = useClientNotifications(); + const [activeIndex, setActiveIndex] = useState(0); const [detailsOpen, setDetailsOpen] = useState(false); + const count = stickyAnnouncements.length; + // Derived rather than clamped via effect — safe the instant the array + // shrinks (e.g. after a dismiss), no render with a stale out-of-range index. + const safeIndex = count ? Math.min(activeIndex, count - 1) : 0; + + // Auto-rotate through active announcements while more than one is live. + useEffect(() => { + if (count <= 1) return; + const id = setInterval(() => { + setActiveIndex((i) => (i + 1) % count); + }, ROTATE_INTERVAL_MS); + return () => clearInterval(id); + }, [count]); + + const current = stickyAnnouncements[safeIndex]; + const onDismiss = useCallback(async () => { - if (!stickyAnnouncement) return; - await markSeen(stickyAnnouncement.notification_id); - }, [stickyAnnouncement, markSeen]); + if (!current) return; + await markSeen(current.notification_id); + }, [current, 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. + // Opening the dialog must NOT mark it seen — markSeen removes the row from + // stickyAnnouncements, which would unmount this component (dialog included) + // before it ever shows. const onClickBanner = useCallback(() => { - if (!stickyAnnouncement) return; + if (!current) return; setDetailsOpen(true); - }, [stickyAnnouncement]); + }, [current]); - if (!stickyAnnouncement) return null; + // Closing the details dialog (X, Escape, overlay click — any reason) + // dismisses whichever announcement was being viewed at the time. This is + // the only dismiss path when multiple are active (no per-item X on the bar + // itself — see the count > 1 branch below). + const onDialogOpenChange = useCallback((open) => { + setDetailsOpen(open); + if (!open) void onDismiss(); + }, [onDismiss]); - const accentClass = getTypeAccent(stickyAnnouncement.type); - const inlineStyle = resolveStickyStyle(stickyAnnouncement.data); - const clickAction = resolveClickAction(stickyAnnouncement); + if (!current) return null; + + const swatch = getTierColor(current.color || "indigo").swatch; + const textColor = getContrastText(swatch, current.color || "indigo"); + const clickAction = resolveClickAction(current); return ( <> -
+
-
-
- -
+
+

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

-
-

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

-

- {stickyAnnouncement.message || ""} -

-
+ {clickAction && ( + + )}
- + {count > 1 && ( +
+ {stickyAnnouncements.map((a, i) => ( +
+ )} + + {/* Dismiss-X only makes sense for a single active announcement — + with multiple, the dialog's own close button (shadcn Dialog) + is the way to close/step away, no per-item dismiss from the bar. */} + {count <= 1 && ( +
{ + e.preventDefault(); + e.stopPropagation(); + void onDismiss(); + }} + onKeyDown={(e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + e.stopPropagation(); + void onDismiss(); + }} + aria-label="Dismiss sticky announcement" + title="Dismiss" + className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity" + style={{ color: textColor }} + > + +
+ )}
- {/* 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/Table/DataTable.jsx b/src/components/generic/Table/DataTable.jsx index 3127479..deea9fb 100644 --- a/src/components/generic/Table/DataTable.jsx +++ b/src/components/generic/Table/DataTable.jsx @@ -97,6 +97,15 @@ export default function DataTable({ const handleOpenFilterSheet = async (e, column, attr) => { e.preventDefault(); setActiveColumn(null); + + // Enum/boolean columns already carry their full value set in + // attr.options.choices (see FilterSheet.jsx's sourceData) — hitting + // the field-values endpoint for them is a wasted round-trip. + if (attr?.type === "enum") { + setFilterState({ open: true, column, attr, data: [] }); + return; + } + const data = await onFetchFilterData(attr.field); setFilterState({ open: true, column, attr, data }); }; diff --git a/src/components/generic/notificationDisplay.jsx b/src/components/generic/notificationDisplay.jsx index a74f86a..f691b0a 100644 --- a/src/components/generic/notificationDisplay.jsx +++ b/src/components/generic/notificationDisplay.jsx @@ -22,26 +22,6 @@ 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/AdminAchievementsContext.jsx b/src/contexts/AdminAchievementsContext.jsx deleted file mode 100644 index aea9007..0000000 --- a/src/contexts/AdminAchievementsContext.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import { createContext, useCallback, useContext, useState } from "react"; -import api from "@/utils/api.util"; -import { toast } from "sonner"; - -const AdminAchievementsContext = createContext(null); - -export function useAdminAchievements() { - const ctx = useContext(AdminAchievementsContext); - if (!ctx) throw new Error("useAdminAchievements must be used inside AdminAchievementsProvider"); - return ctx; -} - -export function AdminAchievementsProvider({ children }) { - const [achievements, setAchievements] = useState([]); - const [achievement, setAchievement] = useState(null); - const [loading, setLoading] = useState(false); - - const request = useCallback(async (fn) => { - setLoading(true); - try { return await fn(); } - catch (err) { - toast(err?.response?.data?.message ?? "Something went wrong."); - return null; - } finally { setLoading(false); } - }, []); - - const fetchAchievements = useCallback(() => - request(async () => { - const { data } = await api.get("/admin/achievements"); - setAchievements(data.data ?? []); - return data.data; - }), [request]); - - const fetchAchievement = useCallback((id) => - request(async () => { - const { data } = await api.get(`/admin/achievements/${id}`); - setAchievement(data.data ?? null); - return data.data; - }), [request]); - - const createAchievement = useCallback((payload) => - request(async () => { - const { data } = await api.post("/admin/achievements", payload); - toast("Achievement created."); - return data.data; - }), [request]); - - const updateAchievement = useCallback((id, payload) => - request(async () => { - const { data } = await api.put(`/admin/achievements/${id}`, payload); - setAchievements((prev) => - prev.map((a) => (String(a.achievement_definition_id) === String(id) ? data.data : a)) - ); - if (achievement && String(achievement.achievement_definition_id) === String(id)) setAchievement(data.data); - toast("Achievement updated."); - return data.data; - }), [request, achievement]); - - const deleteAchievement = useCallback((id) => - request(async () => { - await api.delete(`/admin/achievements/${id}`); - setAchievements((prev) => prev.filter((a) => String(a.achievement_definition_id) !== String(id))); - toast("Achievement deleted."); - return true; - }), [request]); - - return ( - - {children} - - ); -} diff --git a/src/contexts/AdminAssetsContext.jsx b/src/contexts/AdminAssetsContext.jsx index 2a12451..8edaa21 100644 --- a/src/contexts/AdminAssetsContext.jsx +++ b/src/contexts/AdminAssetsContext.jsx @@ -218,9 +218,11 @@ export function AssetsProvider({ children }) { }); if (file) formData.append("file", file); - const res = await api.patch(`/admin/assets/${assetId}`, formData, { - headers: { "Content-Type": "multipart/form-data" }, - }); + // No explicit Content-Type here — axios/the browser must set it + // itself so the multipart boundary is included. A hardcoded + // "multipart/form-data" header (no boundary) makes multer fail + // to parse the body, silently dropping the file and every field. + const res = await api.patch(`/admin/assets/${assetId}`, formData); const asset = res.data?.data?.data ?? null; if (asset) { diff --git a/src/contexts/AdminNotificationBroadcastContext.jsx b/src/contexts/AdminNotificationBroadcastContext.jsx index 8cb992f..0d791ed 100644 --- a/src/contexts/AdminNotificationBroadcastContext.jsx +++ b/src/contexts/AdminNotificationBroadcastContext.jsx @@ -26,6 +26,7 @@ export function NotificationBroadcastsProvider({ children }) { const [attributes, setAttributes] = useState([]); const [pagination, setPagination] = useState(PAGINATION_INIT); const [selectedBroadcast, setSelectedBroadcast] = useState(null); + const [stickyBannerSetting, setStickyBannerSetting] = useState(null); const [loading, setLoading] = useState(false); const request = useCallback(async (fn) => { @@ -207,6 +208,35 @@ export function NotificationBroadcastsProvider({ children }) { [request] ); + // ─── GET /api/admin/announcements/sticky-banner ─────────────────────────── + // Shared banner image for the whole rotating sticky bar — one image for + // all (up to 3) concurrently-active announcements, not one per announcement. + const fetchStickyBannerSetting = useCallback( + () => + request(async () => { + const res = await api.get("/admin/announcements/sticky-banner"); + const setting = res.data?.data?.data ?? null; + setStickyBannerSetting(setting); + return res.data; + }), + [request] + ); + + // ─── PATCH /api/admin/announcements/sticky-banner ──────────────────────── + const updateStickyBannerSetting = useCallback( + (fields) => + request(async () => { + const res = await api.patch("/admin/announcements/sticky-banner", fields); + const setting = res.data?.data?.data ?? null; + if (setting) { + setStickyBannerSetting((prev) => ({ ...prev, ...setting })); + toast("Sticky banner image updated."); + } + return res.data; + }), + [request] + ); + // ─── DELETE /api/admin/announcements/bulk/permanent ────────────────────── const permanentlyDeleteBroadcasts = useCallback( ({ ids }) => @@ -225,6 +255,7 @@ export function NotificationBroadcastsProvider({ children }) { attributes, pagination, selectedBroadcast, + stickyBannerSetting, loading, setPagination, setSelectedBroadcast, @@ -240,6 +271,8 @@ export function NotificationBroadcastsProvider({ children }) { restoreBroadcasts, permanentlyDeleteBroadcast, permanentlyDeleteBroadcasts, + fetchStickyBannerSetting, + updateStickyBannerSetting, }}> {children} diff --git a/src/contexts/AdminNotificationContext.jsx b/src/contexts/AdminNotificationContext.jsx index 0c47a01..0656906 100644 --- a/src/contexts/AdminNotificationContext.jsx +++ b/src/contexts/AdminNotificationContext.jsx @@ -14,7 +14,8 @@ export function useAdminNotifications() { export function AdminNotificationProvider({ children }) { const [notifications, setNotifications] = useState([]); const [unseenCount, setUnseenCount] = useState(0); - const [stickyAnnouncement, setStickyAnnouncement] = useState(null); + const [stickyAnnouncements, setStickyAnnouncements] = useState([]); + const [bannerImage, setBannerImage] = useState(null); const [loading, setLoading] = useState(false); const intervalRef = useRef(null); @@ -30,7 +31,8 @@ export function AdminNotificationProvider({ children }) { const fetchStickyAnnouncement = useCallback(async () => { try { const res = await api.get("/admin/notifications/sticky"); - setStickyAnnouncement(res.data?.data?.announcement ?? null); + setStickyAnnouncements(res.data?.data?.announcements ?? []); + setBannerImage(res.data?.data?.bannerImage ?? null); } catch { // silent } @@ -57,7 +59,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)); + setStickyAnnouncements(prev => prev.filter(a => a.notification_id !== id)); } catch { // silent } @@ -68,7 +70,7 @@ export function AdminNotificationProvider({ children }) { await api.patch("/admin/notifications/seen-all"); setNotifications(prev => prev.map(n => ({ ...n, seen: true }))); setUnseenCount(0); - setStickyAnnouncement(null); + setStickyAnnouncements([]); } catch { // silent } @@ -89,7 +91,8 @@ export function AdminNotificationProvider({ children }) { { - setLoading(true); - try { return await fn(); } - catch (err) { - toast(err?.response?.data?.message ?? "Something went wrong."); - return null; - } finally { setLoading(false); } - }, []); - - const fetchTemplates = useCallback(() => - request(async () => { - const { data } = await api.get("/admin/announcement-templates"); - setTemplates(data.data ?? []); - return data.data; - }), [request]); - - const fetchTemplate = useCallback((id) => - request(async () => { - const { data } = await api.get(`/admin/announcement-templates/${id}`); - setTemplate(data.data ?? null); - return data.data; - }), [request]); - - const updateTemplate = useCallback((id, payload) => - request(async () => { - const { data } = await api.put(`/admin/announcement-templates/${id}`, payload); - setTemplates((prev) => - prev.map((t) => (String(t.notification_template_id) === String(id) ? data.data : t)) - ); - if (template && String(template.notification_template_id) === String(id)) setTemplate(data.data); - toast("Notification template updated."); - return data.data; - }), [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 ( - - {children} - - ); -} diff --git a/src/contexts/ClientAdvertisementContext.jsx b/src/contexts/ClientAdvertisementContext.jsx index 6ac3a4f..d2488cb 100644 --- a/src/contexts/ClientAdvertisementContext.jsx +++ b/src/contexts/ClientAdvertisementContext.jsx @@ -29,7 +29,7 @@ export function useClientAdvertisements() { export function ClientAdvertisementsProvider({ children }) { const navigate = useNavigate(); - const { profile, getProfile, updateProfile } = useProfile(); + const { profile, getProfile } = useProfile(); // Keyed by placement so multiple slots on the same page (e.g. dashboard.hero + // dashboard.popup) can be fetched independently without clobbering each other. @@ -43,7 +43,6 @@ export function ClientAdvertisementsProvider({ children }) { const [listLoading, setListLoading] = useState({}); const [clickCounts, setClickCounts] = useState(loadClickCounts); const [pendingClick, setPendingClick] = useState(null); // { ad, cta } awaiting confirmation - const [dismissConfirmOpen, setDismissConfirmOpen] = useState(false); // Ad fetches must know the real preference before deciding visibility — never // assume "show" as a default just because profile hasn't loaded yet. profileRef @@ -63,14 +62,11 @@ export function ClientAdvertisementsProvider({ children }) { return fresh; }, [getProfile]); - // Popups are gated separately from hero/banner/sidebar so "Don't show this - // ad again" only ever touches popups, per the Settings → Advertisements toggles. + // Gated by the Settings → Advertisements "Other ads" toggle. const resolveVisibility = (profileData, ad) => { if (!ad) return ad; - const showPopupAds = profileData?.personal_info?.show_popup_ads ?? true; const showOtherAds = profileData?.personal_info?.show_other_ads ?? true; - const hidden = ad.type === "popup" ? !showPopupAds : !showOtherAds; - return hidden ? null : ad; + return showOtherAds ? ad : null; }; // ─── GET /api/client/advertisements/active?placement=dashboard.hero ─────── @@ -171,15 +167,18 @@ export function ClientAdvertisementsProvider({ children }) { [] ); - // Tracks the click then follows the CTA link (external → new tab, internal → router nav). + // Tracks the click then follows the link: the CTA's own link wins if present, + // else the ad's redirect_link, else its internal Page Builder landing page + // (/ads/:uuid) if one was authored. External links open in a new tab. const goToCta = useCallback( (ad, cta) => { trackClick(ad?.advertisement_id); - if (!cta?.link) return; - if (/^https?:\/\//.test(cta.link)) { - window.open(cta.link, "_blank", "noopener,noreferrer"); + const link = cta?.link || ad?.redirect_link || (ad?.landing_page ? `/ads/${ad.uuid}` : null); + if (!link) return; + if (/^https?:\/\//.test(link)) { + window.open(link, "_blank", "noopener,noreferrer"); } else { - navigate(cta.link); + navigate(link); } }, [navigate, trackClick] @@ -219,20 +218,6 @@ export function ClientAdvertisementsProvider({ children }) { setPendingClick(null); }; - // ─── "Don't show this ad again" (popups only) ────────────────────────── - // Persists the preference to the account (so it follows across devices), - // then shows a one-time confirmation pointing at where to turn it back on. - const dismissPopupForever = useCallback(async () => { - const result = await updateProfile({ show_popup_ads: false }); - if (result?.data) profileRef.current = result.data; - setDismissConfirmOpen(true); - }, [updateProfile]); - - const goToAdSettings = () => { - setDismissConfirmOpen(false); - navigate("/settings"); - }; - return ( {children} @@ -260,19 +244,6 @@ export function ClientAdvertisementsProvider({ children }) { } /> - - - - - - } - /> ); } diff --git a/src/contexts/ClientNotificationContext.jsx b/src/contexts/ClientNotificationContext.jsx index 2f8daab..80aa21f 100644 --- a/src/contexts/ClientNotificationContext.jsx +++ b/src/contexts/ClientNotificationContext.jsx @@ -17,7 +17,8 @@ const DEFAULT_PAGINATION = { page: 1, limit: 10, pages: 1, total: 0 }; export function ClientNotificationProvider({ children }) { const [notifications, setNotifications] = useState([]); const [unseenCount, setUnseenCount] = useState(0); - const [stickyAnnouncement, setStickyAnnouncement] = useState(null); + const [stickyAnnouncements, setStickyAnnouncements] = useState([]); + const [bannerImage, setBannerImage] = useState(null); const [loading, setLoading] = useState(false); const [pagination, setPagination] = useState(DEFAULT_PAGINATION); const intervalRef = useRef(null); @@ -35,7 +36,8 @@ export function ClientNotificationProvider({ children }) { const fetchStickyAnnouncement = useCallback(async () => { try { const res = await api.get("/client/notifications/sticky"); - setStickyAnnouncement(res.data?.data?.announcement ?? null); + setStickyAnnouncements(res.data?.data?.announcements ?? []); + setBannerImage(res.data?.data?.bannerImage ?? null); } catch { // silent } @@ -66,7 +68,7 @@ export function ClientNotificationProvider({ children }) { setNotifications([]); setUnseenCount(0); setPagination(DEFAULT_PAGINATION); - setStickyAnnouncement(null); + setStickyAnnouncements([]); return true; } catch { return false; @@ -80,9 +82,7 @@ export function ClientNotificationProvider({ children }) { prev.map(n => n.notification_id === id ? { ...n, seen: true } : n) ); - if (stickyAnnouncement?.notification_id === id) { - setStickyAnnouncement(null); - } + setStickyAnnouncements(prev => prev.filter(a => a.notification_id !== id)); // Re-sync badge + sticky immediately (handles cases where the marked // row isn't present in the currently loaded notifications page). @@ -90,14 +90,14 @@ export function ClientNotificationProvider({ children }) { } catch { // silent } - }, [stickyAnnouncement?.notification_id, fetchUnseen, fetchStickyAnnouncement]); + }, [fetchUnseen, fetchStickyAnnouncement]); const markAllSeen = useCallback(async () => { try { await api.patch("/client/notifications/seen-all"); setNotifications(prev => prev.map(n => ({ ...n, seen: true }))); setUnseenCount(0); - setStickyAnnouncement(null); + setStickyAnnouncements([]); } catch { // silent } @@ -131,7 +131,8 @@ export function ClientNotificationProvider({ children }) { 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" }, - { value: "popup", label: "Popup", icon: BellRing, description: "Modal-style popup shown on page load" }, - { value: "sidebar", label: "Sidebar", icon: PanelRight, description: "Compact image placed in a sidebar slot" }, ]; export const ADVERTISEMENT_TYPE_MAP = Object.fromEntries( ADVERTISEMENT_TYPES.map((t) => [t.value, t]) ); -// Types that show the rich content fields (headline, description, CTAs) in the form -export const RICH_CONTENT_TYPES = ["hero"]; +// ─── Content modes ────────────────────────────────────────────────────────── +// Whether an ad is image-only or carries badge/headline/description/CTAs +// alongside the image — an explicit admin choice, decoupled from placement/format. +export const CONTENT_MODES = [ + { value: "image", label: "Full image" }, + { value: "content", label: "Content + image" }, +]; // ─── 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" }, { value: "scheduled", label: "Scheduled", badgeClass: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400" }, { value: "expired", label: "Expired", badgeClass: "bg-muted text-muted-foreground" }, - { value: "archived", label: "Archived", badgeClass: "bg-muted text-muted-foreground" }, ]; export const ADVERTISEMENT_STATUS_MAP = Object.fromEntries( diff --git a/src/data/notificationTemplatePlaceholders.data.js b/src/data/notificationTemplatePlaceholders.data.js deleted file mode 100644 index 26e6cdc..0000000 --- a/src/data/notificationTemplatePlaceholders.data.js +++ /dev/null @@ -1,20 +0,0 @@ -// Reference-only registry of {{placeholder}} tokens available per system -// notification type. Purely informational for the admin editor — the backend -// derives the real substitution data from wherever renderNotification({ type, -// data }) is called in code, this just tells the admin what's actually -// available to reference. Mirrors emailTemplatePlaceholders.data.js. -export const NOTIFICATION_TEMPLATE_PLACEHOLDERS = { - task_overdue: ["count", "task_word"], - user_registration: ["groupName", "groupCode", "userEmail"], - nogrp_user_registered: ["userEmail", "regType"], - task_requirements_updated: ["taskName", "taskListId", "groupId"], - user_task_overdue: ["count", "task_label", "task_list_ids"], - task_reminder: ["taskName", "deadline", "taskListId", "groupId"], - course_unlocked: ["courseTitle", "courseUuid"], - course_completed: ["courseTitle", "courseUuid"], - certificate_issued: ["courseTitle", "courseUuid"], - welcome: ["greeting", "group_suffix", "groupName", "groupCode", "accType"], - nogrp_welcome: [], - assessment_updated: ["assessmentTitle", "courseTitle", "courseUuid"], - tier_expired: ["planLabel", "tier", "label", "planId"], -}; diff --git a/src/data/notificationTemplateTypes.data.js b/src/data/notificationTemplateTypes.data.js deleted file mode 100644 index 17a3938..0000000 --- a/src/data/notificationTemplateTypes.data.js +++ /dev/null @@ -1,20 +0,0 @@ -import { ListChecks, GraduationCap, Megaphone, ClipboardCheck, CreditCard, UserPlus, Users } from "lucide-react"; - -// Groups notification templates by the `notify_type` written into the -// delivered notification row — every row here is is_system, so a category -// axis (like email templates' announcement/advertisement/system/other) would -// always resolve to a single value and add nothing. This is the axis that -// actually varies. Mirrors the shape of emailTemplateCategories.data.js. -export const NOTIFICATION_TEMPLATE_TYPES = [ - { value: "task_overdue", label: "Tasks (Admin)", icon: ListChecks }, - { value: "task", label: "Tasks (User)", icon: ListChecks }, - { value: "course", label: "Courses", icon: GraduationCap }, - { value: "announcement", label: "Announcements", icon: Megaphone }, - { value: "assessment", label: "Assessments", icon: ClipboardCheck }, - { value: "tier_expired", label: "Subscriptions", icon: CreditCard }, - { value: "user_registration", label: "New Registrations", icon: UserPlus }, - { value: "nogrp_user_registered", label: "Unaffiliated Users", icon: Users }, -]; - -export const getNotificationTemplateType = (value) => - NOTIFICATION_TEMPLATE_TYPES.find((t) => t.value === value) ?? null; diff --git a/src/data/placement.data.js b/src/data/placement.data.js index 55f3921..41f26f8 100644 --- a/src/data/placement.data.js +++ b/src/data/placement.data.js @@ -6,17 +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)" }, - { key: "course_list.banner", format: "banner", page: "course_list", pageLabel: "Courses", slotLabel: "Banner (above course grid)" }, + { key: "tier_plans.banner", format: "banner", page: "tier_plans", pageLabel: "Tier Plans", slotLabel: "Banner (above plan cards)" }, { key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" }, - { key: "course_details.sidebar", format: "sidebar", page: "course_details", pageLabel: "Course Details", slotLabel: "Sidebar (beside course content)" }, - { key: "plans.banner", format: "banner", page: "plans", pageLabel: "Plans", slotLabel: "Banner (above plan cards)" }, ]; export const PLACEMENT_MAP = Object.fromEntries(PLACEMENTS.map((p) => [p.key, p])); diff --git a/src/data/placementLayouts.data.js b/src/data/placementLayouts.data.js index 945f521..fdd3818 100644 --- a/src/data/placementLayouts.data.js +++ b/src/data/placementLayouts.data.js @@ -26,46 +26,15 @@ export const PLACEMENT_LAYOUTS = { { kind: "grid", label: "Courses" }, ], }, - "dashboard.popup": { - blocks: [ - { kind: "nav" }, - { kind: "bar", size: "xl" }, - { kind: "list", label: "My Groups" }, - { kind: "grid", label: "Courses" }, - ], - overlay: { label: "Popup" }, - }, - "course_list.banner": { - blocks: [ - { kind: "nav" }, - { kind: "filters" }, - { kind: "bar", size: "md", highlight: true, label: "Banner" }, - { kind: "grid", label: "Course cards" }, - ], - }, "course_details.banner": { blocks: [ { kind: "nav" }, { kind: "bar", size: "lg", label: "Course hero" }, { kind: "bar", size: "sm", highlight: true, label: "Banner" }, - { kind: "row", columns: [ - { label: "Course content", width: "flex-1" }, - { label: "Sidebar", width: "w-1/4" }, - ] }, + { kind: "list", label: "Course content" }, ], }, - "course_details.sidebar": { - blocks: [ - { kind: "nav" }, - { kind: "bar", size: "lg", label: "Course hero" }, - { kind: "bar", size: "sm", label: "Banner" }, - { kind: "row", columns: [ - { label: "Course content", width: "flex-1" }, - { label: "Sidebar", width: "w-1/4", highlight: true }, - ] }, - ], - }, - "plans.banner": { + "tier_plans.banner": { blocks: [ { kind: "nav" }, { kind: "bar", size: "md", highlight: true, label: "Banner" }, diff --git a/src/modules/admin/config/advertisements/archive/columns.config.jsx b/src/modules/admin/config/advertisements/archive/columns.config.jsx index 5d61193..ad04146 100644 --- a/src/modules/admin/config/advertisements/archive/columns.config.jsx +++ b/src/modules/admin/config/advertisements/archive/columns.config.jsx @@ -11,10 +11,6 @@ export const columnPinning = { const cellOverrides = {}; -// TODO(ads-9): Fix Sort and Columns on the Archived Advertisements table — -// sorting/column visibility currently misbehaves. Compare against a working -// DataTable usage elsewhere in admin/config to see what's diverging (likely -// an attributes/sort-key mismatch coming out of the paginate() response). /** * Builds the full column array for the Archived Advertisements table. * diff --git a/src/modules/admin/pages/achievements/Achievements.jsx b/src/modules/admin/pages/achievements/Achievements.jsx deleted file mode 100644 index fff653a..0000000 --- a/src/modules/admin/pages/achievements/Achievements.jsx +++ /dev/null @@ -1,172 +0,0 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import * as LucideIcons from "lucide-react"; -import { House, Plus, Pencil, Trash2, Trophy, Lock } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Spinner } from "@/components/ui/spinner"; -import { Separator } from "@/components/ui/separator"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; -import { PageMeta } from "@/contexts/MetadataContext"; -import { - AdminAchievementsProvider, - useAdminAchievements, -} from "@/contexts/AdminAchievementsContext"; - -function AchievementCard({ item, onEdit, onDelete }) { - const Icon = LucideIcons[item.icon] ?? Trophy; - return ( -
-
-
- -
-
-
-

{item.label}

- {item.key} - {item.type} - {!item.is_active && Inactive} - {item.is_system && ( - - System - - )} -
- {item.description && ( -

{item.description}

- )} - {item.trigger && ( -

- Trigger: {item.trigger} -

- )} -
-
-
- - {!item.is_system && ( - - )} -
-
- ); -} - -function AchievementsInner() { - const navigate = useNavigate(); - const { achievements, loading, fetchAchievements, deleteAchievement } = useAdminAchievements(); - - const [deleteTarget, setDeleteTarget] = useState(null); - const [deleting, setDeleting] = useState(false); - - useEffect(() => { fetchAchievements(); }, []); - - const confirmDelete = async () => { - if (!deleteTarget) return; - setDeleting(true); - await deleteAchievement(deleteTarget.achievement_definition_id); - setDeleting(false); - setDeleteTarget(null); - }; - - return ( -
- -
-
- -
- , to: "/admin" }, - { label: "Achievements" }, - ]} /> -
- -
-
-

Achievements

-

- Badges and milestones learners can earn across the platform. -

-
- -
- -
- -

- System achievements are auto-granted by platform events (registration, course completion, etc.) - and cannot be deleted or have their key/type changed — everything else stays editable. -

-
- - - - {loading && !achievements.length ? ( -
- ) : !achievements.length ? ( -

No achievements found.

- ) : ( -
- {achievements.map((item) => ( - navigate(`/admin/achievements/${a.achievement_definition_id}/edit`)} - onDelete={(a) => setDeleteTarget(a)} - /> - ))} -
- )} -
-
- - {/* Delete confirmation dialog */} - { if (!open) setDeleteTarget(null); }}> - - - Delete Achievement - - Are you sure you want to delete{" "} - {deleteTarget?.label}? - This action cannot be undone. Any courses referencing this achievement must be unassigned first. - - - - - - - - -
- ); -} - -export default function Achievements() { - return ( - - - - ); -} diff --git a/src/modules/admin/pages/achievements/EditAchievement.jsx b/src/modules/admin/pages/achievements/EditAchievement.jsx deleted file mode 100644 index fb516a6..0000000 --- a/src/modules/admin/pages/achievements/EditAchievement.jsx +++ /dev/null @@ -1,255 +0,0 @@ -import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { ArrowLeft, House, X, Lock } from "lucide-react"; -import { BADGE_ICON_OPTIONS } from "@/modules/admin/pages/tiers/EditTierCategory"; -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 { Switch } from "@/components/ui/switch"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; -import { PageMeta } from "@/contexts/MetadataContext"; -import { - AdminAchievementsProvider, - useAdminAchievements, -} from "@/contexts/AdminAchievementsContext"; - -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)" }, -]; - -function SectionCard({ title, children }) { - return ( -
- {title &&

{title}

} - {children} -
- ); -} - -function FieldError({ message }) { - if (!message) return null; - return

{message}

; -} - -function EditAchievementInner({ isAdd }) { - const navigate = useNavigate(); - const { id } = useParams(); - const { achievement, loading, fetchAchievement, createAchievement, updateAchievement } = useAdminAchievements(); - - const [key, setKey] = useState(""); - const [type, setType] = useState("badge"); - const [label, setLabel] = useState(""); - const [description, setDescription] = useState(""); - const [icon, setIcon] = useState(null); - const [trigger, setTrigger] = useState("manual"); - const [isActive, setIsActive] = useState(true); - const [errors, setErrors] = useState({}); - - useEffect(() => { - if (!isAdd && id) fetchAchievement(id); - }, [id, isAdd]); - - useEffect(() => { - if (achievement && !isAdd) { - setKey(achievement.key ?? ""); - setType(achievement.type ?? "badge"); - setLabel(achievement.label ?? ""); - setDescription(achievement.description ?? ""); - setIcon(achievement.icon ?? null); - setTrigger(achievement.trigger ?? "manual"); - setIsActive(achievement.is_active ?? true); - } - }, [achievement, isAdd]); - - const validate = () => { - const e = {}; - if (!label.trim()) e.label = "Label is required."; - if (isAdd && !key.trim()) e.key = "Key is required."; - if (isAdd && !/^[a-z0-9_]+$/.test(key)) e.key = "Key must be lowercase letters, numbers or underscores."; - setErrors(e); - return !Object.keys(e).length; - }; - - const handleSave = async () => { - if (!validate()) return; - - const payload = { - type, - label: label.trim(), - description: description.trim() || null, - icon: icon || null, - trigger: trigger || null, - is_active: isActive, - }; - - if (isAdd) { - payload.key = key.trim(); - const result = await createAchievement(payload); - if (result) navigate("/admin/achievements"); - } else { - const result = await updateAchievement(id, payload); - if (result) navigate("/admin/achievements"); - } - }; - - const isSystem = !isAdd && achievement?.is_system; - - return ( -
- -
-
- -
- , to: "/admin" }, - { label: "Achievements", to: "/admin/achievements" }, - { label: isAdd ? "Add Achievement" : (achievement?.label ?? "Edit") }, - ]} /> -
- -
- -
-

{isAdd ? "Add Achievement" : "Edit Achievement"}

-

- {isAdd ? "Define a new badge or milestone learners can earn." : "Update this achievement's details."} -

-
-
- - {isSystem && ( -
- -

- This is a system achievement — it's auto-granted by platform code that references - its key directly, so the key and type are locked. Label, description, icon, trigger and active state are still editable. -

-
- )} - -
- - -
- - setKey(e.target.value.toLowerCase())} - placeholder="e.g. course_marathon" - disabled={!isAdd} - /> -

Lowercase, no spaces. Cannot be changed after creation.

- -
- -
- - setLabel(e.target.value)} placeholder="e.g. Course Marathon" /> - -
- -
- -