@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escalated-dev/escalated",
3
- "version": "0.11.10",
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 second \"npm run demo:serve\" \"wait-on http://localhost:3000 && npm run demo:record\"",
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": "^20.14.0",
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.11.10",
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
- "priorities"
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({ widgetPath: `/${routePrefix}/widget` });
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({ widgetPath: `/${routePrefix}/widget` });
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
- viewers.value = data.viewers || data || [];
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
- joinedChannelName = `escalated.tickets.${props.ticketId}`;
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.leave(joinedChannelName);
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, { immediate: true });
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
  >
@@ -25,7 +25,7 @@ function handleInput(index, event) {
25
25
  nextTick(() => inputs.value[index + 1]?.focus());
26
26
  }
27
27
 
28
- if (code.length === 6 && !code.includes('')) {
28
+ if (code.length === 6) {
29
29
  emit('complete', code);
30
30
  }
31
31
  }
@@ -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, listen } = useRealtime();
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
- let lastTypingSent = 0;
71
+ const lastTypingSent = new Map();
72
72
  async function sendTyping(sessionId) {
73
73
  const now = Date.now();
74
- if (now - lastTypingSent < 3000) return;
75
- lastTypingSent = now;
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 channelName = `escalated.chat.${sessionId}`;
127
- const unsubs = [];
128
-
129
- if (callbacks.onMessage) {
130
- unsubs.push(listen(channelName, '.chat.message', callbacks.onMessage));
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
- unsubs.forEach((fn) => fn());
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
- const channelName = 'escalated.chat.queue';
161
- const unsubs = [];
162
-
163
- if (callbacks.onNewChat) {
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
- if (response.ok) {
82
- suggestions.value = await response.json();
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(clearTimer);
128
+ onUnmounted(close);
124
129
 
125
130
  return {
126
131
  suggestions,