andy-widget-react 2.2.0 → 2.3.0

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/dist/index.esm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
- import { useState, useRef, useEffect, useCallback } from 'react';
4
- import ReactDOM from 'react-dom';
3
+ import { useRef, useState, useSyncExternalStore, useEffect, useCallback } from 'react';
4
+ import ReactDOM, { createPortal } from 'react-dom';
5
5
 
6
6
  /**
7
7
  * Design tokens for AndyChat
@@ -771,6 +771,11 @@ const sendMessageForWidget = async (embedId, sessionId, message, options = {}) =
771
771
  messages: [{ role: 'user', parts: [{ type: 'text', text: message }] }]
772
772
  })
773
773
  });
774
+ return consumeWidgetReply(response, options)
775
+ };
776
+
777
+ /** Shared text-only UI stream decoder; no retry can occur after reading starts. */
778
+ const consumeWidgetReply = async (response, options = {}) => {
774
779
  if (!response.ok || !response.headers.get('content-type')?.includes('text/event-stream')) {
775
780
  const data = await response.json().catch(() => null);
776
781
  throw new Error(data?.error || data?.message || 'Chat is temporarily unavailable.')
@@ -811,6 +816,7 @@ const sendMessageForWidget = async (embedId, sessionId, message, options = {}) =
811
816
  };
812
817
  try {
813
818
  while (!complete) {
819
+ options.signal?.throwIfAborted();
814
820
  const { value, done } = await reader.read();
815
821
  buffer += decoder.decode(value, { stream: !done });
816
822
  let newline;
@@ -1493,9 +1499,6 @@ const generateDesignTokenVariablesString = () => {
1493
1499
  --andy-chat-avatar-size: 32px;
1494
1500
  --andy-chat-avatar-size-small: 24px;
1495
1501
 
1496
- /* Keyboard handling */
1497
- --andy-keyboard-height: 0px;
1498
-
1499
1502
  /* === THEMABLE VARIABLES (Default Light Theme) === */
1500
1503
  --andy-themable-background: var(--andy-color-background);
1501
1504
  --andy-themable-background-secondary: var(--andy-color-white);
@@ -2879,11 +2882,6 @@ const getCoreStructuralCssString = () => {
2879
2882
  }
2880
2883
  }
2881
2884
 
2882
- /* No special handling for keyboard - let browser handle it naturally */
2883
- .andy-chat-window--keyboard-open {
2884
- /* Browser handles positioning automatically with our margin-based approach */
2885
- }
2886
-
2887
2885
  /* Ensure the window content is properly laid out */
2888
2886
  .andy-chat-window__messages {
2889
2887
  flex: 1 1 auto;
@@ -3001,17 +2999,12 @@ const getCoreStructuralCssString = () => {
3001
2999
  }
3002
3000
  }
3003
3001
 
3004
- /* Desktop keyboard handling (for testing) */
3002
+ /* Desktop */
3005
3003
  @media (min-width: 521px) {
3006
3004
  /* Desktop retains original positioning behavior */
3007
3005
  .andy-chat-window {
3008
3006
  transition: transform 0.3s ease-out;
3009
3007
  }
3010
-
3011
- /* Optional: Add keyboard handling for desktop if needed */
3012
- .andy-chat-window--keyboard-open {
3013
- /* No transform on desktop - keyboard doesn't affect viewport */
3014
- }
3015
3008
  }
3016
3009
 
3017
3010
  /* Orientation */
@@ -3503,244 +3496,6 @@ const UI_STRINGS = {
3503
3496
  */
3504
3497
  const getUiStrings = lang => UI_STRINGS[lang] || UI_STRINGS.en;
3505
3498
 
3506
- /**
3507
- * Keyboard detection utility for handling mobile virtual keyboards
3508
- * Provides cross-browser support for detecting keyboard open/close events
3509
- */
3510
-
3511
- class KeyboardDetector {
3512
- constructor() {
3513
- this.isKeyboardOpen = false;
3514
- this.keyboardHeight = 0;
3515
- this.listeners = new Set();
3516
- this.inputElement = null;
3517
- this.viewportHeight = window.innerHeight;
3518
- this.visualViewport = window.visualViewport;
3519
-
3520
- this.initializeDetection();
3521
- }
3522
-
3523
- /**
3524
- * Initialize keyboard detection
3525
- */
3526
- initializeDetection() {
3527
- // Use Visual Viewport API if available (most modern browsers)
3528
- if (this.visualViewport) {
3529
- this.visualViewport.addEventListener('resize', this.handleViewportResize.bind(this));
3530
- this.visualViewport.addEventListener('scroll', this.handleViewportScroll.bind(this));
3531
- } else {
3532
- // Fallback for older browsers
3533
- window.addEventListener('resize', this.handleWindowResize.bind(this));
3534
- }
3535
-
3536
- // Additional detection via focus/blur events
3537
- document.addEventListener('focusin', this.handleFocusIn.bind(this), true);
3538
- document.addEventListener('focusout', this.handleFocusOut.bind(this), true);
3539
-
3540
- // Orientation change handler
3541
- window.addEventListener('orientationchange', this.handleOrientationChange.bind(this));
3542
- }
3543
-
3544
- /**
3545
- * Handle visual viewport resize (modern browsers)
3546
- */
3547
- handleViewportResize() {
3548
- const currentHeight = this.visualViewport.height;
3549
- const heightDifference = window.innerHeight - currentHeight;
3550
-
3551
- // If viewport shrunk by more than 100px, keyboard is likely open
3552
- if (heightDifference > 100) {
3553
- this.setKeyboardOpen(true, heightDifference);
3554
- } else if (heightDifference < 50) {
3555
- this.setKeyboardOpen(false, 0);
3556
- }
3557
- }
3558
-
3559
- /**
3560
- * Handle visual viewport scroll
3561
- */
3562
- handleViewportScroll() {
3563
- // On iOS, viewport scrolls when keyboard opens
3564
- if (this.visualViewport.offsetTop > 0) {
3565
- this.setKeyboardOpen(true, this.visualViewport.offsetTop);
3566
- }
3567
- }
3568
-
3569
- /**
3570
- * Handle window resize (fallback for older browsers)
3571
- */
3572
- handleWindowResize() {
3573
- const currentHeight = window.innerHeight;
3574
- const heightDifference = this.viewportHeight - currentHeight;
3575
-
3576
- // Significant height reduction likely means keyboard opened
3577
- if (heightDifference > 100) {
3578
- this.setKeyboardOpen(true, heightDifference);
3579
- } else if (Math.abs(heightDifference) < 50) {
3580
- this.setKeyboardOpen(false, 0);
3581
- this.viewportHeight = currentHeight;
3582
- }
3583
- }
3584
-
3585
- /**
3586
- * Handle input focus
3587
- */
3588
- handleFocusIn(e) {
3589
- const isInput = ['input', 'textarea'].includes(e.target.tagName.toLowerCase());
3590
- if (isInput) {
3591
- this.inputElement = e.target;
3592
- // Delay to allow keyboard animation
3593
- setTimeout(() => {
3594
- if (this.inputElement === e.target) {
3595
- this.checkKeyboardStatus();
3596
- }
3597
- }, 300);
3598
- }
3599
- }
3600
-
3601
- /**
3602
- * Handle input blur
3603
- */
3604
- handleFocusOut(e) {
3605
- const isInput = ['input', 'textarea'].includes(e.target.tagName.toLowerCase());
3606
- if (isInput && this.inputElement === e.target) {
3607
- this.inputElement = null;
3608
- // Delay to allow keyboard animation
3609
- setTimeout(() => {
3610
- if (!this.inputElement) {
3611
- this.setKeyboardOpen(false, 0);
3612
- }
3613
- }, 300);
3614
- }
3615
- }
3616
-
3617
- /**
3618
- * Handle orientation change
3619
- */
3620
- handleOrientationChange() {
3621
- // Reset viewport height after orientation change
3622
- setTimeout(() => {
3623
- this.viewportHeight = window.innerHeight;
3624
- this.checkKeyboardStatus();
3625
- }, 500);
3626
- }
3627
-
3628
- /**
3629
- * Check keyboard status (for browsers without Visual Viewport API)
3630
- */
3631
- checkKeyboardStatus() {
3632
- if (!this.visualViewport) {
3633
- const currentHeight = window.innerHeight;
3634
- const heightDifference = this.viewportHeight - currentHeight;
3635
-
3636
- if (this.inputElement && heightDifference > 100) {
3637
- this.setKeyboardOpen(true, heightDifference);
3638
- }
3639
- }
3640
- }
3641
-
3642
- /**
3643
- * Set keyboard state and notify listeners
3644
- */
3645
- setKeyboardOpen(isOpen, height) {
3646
- const changed = this.isKeyboardOpen !== isOpen || this.keyboardHeight !== height;
3647
-
3648
- this.isKeyboardOpen = isOpen;
3649
- this.keyboardHeight = height;
3650
-
3651
- if (changed) {
3652
- this.notifyListeners();
3653
- }
3654
- }
3655
-
3656
- /**
3657
- * Add listener for keyboard state changes
3658
- */
3659
- addListener(callback) {
3660
- this.listeners.add(callback);
3661
- // Immediately notify with current state
3662
- callback({
3663
- isOpen: this.isKeyboardOpen,
3664
- height: this.keyboardHeight
3665
- });
3666
- }
3667
-
3668
- /**
3669
- * Remove listener
3670
- */
3671
- removeListener(callback) {
3672
- this.listeners.delete(callback);
3673
- }
3674
-
3675
- /**
3676
- * Notify all listeners of state change
3677
- */
3678
- notifyListeners() {
3679
- const state = {
3680
- isOpen: this.isKeyboardOpen,
3681
- height: this.keyboardHeight
3682
- };
3683
-
3684
- this.listeners.forEach(callback => {
3685
- try {
3686
- callback(state);
3687
- } catch (error) {
3688
- console.error('Error in keyboard detector listener:', error);
3689
- }
3690
- });
3691
- }
3692
-
3693
- /**
3694
- * Get current keyboard state
3695
- */
3696
- getState() {
3697
- return {
3698
- isOpen: this.isKeyboardOpen,
3699
- height: this.keyboardHeight
3700
- }
3701
- }
3702
-
3703
- /**
3704
- * Cleanup
3705
- */
3706
- destroy() {
3707
- if (this.visualViewport) {
3708
- this.visualViewport.removeEventListener('resize', this.handleViewportResize.bind(this));
3709
- this.visualViewport.removeEventListener('scroll', this.handleViewportScroll.bind(this));
3710
- } else {
3711
- window.removeEventListener('resize', this.handleWindowResize.bind(this));
3712
- }
3713
-
3714
- document.removeEventListener('focusin', this.handleFocusIn.bind(this), true);
3715
- document.removeEventListener('focusout', this.handleFocusOut.bind(this), true);
3716
- window.removeEventListener('orientationchange', this.handleOrientationChange.bind(this));
3717
-
3718
- this.listeners.clear();
3719
- }
3720
- }
3721
-
3722
- // Create singleton instance
3723
- let keyboardDetectorInstance = null;
3724
-
3725
- /**
3726
- * Get or create keyboard detector instance
3727
- */
3728
- function getKeyboardDetector() {
3729
- if (!keyboardDetectorInstance) {
3730
- keyboardDetectorInstance = new KeyboardDetector();
3731
- }
3732
- return keyboardDetectorInstance
3733
- }
3734
-
3735
- /**
3736
- * Update CSS custom property on host element
3737
- */
3738
- function updateKeyboardHeight(hostElement, height) {
3739
- if (hostElement) {
3740
- hostElement.style.setProperty('--andy-keyboard-height', `${height}px`);
3741
- }
3742
- }
3743
-
3744
3499
  /**
3745
3500
  * Mobile detection utility for AndyChat
3746
3501
  * Provides reliable mobile device detection with multiple methods
@@ -3926,7 +3681,425 @@ const createBrowserHandoff = (chatbotId, bootstrap, onProjection, fresh = false)
3926
3681
  }
3927
3682
  };
3928
3683
 
3929
- const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessage, bubbleLabel, lang, predefinedQuestions, theme, orientation, brandColor, bubbleStyle, useBrandColor, logoUrl, previewMode = false, initialOpen = false }) => {
3684
+ /** In-page Requester credentials. A failed response is renewed once, before its body is consumed. */
3685
+ const createRequesterFetch = getRequesterPass => {
3686
+ let pass = null;
3687
+ let loading = null;
3688
+ let revision = 0;
3689
+ const obtain = async () => {
3690
+ const current = revision;
3691
+ if (!loading) {
3692
+ const pending = Promise.resolve().then(getRequesterPass).then(value => {
3693
+ if (current !== revision || typeof value !== 'string' || !value.trim()) throw new Error('Requester authentication is unavailable.')
3694
+ pass = value;
3695
+ return value
3696
+ }).catch(() => { throw new Error('Requester authentication is unavailable.') }).finally(() => { if (loading === pending) loading = null; });
3697
+ loading = pending;
3698
+ }
3699
+ return loading
3700
+ };
3701
+ const requesterFetch = async (url, options = {}) => {
3702
+ const current = revision;
3703
+ const headers = new Headers(options.headers);
3704
+ if (headers.has('authorization')) throw new Error('Requester requests cannot use operator authorization.')
3705
+ const attempt = async credential => {
3706
+ options.signal?.throwIfAborted();
3707
+ if (current !== revision) throw new DOMException('Requester context changed.', 'AbortError')
3708
+ const selected = new Headers(headers);
3709
+ selected.set('X-Andy-Requester', credential);
3710
+ const response = await fetch(url, { ...options, headers: selected, credentials: 'omit', cache: 'no-store' });
3711
+ if (current !== revision) throw new DOMException('Requester context changed.', 'AbortError')
3712
+ return response
3713
+ };
3714
+ let response = await attempt(pass || await obtain());
3715
+ if (!response.ok) {
3716
+ const problem = await response.clone().json().catch(() => null);
3717
+ // Private help and canonical Ticket HTTP use their existing problem envelopes.
3718
+ if (['expired', 'unknown_key'].includes(problem?.problem?.code ?? problem?.code)) {
3719
+ pass = null;
3720
+ response = await attempt(await obtain());
3721
+ }
3722
+ }
3723
+ return response
3724
+ };
3725
+ requesterFetch.clear = () => { revision++; pass = null; loading = null; };
3726
+ return requesterFetch
3727
+ };
3728
+
3729
+ const createSupportClient = ({ installationId, getRequesterPass, apiBaseUrl }) => {
3730
+ if (!/^[a-zA-Z0-9_-]{3,100}$/.test(installationId)) throw new Error('A Support installation is required.')
3731
+ const base = (apiBaseUrl || getApiBaseUrl()).replace(/\/+$/, '');
3732
+ const requesterFetch = createRequesterFetch(getRequesterPass);
3733
+ const widgetUrl = `${base}/support/widget/${encodeURIComponent(installationId)}`;
3734
+ const json = async response => {
3735
+ const result = await response.json().catch(() => null);
3736
+ if (!response.ok || result?.ok === false) {
3737
+ const error = new Error('Support is temporarily unavailable. Please retry.');
3738
+ error.code = result?.problem?.code || result?.code || 'unavailable';
3739
+ throw error
3740
+ }
3741
+ return result
3742
+ };
3743
+ return {
3744
+ bootstrap: signal => requesterFetch(widgetUrl, { signal }).then(json),
3745
+ answer: async (messages, options = {}) => {
3746
+ const response = await requesterFetch(widgetUrl, {
3747
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: options.signal,
3748
+ body: JSON.stringify({ messages })
3749
+ });
3750
+ if (!response.ok) await json(response);
3751
+ return consumeWidgetReply(response, options)
3752
+ },
3753
+ operation: async (action, input, signal) => json(await requesterFetch(`${base}/public/operations/support/request/${action}`, {
3754
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, signal, body: JSON.stringify(input)
3755
+ })),
3756
+ dispose: () => requesterFetch.clear()
3757
+ }
3758
+ };
3759
+
3760
+ /** One in-memory conversation/form receipt lifecycle shared by React renderers and hooks. */
3761
+ const createSupportController = options => {
3762
+ const client = createSupportClient(options);
3763
+ const listeners = new Set();
3764
+ let state = { config: null, view: 'conversation', messages: [], transcript: [], tickets: [], ticket: null, entries: [], loading: true, busy: false, error: null, submission: null, followup: null, unreadCount: 0 };
3765
+ let active = null;
3766
+ let generation = 0;
3767
+ let disposed = false;
3768
+ let submitting = null;
3769
+ let following = null;
3770
+ let visible = true;
3771
+ let timer = null;
3772
+ const schedule = () => {
3773
+ clearTimeout(timer);
3774
+ if (!disposed && state.config && listeners.size) {
3775
+ timer = setTimeout(() => void refresh(), 3000);
3776
+ timer.unref?.();
3777
+ }
3778
+ };
3779
+ const publish = changes => {
3780
+ if (disposed) return
3781
+ state = { ...state, ...changes };
3782
+ listeners.forEach(listener => listener());
3783
+ };
3784
+ const abort = () => { generation++; active?.abort(); active = null; clearTimeout(timer); };
3785
+ const fail = error => {
3786
+ if (error?.code === 'resource_not_found' && state.view === 'request') publish({ ticket: null, entries: [], followup: null });
3787
+ if (error?.code === 'ticket_resolved' && state.ticket) {
3788
+ updateTicket({ ...state.ticket, status: 'resolved' });
3789
+ publish({ followup: null });
3790
+ }
3791
+ publish({ error: error?.code || 'unavailable', busy: false, loading: false });
3792
+ };
3793
+ const request = async (work, background = false) => {
3794
+ abort();
3795
+ const epoch = generation;
3796
+ const controller = new AbortController();
3797
+ active = controller;
3798
+ if (!background) publish({ busy: true, error: null });
3799
+ try {
3800
+ const result = await work(controller.signal, epoch);
3801
+ if (disposed || generation !== epoch || controller.signal.aborted) return
3802
+ return result
3803
+ } catch (error) {
3804
+ if (!controller.signal.aborted && generation === epoch) fail(error);
3805
+ } finally {
3806
+ if (active === controller) { active = null; if (!background) publish({ busy: false }); schedule(); }
3807
+ }
3808
+ };
3809
+ const current = (signal, epoch) => !disposed && generation === epoch && !signal.aborted;
3810
+ const page = async (action, input, signal, field) => {
3811
+ let result, cursor;
3812
+ const values = [];
3813
+ do {
3814
+ result = await client.operation(action, { ...input, pageSize: 100, ...(cursor ? { cursor } : {}) }, signal);
3815
+ values.push(...result.data[field]);
3816
+ cursor = result.meta?.nextCursor;
3817
+ } while (cursor && !signal.aborted)
3818
+ return { ...result.data, [field]: values }
3819
+ };
3820
+ const updateTicket = ticket => {
3821
+ const tickets = state.tickets.map(previous => previous.id === ticket.id ? ticket : previous);
3822
+ publish({ ticket, tickets, unreadCount: tickets.filter(ticket => ticket.unread).length });
3823
+ };
3824
+ const readTicket = async (ticketId, signal, epoch) => {
3825
+ const result = await page('inspect', { ticketId }, signal, 'entries');
3826
+ if (!current(signal, epoch)) return
3827
+ // Only entries returned for this reader can advance the server's seen mark.
3828
+ const entries = result.entries.sort((a, b) => a.sequence - b.sequence);
3829
+ updateTicket(result.ticket);
3830
+ publish({ entries });
3831
+ const throughSequence = Math.max(0, ...entries.map(entry => entry.sequence || 0));
3832
+ if (visible && globalThis.document?.visibilityState !== 'hidden' && throughSequence > result.ticket.requesterLastSeenSequence) {
3833
+ const seen = await client.operation('mark_seen', { ticketId, throughSequence }, signal);
3834
+ if (current(signal, epoch)) updateTicket(seen.data.ticket);
3835
+ }
3836
+ };
3837
+ const readRequests = async (signal, epoch) => {
3838
+ const result = await page('list', {}, signal, 'tickets');
3839
+ if (current(signal, epoch)) publish({ tickets: result.tickets, unreadCount: result.tickets.filter(ticket => ticket.unread).length });
3840
+ };
3841
+ const refresh = () => {
3842
+ if (disposed || !state.config) return Promise.resolve()
3843
+ if (state.busy || globalThis.document?.visibilityState === 'hidden') { schedule(); return Promise.resolve() }
3844
+ return request(async (signal, epoch) => {
3845
+ await readRequests(signal, epoch);
3846
+ if (current(signal, epoch) && visible && state.view === 'request' && state.ticket) await readTicket(state.ticket.id, signal, epoch);
3847
+ }, true)
3848
+ };
3849
+ const transcript = () => state.messages.map(({ role, content }) => ({ role, content }));
3850
+ const bootstrap = () => request(async (signal, epoch) => {
3851
+ const config = await client.bootstrap(signal);
3852
+ if (generation !== epoch || signal.aborted) return
3853
+ publish({ config, loading: false, messages: [{ role: 'assistant', content: config.greeting }] });
3854
+ });
3855
+ const send = async content => {
3856
+ if (state.busy || state.loading || state.view !== 'conversation' || !content.trim() || content.length > 10_000 || state.messages.length >= 39) return
3857
+ publish({ messages: [...transcript(), { role: 'user', content: content.trim() }] });
3858
+ const history = transcript();
3859
+ return request(async (signal, epoch) => {
3860
+ const show = text => {
3861
+ if (generation === epoch && !signal.aborted && state.view === 'conversation')
3862
+ publish({ messages: [...history, { role: 'assistant', content: text }] });
3863
+ };
3864
+ const result = await client.answer(history, { signal, onText: show });
3865
+ if (generation === epoch && !signal.aborted && result.status === 'agent') show(result.text);
3866
+ })
3867
+ };
3868
+ const escalate = () => {
3869
+ if (!state.config) return
3870
+ abort();
3871
+ publish({ view: 'form', transcript: transcript(), busy: false, error: null, submission: null, ticket: null });
3872
+ schedule();
3873
+ };
3874
+ const submit = (subject, body, replyLanguage = 'es') => {
3875
+ if (submitting) return submitting
3876
+ if (state.view !== 'form' || !subject.trim() || !body.trim()) return Promise.resolve()
3877
+ // A retry uses the captured consent/input, even if the caller passes different arguments.
3878
+ const submission = state.submission || { subject: subject.trim(), body: body.trim(), replyLanguage, transcript: state.transcript, idempotencyKey: crypto.randomUUID() };
3879
+ publish({ submission });
3880
+ submitting = request(async (signal, epoch) => {
3881
+ const result = await client.operation('open', submission, signal);
3882
+ if (generation === epoch && !signal.aborted) publish({ view: 'receipt', ticket: result.data.ticket, error: null });
3883
+ return result
3884
+ }).finally(() => { submitting = null; });
3885
+ return submitting
3886
+ };
3887
+ const showRequests = () => {
3888
+ abort();
3889
+ publish({ view: 'requests', busy: false, error: null });
3890
+ return request(readRequests)
3891
+ };
3892
+ const inspect = ticketId => {
3893
+ abort();
3894
+ publish({ view: 'request', ticket: null, entries: [], followup: null, busy: false, error: null });
3895
+ return request((signal, epoch) => readTicket(ticketId, signal, epoch))
3896
+ };
3897
+ const reply = (body, reopen = false) => {
3898
+ if (following) return following
3899
+ if (state.view !== 'request' || !state.ticket || !body.trim() || body.length > 20_000) return Promise.resolve()
3900
+ if (state.ticket.status === 'resolved' && !reopen) { publish({ error: 'ticket_resolved' }); return Promise.resolve() }
3901
+ const action = reopen ? 'reopen' : 'reply';
3902
+ const followup = state.followup?.action === action ? state.followup : { action, ticketId: state.ticket.id, body: body.trim(), idempotencyKey: crypto.randomUUID() };
3903
+ publish({ followup });
3904
+ following = request(async (signal, epoch) => {
3905
+ const result = await client.operation(followup.action, { ticketId: followup.ticketId, body: followup.body, idempotencyKey: followup.idempotencyKey }, signal);
3906
+ if (!current(signal, epoch)) return
3907
+ await readTicket(followup.ticketId, signal, epoch);
3908
+ if (current(signal, epoch)) publish({ followup: null });
3909
+ return result
3910
+ }).finally(() => { following = null; });
3911
+ return following
3912
+ };
3913
+ const newProblem = () => {
3914
+ abort();
3915
+ publish({ view: 'form', transcript: [], submission: null, followup: null, ticket: null, entries: [], busy: false, error: null });
3916
+ schedule();
3917
+ };
3918
+ const newConversation = () => {
3919
+ abort();
3920
+ publish({ view: 'conversation', messages: state.config ? [{ role: 'assistant', content: state.config.greeting }] : [], transcript: [], ticket: null, entries: [], error: null, busy: false, submission: null, followup: null });
3921
+ schedule();
3922
+ };
3923
+ return {
3924
+ getSnapshot: () => state,
3925
+ subscribe: listener => { listeners.add(listener); schedule(); return () => { listeners.delete(listener); if (!listeners.size) clearTimeout(timer); } },
3926
+ bootstrap, send, escalate, submit, showRequests, inspect, newConversation, refresh, reply, newProblem,
3927
+ setVisible: value => { visible = value; if (value) void refresh(); },
3928
+ back: () => { abort(); publish({ view: 'conversation', busy: false, error: null, followup: null }); schedule(); },
3929
+ dispose: () => { abort(); publish({ config: null, view: 'conversation', messages: [], transcript: [], ticket: null, entries: [], tickets: [], submission: null, followup: null, unreadCount: 0, busy: false, loading: true, error: null }); disposed = true; client.dispose(); listeners.clear(); }
3930
+ }
3931
+ };
3932
+
3933
+ /** React Support panel uses the shared design tokens and its own BEM namespace. */
3934
+ const getSupportStylesheet = () => `
3935
+ :host { font-family: var(--andy-font-family-primary); color: #171717; line-height: 1.5; --andy-support-color: #171717; --andy-support-contrast: #fff; }
3936
+ *, *::before, *::after { box-sizing: border-box; }
3937
+ .andy-support button, .andy-support input, .andy-support textarea { font: inherit; }
3938
+ .andy-support button { cursor: pointer; }
3939
+ .andy-support button:disabled { cursor: default; opacity: .45; }
3940
+ .andy-support button:focus-visible, .andy-support a:focus-visible, .andy-support input:focus-visible, .andy-support textarea:focus-visible, .andy-support summary:focus-visible { outline: 2px solid var(--andy-support-color); outline-offset: 3px; }
3941
+ .andy-support__launcher { position: fixed; bottom: 24px; right: 24px; z-index: var(--andy-z-fixed); border: 0; border-radius: var(--andy-radius-full); padding: 14px 20px; background: var(--andy-support-color); color: var(--andy-support-contrast); box-shadow: var(--andy-shadow-lg); display: flex; gap: 24px; align-items: center; font-size: 14px; }
3942
+ .andy-support__panel { position: fixed; right: 24px; bottom: 24px; z-index: var(--andy-z-fixed); width: 400px; max-width: calc(100vw - 48px); height: min(680px, calc(100dvh - 48px)); background: #fff; border: 1px solid #e5e7eb; border-radius: 20px; box-shadow: var(--andy-shadow-xl); display: flex; flex-direction: column; overflow: hidden; font-size: 14px; }
3943
+ :host(.andy-support-host--left) .andy-support__launcher, :host(.andy-support-host--left) .andy-support__panel { left: 24px; right: auto; }
3944
+ .andy-support__header { flex-shrink: 0; position: relative; display: flex; align-items: center; gap: 10px; padding: 18px; border-bottom: 1px solid #f0f0f0; }
3945
+ .andy-support__header > button { border: 0; background: transparent; color: #737373; padding: 5px; font-size: 22px; line-height: 1; min-width: 28px; }
3946
+ .andy-support__mark { width: 34px; height: 34px; border-radius: 10px; background: var(--andy-support-color); color: var(--andy-support-contrast); display: grid; place-items: center; font-weight: 600; }
3947
+ .andy-support__identity { flex: 1; min-width: 0; display: flex; flex-direction: column; }
3948
+ .andy-support__identity strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
3949
+ .andy-support__identity small { color: #737373; font-size: 12px; }
3950
+ .andy-support__menu { position: absolute; top: 65px; right: 16px; background: #fff; box-shadow: var(--andy-shadow-lg); border: 1px solid #e5e7eb; border-radius: 12px; padding: 6px; z-index: 1; min-width: 210px; }
3951
+ .andy-support__menu button { display: block; border: 0; border-radius: 6px; background: none; padding: 10px; text-align: left; width: 100%; }
3952
+ .andy-support__menu button:hover { background: #f5f5f5; }
3953
+ .andy-support__content { flex: 1; min-height: 0; overflow-y: auto; padding: 24px 20px; overscroll-behavior: contain; }
3954
+ .andy-support h2 { font-size: 17px; font-weight: 600; margin: 0 0 20px; }
3955
+ .andy-support__message { white-space: pre-wrap; overflow-wrap: anywhere; margin-bottom: 16px; line-height: 1.65; }
3956
+ .andy-support__message--user { margin-left: auto; width: fit-content; max-width: 90%; padding: 10px 14px; border-radius: 16px 16px 4px 16px; background: #f5f5f5; }
3957
+ .andy-support__questions { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; margin-top: 22px; }
3958
+ .andy-support__questions button, .andy-support__secondary { border: 1px solid #e5e7eb; border-radius: 18px; background: #fff; padding: 10px 14px; text-align: left; color: #525252; }
3959
+ .andy-support__questions button:hover, .andy-support__secondary:hover { background: #fafafa; }
3960
+ .andy-support__secondary { margin-top: 8px; }
3961
+ .andy-support__composer { flex-shrink: 0; display: flex; gap: 8px; margin: 0 18px 12px; padding: 8px; border: 1px solid #e5e7eb; border-radius: var(--andy-radius-full); background: #fafafa; }
3962
+ .andy-support__composer input { flex: 1; min-width: 0; border: 0; background: none; padding: 4px 8px; outline-offset: 0; color: inherit; }
3963
+ .andy-support__composer button { border: 0; width: 32px; height: 32px; border-radius: 50%; background: var(--andy-support-color); color: var(--andy-support-contrast); font-size: 20px; }
3964
+ .andy-support__footer { flex-shrink: 0; display: flex; justify-content: center; flex-wrap: wrap; gap: 10px; padding: 0 12px 14px; font-size: 10px; color: #a3a3a3; }
3965
+ .andy-support__footer a { color: #737373; text-decoration: none; }
3966
+ .andy-support__form { display: flex; flex-direction: column; gap: 16px; }
3967
+ .andy-support__form h2 { margin-bottom: 0; }
3968
+ .andy-support__form label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-weight: 500; }
3969
+ .andy-support__form input, .andy-support__form textarea { width: 100%; border: 1px solid #d4d4d4; border-radius: 10px; background: #fff; padding: 10px 12px; font-size: 14px; font-weight: 400; color: inherit; }
3970
+ .andy-support__form textarea { resize: vertical; }
3971
+ .andy-support__transcript { border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; font-size: 12px; }
3972
+ .andy-support__transcript summary { cursor: pointer; }
3973
+ .andy-support__transcript p, .andy-support__entry p { white-space: pre-wrap; overflow-wrap: anywhere; }
3974
+ .andy-support__muted { color: #737373; font-size: 12px; }
3975
+ .andy-support__primary { border: 0; border-radius: 10px; background: var(--andy-support-color); color: var(--andy-support-contrast); padding: 12px 18px; }
3976
+ .andy-support__receipt { text-align: center; padding: 30px 8px; }
3977
+ .andy-support__check { display: inline-grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; background: #f5f5f5; font-size: 24px; margin-bottom: 20px; }
3978
+ .andy-support__receipt h2 { margin-bottom: 10px; }
3979
+ .andy-support__receipt > strong { font-size: 22px; }
3980
+ .andy-support__receipt .andy-support__primary { margin-top: 16px; }
3981
+ .andy-support__ticket { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; width: 100%; text-align: left; padding: 16px 0; border: 0; border-bottom: 1px solid #eee; background: none; }
3982
+ .andy-support__ticket small { grid-column: 1; color: #737373; }
3983
+ .andy-support__ticket > span:last-child { grid-column: 2; grid-row: 1 / 3; align-self: center; font-size: 20px; }
3984
+ .andy-support__entry { border-bottom: 1px solid #eee; padding: 16px 0; }
3985
+ .andy-support__unread { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; margin-left: 6px; vertical-align: middle; }
3986
+ .andy-support__ticket .andy-support__unread, .andy-support__menu .andy-support__unread { color: var(--andy-support-color); }
3987
+ .andy-support__request-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
3988
+ .andy-support__request-actions .andy-support__secondary { margin-top: 0; }
3989
+ .andy-support__followup { flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; margin: 0 18px 12px; }
3990
+ .andy-support__followup label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
3991
+ .andy-support__followup textarea { width: 100%; max-height: 120px; resize: vertical; border: 1px solid #d4d4d4; border-radius: 10px; padding: 10px 12px; color: inherit; background: #fff; }
3992
+ .andy-support__followup button { align-self: flex-end; }
3993
+ .andy-support__error { margin-top: 16px; padding: 12px; border-radius: 10px; background: #fef2f2; color: #991b1b; font-size: 12px; }
3994
+ .andy-support__error button { border: 0; background: none; color: inherit; text-decoration: underline; padding: 0; }
3995
+ @media (max-width: 600px) { .andy-support__panel, :host(.andy-support-host--left) .andy-support__panel { inset: 0; width: 100%; max-width: none; height: 100dvh; border-radius: 0; border: 0; padding-bottom: env(safe-area-inset-bottom); } .andy-support__launcher { bottom: calc(18px + env(safe-area-inset-bottom)); right: 18px; } }
3996
+ @media (prefers-reduced-motion: reduce) { .andy-support__content { scroll-behavior: auto; } }
3997
+ `;
3998
+
3999
+ const empty = { config: null, view: 'conversation', messages: [], transcript: [], tickets: [], ticket: null, entries: [], loading: true, busy: false, error: null, submission: null, followup: null, unreadCount: 0 };
4000
+ const noSubscribe = () => () => { };
4001
+ const emptySnapshot = () => empty;
4002
+ /** The host context key changes before a new user/company is allowed to see any previous state. */
4003
+ const useSupportChat = (options) => {
4004
+ const key = options ? JSON.stringify([options.installationId, options.contextKey, options.apiBaseUrl]) : null;
4005
+ const callback = useRef(options?.getRequesterPass);
4006
+ callback.current = options?.getRequesterPass;
4007
+ const [owned, setOwned] = useState(null);
4008
+ const controller = owned?.key === key ? owned?.controller : null;
4009
+ const state = useSyncExternalStore(controller?.subscribe || noSubscribe, controller?.getSnapshot || emptySnapshot, emptySnapshot);
4010
+ useEffect(() => {
4011
+ if (!options || !key)
4012
+ return;
4013
+ const controller = createSupportController({ ...options, getRequesterPass: () => callback.current() });
4014
+ setOwned({ key, controller });
4015
+ void controller.bootstrap();
4016
+ return () => controller.dispose();
4017
+ }, [key]);
4018
+ useEffect(() => {
4019
+ if (state.config && options?.ticketId)
4020
+ void controller?.inspect(options.ticketId);
4021
+ }, [controller, state.config, options?.ticketId]);
4022
+ return { mode: 'support', state, controller };
4023
+ };
4024
+
4025
+ const copy = {
4026
+ es: { support: 'Soporte', close: 'Cerrar', back: 'Volver', menu: 'Menú', new: 'Nueva conversación', team: 'Hablar con el equipo', requests: 'Mis solicitudes', placeholder: '¿En qué te podemos ayudar?', send: 'Enviar', subject: 'Asunto', detail: 'Cuéntanos qué ocurrió', notice: 'Se adjuntará esta conversación', consent: 'Al enviar, aceptas adjuntar esta conversación para que el equipo pueda ayudarte.', submit: 'Enviar solicitud', receipt: 'Solicitud enviada', promise: 'El equipo responderá en', businessDay: 'un día hábil', hours: 'horas hábiles', minutes: 'minutos hábiles', empty: 'Todavía no tienes solicitudes.', powered: 'Con tecnología de Andy', loading: 'Cargando…', retry: 'Reintentar', error: 'No pudimos completar la solicitud. Puedes reintentar.', auth: 'Tu sesión de soporte no está disponible. Vuelve a iniciar sesión en esta aplicación.', denied: 'El soporte no está disponible para esta cuenta.', transcript: 'mensajes', reply: 'Escribe tu respuesta', reopen: 'Reabrir', reopening: 'Cuéntanos qué volvió a ocurrir', otherProblem: 'Es otro problema', reopened: 'Reabierto', unread: 'Respuestas sin leer', resolvedReply: 'Esta solicitud está resuelta. Reábrela para continuar con el mismo problema.', open: 'Recibido', in_progress: 'En curso', waiting_customer: 'Espera tu respuesta', resolved: 'Resuelto' },
4027
+ en: { support: 'Support', close: 'Close', back: 'Back', menu: 'Menu', new: 'New conversation', team: 'Talk to the team', requests: 'My requests', placeholder: 'How can we help?', send: 'Send', subject: 'Subject', detail: 'Tell us what happened', notice: 'This conversation will be attached', consent: 'By sending, you agree to attach this conversation so the team can help you.', submit: 'Send request', receipt: 'Request sent', promise: 'The team will reply within', businessDay: 'one business day', hours: 'business hours', minutes: 'business minutes', empty: 'You have no requests yet.', powered: 'Powered by Andy', loading: 'Loading…', retry: 'Retry', error: 'We could not complete the request. You can retry.', auth: 'Your support session is unavailable. Sign in again in this application.', denied: 'Support is unavailable for this account.', transcript: 'messages', reply: 'Write your reply', reopen: 'Reopen', reopening: 'Tell us what happened again', otherProblem: 'This is another problem', reopened: 'Reopened', unread: 'Unread replies', resolvedReply: 'This request is resolved. Reopen it to continue with the same problem.', open: 'Received', in_progress: 'In progress', waiting_customer: 'Waiting for your reply', resolved: 'Resolved' }
4028
+ };
4029
+ /** Private Requester UI. Sales bootstrap, visitor identity and storage never enter this renderer. */
4030
+ const SupportPanel = ({ support, lang, orientation = 'right', initialOpen = false, tooltip }) => {
4031
+ const { state, controller } = useSupportChat(support);
4032
+ const replyLanguage = lang || state.config?.defaultLang || 'es';
4033
+ const strings = copy[replyLanguage];
4034
+ const [open, setOpen] = useState(initialOpen);
4035
+ const [menu, setMenu] = useState(false);
4036
+ const [draft, setDraft] = useState('');
4037
+ const [subject, setSubject] = useState('');
4038
+ const [body, setBody] = useState('');
4039
+ const [replyDraft, setReplyDraft] = useState('');
4040
+ const [reopening, setReopening] = useState(false);
4041
+ const [selectedTicketId, setSelectedTicketId] = useState(null);
4042
+ const host = useRef(null);
4043
+ const messages = useRef(null);
4044
+ const [portal, setPortal] = useState(null);
4045
+ useEffect(() => {
4046
+ if (!host.current)
4047
+ return;
4048
+ const shadow = host.current.shadowRoot || host.current.attachShadow({ mode: 'open' });
4049
+ const style = document.createElement('style');
4050
+ style.textContent = generateDesignTokenVariablesString() + getSupportStylesheet();
4051
+ const target = document.createElement('div');
4052
+ shadow.append(style, target);
4053
+ setPortal(target);
4054
+ return () => { style.remove(); target.remove(); };
4055
+ }, []);
4056
+ useEffect(() => {
4057
+ const color = state.config?.branding.color || '#171717';
4058
+ host.current?.style.setProperty('--andy-support-color', color);
4059
+ host.current?.style.setProperty('--andy-support-contrast', getContrastTextColor(color));
4060
+ }, [state.config?.branding.color]);
4061
+ useEffect(() => { messages.current?.scrollTo?.({ top: messages.current.scrollHeight, behavior: 'smooth' }); }, [state.messages, state.view]);
4062
+ useEffect(() => { controller?.setVisible(open); }, [controller, open]);
4063
+ useEffect(() => { if (support.ticketId)
4064
+ setOpen(true); }, [support.ticketId]);
4065
+ useEffect(() => { if (state.ticket?.status !== 'resolved' && state.followup?.action !== 'reopen')
4066
+ setReopening(false); }, [state.ticket?.status, state.followup?.action]);
4067
+ const choose = (action) => { setMenu(false); action(); };
4068
+ const escalate = () => { setSubject(''); setBody(''); controller?.escalate(); };
4069
+ const send = () => { if (!draft.trim() || state.busy)
4070
+ return; void controller?.send(draft); setDraft(''); };
4071
+ const newProblem = () => { setSubject(''); setBody(''); setReplyDraft(''); setReopening(false); controller?.newProblem(); };
4072
+ const followup = async () => {
4073
+ const result = await controller?.reply(replyDraft, reopening);
4074
+ if (result) {
4075
+ setReplyDraft('');
4076
+ setReopening(false);
4077
+ }
4078
+ };
4079
+ const unread = jsx("span", { className: 'andy-support__unread', role: 'img', "aria-label": strings.unread });
4080
+ const title = state.config?.branding.name || strings.support;
4081
+ const ticketRow = (ticket) => jsxs("button", { type: 'button', className: 'andy-support__ticket', onClick: () => { setSelectedTicketId(ticket.id); setReplyDraft(''); setReopening(false); void controller?.inspect(ticket.id); }, children: [jsxs("span", { children: ["#", ticket.number, " \u00B7 ", ticket.subject, " ", ticket.unread && unread] }), jsx("small", { children: strings[ticket.status] }), jsx("span", { "aria-hidden": 'true', children: "\u203A" })] }, ticket.id);
4082
+ const retry = () => {
4083
+ if (!state.config)
4084
+ void controller?.bootstrap();
4085
+ else if (state.view === 'form')
4086
+ void controller?.submit(subject, body, replyLanguage);
4087
+ else if (state.view === 'requests')
4088
+ void controller?.showRequests();
4089
+ else if (state.view === 'request' && state.followup)
4090
+ void followup();
4091
+ else if (state.view === 'request' && (selectedTicketId || support.ticketId))
4092
+ void controller?.inspect((selectedTicketId || support.ticketId));
4093
+ };
4094
+ const errorText = state.error === 'ticket_resolved' ? strings.resolvedReply : ['expired', 'unknown_key', 'malformed', 'authentication_required', 'invalid_signature'].includes(state.error || '') ? strings.auth : state.error === 'permission_denied' ? strings.denied : strings.error;
4095
+ const promise = state.ticket?.replyPromise || state.config?.replyPromise;
4096
+ const promiseText = !promise ? null : promise.minutes === promise.businessDayMinutes ? strings.businessDay : promise.minutes % 60 === 0 ? `${promise.minutes / 60} ${strings.hours}` : `${promise.minutes} ${strings.minutes}`;
4097
+ return jsx("div", { ref: host, className: `andy-support-host andy-support-host--${orientation}`, children: portal && createPortal(jsxs("div", { className: 'andy-support', "data-private": true, "data-ph-no-capture": true, "data-sentry-block": true, lang: replyLanguage, children: [!open && jsxs("button", { type: 'button', className: 'andy-support__launcher', title: tooltip, onClick: () => setOpen(true), children: [jsxs("span", { children: [strings.support, " \u00B7 ", title, " ", state.unreadCount > 0 && unread] }), jsx("span", { "aria-hidden": 'true', children: "\u2197" })] }), open && jsxs("section", { className: 'andy-support__panel', role: 'dialog', "aria-label": `${strings.support} · ${title}`, children: [jsxs("header", { className: 'andy-support__header', children: [state.view !== 'conversation' && jsx("button", { type: 'button', "aria-label": strings.back, onClick: () => state.view === 'request' ? void controller?.showRequests() : controller?.back(), children: "\u2039" }), jsx("span", { className: 'andy-support__mark', "aria-hidden": 'true', children: title.slice(0, 1) }), jsxs("div", { className: 'andy-support__identity', children: [jsx("strong", { children: title }), jsx("small", { children: strings.support })] }), jsx("button", { type: 'button', "aria-label": strings.menu, "aria-expanded": menu, disabled: !state.config, onClick: () => setMenu(!menu), children: "\u00B7\u00B7\u00B7" }), jsx("button", { type: 'button', "aria-label": strings.close, onClick: () => setOpen(false), children: "\u00D7" }), menu && jsxs("nav", { className: 'andy-support__menu', "aria-label": strings.menu, children: [jsx("button", { type: 'button', onClick: () => choose(() => controller?.newConversation()), children: strings.new }), jsx("button", { type: 'button', onClick: () => choose(escalate), children: strings.team }), jsxs("button", { type: 'button', onClick: () => choose(() => void controller?.showRequests()), children: [strings.requests, " ", state.unreadCount > 0 && unread] })] })] }), jsxs("div", { className: 'andy-support__content', ref: messages, children: [state.loading && jsx("p", { role: 'status', children: strings.loading }), state.view === 'conversation' && jsxs(Fragment, { children: [state.messages.map((message, index) => jsx("div", { className: `andy-support__message andy-support__message--${message.role}`, children: message.content }, index)), state.config && state.messages.length === 1 && jsx("div", { className: 'andy-support__questions', children: state.config.questions.map(question => jsx("button", { type: 'button', disabled: state.busy, onClick: () => void controller?.send(question), children: question }, question)) }), state.busy && jsx("p", { className: 'andy-support__muted', role: 'status', children: "\u00B7\u00B7\u00B7" }), state.messages.length > 1 && !state.busy && jsxs("button", { type: 'button', className: 'andy-support__secondary', onClick: escalate, children: [strings.team, " \u2197"] })] }), state.view === 'form' && jsxs("form", { className: 'andy-support__form', onSubmit: event => { event.preventDefault(); void controller?.submit(subject, body, replyLanguage); }, children: [jsx("h2", { children: strings.team }), jsxs("label", { children: [strings.subject, jsx("input", { required: true, maxLength: 200, value: state.submission?.subject ?? subject, readOnly: !!state.submission, disabled: state.busy, onChange: event => setSubject(event.target.value) })] }), jsxs("label", { children: [strings.detail, jsx("textarea", { required: true, maxLength: 20000, rows: 5, value: state.submission?.body ?? body, readOnly: !!state.submission, disabled: state.busy, onChange: event => setBody(event.target.value) })] }), jsxs("details", { className: 'andy-support__transcript', children: [jsxs("summary", { children: [strings.notice, " \u00B7 ", state.transcript.length, " ", strings.transcript] }), state.transcript.map((message, index) => jsx("p", { children: message.content }, index))] }), jsx("p", { className: 'andy-support__muted', children: strings.consent }), jsx("button", { type: 'submit', className: 'andy-support__primary', disabled: state.busy || !subject.trim() || !body.trim(), children: state.busy ? strings.loading : strings.submit })] }), state.view === 'receipt' && state.ticket && jsxs("div", { className: 'andy-support__receipt', children: [jsx("span", { className: 'andy-support__check', "aria-hidden": 'true', children: "\u2713" }), jsx("h2", { children: strings.receipt }), jsxs("strong", { children: ["#", state.ticket.number] }), jsx("p", { children: state.ticket.subject }), promiseText && jsxs("p", { children: [strings.promise, " ", jsx("strong", { children: promiseText }), "."] }), jsx("button", { type: 'button', className: 'andy-support__primary', onClick: () => void controller?.showRequests(), children: strings.requests })] }), state.view === 'requests' && jsxs(Fragment, { children: [jsx("h2", { children: strings.requests }), state.tickets.map(ticketRow), !state.busy && !state.error && !state.tickets.length && jsx("p", { className: 'andy-support__muted', children: strings.empty })] }), state.view === 'request' && state.ticket && jsxs(Fragment, { children: [jsxs("h2", { children: ["#", state.ticket.number, " \u00B7 ", state.ticket.subject] }), jsxs("p", { className: 'andy-support__muted', children: [strings[state.ticket.status], state.ticket.reopened && ` · ${strings.reopened}`] }), state.entries.filter(entry => ['requester_message', 'public_reply'].includes(entry.kind)).map(entry => jsxs("article", { className: 'andy-support__entry', children: [jsx("strong", { children: entry.authorName }), jsx("p", { children: entry.body })] }, entry.id)), state.ticket.status === 'resolved' && !reopening && jsxs("div", { className: 'andy-support__request-actions', children: [jsx("button", { type: 'button', className: 'andy-support__primary', onClick: () => setReopening(true), children: strings.reopen }), jsx("button", { type: 'button', className: 'andy-support__secondary', onClick: newProblem, children: strings.otherProblem })] })] }), state.error && jsxs("div", { className: 'andy-support__error', role: 'alert', children: [jsx("p", { children: errorText }), (!state.config || state.view === 'form' || state.view === 'requests' || (state.view === 'request' && state.error !== 'ticket_resolved' && (selectedTicketId || support.ticketId))) && jsx("button", { type: 'button', onClick: retry, disabled: state.busy, children: strings.retry })] })] }), state.view === 'conversation' && jsxs("form", { className: 'andy-support__composer', onSubmit: event => { event.preventDefault(); send(); }, children: [jsx("input", { "aria-label": strings.placeholder, placeholder: strings.placeholder, value: draft, maxLength: 10000, disabled: !state.config, onChange: event => setDraft(event.target.value) }), jsx("button", { type: 'submit', "aria-label": strings.send, disabled: state.busy || !state.config || !draft.trim(), children: "\u2191" })] }), state.view === 'request' && state.ticket && (state.ticket.status !== 'resolved' || reopening) && jsxs("form", { className: 'andy-support__followup', onSubmit: event => { event.preventDefault(); void followup(); }, children: [jsxs("label", { children: [reopening ? strings.reopening : strings.reply, jsx("textarea", { rows: 3, maxLength: 20000, required: true, value: state.followup?.body ?? replyDraft, readOnly: !!state.followup, disabled: state.busy, onChange: event => setReplyDraft(event.target.value) })] }), jsx("button", { type: 'submit', className: 'andy-support__primary', disabled: state.busy || !replyDraft.trim(), children: state.busy ? strings.loading : reopening ? strings.reopen : strings.send })] }), jsxs("footer", { className: 'andy-support__footer', children: [jsx("span", { children: strings.powered }), state.config?.branding.footer.map(link => jsx("a", { href: link.url, target: '_blank', rel: 'noopener noreferrer', children: link.label }, link.url))] })] })] }), portal) });
4098
+ };
4099
+ /** A host identity change also destroys local drafts and menu state before the next render. */
4100
+ const SupportChat = props => jsx(SupportPanel, { ...props }, JSON.stringify([props.support.installationId, props.support.contextKey, props.support.apiBaseUrl]));
4101
+
4102
+ const SalesAndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessage, bubbleLabel, lang, predefinedQuestions, theme, orientation, brandColor, bubbleStyle, useBrandColor, logoUrl, previewMode = false, initialOpen = false }) => {
3930
4103
  const [config, setConfig] = useState(null);
3931
4104
  // The visitor's language wins over the Agent's own default: a Spanish Agent
3932
4105
  // embedded on an English page must still render English chrome.
@@ -4030,22 +4203,7 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4030
4203
  portalRef.current = portalTarget;
4031
4204
  }
4032
4205
  setShadowReady(true);
4033
- const keyboardDetector = getKeyboardDetector();
4034
- const keyboardListener = ({ isOpen: kbOpen, height }) => {
4035
- updateKeyboardHeight(containerRef.current, height);
4036
- const chatWindow = shadowRef.current?.querySelector('.andy-chat-window');
4037
- if (chatWindow) {
4038
- if (kbOpen && height > 0) {
4039
- chatWindow.classList.add('andy-chat-window--keyboard-open');
4040
- }
4041
- else {
4042
- chatWindow.classList.remove('andy-chat-window--keyboard-open');
4043
- }
4044
- }
4045
- };
4046
- keyboardDetector.addListener(keyboardListener);
4047
4206
  return () => {
4048
- keyboardDetector.removeListener(keyboardListener);
4049
4207
  shadowRef.current = null;
4050
4208
  portalRef.current = null;
4051
4209
  setShadowReady(false);
@@ -4566,10 +4724,14 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4566
4724
  config?.welcomeMessage ||
4567
4725
  'Welcome! How can I help you today?' }), jsx("button", { type: 'button', className: 'andy-welcome-popup__close', "aria-label": strings.closeWelcomeMessage, onClick: () => setShowWelcomePopup(false), children: jsx("svg", { className: 'andy-welcome-popup__close-icon', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', children: jsx("path", { d: 'M18 6L6 18M6 6l12 12', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' }) }) })] }), jsx("div", { className: 'andy-welcome-popup__arrow' })] }), portalRef.current)] })) }));
