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.esm.js CHANGED
@@ -869,7 +869,7 @@ const _mapApiDataToChatbotConfig = (apiData, defaultConfig) => {
869
869
  throw new Error('Invalid chatbot configuration response.')
870
870
  }
871
871
 
872
- const { chatbot, faqs: rawFaqs } = apiData;
872
+ const { chatbot, faqs: rawFaqs, bootstrap } = apiData;
873
873
  const appearance = chatbot.appearance || {};
874
874
  const behavior = chatbot.behavior || {};
875
875
 
@@ -939,6 +939,7 @@ const _mapApiDataToChatbotConfig = (apiData, defaultConfig) => {
939
939
  )
940
940
  .map(faq => ({ question: faq.question.trim(), answer: faq.answer.trim() }));
941
941
  }
942
+ config.bootstrap = bootstrap;
942
943
 
943
944
  return config
944
945
  };
@@ -988,7 +989,7 @@ const fetchChatbotConfig = async embedId => {
988
989
  * @param {string} message - The user's message
989
990
  * @returns {Promise<string>} The bot's response text
990
991
  */
991
- const sendMessage = async (embedId, sessionId, message) => {
992
+ const sendMessageForWidget = async (embedId, sessionId, message) => {
992
993
  try {
993
994
  const apiBaseUrl = getApiBaseUrl();
994
995
  const response = await fetch(`${apiBaseUrl}/messages`, {
@@ -1009,10 +1010,13 @@ const sendMessage = async (embedId, sessionId, message) => {
1009
1010
  if (data.error || data.limitStatus) {
1010
1011
  throw new Error(data.error || 'Chat is temporarily unavailable.')
1011
1012
  }
1013
+ if (data.handoffStatus === 'paused') {
1014
+ return { status: 'waiting' }
1015
+ }
1012
1016
  if (typeof data.botResponse !== 'string' || !data.botResponse.trim()) {
1013
1017
  throw new Error('Unexpected response format from server.')
1014
1018
  }
1015
- return data.botResponse
1019
+ return { status: 'agent', text: data.botResponse }
1016
1020
  } else {
1017
1021
  // Handle HTTP errors (e.g., 500, 404)
1018
1022
  let errorData;
@@ -3694,12 +3698,167 @@ const isMobileDevice = () => {
3694
3698
  return (hasTouch && isSmallScreen) || isMobileUserAgent || isMobileMediaQuery
3695
3699
  };
3696
3700
 
3701
+ const identityKey = chatbotId => `andy-browser:${chatbotId}`;
3702
+
3703
+ const randomIdentity = () => ({
3704
+ sessionId: window.crypto.randomUUID(),
3705
+ capability: btoa(String.fromCharCode(...(() => {
3706
+ const bytes = new Uint8Array(32);
3707
+ window.crypto.getRandomValues(bytes);
3708
+ return bytes
3709
+ })()))
3710
+ });
3711
+
3712
+ const callConvex = async (convexUrl, kind, path, wireArgs, keepalive = false) => {
3713
+ const response = await fetch(`${convexUrl}/api/${kind}`, {
3714
+ method: 'POST',
3715
+ headers: { 'Content-Type': 'application/json' },
3716
+ body: JSON.stringify({ path, format: 'convex_encoded_json', args: [wireArgs] }),
3717
+ keepalive
3718
+ });
3719
+ if (!response.ok) throw new Error(`Browser handoff ${kind} failed: ${response.status}`)
3720
+ const data = await response.json();
3721
+ if (data.status !== 'success') throw new Error(data.errorMessage || `Browser handoff ${path} failed`)
3722
+ return data.value
3723
+ };
3724
+
3725
+ const sortMessages = messages => [...messages].sort((left, right) =>
3726
+ left.createdAt - right.createdAt || String(left.id).localeCompare(String(right.id)));
3727
+
3728
+ const createBrowserHandoff = (chatbotId, bootstrap, onProjection) => {
3729
+ if (!bootstrap?.convexUrl || !bootstrap.surfaceKey || !bootstrap.surfaceProof) return null
3730
+ const storageKey = identityKey(chatbotId);
3731
+ let identity;
3732
+ try {
3733
+ const stored = JSON.parse(window.sessionStorage.getItem(storageKey) || 'null');
3734
+ identity = stored?.sessionId && stored?.capability && stored.surfaceKey === bootstrap.surfaceKey
3735
+ ? { sessionId: stored.sessionId, capability: stored.capability }
3736
+ : randomIdentity();
3737
+ window.sessionStorage.setItem(storageKey, JSON.stringify({ ...identity, surfaceKey: bootstrap.surfaceKey }));
3738
+ } catch {
3739
+ identity = randomIdentity();
3740
+ }
3741
+ const wireArgs = () => ({ chatbotId, sessionId: identity.sessionId, capability: identity.capability, surfaceProof: bootstrap.surfaceProof });
3742
+ let active = true;
3743
+ let visible = document.visibilityState === 'visible';
3744
+ let cursor;
3745
+ let timer;
3746
+ const pending = new Set();
3747
+ const acknowledged = new Set();
3748
+ const acknowledging = new Set();
3749
+ const localMessages = new Map();
3750
+ const emit = projection => {
3751
+ if (!active) return
3752
+ if (projection?.ok === false && projection.code === 'browser_presence_unavailable') {
3753
+ onProjection({ offline: true, refreshBootstrap: Date.now() >= bootstrap.surfaceProofExpiresAt, identity, messages: [] });
3754
+ return
3755
+ }
3756
+ if (!projection?.ok) return
3757
+ cursor = projection.cursor || cursor;
3758
+ const transcript = projection.transcript || [];
3759
+ const projected = (projection.messages || []).map(message => ({ ...message, role: 'bot', sentByHuman: true }));
3760
+ const byId = new Map(transcript.map(message => [String(message.id), message]));
3761
+ for (const message of localMessages.values()) {
3762
+ const persisted = transcript.some(candidate => candidate.role === 'user' && candidate.content === message.content && Math.abs(candidate.createdAt - message.createdAt) < 30000);
3763
+ if (persisted) localMessages.delete(message.id);
3764
+ else byId.set(message.id, message);
3765
+ }
3766
+ projected.forEach(message => byId.set(String(message.id), message));
3767
+ const messages = sortMessages([...byId.values()]);
3768
+ const pendingIds = projected.map(message => String(message.id)).filter(id => !acknowledged.has(id));
3769
+ pendingIds.forEach(id => pending.add(id));
3770
+ onProjection({ identity, control: projection.control, messages, pendingProjectionIds: pendingIds });
3771
+ };
3772
+ const project = async () => {
3773
+ if (!active || !visible) return
3774
+ if (Date.now() >= bootstrap.surfaceProofExpiresAt) {
3775
+ emit({ ok: false, code: 'browser_presence_unavailable' });
3776
+ return
3777
+ }
3778
+ try {
3779
+ const result = await callConvex(bootstrap.convexUrl, 'query', 'browserHandoff:project', { ...wireArgs(), ...(cursor ? { cursor } : {}) });
3780
+ emit(result);
3781
+ } catch {
3782
+ if (active) onProjection({ offline: true, identity, messages: [] });
3783
+ }
3784
+ };
3785
+ const connect = async () => {
3786
+ if (!active || Date.now() >= bootstrap.surfaceProofExpiresAt) {
3787
+ emit({ ok: false, code: 'browser_presence_unavailable' });
3788
+ return
3789
+ }
3790
+ try {
3791
+ const result = await callConvex(bootstrap.convexUrl, 'mutation', 'browserHandoff:connect', wireArgs());
3792
+ if (result?.ok === false) return emit(result)
3793
+ await project();
3794
+ } catch {
3795
+ if (active) onProjection({ offline: true, identity, messages: [] });
3796
+ }
3797
+ };
3798
+ const release = () => callConvex(bootstrap.convexUrl, 'mutation', 'browserHandoff:release', wireArgs(), true).catch(() => undefined);
3799
+ const onVisibility = () => {
3800
+ visible = document.visibilityState === 'visible';
3801
+ if (visible) void connect();
3802
+ else void release();
3803
+ };
3804
+ document.addEventListener('visibilitychange', onVisibility);
3805
+ window.addEventListener('pagehide', release);
3806
+ void connect();
3807
+ timer = window.setInterval(async () => {
3808
+ if (!active || !visible) return
3809
+ if (Date.now() >= bootstrap.surfaceProofExpiresAt) {
3810
+ window.clearInterval(timer);
3811
+ emit({ ok: false, code: 'browser_presence_unavailable' });
3812
+ return
3813
+ }
3814
+ try {
3815
+ const result = await callConvex(bootstrap.convexUrl, 'mutation', 'browserHandoff:heartbeat', wireArgs());
3816
+ if (result?.ok === false) emit(result);
3817
+ else await project();
3818
+ } catch {
3819
+ if (active) onProjection({ offline: true, identity, messages: [] });
3820
+ }
3821
+ }, 15000);
3822
+ return {
3823
+ identity: { sessionId: identity.sessionId },
3824
+ recordVisitorMessage: content => {
3825
+ const id = `local-${window.crypto.randomUUID()}`;
3826
+ localMessages.set(id, { id, role: 'user', content, createdAt: Date.now(), sentByHuman: false });
3827
+ },
3828
+ markRendered: async messageId => {
3829
+ if (!active || !pending.has(messageId) || acknowledged.has(messageId) || acknowledging.has(messageId)) return
3830
+ acknowledging.add(messageId);
3831
+ try {
3832
+ const result = await callConvex(bootstrap.convexUrl, 'mutation', 'browserHandoff:acknowledge', { ...wireArgs(), messageId });
3833
+ if (result?.ok) {
3834
+ acknowledged.add(messageId);
3835
+ pending.delete(messageId);
3836
+ }
3837
+ } catch {
3838
+ if (active) onProjection({ offline: true, identity, messages: [] });
3839
+ } finally {
3840
+ acknowledging.delete(messageId);
3841
+ }
3842
+ },
3843
+ stop: () => {
3844
+ if (!active) return
3845
+ active = false;
3846
+ window.clearInterval(timer);
3847
+ document.removeEventListener('visibilitychange', onVisibility);
3848
+ window.removeEventListener('pagehide', release);
3849
+ void release();
3850
+ }
3851
+ }
3852
+ };
3853
+
3697
3854
  const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessage, bubbleLabel, theme, orientation, brandColor, bubbleStyle, useBrandColor, logoUrl, previewMode = false, initialOpen = false }) => {
3698
3855
  const [config, setConfig] = useState(null);
3699
3856
  const [isOpen, setIsOpen] = useState(initialOpen);
3700
3857
  const [messages, setMessages] = useState([]);
3701
3858
  const [isLoading, setIsLoading] = useState(false);
3702
3859
  const [sessionId, setSessionId] = useState(() => generateSessionId());
3860
+ const handoffRef = useRef(null);
3861
+ const pendingHandoffIdsRef = useRef([]);
3703
3862
  const [shadowReady, setShadowReady] = useState(false);
3704
3863
  const [showPredefinedQuestions, setShowPredefinedQuestions] = useState(true);
3705
3864
  const [userInfoCollected, setUserInfoCollected] = useState(false);
@@ -3713,6 +3872,29 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
3713
3872
  const messagesContainerRef = useRef(null);
3714
3873
  const welcomePopupShownRef = useRef(false);
3715
3874
  const welcomePopupTimeoutRef = useRef(null);
3875
+ useEffect(() => {
3876
+ if (!config?.bootstrap || !config.id)
3877
+ return;
3878
+ handoffRef.current?.stop();
3879
+ const handoff = createBrowserHandoff(config.id, config.bootstrap, projection => {
3880
+ if (projection.offline)
3881
+ return;
3882
+ setSessionId(projection.identity.sessionId);
3883
+ pendingHandoffIdsRef.current = projection.pendingProjectionIds || [];
3884
+ 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 }));
3885
+ setMessages(projection.control === 'requested' || projection.control === 'human'
3886
+ ? [...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 }]
3887
+ : projectedMessages);
3888
+ });
3889
+ handoffRef.current = handoff;
3890
+ return () => {
3891
+ handoff?.stop();
3892
+ handoffRef.current = null;
3893
+ };
3894
+ }, [config?.bootstrap, config?.id]);
3895
+ useEffect(() => {
3896
+ pendingHandoffIdsRef.current.forEach(messageId => void handoffRef.current?.markRendered(messageId));
3897
+ }, [messages]);
3716
3898
  // Create shadow DOM container on mount
3717
3899
  useEffect(() => {
3718
3900
  if (!containerRef.current)
@@ -4039,16 +4221,21 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4039
4221
  sender: 'user',
4040
4222
  timestamp: new Date().toISOString()
4041
4223
  };
4224
+ handoffRef.current?.recordVisitorMessage(messageText);
4042
4225
  setMessages(prev => [...prev, userMessage]);
4043
4226
  if (hidePredefined) {
4044
4227
  setShowPredefinedQuestions(false);
4045
4228
  }
4046
4229
  try {
4047
4230
  setIsLoading(true);
4048
- const response = await sendMessage(embedId, sessionId, messageText);
4231
+ const response = await sendMessageForWidget(embedId, sessionId, messageText);
4232
+ if (response.status === 'waiting') {
4233
+ 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 }]);
4234
+ return;
4235
+ }
4049
4236
  const botMessage = {
4050
4237
  id: timestamp + 1,
4051
- content: response,
4238
+ content: response.text,
4052
4239
  sender: 'bot',
4053
4240
  timestamp: new Date().toISOString()
4054
4241
  };
@@ -4241,7 +4428,7 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4241
4428
  if (message.isLoading) {
4242
4429
  return (jsx("div", { className: 'andy-message-container andy-message-container--bot andy-message-container--loading', children: jsx("div", { className: 'andy-message andy-message--bot', children: jsxs("div", { className: 'andy-typing-indicator', children: [jsx("div", { className: 'andy-typing-indicator__dot' }), jsx("div", { className: 'andy-typing-indicator__dot' }), jsx("div", { className: 'andy-typing-indicator__dot' })] }) }) }, message.id));
4243
4430
  }
4244
- return (jsxs("div", { className: `andy-message-container andy-message-container--${message.sender}`, children: [message.sender === 'bot' && (jsxs("div", { className: `andy-message andy-message--bot ${message.isError ? 'andy-message--error' : ''}`, children: [jsxs("div", { className: 'andy-bot-header', children: [jsx("div", { className: 'andy-bot-header__icon', children: config?.logoUrl ? (jsx("img", { src: config.logoUrl, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image' })) : (jsx("img", { src: CHAT_BUBBLE_DEFAULT_ICON_URL, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image andy-bot-header__icon-image--default' })) }), jsx("span", { className: 'andy-bot-header__name', children: config?.name || 'Andy bot' })] }), jsx("div", { className: 'andy-message__content', children: jsx("div", { dangerouslySetInnerHTML: { __html: parseMarkdown(message.content) } }) })] })), message.sender === 'user' && (jsx("div", { className: 'andy-message andy-message--user', children: jsx("div", { className: 'andy-message__content', children: jsx("p", { children: message.content }) }) }))] }, message.id));
4431
+ return (jsxs("div", { className: `andy-message-container andy-message-container--${message.sender} ${message.isWaiting ? 'andy-message-container--waiting' : ''}`, children: [message.sender === 'bot' && (jsxs("div", { className: `andy-message andy-message--bot ${message.isHuman ? 'andy-message--operator' : ''} ${message.isWaiting ? 'andy-message--waiting' : ''} ${message.isError ? 'andy-message--error' : ''}`, children: [jsxs("div", { className: 'andy-bot-header', children: [jsx("div", { className: 'andy-bot-header__icon', children: config?.logoUrl ? (jsx("img", { src: config.logoUrl, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image' })) : (jsx("img", { src: CHAT_BUBBLE_DEFAULT_ICON_URL, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image andy-bot-header__icon-image--default' })) }), jsx("span", { className: 'andy-bot-header__name', children: message.isHuman ? (config?.defaultLang === 'es' ? 'Equipo' : 'Team') : config?.name || 'Andy bot' })] }), jsx("div", { className: 'andy-message__content', children: jsx("div", { dangerouslySetInnerHTML: { __html: parseMarkdown(message.content) } }) })] })), message.sender === 'user' && (jsx("div", { className: 'andy-message andy-message--user', children: jsx("div", { className: 'andy-message__content', children: jsx("p", { children: message.content }) }) }))] }, message.id));
4245
4432
  }), isLoading && (jsx("div", { className: 'andy-message-container andy-message-container--bot andy-message-container--typing', children: jsx("div", { className: 'andy-message andy-message--bot andy-message--typing', children: jsxs("div", { className: 'andy-typing-indicator', children: [jsx("div", { className: 'andy-typing-indicator__dot' }), jsx("div", { className: 'andy-typing-indicator__dot' }), jsx("div", { className: 'andy-typing-indicator__dot' })] }) }) }))] }), jsxs("div", { className: 'andy-chat-window__bottom', children: [config?.predefinedQuestions &&
4246
4433
  config.predefinedQuestions.length > 0 &&
4247
4434
  showPredefinedQuestions && (jsxs("div", { className: 'andy-predefined-questions', id: 'andy-predefined-questions', children: [jsx("div", { className: 'andy-predefined-questions__header', children: jsx("p", { className: 'andy-predefined-questions__title', children: "What would you like to know?" }) }), jsx("div", { className: 'andy-predefined-questions__list', children: config.predefinedQuestions.map((q, index) => (jsxs("button", { type: 'button', className: 'andy-predefined-question', "data-question": q.question, disabled: isCollectingUserInfo || isLoading || !config, children: [jsx("span", { className: 'andy-predefined-question__icon', children: "\uD83D\uDCAC" }), jsx("span", { className: 'andy-predefined-question__text', children: q.question })] }, index))) })] })), jsx("div", { className: 'andy-chat-window__input', children: jsx("div", { className: 'andy-input-container', children: jsxs("div", { className: 'andy-input', children: [jsx("input", { type: 'text', className: 'andy-input__field', id: 'andy-message-input', placeholder: config ? 'Type your message...' : '...', "aria-label": 'Message input', onKeyDown: e => {
@@ -4346,7 +4533,7 @@ const useAndyChat = (embedId) => {
4346
4533
  return newIsOpen;
4347
4534
  });
4348
4535
  }, [config, welcomePopupShown, userInfoCollected]);
4349
- const sendMessage$1 = useCallback(async (messageText) => {
4536
+ const sendMessage = useCallback(async (messageText) => {
4350
4537
  if (!messageText.trim() || !config || !sessionId) {
4351
4538
  return;
4352
4539
  }
@@ -4358,10 +4545,12 @@ const useAndyChat = (embedId) => {
4358
4545
  };
4359
4546
  setMessages(prev => [...prev, userMessage]);
4360
4547
  try {
4361
- const response = await sendMessage(embedId, sessionId, messageText);
4548
+ const response = await sendMessageForWidget(embedId, sessionId, messageText);
4549
+ if (response.status === 'waiting')
4550
+ return response;
4362
4551
  const botMessage = {
4363
4552
  id: Date.now() + 1,
4364
- content: response || 'I apologize, but I encountered an error processing your request.',
4553
+ content: response.text || 'I apologize, but I encountered an error processing your request.',
4365
4554
  sender: 'bot',
4366
4555
  timestamp: new Date().toISOString()
4367
4556
  };
@@ -4458,7 +4647,7 @@ const useAndyChat = (embedId) => {
4458
4647
  error,
4459
4648
  initializeChatbot,
4460
4649
  toggleChat,
4461
- sendMessage: sendMessage$1,
4650
+ sendMessage,
4462
4651
  showFaq,
4463
4652
  hideFaq,
4464
4653
  showWelcomePopup,