@escalated-dev/escalated 0.11.9 → 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/components/QuickFilters.vue +6 -3
- package/src/components/TicketList.vue +8 -5
- 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/DatabaseConnection.vue +3 -3
- 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
package/README.md
CHANGED
|
@@ -359,6 +359,42 @@ npm install @escalated-dev/plugin-jira
|
|
|
359
359
|
|
|
360
360
|
## For Package Maintainers
|
|
361
361
|
|
|
362
|
+
Verified guest access is enabled by backend capabilities. `Escalated/Guest/Create`
|
|
363
|
+
accepts optional `verification_url` and `lookup_url` props. With these URLs, the
|
|
364
|
+
form requests an email code before posting a ticket; a separate form verifies
|
|
365
|
+
email to find existing tickets by tracking/ticket reference and renew private
|
|
366
|
+
links. `Guest/Show` displays `ticket.guest_access_expires_at` when supplied.
|
|
367
|
+
|
|
368
|
+
The built-in widget enables the same flow only when `/config` returns
|
|
369
|
+
`guest_verification_required: true`. The backend must provide POST `/verification`
|
|
370
|
+
with email and purpose (`ticket`, `chat`, `lookup`), returning `verification_id`;
|
|
371
|
+
creation accepts `verification_id` and the eight-digit `verification_code`.
|
|
372
|
+
POST `/lookup` returns `{ data: [{ reference, subject, guest_access_token,
|
|
373
|
+
expires_at }] }`; subsequent widget status reads send the token as a Bearer
|
|
374
|
+
header. Chat creation returns an opaque `id` and polling uses
|
|
375
|
+
`/chat/{id}/messages`. Proofs and private results are cleared on destination or
|
|
376
|
+
identity changes. Tokens remain in component memory. CSRF headers are sent only
|
|
377
|
+
to the page's own origin.
|
|
378
|
+
|
|
379
|
+
Backends that omit these capabilities retain their existing form contract. Hosts
|
|
380
|
+
must ship these forms before requiring verification in their backend. This does
|
|
381
|
+
not implement verification in the other backend packages, release the separate
|
|
382
|
+
web-widget plugin, or configure cross-origin CORS/CSRF policy.
|
|
383
|
+
|
|
384
|
+
Backends with account isolation can share `escalated.broadcasting.channel_prefix`
|
|
385
|
+
through Inertia, for example `escalated.tenants.<opaque-account-namespace>`.
|
|
386
|
+
Ticket, agent, chat and presence subscriptions use this prefix and rejoin when it
|
|
387
|
+
changes. Omitting it preserves the existing `escalated` channels. Standalone
|
|
388
|
+
`PresenceIndicator` consumers can pass its `channelPrefix` prop; `useRealtime`
|
|
389
|
+
and `useChat` accept `channelPrefix` as a string, ref or getter. Raw
|
|
390
|
+
`useRealtime().listen(channel, event, callback)` still treats the channel literally.
|
|
391
|
+
|
|
392
|
+
The backend must authorize the current account and each private/presence join.
|
|
393
|
+
Channel names alone are not authorization. Ticket presence should expose only
|
|
394
|
+
agent identity/display names, with requester access limited to private ticket
|
|
395
|
+
updates. Deploy a frontend version containing this support before enabling a
|
|
396
|
+
backend's account-scoped channels.
|
|
397
|
+
|
|
362
398
|
If you're building a new backend integration, this package is available on npm:
|
|
363
399
|
|
|
364
400
|
```bash
|
|
@@ -378,6 +414,21 @@ import CustomerIndex from '@escalated-dev/escalated/pages/Customer/Index.vue'
|
|
|
378
414
|
|
|
379
415
|
Peer dependencies: `vue` ^3.3.0, `@inertiajs/vue3` ^1.0.0 || ^2.0.0
|
|
380
416
|
|
|
417
|
+
## Backend settings capabilities
|
|
418
|
+
|
|
419
|
+
The `Escalated/Admin/Settings` page accepts optional `supported_settings` and
|
|
420
|
+
`update_url` props. Pass an array of editable setting names to show and submit only
|
|
421
|
+
those fields, and a URL accepting a flat POST payload. An empty array exposes no
|
|
422
|
+
editable settings. Omitting these props preserves the full settings form and its
|
|
423
|
+
Laravel named route. Backends must validate their own setting allowlist; hiding a
|
|
424
|
+
control is not authorization.
|
|
425
|
+
|
|
426
|
+
For example, a backend implementing only knowledge-base and branding preferences
|
|
427
|
+
can expose `knowledge_base_enabled`, `knowledge_base_public`,
|
|
428
|
+
`knowledge_base_feedback_enabled`, and `show_powered_by`. Supply typed values in
|
|
429
|
+
the `settings` prop. Changing the settings/account props clears stale form values
|
|
430
|
+
and pending requests, including previously entered credentials.
|
|
431
|
+
|
|
381
432
|
## Ecosystem
|
|
382
433
|
|
|
383
434
|
This is the shared frontend for the Escalated support ticket system. Host framework packages and client SDKs available across the ecosystem:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escalated-dev/escalated",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Vue 3 + Inertia.js UI components for Escalated — the embeddable support ticket system",
|
|
5
5
|
"author": "Escalated Dev <hello@escalated.dev>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -59,6 +59,11 @@
|
|
|
59
59
|
"prettier --write"
|
|
60
60
|
]
|
|
61
61
|
},
|
|
62
|
+
"overrides": {
|
|
63
|
+
"concurrently": {
|
|
64
|
+
"shell-quote": "^1.12.0"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
62
67
|
"devDependencies": {
|
|
63
68
|
"@eslint/js": "^10.0.1",
|
|
64
69
|
"@inertiajs/vue3": "^3.7.0",
|
package/pages.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "Generated by scripts/generate-page-manifest.js from src/pages. Every name here resolves to a component; a name a backend renders that is not here is a blank screen. `props` is what each component reads -- a backend sending something else renders the screen with no content in it.",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"pages": [
|
|
5
5
|
"Escalated/Admin/ApiTokens/Index",
|
|
6
6
|
"Escalated/Admin/AuditLog/Index",
|
|
@@ -564,7 +564,9 @@
|
|
|
564
564
|
"Escalated/Admin/Settings": {
|
|
565
565
|
"props": [
|
|
566
566
|
"departments",
|
|
567
|
-
"settings"
|
|
567
|
+
"settings",
|
|
568
|
+
"supported_settings",
|
|
569
|
+
"update_url"
|
|
568
570
|
],
|
|
569
571
|
"required": []
|
|
570
572
|
},
|
|
@@ -837,7 +839,9 @@
|
|
|
837
839
|
"Escalated/Guest/Create": {
|
|
838
840
|
"props": [
|
|
839
841
|
"departments",
|
|
840
|
-
"
|
|
842
|
+
"lookup_url",
|
|
843
|
+
"priorities",
|
|
844
|
+
"verification_url"
|
|
841
845
|
],
|
|
842
846
|
"required": []
|
|
843
847
|
},
|
|
@@ -95,7 +95,10 @@ function chatInitials(chat) {
|
|
|
95
95
|
// that use '/escalated/widget' instead of '/support/widget'.
|
|
96
96
|
const page = usePage();
|
|
97
97
|
const routePrefix = page.props.escalated?.prefix || 'support';
|
|
98
|
-
const { subscribeToChatQueue } = useChat({
|
|
98
|
+
const { subscribeToChatQueue } = useChat({
|
|
99
|
+
channelPrefix: () => page.props.escalated?.broadcasting?.channel_prefix,
|
|
100
|
+
widgetPath: `/${routePrefix}/widget`,
|
|
101
|
+
});
|
|
99
102
|
|
|
100
103
|
onMounted(() => {
|
|
101
104
|
subscribeToChatQueue({
|
|
@@ -73,7 +73,10 @@ async function acceptChat(session) {
|
|
|
73
73
|
// other framework ('/support/widget').
|
|
74
74
|
const page = usePage();
|
|
75
75
|
const routePrefix = page.props.escalated?.prefix || 'support';
|
|
76
|
-
const { subscribeToChatQueue } = useChat({
|
|
76
|
+
const { subscribeToChatQueue } = useChat({
|
|
77
|
+
channelPrefix: () => page.props.escalated?.broadcasting?.channel_prefix,
|
|
78
|
+
widgetPath: `/${routePrefix}/widget`,
|
|
79
|
+
});
|
|
77
80
|
|
|
78
81
|
onMounted(() => {
|
|
79
82
|
subscribeToChatQueue({
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
defineProps({ verification: { type: Object, required: true } });
|
|
3
|
+
defineEmits(['code']);
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<div class="guest-email-code space-y-2" aria-live="polite">
|
|
8
|
+
<p v-if="verification.state.message" class="text-sm">{{ verification.state.message }}</p>
|
|
9
|
+
<p v-if="verification.state.error" class="text-sm text-red-600" role="alert">{{ verification.state.error }}</p>
|
|
10
|
+
<label v-if="verification.state.id" class="block text-sm">
|
|
11
|
+
Email verification code
|
|
12
|
+
<input
|
|
13
|
+
:value="verification.state.code"
|
|
14
|
+
type="text"
|
|
15
|
+
inputmode="numeric"
|
|
16
|
+
autocomplete="one-time-code"
|
|
17
|
+
maxlength="8"
|
|
18
|
+
class="mt-1 block w-full rounded-lg border border-gray-300 p-2 text-gray-900"
|
|
19
|
+
@input="$emit('code', $event.target.value)"
|
|
20
|
+
/>
|
|
21
|
+
</label>
|
|
22
|
+
<button
|
|
23
|
+
v-if="verification.state.id"
|
|
24
|
+
type="button"
|
|
25
|
+
class="text-sm underline"
|
|
26
|
+
:disabled="verification.state.pending"
|
|
27
|
+
@click="verification.send()"
|
|
28
|
+
>
|
|
29
|
+
Send a new code
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<style scoped>
|
|
35
|
+
.guest-email-code {
|
|
36
|
+
margin: 12px 0;
|
|
37
|
+
font-size: 14px;
|
|
38
|
+
}
|
|
39
|
+
.guest-email-code label,
|
|
40
|
+
.guest-email-code input {
|
|
41
|
+
display: block;
|
|
42
|
+
}
|
|
43
|
+
.guest-email-code input {
|
|
44
|
+
box-sizing: border-box;
|
|
45
|
+
width: 100%;
|
|
46
|
+
margin-top: 6px;
|
|
47
|
+
padding: 8px;
|
|
48
|
+
border: 1px solid #d1d5db;
|
|
49
|
+
border-radius: 8px;
|
|
50
|
+
color: #111827;
|
|
51
|
+
background: #fff;
|
|
52
|
+
}
|
|
53
|
+
.guest-email-code button {
|
|
54
|
+
margin-top: 8px;
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
text-decoration: underline;
|
|
57
|
+
}
|
|
58
|
+
</style>
|
|
@@ -4,6 +4,7 @@ import { ref, computed, inject, onUnmounted, watch } from 'vue';
|
|
|
4
4
|
const props = defineProps({
|
|
5
5
|
ticketReference: { type: String, required: true },
|
|
6
6
|
ticketId: { type: [Number, String], default: null },
|
|
7
|
+
channelPrefix: { type: String, default: 'escalated' },
|
|
7
8
|
routePrefix: { type: String, required: true },
|
|
8
9
|
pollInterval: { type: Number, default: 30000 },
|
|
9
10
|
showLabel: { type: Boolean, default: false },
|
|
@@ -23,11 +24,18 @@ let presenceChannel = null;
|
|
|
23
24
|
// Track the exact channel name we joined so we leave the RIGHT channel when the
|
|
24
25
|
// ticket changes — props.ticketId would already hold the new id by then.
|
|
25
26
|
let joinedChannelName = null;
|
|
27
|
+
let generation = 0;
|
|
28
|
+
let pendingRequest = null;
|
|
26
29
|
|
|
27
30
|
async function fetchPresence() {
|
|
31
|
+
const currentGeneration = generation;
|
|
32
|
+
pendingRequest?.abort();
|
|
33
|
+
const controller = new window.AbortController();
|
|
34
|
+
pendingRequest = controller;
|
|
28
35
|
try {
|
|
29
36
|
const response = await fetch(route(`${props.routePrefix}.tickets.presence`, props.ticketReference), {
|
|
30
37
|
method: 'POST',
|
|
38
|
+
signal: controller.signal,
|
|
31
39
|
headers: {
|
|
32
40
|
Accept: 'application/json',
|
|
33
41
|
'Content-Type': 'application/json',
|
|
@@ -37,7 +45,9 @@ async function fetchPresence() {
|
|
|
37
45
|
});
|
|
38
46
|
if (response.ok) {
|
|
39
47
|
const data = await response.json();
|
|
40
|
-
|
|
48
|
+
if (currentGeneration === generation && !controller.signal.aborted) {
|
|
49
|
+
viewers.value = data.viewers || data || [];
|
|
50
|
+
}
|
|
41
51
|
}
|
|
42
52
|
} catch {
|
|
43
53
|
// silently ignore network errors
|
|
@@ -52,19 +62,23 @@ function initPresenceChannel() {
|
|
|
52
62
|
if (!echoAvailable || !props.ticketId) return false;
|
|
53
63
|
|
|
54
64
|
try {
|
|
55
|
-
|
|
65
|
+
const currentGeneration = generation;
|
|
66
|
+
joinedChannelName = `${props.channelPrefix}.tickets.${props.ticketId}`;
|
|
56
67
|
presenceChannel = window.Echo.join(joinedChannelName);
|
|
57
68
|
|
|
58
69
|
presenceChannel
|
|
59
70
|
.here((users) => {
|
|
71
|
+
if (currentGeneration !== generation) return;
|
|
60
72
|
viewers.value = users;
|
|
61
73
|
})
|
|
62
74
|
.joining((user) => {
|
|
75
|
+
if (currentGeneration !== generation) return;
|
|
63
76
|
if (!viewers.value.find((v) => v.id === user.id)) {
|
|
64
77
|
viewers.value = [...viewers.value, user];
|
|
65
78
|
}
|
|
66
79
|
})
|
|
67
80
|
.leaving((user) => {
|
|
81
|
+
if (currentGeneration !== generation) return;
|
|
68
82
|
viewers.value = viewers.value.filter((v) => v.id !== user.id);
|
|
69
83
|
});
|
|
70
84
|
|
|
@@ -104,13 +118,17 @@ function getColor(index) {
|
|
|
104
118
|
}
|
|
105
119
|
|
|
106
120
|
function teardown() {
|
|
121
|
+
generation++;
|
|
122
|
+
pendingRequest?.abort();
|
|
123
|
+
pendingRequest = null;
|
|
107
124
|
if (intervalId) {
|
|
108
125
|
clearInterval(intervalId);
|
|
109
126
|
intervalId = null;
|
|
110
127
|
}
|
|
111
128
|
if (presenceChannel) {
|
|
112
129
|
try {
|
|
113
|
-
window.Echo.
|
|
130
|
+
if (window.Echo.leaveChannel) window.Echo.leaveChannel(`presence-${joinedChannelName}`);
|
|
131
|
+
else window.Echo.leave(`presence-${joinedChannelName}`);
|
|
114
132
|
} catch {
|
|
115
133
|
// ignore
|
|
116
134
|
}
|
|
@@ -134,7 +152,10 @@ function start() {
|
|
|
134
152
|
}
|
|
135
153
|
|
|
136
154
|
// (Re)initialise whenever the ticket identity changes; `immediate` covers mount.
|
|
137
|
-
watch(() => [props.ticketId, props.ticketReference], start, {
|
|
155
|
+
watch(() => [props.ticketId, props.ticketReference, props.routePrefix, props.channelPrefix], start, {
|
|
156
|
+
immediate: true,
|
|
157
|
+
flush: 'sync',
|
|
158
|
+
});
|
|
138
159
|
|
|
139
160
|
onUnmounted(teardown);
|
|
140
161
|
</script>
|
|
@@ -51,17 +51,20 @@ function selectTab(tab) {
|
|
|
51
51
|
activeTab === tab.key
|
|
52
52
|
? escDark
|
|
53
53
|
? 'text-cyan-400 focus-visible:ring-cyan-500/40'
|
|
54
|
-
: 'text-
|
|
54
|
+
: 'text-[var(--esc-panel-accent)] focus-visible:ring-[var(--esc-panel-accent)]'
|
|
55
55
|
: escDark
|
|
56
56
|
? 'text-neutral-500 hover:text-neutral-300 focus-visible:ring-cyan-500/40'
|
|
57
|
-
: 'text-gray-500 hover:text-gray-700 focus-visible:ring-
|
|
57
|
+
: 'text-gray-500 hover:text-gray-700 focus-visible:ring-[var(--esc-panel-accent)]',
|
|
58
58
|
]"
|
|
59
59
|
@click="selectTab(tab)"
|
|
60
60
|
>
|
|
61
61
|
{{ tab.label }}
|
|
62
62
|
<span
|
|
63
63
|
v-if="activeTab === tab.key"
|
|
64
|
-
:class="[
|
|
64
|
+
:class="[
|
|
65
|
+
'absolute bottom-0 left-0 right-0 h-0.5 rounded-t',
|
|
66
|
+
escDark ? 'bg-cyan-400' : 'bg-[var(--esc-panel-accent)]',
|
|
67
|
+
]"
|
|
65
68
|
></span>
|
|
66
69
|
</button>
|
|
67
70
|
</div>
|
|
@@ -439,7 +439,7 @@ const colCount = computed(() => {
|
|
|
439
439
|
type="checkbox"
|
|
440
440
|
:checked="allSelected"
|
|
441
441
|
aria-label="Select all tickets"
|
|
442
|
-
class="h-4 w-4 rounded border-gray-300 text-
|
|
442
|
+
class="h-4 w-4 rounded border-gray-300 text-[var(--esc-panel-accent)] focus:ring-[var(--esc-panel-accent)]"
|
|
443
443
|
@change="toggleAll"
|
|
444
444
|
/>
|
|
445
445
|
</th>
|
|
@@ -490,7 +490,7 @@ const colCount = computed(() => {
|
|
|
490
490
|
<input
|
|
491
491
|
type="checkbox"
|
|
492
492
|
:checked="isColumnActive(col.key)"
|
|
493
|
-
class="h-3.5 w-3.5 rounded border-gray-300 text-
|
|
493
|
+
class="h-3.5 w-3.5 rounded border-gray-300 text-[var(--esc-panel-accent)] focus:ring-[var(--esc-panel-accent)]"
|
|
494
494
|
@change="toggleColumn(col.key)"
|
|
495
495
|
/>
|
|
496
496
|
{{ col.label }}
|
|
@@ -504,14 +504,17 @@ const colCount = computed(() => {
|
|
|
504
504
|
<tr
|
|
505
505
|
v-for="(ticket, index) in tickets.data"
|
|
506
506
|
:key="ticket.id"
|
|
507
|
-
:class="[
|
|
507
|
+
:class="[
|
|
508
|
+
'group',
|
|
509
|
+
navigable && focusedIndex === index ? 'bg-[var(--esc-panel-active)]' : 'hover:bg-gray-50',
|
|
510
|
+
]"
|
|
508
511
|
>
|
|
509
512
|
<td v-if="selectable" class="w-10 px-3 py-3">
|
|
510
513
|
<input
|
|
511
514
|
type="checkbox"
|
|
512
515
|
:checked="selectedIds.includes(ticket.id)"
|
|
513
516
|
aria-label="Select ticket"
|
|
514
|
-
class="h-4 w-4 rounded border-gray-300 text-
|
|
517
|
+
class="h-4 w-4 rounded border-gray-300 text-[var(--esc-panel-accent)] focus:ring-[var(--esc-panel-accent)]"
|
|
515
518
|
@change="toggleOne(ticket.id)"
|
|
516
519
|
/>
|
|
517
520
|
</td>
|
|
@@ -527,7 +530,7 @@ const colCount = computed(() => {
|
|
|
527
530
|
></span>
|
|
528
531
|
<Link
|
|
529
532
|
:href="route(`${routePrefix}.show`, ticket.reference)"
|
|
530
|
-
class="text-
|
|
533
|
+
class="text-[var(--esc-panel-accent)] hover:text-[var(--esc-panel-accent-hover)]"
|
|
531
534
|
>
|
|
532
535
|
{{ ticket.reference }}
|
|
533
536
|
</Link>
|
|
@@ -20,7 +20,7 @@ export function useChat(options = {}) {
|
|
|
20
20
|
const connectionState = ref('disconnected'); // disconnected | connecting | connected
|
|
21
21
|
const messageBuffer = ref([]);
|
|
22
22
|
|
|
23
|
-
const { echoAvailable,
|
|
23
|
+
const { echoAvailable, listenScoped } = useRealtime(options);
|
|
24
24
|
|
|
25
25
|
function csrfToken() {
|
|
26
26
|
return typeof document !== 'undefined' ? document.querySelector('meta[name="csrf-token"]')?.content || '' : '';
|
|
@@ -123,26 +123,17 @@ export function useChat(options = {}) {
|
|
|
123
123
|
function subscribeToChat(sessionId, callbacks = {}) {
|
|
124
124
|
if (!echoAvailable.value) return () => {};
|
|
125
125
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
if (callbacks.onTyping) {
|
|
133
|
-
unsubs.push(listen(channelName, '.chat.typing', callbacks.onTyping));
|
|
134
|
-
}
|
|
135
|
-
if (callbacks.onEnded) {
|
|
136
|
-
unsubs.push(listen(channelName, '.chat.ended', callbacks.onEnded));
|
|
137
|
-
}
|
|
138
|
-
if (callbacks.onAssigned) {
|
|
139
|
-
unsubs.push(listen(channelName, '.chat.assigned', callbacks.onAssigned));
|
|
140
|
-
}
|
|
126
|
+
const unsubscribe = listenScoped(`chat.${sessionId}`, {
|
|
127
|
+
'.chat.message': callbacks.onMessage,
|
|
128
|
+
'.chat.typing': callbacks.onTyping,
|
|
129
|
+
'.chat.ended': callbacks.onEnded,
|
|
130
|
+
'.chat.assigned': callbacks.onAssigned,
|
|
131
|
+
});
|
|
141
132
|
|
|
142
133
|
connectionState.value = 'connected';
|
|
143
134
|
|
|
144
135
|
return () => {
|
|
145
|
-
|
|
136
|
+
unsubscribe();
|
|
146
137
|
connectionState.value = 'disconnected';
|
|
147
138
|
};
|
|
148
139
|
}
|
|
@@ -157,19 +148,10 @@ export function useChat(options = {}) {
|
|
|
157
148
|
function subscribeToChatQueue(callbacks = {}) {
|
|
158
149
|
if (!echoAvailable.value) return () => {};
|
|
159
150
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
unsubs.push(listen(channelName, '.chat.new', callbacks.onNewChat));
|
|
165
|
-
}
|
|
166
|
-
if (callbacks.onChatAccepted) {
|
|
167
|
-
unsubs.push(listen(channelName, '.chat.accepted', callbacks.onChatAccepted));
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return () => {
|
|
171
|
-
unsubs.forEach((fn) => fn());
|
|
172
|
-
};
|
|
151
|
+
return listenScoped('chat.queue', {
|
|
152
|
+
'.chat.new': callbacks.onNewChat,
|
|
153
|
+
'.chat.accepted': callbacks.onChatAccepted,
|
|
154
|
+
});
|
|
173
155
|
}
|
|
174
156
|
|
|
175
157
|
return {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { onScopeDispose, reactive, watch } from 'vue';
|
|
2
|
+
|
|
3
|
+
/** Keep each mailbox proof tied to the form and destination that requested it. */
|
|
4
|
+
export function useGuestVerification(email, purpose, requestCode, context = () => '') {
|
|
5
|
+
const state = reactive({ id: '', code: '', message: '', error: '', pending: false });
|
|
6
|
+
let generation = 0;
|
|
7
|
+
function reset() {
|
|
8
|
+
generation++;
|
|
9
|
+
Object.assign(state, { id: '', code: '', message: '', error: '', pending: false });
|
|
10
|
+
}
|
|
11
|
+
watch([email, context], reset, { flush: 'sync' });
|
|
12
|
+
onScopeDispose(reset);
|
|
13
|
+
|
|
14
|
+
async function send() {
|
|
15
|
+
if (state.pending) return;
|
|
16
|
+
reset();
|
|
17
|
+
const current = generation;
|
|
18
|
+
state.pending = true;
|
|
19
|
+
try {
|
|
20
|
+
const result = await requestCode(email(), purpose);
|
|
21
|
+
if (current !== generation) return;
|
|
22
|
+
if (typeof result.verification_id !== 'string' || !result.verification_id) {
|
|
23
|
+
throw new Error('Could not request a verification code.');
|
|
24
|
+
}
|
|
25
|
+
state.id = result.verification_id;
|
|
26
|
+
state.message = 'Check your email. Enter the 8-digit code to continue; it expires in 10 minutes.';
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (current === generation) state.error = error.message || 'Could not request a verification code.';
|
|
29
|
+
} finally {
|
|
30
|
+
if (current === generation) state.pending = false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function ready() {
|
|
35
|
+
if (!state.id) {
|
|
36
|
+
await send();
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
state.error = '';
|
|
40
|
+
if (!/^[0-9]{8}$/.test(state.code)) {
|
|
41
|
+
state.error = 'Enter the 8-digit code from your email.';
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { state, ready, send, reset, fields: () => ({ verification_id: state.id, verification_code: state.code }) };
|
|
48
|
+
}
|