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