@escalated-dev/escalated 0.8.0 → 0.9.0

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