@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.
Files changed (97) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +171 -37
  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/utils/buildEnv.js +67 -0
  78. package/src/components/utils/displayValue.js +94 -0
  79. package/src/components/utils/useDensity.js +345 -9
  80. package/src/components/vault/OtpChip.jsx +208 -0
  81. package/src/components/vault/PasswordGenerator.jsx +145 -0
  82. package/src/components/vault/QrScanner.jsx +326 -0
  83. package/src/components/vault/VaultAccessLog.jsx +225 -0
  84. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  85. package/src/components/vault/VaultEntryForm.jsx +740 -0
  86. package/src/components/vault/VaultManager.jsx +1000 -0
  87. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  88. package/src/components/vault/qrDecode.js +257 -0
  89. package/src/components/vault/useDebouncedValue.js +22 -0
  90. package/src/components/vault/useVaultReveal.js +160 -0
  91. package/src/components/vault/vaultClipboard.js +33 -0
  92. package/src/components/vault/vaultEndpoints.js +40 -0
  93. package/src/components/vault/vaultFit.js +116 -0
  94. package/src/components/vault/vaultHelpers.js +547 -0
  95. package/src/components/vault/vaultNavigation.jsx +58 -0
  96. package/src/components/vault/vaultOtp.js +105 -0
  97. package/src/index.js +233 -1
