From 4b7d270f6261a7fdabb37a654be69c31c5df96a8 Mon Sep 17 00:00:00 2001 From: Trae AI Date: Thu, 25 Jun 2026 16:18:01 +0100 Subject: [PATCH] Implement Notification Templates Library --- dashboard/src/App.tsx | 40 +-- dashboard/src/index.css | 228 +++++++++++++++ dashboard/src/pages/TemplatesPage.tsx | 259 ++++++++++++++++++ dashboard/src/services/templatesApi.ts | 48 ++++ dashboard/src/types/notificationTemplate.ts | 30 ++ listener/src/api/events-server.ts | 52 ++++ .../notification-template-repository.ts | 17 ++ .../services/notification-template-service.ts | 8 + 8 files changed, 653 insertions(+), 29 deletions(-) create mode 100644 dashboard/src/pages/TemplatesPage.tsx create mode 100644 dashboard/src/services/templatesApi.ts create mode 100644 dashboard/src/types/notificationTemplate.ts diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 8731feb..7f0ced5 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,36 +1,9 @@ import { useState } from 'react'; import { EventExplorerPage } from './pages/EventExplorerPage'; -import { ExportHistoryPage } from './pages/ExportHistoryPage'; - -export function App() { - const [activeTab, setActiveTab] = useState<'explorer' | 'exports'>('explorer'); - - return ( -
- - - {activeTab === 'explorer' ? : } import { NotificationTimelineView } from './components/NotificationTimelineView'; +import { TemplatesPage } from './pages/TemplatesPage'; -type Tab = 'explorer' | 'timeline'; +type Tab = 'explorer' | 'timeline' | 'templates'; export function App() { const [tab, setTab] = useState('explorer'); @@ -54,10 +27,19 @@ export function App() { > Delivery Timeline + {tab === 'explorer' && } {tab === 'timeline' && } + {tab === 'templates' && }
); } diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 1ff6939..0774738 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -1517,3 +1517,231 @@ body { text-align: center; } } + +/* ─── Templates Page Styles ────────────────────────────────────────────────── */ +.templates-page { + max-width: 1100px; + margin: 0 auto; + padding: 24px; +} + +.templates-list__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.templates-list__header h3 { + margin: 0; + font-size: 1.5rem; +} + +.templates-list__header button { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 8px 16px; + background: rgba(255, 255, 255, 0.06); + color: inherit; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.templates-list__header button:hover { + border-color: rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.1); +} + +.templates-list__items { + display: grid; + gap: 16px; +} + +.templates-list__item { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 16px; + background: rgba(255, 255, 255, 0.02); + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + +.templates-list__item-info h4 { + margin: 0 0 8px; + font-size: 1.1rem; +} + +.templates-list__item-body { + color: #9aa0a6; + margin: 8px 0 0; +} + +.templates-list__item-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.templates-list__item-actions button { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.06); + color: inherit; + font-size: 0.85rem; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.templates-list__item-actions button:hover { + border-color: rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.1); +} + +.templates-list__empty { + text-align: center; + padding: 48px 24px; + border: 1px dashed rgba(255, 255, 255, 0.16); + border-radius: 16px; + background: rgba(255, 255, 255, 0.02); + color: #cbd5e1; +} + +.templates-form, +.templates-preview { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 24px; + background: rgba(255, 255, 255, 0.02); +} + +.templates-form__header, +.templates-preview__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.templates-form__header h3, +.templates-preview__header h3 { + margin: 0; + font-size: 1.5rem; +} + +.templates-form__header button, +.templates-preview__header button { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 8px 16px; + background: rgba(255, 255, 255, 0.06); + color: inherit; + font-size: 0.9rem; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.templates-form__fields { + display: grid; + gap: 16px; +} + +.templates-form__field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.templates-form__field label { + font-size: 0.85rem; + color: #9aa0a6; +} + +.templates-form__field input, +.templates-form__field select, +.templates-form__field textarea { + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.05); + color: inherit; + font-size: 0.95rem; +} + +.templates-form__field textarea { + min-height: 120px; + resize: vertical; +} + +.templates-form button[type="button"]:not(.templates-form__header button) { + margin-top: 16px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 10px 20px; + background: #1a73e8; + color: #fff; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease; +} + +.templates-form button[type="button"]:not(.templates-form__header button):hover:not(:disabled) { + background: #4285f4; +} + +.templates-form button[type="button"]:not(.templates-form__header button):disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.templates-preview__variables { + margin-bottom: 24px; + padding: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); +} + +.templates-preview__variable { + display: grid; + grid-template-columns: 120px 1fr; + gap: 12px; + align-items: center; + margin-top: 8px; +} + +.templates-preview__variable label { + font-size: 0.85rem; + color: #9aa0a6; +} + +.templates-preview__variable input { + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.05); + color: inherit; + font-size: 0.95rem; +} + +.templates-preview__content { + padding: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); +} + +.templates-preview__subject, +.templates-preview__body { + margin-top: 12px; +} + +.templates-preview__body pre { + margin: 8px 0 0; + white-space: pre-wrap; +} diff --git a/dashboard/src/pages/TemplatesPage.tsx b/dashboard/src/pages/TemplatesPage.tsx new file mode 100644 index 0000000..20e932d --- /dev/null +++ b/dashboard/src/pages/TemplatesPage.tsx @@ -0,0 +1,259 @@ +import { useState, useEffect } from 'react'; +import { + NotificationTemplate, + CreateNotificationTemplateInput, + UpdateNotificationTemplateInput +} from '../types/notificationTemplate'; +import { templatesApi } from '../services/templatesApi'; + +type ViewMode = 'list' | 'create' | 'edit' | 'preview'; + +export function TemplatesPage() { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [viewMode, setViewMode] = useState('list'); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [formData, setFormData] = useState>({ + type: 'email', + variables: [] + }); + const [previewVariables, setPreviewVariables] = useState>({}); + + useEffect(() => { + loadTemplates(); + }, []); + + async function loadTemplates() { + try { + setLoading(true); + const data = await templatesApi.getAll(); + setTemplates(data); + } catch (error) { + console.error('Failed to load templates:', error); + } finally { + setLoading(false); + } + } + + function handleCreateClick() { + setFormData({ type: 'email', variables: [] }); + setViewMode('create'); + } + + function handleEditClick(template: NotificationTemplate) { + setSelectedTemplate(template); + setFormData({ ...template }); + setViewMode('edit'); + } + + function handlePreviewClick(template: NotificationTemplate) { + setSelectedTemplate(template); + setPreviewVariables({}); + setViewMode('preview'); + } + + async function handleSave() { + try { + if (viewMode === 'create' && formData.id && formData.name && formData.body) { + await templatesApi.create(formData as CreateNotificationTemplateInput); + } else if (viewMode === 'edit' && selectedTemplate) { + await templatesApi.update(selectedTemplate.id, formData as UpdateNotificationTemplateInput); + } + await loadTemplates(); + setViewMode('list'); + setSelectedTemplate(null); + } catch (error) { + console.error('Failed to save template:', error); + } + } + + async function handleDelete(id: string) { + if (!confirm('Are you sure you want to delete this template?')) return; + try { + await templatesApi.delete(id); + await loadTemplates(); + } catch (error) { + console.error('Failed to delete template:', error); + } + } + + function renderPreview() { + if (!selectedTemplate) return null; + const renderedSubject = selectedTemplate.subject + ? selectedTemplate.subject.replace(/\{\{(\w+)\}\}/g, (_, key) => previewVariables[key] || `{{${key}}}`) + : ''; + const renderedBody = selectedTemplate.body.replace(/\{\{(\w+)\}\}/g, (_, key) => previewVariables[key] || `{{${key}}}`); + + return ( +
+
+

Preview: {selectedTemplate.name}

+ +
+
+

Variables

+ {selectedTemplate.variables?.map((varName) => ( +
+ + setPreviewVariables({ ...previewVariables, [varName]: e.target.value })} + /> +
+ ))} +
+
+

Rendered Template

+ {renderedSubject && ( +
+ Subject: {renderedSubject} +
+ )} +
+ Body: +
{renderedBody}
+
+
+
+ ); + } + + function renderForm() { + return ( +
+
+

{viewMode === 'create' ? 'Create Template' : 'Edit Template'}

+ +
+
+
+ + setFormData({ ...formData, id: e.target.value })} + disabled={viewMode === 'edit'} + /> +
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+
+ + +
+
+ + setFormData({ ...formData, subject: e.target.value })} + /> +
+
+ +