@escalated-dev/escalated 0.7.0 → 0.7.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.
Files changed (179) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +31 -0
  3. package/package.json +12 -6
  4. package/src/components/ActiveChatsPanel.vue +246 -0
  5. package/src/components/ActivityTimeline.stories.js +55 -55
  6. package/src/components/ActivityTimeline.vue +83 -83
  7. package/src/components/AdminDashboard.stories.js +1423 -1095
  8. package/src/components/AgentLoadIndicator.stories.js +37 -37
  9. package/src/components/AgentLoadIndicator.vue +49 -49
  10. package/src/components/AgentPerformanceCard.vue +44 -44
  11. package/src/components/ArticleEditor.vue +82 -82
  12. package/src/components/AssigneeSelect.vue +37 -37
  13. package/src/components/AttachmentList.vue +13 -1
  14. package/src/components/AuditLogEntry.vue +95 -95
  15. package/src/components/BulkActionBar.vue +166 -166
  16. package/src/components/CategoryTree.vue +89 -89
  17. package/src/components/ChartWidget.vue +46 -46
  18. package/src/components/ChatActionBar.vue +226 -0
  19. package/src/components/ChatBubble.vue +162 -0
  20. package/src/components/ChatComposer.vue +308 -0
  21. package/src/components/ChatQueue.vue +222 -0
  22. package/src/components/ChatThread.vue +197 -0
  23. package/src/components/CollisionWarning.stories.js +30 -30
  24. package/src/components/CollisionWarning.vue +68 -68
  25. package/src/components/ConditionalFieldRules.vue +135 -135
  26. package/src/components/ContextPanel.vue +66 -66
  27. package/src/components/ContextPanelSection.vue +65 -65
  28. package/src/components/CustomFieldRenderer.vue +159 -159
  29. package/src/components/EscalatedLayout.vue +29 -6
  30. package/src/components/FileDropzone.vue +57 -57
  31. package/src/components/FollowButton.stories.js +30 -30
  32. package/src/components/FollowButton.vue +88 -88
  33. package/src/components/KeyboardShortcutHelp.vue +213 -213
  34. package/src/components/KnowledgePanel.vue +122 -122
  35. package/src/components/KpiCard.stories.js +32 -32
  36. package/src/components/KpiCard.vue +88 -88
  37. package/src/components/MacroDropdown.vue +150 -150
  38. package/src/components/MentionDropdown.vue +154 -0
  39. package/src/components/MetricComparison.vue +89 -0
  40. package/src/components/PercentileChart.vue +67 -0
  41. package/src/components/PeriodSelector.vue +98 -0
  42. package/src/components/PermissionMatrix.vue +107 -107
  43. package/src/components/PinnedNotes.vue +129 -129
  44. package/src/components/PluginSlot.vue +68 -62
  45. package/src/components/PresenceIndicator.vue +46 -2
  46. package/src/components/PriorityBadge.stories.js +33 -33
  47. package/src/components/PriorityBadge.vue +46 -46
  48. package/src/components/ReplyComposer.vue +81 -18
  49. package/src/components/ReplyThread.vue +40 -2
  50. package/src/components/ReportChart.vue +395 -0
  51. package/src/components/ReportExportButton.vue +108 -0
  52. package/src/components/SatisfactionRating.vue +180 -180
  53. package/src/components/SaveViewDialog.vue +172 -0
  54. package/src/components/SavedViewSidebar.vue +208 -0
  55. package/src/components/ScheduleEditor.vue +104 -104
  56. package/src/components/SideConversation.vue +165 -165
  57. package/src/components/SideConversationList.vue +147 -147
  58. package/src/components/SkillTagManager.vue +79 -79
  59. package/src/components/SlaComplianceChart.vue +46 -53
  60. package/src/components/SlaTimer.stories.js +35 -35
  61. package/src/components/SlaTimer.vue +62 -62
  62. package/src/components/SnoozeButton.vue +247 -0
  63. package/src/components/StatsCard.stories.js +24 -24
  64. package/src/components/StatsCard.vue +58 -58
  65. package/src/components/StatusBadge.stories.js +53 -53
  66. package/src/components/StatusBadge.vue +78 -78
  67. package/src/components/TagSelect.vue +84 -84
  68. package/src/components/TicketFilters.vue +270 -225
  69. package/src/components/TicketLinkPanel.vue +193 -193
  70. package/src/components/TicketList.vue +655 -594
  71. package/src/components/TicketMergeDialog.vue +157 -157
  72. package/src/components/TicketSplitDialog.vue +98 -0
  73. package/src/components/TotpInput.stories.js +9 -9
  74. package/src/components/TotpInput.vue +81 -81
  75. package/src/components/TwoFactorSetup.vue +113 -113
  76. package/src/components/VariableTokenInput.vue +111 -0
  77. package/src/components/WorkflowActionConfig.vue +287 -0
  78. package/src/components/WorkflowActionList.vue +350 -0
  79. package/src/components/WorkflowConditionBuilder.vue +265 -0
  80. package/src/components/WorkflowTriggerSelector.vue +125 -0
  81. package/src/composables/useChat.js +187 -0
  82. package/src/composables/useI18n.js +80 -80
  83. package/src/composables/useKeyboardShortcuts.js +46 -46
  84. package/src/composables/useMentions.js +134 -0
  85. package/src/composables/usePluginExtensions.js +159 -159
  86. package/src/composables/usePluginHooks.js +65 -65
  87. package/src/composables/useRealtime.js +182 -0
  88. package/src/defineEscalatedPlugin.js +76 -76
  89. package/src/index.js +84 -72
  90. package/src/locales/ar.json +727 -0
  91. package/src/locales/de.json +678 -430
  92. package/src/locales/en.json +717 -455
  93. package/src/locales/es.json +679 -430
  94. package/src/locales/fr.json +680 -431
  95. package/src/locales/index.js +12 -2
  96. package/src/locales/it.json +727 -0
  97. package/src/locales/ja.json +727 -0
  98. package/src/locales/ko.json +727 -0
  99. package/src/locales/nl.json +727 -0
  100. package/src/locales/pl.json +727 -0
  101. package/src/locales/pt-BR.json +727 -0
  102. package/src/locales/ru.json +727 -0
  103. package/src/locales/tr.json +727 -0
  104. package/src/locales/zh-CN.json +727 -0
  105. package/src/pages/Admin/ApiTokens/Index.vue +381 -381
  106. package/src/pages/Admin/AuditLog/Index.vue +154 -154
  107. package/src/pages/Admin/BusinessHours/Form.vue +172 -172
  108. package/src/pages/Admin/BusinessHours/Index.vue +113 -113
  109. package/src/pages/Admin/CannedResponses/Index.vue +127 -127
  110. package/src/pages/Admin/Capacity/Index.vue +134 -134
  111. package/src/pages/Admin/CustomFields/Form.vue +195 -195
  112. package/src/pages/Admin/CustomFields/Index.vue +134 -134
  113. package/src/pages/Admin/CustomObjects/Form.vue +202 -202
  114. package/src/pages/Admin/CustomObjects/Index.vue +127 -127
  115. package/src/pages/Admin/CustomObjects/Records.vue +228 -228
  116. package/src/pages/Admin/Departments/Form.vue +75 -75
  117. package/src/pages/Admin/Departments/Index.vue +110 -109
  118. package/src/pages/Admin/EscalationRules/Form.vue +157 -157
  119. package/src/pages/Admin/EscalationRules/Index.vue +178 -178
  120. package/src/pages/Admin/Import/Connect.vue +0 -1
  121. package/src/pages/Admin/Import/Index.vue +1 -1
  122. package/src/pages/Admin/Import/Progress.vue +1 -1
  123. package/src/pages/Admin/KnowledgeBase/Articles/Form.vue +142 -142
  124. package/src/pages/Admin/KnowledgeBase/Articles/Index.vue +195 -195
  125. package/src/pages/Admin/KnowledgeBase/Categories/Index.vue +193 -193
  126. package/src/pages/Admin/Macros/Index.vue +448 -448
  127. package/src/pages/Admin/Plugins/Index.vue +327 -327
  128. package/src/pages/Admin/Reports/AgentDetail.vue +132 -0
  129. package/src/pages/Admin/Reports/AgentMetrics.vue +115 -115
  130. package/src/pages/Admin/Reports/AgentRanking.vue +246 -0
  131. package/src/pages/Admin/Reports/Cohorts.vue +180 -0
  132. package/src/pages/Admin/Reports/Comparison.vue +141 -0
  133. package/src/pages/Admin/Reports/CsatReport.vue +127 -127
  134. package/src/pages/Admin/Reports/Dashboard.vue +36 -21
  135. package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -0
  136. package/src/pages/Admin/Reports/ResponseTimes.vue +164 -0
  137. package/src/pages/Admin/Reports/SlaReport.vue +162 -166
  138. package/src/pages/Admin/Reports/SlaTrends.vue +183 -0
  139. package/src/pages/Admin/Reports.vue +206 -206
  140. package/src/pages/Admin/Roles/Form.vue +71 -71
  141. package/src/pages/Admin/Roles/Index.vue +122 -122
  142. package/src/pages/Admin/Settings/CsatSettings.vue +165 -165
  143. package/src/pages/Admin/Settings/DataRetention.vue +182 -182
  144. package/src/pages/Admin/Settings/EmailSettings.vue +243 -243
  145. package/src/pages/Admin/Settings/Sandbox.vue +135 -135
  146. package/src/pages/Admin/Settings/SsoSettings.vue +218 -218
  147. package/src/pages/Admin/Settings/TwoFactor.vue +110 -110
  148. package/src/pages/Admin/Settings.vue +1008 -468
  149. package/src/pages/Admin/Skills/Form.vue +53 -53
  150. package/src/pages/Admin/Skills/Index.vue +94 -94
  151. package/src/pages/Admin/SlaPolicies/Form.vue +118 -118
  152. package/src/pages/Admin/SlaPolicies/Index.vue +121 -120
  153. package/src/pages/Admin/Statuses/Form.vue +114 -114
  154. package/src/pages/Admin/Statuses/Index.vue +153 -153
  155. package/src/pages/Admin/Tags/Index.vue +191 -191
  156. package/src/pages/Admin/Tickets/Index.vue +126 -91
  157. package/src/pages/Admin/Tickets/Show.vue +29 -0
  158. package/src/pages/Admin/Webhooks/DeliveryLog.vue +120 -120
  159. package/src/pages/Admin/Webhooks/Form.vue +110 -110
  160. package/src/pages/Admin/Webhooks/Index.vue +136 -136
  161. package/src/pages/Admin/Workflows/Builder.vue +207 -0
  162. package/src/pages/Admin/Workflows/Index.vue +235 -0
  163. package/src/pages/Admin/Workflows/Logs.vue +286 -0
  164. package/src/pages/Agent/TicketIndex.vue +112 -88
  165. package/src/pages/Agent/TicketShow.vue +192 -60
  166. package/src/pages/Customer/Create.vue +1 -1
  167. package/src/pages/Customer/Index.vue +31 -31
  168. package/src/pages/Customer/KnowledgeBase/Article.vue +4 -2
  169. package/src/pages/Customer/KnowledgeBase/Index.vue +151 -151
  170. package/src/pages/Customer/Show.vue +69 -63
  171. package/src/pages/Guest/Create.vue +40 -15
  172. package/src/pages/Guest/Show.vue +124 -93
  173. package/src/plugin.js +25 -0
  174. package/src/utils/mentionHighlight.js +24 -0
  175. package/src/utils/sanitizeHtml.js +78 -64
  176. package/src/widget/EscalatedWidget.vue +804 -0
  177. package/src/widget/index.js +254 -0
  178. package/src/pages/Admin/Automations/Form.vue +0 -184
  179. package/src/pages/Admin/Automations/Index.vue +0 -129
