@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.
@@ -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 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
+ // 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 preChatForm = ref({ name: '', email: '', department_id: '', message: '' });
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: '', reference: '' });
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 = { name: '', email: '', subject: '', description: '', department_id: '' };
112
- statusForm.value = { email: '', reference: '' };
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 = { name: '', email: '', department_id: '', message: '' };
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>Support</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: activeTab === 'status' }"
559
- @click="
560
- activeTab = 'status';
561
- error = '';
562
- statusResult = null;
563
- "
637
+ :class="{ active: currentTab === tab }"
638
+ @click="selectTab(tab)"
564
639
  >
565
- Check Status
640
+ {{ TAB_LABELS[tab] }}
566
641
  </button>
567
642
  </div>
568
643
 
569
644
  <div class="esc-w-body">
570
- <template v-if="activeTab === 'help' && kbEnabled">
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="activeTab === 'contact'">
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="activeTab === 'chat'">
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="activeTab === 'status'">
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
+ }
@@ -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
  }