@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
|
@@ -1,179 +1,135 @@
|
|
|
1
|
-
import { ref, onUnmounted, readonly } from 'vue';
|
|
1
|
+
import { ref, onUnmounted, readonly, toValue, watch } from 'vue';
|
|
2
|
+
|
|
3
|
+
// Multiple components can listen to the same channel. A component leaving must
|
|
4
|
+
// not disconnect the ticket updates or presence owned by another component.
|
|
5
|
+
const leases = new WeakMap();
|
|
6
|
+
function retainChannel(echo, name, presence) {
|
|
7
|
+
const key = `${presence ? 'presence' : 'private'}-${name}`;
|
|
8
|
+
let counts = leases.get(echo);
|
|
9
|
+
if (!counts) leases.set(echo, (counts = new Map()));
|
|
10
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
11
|
+
return () => {
|
|
12
|
+
const count = counts.get(key) - 1;
|
|
13
|
+
if (count > 0) counts.set(key, count);
|
|
14
|
+
else {
|
|
15
|
+
counts.delete(key);
|
|
16
|
+
if (echo.leaveChannel) echo.leaveChannel(key);
|
|
17
|
+
else echo.leave(presence ? `presence-${name}` : name);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
2
21
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* If `window.Echo` is available (provided by the host app), this composable
|
|
7
|
-
* subscribes to private channels and listens for broadcast events. When Echo
|
|
8
|
-
* is not present, it silently falls back - the caller can check `echoAvailable`
|
|
9
|
-
* and implement polling as needed.
|
|
10
|
-
*
|
|
11
|
-
* Usage:
|
|
12
|
-
* const { echoAvailable, subscribeToTicket, subscribeToTickets, subscribeToAgent } = useRealtime();
|
|
13
|
-
*
|
|
14
|
-
* All subscriptions are automatically cleaned up on component unmount.
|
|
15
|
-
*/
|
|
16
|
-
export function useRealtime() {
|
|
22
|
+
/** Optional channelPrefix accepts a value, ref or getter. Older backends use escalated. */
|
|
23
|
+
export function useRealtime(options = {}) {
|
|
17
24
|
const echoAvailable = ref(typeof window !== 'undefined' && !!window.Echo);
|
|
18
|
-
const subscriptions =
|
|
25
|
+
const subscriptions = new Set();
|
|
26
|
+
|
|
27
|
+
function track(cleanup) {
|
|
28
|
+
let active = true;
|
|
29
|
+
const stop = () => {
|
|
30
|
+
if (!active) return;
|
|
31
|
+
active = false;
|
|
32
|
+
subscriptions.delete(stop);
|
|
33
|
+
cleanup();
|
|
34
|
+
};
|
|
35
|
+
subscriptions.add(stop);
|
|
36
|
+
return stop;
|
|
37
|
+
}
|
|
19
38
|
|
|
20
|
-
|
|
21
|
-
* Subscribe to a private channel and listen for an event.
|
|
22
|
-
* Returns an unsubscribe function.
|
|
23
|
-
*/
|
|
39
|
+
// Raw channel names remain literal for existing integrations.
|
|
24
40
|
function listen(channelName, eventName, callback) {
|
|
25
41
|
if (!echoAvailable.value) return () => {};
|
|
26
|
-
|
|
27
|
-
const channel =
|
|
42
|
+
const echo = window.Echo;
|
|
43
|
+
const channel = echo.private(channelName);
|
|
44
|
+
const release = retainChannel(echo, channelName, false);
|
|
28
45
|
channel.listen(eventName, callback);
|
|
29
|
-
|
|
30
|
-
const unsub = () => {
|
|
46
|
+
return track(() => {
|
|
31
47
|
channel.stopListening(eventName, callback);
|
|
32
|
-
|
|
33
|
-
};
|
|
34
|
-
subscriptions.push(unsub);
|
|
35
|
-
return unsub;
|
|
48
|
+
release();
|
|
49
|
+
});
|
|
36
50
|
}
|
|
37
51
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const unsub = () => {
|
|
79
|
-
listeners.forEach(([event, cb]) => channel.stopListening(event, cb));
|
|
80
|
-
window.Echo.leave(channelName);
|
|
52
|
+
function scopedSubscription(suffix, handlers, presence = false) {
|
|
53
|
+
let currentChannel = null;
|
|
54
|
+
const stopWatch = watch(
|
|
55
|
+
() => {
|
|
56
|
+
const tail = toValue(suffix);
|
|
57
|
+
return tail == null ? null : `${toValue(options.channelPrefix) ?? 'escalated'}.${tail}`;
|
|
58
|
+
},
|
|
59
|
+
(name, previous, onCleanup) => {
|
|
60
|
+
if (!name || !echoAvailable.value) return;
|
|
61
|
+
const echo = window.Echo;
|
|
62
|
+
const channel = presence ? echo.join(name) : echo.private(name);
|
|
63
|
+
currentChannel = channel;
|
|
64
|
+
const release = retainChannel(echo, name, presence);
|
|
65
|
+
let active = true;
|
|
66
|
+
const listeners = Object.entries(handlers)
|
|
67
|
+
.filter(([, callback]) => typeof callback === 'function')
|
|
68
|
+
.map(([event, callback]) => {
|
|
69
|
+
// Ignore an old socket callback already queued during account navigation.
|
|
70
|
+
const guarded = (...args) => {
|
|
71
|
+
if (active) callback(...args);
|
|
72
|
+
};
|
|
73
|
+
if (presence) channel[event](guarded);
|
|
74
|
+
else channel.listen(event, guarded);
|
|
75
|
+
return [event, guarded];
|
|
76
|
+
});
|
|
77
|
+
onCleanup(() => {
|
|
78
|
+
active = false;
|
|
79
|
+
currentChannel = null;
|
|
80
|
+
if (!presence) listeners.forEach(([event, callback]) => channel.stopListening(event, callback));
|
|
81
|
+
release();
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
{ immediate: true, flush: 'sync' },
|
|
85
|
+
);
|
|
86
|
+
return {
|
|
87
|
+
get channel() {
|
|
88
|
+
return currentChannel;
|
|
89
|
+
},
|
|
90
|
+
unsubscribe: track(stopWatch),
|
|
81
91
|
};
|
|
82
|
-
subscriptions.push(unsub);
|
|
83
|
-
return unsub;
|
|
84
92
|
}
|
|
85
93
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
* @param {Object} handlers - Event handler callbacks
|
|
90
|
-
* @param {Function} [handlers.onTicketCreated] - Called when a new ticket is created
|
|
91
|
-
* @returns {Function} Unsubscribe function
|
|
92
|
-
*/
|
|
93
|
-
function subscribeToTickets(handlers = {}) {
|
|
94
|
-
if (!echoAvailable.value) return () => {};
|
|
94
|
+
function listenScoped(suffix, handlers) {
|
|
95
|
+
return scopedSubscription(suffix, handlers).unsubscribe;
|
|
96
|
+
}
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
function identitySuffix(kind, id) {
|
|
99
|
+
return () => (toValue(id) == null ? null : `${kind}.${toValue(id)}`);
|
|
100
|
+
}
|
|
99
101
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
function subscribeToTicket(ticketId, handlers = {}) {
|
|
103
|
+
return listenScoped(identitySuffix('tickets', ticketId), {
|
|
104
|
+
'.reply.created': handlers.onReplyCreated,
|
|
105
|
+
'.ticket.updated': handlers.onTicketUpdated,
|
|
106
|
+
'.ticket.status_changed': handlers.onStatusChanged,
|
|
107
|
+
'.ticket.assigned': handlers.onTicketAssigned,
|
|
108
|
+
'.ticket.escalated': handlers.onTicketEscalated,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
104
111
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
window.Echo.leave(channelName);
|
|
108
|
-
};
|
|
109
|
-
subscriptions.push(unsub);
|
|
110
|
-
return unsub;
|
|
112
|
+
function subscribeToTickets(handlers = {}) {
|
|
113
|
+
return listenScoped('tickets', { '.ticket.created': handlers.onTicketCreated });
|
|
111
114
|
}
|
|
112
115
|
|
|
113
|
-
/**
|
|
114
|
-
* Subscribe to an agent's personal channel for assignment notifications.
|
|
115
|
-
*
|
|
116
|
-
* @param {number|string} agentId - The agent's user ID
|
|
117
|
-
* @param {Object} handlers - Event handler callbacks
|
|
118
|
-
* @param {Function} [handlers.onTicketAssigned] - Called when a ticket is assigned to this agent
|
|
119
|
-
* @returns {Function} Unsubscribe function
|
|
120
|
-
*/
|
|
121
116
|
function subscribeToAgent(agentId, handlers = {}) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const channelName = `escalated.agents.${agentId}`;
|
|
125
|
-
const channel = window.Echo.private(channelName);
|
|
126
|
-
const listeners = [];
|
|
127
|
-
|
|
128
|
-
if (handlers.onTicketAssigned) {
|
|
129
|
-
channel.listen('.ticket.assigned', handlers.onTicketAssigned);
|
|
130
|
-
listeners.push(['.ticket.assigned', handlers.onTicketAssigned]);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const unsub = () => {
|
|
134
|
-
listeners.forEach(([event, cb]) => channel.stopListening(event, cb));
|
|
135
|
-
window.Echo.leave(channelName);
|
|
136
|
-
};
|
|
137
|
-
subscriptions.push(unsub);
|
|
138
|
-
return unsub;
|
|
117
|
+
return listenScoped(identitySuffix('agents', agentId), { '.ticket.assigned': handlers.onTicketAssigned });
|
|
139
118
|
}
|
|
140
119
|
|
|
141
|
-
/**
|
|
142
|
-
* Get a presence channel for a ticket (if Echo supports it).
|
|
143
|
-
* Returns the channel instance or null.
|
|
144
|
-
*/
|
|
145
120
|
function joinTicketPresence(ticketId, callbacks = {}) {
|
|
146
121
|
if (!echoAvailable.value) return null;
|
|
147
|
-
|
|
148
|
-
const channelName = `escalated.tickets.${ticketId}`;
|
|
149
|
-
const channel = window.Echo.join(channelName);
|
|
150
|
-
|
|
151
|
-
if (callbacks.here) channel.here(callbacks.here);
|
|
152
|
-
if (callbacks.joining) channel.joining(callbacks.joining);
|
|
153
|
-
if (callbacks.leaving) channel.leaving(callbacks.leaving);
|
|
154
|
-
|
|
155
|
-
const unsub = () => {
|
|
156
|
-
window.Echo.leave(`presence-${channelName}`);
|
|
157
|
-
};
|
|
158
|
-
subscriptions.push(unsub);
|
|
159
|
-
return { channel, unsubscribe: unsub };
|
|
122
|
+
return scopedSubscription(identitySuffix('tickets', ticketId), callbacks, true);
|
|
160
123
|
}
|
|
161
124
|
|
|
162
|
-
// Cleanup all subscriptions on component unmount
|
|
163
125
|
onUnmounted(() => {
|
|
164
|
-
subscriptions.forEach((
|
|
165
|
-
try {
|
|
166
|
-
unsub();
|
|
167
|
-
} catch {
|
|
168
|
-
/* ignore */
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
subscriptions.length = 0;
|
|
126
|
+
[...subscriptions].forEach((unsubscribe) => unsubscribe());
|
|
172
127
|
});
|
|
173
128
|
|
|
174
129
|
return {
|
|
175
130
|
echoAvailable: readonly(echoAvailable),
|
|
176
131
|
listen,
|
|
132
|
+
listenScoped,
|
|
177
133
|
subscribeToTicket,
|
|
178
134
|
subscribeToTickets,
|
|
179
135
|
subscribeToAgent,
|
|
@@ -88,7 +88,7 @@ function formatCount(value) {
|
|
|
88
88
|
table is not affected — it stays wherever your application keeps it.
|
|
89
89
|
</p>
|
|
90
90
|
|
|
91
|
-
<dl v-if="active" class="grid grid-cols-
|
|
91
|
+
<dl v-if="active" class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-6 gap-y-3 text-sm">
|
|
92
92
|
<dt class="text-[var(--esc-panel-text-muted)]">Connection</dt>
|
|
93
93
|
<dd class="font-medium text-[var(--esc-panel-text)]">
|
|
94
94
|
{{ active.name }}
|
|
@@ -160,7 +160,7 @@ function formatCount(value) {
|
|
|
160
160
|
{{ statusLabel(connection) }}
|
|
161
161
|
</span>
|
|
162
162
|
</span>
|
|
163
|
-
<span class="mt-0.5 block text-xs text-[var(--esc-panel-text-muted)]">
|
|
163
|
+
<span class="mt-0.5 block break-all text-xs text-[var(--esc-panel-text-muted)]">
|
|
164
164
|
{{ connection.driver ?? 'unknown driver' }} ·
|
|
165
165
|
{{ connection.database ?? 'no database configured' }}
|
|
166
166
|
<template v-if="connection.ticket_count !== null">
|
|
@@ -217,7 +217,7 @@ function formatCount(value) {
|
|
|
217
217
|
<button
|
|
218
218
|
type="submit"
|
|
219
219
|
:disabled="form.processing || !isChanging"
|
|
220
|
-
class="rounded-lg bg-[var(--esc-accent)] px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
|
|
220
|
+
class="rounded-lg bg-[var(--esc-panel-accent)] px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
|
|
221
221
|
>
|
|
222
222
|
{{ form.processing ? 'Saving…' : 'Use this connection' }}
|
|
223
223
|
</button>
|