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/dist/index.esm.js CHANGED
@@ -1098,6 +1098,17 @@ const generateSessionId = () => {
1098
1098
  }
1099
1099
  };
1100
1100
 
1101
+ const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
1102
+
1103
+ /**
1104
+ * Escape a value for HTML element content and quoted attribute values.
1105
+ * Use it on every value interpolated into an `innerHTML` template; it
1106
+ * stringifies exactly as `${value}` would.
1107
+ * @param {unknown} value - The value to escape
1108
+ * @returns {string} The escaped text
1109
+ */
1110
+ const escapeHtml = value => String(value).replace(/[&<>"']/g, character => HTML_ESCAPES[character]);
1111
+
1101
1112
  /**
1102
1113
  * Sanitize URL to prevent XSS attacks
1103
1114
  * @param {string} url - The URL to sanitize
@@ -1120,6 +1131,12 @@ const sanitizeUrl = url => {
1120
1131
  return trimmedUrl
1121
1132
  }
1122
1133
 
1134
+ // Root-relative paths stay on the host site. `//`, `/\` and the tabs or
1135
+ // newlines browsers strip from URLs could turn one into another host.
1136
+ if (/^\/(?![/\\])[^\t\n\r]*$/.test(trimmedUrl)) {
1137
+ return trimmedUrl
1138
+ }
1139
+
1123
1140
  // If no protocol specified, assume https for regular URLs
1124
1141
  if (!lowerUrl.includes(':') && !lowerUrl.startsWith('//')) {
1125
1142
  return 'https://' + trimmedUrl
@@ -1130,17 +1147,19 @@ const sanitizeUrl = url => {
1130
1147
  };
1131
1148
 
1132
1149
  /**
1133
- * Parse markdown text to HTML
1150
+ * Parse markdown text to HTML. The text is escaped first, so any HTML in it
1151
+ * renders as text and the result is safe for `innerHTML`.
1134
1152
  * @param {string} text - The markdown text to parse
1135
1153
  * @returns {string} The parsed HTML
1136
1154
  */
1137
1155
  const parseMarkdown = text => {
1138
1156
  if (!text) return ''
1139
1157
 
1140
- let html = text;
1158
+ let html = escapeHtml(text);
1141
1159
 
1142
1160
  // Handle links first (before bold/italic to avoid conflicts)
1143
- // Match [text](url) format
1161
+ // Match [text](url) format. Both parts are already escaped, so a quote
1162
+ // cannot leave the href attribute.
1144
1163
  html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => {
1145
1164
  const sanitizedUrl = sanitizeUrl(url);
1146
1165
  const isExternal = sanitizedUrl.startsWith('http://') || sanitizedUrl.startsWith('https://');
@@ -3769,6 +3788,11 @@ const createSupportController = options => {
3769
3788
  let following = null;
3770
3789
  let visible = true;
3771
3790
  let timer = null;
3791
+ let lang = options.lang;
3792
+ let bootstrapConfig = null;
3793
+ const localizedConfig = () => lang && bootstrapConfig.defaultLang && lang !== bootstrapConfig.defaultLang
3794
+ ? { ...bootstrapConfig, greeting: lang === 'en' ? 'How can we help?' : '¿Cómo podemos ayudarte?', questions: [] }
3795
+ : bootstrapConfig;
3772
3796
  const schedule = () => {
3773
3797
  clearTimeout(timer);
3774
3798
  if (!disposed && state.config && listeners.size) {
@@ -3848,8 +3872,10 @@ const createSupportController = options => {
3848
3872
  };
3849
3873
  const transcript = () => state.messages.map(({ role, content }) => ({ role, content }));
3850
3874
  const bootstrap = () => request(async (signal, epoch) => {
3851
- const config = await client.bootstrap(signal);
3875
+ const bootstrap = await client.bootstrap(signal);
3852
3876
  if (generation !== epoch || signal.aborted) return
3877
+ bootstrapConfig = bootstrap;
3878
+ const config = localizedConfig();
3853
3879
  publish({ config, loading: false, messages: [{ role: 'assistant', content: config.greeting }] });
3854
3880
  });
3855
3881
  const send = async content => {
@@ -3924,6 +3950,13 @@ const createSupportController = options => {
3924
3950
  getSnapshot: () => state,
3925
3951
  subscribe: listener => { listeners.add(listener); schedule(); return () => { listeners.delete(listener); if (!listeners.size) clearTimeout(timer); } },
3926
3952
  bootstrap, send, escalate, submit, showRequests, inspect, newConversation, refresh, reply, newProblem,
3953
+ setLanguage: value => {
3954
+ lang = value;
3955
+ if (!state.config) return
3956
+ const config = localizedConfig();
3957
+ // A started conversation and consented transcript keep their original text.
3958
+ publish({ config, ...(state.view === 'conversation' && state.messages.length === 1 ? { messages: [{ role: 'assistant', content: config.greeting }] } : {}) });
3959
+ },
3927
3960
  setVisible: value => { visible = value; if (value) void refresh(); },
3928
3961
  back: () => { abort(); publish({ view: 'conversation', busy: false, error: null, followup: null }); schedule(); },
3929
3962
  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(); }
@@ -4015,21 +4048,23 @@ const useSupportChat = (options) => {
4015
4048
  void controller.bootstrap();
4016
4049
  return () => controller.dispose();
4017
4050
  }, [key]);
4051
+ useEffect(() => { controller?.setLanguage(options?.lang); }, [controller, options?.lang]);
4052
+ const ready = !!state.config;
4018
4053
  useEffect(() => {
4019
- if (state.config && options?.ticketId)
4054
+ if (ready && options?.ticketId)
4020
4055
  void controller?.inspect(options.ticketId);
4021
- }, [controller, state.config, options?.ticketId]);
4056
+ }, [controller, ready, options?.ticketId]);
4022
4057
  return { mode: 'support', state, controller };
4023
4058
  };
4024
4059
 
4025
4060
  const copy = {
4026
- 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' },
4027
- 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' }
4061
+ 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' },
4062
+ 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' }
4028
4063
  };
4029
4064
  /** Private Requester UI. Sales bootstrap, visitor identity and storage never enter this renderer. */
4030
4065
  const SupportPanel = ({ support, lang, orientation = 'right', initialOpen = false, tooltip }) => {
4031
- const { state, controller } = useSupportChat(support);
4032
- const replyLanguage = lang || state.config?.defaultLang || 'es';
4066
+ const { state, controller } = useSupportChat({ ...support, lang: lang || support.lang });
4067
+ const replyLanguage = lang || support.lang || state.config?.defaultLang || 'es';
4033
4068
  const strings = copy[replyLanguage];
4034
4069
  const [open, setOpen] = useState(initialOpen);
4035
4070
  const [menu, setMenu] = useState(false);
@@ -4094,7 +4129,7 @@ const SupportPanel = ({ support, lang, orientation = 'right', initialOpen = fals
4094
4129
  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;
4095
4130
  const promise = state.ticket?.replyPromise || state.config?.replyPromise;
4096
4131
  const promiseText = !promise ? null : promise.minutes === promise.businessDayMinutes ? strings.businessDay : promise.minutes % 60 === 0 ? `${promise.minutes / 60} ${strings.hours}` : `${promise.minutes} ${strings.minutes}`;
4097
- return jsx("div", { ref: host, className: `andy-support-host andy-support-host--${orientation}`, children: portal && createPortal(jsxs("div", { className: 'andy-support', "data-private": true, "data-ph-no-capture": true, "data-sentry-block": true, lang: replyLanguage, children: [!open && jsxs("button", { type: 'button', className: 'andy-support__launcher', title: tooltip, onClick: () => setOpen(true), children: [jsxs("span", { children: [strings.support, " \u00B7 ", title, " ", state.unreadCount > 0 && unread] }), jsx("span", { "aria-hidden": 'true', children: "\u2197" })] }), open && jsxs("section", { className: 'andy-support__panel', role: 'dialog', "aria-label": `${strings.support} · ${title}`, children: [jsxs("header", { className: 'andy-support__header', children: [state.view !== 'conversation' && jsx("button", { type: 'button', "aria-label": strings.back, onClick: () => state.view === 'request' ? void controller?.showRequests() : controller?.back(), children: "\u2039" }), jsx("span", { className: 'andy-support__mark', "aria-hidden": 'true', children: title.slice(0, 1) }), jsxs("div", { className: 'andy-support__identity', children: [jsx("strong", { children: title }), jsx("small", { children: strings.support })] }), jsx("button", { type: 'button', "aria-label": strings.menu, "aria-expanded": menu, disabled: !state.config, onClick: () => setMenu(!menu), children: "\u00B7\u00B7\u00B7" }), jsx("button", { type: 'button', "aria-label": strings.close, onClick: () => setOpen(false), children: "\u00D7" }), menu && jsxs("nav", { className: 'andy-support__menu', "aria-label": strings.menu, children: [jsx("button", { type: 'button', onClick: () => choose(() => controller?.newConversation()), children: strings.new }), jsx("button", { type: 'button', onClick: () => choose(escalate), children: strings.team }), jsxs("button", { type: 'button', onClick: () => choose(() => void controller?.showRequests()), children: [strings.requests, " ", state.unreadCount > 0 && unread] })] })] }), jsxs("div", { className: 'andy-support__content', ref: messages, children: [state.loading && jsx("p", { role: 'status', children: strings.loading }), state.view === 'conversation' && jsxs(Fragment, { children: [state.messages.map((message, index) => jsx("div", { className: `andy-support__message andy-support__message--${message.role}`, children: message.content }, index)), state.config && state.messages.length === 1 && jsx("div", { className: 'andy-support__questions', children: state.config.questions.map(question => jsx("button", { type: 'button', disabled: state.busy, onClick: () => void controller?.send(question), children: question }, question)) }), state.busy && jsx("p", { className: 'andy-support__muted', role: 'status', children: "\u00B7\u00B7\u00B7" }), state.messages.length > 1 && !state.busy && jsxs("button", { type: 'button', className: 'andy-support__secondary', onClick: escalate, children: [strings.team, " \u2197"] })] }), state.view === 'form' && jsxs("form", { className: 'andy-support__form', onSubmit: event => { event.preventDefault(); void controller?.submit(subject, body, replyLanguage); }, children: [jsx("h2", { children: strings.team }), jsxs("label", { children: [strings.subject, jsx("input", { required: true, maxLength: 200, value: state.submission?.subject ?? subject, readOnly: !!state.submission, disabled: state.busy, onChange: event => setSubject(event.target.value) })] }), jsxs("label", { children: [strings.detail, 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) })] }), jsxs("details", { className: 'andy-support__transcript', children: [jsxs("summary", { children: [strings.notice, " \u00B7 ", state.transcript.length, " ", strings.transcript] }), state.transcript.map((message, index) => jsx("p", { children: message.content }, index))] }), jsx("p", { className: 'andy-support__muted', children: strings.consent }), 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 && jsxs("div", { className: 'andy-support__receipt', children: [jsx("span", { className: 'andy-support__check', "aria-hidden": 'true', children: "\u2713" }), jsx("h2", { children: strings.receipt }), jsxs("strong", { children: ["#", state.ticket.number] }), jsx("p", { children: state.ticket.subject }), promiseText && jsxs("p", { children: [strings.promise, " ", jsx("strong", { children: promiseText }), "."] }), jsx("button", { type: 'button', className: 'andy-support__primary', onClick: () => void controller?.showRequests(), children: strings.requests })] }), state.view === 'requests' && jsxs(Fragment, { children: [jsx("h2", { children: strings.requests }), state.tickets.map(ticketRow), !state.busy && !state.error && !state.tickets.length && jsx("p", { className: 'andy-support__muted', children: strings.empty })] }), state.view === 'request' && state.ticket && jsxs(Fragment, { children: [jsxs("h2", { children: ["#", state.ticket.number, " \u00B7 ", state.ticket.subject] }), 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 => jsxs("article", { className: 'andy-support__entry', children: [jsx("strong", { children: entry.authorName }), jsx("p", { children: entry.body })] }, entry.id)), state.ticket.status === 'resolved' && !reopening && jsxs("div", { className: 'andy-support__request-actions', children: [jsx("button", { type: 'button', className: 'andy-support__primary', onClick: () => setReopening(true), children: strings.reopen }), jsx("button", { type: 'button', className: 'andy-support__secondary', onClick: newProblem, children: strings.otherProblem })] })] }), state.error && jsxs("div", { className: 'andy-support__error', role: 'alert', children: [jsx("p", { children: errorText }), (!state.config || state.view === 'form' || state.view === 'requests' || (state.view === 'request' && state.error !== 'ticket_resolved' && (selectedTicketId || support.ticketId))) && jsx("button", { type: 'button', onClick: retry, disabled: state.busy, children: strings.retry })] })] }), state.view === 'conversation' && jsxs("form", { className: 'andy-support__composer', onSubmit: event => { event.preventDefault(); send(); }, children: [jsx("input", { "aria-label": strings.placeholder, placeholder: strings.placeholder, value: draft, maxLength: 10000, disabled: !state.config, onChange: event => setDraft(event.target.value) }), 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) && jsxs("form", { className: 'andy-support__followup', onSubmit: event => { event.preventDefault(); void followup(); }, children: [jsxs("label", { children: [reopening ? strings.reopening : strings.reply, 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) })] }), jsx("button", { type: 'submit', className: 'andy-support__primary', disabled: state.busy || !replyDraft.trim(), children: state.busy ? strings.loading : reopening ? strings.reopen : strings.send })] }), jsxs("footer", { className: 'andy-support__footer', children: [jsx("span", { children: strings.powered }), state.config?.branding.footer.map(link => jsx("a", { href: link.url, target: '_blank', rel: 'noopener noreferrer', children: link.label }, link.url))] })] })] }), portal) });
4132
+ return jsx("div", { ref: host, className: `andy-support-host andy-support-host--${orientation}`, children: portal && createPortal(jsxs("div", { className: 'andy-support', "data-private": true, "data-ph-no-capture": true, "data-sentry-block": true, lang: replyLanguage, children: [!open && jsxs("button", { type: 'button', className: 'andy-support__launcher', title: tooltip, onClick: () => setOpen(true), children: [jsxs("span", { children: [strings.support, " \u00B7 ", title, " ", state.unreadCount > 0 && unread] }), jsx("span", { "aria-hidden": 'true', children: "\u2197" })] }), open && jsxs("section", { className: 'andy-support__panel', role: 'dialog', "aria-label": `${strings.support} · ${title}`, children: [jsxs("header", { className: 'andy-support__header', children: [state.view !== 'conversation' && jsx("button", { type: 'button', "aria-label": strings.back, onClick: () => state.view === 'request' ? void controller?.showRequests() : controller?.back(), children: "\u2039" }), jsx("span", { className: 'andy-support__mark', "aria-hidden": 'true', children: title.slice(0, 1) }), jsxs("div", { className: 'andy-support__identity', children: [jsx("strong", { children: title }), jsx("small", { children: strings.support })] }), jsx("button", { type: 'button', "aria-label": strings.menu, "aria-expanded": menu, disabled: !state.config, onClick: () => setMenu(!menu), children: "\u00B7\u00B7\u00B7" }), jsx("button", { type: 'button', "aria-label": strings.close, onClick: () => setOpen(false), children: "\u00D7" }), menu && jsxs("nav", { className: 'andy-support__menu', "aria-label": strings.menu, children: [jsx("button", { type: 'button', onClick: () => choose(() => controller?.newConversation()), children: strings.new }), jsx("button", { type: 'button', onClick: () => choose(escalate), children: strings.team }), jsxs("button", { type: 'button', onClick: () => choose(() => void controller?.showRequests()), children: [strings.requests, " ", state.unreadCount > 0 && unread] })] })] }), jsxs("div", { className: 'andy-support__content', ref: messages, children: [state.loading && jsx("p", { role: 'status', children: strings.loading }), state.view === 'conversation' && jsxs(Fragment, { children: [state.messages.map((message, index) => jsx("div", { className: `andy-support__message andy-support__message--${message.role}`, children: message.content }, index)), state.config && state.messages.length === 1 && jsx("div", { className: 'andy-support__questions', children: state.config.questions.map(question => jsx("button", { type: 'button', disabled: state.busy, onClick: () => void controller?.send(question), children: question }, question)) }), state.busy && jsx("p", { className: 'andy-support__muted', role: 'status', children: "\u00B7\u00B7\u00B7" }), state.messages.length > 1 && !state.busy && jsxs("button", { type: 'button', className: 'andy-support__secondary', onClick: escalate, children: [strings.team, " \u2197"] })] }), state.view === 'form' && jsxs("form", { className: 'andy-support__form', onSubmit: event => { event.preventDefault(); void controller?.submit(subject, body, replyLanguage); }, children: [jsx("h2", { children: strings.team }), jsxs("label", { children: [strings.subject, jsx("input", { required: true, maxLength: 200, value: state.submission?.subject ?? subject, readOnly: !!state.submission, disabled: state.busy, onChange: event => setSubject(event.target.value) })] }), jsxs("label", { children: [strings.detail, 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) })] }), jsxs("details", { className: 'andy-support__transcript', children: [jsxs("summary", { children: [strings.notice, " \u00B7 ", state.transcript.length, " ", state.transcript.length === 1 ? strings.transcriptSingular : strings.transcript] }), state.transcript.map((message, index) => jsx("p", { children: message.content }, index))] }), jsx("p", { className: 'andy-support__muted', children: strings.consent }), 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 && jsxs("div", { className: 'andy-support__receipt', children: [jsx("span", { className: 'andy-support__check', "aria-hidden": 'true', children: "\u2713" }), jsx("h2", { children: strings.receipt }), jsxs("strong", { children: ["#", state.ticket.number] }), jsx("p", { children: state.ticket.subject }), promiseText && jsxs("p", { children: [strings.promise, " ", jsx("strong", { children: promiseText }), "."] }), jsx("button", { type: 'button', className: 'andy-support__primary', onClick: () => void controller?.showRequests(), children: strings.requests })] }), state.view === 'requests' && jsxs(Fragment, { children: [jsx("h2", { children: strings.requests }), state.tickets.map(ticketRow), !state.busy && !state.error && !state.tickets.length && jsx("p", { className: 'andy-support__muted', children: strings.empty })] }), state.view === 'request' && state.ticket && jsxs(Fragment, { children: [jsxs("h2", { children: ["#", state.ticket.number, " \u00B7 ", state.ticket.subject] }), 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 => jsxs("article", { className: 'andy-support__entry', children: [jsx("strong", { children: entry.authorName }), jsx("p", { children: entry.body })] }, entry.id)), state.ticket.status === 'resolved' && !reopening && jsxs("div", { className: 'andy-support__request-actions', children: [jsx("button", { type: 'button', className: 'andy-support__primary', onClick: () => setReopening(true), children: strings.reopen }), jsx("button", { type: 'button', className: 'andy-support__secondary', onClick: newProblem, children: strings.otherProblem })] })] }), state.error && jsxs("div", { className: 'andy-support__error', role: 'alert', children: [jsx("p", { children: errorText }), (!state.config || state.view === 'form' || state.view === 'requests' || (state.view === 'request' && state.error !== 'ticket_resolved' && (selectedTicketId || support.ticketId))) && jsx("button", { type: 'button', onClick: retry, disabled: state.busy, children: strings.retry })] })] }), state.view === 'conversation' && jsxs("form", { className: 'andy-support__composer', onSubmit: event => { event.preventDefault(); send(); }, children: [jsx("input", { "aria-label": strings.placeholder, placeholder: strings.placeholder, value: draft, maxLength: 10000, disabled: !state.config, onChange: event => setDraft(event.target.value) }), 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) && jsxs("form", { className: 'andy-support__followup', onSubmit: event => { event.preventDefault(); void followup(); }, children: [jsxs("label", { children: [reopening ? strings.reopening : strings.reply, 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) })] }), jsx("button", { type: 'submit', className: 'andy-support__primary', disabled: state.busy || !replyDraft.trim(), children: state.busy ? strings.loading : reopening ? strings.reopen : strings.send })] }), jsxs("footer", { className: 'andy-support__footer', children: [jsx("span", { children: strings.powered }), state.config?.branding.footer.map(link => jsx("a", { href: sanitizeUrl(link.url), target: '_blank', rel: 'noopener noreferrer', children: link.label }, link.url))] })] })] }), portal) });
4098
4133
  };
4099
4134
  /** A host identity change also destroys local drafts and menu state before the next render. */
4100
4135
  const SupportChat = props => jsx(SupportPanel, { ...props }, JSON.stringify([props.support.installationId, props.support.contextKey, props.support.apiBaseUrl]));
@@ -4637,7 +4672,7 @@ const SalesAndyChat = ({ embedId, tooltip, name, headline, description, welcomeM
4637
4672
  bubble.className = 'andy-chat-bubble';
4638
4673
  bubble.setAttribute('aria-label', strings.openChat);
4639
4674
  const tooltipHTML = tooltip
4640
- ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${tooltip}</span>`
4675
+ ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${escapeHtml(tooltip)}</span>`
4641
4676
  : '';
4642
4677
  bubble.innerHTML = `${CHAT_BUBBLE_DEFAULT_ICON}${tooltipHTML}`;
4643
4678
  shadowRef.current.appendChild(bubble);
@@ -4662,7 +4697,7 @@ const SalesAndyChat = ({ embedId, tooltip, name, headline, description, welcomeM
4662
4697
  bubble.classList.add('andy-chat-bubble--loaded');
4663
4698
  }
4664
4699
  const tooltipHTML = !isOpen && tooltip && !isPillStyle
4665
- ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${tooltip}</span>`
4700
+ ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${escapeHtml(tooltip)}</span>`
4666
4701
  : '';
4667
4702
  const configUseBrandColor = config?.useBrandColor ?? true;
4668
4703
  const isDarkTheme = config?.theme === 'dark';
@@ -4675,13 +4710,13 @@ const SalesAndyChat = ({ embedId, tooltip, name, headline, description, welcomeM
4675
4710
  }
4676
4711
  else if (isPillStyle && displayLabel) {
4677
4712
  bubble.innerHTML = `
4678
- <span class="andy-chat-bubble__label">${displayLabel}</span>
4679
- <img src="${bubbleLogoUrl}" alt="Chat" class="andy-chat-bubble__icon--pill" style="${darkModeIconStyle}" />
4713
+ <span class="andy-chat-bubble__label">${escapeHtml(displayLabel)}</span>
4714
+ <img src="${escapeHtml(bubbleLogoUrl)}" alt="Chat" class="andy-chat-bubble__icon--pill" style="${darkModeIconStyle}" />
4680
4715
  `;
4681
4716
  }
4682
4717
  else {
4683
4718
  if (config?.logoUrl) {
4684
- bubble.innerHTML = `<img src="${config.logoUrl}" alt="Chatbot Logo" class="andy-chat-bubble__logo" style="${darkModeIconStyle}" />${tooltipHTML}`;
4719
+ bubble.innerHTML = `<img src="${escapeHtml(config.logoUrl)}" alt="Chatbot Logo" class="andy-chat-bubble__logo" style="${darkModeIconStyle}" />${tooltipHTML}`;
4685
4720
  }
4686
4721
  else {
4687
4722
  const defaultIconWithStyle = CHAT_BUBBLE_DEFAULT_ICON.replace('class="andy-chat-bubble__icon"', `class="andy-chat-bubble__icon" style="${darkModeIconStyle}"`);
@@ -5160,3 +5195,4 @@ const UserInfoForm = ({ headerText, onSubmit, onClose }) => {
5160
5195
  };
5161
5196
 
5162
5197
  export { AndyChat as Andy, AndyChat, ChatBubble, ChatWindow, FAQ, MessageInput, MessageList, UserInfoForm, WelcomePopup, animation, borderRadius, chat, colors, AndyChat as default, generateSessionId, getApiBaseUrl, getEmbedId, isValidEmbedId, parseMarkdown, sanitizeUrl, setApiBaseUrl, shadows, spacing, theme, typography, useAndyChat, zIndex };
5198
+ //# sourceMappingURL=index.esm.js.map