@@ -0,0 +1,804 @@
1
+ <script setup>
2
+ import { ref, computed, watch, onMounted } from 'vue';
3
+ import { sanitizeHtml } from '../utils/sanitizeHtml';
4
+
5
+ const props = defineProps({
6
+ baseUrl: { type: String, default: '' },
7
+ // Path prefix the host framework mounts widget routes under. Defaults
8
+ // to /support/widget which matches every host-adapter plugin
9
+ // (Laravel / Rails / Django / Adonis / WordPress / Filament /
10
+ // Symfony / .NET / Go / Spring / Phoenix). The NestJS reference
11
+ // mounts at /escalated/widget, so embedders on a NestJS backend
12
+ // should override this with data-widget-path="/escalated/widget".
13
+ widgetPath: { type: String, default: '/support/widget' },
14
+ initialColor: { type: String, default: '#4F46E5' },
15
+ initialPosition: { type: String, default: 'bottom-right' },
16
+ });
17
+
18
+ const isOpen = ref(false);
19
+ const activeTab = ref('help');
20
+ const error = ref('');
21
+ const widgetConfig = ref(null);
22
+
23
+ const color = computed(() => widgetConfig.value?.color || props.initialColor);
24
+ const position = computed(() => widgetConfig.value?.position || props.initialPosition);
25
+ const greeting = computed(() => widgetConfig.value?.greeting || 'Hi there! How can we help?');
26
+ const departments = computed(() => widgetConfig.value?.departments || []);
27
+ const kbEnabled = computed(() => widgetConfig.value?.kb_enabled ?? true);
28
+ const chatAvailable = ref(false);
29
+ const chatAvailabilityMessage = ref('');
30
+
31
+ // Chat state
32
+ const chatPhase = ref('pre'); // pre | live | post
33
+ const chatSession = ref(null);
34
+ const chatMessages = ref([]);
35
+ const chatTypingUser = ref(null);
36
+ const chatAgent = ref(null);
37
+ const chatRating = ref(0);
38
+ const chatComment = ref('');
39
+ const chatRatingSubmitted = ref(false);
40
+ const chatInput = ref('');
41
+ const chatSending = ref(false);
42
+ const chatConnectionStatus = ref('disconnected'); // connected | reconnecting | disconnected
43
+ const preChatForm = ref({ name: '', email: '', department_id: '', message: '' });
44
+ const chatStarting = ref(false);
45
+
46
+ // WebSocket / polling for chat
47
+ let chatPollTimer = null;
48
+ let chatTypingTimer = null;
49
+ let chatReconnectAttempts = 0;
50
+
51
+ const searchQuery = ref('');
52
+ const searchResults = ref([]);
53
+ const searchLoading = ref(false);
54
+ const selectedArticle = ref(null);
55
+ const articleLoading = ref(false);
56
+
57
+ const ticketForm = ref({
58
+ name: '',
59
+ email: '',
60
+ subject: '',
61
+ description: '',
62
+ department_id: '',
63
+ });
64
+ const ticketSubmitting = ref(false);
65
+ const ticketSuccess = ref(null);
66
+
67
+ const statusForm = ref({ email: '', reference: '' });
68
+ const statusLoading = ref(false);
69
+ const statusResult = ref(null);
70
+
71
+ let searchTimer = null;
72
+
73
+ async function api(method, apiPath, body = null) {
74
+ const url = `${props.baseUrl}${props.widgetPath}${apiPath}`;
75
+ const opts = {
76
+ method,
77
+ headers: {
78
+ Accept: 'application/json',
79
+ 'Content-Type': 'application/json',
80
+ },
81
+ };
82
+ if (body) opts.body = JSON.stringify(body);
83
+ const res = await fetch(url, opts);
84
+ const data = await res.json();
85
+ if (!res.ok) throw { status: res.status, data };
86
+ return data;
87
+ }
88
+
89
+ onMounted(async () => {
90
+ try {
91
+ widgetConfig.value = await api('GET', '/config');
92
+ } catch {
93
+ // Widget may be disabled
94
+ }
95
+ // Check chat availability
96
+ try {
97
+ const avail = await api('GET', '/chat/availability');
98
+ chatAvailable.value = avail.available;
99
+ chatAvailabilityMessage.value = avail.message || '';
100
+ } catch {
101
+ chatAvailable.value = false;
102
+ }
103
+ });
104
+
105
+ // Chat functions
106
+ async function startLiveChat() {
107
+ chatStarting.value = true;
108
+ error.value = '';
109
+ try {
110
+ const session = await api('POST', '/chat/start', {
111
+ name: preChatForm.value.name,
112
+ email: preChatForm.value.email,
113
+ department_id: preChatForm.value.department_id || null,
114
+ message: preChatForm.value.message,
115
+ });
116
+ chatSession.value = session;
117
+ chatPhase.value = 'live';
118
+ chatMessages.value = session.messages || [];
119
+ if (session.agent) {
120
+ chatAgent.value = session.agent;
121
+ }
122
+ chatConnectionStatus.value = 'connected';
123
+ startChatPolling();
124
+ } catch (e) {
125
+ if (e.data?.errors) {
126
+ error.value = Object.values(e.data.errors).flat().join(' ');
127
+ } else {
128
+ error.value = e.data?.message || 'Failed to start chat.';
129
+ }
130
+ } finally {
131
+ chatStarting.value = false;
132
+ }
133
+ }
134
+
135
+ async function sendChatMessage() {
136
+ if (!chatInput.value.trim() || chatSending.value || !chatSession.value) return;
137
+ chatSending.value = true;
138
+ const body = chatInput.value;
139
+ chatInput.value = '';
140
+
141
+ // Optimistic add
142
+ chatMessages.value.push({
143
+ id: Date.now(),
144
+ body,
145
+ is_agent: false,
146
+ author: { name: preChatForm.value.name || 'You' },
147
+ created_at: new Date().toISOString(),
148
+ });
149
+
150
+ try {
151
+ await api('POST', `/chat/${chatSession.value.id}/messages`, { body });
152
+ } catch {
153
+ error.value = 'Failed to send message.';
154
+ } finally {
155
+ chatSending.value = false;
156
+ }
157
+ }
158
+
159
+ function onChatKeydown(e) {
160
+ if (e.key === 'Enter' && !e.shiftKey) {
161
+ e.preventDefault();
162
+ sendChatMessage();
163
+ }
164
+ }
165
+
166
+ let lastWidgetTypingSent = 0;
167
+ async function onChatInput() {
168
+ if (!chatSession.value) return;
169
+ const now = Date.now();
170
+ if (now - lastWidgetTypingSent < 3000) return;
171
+ lastWidgetTypingSent = now;
172
+ try {
173
+ await api('POST', `/chat/${chatSession.value.id}/typing`);
174
+ } catch {
175
+ // ignore
176
+ }
177
+ }
178
+
179
+ function startChatPolling() {
180
+ // Poll for new messages every 3 seconds as fallback
181
+ stopChatPolling();
182
+ chatPollTimer = setInterval(async () => {
183
+ if (!chatSession.value) return;
184
+ try {
185
+ const data = await api('GET', `/chat/${chatSession.value.id}/messages`);
186
+ if (data.messages) {
187
+ chatMessages.value = data.messages;
188
+ }
189
+ if (data.typing) {
190
+ chatTypingUser.value = data.typing;
191
+ clearTimeout(chatTypingTimer);
192
+ chatTypingTimer = setTimeout(() => {
193
+ chatTypingUser.value = null;
194
+ }, 4000);
195
+ }
196
+ if (data.agent) {
197
+ chatAgent.value = data.agent;
198
+ }
199
+ if (data.ended) {
200
+ chatPhase.value = 'post';
201
+ stopChatPolling();
202
+ }
203
+ chatConnectionStatus.value = 'connected';
204
+ chatReconnectAttempts = 0;
205
+ } catch {
206
+ chatReconnectAttempts++;
207
+ if (chatReconnectAttempts > 3) {
208
+ chatConnectionStatus.value = 'disconnected';
209
+ } else {
210
+ chatConnectionStatus.value = 'reconnecting';
211
+ }
212
+ }
213
+ }, 3000);
214
+ }
215
+
216
+ function stopChatPolling() {
217
+ if (chatPollTimer) {
218
+ clearInterval(chatPollTimer);
219
+ chatPollTimer = null;
220
+ }
221
+ }
222
+
223
+ async function endLiveChat() {
224
+ if (!chatSession.value) return;
225
+ try {
226
+ await api('POST', `/chat/${chatSession.value.id}/end`);
227
+ } catch {
228
+ // ignore
229
+ }
230
+ chatPhase.value = 'post';
231
+ stopChatPolling();
232
+ }
233
+
234
+ async function submitChatRating() {
235
+ if (!chatSession.value || !chatRating.value) return;
236
+ try {
237
+ await api('POST', `/chat/${chatSession.value.id}/rate`, {
238
+ rating: chatRating.value,
239
+ comment: chatComment.value,
240
+ });
241
+ chatRatingSubmitted.value = true;
242
+ } catch {
243
+ error.value = 'Failed to submit rating.';
244
+ }
245
+ }
246
+
247
+ function startNewChat() {
248
+ chatPhase.value = 'pre';
249
+ chatSession.value = null;
250
+ chatMessages.value = [];
251
+ chatAgent.value = null;
252
+ chatRating.value = 0;
253
+ chatComment.value = '';
254
+ chatRatingSubmitted.value = false;
255
+ chatInput.value = '';
256
+ chatConnectionStatus.value = 'disconnected';
257
+ preChatForm.value = { name: '', email: '', department_id: '', message: '' };
258
+ }
259
+
260
+ function chatBubbleInitials(name) {
261
+ return (name || 'U')
262
+ .split(' ')
263
+ .map((w) => w[0])
264
+ .join('')
265
+ .toUpperCase()
266
+ .slice(0, 2);
267
+ }
268
+
269
+ watch(searchQuery, (q) => {
270
+ clearTimeout(searchTimer);
271
+ if (!q || q.length < 2) {
272
+ searchResults.value = [];
273
+ return;
274
+ }
275
+ searchTimer = setTimeout(async () => {
276
+ searchLoading.value = true;
277
+ error.value = '';
278
+ try {
279
+ const data = await api('GET', `/articles?q=${encodeURIComponent(q)}`);
280
+ searchResults.value = data.articles || [];
281
+ } catch {
282
+ searchResults.value = [];
283
+ } finally {
284
+ searchLoading.value = false;
285
+ }
286
+ }, 300);
287
+ });
288
+
289
+ async function viewArticle(slug) {
290
+ articleLoading.value = true;
291
+ error.value = '';
292
+ try {
293
+ selectedArticle.value = await api('GET', `/articles/${slug}`);
294
+ } catch {
295
+ error.value = 'Could not load article.';
296
+ } finally {
297
+ articleLoading.value = false;
298
+ }
299
+ }
300
+
301
+ function backToSearch() {
302
+ selectedArticle.value = null;
303
+ }
304
+
305
+ async function submitTicket() {
306
+ ticketSubmitting.value = true;
307
+ error.value = '';
308
+ try {
309
+ const data = await api('POST', '/tickets', {
310
+ name: ticketForm.value.name,
311
+ email: ticketForm.value.email,
312
+ subject: ticketForm.value.subject,
313
+ description: ticketForm.value.description,
314
+ department_id: ticketForm.value.department_id || null,
315
+ });
316
+ ticketSuccess.value = data;
317
+ ticketForm.value = {
318
+ name: '',
319
+ email: '',
320
+ subject: '',
321
+ description: '',
322
+ department_id: '',
323
+ };
324
+ } catch (e) {
325
+ if (e.data?.errors) {
326
+ error.value = Object.values(e.data.errors).flat().join(' ');
327
+ } else {
328
+ error.value = e.data?.message || 'Failed to submit ticket.';
329
+ }
330
+ } finally {
331
+ ticketSubmitting.value = false;
332
+ }
333
+ }
334
+
335
+ async function checkStatus() {
336
+ statusLoading.value = true;
337
+ error.value = '';
338
+ statusResult.value = null;
339
+ try {
340
+ const data = await api(
341
+ 'GET',
342
+ `/tickets/${encodeURIComponent(statusForm.value.reference)}?email=${encodeURIComponent(statusForm.value.email)}`,
343
+ );
344
+ statusResult.value = data;
345
+ } catch (e) {
346
+ error.value = e.data?.message || 'Ticket not found.';
347
+ } finally {
348
+ statusLoading.value = false;
349
+ }
350
+ }
351
+
352
+ function togglePanel() {
353
+ isOpen.value = !isOpen.value;
354
+ error.value = '';
355
+ }
356
+
357
+ function formatDate(iso) {
358
+ try {
359
+ return new Date(iso).toLocaleDateString(undefined, {
360
+ month: 'short',
361
+ day: 'numeric',
362
+ hour: '2-digit',
363
+ minute: '2-digit',
364
+ });
365
+ } catch {
366
+ return iso;
367
+ }
368
+ }
369
+ </script>
370
+
371
+ <template>
372
+ <button
373
+ class="esc-w-fab"
374
+ :class="position"
375
+ :style="{ backgroundColor: color }"
376
+ :aria-label="isOpen ? 'Close support widget' : 'Open support widget'"
377
+ @click="togglePanel"
378
+ >
379
+ <svg v-if="!isOpen" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
380
+ <path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" />
381
+ <path d="M7 9h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z" />
382
+ </svg>
383
+ <svg v-else viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
384
+ <path
385
+ d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
386
+ />
387
+ </svg>
388
+ </button>
389
+
390
+ <div
391
+ class="esc-w-panel"
392
+ :class="[position, { 'esc-open': isOpen }]"
393
+ :style="{ '--esc-widget-color': color }"
394
+ data-widget-panel
395
+ >
396
+ <div class="esc-w-header" :style="{ backgroundColor: color }">
397
+ <h2>Support</h2>
398
+ <p>{{ greeting }}</p>
399
+ </div>
400
+
401
+ <div class="esc-w-tabs">
402
+ <button
403
+ v-if="kbEnabled"
404
+ class="esc-w-tab"
405
+ :class="{ active: activeTab === 'help' }"
406
+ @click="
407
+ activeTab = 'help';
408
+ error = '';
409
+ "
410
+ >
411
+ Help
412
+ </button>
413
+ <button
414
+ class="esc-w-tab"
415
+ :class="{ active: activeTab === 'contact' }"
416
+ @click="
417
+ activeTab = 'contact';
418
+ error = '';
419
+ ticketSuccess = null;
420
+ "
421
+ >
422
+ Contact
423
+ </button>
424
+ <button
425
+ v-if="chatAvailable"
426
+ class="esc-w-tab"
427
+ :class="{ active: activeTab === 'chat' }"
428
+ @click="
429
+ activeTab = 'chat';
430
+ error = '';
431
+ "
432
+ >
433
+ Chat
434
+ </button>
435
+ <button
436
+ class="esc-w-tab"
437
+ :class="{ active: activeTab === 'status' }"
438
+ @click="
439
+ activeTab = 'status';
440
+ error = '';
441
+ statusResult = null;
442
+ "
443
+ >
444
+ Check Status
445
+ </button>
446
+ </div>
447
+
448
+ <div class="esc-w-body">
449
+ <template v-if="activeTab === 'help' && kbEnabled">
450
+ <template v-if="!selectedArticle">
451
+ <div class="esc-w-field">
452
+ <input v-model="searchQuery" type="text" class="esc-w-input" placeholder="Search for help..." />
453
+ </div>
454
+ <div v-if="searchLoading" class="esc-w-spinner"></div>
455
+ <div v-else-if="searchResults.length">
456
+ <div
457
+ v-for="article in searchResults"
458
+ :key="article.slug"
459
+ class="esc-w-article"
460
+ @click="viewArticle(article.slug)"
461
+ >
462
+ <h4>{{ article.title }}</h4>
463
+ <p>{{ article.excerpt }}</p>
464
+ </div>
465
+ </div>
466
+ <div v-else-if="searchQuery.length >= 2 && !searchLoading" class="esc-w-empty">
467
+ No articles found.
468
+ </div>
469
+ </template>
470
+ <template v-else>
471
+ <div class="esc-w-article-detail">
472
+ <button class="esc-w-back" @click="backToSearch">
473
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
474
+ <path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
475
+ </svg>
476
+ Back
477
+ </button>
478
+ <div v-if="articleLoading" class="esc-w-spinner"></div>
479
+ <template v-else>
480
+ <h3>{{ selectedArticle.title }}</h3>
481
+ <!-- eslint-disable-next-line vue/no-v-html -->
482
+ <div class="esc-w-article-body" v-html="sanitizeHtml(selectedArticle.body)"></div>
483
+ </template>
484
+ </div>
485
+ </template>
486
+ </template>
487
+
488
+ <template v-if="activeTab === 'contact'">
489
+ <template v-if="ticketSuccess">
490
+ <div class="esc-w-success">
491
+ <svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
492
+ <path
493
+ d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"
494
+ />
495
+ </svg>
496
+ <h4>Ticket Submitted</h4>
497
+ <p>We'll get back to you shortly.</p>
498
+ <span class="esc-w-ref">{{ ticketSuccess.reference }}</span>
499
+ </div>
500
+ </template>
501
+ <template v-else>
502
+ <div v-if="error" class="esc-w-error">{{ error }}</div>
503
+ <form @submit.prevent="submitTicket">
504
+ <div class="esc-w-field">
505
+ <label class="esc-w-label">Name</label>
506
+ <input v-model="ticketForm.name" type="text" class="esc-w-input" required />
507
+ </div>
508
+ <div class="esc-w-field">
509
+ <label class="esc-w-label">Email</label>
510
+ <input v-model="ticketForm.email" type="email" class="esc-w-input" required />
511
+ </div>
512
+ <div class="esc-w-field">
513
+ <label class="esc-w-label">Subject</label>
514
+ <input v-model="ticketForm.subject" type="text" class="esc-w-input" required />
515
+ </div>
516
+ <div class="esc-w-field">
517
+ <label class="esc-w-label">Description</label>
518
+ <textarea v-model="ticketForm.description" class="esc-w-textarea" required></textarea>
519
+ </div>
520
+ <div v-if="departments.length" class="esc-w-field">
521
+ <label class="esc-w-label">Department</label>
522
+ <select v-model="ticketForm.department_id" class="esc-w-select">
523
+ <option value="">Select a department...</option>
524
+ <option v-for="dept in departments" :key="dept.id" :value="dept.id">
525
+ {{ dept.name }}
526
+ </option>
527
+ </select>
528
+ </div>
529
+ <button
530
+ type="submit"
531
+ class="esc-w-btn"
532
+ :style="{ backgroundColor: color }"
533
+ :disabled="ticketSubmitting"
534
+ >
535
+ {{ ticketSubmitting ? 'Submitting...' : 'Submit Ticket' }}
536
+ </button>
537
+ </form>
538
+ </template>
539
+ </template>
540
+
541
+ <!-- Chat Tab -->
542
+ <template v-if="activeTab === 'chat'">
543
+ <!-- Pre-chat form -->
544
+ <template v-if="chatPhase === 'pre'">
545
+ <div class="esc-w-chat-avail">
546
+ <span class="esc-w-chat-dot-green"></span>
547
+ {{ chatAvailabilityMessage || 'Chat with us — typically replies in under 2 min' }}
548
+ </div>
549
+ <div v-if="error" class="esc-w-error">{{ error }}</div>
550
+ <form @submit.prevent="startLiveChat">
551
+ <div class="esc-w-field">
552
+ <label class="esc-w-label">Name</label>
553
+ <input v-model="preChatForm.name" type="text" class="esc-w-input" required />
554
+ </div>
555
+ <div class="esc-w-field">
556
+ <label class="esc-w-label">Email</label>
557
+ <input v-model="preChatForm.email" type="email" class="esc-w-input" required />
558
+ </div>
559
+ <div v-if="departments.length > 1" class="esc-w-field">
560
+ <label class="esc-w-label">Department</label>
561
+ <select v-model="preChatForm.department_id" class="esc-w-select">
562
+ <option value="">Select a department...</option>
563
+ <option v-for="dept in departments" :key="dept.id" :value="dept.id">
564
+ {{ dept.name }}
565
+ </option>
566
+ </select>
567
+ </div>
568
+ <div class="esc-w-field">
569
+ <label class="esc-w-label">Message (optional)</label>
570
+ <textarea v-model="preChatForm.message" class="esc-w-textarea"></textarea>
571
+ </div>
572
+ <button
573
+ type="submit"
574
+ class="esc-w-btn"
575
+ :style="{ backgroundColor: color }"
576
+ :disabled="chatStarting"
577
+ >
578
+ {{ chatStarting ? 'Starting...' : 'Start Chat' }}
579
+ </button>
580
+ </form>
581
+ </template>
582
+
583
+ <!-- Live chat -->
584
+ <template v-else-if="chatPhase === 'live'">
585
+ <div class="esc-w-chat-header-bar">
586
+ <div class="esc-w-chat-agent-info">
587
+ <div v-if="chatAgent" class="esc-w-chat-agent-avatar">
588
+ {{ chatBubbleInitials(chatAgent.name) }}
589
+ </div>
590
+ <span v-if="chatAgent">Chatting with {{ chatAgent.name }}</span>
591
+ <span v-else>Waiting for agent...</span>
592
+ </div>
593
+ <div class="esc-w-chat-header-actions">
594
+ <span :class="['esc-w-chat-conn-dot', 'esc-w-chat-conn-' + chatConnectionStatus]"></span>
595
+ <button class="esc-w-chat-end-link" @click="endLiveChat">End</button>
596
+ </div>
597
+ </div>
598
+ <div id="esc-chat-messages" class="esc-w-chat-messages">
599
+ <div
600
+ v-for="msg in chatMessages"
601
+ :key="msg.id"
602
+ :class="[
603
+ 'esc-w-chat-bubble-row',
604
+ msg.is_agent ? 'esc-w-chat-agent' : 'esc-w-chat-customer',
605
+ ]"
606
+ >
607
+ <div v-if="!msg.is_agent" class="esc-w-chat-avatar-sm">
608
+ {{ chatBubbleInitials(msg.author?.name) }}
609
+ </div>
610
+ <div
611
+ :class="[
612
+ 'esc-w-chat-bubble',
613
+ msg.is_agent ? 'esc-w-chat-bubble-agent' : 'esc-w-chat-bubble-customer',
614
+ ]"
615
+ :style="msg.is_agent ? { backgroundColor: color } : {}"
616
+ >
617
+ {{ msg.body }}
618
+ </div>
619
+ <div v-if="msg.is_agent" class="esc-w-chat-avatar-sm esc-w-chat-avatar-agent">
620
+ {{ chatBubbleInitials(msg.author?.name) }}
621
+ </div>
622
+ </div>
623
+ <div v-if="chatTypingUser" class="esc-w-chat-typing">
624
+ <span class="esc-w-chat-typing-dots"><span></span><span></span><span></span></span>
625
+ {{ chatTypingUser }} is typing...
626
+ </div>
627
+ <div v-if="!chatMessages.length && !chatTypingUser" class="esc-w-empty">
628
+ Chat started — waiting for messages
629
+ </div>
630
+ </div>
631
+ <div class="esc-w-chat-input-bar">
632
+ <input
633
+ v-model="chatInput"
634
+ type="text"
635
+ class="esc-w-chat-input"
636
+ placeholder="Type a message..."
637
+ @keydown="onChatKeydown"
638
+ @input="onChatInput"
639
+ />
640
+ <button
641
+ class="esc-w-chat-send"
642
+ :style="{ backgroundColor: color }"
643
+ :disabled="!chatInput.trim() || chatSending"
644
+ @click="sendChatMessage"
645
+ >
646
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
647
+ <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
648
+ </svg>
649
+ </button>
650
+ </div>
651
+ </template>
652
+
653
+ <!-- Post-chat -->
654
+ <template v-else-if="chatPhase === 'post'">
655
+ <template v-if="!chatRatingSubmitted">
656
+ <div class="esc-w-success">
657
+ <h4>Chat Ended</h4>
658
+ <p>How was your experience?</p>
659
+ </div>
660
+ <div class="esc-w-chat-rating">
661
+ <button
662
+ v-for="star in 5"
663
+ :key="star"
664
+ :class="['esc-w-chat-star', chatRating >= star ? 'esc-w-chat-star-active' : '']"
665
+ @click="chatRating = star"
666
+ >
667
+ <svg viewBox="0 0 24 24" fill="currentColor" width="28" height="28">
668
+ <path
669
+ d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"
670
+ />
671
+ </svg>
672
+ </button>
673
+ </div>
674
+ <div class="esc-w-field">
675
+ <textarea
676
+ v-model="chatComment"
677
+ class="esc-w-textarea"
678
+ placeholder="Any additional feedback? (optional)"
679
+ ></textarea>
680
+ </div>
681
+ <button
682
+ class="esc-w-btn"
683
+ :style="{ backgroundColor: color }"
684
+ :disabled="!chatRating"
685
+ @click="submitChatRating"
686
+ >
687
+ Submit Rating
688
+ </button>
689
+ <button class="esc-w-back" style="margin-top: 12px" @click="startNewChat">
690
+ Start New Chat
691
+ </button>
692
+ </template>
693
+ <template v-else>
694
+ <div class="esc-w-success">
695
+ <svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
696
+ <path
697
+ d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"
698
+ />
699
+ </svg>
700
+ <h4>Thank you!</h4>
701
+ <p>Your feedback has been submitted.</p>
702
+ </div>
703
+ <button class="esc-w-btn" :style="{ backgroundColor: color }" @click="startNewChat">
704
+ Start New Chat
705
+ </button>
706
+ <button class="esc-w-back" style="margin-top: 12px" @click="activeTab = 'status'">Close</button>
707
+ </template>
708
+ </template>
709
+ </template>
710
+
711
+ <template v-if="activeTab === 'status'">
712
+ <div v-if="error" class="esc-w-error">{{ error }}</div>
713
+ <template v-if="!statusResult">
714
+ <form @submit.prevent="checkStatus">
715
+ <div class="esc-w-field">
716
+ <label class="esc-w-label">Email Address</label>
717
+ <input
718
+ v-model="statusForm.email"
719
+ type="email"
720
+ class="esc-w-input"
721
+ placeholder="you@example.com"
722
+ required
723
+ />
724
+ </div>
725
+ <div class="esc-w-field">
726
+ <label class="esc-w-label">Ticket Reference</label>
727
+ <input
728
+ v-model="statusForm.reference"
729
+ type="text"
730
+ class="esc-w-input"
731
+ placeholder="ESC-00001"
732
+ required
733
+ />
734
+ </div>
735
+ <button
736
+ type="submit"
737
+ class="esc-w-btn"
738
+ :style="{ backgroundColor: color }"
739
+ :disabled="statusLoading"
740
+ >
741
+ {{ statusLoading ? 'Checking...' : 'Check Status' }}
742
+ </button>
743
+ </form>
744
+ </template>
745
+ <template v-else>
746
+ <button
747
+ class="esc-w-back"
748
+ @click="
749
+ statusResult = null;
750
+ error = '';
751
+ "
752
+ >
753
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
754
+ <path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
755
+ </svg>
756
+ Back
757
+ </button>
758
+ <div class="esc-w-status-card">
759
+ <div
760
+ style="
761
+ display: flex;
762
+ justify-content: space-between;
763
+ align-items: flex-start;
764
+ margin-bottom: 8px;
765
+ "
766
+ >
767
+ <h4>{{ statusResult.subject }}</h4>
768
+ <span
769
+ class="esc-w-status-badge"
770
+ :style="{
771
+ backgroundColor: statusResult.status_color,
772
+ }"
773
+ >
774
+ {{ statusResult.status_label }}
775
+ </span>
776
+ </div>
777
+ <div style="font-size: 12px; color: #6b7280">
778
+ <span>{{ statusResult.reference }}</span>
779
+ <span v-if="statusResult.department"> &middot; {{ statusResult.department }}</span>
780
+ <span>
781
+ &middot;
782
+ {{ formatDate(statusResult.created_at) }}</span
783
+ >
784
+ </div>
785
+ </div>
786
+ <div v-if="statusResult.replies?.length">
787
+ <div v-for="(reply, i) in statusResult.replies" :key="i" class="esc-w-reply">
788
+ <div style="display: flex; justify-content: space-between; align-items: center">
789
+ <span>
790
+ <span class="esc-w-reply-author">{{ reply.author }}</span>
791
+ <span v-if="reply.is_agent" class="esc-w-reply-agent">Staff</span>
792
+ </span>
793
+ <span class="esc-w-reply-time">{{ formatDate(reply.created_at) }}</span>
794
+ </div>
795
+ <!-- eslint-disable-next-line vue/no-v-html -->
796
+ <div class="esc-w-reply-body" v-html="sanitizeHtml(reply.body)"></div>
797
+ </div>
798
+ </div>
799
+ <div v-else class="esc-w-empty">No replies yet.</div>
800
+ </template>
801
+ </template>
802
+ </div>
803
+ </div>
804
+ </template>