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.
package/README.md CHANGED
@@ -8,7 +8,7 @@ The official React Widget for Andy. Its canonical source is
8
8
  Pin an exact reviewed release:
9
9
 
10
10
  ```bash
11
- bun add andy-widget-react@2.2.1
11
+ bun add andy-widget-react@2.3.2
12
12
  ```
13
13
 
14
14
  React and React DOM 18 or 19 are required peer dependencies.
@@ -87,7 +87,7 @@ Support requires a configured host installation and a server-provisioned Support
87
87
 
88
88
  That host endpoint signs on the server from the authenticated user/company; never put a signing key in the browser. Change `contextKey` whenever identity/company changes, and unmount on sign-out. The SDK aborts and clears old private state. The pass remains in memory and is sent only in `X-Andy-Requester`. Expired/unknown-key refusals renew once; other failures stop inside the panel. `apiBaseUrl` may explicitly select the reviewed Andy API origin; normal production defaults to Andy.
89
89
 
90
- Provider branding and links, greeting/questions and reply promise come from Andy. There is no `embedId`, visitor identity form or Sales action in this mode. Sending the team form consents to attach the frozen conversation; its retry preserves the request key and receipt. Mis solicitudes stays inside the panel and displays the Requester's public human thread. The panel fills phones. Vanilla Support is frozen.
90
+ Provider branding and links, greeting/questions and reply promise come from Andy. When the host `lang` differs from the Agent default language, Support uses a localized default greeting and omits the Agent’s untranslated suggested questions. Existing conversation messages keep their original language. Custom hooks can pass `lang` in their Support options. There is no `embedId`, visitor identity form or Sales action in this mode. Sending the team form consents to attach the frozen conversation; its retry preserves the request key and receipt. Mis solicitudes stays inside the panel and displays the Requester's public human thread. The panel fills phones. Vanilla Support is frozen.
91
91
 
92
92
  For a custom React presentation, `useAndyChat({ mode: 'support', support })` returns `{ mode, state, controller }`. The controller exposes `send`, `escalate`, `submit`, `showRequests`, `inspect`, `back` and `newConversation`; it has the same consent, abort and retry semantics as the mounted panel. Do not use Sales presentation helpers with this result. Ticket replies and explicit reopens use the private human Requester operations, never the AI route.
93
93
 
package/dist/index.cjs CHANGED
@@ -1102,6 +1102,17 @@ const generateSessionId = () => {
1102
1102
  }
1103
1103
  };
1104
1104
 
