@escalated-dev/escalated 0.11.10 → 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 +103 -1
- package/dist/widget/escalated-widget.js +215 -0
- package/package.json +24 -5
- package/pages.json +7 -3
- package/src/components/ActiveChatsPanel.vue +4 -1
- package/src/components/ChatQueue.vue +4 -1
- package/src/components/GuestEmailCode.vue +58 -0
- package/src/components/PresenceIndicator.vue +25 -4
- package/src/components/ReportExportButton.vue +13 -0
- package/src/components/TotpInput.vue +1 -1
- package/src/composables/useChat.js +16 -33
- package/src/composables/useGuestVerification.js +48 -0
- package/src/composables/useKeyboardShortcuts.js +2 -0
- package/src/composables/useMentions.js +16 -11
- package/src/composables/useRealtime.js +108 -147
- package/src/pages/Admin/ApiTokens/Index.vue +3 -4
- package/src/pages/Admin/Settings/TwoFactor.vue +34 -7
- package/src/pages/Admin/Settings.vue +273 -72
- package/src/pages/Agent/TicketIndex.vue +3 -1
- package/src/pages/Agent/TicketShow.vue +11 -3
- package/src/pages/Guest/Create.vue +137 -7
- package/src/pages/Guest/Show.vue +6 -0
- package/src/utils/flash.js +4 -0
- package/src/utils/guestRequests.js +27 -0
- package/src/widget/EscalatedWidget.vue +323 -86
- package/src/widget/embed.js +120 -0
- package/src/widget/index.js +93 -33
|
@@ -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
|
}
|