@escalated-dev/escalated 0.7.1 → 0.9.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/LICENSE +21 -21
- package/README.md +380 -357
- package/package.json +85 -81
- 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 +619 -598
- 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 +29 -22
- 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 +30 -8
- 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 -0
- 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/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/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/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 +164 -4
- package/src/pages/Admin/Skills/Index.vue +14 -3
- 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 -0
- package/src/pages/Admin/Workflows/Builder.vue +207 -207
- 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 +26 -1
- package/src/pages/Customer/KnowledgeBase/Article.vue +129 -129
- 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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="merge-field-dropdown">
|
|
3
|
+
<button type="button" class="merge-field-dropdown__toggle" @click="open = !open">
|
|
4
|
+
{{ $t('newsletters.merge_fields.label') }} ▾
|
|
5
|
+
</button>
|
|
6
|
+
<ul v-if="open" class="merge-field-dropdown__menu">
|
|
7
|
+
<li v-for="opt in options" :key="opt.path" :data-path="opt.path" @click="pick(opt.path)">
|
|
8
|
+
<code>{{ tokenFor(opt.path) }}</code>
|
|
9
|
+
<span class="merge-field-dropdown__hint">{{ opt.label }}</span>
|
|
10
|
+
</li>
|
|
11
|
+
</ul>
|
|
12
|
+
</div>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<script setup>
|
|
16
|
+
import { ref, computed } from 'vue';
|
|
17
|
+
|
|
18
|
+
const props = defineProps({
|
|
19
|
+
metadataKeys: { type: Array, default: () => [] },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits(['insert']);
|
|
23
|
+
const open = ref(false);
|
|
24
|
+
|
|
25
|
+
const options = computed(() => [
|
|
26
|
+
{ path: 'contact.name', label: 'Contact full name' },
|
|
27
|
+
{ path: 'contact.first_name', label: 'Contact first name' },
|
|
28
|
+
{ path: 'contact.email', label: 'Contact email' },
|
|
29
|
+
{ path: 'unsubscribe_url', label: 'Unsubscribe URL' },
|
|
30
|
+
{ path: 'view_in_browser_url', label: 'View-in-browser URL' },
|
|
31
|
+
...props.metadataKeys.map((k) => ({ path: `contact.metadata.${k}`, label: `Metadata: ${k}` })),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function pick(path) {
|
|
35
|
+
emit('insert', path);
|
|
36
|
+
open.value = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function tokenFor(path) {
|
|
40
|
+
return `{{ ${path} }}`;
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<style scoped>
|
|
45
|
+
.merge-field-dropdown {
|
|
46
|
+
position: relative;
|
|
47
|
+
display: inline-block;
|
|
48
|
+
}
|
|
49
|
+
.merge-field-dropdown__toggle {
|
|
50
|
+
padding: 6px 10px;
|
|
51
|
+
border: 1px solid var(--escalated-border, #e2e8f0);
|
|
52
|
+
background: white;
|
|
53
|
+
border-radius: 4px;
|
|
54
|
+
cursor: pointer;
|
|
55
|
+
}
|
|
56
|
+
.merge-field-dropdown__menu {
|
|
57
|
+
position: absolute;
|
|
58
|
+
top: 100%;
|
|
59
|
+
left: 0;
|
|
60
|
+
z-index: 10;
|
|
61
|
+
margin: 4px 0 0;
|
|
62
|
+
padding: 4px 0;
|
|
63
|
+
min-width: 280px;
|
|
64
|
+
list-style: none;
|
|
65
|
+
background: white;
|
|
66
|
+
border: 1px solid var(--escalated-border, #e2e8f0);
|
|
67
|
+
border-radius: 6px;
|
|
68
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
69
|
+
}
|
|
70
|
+
.merge-field-dropdown__menu li {
|
|
71
|
+
padding: 8px 12px;
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
display: flex;
|
|
74
|
+
justify-content: space-between;
|
|
75
|
+
gap: 12px;
|
|
76
|
+
}
|
|
77
|
+
.merge-field-dropdown__menu li:hover {
|
|
78
|
+
background: var(--escalated-bg-hover, #f7fafc);
|
|
79
|
+
}
|
|
80
|
+
.merge-field-dropdown__hint {
|
|
81
|
+
color: var(--escalated-text-muted, #64748b);
|
|
82
|
+
font-size: 12px;
|
|
83
|
+
}
|
|
84
|
+
</style>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import PreviewIframe from './PreviewIframe.vue';
|
|
2
|
+
|
|
3
|
+
export default { title: 'Admin/Newsletters/PreviewIframe', component: PreviewIframe };
|
|
4
|
+
|
|
5
|
+
export const Empty = { args: { html: '<p>(empty)</p>' } };
|
|
6
|
+
export const Rendered = { args: { html: '<h1>Newsletter</h1><p>Hello Maria,</p><p>Welcome aboard.</p>' } };
|
|
7
|
+
export const Loading = { args: { html: '<h1>Newsletter</h1>', loading: true } };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="preview-iframe">
|
|
3
|
+
<div v-if="loading" class="preview-iframe__loading">Rendering preview…</div>
|
|
4
|
+
<iframe :srcdoc="html" sandbox="allow-same-origin" class="preview-iframe__frame" title="Newsletter preview" />
|
|
5
|
+
</div>
|
|
6
|
+
</template>
|
|
7
|
+
|
|
8
|
+
<script setup>
|
|
9
|
+
defineProps({
|
|
10
|
+
html: { type: String, default: '' },
|
|
11
|
+
loading: { type: Boolean, default: false },
|
|
12
|
+
});
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<style scoped>
|
|
16
|
+
.preview-iframe {
|
|
17
|
+
position: relative;
|
|
18
|
+
height: 100%;
|
|
19
|
+
min-height: 480px;
|
|
20
|
+
border: 1px solid var(--escalated-border, #e2e8f0);
|
|
21
|
+
border-radius: 6px;
|
|
22
|
+
overflow: hidden;
|
|
23
|
+
background: white;
|
|
24
|
+
}
|
|
25
|
+
.preview-iframe__frame {
|
|
26
|
+
width: 100%;
|
|
27
|
+
height: 100%;
|
|
28
|
+
border: 0;
|
|
29
|
+
}
|
|
30
|
+
.preview-iframe__loading {
|
|
31
|
+
position: absolute;
|
|
32
|
+
top: 8px;
|
|
33
|
+
right: 12px;
|
|
34
|
+
font-size: 12px;
|
|
35
|
+
color: var(--escalated-text-muted, #64748b);
|
|
36
|
+
background: rgba(255, 255, 255, 0.9);
|
|
37
|
+
padding: 4px 8px;
|
|
38
|
+
border-radius: 4px;
|
|
39
|
+
}
|
|
40
|
+
</style>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import SummaryTiles from './SummaryTiles.vue';
|
|
2
|
+
|
|
3
|
+
export default { title: 'Admin/Newsletters/SummaryTiles', component: SummaryTiles };
|
|
4
|
+
|
|
5
|
+
export const Healthy = {
|
|
6
|
+
args: { summary: { total: 1000, sent: 990, opened: 400, clicked: 80, bounced: 10, complained: 1 } },
|
|
7
|
+
};
|
|
8
|
+
export const Empty = { args: { summary: { total: 0, sent: 0, opened: 0, clicked: 0, bounced: 0, complained: 0 } } };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="summary-tiles">
|
|
3
|
+
<div v-for="tile in tiles" :key="tile.key" class="summary-tiles__tile">
|
|
4
|
+
<div class="summary-tiles__label">{{ tile.label }}</div>
|
|
5
|
+
<div class="summary-tiles__value">{{ tile.value }}</div>
|
|
6
|
+
<div v-if="tile.rate !== null" class="summary-tiles__rate">{{ tile.rate }}</div>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
</template>
|
|
10
|
+
|
|
11
|
+
<script setup>
|
|
12
|
+
import { computed } from 'vue';
|
|
13
|
+
|
|
14
|
+
const props = defineProps({
|
|
15
|
+
summary: { type: Object, required: true },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function rate(num, denom) {
|
|
19
|
+
if (!denom) return '—';
|
|
20
|
+
return `${((num / denom) * 100).toFixed(1)}%`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const tiles = computed(() => {
|
|
24
|
+
const s = props.summary;
|
|
25
|
+
return [
|
|
26
|
+
{ key: 'sent', label: 'Sent', value: s.sent, rate: null },
|
|
27
|
+
{ key: 'opened', label: 'Opened', value: s.opened, rate: rate(s.opened, s.sent) },
|
|
28
|
+
{ key: 'clicked', label: 'Clicked', value: s.clicked, rate: rate(s.clicked, s.sent) },
|
|
29
|
+
{ key: 'bounced', label: 'Bounced', value: s.bounced, rate: rate(s.bounced, s.sent) },
|
|
30
|
+
{ key: 'complained', label: 'Complained', value: s.complained, rate: rate(s.complained, s.sent) },
|
|
31
|
+
];
|
|
32
|
+
});
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<style scoped>
|
|
36
|
+
.summary-tiles {
|
|
37
|
+
display: grid;
|
|
38
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
39
|
+
gap: 12px;
|
|
40
|
+
}
|
|
41
|
+
.summary-tiles__tile {
|
|
42
|
+
background: white;
|
|
43
|
+
border: 1px solid var(--escalated-border, #e2e8f0);
|
|
44
|
+
border-radius: 8px;
|
|
45
|
+
padding: 16px;
|
|
46
|
+
}
|
|
47
|
+
.summary-tiles__label {
|
|
48
|
+
font-size: 12px;
|
|
49
|
+
text-transform: uppercase;
|
|
50
|
+
color: var(--escalated-text-muted, #64748b);
|
|
51
|
+
letter-spacing: 0.04em;
|
|
52
|
+
}
|
|
53
|
+
.summary-tiles__value {
|
|
54
|
+
font-size: 28px;
|
|
55
|
+
font-weight: 600;
|
|
56
|
+
margin-top: 4px;
|
|
57
|
+
}
|
|
58
|
+
.summary-tiles__rate {
|
|
59
|
+
font-size: 13px;
|
|
60
|
+
color: var(--escalated-text-muted, #64748b);
|
|
61
|
+
margin-top: 2px;
|
|
62
|
+
}
|
|
63
|
+
</style>
|
|
@@ -1,187 +1,187 @@
|
|
|
1
|
-
import { ref, readonly } from 'vue';
|
|
2
|
-
import { useRealtime } from './useRealtime';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Composable for live chat functionality.
|
|
6
|
-
*
|
|
7
|
-
* Provides methods to start, send messages, end, and rate chat sessions,
|
|
8
|
-
* plus real-time subscription via useRealtime.
|
|
9
|
-
*
|
|
10
|
-
* @param {Object} [options]
|
|
11
|
-
* @param {string} [options.widgetPath] - Path prefix under which the host
|
|
12
|
-
* framework mounts widget routes. Defaults to `/support/widget` which
|
|
13
|
-
* matches every host-adapter plugin (Laravel / Rails / Django / Adonis /
|
|
14
|
-
* WordPress / Filament / Symfony / .NET / Go / Spring / Phoenix). On a
|
|
15
|
-
* NestJS backend, pass `/escalated/widget` (or read from whatever
|
|
16
|
-
* configuration channel the host app exposes to the client).
|
|
17
|
-
*/
|
|
18
|
-
export function useChat(options = {}) {
|
|
19
|
-
const widgetPath = options.widgetPath ?? '/support/widget';
|
|
20
|
-
const connectionState = ref('disconnected'); // disconnected | connecting | connected
|
|
21
|
-
const messageBuffer = ref([]);
|
|
22
|
-
|
|
23
|
-
const { echoAvailable, listen } = useRealtime();
|
|
24
|
-
|
|
25
|
-
function csrfToken() {
|
|
26
|
-
return typeof document !== 'undefined' ? document.querySelector('meta[name="csrf-token"]')?.content || '' : '';
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function apiRequest(method, path, body = null) {
|
|
30
|
-
const opts = {
|
|
31
|
-
method,
|
|
32
|
-
headers: {
|
|
33
|
-
Accept: 'application/json',
|
|
34
|
-
'Content-Type': 'application/json',
|
|
35
|
-
'X-Requested-With': 'XMLHttpRequest',
|
|
36
|
-
'X-CSRF-TOKEN': csrfToken(),
|
|
37
|
-
},
|
|
38
|
-
};
|
|
39
|
-
if (body) opts.body = JSON.stringify(body);
|
|
40
|
-
const res = await fetch(path, opts);
|
|
41
|
-
const data = await res.json();
|
|
42
|
-
if (!res.ok) throw { status: res.status, data };
|
|
43
|
-
return data;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Start a new chat session.
|
|
48
|
-
* @param {Object} data - { name, email, department_id, message }
|
|
49
|
-
* @returns {Promise<Object>} The created chat session
|
|
50
|
-
*/
|
|
51
|
-
async function startChat(data) {
|
|
52
|
-
return apiRequest('POST', `${widgetPath}/chat/start`, data);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Send a message in a chat session.
|
|
57
|
-
* @param {string|number} sessionId
|
|
58
|
-
* @param {Object} body - { body, is_internal_note?, attachments? }
|
|
59
|
-
* @returns {Promise<Object>}
|
|
60
|
-
*/
|
|
61
|
-
async function sendMessage(sessionId, body) {
|
|
62
|
-
const message = await apiRequest('POST', `${widgetPath}/chat/${sessionId}/messages`, body);
|
|
63
|
-
return message;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Send typing indicator (debounced by caller).
|
|
68
|
-
* @param {string|number} sessionId
|
|
69
|
-
* @returns {Promise<void>}
|
|
70
|
-
*/
|
|
71
|
-
let lastTypingSent = 0;
|
|
72
|
-
async function sendTyping(sessionId) {
|
|
73
|
-
const now = Date.now();
|
|
74
|
-
if (now - lastTypingSent < 3000) return;
|
|
75
|
-
lastTypingSent = now;
|
|
76
|
-
try {
|
|
77
|
-
await apiRequest('POST', `${widgetPath}/chat/${sessionId}/typing`);
|
|
78
|
-
} catch {
|
|
79
|
-
// silently ignore typing failures
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* End a chat session.
|
|
85
|
-
* @param {string|number} sessionId
|
|
86
|
-
* @returns {Promise<Object>}
|
|
87
|
-
*/
|
|
88
|
-
async function endChat(sessionId) {
|
|
89
|
-
return apiRequest('POST', `${widgetPath}/chat/${sessionId}/end`);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Rate a completed chat session.
|
|
94
|
-
* @param {string|number} sessionId
|
|
95
|
-
* @param {number} rating - 1-5 star rating
|
|
96
|
-
* @param {string} [comment]
|
|
97
|
-
* @returns {Promise<Object>}
|
|
98
|
-
*/
|
|
99
|
-
async function rateChat(sessionId, rating, comment = '') {
|
|
100
|
-
return apiRequest('POST', `${widgetPath}/chat/${sessionId}/rate`, { rating, comment });
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Check chat availability for a department.
|
|
105
|
-
* @param {string|number} [departmentId]
|
|
106
|
-
* @returns {Promise<Object>} { available, message, estimated_wait }
|
|
107
|
-
*/
|
|
108
|
-
async function checkAvailability(departmentId = null) {
|
|
109
|
-
const query = departmentId ? `?department_id=${departmentId}` : '';
|
|
110
|
-
return apiRequest('GET', `${widgetPath}/chat/availability${query}`);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Subscribe to real-time chat events for a session.
|
|
115
|
-
* @param {string|number} sessionId
|
|
116
|
-
* @param {Object} callbacks
|
|
117
|
-
* @param {Function} [callbacks.onMessage]
|
|
118
|
-
* @param {Function} [callbacks.onTyping]
|
|
119
|
-
* @param {Function} [callbacks.onEnded]
|
|
120
|
-
* @param {Function} [callbacks.onAssigned]
|
|
121
|
-
* @returns {Function} Unsubscribe function
|
|
122
|
-
*/
|
|
123
|
-
function subscribeToChat(sessionId, callbacks = {}) {
|
|
124
|
-
if (!echoAvailable.value) return () => {};
|
|
125
|
-
|
|
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
|
-
}
|
|
141
|
-
|
|
142
|
-
connectionState.value = 'connected';
|
|
143
|
-
|
|
144
|
-
return () => {
|
|
145
|
-
unsubs.forEach((fn) => fn());
|
|
146
|
-
connectionState.value = 'disconnected';
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Subscribe to the chat queue channel for new incoming chats.
|
|
152
|
-
* @param {Object} callbacks
|
|
153
|
-
* @param {Function} [callbacks.onNewChat]
|
|
154
|
-
* @param {Function} [callbacks.onChatAccepted]
|
|
155
|
-
* @returns {Function} Unsubscribe function
|
|
156
|
-
*/
|
|
157
|
-
function subscribeToChatQueue(callbacks = {}) {
|
|
158
|
-
if (!echoAvailable.value) return () => {};
|
|
159
|
-
|
|
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
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return {
|
|
176
|
-
connectionState: readonly(connectionState),
|
|
177
|
-
messageBuffer: readonly(messageBuffer),
|
|
178
|
-
startChat,
|
|
179
|
-
sendMessage,
|
|
180
|
-
sendTyping,
|
|
181
|
-
endChat,
|
|
182
|
-
rateChat,
|
|
183
|
-
checkAvailability,
|
|
184
|
-
subscribeToChat,
|
|
185
|
-
subscribeToChatQueue,
|
|
186
|
-
};
|
|
187
|
-
}
|
|
1
|
+
import { ref, readonly } from 'vue';
|
|
2
|
+
import { useRealtime } from './useRealtime';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Composable for live chat functionality.
|
|
6
|
+
*
|
|
7
|
+
* Provides methods to start, send messages, end, and rate chat sessions,
|
|
8
|
+
* plus real-time subscription via useRealtime.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} [options]
|
|
11
|
+
* @param {string} [options.widgetPath] - Path prefix under which the host
|
|
12
|
+
* framework mounts widget routes. Defaults to `/support/widget` which
|
|
13
|
+
* matches every host-adapter plugin (Laravel / Rails / Django / Adonis /
|
|
14
|
+
* WordPress / Filament / Symfony / .NET / Go / Spring / Phoenix). On a
|
|
15
|
+
* NestJS backend, pass `/escalated/widget` (or read from whatever
|
|
16
|
+
* configuration channel the host app exposes to the client).
|
|
17
|
+
*/
|
|
18
|
+
export function useChat(options = {}) {
|
|
19
|
+
const widgetPath = options.widgetPath ?? '/support/widget';
|
|
20
|
+
const connectionState = ref('disconnected'); // disconnected | connecting | connected
|
|
21
|
+
const messageBuffer = ref([]);
|
|
22
|
+
|
|
23
|
+
const { echoAvailable, listen } = useRealtime();
|
|
24
|
+
|
|
25
|
+
function csrfToken() {
|
|
26
|
+
return typeof document !== 'undefined' ? document.querySelector('meta[name="csrf-token"]')?.content || '' : '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function apiRequest(method, path, body = null) {
|
|
30
|
+
const opts = {
|
|
31
|
+
method,
|
|
32
|
+
headers: {
|
|
33
|
+
Accept: 'application/json',
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
36
|
+
'X-CSRF-TOKEN': csrfToken(),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
if (body) opts.body = JSON.stringify(body);
|
|
40
|
+
const res = await fetch(path, opts);
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
if (!res.ok) throw { status: res.status, data };
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Start a new chat session.
|
|
48
|
+
* @param {Object} data - { name, email, department_id, message }
|
|
49
|
+
* @returns {Promise<Object>} The created chat session
|
|
50
|
+
*/
|
|
51
|
+
async function startChat(data) {
|
|
52
|
+
return apiRequest('POST', `${widgetPath}/chat/start`, data);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Send a message in a chat session.
|
|
57
|
+
* @param {string|number} sessionId
|
|
58
|
+
* @param {Object} body - { body, is_internal_note?, attachments? }
|
|
59
|
+
* @returns {Promise<Object>}
|
|
60
|
+
*/
|
|
61
|
+
async function sendMessage(sessionId, body) {
|
|
62
|
+
const message = await apiRequest('POST', `${widgetPath}/chat/${sessionId}/messages`, body);
|
|
63
|
+
return message;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Send typing indicator (debounced by caller).
|
|
68
|
+
* @param {string|number} sessionId
|
|
69
|
+
* @returns {Promise<void>}
|
|
70
|
+
*/
|
|
71
|
+
let lastTypingSent = 0;
|
|
72
|
+
async function sendTyping(sessionId) {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
if (now - lastTypingSent < 3000) return;
|
|
75
|
+
lastTypingSent = now;
|
|
76
|
+
try {
|
|
77
|
+
await apiRequest('POST', `${widgetPath}/chat/${sessionId}/typing`);
|
|
78
|
+
} catch {
|
|
79
|
+
// silently ignore typing failures
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* End a chat session.
|
|
85
|
+
* @param {string|number} sessionId
|
|
86
|
+
* @returns {Promise<Object>}
|
|
87
|
+
*/
|
|
88
|
+
async function endChat(sessionId) {
|
|
89
|
+
return apiRequest('POST', `${widgetPath}/chat/${sessionId}/end`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Rate a completed chat session.
|
|
94
|
+
* @param {string|number} sessionId
|
|
95
|
+
* @param {number} rating - 1-5 star rating
|
|
96
|
+
* @param {string} [comment]
|
|
97
|
+
* @returns {Promise<Object>}
|
|
98
|
+
*/
|
|
99
|
+
async function rateChat(sessionId, rating, comment = '') {
|
|
100
|
+
return apiRequest('POST', `${widgetPath}/chat/${sessionId}/rate`, { rating, comment });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check chat availability for a department.
|
|
105
|
+
* @param {string|number} [departmentId]
|
|
106
|
+
* @returns {Promise<Object>} { available, message, estimated_wait }
|
|
107
|
+
*/
|
|
108
|
+
async function checkAvailability(departmentId = null) {
|
|
109
|
+
const query = departmentId ? `?department_id=${departmentId}` : '';
|
|
110
|
+
return apiRequest('GET', `${widgetPath}/chat/availability${query}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Subscribe to real-time chat events for a session.
|
|
115
|
+
* @param {string|number} sessionId
|
|
116
|
+
* @param {Object} callbacks
|
|
117
|
+
* @param {Function} [callbacks.onMessage]
|
|
118
|
+
* @param {Function} [callbacks.onTyping]
|
|
119
|
+
* @param {Function} [callbacks.onEnded]
|
|
120
|
+
* @param {Function} [callbacks.onAssigned]
|
|
121
|
+
* @returns {Function} Unsubscribe function
|
|
122
|
+
*/
|
|
123
|
+
function subscribeToChat(sessionId, callbacks = {}) {
|
|
124
|
+
if (!echoAvailable.value) return () => {};
|
|
125
|
+
|
|
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
|
+
}
|
|
141
|
+
|
|
142
|
+
connectionState.value = 'connected';
|
|
143
|
+
|
|
144
|
+
return () => {
|
|
145
|
+
unsubs.forEach((fn) => fn());
|
|
146
|
+
connectionState.value = 'disconnected';
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Subscribe to the chat queue channel for new incoming chats.
|
|
152
|
+
* @param {Object} callbacks
|
|
153
|
+
* @param {Function} [callbacks.onNewChat]
|
|
154
|
+
* @param {Function} [callbacks.onChatAccepted]
|
|
155
|
+
* @returns {Function} Unsubscribe function
|
|
156
|
+
*/
|
|
157
|
+
function subscribeToChatQueue(callbacks = {}) {
|
|
158
|
+
if (!echoAvailable.value) return () => {};
|
|
159
|
+
|
|
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
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
connectionState: readonly(connectionState),
|
|
177
|
+
messageBuffer: readonly(messageBuffer),
|
|
178
|
+
startChat,
|
|
179
|
+
sendMessage,
|
|
180
|
+
sendTyping,
|
|
181
|
+
endChat,
|
|
182
|
+
rateChat,
|
|
183
|
+
checkAvailability,
|
|
184
|
+
subscribeToChat,
|
|
185
|
+
subscribeToChatQueue,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { ref, computed } from 'vue';
|
|
2
|
-
import
|
|
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';
|
|
3
15
|
|
|
4
16
|
const currentLocale = ref('en');
|
|
5
17
|
const customMessages = ref({});
|
|
@@ -55,21 +67,31 @@ export function useI18n() {
|
|
|
55
67
|
function t(key, replacements) {
|
|
56
68
|
const lang = currentLocale.value;
|
|
57
69
|
|
|
58
|
-
//
|
|
70
|
+
// 1. Host-app supplied messages for the current locale (highest priority).
|
|
59
71
|
const custom = resolve(customMessages.value[lang], key);
|
|
60
72
|
if (typeof custom === 'string') return applyReplacements(custom, replacements);
|
|
61
73
|
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
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);
|
|
65
84
|
|
|
66
|
-
//
|
|
85
|
+
// 4. English fallbacks, in the same priority order.
|
|
67
86
|
if (lang !== 'en') {
|
|
68
87
|
const customEn = resolve(customMessages.value.en, key);
|
|
69
88
|
if (typeof customEn === 'string') return applyReplacements(customEn, replacements);
|
|
70
89
|
|
|
71
|
-
const
|
|
72
|
-
if (typeof
|
|
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);
|
|
73
95
|
}
|
|
74
96
|
|
|
75
97
|
// Return raw key
|