andy-widget-react 2.2.1 → 2.3.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/README.md +35 -0
- package/dist/index.cjs +463 -9
- package/dist/index.esm.js +465 -11
- package/dist/types/SupportChat.d.ts +4 -0
- package/dist/types/hooks/useAndyChat.d.ts +7 -1
- package/dist/types/hooks/useSupportChat.d.ts +24 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/types/index.d.ts +11 -1
- package/package.json +2 -2
- package/src/AndyChat.tsx +7 -2
- package/src/SupportChat.tsx +106 -0
- package/src/hooks/useAndyChat.ts +20 -7
- package/src/hooks/useSupportChat.ts +29 -0
- package/src/index.ts +2 -0
- package/src/types/index.ts +12 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.esm.js.map +0 -1
|
@@ -60,7 +60,8 @@ export interface UserInfo {
|
|
|
60
60
|
name: string;
|
|
61
61
|
email: string;
|
|
62
62
|
}
|
|
63
|
-
export interface
|
|
63
|
+
export interface SalesAndyChatProps {
|
|
64
|
+
mode?: 'sales';
|
|
64
65
|
/** Required chatbot embed ID (not required if previewMode is true) */
|
|
65
66
|
embedId?: string;
|
|
66
67
|
/** Optional tooltip text for the chat bubble */
|
|
@@ -96,6 +97,15 @@ export interface AndyChatProps {
|
|
|
96
97
|
/** If true, start with chat window open */
|
|
97
98
|
initialOpen?: boolean;
|
|
98
99
|
}
|
|
100
|
+
export interface SupportAndyChatProps {
|
|
101
|
+
mode: 'support';
|
|
102
|
+
support: import('andy-widget-core').SupportOptions;
|
|
103
|
+
lang?: 'es' | 'en';
|
|
104
|
+
orientation?: 'left' | 'right';
|
|
105
|
+
initialOpen?: boolean;
|
|
106
|
+
tooltip?: string;
|
|
107
|
+
}
|
|
108
|
+
export type AndyChatProps = SalesAndyChatProps | SupportAndyChatProps;
|
|
99
109
|
export interface FaqItemProps {
|
|
100
110
|
question: string;
|
|
101
111
|
answer: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "andy-widget-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "Official React Widget for embeddable Andy agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -67,6 +67,6 @@
|
|
|
67
67
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"andy-widget-core": "2.
|
|
70
|
+
"andy-widget-core": "2.3.1"
|
|
71
71
|
}
|
|
72
72
|
}
|
package/src/AndyChat.tsx
CHANGED
|
@@ -22,7 +22,8 @@ import {
|
|
|
22
22
|
isMobileDevice
|
|
23
23
|
} from 'andy-widget-core'
|
|
24
24
|
|
|
25
|
-
import type { AndyChatProps, ChatbotConfig, Message } from './types'
|
|
25
|
+
import type { AndyChatProps, SalesAndyChatProps, ChatbotConfig, Message } from './types'
|
|
26
|
+
import { SupportChat } from './SupportChat'
|
|
26
27
|
|
|
27
28
|
interface FaqItemProps {
|
|
28
29
|
question: string
|
|
@@ -65,7 +66,7 @@ const FaqItem: React.FC<FaqItemProps> = ({ question, answer }) => {
|
|
|
65
66
|
)
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
const
|
|
69
|
+
const SalesAndyChat: React.FC<SalesAndyChatProps> = ({
|
|
69
70
|
embedId,
|
|
70
71
|
tooltip,
|
|
71
72
|
name,
|
|
@@ -1142,4 +1143,8 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
1142
1143
|
)
|
|
1143
1144
|
}
|
|
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
|
+
|
|
1145
1150
|
export default AndyChat
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { generateDesignTokenVariablesString, getContrastTextColor, getSupportStylesheet, 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={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} />
|
package/src/hooks/useAndyChat.ts
CHANGED
|
@@ -11,11 +11,13 @@ import {
|
|
|
11
11
|
CONFIG
|
|
12
12
|
} from 'andy-widget-core'
|
|
13
13
|
import type { ChatbotConfig, Message, UserInfo, UseAndyChatReturn } from '../types'
|
|
14
|
+
import type { SupportOptions } from 'andy-widget-core'
|
|
15
|
+
import { useSupportChat } from './useSupportChat'
|
|
14
16
|
|
|
15
17
|
const { SESSION_STORAGE_KEY, FORM_DISPLAY_DELAY_MS, PERSIST_SESSION } = CONFIG
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
initializeConfig('andy-widget-react')
|
|
19
|
+
const useSalesAndyChat = (embedId: string, enabled: boolean): UseAndyChatReturn => {
|
|
20
|
+
if (enabled) initializeConfig('andy-widget-react')
|
|
19
21
|
|
|
20
22
|
const [config, setConfig] = useState<Partial<ChatbotConfig>>({})
|
|
21
23
|
const [isLoading, setIsLoading] = useState(true)
|
|
@@ -41,6 +43,7 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
|
|
|
41
43
|
const userInfoTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
42
44
|
|
|
43
45
|
const initializeChatbot = useCallback(async () => {
|
|
46
|
+
if (!enabled) return
|
|
44
47
|
if (!embedId) {
|
|
45
48
|
throw new Error('Embed ID is required')
|
|
46
49
|
}
|
|
@@ -91,7 +94,7 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
|
|
|
91
94
|
setIsLoading(false)
|
|
92
95
|
throw err
|
|
93
96
|
}
|
|
94
|
-
}, [embedId])
|
|
97
|
+
}, [embedId, enabled])
|
|
95
98
|
|
|
96
99
|
const toggleChat = useCallback(() => {
|
|
97
100
|
setIsOpen(prev => {
|
|
@@ -133,7 +136,7 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
|
|
|
133
136
|
|
|
134
137
|
const sendMessage = useCallback(
|
|
135
138
|
async (messageText: string) => {
|
|
136
|
-
if (!messageText.trim() || !config || !sessionId || activeReplyRef.current) {
|
|
139
|
+
if (!enabled || !messageText.trim() || !config || !sessionId || activeReplyRef.current) {
|
|
137
140
|
return
|
|
138
141
|
}
|
|
139
142
|
|
|
@@ -184,7 +187,7 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
|
|
|
184
187
|
if (activeReplyRef.current === controller) activeReplyRef.current = null
|
|
185
188
|
}
|
|
186
189
|
},
|
|
187
|
-
[embedId, sessionId, config]
|
|
190
|
+
[embedId, sessionId, config, enabled]
|
|
188
191
|
)
|
|
189
192
|
|
|
190
193
|
const showFaq = useCallback(() => {
|
|
@@ -213,6 +216,7 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
|
|
|
213
216
|
|
|
214
217
|
const submitUserInfo = useCallback(
|
|
215
218
|
async (userInfo: UserInfo) => {
|
|
219
|
+
if (!enabled) return
|
|
216
220
|
try {
|
|
217
221
|
await submitUserInfoAPI(embedId, sessionId, userInfo)
|
|
218
222
|
setUserInfoCollected(true)
|
|
@@ -231,10 +235,11 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
|
|
|
231
235
|
throw err
|
|
232
236
|
}
|
|
233
237
|
},
|
|
234
|
-
[embedId, sessionId]
|
|
238
|
+
[embedId, sessionId, enabled]
|
|
235
239
|
)
|
|
236
240
|
|
|
237
241
|
const startNewSession = useCallback(() => {
|
|
242
|
+
if (!enabled) return
|
|
238
243
|
activeReplyRef.current?.abort()
|
|
239
244
|
activeReplyRef.current = null
|
|
240
245
|
const newSessionId = generateSessionId()
|
|
@@ -265,7 +270,7 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
|
|
|
265
270
|
setMessages([greetingMessage])
|
|
266
271
|
}
|
|
267
272
|
}
|
|
268
|
-
}, [config])
|
|
273
|
+
}, [config, enabled])
|
|
269
274
|
|
|
270
275
|
const addMessage = useCallback((message: Message) => {
|
|
271
276
|
setMessages(prev => [...prev, message])
|
|
@@ -297,3 +302,11 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
|
|
|
297
302
|
addMessage
|
|
298
303
|
}
|
|
299
304
|
}
|
|
305
|
+
|
|
306
|
+
export function useAndyChat(embedId: string): UseAndyChatReturn
|
|
307
|
+
export function useAndyChat(options: { mode: 'support'; support: SupportOptions }): ReturnType<typeof useSupportChat>
|
|
308
|
+
export function useAndyChat(input: string | { mode: 'support'; support: SupportOptions }) {
|
|
309
|
+
const support = useSupportChat(typeof input === 'string' ? null : input.support)
|
|
310
|
+
const sales = useSalesAndyChat(typeof input === 'string' ? input : '', typeof input === 'string')
|
|
311
|
+
return typeof input === 'string' ? sales : support
|
|
312
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import { createSupportController, type SupportOptions, type SupportState } from 'andy-widget-core'
|
|
3
|
+
|
|
4
|
+
const empty: SupportState = { config: null, view: 'conversation', messages: [], transcript: [], tickets: [], ticket: null, entries: [], loading: true, busy: false, error: null, submission: null, followup: null, unreadCount: 0 }
|
|
5
|
+
const noSubscribe = () => () => {}
|
|
6
|
+
const emptySnapshot = () => empty
|
|
7
|
+
|
|
8
|
+
/** The host context key changes before a new user/company is allowed to see any previous state. */
|
|
9
|
+
export const useSupportChat = (options: SupportOptions | null) => {
|
|
10
|
+
const key = options ? JSON.stringify([options.installationId, options.contextKey, options.apiBaseUrl]) : null
|
|
11
|
+
const callback = useRef(options?.getRequesterPass)
|
|
12
|
+
callback.current = options?.getRequesterPass
|
|
13
|
+
const [owned, setOwned] = useState<{ key: string; controller: ReturnType<typeof createSupportController> } | null>(null)
|
|
14
|
+
const controller = owned?.key === key ? owned?.controller : null
|
|
15
|
+
const state = useSyncExternalStore(controller?.subscribe || noSubscribe, controller?.getSnapshot || emptySnapshot, emptySnapshot)
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!options || !key) return
|
|
18
|
+
const controller = createSupportController({ ...options, getRequesterPass: () => callback.current!() })
|
|
19
|
+
setOwned({ key, controller })
|
|
20
|
+
void controller.bootstrap()
|
|
21
|
+
return () => controller.dispose()
|
|
22
|
+
}, [key])
|
|
23
|
+
useEffect(() => { controller?.setLanguage(options?.lang) }, [controller, options?.lang])
|
|
24
|
+
const ready = !!state.config
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (ready && options?.ticketId) void controller?.inspect(options.ticketId)
|
|
27
|
+
}, [controller, ready, options?.ticketId])
|
|
28
|
+
return { mode: 'support' as const, state, controller }
|
|
29
|
+
}
|
package/src/index.ts
CHANGED
package/src/types/index.ts
CHANGED
|
@@ -76,7 +76,8 @@ export interface UserInfo {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
// Main AndyChat component props
|
|
79
|
-
export interface
|
|
79
|
+
export interface SalesAndyChatProps {
|
|
80
|
+
mode?: 'sales'
|
|
80
81
|
/** Required chatbot embed ID (not required if previewMode is true) */
|
|
81
82
|
embedId?: string
|
|
82
83
|
/** Optional tooltip text for the chat bubble */
|
|
@@ -113,6 +114,16 @@ export interface AndyChatProps {
|
|
|
113
114
|
initialOpen?: boolean
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
export interface SupportAndyChatProps {
|
|
118
|
+
mode: 'support'
|
|
119
|
+
support: import('andy-widget-core').SupportOptions
|
|
120
|
+
lang?: 'es' | 'en'
|
|
121
|
+
orientation?: 'left' | 'right'
|
|
122
|
+
initialOpen?: boolean
|
|
123
|
+
tooltip?: string
|
|
124
|
+
}
|
|
125
|
+
export type AndyChatProps = SalesAndyChatProps | SupportAndyChatProps
|
|
126
|
+
|
|
116
127
|
// FAQ Item component props
|
|
117
128
|
export interface FaqItemProps {
|
|
118
129
|
question: string
|