@escalated-dev/escalated 0.8.0 → 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.
Files changed (116) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +380 -361
  3. package/package.json +85 -84
  4. package/src/components/ActiveChatsPanel.vue +246 -246
  5. package/src/components/AdminDashboard.stories.js +1423 -1423
  6. package/src/components/AttachmentList.vue +65 -65
  7. package/src/components/ChatActionBar.vue +226 -226
  8. package/src/components/ChatBubble.vue +162 -162
  9. package/src/components/ChatComposer.vue +308 -308
  10. package/src/components/ChatQueue.vue +222 -222
  11. package/src/components/ChatThread.vue +197 -197
  12. package/src/components/CustomTicketActions.stories.js +71 -0
  13. package/src/components/CustomTicketActions.vue +101 -0
  14. package/src/components/EscalatedLayout.vue +619 -604
  15. package/src/components/FollowButton.vue +20 -1
  16. package/src/components/MentionDropdown.vue +154 -154
  17. package/src/components/MetricComparison.vue +89 -89
  18. package/src/components/PercentileChart.vue +67 -67
  19. package/src/components/PeriodSelector.vue +98 -98
  20. package/src/components/PresenceIndicator.vue +228 -213
  21. package/src/components/ReplyComposer.vue +336 -336
  22. package/src/components/ReplyThread.vue +153 -153
  23. package/src/components/ReportChart.vue +395 -395
  24. package/src/components/ReportExportButton.vue +108 -108
  25. package/src/components/SaveViewDialog.vue +172 -172
  26. package/src/components/SavedViewSidebar.vue +208 -208
  27. package/src/components/SkillTagManager.vue +3 -0
  28. package/src/components/SnoozeButton.vue +247 -247
  29. package/src/components/TicketFilters.vue +270 -270
  30. package/src/components/TicketList.vue +655 -655
  31. package/src/components/TicketSidebar.vue +4 -0
  32. package/src/components/TicketSplitDialog.vue +98 -98
  33. package/src/components/TicketSubjects.stories.js +63 -0
  34. package/src/components/TicketSubjects.vue +107 -0
  35. package/src/components/TwoFactorSetup.vue +1 -37
  36. package/src/components/VariableTokenInput.vue +111 -111
  37. package/src/components/WorkflowActionConfig.vue +287 -287
  38. package/src/components/WorkflowActionList.vue +350 -350
  39. package/src/components/WorkflowConditionBuilder.vue +265 -265
  40. package/src/components/WorkflowTriggerSelector.vue +125 -125
  41. package/src/components/admin/newsletters/AnalyticsTiles.stories.js +14 -0
  42. package/src/components/admin/newsletters/AnalyticsTiles.vue +55 -0
  43. package/src/components/admin/newsletters/DeliveriesTable.stories.js +28 -0
  44. package/src/components/admin/newsletters/DeliveriesTable.vue +104 -0
  45. package/src/components/admin/newsletters/DynamicFilterBuilder.stories.js +11 -0
  46. package/src/components/admin/newsletters/DynamicFilterBuilder.vue +53 -0
  47. package/src/components/admin/newsletters/ListMemberTable.stories.js +12 -0
  48. package/src/components/admin/newsletters/ListMemberTable.vue +53 -0
  49. package/src/components/admin/newsletters/MarkdownEditor.stories.js +6 -0
  50. package/src/components/admin/newsletters/MarkdownEditor.vue +53 -0
  51. package/src/components/admin/newsletters/MergeFieldDropdown.stories.js +6 -0
  52. package/src/components/admin/newsletters/MergeFieldDropdown.vue +84 -0
  53. package/src/components/admin/newsletters/PreviewIframe.stories.js +7 -0
  54. package/src/components/admin/newsletters/PreviewIframe.vue +40 -0
  55. package/src/components/admin/newsletters/SummaryTiles.stories.js +8 -0
  56. package/src/components/admin/newsletters/SummaryTiles.vue +63 -0
  57. package/src/composables/useChat.js +187 -187
  58. package/src/composables/useI18n.js +102 -102
  59. package/src/composables/useMentions.js +134 -134
  60. package/src/composables/usePermissions.js +31 -0
  61. package/src/composables/useRealtime.js +182 -182
  62. package/src/index.js +85 -84
  63. package/src/locales/README.md +13 -13
  64. package/src/locales/ar.json +727 -727
  65. package/src/locales/de.json +690 -690
  66. package/src/locales/en.json +889 -729
  67. package/src/locales/es.json +691 -691
  68. package/src/locales/fr.json +691 -691
  69. package/src/locales/index.js +16 -16
  70. package/src/locales/it.json +727 -727
  71. package/src/locales/ja.json +727 -727
  72. package/src/locales/ko.json +727 -727
  73. package/src/locales/nl.json +727 -727
  74. package/src/locales/pl.json +727 -727
  75. package/src/locales/pt-BR.json +727 -727
  76. package/src/locales/ru.json +727 -727
  77. package/src/locales/tr.json +727 -727
  78. package/src/locales/zh-CN.json +727 -727
  79. package/src/pages/Admin/Newsletters/Compose.vue +192 -0
  80. package/src/pages/Admin/Newsletters/Edit.vue +24 -0
  81. package/src/pages/Admin/Newsletters/Index.vue +141 -0
  82. package/src/pages/Admin/Newsletters/Lists/Create.vue +64 -0
  83. package/src/pages/Admin/Newsletters/Lists/Index.vue +59 -0
  84. package/src/pages/Admin/Newsletters/Lists/Show.vue +53 -0
  85. package/src/pages/Admin/Newsletters/Settings.vue +85 -0
  86. package/src/pages/Admin/Newsletters/Show.vue +140 -0
  87. package/src/pages/Admin/Newsletters/Templates/Create.vue +11 -0
  88. package/src/pages/Admin/Newsletters/Templates/Index.vue +54 -0
  89. package/src/pages/Admin/Newsletters/Templates/Show.vue +65 -0
  90. package/src/pages/Admin/Newsletters/usePreview.js +44 -0
  91. package/src/pages/Admin/Reports/AgentDetail.vue +132 -132
  92. package/src/pages/Admin/Reports/AgentRanking.vue +246 -246
  93. package/src/pages/Admin/Reports/Cohorts.vue +180 -180
  94. package/src/pages/Admin/Reports/Comparison.vue +141 -141
  95. package/src/pages/Admin/Reports/Dashboard.vue +226 -226
  96. package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -164
  97. package/src/pages/Admin/Reports/ResponseTimes.vue +164 -164
  98. package/src/pages/Admin/Reports/SlaTrends.vue +183 -183
  99. package/src/pages/Admin/Settings/SsoSettings.vue +157 -4
  100. package/src/pages/Admin/Settings/TwoFactor.vue +32 -6
  101. package/src/pages/Admin/Settings.vue +1008 -1008
  102. package/src/pages/Admin/Skills/Form.vue +30 -10
  103. package/src/pages/Admin/Tickets/Index.vue +126 -126
  104. package/src/pages/Admin/Tickets/Show.vue +254 -249
  105. package/src/pages/Admin/Users/Index.vue +145 -145
  106. package/src/pages/Admin/Workflows/Builder.vue +207 -207
  107. package/src/pages/Admin/Workflows/Index.vue +235 -235
  108. package/src/pages/Admin/Workflows/Logs.vue +286 -286
  109. package/src/pages/Agent/TicketIndex.vue +112 -112
  110. package/src/pages/Agent/TicketShow.vue +547 -494
  111. package/src/pages/Customer/Index.vue +56 -56
  112. package/src/pages/Customer/KnowledgeBase/Article.vue +129 -129
  113. package/src/plugin.js +175 -175
  114. package/src/utils/mentionHighlight.js +24 -24
  115. package/src/widget/EscalatedWidget.vue +804 -804
  116. 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
+ }