andy-widget-react 2.3.2 → 3.0.0-next.10
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/README.md +77 -57
- package/dist/index.cjs +92 -5214
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +88 -5193
- package/dist/index.esm.js.map +1 -1
- package/dist/types/AndyChat.d.ts +7 -0
- package/dist/types/index.d.ts +3 -10
- package/dist/types/types/index.d.ts +38 -161
- package/package.json +3 -10
- package/dist/types/SupportChat.d.ts +0 -4
- package/dist/types/components/ChatBubble.d.ts +0 -9
- package/dist/types/components/ChatWindow.d.ts +0 -11
- package/dist/types/components/FAQ.d.ts +0 -8
- package/dist/types/components/MessageInput.d.ts +0 -8
- package/dist/types/components/MessageList.d.ts +0 -10
- package/dist/types/components/UserInfoForm.d.ts +0 -10
- package/dist/types/components/WelcomePopup.d.ts +0 -8
- package/dist/types/hooks/index.d.ts +0 -1
- package/dist/types/hooks/useAndyChat.d.ts +0 -8
- package/dist/types/hooks/useSupportChat.d.ts +0 -24
- package/src/AndyChat.tsx +0 -1151
- package/src/SupportChat.tsx +0 -106
- package/src/components/ChatBubble.tsx +0 -48
- package/src/components/ChatWindow.tsx +0 -53
- package/src/components/FAQ.tsx +0 -48
- package/src/components/MessageInput.tsx +0 -83
- package/src/components/MessageList.tsx +0 -124
- package/src/components/UserInfoForm.tsx +0 -186
- package/src/components/WelcomePopup.tsx +0 -70
- package/src/hooks/index.ts +0 -1
- package/src/hooks/useAndyChat.ts +0 -312
- package/src/hooks/useSupportChat.ts +0 -29
- package/src/index.ts +0 -67
- package/src/styles/main.css +0 -14
- package/src/types/index.ts +0 -235
package/src/SupportChat.tsx
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
3
|
-
import { generateDesignTokenVariablesString, getContrastTextColor, getSupportStylesheet, sanitizeUrl, type SupportTicket } from 'andy-widget-core'
|
|
4
|
-
import type { SupportAndyChatProps } from './types'
|
|
5
|
-
import { useSupportChat } from './hooks/useSupportChat'
|
|
6
|
-
|
|
7
|
-
const copy = {
|
|
8
|
-
es: { support: 'Soporte', close: 'Cerrar', back: 'Volver', menu: 'Menú', new: 'Nueva conversación', team: 'Hablar con el equipo', requests: 'Mis solicitudes', placeholder: '¿En qué te podemos ayudar?', send: 'Enviar', subject: 'Asunto', detail: 'Cuéntanos qué ocurrió', notice: 'Se adjuntará esta conversación', consent: 'Al enviar, aceptas adjuntar esta conversación para que el equipo pueda ayudarte.', submit: 'Enviar solicitud', receipt: 'Solicitud enviada', promise: 'El equipo responderá en', businessDay: 'un día hábil', hours: 'horas hábiles', minutes: 'minutos hábiles', empty: 'Todavía no tienes solicitudes.', powered: 'Con tecnología de Andy', loading: 'Cargando…', retry: 'Reintentar', error: 'No pudimos completar la solicitud. Puedes reintentar.', auth: 'Tu sesión de soporte no está disponible. Vuelve a iniciar sesión en esta aplicación.', denied: 'El soporte no está disponible para esta cuenta.', transcript: 'mensajes', transcriptSingular: 'mensaje', reply: 'Escribe tu respuesta', reopen: 'Reabrir', reopening: 'Cuéntanos qué volvió a ocurrir', otherProblem: 'Es otro problema', reopened: 'Reabierto', unread: 'Respuestas sin leer', resolvedReply: 'Esta solicitud está resuelta. Reábrela para continuar con el mismo problema.', open: 'Recibido', in_progress: 'En curso', waiting_customer: 'Espera tu respuesta', resolved: 'Resuelto' },
|
|
9
|
-
en: { support: 'Support', close: 'Close', back: 'Back', menu: 'Menu', new: 'New conversation', team: 'Talk to the team', requests: 'My requests', placeholder: 'How can we help?', send: 'Send', subject: 'Subject', detail: 'Tell us what happened', notice: 'This conversation will be attached', consent: 'By sending, you agree to attach this conversation so the team can help you.', submit: 'Send request', receipt: 'Request sent', promise: 'The team will reply within', businessDay: 'one business day', hours: 'business hours', minutes: 'business minutes', empty: 'You have no requests yet.', powered: 'Powered by Andy', loading: 'Loading…', retry: 'Retry', error: 'We could not complete the request. You can retry.', auth: 'Your support session is unavailable. Sign in again in this application.', denied: 'Support is unavailable for this account.', transcript: 'messages', transcriptSingular: 'message', reply: 'Write your reply', reopen: 'Reopen', reopening: 'Tell us what happened again', otherProblem: 'This is another problem', reopened: 'Reopened', unread: 'Unread replies', resolvedReply: 'This request is resolved. Reopen it to continue with the same problem.', open: 'Received', in_progress: 'In progress', waiting_customer: 'Waiting for your reply', resolved: 'Resolved' }
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** Private Requester UI. Sales bootstrap, visitor identity and storage never enter this renderer. */
|
|
13
|
-
const SupportPanel: React.FC<SupportAndyChatProps> = ({ support, lang, orientation = 'right', initialOpen = false, tooltip }) => {
|
|
14
|
-
const { state, controller } = useSupportChat({ ...support, lang: lang || support.lang })
|
|
15
|
-
const replyLanguage = lang || support.lang || state.config?.defaultLang || 'es'
|
|
16
|
-
const strings = copy[replyLanguage]
|
|
17
|
-
const [open, setOpen] = useState(initialOpen)
|
|
18
|
-
const [menu, setMenu] = useState(false)
|
|
19
|
-
const [draft, setDraft] = useState('')
|
|
20
|
-
const [subject, setSubject] = useState('')
|
|
21
|
-
const [body, setBody] = useState('')
|
|
22
|
-
const [replyDraft, setReplyDraft] = useState('')
|
|
23
|
-
const [reopening, setReopening] = useState(false)
|
|
24
|
-
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
|
|
25
|
-
const host = useRef<HTMLDivElement>(null)
|
|
26
|
-
const messages = useRef<HTMLDivElement>(null)
|
|
27
|
-
const [portal, setPortal] = useState<HTMLDivElement | null>(null)
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
if (!host.current) return
|
|
30
|
-
const shadow = host.current.shadowRoot || host.current.attachShadow({ mode: 'open' })
|
|
31
|
-
const style = document.createElement('style')
|
|
32
|
-
style.textContent = generateDesignTokenVariablesString() + getSupportStylesheet()
|
|
33
|
-
const target = document.createElement('div')
|
|
34
|
-
shadow.append(style, target)
|
|
35
|
-
setPortal(target)
|
|
36
|
-
return () => { style.remove(); target.remove() }
|
|
37
|
-
}, [])
|
|
38
|
-
useEffect(() => {
|
|
39
|
-
const color = state.config?.branding.color || '#171717'
|
|
40
|
-
host.current?.style.setProperty('--andy-support-color', color)
|
|
41
|
-
host.current?.style.setProperty('--andy-support-contrast', getContrastTextColor(color))
|
|
42
|
-
}, [state.config?.branding.color])
|
|
43
|
-
useEffect(() => { messages.current?.scrollTo?.({ top: messages.current.scrollHeight, behavior: 'smooth' }) }, [state.messages, state.view])
|
|
44
|
-
useEffect(() => { controller?.setVisible(open) }, [controller, open])
|
|
45
|
-
useEffect(() => { if (support.ticketId) setOpen(true) }, [support.ticketId])
|
|
46
|
-
useEffect(() => { if (state.ticket?.status !== 'resolved' && state.followup?.action !== 'reopen') setReopening(false) }, [state.ticket?.status, state.followup?.action])
|
|
47
|
-
const choose = (action: () => void) => { setMenu(false); action() }
|
|
48
|
-
const escalate = () => { setSubject(''); setBody(''); controller?.escalate() }
|
|
49
|
-
const send = () => { if (!draft.trim() || state.busy) return; void controller?.send(draft); setDraft('') }
|
|
50
|
-
const newProblem = () => { setSubject(''); setBody(''); setReplyDraft(''); setReopening(false); controller?.newProblem() }
|
|
51
|
-
const followup = async () => {
|
|
52
|
-
const result = await controller?.reply(replyDraft, reopening)
|
|
53
|
-
if (result) { setReplyDraft(''); setReopening(false) }
|
|
54
|
-
}
|
|
55
|
-
const unread = <span className='andy-support__unread' role='img' aria-label={strings.unread} />
|
|
56
|
-
const title = state.config?.branding.name || strings.support
|
|
57
|
-
const ticketRow = (ticket: SupportTicket) => <button key={ticket.id} type='button' className='andy-support__ticket' onClick={() => { setSelectedTicketId(ticket.id); setReplyDraft(''); setReopening(false); void controller?.inspect(ticket.id) }}><span>#{ticket.number} · {ticket.subject} {ticket.unread && unread}</span><small>{strings[ticket.status]}</small><span aria-hidden='true'>›</span></button>
|
|
58
|
-
const retry = () => {
|
|
59
|
-
if (!state.config) void controller?.bootstrap()
|
|
60
|
-
else if (state.view === 'form') void controller?.submit(subject, body, replyLanguage)
|
|
61
|
-
else if (state.view === 'requests') void controller?.showRequests()
|
|
62
|
-
else if (state.view === 'request' && state.followup) void followup()
|
|
63
|
-
else if (state.view === 'request' && (selectedTicketId || support.ticketId)) void controller?.inspect((selectedTicketId || support.ticketId)!)
|
|
64
|
-
}
|
|
65
|
-
const errorText = state.error === 'ticket_resolved' ? strings.resolvedReply : ['expired', 'unknown_key', 'malformed', 'authentication_required', 'invalid_signature'].includes(state.error || '') ? strings.auth : state.error === 'permission_denied' ? strings.denied : strings.error
|
|
66
|
-
const promise = state.ticket?.replyPromise || state.config?.replyPromise
|
|
67
|
-
const promiseText = !promise ? null : promise.minutes === promise.businessDayMinutes ? strings.businessDay : promise.minutes % 60 === 0 ? `${promise.minutes / 60} ${strings.hours}` : `${promise.minutes} ${strings.minutes}`
|
|
68
|
-
return <div ref={host} className={`andy-support-host andy-support-host--${orientation}`}>
|
|
69
|
-
{portal && createPortal(<div className='andy-support' data-private data-ph-no-capture data-sentry-block lang={replyLanguage}>
|
|
70
|
-
{!open && <button type='button' className='andy-support__launcher' title={tooltip} onClick={() => setOpen(true)}><span>{strings.support} · {title} {state.unreadCount > 0 && unread}</span><span aria-hidden='true'>↗</span></button>}
|
|
71
|
-
{open && <section className='andy-support__panel' role='dialog' aria-label={`${strings.support} · ${title}`}>
|
|
72
|
-
<header className='andy-support__header'>
|
|
73
|
-
{state.view !== 'conversation' && <button type='button' aria-label={strings.back} onClick={() => state.view === 'request' ? void controller?.showRequests() : controller?.back()}>‹</button>}
|
|
74
|
-
<span className='andy-support__mark' aria-hidden='true'>{title.slice(0, 1)}</span><div className='andy-support__identity'><strong>{title}</strong><small>{strings.support}</small></div>
|
|
75
|
-
<button type='button' aria-label={strings.menu} aria-expanded={menu} disabled={!state.config} onClick={() => setMenu(!menu)}>···</button><button type='button' aria-label={strings.close} onClick={() => setOpen(false)}>×</button>
|
|
76
|
-
{menu && <nav className='andy-support__menu' aria-label={strings.menu}><button type='button' onClick={() => choose(() => controller?.newConversation())}>{strings.new}</button><button type='button' onClick={() => choose(escalate)}>{strings.team}</button><button type='button' onClick={() => choose(() => void controller?.showRequests())}>{strings.requests} {state.unreadCount > 0 && unread}</button></nav>}
|
|
77
|
-
</header>
|
|
78
|
-
<div className='andy-support__content' ref={messages}>
|
|
79
|
-
{state.loading && <p role='status'>{strings.loading}</p>}
|
|
80
|
-
{state.view === 'conversation' && <>
|
|
81
|
-
{state.messages.map((message, index) => <div key={index} className={`andy-support__message andy-support__message--${message.role}`}>{message.content}</div>)}
|
|
82
|
-
{state.config && state.messages.length === 1 && <div className='andy-support__questions'>{state.config.questions.map(question => <button type='button' key={question} disabled={state.busy} onClick={() => void controller?.send(question)}>{question}</button>)}</div>}
|
|
83
|
-
{state.busy && <p className='andy-support__muted' role='status'>···</p>}
|
|
84
|
-
{state.messages.length > 1 && !state.busy && <button type='button' className='andy-support__secondary' onClick={escalate}>{strings.team} ↗</button>}
|
|
85
|
-
</>}
|
|
86
|
-
{state.view === 'form' && <form className='andy-support__form' onSubmit={event => { event.preventDefault(); void controller?.submit(subject, body, replyLanguage) }}>
|
|
87
|
-
<h2>{strings.team}</h2><label>{strings.subject}<input required maxLength={200} value={state.submission?.subject ?? subject} readOnly={!!state.submission} disabled={state.busy} onChange={event => setSubject(event.target.value)} /></label><label>{strings.detail}<textarea required maxLength={20000} rows={5} value={state.submission?.body ?? body} readOnly={!!state.submission} disabled={state.busy} onChange={event => setBody(event.target.value)} /></label>
|
|
88
|
-
<details className='andy-support__transcript'><summary>{strings.notice} · {state.transcript.length} {state.transcript.length === 1 ? strings.transcriptSingular : strings.transcript}</summary>{state.transcript.map((message, index) => <p key={index}>{message.content}</p>)}</details><p className='andy-support__muted'>{strings.consent}</p><button type='submit' className='andy-support__primary' disabled={state.busy || !subject.trim() || !body.trim()}>{state.busy ? strings.loading : strings.submit}</button>
|
|
89
|
-
</form>}
|
|
90
|
-
{state.view === 'receipt' && state.ticket && <div className='andy-support__receipt'><span className='andy-support__check' aria-hidden='true'>✓</span><h2>{strings.receipt}</h2><strong>#{state.ticket.number}</strong><p>{state.ticket.subject}</p>{promiseText && <p>{strings.promise} <strong>{promiseText}</strong>.</p>}<button type='button' className='andy-support__primary' onClick={() => void controller?.showRequests()}>{strings.requests}</button></div>}
|
|
91
|
-
{state.view === 'requests' && <><h2>{strings.requests}</h2>{state.tickets.map(ticketRow)}{!state.busy && !state.error && !state.tickets.length && <p className='andy-support__muted'>{strings.empty}</p>}</>}
|
|
92
|
-
{state.view === 'request' && state.ticket && <><h2>#{state.ticket.number} · {state.ticket.subject}</h2><p className='andy-support__muted'>{strings[state.ticket.status]}{state.ticket.reopened && ` · ${strings.reopened}`}</p>{state.entries.filter(entry => ['requester_message', 'public_reply'].includes(entry.kind)).map(entry => <article className='andy-support__entry' key={entry.id}><strong>{entry.authorName}</strong><p>{entry.body}</p></article>)}
|
|
93
|
-
{state.ticket.status === 'resolved' && !reopening && <div className='andy-support__request-actions'><button type='button' className='andy-support__primary' onClick={() => setReopening(true)}>{strings.reopen}</button><button type='button' className='andy-support__secondary' onClick={newProblem}>{strings.otherProblem}</button></div>}
|
|
94
|
-
</>}
|
|
95
|
-
{state.error && <div className='andy-support__error' role='alert'><p>{errorText}</p>{(!state.config || state.view === 'form' || state.view === 'requests' || (state.view === 'request' && state.error !== 'ticket_resolved' && (selectedTicketId || support.ticketId))) && <button type='button' onClick={retry} disabled={state.busy}>{strings.retry}</button>}</div>}
|
|
96
|
-
</div>
|
|
97
|
-
{state.view === 'conversation' && <form className='andy-support__composer' onSubmit={event => { event.preventDefault(); send() }}><input aria-label={strings.placeholder} placeholder={strings.placeholder} value={draft} maxLength={10000} disabled={!state.config} onChange={event => setDraft(event.target.value)} /><button type='submit' aria-label={strings.send} disabled={state.busy || !state.config || !draft.trim()}>↑</button></form>}
|
|
98
|
-
{state.view === 'request' && state.ticket && (state.ticket.status !== 'resolved' || reopening) && <form className='andy-support__followup' onSubmit={event => { event.preventDefault(); void followup() }}><label>{reopening ? strings.reopening : strings.reply}<textarea rows={3} maxLength={20000} required value={state.followup?.body ?? replyDraft} readOnly={!!state.followup} disabled={state.busy} onChange={event => setReplyDraft(event.target.value)} /></label><button type='submit' className='andy-support__primary' disabled={state.busy || !replyDraft.trim()}>{state.busy ? strings.loading : reopening ? strings.reopen : strings.send}</button></form>}
|
|
99
|
-
<footer className='andy-support__footer'><span>{strings.powered}</span>{state.config?.branding.footer.map(link => <a key={link.url} href={sanitizeUrl(link.url)} target='_blank' rel='noopener noreferrer'>{link.label}</a>)}</footer>
|
|
100
|
-
</section>}
|
|
101
|
-
</div>, portal)}
|
|
102
|
-
</div>
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** A host identity change also destroys local drafts and menu state before the next render. */
|
|
106
|
-
export const SupportChat: React.FC<SupportAndyChatProps> = props => <SupportPanel key={JSON.stringify([props.support.installationId, props.support.contextKey, props.support.apiBaseUrl])} {...props} />
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { CHAT_BUBBLE_DEFAULT_ICON_URL } from 'andy-widget-core'
|
|
3
|
-
import type { ChatbotConfig } from '../types'
|
|
4
|
-
|
|
5
|
-
interface ChatBubbleProps {
|
|
6
|
-
isOpen: boolean
|
|
7
|
-
config: ChatbotConfig | null
|
|
8
|
-
onClick: () => void
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const ChatBubble: React.FC<ChatBubbleProps> = ({ isOpen, config, onClick }) => {
|
|
12
|
-
const bubbleClasses = ['andy-bubble', isOpen && 'andy-bubble--open'].filter(Boolean).join(' ')
|
|
13
|
-
|
|
14
|
-
return (
|
|
15
|
-
<div
|
|
16
|
-
className={bubbleClasses}
|
|
17
|
-
onClick={onClick}
|
|
18
|
-
role='button'
|
|
19
|
-
tabIndex={0}
|
|
20
|
-
aria-label={isOpen ? 'Close chat' : 'Open chat'}
|
|
21
|
-
onKeyDown={e => {
|
|
22
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
23
|
-
e.preventDefault()
|
|
24
|
-
onClick()
|
|
25
|
-
}
|
|
26
|
-
}}
|
|
27
|
-
>
|
|
28
|
-
{isOpen ? (
|
|
29
|
-
<svg
|
|
30
|
-
className='andy-chat-bubble__icon'
|
|
31
|
-
width='28'
|
|
32
|
-
height='28'
|
|
33
|
-
viewBox='0 0 24 24'
|
|
34
|
-
fill='currentColor'
|
|
35
|
-
xmlns='http://www.w3.org/2000/svg'
|
|
36
|
-
>
|
|
37
|
-
<path d='M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z' />
|
|
38
|
-
</svg>
|
|
39
|
-
) : config?.logoUrl ? (
|
|
40
|
-
<img src={config.logoUrl} alt='Chatbot Logo' className='andy-chat-bubble__logo' />
|
|
41
|
-
) : (
|
|
42
|
-
<img src={CHAT_BUBBLE_DEFAULT_ICON_URL} alt='Chat' className='andy-chat-bubble__icon' />
|
|
43
|
-
)}
|
|
44
|
-
</div>
|
|
45
|
-
)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export default ChatBubble
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import MessageList from './MessageList'
|
|
3
|
-
import MessageInput from './MessageInput'
|
|
4
|
-
import { FOOTER_TEXT, FOOTER_URL } from 'andy-widget-core'
|
|
5
|
-
import type { ChatbotConfig, Message } from '../types'
|
|
6
|
-
|
|
7
|
-
interface ChatWindowProps {
|
|
8
|
-
config: ChatbotConfig | null
|
|
9
|
-
messages: Message[]
|
|
10
|
-
isLoading: boolean
|
|
11
|
-
onClose: () => void
|
|
12
|
-
onSendMessage: (message: string) => Promise<void> | void
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const ChatWindow: React.FC<ChatWindowProps> = ({ config, messages, isLoading, onClose, onSendMessage }) => {
|
|
16
|
-
const windowClasses = ['andy-chatbot', 'open'].filter(Boolean).join(' ')
|
|
17
|
-
|
|
18
|
-
return (
|
|
19
|
-
<div className={windowClasses}>
|
|
20
|
-
<div className='andy-header'>
|
|
21
|
-
<div className='andy-title'>{config?.headline || 'Chat with our AI ✨'}</div>
|
|
22
|
-
<div className='andy-subtitle'>{config?.description || 'Ask any question and our AI will answer'}</div>
|
|
23
|
-
|
|
24
|
-
<div className='andy-close' onClick={onClose} role='button' tabIndex={0}>
|
|
25
|
-
✕
|
|
26
|
-
</div>
|
|
27
|
-
</div>
|
|
28
|
-
|
|
29
|
-
<div className='andy-messages' id='andy-messages'>
|
|
30
|
-
<MessageList
|
|
31
|
-
messages={messages}
|
|
32
|
-
isLoading={isLoading}
|
|
33
|
-
logoUrl={config?.logoUrl}
|
|
34
|
-
botName={config?.name}
|
|
35
|
-
/>
|
|
36
|
-
</div>
|
|
37
|
-
|
|
38
|
-
<div className='andy-input-container'>
|
|
39
|
-
<MessageInput onSendMessage={onSendMessage} disabled={isLoading} placeholder='Type your message here...' />
|
|
40
|
-
</div>
|
|
41
|
-
|
|
42
|
-
{config?.showBranding !== false && (
|
|
43
|
-
<div className='andy-footer'>
|
|
44
|
-
<a href={FOOTER_URL} target='_blank' rel='noopener noreferrer'>
|
|
45
|
-
{FOOTER_TEXT}
|
|
46
|
-
</a>
|
|
47
|
-
</div>
|
|
48
|
-
)}
|
|
49
|
-
</div>
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export default ChatWindow
|
package/src/components/FAQ.tsx
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import type { FAQItem } from '../types'
|
|
3
|
-
|
|
4
|
-
interface FAQProps {
|
|
5
|
-
faqs?: FAQItem[]
|
|
6
|
-
onBack: () => void
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const FAQ: React.FC<FAQProps> = ({ faqs = [], onBack }) => {
|
|
10
|
-
return (
|
|
11
|
-
<div className='andy-chat-faq'>
|
|
12
|
-
<button className='andy-chat-faq__back' onClick={onBack} type='button' aria-label='Back to chat'>
|
|
13
|
-
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' width='16' height='16'>
|
|
14
|
-
<path d='M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z' fill='currentColor' />
|
|
15
|
-
</svg>
|
|
16
|
-
Back to chat
|
|
17
|
-
</button>
|
|
18
|
-
|
|
19
|
-
<div className='andy-chat-faq__title'>Frequently Asked Questions</div>
|
|
20
|
-
|
|
21
|
-
<div className='andy-chat-faq__list'>
|
|
22
|
-
{faqs.length > 0 ? (
|
|
23
|
-
faqs.map((faq, index) => (
|
|
24
|
-
<div key={index} className='andy-chat-faq__item'>
|
|
25
|
-
<div className='andy-chat-faq__question'>{faq.question}</div>
|
|
26
|
-
<div className='andy-chat-faq__answer'>{faq.answer}</div>
|
|
27
|
-
</div>
|
|
28
|
-
))
|
|
29
|
-
) : (
|
|
30
|
-
<div className='andy-chat-faq__empty'>
|
|
31
|
-
<div className='andy-chat-faq__empty-icon'>
|
|
32
|
-
<svg width='48' height='48' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
|
33
|
-
<path
|
|
34
|
-
d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z'
|
|
35
|
-
fill='currentColor'
|
|
36
|
-
opacity='0.3'
|
|
37
|
-
/>
|
|
38
|
-
</svg>
|
|
39
|
-
</div>
|
|
40
|
-
<div className='andy-chat-faq__empty-text'>No frequently asked questions available at this time.</div>
|
|
41
|
-
</div>
|
|
42
|
-
)}
|
|
43
|
-
</div>
|
|
44
|
-
</div>
|
|
45
|
-
)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export default FAQ
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import React, { useState, useRef, KeyboardEvent, ChangeEvent, FormEvent } from 'react'
|
|
2
|
-
|
|
3
|
-
interface MessageInputProps {
|
|
4
|
-
onSendMessage: (message: string) => Promise<void> | void
|
|
5
|
-
disabled?: boolean
|
|
6
|
-
placeholder?: string
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const MessageInput: React.FC<MessageInputProps> = ({
|
|
10
|
-
onSendMessage,
|
|
11
|
-
disabled = false,
|
|
12
|
-
placeholder = 'Type your message here...'
|
|
13
|
-
}) => {
|
|
14
|
-
const [message, setMessage] = useState('')
|
|
15
|
-
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
16
|
-
const inputRef = useRef<HTMLInputElement>(null)
|
|
17
|
-
|
|
18
|
-
const handleSubmit = async (e: FormEvent<HTMLFormElement> | KeyboardEvent<HTMLInputElement>) => {
|
|
19
|
-
e.preventDefault()
|
|
20
|
-
|
|
21
|
-
const trimmedMessage = message.trim()
|
|
22
|
-
if (!trimmedMessage || disabled || isSubmitting) {
|
|
23
|
-
return
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
setIsSubmitting(true)
|
|
28
|
-
setMessage('')
|
|
29
|
-
await onSendMessage(trimmedMessage)
|
|
30
|
-
|
|
31
|
-
if (inputRef.current) {
|
|
32
|
-
inputRef.current.focus()
|
|
33
|
-
}
|
|
34
|
-
} catch (error) {
|
|
35
|
-
console.error('Error sending message:', error)
|
|
36
|
-
setMessage(current => current || trimmedMessage)
|
|
37
|
-
} finally {
|
|
38
|
-
setIsSubmitting(false)
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
43
|
-
setMessage(e.target.value)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
47
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
48
|
-
e.preventDefault()
|
|
49
|
-
handleSubmit(e)
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const isInputDisabled = disabled || isSubmitting
|
|
54
|
-
const canSend = message.trim().length > 0 && !isInputDisabled
|
|
55
|
-
|
|
56
|
-
return (
|
|
57
|
-
<div className='andy-input'>
|
|
58
|
-
<input
|
|
59
|
-
ref={inputRef}
|
|
60
|
-
type='text'
|
|
61
|
-
value={message}
|
|
62
|
-
onChange={handleInputChange}
|
|
63
|
-
onKeyDown={handleKeyDown}
|
|
64
|
-
placeholder={placeholder}
|
|
65
|
-
disabled={disabled}
|
|
66
|
-
maxLength={1000}
|
|
67
|
-
autoComplete='off'
|
|
68
|
-
id='andy-input'
|
|
69
|
-
/>
|
|
70
|
-
<button type='button' onClick={() => handleSubmit({ preventDefault: () => {} } as FormEvent<HTMLFormElement>)} disabled={!canSend} id='andy-send'>
|
|
71
|
-
{isSubmitting ? (
|
|
72
|
-
'...'
|
|
73
|
-
) : (
|
|
74
|
-
<svg width='18' height='18' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
|
75
|
-
<path d='M2.01 21L23 12 2.01 3 2 10l15 2-15 2z' fill='white' />
|
|
76
|
-
</svg>
|
|
77
|
-
)}
|
|
78
|
-
</button>
|
|
79
|
-
</div>
|
|
80
|
-
)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export default MessageInput
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useRef } from 'react'
|
|
2
|
-
import { parseMarkdown } from 'andy-widget-core'
|
|
3
|
-
import type { Message } from '../types'
|
|
4
|
-
|
|
5
|
-
interface MessageProps {
|
|
6
|
-
message: Message
|
|
7
|
-
logoUrl?: string
|
|
8
|
-
botName?: string
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const MessageComponent: React.FC<MessageProps> = ({ message, botName }) => {
|
|
12
|
-
const isBot = message.sender === 'bot'
|
|
13
|
-
|
|
14
|
-
const containerClasses = ['andy-message-container', `andy-message-container--${message.sender}`]
|
|
15
|
-
.filter(Boolean)
|
|
16
|
-
.join(' ')
|
|
17
|
-
|
|
18
|
-
const messageClasses = ['andy-message', `andy-message--${message.sender}`].filter(Boolean).join(' ')
|
|
19
|
-
|
|
20
|
-
const messageContent = isBot && !message.isStreaming ? parseMarkdown(message.content) : message.content
|
|
21
|
-
|
|
22
|
-
if (isBot) {
|
|
23
|
-
return (
|
|
24
|
-
<div className={containerClasses}>
|
|
25
|
-
<div className='andy-bot-header'>
|
|
26
|
-
<div className='andy-bot-header__icon'>
|
|
27
|
-
<svg
|
|
28
|
-
width='16'
|
|
29
|
-
height='16'
|
|
30
|
-
viewBox='0 0 24 24'
|
|
31
|
-
fill='none'
|
|
32
|
-
xmlns='http://www.w3.org/2000/svg'
|
|
33
|
-
stroke='currentColor'
|
|
34
|
-
strokeWidth='2'
|
|
35
|
-
strokeLinecap='round'
|
|
36
|
-
strokeLinejoin='round'
|
|
37
|
-
>
|
|
38
|
-
<path d='M12 6V2H8' />
|
|
39
|
-
<path d='m8 18-4 4V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2Z' />
|
|
40
|
-
<path d='M2 12h2' />
|
|
41
|
-
<path d='M9 11v2' />
|
|
42
|
-
<path d='M15 11v2' />
|
|
43
|
-
<path d='M20 12h2' />
|
|
44
|
-
</svg>
|
|
45
|
-
</div>
|
|
46
|
-
<span className='andy-bot-header__name'>{botName || 'Andy bot'}</span>
|
|
47
|
-
</div>
|
|
48
|
-
<div className={messageClasses}>
|
|
49
|
-
{message.isStreaming
|
|
50
|
-
? <div className='andy-message__content andy-message__stream'>{message.content}</div>
|
|
51
|
-
: <div className='andy-message__content' dangerouslySetInnerHTML={{ __html: messageContent }} />}
|
|
52
|
-
</div>
|
|
53
|
-
</div>
|
|
54
|
-
)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return (
|
|
58
|
-
<div className={containerClasses}>
|
|
59
|
-
<div className={messageClasses}>
|
|
60
|
-
<div className='andy-message__content'>
|
|
61
|
-
<p>{messageContent}</p>
|
|
62
|
-
</div>
|
|
63
|
-
</div>
|
|
64
|
-
</div>
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const TypingIndicator: React.FC = () => (
|
|
69
|
-
<div className='andy-message-container andy-message-container--bot andy-message-container--typing'>
|
|
70
|
-
<div className='andy-message andy-message--bot andy-message--typing'>
|
|
71
|
-
<div className='andy-typing-indicator'>
|
|
72
|
-
<div className='andy-typing-indicator__dot'></div>
|
|
73
|
-
<div className='andy-typing-indicator__dot'></div>
|
|
74
|
-
<div className='andy-typing-indicator__dot'></div>
|
|
75
|
-
</div>
|
|
76
|
-
</div>
|
|
77
|
-
</div>
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
interface MessageListProps {
|
|
81
|
-
messages: Message[]
|
|
82
|
-
isLoading: boolean
|
|
83
|
-
logoUrl?: string
|
|
84
|
-
botName?: string
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const MessageList: React.FC<MessageListProps> = ({ messages, isLoading, logoUrl, botName }) => {
|
|
88
|
-
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
89
|
-
|
|
90
|
-
useEffect(() => {
|
|
91
|
-
if (messagesEndRef.current) {
|
|
92
|
-
messagesEndRef.current.scrollIntoView({
|
|
93
|
-
behavior: 'smooth',
|
|
94
|
-
block: 'end'
|
|
95
|
-
})
|
|
96
|
-
}
|
|
97
|
-
}, [messages, isLoading])
|
|
98
|
-
|
|
99
|
-
return (
|
|
100
|
-
<>
|
|
101
|
-
{messages.map(message => (
|
|
102
|
-
<MessageComponent
|
|
103
|
-
key={message.id || message.timestamp}
|
|
104
|
-
message={message}
|
|
105
|
-
logoUrl={logoUrl}
|
|
106
|
-
botName={botName}
|
|
107
|
-
/>
|
|
108
|
-
))}
|
|
109
|
-
|
|
110
|
-
{isLoading && !messages.some(message => message.isStreaming) && <TypingIndicator />}
|
|
111
|
-
|
|
112
|
-
{messages.length === 0 && !isLoading && (
|
|
113
|
-
<div className='andy-empty-state'>
|
|
114
|
-
<div className='andy-empty-state__icon'>💬</div>
|
|
115
|
-
<div className='andy-empty-state__text'>Start a conversation by typing a message below</div>
|
|
116
|
-
</div>
|
|
117
|
-
)}
|
|
118
|
-
|
|
119
|
-
<div ref={messagesEndRef} />
|
|
120
|
-
</>
|
|
121
|
-
)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export default MessageList
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect, MouseEvent, ChangeEvent, FormEvent } from 'react'
|
|
2
|
-
import type { UserInfo } from '../types'
|
|
3
|
-
|
|
4
|
-
interface UserInfoFormProps {
|
|
5
|
-
headerText?: string
|
|
6
|
-
brandColor?: string
|
|
7
|
-
onSubmit: (data: UserInfo) => Promise<void> | void
|
|
8
|
-
onClose: () => void
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface FormErrors {
|
|
12
|
-
name?: string
|
|
13
|
-
email?: string
|
|
14
|
-
submit?: string
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const UserInfoForm: React.FC<UserInfoFormProps> = ({ headerText, onSubmit, onClose }) => {
|
|
18
|
-
const [formData, setFormData] = useState<UserInfo>({
|
|
19
|
-
name: '',
|
|
20
|
-
email: ''
|
|
21
|
-
})
|
|
22
|
-
const [errors, setErrors] = useState<FormErrors>({})
|
|
23
|
-
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
24
|
-
|
|
25
|
-
const validateForm = (): boolean => {
|
|
26
|
-
const newErrors: FormErrors = {}
|
|
27
|
-
|
|
28
|
-
if (!formData.name.trim()) {
|
|
29
|
-
newErrors.name = 'Name is required'
|
|
30
|
-
} else if (formData.name.trim().length < 2) {
|
|
31
|
-
newErrors.name = 'Name must be at least 2 characters'
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
if (!formData.email.trim()) {
|
|
35
|
-
newErrors.email = 'Email is required'
|
|
36
|
-
} else {
|
|
37
|
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
38
|
-
if (!emailRegex.test(formData.email)) {
|
|
39
|
-
newErrors.email = 'Please enter a valid email address'
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
setErrors(newErrors)
|
|
44
|
-
return Object.keys(newErrors).length === 0
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
|
48
|
-
e.preventDefault()
|
|
49
|
-
|
|
50
|
-
if (!validateForm() || isSubmitting) {
|
|
51
|
-
return
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
setIsSubmitting(true)
|
|
56
|
-
await onSubmit(formData)
|
|
57
|
-
} catch (error) {
|
|
58
|
-
console.error('Error submitting user info:', error)
|
|
59
|
-
setErrors({
|
|
60
|
-
submit: 'Failed to save your information. Please try again.'
|
|
61
|
-
})
|
|
62
|
-
} finally {
|
|
63
|
-
setIsSubmitting(false)
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const handleInputChange = (field: keyof UserInfo) => (e: ChangeEvent<HTMLInputElement>) => {
|
|
68
|
-
setFormData(prev => ({
|
|
69
|
-
...prev,
|
|
70
|
-
[field]: e.target.value
|
|
71
|
-
}))
|
|
72
|
-
|
|
73
|
-
if (errors[field]) {
|
|
74
|
-
setErrors(prev => ({
|
|
75
|
-
...prev,
|
|
76
|
-
[field]: undefined
|
|
77
|
-
}))
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
const handleEscape = (e: KeyboardEvent) => {
|
|
83
|
-
if (e.key === 'Escape') {
|
|
84
|
-
onClose()
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
document.addEventListener('keydown', handleEscape)
|
|
89
|
-
return () => document.removeEventListener('keydown', handleEscape)
|
|
90
|
-
}, [onClose])
|
|
91
|
-
|
|
92
|
-
const handleBackdropClick = (e: MouseEvent<HTMLDivElement>) => {
|
|
93
|
-
if (e.target === e.currentTarget) {
|
|
94
|
-
onClose()
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return (
|
|
99
|
-
<div className='andy-chat-user-form__backdrop' onClick={handleBackdropClick}>
|
|
100
|
-
<div className='andy-chat-user-form'>
|
|
101
|
-
<button className='andy-chat-user-form__close' onClick={onClose} aria-label='Close form' type='button'>
|
|
102
|
-
✕
|
|
103
|
-
</button>
|
|
104
|
-
|
|
105
|
-
<div className='andy-chat-user-form__header'>
|
|
106
|
-
<div className='andy-chat-user-form__icon'>
|
|
107
|
-
<svg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
|
108
|
-
<path
|
|
109
|
-
d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'
|
|
110
|
-
fill='currentColor'
|
|
111
|
-
/>
|
|
112
|
-
</svg>
|
|
113
|
-
</div>
|
|
114
|
-
<div className='andy-chat-user-form__title'>{headerText || "Let's get to know you better"}</div>
|
|
115
|
-
<div className='andy-chat-user-form__subtitle'>Please provide your information to continue</div>
|
|
116
|
-
</div>
|
|
117
|
-
|
|
118
|
-
<form className='andy-chat-user-form__form' onSubmit={handleSubmit}>
|
|
119
|
-
<div className='andy-chat-user-form__field'>
|
|
120
|
-
<label htmlFor='andy-user-name' className='andy-chat-user-form__label'>
|
|
121
|
-
Name *
|
|
122
|
-
</label>
|
|
123
|
-
<input
|
|
124
|
-
id='andy-user-name'
|
|
125
|
-
type='text'
|
|
126
|
-
className={`andy-chat-user-form__input ${errors.name ? 'andy-chat-user-form__input--error' : ''}`}
|
|
127
|
-
value={formData.name}
|
|
128
|
-
onChange={handleInputChange('name')}
|
|
129
|
-
placeholder='Enter your name'
|
|
130
|
-
disabled={isSubmitting}
|
|
131
|
-
maxLength={100}
|
|
132
|
-
/>
|
|
133
|
-
{errors.name && <div className='andy-chat-user-form__error'>{errors.name}</div>}
|
|
134
|
-
</div>
|
|
135
|
-
|
|
136
|
-
<div className='andy-chat-user-form__field'>
|
|
137
|
-
<label htmlFor='andy-user-email' className='andy-chat-user-form__label'>
|
|
138
|
-
Email *
|
|
139
|
-
</label>
|
|
140
|
-
<input
|
|
141
|
-
id='andy-user-email'
|
|
142
|
-
type='email'
|
|
143
|
-
className={`andy-chat-user-form__input ${errors.email ? 'andy-chat-user-form__input--error' : ''}`}
|
|
144
|
-
value={formData.email}
|
|
145
|
-
onChange={handleInputChange('email')}
|
|
146
|
-
placeholder='Enter your email'
|
|
147
|
-
disabled={isSubmitting}
|
|
148
|
-
maxLength={100}
|
|
149
|
-
/>
|
|
150
|
-
{errors.email && <div className='andy-chat-user-form__error'>{errors.email}</div>}
|
|
151
|
-
</div>
|
|
152
|
-
|
|
153
|
-
{errors.submit && <div className='andy-chat-user-form__submit-error'>{errors.submit}</div>}
|
|
154
|
-
|
|
155
|
-
<div className='andy-chat-user-form__actions'>
|
|
156
|
-
<button
|
|
157
|
-
type='button'
|
|
158
|
-
className='andy-chat-user-form__button andy-chat-user-form__button--secondary'
|
|
159
|
-
onClick={onClose}
|
|
160
|
-
disabled={isSubmitting}
|
|
161
|
-
>
|
|
162
|
-
Skip for now
|
|
163
|
-
</button>
|
|
164
|
-
<button
|
|
165
|
-
type='submit'
|
|
166
|
-
className='andy-chat-user-form__button andy-chat-user-form__button--primary'
|
|
167
|
-
disabled={isSubmitting}
|
|
168
|
-
>
|
|
169
|
-
{isSubmitting ? (
|
|
170
|
-
<span className='andy-chat-user-form__loading'>
|
|
171
|
-
<span className='andy-chat-user-form__loading-dot'></span>
|
|
172
|
-
<span className='andy-chat-user-form__loading-dot'></span>
|
|
173
|
-
<span className='andy-chat-user-form__loading-dot'></span>
|
|
174
|
-
</span>
|
|
175
|
-
) : (
|
|
176
|
-
'Continue'
|
|
177
|
-
)}
|
|
178
|
-
</button>
|
|
179
|
-
</div>
|
|
180
|
-
</form>
|
|
181
|
-
</div>
|
|
182
|
-
</div>
|
|
183
|
-
)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export default UserInfoForm
|