@visns-studio/visns-components 6.17.1 → 6.20.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.
Files changed (84) hide show
  1. package/package.json +10 -7
  2. package/src/components/AssociationManager.jsx +344 -177
  3. package/src/components/AsyncSelect.jsx +2 -2
  4. package/src/components/Autocomplete.jsx +57 -12
  5. package/src/components/Breadcrumb.jsx +23 -4
  6. package/src/components/ClientAssociationManager.jsx +321 -145
  7. package/src/components/DataGrid.jsx +625 -20
  8. package/src/components/DropZone.jsx +4 -1
  9. package/src/components/Form.jsx +233 -6
  10. package/src/components/MultiSelect.jsx +2 -2
  11. package/src/components/Navigation.jsx +45 -25
  12. package/src/components/TableFilter.jsx +172 -12
  13. package/src/components/UniversalSearch.jsx +390 -0
  14. package/src/components/auth/Login.jsx +164 -8
  15. package/src/components/auth/Profile.jsx +209 -22
  16. package/src/components/auth/authEndpoints.js +4 -0
  17. package/src/components/auth/webauthn.js +194 -0
  18. package/src/components/columns/ColumnRenderers.jsx +6 -1
  19. package/src/components/controls/DataGridSearch.jsx +11 -5
  20. package/src/components/generic/GenericAuth.jsx +6 -0
  21. package/src/components/generic/GenericDashboard.jsx +93 -36
  22. package/src/components/generic/GenericDetail.jsx +146 -61
  23. package/src/components/generic/GenericIndex.jsx +134 -38
  24. package/src/components/generic/GenericSettings.jsx +220 -0
  25. package/src/components/generic/StandardModal.jsx +165 -8
  26. package/src/components/generic/dashboardChartDefaults.js +230 -0
  27. package/src/components/generic/shared/indexLayout.js +38 -0
  28. package/src/components/navActive.js +25 -0
  29. package/src/components/settings/IntegrationsManager.jsx +474 -0
  30. package/src/components/sms/SmsLineSettings.jsx +16 -12
  31. package/src/components/styles/AssociationManager.module.scss +4 -363
  32. package/src/components/styles/AuditLog.module.scss +6 -6
  33. package/src/components/styles/AuditLogs.module.scss +3 -3
  34. package/src/components/styles/Autocomplete.module.scss +7 -7
  35. package/src/components/styles/BusinessCardOcr.module.scss +6 -1
  36. package/src/components/styles/CallQueueSettings.module.scss +4 -1
  37. package/src/components/styles/CameraPlacement.module.scss +19 -19
  38. package/src/components/styles/CategorizedDropZone.module.scss +6 -1
  39. package/src/components/styles/ClientAssociationManager.module.scss +5 -289
  40. package/src/components/styles/ClientAuth.module.scss +1 -1
  41. package/src/components/styles/DataGrid.module.scss +455 -8
  42. package/src/components/styles/Field.module.scss +32 -24
  43. package/src/components/styles/Form.module.scss +123 -3
  44. package/src/components/styles/GenericDashboard.module.scss +612 -825
  45. package/src/components/styles/GenericDetail.module.scss +406 -61
  46. package/src/components/styles/GenericDynamic.module.scss +25 -25
  47. package/src/components/styles/GenericEditableTable.module.scss +33 -33
  48. package/src/components/styles/GenericFormBuilder.module.scss +119 -119
  49. package/src/components/styles/GenericIndex.module.scss +483 -38
  50. package/src/components/styles/GenericMain.module.scss +6 -3
  51. package/src/components/styles/GenericQuote.module.scss +399 -81
  52. package/src/components/styles/GenericReport.module.scss +8 -8
  53. package/src/components/styles/GenericSettings.module.scss +209 -0
  54. package/src/components/styles/GenericSort.module.scss +9 -9
  55. package/src/components/styles/Integrations.module.scss +321 -0
  56. package/src/components/styles/List.module.scss +4 -4
  57. package/src/components/styles/Login.module.scss +57 -0
  58. package/src/components/styles/MultiSelect.module.scss +4 -4
  59. package/src/components/styles/Navigation.module.scss +12 -12
  60. package/src/components/styles/Notification.module.scss +12 -8
  61. package/src/components/styles/NotificationList.module.scss +5 -5
  62. package/src/components/styles/Profile.module.scss +5 -5
  63. package/src/components/styles/QrCode.module.scss +4 -4
  64. package/src/components/styles/QuickAction.module.scss +20 -20
  65. package/src/components/styles/SortableQuoteItems.module.scss +4 -4
  66. package/src/components/styles/TableFilter.module.scss +128 -17
  67. package/src/components/styles/TicketConversation.module.scss +497 -0
  68. package/src/components/styles/UniversalSearch.module.scss +229 -0
  69. package/src/components/styles/Vault.module.scss +413 -0
  70. package/src/components/styles/_associationManager.scss +467 -0
  71. package/src/components/styles/_authBrandPanel.scss +15 -9
  72. package/src/components/styles/_controls.scss +43 -39
  73. package/src/components/styles/global-datagrid.css +196 -0
  74. package/src/components/styles/global.css +168 -17
  75. package/src/components/tickets/TicketConversation.jsx +455 -0
  76. package/src/components/utils/rowActionSettings.js +117 -0
  77. package/src/components/vault/VaultClientPicker.jsx +183 -0
  78. package/src/components/vault/VaultEntryForm.jsx +23 -0
  79. package/src/components/vault/VaultManager.jsx +382 -34
  80. package/src/components/vault/VaultShareModal.jsx +454 -0
  81. package/src/components/vault/vaultEndpoints.js +16 -2
  82. package/src/components/vault/vaultShares.js +247 -0
  83. package/src/index.js +35 -0
  84. package/src/utils/fetchUtil.js +38 -14
