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.cjs CHANGED
@@ -775,6 +775,11 @@ const sendMessageForWidget = async (embedId, sessionId, message, options = {}) =
775
775
  messages: [{ role: 'user', parts: [{ type: 'text', text: message }] }]
776
776
  })
777
777
  });
778
+ return consumeWidgetReply(response, options)
779
+ };
780
+
781
+ /** Shared text-only UI stream decoder; no retry can occur after reading starts. */
782
+ const consumeWidgetReply = async (response, options = {}) => {
778
783
  if (!response.ok || !response.headers.get('content-type')?.includes('text/event-stream')) {
779
784
  const data = await response.json().catch(() => null);
780
785
  throw new Error(data?.error || data?.message || 'Chat is temporarily unavailable.')
@@ -815,6 +820,7 @@ const sendMessageForWidget = async (embedId, sessionId, message, options = {}) =
815
820
  };
816
821
  try {
817
822
  while (!complete) {
823
+ options.signal?.throwIfAborted();
818
824
  const { value, done } = await reader.read();
819
825
  buffer += decoder.decode(value, { stream: !done });
820
826
  let newline;
@@ -1497,9 +1503,6 @@ const generateDesignTokenVariablesString = () => {
1497
1503
  --andy-chat-avatar-size: 32px;
1498
1504
  --andy-chat-avatar-size-small: 24px;
1499
1505
 
1500
- /* Keyboard handling */
1501
- --andy-keyboard-height: 0px;
1502
-
1503
1506
  /* === THEMABLE VARIABLES (Default Light Theme) === */
1504
1507
  --andy-themable-background: var(--andy-color-background);
1505
1508
  --andy-themable-background-secondary: var(--andy-color-white);
@@ -2883,11 +2886,6 @@ const getCoreStructuralCssString = () => {
2883
2886
  }
2884
2887
  }
2885
2888
 
2886
- /* No special handling for keyboard - let browser handle it naturally */
2887
- .andy-chat-window--keyboard-open {
2888
- /* Browser handles positioning automatically with our margin-based approach */
2889
- }
2890
-
2891
2889
  /* Ensure the window content is properly laid out */
2892
2890
  .andy-chat-window__messages {
2893
2891
  flex: 1 1 auto;
@@ -3005,17 +3003,12 @@ const getCoreStructuralCssString = () => {
3005
3003
  }
3006
3004
  }
3007
3005
 
3008
- /* Desktop keyboard handling (for testing) */
3006
+ /* Desktop */
3009
3007
  @media (min-width: 521px) {
3010
3008
  /* Desktop retains original positioning behavior */
3011
3009
  .andy-chat-window {
3012
3010
  transition: transform 0.3s ease-out;
3013
3011
  }
3014
-
3015
- /* Optional: Add keyboard handling for desktop if needed */
3016
- .andy-chat-window--keyboard-open {
3017
- /* No transform on desktop - keyboard doesn't affect viewport */
3018
- }
3019
3012
  }
3020
3013
 
3021
3014
  /* Orientation */
@@ -3507,244 +3500,6 @@ const UI_STRINGS = {
3507
3500
  */
3508
3501
  const getUiStrings = lang => UI_STRINGS[lang] || UI_STRINGS.en;
3509
3502
 
3510
- /**
3511
- * Keyboard detection utility for handling mobile virtual keyboards
3512
- * Provides cross-browser support for detecting keyboard open/close events
3513
- */
3514
-
3515
- class KeyboardDetector {
3516
- constructor() {
3517
- this.isKeyboardOpen = false;
3518
- this.keyboardHeight = 0;
3519
- this.listeners = new Set();
3520
- this.inputElement = null;
3521
- this.viewportHeight = window.innerHeight;
3522
- this.visualViewport = window.visualViewport;
3523
-
3524
- this.initializeDetection();
3525
- }
3526
-
3527
- /**
3528
- * Initialize keyboard detection
3529
- */
3530
- initializeDetection() {
3531
- // Use Visual Viewport API if available (most modern browsers)
3532
- if (this.visualViewport) {
3533
- this.visualViewport.addEventListener('resize', this.handleViewportResize.bind(this));
3534
- this.visualViewport.addEventListener('scroll', this.handleViewportScroll.bind(this));
3535
- } else {
3536
- // Fallback for older browsers
3537
- window.addEventListener('resize', this.handleWindowResize.bind(this));
3538
- }
3539
-
3540
- // Additional detection via focus/blur events
3541
- document.addEventListener('focusin', this.handleFocusIn.bind(this), true);
3542
- document.addEventListener('focusout', this.handleFocusOut.bind(this), true);
3543
-
3544
- // Orientation change handler
3545
- window.addEventListener('orientationchange', this.handleOrientationChange.bind(this));
3546
- }
3547
-
3548
- /**
3549
- * Handle visual viewport resize (modern browsers)
3550
- */
3551
- handleViewportResize() {
3552
- const currentHeight = this.visualViewport.height;
3553
- const heightDifference = window.innerHeight - currentHeight;
3554
-
3555
- // If viewport shrunk by more than 100px, keyboard is likely open
3556
- if (heightDifference > 100) {
3557
- this.setKeyboardOpen(true, heightDifference);
3558
- } else if (heightDifference < 50) {
3559
- this.setKeyboardOpen(false, 0);
3560
- }
3561
- }
3562
-
3563
- /**
3564
- * Handle visual viewport scroll
3565
- */
3566
- handleViewportScroll() {
3567
- // On iOS, viewport scrolls when keyboard opens
3568
- if (this.visualViewport.offsetTop > 0) {
3569
- this.setKeyboardOpen(true, this.visualViewport.offsetTop);
3570
- }
3571
- }
3572
-
3573
- /**
3574
- * Handle window resize (fallback for older browsers)
3575
- */
3576
- handleWindowResize() {
3577
- const currentHeight = window.innerHeight;
3578
- const heightDifference = this.viewportHeight - currentHeight;
3579
-
3580
- // Significant height reduction likely means keyboard opened
3581
- if (heightDifference > 100) {
3582
- this.setKeyboardOpen(true, heightDifference);
3583
- } else if (Math.abs(heightDifference) < 50) {
3584
- this.setKeyboardOpen(false, 0);
3585
- this.viewportHeight = currentHeight;
3586
- }
3587
- }
3588
-
3589
- /**
3590
- * Handle input focus
3591
- */
3592
- handleFocusIn(e) {
3593
- const isInput = ['input', 'textarea'].includes(e.target.tagName.toLowerCase());
3594
- if (isInput) {
3595
- this.inputElement = e.target;
3596
- // Delay to allow keyboard animation
3597
- setTimeout(() => {
3598
- if (this.inputElement === e.target) {
3599
- this.checkKeyboardStatus();
3600
- }
3601
- }, 300);
3602
- }
3603
- }
3604
-
3605
- /**
3606
- * Handle input blur
3607
- */
3608
- handleFocusOut(e) {
3609
- const isInput = ['input', 'textarea'].includes(e.target.tagName.toLowerCase());
3610
- if (isInput && this.inputElement === e.target) {
3611
- this.inputElement = null;
3612
- // Delay to allow keyboard animation
3613
- setTimeout(() => {
3614
- if (!this.inputElement) {
3615
- this.setKeyboardOpen(false, 0);
3616
- }
3617
- }, 300);
3618
- }
3619
- }
3620
-
3621
- /**
3622
- * Handle orientation change
3623
- */
3624
- handleOrientationChange() {
3625
- // Reset viewport height after orientation change
3626
- setTimeout(() => {
3627
- this.viewportHeight = window.innerHeight;
3628
- this.checkKeyboardStatus();
3629
- }, 500);
3630
- }
3631
-
3632
- /**
3633
- * Check keyboard status (for browsers without Visual Viewport API)
3634
- */
3635
- checkKeyboardStatus() {
3636
- if (!this.visualViewport) {
3637
- const currentHeight = window.innerHeight;
3638
- const heightDifference = this.viewportHeight - currentHeight;
3639
-
3640
- if (this.inputElement && heightDifference > 100) {
3641
- this.setKeyboardOpen(true, heightDifference);
3642
- }
3643
- }
3644
- }
3645
-
3646
- /**
3647
- * Set keyboard state and notify listeners
3648
- */
3649
- setKeyboardOpen(isOpen, height) {
3650
- const changed = this.isKeyboardOpen !== isOpen || this.keyboardHeight !== height;
3651
-
3652
- this.isKeyboardOpen = isOpen;
3653
- this.keyboardHeight = height;
3654
-
3655
- if (changed) {
3656
- this.notifyListeners();
3657
- }
3658
- }
3659
-
3660
- /**
3661
- * Add listener for keyboard state changes
3662
- */
3663
- addListener(callback) {
3664
- this.listeners.add(callback);
3665
- // Immediately notify with current state
3666
- callback({
3667
- isOpen: this.isKeyboardOpen,
3668
- height: this.keyboardHeight
3669
- });
3670
- }
3671
-
3672
- /**
3673
- * Remove listener
3674
- */
3675
- removeListener(callback) {
3676
- this.listeners.delete(callback);
3677
- }
3678
-
3679
- /**
3680
- * Notify all listeners of state change
3681
- */
3682
- notifyListeners() {
3683
- const state = {
3684
- isOpen: this.isKeyboardOpen,
3685
- height: this.keyboardHeight
3686
- };
3687
-
3688
- this.listeners.forEach(callback => {
3689
- try {
3690
- callback(state);
3691
- } catch (error) {
3692
- console.error('Error in keyboard detector listener:', error);
3693
- }
3694
- });
3695
- }
3696
-
3697
- /**
3698
- * Get current keyboard state
3699
- */
3700
- getState() {
3701
- return {
3702
- isOpen: this.isKeyboardOpen,
3703
- height: this.keyboardHeight
3704
- }
3705
- }
3706
-
3707
- /**
3708
- * Cleanup
3709
- */
3710
- destroy() {
3711
- if (this.visualViewport) {
3712
- this.visualViewport.removeEventListener('resize', this.handleViewportResize.bind(this));
3713
- this.visualViewport.removeEventListener('scroll', this.handleViewportScroll.bind(this));
3714
- } else {
3715
- window.removeEventListener('resize', this.handleWindowResize.bind(this));
3716
- }
3717
-
3718
- document.removeEventListener('focusin', this.handleFocusIn.bind(this), true);
3719
- document.removeEventListener('focusout', this.handleFocusOut.bind(this), true);
3720
- window.removeEventListener('orientationchange', this.handleOrientationChange.bind(this));
3721
-
3722
- this.listeners.clear();
3723
- }
3724
- }
3725
-
3726
- // Create singleton instance
3727
- let keyboardDetectorInstance = null;
3728
-
3729
- /**
3730
- * Get or create keyboard detector instance
3731
- */
3732
- function getKeyboardDetector() {
3733
- if (!keyboardDetectorInstance) {
3734
- keyboardDetectorInstance = new KeyboardDetector();
3735
- }
3736
- return keyboardDetectorInstance
3737
- }
3738
-
3739
- /**
3740
- * Update CSS custom property on host element
3741
- */
3742
- function updateKeyboardHeight(hostElement, height) {
3743
- if (hostElement) {
3744
- hostElement.style.setProperty('--andy-keyboard-height', `${height}px`);
3745
- }
3746
- }
3747
-
3748
3503
  /**
3749
3504
  * Mobile detection utility for AndyChat
3750
3505
  * Provides reliable mobile device detection with multiple methods
@@ -3930,7 +3685,425 @@ const createBrowserHandoff = (chatbotId, bootstrap, onProjection, fresh = false)
3930
3685
  }
3931
3686
  };
3932
3687
 
3933
- const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessage, bubbleLabel, lang, predefinedQuestions, theme, orientation, brandColor, bubbleStyle, useBrandColor, logoUrl, previewMode = false, initialOpen = false }) => {
3688
+ /** In-page Requester credentials. A failed response is renewed once, before its body is consumed. */
3689
+ const createRequesterFetch = getRequesterPass => {
3690
+ let pass = null;
3691
+ let loading = null;
3692
+ let revision = 0;
3693
+ const obtain = async () => {
3694
+ const current = revision;
3695
+ if (!loading) {
3696
+ const pending = Promise.resolve().then(getRequesterPass).then(value => {
3697
+ if (current !== revision || typeof value !== 'string' || !value.trim()) throw new Error('Requester authentication is unavailable.')
3698
+ pass = value;
3699
+ return value
3700
+ }).catch(() => { throw new Error('Requester authentication is unavailable.') }).finally(() => { if (loading === pending) loading = null; });
3701
+ loading = pending;
3702
+ }
3703
+ return loading
3704
+ };
3705
+ const requesterFetch = async (url, options = {}) => {
3706
+ const current = revision;
3707
+ const headers = new Headers(options.headers);
3708
+ if (headers.has('authorization')) throw new Error('Requester requests cannot use operator authorization.')
3709
+ const attempt = async credential => {
3710
+ options.signal?.throwIfAborted();
3711
+ if (current !== revision) throw new DOMException('Requester context changed.', 'AbortError')
3712
+ const selected = new Headers(headers);
3713
+ selected.set('X-Andy-Requester', credential);
3714
+ const response = await fetch(url, { ...options, headers: selected, credentials: 'omit', cache: 'no-store' });
3715
+ if (current !== revision) throw new DOMException('Requester context changed.', 'AbortError')
3716
+ return response
3717
+ };
3718
+ let response = await attempt(pass || await obtain());
3719
+ if (!response.ok) {
3720
+ const problem = await response.clone().json().catch(() => null);
3721
+ // Private help and canonical Ticket HTTP use their existing problem envelopes.
3722
+ if (['expired', 'unknown_key'].includes(problem?.problem?.code ?? problem?.code)) {
3723
+ pass = null;
3724
+ response = await attempt(await obtain());
3725
+ }
3726
+ }
3727
+ return response
3728
+ };
3729
+ requesterFetch.clear = () => { revision++; pass = null; loading = null; };
3730
+ return requesterFetch
3731
+ };
3732
+
3733
+ const createSupportClient = ({ installationId, getRequesterPass, apiBaseUrl }) => {
3734
+ if (!/^[a-zA-Z0-9_-]{3,100}$/.test(installationId)) throw new Error('A Support installation is required.')
3735
+ const base = (apiBaseUrl || getApiBaseUrl()).replace(/\/+$/, '');
3736
+ const requesterFetch = createRequesterFetch(getRequesterPass);
3737
+ const widgetUrl = `${base}/support/widget/${encodeURIComponent(installationId)}`;
3738
+ const json = async response => {
3739
+ const result = await response.json().catch(() => null);
3740
+ if (!response.ok || result?.ok === false) {
3741
+ const error = new Error('Support is temporarily unavailable. Please retry.');
3742
+ error.code = result?.problem?.code || result?.code || 'unavailable';
3743
+ throw error
3744
+ }
3745
+ return result
3746
+ };
3747
+ return {
3748
+ bootstrap: signal => requesterFetch(widgetUrl, { signal }).then(json),
3749
+ answer: async (messages, options = {}) => {
3750
+ const response = await requesterFetch(widgetUrl, {
3751
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: options.signal,
3752
+ body: JSON.stringify({ messages })
3753
+ });
3754
+ if (!response.ok) await json(response);
3755
+ return consumeWidgetReply(response, options)
3756
+ },
3757
+ operation: async (action, input, signal) => json(await requesterFetch(`${base}/public/operations/support/request/${action}`, {
3758
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, signal, body: JSON.stringify(input)
3759
+ })),
3760
+ dispose: () => requesterFetch.clear()
3761
+ }
3762
+ };
3763
+
3764
+ /** One in-memory conversation/form receipt lifecycle shared by React renderers and hooks. */
3765
+ const createSupportController = options => {
3766
+ const client = createSupportClient(options);
3767
+ const listeners = new Set();
3768
+ let state = { config: null, view: 'conversation', messages: [], transcript: [], tickets: [], ticket: null, entries: [], loading: true, busy: false, error: null, submission: null, followup: null, unreadCount: 0 };
3769
+ let active = null;
3770
+ let generation = 0;
3771
+ let disposed = false;
3772
+ let submitting = null;
3773
+ let following = null;
3774
+ let visible = true;
3775
+ let timer = null;
3776
+ const schedule = () => {
3777
+ clearTimeout(timer);
3778
+ if (!disposed && state.config && listeners.size) {
3779
+ timer = setTimeout(() => void refresh(), 3000);
3780
+ timer.unref?.();
3781
+ }
3782
+ };
3783
+ const publish = changes => {
3784
+ if (disposed) return
3785
+ state = { ...state, ...changes };
3786
+ listeners.forEach(listener => listener());
3787
+ };
3788
+ const abort = () => { generation++; active?.abort(); active = null; clearTimeout(timer); };
3789
+ const fail = error => {
3790
+ if (error?.code === 'resource_not_found' && state.view === 'request') publish({ ticket: null, entries: [], followup: null });
3791
+ if (error?.code === 'ticket_resolved' && state.ticket) {
3792
+ updateTicket({ ...state.ticket, status: 'resolved' });
3793
+ publish({ followup: null });
3794
+ }
3795
+ publish({ error: error?.code || 'unavailable', busy: false, loading: false });
3796
+ };
3797
+ const request = async (work, background = false) => {
3798
+ abort();
3799
+ const epoch = generation;
3800
+ const controller = new AbortController();
3801
+ active = controller;
3802
+ if (!background) publish({ busy: true, error: null });
3803
+ try {
3804
+ const result = await work(controller.signal, epoch);
3805
+ if (disposed || generation !== epoch || controller.signal.aborted) return
3806
+ return result
3807
+ } catch (error) {
3808
+ if (!controller.signal.aborted && generation === epoch) fail(error);
3809
+ } finally {
3810
+ if (active === controller) { active = null; if (!background) publish({ busy: false }); schedule(); }
3811
+ }
3812
+ };
3813
+ const current = (signal, epoch) => !disposed && generation === epoch && !signal.aborted;
3814
+ const page = async (action, input, signal, field) => {
3815
+ let result, cursor;
3816
+ const values = [];
3817
+ do {
3818
+ result = await client.operation(action, { ...input, pageSize: 100, ...(cursor ? { cursor } : {}) }, signal);
3819
+ values.push(...result.data[field]);
3820
+ cursor = result.meta?.nextCursor;
3821
+ } while (cursor && !signal.aborted)
3822
+ return { ...result.data, [field]: values }
3823
+ };
3824
+ const updateTicket = ticket => {
3825
+ const tickets = state.tickets.map(previous => previous.id === ticket.id ? ticket : previous);
3826
+ publish({ ticket, tickets, unreadCount: tickets.filter(ticket => ticket.unread).length });
3827
+ };
3828
+ const readTicket = async (ticketId, signal, epoch) => {
3829
+ const result = await page('inspect', { ticketId }, signal, 'entries');
3830
+ if (!current(signal, epoch)) return
3831
+ // Only entries returned for this reader can advance the server's seen mark.
3832
+ const entries = result.entries.sort((a, b) => a.sequence - b.sequence);
3833
+ updateTicket(result.ticket);
3834
+ publish({ entries });
3835
+ const throughSequence = Math.max(0, ...entries.map(entry => entry.sequence || 0));
3836
+ if (visible && globalThis.document?.visibilityState !== 'hidden' && throughSequence > result.ticket.requesterLastSeenSequence) {
3837
+ const seen = await client.operation('mark_seen', { ticketId, throughSequence }, signal);
3838
+ if (current(signal, epoch)) updateTicket(seen.data.ticket);
3839
+ }
3840
+ };
3841
+ const readRequests = async (signal, epoch) => {
3842
+ const result = await page('list', {}, signal, 'tickets');
3843
+ if (current(signal, epoch)) publish({ tickets: result.tickets, unreadCount: result.tickets.filter(ticket => ticket.unread).length });
3844
+ };
3845
+ const refresh = () => {
3846
+ if (disposed || !state.config) return Promise.resolve()
3847
+ if (state.busy || globalThis.document?.visibilityState === 'hidden') { schedule(); return Promise.resolve() }
3848
+ return request(async (signal, epoch) => {
3849
+ await readRequests(signal, epoch);
3850
+ if (current(signal, epoch) && visible && state.view === 'request' && state.ticket) await readTicket(state.ticket.id, signal, epoch);
3851
+ }, true)
3852
+ };
3853
+ const transcript = () => state.messages.map(({ role, content }) => ({ role, content }));
3854
+ const bootstrap = () => request(async (signal, epoch) => {
3855
+ const config = await client.bootstrap(signal);
3856
+ if (generation !== epoch || signal.aborted) return
3857
+ publish({ config, loading: false, messages: [{ role: 'assistant', content: config.greeting }] });
3858
+ });
3859
+ const send = async content => {
3860
+ if (state.busy || state.loading || state.view !== 'conversation' || !content.trim() || content.length > 10_000 || state.messages.length >= 39) return
3861
+ publish({ messages: [...transcript(), { role: 'user', content: content.trim() }] });
3862
+ const history = transcript();
3863
+ return request(async (signal, epoch) => {
3864
+ const show = text => {
3865
+ if (generation === epoch && !signal.aborted && state.view === 'conversation')
3866
+ publish({ messages: [...history, { role: 'assistant', content: text }] });
3867
+ };
3868
+ const result = await client.answer(history, { signal, onText: show });
3869
+ if (generation === epoch && !signal.aborted && result.status === 'agent') show(result.text);
3870
+ })
3871
+ };
3872
+ const escalate = () => {
3873
+ if (!state.config) return
3874
+ abort();
3875
+ publish({ view: 'form', transcript: transcript(), busy: false, error: null, submission: null, ticket: null });
3876
+ schedule();
3877
+ };
3878
+ const submit = (subject, body, replyLanguage = 'es') => {
3879
+ if (submitting) return submitting
3880
+ if (state.view !== 'form' || !subject.trim() || !body.trim()) return Promise.resolve()
3881
+ // A retry uses the captured consent/input, even if the caller passes different arguments.
3882
+ const submission = state.submission || { subject: subject.trim(), body: body.trim(), replyLanguage, transcript: state.transcript, idempotencyKey: crypto.randomUUID() };
3883
+ publish({ submission });
3884
+ submitting = request(async (signal, epoch) => {
3885
+ const result = await client.operation('open', submission, signal);
3886
+ if (generation === epoch && !signal.aborted) publish({ view: 'receipt', ticket: result.data.ticket, error: null });
3887
+ return result
3888
+ }).finally(() => { submitting = null; });
3889
+ return submitting
3890
+ };
3891
+ const showRequests = () => {
3892
+ abort();
3893
+ publish({ view: 'requests', busy: false, error: null });
3894
+ return request(readRequests)
3895
+ };
3896
+ const inspect = ticketId => {
3897
+ abort();
3898
+ publish({ view: 'request', ticket: null, entries: [], followup: null, busy: false, error: null });
3899
+ return request((signal, epoch) => readTicket(ticketId, signal, epoch))
3900
+ };
3901
+ const reply = (body, reopen = false) => {
3902
+ if (following) return following
3903
+ if (state.view !== 'request' || !state.ticket || !body.trim() || body.length > 20_000) return Promise.resolve()
3904
+ if (state.ticket.status === 'resolved' && !reopen) { publish({ error: 'ticket_resolved' }); return Promise.resolve() }
3905
+ const action = reopen ? 'reopen' : 'reply';
3906
+ const followup = state.followup?.action === action ? state.followup : { action, ticketId: state.ticket.id, body: body.trim(), idempotencyKey: crypto.randomUUID() };
3907
+ publish({ followup });
3908
+ following = request(async (signal, epoch) => {
3909
+ const result = await client.operation(followup.action, { ticketId: followup.ticketId, body: followup.body, idempotencyKey: followup.idempotencyKey }, signal);
3910
+ if (!current(signal, epoch)) return
3911
+ await readTicket(followup.ticketId, signal, epoch);
3912
+ if (current(signal, epoch)) publish({ followup: null });
3913
+ return result
3914
+ }).finally(() => { following = null; });
3915
+ return following
3916
+ };
3917
+ const newProblem = () => {
3918
+ abort();
3919
+ publish({ view: 'form', transcript: [], submission: null, followup: null, ticket: null, entries: [], busy: false, error: null });
3920
+ schedule();
3921
+ };
3922
+ const newConversation = () => {
3923
+ abort();
3924
+ publish({ view: 'conversation', messages: state.config ? [{ role: 'assistant', content: state.config.greeting }] : [], transcript: [], ticket: null, entries: [], error: null, busy: false, submission: null, followup: null });
3925
+ schedule();
3926
+ };
3927
+ return {
3928
+ getSnapshot: () => state,
3929
+ subscribe: listener => { listeners.add(listener); schedule(); return () => { listeners.delete(listener); if (!listeners.size) clearTimeout(timer); } },
3930
+ bootstrap, send, escalate, submit, showRequests, inspect, newConversation, refresh, reply, newProblem,
3931
+ setVisible: value => { visible = value; if (value) void refresh(); },
3932
+ back: () => { abort(); publish({ view: 'conversation', busy: false, error: null, followup: null }); schedule(); },
3933
+ 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(); }
3934
+ }
3935
+ };
3936
+
3937
+ /** React Support panel uses the shared design tokens and its own BEM namespace. */
3938
+ const getSupportStylesheet = () => `
3939
+ :host { font-family: var(--andy-font-family-primary); color: #171717; line-height: 1.5; --andy-support-color: #171717; --andy-support-contrast: #fff; }
3940
+ *, *::before, *::after { box-sizing: border-box; }
3941
+ .andy-support button, .andy-support input, .andy-support textarea { font: inherit; }
3942
+ .andy-support button { cursor: pointer; }
3943
+ .andy-support button:disabled { cursor: default; opacity: .45; }
3944
+ .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; }
3945
+ .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; }
3946
+ .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; }
3947
+ :host(.andy-support-host--left) .andy-support__launcher, :host(.andy-support-host--left) .andy-support__panel { left: 24px; right: auto; }
3948
+ .andy-support__header { flex-shrink: 0; position: relative; display: flex; align-items: center; gap: 10px; padding: 18px; border-bottom: 1px solid #f0f0f0; }
3949
+ .andy-support__header > button { border: 0; background: transparent; color: #737373; padding: 5px; font-size: 22px; line-height: 1; min-width: 28px; }
3950
+ .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; }
3951
+ .andy-support__identity { flex: 1; min-width: 0; display: flex; flex-direction: column; }
3952
+ .andy-support__identity strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
3953
+ .andy-support__identity small { color: #737373; font-size: 12px; }
3954
+ .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; }
3955
+ .andy-support__menu button { display: block; border: 0; border-radius: 6px; background: none; padding: 10px; text-align: left; width: 100%; }
3956
+ .andy-support__menu button:hover { background: #f5f5f5; }
3957
+ .andy-support__content { flex: 1; min-height: 0; overflow-y: auto; padding: 24px 20px; overscroll-behavior: contain; }
3958
+ .andy-support h2 { font-size: 17px; font-weight: 600; margin: 0 0 20px; }
3959
+ .andy-support__message { white-space: pre-wrap; overflow-wrap: anywhere; margin-bottom: 16px; line-height: 1.65; }
3960
+ .andy-support__message--user { margin-left: auto; width: fit-content; max-width: 90%; padding: 10px 14px; border-radius: 16px 16px 4px 16px; background: #f5f5f5; }
3961
+ .andy-support__questions { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; margin-top: 22px; }
3962
+ .andy-support__questions button, .andy-support__secondary { border: 1px solid #e5e7eb; border-radius: 18px; background: #fff; padding: 10px 14px; text-align: left; color: #525252; }
3963
+ .andy-support__questions button:hover, .andy-support__secondary:hover { background: #fafafa; }
3964
+ .andy-support__secondary { margin-top: 8px; }
3965
+ .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; }
3966
+ .andy-support__composer input { flex: 1; min-width: 0; border: 0; background: none; padding: 4px 8px; outline-offset: 0; color: inherit; }
3967
+ .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; }
3968
+ .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; }
3969
+ .andy-support__footer a { color: #737373; text-decoration: none; }
3970
+ .andy-support__form { display: flex; flex-direction: column; gap: 16px; }
3971
+ .andy-support__form h2 { margin-bottom: 0; }
3972
+ .andy-support__form label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-weight: 500; }
3973
+ .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; }
3974
+ .andy-support__form textarea { resize: vertical; }
3975
+ .andy-support__transcript { border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; font-size: 12px; }
3976
+ .andy-support__transcript summary { cursor: pointer; }
3977
+ .andy-support__transcript p, .andy-support__entry p { white-space: pre-wrap; overflow-wrap: anywhere; }
3978
+ .andy-support__muted { color: #737373; font-size: 12px; }
3979
+ .andy-support__primary { border: 0; border-radius: 10px; background: var(--andy-support-color); color: var(--andy-support-contrast); padding: 12px 18px; }
3980
+ .andy-support__receipt { text-align: center; padding: 30px 8px; }
3981
+ .andy-support__check { display: inline-grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; background: #f5f5f5; font-size: 24px; margin-bottom: 20px; }
3982
+ .andy-support__receipt h2 { margin-bottom: 10px; }
3983
+ .andy-support__receipt > strong { font-size: 22px; }
3984
+ .andy-support__receipt .andy-support__primary { margin-top: 16px; }
3985
+ .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; }
3986
+ .andy-support__ticket small { grid-column: 1; color: #737373; }
3987
+ .andy-support__ticket > span:last-child { grid-column: 2; grid-row: 1 / 3; align-self: center; font-size: 20px; }
3988
+ .andy-support__entry { border-bottom: 1px solid #eee; padding: 16px 0; }
3989
+ .andy-support__unread { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; margin-left: 6px; vertical-align: middle; }
3990
+ .andy-support__ticket .andy-support__unread, .andy-support__menu .andy-support__unread { color: var(--andy-support-color); }
3991
+ .andy-support__request-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
3992
+ .andy-support__request-actions .andy-support__secondary { margin-top: 0; }
3993
+ .andy-support__followup { flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; margin: 0 18px 12px; }
3994
+ .andy-support__followup label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
3995
+ .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; }
3996
+ .andy-support__followup button { align-self: flex-end; }
3997
+ .andy-support__error { margin-top: 16px; padding: 12px; border-radius: 10px; background: #fef2f2; color: #991b1b; font-size: 12px; }
3998
+ .andy-support__error button { border: 0; background: none; color: inherit; text-decoration: underline; padding: 0; }
3999
+ @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; } }
4000
+ @media (prefers-reduced-motion: reduce) { .andy-support__content { scroll-behavior: auto; } }
4001
+ `;
4002
+
4003
+ const empty = { config: null, view: 'conversation', messages: [], transcript: [], tickets: [], ticket: null, entries: [], loading: true, busy: false, error: null, submission: null, followup: null, unreadCount: 0 };
4004
+ const noSubscribe = () => () => { };
4005
+ const emptySnapshot = () => empty;
4006
+ /** The host context key changes before a new user/company is allowed to see any previous state. */
4007
+ const useSupportChat = (options) => {
4008
+ const key = options ? JSON.stringify([options.installationId, options.contextKey, options.apiBaseUrl]) : null;
4009
+ const callback = react.useRef(options?.getRequesterPass);
4010
+ callback.current = options?.getRequesterPass;
4011
+ const [owned, setOwned] = react.useState(null);
4012
+ const controller = owned?.key === key ? owned?.controller : null;
4013
+ const state = react.useSyncExternalStore(controller?.subscribe || noSubscribe, controller?.getSnapshot || emptySnapshot, emptySnapshot);
4014
+ react.useEffect(() => {
4015
+ if (!options || !key)
4016
+ return;
4017
+ const controller = createSupportController({ ...options, getRequesterPass: () => callback.current() });
4018
+ setOwned({ key, controller });
4019
+ void controller.bootstrap();
4020
+ return () => controller.dispose();
4021
+ }, [key]);
4022
+ react.useEffect(() => {
4023
+ if (state.config && options?.ticketId)
4024
+ void controller?.inspect(options.ticketId);
4025
+ }, [controller, state.config, options?.ticketId]);
4026
+ return { mode: 'support', state, controller };
4027
+ };
4028
+
4029
+ const copy = {
4030
+ 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' },
4031
+ 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' }
4032
+ };
4033
+ /** Private Requester UI. Sales bootstrap, visitor identity and storage never enter this renderer. */
4034
+ const SupportPanel = ({ support, lang, orientation = 'right', initialOpen = false, tooltip }) => {
4035
+ const { state, controller } = useSupportChat(support);
4036
+ const replyLanguage = lang || state.config?.defaultLang || 'es';
4037
+ const strings = copy[replyLanguage];
4038
+ const [open, setOpen] = react.useState(initialOpen);
4039
+ const [menu, setMenu] = react.useState(false);
4040
+ const [draft, setDraft] = react.useState('');
4041
+ const [subject, setSubject] = react.useState('');
4042
+ const [body, setBody] = react.useState('');
4043
+ const [replyDraft, setReplyDraft] = react.useState('');
4044
+ const [reopening, setReopening] = react.useState(false);
4045
+ const [selectedTicketId, setSelectedTicketId] = react.useState(null);
4046
+ const host = react.useRef(null);
4047
+ const messages = react.useRef(null);
4048
+ const [portal, setPortal] = react.useState(null);
4049
+ react.useEffect(() => {
4050
+ if (!host.current)
4051
+ return;
4052
+ const shadow = host.current.shadowRoot || host.current.attachShadow({ mode: 'open' });
4053
+ const style = document.createElement('style');
4054
+ style.textContent = generateDesignTokenVariablesString() + getSupportStylesheet();
4055
+ const target = document.createElement('div');
4056
+ shadow.append(style, target);
4057
+ setPortal(target);
4058
+ return () => { style.remove(); target.remove(); };
4059
+ }, []);
4060
+ react.useEffect(() => {
4061
+ const color = state.config?.branding.color || '#171717';
4062
+ host.current?.style.setProperty('--andy-support-color', color);
4063
+ host.current?.style.setProperty('--andy-support-contrast', getContrastTextColor(color));
4064
+ }, [state.config?.branding.color]);
4065
+ react.useEffect(() => { messages.current?.scrollTo?.({ top: messages.current.scrollHeight, behavior: 'smooth' }); }, [state.messages, state.view]);
4066
+ react.useEffect(() => { controller?.setVisible(open); }, [controller, open]);
4067
+ react.useEffect(() => { if (support.ticketId)
4068
+ setOpen(true); }, [support.ticketId]);
4069
+ react.useEffect(() => { if (state.ticket?.status !== 'resolved' && state.followup?.action !== 'reopen')
4070
+ setReopening(false); }, [state.ticket?.status, state.followup?.action]);
4071
+ const choose = (action) => { setMenu(false); action(); };
4072
+ const escalate = () => { setSubject(''); setBody(''); controller?.escalate(); };
4073
+ const send = () => { if (!draft.trim() || state.busy)
4074
+ return; void controller?.send(draft); setDraft(''); };
4075
+ const newProblem = () => { setSubject(''); setBody(''); setReplyDraft(''); setReopening(false); controller?.newProblem(); };
4076
+ const followup = async () => {
4077
+ const result = await controller?.reply(replyDraft, reopening);
4078
+ if (result) {
4079
+ setReplyDraft('');
4080
+ setReopening(false);
4081
+ }
4082
+ };
4083
+ const unread = jsxRuntime.jsx("span", { className: 'andy-support__unread', role: 'img', "aria-label": strings.unread });
4084
+ const title = state.config?.branding.name || strings.support;
4085
+ const ticketRow = (ticket) => jsxRuntime.jsxs("button", { type: 'button', className: 'andy-support__ticket', onClick: () => { setSelectedTicketId(ticket.id); setReplyDraft(''); setReopening(false); void controller?.inspect(ticket.id); }, children: [jsxRuntime.jsxs("span", { children: ["#", ticket.number, " \u00B7 ", ticket.subject, " ", ticket.unread && unread] }), jsxRuntime.jsx("small", { children: strings[ticket.status] }), jsxRuntime.jsx("span", { "aria-hidden": 'true', children: "\u203A" })] }, ticket.id);
4086
+ const retry = () => {
4087
+ if (!state.config)
4088
+ void controller?.bootstrap();
4089
+ else if (state.view === 'form')
4090
+ void controller?.submit(subject, body, replyLanguage);
4091
+ else if (state.view === 'requests')
4092
+ void controller?.showRequests();
4093
+ else if (state.view === 'request' && state.followup)
4094
+ void followup();
4095
+ else if (state.view === 'request' && (selectedTicketId || support.ticketId))
4096
+ void controller?.inspect((selectedTicketId || support.ticketId));
4097
+ };
4098
+ 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;
4099
+ const promise = state.ticket?.replyPromise || state.config?.replyPromise;
4100
+ const promiseText = !promise ? null : promise.minutes === promise.businessDayMinutes ? strings.businessDay : promise.minutes % 60 === 0 ? `${promise.minutes / 60} ${strings.hours}` : `${promise.minutes} ${strings.minutes}`;
4101
+ return jsxRuntime.jsx("div", { ref: host, className: `andy-support-host andy-support-host--${orientation}`, children: portal && ReactDOM.createPortal(jsxRuntime.jsxs("div", { className: 'andy-support', "data-private": true, "data-ph-no-capture": true, "data-sentry-block": true, lang: replyLanguage, children: [!open && jsxRuntime.jsxs("button", { type: 'button', className: 'andy-support__launcher', title: tooltip, onClick: () => setOpen(true), children: [jsxRuntime.jsxs("span", { children: [strings.support, " \u00B7 ", title, " ", state.unreadCount > 0 && unread] }), jsxRuntime.jsx("span", { "aria-hidden": 'true', children: "\u2197" })] }), open && jsxRuntime.jsxs("section", { className: 'andy-support__panel', role: 'dialog', "aria-label": `${strings.support} · ${title}`, children: [jsxRuntime.jsxs("header", { className: 'andy-support__header', children: [state.view !== 'conversation' && jsxRuntime.jsx("button", { type: 'button', "aria-label": strings.back, onClick: () => state.view === 'request' ? void controller?.showRequests() : controller?.back(), children: "\u2039" }), jsxRuntime.jsx("span", { className: 'andy-support__mark', "aria-hidden": 'true', children: title.slice(0, 1) }), jsxRuntime.jsxs("div", { className: 'andy-support__identity', children: [jsxRuntime.jsx("strong", { children: title }), jsxRuntime.jsx("small", { children: strings.support })] }), jsxRuntime.jsx("button", { type: 'button', "aria-label": strings.menu, "aria-expanded": menu, disabled: !state.config, onClick: () => setMenu(!menu), children: "\u00B7\u00B7\u00B7" }), jsxRuntime.jsx("button", { type: 'button', "aria-label": strings.close, onClick: () => setOpen(false), children: "\u00D7" }), menu && jsxRuntime.jsxs("nav", { className: 'andy-support__menu', "aria-label": strings.menu, children: [jsxRuntime.jsx("button", { type: 'button', onClick: () => choose(() => controller?.newConversation()), children: strings.new }), jsxRuntime.jsx("button", { type: 'button', onClick: () => choose(escalate), children: strings.team }), jsxRuntime.jsxs("button", { type: 'button', onClick: () => choose(() => void controller?.showRequests()), children: [strings.requests, " ", state.unreadCount > 0 && unread] })] })] }), jsxRuntime.jsxs("div", { className: 'andy-support__content', ref: messages, children: [state.loading && jsxRuntime.jsx("p", { role: 'status', children: strings.loading }), state.view === 'conversation' && jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [state.messages.map((message, index) => jsxRuntime.jsx("div", { className: `andy-support__message andy-support__message--${message.role}`, children: message.content }, index)), state.config && state.messages.length === 1 && jsxRuntime.jsx("div", { className: 'andy-support__questions', children: state.config.questions.map(question => jsxRuntime.jsx("button", { type: 'button', disabled: state.busy, onClick: () => void controller?.send(question), children: question }, question)) }), state.busy && jsxRuntime.jsx("p", { className: 'andy-support__muted', role: 'status', children: "\u00B7\u00B7\u00B7" }), state.messages.length > 1 && !state.busy && jsxRuntime.jsxs("button", { type: 'button', className: 'andy-support__secondary', onClick: escalate, children: [strings.team, " \u2197"] })] }), state.view === 'form' && jsxRuntime.jsxs("form", { className: 'andy-support__form', onSubmit: event => { event.preventDefault(); void controller?.submit(subject, body, replyLanguage); }, children: [jsxRuntime.jsx("h2", { children: strings.team }), jsxRuntime.jsxs("label", { children: [strings.subject, jsxRuntime.jsx("input", { required: true, maxLength: 200, value: state.submission?.subject ?? subject, readOnly: !!state.submission, disabled: state.busy, onChange: event => setSubject(event.target.value) })] }), jsxRuntime.jsxs("label", { children: [strings.detail, jsxRuntime.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) })] }), jsxRuntime.jsxs("details", { className: 'andy-support__transcript', children: [jsxRuntime.jsxs("summary", { children: [strings.notice, " \u00B7 ", state.transcript.length, " ", strings.transcript] }), state.transcript.map((message, index) => jsxRuntime.jsx("p", { children: message.content }, index))] }), jsxRuntime.jsx("p", { className: 'andy-support__muted', children: strings.consent }), jsxRuntime.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 && jsxRuntime.jsxs("div", { className: 'andy-support__receipt', children: [jsxRuntime.jsx("span", { className: 'andy-support__check', "aria-hidden": 'true', children: "\u2713" }), jsxRuntime.jsx("h2", { children: strings.receipt }), jsxRuntime.jsxs("strong", { children: ["#", state.ticket.number] }), jsxRuntime.jsx("p", { children: state.ticket.subject }), promiseText && jsxRuntime.jsxs("p", { children: [strings.promise, " ", jsxRuntime.jsx("strong", { children: promiseText }), "."] }), jsxRuntime.jsx("button", { type: 'button', className: 'andy-support__primary', onClick: () => void controller?.showRequests(), children: strings.requests })] }), state.view === 'requests' && jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("h2", { children: strings.requests }), state.tickets.map(ticketRow), !state.busy && !state.error && !state.tickets.length && jsxRuntime.jsx("p", { className: 'andy-support__muted', children: strings.empty })] }), state.view === 'request' && state.ticket && jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("h2", { children: ["#", state.ticket.number, " \u00B7 ", state.ticket.subject] }), jsxRuntime.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 => jsxRuntime.jsxs("article", { className: 'andy-support__entry', children: [jsxRuntime.jsx("strong", { children: entry.authorName }), jsxRuntime.jsx("p", { children: entry.body })] }, entry.id)), state.ticket.status === 'resolved' && !reopening && jsxRuntime.jsxs("div", { className: 'andy-support__request-actions', children: [jsxRuntime.jsx("button", { type: 'button', className: 'andy-support__primary', onClick: () => setReopening(true), children: strings.reopen }), jsxRuntime.jsx("button", { type: 'button', className: 'andy-support__secondary', onClick: newProblem, children: strings.otherProblem })] })] }), state.error && jsxRuntime.jsxs("div", { className: 'andy-support__error', role: 'alert', children: [jsxRuntime.jsx("p", { children: errorText }), (!state.config || state.view === 'form' || state.view === 'requests' || (state.view === 'request' && state.error !== 'ticket_resolved' && (selectedTicketId || support.ticketId))) && jsxRuntime.jsx("button", { type: 'button', onClick: retry, disabled: state.busy, children: strings.retry })] })] }), state.view === 'conversation' && jsxRuntime.jsxs("form", { className: 'andy-support__composer', onSubmit: event => { event.preventDefault(); send(); }, children: [jsxRuntime.jsx("input", { "aria-label": strings.placeholder, placeholder: strings.placeholder, value: draft, maxLength: 10000, disabled: !state.config, onChange: event => setDraft(event.target.value) }), jsxRuntime.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) && jsxRuntime.jsxs("form", { className: 'andy-support__followup', onSubmit: event => { event.preventDefault(); void followup(); }, children: [jsxRuntime.jsxs("label", { children: [reopening ? strings.reopening : strings.reply, jsxRuntime.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) })] }), jsxRuntime.jsx("button", { type: 'submit', className: 'andy-support__primary', disabled: state.busy || !replyDraft.trim(), children: state.busy ? strings.loading : reopening ? strings.reopen : strings.send })] }), jsxRuntime.jsxs("footer", { className: 'andy-support__footer', children: [jsxRuntime.jsx("span", { children: strings.powered }), state.config?.branding.footer.map(link => jsxRuntime.jsx("a", { href: link.url, target: '_blank', rel: 'noopener noreferrer', children: link.label }, link.url))] })] })] }), portal) });
4102
+ };
4103
+ /** A host identity change also destroys local drafts and menu state before the next render. */
4104
+ const SupportChat = props => jsxRuntime.jsx(SupportPanel, { ...props }, JSON.stringify([props.support.installationId, props.support.contextKey, props.support.apiBaseUrl]));
4105
+
4106
+ const SalesAndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessage, bubbleLabel, lang, predefinedQuestions, theme, orientation, brandColor, bubbleStyle, useBrandColor, logoUrl, previewMode = false, initialOpen = false }) => {
3934
4107
  const [config, setConfig] = react.useState(null);
3935
4108
  // The visitor's language wins over the Agent's own default: a Spanish Agent
3936
4109
  // embedded on an English page must still render English chrome.
@@ -4034,22 +4207,7 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4034
4207
  portalRef.current = portalTarget;
4035
4208
  }