1105
+ const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
1106
+
1107
+ /**
1108
+ * Escape a value for HTML element content and quoted attribute values.
1109
+ * Use it on every value interpolated into an `innerHTML` template; it
1110
+ * stringifies exactly as `${value}` would.
1111
+ * @param {unknown} value - The value to escape
1112
+ * @returns {string} The escaped text
1113
+ */
1114
+ const escapeHtml = value => String(value).replace(/[&<>"']/g, character => HTML_ESCAPES[character]);
1115
+
1105
1116
  /**
1106
1117
  * Sanitize URL to prevent XSS attacks
1107
1118
  * @param {string} url - The URL to sanitize
@@ -1124,6 +1135,12 @@ const sanitizeUrl = url => {
1124
1135
  return trimmedUrl
1125
1136
  }
1126
1137
 
1138
+ // Root-relative paths stay on the host site. `//`, `/\` and the tabs or
1139
+ // newlines browsers strip from URLs could turn one into another host.
1140
+ if (/^\/(?![/\\])[^\t\n\r]*$/.test(trimmedUrl)) {
1141
+ return trimmedUrl
1142
+ }
1143
+
1127
1144
  // If no protocol specified, assume https for regular URLs
1128
1145
  if (!lowerUrl.includes(':') && !lowerUrl.startsWith('//')) {
1129
1146
  return 'https://' + trimmedUrl
@@ -1134,17 +1151,19 @@ const sanitizeUrl = url => {
1134
1151
  };
1135
1152
 
1136
1153
  /**
1137
- * Parse markdown text to HTML
1154
+ * Parse markdown text to HTML. The text is escaped first, so any HTML in it
1155
+ * renders as text and the result is safe for `innerHTML`.
1138
1156
  * @param {string} text - The markdown text to parse
1139
1157
  * @returns {string} The parsed HTML
1140
1158
  */
1141
1159
  const parseMarkdown = text => {
1142
1160
  if (!text) return ''
1143
1161
 
1144
- let html = text;
1162
+ let html = escapeHtml(text);
1145
1163
 
1146
1164
  // Handle links first (before bold/italic to avoid conflicts)
1147
- // Match [text](url) format
1165
+ // Match [text](url) format. Both parts are already escaped, so a quote
1166
+ // cannot leave the href attribute.
1148
1167
  html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => {
1149
1168
  const sanitizedUrl = sanitizeUrl(url);
1150
1169
  const isExternal = sanitizedUrl.startsWith('http://') || sanitizedUrl.startsWith('https://');
@@ -3773,6 +3792,11 @@ const createSupportController = options => {
3773
3792
  let following = null;
3774
3793
  let visible = true;
3775
3794
  let timer = null;
3795
+ let lang = options.lang;
3796
+ let bootstrapConfig = null;
3797
+ const localizedConfig = () => lang && bootstrapConfig.defaultLang && lang !== bootstrapConfig.defaultLang
3798
+ ? { ...bootstrapConfig, greeting: lang === 'en' ? 'How can we help?' : '¿Cómo podemos ayudarte?', questions: [] }
3799
+ : bootstrapConfig;
3776
3800
  const schedule = () => {
3777
3801
  clearTimeout(timer);
3778
3802
  if (!disposed && state.config && listeners.size) {
@@ -3852,8 +3876,10 @@ const createSupportController = options => {
3852
3876
  };
3853
3877
  const transcript = () => state.messages.map(({ role, content }) => ({ role, content }));
3854
3878
  const bootstrap = () => request(async (signal, epoch) => {
3855
- const config = await client.bootstrap(signal);
3879
+ const bootstrap = await client.bootstrap(signal);
3856
3880
  if (generation !== epoch || signal.aborted) return
3881
+ bootstrapConfig = bootstrap;
3882
+ const config = localizedConfig();
3857
3883
  publish({ config, loading: false, messages: [{ role: 'assistant', content: config.greeting }] });
3858
3884
  });
3859
3885
  const send = async content => {
@@ -3928,6 +3954,13 @@ const createSupportController = options => {
3928
3954
  getSnapshot: () => state,
3929
3955
  subscribe: listener => { listeners.add(listener); schedule(); return () => { listeners.delete(listener); if (!listeners.size) clearTimeout(timer); } },
3930
3956
  bootstrap, send, escalate, submit, showRequests, inspect, newConversation, refresh, reply, newProblem,
3957
+ setLanguage: value => {
3958
+ lang = value;
3959
+ if (!state.config) return
3960
+ const config = localizedConfig();
3961
+ // A started conversation and consented transcript keep their original text.
3962
+ publish({ config, ...(state.view === 'conversation' && state.messages.length === 1 ? { messages: [{ role: 'assistant', content: config.greeting }] } : {}) });
3963
+ },
3931
3964
  setVisible: value => { visible = value; if (value) void refresh(); },
3932
3965
  back: () => { abort(); publish({ view: 'conversation', busy: false, error: null, followup: null }); schedule(); },
3933
3966
  dispose: () => { abort(); publish({ config: null, view: 'conversation', messages: [], transcript: [], ticket: null, entries: [], tickets: [], submission: null, followup: null, unreadCount: 0, busy: false, loading: true, error: null }); disposed = true; client.dispose(); listeners.clear(); }
@@ -4019,21 +4052,23 @@ const useSupportChat = (options) => {
4019
4052
  void controller.bootstrap();
4020
4053
  return () => controller.dispose();
4021
4054
  }, [key]);
4055
+ react.useEffect(() => { controller?.setLanguage(options?.lang); }, [controller, options?.lang]);
4056
+ const ready = !!state.config;
4022
4057
  react.useEffect(() => {
4023
- if (state.config && options?.ticketId)
4058
+ if (ready && options?.ticketId)
4024
4059
  void controller?.inspect(options.ticketId);
4025
- }, [controller, state.config, options?.ticketId]);
4060
+ }, [controller, ready, options?.ticketId]);
4026
4061
  return { mode: 'support', state, controller };
4027
4062
  };
4028
4063
 
4029
4064
  const copy = {
4030
- 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' },
4031
- 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' }
4065
+ 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' },
4066
+ 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' }
4032
4067
  };
4033
4068
  /** Private Requester UI. Sales bootstrap, visitor identity and storage never enter this renderer. */
4034
4069
  const SupportPanel = ({ support, lang, orientation = 'right', initialOpen = false, tooltip }) => {
4035
- const { state, controller } = useSupportChat(support);
4036
- const replyLanguage = lang || state.config?.defaultLang || 'es';
4070
+ const { state, controller } = useSupportChat({ ...support, lang: lang || support.lang });
4071
+ const replyLanguage = lang || support.lang || state.config?.defaultLang || 'es';
4037
4072
  const strings = copy[replyLanguage];
4038
4073
  const [open, setOpen] = react.useState(initialOpen);
4039
4074
  const [menu, setMenu] = react.useState(false);
@@ -4098,7 +4133,7 @@ const SupportPanel = ({ support, lang, orientation = 'right', initialOpen = fals
4098
4133
  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;
4099
4134
  const promise = state.ticket?.replyPromise || state.config?.replyPromise;
4100
4135
  const promiseText = !promise ? null : promise.minutes === promise.businessDayMinutes ? strings.businessDay : promise.minutes % 60 === 0 ? `${promise.minutes / 60} ${strings.hours}` : `${promise.minutes} ${strings.minutes}`;
4101
- return jsxRuntime.jsx("div", { ref: host, className: `andy-support-host andy-support-host--${orientation}`, children: portal && ReactDOM.createPortal(jsxRuntime.jsxs("div", { className: 'andy-support', "data-private": true, "data-ph-no-capture": true, "data-sentry-block": true, lang: replyLanguage, children: [!open && jsxRuntime.jsxs("button", { type: 'button', className: 'andy-support__launcher', title: tooltip, onClick: () => setOpen(true), children: [jsxRuntime.jsxs("span", { children: [strings.support, " \u00B7 ", title, " ", state.unreadCount > 0 && unread] }), jsxRuntime.jsx("span", { "aria-hidden": 'true', children: "\u2197" })] }), open && jsxRuntime.jsxs("section", { className: 'andy-support__panel', role: 'dialog', "aria-label": `${strings.support} · ${title}`, children: [jsxRuntime.jsxs("header", { className: 'andy-support__header', children: [state.view !== 'conversation' && jsxRuntime.jsx("button", { type: 'button', "aria-label": strings.back, onClick: () => state.view === 'request' ? void controller?.showRequests() : controller?.back(), children: "\u2039" }), jsxRuntime.jsx("span", { className: 'andy-support__mark', "aria-hidden": 'true', children: title.slice(0, 1) }), jsxRuntime.jsxs("div", { className: 'andy-support__identity', children: [jsxRuntime.jsx("strong", { children: title }), jsxRuntime.jsx("small", { children: strings.support })] }), jsxRuntime.jsx("button", { type: 'button', "aria-label": strings.menu, "aria-expanded": menu, disabled: !state.config, onClick: () => setMenu(!menu), children: "\u00B7\u00B7\u00B7" }), jsxRuntime.jsx("button", { type: 'button', "aria-label": strings.close, onClick: () => setOpen(false), children: "\u00D7" }), menu && jsxRuntime.jsxs("nav", { className: 'andy-support__menu', "aria-label": strings.menu, children: [jsxRuntime.jsx("button", { type: 'button', onClick: () => choose(() => controller?.newConversation()), children: strings.new }), jsxRuntime.jsx("button", { type: 'button', onClick: () => choose(escalate), children: strings.team }), jsxRuntime.jsxs("button", { type: 'button', onClick: () => choose(() => void controller?.showRequests()), children: [strings.requests, " ", state.unreadCount > 0 && unread] })] })] }), jsxRuntime.jsxs("div", { className: 'andy-support__content', ref: messages, children: [state.loading && jsxRuntime.jsx("p", { role: 'status', children: strings.loading }), state.view === 'conversation' && jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [state.messages.map((message, index) => jsxRuntime.jsx("div", { className: `andy-support__message andy-support__message--${message.role}`, children: message.content }, index)), state.config && state.messages.length === 1 && jsxRuntime.jsx("div", { className: 'andy-support__questions', children: state.config.questions.map(question => jsxRuntime.jsx("button", { type: 'button', disabled: state.busy, onClick: () => void controller?.send(question), children: question }, question)) }), state.busy && jsxRuntime.jsx("p", { className: 'andy-support__muted', role: 'status', children: "\u00B7\u00B7\u00B7" }), state.messages.length > 1 && !state.busy && jsxRuntime.jsxs("button", { type: 'button', className: 'andy-support__secondary', onClick: escalate, children: [strings.team, " \u2197"] })] }), state.view === 'form' && jsxRuntime.jsxs("form", { className: 'andy-support__form', onSubmit: event => { event.preventDefault(); void controller?.submit(subject, body, replyLanguage); }, children: [jsxRuntime.jsx("h2", { children: strings.team }), jsxRuntime.jsxs("label", { children: [strings.subject, jsxRuntime.jsx("input", { required: true, maxLength: 200, value: state.submission?.subject ?? subject, readOnly: !!state.submission, disabled: state.busy, onChange: event => setSubject(event.target.value) })] }), jsxRuntime.jsxs("label", { children: [strings.detail, jsxRuntime.jsx("textarea", { required: true, maxLength: 20000, rows: 5, value: state.submission?.body ?? body, readOnly: !!state.submission, disabled: state.busy, onChange: event => setBody(event.target.value) })] }), jsxRuntime.jsxs("details", { className: 'andy-support__transcript', children: [jsxRuntime.jsxs("summary", { children: [strings.notice, " \u00B7 ", state.transcript.length, " ", strings.transcript] }), state.transcript.map((message, index) => jsxRuntime.jsx("p", { children: message.content }, index))] }), jsxRuntime.jsx("p", { className: 'andy-support__muted', children: strings.consent }), jsxRuntime.jsx("button", { type: 'submit', className: 'andy-support__primary', disabled: state.busy || !subject.trim() || !body.trim(), children: state.busy ? strings.loading : strings.submit })] }), state.view === 'receipt' && state.ticket && jsxRuntime.jsxs("div", { className: 'andy-support__receipt', children: [jsxRuntime.jsx("span", { className: 'andy-support__check', "aria-hidden": 'true', children: "\u2713" }), jsxRuntime.jsx("h2", { children: strings.receipt }), jsxRuntime.jsxs("strong", { children: ["#", state.ticket.number] }), jsxRuntime.jsx("p", { children: state.ticket.subject }), promiseText && jsxRuntime.jsxs("p", { children: [strings.promise, " ", jsxRuntime.jsx("strong", { children: promiseText }), "."] }), jsxRuntime.jsx("button", { type: 'button', className: 'andy-support__primary', onClick: () => void controller?.showRequests(), children: strings.requests })] }), state.view === 'requests' && jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("h2", { children: strings.requests }), state.tickets.map(ticketRow), !state.busy && !state.error && !state.tickets.length && jsxRuntime.jsx("p", { className: 'andy-support__muted', children: strings.empty })] }), state.view === 'request' && state.ticket && jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("h2", { children: ["#", state.ticket.number, " \u00B7 ", state.ticket.subject] }), jsxRuntime.jsxs("p", { className: 'andy-support__muted', children: [strings[state.ticket.status], state.ticket.reopened && ` · ${strings.reopened}`] }), state.entries.filter(entry => ['requester_message', 'public_reply'].includes(entry.kind)).map(entry => jsxRuntime.jsxs("article", { className: 'andy-support__entry', children: [jsxRuntime.jsx("strong", { children: entry.authorName }), jsxRuntime.jsx("p", { children: entry.body })] }, entry.id)), state.ticket.status === 'resolved' && !reopening && jsxRuntime.jsxs("div", { className: 'andy-support__request-actions', children: [jsxRuntime.jsx("button", { type: 'button', className: 'andy-support__primary', onClick: () => setReopening(true), children: strings.reopen }), jsxRuntime.jsx("button", { type: 'button', className: 'andy-support__secondary', onClick: newProblem, children: strings.otherProblem })] })] }), state.error && jsxRuntime.jsxs("div", { className: 'andy-support__error', role: 'alert', children: [jsxRuntime.jsx("p", { children: errorText }), (!state.config || state.view === 'form' || state.view === 'requests' || (state.view === 'request' && state.error !== 'ticket_resolved' && (selectedTicketId || support.ticketId))) && jsxRuntime.jsx("button", { type: 'button', onClick: retry, disabled: state.busy, children: strings.retry })] })] }), state.view === 'conversation' && jsxRuntime.jsxs("form", { className: 'andy-support__composer', onSubmit: event => { event.preventDefault(); send(); }, children: [jsxRuntime.jsx("input", { "aria-label": strings.placeholder, placeholder: strings.placeholder, value: draft, maxLength: 10000, disabled: !state.config, onChange: event => setDraft(event.target.value) }), jsxRuntime.jsx("button", { type: 'submit', "aria-label": strings.send, disabled: state.busy || !state.config || !draft.trim(), children: "\u2191" })] }), state.view === 'request' && state.ticket && (state.ticket.status !== 'resolved' || reopening) && jsxRuntime.jsxs("form", { className: 'andy-support__followup', onSubmit: event => { event.preventDefault(); void followup(); }, children: [jsxRuntime.jsxs("label", { children: [reopening ? strings.reopening : strings.reply, jsxRuntime.jsx("textarea", { rows: 3, maxLength: 20000, required: true, value: state.followup?.body ?? replyDraft, readOnly: !!state.followup, disabled: state.busy, onChange: event => setReplyDraft(event.target.value) })] }), jsxRuntime.jsx("button", { type: 'submit', className: 'andy-support__primary', disabled: state.busy || !replyDraft.trim(), children: state.busy ? strings.loading : reopening ? strings.reopen : strings.send })] }), jsxRuntime.jsxs("footer", { className: 'andy-support__footer', children: [jsxRuntime.jsx("span", { children: strings.powered }), state.config?.branding.footer.map(link => jsxRuntime.jsx("a", { href: link.url, target: '_blank', rel: 'noopener noreferrer', children: link.label }, link.url))] })] })] }), portal) });
4136
+ return jsxRuntime.jsx("div", { ref: host, className: `andy-support-host andy-support-host--${orientation}`, children: portal && ReactDOM.createPortal(jsxRuntime.jsxs("div", { className: 'andy-support', "data-private": true, "data-ph-no-capture": true, "data-sentry-block": true, lang: replyLanguage, children: [!open && jsxRuntime.jsxs("button", { type: 'button', className: 'andy-support__launcher', title: tooltip, onClick: () => setOpen(true), children: [jsxRuntime.jsxs("span", { children: [strings.support, " \u00B7 ", title, " ", state.unreadCount > 0 && unread] }), jsxRuntime.jsx("span", { "aria-hidden": 'true', children: "\u2197" })] }), open && jsxRuntime.jsxs("section", { className: 'andy-support__panel', role: 'dialog', "aria-label": `${strings.support} · ${title}`, children: [jsxRuntime.jsxs("header", { className: 'andy-support__header', children: [state.view !== 'conversation' && jsxRuntime.jsx("button", { type: 'button', "aria-label": strings.back, onClick: () => state.view === 'request' ? void controller?.showRequests() : controller?.back(), children: "\u2039" }), jsxRuntime.jsx("span", { className: 'andy-support__mark', "aria-hidden": 'true', children: title.slice(0, 1) }), jsxRuntime.jsxs("div", { className: 'andy-support__identity', children: [jsxRuntime.jsx("strong", { children: title }), jsxRuntime.jsx("small", { children: strings.support })] }), jsxRuntime.jsx("button", { type: 'button', "aria-label": strings.menu, "aria-expanded": menu, disabled: !state.config, onClick: () => setMenu(!menu), children: "\u00B7\u00B7\u00B7" }), jsxRuntime.jsx("button", { type: 'button', "aria-label": strings.close, onClick: () => setOpen(false), children: "\u00D7" }), menu && jsxRuntime.jsxs("nav", { className: 'andy-support__menu', "aria-label": strings.menu, children: [jsxRuntime.jsx("button", { type: 'button', onClick: () => choose(() => controller?.newConversation()), children: strings.new }), jsxRuntime.jsx("button", { type: 'button', onClick: () => choose(escalate), children: strings.team }), jsxRuntime.jsxs("button", { type: 'button', onClick: () => choose(() => void controller?.showRequests()), children: [strings.requests, " ", state.unreadCount > 0 && unread] })] })] }), jsxRuntime.jsxs("div", { className: 'andy-support__content', ref: messages, children: [state.loading && jsxRuntime.jsx("p", { role: 'status', children: strings.loading }), state.view === 'conversation' && jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [state.messages.map((message, index) => jsxRuntime.jsx("div", { className: `andy-support__message andy-support__message--${message.role}`, children: message.content }, index)), state.config && state.messages.length === 1 && jsxRuntime.jsx("div", { className: 'andy-support__questions', children: state.config.questions.map(question => jsxRuntime.jsx("button", { type: 'button', disabled: state.busy, onClick: () => void controller?.send(question), children: question }, question)) }), state.busy && jsxRuntime.jsx("p", { className: 'andy-support__muted', role: 'status', children: "\u00B7\u00B7\u00B7" }), state.messages.length > 1 && !state.busy && jsxRuntime.jsxs("button", { type: 'button', className: 'andy-support__secondary', onClick: escalate, children: [strings.team, " \u2197"] })] }), state.view === 'form' && jsxRuntime.jsxs("form", { className: 'andy-support__form', onSubmit: event => { event.preventDefault(); void controller?.submit(subject, body, replyLanguage); }, children: [jsxRuntime.jsx("h2", { children: strings.team }), jsxRuntime.jsxs("label", { children: [strings.subject, jsxRuntime.jsx("input", { required: true, maxLength: 200, value: state.submission?.subject ?? subject, readOnly: !!state.submission, disabled: state.busy, onChange: event => setSubject(event.target.value) })] }), jsxRuntime.jsxs("label", { children: [strings.detail, jsxRuntime.jsx("textarea", { required: true, maxLength: 20000, rows: 5, value: state.submission?.body ?? body, readOnly: !!state.submission, disabled: state.busy, onChange: event => setBody(event.target.value) })] }), jsxRuntime.jsxs("details", { className: 'andy-support__transcript', children: [jsxRuntime.jsxs("summary", { children: [strings.notice, " \u00B7 ", state.transcript.length, " ", state.transcript.length === 1 ? strings.transcriptSingular : strings.transcript] }), state.transcript.map((message, index) => jsxRuntime.jsx("p", { children: message.content }, index))] }), jsxRuntime.jsx("p", { className: 'andy-support__muted', children: strings.consent }), jsxRuntime.jsx("button", { type: 'submit', className: 'andy-support__primary', disabled: state.busy || !subject.trim() || !body.trim(), children: state.busy ? strings.loading : strings.submit })] }), state.view === 'receipt' && state.ticket && jsxRuntime.jsxs("div", { className: 'andy-support__receipt', children: [jsxRuntime.jsx("span", { className: 'andy-support__check', "aria-hidden": 'true', children: "\u2713" }), jsxRuntime.jsx("h2", { children: strings.receipt }), jsxRuntime.jsxs("strong", { children: ["#", state.ticket.number] }), jsxRuntime.jsx("p", { children: state.ticket.subject }), promiseText && jsxRuntime.jsxs("p", { children: [strings.promise, " ", jsxRuntime.jsx("strong", { children: promiseText }), "."] }), jsxRuntime.jsx("button", { type: 'button', className: 'andy-support__primary', onClick: () => void controller?.showRequests(), children: strings.requests })] }), state.view === 'requests' && jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("h2", { children: strings.requests }), state.tickets.map(ticketRow), !state.busy && !state.error && !state.tickets.length && jsxRuntime.jsx("p", { className: 'andy-support__muted', children: strings.empty })] }), state.view === 'request' && state.ticket && jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("h2", { children: ["#", state.ticket.number, " \u00B7 ", state.ticket.subject] }), jsxRuntime.jsxs("p", { className: 'andy-support__muted', children: [strings[state.ticket.status], state.ticket.reopened && ` · ${strings.reopened}`] }), state.entries.filter(entry => ['requester_message', 'public_reply'].includes(entry.kind)).map(entry => jsxRuntime.jsxs("article", { className: 'andy-support__entry', children: [jsxRuntime.jsx("strong", { children: entry.authorName }), jsxRuntime.jsx("p", { children: entry.body })] }, entry.id)), state.ticket.status === 'resolved' && !reopening && jsxRuntime.jsxs("div", { className: 'andy-support__request-actions', children: [jsxRuntime.jsx("button", { type: 'button', className: 'andy-support__primary', onClick: () => setReopening(true), children: strings.reopen }), jsxRuntime.jsx("button", { type: 'button', className: 'andy-support__secondary', onClick: newProblem, children: strings.otherProblem })] })] }), state.error && jsxRuntime.jsxs("div", { className: 'andy-support__error', role: 'alert', children: [jsxRuntime.jsx("p", { children: errorText }), (!state.config || state.view === 'form' || state.view === 'requests' || (state.view === 'request' && state.error !== 'ticket_resolved' && (selectedTicketId || support.ticketId))) && jsxRuntime.jsx("button", { type: 'button', onClick: retry, disabled: state.busy, children: strings.retry })] })] }), state.view === 'conversation' && jsxRuntime.jsxs("form", { className: 'andy-support__composer', onSubmit: event => { event.preventDefault(); send(); }, children: [jsxRuntime.jsx("input", { "aria-label": strings.placeholder, placeholder: strings.placeholder, value: draft, maxLength: 10000, disabled: !state.config, onChange: event => setDraft(event.target.value) }), jsxRuntime.jsx("button", { type: 'submit', "aria-label": strings.send, disabled: state.busy || !state.config || !draft.trim(), children: "\u2191" })] }), state.view === 'request' && state.ticket && (state.ticket.status !== 'resolved' || reopening) && jsxRuntime.jsxs("form", { className: 'andy-support__followup', onSubmit: event => { event.preventDefault(); void followup(); }, children: [jsxRuntime.jsxs("label", { children: [reopening ? strings.reopening : strings.reply, jsxRuntime.jsx("textarea", { rows: 3, maxLength: 20000, required: true, value: state.followup?.body ?? replyDraft, readOnly: !!state.followup, disabled: state.busy, onChange: event => setReplyDraft(event.target.value) })] }), jsxRuntime.jsx("button", { type: 'submit', className: 'andy-support__primary', disabled: state.busy || !replyDraft.trim(), children: state.busy ? strings.loading : reopening ? strings.reopen : strings.send })] }), jsxRuntime.jsxs("footer", { className: 'andy-support__footer', children: [jsxRuntime.jsx("span", { children: strings.powered }), state.config?.branding.footer.map(link => jsxRuntime.jsx("a", { href: sanitizeUrl(link.url), target: '_blank', rel: 'noopener noreferrer', children: link.label }, link.url))] })] })] }), portal) });
4102
4137
  };