@@ -0,0 +1,38 @@
1
+ import { isFlatFilterList } from '../../TableFilter';
2
+
3
+ /**
4
+ * Should this index lay its filter menu out as a horizontal tab strip?
5
+ *
6
+ * Split out of GenericIndex rather than written inline because it is the one
7
+ * decision in the embedded-index work that can be wrong silently: get it
8
+ * wrong and the page builds a strip-shaped container around a vertical rail,
9
+ * which no test of the rendered page would catch either — GenericIndex pulls
10
+ * in the datagrid and cannot be imported by the test runner at all.
11
+ *
12
+ * Three conditions, all necessary:
13
+ *
14
+ * - the caller asked for it (`tabLayout === 'underline'`; the default,
15
+ * `'rail'`, is what every index page has always rendered);
16
+ * - the layout is not `simple`, which builds its own `tabcontent` frame that
17
+ * a strip has no place in;
18
+ * - the filters are a FLAT list. `isFlatFilterList` is TableFilter's own
19
+ * gate for the strip, imported rather than repeated so the container built
20
+ * here can never disagree with the markup TableFilter puts inside it. A
21
+ * rail with expandable parents is a two-level menu with no single row to
22
+ * lay out, and keeps the rail.
23
+ *
24
+ * @param {object} options
25
+ * @param {string} [options.tabLayout] 'rail' (default) or 'underline'.
26
+ * @param {string} [options.layout] GenericIndex's `layout` prop.
27
+ * @param {Array} [options.filters] The live subnav items.
28
+ * @returns {boolean}
29
+ */
30
+ export function shouldUnderlineTabs({ tabLayout, layout, filters } = {}) {
31
+ return (
32
+ tabLayout === 'underline' &&
33
+ layout !== 'simple' &&
34
+ isFlatFilterList(filters)
35
+ );
36
+ }
37
+
38
+ export default shouldUnderlineTabs;
@@ -57,4 +57,29 @@ export function isNavActive(navUrl, childUrls, currentUrl) {
57
57
  return (childUrls || []).some((child) => covers(cleanUrl(child), current));
58
58
  }
59
59
 
60
+
61
+ /**
62
+ * Which ONE of a set of sibling urls is the current page's best match: the
63
+ * longest url that covers it. `/reports` and `/reports/prebuilt` both cover
64
+ * `/reports/prebuilt`; only the second is the page you are on.
65
+ *
66
+ * @param {string[]} urls sibling urls (dropdown children).
67
+ * @param {string} currentUrl the path being viewed.
68
+ * @returns {string|null} the cleaned winning url, or null when none covers it.
69
+ */
70
+ export function bestMatchingUrl(urls, currentUrl) {
71
+ const current = cleanUrl(currentUrl) || '/';
72
+ let best = null;
73
+
74
+ (urls || []).forEach((url) => {
75
+ const base = cleanUrl(url);
76
+
77
+ if (!base || base === '/') return;
78
+ if (!covers(base, current)) return;
79
+ if (best === null || base.length > best.length) best = base;
80
+ });
81
+
82
+ return best;
83
+ }
84
+
60
85
  export default isNavActive;
