andy-widget-react 2.3.0 → 2.3.2

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.
@@ -16,6 +16,7 @@ export declare const useSupportChat: (options: SupportOptions | null) => {
16
16
  newProblem: () => void;
17
17
  refresh: () => Promise<unknown>;
18
18
  setVisible: (visible: boolean) => void;
19
+ setLanguage: (lang?: "es" | "en") => void;
19
20
  newConversation: () => void;
20
21
  back: () => void;
21
22
  dispose: () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "andy-widget-react",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
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.3.0"
70
+ "andy-widget-core": "2.3.2"
71
71
  }
72
72
  }
package/src/AndyChat.tsx CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  getHostClasses,
13
13
  createBrowserHandoff,
14
14
  parseMarkdown,
15
+ escapeHtml,
15
16
  CHAT_BUBBLE_DEFAULT_ICON,
16
17
  CHAT_BUBBLE_DEFAULT_ICON_URL,
17
18
  SVG_ICONS,
@@ -713,7 +714,7 @@ const SalesAndyChat: React.FC<SalesAndyChatProps> = ({
713
714
  bubble.setAttribute('aria-label', strings.openChat)
714
715
 
715
716
  const tooltipHTML = tooltip
716
- ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${tooltip}</span>`
717
+ ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${escapeHtml(tooltip)}</span>`
717
718
  : ''
718
719
  bubble.innerHTML = `${CHAT_BUBBLE_DEFAULT_ICON}${tooltipHTML}`
719
720
  shadowRef.current.appendChild(bubble)
@@ -744,7 +745,7 @@ const SalesAndyChat: React.FC<SalesAndyChatProps> = ({
744
745
 
745
746
  const tooltipHTML =
746
747
  !isOpen && tooltip && !isPillStyle
747
- ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${tooltip}</span>`
748
+ ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${escapeHtml(tooltip)}</span>`
748
749
  : ''
749
750
 
750
751
  const configUseBrandColor = config?.useBrandColor ?? true
@@ -762,12 +763,12 @@ const SalesAndyChat: React.FC<SalesAndyChatProps> = ({
762
763
  bubble.innerHTML = chevronWithStyle
763
764
  } else if (isPillStyle && displayLabel) {
764
765
  bubble.innerHTML = `
765
- <span class="andy-chat-bubble__label">${displayLabel}</span>
766
- <img src="${bubbleLogoUrl}" alt="Chat" class="andy-chat-bubble__icon--pill" style="${darkModeIconStyle}" />
766
+ <span class="andy-chat-bubble__label">${escapeHtml(displayLabel)}</span>
767
+ <img src="${escapeHtml(bubbleLogoUrl)}" alt="Chat" class="andy-chat-bubble__icon--pill" style="${darkModeIconStyle}" />
767
768
  `
768
769
  } else {
769
770
  if (config?.logoUrl) {
770
- bubble.innerHTML = `<img src="${config.logoUrl}" alt="Chatbot Logo" class="andy-chat-bubble__logo" style="${darkModeIconStyle}" />${tooltipHTML}`
771
+ bubble.innerHTML = `<img src="${escapeHtml(config.logoUrl)}" alt="Chatbot Logo" class="andy-chat-bubble__logo" style="${darkModeIconStyle}" />${tooltipHTML}`
771
772
  } else {
772
773
  const defaultIconWithStyle = CHAT_BUBBLE_DEFAULT_ICON.replace(
773
774
  'class="andy-chat-bubble__icon"',
@@ -1,18 +1,18 @@
1
1
  import React, { useEffect, useRef, useState } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { generateDesignTokenVariablesString, getContrastTextColor, getSupportStylesheet, type SupportTicket } from 'andy-widget-core'
3
+ import { generateDesignTokenVariablesString, getContrastTextColor, getSupportStylesheet, sanitizeUrl, type SupportTicket } from 'andy-widget-core'
4
4
  import type { SupportAndyChatProps } from './types'
5
5
  import { useSupportChat } from './hooks/useSupportChat'
6
6
 
7
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' }
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
10
  }
11
11
 
12
12
  /** Private Requester UI. Sales bootstrap, visitor identity and storage never enter this renderer. */
13
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'
14
+ const { state, controller } = useSupportChat({ ...support, lang: lang || support.lang })
15
+ const replyLanguage = lang || support.lang || state.config?.defaultLang || 'es'
16
16
  const strings = copy[replyLanguage]
17
17
  const [open, setOpen] = useState(initialOpen)
18
18
  const [menu, setMenu] = useState(false)
@@ -85,7 +85,7 @@ const SupportPanel: React.FC<SupportAndyChatProps> = ({ support, lang, orientati
85
85
  </>}
86
86
  {state.view === 'form' && <form className='andy-support__form' onSubmit={event => { event.preventDefault(); void controller?.submit(subject, body, replyLanguage) }}>
87
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>
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
89
  </form>}
90
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
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>}</>}
@@ -96,7 +96,7 @@ const SupportPanel: React.FC<SupportAndyChatProps> = ({ support, lang, orientati
96
96
  </div>
97
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
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>
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
100
  </section>}
101
101
  </div>, portal)}
102
102
  </div>
@@ -20,8 +20,10 @@ export const useSupportChat = (options: SupportOptions | null) => {
20
20
  void controller.bootstrap()
21
21
  return () => controller.dispose()
22
22
  }, [key])
23
+ useEffect(() => { controller?.setLanguage(options?.lang) }, [controller, options?.lang])
24
+ const ready = !!state.config
23
25
  useEffect(() => {
24
- if (state.config && options?.ticketId) void controller?.inspect(options.ticketId)
25
- }, [controller, state.config, options?.ticketId])
26
+ if (ready && options?.ticketId) void controller?.inspect(options.ticketId)
27
+ }, [controller, ready, options?.ticketId])
26
28
  return { mode: 'support' as const, state, controller }
27
29
  }