4036
4209
  setShadowReady(true);
4037
- const keyboardDetector = getKeyboardDetector();
4038
- const keyboardListener = ({ isOpen: kbOpen, height }) => {
4039
- updateKeyboardHeight(containerRef.current, height);
4040
- const chatWindow = shadowRef.current?.querySelector('.andy-chat-window');
4041
- if (chatWindow) {
4042
- if (kbOpen && height > 0) {
4043
- chatWindow.classList.add('andy-chat-window--keyboard-open');
4044
- }
4045
- else {
4046
- chatWindow.classList.remove('andy-chat-window--keyboard-open');
4047
- }
4048
- }
4049
- };
4050
- keyboardDetector.addListener(keyboardListener);
4051
4210
  return () => {
4052
- keyboardDetector.removeListener(keyboardListener);
4053
4211
  shadowRef.current = null;
4054
4212
  portalRef.current = null;
4055
4213
  setShadowReady(false);
@@ -4570,10 +4728,14 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4570
4728
  config?.welcomeMessage ||
4571
4729
  'Welcome! How can I help you today?' }), jsxRuntime.jsx("button", { type: 'button', className: 'andy-welcome-popup__close', "aria-label": strings.closeWelcomeMessage, onClick: () => setShowWelcomePopup(false), children: jsxRuntime.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: jsxRuntime.jsx("path", { d: 'M18 6L6 18M6 6l12 12', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' }) }) })] }), jsxRuntime.jsx("div", { className: 'andy-welcome-popup__arrow' })] }), portalRef.current)] })) }));
4572
4730
  };
4731
+ const AndyChat = props => props.mode === 'support'
4732
+ ? jsxRuntime.jsx(SupportChat, { ...props }, `${props.support.installationId}:${props.support.contextKey}`)
4733
+ : jsxRuntime.jsx(SalesAndyChat, { ...props });
4573
4734
 
4574
4735
  const { SESSION_STORAGE_KEY, FORM_DISPLAY_DELAY_MS, PERSIST_SESSION } = CONFIG;
4575
- const useAndyChat = (embedId) => {
4576
- initializeConfig('andy-widget-react');
4736
+ const useSalesAndyChat = (embedId, enabled) => {
4737
+ if (enabled)
4738
+ initializeConfig('andy-widget-react');
4577
4739
  const [config, setConfig] = react.useState({});
4578
4740
  const [isLoading, setIsLoading] = react.useState(true);
4579
4741
  const [isOpen, setIsOpen] = react.useState(false);
@@ -4593,6 +4755,8 @@ const useAndyChat = (embedId) => {
4593
4755
  const welcomeTimeoutRef = react.useRef(null);
4594
4756
  const userInfoTimeoutRef = react.useRef(null);
4595
4757
  const initializeChatbot = react.useCallback(async () => {
4758
+ if (!enabled)
4759
+ return;
4596
4760
  if (!embedId) {
4597
4761
  throw new Error('Embed ID is required');
4598
4762
  }
@@ -4627,7 +4791,7 @@ const useAndyChat = (embedId) => {
4627
4791
  setIsLoading(false);
4628
4792
  throw err;
4629
4793
  }
4630
- }, [embedId]);
4794
+ }, [embedId, enabled]);
4631
4795
  const toggleChat = react.useCallback(() => {
4632
4796
  setIsOpen(prev => {
4633
4797
  const newIsOpen = !prev;
@@ -4661,7 +4825,7 @@ const useAndyChat = (embedId) => {
4661
4825
  });
4662
4826
  }, [config, welcomePopupShown, userInfoCollected]);
4663
4827
  const sendMessage = react.useCallback(async (messageText) => {
4664
- if (!messageText.trim() || !config || !sessionId || activeReplyRef.current) {
4828
+ if (!enabled || !messageText.trim() || !config || !sessionId || activeReplyRef.current) {
4665
4829
  return;
4666
4830
  }
4667
4831
  const controller = new AbortController();
@@ -4711,7 +4875,7 @@ const useAndyChat = (embedId) => {
4711
4875
  if (activeReplyRef.current === controller)
4712
4876
  activeReplyRef.current = null;
4713
4877
  }
4714
- }, [embedId, sessionId, config]);
4878
+ }, [embedId, sessionId, config, enabled]);
4715
4879
  const showFaq = react.useCallback(() => {
4716
4880
  setShowingFaq(true);
4717
4881
  }, []);
@@ -4731,6 +4895,8 @@ const useAndyChat = (embedId) => {
4731
4895
  setShowingUserInfoForm(false);
4732
4896
  }, []);
4733
4897
  const submitUserInfo$1 = react.useCallback(async (userInfo) => {
4898
+ if (!enabled)
4899
+ return;
4734
4900
  try {
4735
4901
  await submitUserInfo(embedId, sessionId, userInfo);
4736
4902
  setUserInfoCollected(true);
@@ -4747,8 +4913,10 @@ const useAndyChat = (embedId) => {
4747
4913
  console.error('Error submitting user info:', err);
4748
4914
  throw err;
4749
4915
  }
4750
- }, [embedId, sessionId]);
4916
+ }, [embedId, sessionId, enabled]);
4751
4917
  const startNewSession = react.useCallback(() => {
4918
+ if (!enabled)
4919
+ return;
4752
4920
  activeReplyRef.current?.abort();
4753
4921
  activeReplyRef.current = null;
4754
4922
  const newSessionId = generateSessionId();
@@ -4774,7 +4942,7 @@ const useAndyChat = (embedId) => {
4774
4942
  setMessages([greetingMessage]);
4775
4943
  }
4776
4944
  }
4777
- }, [config]);
4945
+ }, [config, enabled]);
4778
4946
  const addMessage = react.useCallback((message) => {
4779
4947
  setMessages(prev => [...prev, message]);
4780
4948
  }, []);
@@ -4803,6 +4971,11 @@ const useAndyChat = (embedId) => {
4803
4971
  addMessage
4804
4972
  };
4805
4973
  };
4974
+ function useAndyChat(input) {
4975
+ const support = useSupportChat(typeof input === 'string' ? null : input.support);
4976
+ const sales = useSalesAndyChat(typeof input === 'string' ? input : '', typeof input === 'string');
4977
+ return typeof input === 'string' ? sales : support;
4978
+ }
4806
4979
 
4807
4980
  const ChatBubble = ({ isOpen, config, onClick }) => {
4808
4981
  const bubbleClasses = ['andy-bubble', isOpen && 'andy-bubble--open'].filter(Boolean).join(' ');
@@ -5017,4 +5190,3 @@ exports.theme = theme;
5017
5190
  exports.typography = typography;
5018
5191
  exports.useAndyChat = useAndyChat;
5019
5192
  exports.zIndex = zIndex;
5020
- //# sourceMappingURL=index.cjs.map