@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.
@@ -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,4 @@
1
+ /** Shared props remain supported; Inertia 3's explicit values take precedence. */
2
+ export function readFlash(page) {
3
+ return { ...page?.props?.flash, ...page?.flash };
4
+ }
@@ -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
+ }