diff --git a/.cursor/rules/design-context.mdc b/.cursor/rules/design-context.mdc
new file mode 100644
index 000000000..777a681d5
--- /dev/null
+++ b/.cursor/rules/design-context.mdc
@@ -0,0 +1,45 @@
+---
+description: Project design context for Conduit-UI. Guides Quintessential rule application to this existing admin panel codebase.
+globs: "**/*.{tsx,jsx,ts,js,css}"
+alwaysApply: true
+---
+
+# Design Context -- Conduit-UI
+
+This is an existing Next.js 16 admin panel. Apply Quintessential standards with awareness of what's already built.
+
+## Current Design Language (preserve)
+- Font: Inter via next/font/google -- keep
+- Color system: 26 semantic CSS variables in @theme with :root/:dark HSL channels -- extend, don't replace
+- Component library: 56 shadcn/ui + Radix primitives in src/components/ui/ -- extend, don't replace
+- Class merging: cn() (clsx + twMerge) -- use consistently
+- Variants: cva for multi-variant components -- follow this pattern for new components
+- Spacing: Tailwind v4 default 4px-based scale -- stay on-scale
+- Radius: Tokenized via --radius (0.5rem) with derived md/sm -- use radius tokens, not arbitrary values
+- Theme: next-themes with class="dark" toggle -- respect both modes
+- Overlays: Radix Dialog/Sheet + Vaul Drawer -- use for new overlays
+
+## Migration Targets (improve incrementally)
+- Typography: Add antialiased to body, text-wrap: balance on headings, tabular-nums on data columns
+- Colors: Extract hardcoded chart/viz hex values to --color-chart-* tokens
+- Colors: Define --popover and --card in :root (aligned with light theme)
+- Shadows: Use --shadow-1 through --shadow-4 layered scale; prefer shadow tokens over arbitrary shadow-[...] values
+- Animation: prefers-reduced-motion support in globals
+- Animation: Standardize on one primary easing curve where practical
+- Components: Prefer shared DataTable in src/components/ui/data-table.tsx for TanStack table UIs
+- Components: Align Textarea styling with Input (text-sm, ring-2)
+- Interaction: Add keyboard shortcut hints to tooltips where relevant
+- Interaction: Guard hover-only affordances with @media (hover: hover) where appropriate
+
+## Rules of Engagement
+- Apply Quintessential standards to ALL new code
+- When modifying existing files, improve what you touch
+- Do NOT refactor untouched code unprompted
+- Do NOT rewrite working components for style alone
+- When you spot a major violation in code you're editing, fix it silently
+- When you spot a major violation in code you're NOT editing, leave a brief comment noting the opportunity
+- Always use cn() for class merging
+- Always use cva for components with more than 2 variant axes
+- Always add both light and dark mode styles
+- Always use CSS variable colors for theme-bound UI; use chart tokens for charts/viz
+- Always respect the spacing scale -- no arbitrary pixel values
diff --git a/.gitignore b/.gitignore
index d72e2ac7d..6d265d339 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,6 @@
# Logs
node_modules
-logs
+/logs
*.log
npm-debug.log*
.pnpm-debug.log*
diff --git a/src/app/(dashboard)/(modules)/communications/logs/page.tsx b/src/app/(dashboard)/(modules)/communications/logs/page.tsx
new file mode 100644
index 000000000..ac7fc6546
--- /dev/null
+++ b/src/app/(dashboard)/(modules)/communications/logs/page.tsx
@@ -0,0 +1,83 @@
+import { getTokens } from '@/lib/api/notifications';
+import { CommunicationsLogsTabs } from '@/components/communications/logs/communications-logs-tabs';
+import {
+ PageDescription,
+ PageHeader,
+ PageTitle,
+} from '@/components/ui/page-header';
+
+type CommunicationsLogsParams = {
+ searchParams: Promise<{
+ tab?: string;
+ messageId?: string;
+ templateId?: string;
+ receiver?: string;
+ sender?: string;
+ cc?: string;
+ replyTo?: string;
+ startDate?: string;
+ endDate?: string;
+ skip?: string;
+ sort?: string;
+ limit?: string;
+ search?: string;
+ platform?: string;
+ }>;
+};
+
+function parseTab(tab: string | undefined): 'email' | 'push' {
+ return tab === 'push' ? 'push' : 'email';
+}
+
+export default async function CommunicationsLogsPage(
+ props: Readonly
+) {
+ const searchParams = await props.searchParams;
+ const tab = parseTab(searchParams.tab);
+
+ const pushTokensData =
+ tab === 'push'
+ ? await getTokens(
+ Number.parseInt(searchParams.skip ?? '0', 10) || 0,
+ Number.parseInt(searchParams.limit ?? '20', 10) || 20,
+ {
+ sort: searchParams.sort,
+ search: searchParams.search,
+ platform: searchParams.platform,
+ }
+ )
+ : undefined;
+
+ const refreshPushTokens = async (search: string) => {
+ 'use server';
+ const { tokens } = await getTokens(
+ Number.parseInt(searchParams.skip ?? '0', 10) || 0,
+ Number.parseInt(searchParams.limit ?? '20', 10) || 20,
+ {
+ sort: searchParams.sort,
+ search,
+ platform: searchParams.platform,
+ }
+ );
+ return tokens;
+ };
+
+ return (
+
+
+
+
Logs & Devices
+
+ Email delivery history and registered push device tokens.
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/(modules)/communications/page.tsx b/src/app/(dashboard)/(modules)/communications/page.tsx
new file mode 100644
index 000000000..45d208cff
--- /dev/null
+++ b/src/app/(dashboard)/(modules)/communications/page.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { FileText, History, MessagesSquare, Settings } from 'lucide-react';
+import { ModuleDashboard } from '@/components/dashboard/ModuleDashboard';
+import {
+ getEmailMetrics,
+ getModuleStatus,
+ getModuleUptime,
+ getPushNotificationMetrics,
+ getSystemMetrics,
+} from '@/lib/prometheus/metrics';
+import {
+ COMMUNICATIONS_SHARED_RUNTIME,
+ getApiModuleNameFromPath,
+} from '@/lib/utils/module-utils';
+import { ModuleStatus } from '@/components/dashboard/ModuleStatusCard';
+import { QuickAction } from '@/components/dashboard/QuickActionsCard';
+import { MetricCardProps } from '@/components/dashboard/MetricCard';
+import { getPrometheusAvailability } from '@/lib/observability/prometheusAvailability';
+
+export default async function CommunicationsDashboard() {
+ const promAvailability = await getPrometheusAvailability();
+ const [emailMetrics, pushMetrics] = await Promise.all([
+ getEmailMetrics(),
+ getPushNotificationMetrics(),
+ ]);
+ const metrics = [...emailMetrics, ...pushMetrics];
+
+ const metricCards: MetricCardProps[] = metrics.map(metric => ({
+ title: metric.name,
+ value: metric.value,
+ description: metric.description,
+ status: metric.status,
+ }));
+
+ const apiModuleName =
+ getApiModuleNameFromPath('/communications') || 'communications';
+
+ const systemMetrics = await getSystemMetrics(apiModuleName);
+ const uptime = await getModuleUptime(apiModuleName);
+ const status = await getModuleStatus(apiModuleName);
+
+ const moduleStatus: ModuleStatus = {
+ name: 'Communications',
+ status: status,
+ uptime: uptime,
+ version: '1.0.0',
+ instances: 1,
+ description: 'Email, SMS, and push notifications',
+ };
+
+ const quickActions: QuickAction[] = [
+ {
+ title: 'Templates',
+ description: 'Manage email, SMS, and push templates',
+ icon: ,
+ href: '/communications/templates',
+ },
+ {
+ title: 'View logs',
+ description: 'Email logs and push device tokens',
+ icon: ,
+ href: '/communications/logs',
+ },
+ {
+ title: 'Channel settings',
+ description: 'Configure email, SMS, and push providers',
+ icon: ,
+ href: '/communications/settings',
+ },
+ ];
+
+ return (
+ }
+ moduleStatus={moduleStatus}
+ metrics={metricCards}
+ systemMetrics={systemMetrics}
+ quickActions={quickActions}
+ prometheusState={promAvailability.state}
+ sharedRuntime={COMMUNICATIONS_SHARED_RUNTIME}
+ />
+ );
+}
diff --git a/src/app/(dashboard)/(modules)/communications/settings/page.tsx b/src/app/(dashboard)/(modules)/communications/settings/page.tsx
new file mode 100644
index 000000000..bc81deb64
--- /dev/null
+++ b/src/app/(dashboard)/(modules)/communications/settings/page.tsx
@@ -0,0 +1,57 @@
+import { getEmailSettings } from '@/lib/api/email';
+import { getSmsSettings } from '@/lib/api/sms';
+import { getNotificationSettings } from '@/lib/api/notifications';
+import { CommunicationsSettingsTabs } from '@/components/communications/settings/communications-settings-tabs';
+import {
+ PageDescription,
+ PageHeader,
+ PageTitle,
+} from '@/components/ui/page-header';
+
+type CommunicationsSettingsParams = {
+ searchParams: Promise<{
+ tab?: string;
+ }>;
+};
+
+function parseTab(tab: string | undefined): 'email' | 'sms' | 'push' {
+ if (tab === 'sms' || tab === 'push') return tab;
+ return 'email';
+}
+
+export default async function CommunicationsSettingsPage(
+ props: Readonly
+) {
+ const searchParams = await props.searchParams;
+ const tab = parseTab(searchParams.tab);
+
+ const [
+ { config: emailSettings },
+ { config: smsSettings },
+ { config: pushSettings },
+ ] = await Promise.all([
+ getEmailSettings(),
+ getSmsSettings(),
+ getNotificationSettings(),
+ ]);
+
+ return (
+
+
+
+
Settings
+
+ Configure email, SMS, and push providers.
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/(modules)/communications/templates/[_id]/page.tsx b/src/app/(dashboard)/(modules)/communications/templates/[_id]/page.tsx
new file mode 100644
index 000000000..998b5dc68
--- /dev/null
+++ b/src/app/(dashboard)/(modules)/communications/templates/[_id]/page.tsx
@@ -0,0 +1,257 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { CommunicationTemplate } from '@/lib/models/communications/templates';
+import {
+ getCommunicationTemplate,
+ updateCommunicationTemplate,
+} from '@/lib/api/communications/templates';
+import { formatCommunicationsApiError } from '@/lib/logic/communications-api-error';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { useToast } from '@/lib/hooks/use-toast';
+import {
+ PageHeader,
+ PageTitle,
+ PageActions,
+} from '@/components/ui/page-header';
+import {
+ CommunicationTemplateForm,
+ CommunicationTemplateFormValues,
+} from '@/components/communications/templates/template-form';
+import { EmailTemplatePreview } from '@/components/communications/templates/email-template-preview';
+import { EmailBodyEditor } from '@/components/communications/templates/email-body-editor';
+import { HtmlViewer } from '@/components/email/templates/HtmlViewer';
+import { Code, Edit } from 'lucide-react';
+
+type CommunicationTemplatePageProps = {
+ params: Promise<{ _id: string }>;
+};
+
+export default function CommunicationTemplateDetailPage(
+ props: CommunicationTemplatePageProps
+) {
+ const [template, setTemplate] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [editingMetadata, setEditingMetadata] = useState(false);
+ const [showEmailEditor, setShowEmailEditor] = useState(false);
+ const [showHtmlDialog, setShowHtmlDialog] = useState(false);
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { toast } = useToast();
+
+ const loadTemplate = async (id: string) => {
+ const data = await getCommunicationTemplate(id);
+ setTemplate(data);
+ };
+
+ useEffect(() => {
+ const init = async () => {
+ const { _id } = await props.params;
+ try {
+ await loadTemplate(_id);
+ } catch (error) {
+ console.error(error);
+ toast({
+ title: 'Communications',
+ description: formatCommunicationsApiError(error),
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+ init();
+ }, [props, toast]);
+
+ useEffect(() => {
+ if (
+ searchParams.get('editor-open') === 'true' &&
+ template?.channels.includes('email')
+ ) {
+ setShowEmailEditor(true);
+ }
+ }, [searchParams, template]);
+
+ const handleSaveMetadata = async (
+ values: CommunicationTemplateFormValues
+ ) => {
+ if (!template) return;
+ try {
+ const updated = await updateCommunicationTemplate(template._id, values);
+ setTemplate(updated);
+ setEditingMetadata(false);
+ toast({
+ title: 'Communications',
+ description: 'Template updated',
+ });
+ router.refresh();
+ } catch (err) {
+ toast({
+ title: 'Communications',
+ description: formatCommunicationsApiError(err),
+ });
+ }
+ };
+
+ const handleSaveEmailBody = async (html: string, variables: string[]) => {
+ if (!template) return;
+ const updated = await updateCommunicationTemplate(template._id, {
+ email: {
+ ...template.email,
+ body: html,
+ },
+ variables,
+ });
+ setTemplate(updated);
+ setShowEmailEditor(false);
+ router.refresh();
+ };
+
+ if (loading) {
+ return Loading template...
;
+ }
+
+ if (!template) {
+ return Template not found
;
+ }
+
+ if (showEmailEditor && template.channels.includes('email')) {
+ return (
+
'}
+ variables={template.variables}
+ onSave={handleSaveEmailBody}
+ onClose={() => setShowEmailEditor(false)}
+ />
+ );
+ }
+
+ const defaultValues: CommunicationTemplateFormValues = {
+ name: template.name,
+ templateDescription: template.summary,
+ channels: template.channels,
+ email: template.email,
+ push: template.push,
+ sms: template.sms,
+ };
+
+ const hasEmail = template.channels.includes('email');
+ const hasPush = template.channels.includes('push');
+ const hasSms = template.channels.includes('sms');
+
+ return (
+
+
+ {template.name}
+
+ {hasEmail && (
+ <>
+ setShowHtmlDialog(true)}
+ className="flex items-center gap-2"
+ >
+
+ View HTML
+
+ setShowEmailEditor(true)}
+ className="flex items-center gap-2"
+ >
+
+ Edit email
+
+ >
+ )}
+ setEditingMetadata(current => !current)}
+ >
+ {editingMetadata ? 'Cancel edit' : 'Edit channels & content'}
+
+
+
+
+ {!editingMetadata && (
+
+ {template.summary && (
+
{template.summary}
+ )}
+
+
+ {template.channels.map(channel => (
+
+ {channel}
+
+ ))}
+
+
+ {hasEmail && (
+
+ )}
+
+ {hasPush && (
+
+
+ Push notification
+
+
+
+ Title:
+ {template.push?.title || '—'}
+
+
+ Body:
+ {template.push?.body || '—'}
+
+
+
+ )}
+
+ {hasSms && (
+
+
+ SMS
+
+
+ {template.sms?.message || '—'}
+
+
+ )}
+
+ )}
+
+ {editingMetadata && (
+
+ )}
+
+ {hasEmail && (
+
setShowHtmlDialog(false)}
+ />
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/(modules)/communications/templates/email/[_id]/page.tsx b/src/app/(dashboard)/(modules)/communications/templates/email/[_id]/page.tsx
new file mode 100644
index 000000000..c45232990
--- /dev/null
+++ b/src/app/(dashboard)/(modules)/communications/templates/email/[_id]/page.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import { EmailTemplateDetail } from '@/components/communications/templates/email-template-detail';
+import { use, useMemo } from 'react';
+
+type EmailTemplatePageProps = {
+ params: Promise<{
+ _id: string;
+ }>;
+ searchParams: Promise<{
+ 'editor-open'?: string;
+ }>;
+};
+
+export default function CommunicationEmailTemplatePage(
+ props: EmailTemplatePageProps
+) {
+ const { _id } = use(props.params);
+ const searchParams = use(props.searchParams);
+ const editorOpen = useMemo(
+ () => searchParams['editor-open'] === 'true',
+ [searchParams]
+ );
+
+ return ;
+}
diff --git a/src/app/(dashboard)/(modules)/communications/templates/page.tsx b/src/app/(dashboard)/(modules)/communications/templates/page.tsx
new file mode 100644
index 000000000..d14d148c2
--- /dev/null
+++ b/src/app/(dashboard)/(modules)/communications/templates/page.tsx
@@ -0,0 +1,65 @@
+import { getCommunicationTemplates } from '@/lib/api/communications/templates';
+import { getExternalTemplates, getTemplates } from '@/lib/api/email';
+import { CommunicationTemplatesDashboard } from '@/components/communications/templates/dashboard';
+import {
+ PageDescription,
+ PageHeader,
+ PageTitle,
+} from '@/components/ui/page-header';
+
+const TEMPLATE_LIST_LIMIT = 500;
+
+type CommunicationTemplatesParams = {
+ searchParams: Promise<{
+ pageIndex?: number;
+ sort?: string;
+ search?: string;
+ email?: string;
+ legacy?: string;
+ }>;
+};
+
+export default async function CommunicationTemplatesPage(
+ props: Readonly
+) {
+ const searchParams = await props.searchParams;
+
+ const fetchArgs = {
+ skip: searchParams.pageIndex ? searchParams.pageIndex * 10 : 0,
+ limit: TEMPLATE_LIST_LIMIT,
+ sort: searchParams.sort,
+ search: searchParams.search,
+ };
+
+ const [communicationTemplates, emailTemplates, externalTemplates] =
+ await Promise.all([
+ getCommunicationTemplates(fetchArgs),
+ getTemplates(fetchArgs),
+ getExternalTemplates({
+ limit: TEMPLATE_LIST_LIMIT,
+ sortByName: true,
+ }).catch(() => null),
+ ]);
+
+ const emailTemplateId = searchParams.email ?? searchParams.legacy;
+
+ return (
+
+
+
+
Templates
+
+ Manage multi-channel message templates across email, SMS, and push.
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/(modules)/email/page.tsx b/src/app/(dashboard)/(modules)/email/page.tsx
index dd7f74a2a..a717b81ef 100644
--- a/src/app/(dashboard)/(modules)/email/page.tsx
+++ b/src/app/(dashboard)/(modules)/email/page.tsx
@@ -1,99 +1,5 @@
-import React from 'react';
-import { FileText, History, Mail, Plus, Send, Settings } from 'lucide-react';
-import { ModuleDashboard } from '@/components/dashboard/ModuleDashboard';
-import {
- getEmailMetrics,
- getModuleStatus,
- getModuleUptime,
- getSystemMetrics,
-} from '@/lib/prometheus/metrics';
-import {
- COMMUNICATIONS_SHARED_RUNTIME,
- getApiModuleNameFromPath,
-} from '@/lib/utils/module-utils';
-import { ModuleStatus } from '@/components/dashboard/ModuleStatusCard';
-import { QuickAction } from '@/components/dashboard/QuickActionsCard';
-import { MetricCardProps } from '@/components/dashboard/MetricCard';
-import { getPrometheusAvailability } from '@/lib/observability/prometheusAvailability';
+import { redirect } from 'next/navigation';
-export default async function EmailDashboard() {
- const promAvailability = await getPrometheusAvailability();
- // Fetch metrics
- const metrics = await getEmailMetrics();
-
- // Convert to MetricCardProps format
- const metricCards: MetricCardProps[] = metrics.map(metric => ({
- title: metric.name,
- value: metric.value,
- description: metric.description,
- status: metric.status,
- }));
-
- const apiModuleName = getApiModuleNameFromPath('/email') || 'email';
-
- // Get system metrics scoped to the shared Communications runtime
- const systemMetrics = await getSystemMetrics(apiModuleName);
- const uptime = await getModuleUptime(apiModuleName);
-
- // Get real module status - use correct API module name
- const status = await getModuleStatus(apiModuleName);
-
- // Module status
- const moduleStatus: ModuleStatus = {
- name: 'Email',
- status: status,
- uptime: uptime,
- version: '1.0.0',
- instances: 1,
- description: 'Email sending and template management',
- };
-
- // Quick actions
- const quickActions: QuickAction[] = [
- {
- title: 'Send Email',
- description: 'Send a new email message',
- icon: ,
- href: '/email/send',
- },
- {
- title: 'Create Template',
- description: 'Create a new email template',
- icon: ,
- href: '/email/templates',
- },
- {
- title: 'Templates',
- description: 'Manage email templates',
- icon: ,
- href: '/email/templates',
- },
- {
- title: 'Records',
- description: 'View email sending history',
- icon: ,
- href: '/email/records',
- },
- {
- title: 'Settings',
- description: 'Email module configuration',
- icon: ,
- href: '/email/settings',
- },
- ];
-
- return (
-
- }
- moduleStatus={moduleStatus}
- metrics={metricCards}
- systemMetrics={systemMetrics}
- quickActions={quickActions}
- prometheusState={promAvailability.state}
- sharedRuntime={COMMUNICATIONS_SHARED_RUNTIME}
- />
-
- );
+export default async function Page() {
+ redirect('/communications');
}
diff --git a/src/app/(dashboard)/(modules)/email/records/page.tsx b/src/app/(dashboard)/(modules)/email/records/page.tsx
index e5a0fee24..6e3e1dabe 100644
--- a/src/app/(dashboard)/(modules)/email/records/page.tsx
+++ b/src/app/(dashboard)/(modules)/email/records/page.tsx
@@ -1,151 +1,5 @@
-'use client';
+import { redirect } from 'next/navigation';
-import { useEffect, useState } from 'react';
-import { useRouter, useSearchParams } from 'next/navigation';
-import { EmailRecord } from '@/lib/models/email';
-import { EmailFilters } from '@/components/email/records/email-filters';
-import { EmailDetail } from '@/components/email/records/email-detail';
-import { EmailList } from '@/components/email/records/email-list';
-import { fetchRecords } from '@/lib/api/email';
-import { PageHeader, PageTitle } from '@/components/ui/page-header';
-
-export default function EmailPage() {
- const searchParams = useSearchParams();
- const router = useRouter();
- const [emails, setEmails] = useState([]);
- const [loading, setLoading] = useState(true);
- const [totalCount, setTotalCount] = useState(0);
- const [selectedEmail, setSelectedEmail] = useState(null);
- const [isDetailOpen, setIsDetailOpen] = useState(false);
-
- // Parse query params
- const messageId = searchParams.get('messageId') || undefined;
- const templateId = searchParams.get('templateId') || undefined;
- const receiver = searchParams.get('receiver') || undefined;
- const sender = searchParams.get('sender') || undefined;
- const cc = searchParams.get('cc')
- ? searchParams.get('cc')?.split(',')
- : undefined;
- const replyTo = searchParams.get('replyTo') || undefined;
- const startDate = searchParams.get('startDate') || undefined;
- const endDate = searchParams.get('endDate') || undefined;
- const skip = Number.parseInt(searchParams.get('skip') || '0');
- const limit = 10; // Fixed page size
- const sort = searchParams.get('sort') || '-createdAt'; // Default sort by newest
-
- // Fetch emails when query params change
- useEffect(() => {
- const getEmails = async () => {
- setLoading(true);
- try {
- const { records, count } = await fetchRecords({
- messageId,
- templateId,
- receiver,
- sender,
- cc,
- replyTo,
- startDate,
- endDate,
- skip,
- limit,
- sort,
- });
- setEmails(records);
- setTotalCount(count);
- } catch (error) {
- console.error('Failed to fetch emails:', error);
- } finally {
- setLoading(false);
- }
- };
-
- getEmails();
- }, [
- messageId,
- templateId,
- receiver,
- sender,
- cc,
- replyTo,
- startDate,
- endDate,
- skip,
- limit,
- sort,
- ]);
-
- // Update query params
- const updateQueryParams = (params: Record) => {
- const newParams = new URLSearchParams(searchParams.toString());
-
- // Update or remove params
- Object.entries(params).forEach(([key, value]) => {
- if (value === undefined || value === '') {
- newParams.delete(key);
- } else {
- newParams.set(key, value);
- }
- });
-
- // Reset skip when filters change
- if (!Object.keys(params).includes('skip')) {
- newParams.set('skip', '0');
- }
-
- router.push(`/email/records?${newParams.toString()}`);
- };
-
- const handleOpenDetail = (email: EmailRecord) => {
- setSelectedEmail(email);
- setIsDetailOpen(true);
- };
-
- const handleCloseDetail = () => {
- setIsDetailOpen(false);
- };
-
- return (
-
-
- Email Management
-
-
-
-
-
- updateQueryParams({ skip: ((page - 1) * limit).toString() })
- }
- onSortChange={sortField => updateQueryParams({ sort: sortField })}
- currentSort={sort}
- onViewEmail={handleOpenDetail}
- />
-
- {selectedEmail && (
-
- )}
-
- );
+export default async function Page() {
+ redirect('/communications/logs?tab=email');
}
diff --git a/src/app/(dashboard)/(modules)/email/settings/page.tsx b/src/app/(dashboard)/(modules)/email/settings/page.tsx
index 1deefb60b..03a8fce8a 100644
--- a/src/app/(dashboard)/(modules)/email/settings/page.tsx
+++ b/src/app/(dashboard)/(modules)/email/settings/page.tsx
@@ -1,7 +1,5 @@
-import { Settings } from '@/components/email/settings/settings';
-import { getEmailSettings } from '@/lib/api/email';
+import { redirect } from 'next/navigation';
-export default async function EmailSettings() {
- const { config: data } = await getEmailSettings();
- return ;
+export default async function Page() {
+ redirect('/communications/settings?tab=email');
}
diff --git a/src/app/(dashboard)/(modules)/email/templates/[_id]/page.tsx b/src/app/(dashboard)/(modules)/email/templates/[_id]/page.tsx
index c3b227048..d985a5532 100644
--- a/src/app/(dashboard)/(modules)/email/templates/[_id]/page.tsx
+++ b/src/app/(dashboard)/(modules)/email/templates/[_id]/page.tsx
@@ -1,174 +1,20 @@
-'use client';
+import { redirect } from 'next/navigation';
-import { getTemplates } from '@/lib/api/email';
-import React, { useEffect, useState } from 'react';
-import { EmailTemplate } from '@/lib/models/email';
-import { Button } from '@/components/ui/button';
-import { Code, Edit, ExternalLink } from 'lucide-react';
-import { TemplatePreview } from '@/components/email/templates/templatePreview';
-import { TemplateEditor } from '@/components/email/templates/TemplateEditor';
-import { HtmlViewer } from '@/components/email/templates/HtmlViewer';
-import { isExternallyManaged } from '@/lib/utils/template-utils';
-import { useToast } from '@/lib/hooks/use-toast';
-import {
- PageHeader,
- PageTitle,
- PageActions,
-} from '@/components/ui/page-header';
-
-type EmailTemplateProps = {
+type EmailTemplateRedirectProps = {
params: Promise<{
_id: string;
}>;
searchParams: Promise<{
- 'editor-open': string;
+ 'editor-open'?: string;
}>;
};
-export default function EmailTemplatePage(props: EmailTemplateProps) {
- const [template, setTemplate] = useState(null);
- const [loading, setLoading] = useState(true);
- const [showEditor, setShowEditor] = useState(false);
- const [showHtmlDialog, setShowHtmlDialog] = useState(false);
- const [params, setParams] = useState<{ _id: string } | null>(null);
- const { toast } = useToast();
-
- useEffect(() => {
- const initializePage = async () => {
- const resolvedParams = await props.params;
- const searchParams = await props.searchParams;
-
- setParams(resolvedParams);
- setShowEditor(searchParams['editor-open'] === 'true');
-
- try {
- const templateData = await getTemplates({ search: resolvedParams._id });
- if (templateData.templateDocuments.length > 0) {
- setTemplate(templateData.templateDocuments[0]);
- }
- } catch (error) {
- console.error('Failed to load template:', error);
- } finally {
- setLoading(false);
- }
- };
-
- initializePage();
- }, [props]);
-
- const handleEdit = () => {
- if (isExternallyManaged(template!)) {
- toast({
- title: 'External Template',
- description:
- 'This template is managed by an external system and cannot be edited here.',
- });
- return;
- }
- setShowEditor(true);
- };
-
- const refreshTemplate = async () => {
- if (!params?._id) return;
-
- try {
- const templateData = await getTemplates({ search: params._id });
- if (templateData.templateDocuments.length > 0) {
- setTemplate(templateData.templateDocuments[0]);
- }
- } catch (error) {
- console.error('Failed to refresh template:', error);
- }
- };
-
- const handleViewHtml = () => {
- setShowHtmlDialog(true);
- };
-
- if (loading) {
- return (
-
-
-
-
Loading template...
-
-
- );
- }
-
- if (!template) {
- return (
-
- );
- }
-
- if (showEditor) {
- if (isExternallyManaged(template)) {
- return (
-
-
-
-
External Template
-
- This template is managed by an external system and cannot be
- edited here.
-
-
setShowEditor(false)}>
- Back to Template
-
-
-
- );
- }
-
- return (
- setShowEditor(false)}
- onTemplateUpdate={refreshTemplate}
- />
- );
- }
-
- return (
-
-
- Template: {template.name}
-
- {!isExternallyManaged(template) && (
-
-
- View HTML
-
- )}
-
-
- Edit Template
-
-
-
-
-
-
-
setShowHtmlDialog(false)}
- />
-
- );
+export default async function EmailTemplateRedirectPage(
+ props: EmailTemplateRedirectProps
+) {
+ const { _id } = await props.params;
+ const searchParams = await props.searchParams;
+ const query =
+ searchParams['editor-open'] === 'true' ? '?editor-open=true' : '';
+ redirect(`/communications/templates/email/${_id}${query}`);
}
diff --git a/src/app/(dashboard)/(modules)/email/templates/page.tsx b/src/app/(dashboard)/(modules)/email/templates/page.tsx
index c1c8cb0de..1f8b5e2d4 100644
--- a/src/app/(dashboard)/(modules)/email/templates/page.tsx
+++ b/src/app/(dashboard)/(modules)/email/templates/page.tsx
@@ -1,37 +1,5 @@
-import { getExternalTemplates, getTemplates } from '@/lib/api/email';
-import { TemplatesDashboard } from '@/components/email/templates/dashboard';
+import { redirect } from 'next/navigation';
-type EmailTemplatesParams = {
- searchParams: Promise<{
- pageIndex?: number;
- sort?: string;
- search?: string;
- externalPageIndex?: number;
- sortByName?: boolean;
- }>;
-};
-
-export default async function EmailTemplates(
- props: Readonly
-) {
- const searchParams = await props.searchParams;
-
- const templates = await getTemplates({
- skip: searchParams.pageIndex ? searchParams.pageIndex * 10 : 0,
- limit: 10,
- sort: searchParams.sort,
- search: searchParams.search,
- });
-
- const external = await getExternalTemplates({
- skip: searchParams.externalPageIndex
- ? searchParams.externalPageIndex * 10
- : 0,
- limit: 10,
- sortByName: searchParams.sortByName,
- })
- .then(res => res)
- .catch(() => null);
-
- return ;
+export default async function Page() {
+ redirect('/communications/templates');
}
diff --git a/src/app/(dashboard)/(modules)/push-notifications/page.tsx b/src/app/(dashboard)/(modules)/push-notifications/page.tsx
index 4e388496a..7b0afb0fa 100644
--- a/src/app/(dashboard)/(modules)/push-notifications/page.tsx
+++ b/src/app/(dashboard)/(modules)/push-notifications/page.tsx
@@ -1,93 +1,5 @@
-import React from 'react';
-import { Bell, Send, Settings, Users } from 'lucide-react';
-import { ModuleDashboard } from '@/components/dashboard/ModuleDashboard';
-import {
- getModuleStatus,
- getModuleUptime,
- getPushNotificationMetrics,
- getSystemMetrics,
-} from '@/lib/prometheus/metrics';
-import {
- COMMUNICATIONS_SHARED_RUNTIME,
- getApiModuleNameFromPath,
-} from '@/lib/utils/module-utils';
-import { ModuleStatus } from '@/components/dashboard/ModuleStatusCard';
-import { QuickAction } from '@/components/dashboard/QuickActionsCard';
-import { MetricCardProps } from '@/components/dashboard/MetricCard';
-import { getPrometheusAvailability } from '@/lib/observability/prometheusAvailability';
+import { redirect } from 'next/navigation';
-export default async function PushNotificationsDashboard() {
- const promAvailability = await getPrometheusAvailability();
- // Fetch metrics
- const metrics = await getPushNotificationMetrics();
-
- // Convert to MetricCardProps format
- const metricCards: MetricCardProps[] = metrics.map(metric => ({
- title: metric.name,
- value: metric.value,
- description: metric.description,
- status: metric.status,
- }));
-
- const apiModuleName =
- getApiModuleNameFromPath('/push-notifications') || 'pushNotifications';
-
- const systemMetrics = await getSystemMetrics(apiModuleName);
- const uptime = await getModuleUptime(apiModuleName);
-
- // Get real module status - use camelCase module name for API calls
- const status = await getModuleStatus(apiModuleName);
-
- // Module status
- const moduleStatus: ModuleStatus = {
- name: 'Push Notifications',
- status: status,
- uptime: uptime,
- version: '1.0.0',
- instances: 1,
- description: 'Push notification delivery and management',
- };
-
- // Quick actions
- const quickActions: QuickAction[] = [
- {
- title: 'Send Notification',
- description: 'Test send to users or devices',
- icon: ,
- href: '/push-notifications/test',
- },
- {
- title: 'Manage Tokens',
- description: 'View registered device tokens',
- icon: ,
- href: '/push-notifications/tokens',
- },
- {
- title: 'Platform users',
- description: 'Manage users for targeted notifications',
- icon: ,
- href: '/authentication/users',
- },
- {
- title: 'Settings',
- description: 'Push notifications configuration',
- icon: ,
- href: '/push-notifications/settings',
- },
- ];
-
- return (
-
- }
- moduleStatus={moduleStatus}
- metrics={metricCards}
- systemMetrics={systemMetrics}
- quickActions={quickActions}
- prometheusState={promAvailability.state}
- sharedRuntime={COMMUNICATIONS_SHARED_RUNTIME}
- />
-
- );
+export default async function Page() {
+ redirect('/communications/logs?tab=push');
}
diff --git a/src/app/(dashboard)/(modules)/push-notifications/settings/page.tsx b/src/app/(dashboard)/(modules)/push-notifications/settings/page.tsx
index 6134a413f..68567fc20 100644
--- a/src/app/(dashboard)/(modules)/push-notifications/settings/page.tsx
+++ b/src/app/(dashboard)/(modules)/push-notifications/settings/page.tsx
@@ -1,7 +1,5 @@
-import { Settings } from '@/components/notifications/settings';
-import { getNotificationSettings } from '@/lib/api/notifications';
+import { redirect } from 'next/navigation';
-export default async function NotificationSettings() {
- const { config: data } = await getNotificationSettings();
- return ;
+export default async function Page() {
+ redirect('/communications/settings?tab=push');
}
diff --git a/src/app/(dashboard)/(modules)/push-notifications/tokens/page.tsx b/src/app/(dashboard)/(modules)/push-notifications/tokens/page.tsx
index e677c5847..7b0afb0fa 100644
--- a/src/app/(dashboard)/(modules)/push-notifications/tokens/page.tsx
+++ b/src/app/(dashboard)/(modules)/push-notifications/tokens/page.tsx
@@ -1,36 +1,5 @@
-import { getTokens } from '@/lib/api/notifications';
-import NotificationTokensTable from '@/components/notifications/tokens/tokens';
+import { redirect } from 'next/navigation';
-export default async function TokensPage(props: {
- searchParams: Promise<{
- skip: number;
- limit: number;
- sort?: string;
- search?: string;
- platform?: string;
- }>;
-}) {
- const searchParams = await props.searchParams;
- const data = await getTokens(
- searchParams.skip ?? 0,
- searchParams.limit ?? 20,
- { ...searchParams }
- );
- const refreshTokens = async (search: string) => {
- 'use server';
- const { tokens } = await getTokens(
- searchParams.skip ?? 0,
- searchParams.limit ?? 20,
- { ...searchParams }
- );
- return tokens;
- };
-
- return (
-
- );
+export default async function Page() {
+ redirect('/communications/logs?tab=push');
}
diff --git a/src/app/(dashboard)/(modules)/sms/page.tsx b/src/app/(dashboard)/(modules)/sms/page.tsx
index 07bcd5280..8c43e97d4 100644
--- a/src/app/(dashboard)/(modules)/sms/page.tsx
+++ b/src/app/(dashboard)/(modules)/sms/page.tsx
@@ -1,87 +1,5 @@
-import React from 'react';
-import { MessageSquare, Send, Settings } from 'lucide-react';
-import { ModuleDashboard } from '@/components/dashboard/ModuleDashboard';
-import {
- getModuleMetrics,
- getModuleStatus,
- getModuleUptime,
- getSystemMetrics,
-} from '@/lib/prometheus/metrics';
-import {
- COMMUNICATIONS_SHARED_RUNTIME,
- getApiModuleNameFromPath,
-} from '@/lib/utils/module-utils';
-import { ModuleStatus } from '@/components/dashboard/ModuleStatusCard';
-import { QuickAction } from '@/components/dashboard/QuickActionsCard';
-import { MetricCardProps } from '@/components/dashboard/MetricCard';
-import { getPrometheusAvailability } from '@/lib/observability/prometheusAvailability';
+import { redirect } from 'next/navigation';
-export default async function SmsDashboard() {
- const promAvailability = await getPrometheusAvailability();
- // Fetch metrics
- const apiModuleName = getApiModuleNameFromPath('/sms') || 'sms';
- const metrics = await getModuleMetrics(apiModuleName);
-
- // Convert to MetricCardProps format
- const metricCards: MetricCardProps[] = metrics.map(metric => ({
- title: metric.name,
- value: metric.value,
- description: metric.description,
- status: metric.status,
- }));
-
- const systemMetrics = await getSystemMetrics(apiModuleName);
-
- // Get real uptime data - use correct API module name
- const uptime = await getModuleUptime(apiModuleName);
-
- // Get real module status - use correct API module name
- const status = await getModuleStatus(apiModuleName);
-
- // Module status
- const moduleStatus: ModuleStatus = {
- name: 'SMS',
- status: status,
- uptime: uptime,
- version: '1.0.0',
- instances: 1,
- description: 'SMS messaging and delivery management',
- };
-
- // Quick actions
- const quickActions: QuickAction[] = [
- {
- title: 'Send SMS',
- description: 'Send a test SMS message',
- icon: ,
- href: '/sms/send',
- },
- {
- title: 'SMS settings',
- description: 'Provider and module configuration',
- icon: ,
- href: '/sms/settings',
- },
- {
- title: 'Settings',
- description: 'SMS module configuration',
- icon: ,
- href: '/sms/settings',
- },
- ];
-
- return (
-
- }
- moduleStatus={moduleStatus}
- metrics={metricCards}
- systemMetrics={systemMetrics}
- quickActions={quickActions}
- prometheusState={promAvailability.state}
- sharedRuntime={COMMUNICATIONS_SHARED_RUNTIME}
- />
-
- );
+export default async function Page() {
+ redirect('/communications/settings?tab=sms');
}
diff --git a/src/app/(dashboard)/(modules)/sms/settings/page.tsx b/src/app/(dashboard)/(modules)/sms/settings/page.tsx
index c3d71aaff..8c43e97d4 100644
--- a/src/app/(dashboard)/(modules)/sms/settings/page.tsx
+++ b/src/app/(dashboard)/(modules)/sms/settings/page.tsx
@@ -1,7 +1,5 @@
-import { Settings } from '@/components/sms/settings';
-import { getSmsSettings } from '@/lib/api/sms';
+import { redirect } from 'next/navigation';
-export default async function SmsSettings() {
- const { config: data } = await getSmsSettings();
- return ;
+export default async function Page() {
+ redirect('/communications/settings?tab=sms');
}
diff --git a/src/app/(dashboard)/template.tsx b/src/app/(dashboard)/template.tsx
index 5c2b5a926..4950fed6b 100644
--- a/src/app/(dashboard)/template.tsx
+++ b/src/app/(dashboard)/template.tsx
@@ -43,9 +43,27 @@ const MODULE_NAMES: { [key: string]: string } = {
sms: 'SMS',
router: 'Router',
functions: 'Functions',
+ communications: 'Communications',
'push-notifications': 'Notifications',
payments: 'Payments',
};
+
+const SEGMENT_LABELS: Record = {
+ templates: 'Templates',
+ logs: 'Logs & Devices',
+ settings: 'Settings',
+ test: 'Test Send',
+};
+
+function formatBreadcrumbSegment(segment: string): string {
+ return (
+ SEGMENT_LABELS[segment] ??
+ segment
+ .split('-')
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
+ .join(' ')
+ );
+}
export default function ModuleHeader({
children,
}: {
@@ -146,6 +164,8 @@ export default function ModuleHeader({
];
const pathSegments = pathname.split('/').filter(Boolean);
+ const isCommunicationsTemplates =
+ pathSegments[0] === 'communications' && pathSegments[1] === 'templates';
return (
@@ -161,40 +181,75 @@ export default function ModuleHeader({
{pathSegments.length > 0 && (
<>
-
- {pathSegments.length === 1 ? (
-
- {moduleName}
- {moduleName === 'Database' && databaseType && (
-
- {databaseType}
-
- )}
-
- ) : (
-
-
- {moduleName}
- {moduleName === 'Database' && databaseType && (
-
- {databaseType}
-
- )}
-
-
- )}
-
- {pathSegments.length > 1 && (
+ {isCommunicationsTemplates ? (
<>
+
+
+ Communications
+
+
-
- {pathSegments[pathSegments.length - 1]
- .split('-')
- .map(w => w.charAt(0).toUpperCase() + w.slice(1))
- .join(' ')}
-
+ {pathSegments.length === 2 ? (
+ Templates
+ ) : (
+
+
+ Templates
+
+
+ )}
+
+ {pathSegments.length > 2 && (
+ <>
+
+
+
+ {formatBreadcrumbSegment(
+ pathSegments[pathSegments.length - 1]
+ )}
+
+
+ >
+ )}
+ >
+ ) : (
+ <>
+
+ {pathSegments.length === 1 ? (
+
+ {moduleName}
+ {moduleName === 'Database' && databaseType && (
+
+ {databaseType}
+
+ )}
+
+ ) : (
+
+
+ {moduleName}
+ {moduleName === 'Database' && databaseType && (
+
+ {databaseType}
+
+ )}
+
+
+ )}
+ {pathSegments.length > 1 && (
+ <>
+
+
+
+ {formatBreadcrumbSegment(
+ pathSegments[pathSegments.length - 1]
+ )}
+
+
+ >
+ )}
>
)}
>
diff --git a/src/components/communications/logs/communications-logs-tabs.tsx b/src/components/communications/logs/communications-logs-tabs.tsx
new file mode 100644
index 000000000..6331440b5
--- /dev/null
+++ b/src/components/communications/logs/communications-logs-tabs.tsx
@@ -0,0 +1,199 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { EmailRecord } from '@/lib/models/email';
+import { EmailFilters } from '@/components/email/records/email-filters';
+import { EmailDetail } from '@/components/email/records/email-detail';
+import { EmailList } from '@/components/email/records/email-list';
+import { fetchRecords } from '@/lib/api/email';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import NotificationTokensTable from '@/components/notifications/tokens/tokens';
+import { NotificationToken } from '@/lib/models/notification/NotificationToken';
+
+const LOGS_TABS = ['email', 'push'] as const;
+type LogsTab = (typeof LOGS_TABS)[number];
+
+function parseLogsTab(tab: string | undefined): LogsTab {
+ return tab === 'push' ? 'push' : 'email';
+}
+
+type PushTokensData = {
+ tokens: NotificationToken[];
+ count: number;
+};
+
+type CommunicationsLogsTabsProps = {
+ initialTab: LogsTab;
+ pushTokensData?: PushTokensData;
+ refreshPushTokens: (search: string) => Promise
;
+};
+
+function EmailLogsPanel() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const [emails, setEmails] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [totalCount, setTotalCount] = useState(0);
+ const [selectedEmail, setSelectedEmail] = useState(null);
+ const [isDetailOpen, setIsDetailOpen] = useState(false);
+
+ const messageId = searchParams.get('messageId') || undefined;
+ const templateId = searchParams.get('templateId') || undefined;
+ const receiver = searchParams.get('receiver') || undefined;
+ const sender = searchParams.get('sender') || undefined;
+ const cc = searchParams.get('cc')
+ ? searchParams.get('cc')?.split(',')
+ : undefined;
+ const replyTo = searchParams.get('replyTo') || undefined;
+ const startDate = searchParams.get('startDate') || undefined;
+ const endDate = searchParams.get('endDate') || undefined;
+ const skip = Number.parseInt(searchParams.get('skip') || '0');
+ const limit = 10;
+ const sort = searchParams.get('sort') || '-createdAt';
+
+ useEffect(() => {
+ const getEmails = async () => {
+ setLoading(true);
+ try {
+ const { records, count } = await fetchRecords({
+ messageId,
+ templateId,
+ receiver,
+ sender,
+ cc,
+ replyTo,
+ startDate,
+ endDate,
+ skip,
+ limit,
+ sort,
+ });
+ setEmails(records);
+ setTotalCount(count);
+ } catch (error) {
+ console.error('Failed to fetch emails:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ void getEmails();
+ }, [
+ messageId,
+ templateId,
+ receiver,
+ sender,
+ cc,
+ replyTo,
+ startDate,
+ endDate,
+ skip,
+ limit,
+ sort,
+ ]);
+
+ const updateQueryParams = useCallback(
+ (params: Record) => {
+ const newParams = new URLSearchParams(searchParams.toString());
+ newParams.set('tab', 'email');
+
+ Object.entries(params).forEach(([key, value]) => {
+ if (value === undefined || value === '') {
+ newParams.delete(key);
+ } else {
+ newParams.set(key, value);
+ }
+ });
+
+ if (!Object.keys(params).includes('skip')) {
+ newParams.set('skip', '0');
+ }
+
+ router.replace(`/communications/logs?${newParams.toString()}`, {
+ scroll: false,
+ });
+ },
+ [router, searchParams]
+ );
+
+ return (
+
+
+
+
+ updateQueryParams({ skip: ((page - 1) * limit).toString() })
+ }
+ onSortChange={sortField => updateQueryParams({ sort: sortField })}
+ currentSort={sort}
+ onViewEmail={email => {
+ setSelectedEmail(email);
+ setIsDetailOpen(true);
+ }}
+ />
+
+ {selectedEmail && (
+ setIsDetailOpen(false)}
+ />
+ )}
+
+ );
+}
+
+export function CommunicationsLogsTabs({
+ initialTab,
+ pushTokensData,
+ refreshPushTokens,
+}: CommunicationsLogsTabsProps) {
+ const router = useRouter();
+ const activeTab = initialTab;
+
+ const handleTabChange = (value: string) => {
+ const tab = parseLogsTab(value);
+ router.replace(`/communications/logs?tab=${tab}`, { scroll: false });
+ };
+
+ return (
+
+
+ Email Logs
+ Push Devices
+
+
+
+
+
+
+
+ {pushTokensData ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/communications/settings/communications-settings-tabs.tsx b/src/components/communications/settings/communications-settings-tabs.tsx
new file mode 100644
index 000000000..12b4e4720
--- /dev/null
+++ b/src/components/communications/settings/communications-settings-tabs.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { Settings as EmailSettings } from '@/components/email/settings/settings';
+import { Settings as SmsSettings } from '@/components/sms/settings';
+import { Settings as PushSettings } from '@/components/notifications/settings';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { EmailSettings as EmailSettingsData } from '@/lib/models/email';
+import { SmsSettings as SmsSettingsData } from '@/lib/models/Sms';
+import { NotificationSettings } from '@/lib/models/Notification';
+
+const SETTINGS_TABS = ['email', 'sms', 'push'] as const;
+type SettingsTab = (typeof SETTINGS_TABS)[number];
+
+function parseSettingsTab(tab: string | undefined): SettingsTab {
+ if (tab === 'sms' || tab === 'push') return tab;
+ return 'email';
+}
+
+type CommunicationsSettingsTabsProps = {
+ initialTab: SettingsTab;
+ emailSettings: EmailSettingsData;
+ smsSettings: SmsSettingsData;
+ pushSettings: NotificationSettings;
+};
+
+export function CommunicationsSettingsTabs({
+ initialTab,
+ emailSettings,
+ smsSettings,
+ pushSettings,
+}: CommunicationsSettingsTabsProps) {
+ const router = useRouter();
+ const activeTab = initialTab;
+
+ const handleTabChange = (value: string) => {
+ const tab = parseSettingsTab(value);
+ router.replace(`/communications/settings?tab=${tab}`, { scroll: false });
+ };
+
+ return (
+
+
+ Email
+ SMS
+ Push
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/communications/templates/add-channels-dialog.tsx b/src/components/communications/templates/add-channels-dialog.tsx
new file mode 100644
index 000000000..f18f95a70
--- /dev/null
+++ b/src/components/communications/templates/add-channels-dialog.tsx
@@ -0,0 +1,183 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { migrateFromEmailTemplate } from '@/lib/api/communications/templates';
+import { MigrationResponse } from '@/lib/models/communications/template-row';
+import { formatCommunicationsApiError } from '@/lib/logic/communications-api-error';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { useToast } from '@/lib/hooks/use-toast';
+import { Loader2 } from 'lucide-react';
+
+type AddChannelsDialogProps = {
+ emailTemplateId: string | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+
+export function AddChannelsDialog({
+ emailTemplateId,
+ open,
+ onOpenChange,
+}: AddChannelsDialogProps) {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [preview, setPreview] = useState(null);
+ const [isLoadingPreview, setIsLoadingPreview] = useState(false);
+ const [isMigrating, setIsMigrating] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!open || !emailTemplateId) {
+ setPreview(null);
+ setError(null);
+ return;
+ }
+
+ let cancelled = false;
+ setIsLoadingPreview(true);
+ setError(null);
+
+ migrateFromEmailTemplate({ emailTemplateId, dryRun: true })
+ .then(response => {
+ if (!cancelled) {
+ setPreview(response);
+ }
+ })
+ .catch(err => {
+ if (!cancelled) {
+ setError(formatCommunicationsApiError(err));
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setIsLoadingPreview(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [open, emailTemplateId]);
+
+ const handleConfirm = async () => {
+ if (!emailTemplateId) return;
+
+ setIsMigrating(true);
+ try {
+ await migrateFromEmailTemplate({
+ emailTemplateId,
+ deleteSource: false,
+ });
+ toast({
+ title: 'Communications',
+ description: 'Unified template created',
+ });
+ onOpenChange(false);
+ router.refresh();
+ } catch (err) {
+ toast({
+ title: 'Communications',
+ description: formatCommunicationsApiError(err),
+ });
+ } finally {
+ setIsMigrating(false);
+ }
+ };
+
+ const plannedItem = preview?.planned[0];
+
+ return (
+
+
+
+ Add channels
+
+ Create a unified copy of this email template so you can add push and
+ SMS content. The source email template will be kept.
+
+
+
+ {isLoadingPreview && (
+
+
+ Loading preview…
+
+ )}
+
+ {error && (
+
+ Preview failed
+ {error}
+
+ )}
+
+ {plannedItem && !isLoadingPreview && (
+
+
+ Source:
+ {plannedItem.sourceName}
+
+
+ Channels:
+ {plannedItem.target.channels.join(', ')}
+
+ {plannedItem.target.variables &&
+ plannedItem.target.variables.length > 0 && (
+
+ Variables:
+ {plannedItem.target.variables.join(', ')}
+
+ )}
+ {plannedItem.warning && (
+
+ Warning
+ {plannedItem.warning}
+
+ )}
+ {plannedItem.skipped && (
+
+ Skipped
+
+ A unified template with this name already exists.
+
+
+ )}
+
+ )}
+
+
+ onOpenChange(false)}
+ disabled={isMigrating}
+ >
+ Cancel
+
+
+ {isMigrating && }
+ Create unified copy
+
+
+
+
+ );
+}
diff --git a/src/components/communications/templates/bulk-add-channels-button.tsx b/src/components/communications/templates/bulk-add-channels-button.tsx
new file mode 100644
index 000000000..8d3161385
--- /dev/null
+++ b/src/components/communications/templates/bulk-add-channels-button.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { migrateFromEmailTemplate } from '@/lib/api/communications/templates';
+import { formatCommunicationsApiError } from '@/lib/logic/communications-api-error';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
+import { Button } from '@/components/ui/button';
+import { useToast } from '@/lib/hooks/use-toast';
+import { Layers, Loader2 } from 'lucide-react';
+
+type BulkAddChannelsButtonProps = {
+ emailTemplateCount: number;
+};
+
+export function BulkAddChannelsButton({
+ emailTemplateCount,
+}: BulkAddChannelsButtonProps) {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [isMigrating, setIsMigrating] = useState(false);
+
+ if (emailTemplateCount <= 0) {
+ return null;
+ }
+
+ const handleBulkMigrate = async () => {
+ setIsMigrating(true);
+ try {
+ const response = await migrateFromEmailTemplate({
+ skipExisting: true,
+ });
+ const createdCount = response.count ?? response.created?.length ?? 0;
+ toast({
+ title: 'Communications',
+ description: `Created ${createdCount} unified template${createdCount === 1 ? '' : 's'}`,
+ });
+ router.refresh();
+ } catch (err) {
+ toast({
+ title: 'Communications',
+ description: formatCommunicationsApiError(err),
+ });
+ } finally {
+ setIsMigrating(false);
+ }
+ };
+
+ return (
+
+
+
+ {isMigrating ? (
+
+ ) : (
+
+ )}
+ Add channels to all ({emailTemplateCount})
+
+
+
+
+ Create unified copies
+
+ Create unified copies for {emailTemplateCount} email template
+ {emailTemplateCount === 1 ? '' : 's'}. Templates that already have a
+ unified counterpart will be skipped. Source email templates will be
+ kept.
+
+
+
+ Cancel
+
+ {isMigrating && }
+ Create copies
+
+
+
+
+ );
+}
diff --git a/src/components/communications/templates/columns.tsx b/src/components/communications/templates/columns.tsx
new file mode 100644
index 000000000..66858b0f8
--- /dev/null
+++ b/src/components/communications/templates/columns.tsx
@@ -0,0 +1,249 @@
+'use client';
+
+import { ColumnDef } from '@tanstack/react-table';
+import moment from 'moment/moment';
+import { DeleteAlert } from '@/components/helpers/delete';
+import React, { useMemo, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { EyeIcon } from 'lucide-react';
+import { useToast } from '@/lib/hooks/use-toast';
+import { deleteCommunicationTemplate } from '@/lib/api/communications/templates';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ TemplateRow,
+ getTemplateRowCreatedAt,
+ getTemplateRowName,
+ getTemplateRowVariablesCount,
+} from '@/lib/models/communications/template-row';
+import { ExternalTemplate } from '@/lib/models/email';
+import { EmailTemplatePreview } from './email-template-preview';
+
+type TemplateRowColumnsOptions = {
+ onAddChannels: (emailTemplateId: string) => void;
+};
+
+function ExternalTemplateViewDialog({
+ template,
+ open,
+ onOpenChange,
+}: {
+ template: ExternalTemplate;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ return (
+
+
+
+ {template.name}
+
+
+
+
+ );
+}
+
+function StatusBadge({ row }: { row: TemplateRow }) {
+ switch (row.kind) {
+ case 'email':
+ return null;
+ case 'unified':
+ if (row.template.channels.length > 1) {
+ return Unified ;
+ }
+ return null;
+ case 'external':
+ return External ;
+ default: {
+ const _exhaustive: never = row;
+ return _exhaustive;
+ }
+ }
+}
+
+function ChannelsCell({ row }: { row: TemplateRow }) {
+ switch (row.kind) {
+ case 'unified':
+ return (
+
+ {row.template.channels.map(channel => (
+
+ {channel}
+
+ ))}
+
+ );
+ case 'email':
+ case 'external':
+ return (
+
+ email
+
+ );
+ default: {
+ const _exhaustive: never = row;
+ return _exhaustive;
+ }
+ }
+}
+
+function ActionsCell({
+ row,
+ onAddChannels,
+}: {
+ row: TemplateRow;
+ onAddChannels: (emailTemplateId: string) => void;
+}) {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [externalViewOpen, setExternalViewOpen] = useState(false);
+
+ switch (row.kind) {
+ case 'unified':
+ return (
+
+
+ deleteCommunicationTemplate(row.template._id)
+ .then(() => router.refresh())
+ .catch(err =>
+ toast({ title: 'Communications', description: err.message })
+ )
+ }
+ />
+
+
+
+
+ );
+ case 'email':
+ return (
+
+ onAddChannels(row.template._id)}
+ >
+ Add channels
+
+
+
+
+
+ );
+ case 'external':
+ return (
+
+
+ setExternalViewOpen(true)}
+ aria-label={`View ${row.template.name}`}
+ >
+
+
+
+ );
+ default: {
+ const _exhaustive: never = row;
+ return _exhaustive;
+ }
+ }
+}
+
+export function useTemplateRowColumns({
+ onAddChannels,
+}: TemplateRowColumnsOptions) {
+ return useMemo[]>(
+ () => [
+ {
+ id: 'name',
+ header: 'Name',
+ accessorFn: row => getTemplateRowName(row),
+ cell: ({ row }) => {
+ const name = getTemplateRowName(row.original);
+ if (row.original.kind === 'unified') {
+ return (
+
+ {name}
+
+ );
+ }
+ if (row.original.kind === 'email') {
+ return (
+
+ {name}
+
+ );
+ }
+ return {name} ;
+ },
+ },
+ {
+ id: 'channels',
+ header: 'Channels',
+ cell: ({ row }) => ,
+ },
+ {
+ id: 'status',
+ header: 'Status',
+ cell: ({ row }) => ,
+ },
+ {
+ id: 'variables',
+ header: 'Variables',
+ accessorFn: row => getTemplateRowVariablesCount(row),
+ },
+ {
+ id: 'createdAt',
+ header: 'Created',
+ accessorFn: row => getTemplateRowCreatedAt(row),
+ cell: props => moment(props.getValue() as string).format('DD MMM YYYY'),
+ },
+ {
+ id: 'actions',
+ header: '',
+ cell: ({ row }) => (
+
+ ),
+ },
+ ],
+ [onAddChannels]
+ );
+}
diff --git a/src/components/communications/templates/create-template-sheet.tsx b/src/components/communications/templates/create-template-sheet.tsx
new file mode 100644
index 000000000..3e203a145
--- /dev/null
+++ b/src/components/communications/templates/create-template-sheet.tsx
@@ -0,0 +1,84 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from '@/components/ui/sheet';
+import { Button } from '@/components/ui/button';
+import { Plus } from 'lucide-react';
+import { useToast } from '@/lib/hooks/use-toast';
+import { createCommunicationTemplate } from '@/lib/api/communications/templates';
+import { formatCommunicationsApiError } from '@/lib/logic/communications-api-error';
+import {
+ CommunicationTemplateForm,
+ CommunicationTemplateFormValues,
+} from './template-form';
+
+const EMAIL_BODY_PLACEHOLDER = '
';
+
+export function CreateCommunicationTemplateSheet() {
+ const [open, setOpen] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ const handleSubmit = async (values: CommunicationTemplateFormValues) => {
+ const payload = { ...values };
+
+ if (payload.channels.includes('email')) {
+ payload.email = {
+ ...payload.email,
+ body: EMAIL_BODY_PLACEHOLDER,
+ };
+ }
+
+ try {
+ const created = await createCommunicationTemplate(payload);
+ toast({
+ title: 'Communications',
+ description: 'Template created',
+ });
+ setOpen(false);
+
+ const openEditor = payload.channels.includes('email');
+ router.push(
+ openEditor
+ ? `/communications/templates/${created._id}?editor-open=true`
+ : '/communications/templates'
+ );
+ router.refresh();
+ } catch (err) {
+ toast({
+ title: 'Communications',
+ description: formatCommunicationsApiError(err),
+ });
+ }
+ };
+
+ return (
+
+
+
+
+ New template
+
+
+
+
+ Create template
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/communications/templates/dashboard.tsx b/src/components/communications/templates/dashboard.tsx
new file mode 100644
index 000000000..39e4916dd
--- /dev/null
+++ b/src/components/communications/templates/dashboard.tsx
@@ -0,0 +1,202 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+import { CommunicationTemplatesTable } from './data-table';
+import { SearchInput } from '@/components/helpers/search';
+import { CreateCommunicationTemplateSheet } from './create-template-sheet';
+import { BulkAddChannelsButton } from './bulk-add-channels-button';
+import { AddChannelsDialog } from './add-channels-dialog';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Button } from '@/components/ui/button';
+import { useToast } from '@/lib/hooks/use-toast';
+import { syncTemplates } from '@/lib/api/email';
+import { RefreshCw } from 'lucide-react';
+import { isNil } from 'lodash';
+import { mergeTemplateRows } from '@/lib/logic/merge-template-rows';
+import {
+ ChannelFilter,
+ filterTemplateRows,
+ rowMatchesChannel,
+ TemplateFilter,
+} from '@/lib/models/communications/template-row';
+import { CommunicationTemplate } from '@/lib/models/communications/templates';
+import { EmailTemplate, ExternalTemplate } from '@/lib/models/email';
+
+type CommunicationTemplatesResponse = {
+ templateDocuments: CommunicationTemplate[];
+ count: number;
+};
+
+type EmailTemplatesResponse = {
+ templateDocuments: EmailTemplate[];
+ count: number;
+};
+
+type ExternalTemplatesResponse = {
+ templateDocuments: ExternalTemplate[];
+ count: number;
+};
+
+export function CommunicationTemplatesDashboard({
+ communicationTemplates,
+ emailTemplates,
+ externalTemplates,
+ emailTemplateId,
+}: {
+ communicationTemplates: CommunicationTemplatesResponse;
+ emailTemplates: EmailTemplatesResponse;
+ externalTemplates: ExternalTemplatesResponse | null;
+ emailTemplateId?: string;
+}) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const { toast } = useToast();
+
+ const [sourceFilter, setSourceFilter] = useState('all');
+ const [channelFilter, setChannelFilter] = useState('all');
+ const [addChannelsDialogId, setAddChannelsDialogId] = useState(
+ null
+ );
+ const [addChannelsDialogOpen, setAddChannelsDialogOpen] = useState(false);
+
+ const mergedRows = useMemo(
+ () =>
+ mergeTemplateRows(
+ communicationTemplates.templateDocuments,
+ emailTemplates.templateDocuments,
+ externalTemplates?.templateDocuments ?? null
+ ),
+ [
+ communicationTemplates.templateDocuments,
+ emailTemplates.templateDocuments,
+ externalTemplates?.templateDocuments,
+ ]
+ );
+
+ const emailTemplateCount = useMemo(
+ () => mergedRows.filter(row => row.kind === 'email').length,
+ [mergedRows]
+ );
+
+ const filteredRows = useMemo(() => {
+ const bySource = filterTemplateRows(mergedRows, sourceFilter);
+ return bySource.filter(row => rowMatchesChannel(row, channelFilter));
+ }, [mergedRows, sourceFilter, channelFilter]);
+
+ const searchTerm = (searchParams.get('search') ?? '').toLowerCase();
+ const displayedRows = useMemo(() => {
+ if (!searchTerm) return filteredRows;
+ return filteredRows.filter(row =>
+ row.template.name.toLowerCase().includes(searchTerm)
+ );
+ }, [filteredRows, searchTerm]);
+
+ const openAddChannelsDialog = useCallback((templateId: string) => {
+ setAddChannelsDialogId(templateId);
+ setAddChannelsDialogOpen(true);
+ }, []);
+
+ const handleAddChannelsDialogOpenChange = useCallback(
+ (open: boolean) => {
+ setAddChannelsDialogOpen(open);
+ if (!open) {
+ setAddChannelsDialogId(null);
+ if (searchParams.get('email')) {
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete('email');
+ const query = params.toString();
+ router.replace(query ? `${pathname}?${query}` : pathname);
+ }
+ }
+ },
+ [pathname, router, searchParams]
+ );
+
+ useEffect(() => {
+ if (emailTemplateId) {
+ openAddChannelsDialog(emailTemplateId);
+ }
+ }, [emailTemplateId, openAddChannelsDialog]);
+
+ const handleSyncExternal = () => {
+ syncTemplates()
+ .then(() => {
+ toast({
+ title: 'Communications',
+ description: 'External templates synced successfully',
+ });
+ router.refresh();
+ })
+ .catch(err =>
+ toast({
+ title: 'Communications',
+ description: err.message,
+ })
+ );
+ };
+
+ return (
+
+
+ setSourceFilter(value as TemplateFilter)}
+ >
+
+ All
+ Unified
+ Email
+ External
+
+
+
+ setChannelFilter(value as ChannelFilter)}
+ >
+
+ All channels
+ Email
+ Push
+ SMS
+
+
+
+
+
+
+
+ {!isNil(externalTemplates) && (
+
+
+ Sync external
+
+ )}
+
+
+
+
+
+ {sourceFilter === 'external' && isNil(externalTemplates) && (
+
+ External templates are not available for this email provider.
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/communications/templates/data-table.tsx b/src/components/communications/templates/data-table.tsx
new file mode 100644
index 000000000..4f88f883b
--- /dev/null
+++ b/src/components/communications/templates/data-table.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import { DataTable } from '@/components/ui/data-table';
+import { TemplateRow } from '@/lib/models/communications/template-row';
+import { useTemplateRowColumns } from './columns';
+
+type CommunicationTemplatesTableProps = {
+ rows: TemplateRow[];
+ onAddChannels: (emailTemplateId: string) => void;
+};
+
+export function CommunicationTemplatesTable({
+ rows,
+ onAddChannels,
+}: CommunicationTemplatesTableProps) {
+ const columns = useTemplateRowColumns({ onAddChannels });
+
+ return ;
+}
diff --git a/src/components/communications/templates/email-body-editor.tsx b/src/components/communications/templates/email-body-editor.tsx
new file mode 100644
index 000000000..3875c59ef
--- /dev/null
+++ b/src/components/communications/templates/email-body-editor.tsx
@@ -0,0 +1,272 @@
+'use client';
+
+import { useRef, useState, useCallback } from 'react';
+import { EmailEditor, type EmailEditorRef } from '@react-email/editor';
+import { Inspector } from '@react-email/editor/ui';
+import '@react-email/editor/themes/default.css';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Save, ArrowLeft, Plus, X, Code, Eye } from 'lucide-react';
+import { useToast } from '@/lib/hooks/use-toast';
+import { validateVariableName } from '@/lib/utils/template-utils';
+
+type EmailBodyEditorProps = {
+ title: string;
+ html: string;
+ variables?: string[];
+ onSave: (html: string, variables: string[]) => Promise;
+ onClose: () => void;
+};
+
+type Variable = { name: string };
+
+const uploadImageAsDataUrl = async (file: File): Promise<{ url: string }> => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve({ url: reader.result as string });
+ reader.onerror = () => reject(new Error('Failed to read image file'));
+ reader.readAsDataURL(file);
+ });
+};
+
+export function EmailBodyEditor({
+ title,
+ html,
+ variables: initialVariables = [],
+ onSave,
+ onClose,
+}: EmailBodyEditorProps) {
+ const editorRef = useRef(null);
+ const { toast } = useToast();
+
+ const [variables, setVariables] = useState(
+ initialVariables.map(name => ({ name }))
+ );
+ const [newVariable, setNewVariable] = useState('');
+ const [saving, setSaving] = useState(false);
+ const [mode, setMode] = useState<'visual' | 'source'>('visual');
+ const [sourceHtml, setSourceHtml] = useState(html || '');
+
+ const switchToSource = useCallback(async () => {
+ if (editorRef.current) {
+ const editorHtml = await editorRef.current.getEmailHTML();
+ setSourceHtml(editorHtml);
+ }
+ setMode('source');
+ }, []);
+
+ const switchToVisual = useCallback(() => {
+ setMode('visual');
+ }, []);
+
+ const saveTemplate = useCallback(async () => {
+ setSaving(true);
+ try {
+ let finalHtml: string;
+ if (mode === 'visual' && editorRef.current) {
+ finalHtml = await editorRef.current.getEmailHTML();
+ } else {
+ finalHtml = sourceHtml;
+ }
+ await onSave(
+ finalHtml,
+ variables.map(variable => variable.name)
+ );
+ toast({ title: 'Success', description: 'Template saved successfully' });
+ } catch (error) {
+ toast({
+ title: 'Error',
+ description:
+ error instanceof Error ? error.message : 'Failed to save template',
+ });
+ } finally {
+ setSaving(false);
+ }
+ }, [mode, sourceHtml, variables, onSave, toast]);
+
+ const addVariable = () => {
+ const trimmedName = newVariable.trim();
+ if (!trimmedName) return;
+
+ const validation = validateVariableName(trimmedName);
+ if (!validation.isValid) {
+ toast({
+ title: 'Invalid Variable Name',
+ description: validation.error || 'Please enter a valid variable name',
+ });
+ return;
+ }
+
+ if (!variables.find(v => v.name === trimmedName)) {
+ setVariables(prev => [...prev, { name: trimmedName }]);
+ setNewVariable('');
+ } else {
+ toast({
+ title: 'Variable Exists',
+ description: 'A variable with this name already exists',
+ });
+ }
+ };
+
+ const removeVariable = (name: string) => {
+ setVariables(prev => prev.filter(v => v.name !== name));
+ };
+
+ return (
+
+
+
+
+
+
+ Back
+
+
+
{title}
+
+ {mode === 'visual' ? 'Visual Editor' : 'Source Editor'}
+
+
+
+
+ {mode === 'visual' ? (
+
+
+ View Source
+
+ ) : (
+
+
+ Visual Editor
+
+ )}
+
+
+ {saving ? 'Saving...' : 'Save Template'}
+
+
+
+
+
+ {mode === 'visual' ? (
+ <>
+
+ Start editing your template...'}
+ theme="basic"
+ className="min-w-0 flex-1 overflow-y-auto"
+ onUploadImage={uploadImageAsDataUrl}
+ >
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+function VariablesSidebar({
+ variables,
+ newVariable,
+ onNewVariableChange,
+ onAdd,
+ onRemove,
+}: {
+ variables: Variable[];
+ newVariable: string;
+ onNewVariableChange: (value: string) => void;
+ onAdd: () => void;
+ onRemove: (name: string) => void;
+}) {
+ return (
+
+
+
+ Template Variables
+
+
+
+ Use {'{{variableName}}'} in your
+ template to insert dynamic content.
+
+
+
onNewVariableChange(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && onAdd()}
+ />
+
+
+
+
+
+ {variables.map(variable => (
+
+
+ {`{{${variable.name}}}`}
+
+ onRemove(variable.name)}
+ >
+
+
+
+ ))}
+
+ {variables.length === 0 && (
+
+ No variables defined
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/communications/templates/email-template-detail.tsx b/src/components/communications/templates/email-template-detail.tsx
new file mode 100644
index 000000000..1a0839567
--- /dev/null
+++ b/src/components/communications/templates/email-template-detail.tsx
@@ -0,0 +1,189 @@
+'use client';
+
+import { getTemplates } from '@/lib/api/email';
+import { migrateFromEmailTemplate } from '@/lib/api/communications/templates';
+import { formatCommunicationsApiError } from '@/lib/logic/communications-api-error';
+import React, { useEffect, useState } from 'react';
+import { EmailTemplate } from '@/lib/models/email';
+import { Button } from '@/components/ui/button';
+import { Code, Edit, ExternalLink } from 'lucide-react';
+import { TemplatePreview } from '@/components/email/templates/templatePreview';
+import { TemplateEditor } from '@/components/email/templates/TemplateEditor';
+import { HtmlViewer } from '@/components/email/templates/HtmlViewer';
+import { isExternallyManaged } from '@/lib/utils/template-utils';
+import { useToast } from '@/lib/hooks/use-toast';
+import {
+ PageHeader,
+ PageTitle,
+ PageActions,
+} from '@/components/ui/page-header';
+
+type EmailTemplateDetailProps = {
+ templateId: string;
+ editorOpen?: boolean;
+};
+
+export function EmailTemplateDetail({
+ templateId,
+ editorOpen = false,
+}: EmailTemplateDetailProps) {
+ const [template, setTemplate] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [showEditor, setShowEditor] = useState(editorOpen);
+ const [showHtmlDialog, setShowHtmlDialog] = useState(false);
+ const { toast } = useToast();
+
+ const loadTemplate = async (id: string) => {
+ const templateData = await getTemplates({ search: id });
+ if (templateData.templateDocuments.length > 0) {
+ setTemplate(templateData.templateDocuments[0]);
+ } else {
+ setTemplate(null);
+ }
+ };
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const init = async () => {
+ setLoading(true);
+ try {
+ await loadTemplate(templateId);
+ } catch (error) {
+ console.error('Failed to load template:', error);
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ };
+
+ init();
+ setShowEditor(editorOpen);
+
+ return () => {
+ cancelled = true;
+ };
+ }, [templateId, editorOpen]);
+
+ const handleEdit = () => {
+ if (!template || isExternallyManaged(template)) {
+ toast({
+ title: 'External Template',
+ description:
+ 'This template is managed by an external system and cannot be edited here.',
+ });
+ return;
+ }
+ setShowEditor(true);
+ };
+
+ const refreshTemplate = async () => {
+ await loadTemplate(templateId);
+ };
+
+ if (loading) {
+ return (
+
+
+
+
Loading template...
+
+
+ );
+ }
+
+ if (!template) {
+ return (
+
+ );
+ }
+
+ if (showEditor) {
+ if (isExternallyManaged(template)) {
+ return (
+
+
+
+
External Template
+
+ This template is managed by an external system and cannot be
+ edited here.
+
+
setShowEditor(false)}>
+ Back to Template
+
+
+
+ );
+ }
+
+ return (
+ setShowEditor(false)}
+ onTemplateUpdate={refreshTemplate}
+ />
+ );
+ }
+
+ return (
+
+
+ Template: {template.name}
+
+ {!isExternallyManaged(template) && (
+ setShowHtmlDialog(true)}
+ className="flex items-center space-x-2"
+ >
+
+ View HTML
+
+ )}
+
+
+ Edit Template
+
+ {!isExternallyManaged(template) && (
+
+ migrateFromEmailTemplate({ emailTemplateId: template._id })
+ .then(() =>
+ toast({
+ title: 'Communications',
+ description: 'Unified template created',
+ })
+ )
+ .catch(err =>
+ toast({
+ title: 'Communications',
+ description: formatCommunicationsApiError(err),
+ })
+ )
+ }
+ >
+ Add channels
+
+ )}
+
+
+
+
+
+
setShowHtmlDialog(false)}
+ />
+
+ );
+}
diff --git a/src/components/communications/templates/email-template-preview.tsx b/src/components/communications/templates/email-template-preview.tsx
new file mode 100644
index 000000000..dd213bf30
--- /dev/null
+++ b/src/components/communications/templates/email-template-preview.tsx
@@ -0,0 +1,271 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Eye, Code, Edit, ExternalLink, AlertTriangle } from 'lucide-react';
+import {
+ generateSampleData,
+ compileHandlebarsTemplate,
+} from '@/lib/utils/template-utils';
+
+export type EmailPreviewSource = {
+ name: string;
+ subject?: string;
+ sender?: string;
+ body?: string;
+ variables?: string[];
+ externalId?: string;
+ createdAt?: string | Date;
+ isExternal?: boolean;
+};
+
+type EmailTemplatePreviewProps = {
+ template: EmailPreviewSource;
+ onEdit?: () => void;
+ onViewHtml?: () => void;
+ readOnlyDetails?: boolean;
+};
+
+export function EmailTemplatePreview({
+ template,
+ onEdit,
+ onViewHtml,
+ readOnlyDetails = false,
+}: EmailTemplatePreviewProps) {
+ const [previewData, setPreviewData] = useState>({});
+ const [activeTab, setActiveTab] = useState<
+ 'preview' | 'variables' | 'details'
+ >('preview');
+
+ useEffect(() => {
+ if (template.variables && template.variables.length > 0) {
+ setPreviewData(generateSampleData(template.variables));
+ }
+ }, [template.variables]);
+
+ const getPreviewHtml = () => {
+ if (!template.body) return '';
+ return compileHandlebarsTemplate(template.body, previewData);
+ };
+
+ const updatePreviewData = (variableName: string, value: string) => {
+ setPreviewData(prev => ({ ...prev, [variableName]: value }));
+ };
+
+ if (template.isExternal) {
+ return (
+
+
+
+
+
+ External Template
+
+ External
+
+
+
+
+
+
+
+ Externally managed template
+
+
+ This template is managed by your email provider. Update it in
+ the provider's interface.
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Template Preview
+
+
+ {onViewHtml && (
+
+
+ View HTML
+
+ )}
+ {onEdit && (
+
+
+ Edit Template
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+function PreviewTabs({
+ template,
+ activeTab,
+ onTabChange,
+ previewData,
+ onPreviewDataChange,
+ getPreviewHtml,
+ readOnlyDetails,
+}: {
+ template: EmailPreviewSource;
+ activeTab: 'preview' | 'variables' | 'details';
+ onTabChange: (tab: 'preview' | 'variables' | 'details') => void;
+ previewData: Record;
+ onPreviewDataChange: (name: string, value: string) => void;
+ getPreviewHtml: () => string;
+ readOnlyDetails: boolean;
+}) {
+ return (
+
+ onTabChange(value as 'preview' | 'variables' | 'details')
+ }
+ >
+
+ Preview
+ {template.variables && template.variables.length > 0 && (
+
+ Variables ({template.variables.length})
+
+ )}
+ Details
+
+
+
+
+
Email Preview
+
+
+
+ Subject: {template.subject || 'No subject'}
+
+ {template.sender && (
+
+ From: {template.sender}
+
+ )}
+
+
+
+
+
+
+
+
+ {template.variables && template.variables.length > 0 && (
+
+
+
Template Variables
+
+ Adjust the values below to see how they affect the template
+ preview.
+
+
+ {template.variables.map(variable => (
+
+ {variable}
+
+ onPreviewDataChange(variable, e.target.value)
+ }
+ placeholder={`Enter value for ${variable}`}
+ className="text-sm"
+ />
+
+ ))}
+
+
{
+ const sampleData = generateSampleData(template.variables || []);
+ Object.entries(sampleData).forEach(([key, value]) =>
+ onPreviewDataChange(key, value)
+ );
+ }}
+ >
+ Reset to sample data
+
+
+
+ )}
+
+
+
+
+ Name: {template.name}
+
+
+ Subject: {template.subject || 'Not set'}
+
+
+ Sender: {template.sender || 'Not set'}
+
+
+ Variables: {template.variables?.length || 0}
+
+ {template.externalId && (
+
+ External ID: {template.externalId}
+
+ )}
+ {template.createdAt && (
+
+ Created: {' '}
+ {new Date(template.createdAt).toLocaleDateString()}
+
+ )}
+
+ {readOnlyDetails && (
+
+ Template details are read-only.
+
+ )}
+
+
+ );
+}
diff --git a/src/components/communications/templates/template-form.tsx b/src/components/communications/templates/template-form.tsx
new file mode 100644
index 000000000..ea3792edc
--- /dev/null
+++ b/src/components/communications/templates/template-form.tsx
@@ -0,0 +1,327 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { CommunicationChannel } from '@/lib/models/communications/templates';
+import { cn } from '@/lib/utils';
+
+const channelOptions: { id: CommunicationChannel; label: string }[] = [
+ { id: 'email', label: 'Email' },
+ { id: 'push', label: 'Push' },
+ { id: 'sms', label: 'SMS' },
+];
+
+const baseSchema = z.object({
+ name: z.string().min(1, 'Name is required'),
+ templateDescription: z.string().optional(),
+ channels: z.array(z.enum(['email', 'push', 'sms'])).min(1),
+ email: z
+ .object({
+ subject: z.string().optional(),
+ body: z.string().optional(),
+ sender: z.string().optional(),
+ })
+ .optional(),
+ push: z
+ .object({
+ title: z.string().optional(),
+ body: z.string().optional(),
+ })
+ .optional(),
+ sms: z
+ .object({
+ message: z.string().optional(),
+ })
+ .optional(),
+});
+
+function buildSchema(mode: 'create' | 'edit') {
+ return baseSchema.superRefine((data, ctx) => {
+ if (data.channels.includes('email')) {
+ if (!data.email?.subject?.trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Email subject is required',
+ path: ['email', 'subject'],
+ });
+ }
+ if (
+ mode === 'edit' &&
+ data.channels.includes('email') &&
+ !data.email?.body?.trim()
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Email body is required',
+ path: ['email', 'body'],
+ });
+ }
+ }
+ if (data.channels.includes('push')) {
+ if (!data.push?.title?.trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Push title is required',
+ path: ['push', 'title'],
+ });
+ }
+ if (!data.push?.body?.trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Push body is required',
+ path: ['push', 'body'],
+ });
+ }
+ }
+ if (data.channels.includes('sms') && !data.sms?.message?.trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'SMS message is required',
+ path: ['sms', 'message'],
+ });
+ }
+ });
+}
+
+export type CommunicationTemplateFormValues = z.infer;
+
+export function CommunicationTemplateForm({
+ defaultValues,
+ onSubmit,
+ submitLabel,
+ disableName,
+ mode = 'edit',
+}: {
+ defaultValues?: Partial;
+ onSubmit: (values: CommunicationTemplateFormValues) => Promise;
+ submitLabel: string;
+ disableName?: boolean;
+ mode?: 'create' | 'edit';
+}) {
+ const schema = buildSchema(mode);
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ name: '',
+ templateDescription: '',
+ channels: ['email'],
+ email: { subject: '', body: '', sender: '' },
+ push: { title: '', body: '' },
+ sms: { message: '' },
+ ...defaultValues,
+ },
+ });
+
+ const channels = form.watch('channels');
+
+ const toggleChannel = (channel: CommunicationChannel, checked: boolean) => {
+ const current = form.getValues('channels');
+ if (checked) {
+ form.setValue('channels', [...new Set([...current, channel])], {
+ shouldValidate: true,
+ });
+ return;
+ }
+ form.setValue(
+ 'channels',
+ current.filter(c => c !== channel),
+ { shouldValidate: true }
+ );
+ };
+
+ return (
+
+
+ );
+}
diff --git a/src/components/email/settings/settings.tsx b/src/components/email/settings/settings.tsx
index b3d81502d..866e9def0 100644
--- a/src/components/email/settings/settings.tsx
+++ b/src/components/email/settings/settings.tsx
@@ -22,6 +22,7 @@ import {
interface Props {
data: EmailSettings;
+ embedded?: boolean;
}
const EMAIL_MODULE_NAMES = ['communications', 'email'] as const;
@@ -119,7 +120,7 @@ function isEmailActivationSuccess(result: PatchSettingsResult | void) {
return isCommunicationsModuleServing(result.modules, EMAIL_MODULE_NAMES);
}
-export const Settings = ({ data }: Props) => {
+export const Settings = ({ data, embedded = false }: Props) => {
const [emailModule, setEmailModule] = useState(false);
const [edit, setEdit] = useState(false);
const { addAlert } = useAlerts();
@@ -184,57 +185,57 @@ export const Settings = ({ data }: Props) => {
};
return (
-
-
-
-
-
-
- Since you have created an account on one of the Supported
- Providers (Mailgun, Sendgrid, Mandrill, Smtp), you need to
- configure the provider to proceed with the activation of the
- module. Visit documentation for{' '}
-
- Mandrill
-
- ,{' '}
-
- Sendgrid
-
- .
-
-
+
+
+
+
+
+ Since you have created an account on one of the Supported Providers
+ (Mailgun, Sendgrid, Mandrill, Smtp), you need to configure the
+ provider to proceed with the activation of the module. Visit
+ documentation for{' '}
+
+ Mandrill
+
+ ,{' '}
+
+ Sendgrid
+
+ .
+
- {emailModule && (
-
- undefined)}>
-
-
-
- )}
+ {emailModule && (
+
+ undefined)}>
+
+
+
+ )}
);
};
diff --git a/src/components/email/templates/TemplateEditor.tsx b/src/components/email/templates/TemplateEditor.tsx
index c02ff52d1..8a790f6ef 100644
--- a/src/components/email/templates/TemplateEditor.tsx
+++ b/src/components/email/templates/TemplateEditor.tsx
@@ -1,20 +1,10 @@
'use client';
-import React, { useRef, useState, useCallback } from 'react';
-import { EmailEditor, type EmailEditorRef } from '@react-email/editor';
-import { Inspector } from '@react-email/editor/ui';
-import '@react-email/editor/themes/default.css';
-import { Button } from '@/components/ui/button';
+import React from 'react';
import { useRouter } from 'next/navigation';
import { patchTemplates } from '@/lib/api/email';
-import { useToast } from '@/lib/hooks/use-toast';
import { EmailTemplate } from '@/lib/models/email';
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Badge } from '@/components/ui/badge';
-import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
-import { Save, ArrowLeft, Plus, X, Code, Eye } from 'lucide-react';
-import { validateVariableName } from '@/lib/utils/template-utils';
+import { EmailBodyEditor } from '@/components/communications/templates/email-body-editor';
interface TemplateEditorProps {
template: EmailTemplate;
@@ -22,272 +12,28 @@ interface TemplateEditorProps {
onTemplateUpdate?: () => void;
}
-interface Variable {
- name: string;
-}
-
-const uploadImageAsDataUrl = async (file: File): Promise<{ url: string }> => {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => resolve({ url: reader.result as string });
- reader.onerror = () => reject(new Error('Failed to read image file'));
- reader.readAsDataURL(file);
- });
-};
-
export const TemplateEditor: React.FC
= ({
template,
onClose,
onTemplateUpdate,
}) => {
- const editorRef = useRef(null);
const router = useRouter();
- const { toast } = useToast();
-
- const [variables, setVariables] = useState(
- template.variables?.map(v => ({ name: v })) || []
- );
- const [newVariable, setNewVariable] = useState('');
- const [saving, setSaving] = useState(false);
- const [mode, setMode] = useState<'visual' | 'source'>('visual');
- const [sourceHtml, setSourceHtml] = useState(template.body || '');
-
- const switchToSource = useCallback(async () => {
- if (editorRef.current) {
- const html = await editorRef.current.getEmailHTML();
- setSourceHtml(html);
- }
- setMode('source');
- }, []);
-
- const switchToVisual = useCallback(() => {
- setMode('visual');
- }, []);
-
- const saveTemplate = useCallback(async () => {
- if (!template._id) {
- toast({ title: 'Error', description: 'Template ID is required' });
- return;
- }
-
- setSaving(true);
- try {
- let finalHtml: string;
-
- if (mode === 'visual' && editorRef.current) {
- finalHtml = await editorRef.current.getEmailHTML();
- } else {
- finalHtml = sourceHtml;
- }
-
- await patchTemplates(template._id, { body: finalHtml });
-
- toast({ title: 'Success', description: 'Template saved successfully' });
- router.refresh();
- onTemplateUpdate?.();
- } catch (error) {
- toast({
- title: 'Error',
- description:
- error instanceof Error ? error.message : 'Failed to save template',
- });
- } finally {
- setSaving(false);
- }
- }, [template._id, mode, sourceHtml, router, toast, onTemplateUpdate]);
- const addVariable = () => {
- const trimmedName = newVariable.trim();
- if (!trimmedName) return;
-
- const validation = validateVariableName(trimmedName);
- if (!validation.isValid) {
- toast({
- title: 'Invalid Variable Name',
- description: validation.error || 'Please enter a valid variable name',
- });
- return;
- }
-
- if (!variables.find(v => v.name === trimmedName)) {
- setVariables(prev => [...prev, { name: trimmedName }]);
- setNewVariable('');
- } else {
- toast({
- title: 'Variable Exists',
- description: 'A variable with this name already exists',
- });
- }
- };
-
- const removeVariable = (name: string) => {
- setVariables(prev => prev.filter(v => v.name !== name));
+ const handleSave = async (html: string) => {
+ await patchTemplates(template._id, {
+ body: html,
+ });
+ router.refresh();
+ onTemplateUpdate?.();
};
return (
-
-
- {/* Header */}
-
-
-
-
- Back
-
-
-
- Edit Template: {template.name}
-
-
- {mode === 'visual' ? 'Visual Editor' : 'Source Editor'}
-
-
-
-
- {mode === 'visual' ? (
-
-
- View Source
-
- ) : (
-
-
- Visual Editor
-
- )}
-
-
- {saving ? 'Saving...' : 'Save Template'}
-
-
-
-
-
- {mode === 'visual' ? (
- <>
-
- Start editing your template...'
- }
- theme="basic"
- className="flex-1 min-w-0 overflow-y-auto"
- onUploadImage={uploadImageAsDataUrl}
- >
-
-
-
-
-
-
-
-
-
- {/* Variables sidebar */}
-
-
-
- >
- ) : (
- <>
-
- setSourceHtml(e.target.value)}
- className="h-full font-mono text-sm resize-none"
- placeholder="Enter your HTML template..."
- />
-
-
-
-
-
- >
- )}
-
-
-
+ handleSave(html)}
+ onClose={onClose}
+ />
);
};
-
-function VariablesSidebar({
- variables,
- newVariable,
- onNewVariableChange,
- onAdd,
- onRemove,
-}: {
- variables: Variable[];
- newVariable: string;
- onNewVariableChange: (value: string) => void;
- onAdd: () => void;
- onRemove: (name: string) => void;
-}) {
- return (
-
-
- Template Variables
-
-
-
- Use {'{{variableName}}'} in your
- template to insert dynamic content.
-
-
-
onNewVariableChange(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && onAdd()}
- />
-
-
-
-
-
-
- {variables.map(variable => (
-
-
- {`{{${variable.name}}}`}
-
- onRemove(variable.name)}
- >
-
-
-
- ))}
-
-
- {variables.length === 0 && (
-
- No variables defined
-
- )}
-
-
- );
-}
diff --git a/src/components/email/templates/createTemplateSheet.tsx b/src/components/email/templates/createTemplateSheet.tsx
index 4845e2250..865d417c7 100644
--- a/src/components/email/templates/createTemplateSheet.tsx
+++ b/src/components/email/templates/createTemplateSheet.tsx
@@ -45,7 +45,9 @@ export const CreateTemplateSheet = () => {
const templateId = await createTemplate(data).then(
res => res.template._id
);
- router.push(`/email/templates/${templateId}?editor-open=true`);
+ router.push(
+ `/communications/templates/email/${templateId}?editor-open=true`
+ );
},
() => undefined
)}
diff --git a/src/components/email/templates/dashboard.tsx b/src/components/email/templates/dashboard.tsx
index a2e801c38..c3bdd35f6 100644
--- a/src/components/email/templates/dashboard.tsx
+++ b/src/components/email/templates/dashboard.tsx
@@ -17,6 +17,7 @@ import { isNil } from 'lodash';
import { syncTemplates } from '@/lib/api/email';
import { useToast } from '@/lib/hooks/use-toast';
import { useRouter } from 'next/navigation';
+import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { CreateTemplateSheet } from '@/components/email/templates/createTemplateSheet';
import { RefreshCw } from 'lucide-react';
@@ -33,6 +34,17 @@ export const TemplatesDashboard = ({
return (
+
+ Email-only templates are still supported. For new multi-channel work,
+ use{' '}
+
+ Templates
+
+ .
+
diff --git a/src/components/email/templates/tables/templates/columns.tsx b/src/components/email/templates/tables/templates/columns.tsx
index b607ae2a3..25d5d7c0f 100644
--- a/src/components/email/templates/tables/templates/columns.tsx
+++ b/src/components/email/templates/tables/templates/columns.tsx
@@ -93,7 +93,9 @@ export function useColumns() {
header: '',
enableSorting: false,
cell: props => (
-
+
),
diff --git a/src/components/navigation/navList.config.ts b/src/components/navigation/navList.config.ts
index e5e27c150..b9de1b3cd 100644
--- a/src/components/navigation/navList.config.ts
+++ b/src/components/navigation/navList.config.ts
@@ -1,5 +1,4 @@
import {
- BellDot,
CodeIcon,
Database,
HardDrive,
@@ -7,7 +6,6 @@ import {
KeyRound,
Logs,
LucideMail,
- LucideMessageSquare,
MessagesSquare,
Router,
Settings,
@@ -102,33 +100,14 @@ export const navGroups: NavGroup[] = [
id: 'communications',
items: [
{
- title: 'Email',
- url: '/email',
+ title: 'Communications',
+ url: '/communications',
icon: LucideMail,
items: [
- { title: 'Templates', url: '/email/templates' },
- { title: 'Send', url: '/email/send' },
- { title: 'Records', url: '/email/records' },
- { title: 'Settings', url: '/email/settings' },
- ],
- },
- {
- title: 'SMS',
- url: '/sms',
- icon: LucideMessageSquare,
- items: [
- { title: 'Test Send', url: '/sms/send' },
- { title: 'Settings', url: '/sms/settings' },
- ],
- },
- {
- title: 'Notifications',
- url: '/push-notifications',
- icon: BellDot,
- items: [
- { title: 'Tokens', url: '/push-notifications/tokens' },
- { title: 'Test Send', url: '/push-notifications/test' },
- { title: 'Settings', url: '/push-notifications/settings' },
+ { title: 'Overview', url: '/communications' },
+ { title: 'Templates', url: '/communications/templates' },
+ { title: 'Logs & Devices', url: '/communications/logs' },
+ { title: 'Settings', url: '/communications/settings' },
],
},
],
diff --git a/src/components/notifications/settings.tsx b/src/components/notifications/settings.tsx
index f445de9ae..c3dc84a54 100644
--- a/src/components/notifications/settings.tsx
+++ b/src/components/notifications/settings.tsx
@@ -21,6 +21,7 @@ import {
interface Props {
data: NotificationSettings;
+ embedded?: boolean;
}
const PUSH_MODULE_NAMES = ['communications', 'pushNotifications'] as const;
@@ -129,7 +130,7 @@ function isPushActivationSuccess(result: PatchSettingsResult | void) {
return isCommunicationsModuleServing(result.modules, PUSH_MODULE_NAMES);
}
-export const Settings = ({ data }: Props) => {
+export const Settings = ({ data, embedded = false }: Props) => {
const [notificationModule, setNotificationModule] = useState(false);
const [edit, setEdit] = useState(false);
const { addAlert } = useAlerts();
@@ -248,49 +249,53 @@ export const Settings = ({ data }: Props) => {
};
return (
-
-
-
-
-
-
- To see more information regarding the Push Notifications config,
- visit our{' '}
-
- docs
-
- .
-
-
+
+
+
+
+
+ To see more information regarding the Push Notifications config,
+ visit our{' '}
+
+ docs
+
+ .
+
- {notificationModule && (
-
-
-
-
-
- )}
+ {notificationModule && (
+
+
+
+
+
+ )}
);
};
diff --git a/src/components/notifications/testSend/testSendForm.tsx b/src/components/notifications/testSend/testSendForm.tsx
index 2dadc5205..05bea17be 100644
--- a/src/components/notifications/testSend/testSendForm.tsx
+++ b/src/components/notifications/testSend/testSendForm.tsx
@@ -16,6 +16,7 @@ import { toast } from '@/lib/hooks/use-toast';
interface Props {
token?: NotificationToken;
+ embedded?: boolean;
}
const FormSchema = z.object({
@@ -27,7 +28,7 @@ const FormSchema = z.object({
doNotStore: z.boolean().optional(),
});
-export const TestSendForm = ({ token }: Props) => {
+export const TestSendForm = ({ token, embedded = false }: Props) => {
const form = useForm
>({
resolver: rhfZodResolver(FormSchema),
});
@@ -71,77 +72,76 @@ export const TestSendForm = ({ token }: Props) => {
reset();
};
- return (
-
-
-
-
- {
- openPicker(
- users => {
- setValue('users', users);
- },
- {
- title: 'Add Recipient(s)',
- description:
- 'Select the recipient(s) to send the notification to',
- multiple: true,
- }
- );
- }}
+ const formBody = (
+
+
+ {
+ openPicker(
+ users => {
+ setValue('users', users);
+ },
+ {
+ title: 'Add Recipient(s)',
+ description:
+ 'Select the recipient(s) to send the notification to',
+ multiple: true,
+ }
+ );
+ }}
+ >
+
+ Add recipient(s)
+
+
+ {form.watch('users')?.map((user, index) => (
+
-
- Add recipient(s)
-
-
- {form.watch('users')?.map((user, index) => (
-
-
{user.email}
-
{
- const users = watch('users');
- setValue(
- 'users',
- users.filter((_, i) => i !== index)
- );
- }}
- >
-
-
-
- ))}
-
-
-
-
-
-
-
-
reset()}>
- Reset
+ {user.email}
+ {
+ const users = watch('users');
+ setValue(
+ 'users',
+ users.filter((_, i) => i !== index)
+ );
+ }}
+ >
+
- Send Notification
-
-
-
+ ))}
+
+
+
+
+
+
+
+ reset()}>
+ Reset
+
+ Send Notification
+
+
+
+ );
+
+ if (embedded) return formBody;
+
+ return (
+
);
};
diff --git a/src/components/notifications/tokens/tokens.tsx b/src/components/notifications/tokens/tokens.tsx
index 7d15a5b40..909344b36 100644
--- a/src/components/notifications/tokens/tokens.tsx
+++ b/src/components/notifications/tokens/tokens.tsx
@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import { Input } from '@/components/ui/input';
// @ts-ignore
import { useDebounce } from '@uidotdev/usehooks';
-import { useSearchParams } from 'next/navigation';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { NotificationToken } from '@/lib/models/notification/NotificationToken';
import { DataTable } from '@/components/ui/data-table';
import { columns } from '@/components/notifications/tokens/columns';
@@ -18,6 +18,8 @@ export default function NotificationTokensTable({
refreshData: (searchString: string) => Promise
;
}) {
const searchParams = useSearchParams();
+ const router = useRouter();
+ const pathname = usePathname();
const [tokens, setTokens] = useState(data);
const [search, setSearch] = useState(
@@ -30,7 +32,7 @@ export default function NotificationTokensTable({
if (debouncedSearchTerm === '') {
params.delete('search');
}
- window.history.pushState(null, '', `?${params.toString()}`);
+ router.replace(`${pathname}?${params.toString()}`, { scroll: false });
if (debouncedSearchTerm === '') {
setTokens(data);
return;
diff --git a/src/components/sms/settings.tsx b/src/components/sms/settings.tsx
index 21831779c..63bdc14e4 100644
--- a/src/components/sms/settings.tsx
+++ b/src/components/sms/settings.tsx
@@ -21,6 +21,7 @@ import {
interface Props {
data: SmsSettings;
+ embedded?: boolean;
}
const SMS_MODULE_NAMES = ['communications', 'sms'] as const;
@@ -78,7 +79,7 @@ function isSmsActivationSuccess(result: PatchSettingsResult | void) {
return isCommunicationsModuleServing(result.modules, SMS_MODULE_NAMES);
}
-export const Settings = ({ data }: Props) => {
+export const Settings = ({ data, embedded = false }: Props) => {
const [smsModule, setSmsModule] = useState(false);
const [edit, setEdit] = useState(false);
const { addAlert } = useAlerts();
@@ -142,38 +143,42 @@ export const Settings = ({ data }: Props) => {
};
return (
-
-
-
-
-
-
- To an idea on how to setup your SMS provider take a look at the
- documentation.
-
-
+
+
+
+
+
+ To an idea on how to setup your SMS provider take a look at the
+ documentation.
+
- {smsModule && (
-
-
-
-
-
- )}
+ {smsModule && (
+
+
+
+
+
+ )}
);
};
diff --git a/src/components/sms/smsTest/testSendSmsForm.tsx b/src/components/sms/smsTest/testSendSmsForm.tsx
index 02c11bdda..98d92ac0b 100644
--- a/src/components/sms/smsTest/testSendSmsForm.tsx
+++ b/src/components/sms/smsTest/testSendSmsForm.tsx
@@ -14,11 +14,15 @@ const FormSchema = z.object({
message: z.string(),
});
-export const TestSendSmsForm = () => {
+export const TestSendSmsForm = ({
+ embedded = false,
+}: {
+ embedded?: boolean;
+}) => {
const form = useForm
>({
resolver: rhfZodResolver(FormSchema),
});
- const { reset, handleSubmit, setValue, watch } = form;
+ const { reset, handleSubmit } = form;
const onSubmit = async (data: z.infer) => {
await testSendSMS({
@@ -30,7 +34,7 @@ export const TestSendSmsForm = () => {
description: 'Text sent',
});
})
- .catch(e => {
+ .catch(() => {
toast({
title: 'SMS',
description: 'Failed to send',
@@ -39,22 +43,26 @@ export const TestSendSmsForm = () => {
reset();
};
+ const formBody = (
+
+
+
+
+
+ reset()}>
+ Reset
+
+ Send SMS
+
+
+
+ );
+
+ if (embedded) return formBody;
+
return (
-
-
-
-
-
-
-
- reset()}>
- Reset
-
- Send SMS
-
-
-
-
+
);
};
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
index 2919ae100..1ac0cd155 100644
--- a/src/components/ui/alert-dialog.tsx
+++ b/src/components/ui/alert-dialog.tsx
@@ -37,7 +37,7 @@ const AlertDialogContent = React.forwardRef<
(fn: () => Promise): Promise {
+ try {
+ return await fn();
+ } catch (err) {
+ throw new Error(formatCommunicationsApiError(err));
+ }
+}
+
+export const getCommunicationTemplates = async (args: {
+ skip?: number;
+ limit?: number;
+ sort?: string;
+ search?: string;
+}) => {
+ type Response = {
+ templateDocuments: CommunicationTemplate[];
+ count: number;
+ };
+ return withCommunicationsError(async () =>
+ (await getApiClient())
+ .get('/communications/templates', { params: args })
+ .then(res => res.data)
+ );
+};
+
+export const getCommunicationTemplate = async (id: string) => {
+ return withCommunicationsError(async () =>
+ (await getApiClient())
+ .get<{
+ template: CommunicationTemplate;
+ }>(`/communications/templates/${id}`)
+ .then(res => res.data.template)
+ );
+};
+
+export const createCommunicationTemplate = async (
+ data: CommunicationTemplatePayload
+) => {
+ return withCommunicationsError(async () =>
+ (await getApiClient())
+ .post<{
+ template: CommunicationTemplate;
+ }>('/communications/templates', data)
+ .then(res => res.data.template)
+ );
+};
+
+export const updateCommunicationTemplate = async (
+ id: string,
+ data: Partial
+) => {
+ return withCommunicationsError(async () =>
+ (await getApiClient())
+ .patch<{
+ template: CommunicationTemplate;
+ }>(`/communications/templates/${id}`, data)
+ .then(res => res.data.template)
+ );
+};
+
+export const deleteCommunicationTemplate = async (id: string) => {
+ return withCommunicationsError(async () =>
+ (await getApiClient())
+ .delete<{ deleted: boolean }>(`/communications/templates/${id}`)
+ .then(res => res.data)
+ );
+};
+
+export const migrateFromEmailTemplate = async (options: {
+ emailTemplateId?: string;
+ dryRun?: boolean;
+ skipExisting?: boolean;
+ deleteSource?: boolean;
+}) => {
+ return withCommunicationsError(async () =>
+ (await getApiClient())
+ .post(
+ '/communications/templates/migrate-from-email',
+ options
+ )
+ .then(res => res.data)
+ );
+};
diff --git a/src/lib/logic/communications-api-error.ts b/src/lib/logic/communications-api-error.ts
new file mode 100644
index 000000000..fb8ed646a
--- /dev/null
+++ b/src/lib/logic/communications-api-error.ts
@@ -0,0 +1,15 @@
+export function formatCommunicationsApiError(err: unknown): string {
+ if (err && typeof err === 'object' && 'response' in err) {
+ const axiosErr = err as {
+ response?: { status?: number; data?: { message?: string } };
+ message?: string;
+ };
+ if (axiosErr.response?.status === 404) {
+ return 'Unified templates API not available — upgrade Conduit to a build that includes CommunicationTemplate CRUD';
+ }
+ return (
+ axiosErr.response?.data?.message ?? axiosErr.message ?? 'Request failed'
+ );
+ }
+ return err instanceof Error ? err.message : 'Request failed';
+}
diff --git a/src/lib/logic/merge-template-rows.ts b/src/lib/logic/merge-template-rows.ts
new file mode 100644
index 000000000..bc2492da6
--- /dev/null
+++ b/src/lib/logic/merge-template-rows.ts
@@ -0,0 +1,42 @@
+import {
+ TemplateRow,
+ getTemplateRowName,
+} from '@/lib/models/communications/template-row';
+import { CommunicationTemplate } from '@/lib/models/communications/templates';
+import { EmailTemplate, ExternalTemplate } from '@/lib/models/email';
+
+export function mergeTemplateRows(
+ communicationTemplates: CommunicationTemplate[],
+ emailTemplates: EmailTemplate[],
+ externalTemplates: ExternalTemplate[] | null
+): TemplateRow[] {
+ const unifiedNames = new Set(
+ communicationTemplates.map(template => template.name)
+ );
+
+ const rows: TemplateRow[] = [
+ ...communicationTemplates.map(template => ({
+ kind: 'unified' as const,
+ template,
+ })),
+ ...emailTemplates
+ .filter(template => !unifiedNames.has(template.name))
+ .map(template => ({
+ kind: 'email' as const,
+ template,
+ })),
+ ];
+
+ if (externalTemplates) {
+ rows.push(
+ ...externalTemplates.map(template => ({
+ kind: 'external' as const,
+ template,
+ }))
+ );
+ }
+
+ return rows.sort((a, b) =>
+ getTemplateRowName(a).localeCompare(getTemplateRowName(b))
+ );
+}
diff --git a/src/lib/models/communications/template-row.ts b/src/lib/models/communications/template-row.ts
new file mode 100644
index 000000000..ec2befac2
--- /dev/null
+++ b/src/lib/models/communications/template-row.ts
@@ -0,0 +1,84 @@
+import {
+ CommunicationTemplate,
+ CommunicationTemplatePayload,
+} from '@/lib/models/communications/templates';
+import { EmailTemplate, ExternalTemplate } from '@/lib/models/email';
+
+export type TemplateRow =
+ | { kind: 'unified'; template: CommunicationTemplate }
+ | { kind: 'email'; template: EmailTemplate }
+ | { kind: 'external'; template: ExternalTemplate };
+
+export type TemplateFilter = 'all' | 'unified' | 'email' | 'external';
+
+export type ChannelFilter = 'all' | 'email' | 'push' | 'sms';
+
+export type MigrationPlannedItem = {
+ sourceId: string;
+ sourceName: string;
+ target: CommunicationTemplatePayload;
+ skipped?: boolean;
+ warning?: string;
+};
+
+export type MigrationResponse = {
+ dryRun: boolean;
+ planned: MigrationPlannedItem[];
+ created?: CommunicationTemplate[];
+ count?: number;
+};
+
+export function getTemplateRowName(row: TemplateRow): string {
+ return row.template.name;
+}
+
+export function getTemplateRowId(row: TemplateRow): string {
+ return row.template._id;
+}
+
+export function getTemplateRowCreatedAt(row: TemplateRow): string {
+ const createdAt = row.template.createdAt;
+ return typeof createdAt === 'string' ? createdAt : createdAt.toISOString();
+}
+
+export function getTemplateRowVariablesCount(row: TemplateRow): number {
+ return row.template.variables?.length ?? 0;
+}
+
+export function filterTemplateRows(
+ rows: TemplateRow[],
+ filter: TemplateFilter
+): TemplateRow[] {
+ switch (filter) {
+ case 'all':
+ return rows;
+ case 'unified':
+ return rows.filter(row => row.kind === 'unified');
+ case 'email':
+ return rows.filter(row => row.kind === 'email');
+ case 'external':
+ return rows.filter(row => row.kind === 'external');
+ default: {
+ const _exhaustive: never = filter;
+ return _exhaustive;
+ }
+ }
+}
+
+export function rowMatchesChannel(
+ row: TemplateRow,
+ channel: ChannelFilter
+): boolean {
+ if (channel === 'all') return true;
+ switch (row.kind) {
+ case 'email':
+ case 'external':
+ return channel === 'email';
+ case 'unified':
+ return row.template.channels.includes(channel);
+ default: {
+ const _exhaustive: never = row;
+ return _exhaustive;
+ }
+ }
+}
diff --git a/src/lib/models/communications/templates.ts b/src/lib/models/communications/templates.ts
new file mode 100644
index 000000000..33a3dd29e
--- /dev/null
+++ b/src/lib/models/communications/templates.ts
@@ -0,0 +1,33 @@
+export type CommunicationChannel = 'email' | 'push' | 'sms';
+
+export interface CommunicationTemplate {
+ _id: string;
+ name: string;
+ summary?: string;
+ channels: CommunicationChannel[];
+ email?: {
+ subject?: string;
+ body?: string;
+ sender?: string;
+ };
+ push?: {
+ title?: string;
+ body?: string;
+ };
+ sms?: {
+ message?: string;
+ };
+ variables?: string[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CommunicationTemplatePayload {
+ name: string;
+ channels: CommunicationChannel[];
+ email?: CommunicationTemplate['email'];
+ push?: CommunicationTemplate['push'];
+ sms?: CommunicationTemplate['sms'];
+ variables?: string[];
+ templateDescription?: string;
+}
diff --git a/src/lib/utils/module-utils.ts b/src/lib/utils/module-utils.ts
index 2340abbc3..d4d3ce239 100644
--- a/src/lib/utils/module-utils.ts
+++ b/src/lib/utils/module-utils.ts
@@ -60,7 +60,7 @@ export const MODULE_DISPLAY_NAMES: Record = {
sms: 'SMS',
router: 'Router',
functions: 'Functions',
- 'push-notifications': 'Notifications',
+ 'push-notifications': 'Push',
payments: 'Payments',
settings: 'Settings',
communications: 'Communications',
@@ -78,6 +78,7 @@ export const MODULE_URL_TO_NAME: Record = {
'/router': 'router',
'/functions': 'functions',
'/push-notifications': 'pushNotifications', // URL path maps to API module name
+ '/communications': 'communications',
'/payments': 'payments',
'/settings': 'settings',
};
@@ -94,6 +95,7 @@ export const MODULE_NAME_TO_URL: Record = {
router: 'router',
functions: 'functions',
pushNotifications: 'push-notifications', // API module name maps to URL path
+ communications: 'communications',
payments: 'payments',
settings: 'settings',
};