@escalated-dev/escalated 0.11.10 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,7 +49,9 @@ function applyQuickFilter(filter) {
49
49
  }
50
50
 
51
51
  // Real-time updates - refresh the ticket list when new tickets arrive or statuses change
52
- const { echoAvailable, subscribeToTickets, subscribeToAgent } = useRealtime();
52
+ const { echoAvailable, subscribeToTickets, subscribeToAgent } = useRealtime({
53
+ channelPrefix: () => page.props.escalated?.broadcasting?.channel_prefix,
54
+ });
53
55
 
54
56
  onMounted(() => {
55
57
  if (!echoAvailable.value) return;
@@ -92,7 +92,10 @@ const chatTypingUser = ref(null);
92
92
  // Subscribe to chat channel for real-time messages. Pass widgetPath so
93
93
  // useChat's API calls resolve against whatever prefix the host framework
94
94
  // serves Escalated under (config on NestJS, default /support elsewhere).
95
- const { subscribeToChat } = useChat({ widgetPath: widgetPath.value });
95
+ const { subscribeToChat } = useChat({
96
+ channelPrefix: () => page.props.escalated?.broadcasting?.channel_prefix,
97
+ widgetPath: widgetPath.value,
98
+ });
96
99
  let chatTypingTimer = null;
97
100
  let chatUnsubscribe = null;
98
101
 
@@ -100,9 +103,11 @@ let chatUnsubscribe = null;
100
103
  // reuses this page component across ticket navigation (setup() does not re-run),
101
104
  // so without this the previous ticket's chat would persist.
102
105
  watch(
103
- () => props.ticket.id,
106
+ () => [props.ticket.id, page.props.escalated?.broadcasting?.channel_prefix],
104
107
  () => {
105
108
  chatMessages.value = props.ticket.chat_messages || [];
109
+ chatTypingUser.value = null;
110
+ clearTimeout(chatTypingTimer);
106
111
  },
107
112
  );
108
113
 
@@ -187,7 +192,9 @@ useKeyboardShortcuts({
187
192
  });
188
193
 
189
194
  // Real-time updates via WebSocket (gracefully degrades to no-op when Echo is absent)
190
- const { echoAvailable, subscribeToTicket } = useRealtime();
195
+ const { echoAvailable, subscribeToTicket } = useRealtime({
196
+ channelPrefix: () => page.props.escalated?.broadcasting?.channel_prefix,
197
+ });
191
198
  let ticketUnsubscribe = null;
192
199
 
193
200
  // (Re)subscribe whenever the ticket id changes. Because Inertia reuses this
@@ -243,6 +250,7 @@ onUnmounted(() => {
243
250
  <PresenceIndicator
244
251
  :ticket-reference="ticket.reference"
245
252
  :ticket-id="ticket.id"
253
+ :channel-prefix="page.props.escalated?.broadcasting?.channel_prefix"
246
254
  route-prefix="escalated.agent"
247
255
  />
248
256
  <div class="ml-auto flex items-center gap-2">
@@ -1,11 +1,17 @@
1
1
  <script setup>
2
2
  import EscalatedLayout from '../../components/EscalatedLayout.vue';
3
3
  import FileDropzone from '../../components/FileDropzone.vue';
4
- import { useForm, Link } from '@inertiajs/vue3';
4
+ import GuestEmailCode from '../../components/GuestEmailCode.vue';
5
+ import { useGuestVerification } from '../../composables/useGuestVerification';
6
+ import { guestRequest } from '../../utils/guestRequests';
7
+ import { useForm, usePage, Link } from '@inertiajs/vue3';
8
+ import { reactive, ref, watch } from 'vue';
5
9
 
6
- defineProps({
10
+ const props = defineProps({
7
11
  departments: Array,
8
12
  priorities: Array,
13
+ verification_url: { type: String, default: null },
14
+ lookup_url: { type: String, default: null },
9
15
  });
10
16
 
11
17
  const form = useForm({
@@ -16,10 +22,62 @@ const form = useForm({
16
22
  priority: 'medium',
17
23
  department_id: '',
18
24
  attachments: [],
25
+ verification_id: '',
26
+ verification_code: '',
19
27
  });
20
28
 
21
- function submit() {
22
- form.post(route('escalated.guest.tickets.store'));
29
+ const page = usePage();
30
+ const context = () =>
31
+ `${props.verification_url}|${props.lookup_url}|${page.props.escalated?.broadcasting?.channel_prefix || ''}`;
32
+ const requestCode = (email, purpose) => guestRequest(props.verification_url, { email, purpose });
33
+ const verification = useGuestVerification(() => form.guest_email, 'ticket', requestCode, context);
34
+ const lookup = reactive({ email: '', reference: '', pending: false, error: '', searched: false });
35
+ const matches = ref([]);
36
+ watch(
37
+ () => `${lookup.email}|${lookup.reference}`,
38
+ () => {
39
+ matches.value = [];
40
+ lookup.searched = false;
41
+ },
42
+ );
43
+ const lookupVerification = useGuestVerification(() => lookup.email, 'lookup', requestCode, context);
44
+ watch(context, () => {
45
+ form.cancel();
46
+ form.reset();
47
+ Object.assign(lookup, { email: '', reference: '', pending: false, error: '', searched: false });
48
+ matches.value = [];
49
+ });
50
+
51
+ async function submit() {
52
+ if (props.verification_url) {
53
+ if (!(await verification.ready())) return;
54
+ Object.assign(form, verification.fields());
55
+ }
56
+ form.post(route('escalated.guest.tickets.store'), { onSuccess: () => verification.reset() });
57
+ }
58
+
59
+ async function findTickets() {
60
+ lookup.error = '';
61
+ if (!(await lookupVerification.ready())) return;
62
+ lookup.pending = true;
63
+ const current = context();
64
+ const email = lookup.email;
65
+ const reference = lookup.reference;
66
+ try {
67
+ const data = await guestRequest(props.lookup_url, {
68
+ email,
69
+ reference: lookup.reference,
70
+ ...lookupVerification.fields(),
71
+ });
72
+ if (current !== context() || email !== lookup.email || reference !== lookup.reference) return;
73
+ matches.value = data.data || [];
74
+ lookup.searched = true;
75
+ lookupVerification.reset();
76
+ } catch (error) {
77
+ if (current === context() && email === lookup.email) lookup.error = error.message;
78
+ } finally {
79
+ if (current === context()) lookup.pending = false;
80
+ }
23
81
  }
24
82
  </script>
25
83
 
@@ -28,7 +86,13 @@ function submit() {
28
86
  <div class="mx-auto max-w-2xl">
29
87
  <div class="mb-6 text-center">
30
88
  <h1 class="text-xl font-semibold text-gray-900">Submit a Support Ticket</h1>
31
- <p class="mt-1 text-sm text-gray-500">No account needed. We'll give you a link to track your ticket.</p>
89
+ <p class="mt-1 text-sm text-gray-500">
90
+ {{
91
+ verification_url
92
+ ? 'No account needed. Verify your email to submit a ticket and receive a private link.'
93
+ : "No account needed. We'll give you a link to track your ticket."
94
+ }}
95
+ </p>
32
96
  <Link :href="route('login')" class="mt-2 inline-block text-sm text-indigo-600 hover:text-indigo-700">
33
97
  Already have an account? Sign in
34
98
  </Link>
@@ -107,16 +171,82 @@ function submit() {
107
171
  <FileDropzone v-model="form.attachments" />
108
172
  </div>
109
173
 
174
+ <GuestEmailCode
175
+ v-if="verification_url"
176
+ :verification="verification"
177
+ @code="verification.state.code = $event"
178
+ />
179
+ <p
180
+ v-if="form.errors.verification_code || form.errors.verification_id"
181
+ class="text-sm text-red-600"
182
+ role="alert"
183
+ >
184
+ {{ form.errors.verification_code || form.errors.verification_id }}
185
+ </p>
110
186
  <div class="flex justify-end">
111
187
  <button
112
188
  type="submit"
113
- :disabled="form.processing"
189
+ :disabled="form.processing || verification.state.pending"
114
190
  class="rounded-lg bg-indigo-600 px-6 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
115
191
  >
116
- {{ form.processing ? 'Submitting...' : 'Submit Ticket' }}
192
+ {{
193
+ form.processing
194
+ ? 'Submitting...'
195
+ : verification_url && !verification.state.id
196
+ ? 'Send verification code'
197
+ : 'Submit Ticket'
198
+ }}
117
199
  </button>
118
200
  </div>
119
201
  </form>
202
+
203
+ <form
204
+ v-if="lookup_url"
205
+ class="mt-8 space-y-4 rounded-lg border border-gray-200 bg-white p-6"
206
+ @submit.prevent="findTickets"
207
+ >
208
+ <h2 class="text-lg font-semibold">Find a ticket or renew your link</h2>
209
+ <label class="block text-sm"
210
+ >Email address
211
+ <input
212
+ v-model="lookup.email"
213
+ type="email"
214
+ required
215
+ class="mt-1 block w-full rounded-lg border-gray-300"
216
+ />
217
+ </label>
218
+ <label class="block text-sm"
219
+ >Tracking or ticket reference
220
+ <input
221
+ v-model="lookup.reference"
222
+ type="text"
223
+ maxlength="255"
224
+ required
225
+ class="mt-1 block w-full rounded-lg border-gray-300"
226
+ />
227
+ </label>
228
+ <GuestEmailCode :verification="lookupVerification" @code="lookupVerification.state.code = $event" />
229
+ <p v-if="lookup.error" class="text-sm text-red-600" role="alert">{{ lookup.error }}</p>
230
+ <button
231
+ type="submit"
232
+ :disabled="lookup.pending || lookupVerification.state.pending"
233
+ class="rounded-lg bg-indigo-600 px-4 py-2 text-sm text-white disabled:opacity-50"
234
+ >
235
+ {{ lookupVerification.state.id ? 'Find tickets' : 'Send verification code' }}
236
+ </button>
237
+ <p v-if="lookup.searched && !matches.length" class="text-sm">
238
+ No tickets matched that reference and email.
239
+ </p>
240
+ <ul v-if="matches.length" class="space-y-2">
241
+ <li v-for="match in matches" :key="match.reference">
242
+ <a
243
+ :href="route('escalated.guest.tickets.show', match.guest_access_token)"
244
+ class="text-indigo-600 underline"
245
+ >{{ match.reference }}: {{ match.subject }}</a
246
+ >
247
+ </li>
248
+ </ul>
249
+ </form>
120
250
  </div>
121
251
  </EscalatedLayout>
122
252
  </template>
@@ -52,6 +52,12 @@ function copyLink() {
52
52
  <p class="mt-0.5 text-xs text-amber-600">
53
53
  This is your private link to view and reply to this ticket.
54
54
  </p>
55
+ <p v-if="ticket.guest_access_expires_at" class="mt-1 text-xs text-amber-600">
56
+ Expires {{ new Date(ticket.guest_access_expires_at).toLocaleString() }}.
57
+ <a :href="route('escalated.guest.tickets.create')" class="underline"
58
+ >Verify your email to renew it.</a
59
+ >
60
+ </p>
55
61
  </div>
56
62
  <button
57
63
  class="ml-auto shrink-0 rounded-md border border-amber-300 bg-white px-2.5 py-1 text-xs font-medium text-amber-700 hover:bg-amber-50"
@@ -0,0 +1,27 @@
1
+ /** Never forward the embedding site's CSRF token to another origin. */
2
+ export function guestCsrfHeaders(url) {
3
+ if (typeof window === 'undefined' || new URL(url, window.location.href).origin !== window.location.origin)
4
+ return {};
5
+ const token = document.querySelector('meta[name="csrf-token"]')?.content;
6
+ if (token) return { 'X-CSRF-TOKEN': token };
7
+ const cookie = document.cookie.split('; ').find((value) => value.startsWith('XSRF-TOKEN='));
8
+ if (!cookie) return {};
9
+ try {
10
+ return { 'X-XSRF-TOKEN': decodeURIComponent(cookie.slice('XSRF-TOKEN='.length)) };
11
+ } catch {
12
+ return {};
13
+ }
14
+ }
15
+
16
+ export async function guestRequest(url, body) {
17
+ const response = await fetch(url, {
18
+ method: 'POST',
19
+ credentials: 'same-origin',
20
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...guestCsrfHeaders(url) },
21
+ body: JSON.stringify(body),
22
+ });
23
+ const data = await response.json();
24
+ if (!response.ok)
25
+ throw new Error(data.errors ? Object.values(data.errors).flat().join(' ') : data.message || 'Request failed.');
26
+ return data;
27
+ }
@@ -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: '' },
@@ -67,53 +70,117 @@ const ticketSuccess = ref(null);
67
70
  const statusForm = ref({ email: '', reference: '' });
68
71
  const statusLoading = ref(false);
69
72
  const statusResult = ref(null);
73
+ const statusMatches = ref([]);
74
+ let destinationGeneration = 0;
75
+ let disposed = false;
76
+ const verificationRequired = computed(() => widgetConfig.value?.guest_verification_required === true);
77
+ const verificationContext = () => `${props.baseUrl}|${props.widgetPath}`;
78
+ const requestCode = (email, purpose) => api('POST', '/verification', { email, purpose });
79
+ const ticketVerification = useGuestVerification(
80
+ () => ticketForm.value.email,
81
+ 'ticket',
82
+ requestCode,
83
+ verificationContext,
84
+ );
85
+ const chatVerification = useGuestVerification(() => preChatForm.value.email, 'chat', requestCode, verificationContext);
86
+ const statusVerification = useGuestVerification(
87
+ () => statusForm.value.email,
88
+ 'lookup',
89
+ requestCode,
90
+ verificationContext,
91
+ );
92
+ watch(
93
+ () => `${statusForm.value.email}|${statusForm.value.reference}`,
94
+ () => {
95
+ statusMatches.value = [];
96
+ statusResult.value = null;
97
+ },
98
+ { flush: 'sync' },
99
+ );
100
+
101
+ watch(
102
+ verificationContext,
103
+ () => {
104
+ destinationGeneration++;
105
+ stopChatPolling();
106
+ clearTimeout(chatTypingTimer);
107
+ ticketSuccess.value = null;
108
+ statusMatches.value = [];
109
+ statusResult.value = null;
110
+ startNewChat();
111
+ ticketForm.value = { name: '', email: '', subject: '', description: '', department_id: '' };
112
+ statusForm.value = { email: '', reference: '' };
113
+ widgetConfig.value = null;
114
+ chatAvailable.value = false;
115
+ error.value = '';
116
+ loadWidget();
117
+ },
118
+ { flush: 'sync' },
119
+ );
70
120
 
71
121
  let searchTimer = null;
72
122
 
73
- async function api(method, apiPath, body = null) {
123
+ async function api(method, apiPath, body = null, token = null) {
124
+ const generation = destinationGeneration;
74
125
  const url = `${props.baseUrl}${props.widgetPath}${apiPath}`;
75
126
  const opts = {
76
127
  method,
77
128
  headers: {
78
129
  Accept: 'application/json',
79
130
  'Content-Type': 'application/json',
131
+ ...guestCsrfHeaders(url),
132
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
80
133
  },
81
134
  };
82
135
  if (body) opts.body = JSON.stringify(body);
83
136
  const res = await fetch(url, opts);
84
137
  const data = await res.json();
85
- if (!res.ok) throw { status: res.status, data };
138
+ if (disposed || generation !== destinationGeneration)
139
+ throw Object.assign(new Error('Widget destination changed.'), { name: 'AbortError' });
140
+ if (!res.ok)
141
+ throw Object.assign(
142
+ new Error(data.errors ? Object.values(data.errors).flat().join(' ') : data.message || 'Request failed.'),
143
+ { status: res.status, data },
144
+ );
86
145
  return data;
87
146
  }
88
147
 
89
- onMounted(async () => {
148
+ async function loadWidget() {
149
+ const generation = destinationGeneration;
90
150
  try {
91
151
  widgetConfig.value = await api('GET', '/config');
92
152
  } catch {
93
153
  // Widget may be disabled
94
154
  }
155
+ if (disposed || generation !== destinationGeneration) return;
95
156
  // Check chat availability
96
157
  try {
97
158
  const avail = await api('GET', '/chat/availability');
98
159
  chatAvailable.value = avail.available;
99
160
  chatAvailabilityMessage.value = avail.message || '';
100
161
  } catch {
101
- chatAvailable.value = false;
162
+ if (generation === destinationGeneration && !disposed) chatAvailable.value = false;
102
163
  }
103
- });
164
+ }
165
+ onMounted(loadWidget);
104
166
 
105
167
  // Chat functions
106
168
  async function startLiveChat() {
169
+ const email = preChatForm.value.email;
107
170
  chatStarting.value = true;
108
171
  error.value = '';
109
172
  try {
173
+ if (verificationRequired.value && !(await chatVerification.ready())) return;
110
174
  const session = await api('POST', '/chat/start', {
111
175
  name: preChatForm.value.name,
112
176
  email: preChatForm.value.email,
113
177
  department_id: preChatForm.value.department_id || null,
114
178
  message: preChatForm.value.message,
179
+ ...(verificationRequired.value ? chatVerification.fields() : {}),
115
180
  });
181
+ if (email !== preChatForm.value.email) return;
116
182
  chatSession.value = session;
183
+ chatVerification.reset();
117
184
  chatPhase.value = 'live';
118
185
  chatMessages.value = session.messages || [];
119
186
  if (session.agent) {
@@ -122,6 +189,7 @@ async function startLiveChat() {
122
189
  chatConnectionStatus.value = 'connected';
123
190
  startChatPolling();
124
191
  } catch (e) {
192
+ if (e.name === 'AbortError') return;
125
193
  if (e.data?.errors) {
126
194
  error.value = Object.values(e.data.errors).flat().join(' ');
127
195
  } else {
@@ -181,8 +249,10 @@ function startChatPolling() {
181
249
  stopChatPolling();
182
250
  chatPollTimer = setInterval(async () => {
183
251
  if (!chatSession.value) return;
252
+ const sessionId = chatSession.value.id;
184
253
  try {
185
254
  const data = await api('GET', `/chat/${chatSession.value.id}/messages`);
255
+ if (chatSession.value?.id !== sessionId) return;
186
256
  if (data.messages) {
187
257
  chatMessages.value = data.messages;
188
258
  }
@@ -202,7 +272,8 @@ function startChatPolling() {
202
272
  }
203
273
  chatConnectionStatus.value = 'connected';
204
274
  chatReconnectAttempts = 0;
205
- } catch {
275
+ } catch (e) {
276
+ if (e.name === 'AbortError' || chatSession.value?.id !== sessionId) return;
206
277
  chatReconnectAttempts++;
207
278
  if (chatReconnectAttempts > 3) {
208
279
  chatConnectionStatus.value = 'disconnected';
@@ -220,31 +291,46 @@ function stopChatPolling() {
220
291
  }
221
292
  }
222
293
 
294
+ onUnmounted(() => {
295
+ disposed = true;
296
+ stopChatPolling();
297
+ clearTimeout(searchTimer);
298
+ clearTimeout(chatTypingTimer);
299
+ });
300
+
223
301
  async function endLiveChat() {
224
302
  if (!chatSession.value) return;
303
+ const sessionId = chatSession.value.id;
225
304
  try {
226
305
  await api('POST', `/chat/${chatSession.value.id}/end`);
227
306
  } catch {
228
307
  // ignore
229
308
  }
309
+ if (chatSession.value?.id !== sessionId || disposed) return;
230
310
  chatPhase.value = 'post';
231
311
  stopChatPolling();
232
312
  }
233
313
 
234
314
  async function submitChatRating() {
235
315
  if (!chatSession.value || !chatRating.value) return;
316
+ const sessionId = chatSession.value.id;
236
317
  try {
237
318
  await api('POST', `/chat/${chatSession.value.id}/rate`, {
238
319
  rating: chatRating.value,
239
320
  comment: chatComment.value,
240
321
  });
241
- chatRatingSubmitted.value = true;
242
- } catch {
322
+ if (chatSession.value?.id === sessionId) chatRatingSubmitted.value = true;
323
+ } catch (e) {
324
+ if (e.name === 'AbortError' || chatSession.value?.id !== sessionId) return;
243
325
  error.value = 'Failed to submit rating.';
244
326
  }
245
327
  }
246
328
 
247
329
  function startNewChat() {
330
+ stopChatPolling();
331
+ clearTimeout(chatTypingTimer);
332
+ chatTypingUser.value = null;
333
+ chatReconnectAttempts = 0;
248
334
  chatPhase.value = 'pre';
249
335
  chatSession.value = null;
250
336
  chatMessages.value = [];
@@ -303,17 +389,22 @@ function backToSearch() {
303
389
  }
304
390
 
305
391
  async function submitTicket() {
392
+ const email = ticketForm.value.email;
306
393
  ticketSubmitting.value = true;
307
394
  error.value = '';
308
395
  try {
396
+ if (verificationRequired.value && !(await ticketVerification.ready())) return;
309
397
  const data = await api('POST', '/tickets', {
310
398
  name: ticketForm.value.name,
311
399
  email: ticketForm.value.email,
312
400
  subject: ticketForm.value.subject,
313
401
  description: ticketForm.value.description,
314
402
  department_id: ticketForm.value.department_id || null,
403
+ ...(verificationRequired.value ? ticketVerification.fields() : {}),
315
404
  });
405
+ if (email !== ticketForm.value.email) return;
316
406
  ticketSuccess.value = data;
407
+ ticketVerification.reset();
317
408
  ticketForm.value = {
318
409
  name: '',
319
410
  email: '',
@@ -322,6 +413,7 @@ async function submitTicket() {
322
413
  department_id: '',
323
414
  };
324
415
  } catch (e) {
416
+ if (e.name === 'AbortError') return;
325
417
  if (e.data?.errors) {
326
418
  error.value = Object.values(e.data.errors).flat().join(' ');
327
419
  } else {
@@ -333,22 +425,51 @@ async function submitTicket() {
333
425
  }
334
426
 
335
427
  async function checkStatus() {
428
+ const identity = `${statusForm.value.email}|${statusForm.value.reference}`;
336
429
  statusLoading.value = true;
337
430
  error.value = '';
338
431
  statusResult.value = null;
339
432
  try {
433
+ if (verificationRequired.value) {
434
+ if (!(await statusVerification.ready())) return;
435
+ const result = await api('POST', '/lookup', { ...statusForm.value, ...statusVerification.fields() });
436
+ if (identity !== `${statusForm.value.email}|${statusForm.value.reference}`) return;
437
+ statusVerification.reset();
438
+ statusMatches.value = result.data || [];
439
+ if (statusMatches.value.length === 1) await loadVerifiedStatus(statusMatches.value[0]);
440
+ if (!statusMatches.value.length) error.value = 'No tickets matched that reference and email.';
441
+ return;
442
+ }
340
443
  const data = await api(
341
444
  'GET',
342
445
  `/tickets/${encodeURIComponent(statusForm.value.reference)}?email=${encodeURIComponent(statusForm.value.email)}`,
343
446
  );
344
- statusResult.value = data;
447
+ if (identity === `${statusForm.value.email}|${statusForm.value.reference}`) statusResult.value = data;
345
448
  } catch (e) {
449
+ if (e.name === 'AbortError') return;
346
450
  error.value = e.data?.message || 'Ticket not found.';
347
451
  } finally {
348
452
  statusLoading.value = false;
349
453
  }
350
454
  }
351
455
 
456
+ async function loadVerifiedStatus(match) {
457
+ const identity = `${statusForm.value.email}|${statusForm.value.reference}`;
458
+ error.value = '';
459
+ try {
460
+ const result = await api(
461
+ 'GET',
462
+ `/tickets/${encodeURIComponent(match.reference)}`,
463
+ null,
464
+ match.guest_access_token,
465
+ );
466
+ if (identity === `${statusForm.value.email}|${statusForm.value.reference}`) statusResult.value = result;
467
+ } catch (e) {
468
+ if (e.name === 'AbortError') return;
469
+ error.value = e.data?.message || 'The link has expired. Verify your email for a new one.';
470
+ }
471
+ }
472
+
352
473
  function togglePanel() {
353
474
  isOpen.value = !isOpen.value;
354
475
  error.value = '';
@@ -526,13 +647,24 @@ function formatDate(iso) {
526
647
  </option>
527
648
  </select>
528
649
  </div>
650
+ <GuestEmailCode
651
+ v-if="verificationRequired"
652
+ :verification="ticketVerification"
653
+ @code="ticketVerification.state.code = $event"
654
+ />
529
655
  <button
530
656
  type="submit"
531
657
  class="esc-w-btn"
532
658
  :style="{ backgroundColor: color }"
533
- :disabled="ticketSubmitting"
659
+ :disabled="ticketSubmitting || ticketVerification.state.pending"
534
660
  >
535
- {{ ticketSubmitting ? 'Submitting...' : 'Submit Ticket' }}
661
+ {{
662
+ ticketSubmitting
663
+ ? 'Submitting...'
664
+ : verificationRequired && !ticketVerification.state.id
665
+ ? 'Send verification code'
666
+ : 'Submit Ticket'
667
+ }}
536
668
  </button>
537
669
  </form>
538
670
  </template>
@@ -569,13 +701,24 @@ function formatDate(iso) {
569
701
  <label class="esc-w-label">Message (optional)</label>
570
702
  <textarea v-model="preChatForm.message" class="esc-w-textarea"></textarea>
571
703
  </div>
704
+ <GuestEmailCode
705
+ v-if="verificationRequired"
706
+ :verification="chatVerification"
707
+ @code="chatVerification.state.code = $event"
708
+ />
572
709
  <button
573
710
  type="submit"
574
711
  class="esc-w-btn"
575
712
  :style="{ backgroundColor: color }"
576
- :disabled="chatStarting"
713
+ :disabled="chatStarting || chatVerification.state.pending"
577
714
  >
578
- {{ chatStarting ? 'Starting...' : 'Start Chat' }}
715
+ {{
716
+ chatStarting
717
+ ? 'Starting...'
718
+ : verificationRequired && !chatVerification.state.id
719
+ ? 'Send verification code'
720
+ : 'Start Chat'
721
+ }}
579
722
  </button>
580
723
  </form>
581
724
  </template>
@@ -723,7 +866,9 @@ function formatDate(iso) {
723
866
  />
724
867
  </div>
725
868
  <div class="esc-w-field">
726
- <label class="esc-w-label">Ticket Reference</label>
869
+ <label class="esc-w-label">{{
870
+ verificationRequired ? 'Tracking or ticket reference' : 'Ticket Reference'
871
+ }}</label>
727
872
  <input
728
873
  v-model="statusForm.reference"
729
874
  type="text"
@@ -732,15 +877,33 @@ function formatDate(iso) {
732
877
  required
733
878
  />
734
879
  </div>
880
+ <GuestEmailCode
881
+ v-if="verificationRequired"
882
+ :verification="statusVerification"
883
+ @code="statusVerification.state.code = $event"
884
+ />
735
885
  <button
736
886
  type="submit"
737
887
  class="esc-w-btn"
738
888
  :style="{ backgroundColor: color }"
739
- :disabled="statusLoading"
889
+ :disabled="statusLoading || statusVerification.state.pending"
740
890
  >
741
- {{ statusLoading ? 'Checking...' : 'Check Status' }}
891
+ {{
892
+ statusLoading
893
+ ? 'Checking...'
894
+ : verificationRequired && !statusVerification.state.id
895
+ ? 'Send verification code'
896
+ : 'Check Status'
897
+ }}
742
898
  </button>
743
899
  </form>
900
+ <ul v-if="statusMatches.length" class="esc-w-results">
901
+ <li v-for="match in statusMatches" :key="match.reference">
902
+ <button type="button" class="esc-w-back" @click="loadVerifiedStatus(match)">
903
+ {{ match.reference }}: {{ match.subject }}
904
+ </button>
905
+ </li>
906
+ </ul>
744
907
  </template>
745
908
  <template v-else>
746
909
  <button