@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.
@@ -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 isOpen = ref(false);
19
- const activeTab = ref('help');
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 preChatForm = ref({ name: '', email: '', department_id: '', message: '' });
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: '', reference: '' });
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 (!res.ok) throw { status: res.status, data };
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
- onMounted(async () => {
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 = { name: '', email: '', department_id: '', message: '' };
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
- ticketForm.value = {
318
- name: '',
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>Support</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-if="chatAvailable"
633
+ v-for="tab in visibleTabs"
634
+ :key="tab"
426
635
  class="esc-w-tab"
427
- :class="{ active: activeTab === 'chat' }"
428
- @click="
429
- activeTab = 'chat';
430
- error = '';
431
- "
636
+ :class="{ active: currentTab === tab }"
637
+ @click="selectTab(tab)"
432
638
  >
433
- Chat
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="activeTab === 'help' && kbEnabled">
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="activeTab === 'contact'">
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
- {{ ticketSubmitting ? 'Submitting...' : 'Submit Ticket' }}
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="activeTab === 'chat'">
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
- {{ chatStarting ? 'Starting...' : 'Start Chat' }}
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="activeTab === 'status'">
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">Ticket Reference</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
- {{ statusLoading ? 'Checking...' : 'Check Status' }}
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