mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
add: more commits
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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 (
|
||||
<AdminNotificationTemplateContext.Provider value={{
|
||||
templates, template, loading,
|
||||
fetchTemplates, fetchTemplate, updateTemplate,
|
||||
}}>
|
||||
{children}
|
||||
</AdminNotificationTemplateContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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"],
|
||||
};
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function 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 (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="Edit Notification Template - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<AppBreadcrumb items={[
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Notifications", to: "/admin/notifications" },
|
||||
{ label: "Templates", to: "/admin/notification-templates" },
|
||||
{ label: template?.label ?? "Edit" },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-semibold">Edit Notification Template</h1>
|
||||
{template && (
|
||||
<Badge variant="outline" className={cn("gap-1", status.badgeClass)}>
|
||||
<Send className="h-3 w-3" /> {status.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Update this notification's title and message.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pending && (
|
||||
<div className="rounded-lg border border-amber-400 bg-amber-50 dark:bg-amber-950/40 dark:border-amber-700 p-4 flex items-start gap-3 mb-5">
|
||||
<Clock3 className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300">
|
||||
This template has <strong>pending changes</strong> that haven't gone out yet — the version
|
||||
currently used is the last one you published. Press <strong>Publish</strong> below to
|
||||
apply these edits, or <strong>Save as Draft</strong> to keep working without publishing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
|
||||
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This is a <strong>system</strong> notification — code fires it by referencing this exact type,
|
||||
so the type is locked. Label, title and message are still fully editable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
|
||||
<SectionCard title="Template Details">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Type</Label>
|
||||
<Input value={template?.type ?? ""} disabled />
|
||||
<p className="text-xs text-muted-foreground">Cannot be changed — this is what code looks up.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
||||
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Tasks Overdue (Admin)" />
|
||||
<FieldError message={errors.label} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Tasks Overdue" />
|
||||
<FieldError message={errors.title} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
<div className="flex items-center justify-between border-b pb-3">
|
||||
<p className="text-sm font-semibold">Message</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Plain text only — no HTML, no conditional logic, just straight{" "}
|
||||
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens.
|
||||
</p>
|
||||
|
||||
{(knownPlaceholders !== null) && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Available placeholders</p>
|
||||
{knownPlaceholders.length ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{knownPlaceholders.map((ph) => (
|
||||
<Badge key={ph} variant="outline" className="font-mono text-[10px]">
|
||||
{`{{${ph}}}`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">This template has no dynamic placeholders.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={6}
|
||||
className="font-mono text-xs"
|
||||
placeholder="{{count}} {{task_word}} automatically marked as overdue."
|
||||
/>
|
||||
<FieldError message={errors.message} />
|
||||
</SectionCard>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
||||
<Button type="button" variant="outline" onClick={() => handleSave(false)} disabled={loading}>
|
||||
Save as Draft
|
||||
</Button>
|
||||
<Button type="button" onClick={() => handleSave(true)} disabled={loading}>
|
||||
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
|
||||
Publish
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditNotificationTemplate() {
|
||||
return (
|
||||
<AdminNotificationTemplateProvider>
|
||||
<EditNotificationTemplateInner />
|
||||
</AdminNotificationTemplateProvider>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<p className="text-sm text-muted-foreground">Compose and send announcements to admins and users</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => navigate("/admin/notification-templates")}>
|
||||
<FileText className="size-4" />
|
||||
Templates
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate("/admin/notifications/settings")}>
|
||||
<Settings className="size-4" />
|
||||
Settings
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-lg border bg-card p-5 flex flex-col gap-4 h-full">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center shrink-0">
|
||||
<TypeIcon className="h-4.5 w-4.5 text-muted-foreground" />
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<p className="text-sm font-semibold truncate">{item.label}</p>
|
||||
{item.is_system && (
|
||||
<Badge variant="secondary" className="gap-1 shrink-0">
|
||||
<Lock className="h-2.5 w-2.5" /> System
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{item.type}</code>
|
||||
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">
|
||||
<span className="text-foreground">{item.title || item.draft_title || "No title yet"}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{typeMeta && (
|
||||
<Badge variant="outline" className="gap-1 text-[11px]">
|
||||
<typeMeta.icon className="h-3 w-3" /> {typeMeta.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className={cn("gap-1 text-[11px]", status.badgeClass)}>
|
||||
<Send className="h-3 w-3" /> {status.label}
|
||||
</Badge>
|
||||
{pending && (
|
||||
<Badge variant="outline" className="gap-1 text-[11px] bg-amber-100 text-amber-700 border-amber-400 dark:bg-amber-900/40 dark:text-amber-400 dark:border-amber-700">
|
||||
<Clock3 className="h-3 w-3" /> Pending changes
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="Notification Templates - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||
<div className="w-full max-w-6xl mx-auto">
|
||||
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<AppBreadcrumb items={[
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Notifications", to: "/admin/notifications" },
|
||||
{ label: "Templates" },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Notification Templates</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Title and message wording for every automated notification STARR sends.
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Send className="h-3 w-3 text-emerald-600" />
|
||||
{templates.filter((t) => t.status === "sent").length} sent
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Pencil className="h-3 w-3" />
|
||||
{templates.filter((t) => t.status === "draft").length} draft
|
||||
</span>
|
||||
{templates.some(hasPendingChanges) && (
|
||||
<span className="flex items-center gap-1 text-amber-600">
|
||||
<Clock3 className="h-3 w-3" />
|
||||
{templates.filter(hasPendingChanges).length} with pending changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
|
||||
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>
|
||||
Every template here is <strong>system</strong>-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.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Draft vs. Sent:</strong> a <strong>Sent</strong> 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 <strong>Publish</strong> again.
|
||||
</p>
|
||||
<p>
|
||||
Only plain text is supported — no HTML, no conditional logic, just straight{" "}
|
||||
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens that get swapped
|
||||
for real values when the notification fires.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mb-5">
|
||||
<Button
|
||||
type="button" size="sm" variant={activeType === "all" ? "secondary" : "outline"}
|
||||
onClick={() => setActiveType("all")}
|
||||
>
|
||||
All ({templates.length})
|
||||
</Button>
|
||||
{typesInUse.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const count = templates.filter((tpl) => tpl.notify_type === t.value).length;
|
||||
return (
|
||||
<Button
|
||||
key={t.value}
|
||||
type="button" size="sm"
|
||||
variant={activeType === t.value ? "secondary" : "outline"}
|
||||
onClick={() => setActiveType(t.value)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" /> {t.label} ({count})
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Separator className="mb-5" />
|
||||
|
||||
{loading && !templates.length ? (
|
||||
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
|
||||
) : !filtered.length ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">No notification templates found.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map((item) => (
|
||||
<TemplateCard
|
||||
key={item.notification_template_id}
|
||||
item={item}
|
||||
onEdit={(t) => navigate(`/admin/notification-templates/${t.notification_template_id}/edit`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationTemplates() {
|
||||
return (
|
||||
<AdminNotificationTemplateProvider>
|
||||
<NotificationTemplatesInner />
|
||||
</AdminNotificationTemplateProvider>
|
||||
);
|
||||
}
|
||||
@@ -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: <EditNotificationBroadcast /> },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'notification-templates',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <NotificationTemplates /> },
|
||||
{ path: ':id/edit', element: <EditNotificationTemplate /> },
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
// Activity Feed
|
||||
|
||||
@@ -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 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="phone" className="text-sm font-medium flex items-center gap-1.5">
|
||||
Phone number
|
||||
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
<label htmlFor="phone" className="text-sm font-medium">
|
||||
Phone number <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="phone"
|
||||
|
||||
@@ -69,7 +69,9 @@ export default function IntroPage() {
|
||||
const age = (Date.now() - new Date(dateOfBirth)) / (1000 * 60 * 60 * 24 * 365.25)
|
||||
if (isNaN(age) || age < 13 || age > 120) e.dateOfBirth = 'Please enter a valid date of birth.'
|
||||
}
|
||||
if (phone.trim() && !/^\+?[0-9\s\-() ]{7,20}$/.test(phone.trim())) {
|
||||
if (!phone.trim()) {
|
||||
e.phone = 'Phone number is required.'
|
||||
} else if (!/^\+?[0-9\s\-() ]{7,20}$/.test(phone.trim())) {
|
||||
e.phone = 'Invalid phone number.'
|
||||
}
|
||||
return e
|
||||
@@ -239,7 +241,7 @@ export default function IntroPage() {
|
||||
{/* Phone */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">
|
||||
Phone number <span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
Phone number <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="tel"
|
||||
|
||||
Reference in New Issue
Block a user