@escalated-dev/escalated 0.12.0 → 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.
@@ -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
  }
@@ -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 {
@@ -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,
@@ -42,9 +42,14 @@ export function useRealtime(options = {}) {
42
42
  const echo = window.Echo;
43
43
  const channel = echo.private(channelName);
44
44
  const release = retainChannel(echo, channelName, false);
45
- channel.listen(eventName, callback);
45
+ let active = true;
46
+ const guarded = (...args) => {
47
+ if (active) callback(...args);
48
+ };
49
+ channel.listen(eventName, guarded);
46
50
  return track(() => {
47
- channel.stopListening(eventName, callback);
51
+ active = false;
52
+ channel.stopListening(eventName, guarded);
48
53
  release();
49
54
  });
50
55
  }
@@ -2,6 +2,7 @@
2
2
  import { ref, computed, inject } from 'vue';
3
3
  import { useForm, usePage } from '@inertiajs/vue3';
4
4
  import EscalatedLayout from '../../../components/EscalatedLayout.vue';
5
+ import { readFlash } from '../../../utils/flash';
5
6
 
6
7
  defineProps({
7
8
  tokens: Array,
@@ -33,10 +34,8 @@ function createToken() {
33
34
  form.post(`${prefix.value}/admin/api-tokens`, {
34
35
  preserveScroll: true,
35
36
  onSuccess: (page) => {
36
- const flash = page.props.flash || {};
37
- if (flash.plain_text_token) {
38
- copiedToken.value = flash.plain_text_token;
39
- }
37
+ const token = readFlash(page).plain_text_token;
38
+ copiedToken.value = typeof token === 'string' ? token : null;
40
39
  form.reset();
41
40
  showCreate.value = false;
42
41
  },
@@ -2,7 +2,8 @@
2
2
  import EscalatedLayout from '../../../components/EscalatedLayout.vue';
3
3
  import TwoFactorSetup from '../../../components/TwoFactorSetup.vue';
4
4
  import { router, usePage } from '@inertiajs/vue3';
5
- import { ref, computed } from 'vue';
5
+ import { ref, computed, watch } from 'vue';
6
+ import { readFlash } from '../../../utils/flash';
6
7
 
7
8
  defineProps({
8
9
  enabled: { type: Boolean, default: false },
@@ -11,17 +12,28 @@ defineProps({
11
12
 
12
13
  const page = usePage();
13
14
  const showSetup = ref(false);
14
- const setupData = computed(() => page.props.flash?.two_factor_setup);
15
- const confirmedData = computed(() => page.props.flash?.two_factor_confirmed);
15
+ const startingSetup = ref(false);
16
+ const flash = computed(() => readFlash(page));
17
+ const setupData = computed(() => flash.value.two_factor_setup);
18
+ const confirmedData = computed(() => flash.value.two_factor_confirmed);
19
+ const copyError = ref('');
20
+
21
+ watch(setupData, (data) => (showSetup.value = Boolean(data)), { immediate: true });
22
+ watch(confirmedData, () => (copyError.value = ''));
16
23
 
17
24
  function startSetup() {
25
+ if (startingSetup.value) return;
26
+ startingSetup.value = true;
18
27
  router.post(
19
28
  route('escalated.admin.two-factor.setup'),
20
29
  {},
21
30
  {
22
31
  preserveScroll: true,
23
32
  onSuccess: () => {
24
- showSetup.value = true;
33
+ showSetup.value = Boolean(setupData.value);
34
+ },
35
+ onFinish: () => {
36
+ startingSetup.value = false;
25
37
  },
26
38
  },
27
39
  );
@@ -48,6 +60,18 @@ function disable() {
48
60
  );
49
61
  }
50
62
  }
63
+
64
+ async function copyRecoveryCodes() {
65
+ const codes = confirmedData.value?.recovery_codes;
66
+ if (!codes?.length) return;
67
+ copyError.value = '';
68
+ try {
69
+ if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
70
+ await navigator.clipboard.writeText(codes.join('\n'));
71
+ } catch {
72
+ copyError.value = 'Could not copy recovery codes. Select and copy the codes below.';
73
+ }
74
+ }
51
75
  </script>
52
76
 
53
77
  <template>
@@ -75,7 +99,7 @@ function disable() {
75
99
  Disable 2FA
76
100
  </button>
77
101
  </div>
78
- <div v-else-if="!showSetup && !setupData">
102
+ <div v-else-if="!showSetup">
79
103
  <span
80
104
  class="mb-3 inline-flex items-center rounded-full bg-neutral-700/50 px-3 py-1 text-xs font-medium text-[var(--esc-panel-text-tertiary)]"
81
105
  >
@@ -84,6 +108,8 @@ function disable() {
84
108
  <div class="mt-3">
85
109
  <button
86
110
  type="button"
111
+ :disabled="startingSetup"
112
+ :aria-busy="startingSetup"
87
113
  class="rounded-lg bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] px-5 py-2 text-sm font-medium text-white shadow-lg shadow-[var(--esc-panel-bg)]/20 transition-all hover:from-[var(--esc-panel-accent-hover)] hover:to-[var(--esc-panel-accent-secondary-hover)]"
88
114
  @click="startSetup"
89
115
  >
@@ -96,7 +122,7 @@ function disable() {
96
122
 
97
123
  <!-- Setup Wizard -->
98
124
  <div
99
- v-if="setupData && !enabled"
125
+ v-if="showSetup && setupData && !enabled"
100
126
  class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6"
101
127
  >
102
128
  <TwoFactorSetup :qr-uri="setupData.qr_uri" @confirm="confirmCode" @cancel="showSetup = false" />
@@ -116,11 +142,12 @@ function disable() {
116
142
  <button
117
143
  type="button"
118
144
  class="rounded-lg border border-[var(--esc-panel-border)] px-4 py-2 text-sm text-[var(--esc-panel-text-secondary)] hover:bg-[var(--esc-panel-surface-alt)]"
119
- @click="navigator.clipboard?.writeText(confirmedData.recovery_codes.join('\n'))"
145
+ @click="copyRecoveryCodes"
120
146
  >
121
147
  Copy Codes
122
148
  </button>
123
149
  </div>
150
+ <p v-if="copyError" role="alert" class="mt-3 text-sm text-rose-400">{{ copyError }}</p>
124
151
  <div class="mt-4 grid gap-2 md:grid-cols-2">
125
152
  <code
126
153
  v-for="code in confirmedData.recovery_codes"
@@ -15,6 +15,8 @@ const supported = computed(() => (props.supported_settings === null ? null : new
15
15
  const supports = (key) => supported.value === null || supported.value.has(key);
16
16
  const supportsAny = (keys) => keys.some(supports);
17
17
  const page = usePage();
18
+ // Absolute, so the snippet also works when pasted into another site.
19
+ const siteOrigin = typeof window === 'undefined' ? '' : window.location.origin;
18
20
 
19
21
  const settingsData = () => ({
20
22
  guest_tickets_enabled: props.settings.guest_tickets_enabled,
@@ -42,6 +44,7 @@ const settingsData = () => ({
42
44
  widget_position: props.settings.widget_position ?? 'bottom-right',
43
45
  widget_greeting: props.settings.widget_greeting ?? 'Hi there! How can we help?',
44
46
  widget_departments: props.settings.widget_departments ?? '',
47
+ widget_allowed_origins: props.settings.widget_allowed_origins ?? '',
45
48
  knowledge_base_enabled: props.settings.knowledge_base_enabled ?? true,
46
49
  knowledge_base_public: props.settings.knowledge_base_public ?? true,
47
50
  knowledge_base_feedback_enabled: props.settings.knowledge_base_feedback_enabled ?? true,
@@ -685,6 +688,7 @@ function submit() {
685
688
  'widget_greeting',
686
689
  'widget_position',
687
690
  'widget_departments',
691
+ 'widget_allowed_origins',
688
692
  ])
689
693
  "
690
694
  class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6"
@@ -719,7 +723,13 @@ function submit() {
719
723
  <template
720
724
  v-if="
721
725
  form.widget_enabled &&
722
- supportsAny(['widget_color', 'widget_greeting', 'widget_position', 'widget_departments'])
726
+ supportsAny([
727
+ 'widget_color',
728
+ 'widget_greeting',
729
+ 'widget_position',
730
+ 'widget_departments',
731
+ 'widget_allowed_origins',
732
+ ])
723
733
  "
724
734
  >
725
735
  <div v-if="supports('widget_color')">
@@ -799,6 +809,26 @@ function submit() {
799
809
  </label>
800
810
  </div>
801
811
  </div>
812
+ <div v-if="supports('widget_allowed_origins')">
813
+ <label
814
+ for="widget-allowed-origins"
815
+ class="block text-sm font-medium text-[var(--esc-panel-text-secondary)]"
816
+ >Sites that can embed the widget</label
817
+ >
818
+ <p class="mt-0.5 text-xs text-[var(--esc-panel-text-muted)]">
819
+ One site per line, such as https://shop.example.com. Use https://*.example.com for every
820
+ subdomain. Leave empty to allow this site only.
821
+ </p>
822
+ <textarea
823
+ id="widget-allowed-origins"
824
+ v-model="form.widget_allowed_origins"
825
+ rows="3"
826
+ maxlength="5000"
827
+ spellcheck="false"
828
+ placeholder="https://shop.example.com"
829
+ class="mt-2 w-full max-w-lg rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)] px-3 py-2 font-mono text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-border-input)] focus:outline-none focus:ring-1 focus:ring-[var(--esc-panel-border-input)]"
830
+ ></textarea>
831
+ </div>
802
832
  <div
803
833
  class="rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface-alt)] p-4"
804
834
  >
@@ -810,8 +840,9 @@ function submit() {
810
840
  </p>
811
841
  <code
812
842
  class="mt-2 block whitespace-pre-wrap break-all rounded-lg bg-[var(--esc-panel-bg)] p-3 text-xs text-[var(--esc-panel-text-tertiary)] select-all"
813
- >&lt;script src="/{{ page.props.escalated?.prefix || 'support' }}/widget/embed.js"
814
- data-color="{{ form.widget_color }}" data-position="{{
843
+ >&lt;script src="{{ siteOrigin }}/{{
844
+ page.props.escalated?.prefix || 'support'
845
+ }}/widget/embed.js" data-color="{{ form.widget_color }}" data-position="{{
815
846
  form.widget_position
816
847
  }}"&gt;&lt;/script&gt;</code
817
848
  >
@@ -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
+ }