@@ -0,0 +1,474 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { toast } from 'react-toastify';
3
+ import {
4
+ AlertTriangle,
5
+ Check,
6
+ ExternalLink,
7
+ Link2,
8
+ Loader2,
9
+ Plug,
10
+ RefreshCw,
11
+ Trash2,
12
+ X,
13
+ } from 'lucide-react';
14
+
15
+ import CustomFetch from '../Fetch';
16
+ import styles from '../styles/Integrations.module.scss';
17
+
18
+ const DEFAULT_BASE = '/ajax/integrations';
19
+
20
+ /**
21
+ * Settings → Integrations.
22
+ *
23
+ * One card per connected system. Two kinds, and the card shape is the same for
24
+ * both because to the person configuring them they are the same job:
25
+ *
26
+ * oauth2 — fill in the client id/secret, then Connect, which sends the
27
+ * browser to the provider's consent screen.
28
+ * api_key — fill in the fields and save.
29
+ *
30
+ * A SECRET IS NEVER SENT BACK TO THIS PAGE. The API reports which fields hold
31
+ * a value, not what they hold, so a secret field renders empty with a "Set"
32
+ * badge beside it. Leaving it empty on save means "leave it alone" — that is
33
+ * the whole reason the API treats an absent key as unchanged. Clearing one
34
+ * takes a deliberate press of the field's clear button, which sends an
35
+ * explicit empty string.
36
+ */
37
+ const IntegrationsManager = ({ base = DEFAULT_BASE, canManage = true }) => {
38
+ const [items, setItems] = useState([]);
39
+ const [loading, setLoading] = useState(true);
40
+ const [busy, setBusy] = useState(null);
41
+ const [drafts, setDrafts] = useState({});
42
+ const mountedRef = useRef(true);
43
+
44
+ useEffect(() => () => {
45
+ mountedRef.current = false;
46
+ }, []);
47
+
48
+ const load = useCallback(() => {
49
+ setLoading(true);
50
+
51
+ CustomFetch(base, 'GET', {}, (result) => {
52
+ if (!mountedRef.current) return;
53
+
54
+ setItems(Array.isArray(result?.data) ? result.data : []);
55
+ setLoading(false);
56
+ }, null, { cache: false }).catch(() => {
57
+ if (!mountedRef.current) return;
58
+
59
+ setItems([]);
60
+ setLoading(false);
61
+ });
62
+ }, [base]);
63
+
64
+ useEffect(() => {
65
+ load();
66
+ }, [load]);
67
+
68
+ const draftFor = (provider) => drafts[provider] ?? {};
69
+
70
+ const setDraft = (provider, key, value) =>
71
+ setDrafts((prev) => ({
72
+ ...prev,
73
+ [provider]: { ...(prev[provider] ?? {}), [key]: value },
74
+ }));
75
+
76
+ const save = (item) => {
77
+ const draft = draftFor(item.provider);
78
+
79
+ if (Object.keys(draft).length === 0) {
80
+ toast.info('Nothing changed.');
81
+
82
+ return;
83
+ }
84
+
85
+ setBusy(item.provider);
86
+
87
+ CustomFetch(
88
+ `${base}/${item.provider}`,
89
+ 'PUT',
90
+ { credentials: draft },
91
+ (result) => {
92
+ if (!mountedRef.current) return;
93
+
94
+ setBusy(null);
95
+ setDrafts((prev) => ({ ...prev, [item.provider]: {} }));
96
+ toast.success(`${item.name} saved`);
97
+
98
+ if (result?.data) {
99
+ setItems((prev) =>
100
+ prev.map((i) =>
101
+ i.provider === item.provider ? result.data : i
102
+ )
103
+ );
104
+ }
105
+ },
106
+ null
107
+ ).catch(() => {
108
+ if (!mountedRef.current) return;
109
+
110
+ setBusy(null);
111
+ toast.error(`Could not save ${item.name}`);
112
+ });
113
+ };
114
+
115
+ const test = (item) => {
116
+ setBusy(item.provider);
117
+
118
+ CustomFetch(
119
+ `${base}/${item.provider}/test`,
120
+ 'POST',
121
+ {},
122
+ (result) => {
123
+ if (!mountedRef.current) return;
124
+
125
+ setBusy(null);
126
+ toast.success(result?.data?.message || 'Connected');
127
+ load();
128
+ },
129
+ null
130
+ ).catch((error) => {
131
+ if (!mountedRef.current) return;
132
+
133
+ setBusy(null);
134
+ // The probe answers 422 with the reason it failed, and that reason
135
+ // is the entire value of the button.
136
+ toast.error(error?.data?.data?.message || `${item.name} refused the credentials`);
137
+ load();
138
+ });
139
+ };
140
+
141
+ const connect = (item) => {
142
+ setBusy(item.provider);
143
+
144
+ CustomFetch(
145
+ `${base}/${item.provider}/authorize-url`,
146
+ 'GET',
147
+ {},
148
+ (result) => {
149
+ const url = result?.data?.url;
150
+
151
+ if (!url) {
152
+ setBusy(null);
153
+ toast.error('Could not build the consent URL.');
154
+
155
+ return;
156
+ }
157
+
158
+ // A full navigation, not a fetch: the consent screen is the
159
+ // provider's own page and cannot be loaded cross-origin.
160
+ window.location.href = url;
161
+ },
162
+ null
163
+ ).catch((error) => {
164
+ if (!mountedRef.current) return;
165
+
166
+ setBusy(null);
167
+ toast.error(error?.data?.message || 'Could not start the connection.');
168
+ });
169
+ };
170
+
171
+ const disconnect = (item) => {
172
+ setBusy(item.provider);
173
+
174
+ CustomFetch(
175
+ `${base}/${item.provider}`,
176
+ 'DELETE',
177
+ {},
178
+ (result) => {
179
+ if (!mountedRef.current) return;
180
+
181
+ setBusy(null);
182
+
183
+ // Clearing the database does not clear the server's .env, and
184
+ // saying "disconnected" when a key is still resolving from
185
+ // there would be a lie the user cannot see through.
186
+ toast.success(
187
+ result?.still_configured_from_env
188
+ ? `${item.name} cleared — it is still configured from the server environment.`
189
+ : `${item.name} disconnected`
190
+ );
191
+ load();
192
+ },
193
+ null
194
+ ).catch(() => {
195
+ if (!mountedRef.current) return;
196
+
197
+ setBusy(null);
198
+ toast.error('Could not disconnect.');
199
+ });
200
+ };
201
+
202
+ const grouped = useMemo(() => {
203
+ const out = new Map();
204
+
205
+ items.forEach((item) => {
206
+ const key = item.category || 'Other';
207
+
208
+ if (!out.has(key)) out.set(key, []);
209
+ out.get(key).push(item);
210
+ });
211
+
212
+ return [...out.entries()];
213
+ }, [items]);
214
+
215
+ if (loading) {
216
+ return (
217
+ <div className={styles.loading}>
218
+ <Loader2 size={18} className={styles.spin} />
219
+ Loading integrations…
220
+ </div>
221
+ );
222
+ }
223
+
224
+ if (items.length === 0) {
225
+ return (
226
+ <p className={styles.empty}>
227
+ No integrations are configured for this application.
228
+ </p>
229
+ );
230
+ }
231
+
232
+ return (
233
+ <div className={styles.wrap}>
234
+ {grouped.map(([category, group]) => (
235
+ <section key={category} className={styles.group}>
236
+ <h3 className={styles.groupTitle}>{category}</h3>
237
+
238
+ {group.map((item) => (
239
+ <IntegrationCard
240
+ key={item.provider}
241
+ item={item}
242
+ busy={busy === item.provider}
243
+ canManage={canManage}
244
+ draft={draftFor(item.provider)}
245
+ onChange={(k, v) => setDraft(item.provider, k, v)}
246
+ onSave={() => save(item)}
247
+ onTest={() => test(item)}
248
+ onConnect={() => connect(item)}
249
+ onDisconnect={() => disconnect(item)}
250
+ />
251
+ ))}
252
+ </section>
253
+ ))}
254
+ </div>
255
+ );
256
+ };
257
+
258
+ const STATUS_TONE = {
259
+ connected: 'ok',
260
+ configured: 'ready',
261
+ incomplete: 'warn',
262
+ expired: 'bad',
263
+ disabled: 'muted',
264
+ };
265
+
266
+ const IntegrationCard = ({
267
+ item,
268
+ busy,
269
+ canManage,
270
+ draft,
271
+ onChange,
272
+ onSave,
273
+ onTest,
274
+ onConnect,
275
+ onDisconnect,
276
+ }) => {
277
+ const tone = STATUS_TONE[item.status?.state] || 'muted';
278
+ const isOAuth = item.driver === 'oauth2';
279
+ const connected = item.status?.state === 'connected';
280
+ const dirty = Object.keys(draft).length > 0;
281
+
282
+ return (
283
+ <article className={styles.card}>
284
+ <header className={styles.cardHead}>
285
+ <span className={styles.cardIcon} aria-hidden="true">
286
+ <Plug size={16} strokeWidth={2} />
287
+ </span>
288
+
289
+ <div className={styles.cardTitle}>
290
+ <h4>{item.name}</h4>
291
+ {item.description && <p>{item.description}</p>}
292
+ {item.services?.length > 0 && (
293
+ <p className={styles.services}>
294
+ {item.services.join(' · ')}
295
+ </p>
296
+ )}
297
+ </div>
298
+
299
+ {/* Never colour alone: the badge carries a word as well. */}
300
+ <span className={`${styles.badge} ${styles[tone]}`}>
301
+ {tone === 'ok' && <Check size={12} strokeWidth={3} />}
302
+ {tone === 'warn' && <AlertTriangle size={12} strokeWidth={2.5} />}
303
+ {tone === 'bad' && <X size={12} strokeWidth={3} />}
304
+ {item.status?.label}
305
+ </span>
306
+ </header>
307
+
308
+ {item.fields?.length > 0 && (
309
+ <div className={styles.fields}>
310
+ {item.fields.map((field) => (
311
+ <CredentialField
312
+ key={field.key}
313
+ field={field}
314
+ value={draft[field.key]}
315
+ disabled={!canManage || busy}
316
+ onChange={(v) => onChange(field.key, v)}
317
+ />
318
+ ))}
319
+ </div>
320
+ )}
321
+
322
+ {isOAuth && item.redirect_uri && (
323
+ <p className={styles.redirect}>
324
+ <strong>Redirect URI</strong> — paste this into the provider’s
325
+ console exactly as shown:
326
+ <code>{item.redirect_uri}</code>
327
+ </p>
328
+ )}
329
+
330
+ {item.last_test && (
331
+ <p
332
+ className={
333
+ item.last_test.status === 'ok'
334
+ ? styles.testOk
335
+ : styles.testBad
336
+ }
337
+ >
338
+ Last test {new Date(item.last_test.at).toLocaleString()} —{' '}
339
+ {item.last_test.message}
340
+ </p>
341
+ )}
342
+
343
+ <footer className={styles.cardFoot}>
344
+ {item.docs_url && (
345
+ <a
346
+ className={styles.docs}
347
+ href={item.docs_url}
348
+ target="_blank"
349
+ rel="noreferrer noopener"
350
+ >
351
+ Provider console <ExternalLink size={12} />
352
+ </a>
353
+ )}
354
+
355
+ <span className={styles.spacer} />
356
+
357
+ {canManage && (connected || item.fields?.some((f) => f.source === 'database')) && (
358
+ <button
359
+ type="button"
360
+ className={styles.ghost}
361
+ onClick={onDisconnect}
362
+ disabled={busy}
363
+ >
364
+ <Trash2 size={13} /> Clear
365
+ </button>
366
+ )}
367
+
368
+ {item.can_test && (
369
+ <button
370
+ type="button"
371
+ className={styles.secondary}
372
+ onClick={onTest}
373
+ disabled={busy}
374
+ >
375
+ {busy ? (
376
+ <Loader2 size={13} className={styles.spin} />
377
+ ) : (
378
+ <RefreshCw size={13} />
379
+ )}
380
+ Test connection
381
+ </button>
382
+ )}
383
+
384
+ {canManage && (
385
+ <button
386
+ type="button"
387
+ className={styles.primary}
388
+ onClick={onSave}
389
+ disabled={busy || !dirty}
390
+ >
391
+ Save
392
+ </button>
393
+ )}
394
+
395
+ {canManage && isOAuth && (
396
+ <button
397
+ type="button"
398
+ className={styles.primary}
399
+ onClick={onConnect}
400
+ disabled={busy || item.status?.state === 'incomplete'}
401
+ title={
402
+ item.status?.state === 'incomplete'
403
+ ? 'Fill in the required fields first'
404
+ : undefined
405
+ }
406
+ >
407
+ <Link2 size={13} />
408
+ {connected ? 'Reconnect' : 'Connect'}
409
+ </button>
410
+ )}
411
+ </footer>
412
+ </article>
413
+ );
414
+ };
415
+
416
+ /**
417
+ * One credential field.
418
+ *
419
+ * A secret renders EMPTY even when it holds a value, because the value never
420
+ * left the server. "Set" beside the label is the only thing said about it, and
421
+ * `source` says where it came from — the difference between "I still need to
422
+ * type this" and "this is already in the server's .env".
423
+ */
424
+ const CredentialField = ({ field, value, disabled, onChange }) => {
425
+ const id = `integration-${field.key}`;
426
+ const isSelect = field.type === 'select' && Array.isArray(field.options);
427
+
428
+ return (
429
+ <div className={styles.field}>
430
+ <label htmlFor={id}>
431
+ {field.label}
432
+ {field.required && <span aria-hidden="true"> *</span>}
433
+ {field.is_set && (
434
+ <span className={styles.setTag}>
435
+ Set{field.source === 'env' ? ' · from .env' : ''}
436
+ </span>
437
+ )}
438
+ </label>
439
+
440
+ {isSelect ? (
441
+ <select
442
+ id={id}
443
+ disabled={disabled}
444
+ value={value ?? field.value ?? ''}
445
+ onChange={(e) => onChange(e.target.value)}
446
+ >
447
+ {field.options.map((option) => (
448
+ <option key={option} value={option}>
449
+ {option}
450
+ </option>
451
+ ))}
452
+ </select>
453
+ ) : (
454
+ <input
455
+ id={id}
456
+ type={field.secret ? 'password' : 'text'}
457
+ autoComplete={field.secret ? 'new-password' : 'off'}
458
+ disabled={disabled}
459
+ placeholder={
460
+ field.secret && field.is_set
461
+ ? '•••••••• (leave blank to keep)'
462
+ : field.placeholder || ''
463
+ }
464
+ value={value ?? (field.secret ? '' : field.value ?? '')}
465
+ onChange={(e) => onChange(e.target.value)}
466
+ />
467
+ )}
468
+
469
+ {field.help && <p className={styles.help}>{field.help}</p>}
470
+ </div>
471
+ );
472
+ };
473
+
474
+ export default IntegrationsManager;
@@ -44,6 +44,8 @@ const SmsLineSettings = ({
44
44
  // Accepted for consistency with the CRM's other settings screens.
45
45
  userProfile: _userProfile = null,
46
46
  staffOptions = [],
47
+ // `null` drops the header, for a host that already printed a heading above
48
+ // this one — the settings hub prints the section title and description.
47
49
  pageTitle = 'Messaging',
48
50
  }) => {
49
51
  const routes = useMemo(() => resolveSmsEndpoints(endpoints), [endpoints]);
@@ -259,19 +261,21 @@ const SmsLineSettings = ({
259
261
 
260
262
  return (
261
263
  <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>
264
+ {pageTitle !== null && (
265
+ <div className={styles.settingsHead}>
266
+ <div>
267
+ <h1 className={styles.pageTitle}>
268
+ <MessageSquare size={22} strokeWidth={2.2} aria-hidden="true" />
269
+ {pageTitle}
270
+ </h1>
271
+ <p className={styles.pageIntro}>
272
+ A line is one Zoom Phone number and the staff who can see its
273
+ conversations. Everything staff send goes out from the line they
274
+ are replying on.
275
+ </p>
276
+ </div>
273
277
  </div>
274
- </div>
278
+ )}
275
279
 
276
280
  <div className={styles.statusCard}>
277
281
  <div className={styles.statusItem}>