@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.
- package/README.md +103 -1
- package/dist/widget/escalated-widget.js +215 -0
- package/package.json +24 -5
- 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/ReportExportButton.vue +13 -0
- package/src/components/TotpInput.vue +1 -1
- package/src/composables/useChat.js +16 -33
- package/src/composables/useGuestVerification.js +48 -0
- package/src/composables/useKeyboardShortcuts.js +2 -0
- package/src/composables/useMentions.js +16 -11
- package/src/composables/useRealtime.js +108 -147
- package/src/pages/Admin/ApiTokens/Index.vue +3 -4
- package/src/pages/Admin/Settings/TwoFactor.vue +34 -7
- package/src/pages/Admin/Settings.vue +273 -72
- 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/flash.js +4 -0
- package/src/utils/guestRequests.js +27 -0
- package/src/widget/EscalatedWidget.vue +323 -86
- package/src/widget/embed.js +120 -0
- package/src/widget/index.js +93 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escalated-dev/escalated",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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",
|
|
@@ -18,16 +18,26 @@
|
|
|
18
18
|
"escalated"
|
|
19
19
|
],
|
|
20
20
|
"type": "module",
|
|
21
|
+
"devEngines": {
|
|
22
|
+
"runtime": {
|
|
23
|
+
"name": "node",
|
|
24
|
+
"version": "^22.22.2 || ^24.15.0 || >=26.0.0",
|
|
25
|
+
"onFail": "error"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
21
28
|
"exports": {
|
|
22
29
|
"./components/*": "./src/components/*",
|
|
23
30
|
"./pages/*": "./src/pages/*",
|
|
24
31
|
"./locales": "./src/locales/index.js",
|
|
25
32
|
"./locales/*": "./src/locales/*",
|
|
26
33
|
".": "./src/index.js",
|
|
27
|
-
"./pages.json": "./pages.json"
|
|
34
|
+
"./pages.json": "./pages.json",
|
|
35
|
+
"./widget": "./dist/widget/escalated-widget.js",
|
|
36
|
+
"./widget/source": "./src/widget/index.js"
|
|
28
37
|
},
|
|
29
38
|
"files": [
|
|
30
39
|
"src",
|
|
40
|
+
"dist/widget",
|
|
31
41
|
"pages.json"
|
|
32
42
|
],
|
|
33
43
|
"peerDependencies": {
|
|
@@ -39,6 +49,7 @@
|
|
|
39
49
|
},
|
|
40
50
|
"scripts": {
|
|
41
51
|
"test": "vitest run",
|
|
52
|
+
"test:inertia": "node scripts/verify-inertia-flash.mjs",
|
|
42
53
|
"lint": "eslint src/ --max-warnings 0",
|
|
43
54
|
"lint:fix": "eslint src/ --fix",
|
|
44
55
|
"format": "prettier --write src/",
|
|
@@ -49,9 +60,11 @@
|
|
|
49
60
|
"screenshot": "npx playwright test --config=playwright-screenshots.config.js",
|
|
50
61
|
"demo:serve": "node scripts/demo-server.js",
|
|
51
62
|
"demo:record": "node scripts/generate-demo.js",
|
|
52
|
-
"demo": "concurrently -k -s
|
|
63
|
+
"demo": "concurrently -k -s command-1 \"npm run demo:serve\" \"wait-on http://localhost:3000 && npm run demo:record\"",
|
|
53
64
|
"pages:manifest": "node scripts/generate-page-manifest.js",
|
|
54
|
-
"pages:check": "node scripts/generate-page-manifest.js --check"
|
|
65
|
+
"pages:check": "node scripts/generate-page-manifest.js --check",
|
|
66
|
+
"build:widget": "vite build -c vite.widget.config.js",
|
|
67
|
+
"prepack": "vite build -c vite.widget.config.js --logLevel error"
|
|
55
68
|
},
|
|
56
69
|
"lint-staged": {
|
|
57
70
|
"*.{js,vue}": [
|
|
@@ -59,6 +72,11 @@
|
|
|
59
72
|
"prettier --write"
|
|
60
73
|
]
|
|
61
74
|
},
|
|
75
|
+
"overrides": {
|
|
76
|
+
"concurrently": {
|
|
77
|
+
"shell-quote": "^1.12.0"
|
|
78
|
+
}
|
|
79
|
+
},
|
|
62
80
|
"devDependencies": {
|
|
63
81
|
"@eslint/js": "^10.0.1",
|
|
64
82
|
"@inertiajs/vue3": "^3.7.0",
|
|
@@ -74,7 +92,7 @@
|
|
|
74
92
|
"eslint": "^10.9.1",
|
|
75
93
|
"eslint-plugin-storybook": "^10.5.10",
|
|
76
94
|
"eslint-plugin-vue": "^10.10.0",
|
|
77
|
-
"happy-dom": "^
|
|
95
|
+
"happy-dom": "^21.0.1",
|
|
78
96
|
"husky": "^9.1.7",
|
|
79
97
|
"lint-staged": "^17.4.1",
|
|
80
98
|
"playwright": "^1.62.1",
|
|
@@ -82,6 +100,7 @@
|
|
|
82
100
|
"prettier": "^3.9.6",
|
|
83
101
|
"storybook": "^10.5.10",
|
|
84
102
|
"tailwindcss": "^4.3.3",
|
|
103
|
+
"vite": "^8.2.2",
|
|
85
104
|
"vitest": "^5.0.0",
|
|
86
105
|
"vue": "^3.5.42",
|
|
87
106
|
"wait-on": "^9.1.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.1",
|
|
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>
|
|
@@ -9,9 +9,12 @@ const props = defineProps({
|
|
|
9
9
|
|
|
10
10
|
const open = ref(false);
|
|
11
11
|
const loading = ref(false);
|
|
12
|
+
const error = ref('');
|
|
12
13
|
|
|
13
14
|
async function exportAs(format) {
|
|
15
|
+
if (loading.value) return;
|
|
14
16
|
loading.value = true;
|
|
17
|
+
error.value = '';
|
|
15
18
|
try {
|
|
16
19
|
const separator = props.exportUrl.includes('?') ? '&' : '?';
|
|
17
20
|
const url = props.exportUrl ? `${props.exportUrl}${separator}format=${format}&days=${props.periodDays}` : '#';
|
|
@@ -28,6 +31,7 @@ async function exportAs(format) {
|
|
|
28
31
|
URL.revokeObjectURL(a.href);
|
|
29
32
|
} else {
|
|
30
33
|
const response = await fetch(url);
|
|
34
|
+
if (!response.ok) throw new Error(`Export failed (${response.status}). Please try again.`);
|
|
31
35
|
const blob = await response.blob();
|
|
32
36
|
const a = document.createElement('a');
|
|
33
37
|
a.href = URL.createObjectURL(blob);
|
|
@@ -35,6 +39,8 @@ async function exportAs(format) {
|
|
|
35
39
|
a.click();
|
|
36
40
|
URL.revokeObjectURL(a.href);
|
|
37
41
|
}
|
|
42
|
+
} catch {
|
|
43
|
+
error.value = 'Export failed. Please try again.';
|
|
38
44
|
} finally {
|
|
39
45
|
loading.value = false;
|
|
40
46
|
open.value = false;
|
|
@@ -44,7 +50,10 @@ async function exportAs(format) {
|
|
|
44
50
|
|
|
45
51
|
<template>
|
|
46
52
|
<div class="relative">
|
|
53
|
+
<p v-if="error" role="alert" class="mb-2 text-sm text-red-600">{{ error }}</p>
|
|
47
54
|
<button
|
|
55
|
+
type="button"
|
|
56
|
+
:disabled="loading"
|
|
48
57
|
class="flex items-center gap-2 rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] px-3.5 py-1.5 text-sm font-medium text-[var(--esc-panel-text-tertiary)] transition-all hover:text-[var(--esc-panel-text-secondary)]"
|
|
49
58
|
@click="open = !open"
|
|
50
59
|
>
|
|
@@ -66,6 +75,8 @@ async function exportAs(format) {
|
|
|
66
75
|
class="absolute right-0 top-full z-10 mt-1 w-44 rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] py-1 shadow-xl"
|
|
67
76
|
>
|
|
68
77
|
<button
|
|
78
|
+
type="button"
|
|
79
|
+
:disabled="loading"
|
|
69
80
|
class="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-[var(--esc-panel-text-secondary)] hover:bg-[var(--esc-panel-hover)]"
|
|
70
81
|
@click="exportAs('csv')"
|
|
71
82
|
>
|
|
@@ -85,6 +96,8 @@ async function exportAs(format) {
|
|
|
85
96
|
Export as CSV
|
|
86
97
|
</button>
|
|
87
98
|
<button
|
|
99
|
+
type="button"
|
|
100
|
+
:disabled="loading"
|
|
88
101
|
class="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-[var(--esc-panel-text-secondary)] hover:bg-[var(--esc-panel-hover)]"
|
|
89
102
|
@click="exportAs('json')"
|
|
90
103
|
>
|
|
@@ -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 || '' : '';
|
|
@@ -68,11 +68,12 @@ export function useChat(options = {}) {
|
|
|
68
68
|
* @param {string|number} sessionId
|
|
69
69
|
* @returns {Promise<void>}
|
|
70
70
|
*/
|
|
71
|
-
|
|
71
|
+
const lastTypingSent = new Map();
|
|
72
72
|
async function sendTyping(sessionId) {
|
|
73
73
|
const now = Date.now();
|
|
74
|
-
|
|
75
|
-
lastTypingSent
|
|
74
|
+
const key = String(sessionId);
|
|
75
|
+
if (lastTypingSent.has(key) && now - lastTypingSent.get(key) < 3000) return;
|
|
76
|
+
lastTypingSent.set(key, now);
|
|
76
77
|
try {
|
|
77
78
|
await apiRequest('POST', `${widgetPath}/chat/${sessionId}/typing`);
|
|
78
79
|
} catch {
|
|
@@ -123,26 +124,17 @@ export function useChat(options = {}) {
|
|
|
123
124
|
function subscribeToChat(sessionId, callbacks = {}) {
|
|
124
125
|
if (!echoAvailable.value) return () => {};
|
|
125
126
|
|
|
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
|
-
}
|
|
127
|
+
const unsubscribe = listenScoped(`chat.${sessionId}`, {
|
|
128
|
+
'.chat.message': callbacks.onMessage,
|
|
129
|
+
'.chat.typing': callbacks.onTyping,
|
|
130
|
+
'.chat.ended': callbacks.onEnded,
|
|
131
|
+
'.chat.assigned': callbacks.onAssigned,
|
|
132
|
+
});
|
|
141
133
|
|
|
142
134
|
connectionState.value = 'connected';
|
|
143
135
|
|
|
144
136
|
return () => {
|
|
145
|
-
|
|
137
|
+
unsubscribe();
|
|
146
138
|
connectionState.value = 'disconnected';
|
|
147
139
|
};
|
|
148
140
|
}
|
|
@@ -157,19 +149,10 @@ export function useChat(options = {}) {
|
|
|
157
149
|
function subscribeToChatQueue(callbacks = {}) {
|
|
158
150
|
if (!echoAvailable.value) return () => {};
|
|
159
151
|
|
|
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
|
-
};
|
|
152
|
+
return listenScoped('chat.queue', {
|
|
153
|
+
'.chat.new': callbacks.onNewChat,
|
|
154
|
+
'.chat.accepted': callbacks.onChatAccepted,
|
|
155
|
+
});
|
|
173
156
|
}
|
|
174
157
|
|
|
175
158
|
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
|
+
}
|
|
@@ -11,6 +11,8 @@ export function useKeyboardShortcuts(shortcuts, options = {}) {
|
|
|
11
11
|
const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
|
12
12
|
|
|
13
13
|
function handleKeydown(event) {
|
|
14
|
+
if (event.defaultPrevented || event.isComposing || event.keyCode === 229) return;
|
|
15
|
+
|
|
14
16
|
// Check if shortcuts are enabled
|
|
15
17
|
if (options.enabled !== undefined && !unref(options.enabled)) {
|
|
16
18
|
return;
|
|
@@ -20,6 +20,7 @@ export function useMentions(options = {}) {
|
|
|
20
20
|
const loading = ref(false);
|
|
21
21
|
|
|
22
22
|
let timer = null;
|
|
23
|
+
let requestVersion = 0;
|
|
23
24
|
|
|
24
25
|
function clearTimer() {
|
|
25
26
|
if (timer !== null) {
|
|
@@ -55,18 +56,23 @@ export function useMentions(options = {}) {
|
|
|
55
56
|
|
|
56
57
|
function debouncedFetch(searchQuery) {
|
|
57
58
|
clearTimer();
|
|
59
|
+
const version = ++requestVersion;
|
|
60
|
+
// Keep the current list on screen while the next search is pending,
|
|
61
|
+
// so the dropdown stays open and Enter/Tab still pick a mention.
|
|
62
|
+
// requestVersion already discards any older response.
|
|
63
|
+
loading.value = true;
|
|
58
64
|
|
|
59
65
|
if (searchQuery.length < 1) {
|
|
60
|
-
fetchSuggestions('');
|
|
66
|
+
fetchSuggestions('', version);
|
|
61
67
|
return;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
timer = window.setTimeout(() => {
|
|
65
|
-
fetchSuggestions(searchQuery);
|
|
71
|
+
fetchSuggestions(searchQuery, version);
|
|
66
72
|
}, debounce);
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
async function fetchSuggestions(searchQuery) {
|
|
75
|
+
async function fetchSuggestions(searchQuery, version) {
|
|
70
76
|
loading.value = true;
|
|
71
77
|
try {
|
|
72
78
|
const endpoint = options.searchEndpoint || '/support/admin/agents/search';
|
|
@@ -78,15 +84,12 @@ export function useMentions(options = {}) {
|
|
|
78
84
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
79
85
|
},
|
|
80
86
|
});
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
} else {
|
|
84
|
-
suggestions.value = [];
|
|
85
|
-
}
|
|
87
|
+
const results = response.ok ? await response.json() : [];
|
|
88
|
+
if (version === requestVersion) suggestions.value = results;
|
|
86
89
|
} catch {
|
|
87
|
-
suggestions.value = [];
|
|
90
|
+
if (version === requestVersion) suggestions.value = [];
|
|
88
91
|
} finally {
|
|
89
|
-
loading.value = false;
|
|
92
|
+
if (version === requestVersion) loading.value = false;
|
|
90
93
|
}
|
|
91
94
|
}
|
|
92
95
|
|
|
@@ -113,6 +116,8 @@ export function useMentions(options = {}) {
|
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
function close() {
|
|
119
|
+
requestVersion++;
|
|
120
|
+
loading.value = false;
|
|
116
121
|
isActive.value = false;
|
|
117
122
|
suggestions.value = [];
|
|
118
123
|
query.value = '';
|
|
@@ -120,7 +125,7 @@ export function useMentions(options = {}) {
|
|
|
120
125
|
clearTimer();
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
onUnmounted(
|
|
128
|
+
onUnmounted(close);
|
|
124
129
|
|
|
125
130
|
return {
|
|
126
131
|
suggestions,
|