From 72298b86cd3a4a472c5a41f0e26f2b6969c626a0 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Fri, 3 Jul 2026 21:40:10 +0800 Subject: [PATCH] add: more commits Signed-off-by: Kenneth Obsequio --- .../AdminNotificationTemplateContext.jsx | 60 +++++ .../notificationTemplatePlaceholders.data.js | 20 ++ src/data/notificationTemplateStatus.data.js | 12 + src/data/notificationTemplateTypes.data.js | 20 ++ .../EditNotificationTemplate.jsx | 217 ++++++++++++++++++ .../NotificationBroadcastList.jsx | 6 +- .../notifications/NotificationTemplates.jsx | 199 ++++++++++++++++ src/modules/admin/routes/AdminRoutes.jsx | 10 + src/modules/auth/components/RegisterForm.jsx | 7 +- src/modules/auth/pages/Intro.jsx | 6 +- 10 files changed, 550 insertions(+), 7 deletions(-) create mode 100644 src/contexts/AdminNotificationTemplateContext.jsx create mode 100644 src/data/notificationTemplatePlaceholders.data.js create mode 100644 src/data/notificationTemplateStatus.data.js create mode 100644 src/data/notificationTemplateTypes.data.js create mode 100644 src/modules/admin/pages/notifications/EditNotificationTemplate.jsx create mode 100644 src/modules/admin/pages/notifications/NotificationTemplates.jsx diff --git a/src/contexts/AdminNotificationTemplateContext.jsx b/src/contexts/AdminNotificationTemplateContext.jsx new file mode 100644 index 0000000..e7be244 --- /dev/null +++ b/src/contexts/AdminNotificationTemplateContext.jsx @@ -0,0 +1,60 @@ +import { createContext, useCallback, useContext, useState } from "react"; +import api from "@/utils/api.util"; +import { toast } from "sonner"; + +const AdminNotificationTemplateContext = createContext(null); + +export function useAdminNotificationTemplates() { + const ctx = useContext(AdminNotificationTemplateContext); + if (!ctx) throw new Error("useAdminNotificationTemplates must be used inside AdminNotificationTemplateProvider"); + return ctx; +} + +export function AdminNotificationTemplateProvider({ children }) { + const [templates, setTemplates] = useState([]); + const [template, setTemplate] = useState(null); + const [loading, setLoading] = useState(false); + + const request = useCallback(async (fn) => { + setLoading(true); + try { return await fn(); } + catch (err) { + toast.error(err?.response?.data?.message ?? "Something went wrong."); + return null; + } finally { setLoading(false); } + }, []); + + const fetchTemplates = useCallback(() => + request(async () => { + const { data } = await api.get("/admin/notification-templates"); + setTemplates(data.data ?? []); + return data.data; + }), [request]); + + const fetchTemplate = useCallback((id) => + request(async () => { + const { data } = await api.get(`/admin/notification-templates/${id}`); + setTemplate(data.data ?? null); + return data.data; + }), [request]); + + const updateTemplate = useCallback((id, payload) => + request(async () => { + const { data } = await api.put(`/admin/notification-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.success("Notification template updated."); + return data.data; + }), [request, template]); + + return ( + + {children} + + ); +} diff --git a/src/data/notificationTemplatePlaceholders.data.js b/src/data/notificationTemplatePlaceholders.data.js new file mode 100644 index 0000000..26e6cdc --- /dev/null +++ b/src/data/notificationTemplatePlaceholders.data.js @@ -0,0 +1,20 @@ +// 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/notificationTemplateStatus.data.js b/src/data/notificationTemplateStatus.data.js new file mode 100644 index 0000000..45756b9 --- /dev/null +++ b/src/data/notificationTemplateStatus.data.js @@ -0,0 +1,12 @@ +// A template is "sent" once it has a live title/message — that's the only +// content services/notificationTemplate.service.js's renderNotification() ever +// reads on the backend. Editing a sent template writes to draft_title/ +// draft_message instead, so "pending changes" means there's a draft sitting on +// top of the live version. Mirrors emailTemplateStatus.data.js. +export const hasPendingChanges = (t) => + t?.status === "sent" && (t?.draft_title != null || t?.draft_message != null); + +export const STATUS_META = { + draft: { label: "Draft", badgeClass: "bg-muted text-muted-foreground border-border" }, + sent: { label: "Sent", badgeClass: "bg-emerald-100 text-emerald-700 border-emerald-400 dark:bg-emerald-900/40 dark:text-emerald-400 dark:border-emerald-700" }, +}; diff --git a/src/data/notificationTemplateTypes.data.js b/src/data/notificationTemplateTypes.data.js new file mode 100644 index 0000000..17a3938 --- /dev/null +++ b/src/data/notificationTemplateTypes.data.js @@ -0,0 +1,20 @@ +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/modules/admin/pages/notifications/EditNotificationTemplate.jsx b/src/modules/admin/pages/notifications/EditNotificationTemplate.jsx new file mode 100644 index 0000000..34f3007 --- /dev/null +++ b/src/modules/admin/pages/notifications/EditNotificationTemplate.jsx @@ -0,0 +1,217 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { ArrowLeft, House, Lock, Send, Clock3 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Spinner } from "@/components/ui/spinner"; +import { Badge } from "@/components/ui/badge"; +import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; +import { PageMeta } from "@/contexts/MetadataContext"; +import { + AdminNotificationTemplateProvider, + useAdminNotificationTemplates, +} from "@/contexts/AdminNotificationTemplateContext"; +import { NOTIFICATION_TEMPLATE_PLACEHOLDERS } from "@/data/notificationTemplatePlaceholders.data"; +import { STATUS_META, hasPendingChanges } from "@/data/notificationTemplateStatus.data"; +import { cn } from "@/lib/utils"; + +function SectionCard({ title, children }) { + return ( +
+ {title &&

{title}

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

{message}

; +} + +function EditNotificationTemplateInner() { + const navigate = useNavigate(); + const { id } = useParams(); + const { template, loading, fetchTemplate, updateTemplate } = useAdminNotificationTemplates(); + + const [label, setLabel] = useState(""); + const [title, setTitle] = useState(""); + const [message, setMessage] = useState(""); + const [errors, setErrors] = useState({}); + + useEffect(() => { + if (id) fetchTemplate(id); + }, [id]); + + useEffect(() => { + if (template) { + setLabel(template.label ?? ""); + // Prefer whatever's pending (unpublished) over the live version, so + // reopening a template with pending changes resumes editing them. + setTitle(template.draft_title ?? template.title ?? ""); + setMessage(template.draft_message ?? template.message ?? ""); + } + }, [template]); + + const status = STATUS_META[template?.status] ?? STATUS_META.draft; + const pending = hasPendingChanges(template); + const knownPlaceholders = NOTIFICATION_TEMPLATE_PLACEHOLDERS[template?.type] ?? null; + + const validate = () => { + const e = {}; + if (!label.trim()) e.label = "Label is required."; + if (!title.trim()) e.title = "Title is required."; + if (!message.trim()) e.message = "Message is required."; + setErrors(e); + return !Object.keys(e).length; + }; + + const handleSave = async (publish) => { + if (!validate()) return; + + const result = await updateTemplate(id, { + label: label.trim(), + title: title.trim(), + message: message.trim(), + publish, + }); + if (result) navigate("/admin/notification-templates"); + }; + + return ( +
+ +
+
+ +
+ , to: "/admin" }, + { label: "Notifications", to: "/admin/notifications" }, + { label: "Templates", to: "/admin/notification-templates" }, + { label: template?.label ?? "Edit" }, + ]} /> +
+ +
+ +
+
+

Edit Notification Template

+ {template && ( + + {status.label} + + )} +
+

Update this notification's title and message.

+
+
+ + {pending && ( +
+ +

+ This template has pending changes that haven't gone out yet — the version + currently used is the last one you published. Press Publish below to + apply these edits, or Save as Draft to keep working without publishing. +

+
+ )} + +
+ +

+ This is a system notification — code fires it by referencing this exact type, + so the type is locked. Label, title and message are still fully editable. +

+
+ +
+ + +
+ + +

Cannot be changed — this is what code looks up.

+
+ +
+ + setLabel(e.target.value)} placeholder="e.g. Tasks Overdue (Admin)" /> + +
+ +
+ + setTitle(e.target.value)} placeholder="e.g. Tasks Overdue" /> + +
+
+ + +
+

Message

+
+ +

+ Plain text only — no HTML, no conditional logic, just straight{" "} + {"{{placeholder}}"} tokens. +

+ + {(knownPlaceholders !== null) && ( +
+

Available placeholders

+ {knownPlaceholders.length ? ( +
+ {knownPlaceholders.map((ph) => ( + + {`{{${ph}}}`} + + ))} +
+ ) : ( +

This template has no dynamic placeholders.

+ )} +
+ )} + +