@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.
- package/README.md +51 -0
- package/package.json +6 -1
- 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/composables/useChat.js +12 -30
- package/src/composables/useGuestVerification.js +48 -0
- package/src/composables/useRealtime.js +102 -146
- package/src/pages/Admin/Settings.vue +240 -70
- 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/guestRequests.js +27 -0
- package/src/widget/EscalatedWidget.vue +180 -17
|
@@ -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({
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
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">
|
|
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
|
-
{{
|
|
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>
|
package/src/pages/Guest/Show.vue
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
{{
|
|
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
|
-
{{
|
|
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">
|
|
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
|
-
{{
|
|
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
|