@escalated-dev/escalated 0.12.0 → 0.12.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/README.md +54 -3
- package/dist/widget/escalated-widget.js +215 -0
- package/package.json +19 -5
- package/pages.json +1 -1
- package/src/components/ReportExportButton.vue +13 -0
- package/src/components/TotpInput.vue +1 -1
- package/src/composables/useChat.js +4 -3
- package/src/composables/useKeyboardShortcuts.js +2 -0
- package/src/composables/useMentions.js +16 -11
- package/src/composables/useRealtime.js +7 -2
- package/src/pages/Admin/ApiTokens/Index.vue +3 -4
- package/src/pages/Admin/Settings/TwoFactor.vue +34 -7
- package/src/pages/Admin/Settings.vue +34 -3
- package/src/utils/flash.js +4 -0
- package/src/widget/EscalatedWidget.vue +145 -71
- package/src/widget/embed.js +120 -0
- package/src/widget/index.js +93 -33
|
@@ -16,19 +16,59 @@ const props = defineProps({
|
|
|
16
16
|
widgetPath: { type: String, default: '/support/widget' },
|
|
17
17
|
initialColor: { type: String, default: '#4F46E5' },
|
|
18
18
|
initialPosition: { type: String, default: 'bottom-right' },
|
|
19
|
+
// 'launcher' floats a button on the page; 'inline' renders the panel in
|
|
20
|
+
// place, e.g. as the support form on a host site's contact page.
|
|
21
|
+
mode: { type: String, default: 'launcher' },
|
|
22
|
+
// Subset and order of tabs to offer: help, contact, chat, status.
|
|
23
|
+
tabs: { type: Array, default: null },
|
|
24
|
+
defaultTab: { type: String, default: null },
|
|
25
|
+
// Department to preselect; lockDepartment hides the department choice.
|
|
26
|
+
department: { type: [String, Number], default: null },
|
|
27
|
+
lockDepartment: { type: Boolean, default: false },
|
|
28
|
+
// Visitor details the host page already knows: name, email, subject, message.
|
|
29
|
+
prefill: { type: Object, default: () => ({}) },
|
|
30
|
+
title: { type: String, default: 'Support' },
|
|
31
|
+
greeting: { type: String, default: null },
|
|
32
|
+
openOnLoad: { type: Boolean, default: false },
|
|
33
|
+
hideLauncher: { type: Boolean, default: false },
|
|
34
|
+
// Host parameters (order number, plan, ...) sent with tickets and chats
|
|
35
|
+
// when the backend accepts them. metadataProvider is called at submit.
|
|
36
|
+
metadata: { type: Object, default: () => ({}) },
|
|
37
|
+
metadataProvider: { type: Function, default: null },
|
|
19
38
|
});
|
|
20
39
|
|
|
21
|
-
const
|
|
22
|
-
|
|
40
|
+
const inline = computed(() => props.mode === 'inline');
|
|
41
|
+
|
|
42
|
+
const isOpen = ref(props.mode === 'inline' || props.openOnLoad);
|
|
43
|
+
const activeTab = ref(props.defaultTab);
|
|
23
44
|
const error = ref('');
|
|
24
45
|
const widgetConfig = ref(null);
|
|
25
46
|
|
|
26
47
|
const color = computed(() => widgetConfig.value?.color || props.initialColor);
|
|
27
48
|
const position = computed(() => widgetConfig.value?.position || props.initialPosition);
|
|
28
|
-
const greeting = computed(() => widgetConfig.value?.greeting || 'Hi there! How can we help?');
|
|
49
|
+
const greeting = computed(() => props.greeting || widgetConfig.value?.greeting || 'Hi there! How can we help?');
|
|
29
50
|
const departments = computed(() => widgetConfig.value?.departments || []);
|
|
30
51
|
const kbEnabled = computed(() => widgetConfig.value?.kb_enabled ?? true);
|
|
31
52
|
const chatAvailable = ref(false);
|
|
53
|
+
const visibleTabs = computed(() => {
|
|
54
|
+
const available = {
|
|
55
|
+
help: kbEnabled.value,
|
|
56
|
+
contact: true,
|
|
57
|
+
chat: chatAvailable.value,
|
|
58
|
+
status: true,
|
|
59
|
+
};
|
|
60
|
+
const wanted = props.tabs?.length ? props.tabs : ['help', 'contact', 'chat', 'status'];
|
|
61
|
+
return wanted.filter((tab) => available[tab]);
|
|
62
|
+
});
|
|
63
|
+
// The chosen tab while it is on offer, otherwise the first one that is.
|
|
64
|
+
const currentTab = computed(() =>
|
|
65
|
+
visibleTabs.value.includes(activeTab.value) ? activeTab.value : visibleTabs.value[0] || null,
|
|
66
|
+
);
|
|
67
|
+
const identity = ref({ name: props.prefill?.name || '', email: props.prefill?.email || '' });
|
|
68
|
+
const lockedDepartment = ref(props.lockDepartment && props.department != null ? props.department : null);
|
|
69
|
+
const presetDepartment = ref(props.department ?? '');
|
|
70
|
+
const departmentLocked = computed(() => lockedDepartment.value != null);
|
|
71
|
+
const hostMetadata = ref({ ...(props.metadata || {}) });
|
|
32
72
|
const chatAvailabilityMessage = ref('');
|
|
33
73
|
|
|
34
74
|
// Chat state
|
|
@@ -43,7 +83,20 @@ const chatRatingSubmitted = ref(false);
|
|
|
43
83
|
const chatInput = ref('');
|
|
44
84
|
const chatSending = ref(false);
|
|
45
85
|
const chatConnectionStatus = ref('disconnected'); // connected | reconnecting | disconnected
|
|
46
|
-
const
|
|
86
|
+
const blankChatForm = () => ({
|
|
87
|
+
name: identity.value.name,
|
|
88
|
+
email: identity.value.email,
|
|
89
|
+
department_id: presetDepartment.value,
|
|
90
|
+
message: props.prefill?.message || '',
|
|
91
|
+
});
|
|
92
|
+
const blankTicketForm = () => ({
|
|
93
|
+
name: identity.value.name,
|
|
94
|
+
email: identity.value.email,
|
|
95
|
+
subject: props.prefill?.subject || '',
|
|
96
|
+
description: props.prefill?.message || '',
|
|
97
|
+
department_id: presetDepartment.value,
|
|
98
|
+
});
|
|
99
|
+
const preChatForm = ref(blankChatForm());
|
|
47
100
|
const chatStarting = ref(false);
|
|
48
101
|
|
|
49
102
|
// WebSocket / polling for chat
|
|
@@ -57,17 +110,11 @@ const searchLoading = ref(false);
|
|
|
57
110
|
const selectedArticle = ref(null);
|
|
58
111
|
const articleLoading = ref(false);
|
|
59
112
|
|
|
60
|
-
const ticketForm = ref(
|
|
61
|
-
name: '',
|
|
62
|
-
email: '',
|
|
63
|
-
subject: '',
|
|
64
|
-
description: '',
|
|
65
|
-
department_id: '',
|
|
66
|
-
});
|
|
113
|
+
const ticketForm = ref(blankTicketForm());
|
|
67
114
|
const ticketSubmitting = ref(false);
|
|
68
115
|
const ticketSuccess = ref(null);
|
|
69
116
|
|
|
70
|
-
const statusForm = ref({ email:
|
|
117
|
+
const statusForm = ref({ email: identity.value.email, reference: '' });
|
|
71
118
|
const statusLoading = ref(false);
|
|
72
119
|
const statusResult = ref(null);
|
|
73
120
|
const statusMatches = ref([]);
|
|
@@ -108,8 +155,8 @@ watch(
|
|
|
108
155
|
statusMatches.value = [];
|
|
109
156
|
statusResult.value = null;
|
|
110
157
|
startNewChat();
|
|
111
|
-
ticketForm.value =
|
|
112
|
-
statusForm.value = { email:
|
|
158
|
+
ticketForm.value = blankTicketForm();
|
|
159
|
+
statusForm.value = { email: identity.value.email, reference: '' };
|
|
113
160
|
widgetConfig.value = null;
|
|
114
161
|
chatAvailable.value = false;
|
|
115
162
|
error.value = '';
|
|
@@ -176,6 +223,7 @@ async function startLiveChat() {
|
|
|
176
223
|
email: preChatForm.value.email,
|
|
177
224
|
department_id: preChatForm.value.department_id || null,
|
|
178
225
|
message: preChatForm.value.message,
|
|
226
|
+
...metadataPayload(),
|
|
179
227
|
...(verificationRequired.value ? chatVerification.fields() : {}),
|
|
180
228
|
});
|
|
181
229
|
if (email !== preChatForm.value.email) return;
|
|
@@ -340,7 +388,7 @@ function startNewChat() {
|
|
|
340
388
|
chatRatingSubmitted.value = false;
|
|
341
389
|
chatInput.value = '';
|
|
342
390
|
chatConnectionStatus.value = 'disconnected';
|
|
343
|
-
preChatForm.value =
|
|
391
|
+
preChatForm.value = blankChatForm();
|
|
344
392
|
}
|
|
345
393
|
|
|
346
394
|
function chatBubbleInitials(name) {
|
|
@@ -400,18 +448,13 @@ async function submitTicket() {
|
|
|
400
448
|
subject: ticketForm.value.subject,
|
|
401
449
|
description: ticketForm.value.description,
|
|
402
450
|
department_id: ticketForm.value.department_id || null,
|
|
451
|
+
...metadataPayload(),
|
|
403
452
|
...(verificationRequired.value ? ticketVerification.fields() : {}),
|
|
404
453
|
});
|
|
405
454
|
if (email !== ticketForm.value.email) return;
|
|
406
455
|
ticketSuccess.value = data;
|
|
407
456
|
ticketVerification.reset();
|
|
408
|
-
ticketForm.value =
|
|
409
|
-
name: '',
|
|
410
|
-
email: '',
|
|
411
|
-
subject: '',
|
|
412
|
-
description: '',
|
|
413
|
-
department_id: '',
|
|
414
|
-
};
|
|
457
|
+
ticketForm.value = blankTicketForm();
|
|
415
458
|
} catch (e) {
|
|
416
459
|
if (e.name === 'AbortError') return;
|
|
417
460
|
if (e.data?.errors) {
|
|
@@ -470,11 +513,76 @@ async function loadVerifiedStatus(match) {
|
|
|
470
513
|
}
|
|
471
514
|
}
|
|
472
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Host parameters for a ticket or chat, plus the page it came from (origin
|
|
518
|
+
* and path only: query strings can carry tokens). Only sent to backends
|
|
519
|
+
* whose /config says they accept them, so older backends see no new field.
|
|
520
|
+
*/
|
|
521
|
+
function metadataPayload() {
|
|
522
|
+
if (widgetConfig.value?.accepts_metadata !== true) return {};
|
|
523
|
+
let provided;
|
|
524
|
+
try {
|
|
525
|
+
provided = props.metadataProvider?.() || {};
|
|
526
|
+
} catch {
|
|
527
|
+
provided = {};
|
|
528
|
+
}
|
|
529
|
+
const metadata = { ...hostMetadata.value, ...(typeof provided === 'object' ? provided : {}) };
|
|
530
|
+
if (metadata.page_url == null && typeof window !== 'undefined' && window.location?.origin) {
|
|
531
|
+
metadata.page_url = window.location.origin + window.location.pathname;
|
|
532
|
+
}
|
|
533
|
+
return { metadata };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const TAB_LABELS = { help: 'Help', contact: 'Contact', chat: 'Chat', status: 'Check Status' };
|
|
537
|
+
|
|
538
|
+
function selectTab(tab) {
|
|
539
|
+
activeTab.value = tab;
|
|
540
|
+
error.value = '';
|
|
541
|
+
if (tab === 'contact') ticketSuccess.value = null;
|
|
542
|
+
if (tab === 'status') statusResult.value = null;
|
|
543
|
+
}
|
|
544
|
+
|
|
473
545
|
function togglePanel() {
|
|
546
|
+
if (inline.value) return;
|
|
474
547
|
isOpen.value = !isOpen.value;
|
|
475
548
|
error.value = '';
|
|
476
549
|
}
|
|
477
550
|
|
|
551
|
+
function open(tab = null) {
|
|
552
|
+
if (tab) activeTab.value = tab;
|
|
553
|
+
isOpen.value = true;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function close() {
|
|
557
|
+
if (!inline.value) isOpen.value = false;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Fill in who the visitor is, without overwriting what they typed. */
|
|
561
|
+
function identify(person = {}) {
|
|
562
|
+
identity.value = {
|
|
563
|
+
name: person.name ?? identity.value.name,
|
|
564
|
+
email: person.email ?? identity.value.email,
|
|
565
|
+
};
|
|
566
|
+
for (const form of [ticketForm, preChatForm]) {
|
|
567
|
+
if (!form.value.name) form.value.name = identity.value.name;
|
|
568
|
+
if (!form.value.email) form.value.email = identity.value.email;
|
|
569
|
+
}
|
|
570
|
+
if (!statusForm.value.email) statusForm.value.email = identity.value.email;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function setMetadata(metadata = {}, { merge = true } = {}) {
|
|
574
|
+
hostMetadata.value = merge ? { ...hostMetadata.value, ...metadata } : { ...metadata };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function setDepartment(id, { lock = false } = {}) {
|
|
578
|
+
presetDepartment.value = id ?? '';
|
|
579
|
+
lockedDepartment.value = lock && id != null ? id : null;
|
|
580
|
+
ticketForm.value.department_id = presetDepartment.value;
|
|
581
|
+
preChatForm.value.department_id = presetDepartment.value;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
defineExpose({ open, close, toggle: togglePanel, identify, setMetadata, setDepartment });
|
|
585
|
+
|
|
478
586
|
function formatDate(iso) {
|
|
479
587
|
try {
|
|
480
588
|
return new Date(iso).toLocaleDateString(undefined, {
|
|
@@ -491,6 +599,7 @@ function formatDate(iso) {
|
|
|
491
599
|
|
|
492
600
|
<template>
|
|
493
601
|
<button
|
|
602
|
+
v-if="!inline && !hideLauncher"
|
|
494
603
|
class="esc-w-fab"
|
|
495
604
|
:class="position"
|
|
496
605
|
:style="{ backgroundColor: color }"
|
|
@@ -510,64 +619,29 @@ function formatDate(iso) {
|
|
|
510
619
|
|
|
511
620
|
<div
|
|
512
621
|
class="esc-w-panel"
|
|
513
|
-
:class="[position, { 'esc-open': isOpen }]"
|
|
622
|
+
:class="inline ? ['esc-w-inline', 'esc-open'] : [position, { 'esc-open': isOpen }]"
|
|
514
623
|
:style="{ '--esc-widget-color': color }"
|
|
515
624
|
data-widget-panel
|
|
516
625
|
>
|
|
517
626
|
<div class="esc-w-header" :style="{ backgroundColor: color }">
|
|
518
|
-
<h2>
|
|
627
|
+
<h2>{{ title }}</h2>
|
|
519
628
|
<p>{{ greeting }}</p>
|
|
520
629
|
</div>
|
|
521
630
|
|
|
522
|
-
<div class="esc-w-tabs">
|
|
523
|
-
<button
|
|
524
|
-
v-if="kbEnabled"
|
|
525
|
-
class="esc-w-tab"
|
|
526
|
-
:class="{ active: activeTab === 'help' }"
|
|
527
|
-
@click="
|
|
528
|
-
activeTab = 'help';
|
|
529
|
-
error = '';
|
|
530
|
-
"
|
|
531
|
-
>
|
|
532
|
-
Help
|
|
533
|
-
</button>
|
|
534
|
-
<button
|
|
535
|
-
class="esc-w-tab"
|
|
536
|
-
:class="{ active: activeTab === 'contact' }"
|
|
537
|
-
@click="
|
|
538
|
-
activeTab = 'contact';
|
|
539
|
-
error = '';
|
|
540
|
-
ticketSuccess = null;
|
|
541
|
-
"
|
|
542
|
-
>
|
|
543
|
-
Contact
|
|
544
|
-
</button>
|
|
545
|
-
<button
|
|
546
|
-
v-if="chatAvailable"
|
|
547
|
-
class="esc-w-tab"
|
|
548
|
-
:class="{ active: activeTab === 'chat' }"
|
|
549
|
-
@click="
|
|
550
|
-
activeTab = 'chat';
|
|
551
|
-
error = '';
|
|
552
|
-
"
|
|
553
|
-
>
|
|
554
|
-
Chat
|
|
555
|
-
</button>
|
|
631
|
+
<div v-if="visibleTabs.length > 1" class="esc-w-tabs">
|
|
556
632
|
<button
|
|
633
|
+
v-for="tab in visibleTabs"
|
|
634
|
+
:key="tab"
|
|
557
635
|
class="esc-w-tab"
|
|
558
|
-
:class="{ active:
|
|
559
|
-
@click="
|
|
560
|
-
activeTab = 'status';
|
|
561
|
-
error = '';
|
|
562
|
-
statusResult = null;
|
|
563
|
-
"
|
|
636
|
+
:class="{ active: currentTab === tab }"
|
|
637
|
+
@click="selectTab(tab)"
|
|
564
638
|
>
|
|
565
|
-
|
|
639
|
+
{{ TAB_LABELS[tab] }}
|
|
566
640
|
</button>
|
|
567
641
|
</div>
|
|
568
642
|
|
|
569
643
|
<div class="esc-w-body">
|
|
570
|
-
<template v-if="
|
|
644
|
+
<template v-if="currentTab === 'help' && kbEnabled">
|
|
571
645
|
<template v-if="!selectedArticle">
|
|
572
646
|
<div class="esc-w-field">
|
|
573
647
|
<input v-model="searchQuery" type="text" class="esc-w-input" placeholder="Search for help..." />
|
|
@@ -606,7 +680,7 @@ function formatDate(iso) {
|
|
|
606
680
|
</template>
|
|
607
681
|
</template>
|
|
608
682
|
|
|
609
|
-
<template v-if="
|
|
683
|
+
<template v-if="currentTab === 'contact'">
|
|
610
684
|
<template v-if="ticketSuccess">
|
|
611
685
|
<div class="esc-w-success">
|
|
612
686
|
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -638,7 +712,7 @@ function formatDate(iso) {
|
|
|
638
712
|
<label class="esc-w-label">Description</label>
|
|
639
713
|
<textarea v-model="ticketForm.description" class="esc-w-textarea" required></textarea>
|
|
640
714
|
</div>
|
|
641
|
-
<div v-if="departments.length" class="esc-w-field">
|
|
715
|
+
<div v-if="departments.length && !departmentLocked" class="esc-w-field">
|
|
642
716
|
<label class="esc-w-label">Department</label>
|
|
643
717
|
<select v-model="ticketForm.department_id" class="esc-w-select">
|
|
644
718
|
<option value="">Select a department...</option>
|
|
@@ -671,7 +745,7 @@ function formatDate(iso) {
|
|
|
671
745
|
</template>
|
|
672
746
|
|
|
673
747
|
<!-- Chat Tab -->
|
|
674
|
-
<template v-if="
|
|
748
|
+
<template v-if="currentTab === 'chat'">
|
|
675
749
|
<!-- Pre-chat form -->
|
|
676
750
|
<template v-if="chatPhase === 'pre'">
|
|
677
751
|
<div class="esc-w-chat-avail">
|
|
@@ -688,7 +762,7 @@ function formatDate(iso) {
|
|
|
688
762
|
<label class="esc-w-label">Email</label>
|
|
689
763
|
<input v-model="preChatForm.email" type="email" class="esc-w-input" required />
|
|
690
764
|
</div>
|
|
691
|
-
<div v-if="departments.length > 1" class="esc-w-field">
|
|
765
|
+
<div v-if="departments.length > 1 && !departmentLocked" class="esc-w-field">
|
|
692
766
|
<label class="esc-w-label">Department</label>
|
|
693
767
|
<select v-model="preChatForm.department_id" class="esc-w-select">
|
|
694
768
|
<option value="">Select a department...</option>
|
|
@@ -851,7 +925,7 @@ function formatDate(iso) {
|
|
|
851
925
|
</template>
|
|
852
926
|
</template>
|
|
853
927
|
|
|
854
|
-
<template v-if="
|
|
928
|
+
<template v-if="currentTab === 'status'">
|
|
855
929
|
<div v-if="error" class="esc-w-error">{{ error }}</div>
|
|
856
930
|
<template v-if="!statusResult">
|
|
857
931
|
<form @submit.prevent="checkStatus">
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads how a host page wants the widget embedded: from the <script> tag's
|
|
3
|
+
* data-* attributes and from a `window.EscalatedWidget` object set before
|
|
4
|
+
* the script loads. Attributes win over the global object.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const TAB_ALIASES = {
|
|
8
|
+
help: 'help',
|
|
9
|
+
kb: 'help',
|
|
10
|
+
articles: 'help',
|
|
11
|
+
contact: 'contact',
|
|
12
|
+
ticket: 'contact',
|
|
13
|
+
chat: 'chat',
|
|
14
|
+
status: 'status',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const WIDGET_TABS = ['help', 'contact', 'chat', 'status'];
|
|
18
|
+
|
|
19
|
+
export function normalizeTab(tab) {
|
|
20
|
+
return (
|
|
21
|
+
TAB_ALIASES[
|
|
22
|
+
String(tab ?? '')
|
|
23
|
+
.trim()
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
] ?? null
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeTabs(value) {
|
|
30
|
+
if (value == null || value === '') return null;
|
|
31
|
+
const list = Array.isArray(value) ? value : String(value).split(',');
|
|
32
|
+
const tabs = [...new Set(list.map(normalizeTab).filter(Boolean))];
|
|
33
|
+
return tabs.length ? tabs : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function bool(value, fallback = false) {
|
|
37
|
+
if (value == null) return fallback;
|
|
38
|
+
if (typeof value === 'boolean') return value;
|
|
39
|
+
return !['false', '0', 'no', 'off'].includes(String(value).trim().toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseJson(value) {
|
|
43
|
+
if (!value) return {};
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(value);
|
|
46
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
47
|
+
} catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** data-meta-order-number="A-1" → { order_number: 'A-1' } */
|
|
53
|
+
function metaAttributes(scriptTag) {
|
|
54
|
+
const meta = {};
|
|
55
|
+
for (const attr of scriptTag?.attributes ?? []) {
|
|
56
|
+
if (attr.name.startsWith('data-meta-') && attr.name.length > 10) {
|
|
57
|
+
meta[attr.name.slice(10).replace(/-/g, '_')] = attr.value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return meta;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* When the script is the bundle a backend serves at {widgetPath}/embed.js,
|
|
65
|
+
* the backend is the script's own origin and path: no data-base-url needed.
|
|
66
|
+
*/
|
|
67
|
+
function fromScriptSrc(scriptTag, location) {
|
|
68
|
+
const src = scriptTag?.getAttribute('src');
|
|
69
|
+
if (!src) return {};
|
|
70
|
+
try {
|
|
71
|
+
const url = new URL(src, location?.href);
|
|
72
|
+
const match = url.pathname.match(/^(.*\/widget)\/embed\.js$/);
|
|
73
|
+
return match ? { baseUrl: url.origin, widgetPath: match[1] } : {};
|
|
74
|
+
} catch {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function readEmbedConfig(scriptTag, globalConfig = {}, location = globalThis.location) {
|
|
80
|
+
const attr = (name) => scriptTag?.getAttribute(`data-${name}`) ?? undefined;
|
|
81
|
+
const g = globalConfig && typeof globalConfig === 'object' ? globalConfig : {};
|
|
82
|
+
const pick = (name, key = name) => attr(name) ?? g[key];
|
|
83
|
+
const src = fromScriptSrc(scriptTag, location);
|
|
84
|
+
|
|
85
|
+
const department = pick('department');
|
|
86
|
+
const metadata = {
|
|
87
|
+
...(typeof g.metadata === 'object' && g.metadata ? g.metadata : {}),
|
|
88
|
+
...parseJson(attr('metadata')),
|
|
89
|
+
...metaAttributes(scriptTag),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
baseUrl: attr('base-url') ?? g.baseUrl ?? src.baseUrl ?? '',
|
|
94
|
+
// Host-framework-specific path prefix. Every plugin except the NestJS
|
|
95
|
+
// reference mounts at /support/widget; NestJS mounts at
|
|
96
|
+
// /escalated/widget. Set via data-widget-path on NestJS backends,
|
|
97
|
+
// unless the script itself was loaded from {widgetPath}/embed.js.
|
|
98
|
+
widgetPath: attr('widget-path') ?? g.widgetPath ?? src.widgetPath ?? '/support/widget',
|
|
99
|
+
color: pick('color') || '#4F46E5',
|
|
100
|
+
position: pick('position') || 'bottom-right',
|
|
101
|
+
mode: pick('mode') === 'inline' ? 'inline' : 'launcher',
|
|
102
|
+
target: pick('target') ?? null,
|
|
103
|
+
tabs: normalizeTabs(pick('tabs')),
|
|
104
|
+
defaultTab: normalizeTab(attr('tab') ?? attr('default-tab') ?? g.defaultTab),
|
|
105
|
+
department: department === '' || department == null ? null : department,
|
|
106
|
+
lockDepartment: bool(pick('lock-department', 'lockDepartment')),
|
|
107
|
+
prefill: {
|
|
108
|
+
name: pick('name') ?? g.prefill?.name ?? '',
|
|
109
|
+
email: pick('email') ?? g.prefill?.email ?? '',
|
|
110
|
+
subject: pick('subject') ?? g.prefill?.subject ?? '',
|
|
111
|
+
message: pick('message') ?? g.prefill?.message ?? '',
|
|
112
|
+
},
|
|
113
|
+
title: pick('title') || 'Support',
|
|
114
|
+
greeting: pick('greeting') ?? null,
|
|
115
|
+
openOnLoad: bool(pick('open', 'openOnLoad')),
|
|
116
|
+
hideLauncher: bool(pick('hide-launcher', 'hideLauncher')),
|
|
117
|
+
metadata,
|
|
118
|
+
metadataProvider: typeof g.metadata === 'function' ? g.metadata : null,
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/widget/index.js
CHANGED
|
@@ -1,56 +1,102 @@
|
|
|
1
|
-
import { createApp, h } from 'vue';
|
|
1
|
+
import { createApp, h, ref } from 'vue';
|
|
2
2
|
import EscalatedWidget from './EscalatedWidget.vue';
|
|
3
|
+
import { normalizeTab, readEmbedConfig } from './embed.js';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Mounts one widget into its own shadow root so the host page's CSS cannot
|
|
7
|
+
* reach it. Launcher mode floats a button on the page; inline mode renders
|
|
8
|
+
* the panel inside `target` (an element or selector), e.g. as the support
|
|
9
|
+
* form on a contact page. Returns the instance's control API.
|
|
10
|
+
*/
|
|
11
|
+
export function mountEscalatedWidget(options = {}) {
|
|
12
|
+
const config = { ...readEmbedConfig(null, {}), ...options };
|
|
13
|
+
if (options.prefill) config.prefill = { ...readEmbedConfig(null, {}).prefill, ...options.prefill };
|
|
14
|
+
const inline = config.mode === 'inline';
|
|
15
|
+
const target = typeof config.target === 'string' ? document.querySelector(config.target) : config.target;
|
|
16
|
+
if (inline && !target) {
|
|
17
|
+
console.warn('[Escalated] Inline widget target not found:', config.target);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
17
20
|
|
|
18
21
|
const host = document.createElement('div');
|
|
19
|
-
host.
|
|
20
|
-
|
|
22
|
+
host.className = inline ? 'escalated-widget-inline' : '';
|
|
23
|
+
if (!inline) host.id = 'escalated-widget-host';
|
|
24
|
+
(inline ? target : document.body).appendChild(host);
|
|
21
25
|
|
|
22
26
|
const shadow = host.attachShadow({ mode: 'open' });
|
|
23
|
-
|
|
27
|
+
const styleEl = document.createElement('style');
|
|
28
|
+
styleEl.textContent = getWidgetStyles() + (globalThis.__ESCALATED_WIDGET_CSS__ || '');
|
|
29
|
+
shadow.appendChild(styleEl);
|
|
24
30
|
const mountEl = document.createElement('div');
|
|
25
31
|
mountEl.id = 'escalated-widget-root';
|
|
26
32
|
shadow.appendChild(mountEl);
|
|
27
33
|
|
|
28
|
-
const
|
|
29
|
-
styleEl.textContent = getWidgetStyles();
|
|
30
|
-
shadow.appendChild(styleEl);
|
|
31
|
-
|
|
34
|
+
const widget = ref(null);
|
|
32
35
|
const app = createApp({
|
|
33
|
-
render()
|
|
34
|
-
|
|
36
|
+
render: () =>
|
|
37
|
+
h(EscalatedWidget, {
|
|
38
|
+
ref: widget,
|
|
35
39
|
baseUrl: config.baseUrl,
|
|
36
40
|
widgetPath: config.widgetPath,
|
|
37
41
|
initialColor: config.color,
|
|
38
42
|
initialPosition: config.position,
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
mode: config.mode,
|
|
44
|
+
tabs: config.tabs,
|
|
45
|
+
defaultTab: config.defaultTab,
|
|
46
|
+
department: config.department,
|
|
47
|
+
lockDepartment: config.lockDepartment,
|
|
48
|
+
prefill: config.prefill,
|
|
49
|
+
title: config.title,
|
|
50
|
+
greeting: config.greeting,
|
|
51
|
+
openOnLoad: config.openOnLoad,
|
|
52
|
+
hideLauncher: config.hideLauncher,
|
|
53
|
+
metadata: config.metadata,
|
|
54
|
+
metadataProvider: config.metadataProvider,
|
|
55
|
+
}),
|
|
41
56
|
});
|
|
42
|
-
|
|
43
57
|
app.mount(mountEl);
|
|
44
58
|
|
|
45
|
-
|
|
46
|
-
open()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
return {
|
|
60
|
+
open: (tab) => widget.value?.open(normalizeTab(tab)),
|
|
61
|
+
close: () => widget.value?.close(),
|
|
62
|
+
toggle: () => widget.value?.toggle(),
|
|
63
|
+
identify: (person) => widget.value?.identify(person),
|
|
64
|
+
setMetadata: (metadata, opts) => widget.value?.setMetadata(metadata, opts),
|
|
65
|
+
setDepartment: (id, opts) => widget.value?.setDepartment(id, opts),
|
|
66
|
+
destroy() {
|
|
67
|
+
app.unmount();
|
|
68
|
+
host.remove();
|
|
51
69
|
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function boot() {
|
|
74
|
+
const existing = window.EscalatedWidget;
|
|
75
|
+
// Loading the script twice must not mount a second launcher.
|
|
76
|
+
if (existing && existing.__escalatedLoaded) return;
|
|
77
|
+
|
|
78
|
+
const scriptTag = document.currentScript || document.querySelector('script[data-escalated-widget]');
|
|
79
|
+
const config = readEmbedConfig(scriptTag, existing || {});
|
|
80
|
+
const instance = mountEscalatedWidget(config);
|
|
81
|
+
|
|
82
|
+
window.EscalatedWidget = Object.assign(existing && typeof existing === 'object' ? existing : {}, {
|
|
83
|
+
__escalatedLoaded: true,
|
|
84
|
+
open: (tab) => instance?.open(tab),
|
|
85
|
+
close: () => instance?.close(),
|
|
86
|
+
toggle: () => instance?.toggle(),
|
|
87
|
+
identify: (person) => instance?.identify(person),
|
|
88
|
+
setMetadata: (metadata, opts) => instance?.setMetadata(metadata, opts),
|
|
89
|
+
setDepartment: (id, opts) => instance?.setDepartment(id, opts),
|
|
90
|
+
destroy: () => instance?.destroy(),
|
|
91
|
+
mount: (options) => mountEscalatedWidget({ ...config, ...options }),
|
|
52
92
|
});
|
|
53
|
-
})
|
|
93
|
+
window.dispatchEvent(new window.CustomEvent('escalated:widget-ready', { detail: window.EscalatedWidget }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof window !== 'undefined' && !globalThis.__ESCALATED_WIDGET_NO_AUTOBOOT__) {
|
|
97
|
+
if (document.body) boot();
|
|
98
|
+
else document.addEventListener('DOMContentLoaded', boot, { once: true });
|
|
99
|
+
}
|
|
54
100
|
|
|
55
101
|
function getWidgetStyles() {
|
|
56
102
|
return `
|
|
@@ -250,5 +296,19 @@ function getWidgetStyles() {
|
|
|
250
296
|
border-radius: 50%; animation: esc-spin 0.6s linear infinite; margin: 24px auto;
|
|
251
297
|
}
|
|
252
298
|
@keyframes esc-spin { to { transform: rotate(360deg); } }
|
|
299
|
+
.esc-w-panel.esc-w-inline {
|
|
300
|
+
position: relative; inset: auto; z-index: auto; width: 100%; max-width: none;
|
|
301
|
+
height: auto; min-height: 420px; max-height: none;
|
|
302
|
+
transform: none; opacity: 1; pointer-events: auto; transition: none;
|
|
303
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.08); border: 1px solid #e5e7eb;
|
|
304
|
+
}
|
|
305
|
+
.esc-w-panel.esc-w-inline .esc-w-body { overflow: visible; }
|
|
306
|
+
.esc-w-panel.esc-w-inline .esc-w-chat-messages { max-height: 360px; }
|
|
307
|
+
@media (max-width: 440px) {
|
|
308
|
+
.esc-w-panel.esc-w-inline {
|
|
309
|
+
width: 100%; height: auto; max-height: none; border-radius: 12px;
|
|
310
|
+
bottom: auto !important; right: auto !important; left: auto !important;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
253
313
|
`;
|
|
254
314
|
}
|