@visns-studio/visns-components 6.6.4 → 6.15.1

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.
Files changed (98) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +181 -38
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/ColumnRenderers.jsx +53 -7
  32. package/src/components/generic/GenericAuth.jsx +163 -96
  33. package/src/components/generic/GenericDetail.jsx +215 -90
  34. package/src/components/generic/GenericIndex.jsx +20 -47
  35. package/src/components/generic/GenericMain.jsx +5 -0
  36. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  37. package/src/components/generic/StandardModal.jsx +17 -5
  38. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  39. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  40. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  41. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  42. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  44. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  45. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  46. package/src/components/navActive.js +60 -0
  47. package/src/components/notify/desktopNotifications.js +256 -0
  48. package/src/components/sketch/SketchField.jsx +12 -2
  49. package/src/components/sms/SmsComposeModal.jsx +495 -0
  50. package/src/components/sms/SmsInbox.jsx +596 -0
  51. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  52. package/src/components/sms/SmsLineSettings.jsx +854 -0
  53. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  54. package/src/components/sms/smsEndpoints.js +56 -0
  55. package/src/components/sms/smsHelpers.js +1075 -0
  56. package/src/components/sms/smsLiveState.js +132 -0
  57. package/src/components/sms/useSmsLive.js +307 -0
  58. package/src/components/styles/CallQueuePop.module.scss +646 -0
  59. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  60. package/src/components/styles/ClientAuth.module.scss +229 -0
  61. package/src/components/styles/GenericDetail.module.scss +153 -26
  62. package/src/components/styles/GenericIndex.module.scss +13 -0
  63. package/src/components/styles/GenericMain.module.scss +19 -2
  64. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  65. package/src/components/styles/Login.module.scss +13 -122
  66. package/src/components/styles/LogoutScreen.module.scss +89 -0
  67. package/src/components/styles/Navigation.module.scss +509 -223
  68. package/src/components/styles/Notification.module.scss +103 -0
  69. package/src/components/styles/Reset.module.scss +52 -186
  70. package/src/components/styles/Sms.module.scss +2382 -0
  71. package/src/components/styles/TableFilter.module.scss +118 -98
  72. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  73. package/src/components/styles/Vault.module.scss +1983 -0
  74. package/src/components/styles/Verify.module.scss +57 -180
  75. package/src/components/styles/_authBrandPanel.scss +163 -0
  76. package/src/components/styles/_authShell.scss +369 -0
  77. package/src/components/styles/global-datagrid.css +27 -2
  78. package/src/components/utils/buildEnv.js +67 -0
  79. package/src/components/utils/displayValue.js +94 -0
  80. package/src/components/utils/useDensity.js +345 -9
  81. package/src/components/vault/OtpChip.jsx +208 -0
  82. package/src/components/vault/PasswordGenerator.jsx +145 -0
  83. package/src/components/vault/QrScanner.jsx +326 -0
  84. package/src/components/vault/VaultAccessLog.jsx +225 -0
  85. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  86. package/src/components/vault/VaultEntryForm.jsx +740 -0
  87. package/src/components/vault/VaultManager.jsx +1000 -0
  88. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  89. package/src/components/vault/qrDecode.js +257 -0
  90. package/src/components/vault/useDebouncedValue.js +22 -0
  91. package/src/components/vault/useVaultReveal.js +160 -0
  92. package/src/components/vault/vaultClipboard.js +33 -0
  93. package/src/components/vault/vaultEndpoints.js +40 -0
  94. package/src/components/vault/vaultFit.js +116 -0
  95. package/src/components/vault/vaultHelpers.js +547 -0
  96. package/src/components/vault/vaultNavigation.jsx +58 -0
  97. package/src/components/vault/vaultOtp.js +105 -0
  98. 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;