@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.
- package/README.md +1623 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +181 -38
- package/src/components/Fetch.jsx +80 -3
- package/src/components/Form.jsx +9 -0
- package/src/components/Navigation.jsx +109 -50
- package/src/components/Notification.jsx +279 -9
- package/src/components/TableFilter.jsx +14 -1
- package/src/components/auth/AuthBrandPanel.jsx +43 -0
- package/src/components/auth/AuthLoading.jsx +78 -0
- package/src/components/auth/AuthShell.jsx +59 -0
- package/src/components/auth/ClientAuth.jsx +161 -0
- package/src/components/auth/ClientAuthFrame.jsx +59 -0
- package/src/components/auth/ClientLogin.jsx +266 -55
- package/src/components/auth/ClientOTPVerify.jsx +587 -115
- package/src/components/auth/ImpersonateGate.jsx +254 -0
- package/src/components/auth/Login.jsx +134 -41
- package/src/components/auth/LogoutScreen.jsx +112 -0
- package/src/components/auth/Reset.jsx +134 -77
- package/src/components/auth/TwoFactorAuth.jsx +475 -297
- package/src/components/auth/Verify.jsx +237 -126
- package/src/components/auth/authEndpoints.js +105 -0
- package/src/components/auth/authFont.js +23 -0
- package/src/components/auth/authHelpers.js +465 -0
- package/src/components/auth/clientAuthProtocols.js +240 -0
- package/src/components/auth/useOptionalRouter.js +49 -0
- package/src/components/callQueue/CallQueuePop.jsx +1502 -0
- package/src/components/callQueue/CallQueueSettings.jsx +508 -0
- package/src/components/callQueue/callQueueHelpers.js +280 -0
- package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
- package/src/components/columns/ColumnRenderers.jsx +53 -7
- package/src/components/generic/GenericAuth.jsx +163 -96
- package/src/components/generic/GenericDetail.jsx +215 -90
- package/src/components/generic/GenericIndex.jsx +20 -47
- package/src/components/generic/GenericMain.jsx +5 -0
- package/src/components/generic/GroupedReportRenderer.jsx +1 -5
- package/src/components/generic/StandardModal.jsx +17 -5
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
- package/src/components/navActive.js +60 -0
- package/src/components/notify/desktopNotifications.js +256 -0
- package/src/components/sketch/SketchField.jsx +12 -2
- package/src/components/sms/SmsComposeModal.jsx +495 -0
- package/src/components/sms/SmsInbox.jsx +596 -0
- package/src/components/sms/SmsInboxBadge.jsx +468 -0
- package/src/components/sms/SmsLineSettings.jsx +854 -0
- package/src/components/sms/SmsThreadPanel.jsx +1184 -0
- package/src/components/sms/smsEndpoints.js +56 -0
- package/src/components/sms/smsHelpers.js +1075 -0
- package/src/components/sms/smsLiveState.js +132 -0
- package/src/components/sms/useSmsLive.js +307 -0
- package/src/components/styles/CallQueuePop.module.scss +646 -0
- package/src/components/styles/CallQueueSettings.module.scss +460 -0
- package/src/components/styles/ClientAuth.module.scss +229 -0
- package/src/components/styles/GenericDetail.module.scss +153 -26
- package/src/components/styles/GenericIndex.module.scss +13 -0
- package/src/components/styles/GenericMain.module.scss +19 -2
- package/src/components/styles/ImpersonateGate.module.scss +85 -0
- package/src/components/styles/Login.module.scss +13 -122
- package/src/components/styles/LogoutScreen.module.scss +89 -0
- package/src/components/styles/Navigation.module.scss +509 -223
- package/src/components/styles/Notification.module.scss +103 -0
- package/src/components/styles/Reset.module.scss +52 -186
- package/src/components/styles/Sms.module.scss +2382 -0
- package/src/components/styles/TableFilter.module.scss +118 -98
- package/src/components/styles/TwoFactorAuth.module.scss +115 -176
- package/src/components/styles/Vault.module.scss +1983 -0
- package/src/components/styles/Verify.module.scss +57 -180
- package/src/components/styles/_authBrandPanel.scss +163 -0
- package/src/components/styles/_authShell.scss +369 -0
- package/src/components/styles/global-datagrid.css +27 -2
- package/src/components/utils/buildEnv.js +67 -0
- package/src/components/utils/displayValue.js +94 -0
- package/src/components/utils/useDensity.js +345 -9
- package/src/components/vault/OtpChip.jsx +208 -0
- package/src/components/vault/PasswordGenerator.jsx +145 -0
- package/src/components/vault/QrScanner.jsx +326 -0
- package/src/components/vault/VaultAccessLog.jsx +225 -0
- package/src/components/vault/VaultConfirmPanel.jsx +113 -0
- package/src/components/vault/VaultEntryForm.jsx +740 -0
- package/src/components/vault/VaultManager.jsx +1000 -0
- package/src/components/vault/VaultQuickSearch.jsx +681 -0
- package/src/components/vault/qrDecode.js +257 -0
- package/src/components/vault/useDebouncedValue.js +22 -0
- package/src/components/vault/useVaultReveal.js +160 -0
- package/src/components/vault/vaultClipboard.js +33 -0
- package/src/components/vault/vaultEndpoints.js +40 -0
- package/src/components/vault/vaultFit.js +116 -0
- package/src/components/vault/vaultHelpers.js +547 -0
- package/src/components/vault/vaultNavigation.jsx +58 -0
- package/src/components/vault/vaultOtp.js +105 -0
- package/src/index.js +233 -1
|
@@ -0,0 +1,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;
|