@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
@@ -21,11 +21,16 @@ import {
21
21
  Shield,
22
22
  Image,
23
23
  Phone,
24
+ KeyRound,
25
+ Lock,
26
+ MessageSquare,
27
+ MessagesSquare,
24
28
  } from 'lucide-react';
25
29
  import CustomFetch from './Fetch';
26
30
  import Notification from './Notification';
27
31
  import SwitchAccount from './SwitchAccount';
28
32
  import BusinessCardOcr from './BusinessCardOcr';
33
+ import { isNavActive } from './navActive';
29
34
  import styles from './styles/Navigation.module.scss';
30
35
 
31
36
  // Opt-in icons for the sidebar (simple/cms) layouts: a nav entry names one
@@ -56,6 +61,10 @@ const NAV_ICONS = {
56
61
  const SETTING_ICONS = {
57
62
  bug: Bug,
58
63
  calendar: Calendar,
64
+ key: KeyRound,
65
+ lock: Lock,
66
+ message: MessageSquare,
67
+ messages: MessagesSquare,
59
68
  draft: FileText,
60
69
  image: Image,
61
70
  peopleGroup: Users,
@@ -76,6 +85,31 @@ const RAIL_ACTION_LABELS = {
76
85
  notifications: 'Notifications',
77
86
  };
78
87
 
88
+ /**
89
+ * A chip is an icon and nothing else, so its name has to come from somewhere:
90
+ * it is the tooltip, and it is the only thing a screen reader has to go on.
91
+ * A config entry that forgot its `label` used to produce a nameless button —
92
+ * so the id, which is always there, is humanised as the last resort
93
+ * (`callRecordings` -> `Call Recordings`).
94
+ */
95
+ const actionLabel = (n) => {
96
+ if (n.label) {
97
+ return n.label;
98
+ }
99
+ if (RAIL_ACTION_LABELS[n.id]) {
100
+ return RAIL_ACTION_LABELS[n.id];
101
+ }
102
+ if (!n.id) {
103
+ return '';
104
+ }
105
+ return n.id
106
+ .replace(/[-_]+/g, ' ')
107
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
108
+ .replace(/\s+/g, ' ')
109
+ .trim()
110
+ .replace(/^./, (c) => c.toUpperCase());
111
+ };
112
+
79
113
  const SearchComponent = ({
80
114
  navigate,
81
115
  search,
@@ -242,6 +276,22 @@ function Navigation({
242
276
  themeType,
243
277
  userProfile,
244
278
  debugIcons = false, // Add debug flag
279
+ /*
280
+ * Per-id renderers for account actions: `{ [setting.id]: ({ setting,
281
+ * userProfile, setSystemAuth }) => node }`.
282
+ *
283
+ * The chain below is hard-coded because every action it knows about ships
284
+ * with this library. An action that lives in a consuming app — or in a
285
+ * module the library carries but the header should not import eagerly —
286
+ * has nowhere to go. This is that slot: a matching id renders the
287
+ * supplied node inside the same row wrapper the built-in branches use,
288
+ * on both the header chips and the sidebar rail.
289
+ *
290
+ * Checked before the built-in chain, so a project can also override a
291
+ * shipped action. Empty by default, so nothing changes for anyone who
292
+ * does not pass it.
293
+ */
294
+ renderers = {},
245
295
  }) {
246
296
  const [search, setSearch] = useState('');
247
297
  const [searchResultShow, setSearchResultShow] = useState(false);
@@ -304,6 +354,19 @@ function Navigation({
304
354
  // Add active class if the setting is active
305
355
  const activeClass = isActive ? styles.active : '';
306
356
 
357
+ // A consumer-supplied renderer wins over the built-in chain.
358
+ if (typeof renderers[n.id] === 'function') {
359
+ return (
360
+ <li>
361
+ {renderers[n.id]({
362
+ setting: n,
363
+ userProfile,
364
+ setSystemAuth,
365
+ })}
366
+ </li>
367
+ );
368
+ }
369
+
307
370
  if (n.id === 'notifications') {
308
371
  return (
309
372
  <li>
@@ -314,8 +377,11 @@ function Navigation({
314
377
  return (
315
378
  <li>
316
379
  <button
380
+ type="button"
317
381
  data-tooltip-id="system-tooltip"
382
+ data-tooltip-place="bottom"
318
383
  data-tooltip-content="Scan Business Card"
384
+ aria-label="Scan Business Card"
319
385
  onClick={() => setShowBusinessCardOcr(true)}
320
386
  className={activeClass}
321
387
  >
@@ -335,11 +401,15 @@ function Navigation({
335
401
  );
336
402
  } else if (n.id === 'logout') {
337
403
  const IconComponent = iconComponents[n.icon];
404
+ const label = actionLabel(n);
338
405
  return (
339
406
  <li>
340
407
  <button
408
+ type="button"
341
409
  data-tooltip-id="system-tooltip"
342
- data-tooltip-content={n.label}
410
+ data-tooltip-place="bottom"
411
+ data-tooltip-content={label}
412
+ aria-label={label}
343
413
  onClick={handleLogout}
344
414
  className={activeClass} // Add active class to the logout button
345
415
  >
@@ -349,13 +419,16 @@ function Navigation({
349
419
  );
350
420
  } else {
351
421
  const IconComponent = iconComponents[n.icon];
422
+ const label = actionLabel(n);
352
423
 
353
424
  return (
354
425
  <li>
355
426
  <Link
356
427
  to={n.url}
357
428
  data-tooltip-id="system-tooltip"
358
- data-tooltip-content={n.label}
429
+ data-tooltip-place="bottom"
430
+ data-tooltip-content={label}
431
+ aria-label={label}
359
432
  className={`${activeClass}`}
360
433
  target={n.target ? n.target : '_self'}
361
434
  >
@@ -385,11 +458,25 @@ function Navigation({
385
458
  const renderRailSetting = (n) => {
386
459
  const isActive = n.url && n.url === `/${currentPage}`;
387
460
  const activeClass = isActive ? styles.active : '';
388
- const label = n.label || RAIL_ACTION_LABELS[n.id] || '';
461
+ const label = actionLabel(n);
389
462
  // A CSS string, quoted and escaped, for `content`.
390
463
  const labelVar = { '--nav-action-label': JSON.stringify(label) };
391
464
  const IconComponent = SETTING_ICONS[n.icon];
392
465
 
466
+ // Same slot as the header's, publishing the row label the same way a
467
+ // mounted component gets one (see the note above renderRailSetting).
468
+ if (typeof renderers[n.id] === 'function') {
469
+ return (
470
+ <li style={labelVar}>
471
+ {renderers[n.id]({
472
+ setting: n,
473
+ userProfile,
474
+ setSystemAuth,
475
+ })}
476
+ </li>
477
+ );
478
+ }
479
+
393
480
  if (n.id === 'notifications') {
394
481
  return (
395
482
  <li style={labelVar}>
@@ -510,9 +597,8 @@ function Navigation({
510
597
  <li key={`nav-child-${childKey}`}>
511
598
  <Link
512
599
  to={child.url}
513
- title={child.label}
514
600
  className={
515
- location.pathname === child.url
601
+ isNavActive(child.url, [], currentPage)
516
602
  ? styles.active
517
603
  : ''
518
604
  }
@@ -543,7 +629,7 @@ function Navigation({
543
629
  {n.label}
544
630
  </Link>
545
631
  ) : (
546
- <span title={n.label}>{n.label}</span>
632
+ <span>{n.label}</span>
547
633
  )}
548
634
  {renderChildren()}
549
635
  </li>
@@ -685,52 +771,25 @@ function Navigation({
685
771
 
686
772
  useEffect(() => {
687
773
  setNavData((prevNavData) => {
774
+ /**
775
+ * One rule at every depth — see navActive.js, which holds it and
776
+ * is tested directly. This used to branch on how many segments the
777
+ * URL happened to have, only consulted an item's children in one
778
+ * of those branches, and compared paths with `String.includes`.
779
+ * The visible cost: standing on `/reports`, which Admin owns as a
780
+ * child, nothing in the bar was marked at all.
781
+ */
688
782
  const updateNavItems = (nav) => {
689
- const cleanUrl = (url) => {
690
- if (url) {
691
- // Ensure the URL starts with a slash. If it doesn't, prepend it.
692
- return url.startsWith('/') ? url : `/${url}`;
693
- }
694
- return '';
695
- };
696
-
697
- // Ensure both URLs have the leading slash for consistent comparison
698
- const currentUrl = cleanUrl(currentPage);
699
- const navUrl = nav.url ? cleanUrl(nav.url) : 'dashboard';
700
-
701
- // Split current URL into segments
702
- const currentUrlSegments = currentUrl
703
- .split('/')
704
- .filter(Boolean);
705
-
706
- // Handle the case where navUrl is just '/'
707
- if (
708
- navUrl === '/' &&
709
- (currentUrl === '/dashboard' || currentUrl === '/')
710
- ) {
711
- return { ...nav, class: 'nav-item active' }; // Mark as active for dashboard if navUrl is '/'
712
- }
713
-
714
- // If current URL has 2 or fewer segments, check if navUrl is contained in currentUrl
715
- if (currentUrlSegments.length <= 2) {
716
- if (currentUrl.includes(navUrl) && nav.id !== 'dashboard') {
717
- return { ...nav, class: 'nav-item active' }; // Mark as active if contained
718
- }
719
- } else {
720
- // If more than 2 segments, use exact match logic
721
- if (
722
- navUrl === currentUrl ||
723
- (nav.children &&
724
- nav.children.some(
725
- (child) =>
726
- cleanUrl(child.url) === `/${currentPage}`
727
- ))
728
- ) {
729
- return { ...nav, class: 'nav-item active' }; // Mark as active
730
- }
731
- }
783
+ const childUrls = (nav.children || []).map(
784
+ (child) => child.url
785
+ );
732
786
 
733
- return { ...nav, class: 'nav-item' }; // Default class
787
+ return {
788
+ ...nav,
789
+ class: isNavActive(nav.url, childUrls, currentPage)
790
+ ? 'nav-item active'
791
+ : 'nav-item',
792
+ };
734
793
  };
735
794
 
736
795
  return {
@@ -3,10 +3,36 @@ import { useNavigate } from 'react-router-dom';
3
3
  import moment from 'moment';
4
4
  import parse from 'html-react-parser';
5
5
  import { toast } from 'react-toastify';
6
- import { Bell, X, Check } from 'lucide-react';
6
+ import { Bell, X, Check, Monitor } from 'lucide-react';
7
7
  import CustomFetch from './Fetch';
8
+ import {
9
+ getPermission,
10
+ isEnabled as desktopAlertsEnabled,
11
+ isSupported as desktopAlertsSupported,
12
+ notify as desktopNotify,
13
+ requestPermission as requestDesktopPermission,
14
+ setEnabled as setDesktopAlertsEnabled,
15
+ } from './notify/desktopNotifications';
16
+ // Pure subscription bookkeeping. It lives under sms/ because that is where it
17
+ // was written, but there is nothing messaging-specific in it — importing it
18
+ // keeps one implementation of "has this channel actually been authorised?".
19
+ import { subscriptionMonitor } from './sms/smsLiveState';
8
20
  import styles from './styles/Notification.module.scss';
9
21
 
22
+ /**
23
+ * The event Laravel broadcasts a notification as, and what Echo's
24
+ * `.notification()` helper binds to. Named here so the fallback path below can
25
+ * unbind it explicitly.
26
+ */
27
+ const BROADCAST_EVENT = '.Illuminate\\Notifications\\Events\\BroadcastNotificationCreated';
28
+
29
+ /** Polling, with and without a confirmed websocket. */
30
+ const POLL_INTERVAL = 60000;
31
+ const LIVE_POLL_INTERVAL = 300000;
32
+
33
+ /** The bold line on a desktop alert. */
34
+ const ALERT_TITLE = 'Throughlife';
35
+
10
36
  // Helper function to sanitize HTML content and remove unwanted styling from anchor tags
11
37
  const sanitizeHtml = (html) => {
12
38
  if (!html) return '';
@@ -47,6 +73,26 @@ const sanitizeHtml = (html) => {
47
73
  return tempDiv.innerHTML;
48
74
  };
49
75
 
76
+ /**
77
+ * A label with its markup taken off, for the desktop toast.
78
+ *
79
+ * Notification labels are stored as HTML (the bell parses them), and the OS
80
+ * toast renders text — so "<b>New task</b>" would otherwise appear verbatim.
81
+ */
82
+ const plainText = (html) => {
83
+ if (!html) return '';
84
+
85
+ if (typeof document === 'undefined') {
86
+ return String(html).replace(/<[^>]*>/g, '').trim();
87
+ }
88
+
89
+ const holder = document.createElement('div');
90
+
91
+ holder.innerHTML = String(html);
92
+
93
+ return (holder.textContent || '').replace(/\s+/g, ' ').trim();
94
+ };
95
+
50
96
  function Notification(props) {
51
97
  const navigate = useNavigate();
52
98
  const wrapperRef = useRef(null);
@@ -54,7 +100,22 @@ function Notification(props) {
54
100
  const [show, setShow] = useState(false);
55
101
  const [loading, setLoading] = useState(false);
56
102
  const [processingIds, setProcessingIds] = useState([]);
57
- const { setSystemAuth } = props;
103
+ const { setSystemAuth, echo = null, channel = null } = props;
104
+
105
+ // Live means *confirmed*: Echo hands back a channel object long before
106
+ // /broadcasting/auth has approved it, and treating that as live is how a
107
+ // bell ends up subscribed to nothing and polling every five minutes.
108
+ const [live, setLive] = useState(false);
109
+ const [alertsOn, setAlertsOn] = useState(() => desktopAlertsEnabled());
110
+ const [permission, setPermission] = useState(() => getPermission());
111
+
112
+ const alertsSupported = desktopAlertsSupported();
113
+
114
+ // Read through refs so a consumer passing inline arrows — `echo={() =>
115
+ // getEcho()}` is the normal way to write it — does not tear the
116
+ // subscription down on every render. Only the channel name does that.
117
+ const echoRef = useRef(echo);
118
+ echoRef.current = echo;
58
119
 
59
120
  const fetchNotification = useCallback(() => {
60
121
  setLoading(true);
@@ -184,26 +245,191 @@ function Notification(props) {
184
245
  setShow(false);
185
246
  }, [navigate]);
186
247
 
248
+ /**
249
+ * One notification, straight off the websocket.
250
+ *
251
+ * The payload is App\Notifications\SystemAlert::toBroadcast() — the same
252
+ * id the database row was written under, so a live item and a polled one
253
+ * are interchangeable and "mark as read" works on either.
254
+ */
255
+ const handleBroadcast = useCallback(
256
+ (payload) => {
257
+ const label = payload?.label;
258
+
259
+ if (!label) return;
260
+
261
+ const id = payload.id ?? `live-${Date.now()}`;
262
+ const url = payload.url ?? null;
263
+
264
+ setItems((prev) => {
265
+ // Pusher can redeliver, and a poll may have raced us here.
266
+ if (prev.some((item) => item.id === id)) return prev;
267
+
268
+ return [
269
+ {
270
+ id,
271
+ data: { label, url },
272
+ read_at: null,
273
+ created_at: payload.created_at ?? new Date().toISOString(),
274
+ },
275
+ ...prev,
276
+ ];
277
+ });
278
+
279
+ desktopNotify({
280
+ title: ALERT_TITLE,
281
+ body: plainText(label),
282
+ tag: `notification-${id}`,
283
+ url,
284
+ navigate,
285
+ });
286
+ },
287
+ [navigate]
288
+ );
289
+
290
+ const broadcastRef = useRef(handleBroadcast);
291
+ broadcastRef.current = handleBroadcast;
292
+
293
+ /**
294
+ * Subscribe to this user's private channel, when the consumer named one.
295
+ *
296
+ * `channel` is deliberately allowed to be null — a build with no
297
+ * VITE_APP_ENV cannot know whether it is dev or production, and dev and
298
+ * production share one Pusher app. Guessing there would put a developer's
299
+ * browser on the production channel; polling is the safe answer.
300
+ */
187
301
  useEffect(() => {
188
- fetchNotification();
302
+ if (!channel || typeof window === 'undefined') {
303
+ setLive(false);
304
+
305
+ return undefined;
306
+ }
307
+
308
+ const source = echoRef.current;
309
+ const instance = typeof source === 'function' ? source() : source;
310
+
311
+ if (!instance || typeof instance.private !== 'function') {
312
+ setLive(false);
313
+
314
+ return undefined;
315
+ }
316
+
317
+ let subscription;
318
+
319
+ try {
320
+ subscription = instance.private(channel);
321
+ } catch (error) {
322
+ setLive(false);
323
+
324
+ return undefined;
325
+ }
326
+
327
+ if (!subscription) {
328
+ setLive(false);
189
329
 
190
- const interval = setInterval(() => {
191
- if (!document.hidden) {
192
- fetchNotification();
330
+ return undefined;
331
+ }
332
+
333
+ const handler = (payload) => broadcastRef.current?.(payload || {});
334
+
335
+ try {
336
+ // Echo's own helper for Laravel's broadcast notifications; the
337
+ // raw `listen` is the fallback for anything hand-rolled.
338
+ if (typeof subscription.notification === 'function') {
339
+ subscription.notification(handler);
340
+ } else {
341
+ subscription.listen(BROADCAST_EVENT, handler);
193
342
  }
194
- }, 60000);
343
+ } catch (error) {
344
+ setLive(false);
345
+
346
+ return undefined;
347
+ }
348
+
349
+ const stopMonitoring = subscriptionMonitor(subscription, {
350
+ onSuccess: () => setLive(true),
351
+ onError: () => setLive(false),
352
+ });
353
+
354
+ return () => {
355
+ setLive(false);
356
+ stopMonitoring();
357
+
358
+ try {
359
+ subscription.stopListening?.(BROADCAST_EVENT);
360
+ instance.leave(channel);
361
+ } catch (error) {
362
+ // Nothing to clean up.
363
+ }
364
+ };
365
+ }, [channel]);
366
+
367
+ /**
368
+ * The fallback, and the safety net.
369
+ *
370
+ * Off the websocket this is the only thing keeping the bell current, so it
371
+ * runs every minute. Once a channel confirms it drops to five — slow
372
+ * enough to be free, quick enough that a dropped event is invisible.
373
+ */
374
+ useEffect(() => {
375
+ fetchNotification();
376
+
377
+ const interval = setInterval(
378
+ () => {
379
+ if (!document.hidden) {
380
+ fetchNotification();
381
+ }
382
+ },
383
+ live ? LIVE_POLL_INTERVAL : POLL_INTERVAL
384
+ );
195
385
 
386
+ return () => clearInterval(interval);
387
+ }, [fetchNotification, live]);
388
+
389
+ useEffect(() => {
196
390
  document.addEventListener('mousedown', handleClickOutside, false);
197
391
 
198
392
  return function cleanup() {
199
- clearInterval(interval);
200
393
  document.removeEventListener(
201
394
  'mousedown',
202
395
  handleClickOutside,
203
396
  false
204
397
  );
205
398
  };
206
- }, [fetchNotification, handleClickOutside]);
399
+ }, [handleClickOutside]);
400
+
401
+ /**
402
+ * The switch at the foot of the dropdown.
403
+ *
404
+ * Turning it on for the first time is what asks the browser for
405
+ * permission — the prompt only appears inside a user gesture, which is why
406
+ * this cannot be done on mount. A refusal leaves the switch off and the
407
+ * row explains itself; the browser will not ask twice.
408
+ */
409
+ const toggleDesktopAlerts = useCallback(() => {
410
+ if (alertsOn) {
411
+ setDesktopAlertsEnabled(false);
412
+ setAlertsOn(false);
413
+
414
+ return;
415
+ }
416
+
417
+ if (getPermission() === 'granted') {
418
+ setDesktopAlertsEnabled(true);
419
+ setAlertsOn(true);
420
+
421
+ return;
422
+ }
423
+
424
+ requestDesktopPermission().then((result) => {
425
+ setPermission(result);
426
+
427
+ if (result !== 'granted') return;
428
+
429
+ setDesktopAlertsEnabled(true);
430
+ setAlertsOn(true);
431
+ });
432
+ }, [alertsOn]);
207
433
 
208
434
  const formatTimeAgo = (date) => {
209
435
  const now = moment();
@@ -225,6 +451,7 @@ function Notification(props) {
225
451
  className={styles.notificationTrigger}
226
452
  onClick={toggleNotification}
227
453
  data-tooltip-id="system-tooltip"
454
+ data-tooltip-place="bottom"
228
455
  data-tooltip-content="Notifications"
229
456
  aria-label="Notifications"
230
457
  >
@@ -331,6 +558,49 @@ function Notification(props) {
331
558
  )}
332
559
  </div>
333
560
 
561
+ {alertsSupported && (
562
+ <div className={styles.alertsRow}>
563
+ <span className={styles.alertsLabel}>
564
+ <Monitor
565
+ size={14}
566
+ strokeWidth={2}
567
+ aria-hidden="true"
568
+ />
569
+ Desktop alerts
570
+ </span>
571
+
572
+ {permission === 'denied' ? (
573
+ <span className={styles.alertsBlocked}>
574
+ Blocked in browser settings
575
+ </span>
576
+ ) : (
577
+ <button
578
+ type="button"
579
+ className={[
580
+ styles.toggle,
581
+ alertsOn ? styles.toggleOn : '',
582
+ ]
583
+ .filter(Boolean)
584
+ .join(' ')}
585
+ onClick={toggleDesktopAlerts}
586
+ role="switch"
587
+ aria-checked={alertsOn}
588
+ aria-label="Desktop alerts"
589
+ >
590
+ <span
591
+ className={styles.toggleTrack}
592
+ aria-hidden="true"
593
+ >
594
+ <span
595
+ className={styles.toggleKnob}
596
+ />
597
+ </span>
598
+ {alertsOn ? 'On' : 'Off'}
599
+ </button>
600
+ )}
601
+ </div>
602
+ )}
603
+
334
604
  <div className={styles.notiboxActions}>
335
605
  <button
336
606
  className={styles.viewAllBtn}
@@ -9,6 +9,15 @@ function TableFilter({
9
9
  type,
10
10
  onFilterChange,
11
11
  tabs,
12
+ // `variant="underline"` lays the rail out as a horizontal tab strip with
13
+ // an accent edge under the selected tab, at every width — the same
14
+ // treatment narrow viewports already get for a flat rail (see the
15
+ // `.flatFilter` block in the stylesheet), promoted to an opt-in.
16
+ //
17
+ // A vertical rail spends a fifth of the page on navigation, which pays for
18
+ // itself at seven tabs and does not at two. Pages say which they are; the
19
+ // default is unchanged.
20
+ variant,
12
21
  }) {
13
22
  const [visibleChildren, setVisibleChildren] = useState({});
14
23
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@@ -661,11 +670,15 @@ function TableFilter({
661
670
  }
662
671
  }, [isFlatStrip, activeId]);
663
672
 
673
+ // Gated on `isFlatStrip`: the strip is one row of tabs, and a rail with
674
+ // expandable parents has no single row to lay out.
675
+ const isUnderline = variant === 'underline' && isFlatStrip;
676
+
664
677
  return (
665
678
  <div
666
679
  className={`${styles.tableFilterContainer} ${
667
680
  isFlatStrip ? styles.flatFilter : ''
668
- }`}
681
+ } ${isUnderline ? styles.underlineFilter : ''}`}
669
682
  >
670
683
  {!isFlatStrip && (
671
684
  <div className={styles.mobileToggle} onClick={toggleMobileMenu}>
@@ -0,0 +1,43 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * The brand side of a two-pane auth screen.
5
+ *
6
+ * Markup counterpart to the `auth-brand-panel` mixin in
7
+ * styles/_authBrandPanel.scss — the staff Login, the shared AuthShell and the
8
+ * client portal's split layout all render exactly this, and each supplies its
9
+ * own CSS module (the class names are identical because they all include that
10
+ * one mixin).
11
+ *
12
+ * `aria-hidden` is deliberate and applies to every consumer: everything this
13
+ * panel says is said again, in words, by the form beside it. A screen reader
14
+ * announcing the brand line twice before reaching the first field is worse
15
+ * than not announcing the photograph at all.
16
+ *
17
+ * Renders the `<aside>` unconditionally, even with nothing to put in it — it is
18
+ * a layout column, and a grid with a missing child is a broken screen rather
19
+ * than a tidier one. Only the ink block is conditional.
20
+ */
21
+ const AuthBrandPanel = ({ styles, loginBg, branding = {} }) => (
22
+ <aside
23
+ className={styles.brand}
24
+ style={loginBg ? { backgroundImage: `url(${loginBg})` } : undefined}
25
+ aria-hidden="true"
26
+ >
27
+ {(branding.eyebrow || branding.name || branding.tagline) && (
28
+ <div className={styles.brandInk}>
29
+ {branding.eyebrow && (
30
+ <p className={styles.eyebrow}>{branding.eyebrow}</p>
31
+ )}
32
+ {branding.name && (
33
+ <p className={styles.brandLine}>{branding.name}</p>
34
+ )}
35
+ {branding.tagline && (
36
+ <p className={styles.brandSub}>{branding.tagline}</p>
37
+ )}
38
+ </div>
39
+ )}
40
+ </aside>
41
+ );
42
+
43
+ export default AuthBrandPanel;