@visns-studio/visns-components 6.6.3 → 6.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1658 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +619 -158
- package/src/components/Fetch.jsx +80 -3
- package/src/components/Form.jsx +9 -0
- package/src/components/Navigation.jsx +109 -50
- package/src/components/Notification.jsx +279 -9
- package/src/components/TableFilter.jsx +14 -1
- package/src/components/auth/AuthBrandPanel.jsx +43 -0
- package/src/components/auth/AuthLoading.jsx +78 -0
- package/src/components/auth/AuthShell.jsx +59 -0
- package/src/components/auth/ClientAuth.jsx +161 -0
- package/src/components/auth/ClientAuthFrame.jsx +59 -0
- package/src/components/auth/ClientLogin.jsx +266 -55
- package/src/components/auth/ClientOTPVerify.jsx +587 -115
- package/src/components/auth/ImpersonateGate.jsx +254 -0
- package/src/components/auth/Login.jsx +134 -41
- package/src/components/auth/LogoutScreen.jsx +112 -0
- package/src/components/auth/Reset.jsx +134 -77
- package/src/components/auth/TwoFactorAuth.jsx +475 -297
- package/src/components/auth/Verify.jsx +237 -126
- package/src/components/auth/authEndpoints.js +105 -0
- package/src/components/auth/authFont.js +23 -0
- package/src/components/auth/authHelpers.js +465 -0
- package/src/components/auth/clientAuthProtocols.js +240 -0
- package/src/components/auth/useOptionalRouter.js +49 -0
- package/src/components/callQueue/CallQueuePop.jsx +1502 -0
- package/src/components/callQueue/CallQueueSettings.jsx +508 -0
- package/src/components/callQueue/callQueueHelpers.js +280 -0
- package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
- package/src/components/columns/AutoGrowCell.jsx +141 -0
- package/src/components/columns/ColumnRenderers.jsx +53 -7
- package/src/components/generic/GenericAuth.jsx +163 -96
- package/src/components/generic/GenericDetail.jsx +215 -90
- package/src/components/generic/GenericIndex.jsx +20 -47
- package/src/components/generic/GenericMain.jsx +5 -0
- package/src/components/generic/GroupedReportRenderer.jsx +1 -5
- package/src/components/generic/StandardModal.jsx +17 -5
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
- package/src/components/navActive.js +60 -0
- package/src/components/notify/desktopNotifications.js +256 -0
- package/src/components/sketch/SketchField.jsx +12 -2
- package/src/components/sms/SmsComposeModal.jsx +495 -0
- package/src/components/sms/SmsInbox.jsx +596 -0
- package/src/components/sms/SmsInboxBadge.jsx +468 -0
- package/src/components/sms/SmsLineSettings.jsx +854 -0
- package/src/components/sms/SmsThreadPanel.jsx +1184 -0
- package/src/components/sms/smsEndpoints.js +56 -0
- package/src/components/sms/smsHelpers.js +1075 -0
- package/src/components/sms/smsLiveState.js +132 -0
- package/src/components/sms/useSmsLive.js +307 -0
- package/src/components/styles/CallQueuePop.module.scss +646 -0
- package/src/components/styles/CallQueueSettings.module.scss +460 -0
- package/src/components/styles/ClientAuth.module.scss +229 -0
- package/src/components/styles/DataGrid.module.scss +10 -0
- package/src/components/styles/GenericDetail.module.scss +153 -26
- package/src/components/styles/GenericIndex.module.scss +13 -0
- package/src/components/styles/GenericMain.module.scss +19 -2
- package/src/components/styles/ImpersonateGate.module.scss +85 -0
- package/src/components/styles/Login.module.scss +13 -122
- package/src/components/styles/LogoutScreen.module.scss +89 -0
- package/src/components/styles/Navigation.module.scss +509 -223
- package/src/components/styles/Notification.module.scss +103 -0
- package/src/components/styles/Reset.module.scss +52 -186
- package/src/components/styles/Sms.module.scss +2382 -0
- package/src/components/styles/TableFilter.module.scss +118 -98
- package/src/components/styles/TwoFactorAuth.module.scss +115 -176
- package/src/components/styles/Vault.module.scss +1983 -0
- package/src/components/styles/Verify.module.scss +57 -180
- package/src/components/styles/_authBrandPanel.scss +163 -0
- package/src/components/styles/_authShell.scss +369 -0
- package/src/components/styles/global-datagrid.css +29 -0
- package/src/components/utils/buildEnv.js +67 -0
- package/src/components/utils/displayValue.js +94 -0
- package/src/components/utils/useDensity.js +345 -9
- package/src/components/vault/OtpChip.jsx +208 -0
- package/src/components/vault/PasswordGenerator.jsx +145 -0
- package/src/components/vault/QrScanner.jsx +326 -0
- package/src/components/vault/VaultAccessLog.jsx +225 -0
- package/src/components/vault/VaultConfirmPanel.jsx +113 -0
- package/src/components/vault/VaultEntryForm.jsx +740 -0
- package/src/components/vault/VaultManager.jsx +1000 -0
- package/src/components/vault/VaultQuickSearch.jsx +681 -0
- package/src/components/vault/qrDecode.js +257 -0
- package/src/components/vault/useDebouncedValue.js +22 -0
- package/src/components/vault/useVaultReveal.js +160 -0
- package/src/components/vault/vaultClipboard.js +33 -0
- package/src/components/vault/vaultEndpoints.js +40 -0
- package/src/components/vault/vaultFit.js +116 -0
- package/src/components/vault/vaultHelpers.js +547 -0
- package/src/components/vault/vaultNavigation.jsx +58 -0
- package/src/components/vault/vaultOtp.js +105 -0
- package/src/index.js +233 -1
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { FileText, Loader2, PenSquare, Send, X } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
import CustomFetch from '../Fetch';
|
|
5
|
+
import StandardModal from '../generic/StandardModal';
|
|
6
|
+
import styles from '../styles/Sms.module.scss';
|
|
7
|
+
import useDebouncedValue from '../vault/useDebouncedValue';
|
|
8
|
+
import { resolveSmsEndpoints } from './smsEndpoints';
|
|
9
|
+
import {
|
|
10
|
+
MAX_SEGMENTS,
|
|
11
|
+
describeRecipient,
|
|
12
|
+
describeTransport,
|
|
13
|
+
initialsFor,
|
|
14
|
+
lineNumberForDisplay,
|
|
15
|
+
looksLikeNumber,
|
|
16
|
+
normaliseNumberForDisplay,
|
|
17
|
+
segmentCount,
|
|
18
|
+
} from './smsHelpers';
|
|
19
|
+
|
|
20
|
+
/** The hint and the server's complaint both hang off the To field by id. */
|
|
21
|
+
const TO_HINT_ID = 'sms-compose-to-hint';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The modal shell is a generic component sized for forms, and it clips its own
|
|
25
|
+
* content so a long one can scroll. The templates menu opens upward and out of
|
|
26
|
+
* the box, so this one asks for a visible overflow — and for a width a compose
|
|
27
|
+
* box actually wants, rather than the 80vw a settings form does.
|
|
28
|
+
*/
|
|
29
|
+
const MODAL_STYLES = {
|
|
30
|
+
modal: {
|
|
31
|
+
width: 'min(38rem, 95vw)',
|
|
32
|
+
minWidth: 0,
|
|
33
|
+
maxWidth: 'min(38rem, 95vw)',
|
|
34
|
+
maxHeight: 'none',
|
|
35
|
+
overflow: 'visible',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Laravel's error bag arrives joined with `<br />`; a field label is text. */
|
|
40
|
+
const asPlainText = (message) =>
|
|
41
|
+
String(message ?? '')
|
|
42
|
+
.replace(/<br\s*\/?>/gi, ' ')
|
|
43
|
+
.replace(/\s+/g, ' ')
|
|
44
|
+
.trim();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Start a conversation that does not exist yet.
|
|
48
|
+
*
|
|
49
|
+
* "To" takes either a number or a client. Typing digits is treated as a
|
|
50
|
+
* number the moment it looks like one; anything else searches clients, and
|
|
51
|
+
* picking one that has more than one number asks which. The body is optional
|
|
52
|
+
* — the contract allows creating an empty thread — but the button says Send
|
|
53
|
+
* rather than Create, because that is what people are actually doing.
|
|
54
|
+
*
|
|
55
|
+
* A typed number is judged before it is sent rather than after: the server
|
|
56
|
+
* refuses a landline, and finding that out from a 422 after writing the
|
|
57
|
+
* message is a worse way to learn it than being told while typing.
|
|
58
|
+
*/
|
|
59
|
+
const SmsComposeModal = ({
|
|
60
|
+
open,
|
|
61
|
+
onClose,
|
|
62
|
+
endpoints,
|
|
63
|
+
lines = [],
|
|
64
|
+
status = null,
|
|
65
|
+
templates: templatesProp = null,
|
|
66
|
+
onCreated,
|
|
67
|
+
}) => {
|
|
68
|
+
const routes = useMemo(() => resolveSmsEndpoints(endpoints), [endpoints]);
|
|
69
|
+
|
|
70
|
+
const [lineId, setLineId] = useState('');
|
|
71
|
+
const [to, setTo] = useState('');
|
|
72
|
+
const [picked, setPicked] = useState(null);
|
|
73
|
+
const [body, setBody] = useState('');
|
|
74
|
+
const [results, setResults] = useState([]);
|
|
75
|
+
const [searching, setSearching] = useState(false);
|
|
76
|
+
const [sending, setSending] = useState(false);
|
|
77
|
+
const [error, setError] = useState('');
|
|
78
|
+
const [templates, setTemplates] = useState(templatesProp);
|
|
79
|
+
const [templatesOpen, setTemplatesOpen] = useState(false);
|
|
80
|
+
|
|
81
|
+
const mountedRef = useRef(true);
|
|
82
|
+
const debouncedTo = useDebouncedValue(to, 250);
|
|
83
|
+
|
|
84
|
+
useEffect(
|
|
85
|
+
() => () => {
|
|
86
|
+
mountedRef.current = false;
|
|
87
|
+
},
|
|
88
|
+
[]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// A fresh modal every time, and the first line preselected: with one line
|
|
92
|
+
// configured — which is the common case — there is nothing to choose.
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!open) return;
|
|
95
|
+
|
|
96
|
+
setLineId(lines.length > 0 ? String(lines[0].id) : '');
|
|
97
|
+
setTo('');
|
|
98
|
+
setPicked(null);
|
|
99
|
+
setBody('');
|
|
100
|
+
setResults([]);
|
|
101
|
+
setError('');
|
|
102
|
+
}, [open, lines]);
|
|
103
|
+
|
|
104
|
+
const typedNumber = looksLikeNumber(to);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!open || picked || typedNumber) {
|
|
108
|
+
setResults([]);
|
|
109
|
+
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const query = debouncedTo.trim();
|
|
114
|
+
|
|
115
|
+
if (query.length < 2) {
|
|
116
|
+
setResults([]);
|
|
117
|
+
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setSearching(true);
|
|
122
|
+
|
|
123
|
+
CustomFetch(
|
|
124
|
+
routes.clientSearch,
|
|
125
|
+
'GET',
|
|
126
|
+
{ q: query },
|
|
127
|
+
(result) => {
|
|
128
|
+
if (!mountedRef.current) return;
|
|
129
|
+
|
|
130
|
+
setResults(Array.isArray(result?.data) ? result.data : []);
|
|
131
|
+
setSearching(false);
|
|
132
|
+
},
|
|
133
|
+
() => {
|
|
134
|
+
if (mountedRef.current) setSearching(false);
|
|
135
|
+
}
|
|
136
|
+
).catch(() => {
|
|
137
|
+
if (mountedRef.current) setSearching(false);
|
|
138
|
+
});
|
|
139
|
+
}, [open, debouncedTo, picked, typedNumber, routes]);
|
|
140
|
+
|
|
141
|
+
const loadTemplates = useCallback(() => {
|
|
142
|
+
if (templates !== null) return;
|
|
143
|
+
|
|
144
|
+
CustomFetch(
|
|
145
|
+
routes.templates,
|
|
146
|
+
'GET',
|
|
147
|
+
null,
|
|
148
|
+
(result) => {
|
|
149
|
+
if (mountedRef.current) {
|
|
150
|
+
setTemplates(Array.isArray(result?.data) ? result.data : []);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
() => {
|
|
154
|
+
if (mountedRef.current) setTemplates([]);
|
|
155
|
+
}
|
|
156
|
+
).catch(() => {
|
|
157
|
+
if (mountedRef.current) setTemplates([]);
|
|
158
|
+
});
|
|
159
|
+
}, [routes, templates]);
|
|
160
|
+
|
|
161
|
+
const counts = segmentCount(body);
|
|
162
|
+
const overLimit = counts.segments > MAX_SEGMENTS;
|
|
163
|
+
|
|
164
|
+
// A number the person typed is checked here; a number attached to a client
|
|
165
|
+
// record is taken as given — it is the CRM's number for them, and second
|
|
166
|
+
// guessing it would only ever get in the way of texting a client.
|
|
167
|
+
const recipient = useMemo(() => describeRecipient(to), [to]);
|
|
168
|
+
const destination = picked ? picked.number : to.trim();
|
|
169
|
+
const recipientReady = picked !== null || recipient.kind === 'mobile';
|
|
170
|
+
const canSend =
|
|
171
|
+
lineId !== '' && destination !== '' && recipientReady && !overLimit && !sending;
|
|
172
|
+
|
|
173
|
+
// The server's 422 belongs to the To field as much as our own complaint
|
|
174
|
+
// does — it is about the same number, and a toast behind the modal is not
|
|
175
|
+
// where anybody looks for it.
|
|
176
|
+
const toProblem =
|
|
177
|
+
error !== '' ? error : picked === null && !recipient.ok ? recipient.message : '';
|
|
178
|
+
const toIsBad = toProblem !== '';
|
|
179
|
+
|
|
180
|
+
const changeTo = (value) => {
|
|
181
|
+
setTo(value);
|
|
182
|
+
// Whatever the server objected to, it was about the old number.
|
|
183
|
+
if (error !== '') setError('');
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const submit = () => {
|
|
187
|
+
if (!canSend) return;
|
|
188
|
+
|
|
189
|
+
setSending(true);
|
|
190
|
+
setError('');
|
|
191
|
+
|
|
192
|
+
CustomFetch(
|
|
193
|
+
routes.createThread,
|
|
194
|
+
'POST',
|
|
195
|
+
{
|
|
196
|
+
line_id: lineId,
|
|
197
|
+
to: destination,
|
|
198
|
+
body: body.trim() === '' ? undefined : body.trim(),
|
|
199
|
+
},
|
|
200
|
+
(result) => {
|
|
201
|
+
if (!mountedRef.current) return;
|
|
202
|
+
|
|
203
|
+
setSending(false);
|
|
204
|
+
onCreated?.(result?.thread ?? null, result?.message ?? null);
|
|
205
|
+
},
|
|
206
|
+
(message) => {
|
|
207
|
+
if (!mountedRef.current) return;
|
|
208
|
+
|
|
209
|
+
setSending(false);
|
|
210
|
+
// 422 comes back when the number cannot be used, which is the
|
|
211
|
+
// one error a person can actually fix from here — so it stays
|
|
212
|
+
// in the modal, under the field it is about.
|
|
213
|
+
setError(
|
|
214
|
+
asPlainText(message) || 'That number could not be used.'
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
).catch(() => {
|
|
218
|
+
if (mountedRef.current) setSending(false);
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
if (!open) return null;
|
|
223
|
+
|
|
224
|
+
const transport = describeTransport(status);
|
|
225
|
+
const onlyLine = lines.length === 1 ? lines[0] : null;
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<StandardModal
|
|
229
|
+
isOpen={open}
|
|
230
|
+
onClose={onClose}
|
|
231
|
+
size="small"
|
|
232
|
+
customStyles={MODAL_STYLES}
|
|
233
|
+
>
|
|
234
|
+
<div className={`${styles.modalForm} ${styles.composeForm}`}>
|
|
235
|
+
<div className={styles.modalHead}>
|
|
236
|
+
<h3 className={styles.modalTitle}>
|
|
237
|
+
<PenSquare size={16} strokeWidth={2.2} aria-hidden="true" />
|
|
238
|
+
New message
|
|
239
|
+
</h3>
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
className={styles.iconButton}
|
|
243
|
+
onClick={onClose}
|
|
244
|
+
aria-label="Close"
|
|
245
|
+
>
|
|
246
|
+
<X size={15} strokeWidth={2} aria-hidden="true" />
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
{transport.tone !== 'ok' && (
|
|
251
|
+
<p className={`${styles.banner} ${styles.bannerWarn}`}>
|
|
252
|
+
<span>
|
|
253
|
+
<strong>{transport.title}</strong>
|
|
254
|
+
{transport.detail}
|
|
255
|
+
</span>
|
|
256
|
+
</p>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
<div className={styles.composeRow}>
|
|
260
|
+
{onlyLine ? (
|
|
261
|
+
<div className={styles.composeField}>
|
|
262
|
+
<span className={styles.labelSmall}>From</span>
|
|
263
|
+
<span className={styles.fromChip}>
|
|
264
|
+
<strong>{onlyLine.label || 'Line'}</strong>
|
|
265
|
+
<small>{lineNumberForDisplay(onlyLine)}</small>
|
|
266
|
+
</span>
|
|
267
|
+
</div>
|
|
268
|
+
) : (
|
|
269
|
+
<label className={styles.composeField}>
|
|
270
|
+
<span className={styles.labelSmall}>From</span>
|
|
271
|
+
<select
|
|
272
|
+
className={styles.select}
|
|
273
|
+
value={lineId}
|
|
274
|
+
onChange={(event) => setLineId(event.target.value)}
|
|
275
|
+
>
|
|
276
|
+
{lines.length === 0 && (
|
|
277
|
+
<option value="">No lines available</option>
|
|
278
|
+
)}
|
|
279
|
+
{lines.map((line) => (
|
|
280
|
+
<option key={line.id} value={line.id}>
|
|
281
|
+
{(line.label || 'Line') +
|
|
282
|
+
' · ' +
|
|
283
|
+
lineNumberForDisplay(line)}
|
|
284
|
+
</option>
|
|
285
|
+
))}
|
|
286
|
+
</select>
|
|
287
|
+
</label>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
<div className={`${styles.composeField} ${styles.typeahead}`}>
|
|
291
|
+
<span className={styles.labelSmall}>To</span>
|
|
292
|
+
|
|
293
|
+
{picked ? (
|
|
294
|
+
<div className={styles.searchBar}>
|
|
295
|
+
<span className={styles.avatar} aria-hidden="true">
|
|
296
|
+
{initialsFor(picked.name)}
|
|
297
|
+
</span>
|
|
298
|
+
<span className={styles.searchInput}>
|
|
299
|
+
<strong>{picked.name}</strong>{' '}
|
|
300
|
+
<span className={styles.muted}>
|
|
301
|
+
{normaliseNumberForDisplay(picked.number)}
|
|
302
|
+
</span>
|
|
303
|
+
</span>
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
className={styles.iconButton}
|
|
307
|
+
onClick={() => {
|
|
308
|
+
setPicked(null);
|
|
309
|
+
setTo('');
|
|
310
|
+
setError('');
|
|
311
|
+
}}
|
|
312
|
+
aria-label="Choose someone else"
|
|
313
|
+
>
|
|
314
|
+
<X size={14} strokeWidth={2} aria-hidden="true" />
|
|
315
|
+
</button>
|
|
316
|
+
</div>
|
|
317
|
+
) : (
|
|
318
|
+
<input
|
|
319
|
+
type="text"
|
|
320
|
+
className={styles.input}
|
|
321
|
+
value={to}
|
|
322
|
+
placeholder="A mobile number, or search for a client…"
|
|
323
|
+
autoComplete="off"
|
|
324
|
+
aria-invalid={toIsBad ? 'true' : undefined}
|
|
325
|
+
aria-describedby={
|
|
326
|
+
toProblem !== '' || recipient.message !== ''
|
|
327
|
+
? TO_HINT_ID
|
|
328
|
+
: undefined
|
|
329
|
+
}
|
|
330
|
+
onChange={(event) => changeTo(event.target.value)}
|
|
331
|
+
/>
|
|
332
|
+
)}
|
|
333
|
+
|
|
334
|
+
{toProblem !== '' ? (
|
|
335
|
+
<p className={styles.fieldError} id={TO_HINT_ID}>
|
|
336
|
+
{toProblem}
|
|
337
|
+
</p>
|
|
338
|
+
) : (
|
|
339
|
+
!picked &&
|
|
340
|
+
recipient.kind === 'mobile' && (
|
|
341
|
+
<p className={styles.help} id={TO_HINT_ID}>
|
|
342
|
+
{recipient.message}
|
|
343
|
+
</p>
|
|
344
|
+
)
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
{!picked && searching && <p className={styles.help}>Searching…</p>}
|
|
348
|
+
|
|
349
|
+
{!picked && results.length > 0 && (
|
|
350
|
+
<ul className={styles.typeaheadList}>
|
|
351
|
+
{results.map((client) =>
|
|
352
|
+
(client.numbers || []).map((number) => (
|
|
353
|
+
<li key={`${client.id}-${number.number}`}>
|
|
354
|
+
<button
|
|
355
|
+
type="button"
|
|
356
|
+
className={styles.typeaheadRow}
|
|
357
|
+
onClick={() => {
|
|
358
|
+
setPicked({
|
|
359
|
+
id: client.id,
|
|
360
|
+
name: client.name,
|
|
361
|
+
number: number.number,
|
|
362
|
+
});
|
|
363
|
+
setResults([]);
|
|
364
|
+
setError('');
|
|
365
|
+
}}
|
|
366
|
+
>
|
|
367
|
+
<span
|
|
368
|
+
className={styles.avatar}
|
|
369
|
+
aria-hidden="true"
|
|
370
|
+
>
|
|
371
|
+
{initialsFor(client.name)}
|
|
372
|
+
</span>
|
|
373
|
+
<span>
|
|
374
|
+
<strong>{client.name}</strong>{' '}
|
|
375
|
+
<small>
|
|
376
|
+
{number.label
|
|
377
|
+
? `${number.label} · `
|
|
378
|
+
: ''}
|
|
379
|
+
{normaliseNumberForDisplay(
|
|
380
|
+
number.number
|
|
381
|
+
)}
|
|
382
|
+
</small>
|
|
383
|
+
</span>
|
|
384
|
+
</button>
|
|
385
|
+
</li>
|
|
386
|
+
))
|
|
387
|
+
)}
|
|
388
|
+
</ul>
|
|
389
|
+
)}
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
{/* The same card the conversation composer is: the text, and a
|
|
394
|
+
bar inside the same border carrying everything about it. */}
|
|
395
|
+
<div className={styles.composerBox}>
|
|
396
|
+
<textarea
|
|
397
|
+
className={`${styles.composerInput} ${styles.composeBody}`}
|
|
398
|
+
rows={4}
|
|
399
|
+
value={body}
|
|
400
|
+
placeholder="Write a message…"
|
|
401
|
+
aria-label="Message"
|
|
402
|
+
onChange={(event) => setBody(event.target.value)}
|
|
403
|
+
/>
|
|
404
|
+
|
|
405
|
+
<div className={`${styles.composerBar} ${styles.composeBar}`}>
|
|
406
|
+
<div className={styles.composerTools}>
|
|
407
|
+
<div className={styles.menuWrap}>
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
className={styles.ghostButton}
|
|
411
|
+
onClick={() => {
|
|
412
|
+
loadTemplates();
|
|
413
|
+
setTemplatesOpen((prev) => !prev);
|
|
414
|
+
}}
|
|
415
|
+
aria-expanded={templatesOpen}
|
|
416
|
+
aria-haspopup="menu"
|
|
417
|
+
>
|
|
418
|
+
<FileText size={14} strokeWidth={2} aria-hidden="true" />
|
|
419
|
+
Templates
|
|
420
|
+
</button>
|
|
421
|
+
|
|
422
|
+
{templatesOpen && (
|
|
423
|
+
<div className={styles.menu} role="menu">
|
|
424
|
+
{(templates || []).filter(
|
|
425
|
+
(one) => one.active !== false
|
|
426
|
+
).length === 0 ? (
|
|
427
|
+
<p className={styles.menuEmpty}>
|
|
428
|
+
{templates === null
|
|
429
|
+
? 'Loading…'
|
|
430
|
+
: 'No templates yet.'}
|
|
431
|
+
</p>
|
|
432
|
+
) : (
|
|
433
|
+
(templates || [])
|
|
434
|
+
.filter((one) => one.active !== false)
|
|
435
|
+
.map((template) => (
|
|
436
|
+
<button
|
|
437
|
+
key={template.id}
|
|
438
|
+
type="button"
|
|
439
|
+
className={styles.menuItem}
|
|
440
|
+
role="menuitem"
|
|
441
|
+
onClick={() => {
|
|
442
|
+
setTemplatesOpen(false);
|
|
443
|
+
setBody((prev) =>
|
|
444
|
+
prev.trim() === ''
|
|
445
|
+
? template.body
|
|
446
|
+
: `${prev}\n${template.body}`
|
|
447
|
+
);
|
|
448
|
+
}}
|
|
449
|
+
>
|
|
450
|
+
<strong>{template.name}</strong>
|
|
451
|
+
<small>{template.body}</small>
|
|
452
|
+
</button>
|
|
453
|
+
))
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
)}
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
<span
|
|
460
|
+
className={`${styles.counter} ${
|
|
461
|
+
overLimit ? styles.counterOver : ''
|
|
462
|
+
}`}
|
|
463
|
+
>
|
|
464
|
+
{counts.characters} characters · {counts.segments} SMS ·{' '}
|
|
465
|
+
{counts.encoding}
|
|
466
|
+
{overLimit ? ` · over the ${MAX_SEGMENTS}-part limit` : ''}
|
|
467
|
+
</span>
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
<div className={styles.modalActions}>
|
|
473
|
+
<button type="button" className={styles.ghostButton} onClick={onClose}>
|
|
474
|
+
Cancel
|
|
475
|
+
</button>
|
|
476
|
+
<button
|
|
477
|
+
type="button"
|
|
478
|
+
className={styles.primaryButton}
|
|
479
|
+
onClick={submit}
|
|
480
|
+
disabled={!canSend}
|
|
481
|
+
>
|
|
482
|
+
{sending ? (
|
|
483
|
+
<Loader2 size={14} className={styles.spin} aria-hidden="true" />
|
|
484
|
+
) : (
|
|
485
|
+
<Send size={14} strokeWidth={2} aria-hidden="true" />
|
|
486
|
+
)}
|
|
487
|
+
Send
|
|
488
|
+
</button>
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
</StandardModal>
|
|
492
|
+
);
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
export default SmsComposeModal;
|