andy-widget-react 2.3.0 → 2.3.1
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 +1 -1
- package/dist/index.cjs +24 -8
- package/dist/index.esm.js +24 -8
- package/dist/types/hooks/useSupportChat.d.ts +1 -0
- package/package.json +2 -2
- package/src/SupportChat.tsx +5 -5
- package/src/hooks/useSupportChat.ts +4 -2
package/README.md
CHANGED
|
@@ -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
|
@@ -3773,6 +3773,11 @@ const createSupportController = options => {
|
|
|
3773
3773
|
let following = null;
|
|
3774
3774
|
let visible = true;
|
|
3775
3775
|
let timer = null;
|
|
3776
|
+
let lang = options.lang;
|
|
3777
|
+
let bootstrapConfig = null;
|
|
3778
|
+
const localizedConfig = () => lang && bootstrapConfig.defaultLang && lang !== bootstrapConfig.defaultLang
|
|
3779
|
+
? { ...bootstrapConfig, greeting: lang === 'en' ? 'How can we help?' : '¿Cómo podemos ayudarte?', questions: [] }
|
|
3780
|
+
: bootstrapConfig;
|
|
3776
3781
|
const schedule = () => {
|
|
3777
3782
|
clearTimeout(timer);
|
|
3778
3783
|
if (!disposed && state.config && listeners.size) {
|
|
@@ -3852,8 +3857,10 @@ const createSupportController = options => {
|
|
|
3852
3857
|
};
|
|
3853
3858
|
const transcript = () => state.messages.map(({ role, content }) => ({ role, content }));
|
|
3854
3859
|
const bootstrap = () => request(async (signal, epoch) => {
|
|
3855
|
-
const
|
|
3860
|
+
const bootstrap = await client.bootstrap(signal);
|
|
3856
3861
|
if (generation !== epoch || signal.aborted) return
|
|
3862
|
+
bootstrapConfig = bootstrap;
|
|
3863
|
+
const config = localizedConfig();
|
|
3857
3864
|
publish({ config, loading: false, messages: [{ role: 'assistant', content: config.greeting }] });
|
|
3858
3865
|
});
|
|
3859
3866
|
const send = async content => {
|
|
@@ -3928,6 +3935,13 @@ const createSupportController = options => {
|
|
|
3928
3935
|
getSnapshot: () => state,
|
|
3929
3936
|
subscribe: listener => { listeners.add(listener); schedule(); return () => { listeners.delete(listener); if (!listeners.size) clearTimeout(timer); } },
|
|
3930
3937
|
bootstrap, send, escalate, submit, showRequests, inspect, newConversation, refresh, reply, newProblem,
|
|
3938
|
+
setLanguage: value => {
|
|
3939
|
+
lang = value;
|
|
3940
|
+
if (!state.config) return
|
|
3941
|
+
const config = localizedConfig();
|
|
3942
|
+
// A started conversation and consented transcript keep their original text.
|
|
3943
|
+
publish({ config, ...(state.view === 'conversation' && state.messages.length === 1 ? { messages: [{ role: 'assistant', content: config.greeting }] } : {}) });
|
|
3944
|
+
},
|
|
3931
3945
|
setVisible: value => { visible = value; if (value) void refresh(); },
|
|
3932
3946
|
back: () => { abort(); publish({ view: 'conversation', busy: false, error: null, followup: null }); schedule(); },
|
|
3933
3947
|
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 +4033,23 @@ const useSupportChat = (options) => {
|
|
|
4019
4033
|
void controller.bootstrap();
|
|
4020
4034
|
return () => controller.dispose();
|
|
4021
4035
|
}, [key]);
|
|
4036
|
+
react.useEffect(() => { controller?.setLanguage(options?.lang); }, [controller, options?.lang]);
|
|
4037
|
+
const ready = !!state.config;
|
|
4022
4038
|
react.useEffect(() => {
|
|
4023
|
-
if (
|
|
4039
|
+
if (ready && options?.ticketId)
|
|
4024
4040
|
void controller?.inspect(options.ticketId);
|
|
4025
|
-
}, [controller,
|
|
4041
|
+
}, [controller, ready, options?.ticketId]);
|
|
4026
4042
|
return { mode: 'support', state, controller };
|
|
4027
4043
|
};
|
|
4028
4044
|
|
|
4029
4045
|
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' }
|
|
4046
|
+
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' },
|
|
4047
|
+
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
4048
|
};
|
|
4033
4049
|
/** Private Requester UI. Sales bootstrap, visitor identity and storage never enter this renderer. */
|
|
4034
4050
|
const SupportPanel = ({ support, lang, orientation = 'right', initialOpen = false, tooltip }) => {
|
|
4035
|
-
const { state, controller } = useSupportChat(support);
|
|
4036
|
-
const replyLanguage = lang || state.config?.defaultLang || 'es';
|
|
4051
|
+
const { state, controller } = useSupportChat({ ...support, lang: lang || support.lang });
|
|
4052
|
+
const replyLanguage = lang || support.lang || state.config?.defaultLang || 'es';
|
|
4037
4053
|
const strings = copy[replyLanguage];
|
|
4038
4054
|
const [open, setOpen] = react.useState(initialOpen);
|
|
4039
4055
|
const [menu, setMenu] = react.useState(false);
|
|
@@ -4098,7 +4114,7 @@ const SupportPanel = ({ support, lang, orientation = 'right', initialOpen = fals
|
|
|
4098
4114
|
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
4115
|
const promise = state.ticket?.replyPromise || state.config?.replyPromise;
|
|
4100
4116
|
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) });
|
|
4117
|
+
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: link.url, target: '_blank', rel: 'noopener noreferrer', children: link.label }, link.url))] })] })] }), portal) });
|
|
4102
4118
|
};
|
|
4103
4119
|
/** A host identity change also destroys local drafts and menu state before the next render. */
|
|
4104
4120
|
const SupportChat = props => jsxRuntime.jsx(SupportPanel, { ...props }, JSON.stringify([props.support.installationId, props.support.contextKey, props.support.apiBaseUrl]));
|
package/dist/index.esm.js
CHANGED
|
@@ -3769,6 +3769,11 @@ const createSupportController = options => {
|
|
|
3769
3769
|
let following = null;
|
|
3770
3770
|
let visible = true;
|
|
3771
3771
|
let timer = null;
|
|
3772
|
+
let lang = options.lang;
|
|
3773
|
+
let bootstrapConfig = null;
|
|
3774
|
+
const localizedConfig = () => lang && bootstrapConfig.defaultLang && lang !== bootstrapConfig.defaultLang
|
|
3775
|
+
? { ...bootstrapConfig, greeting: lang === 'en' ? 'How can we help?' : '¿Cómo podemos ayudarte?', questions: [] }
|
|
3776
|
+
: bootstrapConfig;
|
|
3772
3777
|
const schedule = () => {
|
|
3773
3778
|
clearTimeout(timer);
|
|
3774
3779
|
if (!disposed && state.config && listeners.size) {
|
|
@@ -3848,8 +3853,10 @@ const createSupportController = options => {
|
|
|
3848
3853
|
};
|
|
3849
3854
|
const transcript = () => state.messages.map(({ role, content }) => ({ role, content }));
|
|
3850
3855
|
const bootstrap = () => request(async (signal, epoch) => {
|
|
3851
|
-
const
|
|
3856
|
+
const bootstrap = await client.bootstrap(signal);
|
|
3852
3857
|
if (generation !== epoch || signal.aborted) return
|
|
3858
|
+
bootstrapConfig = bootstrap;
|
|
3859
|
+
const config = localizedConfig();
|
|
3853
3860
|
publish({ config, loading: false, messages: [{ role: 'assistant', content: config.greeting }] });
|
|
3854
3861
|
});
|
|
3855
3862
|
const send = async content => {
|
|
@@ -3924,6 +3931,13 @@ const createSupportController = options => {
|
|
|
3924
3931
|
getSnapshot: () => state,
|
|
3925
3932
|
subscribe: listener => { listeners.add(listener); schedule(); return () => { listeners.delete(listener); if (!listeners.size) clearTimeout(timer); } },
|
|
3926
3933
|
bootstrap, send, escalate, submit, showRequests, inspect, newConversation, refresh, reply, newProblem,
|
|
3934
|
+
setLanguage: value => {
|
|
3935
|
+
lang = value;
|
|
3936
|
+
if (!state.config) return
|
|
3937
|
+
const config = localizedConfig();
|
|
3938
|
+
// A started conversation and consented transcript keep their original text.
|
|
3939
|
+
publish({ config, ...(state.view === 'conversation' && state.messages.length === 1 ? { messages: [{ role: 'assistant', content: config.greeting }] } : {}) });
|
|
3940
|
+
},
|
|
3927
3941
|
setVisible: value => { visible = value; if (value) void refresh(); },
|
|
3928
3942
|
back: () => { abort(); publish({ view: 'conversation', busy: false, error: null, followup: null }); schedule(); },
|
|
3929
3943
|
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 +4029,23 @@ const useSupportChat = (options) => {
|
|
|
4015
4029
|
void controller.bootstrap();
|
|
4016
4030
|
return () => controller.dispose();
|
|
4017
4031
|
}, [key]);
|
|
4032
|
+
useEffect(() => { controller?.setLanguage(options?.lang); }, [controller, options?.lang]);
|
|
4033
|
+
const ready = !!state.config;
|
|
4018
4034
|
useEffect(() => {
|
|
4019
|
-
if (
|
|
4035
|
+
if (ready && options?.ticketId)
|
|
4020
4036
|
void controller?.inspect(options.ticketId);
|
|
4021
|
-
}, [controller,
|
|
4037
|
+
}, [controller, ready, options?.ticketId]);
|
|
4022
4038
|
return { mode: 'support', state, controller };
|
|
4023
4039
|
};
|
|
4024
4040
|
|
|
4025
4041
|
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' }
|
|
4042
|
+
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' },
|
|
4043
|
+
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
4044
|
};
|
|
4029
4045
|
/** Private Requester UI. Sales bootstrap, visitor identity and storage never enter this renderer. */
|
|
4030
4046
|
const SupportPanel = ({ support, lang, orientation = 'right', initialOpen = false, tooltip }) => {
|
|
4031
|
-
const { state, controller } = useSupportChat(support);
|
|
4032
|
-
const replyLanguage = lang || state.config?.defaultLang || 'es';
|
|
4047
|
+
const { state, controller } = useSupportChat({ ...support, lang: lang || support.lang });
|
|
4048
|
+
const replyLanguage = lang || support.lang || state.config?.defaultLang || 'es';
|
|
4033
4049
|
const strings = copy[replyLanguage];
|
|
4034
4050
|
const [open, setOpen] = useState(initialOpen);
|
|
4035
4051
|
const [menu, setMenu] = useState(false);
|
|
@@ -4094,7 +4110,7 @@ const SupportPanel = ({ support, lang, orientation = 'right', initialOpen = fals
|
|
|
4094
4110
|
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
4111
|
const promise = state.ticket?.replyPromise || state.config?.replyPromise;
|
|
4096
4112
|
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) });
|
|
4113
|
+
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: link.url, target: '_blank', rel: 'noopener noreferrer', children: link.label }, link.url))] })] })] }), portal) });
|
|
4098
4114
|
};
|
|
4099
4115
|
/** A host identity change also destroys local drafts and menu state before the next render. */
|
|
4100
4116
|
const SupportChat = props => jsx(SupportPanel, { ...props }, JSON.stringify([props.support.installationId, props.support.contextKey, props.support.apiBaseUrl]));
|
|
@@ -16,6 +16,7 @@ export declare const useSupportChat: (options: SupportOptions | null) => {
|
|
|
16
16
|
newProblem: () => void;
|
|
17
17
|
refresh: () => Promise<unknown>;
|
|
18
18
|
setVisible: (visible: boolean) => void;
|
|
19
|
+
setLanguage: (lang?: "es" | "en") => void;
|
|
19
20
|
newConversation: () => void;
|
|
20
21
|
back: () => void;
|
|
21
22
|
dispose: () => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "andy-widget-react",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "Official React Widget for embeddable Andy agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -67,6 +67,6 @@
|
|
|
67
67
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"andy-widget-core": "2.3.
|
|
70
|
+
"andy-widget-core": "2.3.1"
|
|
71
71
|
}
|
|
72
72
|
}
|
package/src/SupportChat.tsx
CHANGED
|
@@ -5,14 +5,14 @@ import type { SupportAndyChatProps } from './types'
|
|
|
5
5
|
import { useSupportChat } from './hooks/useSupportChat'
|
|
6
6
|
|
|
7
7
|
const copy = {
|
|
8
|
-
es: { support: 'Soporte', close: 'Cerrar', back: 'Volver', menu: 'Menú', new: 'Nueva conversación', team: 'Hablar con el equipo', requests: 'Mis solicitudes', placeholder: '¿En qué te podemos ayudar?', send: 'Enviar', subject: 'Asunto', detail: 'Cuéntanos qué ocurrió', notice: 'Se adjuntará esta conversación', consent: 'Al enviar, aceptas adjuntar esta conversación para que el equipo pueda ayudarte.', submit: 'Enviar solicitud', receipt: 'Solicitud enviada', promise: 'El equipo responderá en', businessDay: 'un día hábil', hours: 'horas hábiles', minutes: 'minutos hábiles', empty: 'Todavía no tienes solicitudes.', powered: 'Con tecnología de Andy', loading: 'Cargando…', retry: 'Reintentar', error: 'No pudimos completar la solicitud. Puedes reintentar.', auth: 'Tu sesión de soporte no está disponible. Vuelve a iniciar sesión en esta aplicación.', denied: 'El soporte no está disponible para esta cuenta.', transcript: 'mensajes', reply: 'Escribe tu respuesta', reopen: 'Reabrir', reopening: 'Cuéntanos qué volvió a ocurrir', otherProblem: 'Es otro problema', reopened: 'Reabierto', unread: 'Respuestas sin leer', resolvedReply: 'Esta solicitud está resuelta. Reábrela para continuar con el mismo problema.', open: 'Recibido', in_progress: 'En curso', waiting_customer: 'Espera tu respuesta', resolved: 'Resuelto' },
|
|
9
|
-
en: { support: 'Support', close: 'Close', back: 'Back', menu: 'Menu', new: 'New conversation', team: 'Talk to the team', requests: 'My requests', placeholder: 'How can we help?', send: 'Send', subject: 'Subject', detail: 'Tell us what happened', notice: 'This conversation will be attached', consent: 'By sending, you agree to attach this conversation so the team can help you.', submit: 'Send request', receipt: 'Request sent', promise: 'The team will reply within', businessDay: 'one business day', hours: 'business hours', minutes: 'business minutes', empty: 'You have no requests yet.', powered: 'Powered by Andy', loading: 'Loading…', retry: 'Retry', error: 'We could not complete the request. You can retry.', auth: 'Your support session is unavailable. Sign in again in this application.', denied: 'Support is unavailable for this account.', transcript: 'messages', reply: 'Write your reply', reopen: 'Reopen', reopening: 'Tell us what happened again', otherProblem: 'This is another problem', reopened: 'Reopened', unread: 'Unread replies', resolvedReply: 'This request is resolved. Reopen it to continue with the same problem.', open: 'Received', in_progress: 'In progress', waiting_customer: 'Waiting for your reply', resolved: 'Resolved' }
|
|
8
|
+
es: { support: 'Soporte', close: 'Cerrar', back: 'Volver', menu: 'Menú', new: 'Nueva conversación', team: 'Hablar con el equipo', requests: 'Mis solicitudes', placeholder: '¿En qué te podemos ayudar?', send: 'Enviar', subject: 'Asunto', detail: 'Cuéntanos qué ocurrió', notice: 'Se adjuntará esta conversación', consent: 'Al enviar, aceptas adjuntar esta conversación para que el equipo pueda ayudarte.', submit: 'Enviar solicitud', receipt: 'Solicitud enviada', promise: 'El equipo responderá en', businessDay: 'un día hábil', hours: 'horas hábiles', minutes: 'minutos hábiles', empty: 'Todavía no tienes solicitudes.', powered: 'Con tecnología de Andy', loading: 'Cargando…', retry: 'Reintentar', error: 'No pudimos completar la solicitud. Puedes reintentar.', auth: 'Tu sesión de soporte no está disponible. Vuelve a iniciar sesión en esta aplicación.', denied: 'El soporte no está disponible para esta cuenta.', transcript: 'mensajes', transcriptSingular: 'mensaje', reply: 'Escribe tu respuesta', reopen: 'Reabrir', reopening: 'Cuéntanos qué volvió a ocurrir', otherProblem: 'Es otro problema', reopened: 'Reabierto', unread: 'Respuestas sin leer', resolvedReply: 'Esta solicitud está resuelta. Reábrela para continuar con el mismo problema.', open: 'Recibido', in_progress: 'En curso', waiting_customer: 'Espera tu respuesta', resolved: 'Resuelto' },
|
|
9
|
+
en: { support: 'Support', close: 'Close', back: 'Back', menu: 'Menu', new: 'New conversation', team: 'Talk to the team', requests: 'My requests', placeholder: 'How can we help?', send: 'Send', subject: 'Subject', detail: 'Tell us what happened', notice: 'This conversation will be attached', consent: 'By sending, you agree to attach this conversation so the team can help you.', submit: 'Send request', receipt: 'Request sent', promise: 'The team will reply within', businessDay: 'one business day', hours: 'business hours', minutes: 'business minutes', empty: 'You have no requests yet.', powered: 'Powered by Andy', loading: 'Loading…', retry: 'Retry', error: 'We could not complete the request. You can retry.', auth: 'Your support session is unavailable. Sign in again in this application.', denied: 'Support is unavailable for this account.', transcript: 'messages', transcriptSingular: 'message', reply: 'Write your reply', reopen: 'Reopen', reopening: 'Tell us what happened again', otherProblem: 'This is another problem', reopened: 'Reopened', unread: 'Unread replies', resolvedReply: 'This request is resolved. Reopen it to continue with the same problem.', open: 'Received', in_progress: 'In progress', waiting_customer: 'Waiting for your reply', resolved: 'Resolved' }
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/** Private Requester UI. Sales bootstrap, visitor identity and storage never enter this renderer. */
|
|
13
13
|
const SupportPanel: React.FC<SupportAndyChatProps> = ({ support, lang, orientation = 'right', initialOpen = false, tooltip }) => {
|
|
14
|
-
const { state, controller } = useSupportChat(support)
|
|
15
|
-
const replyLanguage = lang || state.config?.defaultLang || 'es'
|
|
14
|
+
const { state, controller } = useSupportChat({ ...support, lang: lang || support.lang })
|
|
15
|
+
const replyLanguage = lang || support.lang || state.config?.defaultLang || 'es'
|
|
16
16
|
const strings = copy[replyLanguage]
|
|
17
17
|
const [open, setOpen] = useState(initialOpen)
|
|
18
18
|
const [menu, setMenu] = useState(false)
|
|
@@ -85,7 +85,7 @@ const SupportPanel: React.FC<SupportAndyChatProps> = ({ support, lang, orientati
|
|
|
85
85
|
</>}
|
|
86
86
|
{state.view === 'form' && <form className='andy-support__form' onSubmit={event => { event.preventDefault(); void controller?.submit(subject, body, replyLanguage) }}>
|
|
87
87
|
<h2>{strings.team}</h2><label>{strings.subject}<input required maxLength={200} value={state.submission?.subject ?? subject} readOnly={!!state.submission} disabled={state.busy} onChange={event => setSubject(event.target.value)} /></label><label>{strings.detail}<textarea required maxLength={20000} rows={5} value={state.submission?.body ?? body} readOnly={!!state.submission} disabled={state.busy} onChange={event => setBody(event.target.value)} /></label>
|
|
88
|
-
<details className='andy-support__transcript'><summary>{strings.notice} · {state.transcript.length} {strings.transcript}</summary>{state.transcript.map((message, index) => <p key={index}>{message.content}</p>)}</details><p className='andy-support__muted'>{strings.consent}</p><button type='submit' className='andy-support__primary' disabled={state.busy || !subject.trim() || !body.trim()}>{state.busy ? strings.loading : strings.submit}</button>
|
|
88
|
+
<details className='andy-support__transcript'><summary>{strings.notice} · {state.transcript.length} {state.transcript.length === 1 ? strings.transcriptSingular : strings.transcript}</summary>{state.transcript.map((message, index) => <p key={index}>{message.content}</p>)}</details><p className='andy-support__muted'>{strings.consent}</p><button type='submit' className='andy-support__primary' disabled={state.busy || !subject.trim() || !body.trim()}>{state.busy ? strings.loading : strings.submit}</button>
|
|
89
89
|
</form>}
|
|
90
90
|
{state.view === 'receipt' && state.ticket && <div className='andy-support__receipt'><span className='andy-support__check' aria-hidden='true'>✓</span><h2>{strings.receipt}</h2><strong>#{state.ticket.number}</strong><p>{state.ticket.subject}</p>{promiseText && <p>{strings.promise} <strong>{promiseText}</strong>.</p>}<button type='button' className='andy-support__primary' onClick={() => void controller?.showRequests()}>{strings.requests}</button></div>}
|
|
91
91
|
{state.view === 'requests' && <><h2>{strings.requests}</h2>{state.tickets.map(ticketRow)}{!state.busy && !state.error && !state.tickets.length && <p className='andy-support__muted'>{strings.empty}</p>}</>}
|
|
@@ -20,8 +20,10 @@ export const useSupportChat = (options: SupportOptions | null) => {
|
|
|
20
20
|
void controller.bootstrap()
|
|
21
21
|
return () => controller.dispose()
|
|
22
22
|
}, [key])
|
|
23
|
+
useEffect(() => { controller?.setLanguage(options?.lang) }, [controller, options?.lang])
|
|
24
|
+
const ready = !!state.config
|
|
23
25
|
useEffect(() => {
|
|
24
|
-
if (
|
|
25
|
-
}, [controller,
|
|
26
|
+
if (ready && options?.ticketId) void controller?.inspect(options.ticketId)
|
|
27
|
+
}, [controller, ready, options?.ticketId])
|
|
26
28
|
return { mode: 'support' as const, state, controller }
|
|
27
29
|
}
|