andy-widget-react 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ import React from 'react';
2
+ import type { SupportAndyChatProps } from './types';
3
+ /** A host identity change also destroys local drafts and menu state before the next render. */
4
+ export declare const SupportChat: React.FC<SupportAndyChatProps>;
@@ -1,2 +1,8 @@
1
1
  import type { UseAndyChatReturn } from '../types';
2
- export declare const useAndyChat: (embedId: string) => UseAndyChatReturn;
2
+ import type { SupportOptions } from 'andy-widget-core';
3
+ import { useSupportChat } from './useSupportChat';
4
+ export declare function useAndyChat(embedId: string): UseAndyChatReturn;
5
+ export declare function useAndyChat(options: {
6
+ mode: 'support';
7
+ support: SupportOptions;
8
+ }): ReturnType<typeof useSupportChat>;
@@ -0,0 +1,23 @@
1
+ import { type SupportOptions, type SupportState } from 'andy-widget-core';
2
+ /** The host context key changes before a new user/company is allowed to see any previous state. */
3
+ export declare const useSupportChat: (options: SupportOptions | null) => {
4
+ mode: "support";
5
+ state: SupportState;
6
+ controller: {
7
+ getSnapshot: () => SupportState;
8
+ subscribe: (listener: () => void) => () => void;
9
+ bootstrap: () => Promise<unknown>;
10
+ send: (content: string) => Promise<unknown>;
11
+ escalate: () => void;
12
+ submit: (subject: string, body: string, replyLanguage?: "es" | "en") => Promise<unknown>;
13
+ showRequests: () => Promise<unknown>;
14
+ inspect: (ticketId: string) => Promise<unknown>;
15
+ reply: (body: string, reopen?: boolean) => Promise<unknown>;
16
+ newProblem: () => void;
17
+ refresh: () => Promise<unknown>;
18
+ setVisible: (visible: boolean) => void;
19
+ newConversation: () => void;
20
+ back: () => void;
21
+ dispose: () => void;
22
+ } | null;
23
+ };
@@ -8,6 +8,6 @@ export { default as MessageInput } from './components/MessageInput';
8
8
  export { default as FAQ } from './components/FAQ';
9
9
  export { default as WelcomePopup } from './components/WelcomePopup';
10
10
  export { default as UserInfoForm } from './components/UserInfoForm';
11
- export type { AndyChatProps, ChatbotConfig, Message, FAQItem, PredefinedQuestion, WelcomePopupConfig, CollectUserInfoConfig, UserInfo, UseAndyChatReturn, ChatBubbleProps, ChatWindowProps, MessageListProps, MessageInputProps, FAQProps, WelcomePopupProps, UserInfoFormProps, FaqItemProps } from './types';
11
+ export type { AndyChatProps, SupportAndyChatProps, SalesAndyChatProps, ChatbotConfig, Message, FAQItem, PredefinedQuestion, WelcomePopupConfig, CollectUserInfoConfig, UserInfo, UseAndyChatReturn, ChatBubbleProps, ChatWindowProps, MessageListProps, MessageInputProps, FAQProps, WelcomePopupProps, UserInfoFormProps, FaqItemProps } from './types';
12
12
  export { theme, colors, typography, spacing, borderRadius, shadows, zIndex, animation, chat, generateSessionId, parseMarkdown, sanitizeUrl, isValidEmbedId, getEmbedId, getApiBaseUrl, setApiBaseUrl } from 'andy-widget-core';
13
13
  export { default } from './AndyChat';
@@ -60,7 +60,8 @@ export interface UserInfo {
60
60
  name: string;
61
61
  email: string;
62
62
  }
63
- export interface AndyChatProps {
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;
@@ -181,13 +191,3 @@ export interface HostClassOptions {
181
191
  theme?: 'light' | 'dark' | 'auto';
182
192
  orientation?: 'left' | 'right';
183
193
  }
184
- export interface KeyboardListenerCallback {
185
- (state: {
186
- isOpen: boolean;
187
- height: number;
188
- }): void;
189
- }
190
- export interface KeyboardDetector {
191
- addListener: (callback: KeyboardListenerCallback) => void;
192
- removeListener: (callback: KeyboardListenerCallback) => void;
193
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "andy-widget-react",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
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.2.0"
70
+ "andy-widget-core": "2.3.0"
71
71
  }
72
72
  }
