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.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
|
|
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
|
-
|
|
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
|
|
4576
|
-
|
|
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
|