@escalated-dev/escalated 0.7.0 → 0.8.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 (181) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +36 -1
  3. package/package.json +16 -7
  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 +35 -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 +83 -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 +30 -8
  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/README.md +13 -0
  91. package/src/locales/ar.json +727 -0
  92. package/src/locales/de.json +678 -430
  93. package/src/locales/en.json +717 -455
  94. package/src/locales/es.json +679 -430
  95. package/src/locales/fr.json +680 -431
  96. package/src/locales/index.js +12 -2
  97. package/src/locales/it.json +727 -0
  98. package/src/locales/ja.json +727 -0
  99. package/src/locales/ko.json +727 -0
  100. package/src/locales/nl.json +727 -0
  101. package/src/locales/pl.json +727 -0
  102. package/src/locales/pt-BR.json +727 -0
  103. package/src/locales/ru.json +727 -0
  104. package/src/locales/tr.json +727 -0
  105. package/src/locales/zh-CN.json +727 -0
  106. package/src/pages/Admin/ApiTokens/Index.vue +381 -381
  107. package/src/pages/Admin/AuditLog/Index.vue +154 -154
  108. package/src/pages/Admin/BusinessHours/Form.vue +172 -172
  109. package/src/pages/Admin/BusinessHours/Index.vue +113 -113
  110. package/src/pages/Admin/CannedResponses/Index.vue +127 -127
  111. package/src/pages/Admin/Capacity/Index.vue +134 -134
  112. package/src/pages/Admin/CustomFields/Form.vue +195 -195
  113. package/src/pages/Admin/CustomFields/Index.vue +134 -134
  114. package/src/pages/Admin/CustomObjects/Form.vue +202 -202
  115. package/src/pages/Admin/CustomObjects/Index.vue +127 -127
  116. package/src/pages/Admin/CustomObjects/Records.vue +228 -228
  117. package/src/pages/Admin/Departments/Form.vue +75 -75
  118. package/src/pages/Admin/Departments/Index.vue +110 -109
  119. package/src/pages/Admin/EscalationRules/Form.vue +157 -157
  120. package/src/pages/Admin/EscalationRules/Index.vue +178 -178
  121. package/src/pages/Admin/Import/Connect.vue +0 -1
  122. package/src/pages/Admin/Import/Index.vue +1 -1
  123. package/src/pages/Admin/Import/Progress.vue +1 -1
  124. package/src/pages/Admin/KnowledgeBase/Articles/Form.vue +142 -142
  125. package/src/pages/Admin/KnowledgeBase/Articles/Index.vue +195 -195
  126. package/src/pages/Admin/KnowledgeBase/Categories/Index.vue +193 -193
  127. package/src/pages/Admin/Macros/Index.vue +448 -448
  128. package/src/pages/Admin/Plugins/Index.vue +327 -327
  129. package/src/pages/Admin/Reports/AgentDetail.vue +132 -0
  130. package/src/pages/Admin/Reports/AgentMetrics.vue +115 -115
  131. package/src/pages/Admin/Reports/AgentRanking.vue +246 -0
  132. package/src/pages/Admin/Reports/Cohorts.vue +180 -0
  133. package/src/pages/Admin/Reports/Comparison.vue +141 -0
  134. package/src/pages/Admin/Reports/CsatReport.vue +127 -127
  135. package/src/pages/Admin/Reports/Dashboard.vue +36 -21
  136. package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -0
  137. package/src/pages/Admin/Reports/ResponseTimes.vue +164 -0
  138. package/src/pages/Admin/Reports/SlaReport.vue +162 -166
  139. package/src/pages/Admin/Reports/SlaTrends.vue +183 -0
  140. package/src/pages/Admin/Reports.vue +206 -206
  141. package/src/pages/Admin/Roles/Form.vue +71 -71
  142. package/src/pages/Admin/Roles/Index.vue +122 -122
  143. package/src/pages/Admin/Settings/CsatSettings.vue +165 -165
  144. package/src/pages/Admin/Settings/DataRetention.vue +182 -182
  145. package/src/pages/Admin/Settings/EmailSettings.vue +243 -243
  146. package/src/pages/Admin/Settings/Sandbox.vue +135 -135
  147. package/src/pages/Admin/Settings/SsoSettings.vue +218 -218
  148. package/src/pages/Admin/Settings/TwoFactor.vue +110 -110
  149. package/src/pages/Admin/Settings.vue +1008 -468
  150. package/src/pages/Admin/Skills/Form.vue +193 -53
  151. package/src/pages/Admin/Skills/Index.vue +105 -94
  152. package/src/pages/Admin/SlaPolicies/Form.vue +118 -118
  153. package/src/pages/Admin/SlaPolicies/Index.vue +121 -120
  154. package/src/pages/Admin/Statuses/Form.vue +114 -114
  155. package/src/pages/Admin/Statuses/Index.vue +153 -153
  156. package/src/pages/Admin/Tags/Index.vue +191 -191
  157. package/src/pages/Admin/Tickets/Index.vue +126 -91
  158. package/src/pages/Admin/Tickets/Show.vue +29 -0
  159. package/src/pages/Admin/Users/Index.vue +145 -0
  160. package/src/pages/Admin/Webhooks/DeliveryLog.vue +120 -120
  161. package/src/pages/Admin/Webhooks/Form.vue +110 -110
  162. package/src/pages/Admin/Webhooks/Index.vue +136 -136
  163. package/src/pages/Admin/Workflows/Builder.vue +207 -0
  164. package/src/pages/Admin/Workflows/Index.vue +235 -0
  165. package/src/pages/Admin/Workflows/Logs.vue +286 -0
  166. package/src/pages/Agent/TicketIndex.vue +112 -88
  167. package/src/pages/Agent/TicketShow.vue +192 -60
  168. package/src/pages/Customer/Create.vue +1 -1
  169. package/src/pages/Customer/Index.vue +26 -1
  170. package/src/pages/Customer/KnowledgeBase/Article.vue +4 -2
  171. package/src/pages/Customer/KnowledgeBase/Index.vue +151 -151
  172. package/src/pages/Customer/Show.vue +69 -63
  173. package/src/pages/Guest/Create.vue +40 -15
  174. package/src/pages/Guest/Show.vue +124 -93
  175. package/src/plugin.js +26 -1
  176. package/src/utils/mentionHighlight.js +24 -0
  177. package/src/utils/sanitizeHtml.js +78 -64
  178. package/src/widget/EscalatedWidget.vue +804 -0
  179. package/src/widget/index.js +254 -0
  180. package/src/pages/Admin/Automations/Form.vue +0 -184
  181. 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>