andy-widget-react 2.0.5

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