4568
4726
  };
4727
+ const AndyChat = props => props.mode === 'support'
4728
+ ? jsx(SupportChat, { ...props }, `${props.support.installationId}:${props.support.contextKey}`)
4729
+ : jsx(SalesAndyChat, { ...props });
4569
4730
 
4570
4731
  const { SESSION_STORAGE_KEY, FORM_DISPLAY_DELAY_MS, PERSIST_SESSION } = CONFIG;
4571
- const useAndyChat = (embedId) => {
4572
- initializeConfig('andy-widget-react');
4732
+ const useSalesAndyChat = (embedId, enabled) => {
4733
+ if (enabled)
4734
+ initializeConfig('andy-widget-react');
4573
4735
  const [config, setConfig] = useState({});
4574
4736
  const [isLoading, setIsLoading] = useState(true);
4575
4737
  const [isOpen, setIsOpen] = useState(false);
@@ -4589,6 +4751,8 @@ const useAndyChat = (embedId) => {
4589
4751
  const welcomeTimeoutRef = useRef(null);
4590
4752
  const userInfoTimeoutRef = useRef(null);
4591
4753
  const initializeChatbot = useCallback(async () => {
4754
+ if (!enabled)
4755
+ return;
4592
4756
  if (!embedId) {
4593
4757
  throw new Error('Embed ID is required');
4594
4758
  }
@@ -4623,7 +4787,7 @@ const useAndyChat = (embedId) => {
4623
4787
  setIsLoading(false);
4624
4788
  throw err;
4625
4789
  }
4626
- }, [embedId]);
4790
+ }, [embedId, enabled]);
4627
4791
  const toggleChat = useCallback(() => {
4628
4792
  setIsOpen(prev => {
4629
4793
  const newIsOpen = !prev;
@@ -4657,7 +4821,7 @@ const useAndyChat = (embedId) => {
4657
4821
  });
4658
4822
  }, [config, welcomePopupShown, userInfoCollected]);
4659
4823
  const sendMessage = useCallback(async (messageText) => {
4660
- if (!messageText.trim() || !config || !sessionId || activeReplyRef.current) {
4824
+ if (!enabled || !messageText.trim() || !config || !sessionId || activeReplyRef.current) {
4661
4825
  return;
4662
4826
  }
4663
4827
  const controller = new AbortController();
@@ -4707,7 +4871,7 @@ const useAndyChat = (embedId) => {
4707
4871
  if (activeReplyRef.current === controller)
4708
4872
  activeReplyRef.current = null;
4709
4873
  }
4710
- }, [embedId, sessionId, config]);
4874
+ }, [embedId, sessionId, config, enabled]);
4711
4875
  const showFaq = useCallback(() => {
4712
4876
  setShowingFaq(true);
4713
4877
  }, []);
@@ -4727,6 +4891,8 @@ const useAndyChat = (embedId) => {
4727
4891
  setShowingUserInfoForm(false);
4728
4892
  }, []);