@@ -0,0 +1,1502 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import {
4
+ ChevronDown,
5
+ ExternalLink,
6
+ FlaskConical,
7
+ Phone,
8
+ PhoneIncoming,
9
+ X,
10
+ } from 'lucide-react';
11
+ import { toast } from 'react-toastify';
12
+
13
+ import CustomFetch from '../Fetch';
14
+ import styles from '../styles/CallQueuePop.module.scss';
15
+
16
+ // Pure helpers live in a plain .js sibling so `node --test` can import them;
17
+ // re-exported below so this file's public surface is unchanged.
18
+ import {
19
+ CLIENT_DETAIL_FIELDS,
20
+ FALLBACK_QUEUE_NAME,
21
+ MONITOR_PERMISSION,
22
+ clientDetails,
23
+ defaultClientTasksUrl,
24
+ defaultClientUrl,
25
+ defaultTaskUrl,
26
+ formatAuPhone,
27
+ formatDueDate,
28
+ formatElapsed,
29
+ formatEventDate,
30
+ hasMonitorPermission,
31
+ normaliseCall,
32
+ normalisePickupCodes,
33
+ toCallWorkspaceId,
34
+ toLocalDigits,
35
+ } from './callQueueHelpers';
36
+
37
+ export {
38
+ CLIENT_DETAIL_FIELDS,
39
+ clientDetails,
40
+ formatAuPhone,
41
+ formatDueDate,
42
+ formatElapsed,
43
+ formatEventDate,
44
+ hasMonitorPermission,
45
+ normaliseCall,
46
+ normalisePickupCodes,
47
+ toCallWorkspaceId,
48
+ toLocalDigits,
49
+ };
50
+
51
+ /**
52
+ * Call Queue Pop
53
+ * -----------------------------------------------------------------------
54
+ * A permission-gated, root-level stack of "call is ringing" cards for every
55
+ * Zoom Phone call queue in the account, so monitoring staff can see an incoming
56
+ * call and pick it up without watching the Zoom client. Each card's badge is
57
+ * simply the queue's own name, as Zoom named it in the webhook payload.
58
+ *
59
+ * Everything that talks to the server is optional and fails silently, so the
60
+ * component is safe to ship ahead of (or alongside) its backend:
61
+ * - GET `endpoints.live` -> `{ calls: [...], pickup_codes: {...} }`
62
+ * - GET `endpoints.clientTasks` -> `{ tasks: [...] }`, fetched lazily (once
63
+ * per call) when the open-task badge is expanded
64
+ * - Echo private channel (only when the `echo` prop yields an instance, i.e.
65
+ * the host app has a broadcaster configured)
66
+ * With neither present the component renders nothing and logs nothing.
67
+ *
68
+ * Pickup codes arrive as the snapshot's `pickup_codes` map, keyed by Zoom call
69
+ * queue id. A queue with no code still pops — its card just has no Pick up
70
+ * button, because there is no dial string to offer.
71
+ *
72
+ * The caller -> client match arrives already resolved, as each call's `client`
73
+ * block (computed once by the Zoom webhook, server-side). This component never
74
+ * looks a caller up itself — every watching member of staff sees every pop, and
75
+ * that was one duplicate lookup per watcher per ringing phone.
76
+ *
77
+ * Cross-tab sync: every open tab shares a BroadcastChannel, so dismissing or
78
+ * picking up a call in one tab clears that card everywhere immediately rather
79
+ * than waiting on the server's `queue.answered` event.
80
+ *
81
+ * Background awareness: when the tab is hidden/unfocused a new ringing call
82
+ * raises a system notification (deduped across tabs by `tag`) and the document
83
+ * title flashes until the user comes back.
84
+ *
85
+ * Demo mode lets the UI be reviewed today. From the browser console:
86
+ * window.callPopDemo() -> inject two sample calls (broadcast to all tabs)
87
+ * window.callPopClear() -> clear the stack (in all tabs)
88
+ * Demo calls bypass the permission gate and never hit a real endpoint.
89
+ *
90
+ * Library note: this is the portable version of the CRM's original component.
91
+ * Every host-specific decision it hard-coded — the Echo instance, the channel
92
+ * name, the endpoints, the routes it links to, whether Pick up is live — is now
93
+ * a prop whose default reproduces the CRM's behaviour exactly.
94
+ */
95
+
96
+ /**
97
+ * Events the backend broadcasts. The channel NAME is not a constant: it is
98
+ * environment-scoped ('call-queue-monitor.{env}') because dev and prod may
99
+ * share one Pusher app, and it arrives in the live snapshot — the `channel`
100
+ * prop is only the fallback for a server that does not name one.
101
+ */
102
+ const EVENT_RINGING = '.queue.ringing';
103
+ const EVENT_ANSWERED = '.queue.answered';
104
+ const EVENT_ENDED = '.queue.ended';
105
+
106
+ /** Default BroadcastChannel used to keep every open tab's stack in step. */
107
+ const SYNC_CHANNEL = 'throughlife-call-queue-pop';
108
+
109
+ /** Default Echo channel, used when the snapshot does not name its own. */
110
+ const DEFAULT_CHANNEL = 'call-queue-monitor';
111
+
112
+ /** How long the exit animation runs before the card is dropped from state. */
113
+ const EXIT_MS = 220;
114
+
115
+ /** Title shown on alternate ticks while a call rings in a hidden tab. */
116
+ const FLASH_TITLE = '📞 Incoming call…';
117
+
118
+ /** How often the hidden-tab title alternates. */
119
+ const FLASH_MS = 1000;
120
+
121
+ /** The trial-run banner's copy, when `trialBadge` is left at its default. */
122
+ const TRIAL_BADGE_TEXT = 'Trial run — the call pop is being tested';
123
+
124
+ /**
125
+ * The demo queue id + code injected by `window.callPopDemo()`, so the demo can
126
+ * show a card WITH a Pick up button next to one without.
127
+ */
128
+ const DEMO_QUEUE_ID = 'demo-test-queue';
129
+ const DEMO_PICKUP_CODE = '*996439';
130
+
131
+ /** Endpoints the component talks to; `endpoints` is merged over these. */
132
+ const DEFAULT_ENDPOINTS = {
133
+ live: '/ajax/call-queue/live',
134
+ clientTasks: (clientId) => `/ajax/call-queue/client/${clientId}/tasks`,
135
+ };
136
+
137
+ /**
138
+ * Substitute a `{token}` placeholder in a string template. Used so every
139
+ * URL prop accepts a plain string as well as a builder function.
140
+ */
141
+ const applyTemplate = (template, token, value) =>
142
+ String(template).split(token).join(value === null || value === undefined ? '' : String(value));
143
+
144
+ /**
145
+ * Resolve a URL prop that may be a builder function, a `{token}` template, or
146
+ * absent — in which case the caller's own fallback builder is used.
147
+ */
148
+ const resolveUrl = (option, token, value, fallback) => {
149
+ if (typeof option === 'function') {
150
+ return option(value);
151
+ }
152
+
153
+ if (typeof option === 'string' && option !== '') {
154
+ return applyTemplate(option, token, value);
155
+ }
156
+
157
+ return fallback(value);
158
+ };
159
+
160
+
161
+ /**
162
+ * Is the user actually looking at this tab? Both checks matter: a focused but
163
+ * backgrounded window reports `visible`, and a foreground window whose focus
164
+ * sits in another application reports `hasFocus() === false`.
165
+ */
166
+ const isPageVisible = () => {
167
+ if (typeof document === 'undefined') {
168
+ return true;
169
+ }
170
+
171
+ if (document.visibilityState !== 'visible') {
172
+ return false;
173
+ }
174
+
175
+ return typeof document.hasFocus === 'function' ? document.hasFocus() : true;
176
+ };
177
+
178
+ const CallQueuePop = ({
179
+ userProfile,
180
+ echo = null,
181
+ channel = DEFAULT_CHANNEL,
182
+ pickupEnabled = false,
183
+ trialBadge = true,
184
+ monitorPermission = MONITOR_PERMISSION,
185
+ endpoints,
186
+ onOpenTask = null,
187
+ taskUrl = defaultTaskUrl,
188
+ clientUrl = defaultClientUrl,
189
+ clientTasksUrl = defaultClientTasksUrl,
190
+ onOpenCalendar = null,
191
+ calendarPath = '/calendar',
192
+ callWorkspacePath = '/call/{number}',
193
+ syncChannelName = SYNC_CHANNEL,
194
+ demoEnabled = true,
195
+ }) => {
196
+ const [calls, setCalls] = useState([]);
197
+ const [now, setNow] = useState(() => Date.now());
198
+
199
+ // Pickup codes keyed by Zoom call queue id. Empty until the snapshot fills
200
+ // it in — there is no sensible client-side default for a dial string.
201
+ const [pickupCodes, setPickupCodes] = useState({});
202
+ // Environment-scoped Echo channel name, delivered by the snapshot.
203
+ const [echoChannel, setEchoChannel] = useState(null);
204
+
205
+ // Open-task drill-down, keyed by callId: which cards are expanded, and the
206
+ // tasks fetched for each as `{ status: idle|loading|ready|error, tasks }`.
207
+ const [expandedTasks, setExpandedTasks] = useState({});
208
+ const [taskLists, setTaskLists] = useState({});
209
+
210
+ // Timeouts for exit animations, cleared on unmount so no setState fires
211
+ // against an unmounted tree.
212
+ const exitTimers = useRef([]);
213
+
214
+ // callIds whose drill-down request has already gone out — the endpoint is
215
+ // hit once per call however often the chip is toggled.
216
+ const taskFetched = useRef(new Set());
217
+
218
+ // The cross-tab channel, and the live system notifications keyed by callId
219
+ // so each can be closed the moment its call goes away.
220
+ const syncChannel = useRef(null);
221
+ const notifications = useRef(new Map());
222
+ const notified = useRef(new Set());
223
+
224
+ // The Echo instance is read through a ref so a consumer passing an inline
225
+ // factory (`echo={() => getEcho()}`) does not tear the subscription down
226
+ // and rebuild it on every render. Changing the channel name is what forces
227
+ // a resubscribe, exactly as it did in the CRM.
228
+ const echoRef = useRef(echo);
229
+ echoRef.current = echo;
230
+
231
+ // The snapshot's own channel wins; the prop is the fallback for a server
232
+ // that does not name one. Pass `channel={null}` to restore the original
233
+ // "subscribe to nothing until the snapshot says so" behaviour.
234
+ const activeChannel = echoChannel || channel || null;
235
+
236
+ // Snapshot URL as a plain string, so it can sit in an effect's dep list
237
+ // without an object identity re-running the fetch on every render.
238
+ const liveUrl = endpoints?.live ?? DEFAULT_ENDPOINTS.live;
239
+
240
+ /** The drill-down endpoint for a client, from a builder or `{id}` template. */
241
+ const clientTasksEndpoint = useCallback(
242
+ (clientId) =>
243
+ resolveUrl(
244
+ endpoints?.clientTasks,
245
+ '{id}',
246
+ clientId,
247
+ DEFAULT_ENDPOINTS.clientTasks
248
+ ),
249
+ [endpoints]
250
+ );
251
+
252
+ const canMonitor = hasMonitorPermission(userProfile, monitorPermission);
253
+ const hasDemoCalls = calls.some((call) => call.isDemo);
254
+ const hasRinging = calls.some((call) => !call.leaving);
255
+ const isVisible = canMonitor || hasDemoCalls;
256
+
257
+ /** The trial banner's copy, or '' when the banner is switched off. */
258
+ const trialText = useMemo(() => {
259
+ if (typeof trialBadge === 'string') {
260
+ return trialBadge;
261
+ }
262
+
263
+ return trialBadge ? TRIAL_BADGE_TEXT : '';
264
+ }, [trialBadge]);
265
+
266
+ /** Add a call, ignoring duplicates of a callId already on screen. */
267
+ const addCall = useCallback((call) => {
268
+ if (!call) {
269
+ return;
270
+ }
271
+
272
+ setCalls((previous) => {
273
+ if (previous.some((item) => item.callId === call.callId)) {
274
+ return previous;
275
+ }
276
+
277
+ return [...previous, call];
278
+ });
279
+ }, []);
280
+
281
+ /** Animate a card out, then drop it from state. */
282
+ const removeCall = useCallback((callId) => {
283
+ setCalls((previous) =>
284
+ previous.map((call) =>
285
+ call.callId === String(callId)
286
+ ? { ...call, leaving: true }
287
+ : call
288
+ )
289
+ );
290
+
291
+ const timer = setTimeout(() => {
292
+ setCalls((previous) =>
293
+ previous.filter((call) => call.callId !== String(callId))
294
+ );
295
+ }, EXIT_MS);
296
+
297
+ exitTimers.current.push(timer);
298
+ }, []);
299
+
300
+ /** Drop the whole stack (no exit animation — used by clear). */
301
+ const clearCalls = useCallback(() => {
302
+ setCalls([]);
303
+ }, []);
304
+
305
+ /**
306
+ * The demo's "queue with a pickup code" case has no server behind it, so
307
+ * its code is injected locally whenever a demo call for that queue lands —
308
+ * in the tab that ran callPopDemo() and in every tab it was mirrored to, so
309
+ * the demo looks the same everywhere.
310
+ */
311
+ const addDemoPickupCode = useCallback((call) => {
312
+ if (!call?.isDemo || call.queueId !== DEMO_QUEUE_ID) {
313
+ return;
314
+ }
315
+
316
+ setPickupCodes((previous) =>
317
+ previous[DEMO_QUEUE_ID] === DEMO_PICKUP_CODE
318
+ ? previous
319
+ : { ...previous, [DEMO_QUEUE_ID]: DEMO_PICKUP_CODE }
320
+ );
321
+ }, []);
322
+
323
+ /** Undo the above, so the map only ever holds real configured codes. */
324
+ const dropDemoPickupCode = useCallback(() => {
325
+ setPickupCodes((previous) => {
326
+ if (!(DEMO_QUEUE_ID in previous)) {
327
+ return previous;
328
+ }
329
+
330
+ const next = { ...previous };
331
+ delete next[DEMO_QUEUE_ID];
332
+
333
+ return next;
334
+ });
335
+ }, []);
336
+
337
+ /**
338
+ * Fire-and-forget message to every other tab. No-op when the channel is
339
+ * unsupported or already closed.
340
+ */
341
+ const broadcastSync = useCallback((message) => {
342
+ const bus = syncChannel.current;
343
+
344
+ if (!bus) {
345
+ return;
346
+ }
347
+
348
+ try {
349
+ bus.postMessage(message);
350
+ } catch (error) {
351
+ // A closed/unsupported channel must never break the UI.
352
+ }
353
+ }, []);
354
+
355
+ /**
356
+ * Local removal plus a cross-tab `remove`, for the two user actions that
357
+ * settle a call: Dismiss and Pick up.
358
+ */
359
+ const dismissCall = useCallback(
360
+ (callId) => {
361
+ removeCall(callId);
362
+ broadcastSync({ type: 'remove', callId: String(callId) });
363
+ },
364
+ [broadcastSync, removeCall]
365
+ );
366
+
367
+ /**
368
+ * Expand/collapse a card's open-task list, fetching it the first time it is
369
+ * opened. Like everything else here the request fails silently — a card that
370
+ * cannot list its tasks still rings, picks up and dismisses.
371
+ */
372
+ const toggleTasks = useCallback(
373
+ (call) => {
374
+ const callId = call.callId;
375
+
376
+ setExpandedTasks((previous) => ({
377
+ ...previous,
378
+ [callId]: previous[callId] !== true,
379
+ }));
380
+
381
+ // Demo cards carry their own tasks, and the demo client (id 0) has
382
+ // nothing on the server to ask for.
383
+ if (Array.isArray(call.tasks) || !call.client?.id) {
384
+ return;
385
+ }
386
+
387
+ if (taskFetched.current.has(callId)) {
388
+ return;
389
+ }
390
+
391
+ taskFetched.current.add(callId);
392
+
393
+ const settle = (status, tasks) => {
394
+ setTaskLists((previous) => ({
395
+ ...previous,
396
+ [callId]: { status, tasks },
397
+ }));
398
+ };
399
+
400
+ settle('loading', []);
401
+
402
+ try {
403
+ const request = CustomFetch(
404
+ clientTasksEndpoint(call.client.id),
405
+ 'GET',
406
+ {},
407
+ function (result) {
408
+ const tasks = Array.isArray(result)
409
+ ? result
410
+ : result?.tasks;
411
+
412
+ settle('ready', Array.isArray(tasks) ? tasks : []);
413
+ },
414
+ function () {
415
+ settle('error', []);
416
+ }
417
+ );
418
+
419
+ if (request && typeof request.catch === 'function') {
420
+ request.catch(() => {});
421
+ }
422
+ } catch (error) {
423
+ settle('error', []);
424
+ }
425
+ },
426
+ [clientTasksEndpoint]
427
+ );
428
+
429
+ /**
430
+ * Ingest a call from the snapshot or a broadcast. The client block is
431
+ * already on the payload — the server resolves the caller -> client match
432
+ * once per call rather than every watching browser doing it.
433
+ */
434
+ const ingestCall = useCallback(
435
+ (payload) => {
436
+ addCall(normaliseCall(payload));
437
+ },
438
+ [addCall]
439
+ );
440
+
441
+ /**
442
+ * Demo helpers. Registered unconditionally (when `demoEnabled`) so the UI
443
+ * can be reviewed by anyone, including a user without the monitor
444
+ * permission.
445
+ */
446
+ useEffect(() => {
447
+ if (!demoEnabled || typeof window === 'undefined') {
448
+ return undefined;
449
+ }
450
+
451
+ /** Add a demo call here and mirror it into every other open tab. */
452
+ const pushDemo = (call) => {
453
+ addDemoPickupCode(call);
454
+ addCall(call);
455
+ broadcastSync({ type: 'add', call });
456
+ };
457
+
458
+ window.callPopDemo = () => {
459
+ // Fixed ids (no Date.now()): a second tab running callPopDemo()
460
+ // itself can then never duplicate a card, and a `remove` broadcast
461
+ // matches the same card in every tab.
462
+ //
463
+ // This first card has no pickup code for its queue, so it renders
464
+ // the "Dismiss only" state.
465
+ pushDemo({
466
+ callId: 'demo-primary',
467
+ queueId: 'demo-main-queue',
468
+ queueName: 'Main CQ',
469
+ callerNumber: '+61412345678',
470
+ callerName: '',
471
+ client: null,
472
+ startedAt: new Date().toISOString(),
473
+ status: 'ringing',
474
+ isDemo: true,
475
+ leaving: false,
476
+ });
477
+
478
+ const secondaryTimer = setTimeout(() => {
479
+ // This one's queue IS in the pickup-code map, so it renders
480
+ // with a working Pick up button.
481
+ pushDemo({
482
+ callId: 'demo-secondary',
483
+ queueId: DEMO_QUEUE_ID,
484
+ queueName: 'Test Call Queue',
485
+ callerNumber: '+61298765432',
486
+ callerName: 'Margaret Chen',
487
+ // Every field the server's client block can carry, so the
488
+ // rich card can be reviewed without a real match. id 0
489
+ // renders the name as plain text, not a link.
490
+ client: {
491
+ id: 0,
492
+ name: 'Chen, Margaret (Mrs)',
493
+ matched_on: 'home',
494
+ adviser: 'Alina Bailey',
495
+ coding: 'Early Retirement',
496
+ age: 66,
497
+ email: 'margaret.chen@example.com',
498
+ city: 'Fremantle',
499
+ next_event: {
500
+ title: 'Client - Annual Review',
501
+ date: new Date(
502
+ Date.now() + 2 * 24 * 60 * 60 * 1000
503
+ ).toISOString(),
504
+ },
505
+ open_tasks: 2,
506
+ },
507
+ // Pre-supplied so the expansion can be reviewed without a
508
+ // backend: with these present the chip never fetches.
509
+ tasks: [
510
+ {
511
+ id: 90001,
512
+ label: 'Confirm rollover paperwork',
513
+ due_date: new Date(
514
+ Date.now() + 3 * 24 * 60 * 60 * 1000
515
+ ).toISOString(),
516
+ task_type: 'Superannuation',
517
+ },
518
+ {
519
+ id: 90002,
520
+ label: 'Send updated risk profile questionnaire',
521
+ due_date: new Date(
522
+ Date.now() + 9 * 24 * 60 * 60 * 1000
523
+ ).toISOString(),
524
+ task_type: 'Insurance',
525
+ },
526
+ ],
527
+ startedAt: new Date().toISOString(),
528
+ status: 'ringing',
529
+ isDemo: true,
530
+ leaving: false,
531
+ });
532
+ }, 1500);
533
+
534
+ exitTimers.current.push(secondaryTimer);
535
+ };
536
+
537
+ window.callPopClear = () => {
538
+ clearCalls();
539
+ dropDemoPickupCode();
540
+ broadcastSync({ type: 'clear' });
541
+ };
542
+
543
+ return () => {
544
+ delete window.callPopDemo;
545
+ delete window.callPopClear;
546
+ };
547
+ }, [
548
+ addCall,
549
+ addDemoPickupCode,
550
+ broadcastSync,
551
+ clearCalls,
552
+ demoEnabled,
553
+ dropDemoPickupCode,
554
+ ]);
555
+
556
+ /**
557
+ * Cross-tab sync.
558
+ *
559
+ * Protocol (all messages are plain objects with a `type`):
560
+ * { type: 'add', call } -> demo injection mirrored to other tabs
561
+ * { type: 'remove', callId } -> Dismiss / Pick up settled the call
562
+ * { type: 'clear' } -> window.callPopClear()
563
+ * Handling an inbound message never re-broadcasts, so there is no loop.
564
+ * Unknown types are ignored and malformed payloads never throw.
565
+ */
566
+ useEffect(() => {
567
+ if (
568
+ typeof window === 'undefined' ||
569
+ typeof BroadcastChannel === 'undefined' ||
570
+ !syncChannelName
571
+ ) {
572
+ return undefined;
573
+ }
574
+
575
+ let bus = null;
576
+
577
+ try {
578
+ bus = new BroadcastChannel(syncChannelName);
579
+ } catch (error) {
580
+ return undefined;
581
+ }
582
+
583
+ syncChannel.current = bus;
584
+
585
+ bus.onmessage = (event) => {
586
+ try {
587
+ const message = event?.data;
588
+
589
+ if (!message || typeof message !== 'object') {
590
+ return;
591
+ }
592
+
593
+ if (message.type === 'remove') {
594
+ if (message.callId) {
595
+ removeCall(message.callId);
596
+ }
597
+
598
+ return;
599
+ }
600
+
601
+ if (message.type === 'clear') {
602
+ clearCalls();
603
+ dropDemoPickupCode();
604
+
605
+ return;
606
+ }
607
+
608
+ if (message.type === 'add') {
609
+ // normaliseCall carries isDemo through, so a demo card
610
+ // received here still bypasses the permission gate.
611
+ const call = normaliseCall(message.call);
612
+
613
+ addDemoPickupCode(call);
614
+ addCall(call);
615
+ }
616
+ } catch (error) {
617
+ // A bad message from another tab must never break this one.
618
+ }
619
+ };
620
+
621
+ return () => {
622
+ try {
623
+ bus.onmessage = null;
624
+ bus.close();
625
+ } catch (error) {
626
+ // Already closed.
627
+ }
628
+
629
+ if (syncChannel.current === bus) {
630
+ syncChannel.current = null;
631
+ }
632
+ };
633
+ }, [
634
+ addCall,
635
+ addDemoPickupCode,
636
+ clearCalls,
637
+ dropDemoPickupCode,
638
+ removeCall,
639
+ syncChannelName,
640
+ ]);
641
+
642
+ /** Snapshot of what is already ringing when the page loads. */
643
+ useEffect(() => {
644
+ if (!canMonitor) {
645
+ return undefined;
646
+ }
647
+
648
+ let cancelled = false;
649
+
650
+ try {
651
+ const request = CustomFetch(
652
+ liveUrl,
653
+ 'GET',
654
+ {},
655
+ function (result) {
656
+ if (cancelled) {
657
+ return;
658
+ }
659
+
660
+ if (
661
+ typeof result?.channel === 'string' &&
662
+ result.channel !== ''
663
+ ) {
664
+ setEchoChannel(result.channel);
665
+ }
666
+
667
+ if (result?.pickup_codes ?? result?.pickupCodes) {
668
+ const codes = normalisePickupCodes(
669
+ result.pickup_codes ?? result.pickupCodes
670
+ );
671
+
672
+ // Merged, not replaced: an injected demo code already
673
+ // in the map must survive the snapshot landing.
674
+ setPickupCodes((previous) => ({
675
+ ...previous,
676
+ ...codes,
677
+ }));
678
+ }
679
+
680
+ const payload = Array.isArray(result)
681
+ ? result
682
+ : result?.calls;
683
+
684
+ if (!Array.isArray(payload)) {
685
+ return;
686
+ }
687
+
688
+ payload.forEach((item) => ingestCall(item));
689
+ },
690
+ function () {
691
+ // Absence of the endpoint is expected pre-backend.
692
+ }
693
+ );
694
+
695
+ if (request && typeof request.catch === 'function') {
696
+ request.catch(() => {});
697
+ }
698
+ } catch (error) {
699
+ // Silent by design.
700
+ }
701
+
702
+ return () => {
703
+ cancelled = true;
704
+ };
705
+ }, [canMonitor, ingestCall, liveUrl]);
706
+
707
+ /** Live updates over Laravel Echo, when the broadcaster is wired up. */
708
+ useEffect(() => {
709
+ if (!canMonitor || !activeChannel || typeof window === 'undefined') {
710
+ return undefined;
711
+ }
712
+
713
+ // The prop may be an instance or a factory. A factory is called here
714
+ // rather than at mount so only a permission-gated user ever opens a
715
+ // Pusher connection — the lazy `getEcho()` behaviour, preserved.
716
+ const source = echoRef.current;
717
+ const instance = typeof source === 'function' ? source() : source;
718
+
719
+ if (!instance) {
720
+ return undefined;
721
+ }
722
+
723
+ let subscription = null;
724
+
725
+ try {
726
+ // Until /broadcasting/auth knows this channel the subscription just
727
+ // fails auth — pusher-js logs a warning, nothing of ours throws.
728
+ subscription = instance.private(activeChannel);
729
+
730
+ subscription.listen(EVENT_RINGING, (event) => {
731
+ ingestCall(event?.call ?? event);
732
+ });
733
+
734
+ subscription.listen(EVENT_ANSWERED, (event) => {
735
+ const callId =
736
+ event?.callId ?? event?.call_id ?? event?.call?.callId;
737
+
738
+ if (callId) {
739
+ removeCall(callId);
740
+ }
741
+ });
742
+
743
+ subscription.listen(EVENT_ENDED, (event) => {
744
+ const callId =
745
+ event?.callId ?? event?.call_id ?? event?.call?.callId;
746
+
747
+ if (callId) {
748
+ removeCall(callId);
749
+ }
750
+ });
751
+ } catch (error) {
752
+ subscription = null;
753
+ }
754
+
755
+ return () => {
756
+ try {
757
+ if (subscription) {
758
+ subscription.stopListening(EVENT_RINGING);
759
+ subscription.stopListening(EVENT_ANSWERED);
760
+ subscription.stopListening(EVENT_ENDED);
761
+ }
762
+
763
+ instance.leave(activeChannel);
764
+ } catch (error) {
765
+ // Nothing to clean up.
766
+ }
767
+ };
768
+ }, [activeChannel, canMonitor, ingestCall, removeCall]);
769
+
770
+ /**
771
+ * One interval for the whole stack, started only while cards are on screen
772
+ * and torn down the moment the stack empties.
773
+ */
774
+ useEffect(() => {
775
+ if (calls.length === 0) {
776
+ return undefined;
777
+ }
778
+
779
+ setNow(Date.now());
780
+
781
+ const interval = setInterval(() => {
782
+ setNow(Date.now());
783
+ }, 1000);
784
+
785
+ return () => clearInterval(interval);
786
+ }, [calls.length]);
787
+
788
+ /**
789
+ * Ask for notification permission, lazily and only for gated-in users.
790
+ *
791
+ * Chrome/Safari reject `requestPermission()` outside a user gesture, so the
792
+ * request is parked behind the first click or keypress after mount and the
793
+ * listeners are torn down as soon as it fires. Demo-only visitors never get
794
+ * a prompt — a demo call still notifies, but only if permission is already
795
+ * granted.
796
+ */
797
+ useEffect(() => {
798
+ if (!canMonitor || typeof window === 'undefined') {
799
+ return undefined;
800
+ }
801
+
802
+ if (typeof Notification === 'undefined') {
803
+ return undefined;
804
+ }
805
+
806
+ if (Notification.permission !== 'default') {
807
+ return undefined;
808
+ }
809
+
810
+ let asked = false;
811
+
812
+ const detach = () => {
813
+ window.removeEventListener('click', request, true);
814
+ window.removeEventListener('keydown', request, true);
815
+ };
816
+
817
+ function request() {
818
+ if (asked) {
819
+ return;
820
+ }
821
+
822
+ asked = true;
823
+ detach();
824
+
825
+ try {
826
+ const result = Notification.requestPermission();
827
+
828
+ if (result && typeof result.catch === 'function') {
829
+ result.catch(() => {});
830
+ }
831
+ } catch (error) {
832
+ // Legacy callback-only implementations, or a blocked prompt.
833
+ }
834
+ }
835
+
836
+ window.addEventListener('click', request, true);
837
+ window.addEventListener('keydown', request, true);
838
+
839
+ return detach;
840
+ }, [canMonitor]);
841
+
842
+ /**
843
+ * System notifications: raise one per newly-arrived call while the tab is
844
+ * in the background, and close it again the instant the call leaves the
845
+ * stack (dismissed, picked up, cross-tab remove, answered/ended, cleared).
846
+ *
847
+ * `tag` is keyed on the callId so N open tabs collapse to a single
848
+ * notification for a given call rather than N of them.
849
+ */
850
+ useEffect(() => {
851
+ const live = calls.filter((call) => !call.leaving);
852
+ const liveIds = new Set(live.map((call) => call.callId));
853
+
854
+ notifications.current.forEach((notification, callId) => {
855
+ if (liveIds.has(callId)) {
856
+ return;
857
+ }
858
+
859
+ try {
860
+ notification.close();
861
+ } catch (error) {
862
+ // Already dismissed by the OS.
863
+ }
864
+
865
+ notifications.current.delete(callId);
866
+ });
867
+
868
+ notified.current.forEach((callId) => {
869
+ if (!liveIds.has(callId)) {
870
+ notified.current.delete(callId);
871
+ }
872
+ });
873
+
874
+ live.forEach((call) => {
875
+ if (notified.current.has(call.callId)) {
876
+ return;
877
+ }
878
+
879
+ // Marked regardless of outcome: a call only ever notifies on
880
+ // arrival, never later when the tab happens to lose focus.
881
+ notified.current.add(call.callId);
882
+
883
+ try {
884
+ if (typeof Notification === 'undefined') {
885
+ return;
886
+ }
887
+
888
+ if (Notification.permission !== 'granted') {
889
+ return;
890
+ }
891
+
892
+ if (isPageVisible()) {
893
+ return;
894
+ }
895
+
896
+ const number = formatAuPhone(call.callerNumber);
897
+ const clientName = call.client?.name || '';
898
+
899
+ // A matched client leads: who is calling matters more than the
900
+ // digits they are calling from.
901
+ const body = clientName
902
+ ? `${clientName} · ${number}`
903
+ : [number, call.callerName].filter(Boolean).join(' · ');
904
+
905
+ const notification = new Notification(
906
+ `Incoming call — ${call.queueName}`,
907
+ {
908
+ body,
909
+ tag: `cqpop-${call.callId}`,
910
+ requireInteraction: true,
911
+ }
912
+ );
913
+
914
+ // Focus IS allowed from inside a notification click handler —
915
+ // this is the only sanctioned "bring the window to front" path.
916
+ notification.onclick = () => {
917
+ try {
918
+ window.focus();
919
+ notification.close();
920
+ } catch (error) {
921
+ // Nothing further to do.
922
+ }
923
+ };
924
+
925
+ notifications.current.set(call.callId, notification);
926
+ } catch (error) {
927
+ // Notifications are strictly best-effort.
928
+ }
929
+ });
930
+ }, [calls]);
931
+
932
+ /**
933
+ * Forget the drill-down state of any call that has left the stack, so a
934
+ * later call reusing a callId (the demo cards do) starts collapsed and
935
+ * re-fetches rather than showing the previous card's tasks.
936
+ */
937
+ useEffect(() => {
938
+ const liveIds = new Set(calls.map((call) => call.callId));
939
+
940
+ const prune = (previous) => {
941
+ const keys = Object.keys(previous);
942
+
943
+ if (keys.every((key) => liveIds.has(key))) {
944
+ return previous;
945
+ }
946
+
947
+ const next = {};
948
+
949
+ keys.forEach((key) => {
950
+ if (liveIds.has(key)) {
951
+ next[key] = previous[key];
952
+ }
953
+ });
954
+
955
+ return next;
956
+ };
957
+
958
+ setExpandedTasks(prune);
959
+ setTaskLists(prune);
960
+
961
+ taskFetched.current.forEach((callId) => {
962
+ if (!liveIds.has(callId)) {
963
+ taskFetched.current.delete(callId);
964
+ }
965
+ });
966
+ }, [calls]);
967
+
968
+ /**
969
+ * Flash the tab title while a call rings in a hidden tab. The original
970
+ * title is captured when flashing starts (the SPA rewrites it on
971
+ * navigation) and restored on stop, on unmount, and the moment the tab is
972
+ * looked at again.
973
+ */
974
+ useEffect(() => {
975
+ if (typeof document === 'undefined' || !hasRinging) {
976
+ return undefined;
977
+ }
978
+
979
+ let interval = null;
980
+ let original = document.title;
981
+ let flashing = false;
982
+
983
+ const stop = () => {
984
+ if (interval) {
985
+ clearInterval(interval);
986
+ interval = null;
987
+ }
988
+
989
+ if (flashing) {
990
+ document.title = original;
991
+ flashing = false;
992
+ }
993
+ };
994
+
995
+ const start = () => {
996
+ if (interval) {
997
+ return;
998
+ }
999
+
1000
+ original = document.title;
1001
+
1002
+ interval = setInterval(() => {
1003
+ document.title = flashing ? original : FLASH_TITLE;
1004
+ flashing = !flashing;
1005
+ }, FLASH_MS);
1006
+ };
1007
+
1008
+ const sync = () => {
1009
+ if (document.visibilityState === 'hidden') {
1010
+ start();
1011
+ } else {
1012
+ stop();
1013
+ }
1014
+ };
1015
+
1016
+ sync();
1017
+ document.addEventListener('visibilitychange', sync);
1018
+
1019
+ return () => {
1020
+ document.removeEventListener('visibilitychange', sync);
1021
+ stop();
1022
+ };
1023
+ }, [hasRinging]);
1024
+
1025
+ /** Drop any pending exit timers, notifications and titles on unmount. */
1026
+ useEffect(
1027
+ () => () => {
1028
+ exitTimers.current.forEach((timer) => clearTimeout(timer));
1029
+ exitTimers.current = [];
1030
+
1031
+ notifications.current.forEach((notification) => {
1032
+ try {
1033
+ notification.close();
1034
+ } catch (error) {
1035
+ // Already dismissed.
1036
+ }
1037
+ });
1038
+
1039
+ notifications.current.clear();
1040
+ notified.current.clear();
1041
+ },
1042
+ []
1043
+ );
1044
+
1045
+ /** The dial string for a call's queue, or '' when that queue has none. */
1046
+ const pickupCodeFor = (call) =>
1047
+ (call.queueId !== null && pickupCodes[call.queueId]) || '';
1048
+
1049
+ /** A task's popup target, from the `taskUrl` builder or `{id}` template. */
1050
+ const hrefForTask = (task) =>
1051
+ resolveUrl(taskUrl, '{id}', task, defaultTaskUrl);
1052
+
1053
+ /**
1054
+ * Open a task row. With no `onOpenTask` handler this keeps the CRM's own
1055
+ * behaviour: the same detached popup window the tasks list page uses.
1056
+ */
1057
+ const openTask = (task) => {
1058
+ if (typeof onOpenTask === 'function') {
1059
+ onOpenTask(task);
1060
+
1061
+ return;
1062
+ }
1063
+
1064
+ window.open(
1065
+ hrefForTask(task),
1066
+ '',
1067
+ 'width=1440,height=1024,menubar=1,resizable=1,status=1,titlebar=1,toolbar=1'
1068
+ );
1069
+ };
1070
+
1071
+ /**
1072
+ * Pick up: copy the queue's code, tell the user, and settle the card here
1073
+ * and in every other tab. The `zoomphonecall://` href fires alongside this
1074
+ * handler and hands the code to the Zoom desktop client.
1075
+ *
1076
+ * Only reachable from a card whose queue actually has a code — the button
1077
+ * is not rendered otherwise.
1078
+ */
1079
+ const handlePickup = (call) => {
1080
+ const code = pickupCodeFor(call);
1081
+
1082
+ if (code !== '') {
1083
+ try {
1084
+ if (navigator?.clipboard?.writeText) {
1085
+ navigator.clipboard.writeText(code).catch(() => {});
1086
+ }
1087
+ } catch (error) {
1088
+ // Clipboard is best-effort; the href still fires.
1089
+ }
1090
+
1091
+ toast.info(
1092
+ `Pickup code ${code} copied — dial it in Zoom if the call didn't connect`
1093
+ );
1094
+ }
1095
+
1096
+ // The user is taking this call: clear it here and everywhere else. The
1097
+ // server's `queue.answered` event remains the authoritative cleanup.
1098
+ dismissCall(call.callId);
1099
+ };
1100
+
1101
+ if (!isVisible || calls.length === 0) {
1102
+ return null;
1103
+ }
1104
+
1105
+ return (
1106
+ <div className={styles.cqpopStack}>
1107
+ {trialText ? (
1108
+ <div className={styles.cqpopTrial}>
1109
+ <FlaskConical size={12} />
1110
+ <span>{trialText}</span>
1111
+ </div>
1112
+ ) : null}
1113
+ {calls.map((call) => {
1114
+ const pickupCode = pickupCodeFor(call);
1115
+ const client = call.client;
1116
+ const details = clientDetails(client);
1117
+ const nextEvent = client?.next_event ?? null;
1118
+ const nextEventDate = formatEventDate(nextEvent?.date);
1119
+ const openTasks = Number(client?.open_tasks ?? 0);
1120
+ const workspaceId = toCallWorkspaceId(call.callerNumber);
1121
+ const workspaceHref =
1122
+ typeof callWorkspacePath === 'function'
1123
+ ? callWorkspacePath(workspaceId, call)
1124
+ : applyTemplate(
1125
+ callWorkspacePath,
1126
+ '{number}',
1127
+ workspaceId
1128
+ );
1129
+ const tasksPanelId = `cqpop-tasks-${call.callId}`;
1130
+ const tasksOpen = expandedTasks[call.callId] === true;
1131
+
1132
+ // Demo cards ship a ready-made list; everything else waits on
1133
+ // (or has already had) the drill-down fetch.
1134
+ const tasksPanel = Array.isArray(call.tasks)
1135
+ ? { status: 'ready', tasks: call.tasks }
1136
+ : (taskLists[call.callId] ?? { status: 'idle', tasks: [] });
1137
+
1138
+ return (
1139
+ <div
1140
+ className={`${styles.cqpopCard}${
1141
+ call.leaving ? ` ${styles.cqpopCardLeaving}` : ''
1142
+ }`}
1143
+ key={`cqpop-${call.callId}`}
1144
+ >
1145
+ <div className={styles.cqpopCardHead}>
1146
+ <span className={styles.cqpopBadge}>
1147
+ <span className={styles.cqpopDot} />
1148
+ {call.queueName}
1149
+ </span>
1150
+ <span className={styles.cqpopTimer}>
1151
+ Ringing · {formatElapsed(call.startedAt, now)}
1152
+ </span>
1153
+ </div>
1154
+
1155
+ <div className={styles.cqpopCardBody}>
1156
+ <div className={styles.cqpopCaller}>
1157
+ <PhoneIncoming size={16} />
1158
+ <span className={styles.cqpopNumber}>
1159
+ {formatAuPhone(call.callerNumber)}
1160
+ </span>
1161
+ {client?.matched_on ? (
1162
+ <span className={styles.cqpopMatched}>
1163
+ {client.matched_on}
1164
+ </span>
1165
+ ) : null}
1166
+ </div>
1167
+
1168
+ {call.callerName ? (
1169
+ <div className={styles.cqpopName}>
1170
+ {call.callerName}
1171
+ </div>
1172
+ ) : null}
1173
+
1174
+ {client ? (
1175
+ <div className={styles.cqpopClient}>
1176
+ <div className={styles.cqpopClientHead}>
1177
+ {client.id ? (
1178
+ <Link
1179
+ to={resolveUrl(
1180
+ clientUrl,
1181
+ '{id}',
1182
+ client,
1183
+ defaultClientUrl
1184
+ )}
1185
+ className={
1186
+ styles.cqpopClientLink
1187
+ }
1188
+ >
1189
+ {client.name}
1190
+ </Link>
1191
+ ) : (
1192
+ <span
1193
+ className={
1194
+ styles.cqpopClientName
1195
+ }
1196
+ >
1197
+ {client.name}
1198
+ </span>
1199
+ )}
1200
+ {openTasks > 0 ? (
1201
+ <button
1202
+ type="button"
1203
+ className={`${
1204
+ styles.cqpopClientTasks
1205
+ }${
1206
+ tasksOpen
1207
+ ? ` ${styles.cqpopClientTasksOpen}`
1208
+ : ''
1209
+ }`}
1210
+ onClick={() =>
1211
+ toggleTasks(call)
1212
+ }
1213
+ aria-expanded={tasksOpen}
1214
+ aria-controls={tasksPanelId}
1215
+ >
1216
+ <span>
1217
+ {openTasks} open task
1218
+ {openTasks === 1 ? '' : 's'}
1219
+ </span>
1220
+ <ChevronDown size={11} />
1221
+ </button>
1222
+ ) : null}
1223
+ </div>
1224
+
1225
+ {details.length > 0 ? (
1226
+ <dl className={styles.cqpopClientGrid}>
1227
+ {details.map((row) => (
1228
+ <React.Fragment
1229
+ key={`cqpop-${call.callId}-${row.label}`}
1230
+ >
1231
+ <dt>{row.label}</dt>
1232
+ <dd>{row.value}</dd>
1233
+ </React.Fragment>
1234
+ ))}
1235
+ </dl>
1236
+ ) : null}
1237
+
1238
+ {/* The next meeting is a link into the
1239
+ calendar, or a button when the host app
1240
+ would rather open the event itself. */}
1241
+ {nextEvent?.title ? (
1242
+ typeof onOpenCalendar === 'function' ? (
1243
+ <button
1244
+ type="button"
1245
+ className={
1246
+ styles.cqpopClientEvent
1247
+ }
1248
+ onClick={() =>
1249
+ onOpenCalendar(nextEvent)
1250
+ }
1251
+ >
1252
+ Next: {nextEvent.title}
1253
+ {nextEventDate
1254
+ ? ` · ${nextEventDate}`
1255
+ : ''}
1256
+ </button>
1257
+ ) : (
1258
+ <Link
1259
+ to={calendarPath}
1260
+ className={
1261
+ styles.cqpopClientEvent
1262
+ }
1263
+ >
1264
+ Next: {nextEvent.title}
1265
+ {nextEventDate
1266
+ ? ` · ${nextEventDate}`
1267
+ : ''}
1268
+ </Link>
1269
+ )
1270
+ ) : null}
1271
+
1272
+ {tasksOpen ? (
1273
+ <div
1274
+ className={styles.cqpopTasks}
1275
+ id={tasksPanelId}
1276
+ >
1277
+ {tasksPanel.status === 'loading' ? (
1278
+ <p
1279
+ className={
1280
+ styles.cqpopTasksNote
1281
+ }
1282
+ >
1283
+ Loading tasks…
1284
+ </p>
1285
+ ) : null}
1286
+
1287
+ {tasksPanel.status === 'error' ? (
1288
+ <p
1289
+ className={
1290
+ styles.cqpopTasksNote
1291
+ }
1292
+ >
1293
+ Tasks unavailable
1294
+ </p>
1295
+ ) : null}
1296
+
1297
+ {tasksPanel.status === 'ready' &&
1298
+ tasksPanel.tasks.length === 0 ? (
1299
+ <p
1300
+ className={
1301
+ styles.cqpopTasksNote
1302
+ }
1303
+ >
1304
+ No open tasks to show
1305
+ </p>
1306
+ ) : null}
1307
+
1308
+ {tasksPanel.tasks.length > 0 ? (
1309
+ <ul
1310
+ className={
1311
+ styles.cqpopTasksList
1312
+ }
1313
+ >
1314
+ {tasksPanel.tasks.map(
1315
+ (task) => {
1316
+ const due =
1317
+ formatDueDate(
1318
+ task.due_date
1319
+ );
1320
+
1321
+ const taskBody = (
1322
+ <>
1323
+ <span
1324
+ className={
1325
+ styles.cqpopTasksLabel
1326
+ }
1327
+ >
1328
+ {task.label ||
1329
+ `Task #${task.id}`}
1330
+ </span>
1331
+ <span
1332
+ className={
1333
+ styles.cqpopTasksMeta
1334
+ }
1335
+ >
1336
+ {task.task_type ? (
1337
+ <span
1338
+ className={
1339
+ styles.cqpopTasksType
1340
+ }
1341
+ >
1342
+ {
1343
+ task.task_type
1344
+ }
1345
+ </span>
1346
+ ) : null}
1347
+ {due ? (
1348
+ <span
1349
+ className={
1350
+ styles.cqpopTasksDue
1351
+ }
1352
+ >
1353
+ {
1354
+ due
1355
+ }
1356
+ </span>
1357
+ ) : null}
1358
+ </span>
1359
+ </>
1360
+ );
1361
+
1362
+ return (
1363
+ <li
1364
+ key={`${tasksPanelId}-${task.id}`}
1365
+ >
1366
+ {call.isDemo ? (
1367
+ // Demo tasks have fake ids — linking
1368
+ // to the task route would 404.
1369
+ <span
1370
+ className={
1371
+ styles.cqpopTasksItem
1372
+ }
1373
+ >
1374
+ {
1375
+ taskBody
1376
+ }
1377
+ </span>
1378
+ ) : (
1379
+ // Same popup-window behaviour as the
1380
+ // tasks list page (openFamilyTask),
1381
+ // unless onOpenTask says otherwise.
1382
+ <a
1383
+ href={hrefForTask(
1384
+ task
1385
+ )}
1386
+ className={
1387
+ styles.cqpopTasksItem
1388
+ }
1389
+ onClick={(
1390
+ event
1391
+ ) => {
1392
+ event.preventDefault();
1393
+ openTask(
1394
+ task
1395
+ );
1396
+ }}
1397
+ >
1398
+ {
1399
+ taskBody
1400
+ }
1401
+ </a>
1402
+ )}
1403
+ </li>
1404
+ );
1405
+ }
1406
+ )}
1407
+ </ul>
1408
+ ) : null}
1409
+
1410
+ {client?.id ? (
1411
+ <Link
1412
+ to={resolveUrl(
1413
+ clientTasksUrl,
1414
+ '{id}',
1415
+ client.id,
1416
+ defaultClientTasksUrl
1417
+ )}
1418
+ className={
1419
+ styles.cqpopTasksAll
1420
+ }
1421
+ >
1422
+ View all tasks →
1423
+ </Link>
1424
+ ) : null}
1425
+ </div>
1426
+ ) : null}
1427
+ </div>
1428
+ ) : (
1429
+ <div
1430
+ className={`${styles.cqpopClient} ${styles.cqpopClientNone}`}
1431
+ >
1432
+ No client match
1433
+ </div>
1434
+ )}
1435
+ </div>
1436
+
1437
+ {/* No configured pickup code for this queue means no
1438
+ dial string to offer, so the card falls back to
1439
+ Dismiss alone rather than a button that cannot
1440
+ work. */}
1441
+ <div className={styles.cqpopCardActions}>
1442
+ {pickupCode !== '' ? (
1443
+ pickupEnabled ? (
1444
+ <a
1445
+ className={`${styles.cqpopBtn} ${styles.cqpopBtnAnswer}`}
1446
+ href={`zoomphonecall://${pickupCode}`}
1447
+ onClick={() => handlePickup(call)}
1448
+ >
1449
+ <Phone size={14} />
1450
+ <span>Pick up</span>
1451
+ </a>
1452
+ ) : (
1453
+ <button
1454
+ type="button"
1455
+ className={`${styles.cqpopBtn} ${styles.cqpopBtnAnswer} ${styles.cqpopBtnSoon}`}
1456
+ disabled
1457
+ title="Pick up is coming soon — answer in Zoom for now"
1458
+ >
1459
+ <Phone size={14} />
1460
+ <span>Pick up</span>
1461
+ </button>
1462
+ )
1463
+ ) : null}
1464
+ <button
1465
+ type="button"
1466
+ className={`${styles.cqpopBtn} ${styles.cqpopBtnDismiss}`}
1467
+ onClick={() => dismissCall(call.callId)}
1468
+ >
1469
+ <X size={14} />
1470
+ <span>Dismiss</span>
1471
+ </button>
1472
+ </div>
1473
+
1474
+ {/* Secondary by design: the workspace is a place to go
1475
+ after the call is in hand, never instead of Pick up. */}
1476
+ <div className={styles.cqpopCardMore}>
1477
+ {workspaceId !== '' && !call.isDemo ? (
1478
+ <Link
1479
+ to={workspaceHref}
1480
+ className={styles.cqpopLink}
1481
+ >
1482
+ <ExternalLink size={12} />
1483
+ <span>Open call workspace</span>
1484
+ </Link>
1485
+ ) : (
1486
+ <span
1487
+ className={`${styles.cqpopLink} ${styles.cqpopLinkInert}`}
1488
+ aria-disabled="true"
1489
+ >
1490
+ <ExternalLink size={12} />
1491
+ <span>Open call workspace</span>
1492
+ </span>
1493
+ )}
1494
+ </div>
1495
+ </div>
1496
+ );
1497
+ })}
1498
+ </div>
1499
+ );
1500
+ };
1501
+
1502
+ export default CallQueuePop;