@escalated-dev/escalated 0.12.0 → 0.12.2
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 +146 -71
- package/src/widget/embed.js +120 -0
- package/src/widget/index.js +93 -33
|
@@ -16,19 +16,60 @@ 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
|
+
// Backends without verified guest lookup turn the status check off.
|
|
59
|
+
status: widgetConfig.value?.status_lookup_enabled !== false,
|
|
60
|
+
};
|
|
61
|
+
const wanted = props.tabs?.length ? props.tabs : ['help', 'contact', 'chat', 'status'];
|
|
62
|
+
return wanted.filter((tab) => available[tab]);
|
|
63
|
+
});
|
|
64
|
+
// The chosen tab while it is on offer, otherwise the first one that is.
|
|
65
|
+
const currentTab = computed(() =>
|
|
66
|
+
visibleTabs.value.includes(activeTab.value) ? activeTab.value : visibleTabs.value[0] || null,
|
|
67
|
+
);
|
|
68
|
+
const identity = ref({ name: props.prefill?.name || '', email: props.prefill?.email || '' });
|
|
69
|
+
const lockedDepartment = ref(props.lockDepartment && props.department != null ? props.department : null);
|
|
70
|
+
const presetDepartment = ref(props.department ?? '');
|
|
71
|
+
const departmentLocked = computed(() => lockedDepartment.value != null);
|
|
72
|
+
const hostMetadata = ref({ ...(props.metadata || {}) });
|
|
32
73
|
const chatAvailabilityMessage = ref('');
|
|
33
74
|
|
|
34
75
|
// Chat state
|
|
@@ -43,7 +84,20 @@ const chatRatingSubmitted = ref(false);
|
|
|
43
84
|
const chatInput = ref('');
|
|
44
85
|
const chatSending = ref(false);
|
|
45
86
|
const chatConnectionStatus = ref('disconnected'); // connected | reconnecting | disconnected
|
|
46
|
-
const
|
|
87
|
+
const blankChatForm = () => ({
|
|
88
|
+
name: identity.value.name,
|
|
89
|
+
email: identity.value.email,
|
|
90
|
+
department_id: presetDepartment.value,
|
|
91
|
+
message: props.prefill?.message || '',
|
|
92
|
+
});
|
|
93
|
+
const blankTicketForm = () => ({
|
|
94
|
+
name: identity.value.name,
|
|
95
|
+
email: identity.value.email,
|
|
96
|
+
subject: props.prefill?.subject || '',
|
|
97
|
+
description: props.prefill?.message || '',
|
|
98
|
+
department_id: presetDepartment.value,
|
|
99
|
+
});
|
|
100
|
+
const preChatForm = ref(blankChatForm());
|
|
47
101
|
const chatStarting = ref(false);
|
|
48
102
|
|
|
49
103
|
// WebSocket / polling for chat
|
|
@@ -57,17 +111,11 @@ const searchLoading = ref(false);
|
|
|
57
111
|
const selectedArticle = ref(null);
|
|
58
112
|
const articleLoading = ref(false);
|
|
59
113
|
|
|
60
|
-
const ticketForm = ref(
|
|
61
|
-
name: '',
|
|
62
|
-
email: '',
|
|
63
|
-
subject: '',
|
|
64
|
-
description: '',
|
|
65
|
-
department_id: '',
|
|
66
|
-
});
|
|
114
|
+
const ticketForm = ref(blankTicketForm());
|
|
67
115
|
const ticketSubmitting = ref(false);
|
|
68
116
|
const ticketSuccess = ref(null);
|
|
69
117
|
|
|
70
|
-
const statusForm = ref({ email:
|
|
118
|
+
const statusForm = ref({ email: identity.value.email, reference: '' });
|
|
71
119
|
const statusLoading = ref(false);
|
|
72
120
|
const statusResult = ref(null);
|
|
73
121
|
const statusMatches = ref([]);
|
|
@@ -108,8 +156,8 @@ watch(
|
|
|
108
156
|
statusMatches.value = [];
|
|
109
157
|
statusResult.value = null;
|
|
110
158
|
startNewChat();
|
|
111
|
-
ticketForm.value =
|
|
112
|
-
statusForm.value = { email:
|
|
159
|
+
ticketForm.value = blankTicketForm();
|
|
160
|
+
statusForm.value = { email: identity.value.email, reference: '' };
|
|
113
161
|
widgetConfig.value = null;
|
|
114
162
|
chatAvailable.value = false;
|
|
115
163
|
error.value = '';
|
|
@@ -176,6 +224,7 @@ async function startLiveChat() {
|
|
|
176
224
|
email: preChatForm.value.email,
|
|
177
225
|
department_id: preChatForm.value.department_id || null,
|
|
178
226
|
message: preChatForm.value.message,
|
|
227
|
+
...metadataPayload(),
|
|
179
228
|
...(verificationRequired.value ? chatVerification.fields() : {}),
|
|
180
229
|
});
|
|
181
230
|
if (email !== preChatForm.value.email) return;
|
|
@@ -340,7 +389,7 @@ function startNewChat() {
|
|
|
340
389
|
chatRatingSubmitted.value = false;
|
|
341
390
|
chatInput.value = '';
|
|
342
391
|
chatConnectionStatus.value = 'disconnected';
|
|
343
|
-
preChatForm.value =
|
|
392
|
+
preChatForm.value = blankChatForm();
|
|
344
393
|
}
|
|
345
394
|
|
|
346
395
|
function chatBubbleInitials(name) {
|
|
@@ -400,18 +449,13 @@ async function submitTicket() {
|
|
|
400
449
|
subject: ticketForm.value.subject,
|
|
401
450
|
description: ticketForm.value.description,
|
|
402
451
|
department_id: ticketForm.value.department_id || null,
|
|
452
|
+
...metadataPayload(),
|
|
403
453
|
...(verificationRequired.value ? ticketVerification.fields() : {}),
|
|
404
454
|
});
|
|
405
455
|
if (email !== ticketForm.value.email) return;
|
|
406
456
|
ticketSuccess.value = data;
|
|
407
457
|
ticketVerification.reset();
|
|
408
|
-
ticketForm.value =
|
|
409
|
-
name: '',
|
|
410
|
-
email: '',
|
|
411
|
-
subject: '',
|
|
412
|
-
description: '',
|
|
413
|
-
department_id: '',
|
|
414
|
-
};
|
|
458
|
+
ticketForm.value = blankTicketForm();
|
|
415
459
|
} catch (e) {
|
|
416
460
|
if (e.name === 'AbortError') return;
|
|
417
461
|
if (e.data?.errors) {
|
|
@@ -470,11 +514,76 @@ async function loadVerifiedStatus(match) {
|
|
|
470
514
|
}
|
|
471
515
|
}
|
|
472
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Host parameters for a ticket or chat, plus the page it came from (origin
|
|
519
|
+
* and path only: query strings can carry tokens). Only sent to backends
|
|
520
|
+
* whose /config says they accept them, so older backends see no new field.
|
|
521
|
+
*/
|
|
522
|
+
function metadataPayload() {
|
|
523
|
+
if (widgetConfig.value?.accepts_metadata !== true) return {};
|
|
524
|
+
let provided;
|
|
525
|
+
try {
|
|
526
|
+
provided = props.metadataProvider?.() || {};
|
|
527
|
+
} catch {
|
|
528
|
+
provided = {};
|
|
529
|
+
}
|
|
530
|
+
const metadata = { ...hostMetadata.value, ...(typeof provided === 'object' ? provided : {}) };
|
|
531
|
+
if (metadata.page_url == null && typeof window !== 'undefined' && window.location?.origin) {
|
|
532
|
+
metadata.page_url = window.location.origin + window.location.pathname;
|
|
533
|
+
}
|
|
534
|
+
return { metadata };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const TAB_LABELS = { help: 'Help', contact: 'Contact', chat: 'Chat', status: 'Check Status' };
|
|
538
|
+
|
|
539
|
+
function selectTab(tab) {
|
|
540
|
+
activeTab.value = tab;
|
|
541
|
+
error.value = '';
|
|
542
|
+
if (tab === 'contact') ticketSuccess.value = null;
|
|
543
|
+
if (tab === 'status') statusResult.value = null;
|
|
544
|
+
}
|
|
545
|
+
|
|
473
546
|
function togglePanel() {
|
|
547
|
+
if (inline.value) return;
|
|
474
548
|
isOpen.value = !isOpen.value;
|
|
475
549
|
error.value = '';
|
|
476
550
|
}
|
|
477
551
|
|
|
552
|
+
function open(tab = null) {
|
|
553
|
+
if (tab) activeTab.value = tab;
|
|
554
|
+
isOpen.value = true;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function close() {
|
|
558
|
+
if (!inline.value) isOpen.value = false;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Fill in who the visitor is, without overwriting what they typed. */
|
|
562
|
+
function identify(person = {}) {
|
|
563
|
+
identity.value = {
|
|
564
|
+
name: person.name ?? identity.value.name,
|
|
565
|
+
email: person.email ?? identity.value.email,
|
|
566
|
+
};
|
|
567
|
+
for (const form of [ticketForm, preChatForm]) {
|
|
568
|
+
if (!form.value.name) form.value.name = identity.value.name;
|
|
569
|
+
if (!form.value.email) form.value.email = identity.value.email;
|
|
570
|
+
}
|
|
571
|
+
if (!statusForm.value.email) statusForm.value.email = identity.value.email;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function setMetadata(metadata = {}, { merge = true } = {}) {
|
|
575
|
+
hostMetadata.value = merge ? { ...hostMetadata.value, ...metadata } : { ...metadata };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function setDepartment(id, { lock = false } = {}) {
|
|
579
|
+
presetDepartment.value = id ?? '';
|
|
580
|
+
lockedDepartment.value = lock && id != null ? id : null;
|
|
581
|
+
ticketForm.value.department_id = presetDepartment.value;
|
|
582
|
+
preChatForm.value.department_id = presetDepartment.value;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
defineExpose({ open, close, toggle: togglePanel, identify, setMetadata, setDepartment });
|
|
586
|
+
|
|
478
587
|
function formatDate(iso) {
|
|
479
588
|
try {
|
|
480
589
|
return new Date(iso).toLocaleDateString(undefined, {
|
|
@@ -491,6 +600,7 @@ function formatDate(iso) {
|
|
|
491
600
|
|
|
492
601
|
<template>
|
|
493
602
|
<button
|
|
603
|
+
v-if="!inline && !hideLauncher"
|
|
494
604
|
class="esc-w-fab"
|
|
495
605
|
:class="position"
|
|
496
606
|
:style="{ backgroundColor: color }"
|
|
@@ -510,64 +620,29 @@ function formatDate(iso) {
|
|
|
510
620
|
|
|
511
621
|
<div
|
|
512
622
|
class="esc-w-panel"
|
|
513
|
-
:class="[position, { 'esc-open': isOpen }]"
|
|
623
|
+
:class="inline ? ['esc-w-inline', 'esc-open'] : [position, { 'esc-open': isOpen }]"
|
|
514
624
|
:style="{ '--esc-widget-color': color }"
|
|
515
625
|
data-widget-panel
|
|
516
626
|
>
|
|
517
627
|
<div class="esc-w-header" :style="{ backgroundColor: color }">
|
|
518
|
-
<h2>
|
|
628
|
+
<h2>{{ title }}</h2>
|
|
519
629
|
<p>{{ greeting }}</p>
|
|
520
630
|
</div>
|
|
521
631
|
|
|
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>
|
|
632
|
+
<div v-if="visibleTabs.length > 1" class="esc-w-tabs">
|
|
556
633
|
<button
|
|
634
|
+
v-for="tab in visibleTabs"
|
|
635
|
+
:key="tab"
|
|
557
636
|
class="esc-w-tab"
|
|
558
|
-
:class="{ active:
|
|
559
|
-
@click="
|
|
560
|
-
activeTab = 'status';
|
|
561
|
-
error = '';
|
|
562
|
-
statusResult = null;
|
|
563
|
-
"
|
|
637
|
+
:class="{ active: currentTab === tab }"
|
|
638
|
+
@click="selectTab(tab)"
|
|
564
639
|
>
|
|
565
|
-
|
|
640
|
+
{{ TAB_LABELS[tab] }}
|
|
566
641
|
</button>
|
|
567
642
|
</div>
|
|
568
643
|
|
|
569
644
|
<div class="esc-w-body">
|
|
570
|
-
<template v-if="
|
|
645
|
+
<template v-if="currentTab === 'help' && kbEnabled">
|
|
571
646
|
<template v-if="!selectedArticle">
|
|
572
647
|
<div class="esc-w-field">
|
|
573
648
|
<input v-model="searchQuery" type="text" class="esc-w-input" placeholder="Search for help..." />
|
|
@@ -606,7 +681,7 @@ function formatDate(iso) {
|
|
|
606
681
|
</template>
|
|
607
682
|
</template>
|
|
608
683
|
|
|
609
|
-
<template v-if="
|
|
684
|
+
<template v-if="currentTab === 'contact'">
|
|
610
685
|
<template v-if="ticketSuccess">
|
|
611
686
|
<div class="esc-w-success">
|
|
612
687
|
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -638,7 +713,7 @@ function formatDate(iso) {
|
|
|
638
713
|
<label class="esc-w-label">Description</label>
|
|
639
714
|
<textarea v-model="ticketForm.description" class="esc-w-textarea" required></textarea>
|
|
640
715
|
</div>
|
|
641
|
-
<div v-if="departments.length" class="esc-w-field">
|
|
716
|
+
<div v-if="departments.length && !departmentLocked" class="esc-w-field">
|
|
642
717
|
<label class="esc-w-label">Department</label>
|
|
643
718
|
<select v-model="ticketForm.department_id" class="esc-w-select">
|
|
644
719
|
<option value="">Select a department...</option>
|
|
@@ -671,7 +746,7 @@ function formatDate(iso) {
|
|
|
671
746
|
</template>
|
|
672
747
|
|
|
673
748
|
<!-- Chat Tab -->
|
|
674
|
-
<template v-if="
|
|
749
|
+
<template v-if="currentTab === 'chat'">
|
|
675
750
|
<!-- Pre-chat form -->
|
|
676
751
|
<template v-if="chatPhase === 'pre'">
|
|
677
752
|
<div class="esc-w-chat-avail">
|
|
@@ -688,7 +763,7 @@ function formatDate(iso) {
|
|
|
688
763
|
<label class="esc-w-label">Email</label>
|
|
689
764
|
<input v-model="preChatForm.email" type="email" class="esc-w-input" required />
|
|
690
765
|
</div>
|
|
691
|
-
<div v-if="departments.length > 1" class="esc-w-field">
|
|
766
|
+
<div v-if="departments.length > 1 && !departmentLocked" class="esc-w-field">
|
|
692
767
|
<label class="esc-w-label">Department</label>
|
|
693
768
|
<select v-model="preChatForm.department_id" class="esc-w-select">
|
|
694
769
|
<option value="">Select a department...</option>
|
|
@@ -851,7 +926,7 @@ function formatDate(iso) {
|
|
|
851
926
|
</template>
|
|
852
927
|
</template>
|
|
853
928
|
|
|
854
|
-
<template v-if="
|
|
929
|
+
<template v-if="currentTab === 'status'">
|
|
855
930
|
<div v-if="error" class="esc-w-error">{{ error }}</div>
|
|
856
931
|
<template v-if="!statusResult">
|
|
857
932
|
<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
|
}
|