@escalated-dev/escalated 0.11.10 → 0.12.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 +103 -1
- package/dist/widget/escalated-widget.js +215 -0
- package/package.json +24 -5
- package/pages.json +7 -3
- package/src/components/ActiveChatsPanel.vue +4 -1
- package/src/components/ChatQueue.vue +4 -1
- package/src/components/GuestEmailCode.vue +58 -0
- package/src/components/PresenceIndicator.vue +25 -4
- package/src/components/ReportExportButton.vue +13 -0
- package/src/components/TotpInput.vue +1 -1
- package/src/composables/useChat.js +16 -33
- package/src/composables/useGuestVerification.js +48 -0
- package/src/composables/useKeyboardShortcuts.js +2 -0
- package/src/composables/useMentions.js +16 -11
- package/src/composables/useRealtime.js +108 -147
- package/src/pages/Admin/ApiTokens/Index.vue +3 -4
- package/src/pages/Admin/Settings/TwoFactor.vue +34 -7
- package/src/pages/Admin/Settings.vue +273 -72
- package/src/pages/Agent/TicketIndex.vue +3 -1
- package/src/pages/Agent/TicketShow.vue +11 -3
- package/src/pages/Guest/Create.vue +137 -7
- package/src/pages/Guest/Show.vue +6 -0
- package/src/utils/flash.js +4 -0
- package/src/utils/guestRequests.js +27 -0
- package/src/widget/EscalatedWidget.vue +323 -86
- package/src/widget/embed.js +120 -0
- package/src/widget/index.js +93 -33
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed, watch, onMounted } from 'vue';
|
|
2
|
+
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
|
3
3
|
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
|
4
|
+
import GuestEmailCode from '../components/GuestEmailCode.vue';
|
|
5
|
+
import { useGuestVerification } from '../composables/useGuestVerification';
|
|
6
|
+
import { guestCsrfHeaders } from '../utils/guestRequests';
|
|
4
7
|
|
|
5
8
|
const props = defineProps({
|
|
6
9
|
baseUrl: { type: String, default: '' },
|
|
@@ -13,19 +16,59 @@ const props = defineProps({
|
|
|
13
16
|
widgetPath: { type: String, default: '/support/widget' },
|
|
14
17
|
initialColor: { type: String, default: '#4F46E5' },
|
|
15
18
|
initialPosition: { type: String, default: 'bottom-right' },
|
|
19
|
+
// 'launcher' floats a button on the page; 'inline' renders the panel in
|
|
20
|
+
// place, e.g. as the support form on a host site's contact page.
|
|
21
|
+
mode: { type: String, default: 'launcher' },
|
|
22
|
+
// Subset and order of tabs to offer: help, contact, chat, status.
|
|
23
|
+
tabs: { type: Array, default: null },
|
|
24
|
+
defaultTab: { type: String, default: null },
|
|
25
|
+
// Department to preselect; lockDepartment hides the department choice.
|
|
26
|
+
department: { type: [String, Number], default: null },
|
|
27
|
+
lockDepartment: { type: Boolean, default: false },
|
|
28
|
+
// Visitor details the host page already knows: name, email, subject, message.
|
|
29
|
+
prefill: { type: Object, default: () => ({}) },
|
|
30
|
+
title: { type: String, default: 'Support' },
|
|
31
|
+
greeting: { type: String, default: null },
|
|
32
|
+
openOnLoad: { type: Boolean, default: false },
|
|
33
|
+
hideLauncher: { type: Boolean, default: false },
|
|
34
|
+
// Host parameters (order number, plan, ...) sent with tickets and chats
|
|
35
|
+
// when the backend accepts them. metadataProvider is called at submit.
|
|
36
|
+
metadata: { type: Object, default: () => ({}) },
|
|
37
|
+
metadataProvider: { type: Function, default: null },
|
|
16
38
|
});
|
|
17
39
|
|
|
18
|
-
const
|
|
19
|
-
|
|
40
|
+
const inline = computed(() => props.mode === 'inline');
|
|
41
|
+
|
|
42
|
+
const isOpen = ref(props.mode === 'inline' || props.openOnLoad);
|
|
43
|
+
const activeTab = ref(props.defaultTab);
|
|
20
44
|
const error = ref('');
|
|
21
45
|
const widgetConfig = ref(null);
|
|
22
46
|
|
|
23
47
|
const color = computed(() => widgetConfig.value?.color || props.initialColor);
|
|
24
48
|
const position = computed(() => widgetConfig.value?.position || props.initialPosition);
|
|
25
|
-
const greeting = computed(() => widgetConfig.value?.greeting || 'Hi there! How can we help?');
|
|
49
|
+
const greeting = computed(() => props.greeting || widgetConfig.value?.greeting || 'Hi there! How can we help?');
|
|
26
50
|
const departments = computed(() => widgetConfig.value?.departments || []);
|
|
27
51
|
const kbEnabled = computed(() => widgetConfig.value?.kb_enabled ?? true);
|
|
28
52
|
const chatAvailable = ref(false);
|
|
53
|
+
const visibleTabs = computed(() => {
|
|
54
|
+
const available = {
|
|
55
|
+
help: kbEnabled.value,
|
|
56
|
+
contact: true,
|
|
57
|
+
chat: chatAvailable.value,
|
|
58
|
+
status: true,
|
|
59
|
+
};
|
|
60
|
+
const wanted = props.tabs?.length ? props.tabs : ['help', 'contact', 'chat', 'status'];
|
|
61
|
+
return wanted.filter((tab) => available[tab]);
|
|
62
|
+
});
|
|
63
|
+
// The chosen tab while it is on offer, otherwise the first one that is.
|
|
64
|
+
const currentTab = computed(() =>
|
|
65
|
+
visibleTabs.value.includes(activeTab.value) ? activeTab.value : visibleTabs.value[0] || null,
|
|
66
|
+
);
|
|
67
|
+
const identity = ref({ name: props.prefill?.name || '', email: props.prefill?.email || '' });
|
|
68
|
+
const lockedDepartment = ref(props.lockDepartment && props.department != null ? props.department : null);
|
|
69
|
+
const presetDepartment = ref(props.department ?? '');
|
|
70
|
+
const departmentLocked = computed(() => lockedDepartment.value != null);
|
|
71
|
+
const hostMetadata = ref({ ...(props.metadata || {}) });
|
|
29
72
|
const chatAvailabilityMessage = ref('');
|
|
30
73
|
|
|
31
74
|
// Chat state
|
|
@@ -40,7 +83,20 @@ const chatRatingSubmitted = ref(false);
|
|
|
40
83
|
const chatInput = ref('');
|
|
41
84
|
const chatSending = ref(false);
|
|
42
85
|
const chatConnectionStatus = ref('disconnected'); // connected | reconnecting | disconnected
|
|
43
|
-
const
|
|
86
|
+
const blankChatForm = () => ({
|
|
87
|
+
name: identity.value.name,
|
|
88
|
+
email: identity.value.email,
|
|
89
|
+
department_id: presetDepartment.value,
|
|
90
|
+
message: props.prefill?.message || '',
|
|
91
|
+
});
|
|
92
|
+
const blankTicketForm = () => ({
|
|
93
|
+
name: identity.value.name,
|
|
94
|
+
email: identity.value.email,
|
|
95
|
+
subject: props.prefill?.subject || '',
|
|
96
|
+
description: props.prefill?.message || '',
|
|
97
|
+
department_id: presetDepartment.value,
|
|
98
|
+
});
|
|
99
|
+
const preChatForm = ref(blankChatForm());
|
|
44
100
|
const chatStarting = ref(false);
|
|
45
101
|
|
|
46
102
|
// WebSocket / polling for chat
|
|
@@ -54,66 +110,125 @@ const searchLoading = ref(false);
|
|
|
54
110
|
const selectedArticle = ref(null);
|
|
55
111
|
const articleLoading = ref(false);
|
|
56
112
|
|
|
57
|
-
const ticketForm = ref(
|
|
58
|
-
name: '',
|
|
59
|
-
email: '',
|
|
60
|
-
subject: '',
|
|
61
|
-
description: '',
|
|
62
|
-
department_id: '',
|
|
63
|
-
});
|
|
113
|
+
const ticketForm = ref(blankTicketForm());
|
|
64
114
|
const ticketSubmitting = ref(false);
|
|
65
115
|
const ticketSuccess = ref(null);
|
|
66
116
|
|
|
67
|
-
const statusForm = ref({ email:
|
|
117
|
+
const statusForm = ref({ email: identity.value.email, reference: '' });
|
|
68
118
|
const statusLoading = ref(false);
|
|
69
119
|
const statusResult = ref(null);
|
|
120
|
+
const statusMatches = ref([]);
|
|
121
|
+
let destinationGeneration = 0;
|
|
122
|
+
let disposed = false;
|
|
123
|
+
const verificationRequired = computed(() => widgetConfig.value?.guest_verification_required === true);
|
|
124
|
+
const verificationContext = () => `${props.baseUrl}|${props.widgetPath}`;
|
|
125
|
+
const requestCode = (email, purpose) => api('POST', '/verification', { email, purpose });
|
|
126
|
+
const ticketVerification = useGuestVerification(
|
|
127
|
+
() => ticketForm.value.email,
|
|
128
|
+
'ticket',
|
|
129
|
+
requestCode,
|
|
130
|
+
verificationContext,
|
|
131
|
+
);
|
|
132
|
+
const chatVerification = useGuestVerification(() => preChatForm.value.email, 'chat', requestCode, verificationContext);
|
|
133
|
+
const statusVerification = useGuestVerification(
|
|
134
|
+
() => statusForm.value.email,
|
|
135
|
+
'lookup',
|
|
136
|
+
requestCode,
|
|
137
|
+
verificationContext,
|
|
138
|
+
);
|
|
139
|
+
watch(
|
|
140
|
+
() => `${statusForm.value.email}|${statusForm.value.reference}`,
|
|
141
|
+
() => {
|
|
142
|
+
statusMatches.value = [];
|
|
143
|
+
statusResult.value = null;
|
|
144
|
+
},
|
|
145
|
+
{ flush: 'sync' },
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
watch(
|
|
149
|
+
verificationContext,
|
|
150
|
+
() => {
|
|
151
|
+
destinationGeneration++;
|
|
152
|
+
stopChatPolling();
|
|
153
|
+
clearTimeout(chatTypingTimer);
|
|
154
|
+
ticketSuccess.value = null;
|
|
155
|
+
statusMatches.value = [];
|
|
156
|
+
statusResult.value = null;
|
|
157
|
+
startNewChat();
|
|
158
|
+
ticketForm.value = blankTicketForm();
|
|
159
|
+
statusForm.value = { email: identity.value.email, reference: '' };
|
|
160
|
+
widgetConfig.value = null;
|
|
161
|
+
chatAvailable.value = false;
|
|
162
|
+
error.value = '';
|
|
163
|
+
loadWidget();
|
|
164
|
+
},
|
|
165
|
+
{ flush: 'sync' },
|
|
166
|
+
);
|
|
70
167
|
|
|
71
168
|
let searchTimer = null;
|
|
72
169
|
|
|
73
|
-
async function api(method, apiPath, body = null) {
|
|
170
|
+
async function api(method, apiPath, body = null, token = null) {
|
|
171
|
+
const generation = destinationGeneration;
|
|
74
172
|
const url = `${props.baseUrl}${props.widgetPath}${apiPath}`;
|
|
75
173
|
const opts = {
|
|
76
174
|
method,
|
|
77
175
|
headers: {
|
|
78
176
|
Accept: 'application/json',
|
|
79
177
|
'Content-Type': 'application/json',
|
|
178
|
+
...guestCsrfHeaders(url),
|
|
179
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
80
180
|
},
|
|
81
181
|
};
|
|
82
182
|
if (body) opts.body = JSON.stringify(body);
|
|
83
183
|
const res = await fetch(url, opts);
|
|
84
184
|
const data = await res.json();
|
|
85
|
-
if (
|
|
185
|
+
if (disposed || generation !== destinationGeneration)
|
|
186
|
+
throw Object.assign(new Error('Widget destination changed.'), { name: 'AbortError' });
|
|
187
|
+
if (!res.ok)
|
|
188
|
+
throw Object.assign(
|
|
189
|
+
new Error(data.errors ? Object.values(data.errors).flat().join(' ') : data.message || 'Request failed.'),
|
|
190
|
+
{ status: res.status, data },
|
|
191
|
+
);
|
|
86
192
|
return data;
|
|
87
193
|
}
|
|
88
194
|
|
|
89
|
-
|
|
195
|
+
async function loadWidget() {
|
|
196
|
+
const generation = destinationGeneration;
|
|
90
197
|
try {
|
|
91
198
|
widgetConfig.value = await api('GET', '/config');
|
|
92
199
|
} catch {
|
|
93
200
|
// Widget may be disabled
|
|
94
201
|
}
|
|
202
|
+
if (disposed || generation !== destinationGeneration) return;
|
|
95
203
|
// Check chat availability
|
|
96
204
|
try {
|
|
97
205
|
const avail = await api('GET', '/chat/availability');
|
|
98
206
|
chatAvailable.value = avail.available;
|
|
99
207
|
chatAvailabilityMessage.value = avail.message || '';
|
|
100
208
|
} catch {
|
|
101
|
-
chatAvailable.value = false;
|
|
209
|
+
if (generation === destinationGeneration && !disposed) chatAvailable.value = false;
|
|
102
210
|
}
|
|
103
|
-
}
|
|
211
|
+
}
|
|
212
|
+
onMounted(loadWidget);
|
|
104
213
|
|
|
105
214
|
// Chat functions
|
|
106
215
|
async function startLiveChat() {
|
|
216
|
+
const email = preChatForm.value.email;
|
|
107
217
|
chatStarting.value = true;
|
|
108
218
|
error.value = '';
|
|
109
219
|
try {
|
|
220
|
+
if (verificationRequired.value && !(await chatVerification.ready())) return;
|
|
110
221
|
const session = await api('POST', '/chat/start', {
|
|
111
222
|
name: preChatForm.value.name,
|
|
112
223
|
email: preChatForm.value.email,
|
|
113
224
|
department_id: preChatForm.value.department_id || null,
|
|
114
225
|
message: preChatForm.value.message,
|
|
226
|
+
...metadataPayload(),
|
|
227
|
+
...(verificationRequired.value ? chatVerification.fields() : {}),
|
|
115
228
|
});
|
|
229
|
+
if (email !== preChatForm.value.email) return;
|
|
116
230
|
chatSession.value = session;
|
|
231
|
+
chatVerification.reset();
|
|
117
232
|
chatPhase.value = 'live';
|
|
118
233
|
chatMessages.value = session.messages || [];
|
|
119
234
|
if (session.agent) {
|
|
@@ -122,6 +237,7 @@ async function startLiveChat() {
|
|
|
122
237
|
chatConnectionStatus.value = 'connected';
|
|
123
238
|
startChatPolling();
|
|
124
239
|
} catch (e) {
|
|
240
|
+
if (e.name === 'AbortError') return;
|
|
125
241
|
if (e.data?.errors) {
|
|
126
242
|
error.value = Object.values(e.data.errors).flat().join(' ');
|
|
127
243
|
} else {
|
|
@@ -181,8 +297,10 @@ function startChatPolling() {
|
|
|
181
297
|
stopChatPolling();
|
|
182
298
|
chatPollTimer = setInterval(async () => {
|
|
183
299
|
if (!chatSession.value) return;
|
|
300
|
+
const sessionId = chatSession.value.id;
|
|
184
301
|
try {
|
|
185
302
|
const data = await api('GET', `/chat/${chatSession.value.id}/messages`);
|
|
303
|
+
if (chatSession.value?.id !== sessionId) return;
|
|
186
304
|
if (data.messages) {
|
|
187
305
|
chatMessages.value = data.messages;
|
|
188
306
|
}
|
|
@@ -202,7 +320,8 @@ function startChatPolling() {
|
|
|
202
320
|
}
|
|
203
321
|
chatConnectionStatus.value = 'connected';
|
|
204
322
|
chatReconnectAttempts = 0;
|
|
205
|
-
} catch {
|
|
323
|
+
} catch (e) {
|
|
324
|
+
if (e.name === 'AbortError' || chatSession.value?.id !== sessionId) return;
|
|
206
325
|
chatReconnectAttempts++;
|
|
207
326
|
if (chatReconnectAttempts > 3) {
|
|
208
327
|
chatConnectionStatus.value = 'disconnected';
|
|
@@ -220,31 +339,46 @@ function stopChatPolling() {
|
|
|
220
339
|
}
|
|
221
340
|
}
|
|
222
341
|
|
|
342
|
+
onUnmounted(() => {
|
|
343
|
+
disposed = true;
|
|
344
|
+
stopChatPolling();
|
|
345
|
+
clearTimeout(searchTimer);
|
|
346
|
+
clearTimeout(chatTypingTimer);
|
|
347
|
+
});
|
|
348
|
+
|
|
223
349
|
async function endLiveChat() {
|
|
224
350
|
if (!chatSession.value) return;
|
|
351
|
+
const sessionId = chatSession.value.id;
|
|
225
352
|
try {
|
|
226
353
|
await api('POST', `/chat/${chatSession.value.id}/end`);
|
|
227
354
|
} catch {
|
|
228
355
|
// ignore
|
|
229
356
|
}
|
|
357
|
+
if (chatSession.value?.id !== sessionId || disposed) return;
|
|
230
358
|
chatPhase.value = 'post';
|
|
231
359
|
stopChatPolling();
|
|
232
360
|
}
|
|
233
361
|
|
|
234
362
|
async function submitChatRating() {
|
|
235
363
|
if (!chatSession.value || !chatRating.value) return;
|
|
364
|
+
const sessionId = chatSession.value.id;
|
|
236
365
|
try {
|
|
237
366
|
await api('POST', `/chat/${chatSession.value.id}/rate`, {
|
|
238
367
|
rating: chatRating.value,
|
|
239
368
|
comment: chatComment.value,
|
|
240
369
|
});
|
|
241
|
-
chatRatingSubmitted.value = true;
|
|
242
|
-
} catch {
|
|
370
|
+
if (chatSession.value?.id === sessionId) chatRatingSubmitted.value = true;
|
|
371
|
+
} catch (e) {
|
|
372
|
+
if (e.name === 'AbortError' || chatSession.value?.id !== sessionId) return;
|
|
243
373
|
error.value = 'Failed to submit rating.';
|
|
244
374
|
}
|
|
245
375
|
}
|
|
246
376
|
|
|
247
377
|
function startNewChat() {
|
|
378
|
+
stopChatPolling();
|
|
379
|
+
clearTimeout(chatTypingTimer);
|
|
380
|
+
chatTypingUser.value = null;
|
|
381
|
+
chatReconnectAttempts = 0;
|
|
248
382
|
chatPhase.value = 'pre';
|
|
249
383
|
chatSession.value = null;
|
|
250
384
|
chatMessages.value = [];
|
|
@@ -254,7 +388,7 @@ function startNewChat() {
|
|
|
254
388
|
chatRatingSubmitted.value = false;
|
|
255
389
|
chatInput.value = '';
|
|
256
390
|
chatConnectionStatus.value = 'disconnected';
|
|
257
|
-
preChatForm.value =
|
|
391
|
+
preChatForm.value = blankChatForm();
|
|
258
392
|
}
|
|
259
393
|
|
|
260
394
|
function chatBubbleInitials(name) {
|
|
@@ -303,25 +437,26 @@ function backToSearch() {
|
|
|
303
437
|
}
|
|
304
438
|
|
|
305
439
|
async function submitTicket() {
|
|
440
|
+
const email = ticketForm.value.email;
|
|
306
441
|
ticketSubmitting.value = true;
|
|
307
442
|
error.value = '';
|
|
308
443
|
try {
|
|
444
|
+
if (verificationRequired.value && !(await ticketVerification.ready())) return;
|
|
309
445
|
const data = await api('POST', '/tickets', {
|
|
310
446
|
name: ticketForm.value.name,
|
|
311
447
|
email: ticketForm.value.email,
|
|
312
448
|
subject: ticketForm.value.subject,
|
|
313
449
|
description: ticketForm.value.description,
|
|
314
450
|
department_id: ticketForm.value.department_id || null,
|
|
451
|
+
...metadataPayload(),
|
|
452
|
+
...(verificationRequired.value ? ticketVerification.fields() : {}),
|
|
315
453
|
});
|
|
454
|
+
if (email !== ticketForm.value.email) return;
|
|
316
455
|
ticketSuccess.value = data;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
email: '',
|
|
320
|
-
subject: '',
|
|
321
|
-
description: '',
|
|
322
|
-
department_id: '',
|
|
323
|
-
};
|
|
456
|
+
ticketVerification.reset();
|
|
457
|
+
ticketForm.value = blankTicketForm();
|
|
324
458
|
} catch (e) {
|
|
459
|
+
if (e.name === 'AbortError') return;
|
|
325
460
|
if (e.data?.errors) {
|
|
326
461
|
error.value = Object.values(e.data.errors).flat().join(' ');
|
|
327
462
|
} else {
|
|
@@ -333,27 +468,121 @@ async function submitTicket() {
|
|
|
333
468
|
}
|
|
334
469
|
|
|
335
470
|
async function checkStatus() {
|
|
471
|
+
const identity = `${statusForm.value.email}|${statusForm.value.reference}`;
|
|
336
472
|
statusLoading.value = true;
|
|
337
473
|
error.value = '';
|
|
338
474
|
statusResult.value = null;
|
|
339
475
|
try {
|
|
476
|
+
if (verificationRequired.value) {
|
|
477
|
+
if (!(await statusVerification.ready())) return;
|
|
478
|
+
const result = await api('POST', '/lookup', { ...statusForm.value, ...statusVerification.fields() });
|
|
479
|
+
if (identity !== `${statusForm.value.email}|${statusForm.value.reference}`) return;
|
|
480
|
+
statusVerification.reset();
|
|
481
|
+
statusMatches.value = result.data || [];
|
|
482
|
+
if (statusMatches.value.length === 1) await loadVerifiedStatus(statusMatches.value[0]);
|
|
483
|
+
if (!statusMatches.value.length) error.value = 'No tickets matched that reference and email.';
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
340
486
|
const data = await api(
|
|
341
487
|
'GET',
|
|
342
488
|
`/tickets/${encodeURIComponent(statusForm.value.reference)}?email=${encodeURIComponent(statusForm.value.email)}`,
|
|
343
489
|
);
|
|
344
|
-
statusResult.value = data;
|
|
490
|
+
if (identity === `${statusForm.value.email}|${statusForm.value.reference}`) statusResult.value = data;
|
|
345
491
|
} catch (e) {
|
|
492
|
+
if (e.name === 'AbortError') return;
|
|
346
493
|
error.value = e.data?.message || 'Ticket not found.';
|
|
347
494
|
} finally {
|
|
348
495
|
statusLoading.value = false;
|
|
349
496
|
}
|
|
350
497
|
}
|
|
351
498
|
|
|
499
|
+
async function loadVerifiedStatus(match) {
|
|
500
|
+
const identity = `${statusForm.value.email}|${statusForm.value.reference}`;
|
|
501
|
+
error.value = '';
|
|
502
|
+
try {
|
|
503
|
+
const result = await api(
|
|
504
|
+
'GET',
|
|
505
|
+
`/tickets/${encodeURIComponent(match.reference)}`,
|
|
506
|
+
null,
|
|
507
|
+
match.guest_access_token,
|
|
508
|
+
);
|
|
509
|
+
if (identity === `${statusForm.value.email}|${statusForm.value.reference}`) statusResult.value = result;
|
|
510
|
+
} catch (e) {
|
|
511
|
+
if (e.name === 'AbortError') return;
|
|
512
|
+
error.value = e.data?.message || 'The link has expired. Verify your email for a new one.';
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Host parameters for a ticket or chat, plus the page it came from (origin
|
|
518
|
+
* and path only: query strings can carry tokens). Only sent to backends
|
|
519
|
+
* whose /config says they accept them, so older backends see no new field.
|
|
520
|
+
*/
|
|
521
|
+
function metadataPayload() {
|
|
522
|
+
if (widgetConfig.value?.accepts_metadata !== true) return {};
|
|
523
|
+
let provided;
|
|
524
|
+
try {
|
|
525
|
+
provided = props.metadataProvider?.() || {};
|
|
526
|
+
} catch {
|
|
527
|
+
provided = {};
|
|
528
|
+
}
|
|
529
|
+
const metadata = { ...hostMetadata.value, ...(typeof provided === 'object' ? provided : {}) };
|
|
530
|
+
if (metadata.page_url == null && typeof window !== 'undefined' && window.location?.origin) {
|
|
531
|
+
metadata.page_url = window.location.origin + window.location.pathname;
|
|
532
|
+
}
|
|
533
|
+
return { metadata };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const TAB_LABELS = { help: 'Help', contact: 'Contact', chat: 'Chat', status: 'Check Status' };
|
|
537
|
+
|
|
538
|
+
function selectTab(tab) {
|
|
539
|
+
activeTab.value = tab;
|
|
540
|
+
error.value = '';
|
|
541
|
+
if (tab === 'contact') ticketSuccess.value = null;
|
|
542
|
+
if (tab === 'status') statusResult.value = null;
|
|
543
|
+
}
|
|
544
|
+
|
|
352
545
|
function togglePanel() {
|
|
546
|
+
if (inline.value) return;
|
|
353
547
|
isOpen.value = !isOpen.value;
|
|
354
548
|
error.value = '';
|
|
355
549
|
}
|
|
356
550
|
|
|
551
|
+
function open(tab = null) {
|
|
552
|
+
if (tab) activeTab.value = tab;
|
|
553
|
+
isOpen.value = true;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function close() {
|
|
557
|
+
if (!inline.value) isOpen.value = false;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Fill in who the visitor is, without overwriting what they typed. */
|
|
561
|
+
function identify(person = {}) {
|
|
562
|
+
identity.value = {
|
|
563
|
+
name: person.name ?? identity.value.name,
|
|
564
|
+
email: person.email ?? identity.value.email,
|
|
565
|
+
};
|
|
566
|
+
for (const form of [ticketForm, preChatForm]) {
|
|
567
|
+
if (!form.value.name) form.value.name = identity.value.name;
|
|
568
|
+
if (!form.value.email) form.value.email = identity.value.email;
|
|
569
|
+
}
|
|
570
|
+
if (!statusForm.value.email) statusForm.value.email = identity.value.email;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function setMetadata(metadata = {}, { merge = true } = {}) {
|
|
574
|
+
hostMetadata.value = merge ? { ...hostMetadata.value, ...metadata } : { ...metadata };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function setDepartment(id, { lock = false } = {}) {
|
|
578
|
+
presetDepartment.value = id ?? '';
|
|
579
|
+
lockedDepartment.value = lock && id != null ? id : null;
|
|
580
|
+
ticketForm.value.department_id = presetDepartment.value;
|
|
581
|
+
preChatForm.value.department_id = presetDepartment.value;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
defineExpose({ open, close, toggle: togglePanel, identify, setMetadata, setDepartment });
|
|
585
|
+
|
|
357
586
|
function formatDate(iso) {
|
|
358
587
|
try {
|
|
359
588
|
return new Date(iso).toLocaleDateString(undefined, {
|
|
@@ -370,6 +599,7 @@ function formatDate(iso) {
|
|
|
370
599
|
|
|
371
600
|
<template>
|
|
372
601
|
<button
|
|
602
|
+
v-if="!inline && !hideLauncher"
|
|
373
603
|
class="esc-w-fab"
|
|
374
604
|
:class="position"
|
|
375
605
|
:style="{ backgroundColor: color }"
|
|
@@ -389,64 +619,29 @@ function formatDate(iso) {
|
|
|
389
619
|
|
|
390
620
|
<div
|
|
391
621
|
class="esc-w-panel"
|
|
392
|
-
:class="[position, { 'esc-open': isOpen }]"
|
|
622
|
+
:class="inline ? ['esc-w-inline', 'esc-open'] : [position, { 'esc-open': isOpen }]"
|
|
393
623
|
:style="{ '--esc-widget-color': color }"
|
|
394
624
|
data-widget-panel
|
|
395
625
|
>
|
|
396
626
|
<div class="esc-w-header" :style="{ backgroundColor: color }">
|
|
397
|
-
<h2>
|
|
627
|
+
<h2>{{ title }}</h2>
|
|
398
628
|
<p>{{ greeting }}</p>
|
|
399
629
|
</div>
|
|
400
630
|
|
|
401
|
-
<div class="esc-w-tabs">
|
|
402
|
-
<button
|
|
403
|
-
v-if="kbEnabled"
|
|
404
|
-
class="esc-w-tab"
|
|
405
|
-
:class="{ active: activeTab === 'help' }"
|
|
406
|
-
@click="
|
|
407
|
-
activeTab = 'help';
|
|
408
|
-
error = '';
|
|
409
|
-
"
|
|
410
|
-
>
|
|
411
|
-
Help
|
|
412
|
-
</button>
|
|
413
|
-
<button
|
|
414
|
-
class="esc-w-tab"
|
|
415
|
-
:class="{ active: activeTab === 'contact' }"
|
|
416
|
-
@click="
|
|
417
|
-
activeTab = 'contact';
|
|
418
|
-
error = '';
|
|
419
|
-
ticketSuccess = null;
|
|
420
|
-
"
|
|
421
|
-
>
|
|
422
|
-
Contact
|
|
423
|
-
</button>
|
|
631
|
+
<div v-if="visibleTabs.length > 1" class="esc-w-tabs">
|
|
424
632
|
<button
|
|
425
|
-
v-
|
|
633
|
+
v-for="tab in visibleTabs"
|
|
634
|
+
:key="tab"
|
|
426
635
|
class="esc-w-tab"
|
|
427
|
-
:class="{ active:
|
|
428
|
-
@click="
|
|
429
|
-
activeTab = 'chat';
|
|
430
|
-
error = '';
|
|
431
|
-
"
|
|
636
|
+
:class="{ active: currentTab === tab }"
|
|
637
|
+
@click="selectTab(tab)"
|
|
432
638
|
>
|
|
433
|
-
|
|
434
|
-
</button>
|
|
435
|
-
<button
|
|
436
|
-
class="esc-w-tab"
|
|
437
|
-
:class="{ active: activeTab === 'status' }"
|
|
438
|
-
@click="
|
|
439
|
-
activeTab = 'status';
|
|
440
|
-
error = '';
|
|
441
|
-
statusResult = null;
|
|
442
|
-
"
|
|
443
|
-
>
|
|
444
|
-
Check Status
|
|
639
|
+
{{ TAB_LABELS[tab] }}
|
|
445
640
|
</button>
|
|
446
641
|
</div>
|
|
447
642
|
|
|
448
643
|
<div class="esc-w-body">
|
|
449
|
-
<template v-if="
|
|
644
|
+
<template v-if="currentTab === 'help' && kbEnabled">
|
|
450
645
|
<template v-if="!selectedArticle">
|
|
451
646
|
<div class="esc-w-field">
|
|
452
647
|
<input v-model="searchQuery" type="text" class="esc-w-input" placeholder="Search for help..." />
|
|
@@ -485,7 +680,7 @@ function formatDate(iso) {
|
|
|
485
680
|
</template>
|
|
486
681
|
</template>
|
|
487
682
|
|
|
488
|
-
<template v-if="
|
|
683
|
+
<template v-if="currentTab === 'contact'">
|
|
489
684
|
<template v-if="ticketSuccess">
|
|
490
685
|
<div class="esc-w-success">
|
|
491
686
|
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -517,7 +712,7 @@ function formatDate(iso) {
|
|
|
517
712
|
<label class="esc-w-label">Description</label>
|
|
518
713
|
<textarea v-model="ticketForm.description" class="esc-w-textarea" required></textarea>
|
|
519
714
|
</div>
|
|
520
|
-
<div v-if="departments.length" class="esc-w-field">
|
|
715
|
+
<div v-if="departments.length && !departmentLocked" class="esc-w-field">
|
|
521
716
|
<label class="esc-w-label">Department</label>
|
|
522
717
|
<select v-model="ticketForm.department_id" class="esc-w-select">
|
|
523
718
|
<option value="">Select a department...</option>
|
|
@@ -526,20 +721,31 @@ function formatDate(iso) {
|
|
|
526
721
|
</option>
|
|
527
722
|
</select>
|
|
528
723
|
</div>
|
|
724
|
+
<GuestEmailCode
|
|
725
|
+
v-if="verificationRequired"
|
|
726
|
+
:verification="ticketVerification"
|
|
727
|
+
@code="ticketVerification.state.code = $event"
|
|
728
|
+
/>
|
|
529
729
|
<button
|
|
530
730
|
type="submit"
|
|
531
731
|
class="esc-w-btn"
|
|
532
732
|
:style="{ backgroundColor: color }"
|
|
533
|
-
:disabled="ticketSubmitting"
|
|
733
|
+
:disabled="ticketSubmitting || ticketVerification.state.pending"
|
|
534
734
|
>
|
|
535
|
-
{{
|
|
735
|
+
{{
|
|
736
|
+
ticketSubmitting
|
|
737
|
+
? 'Submitting...'
|
|
738
|
+
: verificationRequired && !ticketVerification.state.id
|
|
739
|
+
? 'Send verification code'
|
|
740
|
+
: 'Submit Ticket'
|
|
741
|
+
}}
|
|
536
742
|
</button>
|
|
537
743
|
</form>
|
|
538
744
|
</template>
|
|
539
745
|
</template>
|
|
540
746
|
|
|
541
747
|
<!-- Chat Tab -->
|
|
542
|
-
<template v-if="
|
|
748
|
+
<template v-if="currentTab === 'chat'">
|
|
543
749
|
<!-- Pre-chat form -->
|
|
544
750
|
<template v-if="chatPhase === 'pre'">
|
|
545
751
|
<div class="esc-w-chat-avail">
|
|
@@ -556,7 +762,7 @@ function formatDate(iso) {
|
|
|
556
762
|
<label class="esc-w-label">Email</label>
|
|
557
763
|
<input v-model="preChatForm.email" type="email" class="esc-w-input" required />
|
|
558
764
|
</div>
|
|
559
|
-
<div v-if="departments.length > 1" class="esc-w-field">
|
|
765
|
+
<div v-if="departments.length > 1 && !departmentLocked" class="esc-w-field">
|
|
560
766
|
<label class="esc-w-label">Department</label>
|
|
561
767
|
<select v-model="preChatForm.department_id" class="esc-w-select">
|
|
562
768
|
<option value="">Select a department...</option>
|
|
@@ -569,13 +775,24 @@ function formatDate(iso) {
|
|
|
569
775
|
<label class="esc-w-label">Message (optional)</label>
|
|
570
776
|
<textarea v-model="preChatForm.message" class="esc-w-textarea"></textarea>
|
|
571
777
|
</div>
|
|
778
|
+
<GuestEmailCode
|
|
779
|
+
v-if="verificationRequired"
|
|
780
|
+
:verification="chatVerification"
|
|
781
|
+
@code="chatVerification.state.code = $event"
|
|
782
|
+
/>
|
|
572
783
|
<button
|
|
573
784
|
type="submit"
|
|
574
785
|
class="esc-w-btn"
|
|
575
786
|
:style="{ backgroundColor: color }"
|
|
576
|
-
:disabled="chatStarting"
|
|
787
|
+
:disabled="chatStarting || chatVerification.state.pending"
|
|
577
788
|
>
|
|
578
|
-
{{
|
|
789
|
+
{{
|
|
790
|
+
chatStarting
|
|
791
|
+
? 'Starting...'
|
|
792
|
+
: verificationRequired && !chatVerification.state.id
|
|
793
|
+
? 'Send verification code'
|
|
794
|
+
: 'Start Chat'
|
|
795
|
+
}}
|
|
579
796
|
</button>
|
|
580
797
|
</form>
|
|
581
798
|
</template>
|
|
@@ -708,7 +925,7 @@ function formatDate(iso) {
|
|
|
708
925
|
</template>
|
|
709
926
|
</template>
|
|
710
927
|
|
|
711
|
-
<template v-if="
|
|
928
|
+
<template v-if="currentTab === 'status'">
|
|
712
929
|
<div v-if="error" class="esc-w-error">{{ error }}</div>
|
|
713
930
|
<template v-if="!statusResult">
|
|
714
931
|
<form @submit.prevent="checkStatus">
|
|
@@ -723,7 +940,9 @@ function formatDate(iso) {
|
|
|
723
940
|
/>
|
|
724
941
|
</div>
|
|
725
942
|
<div class="esc-w-field">
|
|
726
|
-
<label class="esc-w-label">
|
|
943
|
+
<label class="esc-w-label">{{
|
|
944
|
+
verificationRequired ? 'Tracking or ticket reference' : 'Ticket Reference'
|
|
945
|
+
}}</label>
|
|
727
946
|
<input
|
|
728
947
|
v-model="statusForm.reference"
|
|
729
948
|
type="text"
|
|
@@ -732,15 +951,33 @@ function formatDate(iso) {
|
|
|
732
951
|
required
|
|
733
952
|
/>
|
|
734
953
|
</div>
|
|
954
|
+
<GuestEmailCode
|
|
955
|
+
v-if="verificationRequired"
|
|
956
|
+
:verification="statusVerification"
|
|
957
|
+
@code="statusVerification.state.code = $event"
|
|
958
|
+
/>
|
|
735
959
|
<button
|
|
736
960
|
type="submit"
|
|
737
961
|
class="esc-w-btn"
|
|
738
962
|
:style="{ backgroundColor: color }"
|
|
739
|
-
:disabled="statusLoading"
|
|
963
|
+
:disabled="statusLoading || statusVerification.state.pending"
|
|
740
964
|
>
|
|
741
|
-
{{
|
|
965
|
+
{{
|
|
966
|
+
statusLoading
|
|
967
|
+
? 'Checking...'
|
|
968
|
+
: verificationRequired && !statusVerification.state.id
|
|
969
|
+
? 'Send verification code'
|
|
970
|
+
: 'Check Status'
|
|
971
|
+
}}
|
|
742
972
|
</button>
|
|
743
973
|
</form>
|
|
974
|
+
<ul v-if="statusMatches.length" class="esc-w-results">
|
|
975
|
+
<li v-for="match in statusMatches" :key="match.reference">
|
|
976
|
+
<button type="button" class="esc-w-back" @click="loadVerifiedStatus(match)">
|
|
977
|
+
{{ match.reference }}: {{ match.subject }}
|
|
978
|
+
</button>
|
|
979
|
+
</li>
|
|
980
|
+
</ul>
|
|
744
981
|
</template>
|
|
745
982
|
<template v-else>
|
|
746
983
|
<button
|