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 +2 -2
- package/dist/index.cjs +52 -16
- package/dist/index.cjs.map +1 -0
- package/dist/index.esm.js +52 -16
- package/dist/index.esm.js.map +1 -0
- package/dist/types/hooks/useSupportChat.d.ts +1 -0
- package/package.json +2 -2
- package/src/AndyChat.tsx +6 -5
- package/src/SupportChat.tsx +7 -7
- package/src/hooks/useSupportChat.ts +4 -2
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
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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
|
|
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 (
|
|
4058
|
+
if (ready && options?.ticketId)
|
|
4024
4059
|
void controller?.inspect(options.ticketId);
|
|
4025
|
-
}, [controller,
|
|
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
|