Skip to content

Commit 1395f34

Browse files
committed
Added email templates
1 parent 74e140f commit 1395f34

6 files changed

Lines changed: 468 additions & 1 deletion

File tree

src/Authenticated.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const CalendarPage = React.lazy(() => import("./calendars/CalendarPage").then((m
5050
const RegistrationsPage = React.lazy(() => import("./registrations/RegistrationsPage").then((module) => ({ default: module.RegistrationsPage })));
5151
const RegistrationDetailsPage = React.lazy(() => import("./registrations/RegistrationDetailsPage").then((module) => ({ default: module.RegistrationDetailsPage })));
5252
const Site = React.lazy(() => import("./site").then((module) => ({ default: module.Site })));
53+
const EmailTemplatesPage = React.lazy(() => import("./settings/EmailTemplatesPage").then((module) => ({ default: module.EmailTemplatesPage })));
5354

5455
// Loading component for Suspense fallback
5556
const LoadingFallback: React.FC = () => (
@@ -110,6 +111,7 @@ export const Authenticated: React.FC = () => {
110111
<Route path="/forms" element={<FormsPage />} />
111112
<Route path="/reports/:keyName" element={<ReportPage />} />
112113
<Route path="/reports" element={<ReportsPage />} />
114+
<Route path="/email-templates" element={<EmailTemplatesPage />} />
113115
<Route path="/settings/*" element={<Settings />} />
114116
<Route path="/serving/tasks/automations" element={<AutomationsPage />} />
115117
<Route path="/serving/tasks/:id" element={<TaskPage />} />

src/groups/components/GroupBanner.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import {
99
CheckCircle as CheckIcon,
1010
Cancel as CancelIcon,
1111
Event as CalendarIcon,
12-
Sms as SmsIcon
12+
Sms as SmsIcon,
13+
Email as EmailIcon
1314
} from "@mui/icons-material";
1415
import React, { memo, useMemo } from "react";
1516
import { SendTextDialog } from "./SendTextDialog";
17+
import { SendEmailDialog } from "./SendEmailDialog";
1618

1719
interface Props {
1820
group: GroupInterface;
@@ -24,6 +26,7 @@ export const GroupBanner = memo((props: Props) => {
2426
const { group, onEdit, editMode } = props;
2527
const [groupServiceTimes, setGroupServiceTimes] = React.useState<GroupServiceTimeInterface[]>([]);
2628
const [showTextDialog, setShowTextDialog] = React.useState(false);
29+
const [showEmailDialog, setShowEmailDialog] = React.useState(false);
2730
const [hasTextingProvider, setHasTextingProvider] = React.useState(false);
2831

2932
const canEdit = useMemo(() => UserHelper.checkAccess(Permissions.membershipApi.groups.edit), []);
@@ -187,6 +190,11 @@ export const GroupBanner = memo((props: Props) => {
187190
{groupType}
188191
</Stack>
189192
<Stack direction="row" spacing={0.5} alignItems="center">
193+
<Tooltip title="Email this group">
194+
<IconButton size="small" sx={{ color: "#FFF" }} onClick={() => setShowEmailDialog(true)}>
195+
<EmailIcon fontSize="small" />
196+
</IconButton>
197+
</Tooltip>
190198
{canText && hasTextingProvider && (
191199
<Tooltip title="Text this group">
192200
<IconButton size="small" sx={{ color: "#FFF" }} onClick={() => setShowTextDialog(true)}>
@@ -386,6 +394,13 @@ export const GroupBanner = memo((props: Props) => {
386394
onClose={() => setShowTextDialog(false)}
387395
/>
388396
)}
397+
{showEmailDialog && (
398+
<SendEmailDialog
399+
groupId={group?.id}
400+
groupName={group?.name}
401+
onClose={() => setShowEmailDialog(false)}
402+
/>
403+
)}
389404
</div>
390405
);
391406
});
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import React from "react";
2+
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, FormControl, InputLabel, MenuItem, Select, Typography, CircularProgress, Alert } from "@mui/material";
3+
import type { SelectChangeEvent } from "@mui/material";
4+
import { ApiHelper } from "@churchapps/apphelper";
5+
6+
interface EmailTemplateOption {
7+
id: string;
8+
name: string;
9+
subject: string;
10+
category: string;
11+
}
12+
13+
interface PreviewData {
14+
totalMembers: number;
15+
eligibleCount: number;
16+
noEmailCount: number;
17+
}
18+
19+
interface SendResult {
20+
totalMembers: number;
21+
recipientCount: number;
22+
successCount: number;
23+
failCount: number;
24+
noEmailCount: number;
25+
}
26+
27+
interface Props {
28+
groupId: string;
29+
groupName: string;
30+
onClose: () => void;
31+
}
32+
33+
export const SendEmailDialog: React.FC<Props> = (props) => {
34+
const [templates, setTemplates] = React.useState<EmailTemplateOption[]>([]);
35+
const [selectedTemplateId, setSelectedTemplateId] = React.useState("");
36+
const [sending, setSending] = React.useState(false);
37+
const [result, setResult] = React.useState<SendResult | null>(null);
38+
const [error, setError] = React.useState("");
39+
const [preview, setPreview] = React.useState<PreviewData | null>(null);
40+
const [loadingPreview, setLoadingPreview] = React.useState(false);
41+
const [loadingTemplates, setLoadingTemplates] = React.useState(true);
42+
43+
// Load templates on mount
44+
React.useEffect(() => {
45+
setLoadingTemplates(true);
46+
ApiHelper.get("/messaging/emailTemplates", "MessagingApi")
47+
.then((data) => setTemplates(data || []))
48+
.catch(() => { /* templates load failure is handled by empty list */ })
49+
.finally(() => setLoadingTemplates(false));
50+
}, []);
51+
52+
// Load preview data for group
53+
React.useEffect(() => {
54+
if (!props.groupId) return;
55+
setLoadingPreview(true);
56+
ApiHelper.get("/messaging/emailTemplates/preview/" + props.groupId, "MessagingApi")
57+
.then((data) => setPreview(data))
58+
.catch(() => { /* preview is optional */ })
59+
.finally(() => setLoadingPreview(false));
60+
}, [props.groupId]);
61+
62+
const handleSend = async () => {
63+
if (!selectedTemplateId) return;
64+
setSending(true);
65+
setError("");
66+
try {
67+
const resp = await ApiHelper.post("/messaging/emailTemplates/send", { templateId: selectedTemplateId, groupId: props.groupId }, "MessagingApi");
68+
if (resp.error) {
69+
setError(resp.error);
70+
} else {
71+
setResult(resp);
72+
}
73+
} catch (err: any) {
74+
setError(err?.message || "Failed to send email.");
75+
} finally {
76+
setSending(false);
77+
}
78+
};
79+
80+
const renderPreview = () => {
81+
if (loadingPreview) return <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>Loading recipients...</Typography>;
82+
if (!preview) return <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>This will send an email to eligible group members.</Typography>;
83+
84+
return (
85+
<Alert severity={preview.eligibleCount > 0 ? "info" : "warning"} sx={{ mb: 2 }}>
86+
<strong>{preview.eligibleCount}</strong> of {preview.totalMembers} member{preview.totalMembers !== 1 ? "s" : ""} will receive this email.
87+
{preview.noEmailCount > 0 && <><br />{preview.noEmailCount} ha{preview.noEmailCount !== 1 ? "ve" : "s"} no email address on file.</>}
88+
</Alert>
89+
);
90+
};
91+
92+
const renderResult = () => {
93+
if (!result) return null;
94+
return (
95+
<>
96+
<Alert severity={result.failCount === 0 ? "success" : "warning"} sx={{ mt: 1 }}>
97+
Sent to {result.successCount} of {result.recipientCount} eligible recipient{result.recipientCount !== 1 ? "s" : ""}.
98+
{result.failCount > 0 && <><br />{result.failCount} failed to send.</>}
99+
</Alert>
100+
{result.noEmailCount > 0 && (
101+
<Alert severity="info" sx={{ mt: 1 }}>
102+
{result.noEmailCount} skipped (no email address on file).
103+
</Alert>
104+
)}
105+
</>
106+
);
107+
};
108+
109+
const selectedTemplate = templates.find(t => t.id === selectedTemplateId);
110+
const canSend = !sending && !!selectedTemplateId && (!preview || preview.eligibleCount > 0);
111+
112+
return (
113+
<Dialog open={true} onClose={props.onClose} maxWidth="sm" fullWidth>
114+
<DialogTitle>Email Group: {props.groupName}</DialogTitle>
115+
<DialogContent>
116+
{result ? renderResult() : (
117+
<>
118+
{renderPreview()}
119+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
120+
121+
{loadingTemplates ? (
122+
<Typography variant="body2" color="textSecondary">Loading templates...</Typography>
123+
) : templates.length === 0 ? (
124+
<Alert severity="warning">
125+
No email templates found. <a href="/email-templates">Create one first</a>.
126+
</Alert>
127+
) : (
128+
<>
129+
<FormControl fullWidth sx={{ mt: 1 }}>
130+
<InputLabel>Email Template</InputLabel>
131+
<Select
132+
label="Email Template"
133+
value={selectedTemplateId}
134+
onChange={(e: SelectChangeEvent) => setSelectedTemplateId(e.target.value)}
135+
disabled={sending}
136+
>
137+
{templates.map((t) => (
138+
<MenuItem key={t.id} value={t.id}>
139+
{t.name} {t.category ? `(${t.category})` : ""}
140+
</MenuItem>
141+
))}
142+
</Select>
143+
</FormControl>
144+
{selectedTemplate && (
145+
<Typography variant="caption" color="textSecondary" sx={{ mt: 1, display: "block" }}>
146+
Subject: {selectedTemplate.subject}
147+
</Typography>
148+
)}
149+
</>
150+
)}
151+
</>
152+
)}
153+
</DialogContent>
154+
<DialogActions>
155+
{result ? (
156+
<Button onClick={props.onClose}>Close</Button>
157+
) : (
158+
<>
159+
<Button onClick={props.onClose} disabled={sending}>Cancel</Button>
160+
<Button
161+
variant="contained"
162+
onClick={handleSend}
163+
disabled={!canSend}
164+
startIcon={sending ? <CircularProgress size={16} /> : null}
165+
>
166+
{sending ? "Sending..." : "Send Email"}
167+
</Button>
168+
</>
169+
)}
170+
</DialogActions>
171+
</Dialog>
172+
);
173+
};

src/groups/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ export { ServiceTimesEdit } from "./ServiceTimesEdit";
1313
export { SessionAdd } from "./SessionAdd";
1414
export { SessionCard } from "./SessionCard";
1515
export { SendTextDialog } from "./SendTextDialog";
16+
export { SendEmailDialog } from "./SendEmailDialog";
1617
export { Tabs } from "./Tabs";
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import React, { useState, useEffect, useCallback } from "react";
2+
import { Box, Table, TableHead, TableRow, TableCell, TableBody, IconButton, Tooltip, Stack, Button, Typography, Chip } from "@mui/material";
3+
import { Email as EmailIcon, Edit as EditIcon, Delete as DeleteIcon, Add as AddIcon } from "@mui/icons-material";
4+
import { ApiHelper, Locale, Loading, PageHeader, UserHelper, Permissions } from "@churchapps/apphelper";
5+
import { EmailTemplateEdit } from "./components/EmailTemplateEdit";
6+
7+
export interface EmailTemplateInterface {
8+
id?: string;
9+
churchId?: string;
10+
name?: string;
11+
subject?: string;
12+
htmlContent?: string;
13+
category?: string;
14+
dateCreated?: Date;
15+
dateModified?: Date;
16+
}
17+
18+
export const EmailTemplatesPage: React.FC = () => {
19+
const [templates, setTemplates] = useState<EmailTemplateInterface[]>([]);
20+
const [editTemplate, setEditTemplate] = useState<EmailTemplateInterface | null>(null);
21+
const [loading, setLoading] = useState(true);
22+
23+
const loadData = useCallback(() => {
24+
setLoading(true);
25+
ApiHelper.get("/messaging/emailTemplates", "MessagingApi")
26+
.then((data: EmailTemplateInterface[]) => setTemplates(data || []))
27+
.finally(() => setLoading(false));
28+
}, []);
29+
30+
useEffect(() => { loadData(); }, [loadData]);
31+
32+
const handleDelete = async (template: EmailTemplateInterface) => {
33+
if (!window.confirm(`Delete template "${template.name}"?`)) return;
34+
await ApiHelper.delete("/messaging/emailTemplates/" + UserHelper.currentUserChurch.church.id + "/" + template.id, "MessagingApi");
35+
loadData();
36+
};
37+
38+
const handleEdit = (template: EmailTemplateInterface) => {
39+
// Load full template (list view doesn't include htmlContent)
40+
ApiHelper.get("/messaging/emailTemplates/" + template.id, "MessagingApi").then((data: EmailTemplateInterface) => {
41+
setEditTemplate(data);
42+
});
43+
};
44+
45+
const handleNew = () => {
46+
setEditTemplate({ name: "", subject: "", htmlContent: "", category: "General" });
47+
};
48+
49+
const handleSaved = () => {
50+
setEditTemplate(null);
51+
loadData();
52+
};
53+
54+
const formatDate = (date: Date | string | undefined) => {
55+
if (!date) return "";
56+
const d = new Date(date);
57+
return d.toLocaleDateString();
58+
};
59+
60+
if (loading) return <Loading />;
61+
62+
return (
63+
<>
64+
<PageHeader icon={<EmailIcon />} title="Email Templates" subtitle="Create and manage reusable email templates">
65+
<Button variant="contained" startIcon={<AddIcon />} onClick={handleNew} sx={{ color: "#FFF", backgroundColor: "rgba(255,255,255,0.2)", borderColor: "#FFF", "&:hover": { backgroundColor: "rgba(255,255,255,0.3)" } }}>
66+
New Template
67+
</Button>
68+
</PageHeader>
69+
70+
<Box sx={{ p: 2 }}>
71+
{editTemplate !== null ? (
72+
<EmailTemplateEdit template={editTemplate} onSave={handleSaved} onCancel={() => setEditTemplate(null)} onDelete={editTemplate.id ? () => { handleDelete(editTemplate); setEditTemplate(null); } : undefined} />
73+
) : (
74+
<>
75+
{templates.length === 0 ? (
76+
<Box sx={{ textAlign: "center", py: 6 }}>
77+
<EmailIcon sx={{ fontSize: 48, color: "text.secondary", mb: 2 }} />
78+
<Typography variant="h6" color="text.secondary">No email templates yet</Typography>
79+
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>Create a template to get started with bulk email.</Typography>
80+
<Button variant="contained" startIcon={<AddIcon />} onClick={handleNew}>Create Template</Button>
81+
</Box>
82+
) : (
83+
<Table>
84+
<TableHead>
85+
<TableRow>
86+
<TableCell>Name</TableCell>
87+
<TableCell>Subject</TableCell>
88+
<TableCell>Category</TableCell>
89+
<TableCell>Modified</TableCell>
90+
<TableCell align="right">Actions</TableCell>
91+
</TableRow>
92+
</TableHead>
93+
<TableBody>
94+
{templates.map((t) => (
95+
<TableRow key={t.id} hover sx={{ cursor: "pointer" }} onClick={() => handleEdit(t)}>
96+
<TableCell><Typography fontWeight={600}>{t.name}</Typography></TableCell>
97+
<TableCell>{t.subject}</TableCell>
98+
<TableCell>{t.category && <Chip label={t.category} size="small" />}</TableCell>
99+
<TableCell>{formatDate(t.dateModified)}</TableCell>
100+
<TableCell align="right">
101+
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
102+
<Tooltip title="Edit">
103+
<IconButton size="small" onClick={(e) => { e.stopPropagation(); handleEdit(t); }}><EditIcon fontSize="small" /></IconButton>
104+
</Tooltip>
105+
<Tooltip title="Delete">
106+
<IconButton size="small" onClick={(e) => { e.stopPropagation(); handleDelete(t); }}><DeleteIcon fontSize="small" /></IconButton>
107+
</Tooltip>
108+
</Stack>
109+
</TableCell>
110+
</TableRow>
111+
))}
112+
</TableBody>
113+
</Table>
114+
)}
115+
</>
116+
)}
117+
</Box>
118+
</>
119+
);
120+
};
121+
122+
export default EmailTemplatesPage;

0 commit comments

Comments
 (0)