package/src/AndyChat.tsx CHANGED
@@ -19,12 +19,11 @@ import {
19
19
  getFooterUrl,
20
20
  getDefaultBubbleLabel,
21
21
  getUiStrings,
22
- getKeyboardDetector,
23
- updateKeyboardHeight,
24
22
  isMobileDevice
25
23
  } from 'andy-widget-core'
26
24
 
27
- import type { AndyChatProps, ChatbotConfig, Message } from './types'
25
+ import type { AndyChatProps, SalesAndyChatProps, ChatbotConfig, Message } from './types'
26
+ import { SupportChat } from './SupportChat'
28
27
 
29
28
  interface FaqItemProps {
30
29
  question: string
@@ -67,7 +66,7 @@ const FaqItem: React.FC<FaqItemProps> = ({ question, answer }) => {
67
66
  )
68
67
  }
69
68
 
70
- const AndyChat: React.FC<AndyChatProps> = ({
69
+ const SalesAndyChat: React.FC<SalesAndyChatProps> = ({
71
70
  embedId,
72
71
  tooltip,
73
72
  name,
@@ -192,24 +191,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
192
191
 
193
192
  setShadowReady(true)
194
193
 
195
- const keyboardDetector = getKeyboardDetector()
196
- const keyboardListener = ({ isOpen: kbOpen, height }: { isOpen: boolean; height: number }) => {
197
- updateKeyboardHeight(containerRef.current, height)
198
-
199
- const chatWindow = shadowRef.current?.querySelector('.andy-chat-window')
200
- if (chatWindow) {
201
- if (kbOpen && height > 0) {
202
- chatWindow.classList.add('andy-chat-window--keyboard-open')
203
- } else {
204
- chatWindow.classList.remove('andy-chat-window--keyboard-open')
205
- }
206
- }
207
- }
208
-
209
- keyboardDetector.addListener(keyboardListener)
210
-
211
194
  return () => {
212
- keyboardDetector.removeListener(keyboardListener)
213
195
  shadowRef.current = null
214
196
  portalRef.current = null
215
197
  setShadowReady(false)
@@ -1161,4 +1143,8 @@ const AndyChat: React.FC<AndyChatProps> = ({
1161
1143
  )
1162
1144
  }
1163
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
+
1164
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', 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', 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)
15
+ const replyLanguage = 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} {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} />
@@ -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
- export const useAndyChat = (embedId: string): UseAndyChatReturn => {
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,27 @@
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(() => {
24
+ if (state.config && options?.ticketId) void controller?.inspect(options.ticketId)
25
+ }, [controller, state.config, options?.ticketId])
26
+ return { mode: 'support' as const, state, controller }
27
+ }
package/src/index.ts CHANGED
@@ -18,6 +18,8 @@ export { default as UserInfoForm } from './components/UserInfoForm'
18
18
  // Export types for consumers
19
19
  export type {
20
20
  AndyChatProps,
21
+ SupportAndyChatProps,
22
+ SalesAndyChatProps,
21
23
  ChatbotConfig,
22
24
  Message,
23
25
  FAQItem,
@@ -76,7 +76,8 @@ export interface UserInfo {
76
76
  }
77
77
 
78
78
  // Main AndyChat component props
79
- export interface AndyChatProps {
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
@@ -222,14 +233,3 @@ export interface HostClassOptions {
222
233
  theme?: 'light' | 'dark' | 'auto'
223
234
  orientation?: 'left' | 'right'
224
235
  }
225
-
226
- // Keyboard detector listener callback
227
- export interface KeyboardListenerCallback {
228
- (state: { isOpen: boolean; height: number }): void
229
- }
230
-
231
- // Keyboard detector interface
232
- export interface KeyboardDetector {
233
- addListener: (callback: KeyboardListenerCallback) => void
234
- removeListener: (callback: KeyboardListenerCallback) => void
235
- }