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" },
+ ]} />
+
+
+
+
navigate(-1)}>
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
Type
+
+
Cannot be changed — this is what code looks up.
+
+
+
+ Label *
+ setLabel(e.target.value)} placeholder="e.g. Tasks Overdue (Admin)" />
+
+
+
+
+ Title *
+ setTitle(e.target.value)} placeholder="e.g. Tasks Overdue" />
+
+
+
+
+
+
+
+
+ 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.
+ )}
+
+ )}
+
+
+
+
+ navigate(-1)} disabled={loading}>Cancel
+ handleSave(false)} disabled={loading}>
+ Save as Draft
+
+ handleSave(true)} disabled={loading}>
+ {loading ? : }
+ Publish
+
+
+
+
+
+
+ );
+}
+
+export default function EditNotificationTemplate() {
+ return (
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/notifications/NotificationBroadcastList.jsx b/src/modules/admin/pages/notifications/NotificationBroadcastList.jsx
index bd8b6a6..c758c26 100644
--- a/src/modules/admin/pages/notifications/NotificationBroadcastList.jsx
+++ b/src/modules/admin/pages/notifications/NotificationBroadcastList.jsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
-import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, Settings } from "lucide-react";
+import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, Settings, FileText } from "lucide-react";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -72,6 +72,10 @@ export default function NotificationBroadcastList() {
Compose and send announcements to admins and users
+
navigate("/admin/notification-templates")}>
+
+ Templates
+
navigate("/admin/notifications/settings")}>
Settings
diff --git a/src/modules/admin/pages/notifications/NotificationTemplates.jsx b/src/modules/admin/pages/notifications/NotificationTemplates.jsx
new file mode 100644
index 0000000..9ea9c65
--- /dev/null
+++ b/src/modules/admin/pages/notifications/NotificationTemplates.jsx
@@ -0,0 +1,199 @@
+import { useEffect, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { House, Pencil, Bell, Lock, Send, Clock3 } 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 AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
+import { PageMeta } from "@/contexts/MetadataContext";
+import {
+ AdminNotificationTemplateProvider,
+ useAdminNotificationTemplates,
+} from "@/contexts/AdminNotificationTemplateContext";
+import { NOTIFICATION_TEMPLATE_TYPES, getNotificationTemplateType } from "@/data/notificationTemplateTypes.data";
+import { STATUS_META, hasPendingChanges } from "@/data/notificationTemplateStatus.data";
+import { cn } from "@/lib/utils";
+
+function TemplateCard({ item, onEdit }) {
+ const typeMeta = getNotificationTemplateType(item.notify_type);
+ const TypeIcon = typeMeta?.icon ?? Bell;
+ const status = STATUS_META[item.status] ?? STATUS_META.draft;
+ const pending = hasPendingChanges(item);
+
+ return (
+
+
+
+
+
+
onEdit(item)}>
+
+
+
+
+
+
+
{item.label}
+ {item.is_system && (
+
+ System
+
+ )}
+
+
{item.type}
+
+ {item.title || item.draft_title || "No title yet"}
+
+
+
+
+ {typeMeta && (
+
+ {typeMeta.label}
+
+ )}
+
+ {status.label}
+
+ {pending && (
+
+ Pending changes
+
+ )}
+
+
+ );
+}
+
+function NotificationTemplatesInner() {
+ const navigate = useNavigate();
+ const { templates, loading, fetchTemplates } = useAdminNotificationTemplates();
+ const [activeType, setActiveType] = useState("all");
+
+ useEffect(() => { fetchTemplates(); }, []);
+
+ const filtered = useMemo(
+ () => activeType === "all" ? templates : templates.filter((t) => t.notify_type === activeType),
+ [templates, activeType]
+ );
+
+ const typesInUse = useMemo(
+ () => NOTIFICATION_TEMPLATE_TYPES.filter((t) => templates.some((tpl) => tpl.notify_type === t.value)),
+ [templates]
+ );
+
+ return (
+
+
+
+
+
+
+
, to: "/admin" },
+ { label: "Notifications", to: "/admin/notifications" },
+ { label: "Templates" },
+ ]} />
+
+
+
+
+
Notification Templates
+
+ Title and message wording for every automated notification STARR sends.
+
+
+
+
+ {templates.filter((t) => t.status === "sent").length} sent
+
+
+
+ {templates.filter((t) => t.status === "draft").length} draft
+
+ {templates.some(hasPendingChanges) && (
+
+
+ {templates.filter(hasPendingChanges).length} with pending changes
+
+ )}
+
+
+
+
+
+
+
+
+ Every template here is system -triggered — code fires it by referencing its
+ exact type, so no template can be added or removed from this screen. Only the title and
+ message wording is editable.
+
+
+ Draft vs. Sent: a Sent template is the version actually
+ used for real notifications right now. Editing a Sent template doesn't change what goes out
+ immediately — it's held as a pending change until you press Publish again.
+
+
+ Only plain text is supported — no HTML, no conditional logic, just straight{" "}
+ {"{{placeholder}}"} tokens that get swapped
+ for real values when the notification fires.
+
+
+
+
+
+ setActiveType("all")}
+ >
+ All ({templates.length})
+
+ {typesInUse.map((t) => {
+ const Icon = t.icon;
+ const count = templates.filter((tpl) => tpl.notify_type === t.value).length;
+ return (
+ setActiveType(t.value)}
+ className="gap-1.5"
+ >
+ {t.label} ({count})
+
+ );
+ })}
+
+
+
+
+ {loading && !templates.length ? (
+
+ ) : !filtered.length ? (
+
No notification templates found.
+ ) : (
+
+ {filtered.map((item) => (
+ navigate(`/admin/notification-templates/${t.notification_template_id}/edit`)}
+ />
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+export default function NotificationTemplates() {
+ return (
+
+
+
+ );
+}
diff --git a/src/modules/admin/routes/AdminRoutes.jsx b/src/modules/admin/routes/AdminRoutes.jsx
index 3cd60dd..b5993bf 100644
--- a/src/modules/admin/routes/AdminRoutes.jsx
+++ b/src/modules/admin/routes/AdminRoutes.jsx
@@ -118,6 +118,8 @@ import AddNotificationBroadcast from '../pages/notifications/AddNotificationBroa
import EditNotificationBroadcast from '../pages/notifications/EditNotificationBroadcast'
import ViewNotificationBroadcast from '../pages/notifications/ViewNotificationBroadcast'
import NotificationSettings from '../pages/notifications/NotificationSettings'
+import NotificationTemplates from '../pages/notifications/NotificationTemplates'
+import EditNotificationTemplate from '../pages/notifications/EditNotificationTemplate'
// Activity
import ActivityFeed from '../pages/activity/ActivityFeed'
@@ -353,6 +355,14 @@ export const AdminRoutes = {
{ path: ':broadcastId/edit', element: },
]
},
+ {
+ path: 'notification-templates',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: ':id/edit', element: },
+ ]
+ },
// Activity Feed
diff --git a/src/modules/auth/components/RegisterForm.jsx b/src/modules/auth/components/RegisterForm.jsx
index 6cbaed6..b6af084 100644
--- a/src/modules/auth/components/RegisterForm.jsx
+++ b/src/modules/auth/components/RegisterForm.jsx
@@ -53,7 +53,7 @@ const personalSchema = z.object({
return age >= 13 && age <= 120
}, { message: 'Must be at least 13 years old' }),
occupation: z.string().min(1, 'Occupation is required').max(100),
- phone: z.string().regex(/^\+?[0-9\s\-()]{7,20}$/, 'Invalid phone number').optional().or(z.literal('')),
+ phone: z.string().min(1, 'Phone number is required').regex(/^\+?[0-9\s\-()]{7,20}$/, 'Invalid phone number'),
})
const credentialsSchema = z
@@ -431,9 +431,8 @@ export function RegisterForm({ className, ...props }) {
{/* Phone */}