From 0dd318d7c957326ae4b040f0cb1e7a6ae21467a8 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 27 Sep 2026 23:14:52 +0000 Subject: [PATCH] sync: learning operations frontend 2/2 --- Frontend/src/app/learner-communities.ts | 117 ++++---- Frontend/src/app/learner-courses.ts | 111 +++++++- Frontend/src/app/learner-live-courses.ts | 2 + Frontend/src/app/learner-scorm.ts | 136 +++++----- .../src/app/pages/AdminCommunitiesPage.tsx | 68 +++++ .../src/app/pages/AdminNotificationsPage.tsx | 43 +++ .../src/app/pages/LearnerCommunityPage.tsx | 244 ++++++++++------- .../src/app/pages/LearnerCourseDetailPage.tsx | 182 ++++++------- .../src/app/pages/LearnerScormLaunchPage.tsx | 253 ++++++++---------- Frontend/src/app/routes/admin-routes.tsx | 14 +- 10 files changed, 710 insertions(+), 460 deletions(-) create mode 100644 Frontend/src/app/pages/AdminCommunitiesPage.tsx create mode 100644 Frontend/src/app/pages/AdminNotificationsPage.tsx diff --git a/Frontend/src/app/learner-communities.ts b/Frontend/src/app/learner-communities.ts index 2b25640..4290f1d 100644 --- a/Frontend/src/app/learner-communities.ts +++ b/Frontend/src/app/learner-communities.ts @@ -26,11 +26,33 @@ export type LearnerCommunityMembership = { source?: string } -export type LearnerCommunityAccess = { - allowed: boolean - reason?: string +export type LearnerCommunityAuthor = { id: string; name: string } + +export type LearnerCommunityReply = { + id: string + postId: string + body: string + status?: string + createdAt?: number + editedAt?: number + author: LearnerCommunityAuthor } +export type LearnerCommunityPost = { + id: string + communityId: string + channelId: string + body: string + status?: string + pinned?: boolean + createdAt?: number + editedAt?: number + author: LearnerCommunityAuthor + replies: LearnerCommunityReply[] +} + +export type LearnerCommunityAccess = { allowed: boolean; reason?: string } + export type LearnerCommunityItem = { community: LearnerCommunity channels: LearnerCommunityChannel[] @@ -38,83 +60,76 @@ export type LearnerCommunityItem = { access: LearnerCommunityAccess } -function asString(value: unknown): string { - return typeof value === 'string' ? value.trim() : '' -} - -function asBool(value: unknown): boolean | undefined { - return typeof value === 'boolean' ? value : undefined -} +function asString(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } +function asNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined } +function asBool(value: unknown): boolean | undefined { return typeof value === 'boolean' ? value : undefined } function normalizeCommunity(raw: unknown): LearnerCommunity { const source = (raw ?? {}) as Record - return { - id: asString(source.id), - name: asString(source.name), - slug: asString(source.slug) || undefined, - description: asString(source.description) || undefined, - visibility: asString(source.visibility) || undefined, - accessPolicy: asString(source.accessPolicy) || undefined, - } + return { id: asString(source.id), name: asString(source.name), slug: asString(source.slug) || undefined, description: asString(source.description) || undefined, visibility: asString(source.visibility) || undefined, accessPolicy: asString(source.accessPolicy) || undefined } } function normalizeChannel(raw: unknown): LearnerCommunityChannel { const source = (raw ?? {}) as Record - return { - id: asString(source.id), - name: asString(source.name), - slug: asString(source.slug) || undefined, - topic: asString(source.topic) || undefined, - isPrivate: asBool(source.isPrivate), - } + return { id: asString(source.id), name: asString(source.name), slug: asString(source.slug) || undefined, topic: asString(source.topic) || undefined, isPrivate: asBool(source.isPrivate) } } function normalizeMembership(raw: unknown): LearnerCommunityMembership | undefined { const source = (raw ?? {}) as Record const id = asString(source.id) if (!id) return undefined - return { - id, - communityId: asString(source.communityId), - userId: asString(source.userId), - status: asString(source.status) || undefined, - role: asString(source.role) || undefined, - source: asString(source.source) || undefined, - } + return { id, communityId: asString(source.communityId), userId: asString(source.userId), status: asString(source.status) || undefined, role: asString(source.role) || undefined, source: asString(source.source) || undefined } } function normalizeAccess(raw: unknown): LearnerCommunityAccess { const source = (raw ?? {}) as Record - return { - allowed: Boolean(source.allowed), - reason: asString(source.reason) || undefined, - } + return { allowed: Boolean(source.allowed), reason: asString(source.reason) || undefined } +} + +function normalizeAuthor(raw: unknown): LearnerCommunityAuthor { + const source = (raw ?? {}) as Record + return { id: asString(source.id), name: asString(source.name) || 'Member' } +} + +function normalizeReply(raw: unknown): LearnerCommunityReply { + const source = (raw ?? {}) as Record + return { id: asString(source.id), postId: asString(source.postId), body: asString(source.body), status: asString(source.status) || undefined, createdAt: asNumber(source.createdAt), editedAt: asNumber(source.editedAt), author: normalizeAuthor(source.author) } +} + +function normalizePost(raw: unknown): LearnerCommunityPost { + const source = (raw ?? {}) as Record + const replies = Array.isArray(source.replies) ? source.replies.map(normalizeReply) : [] + return { id: asString(source.id), communityId: asString(source.communityId), channelId: asString(source.channelId), body: asString(source.body), status: asString(source.status) || undefined, pinned: Boolean(source.pinned), createdAt: asNumber(source.createdAt), editedAt: asNumber(source.editedAt), author: normalizeAuthor(source.author), replies } } export async function fetchLearnerCommunities(): Promise { const result = await apiRequest('/api/learner/communities') if (!Array.isArray(result)) return [] - return result.map(raw => { const source = (raw ?? {}) as Record const channelsRaw = Array.isArray(source.channels) ? source.channels : [] - return { - community: normalizeCommunity(source.community), - channels: channelsRaw.map(normalizeChannel).filter(channel => Boolean(channel.id && channel.name)), - membership: normalizeMembership(source.membership), - access: normalizeAccess(source.access), - } + return { community: normalizeCommunity(source.community), channels: channelsRaw.map(normalizeChannel).filter(channel => Boolean(channel.id && channel.name)), membership: normalizeMembership(source.membership), access: normalizeAccess(source.access) } }).filter(item => Boolean(item.community.id && item.community.name)) } export async function joinLearnerCommunity(communityID: string): Promise { - const result = await apiRequest(`/api/learner/communities/${encodeURIComponent(communityID)}/join`, { - method: 'POST', - body: JSON.stringify({}), - }) + const result = await apiRequest(`/api/learner/communities/${encodeURIComponent(communityID)}/join`, { method: 'POST', body: JSON.stringify({}) }) const membership = normalizeMembership(result) - if (!membership) { - throw new Error('Join response did not include membership details') - } + if (!membership) throw new Error('Join response did not include membership details') return membership } + +export async function fetchCommunityPosts(communityID: string, channelID: string): Promise { + const result = await apiRequest(`/api/learner/communities/${encodeURIComponent(communityID)}/channels/${encodeURIComponent(channelID)}/posts`) + return Array.isArray(result) ? result.map(normalizePost).filter(post => Boolean(post.id)) : [] +} + +export async function createCommunityPost(communityID: string, channelID: string, body: string): Promise { + const result = await apiRequest(`/api/learner/communities/${encodeURIComponent(communityID)}/channels/${encodeURIComponent(channelID)}/posts`, { method: 'POST', body: JSON.stringify({ body }) }) + return normalizePost(result) +} + +export async function createCommunityReply(communityID: string, postID: string, body: string): Promise { + const result = await apiRequest(`/api/learner/communities/${encodeURIComponent(communityID)}/posts/${encodeURIComponent(postID)}/replies`, { method: 'POST', body: JSON.stringify({ body }) }) + return normalizeReply(result) +} diff --git a/Frontend/src/app/learner-courses.ts b/Frontend/src/app/learner-courses.ts index 328362f..4a49148 100644 --- a/Frontend/src/app/learner-courses.ts +++ b/Frontend/src/app/learner-courses.ts @@ -1,5 +1,6 @@ import { fetchLearnerLiveCourses, type LearnerLiveCourseItem } from './learner-live-courses' import { fetchPublicLiveCourses, fetchPublicLiveCourseBySlug, type LiveCourse } from './live-courses' +import { apiRequest } from './api/client' export type LearnerCourseCard = { id: string @@ -15,13 +16,39 @@ export type LearnerCourseCard = { lockMessage?: string } +export type LearnerCourseLesson = { + id: string + title: string + description?: string + contentType: 'text' | 'video' | 'link' | 'document' + content?: string + sourceUrl?: string + durationMinutes?: number + completed: boolean +} + +export type LearnerCourseModule = { + id: string + title: string + description?: string + lessons: LearnerCourseLesson[] +} + +export type LearnerCourseAsset = { + id: string + title: string + description?: string + fileName?: string + protectionLevel?: string + allowDownload: boolean +} + export type LearnerCourseDetail = { card: LearnerCourseCard - cohorts: { - id: string - title: string - status?: string - }[] + cohorts: { id: string; title: string; status?: string }[] + modules: LearnerCourseModule[] + assets: LearnerCourseAsset[] + progress: { total: number; completed: number; percent: number } } function normalizeCourseSlug(slug: string | undefined, fallbackID: string): string { @@ -42,7 +69,7 @@ function buildEnrolledCard(item: LearnerLiveCourseItem): LearnerCourseCard { description: item.course.description, source: 'enrolled', statusLabel: status, - progressLabel: 'Progress tracking is not yet available for this course.', + progressLabel: typeof item.enrollment.progress === 'number' ? `${Math.round(item.enrollment.progress)}% complete` : 'Ready to continue', actionLabel: 'Resume', locked, lockReason: reason, @@ -131,9 +158,60 @@ export async function fetchLearnerCourseDetailBySlug(slug: string): Promise>(`/api/learner/live-courses/${encodeURIComponent(card.id)}/content`), + apiRequest(`/api/learner/live-courses/${encodeURIComponent(card.id)}/assets`), + ]) + const modulesRaw = Array.isArray(content.modules) ? content.modules : [] + const progressRaw = (content.progress ?? {}) as Record + const modules: LearnerCourseModule[] = modulesRaw.map(raw => { + const source = (raw ?? {}) as Record + const module = (source.module ?? {}) as Record + const lessonsRaw = Array.isArray(source.lessons) ? source.lessons : [] + return { + id: String(module.id ?? ''), + title: String(module.title ?? ''), + description: typeof module.description === 'string' ? module.description : undefined, + lessons: lessonsRaw.map(itemRaw => { + const item = (itemRaw ?? {}) as Record + const lesson = (item.lesson ?? {}) as Record + const lessonProgress = (item.progress ?? {}) as Record + const contentType = String(lesson.contentType ?? 'text') as LearnerCourseLesson['contentType'] + return { + id: String(lesson.id ?? ''), + title: String(lesson.title ?? ''), + description: typeof lesson.description === 'string' ? lesson.description : undefined, + contentType, + content: typeof lesson.content === 'string' ? lesson.content : undefined, + sourceUrl: typeof lesson.sourceUrl === 'string' ? lesson.sourceUrl : undefined, + durationMinutes: typeof lesson.durationMinutes === 'number' ? lesson.durationMinutes : undefined, + completed: String(lessonProgress.status ?? '').toLowerCase() === 'completed', + } + }).filter(lesson => lesson.id && lesson.title), + } + }).filter(module => module.id && module.title) + const normalizedAssets: LearnerCourseAsset[] = Array.isArray(assets) ? assets.map(raw => { + const source = (raw ?? {}) as Record + return { + id: String(source.id ?? ''), + title: String(source.title ?? ''), + description: typeof source.description === 'string' ? source.description : undefined, + fileName: typeof source.fileName === 'string' ? source.fileName : undefined, + protectionLevel: typeof source.protectionLevel === 'string' ? source.protectionLevel : undefined, + allowDownload: Boolean(source.allowDownload), + } + }).filter(asset => asset.id && asset.title) : [] + return { card, cohorts: courseCohorts, + modules, + assets: normalizedAssets, + progress: { + total: Number(progressRaw.total ?? 0), + completed: Number(progressRaw.completed ?? 0), + percent: Number(progressRaw.percent ?? 0), + }, } } @@ -145,5 +223,26 @@ export async function fetchLearnerCourseDetailBySlug(slug: string): Promise { + const result = await apiRequest>(`/api/learner/live-courses/${encodeURIComponent(courseID)}/lessons/${encodeURIComponent(lessonID)}/complete`, { + method: 'POST', + body: JSON.stringify({}), + }) + const summary = (result.courseProgress ?? {}) as Record + return { percent: Number(summary.percent ?? 0) } +} + +export async function accessLearnerCourseAsset(courseID: string, assetID: string): Promise { + const result = await apiRequest<{ url?: string }>(`/api/learner/live-courses/${encodeURIComponent(courseID)}/assets/${encodeURIComponent(assetID)}/access`, { + method: 'POST', + body: JSON.stringify({}), + }) + return String(result.url ?? '') +} diff --git a/Frontend/src/app/learner-live-courses.ts b/Frontend/src/app/learner-live-courses.ts index 88012ec..20708d5 100644 --- a/Frontend/src/app/learner-live-courses.ts +++ b/Frontend/src/app/learner-live-courses.ts @@ -38,6 +38,7 @@ export type LearnerLiveCourseItem = { enrollment: { id: string status?: string + progress?: number } access: LearnerAccess upcomingSessions: LearnerLiveSession[] @@ -119,6 +120,7 @@ export async function fetchLearnerLiveCourses(): Promise).id), status: asString(((source.enrollment ?? {}) as Record).status) || undefined, + progress: asNumber(((source.enrollment ?? {}) as Record).progress), }, access: normalizeAccess(source.access), upcomingSessions: sessionItems, diff --git a/Frontend/src/app/learner-scorm.ts b/Frontend/src/app/learner-scorm.ts index d4256dd..055c67c 100644 --- a/Frontend/src/app/learner-scorm.ts +++ b/Frontend/src/app/learner-scorm.ts @@ -1,19 +1,8 @@ import { apiRequest } from './api/client' +import { normalizeCourseForSubjects, type ScormCourse } from './pages/scorm-shared' -export type LearnerScormCourse = { - id: string - title: string - description?: string - subject?: string - gradeLevel?: string - moduleTypes: string[] -} - -export type LearnerScormEnrollment = { - id: string - status?: string -} - +export type LearnerScormCourse = ScormCourse & { moduleTypes: string[] } +export type LearnerScormEnrollment = { id: string; status?: string; progress?: number; score?: number } export type LearnerScormLaunchState = { allowed: boolean state: 'ready' | 'locked' | 'missing_package' | 'export_not_ready' @@ -23,95 +12,90 @@ export type LearnerScormLaunchState = { resumeAvailable: boolean progressNote?: string } +export type LearnerScormCourseItem = { course: LearnerScormCourse; enrollment: LearnerScormEnrollment; launchState: LearnerScormLaunchState } +export type LearnerScormLaunchDetail = LearnerScormCourseItem -export type LearnerScormCourseItem = { - course: LearnerScormCourse - enrollment: LearnerScormEnrollment - launchState: LearnerScormLaunchState +export type LearnerScormRuntime = { + id?: string + userId?: string + courseId: string + enrollmentId?: string + scoId: string + status?: string + location?: string + suspendData?: string + lessonStatus?: string + completionStatus?: string + successStatus?: string + score?: number + progress?: number + values: Record } -export type LearnerScormLaunchDetail = { - course: LearnerScormCourse - enrollment: LearnerScormEnrollment - launchState: LearnerScormLaunchState -} - -function asString(value: unknown): string { - return typeof value === 'string' ? value.trim() : '' -} +function asString(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } +function asNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined } function normalizeCourse(raw: unknown): LearnerScormCourse { const source = (raw ?? {}) as Record - const scos = Array.isArray(source.scos) ? source.scos : [] - const moduleTypes = scos - .map(item => { - const entry = (item ?? {}) as Record - return asString(entry.moduleType) - }) - .filter(Boolean) - return { - id: asString(source.id), - title: asString(source.title), - description: asString(source.description) || undefined, - subject: asString(source.subject) || undefined, - gradeLevel: asString(source.gradeLevel) || undefined, - moduleTypes: [...new Set(moduleTypes)], - } + const normalized = normalizeCourseForSubjects(source as unknown as ScormCourse) + const moduleTypes = normalized.scos.map(sco => sco.moduleType).filter(Boolean) + return { ...normalized, moduleTypes: [...new Set(moduleTypes)] } } - function normalizeEnrollment(raw: unknown): LearnerScormEnrollment { const source = (raw ?? {}) as Record - return { - id: asString(source.id), - status: asString(source.status) || undefined, - } + return { id: asString(source.id), status: asString(source.status) || undefined, progress: asNumber(source.progress), score: asNumber(source.score) } } - function normalizeLaunchState(raw: unknown): LearnerScormLaunchState { const source = (raw ?? {}) as Record const state = asString(source.state) - const normalizedState = state === 'ready' || state === 'locked' || state === 'missing_package' || state === 'export_not_ready' - ? state - : 'locked' + const normalizedState = state === 'ready' || state === 'locked' || state === 'missing_package' || state === 'export_not_ready' ? state : 'locked' const reason = asString(source.reason) || undefined const message = asString(source.message) || (reason === 'missing_entitlement' ? 'Access is locked because your entitlement is missing or inactive.' : undefined) - + return { allowed: Boolean(source.allowed), state: normalizedState, reason, message, launchUrl: asString(source.launchUrl) || undefined, resumeAvailable: Boolean(source.resumeAvailable), progressNote: asString(source.progressNote) || undefined } +} +function normalizeRuntime(raw: unknown): LearnerScormRuntime { + const source = (raw ?? {}) as Record return { - allowed: Boolean(source.allowed), - state: normalizedState, - reason, - message, - launchUrl: asString(source.launchUrl) || undefined, - resumeAvailable: Boolean(source.resumeAvailable), - progressNote: asString(source.progressNote) || undefined, + id: asString(source.id) || undefined, + userId: asString(source.userId) || undefined, + courseId: asString(source.courseId), + enrollmentId: asString(source.enrollmentId) || undefined, + scoId: asString(source.scoId), + status: asString(source.status) || undefined, + location: asString(source.location) || undefined, + suspendData: asString(source.suspendData) || undefined, + lessonStatus: asString(source.lessonStatus) || undefined, + completionStatus: asString(source.completionStatus) || undefined, + successStatus: asString(source.successStatus) || undefined, + score: asNumber(source.score), + progress: asNumber(source.progress), + values: source.values && typeof source.values === 'object' ? source.values as Record : {}, } } export async function fetchLearnerScormCourses(): Promise { const result = await apiRequest('/api/learner/scorm', { skipAuth: false }) if (!Array.isArray(result)) return [] - - return result - .map(raw => { - const source = (raw ?? {}) as Record - return { - course: normalizeCourse(source.course), - enrollment: normalizeEnrollment(source.enrollment), - launchState: normalizeLaunchState(source.launchState), - } - }) - .filter(item => Boolean(item.course.id && item.course.title)) + return result.map(raw => { + const source = (raw ?? {}) as Record + return { course: normalizeCourse(source.course), enrollment: normalizeEnrollment(source.enrollment), launchState: normalizeLaunchState(source.launchState) } + }).filter(item => Boolean(item.course.id && item.course.title)) } export async function fetchLearnerScormLaunch(courseID: string): Promise { const result = await apiRequest(`/api/learner/scorm/${encodeURIComponent(courseID)}/launch`, { skipAuth: false }) const source = (result ?? {}) as Record - - return { - course: normalizeCourse(source.course), - enrollment: normalizeEnrollment(source.enrollment), - launchState: normalizeLaunchState(source.launchState), - } + return { course: normalizeCourse(source.course), enrollment: normalizeEnrollment(source.enrollment), launchState: normalizeLaunchState(source.launchState) } +} + +export async function fetchLearnerScormRuntime(courseID: string, scoID: string): Promise { + return normalizeRuntime(await apiRequest(`/api/learner/scorm/${encodeURIComponent(courseID)}/runtime?scoId=${encodeURIComponent(scoID)}`)) +} + +export async function commitLearnerScormRuntime(courseID: string, payload: Partial): Promise<{ runtime: LearnerScormRuntime; courseProgress: { percent: number } }> { + const result = await apiRequest>(`/api/learner/scorm/${encodeURIComponent(courseID)}/runtime`, { method: 'PATCH', body: JSON.stringify(payload) }) + const progress = (result.courseProgress ?? {}) as Record + return { runtime: normalizeRuntime(result.runtime), courseProgress: { percent: Number(progress.percent ?? 0) } } } diff --git a/Frontend/src/app/pages/AdminCommunitiesPage.tsx b/Frontend/src/app/pages/AdminCommunitiesPage.tsx new file mode 100644 index 0000000..1b5250c --- /dev/null +++ b/Frontend/src/app/pages/AdminCommunitiesPage.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react' +import { MessageSquare, Plus, ShieldCheck } from 'lucide-react' +import { createAdminCommunity, createAdminCommunityChannel, fetchAdminCommunities, fetchAdminCommunityChannels, fetchAdminCommunityPosts, patchAdminCommunityPost, type AdminCommunity, type AdminCommunityChannel, type AdminCommunityPost } from '../admin-communities' + +export function AdminCommunitiesPage() { + const [communities, setCommunities] = useState([]) + const [communityID, setCommunityID] = useState('') + const [channels, setChannels] = useState([]) + const [channelID, setChannelID] = useState('') + const [posts, setPosts] = useState([]) + const [name, setName] = useState('') + const [channelName, setChannelName] = useState('') + const [error, setError] = useState('') + + const loadCommunities = async () => { + const items = await fetchAdminCommunities() + setCommunities(items) + if (!communityID && items[0]) setCommunityID(items[0].id) + } + useEffect(() => { void loadCommunities().catch(err => setError(String(err))) }, []) + useEffect(() => { + if (!communityID) return + void fetchAdminCommunityChannels(communityID).then(items => { + setChannels(items) + setChannelID(current => items.some(item => item.id === current) ? current : (items[0]?.id ?? '')) + }).catch(err => setError(String(err))) + }, [communityID]) + useEffect(() => { + if (!communityID || !channelID) { setPosts([]); return } + void fetchAdminCommunityPosts(communityID, channelID).then(setPosts).catch(err => setError(String(err))) + }, [communityID, channelID]) + + return ( +
+

Community moderation

Create spaces and channels, pin useful discussions, and hide or restore posts.

+ {error &&

{error}

} +
+ +
+
+
+ setChannelName(e.target.value)} placeholder="Add channel" className="min-w-0 flex-1 rounded-lg border border-stone-200 px-3 py-2 text-sm" /> + +
+
{channels.map(channel => )}
+
+
+ {posts.map(post =>
+

{post.author?.name || 'Member'}

{post.status || 'active'}
+

{post.body}

+
+ + +
+
)} + {channelID && posts.length === 0 &&
No posts in this channel.
} +
+
+
+
+ ) +} diff --git a/Frontend/src/app/pages/AdminNotificationsPage.tsx b/Frontend/src/app/pages/AdminNotificationsPage.tsx new file mode 100644 index 0000000..10b75e0 --- /dev/null +++ b/Frontend/src/app/pages/AdminNotificationsPage.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react' +import { Bell, Mail, Plus } from 'lucide-react' +import { createNotificationDelivery, createNotificationTemplate, fetchNotificationDeliveries, fetchNotificationTemplates, patchNotificationTemplate, type NotificationDelivery, type NotificationTemplate } from '../admin-notifications' + +export function AdminNotificationsPage() { + const [templates, setTemplates] = useState([]) + const [deliveries, setDeliveries] = useState([]) + const [tab, setTab] = useState<'templates' | 'deliveries'>('templates') + const [name, setName] = useState('') + const [subject, setSubject] = useState('') + const [body, setBody] = useState('') + const [testTo, setTestTo] = useState('') + const [error, setError] = useState('') + + const load = async () => { + const [t, d] = await Promise.all([fetchNotificationTemplates(), fetchNotificationDeliveries()]) + setTemplates(Array.isArray(t) ? t : []) + setDeliveries(Array.isArray(d) ? d : []) + } + useEffect(() => { void load().catch(err => setError(String(err))) }, []) + + return ( +
+

Notification operations

Manage transactional templates and inspect delivery outcomes from the existing notification queue.

+ {error &&

{error}

} +
+ {tab === 'templates' &&
+
{ event.preventDefault(); if (!name.trim()) return; void createNotificationTemplate({ name: name.trim(), channel: 'email', subject, body, enabled: true }).then(() => { setName(''); setSubject(''); setBody(''); return load() }) }}> +

New email template

+ setName(e.target.value)} placeholder="Template name" className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm" /> + setSubject(e.target.value)} placeholder="Subject" className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm" /> +