Skip to content

Commit b4d47c8

Browse files
committed
Added texting
1 parent faf9dca commit b4d47c8

7 files changed

Lines changed: 387 additions & 6 deletions

File tree

src/groups/components/GroupBanner.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { type GroupInterface, type GroupServiceTimeInterface } from "@churchapps/helpers";
22
import { UserHelper, Permissions, ApiHelper, Locale } from "@churchapps/apphelper";
3-
import { Typography, Chip, IconButton, Stack, Box } from "@mui/material";
3+
import { Typography, Chip, IconButton, Stack, Box, Tooltip } from "@mui/material";
44
import {
55
Edit as EditIcon,
66
Schedule as ScheduleIcon,
77
LocationOn as LocationIcon,
88
Group as GroupIcon,
99
CheckCircle as CheckIcon,
1010
Cancel as CancelIcon,
11-
Event as CalendarIcon
11+
Event as CalendarIcon,
12+
Sms as SmsIcon
1213
} from "@mui/icons-material";
1314
import React, { memo, useMemo } from "react";
15+
import { SendTextDialog } from "./SendTextDialog";
1416

1517
interface Props {
1618
group: GroupInterface;
@@ -21,8 +23,19 @@ interface Props {
2123
export const GroupBanner = memo((props: Props) => {
2224
const { group, onEdit, editMode } = props;
2325
const [groupServiceTimes, setGroupServiceTimes] = React.useState<GroupServiceTimeInterface[]>([]);
26+
const [showTextDialog, setShowTextDialog] = React.useState(false);
27+
const [hasTextingProvider, setHasTextingProvider] = React.useState(false);
2428

2529
const canEdit = useMemo(() => UserHelper.checkAccess(Permissions.membershipApi.groups.edit), []);
30+
const canText = useMemo(() => UserHelper.checkAccess(Permissions.messagingApi.texting.edit), []);
31+
32+
React.useEffect(() => {
33+
if (canText) {
34+
ApiHelper.get("/texting/providers", "MessagingApi")
35+
.then((data: any[]) => setHasTextingProvider(data?.length > 0))
36+
.catch(() => setHasTextingProvider(false));
37+
}
38+
}, [canText]);
2639

2740
React.useEffect(() => {
2841
if (group?.id) {
@@ -173,6 +186,13 @@ export const GroupBanner = memo((props: Props) => {
173186
</Typography>
174187
{groupType}
175188
</Stack>
189+
{canText && hasTextingProvider && (
190+
<Tooltip title="Text this group">
191+
<IconButton size="small" sx={{ color: "#FFF" }} onClick={() => setShowTextDialog(true)}>
192+
<SmsIcon fontSize="small" />
193+
</IconButton>
194+
</Tooltip>
195+
)}
176196
{canEdit && (
177197
<IconButton size="small" sx={{ color: "#FFF" }} onClick={onEdit}>
178198
<EditIcon fontSize="small" />
@@ -357,6 +377,13 @@ export const GroupBanner = memo((props: Props) => {
357377
</Box>
358378
)}
359379
</Stack>
380+
{showTextDialog && (
381+
<SendTextDialog
382+
groupId={group?.id}
383+
groupName={group?.name}
384+
onClose={() => setShowTextDialog(false)}
385+
/>
386+
)}
360387
</div>
361388
);
362389
});
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import React from "react";
2+
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Typography, CircularProgress, Alert } from "@mui/material";
3+
import { ApiHelper } from "@churchapps/apphelper";
4+
5+
interface Props {
6+
// Group mode
7+
groupId?: string;
8+
groupName?: string;
9+
// Person mode
10+
personId?: string;
11+
personName?: string;
12+
phoneNumber?: string;
13+
// Common
14+
onClose: () => void;
15+
}
16+
17+
interface PreviewData {
18+
totalMembers: number;
19+
eligibleCount: number;
20+
optedOutCount: number;
21+
noPhoneCount: number;
22+
}
23+
24+
interface SendResult {
25+
totalMembers: number;
26+
recipientCount: number;
27+
successCount: number;
28+
failCount: number;
29+
optedOutCount: number;
30+
noPhoneCount: number;
31+
}
32+
33+
export const SendTextDialog: React.FC<Props> = (props) => {
34+
const [message, setMessage] = React.useState("");
35+
const [sending, setSending] = React.useState(false);
36+
const [result, setResult] = React.useState<SendResult | null>(null);
37+
const [error, setError] = React.useState("");
38+
const [preview, setPreview] = React.useState<PreviewData | null>(null);
39+
const [loadingPreview, setLoadingPreview] = React.useState(false);
40+
41+
const isGroupMode = !!props.groupId;
42+
const charCount = message.length;
43+
const segmentCount = charCount <= 160 ? 1 : Math.ceil(charCount / 153);
44+
45+
React.useEffect(() => {
46+
if (!isGroupMode || !props.groupId) return;
47+
setLoadingPreview(true);
48+
ApiHelper.get("/texting/preview/" + props.groupId, "MessagingApi")
49+
.then((data) => { setPreview(data); })
50+
.catch(() => { /* preview is optional — allow send even if it fails */ })
51+
.finally(() => { setLoadingPreview(false); });
52+
}, [isGroupMode, props.groupId]);
53+
54+
const handleSend = async () => {
55+
if (!message.trim()) return;
56+
setSending(true);
57+
setError("");
58+
try {
59+
let resp;
60+
if (isGroupMode) {
61+
resp = await ApiHelper.post("/texting/send", { groupId: props.groupId, message }, "MessagingApi");
62+
} else {
63+
resp = await ApiHelper.post("/texting/sendPerson", { personId: props.personId, phoneNumber: props.phoneNumber, message }, "MessagingApi");
64+
}
65+
if (resp.error) {
66+
setError(resp.error);
67+
} else {
68+
setResult(resp);
69+
}
70+
} catch (err: any) {
71+
setError(err?.message || "Failed to send text message.");
72+
} finally {
73+
setSending(false);
74+
}
75+
};
76+
77+
const getTitle = () => {
78+
if (isGroupMode) return `Text Group: ${props.groupName || ""}`;
79+
return `Text: ${props.personName || ""}`;
80+
};
81+
82+
const renderPreview = () => {
83+
if (!isGroupMode) return <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>Sending to {props.phoneNumber || "phone on file"}.</Typography>;
84+
if (loadingPreview) return <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>Loading recipients...</Typography>;
85+
if (!preview) return <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>This will send an SMS to eligible group members.</Typography>;
86+
87+
return (
88+
<Alert severity={preview.eligibleCount > 0 ? "info" : "warning"} sx={{ mb: 2 }}>
89+
<strong>{preview.eligibleCount}</strong> of {preview.totalMembers} member{preview.totalMembers !== 1 ? "s" : ""} will receive this text.
90+
{preview.optedOutCount > 0 && <><br />{preview.optedOutCount} opted out.</>}
91+
{preview.noPhoneCount > 0 && <><br />{preview.noPhoneCount} ha{preview.noPhoneCount !== 1 ? "ve" : "s"} no phone number on file.</>}
92+
</Alert>
93+
);
94+
};
95+
96+
const renderResult = () => {
97+
if (!result) return null;
98+
const isGroup = result.totalMembers !== undefined && result.totalMembers > 1;
99+
return (
100+
<>
101+
<Alert severity={result.failCount === 0 ? "success" : "warning"} sx={{ mt: 1 }}>
102+
Sent to {result.successCount} of {result.recipientCount} eligible recipient{result.recipientCount !== 1 ? "s" : ""}.
103+
{result.failCount > 0 && <><br />{result.failCount} failed to send.</>}
104+
</Alert>
105+
{isGroup && (result.optedOutCount > 0 || result.noPhoneCount > 0) && (
106+
<Alert severity="info" sx={{ mt: 1 }}>
107+
{result.optedOutCount > 0 && <>{result.optedOutCount} skipped (opted out).<br /></>}
108+
{result.noPhoneCount > 0 && <>{result.noPhoneCount} skipped (no phone number).</>}
109+
</Alert>
110+
)}
111+
</>
112+
);
113+
};
114+
115+
const canSend = !sending && message.trim().length > 0 && (!isGroupMode || !preview || preview.eligibleCount > 0);
116+
117+
return (
118+
<Dialog open={true} onClose={props.onClose} maxWidth="sm" fullWidth>
119+
<DialogTitle>{getTitle()}</DialogTitle>
120+
<DialogContent>
121+
{result ? renderResult() : (
122+
<>
123+
{renderPreview()}
124+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
125+
<TextField
126+
fullWidth
127+
multiline
128+
minRows={3}
129+
maxRows={6}
130+
label="Message"
131+
value={message}
132+
onChange={(e) => setMessage(e.target.value)}
133+
disabled={sending}
134+
inputProps={{ maxLength: 1600 }}
135+
/>
136+
<Typography variant="caption" color="textSecondary" sx={{ mt: 1, display: "block" }}>
137+
{charCount} character{charCount !== 1 ? "s" : ""} ({segmentCount} SMS segment{segmentCount !== 1 ? "s" : ""})
138+
</Typography>
139+
</>
140+
)}
141+
</DialogContent>
142+
<DialogActions>
143+
{result ? (
144+
<Button onClick={props.onClose}>Close</Button>
145+
) : (
146+
<>
147+
<Button onClick={props.onClose} disabled={sending}>Cancel</Button>
148+
<Button
149+
variant="contained"
150+
onClick={handleSend}
151+
disabled={!canSend}
152+
startIcon={sending ? <CircularProgress size={16} /> : null}
153+
>
154+
{sending ? "Sending..." : "Send"}
155+
</Button>
156+
</>
157+
)}
158+
</DialogActions>
159+
</Dialog>
160+
);
161+
};

src/groups/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ export { ServiceTimes } from "./ServiceTimes";
1212
export { ServiceTimesEdit } from "./ServiceTimesEdit";
1313
export { SessionAdd } from "./SessionAdd";
1414
export { SessionCard } from "./SessionCard";
15+
export { SendTextDialog } from "./SendTextDialog";
1516
export { Tabs } from "./Tabs";

src/people/components/PersonBanner.tsx

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { type PersonInterface } from "@churchapps/helpers";
22
import { PersonHelper, UserHelper, Permissions, DateHelper, PersonAvatar, ApiHelper } from "@churchapps/apphelper";
3-
import { Typography, IconButton, Stack, Chip } from "@mui/material";
3+
import { Typography, IconButton, Stack, Chip, Tooltip } from "@mui/material";
44
import {
55
Edit as EditIcon,
66
Phone as PhoneIcon,
77
Email as EmailIcon,
8-
Home as HomeIcon
8+
Home as HomeIcon,
9+
Sms as SmsIcon
910
} from "@mui/icons-material";
1011
import React, { memo, useMemo, useState, useEffect } from "react";
1112
import { StatusChip } from "../../components";
13+
import { SendTextDialog } from "../../groups/components/SendTextDialog";
1214

1315
interface Props {
1416
person: PersonInterface;
@@ -20,6 +22,10 @@ export const PersonBanner = memo((props: Props) => {
2022
const { person, togglePhotoEditor, onEdit } = props;
2123

2224
const [userEmail, setUserEmail] = useState<string>("");
25+
const [showTextDialog, setShowTextDialog] = useState(false);
26+
const [hasTextingProvider, setHasTextingProvider] = useState(false);
27+
28+
const canText = useMemo(() => UserHelper.checkAccess(Permissions.messagingApi.texting.edit), []);
2329

2430
useEffect(() => {
2531
if (person?.id) {
@@ -31,6 +37,14 @@ export const PersonBanner = memo((props: Props) => {
3137
}
3238
}, [person?.id]);
3339

40+
useEffect(() => {
41+
if (canText) {
42+
ApiHelper.get("/texting/providers", "MessagingApi")
43+
.then((data: any[]) => setHasTextingProvider(data?.length > 0))
44+
.catch(() => setHasTextingProvider(false));
45+
}
46+
}, [canText]);
47+
3448
const canEdit = useMemo(() => UserHelper.checkAccess(Permissions.membershipApi.people.edit), []);
3549

3650
const membershipStatus = useMemo(() => {
@@ -78,7 +92,8 @@ export const PersonBanner = memo((props: Props) => {
7892
if (phone) {
7993
info.push({
8094
icon: <PhoneIcon sx={{ color: "#fff", fontSize: 16 }} />,
81-
value: phone
95+
value: phone,
96+
showTextButton: !!person.contactInfo.mobilePhone && canText && hasTextingProvider
8297
});
8398
}
8499

@@ -146,7 +161,7 @@ export const PersonBanner = memo((props: Props) => {
146161

147162
{/* Column 2: Contact Info */}
148163
<Stack spacing={0.5} sx={{ position: { xs: "static", lg: "absolute" }, left: { lg: "50%" }, top: { lg: "50%" }, transform: { lg: "translateY(-50%)" }, minWidth: 0 }}>
149-
{contactInfo.map((info) => (
164+
{contactInfo.map((info: any) => (
150165
<Stack key={info.value} direction="row" spacing={1} alignItems="center" sx={{ minWidth: 0 }}>
151166
{info.icon}
152167
<Typography
@@ -161,10 +176,25 @@ export const PersonBanner = memo((props: Props) => {
161176
onClick={info.action}>
162177
{info.value}
163178
</Typography>
179+
{info.showTextButton && (
180+
<Tooltip title="Send text message">
181+
<IconButton size="small" sx={{ color: "#FFF", p: 0.25 }} onClick={() => setShowTextDialog(true)}>
182+
<SmsIcon sx={{ fontSize: 14 }} />
183+
</IconButton>
184+
</Tooltip>
185+
)}
164186
</Stack>
165187
))}
166188
</Stack>
167189
</Stack>
190+
{showTextDialog && person?.contactInfo?.mobilePhone && (
191+
<SendTextDialog
192+
personId={person.id}
193+
personName={person.name?.display}
194+
phoneNumber={person.contactInfo.mobilePhone}
195+
onClose={() => setShowTextDialog(false)}
196+
/>
197+
)}
168198
</div>
169199
);
170200
});

src/settings/components/ChurchSettingsEdit.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
77
import BusinessIcon from "@mui/icons-material/Business";
88
import TuneIcon from "@mui/icons-material/Tune";
99
import VolunteerActivismIcon from "@mui/icons-material/VolunteerActivism";
10+
import SmsIcon from "@mui/icons-material/Sms";
1011
import LanguageIcon from "@mui/icons-material/Language";
1112
import { DomainSettingsEdit } from "./DomainSettingsEdit";
13+
import { TextingSettingsEdit } from "./TextingSettingsEdit";
1214
import { DirectoryApproveSettingsEdit } from "./DirectoryApproveSettingsEdit";
1315
import { SupportContactSettingsEdit } from "./SupportContactSettingsEdit";
1416
import { VisbilityPrefSettingsEdit } from "./VisibilityPrefSettingsEdit";
@@ -108,6 +110,11 @@ export const ChurchSettingsEdit: React.FC<Props> = (props) => {
108110
setErrors(givingErrors);
109111
};
110112

113+
const handleTextingError = (textingErrors: string[]) => {
114+
childErrorsRef.current = textingErrors;
115+
setErrors(textingErrors);
116+
};
117+
111118
const giveSection = () => {
112119
if (!UserHelper.checkAccess(Permissions.givingApi.settings.edit)) return null;
113120
return <GivingSettingsEdit churchId={church?.id || ""} saveTrigger={saveTrigger} onError={handleGivingError} />;
@@ -244,6 +251,27 @@ export const ChurchSettingsEdit: React.FC<Props> = (props) => {
244251
</Accordion>
245252
)}
246253

254+
{/* Texting Settings Accordion */}
255+
{UserHelper.checkAccess(Permissions.messagingApi.texting.edit) && (
256+
<Accordion
257+
expanded={expanded === "texting"}
258+
onChange={handleAccordionChange("texting")}
259+
sx={accordionStyles}
260+
>
261+
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={accordionSummaryStyles}>
262+
<SettingsSectionHeader
263+
icon={<SmsIcon />}
264+
color="warning"
265+
title="Texting"
266+
subtitle="Configure SMS texting provider"
267+
/>
268+
</AccordionSummary>
269+
<AccordionDetails sx={{ pt: 2 }}>
270+
<TextingSettingsEdit churchId={church?.id || ""} saveTrigger={saveTrigger} onError={handleTextingError} />
271+
</AccordionDetails>
272+
</Accordion>
273+
)}
274+
247275
{/* Domains Accordion */}
248276
<Accordion
249277
expanded={expanded === "domains"}

0 commit comments

Comments
 (0)