@visns-studio/visns-components 6.6.4 → 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 +1623 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +171 -37
- 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/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/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/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,854 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { Loader2, MessageSquare, Plus, TriangleAlert, X } from 'lucide-react';
|
|
3
|
+
import { toast } from 'react-toastify';
|
|
4
|
+
|
|
5
|
+
import CustomFetch from '../Fetch';
|
|
6
|
+
import StandardModal from '../generic/StandardModal';
|
|
7
|
+
import showConfirmDialog from '../generic/ConfirmationDialog';
|
|
8
|
+
import styles from '../styles/Sms.module.scss';
|
|
9
|
+
import { resolveSmsEndpoints } from './smsEndpoints';
|
|
10
|
+
import {
|
|
11
|
+
describeTransport,
|
|
12
|
+
normaliseNumberForDisplay,
|
|
13
|
+
segmentCount,
|
|
14
|
+
} from './smsHelpers';
|
|
15
|
+
|
|
16
|
+
const emptyLine = {
|
|
17
|
+
id: null,
|
|
18
|
+
label: '',
|
|
19
|
+
phone_number: '',
|
|
20
|
+
zoom_user_id: '',
|
|
21
|
+
zoom_user_email: '',
|
|
22
|
+
active: true,
|
|
23
|
+
user_ids: [],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const emptyTemplate = { id: null, name: '', body: '', active: true, sort: 0 };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Settings → Messaging.
|
|
30
|
+
*
|
|
31
|
+
* Two things live here: the lines (a Zoom Phone number, the staff who can see
|
|
32
|
+
* it) and the templates. The status card above them exists because "transport:
|
|
33
|
+
* null" is not a sentence anybody can act on — it says, in words, whether
|
|
34
|
+
* anything actually leaves the building right now.
|
|
35
|
+
*
|
|
36
|
+
* `staffOptions` is required rather than fetched: the contract's
|
|
37
|
+
* `/settings/lines` response carries the staff already ON each line, but there
|
|
38
|
+
* is no route that lists everybody who COULD be on one, and inventing a URL
|
|
39
|
+
* for it here would be a second contract nobody agreed to. The CRM already has
|
|
40
|
+
* that list on every settings screen, so it passes it in.
|
|
41
|
+
*/
|
|
42
|
+
const SmsLineSettings = ({
|
|
43
|
+
endpoints,
|
|
44
|
+
// Accepted for consistency with the CRM's other settings screens.
|
|
45
|
+
userProfile: _userProfile = null,
|
|
46
|
+
staffOptions = [],
|
|
47
|
+
pageTitle = 'Messaging',
|
|
48
|
+
}) => {
|
|
49
|
+
const routes = useMemo(() => resolveSmsEndpoints(endpoints), [endpoints]);
|
|
50
|
+
|
|
51
|
+
const [status, setStatus] = useState(null);
|
|
52
|
+
const [lines, setLines] = useState([]);
|
|
53
|
+
const [zoom, setZoom] = useState({ connected: false, users: [], error: null });
|
|
54
|
+
const [templates, setTemplates] = useState([]);
|
|
55
|
+
const [loading, setLoading] = useState(true);
|
|
56
|
+
|
|
57
|
+
const [lineDraft, setLineDraft] = useState(null);
|
|
58
|
+
const [templateDraft, setTemplateDraft] = useState(null);
|
|
59
|
+
const [saving, setSaving] = useState(false);
|
|
60
|
+
|
|
61
|
+
const mountedRef = useRef(true);
|
|
62
|
+
|
|
63
|
+
useEffect(
|
|
64
|
+
() => () => {
|
|
65
|
+
mountedRef.current = false;
|
|
66
|
+
},
|
|
67
|
+
[]
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
/* -------------------------------------------------------------- loading */
|
|
71
|
+
|
|
72
|
+
const loadLines = useCallback(() => {
|
|
73
|
+
CustomFetch(
|
|
74
|
+
routes.settingsLines,
|
|
75
|
+
'GET',
|
|
76
|
+
null,
|
|
77
|
+
(result) => {
|
|
78
|
+
if (!mountedRef.current) return;
|
|
79
|
+
|
|
80
|
+
setLines(Array.isArray(result?.data) ? result.data : []);
|
|
81
|
+
setZoom({
|
|
82
|
+
connected: Boolean(result?.zoom_connected),
|
|
83
|
+
users: Array.isArray(result?.zoom_users) ? result.zoom_users : [],
|
|
84
|
+
error: result?.zoom_error ?? null,
|
|
85
|
+
});
|
|
86
|
+
setLoading(false);
|
|
87
|
+
},
|
|
88
|
+
() => {
|
|
89
|
+
if (mountedRef.current) setLoading(false);
|
|
90
|
+
}
|
|
91
|
+
).catch(() => {
|
|
92
|
+
if (mountedRef.current) setLoading(false);
|
|
93
|
+
});
|
|
94
|
+
}, [routes]);
|
|
95
|
+
|
|
96
|
+
const loadTemplates = useCallback(() => {
|
|
97
|
+
CustomFetch(
|
|
98
|
+
routes.templates,
|
|
99
|
+
'GET',
|
|
100
|
+
null,
|
|
101
|
+
(result) => {
|
|
102
|
+
if (!mountedRef.current) return;
|
|
103
|
+
|
|
104
|
+
setTemplates(Array.isArray(result?.data) ? result.data : []);
|
|
105
|
+
},
|
|
106
|
+
() => {}
|
|
107
|
+
).catch(() => {});
|
|
108
|
+
}, [routes]);
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
CustomFetch(
|
|
112
|
+
routes.status,
|
|
113
|
+
'GET',
|
|
114
|
+
null,
|
|
115
|
+
(result) => {
|
|
116
|
+
if (mountedRef.current && result) setStatus(result);
|
|
117
|
+
},
|
|
118
|
+
() => {}
|
|
119
|
+
).catch(() => {});
|
|
120
|
+
|
|
121
|
+
loadLines();
|
|
122
|
+
loadTemplates();
|
|
123
|
+
}, [routes, loadLines, loadTemplates]);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Everyone who could be put on a line.
|
|
127
|
+
*
|
|
128
|
+
* `staffOptions` is the real answer. Failing that, the staff already on a
|
|
129
|
+
* line are at least a list — enough to move somebody between lines, which
|
|
130
|
+
* is the commonest edit — so the picker is never empty when it does not
|
|
131
|
+
* have to be.
|
|
132
|
+
*/
|
|
133
|
+
const staff = useMemo(() => {
|
|
134
|
+
if (staffOptions.length > 0) return staffOptions;
|
|
135
|
+
|
|
136
|
+
const seen = new Map();
|
|
137
|
+
|
|
138
|
+
lines.forEach((line) => {
|
|
139
|
+
(line.users || []).forEach((user) => {
|
|
140
|
+
if (!seen.has(user.id)) seen.set(user.id, user);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return Array.from(seen.values());
|
|
145
|
+
}, [staffOptions, lines]);
|
|
146
|
+
|
|
147
|
+
/* --------------------------------------------------------------- saving */
|
|
148
|
+
|
|
149
|
+
const saveLine = () => {
|
|
150
|
+
if (!lineDraft) return;
|
|
151
|
+
|
|
152
|
+
const creating = !lineDraft.id;
|
|
153
|
+
|
|
154
|
+
setSaving(true);
|
|
155
|
+
|
|
156
|
+
CustomFetch(
|
|
157
|
+
creating ? routes.settingsLines : routes.settingsLine(lineDraft.id),
|
|
158
|
+
creating ? 'POST' : 'PUT',
|
|
159
|
+
{
|
|
160
|
+
label: lineDraft.label,
|
|
161
|
+
phone_number: lineDraft.phone_number,
|
|
162
|
+
zoom_user_id: lineDraft.zoom_user_id || null,
|
|
163
|
+
zoom_user_email: lineDraft.zoom_user_email || null,
|
|
164
|
+
active: Boolean(lineDraft.active),
|
|
165
|
+
user_ids: lineDraft.user_ids || [],
|
|
166
|
+
},
|
|
167
|
+
() => {
|
|
168
|
+
if (!mountedRef.current) return;
|
|
169
|
+
|
|
170
|
+
setSaving(false);
|
|
171
|
+
setLineDraft(null);
|
|
172
|
+
toast.success(creating ? 'Line added' : 'Line saved');
|
|
173
|
+
loadLines();
|
|
174
|
+
},
|
|
175
|
+
null
|
|
176
|
+
).catch(() => {
|
|
177
|
+
if (mountedRef.current) setSaving(false);
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const deleteLine = (line) => {
|
|
182
|
+
showConfirmDialog({
|
|
183
|
+
title: 'Delete this line?',
|
|
184
|
+
message:
|
|
185
|
+
'Staff will stop seeing its conversations. The messages themselves are kept.',
|
|
186
|
+
confirmLabel: 'Delete',
|
|
187
|
+
cancelLabel: 'Keep it',
|
|
188
|
+
data: line,
|
|
189
|
+
onConfirm: () => {
|
|
190
|
+
CustomFetch(
|
|
191
|
+
routes.settingsLine(line.id),
|
|
192
|
+
'DELETE',
|
|
193
|
+
{},
|
|
194
|
+
() => {
|
|
195
|
+
toast.success('Line deleted');
|
|
196
|
+
loadLines();
|
|
197
|
+
},
|
|
198
|
+
null
|
|
199
|
+
).catch(() => {});
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const saveTemplate = () => {
|
|
205
|
+
if (!templateDraft) return;
|
|
206
|
+
|
|
207
|
+
const creating = !templateDraft.id;
|
|
208
|
+
|
|
209
|
+
setSaving(true);
|
|
210
|
+
|
|
211
|
+
CustomFetch(
|
|
212
|
+
creating ? routes.templates : routes.template(templateDraft.id),
|
|
213
|
+
creating ? 'POST' : 'PUT',
|
|
214
|
+
{
|
|
215
|
+
name: templateDraft.name,
|
|
216
|
+
body: templateDraft.body,
|
|
217
|
+
active: Boolean(templateDraft.active),
|
|
218
|
+
sort: Number(templateDraft.sort) || 0,
|
|
219
|
+
},
|
|
220
|
+
() => {
|
|
221
|
+
if (!mountedRef.current) return;
|
|
222
|
+
|
|
223
|
+
setSaving(false);
|
|
224
|
+
setTemplateDraft(null);
|
|
225
|
+
toast.success(creating ? 'Template added' : 'Template saved');
|
|
226
|
+
loadTemplates();
|
|
227
|
+
},
|
|
228
|
+
null
|
|
229
|
+
).catch(() => {
|
|
230
|
+
if (mountedRef.current) setSaving(false);
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const deleteTemplate = (template) => {
|
|
235
|
+
showConfirmDialog({
|
|
236
|
+
title: 'Delete this template?',
|
|
237
|
+
message: 'It disappears from the Templates menu in the composer.',
|
|
238
|
+
confirmLabel: 'Delete',
|
|
239
|
+
cancelLabel: 'Keep it',
|
|
240
|
+
data: template,
|
|
241
|
+
onConfirm: () => {
|
|
242
|
+
CustomFetch(
|
|
243
|
+
routes.template(template.id),
|
|
244
|
+
'DELETE',
|
|
245
|
+
{},
|
|
246
|
+
() => {
|
|
247
|
+
toast.success('Template deleted');
|
|
248
|
+
loadTemplates();
|
|
249
|
+
},
|
|
250
|
+
null
|
|
251
|
+
).catch(() => {});
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/* --------------------------------------------------------------- render */
|
|
257
|
+
|
|
258
|
+
const transport = describeTransport(status);
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div className={styles.settings}>
|
|
262
|
+
<div className={styles.settingsHead}>
|
|
263
|
+
<div>
|
|
264
|
+
<h1 className={styles.pageTitle}>
|
|
265
|
+
<MessageSquare size={22} strokeWidth={2.2} aria-hidden="true" />
|
|
266
|
+
{pageTitle}
|
|
267
|
+
</h1>
|
|
268
|
+
<p className={styles.pageIntro}>
|
|
269
|
+
A line is one Zoom Phone number and the staff who can see its
|
|
270
|
+
conversations. Everything staff send goes out from the line they
|
|
271
|
+
are replying on.
|
|
272
|
+
</p>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<div className={styles.statusCard}>
|
|
277
|
+
<div className={styles.statusItem}>
|
|
278
|
+
<span className={styles.statusLabel}>Transport</span>
|
|
279
|
+
<span className={styles.statusValue}>
|
|
280
|
+
<span
|
|
281
|
+
className={`${styles.pill} ${
|
|
282
|
+
transport.tone === 'ok'
|
|
283
|
+
? styles.pillOk
|
|
284
|
+
: transport.tone === 'bad'
|
|
285
|
+
? styles.pillBad
|
|
286
|
+
: styles.pillWarn
|
|
287
|
+
}`}
|
|
288
|
+
>
|
|
289
|
+
{status?.transport ?? '—'}
|
|
290
|
+
</span>
|
|
291
|
+
</span>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<div className={styles.statusItem}>
|
|
295
|
+
<span className={styles.statusLabel}>Connected</span>
|
|
296
|
+
<span className={styles.statusValue}>
|
|
297
|
+
{status?.connected ? 'Yes' : 'No'}
|
|
298
|
+
</span>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<div className={styles.statusItem}>
|
|
302
|
+
<span className={styles.statusLabel}>Lines</span>
|
|
303
|
+
<span className={styles.statusValue}>
|
|
304
|
+
{status?.lines_count ?? lines.length}
|
|
305
|
+
</span>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
<p className={styles.statusMeaning}>
|
|
309
|
+
<strong>{transport.title}</strong>
|
|
310
|
+
<br />
|
|
311
|
+
{transport.detail}
|
|
312
|
+
</p>
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
{zoom.error && (
|
|
316
|
+
<p className={`${styles.banner} ${styles.bannerBad}`}>
|
|
317
|
+
<TriangleAlert size={16} strokeWidth={2} aria-hidden="true" />
|
|
318
|
+
<span>
|
|
319
|
+
<strong>Zoom said no</strong>
|
|
320
|
+
{zoom.error}
|
|
321
|
+
</span>
|
|
322
|
+
</p>
|
|
323
|
+
)}
|
|
324
|
+
|
|
325
|
+
{/* ------------------------------------------------------- lines */}
|
|
326
|
+
|
|
327
|
+
<section className={styles.section}>
|
|
328
|
+
<div className={styles.sectionHead}>
|
|
329
|
+
<h2 className={styles.sectionTitle}>Lines</h2>
|
|
330
|
+
<button
|
|
331
|
+
type="button"
|
|
332
|
+
className={styles.primaryButton}
|
|
333
|
+
onClick={() => setLineDraft({ ...emptyLine })}
|
|
334
|
+
>
|
|
335
|
+
<Plus size={14} strokeWidth={2} aria-hidden="true" />
|
|
336
|
+
Add a line
|
|
337
|
+
</button>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<div className={styles.tableWrap}>
|
|
341
|
+
<table className={styles.table}>
|
|
342
|
+
<thead>
|
|
343
|
+
<tr>
|
|
344
|
+
<th>Label</th>
|
|
345
|
+
<th>Number</th>
|
|
346
|
+
<th>Zoom user</th>
|
|
347
|
+
<th>Staff</th>
|
|
348
|
+
<th>Active</th>
|
|
349
|
+
<th className={styles.actionsHead}>Actions</th>
|
|
350
|
+
</tr>
|
|
351
|
+
</thead>
|
|
352
|
+
<tbody>
|
|
353
|
+
{lines.map((line) => (
|
|
354
|
+
<tr
|
|
355
|
+
key={line.id}
|
|
356
|
+
className={line.deleted_at ? styles.deletedRow : ''}
|
|
357
|
+
>
|
|
358
|
+
<td>
|
|
359
|
+
<strong>{line.label || 'Untitled line'}</strong>
|
|
360
|
+
</td>
|
|
361
|
+
<td className={styles.numberCell}>
|
|
362
|
+
{line.display_number ||
|
|
363
|
+
normaliseNumberForDisplay(line.phone_number)}
|
|
364
|
+
</td>
|
|
365
|
+
<td>
|
|
366
|
+
{line.zoom_user_email || (
|
|
367
|
+
<span className={styles.muted}>Not linked</span>
|
|
368
|
+
)}
|
|
369
|
+
</td>
|
|
370
|
+
<td>
|
|
371
|
+
<span className={styles.chipRow}>
|
|
372
|
+
{(line.users || []).length === 0 ? (
|
|
373
|
+
<span className={styles.muted}>Everyone</span>
|
|
374
|
+
) : (
|
|
375
|
+
(line.users || []).map((user) => (
|
|
376
|
+
<span
|
|
377
|
+
key={user.id}
|
|
378
|
+
className={styles.staffChip}
|
|
379
|
+
>
|
|
380
|
+
{user.name}
|
|
381
|
+
</span>
|
|
382
|
+
))
|
|
383
|
+
)}
|
|
384
|
+
</span>
|
|
385
|
+
</td>
|
|
386
|
+
<td>
|
|
387
|
+
<span
|
|
388
|
+
className={`${styles.pill} ${
|
|
389
|
+
line.active ? styles.pillOk : styles.pillMuted
|
|
390
|
+
}`}
|
|
391
|
+
>
|
|
392
|
+
{line.active ? 'Active' : 'Off'}
|
|
393
|
+
</span>
|
|
394
|
+
</td>
|
|
395
|
+
<td className={styles.actionsCell}>
|
|
396
|
+
<button
|
|
397
|
+
type="button"
|
|
398
|
+
className={styles.linkButton}
|
|
399
|
+
onClick={() =>
|
|
400
|
+
setLineDraft({
|
|
401
|
+
...emptyLine,
|
|
402
|
+
...line,
|
|
403
|
+
zoom_user_id: line.zoom_user_id || '',
|
|
404
|
+
zoom_user_email:
|
|
405
|
+
line.zoom_user_email || '',
|
|
406
|
+
user_ids: (line.users || []).map(
|
|
407
|
+
(user) => user.id
|
|
408
|
+
),
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
>
|
|
412
|
+
Edit
|
|
413
|
+
</button>
|
|
414
|
+
<button
|
|
415
|
+
type="button"
|
|
416
|
+
className={styles.dangerLink}
|
|
417
|
+
onClick={() => deleteLine(line)}
|
|
418
|
+
>
|
|
419
|
+
Delete
|
|
420
|
+
</button>
|
|
421
|
+
</td>
|
|
422
|
+
</tr>
|
|
423
|
+
))}
|
|
424
|
+
|
|
425
|
+
{lines.length === 0 && (
|
|
426
|
+
<tr>
|
|
427
|
+
<td colSpan={6} className={styles.tableEmpty}>
|
|
428
|
+
{loading ? (
|
|
429
|
+
<Loader2
|
|
430
|
+
size={18}
|
|
431
|
+
className={styles.spin}
|
|
432
|
+
aria-label="Loading"
|
|
433
|
+
/>
|
|
434
|
+
) : (
|
|
435
|
+
<p>
|
|
436
|
+
No lines yet. Add one and give it the Zoom
|
|
437
|
+
Phone number staff should message from.
|
|
438
|
+
</p>
|
|
439
|
+
)}
|
|
440
|
+
</td>
|
|
441
|
+
</tr>
|
|
442
|
+
)}
|
|
443
|
+
</tbody>
|
|
444
|
+
</table>
|
|
445
|
+
</div>
|
|
446
|
+
</section>
|
|
447
|
+
|
|
448
|
+
{/* --------------------------------------------------- templates */}
|
|
449
|
+
|
|
450
|
+
<section className={styles.section}>
|
|
451
|
+
<div className={styles.sectionHead}>
|
|
452
|
+
<h2 className={styles.sectionTitle}>Templates</h2>
|
|
453
|
+
<button
|
|
454
|
+
type="button"
|
|
455
|
+
className={styles.primaryButton}
|
|
456
|
+
onClick={() => setTemplateDraft({ ...emptyTemplate })}
|
|
457
|
+
>
|
|
458
|
+
<Plus size={14} strokeWidth={2} aria-hidden="true" />
|
|
459
|
+
Add a template
|
|
460
|
+
</button>
|
|
461
|
+
</div>
|
|
462
|
+
|
|
463
|
+
<div className={styles.tableWrap}>
|
|
464
|
+
<table className={styles.table}>
|
|
465
|
+
<thead>
|
|
466
|
+
<tr>
|
|
467
|
+
<th>Name</th>
|
|
468
|
+
<th>Body</th>
|
|
469
|
+
<th>Length</th>
|
|
470
|
+
<th>Active</th>
|
|
471
|
+
<th className={styles.actionsHead}>Actions</th>
|
|
472
|
+
</tr>
|
|
473
|
+
</thead>
|
|
474
|
+
<tbody>
|
|
475
|
+
{templates.map((template) => {
|
|
476
|
+
const counts = segmentCount(template.body);
|
|
477
|
+
|
|
478
|
+
return (
|
|
479
|
+
<tr key={template.id}>
|
|
480
|
+
<td>
|
|
481
|
+
<strong>{template.name}</strong>
|
|
482
|
+
</td>
|
|
483
|
+
<td>{template.body}</td>
|
|
484
|
+
<td className={styles.numberCell}>
|
|
485
|
+
{counts.characters} · {counts.segments} SMS
|
|
486
|
+
</td>
|
|
487
|
+
<td>
|
|
488
|
+
<span
|
|
489
|
+
className={`${styles.pill} ${
|
|
490
|
+
template.active
|
|
491
|
+
? styles.pillOk
|
|
492
|
+
: styles.pillMuted
|
|
493
|
+
}`}
|
|
494
|
+
>
|
|
495
|
+
{template.active ? 'Active' : 'Off'}
|
|
496
|
+
</span>
|
|
497
|
+
</td>
|
|
498
|
+
<td className={styles.actionsCell}>
|
|
499
|
+
<button
|
|
500
|
+
type="button"
|
|
501
|
+
className={styles.linkButton}
|
|
502
|
+
onClick={() =>
|
|
503
|
+
setTemplateDraft({
|
|
504
|
+
...emptyTemplate,
|
|
505
|
+
...template,
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
>
|
|
509
|
+
Edit
|
|
510
|
+
</button>
|
|
511
|
+
<button
|
|
512
|
+
type="button"
|
|
513
|
+
className={styles.dangerLink}
|
|
514
|
+
onClick={() => deleteTemplate(template)}
|
|
515
|
+
>
|
|
516
|
+
Delete
|
|
517
|
+
</button>
|
|
518
|
+
</td>
|
|
519
|
+
</tr>
|
|
520
|
+
);
|
|
521
|
+
})}
|
|
522
|
+
|
|
523
|
+
{templates.length === 0 && (
|
|
524
|
+
<tr>
|
|
525
|
+
<td colSpan={5} className={styles.tableEmpty}>
|
|
526
|
+
<p>
|
|
527
|
+
No templates yet. They appear in the Templates
|
|
528
|
+
menu beside the composer.
|
|
529
|
+
</p>
|
|
530
|
+
</td>
|
|
531
|
+
</tr>
|
|
532
|
+
)}
|
|
533
|
+
</tbody>
|
|
534
|
+
</table>
|
|
535
|
+
</div>
|
|
536
|
+
</section>
|
|
537
|
+
|
|
538
|
+
{/* ------------------------------------------------------ modals */}
|
|
539
|
+
|
|
540
|
+
{lineDraft && (
|
|
541
|
+
<StandardModal isOpen onClose={() => setLineDraft(null)} size="medium">
|
|
542
|
+
<div className={styles.modalForm}>
|
|
543
|
+
<div className={styles.modalHead}>
|
|
544
|
+
<h3 className={styles.modalTitle}>
|
|
545
|
+
{lineDraft.id ? 'Edit line' : 'Add a line'}
|
|
546
|
+
</h3>
|
|
547
|
+
<button
|
|
548
|
+
type="button"
|
|
549
|
+
className={styles.iconButton}
|
|
550
|
+
onClick={() => setLineDraft(null)}
|
|
551
|
+
aria-label="Close"
|
|
552
|
+
>
|
|
553
|
+
<X size={15} strokeWidth={2} aria-hidden="true" />
|
|
554
|
+
</button>
|
|
555
|
+
</div>
|
|
556
|
+
|
|
557
|
+
<label className={styles.field}>
|
|
558
|
+
<span className={styles.label}>Label</span>
|
|
559
|
+
<input
|
|
560
|
+
type="text"
|
|
561
|
+
className={styles.input}
|
|
562
|
+
value={lineDraft.label}
|
|
563
|
+
placeholder="Reception, Advice team, Reminders…"
|
|
564
|
+
onChange={(event) =>
|
|
565
|
+
setLineDraft({ ...lineDraft, label: event.target.value })
|
|
566
|
+
}
|
|
567
|
+
/>
|
|
568
|
+
</label>
|
|
569
|
+
|
|
570
|
+
<label className={styles.field}>
|
|
571
|
+
<span className={styles.label}>Phone number</span>
|
|
572
|
+
<input
|
|
573
|
+
type="text"
|
|
574
|
+
className={styles.input}
|
|
575
|
+
value={lineDraft.phone_number}
|
|
576
|
+
placeholder="+61 8 9375 2549"
|
|
577
|
+
onChange={(event) =>
|
|
578
|
+
setLineDraft({
|
|
579
|
+
...lineDraft,
|
|
580
|
+
phone_number: event.target.value,
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
/>
|
|
584
|
+
<p className={styles.help}>
|
|
585
|
+
{lineDraft.phone_number
|
|
586
|
+
? `Shown as ${normaliseNumberForDisplay(
|
|
587
|
+
lineDraft.phone_number
|
|
588
|
+
)}`
|
|
589
|
+
: 'The Zoom Phone number this line sends from.'}
|
|
590
|
+
</p>
|
|
591
|
+
</label>
|
|
592
|
+
|
|
593
|
+
{zoom.connected ? (
|
|
594
|
+
<label className={styles.field}>
|
|
595
|
+
<span className={styles.label}>Zoom user</span>
|
|
596
|
+
<select
|
|
597
|
+
className={styles.select}
|
|
598
|
+
value={lineDraft.zoom_user_id || ''}
|
|
599
|
+
onChange={(event) => {
|
|
600
|
+
const user = zoom.users.find(
|
|
601
|
+
(one) => String(one.id) === event.target.value
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
setLineDraft({
|
|
605
|
+
...lineDraft,
|
|
606
|
+
zoom_user_id: event.target.value,
|
|
607
|
+
zoom_user_email: user?.email || '',
|
|
608
|
+
// A Zoom user usually has exactly one
|
|
609
|
+
// number; filling it in saves retyping
|
|
610
|
+
// an E.164 by hand.
|
|
611
|
+
phone_number:
|
|
612
|
+
lineDraft.phone_number ||
|
|
613
|
+
user?.phone_numbers?.[0] ||
|
|
614
|
+
'',
|
|
615
|
+
});
|
|
616
|
+
}}
|
|
617
|
+
>
|
|
618
|
+
<option value="">Not linked to a Zoom user</option>
|
|
619
|
+
{zoom.users.map((user) => (
|
|
620
|
+
<option key={user.id} value={user.id}>
|
|
621
|
+
{user.display_name || user.email}
|
|
622
|
+
{user.phone_numbers?.length
|
|
623
|
+
? ` · ${user.phone_numbers.join(', ')}`
|
|
624
|
+
: ''}
|
|
625
|
+
</option>
|
|
626
|
+
))}
|
|
627
|
+
</select>
|
|
628
|
+
</label>
|
|
629
|
+
) : (
|
|
630
|
+
<p className={styles.help}>
|
|
631
|
+
Zoom is not connected, so there is no user list to pick
|
|
632
|
+
from yet. Fill in the number now and link the Zoom user
|
|
633
|
+
once the connection is live.
|
|
634
|
+
</p>
|
|
635
|
+
)}
|
|
636
|
+
|
|
637
|
+
<div className={styles.field}>
|
|
638
|
+
<span className={styles.label}>Staff who can see this line</span>
|
|
639
|
+
<div className={styles.staffPicker}>
|
|
640
|
+
{staff.length === 0 && (
|
|
641
|
+
<p className={styles.help}>
|
|
642
|
+
No staff list was supplied, so this line is visible
|
|
643
|
+
to everyone with messaging access.
|
|
644
|
+
</p>
|
|
645
|
+
)}
|
|
646
|
+
{staff.map((user) => {
|
|
647
|
+
const checked = (lineDraft.user_ids || []).some(
|
|
648
|
+
(id) => String(id) === String(user.id)
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
return (
|
|
652
|
+
<label key={user.id} className={styles.inlineCheck}>
|
|
653
|
+
<input
|
|
654
|
+
type="checkbox"
|
|
655
|
+
checked={checked}
|
|
656
|
+
onChange={(event) =>
|
|
657
|
+
setLineDraft({
|
|
658
|
+
...lineDraft,
|
|
659
|
+
user_ids: event.target.checked
|
|
660
|
+
? [
|
|
661
|
+
...(lineDraft.user_ids ||
|
|
662
|
+
[]),
|
|
663
|
+
user.id,
|
|
664
|
+
]
|
|
665
|
+
: (
|
|
666
|
+
lineDraft.user_ids || []
|
|
667
|
+
).filter(
|
|
668
|
+
(id) =>
|
|
669
|
+
String(id) !==
|
|
670
|
+
String(user.id)
|
|
671
|
+
),
|
|
672
|
+
})
|
|
673
|
+
}
|
|
674
|
+
/>
|
|
675
|
+
{user.name}
|
|
676
|
+
</label>
|
|
677
|
+
);
|
|
678
|
+
})}
|
|
679
|
+
</div>
|
|
680
|
+
<p className={styles.help}>
|
|
681
|
+
Leave every box clear to let anyone with messaging access
|
|
682
|
+
see this line.
|
|
683
|
+
</p>
|
|
684
|
+
</div>
|
|
685
|
+
|
|
686
|
+
<label className={styles.inlineCheck}>
|
|
687
|
+
<input
|
|
688
|
+
type="checkbox"
|
|
689
|
+
checked={Boolean(lineDraft.active)}
|
|
690
|
+
onChange={(event) =>
|
|
691
|
+
setLineDraft({
|
|
692
|
+
...lineDraft,
|
|
693
|
+
active: event.target.checked,
|
|
694
|
+
})
|
|
695
|
+
}
|
|
696
|
+
/>
|
|
697
|
+
Active
|
|
698
|
+
</label>
|
|
699
|
+
|
|
700
|
+
<div className={styles.modalActions}>
|
|
701
|
+
<button
|
|
702
|
+
type="button"
|
|
703
|
+
className={styles.ghostButton}
|
|
704
|
+
onClick={() => setLineDraft(null)}
|
|
705
|
+
>
|
|
706
|
+
Cancel
|
|
707
|
+
</button>
|
|
708
|
+
<button
|
|
709
|
+
type="button"
|
|
710
|
+
className={styles.primaryButton}
|
|
711
|
+
onClick={saveLine}
|
|
712
|
+
disabled={
|
|
713
|
+
saving ||
|
|
714
|
+
lineDraft.label.trim() === '' ||
|
|
715
|
+
lineDraft.phone_number.trim() === ''
|
|
716
|
+
}
|
|
717
|
+
>
|
|
718
|
+
{saving && (
|
|
719
|
+
<Loader2
|
|
720
|
+
size={14}
|
|
721
|
+
className={styles.spin}
|
|
722
|
+
aria-hidden="true"
|
|
723
|
+
/>
|
|
724
|
+
)}
|
|
725
|
+
Save
|
|
726
|
+
</button>
|
|
727
|
+
</div>
|
|
728
|
+
</div>
|
|
729
|
+
</StandardModal>
|
|
730
|
+
)}
|
|
731
|
+
|
|
732
|
+
{templateDraft && (
|
|
733
|
+
<StandardModal isOpen onClose={() => setTemplateDraft(null)} size="medium">
|
|
734
|
+
<div className={styles.modalForm}>
|
|
735
|
+
<div className={styles.modalHead}>
|
|
736
|
+
<h3 className={styles.modalTitle}>
|
|
737
|
+
{templateDraft.id ? 'Edit template' : 'Add a template'}
|
|
738
|
+
</h3>
|
|
739
|
+
<button
|
|
740
|
+
type="button"
|
|
741
|
+
className={styles.iconButton}
|
|
742
|
+
onClick={() => setTemplateDraft(null)}
|
|
743
|
+
aria-label="Close"
|
|
744
|
+
>
|
|
745
|
+
<X size={15} strokeWidth={2} aria-hidden="true" />
|
|
746
|
+
</button>
|
|
747
|
+
</div>
|
|
748
|
+
|
|
749
|
+
<label className={styles.field}>
|
|
750
|
+
<span className={styles.label}>Name</span>
|
|
751
|
+
<input
|
|
752
|
+
type="text"
|
|
753
|
+
className={styles.input}
|
|
754
|
+
value={templateDraft.name}
|
|
755
|
+
placeholder="Appointment reminder"
|
|
756
|
+
onChange={(event) =>
|
|
757
|
+
setTemplateDraft({
|
|
758
|
+
...templateDraft,
|
|
759
|
+
name: event.target.value,
|
|
760
|
+
})
|
|
761
|
+
}
|
|
762
|
+
/>
|
|
763
|
+
</label>
|
|
764
|
+
|
|
765
|
+
<label className={styles.field}>
|
|
766
|
+
<span className={styles.label}>Body</span>
|
|
767
|
+
<textarea
|
|
768
|
+
className={styles.textarea}
|
|
769
|
+
rows={4}
|
|
770
|
+
value={templateDraft.body}
|
|
771
|
+
onChange={(event) =>
|
|
772
|
+
setTemplateDraft({
|
|
773
|
+
...templateDraft,
|
|
774
|
+
body: event.target.value,
|
|
775
|
+
})
|
|
776
|
+
}
|
|
777
|
+
/>
|
|
778
|
+
<p className={styles.counter}>
|
|
779
|
+
{segmentCount(templateDraft.body).characters} characters ·{' '}
|
|
780
|
+
{segmentCount(templateDraft.body).segments} SMS ·{' '}
|
|
781
|
+
{segmentCount(templateDraft.body).encoding}
|
|
782
|
+
</p>
|
|
783
|
+
<p className={styles.help}>
|
|
784
|
+
Placeholders: {'{first_name}'} {'{last_name}'} {'{name}'}{' '}
|
|
785
|
+
{'{date}'} {'{time}'} {'{number}'} {'{line}'} — filled from
|
|
786
|
+
the conversation when inserted.
|
|
787
|
+
</p>
|
|
788
|
+
</label>
|
|
789
|
+
|
|
790
|
+
<label className={styles.field}>
|
|
791
|
+
<span className={styles.label}>Sort</span>
|
|
792
|
+
<input
|
|
793
|
+
type="number"
|
|
794
|
+
className={styles.input}
|
|
795
|
+
value={templateDraft.sort}
|
|
796
|
+
onChange={(event) =>
|
|
797
|
+
setTemplateDraft({
|
|
798
|
+
...templateDraft,
|
|
799
|
+
sort: event.target.value,
|
|
800
|
+
})
|
|
801
|
+
}
|
|
802
|
+
/>
|
|
803
|
+
</label>
|
|
804
|
+
|
|
805
|
+
<label className={styles.inlineCheck}>
|
|
806
|
+
<input
|
|
807
|
+
type="checkbox"
|
|
808
|
+
checked={Boolean(templateDraft.active)}
|
|
809
|
+
onChange={(event) =>
|
|
810
|
+
setTemplateDraft({
|
|
811
|
+
...templateDraft,
|
|
812
|
+
active: event.target.checked,
|
|
813
|
+
})
|
|
814
|
+
}
|
|
815
|
+
/>
|
|
816
|
+
Active
|
|
817
|
+
</label>
|
|
818
|
+
|
|
819
|
+
<div className={styles.modalActions}>
|
|
820
|
+
<button
|
|
821
|
+
type="button"
|
|
822
|
+
className={styles.ghostButton}
|
|
823
|
+
onClick={() => setTemplateDraft(null)}
|
|
824
|
+
>
|
|
825
|
+
Cancel
|
|
826
|
+
</button>
|
|
827
|
+
<button
|
|
828
|
+
type="button"
|
|
829
|
+
className={styles.primaryButton}
|
|
830
|
+
onClick={saveTemplate}
|
|
831
|
+
disabled={
|
|
832
|
+
saving ||
|
|
833
|
+
templateDraft.name.trim() === '' ||
|
|
834
|
+
templateDraft.body.trim() === ''
|
|
835
|
+
}
|
|
836
|
+
>
|
|
837
|
+
{saving && (
|
|
838
|
+
<Loader2
|
|
839
|
+
size={14}
|
|
840
|
+
className={styles.spin}
|
|
841
|
+
aria-hidden="true"
|
|
842
|
+
/>
|
|
843
|
+
)}
|
|
844
|
+
Save
|
|
845
|
+
</button>
|
|
846
|
+
</div>
|
|
847
|
+
</div>
|
|
848
|
+
</StandardModal>
|
|
849
|
+
)}
|
|
850
|
+
</div>
|
|
851
|
+
);
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
export default SmsLineSettings;
|