@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.
@@ -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 isOpen = ref(false);
22
- const activeTab = ref('help');
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 preChatForm = ref({ name: '', email: '', department_id: '', message: '' });
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: '', reference: '' });
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 = { name: '', email: '', subject: '', description: '', department_id: '' };
112
- statusForm.value = { email: '', reference: '' };
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 = { name: '', email: '', department_id: '', message: '' };
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>Support</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: activeTab === 'status' }"
559
- @click="
560
- activeTab = 'status';
561
- error = '';
562
- statusResult = null;
563
- "
636
+ :class="{ active: currentTab === tab }"
637
+ @click="selectTab(tab)"
564
638
  >
565
- Check Status
639
+ {{ TAB_LABELS[tab] }}
566
640
  </button>
567
641
  </div>
568
642
 
569
643
  <div class="esc-w-body">
570
- <template v-if="activeTab === 'help' && kbEnabled">
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="activeTab === 'contact'">
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="activeTab === 'chat'">
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="activeTab === 'status'">
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
+ }
@@ -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
- (function () {
5
- const scriptTag = document.currentScript || document.querySelector('script[data-escalated-widget]');
6
- const globalConfig = window.EscalatedWidget || {};
7
-
8
- const config = {
9
- baseUrl: scriptTag?.getAttribute('data-base-url') || globalConfig.baseUrl || '',
10
- // Host-framework-specific path prefix. Every plugin except the NestJS
11
- // reference mounts at /support/widget; NestJS mounts at
12
- // /escalated/widget. Set via data-widget-path on NestJS backends.
13
- widgetPath: scriptTag?.getAttribute('data-widget-path') || globalConfig.widgetPath || '/support/widget',
14
- color: scriptTag?.getAttribute('data-color') || globalConfig.color || '#4F46E5',
15
- position: scriptTag?.getAttribute('data-position') || globalConfig.position || 'bottom-right',
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.id = 'escalated-widget-host';
20
- document.body.appendChild(host);
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 styleEl = document.createElement('style');
29
- styleEl.textContent = getWidgetStyles();
30
- shadow.appendChild(styleEl);
31
-
34
+ const widget = ref(null);
32
35
  const app = createApp({
33
- render() {
34
- return h(EscalatedWidget, {
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
- window.EscalatedWidget = Object.assign(window.EscalatedWidget || {}, {
46
- open() {
47
- mountEl.querySelector('[data-widget-panel]')?.classList.add('esc-open');
48
- },
49
- close() {
50
- mountEl.querySelector('[data-widget-panel]')?.classList.remove('esc-open');
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
  }