@escalated-dev/escalated 0.6.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 (187) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +125 -2
  3. package/package.json +40 -5
  4. package/src/components/ActiveChatsPanel.vue +246 -0
  5. package/src/components/ActivityTimeline.stories.js +55 -0
  6. package/src/components/ActivityTimeline.vue +83 -66
  7. package/src/components/AdminDashboard.stories.js +1423 -0
  8. package/src/components/AgentLoadIndicator.stories.js +37 -0
  9. package/src/components/AgentLoadIndicator.vue +49 -0
  10. package/src/components/AgentPerformanceCard.vue +44 -0
  11. package/src/components/ArticleEditor.vue +82 -0
  12. package/src/components/AssigneeSelect.vue +37 -23
  13. package/src/components/AttachmentList.vue +65 -37
  14. package/src/components/AuditLogEntry.vue +95 -0
  15. package/src/components/BulkActionBar.vue +166 -97
  16. package/src/components/CategoryTree.vue +89 -0
  17. package/src/components/ChartWidget.vue +46 -0
  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 -0
  24. package/src/components/CollisionWarning.vue +68 -0
  25. package/src/components/ConditionalFieldRules.vue +135 -0
  26. package/src/components/ContextPanel.vue +66 -0
  27. package/src/components/ContextPanelSection.vue +65 -0
  28. package/src/components/CustomFieldRenderer.vue +159 -0
  29. package/src/components/EscalatedLayout.vue +424 -70
  30. package/src/components/FileDropzone.vue +57 -41
  31. package/src/components/FollowButton.stories.js +30 -0
  32. package/src/components/FollowButton.vue +88 -63
  33. package/src/components/KeyboardShortcutHelp.vue +213 -123
  34. package/src/components/KnowledgePanel.vue +122 -0
  35. package/src/components/KpiCard.stories.js +32 -0
  36. package/src/components/KpiCard.vue +88 -0
  37. package/src/components/MacroDropdown.vue +150 -90
  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 -0
  43. package/src/components/PinnedNotes.vue +129 -90
  44. package/src/components/PluginSlot.vue +68 -62
  45. package/src/components/PresenceIndicator.vue +138 -28
  46. package/src/components/PriorityBadge.stories.js +33 -0
  47. package/src/components/PriorityBadge.vue +46 -39
  48. package/src/components/QuickFilters.vue +65 -49
  49. package/src/components/ReplyComposer.vue +207 -30
  50. package/src/components/ReplyThread.vue +111 -21
  51. package/src/components/ReportChart.vue +395 -0
  52. package/src/components/ReportExportButton.vue +108 -0
  53. package/src/components/SatisfactionRating.vue +180 -128
  54. package/src/components/SaveViewDialog.vue +172 -0
  55. package/src/components/SavedViewSidebar.vue +208 -0
  56. package/src/components/ScheduleEditor.vue +104 -0
  57. package/src/components/SideConversation.vue +165 -0
  58. package/src/components/SideConversationList.vue +147 -0
  59. package/src/components/SkillTagManager.vue +79 -0
  60. package/src/components/SlaComplianceChart.vue +46 -0
  61. package/src/components/SlaTimer.stories.js +35 -0
  62. package/src/components/SlaTimer.vue +62 -49
  63. package/src/components/SnoozeButton.vue +247 -0
  64. package/src/components/StatsCard.stories.js +24 -0
  65. package/src/components/StatsCard.vue +18 -6
  66. package/src/components/StatusBadge.stories.js +53 -0
  67. package/src/components/StatusBadge.vue +78 -46
  68. package/src/components/TagSelect.vue +84 -53
  69. package/src/components/TicketFilters.vue +247 -46
  70. package/src/components/TicketLinkPanel.vue +193 -0
  71. package/src/components/TicketList.vue +536 -74
  72. package/src/components/TicketMergeDialog.vue +157 -0
  73. package/src/components/TicketSidebar.vue +167 -99
  74. package/src/components/TicketSplitDialog.vue +98 -0
  75. package/src/components/TicketTypeSelector.vue +21 -0
  76. package/src/components/TotpInput.stories.js +9 -0
  77. package/src/components/TotpInput.vue +81 -0
  78. package/src/components/TwoFactorSetup.vue +113 -0
  79. package/src/components/VariableTokenInput.vue +111 -0
  80. package/src/components/WorkflowActionConfig.vue +287 -0
  81. package/src/components/WorkflowActionList.vue +350 -0
  82. package/src/components/WorkflowConditionBuilder.vue +265 -0
  83. package/src/components/WorkflowTriggerSelector.vue +125 -0
  84. package/src/composables/useChat.js +187 -0
  85. package/src/composables/useDebouncedSearch.js +38 -0
  86. package/src/composables/useI18n.js +80 -80
  87. package/src/composables/useKeyboardShortcuts.js +46 -46
  88. package/src/composables/useMentions.js +134 -0
  89. package/src/composables/usePluginExtensions.js +159 -67
  90. package/src/composables/usePluginHooks.js +65 -0
  91. package/src/composables/useRealtime.js +182 -0
  92. package/src/defineEscalatedPlugin.js +76 -50
  93. package/src/index.js +77 -28
  94. package/src/locales/ar.json +727 -0
  95. package/src/locales/de.json +678 -430
  96. package/src/locales/en.json +717 -430
  97. package/src/locales/es.json +679 -430
  98. package/src/locales/fr.json +680 -431
  99. package/src/locales/index.js +12 -2
  100. package/src/locales/it.json +727 -0
  101. package/src/locales/ja.json +727 -0
  102. package/src/locales/ko.json +727 -0
  103. package/src/locales/nl.json +727 -0
  104. package/src/locales/pl.json +727 -0
  105. package/src/locales/pt-BR.json +727 -0
  106. package/src/locales/ru.json +727 -0
  107. package/src/locales/tr.json +727 -0
  108. package/src/locales/zh-CN.json +727 -0
  109. package/src/pages/Admin/ApiTokens/Index.vue +381 -217
  110. package/src/pages/Admin/AuditLog/Index.vue +154 -0
  111. package/src/pages/Admin/BusinessHours/Form.vue +172 -0
  112. package/src/pages/Admin/BusinessHours/Index.vue +113 -0
  113. package/src/pages/Admin/CannedResponses/Index.vue +82 -25
  114. package/src/pages/Admin/Capacity/Index.vue +134 -0
  115. package/src/pages/Admin/CustomFields/Form.vue +195 -0
  116. package/src/pages/Admin/CustomFields/Index.vue +134 -0
  117. package/src/pages/Admin/CustomObjects/Form.vue +202 -0
  118. package/src/pages/Admin/CustomObjects/Index.vue +127 -0
  119. package/src/pages/Admin/CustomObjects/Records.vue +228 -0
  120. package/src/pages/Admin/Departments/Form.vue +35 -11
  121. package/src/pages/Admin/Departments/Index.vue +74 -21
  122. package/src/pages/Admin/EscalationRules/Form.vue +86 -25
  123. package/src/pages/Admin/EscalationRules/Index.vue +158 -37
  124. package/src/pages/Admin/Import/Connect.vue +95 -0
  125. package/src/pages/Admin/Import/Index.vue +102 -0
  126. package/src/pages/Admin/Import/Mapping.vue +110 -0
  127. package/src/pages/Admin/Import/Progress.vue +153 -0
  128. package/src/pages/Admin/Import/Review.vue +76 -0
  129. package/src/pages/Admin/KnowledgeBase/Articles/Form.vue +142 -0
  130. package/src/pages/Admin/KnowledgeBase/Articles/Index.vue +195 -0
  131. package/src/pages/Admin/KnowledgeBase/Categories/Index.vue +193 -0
  132. package/src/pages/Admin/Macros/Index.vue +448 -287
  133. package/src/pages/Admin/Plugins/Index.vue +327 -197
  134. package/src/pages/Admin/Reports/AgentDetail.vue +132 -0
  135. package/src/pages/Admin/Reports/AgentMetrics.vue +115 -0
  136. package/src/pages/Admin/Reports/AgentRanking.vue +246 -0
  137. package/src/pages/Admin/Reports/Cohorts.vue +180 -0
  138. package/src/pages/Admin/Reports/Comparison.vue +141 -0
  139. package/src/pages/Admin/Reports/CsatReport.vue +127 -0
  140. package/src/pages/Admin/Reports/Dashboard.vue +226 -0
  141. package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -0
  142. package/src/pages/Admin/Reports/ResponseTimes.vue +164 -0
  143. package/src/pages/Admin/Reports/SlaReport.vue +162 -0
  144. package/src/pages/Admin/Reports/SlaTrends.vue +183 -0
  145. package/src/pages/Admin/Reports.vue +206 -101
  146. package/src/pages/Admin/Roles/Form.vue +71 -0
  147. package/src/pages/Admin/Roles/Index.vue +122 -0
  148. package/src/pages/Admin/Settings/CsatSettings.vue +165 -0
  149. package/src/pages/Admin/Settings/DataRetention.vue +182 -0
  150. package/src/pages/Admin/Settings/EmailSettings.vue +243 -0
  151. package/src/pages/Admin/Settings/Sandbox.vue +135 -0
  152. package/src/pages/Admin/Settings/SsoSettings.vue +218 -0
  153. package/src/pages/Admin/Settings/TwoFactor.vue +110 -0
  154. package/src/pages/Admin/Settings.vue +847 -99
  155. package/src/pages/Admin/Skills/Form.vue +53 -0
  156. package/src/pages/Admin/Skills/Index.vue +94 -0
  157. package/src/pages/Admin/SlaPolicies/Form.vue +62 -19
  158. package/src/pages/Admin/SlaPolicies/Index.vue +85 -23
  159. package/src/pages/Admin/Statuses/Form.vue +114 -0
  160. package/src/pages/Admin/Statuses/Index.vue +153 -0
  161. package/src/pages/Admin/Tags/Index.vue +121 -35
  162. package/src/pages/Admin/Tickets/Index.vue +96 -9
  163. package/src/pages/Admin/Tickets/Show.vue +130 -37
  164. package/src/pages/Admin/Webhooks/DeliveryLog.vue +120 -0
  165. package/src/pages/Admin/Webhooks/Form.vue +110 -0
  166. package/src/pages/Admin/Webhooks/Index.vue +136 -0
  167. package/src/pages/Admin/Workflows/Builder.vue +207 -0
  168. package/src/pages/Admin/Workflows/Index.vue +235 -0
  169. package/src/pages/Admin/Workflows/Logs.vue +286 -0
  170. package/src/pages/Agent/Dashboard.vue +311 -186
  171. package/src/pages/Agent/TicketIndex.vue +80 -6
  172. package/src/pages/Agent/TicketShow.vue +399 -60
  173. package/src/pages/Customer/Create.vue +37 -10
  174. package/src/pages/Customer/Index.vue +9 -2
  175. package/src/pages/Customer/KnowledgeBase/Article.vue +129 -0
  176. package/src/pages/Customer/KnowledgeBase/Index.vue +151 -0
  177. package/src/pages/Customer/Show.vue +69 -63
  178. package/src/pages/Guest/Create.vue +40 -15
  179. package/src/pages/Guest/Show.vue +124 -93
  180. package/src/plugin.js +111 -1
  181. package/src/utils/constants.js +30 -0
  182. package/src/utils/formatting.js +35 -0
  183. package/src/utils/mentionHighlight.js +24 -0
  184. package/src/utils/sanitizeHtml.js +78 -64
  185. package/src/widget/EscalatedWidget.vue +804 -0
  186. package/src/widget/index.js +254 -0
  187. package/src/pages/Admin/Plugins.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>