From 6e9639eb8eba8ed17745bc78fe981480be35b2c9 Mon Sep 17 00:00:00 2001 From: Konstantinos Kopanidis Date: Sun, 21 Jun 2026 19:43:13 +0300 Subject: [PATCH 1/5] feat(ui): unified communications templates management Add list, create, edit, and delete flows for multi-channel templates plus email-template migration entry points under Communications navigation. --- .../communications/templates/[_id]/page.tsx | 138 ++++++++ .../communications/templates/page.tsx | 25 ++ .../(modules)/email/templates/[_id]/page.tsx | 21 ++ .../communications/templates/columns.tsx | 77 +++++ .../templates/create-template-sheet.tsx | 62 ++++ .../communications/templates/dashboard.tsx | 40 +++ .../communications/templates/data-table.tsx | 26 ++ .../templates/template-form.tsx | 312 ++++++++++++++++++ src/components/email/templates/dashboard.tsx | 11 + src/components/navigation/navList.config.ts | 5 + src/lib/api/communications/templates.ts | 66 ++++ src/lib/models/communications/templates.ts | 33 ++ 12 files changed, 816 insertions(+) create mode 100644 src/app/(dashboard)/(modules)/communications/templates/[_id]/page.tsx create mode 100644 src/app/(dashboard)/(modules)/communications/templates/page.tsx create mode 100644 src/components/communications/templates/columns.tsx create mode 100644 src/components/communications/templates/create-template-sheet.tsx create mode 100644 src/components/communications/templates/dashboard.tsx create mode 100644 src/components/communications/templates/data-table.tsx create mode 100644 src/components/communications/templates/template-form.tsx create mode 100644 src/lib/api/communications/templates.ts create mode 100644 src/lib/models/communications/templates.ts 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..54e14c11b --- /dev/null +++ b/src/app/(dashboard)/(modules)/communications/templates/[_id]/page.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { CommunicationTemplate } from '@/lib/models/communications/templates'; +import { + getCommunicationTemplates, + updateCommunicationTemplate, +} from '@/lib/api/communications/templates'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +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'; + +type CommunicationTemplatePageProps = { + params: Promise<{ _id: string }>; +}; + +export default function CommunicationTemplateDetailPage( + props: CommunicationTemplatePageProps +) { + const [template, setTemplate] = useState(null); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + useEffect(() => { + const load = async () => { + const { _id } = await props.params; + try { + const data = await getCommunicationTemplates({ search: _id, limit: 1 }); + if (data.templateDocuments[0]) { + setTemplate(data.templateDocuments[0]); + } + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + load(); + }, [props]); + + const handleSave = async (values: CommunicationTemplateFormValues) => { + if (!template) return; + await updateCommunicationTemplate(template._id, values) + .then(updated => { + setTemplate(updated); + setEditing(false); + toast({ + title: 'Communications', + description: 'Template updated', + }); + router.refresh(); + }) + .catch(err => + toast({ title: 'Communications', description: err.message }) + ); + }; + + if (loading) { + return

Loading template...

