andy-widget-react 2.3.2 → 3.0.0-next.1

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/src/AndyChat.tsx DELETED
@@ -1,1151 +0,0 @@
1
- import React, { useState, useEffect, useRef, useCallback, FormEvent } from 'react'
2
- import ReactDOM from 'react-dom'
3
-
4
- import {
5
- fetchChatbotConfig,
6
- generateSessionId,
7
- createGreetingMessage,
8
- sendMessageForWidget as sendMessageToAPI,
9
- submitUserInfo,
10
- isValidEmbedId,
11
- getFullStylesheet,
12
- getHostClasses,
13
- createBrowserHandoff,
14
- parseMarkdown,
15
- escapeHtml,
16
- CHAT_BUBBLE_DEFAULT_ICON,
17
- CHAT_BUBBLE_DEFAULT_ICON_URL,
18
- SVG_ICONS,
19
- FOOTER_TEXT,
20
- getFooterUrl,
21
- getDefaultBubbleLabel,
22
- getUiStrings,
23
- isMobileDevice
24
- } from 'andy-widget-core'
25
-
26
- import type { AndyChatProps, SalesAndyChatProps, ChatbotConfig, Message } from './types'
27
- import { SupportChat } from './SupportChat'
28
-
29
- interface FaqItemProps {
30
- question: string
31
- answer: string
32
- }
33
-
34
- const FaqItem: React.FC<FaqItemProps> = ({ question, answer }) => {
35
- const [isOpen, setIsOpen] = useState(false)
36
-
37
- return (
38
- <div className={`andy-faq-item ${isOpen ? 'andy-faq-item--open' : ''}`}>
39
- <button
40
- type='button'
41
- className='andy-faq-item__question'
42
- aria-expanded={isOpen}
43
- onClick={() => setIsOpen(!isOpen)}
44
- >
45
- <span className='andy-faq-item__question-text'>{question}</span>
46
- <svg
47
- className='andy-faq-item__chevron'
48
- width='16'
49
- height='16'
50
- viewBox='0 0 24 24'
51
- fill='none'
52
- xmlns='http://www.w3.org/2000/svg'
53
- >
54
- <path
55
- d='M6 9l6 6 6-6'
56
- stroke='currentColor'
57
- strokeWidth='2'
58
- strokeLinecap='round'
59
- strokeLinejoin='round'
60
- />
61
- </svg>
62
- </button>
63
- <div className='andy-faq-item__answer'>
64
- <div className='andy-faq-item__answer-content'>{answer}</div>
65
- </div>
66
- </div>
67
- )
68
- }
69
-
70
- const SalesAndyChat: React.FC<SalesAndyChatProps> = ({
71
- embedId,
72
- tooltip,
73
- name,
74
- headline,
75
- description,
76
- welcomeMessage,
77
- bubbleLabel,
78
- lang,
79
- predefinedQuestions,
80
- theme,
81
- orientation,
82
- brandColor,
83
- bubbleStyle,
84
- useBrandColor,
85
- logoUrl,
86
- previewMode = false,
87
- initialOpen = false
88
- }) => {
89
- const [config, setConfig] = useState<ChatbotConfig | null>(null)
90
- // The visitor's language wins over the Agent's own default: a Spanish Agent
91
- // embedded on an English page must still render English chrome.
92
- const uiLang = lang || config?.defaultLang || 'en'
93
- const strings = getUiStrings(uiLang)
94
- const activeQuestions = predefinedQuestions?.length ? predefinedQuestions : config?.predefinedQuestions
95
- const [isOpen, setIsOpen] = useState(initialOpen)
96
- const [messages, setMessages] = useState<Message[]>([])
97
- const [isLoading, setIsLoading] = useState(false)
98
- const [replyStarted, setReplyStarted] = useState(false)
99
- const activeReplyRef = useRef<AbortController | null>(null)
100
- const [sessionId, setSessionId] = useState(() => generateSessionId())
101
- const handoffRef = useRef<ReturnType<typeof createBrowserHandoff>>(null)
102
- const pendingHandoffIdsRef = useRef<string[]>([])
103
- const [shadowReady, setShadowReady] = useState(false)
104
- const [showPredefinedQuestions, setShowPredefinedQuestions] = useState(true)
105
- const [userInfoCollected, setUserInfoCollected] = useState(false)
106
- const [isCollectingUserInfo, setIsCollectingUserInfo] = useState(false)
107
- const [pendingMessage, setPendingMessage] = useState<string | null>(null)
108
- const [showWelcomePopup, setShowWelcomePopup] = useState(false)
109
-
110
- const containerRef = useRef<HTMLDivElement>(null)
111
- const shadowRef = useRef<ShadowRoot | null>(null)
112
- const portalRef = useRef<HTMLDivElement | null>(null)
113
- const hasOpenedRef = useRef(false)
114
- const messagesContainerRef = useRef<HTMLDivElement>(null)
115
- const welcomePopupShownRef = useRef(false)
116
- const welcomePopupTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
117
-
118
- useEffect(() => {
119
- if (!config?.bootstrap || !config.id) return
120
- handoffRef.current?.stop()
121
- const handoff = createBrowserHandoff(config.id, config.bootstrap, projection => {
122
- if (projection.offline) return
123
- const paused = projection.control === 'requested' || projection.control === 'human'
124
- if (paused) activeReplyRef.current?.abort()
125
- else if (activeReplyRef.current) return
126
- setSessionId(projection.identity.sessionId)
127
- pendingHandoffIdsRef.current = projection.pendingProjectionIds || []
128
- const projectedMessages = projection.messages.map(message => ({ id: message.id, content: message.content, sender: message.role === 'user' ? 'user' as const : 'bot' as const, timestamp: new Date(message.createdAt).toISOString(), isHuman: message.sentByHuman }))
129
- // An empty agent projection does not mean the client-only greeting is gone.
130
- if (projection.control === 'agent' && projectedMessages.length === 0) return
131
- setMessages(projection.control === 'requested' || projection.control === 'human'
132
- ? [...projectedMessages, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }]
133
- : projectedMessages)
134
- })
135
- handoffRef.current = handoff
136
- if (handoff) setSessionId(handoff.identity.sessionId)
137
- return () => {
138
- activeReplyRef.current?.abort()
139
- handoffRef.current?.stop()
140
- handoffRef.current = null
141
- }
142
- }, [config?.bootstrap, config?.id])
143
-
144
- useEffect(() => {
145
- pendingHandoffIdsRef.current.forEach(messageId => void handoffRef.current?.markRendered(messageId))
146
- }, [messages])
147
-
148
- // Create shadow DOM container on mount
149
- useEffect(() => {
150
- if (!containerRef.current) return
151
-
152
- let shadow = shadowRef.current
153
- if (!shadow) {
154
- if (containerRef.current.shadowRoot) {
155
- shadow = containerRef.current.shadowRoot
156
- } else {
157
- shadow = containerRef.current.attachShadow({ mode: 'open' })
158
- }
159
- shadowRef.current = shadow
160
- }
161
-
162
- let styleElement = shadow.querySelector('style')
163
- if (!styleElement) {
164
- styleElement = document.createElement('style')
165
- shadow.appendChild(styleElement)
166
- }
167
-
168
- styleElement.textContent = getFullStylesheet({
169
- color: config?.brandColor,
170
- useBrandColor: config?.useBrandColor ?? true,
171
- theme: config?.theme || 'light',
172
- orientation: config?.orientation || 'right'
173
- })
174
-
175
- if (containerRef.current) {
176
- const hostClasses = getHostClasses({
177
- theme: config?.theme || 'light',
178
- orientation: config?.orientation || 'right'
179
- })
180
- containerRef.current.className = hostClasses
181
- }
182
-
183
- if (!portalRef.current) {
184
- let portalTarget = shadow.querySelector('.andy-react-portal') as HTMLDivElement | null
185
- if (!portalTarget) {
186
- portalTarget = document.createElement('div')
187
- portalTarget.className = 'andy-react-portal'
188
- shadow.appendChild(portalTarget)
189
- }
190
- portalRef.current = portalTarget
191
- }
192
-
193
- setShadowReady(true)
194
-
195
- return () => {
196
- shadowRef.current = null
197
- portalRef.current = null
198
- setShadowReady(false)
199
- }
200
- }, [])
201
-
202
- // Update styles when config changes
203
- useEffect(() => {
204
- if (!shadowRef.current || !config) return
205
-
206
- const styleElement = shadowRef.current.querySelector('style')
207
- if (styleElement) {
208
- styleElement.textContent = getFullStylesheet({
209
- color: config.brandColor,
210
- useBrandColor: config.useBrandColor ?? true,
211
- theme: config.theme || 'light',
212
- orientation: config.orientation || 'right'
213
- })
214
- }
215
-
216
- if (containerRef.current) {
217
- const hostClasses = getHostClasses({
218
- theme: config.theme || 'light',
219
- orientation: config.orientation || 'right'
220
- })
221
- containerRef.current.className = hostClasses
222
- }
223
- }, [config])
224
-
225
- // Close button handler
226
- useEffect(() => {
227
- if (!shadowReady || !shadowRef.current || !isOpen) return
228
-
229
- const timer = setTimeout(() => {
230
- const closeButton = shadowRef.current?.querySelector('#andy-close-button') as HTMLButtonElement | null
231
- if (closeButton) {
232
- const handleCloseClick = () => {
233
- setIsOpen(false)
234
- }
235
- closeButton.addEventListener('click', handleCloseClick)
236
- ;(closeButton as any)._closeHandler = handleCloseClick
237
- }
238
- }, 100)
239
-
240
- return () => {
241
- clearTimeout(timer)
242
- if (shadowRef.current) {
243
- const closeButton = shadowRef.current.querySelector('#andy-close-button') as HTMLButtonElement | null
244
- if (closeButton && (closeButton as any)._closeHandler) {
245
- closeButton.removeEventListener('click', (closeButton as any)._closeHandler)
246
- delete (closeButton as any)._closeHandler
247
- }
248
- }
249
- }
250
- }, [isOpen, shadowReady])
251
-
252
- // Click outside to close
253
- useEffect(() => {
254
- if (!shadowReady || !shadowRef.current) return
255
- if (previewMode) return
256
-
257
- const handleDocumentClick = (event: MouseEvent) => {
258
- if (isMobileDevice()) return
259
- if (!isOpen) return
260
-
261
- const selection = window.getSelection()
262
- if (selection && selection.toString().length > 0) {
263
- return
264
- }
265
-
266
- const chatbotContainer = containerRef.current
267
- let isClickInsideChatbot = false
268
-
269
- if (chatbotContainer && chatbotContainer.contains(event.target as Node)) {
270
- isClickInsideChatbot = true
271
- }
272
-
273
- if (event.composedPath) {
274
- const path = event.composedPath()
275
- if (chatbotContainer && path.includes(chatbotContainer)) {
276
- isClickInsideChatbot = true
277
- }
278
- }
279
-
280
- if (event.target === chatbotContainer) {
281
- isClickInsideChatbot = true
282
- }
283
-
284
- if (!isClickInsideChatbot) {
285
- setIsOpen(false)
286
- }
287
- }
288
-
289
- const handleShadowClick = (event: Event) => {
290
- const target = event.target as Element
291
- if (
292
- target.closest('#andy-close-button') ||
293
- target.closest('#andy-send-button') ||
294
- target.closest('.andy-predefined-question')
295
- ) {
296
- return
297
- }
298
- if (target.closest('.andy-chat-window')) {
299
- event.stopPropagation()
300
- }
301
- }
302
-
303
- document.addEventListener('click', handleDocumentClick)
304
- shadowRef.current.addEventListener('click', handleShadowClick, true)
305
-
306
- return () => {
307
- document.removeEventListener('click', handleDocumentClick)
308
- if (shadowRef.current) {
309
- shadowRef.current.removeEventListener('click', handleShadowClick, true)
310
- }
311
- }
312
- }, [isOpen, shadowReady, previewMode])
313
-
314
- // Initialize config
315
- useEffect(() => {
316
- if (previewMode) {
317
- const previewConfig: ChatbotConfig = {
318
- id: embedId || 'preview',
319
- name: name || 'Andy Bot',
320
- theme: theme || 'light',
321
- logoUrl: logoUrl || '',
322
- headline: headline || '',
323
- brandColor: brandColor || '#3B82F6',
324
- description: description || '',
325
- orientation: orientation || 'right',
326
- bubbleStyle: bubbleStyle || 'icon',
327
- bubbleLabel: bubbleLabel || null,
328
- useBrandColor: useBrandColor !== undefined ? useBrandColor : true,
329
- defaultLang: uiLang,
330
- showBranding: true,
331
- showWelcomePopup: { enabled: false, seconds: 3, message: '' },
332
- collectUserInfo: { enabled: false, message: '' },
333
- welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?',
334
- predefinedQuestions: predefinedQuestions || [],
335
- faqs: []
336
- }
337
- setConfig(previewConfig)
338
- return
339
- }
340
-
341
- if (!embedId || !isValidEmbedId(embedId)) {
342
- setConfig(null)
343
- console.warn('AndyChat: Invalid or missing embedId')
344
- return
345
- }
346
-
347
- let cancelled = false
348
-
349
- const loadConfig = async () => {
350
- try {
351
- setConfig(null)
352
- const rawConfig = await fetchChatbotConfig(embedId)
353
-
354
- const configWithOverrides: ChatbotConfig = {
355
- ...rawConfig,
356
- ...(name && { name }),
357
- ...(headline && { headline }),
358
- ...(description && { description }),
359
- ...(welcomeMessage && { welcomeMessage }),
360
- ...(theme && { theme }),
361
- ...(orientation && { orientation }),
362
- ...(brandColor && { brandColor }),
363
- ...(bubbleStyle && { bubbleStyle }),
364
- ...(bubbleLabel && { bubbleLabel }),
365
- ...(useBrandColor !== undefined && { useBrandColor }),
366
- ...(logoUrl && { logoUrl })
367
- }
368
-
369
- if (!cancelled) setConfig(configWithOverrides)
370
- } catch {
371
- if (!cancelled) {
372
- setConfig(null)
373
- console.error('AndyChat: Failed to load configuration.')
374
- }
375
- }
376
- }
377
-
378
- loadConfig()
379
-
380
- return () => {
381
- cancelled = true
382
- }
383
- }, [
384
- embedId,
385
- name,
386
- headline,
387
- description,
388
- welcomeMessage,
389
- previewMode,
390
- theme,
391
- orientation,
392
- brandColor,
393
- bubbleStyle,
394
- bubbleLabel,
395
- useBrandColor,
396
- logoUrl
397
- ])
398
-
399
- // Welcome popup timer
400
- useEffect(() => {
401
- if (welcomePopupTimeoutRef.current) {
402
- clearTimeout(welcomePopupTimeoutRef.current)
403
- welcomePopupTimeoutRef.current = null
404
- }
405
-
406
- if (config?.showWelcomePopup?.enabled && !welcomePopupShownRef.current && !isOpen) {
407
- const delaySeconds = config.showWelcomePopup.seconds ?? 3
408
- welcomePopupTimeoutRef.current = setTimeout(() => {
409
- if (!isOpen) {
410
- setShowWelcomePopup(true)
411
- welcomePopupShownRef.current = true
412
- }
413
- }, delaySeconds * 1000)
414
- }
415
-
416
- if (isOpen && showWelcomePopup) {
417
- setShowWelcomePopup(false)
418
- }
419
-
420
- return () => {
421
- if (welcomePopupTimeoutRef.current) {
422
- clearTimeout(welcomePopupTimeoutRef.current)
423
- }
424
- }
425
- }, [config, isOpen, showWelcomePopup])
426
-
427
- // Add welcome message on first open
428
- useEffect(() => {
429
- if (isOpen && !hasOpenedRef.current && config?.welcomeMessage) {
430
- hasOpenedRef.current = true
431
- const greeting = createGreetingMessage(config)
432
- if (greeting) {
433
- setMessages([
434
- {
435
- id: Date.now(),
436
- content: greeting,
437
- sender: 'bot',
438
- timestamp: new Date().toISOString()
439
- }
440
- ])
441
- }
442
- }
443
- }, [isOpen, config])
444
-
445
- // Auto-scroll
446
- useEffect(() => {
447
- if (messagesContainerRef.current) {
448
- messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight
449
- }
450
- }, [messages, isLoading])
451
-
452
- // Form submit handler
453
- useEffect(() => {
454
- if (isCollectingUserInfo && shadowRef.current) {
455
- const timer = setTimeout(() => {
456
- const form = shadowRef.current?.querySelector('#andy-user-info-form') as HTMLFormElement | null
457
- if (form) {
458
- const handleSubmit = (e: Event) => {
459
- handleUserInfoSubmit(e as unknown as FormEvent<HTMLFormElement>)
460
- }
461
- form.addEventListener('submit', handleSubmit)
462
- ;(form as any)._submitHandler = handleSubmit
463
- }
464
- }, 100)
465
-
466
- return () => {
467
- clearTimeout(timer)
468
- if (shadowRef.current) {
469
- const form = shadowRef.current.querySelector('#andy-user-info-form') as HTMLFormElement | null
470
- if (form && (form as any)._submitHandler) {
471
- form.removeEventListener('submit', (form as any)._submitHandler)
472
- delete (form as any)._submitHandler
473
- }
474
- }
475
- }
476
- }
477
- }, [isCollectingUserInfo, messages])
478
-
479
- const handleNewChat = () => {
480
- activeReplyRef.current?.abort()
481
- activeReplyRef.current = null
482
- setIsLoading(false)
483
- setReplyStarted(false)
484
- handoffRef.current = handoffRef.current?.restart() || null
485
- pendingHandoffIdsRef.current = []
486
- setSessionId(handoffRef.current?.identity.sessionId || generateSessionId())
487
-
488
- if (config?.welcomeMessage) {
489
- const greeting = createGreetingMessage(config)
490
- if (greeting) {
491
- setMessages([
492
- {
493
- id: Date.now(),
494
- content: greeting,
495
- sender: 'bot',
496
- timestamp: new Date().toISOString()
497
- }
498
- ])
499
- }
500
- } else {
501
- setMessages([])
502
- }
503
-
504
- if (activeQuestions && activeQuestions.length > 0) {
505
- setShowPredefinedQuestions(true)
506
- }
507
- }
508
-
509
- const sendUserMessage = useCallback(
510
- async (rawMessageText: string, options: { hidePredefined?: boolean } = {}) => {
511
- const messageText = typeof rawMessageText === 'string' ? rawMessageText.trim() : ''
512
- if (!messageText || activeReplyRef.current) {
513
- return
514
- }
515
-
516
- const { hidePredefined = true } = options
517
- const timestamp = Date.now()
518
- const controller = new AbortController()
519
- activeReplyRef.current = controller
520
-
521
- const userMessage: Message = {
522
- id: timestamp,
523
- content: messageText,
524
- sender: 'user',
525
- timestamp: new Date().toISOString()
526
- }
527
- handoffRef.current?.recordVisitorMessage(messageText)
528
- setMessages(prev => [...prev, userMessage])
529
-
530
- if (hidePredefined) {
531
- setShowPredefinedQuestions(false)
532
- }
533
-
534
- try {
535
- setIsLoading(true)
536
- setReplyStarted(false)
537
- const showReply = (content: string, isStreaming: boolean) => {
538
- if (controller.signal.aborted) return
539
- setReplyStarted(true)
540
- const reply: Message = { id: timestamp + 1, content, sender: 'bot', timestamp: new Date(timestamp).toISOString(), isStreaming }
541
- setMessages(prev => prev.some(message => message.id === reply.id)
542
- ? prev.map(message => message.id === reply.id ? reply : message)
543
- : [...prev, reply])
544
- }
545
- const response = await sendMessageToAPI(embedId!, sessionId, messageText, {
546
- signal: controller.signal,
547
- onText: text => showReply(text, true)
548
- })
549
- if (controller.signal.aborted) return
550
- if (response.status === 'waiting') {
551
- setMessages(prev => {
552
- const visible = prev.filter(message => message.id !== timestamp + 1)
553
- return visible.some(message => message.isWaiting) ? visible : [...visible, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }]
554
- })
555
- return
556
- }
557
-
558
- showReply(response.text, false)
559
-
560
- if (config?.collectUserInfo?.enabled && !userInfoCollected && !isCollectingUserInfo) {
561
- setPendingMessage(messageText)
562
- setIsCollectingUserInfo(true)
563
-
564
- const formMessage: Message = {
565
- id: timestamp + 2,
566
- content: 'USER_INFO_FORM',
567
- sender: 'bot',
568
- isForm: true,
569
- timestamp: new Date().toISOString()
570
- }
571
- setMessages(prev => [...prev, formMessage])
572
- }
573
- } catch {
574
- if (controller.signal.aborted) return
575
- console.error('AndyChat: Message request failed.')
576
- const errorMessage: Message = {
577
- id: timestamp + 3,
578
- content: strings.sendFailure,
579
- sender: 'bot',
580
- timestamp: new Date().toISOString(),
581
- isError: true
582
- }
583
- setMessages(prev => [...prev.filter(message => message.id !== timestamp + 1), errorMessage])
584
- } finally {
585
- if (activeReplyRef.current === controller) {
586
- activeReplyRef.current = null
587
- setIsLoading(false)
588
- setReplyStarted(false)
589
- }
590
- }
591
- },
592
- [config, embedId, sessionId, userInfoCollected, isCollectingUserInfo]
593
- )
594
-
595
- const handleSendMessage = useCallback(
596
- (messageText: string) => {
597
- return sendUserMessage(messageText)
598
- },
599
- [sendUserMessage]
600
- )
601
-
602
- const handlePredefinedQuestion = useCallback(
603
- (question: string) => {
604
- if (isCollectingUserInfo || isLoading) {
605
- return
606
- }
607
-
608
- const trimmedQuestion = typeof question === 'string' ? question.trim() : ''
609
- if (!trimmedQuestion) {
610
- return
611
- }
612
-
613
- const input = shadowRef.current?.getElementById('andy-message-input') as HTMLInputElement | null
614
- if (input) {
615
- input.value = ''
616
- }
617
-
618
- sendUserMessage(trimmedQuestion)
619
- },
620
- [isCollectingUserInfo, isLoading, sendUserMessage]
621
- )
622
-
623
- // Predefined questions click handler
624
- useEffect(() => {
625
- if (!shadowRef.current) {
626
- return
627
- }
628
-
629
- const shadowRoot = shadowRef.current
630
- const captureClick = (event: Event) => {
631
- const composedTarget = event.composedPath ? event.composedPath()[0] : event.target
632
- const elementTarget = composedTarget instanceof Element ? composedTarget : null
633
- const button =
634
- elementTarget?.closest?.('.andy-predefined-question') ||
635
- shadowRoot.querySelector('.andy-predefined-question:focus')
636
- if (!button) {
637
- return
638
- }
639
- if ((button as HTMLButtonElement).disabled) {
640
- return
641
- }
642
- const question = button.getAttribute('data-question') || button.textContent || ''
643
- handlePredefinedQuestion(question)
644
- }
645
-
646
- shadowRoot.addEventListener('click', captureClick)
647
- return () => {
648
- shadowRoot.removeEventListener('click', captureClick)
649
- }
650
- }, [handlePredefinedQuestion, shadowReady])
651
-
652
- const handleUserInfoSubmit = async (e: FormEvent<HTMLFormElement>) => {
653
- e.preventDefault()
654
-
655
- const form = e.target as HTMLFormElement
656
- const nameInput = form.querySelector('#andy-user-name') as HTMLInputElement | null
657
- const emailInput = form.querySelector('#andy-user-email') as HTMLInputElement | null
658
- const userName = nameInput?.value
659
- const email = emailInput?.value
660
-
661
- if (!userName || !email) return
662
-
663
- setMessages(prev => prev.filter(msg => !msg.isForm))
664
-
665
- const loadingMessage: Message = {
666
- id: Date.now(),
667
- content: 'LOADING',
668
- sender: 'bot',
669
- isLoading: true,
670
- timestamp: new Date().toISOString()
671
- }
672
- setMessages(prev => [...prev, loadingMessage])
673
-
674
- try {
675
- await submitUserInfo(embedId!, sessionId, { name: userName, email })
676
-
677
- setUserInfoCollected(true)
678
- setIsCollectingUserInfo(false)
679
-
680
- setMessages(prev => prev.filter(msg => !msg.isLoading))
681
- setPendingMessage(null)
682
- } catch (error) {
683
- console.error('Error submitting user info:', error)
684
- setMessages(prev => prev.filter(msg => !msg.isLoading))
685
- const errorMessage: Message = {
686
- id: Date.now() + 1,
687
- content: 'Sorry, there was an error submitting your information. Please try again.',
688
- sender: 'bot',
689
- timestamp: new Date().toISOString()
690
- }
691
- setMessages(prev => [...prev, errorMessage])
692
-
693
- setIsCollectingUserInfo(false)
694
- }
695
- }
696
-
697
- if (!previewMode && (!embedId || !isValidEmbedId(embedId))) {
698
- return null
699
- }
700
-
701
- // Render bubble
702
- useEffect(() => {
703
- if (!shadowRef.current || !shadowReady) return
704
- if (!config) {
705
- shadowRef.current.querySelector('.andy-chat-bubble')?.remove()
706
- return
707
- }
708
-
709
- let bubble = shadowRef.current.querySelector('.andy-chat-bubble') as HTMLButtonElement | null
710
- if (!bubble) {
711
- bubble = document.createElement('button')
712
- bubble.type = 'button'
713
- bubble.className = 'andy-chat-bubble'
714
- bubble.setAttribute('aria-label', strings.openChat)
715
-
716
- const tooltipHTML = tooltip
717
- ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${escapeHtml(tooltip)}</span>`
718
- : ''
719
- bubble.innerHTML = `${CHAT_BUBBLE_DEFAULT_ICON}${tooltipHTML}`
720
- shadowRef.current.appendChild(bubble)
721
-
722
- bubble.addEventListener('click', () => setIsOpen(prev => !prev))
723
- }
724
-
725
- const isPillStyle = config?.bubbleStyle === 'pill'
726
- const displayLabel =
727
- bubbleLabel || config?.bubbleLabel || (isPillStyle ? getDefaultBubbleLabel(uiLang) : null)
728
- const bubbleLogoUrl = config?.logoUrl || CHAT_BUBBLE_DEFAULT_ICON_URL
729
-
730
- if (bubble) {
731
- bubble.setAttribute('aria-label', isOpen ? strings.closeChat : strings.openChat)
732
- bubble.className = 'andy-chat-bubble'
733
- if (isPillStyle) {
734
- bubble.classList.add('andy-chat-bubble--pill')
735
- }
736
- if (isOpen) {
737
- bubble.classList.add('andy-chat-bubble--open')
738
- }
739
- if (config?.theme === 'dark') {
740
- bubble.classList.add('andy-chat-bubble--theme-dark')
741
- }
742
- if (config) {
743
- bubble.classList.add('andy-chat-bubble--loaded')
744
- }
745
-
746
- const tooltipHTML =
747
- !isOpen && tooltip && !isPillStyle
748
- ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${escapeHtml(tooltip)}</span>`
749
- : ''
750
-
751
- const configUseBrandColor = config?.useBrandColor ?? true
752
- const isDarkTheme = config?.theme === 'dark'
753
- const needsWhiteIcon = configUseBrandColor || isDarkTheme
754
- const iconFilterStyle = needsWhiteIcon ? '' : 'filter: none;'
755
- const darkModeIconStyle =
756
- !configUseBrandColor && isDarkTheme ? 'filter: brightness(0) invert(1);' : iconFilterStyle
757
-
758
- if (isOpen) {
759
- const chevronWithStyle = SVG_ICONS.chevronDown.replace(
760
- 'class="andy-chat-bubble__icon"',
761
- `class="andy-chat-bubble__icon" style="${darkModeIconStyle}"`
762
- )
763
- bubble.innerHTML = chevronWithStyle
764
- } else if (isPillStyle && displayLabel) {
765
- bubble.innerHTML = `
766
- <span class="andy-chat-bubble__label">${escapeHtml(displayLabel)}</span>
767
- <img src="${escapeHtml(bubbleLogoUrl)}" alt="Chat" class="andy-chat-bubble__icon--pill" style="${darkModeIconStyle}" />
768
- `
769
- } else {
770
- if (config?.logoUrl) {
771
- bubble.innerHTML = `<img src="${escapeHtml(config.logoUrl)}" alt="Chatbot Logo" class="andy-chat-bubble__logo" style="${darkModeIconStyle}" />${tooltipHTML}`
772
- } else {
773
- const defaultIconWithStyle = CHAT_BUBBLE_DEFAULT_ICON.replace(
774
- 'class="andy-chat-bubble__icon"',
775
- `class="andy-chat-bubble__icon" style="${darkModeIconStyle}"`
776
- )
777
- bubble.innerHTML = `${defaultIconWithStyle}${tooltipHTML}`
778
- }
779
- }
780
- }
781
- }, [shadowReady, config, isOpen, tooltip, bubbleLabel])
782
-
783
- return (
784
- <div ref={containerRef} id='andy-chatbot-container'>
785
- {portalRef.current && shadowReady && config && (
786
- <>
787
- {ReactDOM.createPortal(
788
- <div className={`andy-chat-window ${isOpen ? 'andy-chat-window--open' : ''}`}>
789
- <header className='andy-header'>
790
- <button
791
- type='button'
792
- className='andy-header__back'
793
- id='andy-back-button'
794
- aria-label={strings.goBack}
795
- onClick={() => setIsOpen(false)}
796
- >
797
- <svg
798
- className='andy-header__back-icon'
799
- width='20'
800
- height='20'
801
- viewBox='0 0 24 24'
802
- fill='none'
803
- >
804
- <path
805
- d='M15 18l-6-6 6-6'
806
- stroke='currentColor'
807
- strokeWidth='2'
808
- strokeLinecap='round'
809
- strokeLinejoin='round'
810
- />
811
- </svg>
812
- </button>
813
-
814
- <div className='andy-header__bot-info'>
815
- <div className='andy-header__avatar'>
816
- {config?.logoUrl ? (
817
- <img src={config.logoUrl} alt='Bot Avatar' className='andy-header__avatar-image' />
818
- ) : (
819
- <img
820
- src={CHAT_BUBBLE_DEFAULT_ICON_URL}
821
- alt='Bot Avatar'
822
- className='andy-header__avatar-icon andy-header__avatar-icon--default'
823
- />
824
- )}
825
- </div>
826
- <div className='andy-header__text'>
827
- <h1 className='andy-header__name'>{config?.name || 'Andy bot'}</h1>
828
- {config?.headline && (
829
- <p className='andy-header__subtitle' title={config.headline}>
830
- {config.headline}
831
- </p>
832
- )}
833
- </div>
834
- </div>
835
-
836
- <button
837
- type='button'
838
- className='andy-header__close'
839
- id='andy-close-button'
840
- aria-label={strings.closeChat}
841
- onClick={() => setIsOpen(false)}
842
- >
843
- <svg
844
- className='andy-header__close-icon'
845
- width='20'
846
- height='20'
847
- viewBox='0 0 24 24'
848
- fill='none'
849
- >
850
- <path
851
- d='M18 6L6 18M6 6l12 12'
852
- stroke='currentColor'
853
- strokeWidth='2'
854
- strokeLinecap='round'
855
- strokeLinejoin='round'
856
- />
857
- </svg>
858
- </button>
859
- </header>
860
-
861
- <div className='andy-chat-window__messages' id='andy-messages' ref={messagesContainerRef}>
862
- {config?.description && (
863
- <div className='andy-chat-subtitle'>
864
- <p className='andy-chat-subtitle__text'>{config.description}</p>
865
- </div>
866
- )}
867
- {messages.map(message => {
868
- if (message.isForm) {
869
- return (
870
- <div key={message.id} className='andy-message-container andy-message-container--form'>
871
- <div className='andy-avatar'>
872
- <svg
873
- className='andy-avatar__icon'
874
- width='24'
875
- height='24'
876
- viewBox='0 0 24 24'
877
- fill='none'
878
- xmlns='http://www.w3.org/2000/svg'
879
- >
880
- <path
881
- d='M20 2H4C2.9 2 2 2.9 2 4V22L6 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2Z'
882
- fill='currentColor'
883
- />
884
- </svg>
885
- </div>
886
- <div className='andy-message andy-message--bot andy-message--form'>
887
- <div className='andy-user-info-form'>
888
- <div className='andy-user-info-form__content'>
889
- <h3 className='andy-user-info-form__title'>
890
- {config?.collectUserInfo?.message ||
891
- 'Please provide your information to continue:'}
892
- </h3>
893
- <form className='andy-user-info-form__form' id='andy-user-info-form'>
894
- <div className='andy-form-group'>
895
- <label htmlFor='andy-user-name' className='andy-form-group__label'>
896
- Name
897
- </label>
898
- <input
899
- type='text'
900
- id='andy-user-name'
901
- className='andy-form-group__input'
902
- placeholder='Your Name'
903
- required
904
- />
905
- </div>
906
- <div className='andy-form-group'>
907
- <label htmlFor='andy-user-email' className='andy-form-group__label'>
908
- Email
909
- </label>
910
- <input
911
- type='email'
912
- id='andy-user-email'
913
- className='andy-form-group__input'
914
- placeholder='Your Email'
915
- required
916
- />
917
- </div>
918
- <button
919
- type='submit'
920
- className='andy-button andy-button--primary andy-user-info-form__submit'
921
- >
922
- Submit
923
- </button>
924
- </form>
925
- </div>
926
- </div>
927
- </div>
928
- </div>
929
- )
930
- }
931
-
932
- if (message.isLoading) {
933
- return (
934
- <div
935
- key={message.id}
936
- className='andy-message-container andy-message-container--bot andy-message-container--loading'
937
- >
938
- <div className='andy-message andy-message--bot'>
939
- <div className='andy-typing-indicator'>
940
- <div className='andy-typing-indicator__dot'></div>
941
- <div className='andy-typing-indicator__dot'></div>
942
- <div className='andy-typing-indicator__dot'></div>
943
- </div>
944
- </div>
945
- </div>
946
- )
947
- }
948
-
949
- return (
950
- <div
951
- key={message.id}
952
- className={`andy-message-container andy-message-container--${message.sender} ${message.isWaiting ? 'andy-message-container--waiting' : ''}`}
953
- >
954
- {message.sender === 'bot' && (
955
- <div
956
- className={`andy-message andy-message--bot ${message.isHuman ? 'andy-message--operator' : ''} ${message.isWaiting ? 'andy-message--waiting' : ''} ${message.isError ? 'andy-message--error' : ''}`}
957
- >
958
- <div className='andy-bot-header'>
959
- <div className='andy-bot-header__icon'>
960
- {config?.logoUrl ? (
961
- <img
962
- src={config.logoUrl}
963
- alt='Bot Avatar'
964
- className='andy-bot-header__icon-image'
965
- />
966
- ) : (
967
- <img
968
- src={CHAT_BUBBLE_DEFAULT_ICON_URL}
969
- alt='Bot Avatar'
970
- className='andy-bot-header__icon-image andy-bot-header__icon-image--default'
971
- />
972
- )}
973
- </div>
974
- <span className='andy-bot-header__name'>
975
- {message.isHuman ? strings.teamName : config?.name || 'Andy bot'}
976
- </span>
977
- </div>
978
- <div className='andy-message__content'>
979
- {message.isStreaming ? <div className='andy-message__stream'>{message.content}</div> : <div dangerouslySetInnerHTML={{ __html: parseMarkdown(message.content) }} />}
980
- </div>
981
- </div>
982
- )}
983
- {message.sender === 'user' && (
984
- <div className='andy-message andy-message--user'>
985
- <div className='andy-message__content'>
986
- <p>{message.content}</p>
987
- </div>
988
- </div>
989
- )}
990
- </div>
991
- )
992
- })}
993
-
994
- {isLoading && !replyStarted && (
995
- <div className='andy-message-container andy-message-container--bot andy-message-container--typing'>
996
- <div className='andy-message andy-message--bot andy-message--typing'>
997
- <div className='andy-typing-indicator'>
998
- <div className='andy-typing-indicator__dot'></div>
999
- <div className='andy-typing-indicator__dot'></div>
1000
- <div className='andy-typing-indicator__dot'></div>
1001
- </div>
1002
- </div>
1003
- </div>
1004
- )}
1005
- </div>
1006
-
1007
- <div className='andy-chat-window__bottom'>
1008
- {activeQuestions &&
1009
- activeQuestions.length > 0 &&
1010
- showPredefinedQuestions && (
1011
- <div className='andy-predefined-questions' id='andy-predefined-questions'>
1012
- <div className='andy-predefined-questions__header'>
1013
- <p className='andy-predefined-questions__title'>{strings.predefinedQuestionsTitle}</p>
1014
- </div>
1015
- <div className='andy-predefined-questions__list'>
1016
- {activeQuestions.map((q, index) => (
1017
- <button
1018
- key={index}
1019
- type='button'
1020
- className='andy-predefined-question'
1021
- data-question={q.question}
1022
- disabled={isCollectingUserInfo || isLoading || !config}
1023
- >
1024
- <span className='andy-predefined-question__icon'>💬</span>
1025
- <span className='andy-predefined-question__text'>{q.question}</span>
1026
- </button>
1027
- ))}
1028
- </div>
1029
- </div>
1030
- )}
1031
-
1032
- <div className='andy-chat-window__input'>
1033
- <div className='andy-input-container'>
1034
- <div className='andy-input'>
1035
- <input
1036
- type='text'
1037
- className='andy-input__field'
1038
- id='andy-message-input'
1039
- placeholder={config ? strings.inputPlaceholder : '...'}
1040
- aria-label={strings.messageInput}
1041
- onKeyDown={e => {
1042
- if (e.key === 'Enter' && activeReplyRef.current) { e.preventDefault(); return }
1043
- if (e.key === 'Enter' && (e.target as HTMLInputElement).value.trim()) {
1044
- handleSendMessage((e.target as HTMLInputElement).value)
1045
- ;(e.target as HTMLInputElement).value = ''
1046
- }
1047
- }}
1048
- disabled={isCollectingUserInfo || !config}
1049
- />
1050
- <button
1051
- type='button'
1052
- className='andy-input__button'
1053
- id='andy-send-button'
1054
- aria-label={strings.sendMessage}
1055
- onClick={() => {
1056
- if (activeReplyRef.current) return
1057
- const input = shadowRef.current?.getElementById(
1058
- 'andy-message-input'
1059
- ) as HTMLInputElement | null
1060
- if (input && input.value.trim()) {
1061
- handleSendMessage(input.value)
1062
- input.value = ''
1063
- }
1064
- }}
1065
- disabled={isCollectingUserInfo || isLoading || !config}
1066
- >
1067
- <svg
1068
- className='andy-input__button-icon'
1069
- width='18'
1070
- height='18'
1071
- viewBox='0 0 24 24'
1072
- fill='currentColor'
1073
- xmlns='http://www.w3.org/2000/svg'
1074
- >
1075
- <path d='M2.01 21L23 12 2.01 3 2 10l15 2-15 2z' />
1076
- </svg>
1077
- </button>
1078
- </div>
1079
- </div>
1080
- </div>
1081
-
1082
- {config?.showBranding !== false && (
1083
- <footer className='andy-chat-window__footer'>
1084
- <a
1085
- href={getFooterUrl()}
1086
- target='_blank'
1087
- rel='noopener noreferrer'
1088
- className='andy-chat-window__footer-link'
1089
- >
1090
- <span className='andy-chat-window__footer-text'>{FOOTER_TEXT}</span>
1091
- </a>
1092
- </footer>
1093
- )}
1094
- </div>
1095
- </div>,
1096
- portalRef.current
1097
- )}
1098
- {showWelcomePopup &&
1099
- !isOpen &&
1100
- ReactDOM.createPortal(
1101
- <div
1102
- className={`andy-welcome-popup ${config?.orientation === 'left' ? 'andychat-popup-left' : ''}`}
1103
- style={{ display: 'block' }}
1104
- role='dialog'
1105
- aria-label={strings.welcomeMessage}
1106
- >
1107
- <div className='andy-welcome-popup__content'>
1108
- <div className='andy-welcome-popup__message'>
1109
- {config?.showWelcomePopup?.message ||
1110
- config?.welcomeMessage ||
1111
- 'Welcome! How can I help you today?'}
1112
- </div>
1113
- <button
1114
- type='button'
1115
- className='andy-welcome-popup__close'
1116
- aria-label={strings.closeWelcomeMessage}
1117
- onClick={() => setShowWelcomePopup(false)}
1118
- >
1119
- <svg
1120
- className='andy-welcome-popup__close-icon'
1121
- width='20'
1122
- height='20'
1123
- viewBox='0 0 24 24'
1124
- fill='none'
1125
- xmlns='http://www.w3.org/2000/svg'
1126
- >
1127
- <path
1128
- d='M18 6L6 18M6 6l12 12'
1129
- stroke='currentColor'
1130
- strokeWidth='2'
1131
- strokeLinecap='round'
1132
- strokeLinejoin='round'
1133
- />
1134
- </svg>
1135
- </button>
1136
- </div>
1137
- <div className='andy-welcome-popup__arrow'></div>
1138
- </div>,
1139
- portalRef.current
1140
- )}
1141
- </>
1142
- )}
1143
- </div>
1144
- )
1145
- }
1146
-
1147
- const AndyChat: React.FC<AndyChatProps> = props => props.mode === 'support'
1148
- ? <SupportChat key={`${props.support.installationId}:${props.support.contextKey}`} {...props} />
1149
- : <SalesAndyChat {...props} />
1150
-
1151
- export default AndyChat