4103
4138
  /** A host identity change also destroys local drafts and menu state before the next render. */
4104
4139
  const SupportChat = props => jsxRuntime.jsx(SupportPanel, { ...props }, JSON.stringify([props.support.installationId, props.support.contextKey, props.support.apiBaseUrl]));
@@ -4641,7 +4676,7 @@ const SalesAndyChat = ({ embedId, tooltip, name, headline, description, welcomeM
4641
4676
  bubble.className = 'andy-chat-bubble';
4642
4677
  bubble.setAttribute('aria-label', strings.openChat);
4643
4678
  const tooltipHTML = tooltip
4644
- ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${tooltip}</span>`
4679
+ ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${escapeHtml(tooltip)}</span>`
4645
4680
  : '';
4646
4681
  bubble.innerHTML = `${CHAT_BUBBLE_DEFAULT_ICON}${tooltipHTML}`;
4647
4682
  shadowRef.current.appendChild(bubble);
@@ -4666,7 +4701,7 @@ const SalesAndyChat = ({ embedId, tooltip, name, headline, description, welcomeM
4666
4701
  bubble.classList.add('andy-chat-bubble--loaded');
4667
4702
  }
4668
4703
  const tooltipHTML = !isOpen && tooltip && !isPillStyle
4669
- ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${tooltip}</span>`
4704
+ ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${escapeHtml(tooltip)}</span>`
4670
4705
  : '';
4671
4706
  const configUseBrandColor = config?.useBrandColor ?? true;
4672
4707
  const isDarkTheme = config?.theme === 'dark';
@@ -4679,13 +4714,13 @@ const SalesAndyChat = ({ embedId, tooltip, name, headline, description, welcomeM
4679
4714
  }
4680
4715
  else if (isPillStyle && displayLabel) {
4681
4716
  bubble.innerHTML = `
4682
- <span class="andy-chat-bubble__label">${displayLabel}</span>
4683
- <img src="${bubbleLogoUrl}" alt="Chat" class="andy-chat-bubble__icon--pill" style="${darkModeIconStyle}" />
4717
+ <span class="andy-chat-bubble__label">${escapeHtml(displayLabel)}</span>
4718
+ <img src="${escapeHtml(bubbleLogoUrl)}" alt="Chat" class="andy-chat-bubble__icon--pill" style="${darkModeIconStyle}" />
4684
4719
  `;
4685
4720
  }
4686
4721
  else {
4687
4722
  if (config?.logoUrl) {
4688
- bubble.innerHTML = `<img src="${config.logoUrl}" alt="Chatbot Logo" class="andy-chat-bubble__logo" style="${darkModeIconStyle}" />${tooltipHTML}`;
4723
+ bubble.innerHTML = `<img src="${escapeHtml(config.logoUrl)}" alt="Chatbot Logo" class="andy-chat-bubble__logo" style="${darkModeIconStyle}" />${tooltipHTML}`;
4689
4724
  }
4690
4725
  else {
4691
4726
  const defaultIconWithStyle = CHAT_BUBBLE_DEFAULT_ICON.replace('class="andy-chat-bubble__icon"', `class="andy-chat-bubble__icon" style="${darkModeIconStyle}"`);
@@ -5190,3 +5225,4 @@ exports.theme = theme;
5190
5225
  exports.typography = typography;
5191
5226
  exports.useAndyChat = useAndyChat;
5192
5227
  exports.zIndex = zIndex;
5228
+ //# sourceMappingURL=index.cjs.map