andy-widget-react 2.0.5 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -873,7 +873,7 @@ const _mapApiDataToChatbotConfig = (apiData, defaultConfig) => {
873
873
  throw new Error('Invalid chatbot configuration response.')
874
874
  }
875
875
 
876
- const { chatbot, faqs: rawFaqs } = apiData;
876
+ const { chatbot, faqs: rawFaqs, bootstrap } = apiData;
877
877
  const appearance = chatbot.appearance || {};
878
878
  const behavior = chatbot.behavior || {};
879
879
 
@@ -943,6 +943,7 @@ const _mapApiDataToChatbotConfig = (apiData, defaultConfig) => {
943
943
  )
944
944
  .map(faq => ({ question: faq.question.trim(), answer: faq.answer.trim() }));
945
945
  }
946
+ config.bootstrap = bootstrap;
946
947
 
947
948
  return config
948
949
  };
@@ -992,7 +993,7 @@ const fetchChatbotConfig = async embedId => {
992
993
  * @param {string} message - The user's message
993
994
  * @returns {Promise<string>} The bot's response text
994
995
  */
995
- const sendMessage = async (embedId, sessionId, message) => {
996
+ const sendMessageForWidget = async (embedId, sessionId, message) => {
996
997
  try {
997
998
  const apiBaseUrl = getApiBaseUrl();
998
999
  const response = await fetch(`${apiBaseUrl}/messages`, {
@@ -1013,10 +1014,13 @@ const sendMessage = async (embedId, sessionId, message) => {
1013
1014
  if (data.error || data.limitStatus) {
1014
1015
  throw new Error(data.error || 'Chat is temporarily unavailable.')
1015
1016
  }
1017
+ if (data.handoffStatus === 'paused') {
1018
+ return { status: 'waiting' }
1019
+ }
1016
1020
  if (typeof data.botResponse !== 'string' || !data.botResponse.trim()) {
1017
1021
  throw new Error('Unexpected response format from server.')
1018
1022
  }
1019
- return data.botResponse
1023
+ return { status: 'agent', text: data.botResponse }
1020
1024
  } else {
1021
1025
  // Handle HTTP errors (e.g., 500, 404)
1022
1026
  let errorData;
@@ -3698,12 +3702,167 @@ const isMobileDevice = () => {
3698
3702
  return (hasTouch && isSmallScreen) || isMobileUserAgent || isMobileMediaQuery
3699
3703
  };
3700
3704
 
3705
+ const identityKey = chatbotId => `andy-browser:${chatbotId}`;
3706
+
3707
+ const randomIdentity = () => ({
3708
+ sessionId: window.crypto.randomUUID(),
3709
+ capability: btoa(String.fromCharCode(...(() => {
3710
+ const bytes = new Uint8Array(32);
3711
+ window.crypto.getRandomValues(bytes);
3712
+ return bytes
3713
+ })()))
3714
+ });
3715
+
3716
+ const callConvex = async (convexUrl, kind, path, wireArgs, keepalive = false) => {
3717
+ const response = await fetch(`${convexUrl}/api/${kind}`, {
3718
+ method: 'POST',
3719
+ headers: { 'Content-Type': 'application/json' },
3720
+ body: JSON.stringify({ path, format: 'convex_encoded_json', args: [wireArgs] }),
3721
+ keepalive
3722
+ });
3723
+ if (!response.ok) throw new Error(`Browser handoff ${kind} failed: ${response.status}`)
3724
+ const data = await response.json();
3725
+ if (data.status !== 'success') throw new Error(data.errorMessage || `Browser handoff ${path} failed`)
3726
+ return data.value
3727
+ };
3728
+
3729
+ const sortMessages = messages => [...messages].sort((left, right) =>
3730
+ left.createdAt - right.createdAt || String(left.id).localeCompare(String(right.id)));
3731
+
3732
+ const createBrowserHandoff = (chatbotId, bootstrap, onProjection) => {
3733
+ if (!bootstrap?.convexUrl || !bootstrap.surfaceKey || !bootstrap.surfaceProof) return null
3734
+ const storageKey = identityKey(chatbotId);
3735
+ let identity;
3736
+ try {
3737
+ const stored = JSON.parse(window.sessionStorage.getItem(storageKey) || 'null');
3738
+ identity = stored?.sessionId && stored?.capability && stored.surfaceKey === bootstrap.surfaceKey
3739
+ ? { sessionId: stored.sessionId, capability: stored.capability }
3740
+ : randomIdentity();
3741
+ window.sessionStorage.setItem(storageKey, JSON.stringify({ ...identity, surfaceKey: bootstrap.surfaceKey }));
3742
+ } catch {
3743
+ identity = randomIdentity();
3744
+ }
3745
+ const wireArgs = () => ({ chatbotId, sessionId: identity.sessionId, capability: identity.capability, surfaceProof: bootstrap.surfaceProof });
3746
+ let active = true;
3747
+ let visible = document.visibilityState === 'visible';
3748
+ let cursor;
3749
+ let timer;
3750
+ const pending = new Set();
3751
+ const acknowledged = new Set();
3752
+ const acknowledging = new Set();
3753
+ const localMessages = new Map();
3754
+ const emit = projection => {
3755
+ if (!active) return
3756
+ if (projection?.ok === false && projection.code === 'browser_presence_unavailable') {
3757
+ onProjection({ offline: true, refreshBootstrap: Date.now() >= bootstrap.surfaceProofExpiresAt, identity, messages: [] });
3758
+ return
3759
+ }
3760
+ if (!projection?.ok) return
3761
+ cursor = projection.cursor || cursor;
3762
+ const transcript = projection.transcript || [];
3763
+ const projected = (projection.messages || []).map(message => ({ ...message, role: 'bot', sentByHuman: true }));
3764
+ const byId = new Map(transcript.map(message => [String(message.id), message]));
3765
+ for (const message of localMessages.values()) {
3766
+ const persisted = transcript.some(candidate => candidate.role === 'user' && candidate.content === message.content && Math.abs(candidate.createdAt - message.createdAt) < 30000);
3767
+ if (persisted) localMessages.delete(message.id);
3768
+ else byId.set(message.id, message);
3769
+ }
3770
+ projected.forEach(message => byId.set(String(message.id), message));
3771
+ const messages = sortMessages([...byId.values()]);
3772
+ const pendingIds = projected.map(message => String(message.id)).filter(id => !acknowledged.has(id));
3773
+ pendingIds.forEach(id => pending.add(id));
3774
+ onProjection({ identity, control: projection.control, messages, pendingProjectionIds: pendingIds });
3775
+ };
3776
+ const project = async () => {
3777
+ if (!active || !visible) return
3778
+ if (Date.now() >= bootstrap.surfaceProofExpiresAt) {
3779
+ emit({ ok: false, code: 'browser_presence_unavailable' });
3780
+ return
3781
+ }
3782
+ try {
3783
+ const result = await callConvex(bootstrap.convexUrl, 'query', 'browserHandoff:project', { ...wireArgs(), ...(cursor ? { cursor } : {}) });
3784
+ emit(result);
3785
+ } catch {
3786
+ if (active) onProjection({ offline: true, identity, messages: [] });
3787
+ }
3788
+ };
3789
+ const connect = async () => {
3790
+ if (!active || Date.now() >= bootstrap.surfaceProofExpiresAt) {
3791
+ emit({ ok: false, code: 'browser_presence_unavailable' });
3792
+ return
3793
+ }
3794
+ try {
3795
+ const result = await callConvex(bootstrap.convexUrl, 'mutation', 'browserHandoff:connect', wireArgs());
3796
+ if (result?.ok === false) return emit(result)
3797
+ await project();
3798
+ } catch {
3799
+ if (active) onProjection({ offline: true, identity, messages: [] });
3800
+ }
3801
+ };
3802
+ const release = () => callConvex(bootstrap.convexUrl, 'mutation', 'browserHandoff:release', wireArgs(), true).catch(() => undefined);
3803
+ const onVisibility = () => {
3804
+ visible = document.visibilityState === 'visible';
3805
+ if (visible) void connect();
3806
+ else void release();
3807
+ };
3808
+ document.addEventListener('visibilitychange', onVisibility);
3809
+ window.addEventListener('pagehide', release);
3810
+ void connect();
3811
+ timer = window.setInterval(async () => {
3812
+ if (!active || !visible) return
3813
+ if (Date.now() >= bootstrap.surfaceProofExpiresAt) {
3814
+ window.clearInterval(timer);
3815
+ emit({ ok: false, code: 'browser_presence_unavailable' });
3816
+ return
3817
+ }
3818
+ try {
3819
+ const result = await callConvex(bootstrap.convexUrl, 'mutation', 'browserHandoff:heartbeat', wireArgs());
3820
+ if (result?.ok === false) emit(result);
3821
+ else await project();
3822
+ } catch {
3823
+ if (active) onProjection({ offline: true, identity, messages: [] });
3824
+ }
3825
+ }, 15000);
3826
+ return {
3827
+ identity: { sessionId: identity.sessionId },
3828
+ recordVisitorMessage: content => {
3829
+ const id = `local-${window.crypto.randomUUID()}`;
3830
+ localMessages.set(id, { id, role: 'user', content, createdAt: Date.now(), sentByHuman: false });
3831
+ },
3832
+ markRendered: async messageId => {
3833
+ if (!active || !pending.has(messageId) || acknowledged.has(messageId) || acknowledging.has(messageId)) return
3834
+ acknowledging.add(messageId);
3835
+ try {
3836
+ const result = await callConvex(bootstrap.convexUrl, 'mutation', 'browserHandoff:acknowledge', { ...wireArgs(), messageId });
3837
+ if (result?.ok) {
3838
+ acknowledged.add(messageId);
3839
+ pending.delete(messageId);
3840
+ }
3841
+ } catch {
3842
+ if (active) onProjection({ offline: true, identity, messages: [] });
3843
+ } finally {
3844
+ acknowledging.delete(messageId);
3845
+ }
3846
+ },
3847
+ stop: () => {
3848
+ if (!active) return
3849
+ active = false;
3850
+ window.clearInterval(timer);
3851
+ document.removeEventListener('visibilitychange', onVisibility);
3852
+ window.removeEventListener('pagehide', release);
3853
+ void release();
3854
+ }
3855
+ }
3856
+ };
3857
+
3701
3858
  const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessage, bubbleLabel, theme, orientation, brandColor, bubbleStyle, useBrandColor, logoUrl, previewMode = false, initialOpen = false }) => {
3702
3859
  const [config, setConfig] = react.useState(null);
3703
3860
  const [isOpen, setIsOpen] = react.useState(initialOpen);
3704
3861
  const [messages, setMessages] = react.useState([]);
3705
3862
  const [isLoading, setIsLoading] = react.useState(false);
3706
3863
  const [sessionId, setSessionId] = react.useState(() => generateSessionId());
3864
+ const handoffRef = react.useRef(null);
3865
+ const pendingHandoffIdsRef = react.useRef([]);
3707
3866
  const [shadowReady, setShadowReady] = react.useState(false);
3708
3867
  const [showPredefinedQuestions, setShowPredefinedQuestions] = react.useState(true);
3709
3868
  const [userInfoCollected, setUserInfoCollected] = react.useState(false);
@@ -3717,6 +3876,29 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
3717
3876
  const messagesContainerRef = react.useRef(null);
3718
3877
  const welcomePopupShownRef = react.useRef(false);
3719
3878
  const welcomePopupTimeoutRef = react.useRef(null);
3879
+ react.useEffect(() => {
3880
+ if (!config?.bootstrap || !config.id)
3881
+ return;
3882
+ handoffRef.current?.stop();
3883
+ const handoff = createBrowserHandoff(config.id, config.bootstrap, projection => {
3884
+ if (projection.offline)
3885
+ return;
3886
+ setSessionId(projection.identity.sessionId);
3887
+ pendingHandoffIdsRef.current = projection.pendingProjectionIds || [];
3888
+ const projectedMessages = projection.messages.map(message => ({ id: message.id, content: message.content, sender: message.role === 'user' ? 'user' : 'bot', timestamp: new Date(message.createdAt).toISOString(), isHuman: message.sentByHuman }));
3889
+ setMessages(projection.control === 'requested' || projection.control === 'human'
3890
+ ? [...projectedMessages, { id: 'handoff-waiting', content: config.defaultLang === 'es' ? 'Un miembro del equipo te responderá pronto.' : 'A team member will reply soon.', sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }]
3891
+ : projectedMessages);
3892
+ });
3893
+ handoffRef.current = handoff;
3894
+ return () => {
3895
+ handoff?.stop();
3896
+ handoffRef.current = null;
3897
+ };
3898
+ }, [config?.bootstrap, config?.id]);
3899
+ react.useEffect(() => {
3900
+ pendingHandoffIdsRef.current.forEach(messageId => void handoffRef.current?.markRendered(messageId));
3901
+ }, [messages]);
3720
3902
  // Create shadow DOM container on mount
3721
3903
  react.useEffect(() => {
3722
3904
  if (!containerRef.current)
@@ -4043,16 +4225,21 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4043
4225
  sender: 'user',
4044
4226
  timestamp: new Date().toISOString()
4045
4227
  };
4228
+ handoffRef.current?.recordVisitorMessage(messageText);
4046
4229
  setMessages(prev => [...prev, userMessage]);
4047
4230
  if (hidePredefined) {
4048
4231
  setShowPredefinedQuestions(false);
4049
4232
  }
4050
4233
  try {
4051
4234
  setIsLoading(true);
4052
- const response = await sendMessage(embedId, sessionId, messageText);
4235
+ const response = await sendMessageForWidget(embedId, sessionId, messageText);
4236
+ if (response.status === 'waiting') {
4237
+ setMessages(prev => prev.some(message => message.isWaiting) ? prev : [...prev, { id: 'handoff-waiting', content: config?.defaultLang === 'es' ? 'Un miembro del equipo te responderá pronto.' : 'A team member will reply soon.', sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }]);
4238
+ return;
4239
+ }
4053
4240
  const botMessage = {
4054
4241
  id: timestamp + 1,
4055
- content: response,
4242
+ content: response.text,
4056
4243
  sender: 'bot',
4057
4244
  timestamp: new Date().toISOString()
4058
4245
  };
@@ -4245,7 +4432,7 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4245
4432
  if (message.isLoading) {
4246
4433
  return (jsxRuntime.jsx("div", { className: 'andy-message-container andy-message-container--bot andy-message-container--loading', children: jsxRuntime.jsx("div", { className: 'andy-message andy-message--bot', children: jsxRuntime.jsxs("div", { className: 'andy-typing-indicator', children: [jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' }), jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' }), jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' })] }) }) }, message.id));
4247
4434
  }
4248
- return (jsxRuntime.jsxs("div", { className: `andy-message-container andy-message-container--${message.sender}`, children: [message.sender === 'bot' && (jsxRuntime.jsxs("div", { className: `andy-message andy-message--bot ${message.isError ? 'andy-message--error' : ''}`, children: [jsxRuntime.jsxs("div", { className: 'andy-bot-header', children: [jsxRuntime.jsx("div", { className: 'andy-bot-header__icon', children: config?.logoUrl ? (jsxRuntime.jsx("img", { src: config.logoUrl, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image' })) : (jsxRuntime.jsx("img", { src: CHAT_BUBBLE_DEFAULT_ICON_URL, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image andy-bot-header__icon-image--default' })) }), jsxRuntime.jsx("span", { className: 'andy-bot-header__name', children: config?.name || 'Andy bot' })] }), jsxRuntime.jsx("div", { className: 'andy-message__content', children: jsxRuntime.jsx("div", { dangerouslySetInnerHTML: { __html: parseMarkdown(message.content) } }) })] })), message.sender === 'user' && (jsxRuntime.jsx("div", { className: 'andy-message andy-message--user', children: jsxRuntime.jsx("div", { className: 'andy-message__content', children: jsxRuntime.jsx("p", { children: message.content }) }) }))] }, message.id));
4435
+ return (jsxRuntime.jsxs("div", { className: `andy-message-container andy-message-container--${message.sender} ${message.isWaiting ? 'andy-message-container--waiting' : ''}`, children: [message.sender === 'bot' && (jsxRuntime.jsxs("div", { className: `andy-message andy-message--bot ${message.isHuman ? 'andy-message--operator' : ''} ${message.isWaiting ? 'andy-message--waiting' : ''} ${message.isError ? 'andy-message--error' : ''}`, children: [jsxRuntime.jsxs("div", { className: 'andy-bot-header', children: [jsxRuntime.jsx("div", { className: 'andy-bot-header__icon', children: config?.logoUrl ? (jsxRuntime.jsx("img", { src: config.logoUrl, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image' })) : (jsxRuntime.jsx("img", { src: CHAT_BUBBLE_DEFAULT_ICON_URL, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image andy-bot-header__icon-image--default' })) }), jsxRuntime.jsx("span", { className: 'andy-bot-header__name', children: message.isHuman ? (config?.defaultLang === 'es' ? 'Equipo' : 'Team') : config?.name || 'Andy bot' })] }), jsxRuntime.jsx("div", { className: 'andy-message__content', children: jsxRuntime.jsx("div", { dangerouslySetInnerHTML: { __html: parseMarkdown(message.content) } }) })] })), message.sender === 'user' && (jsxRuntime.jsx("div", { className: 'andy-message andy-message--user', children: jsxRuntime.jsx("div", { className: 'andy-message__content', children: jsxRuntime.jsx("p", { children: message.content }) }) }))] }, message.id));
4249
4436
  }), isLoading && (jsxRuntime.jsx("div", { className: 'andy-message-container andy-message-container--bot andy-message-container--typing', children: jsxRuntime.jsx("div", { className: 'andy-message andy-message--bot andy-message--typing', children: jsxRuntime.jsxs("div", { className: 'andy-typing-indicator', children: [jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' }), jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' }), jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' })] }) }) }))] }), jsxRuntime.jsxs("div", { className: 'andy-chat-window__bottom', children: [config?.predefinedQuestions &&
4250
4437
  config.predefinedQuestions.length > 0 &&
4251
4438
  showPredefinedQuestions && (jsxRuntime.jsxs("div", { className: 'andy-predefined-questions', id: 'andy-predefined-questions', children: [jsxRuntime.jsx("div", { className: 'andy-predefined-questions__header', children: jsxRuntime.jsx("p", { className: 'andy-predefined-questions__title', children: "What would you like to know?" }) }), jsxRuntime.jsx("div", { className: 'andy-predefined-questions__list', children: config.predefinedQuestions.map((q, index) => (jsxRuntime.jsxs("button", { type: 'button', className: 'andy-predefined-question', "data-question": q.question, disabled: isCollectingUserInfo || isLoading || !config, children: [jsxRuntime.jsx("span", { className: 'andy-predefined-question__icon', children: "\uD83D\uDCAC" }), jsxRuntime.jsx("span", { className: 'andy-predefined-question__text', children: q.question })] }, index))) })] })), jsxRuntime.jsx("div", { className: 'andy-chat-window__input', children: jsxRuntime.jsx("div", { className: 'andy-input-container', children: jsxRuntime.jsxs("div", { className: 'andy-input', children: [jsxRuntime.jsx("input", { type: 'text', className: 'andy-input__field', id: 'andy-message-input', placeholder: config ? 'Type your message...' : '...', "aria-label": 'Message input', onKeyDown: e => {
@@ -4350,7 +4537,7 @@ const useAndyChat = (embedId) => {
4350
4537
  return newIsOpen;
4351
4538
  });
4352
4539
  }, [config, welcomePopupShown, userInfoCollected]);
4353
- const sendMessage$1 = react.useCallback(async (messageText) => {
4540
+ const sendMessage = react.useCallback(async (messageText) => {
4354
4541
  if (!messageText.trim() || !config || !sessionId) {
4355
4542
  return;
4356
4543
  }
@@ -4362,10 +4549,12 @@ const useAndyChat = (embedId) => {
4362
4549
  };
4363
4550
  setMessages(prev => [...prev, userMessage]);
4364
4551
  try {
4365
- const response = await sendMessage(embedId, sessionId, messageText);
4552
+ const response = await sendMessageForWidget(embedId, sessionId, messageText);
4553
+ if (response.status === 'waiting')
4554
+ return response;
4366
4555
  const botMessage = {
4367
4556
  id: Date.now() + 1,
4368
- content: response || 'I apologize, but I encountered an error processing your request.',
4557
+ content: response.text || 'I apologize, but I encountered an error processing your request.',
4369
4558
  sender: 'bot',
4370
4559
  timestamp: new Date().toISOString()
4371
4560
  };
@@ -4462,7 +4651,7 @@ const useAndyChat = (embedId) => {
4462
4651
  error,
4463
4652
  initializeChatbot,
4464
4653
  toggleChat,
4465
- sendMessage: sendMessage$1,
4654
+ sendMessage,
4466
4655
  showFaq,
4467
4656
  hideFaq,
4468
4657
  showWelcomePopup,