; + } + + if (!template) { + return

Template not found

; + } + + const defaultValues: CommunicationTemplateFormValues = { + name: template.name, + templateDescription: template.summary, + channels: template.channels, + email: template.email, + push: template.push, + sms: template.sms, + }; + + return ( +
+ + {template.name} + + + + + + {!editing && ( +
+ {template.summary && ( +

{template.summary}

+ )} +
+ {template.channels.map(channel => ( + + {channel} + + ))} +
+ {template.variables && template.variables.length > 0 && ( +
+

Variables

+
+ {template.variables.map(variable => ( + + {`{{${variable}}}`} + + ))} +
+
+ )} +
+ )} + + {editing && ( + + )} +
+ ); +} 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..7dec71a5d --- /dev/null +++ b/src/app/(dashboard)/(modules)/communications/templates/page.tsx @@ -0,0 +1,25 @@ +import { getCommunicationTemplates } from '@/lib/api/communications/templates'; +import { CommunicationTemplatesDashboard } from '@/components/communications/templates/dashboard'; + +type CommunicationTemplatesParams = { + searchParams: Promise<{ + pageIndex?: number; + sort?: string; + search?: string; + }>; +}; + +export default async function CommunicationTemplatesPage( + props: Readonly +) { + const searchParams = await props.searchParams; + + const templates = await getCommunicationTemplates({ + skip: searchParams.pageIndex ? searchParams.pageIndex * 10 : 0, + limit: 10, + sort: searchParams.sort, + search: searchParams.search, + }); + + return ; +} diff --git a/src/app/(dashboard)/(modules)/email/templates/[_id]/page.tsx b/src/app/(dashboard)/(modules)/email/templates/[_id]/page.tsx index c3b227048..6076c0cec 100644 --- a/src/app/(dashboard)/(modules)/email/templates/[_id]/page.tsx +++ b/src/app/(dashboard)/(modules)/email/templates/[_id]/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { getTemplates } from '@/lib/api/email'; +import { migrateFromEmailTemplate } from '@/lib/api/communications/templates'; import React, { useEffect, useState } from 'react'; import { EmailTemplate } from '@/lib/models/email'; import { Button } from '@/components/ui/button'; @@ -153,6 +154,26 @@ export default function EmailTemplatePage(props: EmailTemplateProps) { Edit Template + {!isExternallyManaged(template) && ( + + )} diff --git a/src/components/communications/templates/columns.tsx b/src/components/communications/templates/columns.tsx new file mode 100644 index 000000000..28a1a43d2 --- /dev/null +++ b/src/components/communications/templates/columns.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { ColumnDef } from '@tanstack/react-table'; +import moment from 'moment/moment'; +import { DeleteAlert } from '@/components/helpers/delete'; +import React, { useMemo } 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 { CommunicationTemplate } from '@/lib/models/communications/templates'; +import { deleteCommunicationTemplate } from '@/lib/api/communications/templates'; +import { Badge } from '@/components/ui/badge'; + +export function useCommunicationTemplateColumns() { + const router = useRouter(); + const { toast } = useToast(); + + return useMemo[]>( + () => [ + { + accessorKey: 'name', + header: 'Name', + }, + { + accessorKey: 'channels', + header: 'Channels', + cell: ({ row }) => ( +
+ {row.original.channels.map(channel => ( + + {channel} + + ))} +
+ ), + }, + { + accessorKey: 'variables', + header: 'Variables', + cell: ({ row }) => row.original.variables?.length ?? 0, + }, + { + accessorKey: 'createdAt', + header: 'Created', + cell: props => moment(props.getValue() as string).format('DD MMM YYYY'), + }, + { + id: 'delete', + header: '', + cell: props => ( + + deleteCommunicationTemplate(props.row.original._id) + .then(() => router.refresh()) + .catch(err => + toast({ title: 'Communications', description: err.message }) + ) + } + /> + ), + }, + { + id: 'view', + header: '', + cell: props => ( + + + + ), + }, + ], + [router, toast] + ); +} 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..dba0eb9ce --- /dev/null +++ b/src/components/communications/templates/create-template-sheet.tsx @@ -0,0 +1,62 @@ +'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 { + CommunicationTemplateForm, + CommunicationTemplateFormValues, +} from './template-form'; + +export function CreateCommunicationTemplateSheet() { + const [open, setOpen] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + const handleSubmit = async (values: CommunicationTemplateFormValues) => { + await createCommunicationTemplate(values) + .then(() => { + toast({ + title: 'Communications', + description: 'Unified template created', + }); + setOpen(false); + router.refresh(); + }) + .catch(err => + toast({ title: 'Communications', description: err.message }) + ); + }; + + return ( + + + + + + + Create unified template + +
+ +
+
+
+ ); +} diff --git a/src/components/communications/templates/dashboard.tsx b/src/components/communications/templates/dashboard.tsx new file mode 100644 index 000000000..13e3af82b --- /dev/null +++ b/src/components/communications/templates/dashboard.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { CommunicationTemplatesResponse } from './data-table'; +import { CommunicationTemplatesTable } from './data-table'; +import { SearchInput } from '@/components/helpers/search'; +import { CreateCommunicationTemplateSheet } from './create-template-sheet'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import Link from 'next/link'; + +export function CommunicationTemplatesDashboard({ + data, +}: { + data: CommunicationTemplatesResponse; +}) { + return ( +
+ + Unified templates + + Manage multi-channel templates used by orchestrated sends. Email-only + templates remain available under{' '} + + Email → Templates + + . + + + +
+ + +
+ + +
+ ); +} diff --git a/src/components/communications/templates/data-table.tsx b/src/components/communications/templates/data-table.tsx new file mode 100644 index 000000000..364f1599e --- /dev/null +++ b/src/components/communications/templates/data-table.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { DataTable } from '@/components/ui/data-table'; +import { CommunicationTemplate } from '@/lib/models/communications/templates'; +import { useCommunicationTemplateColumns } from './columns'; + +export type CommunicationTemplatesResponse = { + templateDocuments: CommunicationTemplate[]; + count: number; +}; + +export function CommunicationTemplatesTable({ + data, +}: { + data: CommunicationTemplatesResponse; +}) { + const columns = useCommunicationTemplateColumns(); + + return ( + + ); +} diff --git a/src/components/communications/templates/template-form.tsx b/src/components/communications/templates/template-form.tsx new file mode 100644 index 000000000..40dae72c8 --- /dev/null +++ b/src/components/communications/templates/template-form.tsx @@ -0,0 +1,312 @@ +'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' }, +]; + +export const communicationTemplateSchema = 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(), + }) + .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 (!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< + typeof communicationTemplateSchema +>; + +export function CommunicationTemplateForm({ + defaultValues, + onSubmit, + submitLabel, + disableName, +}: { + defaultValues?: Partial; + onSubmit: (values: CommunicationTemplateFormValues) => Promise; + submitLabel: string; + disableName?: boolean; +}) { + const form = useForm({ + resolver: zodResolver(communicationTemplateSchema), + 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 ( +
+ onSubmit(values))} + > +
+ ( + + Template name + + + + + + )} + /> + ( + + Description + + + + + + )} + /> +
+ +
+ Channels +
+ {channelOptions.map(option => ( + + ))} +
+
+ + + + {channels.includes('email') && ( + Email + )} + {channels.includes('push') && ( + Push + )} + {channels.includes('sms') && ( + SMS + )} + + + {channels.includes('email') && ( + + ( + + Sender + + + + + + )} + /> + ( + + Subject + + + + + + )} + /> + ( + + Body + +