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 && ( + <> + + + + )} + + + + + {!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 ( -
-
-

Template not found

-
-
- ); - } - - if (showEditor) { - if (isExternallyManaged(template)) { - return ( -
-
- -

External Template

-

- This template is managed by an external system and cannot be - edited here. -

- -
-
- ); - } - - return ( - setShowEditor(false)} - onTemplateUpdate={refreshTemplate} - /> - ); - } - - return ( -
- - Template: {template.name} - - {!isExternallyManaged(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. + + + )} +
+ )} + + + + + +
+
+ ); +} 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 ( + + + + + + + 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 ( +
+ + + + +
+ ); + case 'external': + return ( +
+ + +
+ ); + 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 ( + + + + + + + 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) && ( + + )} + +
+ + + + {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 ( +
+
+
+
+ +
+

{title}

+

+ {mode === 'visual' ? 'Visual Editor' : 'Source Editor'} +

+
+
+
+ {mode === 'visual' ? ( + + ) : ( + + )} + +
+
+ +
+ {mode === 'visual' ? ( + <> +
+ Start editing your template...

'} + theme="basic" + className="min-w-0 flex-1 overflow-y-auto" + onUploadImage={uploadImageAsDataUrl} + > + + + + + + +
+
+ + + ) : ( + <> +
+