@escalated-dev/escalated 0.8.0 → 0.11.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/LICENSE +21 -21
- package/README.md +388 -361
- package/package.json +85 -84
- package/src/components/ActiveChatsPanel.vue +246 -246
- package/src/components/AdminDashboard.stories.js +1423 -1423
- package/src/components/AttachmentList.vue +65 -65
- package/src/components/ChatActionBar.vue +226 -226
- package/src/components/ChatBubble.vue +162 -162
- package/src/components/ChatComposer.vue +308 -308
- package/src/components/ChatQueue.vue +222 -222
- package/src/components/ChatThread.vue +197 -197
- package/src/components/CustomTicketActions.stories.js +71 -0
- package/src/components/CustomTicketActions.vue +101 -0
- package/src/components/EscalatedLayout.vue +628 -604
- package/src/components/FollowButton.vue +20 -1
- package/src/components/MentionDropdown.vue +154 -154
- package/src/components/MetricComparison.vue +89 -89
- package/src/components/PercentileChart.vue +67 -67
- package/src/components/PeriodSelector.vue +98 -98
- package/src/components/PresenceIndicator.vue +228 -213
- package/src/components/ReplyComposer.vue +336 -336
- package/src/components/ReplyThread.vue +153 -153
- package/src/components/ReportChart.vue +395 -395
- package/src/components/ReportExportButton.vue +108 -108
- package/src/components/SaveViewDialog.vue +172 -172
- package/src/components/SavedViewSidebar.vue +208 -208
- package/src/components/SkillTagManager.vue +3 -0
- package/src/components/SnoozeButton.vue +247 -247
- package/src/components/TicketFilters.vue +270 -270
- package/src/components/TicketList.vue +655 -655
- package/src/components/TicketSidebar.vue +4 -0
- package/src/components/TicketSplitDialog.vue +98 -98
- package/src/components/TicketSubjects.stories.js +63 -0
- package/src/components/TicketSubjects.vue +107 -0
- package/src/components/TwoFactorSetup.vue +1 -37
- package/src/components/VariableTokenInput.vue +111 -111
- package/src/components/WorkflowActionConfig.vue +287 -287
- package/src/components/WorkflowActionList.vue +350 -350
- package/src/components/WorkflowConditionBuilder.vue +265 -265
- package/src/components/WorkflowTriggerSelector.vue +125 -125
- package/src/components/admin/newsletters/AnalyticsTiles.stories.js +14 -0
- package/src/components/admin/newsletters/AnalyticsTiles.vue +55 -0
- package/src/components/admin/newsletters/DeliveriesTable.stories.js +28 -0
- package/src/components/admin/newsletters/DeliveriesTable.vue +104 -0
- package/src/components/admin/newsletters/DynamicFilterBuilder.stories.js +11 -0
- package/src/components/admin/newsletters/DynamicFilterBuilder.vue +53 -0
- package/src/components/admin/newsletters/ListMemberTable.stories.js +12 -0
- package/src/components/admin/newsletters/ListMemberTable.vue +53 -0
- package/src/components/admin/newsletters/MarkdownEditor.stories.js +6 -0
- package/src/components/admin/newsletters/MarkdownEditor.vue +53 -0
- package/src/components/admin/newsletters/MergeFieldDropdown.stories.js +6 -0
- package/src/components/admin/newsletters/MergeFieldDropdown.vue +84 -0
- package/src/components/admin/newsletters/PreviewIframe.stories.js +7 -0
- package/src/components/admin/newsletters/PreviewIframe.vue +40 -0
- package/src/components/admin/newsletters/SummaryTiles.stories.js +8 -0
- package/src/components/admin/newsletters/SummaryTiles.vue +63 -0
- package/src/composables/useChat.js +187 -187
- package/src/composables/useI18n.js +102 -102
- package/src/composables/useMentions.js +134 -134
- package/src/composables/usePermissions.js +31 -0
- package/src/composables/useRealtime.js +182 -182
- package/src/index.js +85 -84
- package/src/locales/README.md +13 -13
- package/src/locales/ar.json +727 -727
- package/src/locales/de.json +690 -690
- package/src/locales/en.json +889 -729
- package/src/locales/es.json +691 -691
- package/src/locales/fr.json +691 -691
- package/src/locales/index.js +16 -16
- package/src/locales/it.json +727 -727
- package/src/locales/ja.json +727 -727
- package/src/locales/ko.json +727 -727
- package/src/locales/nl.json +727 -727
- package/src/locales/pl.json +727 -727
- package/src/locales/pt-BR.json +727 -727
- package/src/locales/ru.json +727 -727
- package/src/locales/tr.json +727 -727
- package/src/locales/zh-CN.json +727 -727
- package/src/pages/Admin/Automations/Form.vue +280 -0
- package/src/pages/Admin/Automations/Index.vue +230 -0
- package/src/pages/Admin/Newsletters/Compose.vue +192 -0
- package/src/pages/Admin/Newsletters/Edit.vue +24 -0
- package/src/pages/Admin/Newsletters/Index.vue +141 -0
- package/src/pages/Admin/Newsletters/Lists/Create.vue +64 -0
- package/src/pages/Admin/Newsletters/Lists/Index.vue +59 -0
- package/src/pages/Admin/Newsletters/Lists/Show.vue +53 -0
- package/src/pages/Admin/Newsletters/Settings.vue +85 -0
- package/src/pages/Admin/Newsletters/Show.vue +140 -0
- package/src/pages/Admin/Newsletters/Templates/Create.vue +11 -0
- package/src/pages/Admin/Newsletters/Templates/Index.vue +54 -0
- package/src/pages/Admin/Newsletters/Templates/Show.vue +65 -0
- package/src/pages/Admin/Newsletters/usePreview.js +44 -0
- package/src/pages/Admin/Plugins/Index.vue +10 -2
- package/src/pages/Admin/Reports/AgentDetail.vue +132 -132
- package/src/pages/Admin/Reports/AgentRanking.vue +246 -246
- package/src/pages/Admin/Reports/Cohorts.vue +180 -180
- package/src/pages/Admin/Reports/Comparison.vue +141 -141
- package/src/pages/Admin/Reports/Dashboard.vue +226 -226
- package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -164
- package/src/pages/Admin/Reports/ResponseTimes.vue +164 -164
- package/src/pages/Admin/Reports/SlaTrends.vue +183 -183
- package/src/pages/Admin/Settings/DatabaseConnection.vue +228 -0
- package/src/pages/Admin/Settings/PublicTickets.vue +163 -0
- package/src/pages/Admin/Settings/SsoSettings.vue +157 -4
- package/src/pages/Admin/Settings/TwoFactor.vue +32 -6
- package/src/pages/Admin/Settings.vue +1008 -1008
- package/src/pages/Admin/Skills/Form.vue +30 -10
- package/src/pages/Admin/Tickets/Index.vue +126 -126
- package/src/pages/Admin/Tickets/Show.vue +254 -249
- package/src/pages/Admin/Users/Index.vue +145 -145
- package/src/pages/Admin/Webhooks/DeliveryLog.vue +1 -2
- package/src/pages/Admin/Workflows/Builder.vue +207 -207
- package/src/pages/Admin/Workflows/Form.vue +24 -0
- package/src/pages/Admin/Workflows/Index.vue +235 -235
- package/src/pages/Admin/Workflows/Logs.vue +286 -286
- package/src/pages/Agent/TicketIndex.vue +112 -112
- package/src/pages/Agent/TicketShow.vue +547 -494
- package/src/pages/Customer/Index.vue +56 -56
- package/src/pages/Customer/KnowledgeBase/Article.vue +129 -129
- package/src/pages/Plugin/Page.vue +61 -0
- package/src/plugin.js +175 -175
- package/src/utils/mentionHighlight.js +24 -24
- package/src/widget/EscalatedWidget.vue +804 -804
- package/src/widget/index.js +254 -254
|
@@ -1,102 +1,102 @@
|
|
|
1
|
-
import { ref, computed } from 'vue';
|
|
2
|
-
import overrideLocales from '../locales/index.js';
|
|
3
|
-
|
|
4
|
-
// Central translations published from escalated-dev/escalated-locale.
|
|
5
|
-
// This is the BASE layer. Local src/locales/*.json files in this repo are
|
|
6
|
-
// loaded as OVERRIDES and win over the central source for any matching key.
|
|
7
|
-
//
|
|
8
|
-
// Until @escalated-dev/locale v0.1.0 ships its consolidated strings, this
|
|
9
|
-
// import resolves to a stub object with the same shape. Local overrides
|
|
10
|
-
// (src/locales/) preserve the current behavior so components keep rendering
|
|
11
|
-
// the strings they always rendered. After Codex finishes consolidating
|
|
12
|
-
// translations into the central package, the local override files can be
|
|
13
|
-
// trimmed (or deleted entirely) and the central source becomes authoritative.
|
|
14
|
-
import centralLocales from '@escalated-dev/locale';
|
|
15
|
-
|
|
16
|
-
const currentLocale = ref('en');
|
|
17
|
-
const customMessages = ref({});
|
|
18
|
-
|
|
19
|
-
function resolve(obj, path) {
|
|
20
|
-
return path.split('.').reduce((acc, key) => acc?.[key], obj);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function applyReplacements(str, replacements) {
|
|
24
|
-
if (!replacements) return str;
|
|
25
|
-
return Object.entries(replacements).reduce(
|
|
26
|
-
(result, [key, value]) => result.replace(new RegExp(`:${key}`, 'g'), value),
|
|
27
|
-
str,
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function setLocale(locale) {
|
|
32
|
-
currentLocale.value = locale;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function getLocale() {
|
|
36
|
-
return currentLocale.value;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function mergeMessages(locale, messages) {
|
|
40
|
-
customMessages.value = {
|
|
41
|
-
...customMessages.value,
|
|
42
|
-
[locale]: deepMerge(customMessages.value[locale] || {}, messages),
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function deepMerge(target, source) {
|
|
47
|
-
const result = { ...target };
|
|
48
|
-
for (const key of Object.keys(source)) {
|
|
49
|
-
if (
|
|
50
|
-
source[key] &&
|
|
51
|
-
typeof source[key] === 'object' &&
|
|
52
|
-
!Array.isArray(source[key]) &&
|
|
53
|
-
target[key] &&
|
|
54
|
-
typeof target[key] === 'object'
|
|
55
|
-
) {
|
|
56
|
-
result[key] = deepMerge(target[key], source[key]);
|
|
57
|
-
} else {
|
|
58
|
-
result[key] = source[key];
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return result;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function useI18n() {
|
|
65
|
-
const locale = computed(() => currentLocale.value);
|
|
66
|
-
|
|
67
|
-
function t(key, replacements) {
|
|
68
|
-
const lang = currentLocale.value;
|
|
69
|
-
|
|
70
|
-
// 1. Host-app supplied messages for the current locale (highest priority).
|
|
71
|
-
const custom = resolve(customMessages.value[lang], key);
|
|
72
|
-
if (typeof custom === 'string') return applyReplacements(custom, replacements);
|
|
73
|
-
|
|
74
|
-
// 2. Local plugin overrides for the current locale (src/locales/*.json).
|
|
75
|
-
// These exist to let this repo win over the central source for any
|
|
76
|
-
// string the plugin needs to phrase differently.
|
|
77
|
-
const override = resolve(overrideLocales[lang], key);
|
|
78
|
-
if (typeof override === 'string') return applyReplacements(override, replacements);
|
|
79
|
-
|
|
80
|
-
// 3. Central source for the current locale (@escalated-dev/locale).
|
|
81
|
-
// This is the canonical base layer.
|
|
82
|
-
const central = resolve(centralLocales?.[lang], key);
|
|
83
|
-
if (typeof central === 'string') return applyReplacements(central, replacements);
|
|
84
|
-
|
|
85
|
-
// 4. English fallbacks, in the same priority order.
|
|
86
|
-
if (lang !== 'en') {
|
|
87
|
-
const customEn = resolve(customMessages.value.en, key);
|
|
88
|
-
if (typeof customEn === 'string') return applyReplacements(customEn, replacements);
|
|
89
|
-
|
|
90
|
-
const overrideEn = resolve(overrideLocales.en, key);
|
|
91
|
-
if (typeof overrideEn === 'string') return applyReplacements(overrideEn, replacements);
|
|
92
|
-
|
|
93
|
-
const centralEn = resolve(centralLocales?.en, key);
|
|
94
|
-
if (typeof centralEn === 'string') return applyReplacements(centralEn, replacements);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Return raw key
|
|
98
|
-
return key;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return { t, locale };
|
|
102
|
-
}
|
|
1
|
+
import { ref, computed } from 'vue';
|
|
2
|
+
import overrideLocales from '../locales/index.js';
|
|
3
|
+
|
|
4
|
+
// Central translations published from escalated-dev/escalated-locale.
|
|
5
|
+
// This is the BASE layer. Local src/locales/*.json files in this repo are
|
|
6
|
+
// loaded as OVERRIDES and win over the central source for any matching key.
|
|
7
|
+
//
|
|
8
|
+
// Until @escalated-dev/locale v0.1.0 ships its consolidated strings, this
|
|
9
|
+
// import resolves to a stub object with the same shape. Local overrides
|
|
10
|
+
// (src/locales/) preserve the current behavior so components keep rendering
|
|
11
|
+
// the strings they always rendered. After Codex finishes consolidating
|
|
12
|
+
// translations into the central package, the local override files can be
|
|
13
|
+
// trimmed (or deleted entirely) and the central source becomes authoritative.
|
|
14
|
+
import centralLocales from '@escalated-dev/locale';
|
|
15
|
+
|
|
16
|
+
const currentLocale = ref('en');
|
|
17
|
+
const customMessages = ref({});
|
|
18
|
+
|
|
19
|
+
function resolve(obj, path) {
|
|
20
|
+
return path.split('.').reduce((acc, key) => acc?.[key], obj);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function applyReplacements(str, replacements) {
|
|
24
|
+
if (!replacements) return str;
|
|
25
|
+
return Object.entries(replacements).reduce(
|
|
26
|
+
(result, [key, value]) => result.replace(new RegExp(`:${key}`, 'g'), value),
|
|
27
|
+
str,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setLocale(locale) {
|
|
32
|
+
currentLocale.value = locale;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getLocale() {
|
|
36
|
+
return currentLocale.value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function mergeMessages(locale, messages) {
|
|
40
|
+
customMessages.value = {
|
|
41
|
+
...customMessages.value,
|
|
42
|
+
[locale]: deepMerge(customMessages.value[locale] || {}, messages),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function deepMerge(target, source) {
|
|
47
|
+
const result = { ...target };
|
|
48
|
+
for (const key of Object.keys(source)) {
|
|
49
|
+
if (
|
|
50
|
+
source[key] &&
|
|
51
|
+
typeof source[key] === 'object' &&
|
|
52
|
+
!Array.isArray(source[key]) &&
|
|
53
|
+
target[key] &&
|
|
54
|
+
typeof target[key] === 'object'
|
|
55
|
+
) {
|
|
56
|
+
result[key] = deepMerge(target[key], source[key]);
|
|
57
|
+
} else {
|
|
58
|
+
result[key] = source[key];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useI18n() {
|
|
65
|
+
const locale = computed(() => currentLocale.value);
|
|
66
|
+
|
|
67
|
+
function t(key, replacements) {
|
|
68
|
+
const lang = currentLocale.value;
|
|
69
|
+
|
|
70
|
+
// 1. Host-app supplied messages for the current locale (highest priority).
|
|
71
|
+
const custom = resolve(customMessages.value[lang], key);
|
|
72
|
+
if (typeof custom === 'string') return applyReplacements(custom, replacements);
|
|
73
|
+
|
|
74
|
+
// 2. Local plugin overrides for the current locale (src/locales/*.json).
|
|
75
|
+
// These exist to let this repo win over the central source for any
|
|
76
|
+
// string the plugin needs to phrase differently.
|
|
77
|
+
const override = resolve(overrideLocales[lang], key);
|
|
78
|
+
if (typeof override === 'string') return applyReplacements(override, replacements);
|
|
79
|
+
|
|
80
|
+
// 3. Central source for the current locale (@escalated-dev/locale).
|
|
81
|
+
// This is the canonical base layer.
|
|
82
|
+
const central = resolve(centralLocales?.[lang], key);
|
|
83
|
+
if (typeof central === 'string') return applyReplacements(central, replacements);
|
|
84
|
+
|
|
85
|
+
// 4. English fallbacks, in the same priority order.
|
|
86
|
+
if (lang !== 'en') {
|
|
87
|
+
const customEn = resolve(customMessages.value.en, key);
|
|
88
|
+
if (typeof customEn === 'string') return applyReplacements(customEn, replacements);
|
|
89
|
+
|
|
90
|
+
const overrideEn = resolve(overrideLocales.en, key);
|
|
91
|
+
if (typeof overrideEn === 'string') return applyReplacements(overrideEn, replacements);
|
|
92
|
+
|
|
93
|
+
const centralEn = resolve(centralLocales?.en, key);
|
|
94
|
+
if (typeof centralEn === 'string') return applyReplacements(centralEn, replacements);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Return raw key
|
|
98
|
+
return key;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { t, locale };
|
|
102
|
+
}
|
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
import { ref, onUnmounted } from 'vue';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Composable that detects `@` typed in a text input/textarea,
|
|
5
|
-
* fetches agent suggestions from the API (debounced), and provides
|
|
6
|
-
* an insert function to replace the mention token.
|
|
7
|
-
*
|
|
8
|
-
* @param {Object} options
|
|
9
|
-
* @param {string} [options.searchEndpoint] - API endpoint for agent search (default: route-based)
|
|
10
|
-
* @param {number} [options.debounce=250] - Debounce delay in ms
|
|
11
|
-
* @returns {Object}
|
|
12
|
-
*/
|
|
13
|
-
export function useMentions(options = {}) {
|
|
14
|
-
const { debounce = 250 } = options;
|
|
15
|
-
|
|
16
|
-
const suggestions = ref([]);
|
|
17
|
-
const isActive = ref(false);
|
|
18
|
-
const query = ref('');
|
|
19
|
-
const mentionStart = ref(null); // cursor position where `@` was typed
|
|
20
|
-
const loading = ref(false);
|
|
21
|
-
|
|
22
|
-
let timer = null;
|
|
23
|
-
|
|
24
|
-
function clearTimer() {
|
|
25
|
-
if (timer !== null) {
|
|
26
|
-
window.clearTimeout(timer);
|
|
27
|
-
timer = null;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Call this on every input/keyup event with the textarea element
|
|
33
|
-
* and the current text value.
|
|
34
|
-
*/
|
|
35
|
-
function onTextChange(textareaEl, text) {
|
|
36
|
-
if (!textareaEl) return;
|
|
37
|
-
|
|
38
|
-
const cursorPos = textareaEl.selectionStart;
|
|
39
|
-
const textBeforeCursor = text.slice(0, cursorPos);
|
|
40
|
-
|
|
41
|
-
// Find the last `@` that starts a mention (preceded by whitespace or start of string)
|
|
42
|
-
const mentionMatch = textBeforeCursor.match(/(?:^|[\s(])@([a-zA-Z0-9_ ]*)$/);
|
|
43
|
-
|
|
44
|
-
if (mentionMatch) {
|
|
45
|
-
const searchQuery = mentionMatch[1];
|
|
46
|
-
// The @ is at cursorPos minus the matched group minus 1 for the @
|
|
47
|
-
mentionStart.value = cursorPos - searchQuery.length - 1;
|
|
48
|
-
query.value = searchQuery;
|
|
49
|
-
isActive.value = true;
|
|
50
|
-
debouncedFetch(searchQuery);
|
|
51
|
-
} else {
|
|
52
|
-
close();
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function debouncedFetch(searchQuery) {
|
|
57
|
-
clearTimer();
|
|
58
|
-
|
|
59
|
-
if (searchQuery.length < 1) {
|
|
60
|
-
fetchSuggestions('');
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
timer = window.setTimeout(() => {
|
|
65
|
-
fetchSuggestions(searchQuery);
|
|
66
|
-
}, debounce);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function fetchSuggestions(searchQuery) {
|
|
70
|
-
loading.value = true;
|
|
71
|
-
try {
|
|
72
|
-
const endpoint = options.searchEndpoint || '/support/admin/agents/search';
|
|
73
|
-
const url = `${endpoint}?q=${encodeURIComponent(searchQuery)}`;
|
|
74
|
-
const response = await fetch(url, {
|
|
75
|
-
headers: {
|
|
76
|
-
Accept: 'application/json',
|
|
77
|
-
'X-Requested-With': 'XMLHttpRequest',
|
|
78
|
-
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
if (response.ok) {
|
|
82
|
-
suggestions.value = await response.json();
|
|
83
|
-
} else {
|
|
84
|
-
suggestions.value = [];
|
|
85
|
-
}
|
|
86
|
-
} catch {
|
|
87
|
-
suggestions.value = [];
|
|
88
|
-
} finally {
|
|
89
|
-
loading.value = false;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Insert the selected agent mention into the text.
|
|
95
|
-
* Returns the new text value with the mention inserted.
|
|
96
|
-
*
|
|
97
|
-
* @param {string} currentText - Current textarea value
|
|
98
|
-
* @param {Object} agent - Agent object with `name` property
|
|
99
|
-
* @returns {string} Updated text
|
|
100
|
-
*/
|
|
101
|
-
function insertMention(currentText, agent) {
|
|
102
|
-
if (mentionStart.value === null) return currentText;
|
|
103
|
-
|
|
104
|
-
const before = currentText.slice(0, mentionStart.value);
|
|
105
|
-
const cursorPos = mentionStart.value + 1 + query.value.length;
|
|
106
|
-
const after = currentText.slice(cursorPos);
|
|
107
|
-
|
|
108
|
-
const mentionText = `@{${agent.name}} `;
|
|
109
|
-
const newText = before + mentionText + after;
|
|
110
|
-
|
|
111
|
-
close();
|
|
112
|
-
return newText;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function close() {
|
|
116
|
-
isActive.value = false;
|
|
117
|
-
suggestions.value = [];
|
|
118
|
-
query.value = '';
|
|
119
|
-
mentionStart.value = null;
|
|
120
|
-
clearTimer();
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
onUnmounted(clearTimer);
|
|
124
|
-
|
|
125
|
-
return {
|
|
126
|
-
suggestions,
|
|
127
|
-
isActive,
|
|
128
|
-
query,
|
|
129
|
-
loading,
|
|
130
|
-
onTextChange,
|
|
131
|
-
insertMention,
|
|
132
|
-
close,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
1
|
+
import { ref, onUnmounted } from 'vue';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Composable that detects `@` typed in a text input/textarea,
|
|
5
|
+
* fetches agent suggestions from the API (debounced), and provides
|
|
6
|
+
* an insert function to replace the mention token.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
* @param {string} [options.searchEndpoint] - API endpoint for agent search (default: route-based)
|
|
10
|
+
* @param {number} [options.debounce=250] - Debounce delay in ms
|
|
11
|
+
* @returns {Object}
|
|
12
|
+
*/
|
|
13
|
+
export function useMentions(options = {}) {
|
|
14
|
+
const { debounce = 250 } = options;
|
|
15
|
+
|
|
16
|
+
const suggestions = ref([]);
|
|
17
|
+
const isActive = ref(false);
|
|
18
|
+
const query = ref('');
|
|
19
|
+
const mentionStart = ref(null); // cursor position where `@` was typed
|
|
20
|
+
const loading = ref(false);
|
|
21
|
+
|
|
22
|
+
let timer = null;
|
|
23
|
+
|
|
24
|
+
function clearTimer() {
|
|
25
|
+
if (timer !== null) {
|
|
26
|
+
window.clearTimeout(timer);
|
|
27
|
+
timer = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Call this on every input/keyup event with the textarea element
|
|
33
|
+
* and the current text value.
|
|
34
|
+
*/
|
|
35
|
+
function onTextChange(textareaEl, text) {
|
|
36
|
+
if (!textareaEl) return;
|
|
37
|
+
|
|
38
|
+
const cursorPos = textareaEl.selectionStart;
|
|
39
|
+
const textBeforeCursor = text.slice(0, cursorPos);
|
|
40
|
+
|
|
41
|
+
// Find the last `@` that starts a mention (preceded by whitespace or start of string)
|
|
42
|
+
const mentionMatch = textBeforeCursor.match(/(?:^|[\s(])@([a-zA-Z0-9_ ]*)$/);
|
|
43
|
+
|
|
44
|
+
if (mentionMatch) {
|
|
45
|
+
const searchQuery = mentionMatch[1];
|
|
46
|
+
// The @ is at cursorPos minus the matched group minus 1 for the @
|
|
47
|
+
mentionStart.value = cursorPos - searchQuery.length - 1;
|
|
48
|
+
query.value = searchQuery;
|
|
49
|
+
isActive.value = true;
|
|
50
|
+
debouncedFetch(searchQuery);
|
|
51
|
+
} else {
|
|
52
|
+
close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function debouncedFetch(searchQuery) {
|
|
57
|
+
clearTimer();
|
|
58
|
+
|
|
59
|
+
if (searchQuery.length < 1) {
|
|
60
|
+
fetchSuggestions('');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
timer = window.setTimeout(() => {
|
|
65
|
+
fetchSuggestions(searchQuery);
|
|
66
|
+
}, debounce);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function fetchSuggestions(searchQuery) {
|
|
70
|
+
loading.value = true;
|
|
71
|
+
try {
|
|
72
|
+
const endpoint = options.searchEndpoint || '/support/admin/agents/search';
|
|
73
|
+
const url = `${endpoint}?q=${encodeURIComponent(searchQuery)}`;
|
|
74
|
+
const response = await fetch(url, {
|
|
75
|
+
headers: {
|
|
76
|
+
Accept: 'application/json',
|
|
77
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
78
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
if (response.ok) {
|
|
82
|
+
suggestions.value = await response.json();
|
|
83
|
+
} else {
|
|
84
|
+
suggestions.value = [];
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
suggestions.value = [];
|
|
88
|
+
} finally {
|
|
89
|
+
loading.value = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Insert the selected agent mention into the text.
|
|
95
|
+
* Returns the new text value with the mention inserted.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} currentText - Current textarea value
|
|
98
|
+
* @param {Object} agent - Agent object with `name` property
|
|
99
|
+
* @returns {string} Updated text
|
|
100
|
+
*/
|
|
101
|
+
function insertMention(currentText, agent) {
|
|
102
|
+
if (mentionStart.value === null) return currentText;
|
|
103
|
+
|
|
104
|
+
const before = currentText.slice(0, mentionStart.value);
|
|
105
|
+
const cursorPos = mentionStart.value + 1 + query.value.length;
|
|
106
|
+
const after = currentText.slice(cursorPos);
|
|
107
|
+
|
|
108
|
+
const mentionText = `@{${agent.name}} `;
|
|
109
|
+
const newText = before + mentionText + after;
|
|
110
|
+
|
|
111
|
+
close();
|
|
112
|
+
return newText;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function close() {
|
|
116
|
+
isActive.value = false;
|
|
117
|
+
suggestions.value = [];
|
|
118
|
+
query.value = '';
|
|
119
|
+
mentionStart.value = null;
|
|
120
|
+
clearTimer();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
onUnmounted(clearTimer);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
suggestions,
|
|
127
|
+
isActive,
|
|
128
|
+
query,
|
|
129
|
+
loading,
|
|
130
|
+
onTextChange,
|
|
131
|
+
insertMention,
|
|
132
|
+
close,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { computed } from 'vue';
|
|
2
|
+
import { usePage } from '@inertiajs/vue3';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read the current user's permission slugs from Inertia shared props.
|
|
6
|
+
* Canonical source: `page.props.escalated.permissions` (string[]).
|
|
7
|
+
*/
|
|
8
|
+
export function usePermissions() {
|
|
9
|
+
const page = usePage();
|
|
10
|
+
|
|
11
|
+
const permissions = computed(() => {
|
|
12
|
+
const raw = page.props.escalated?.permissions;
|
|
13
|
+
if (raw === undefined || raw === null) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return Array.isArray(raw) ? raw : [];
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function hasPermission(slug) {
|
|
20
|
+
const perms = permissions.value;
|
|
21
|
+
if (perms === null) {
|
|
22
|
+
return page.props.escalated?.is_admin === true;
|
|
23
|
+
}
|
|
24
|
+
if (perms.includes('*')) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return perms.includes(slug);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { permissions, hasPermission };
|
|
31
|
+
}
|