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/README.md +36 -1
- package/dist/index.cjs +448 -276
- package/dist/index.esm.js +450 -278
- package/dist/types/SupportChat.d.ts +4 -0
- package/dist/types/hooks/useAndyChat.d.ts +7 -1
- package/dist/types/hooks/useSupportChat.d.ts +23 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/types/index.d.ts +11 -11
- package/package.json +2 -2
- package/src/AndyChat.tsx +7 -21
- package/src/SupportChat.tsx +106 -0
- package/src/hooks/useAndyChat.ts +20 -7
- package/src/hooks/useSupportChat.ts +27 -0
- package/src/index.ts +2 -0
- package/src/types/index.ts +12 -12
- package/dist/index.cjs.map +0 -1
- package/dist/index.esm.js.map +0 -1
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
4572
|
-
|
|
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
|