@escalated-dev/escalated 0.8.0 → 0.11.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.
- package/LICENSE +21 -21
- package/README.md +388 -361
- package/package.json +85 -84
- package/src/components/ActiveChatsPanel.vue +246 -246
- package/src/components/AdminDashboard.stories.js +1423 -1423
- package/src/components/AttachmentList.vue +65 -65
- package/src/components/ChatActionBar.vue +226 -226
- package/src/components/ChatBubble.vue +162 -162
- package/src/components/ChatComposer.vue +308 -308
- package/src/components/ChatQueue.vue +222 -222
- package/src/components/ChatThread.vue +197 -197
- package/src/components/CustomTicketActions.stories.js +71 -0
- package/src/components/CustomTicketActions.vue +101 -0
- package/src/components/EscalatedLayout.vue +628 -604
- package/src/components/FollowButton.vue +20 -1
- package/src/components/MentionDropdown.vue +154 -154
- package/src/components/MetricComparison.vue +89 -89
- package/src/components/PercentileChart.vue +67 -67
- package/src/components/PeriodSelector.vue +98 -98
- package/src/components/PresenceIndicator.vue +228 -213
- package/src/components/ReplyComposer.vue +336 -336
- package/src/components/ReplyThread.vue +153 -153
- package/src/components/ReportChart.vue +395 -395
- package/src/components/ReportExportButton.vue +108 -108
- package/src/components/SaveViewDialog.vue +172 -172
- package/src/components/SavedViewSidebar.vue +208 -208
- package/src/components/SkillTagManager.vue +3 -0
- package/src/components/SnoozeButton.vue +247 -247
- package/src/components/TicketFilters.vue +270 -270
- package/src/components/TicketList.vue +655 -655
- package/src/components/TicketSidebar.vue +4 -0
- package/src/components/TicketSplitDialog.vue +98 -98
- package/src/components/TicketSubjects.stories.js +63 -0
- package/src/components/TicketSubjects.vue +107 -0
- package/src/components/TwoFactorSetup.vue +1 -37
- package/src/components/VariableTokenInput.vue +111 -111
- package/src/components/WorkflowActionConfig.vue +287 -287
- package/src/components/WorkflowActionList.vue +350 -350
- package/src/components/WorkflowConditionBuilder.vue +265 -265
- package/src/components/WorkflowTriggerSelector.vue +125 -125
- package/src/components/admin/newsletters/AnalyticsTiles.stories.js +14 -0
- package/src/components/admin/newsletters/AnalyticsTiles.vue +55 -0
- package/src/components/admin/newsletters/DeliveriesTable.stories.js +28 -0
- package/src/components/admin/newsletters/DeliveriesTable.vue +104 -0
- package/src/components/admin/newsletters/DynamicFilterBuilder.stories.js +11 -0
- package/src/components/admin/newsletters/DynamicFilterBuilder.vue +53 -0
- package/src/components/admin/newsletters/ListMemberTable.stories.js +12 -0
- package/src/components/admin/newsletters/ListMemberTable.vue +53 -0
- package/src/components/admin/newsletters/MarkdownEditor.stories.js +6 -0
- package/src/components/admin/newsletters/MarkdownEditor.vue +53 -0
- package/src/components/admin/newsletters/MergeFieldDropdown.stories.js +6 -0
- package/src/components/admin/newsletters/MergeFieldDropdown.vue +84 -0
- package/src/components/admin/newsletters/PreviewIframe.stories.js +7 -0
- package/src/components/admin/newsletters/PreviewIframe.vue +40 -0
- package/src/components/admin/newsletters/SummaryTiles.stories.js +8 -0
- package/src/components/admin/newsletters/SummaryTiles.vue +63 -0
- package/src/composables/useChat.js +187 -187
- package/src/composables/useI18n.js +102 -102
- package/src/composables/useMentions.js +134 -134
- package/src/composables/usePermissions.js +31 -0
- package/src/composables/useRealtime.js +182 -182
- package/src/index.js +85 -84
- package/src/locales/README.md +13 -13
- package/src/locales/ar.json +727 -727
- package/src/locales/de.json +690 -690
- package/src/locales/en.json +889 -729
- package/src/locales/es.json +691 -691
- package/src/locales/fr.json +691 -691
- package/src/locales/index.js +16 -16
- package/src/locales/it.json +727 -727
- package/src/locales/ja.json +727 -727
- package/src/locales/ko.json +727 -727
- package/src/locales/nl.json +727 -727
- package/src/locales/pl.json +727 -727
- package/src/locales/pt-BR.json +727 -727
- package/src/locales/ru.json +727 -727
- package/src/locales/tr.json +727 -727
- package/src/locales/zh-CN.json +727 -727
- package/src/pages/Admin/Automations/Form.vue +280 -0
- package/src/pages/Admin/Automations/Index.vue +230 -0
- package/src/pages/Admin/Newsletters/Compose.vue +192 -0
- package/src/pages/Admin/Newsletters/Edit.vue +24 -0
- package/src/pages/Admin/Newsletters/Index.vue +141 -0
- package/src/pages/Admin/Newsletters/Lists/Create.vue +64 -0
- package/src/pages/Admin/Newsletters/Lists/Index.vue +59 -0
- package/src/pages/Admin/Newsletters/Lists/Show.vue +53 -0
- package/src/pages/Admin/Newsletters/Settings.vue +85 -0
- package/src/pages/Admin/Newsletters/Show.vue +140 -0
- package/src/pages/Admin/Newsletters/Templates/Create.vue +11 -0
- package/src/pages/Admin/Newsletters/Templates/Index.vue +54 -0
- package/src/pages/Admin/Newsletters/Templates/Show.vue +65 -0
- package/src/pages/Admin/Newsletters/usePreview.js +44 -0
- package/src/pages/Admin/Plugins/Index.vue +10 -2
- package/src/pages/Admin/Reports/AgentDetail.vue +132 -132
- package/src/pages/Admin/Reports/AgentRanking.vue +246 -246
- package/src/pages/Admin/Reports/Cohorts.vue +180 -180
- package/src/pages/Admin/Reports/Comparison.vue +141 -141
- package/src/pages/Admin/Reports/Dashboard.vue +226 -226
- package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -164
- package/src/pages/Admin/Reports/ResponseTimes.vue +164 -164
- package/src/pages/Admin/Reports/SlaTrends.vue +183 -183
- package/src/pages/Admin/Settings/DatabaseConnection.vue +228 -0
- package/src/pages/Admin/Settings/PublicTickets.vue +163 -0
- package/src/pages/Admin/Settings/SsoSettings.vue +157 -4
- package/src/pages/Admin/Settings/TwoFactor.vue +32 -6
- package/src/pages/Admin/Settings.vue +1008 -1008
- package/src/pages/Admin/Skills/Form.vue +30 -10
- package/src/pages/Admin/Tickets/Index.vue +126 -126
- package/src/pages/Admin/Tickets/Show.vue +254 -249
- package/src/pages/Admin/Users/Index.vue +145 -145
- package/src/pages/Admin/Webhooks/DeliveryLog.vue +1 -2
- package/src/pages/Admin/Workflows/Builder.vue +207 -207
- package/src/pages/Admin/Workflows/Form.vue +24 -0
- package/src/pages/Admin/Workflows/Index.vue +235 -235
- package/src/pages/Admin/Workflows/Logs.vue +286 -286
- package/src/pages/Agent/TicketIndex.vue +112 -112
- package/src/pages/Agent/TicketShow.vue +547 -494
- package/src/pages/Customer/Index.vue +56 -56
- package/src/pages/Customer/KnowledgeBase/Article.vue +129 -129
- package/src/pages/Plugin/Page.vue +61 -0
- package/src/plugin.js +175 -175
- package/src/utils/mentionHighlight.js +24 -24
- package/src/widget/EscalatedWidget.vue +804 -804
- 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"> · {{ statusResult.department }}</span>
|
|
780
|
-
<span>
|
|
781
|
-
·
|
|
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"> · {{ statusResult.department }}</span>
|
|
780
|
+
<span>
|
|
781
|
+
·
|
|
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>
|