4729
4893
  const submitUserInfo$1 = useCallback(async (userInfo) => {
4894
+ if (!enabled)
4895
+ return;
4730
4896
  try {
4731
4897
  await submitUserInfo(embedId, sessionId, userInfo);
4732
4898
  setUserInfoCollected(true);
@@ -4743,8 +4909,10 @@ const useAndyChat = (embedId) => {
4743
4909
  console.error('Error submitting user info:', err);
4744
4910
  throw err;
4745
4911
  }
4746
- }, [embedId, sessionId]);
4912
+ }, [embedId, sessionId, enabled]);
4747
4913
  const startNewSession = useCallback(() => {
4914
+ if (!enabled)
4915
+ return;
4748
4916
  activeReplyRef.current?.abort();
4749
4917
  activeReplyRef.current = null;
4750
4918
  const newSessionId = generateSessionId();
@@ -4770,7 +4938,7 @@ const useAndyChat = (embedId) => {
4770
4938
  setMessages([greetingMessage]);
4771
4939
  }
4772
4940
  }
4773
- }, [config]);
4941
+ }, [config, enabled]);
4774
4942
  const addMessage = useCallback((message) => {
4775
4943
  setMessages(prev => [...prev, message]);
4776
4944
  }, []);
@@ -4799,6 +4967,11 @@ const useAndyChat = (embedId) => {
4799
4967
  addMessage
4800
4968
  };
4801
4969
  };
4970
+ function useAndyChat(input) {
4971
+ const support = useSupportChat(typeof input === 'string' ? null : input.support);
4972
+ const sales = useSalesAndyChat(typeof input === 'string' ? input : '', typeof input === 'string');
4973
+ return typeof input === 'string' ? sales : support;
4974
+ }
4802
4975
 
4803
4976
  const ChatBubble = ({ isOpen, config, onClick }) => {
4804
4977
  const bubbleClasses = ['andy-bubble', isOpen && 'andy-bubble--open'].filter(Boolean).join(' ');
@@ -4987,4 +5160,3 @@ const UserInfoForm = ({ headerText, onSubmit, onClose }) => {
4987
5160
  };
4988
5161
 
4989
5162
  export { AndyChat as Andy, AndyChat, ChatBubble, ChatWindow, FAQ, MessageInput, MessageList, UserInfoForm, WelcomePopup, animation, borderRadius, chat, colors, AndyChat as default, generateSessionId, getApiBaseUrl, getEmbedId, isValidEmbedId, parseMarkdown, sanitizeUrl, setApiBaseUrl, shadows, spacing, theme, typography, useAndyChat, zIndex };
4990
- //# sourceMappingURL=index.esm.js.map