andy-widget-react 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,6 +65,34 @@ capture are disabled in this release because there is no durable public submissi
65
65
  This release intentionally has no Widget analytics or event callback API. Use Andy's existing
66
66
  conversation analytics rather than observing the Shadow DOM.
67
67
 
68
+ ## Authenticated Support
69
+
70
+ Support requires a configured host installation and a server-provisioned Support Agent. Use the accepted core/React package pair; this source addition is unpublished until the coordinated release is approved.
71
+
72
+ ```tsx
73
+ <AndyChat
74
+ mode='support'
75
+ lang='es'
76
+ support={{
77
+ installationId: hostInstallationId,
78
+ contextKey: `${signedInUserId}:${selectedCompanyId}`,
79
+ getRequesterPass: async () => {
80
+ const response = await fetch('/api/andy/requester-pass', { cache: 'no-store' })
81
+ if (!response.ok) throw new Error('Support authentication unavailable')
82
+ return (await response.json()).pass
83
+ }
84
+ }}
85
+ />
86
+ ```
87
+
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
+
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.
91
+
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
+
94
+ The panel captures its displayed `lang` on the first consented open as the ticket's Reply Email preference. A retry keeps that original choice even if the host changes language. Custom presentations call `controller.submit(subject, body, 'es' | 'en')` with their displayed language; omission defaults to Spanish. This copy preference never enters the signed pass or provider configuration.
95
+
68
96
  ## Advanced exports
69
97
 
70
98
  The package also exports `useAndyChat`, individual presentation components, its public TypeScript
@@ -79,3 +107,10 @@ legacy hosted `/widget.js` redirect for new integrations.
79
107
  ## License
80
108
 
81
109
  MIT
110
+
111
+
112
+ To handle a Reply Email link, pass the host URL's ticket parameter as `support.ticketId` after normal host sign-in. The widget opens that request only after Andy authorizes the current Requester/company. On another company or user, keep changing `contextKey`; on sign-out, unmount. Never supply an old pass as URL data.
113
+
114
+ The human thread follows replies and status changes without a page reload. Private polling runs every three seconds while subscribed and pauses in hidden documents. The launcher and My requests show unread replies. Opening a visible thread marks only its displayed public entries seen; closing it preserves unread replies. Resolved requests offer Reopen with a message for the same problem, or This is another problem for a fresh consented request. Reply and reopen retries retain their original body and request key.
115
+
116
+ Custom presentations also use `controller.reply(body, reopen?)`, `newProblem()` and `refresh()`. Call `setVisible(false)` when a displayed thread is closed or hidden, and `setVisible(true)` when it is being read. Subscription disposal stops automatic refresh. The controller's `unreadCount`, Ticket sequence marks and public entries carry the same behavior as the mounted panel.
package/dist/index.cjs CHANGED
@@ -775,6 +775,11 @@ const sendMessageForWidget = async (embedId, sessionId, message, options = {}) =
775
775
  messages: [{ role: 'user', parts: [{ type: 'text', text: message }] }]
776
776
  })
777
777
  });
778
+ return consumeWidgetReply(response, options)
779
+ };
780
+
781
+ /** Shared text-only UI stream decoder; no retry can occur after reading starts. */
782
+ const consumeWidgetReply = async (response, options = {}) => {
778
783
  if (!response.ok || !response.headers.get('content-type')?.includes('text/event-stream')) {
779
784
  const data = await response.json().catch(() => null);
780
785
  throw new Error(data?.error || data?.message || 'Chat is temporarily unavailable.')
@@ -815,6 +820,7 @@ const sendMessageForWidget = async (embedId, sessionId, message, options = {}) =
815
820
  };
816
821
  try {
817
822
  while (!complete) {
823
+ options.signal?.throwIfAborted();
818
824
  const { value, done } = await reader.read();
819
825
  buffer += decoder.decode(value, { stream: !done });
820
826
  let newline;
@@ -3679,7 +3685,425 @@ const createBrowserHandoff = (chatbotId, bootstrap, onProjection, fresh = false)
3679
3685
  }
3680
3686
  };
3681
3687
 
3682
- const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessage, bubbleLabel, lang, predefinedQuestions, theme, orientation, brandColor, bubbleStyle, useBrandColor, logoUrl, previewMode = false, initialOpen = false }) => {
3688
+ /** In-page Requester credentials. A failed response is renewed once, before its body is consumed. */
3689
+ const createRequesterFetch = getRequesterPass => {
3690
+ let pass = null;
3691
+ let loading = null;
3692
+ let revision = 0;
3693
+ const obtain = async () => {
3694
+ const current = revision;
3695
+ if (!loading) {
3696
+ const pending = Promise.resolve().then(getRequesterPass).then(value => {
3697
+ if (current !== revision || typeof value !== 'string' || !value.trim()) throw new Error('Requester authentication is unavailable.')
3698
+ pass = value;
3699
+ return value
3700
+ }).catch(() => { throw new Error('Requester authentication is unavailable.') }).finally(() => { if (loading === pending) loading = null; });
3701
+ loading = pending;
3702
+ }
3703
+ return loading
3704
+ };
3705
+ const requesterFetch = async (url, options = {}) => {
3706
+ const current = revision;
3707
+ const headers = new Headers(options.headers);
3708
+ if (headers.has('authorization')) throw new Error('Requester requests cannot use operator authorization.')
3709
+ const attempt = async credential => {
3710
+ options.signal?.throwIfAborted();
3711
+ if (current !== revision) throw new DOMException('Requester context changed.', 'AbortError')
3712
+ const selected = new Headers(headers);
3713
+ selected.set('X-Andy-Requester', credential);
3714
+ const response = await fetch(url, { ...options, headers: selected, credentials: 'omit', cache: 'no-store' });
3715
+ if (current !== revision) throw new DOMException('Requester context changed.', 'AbortError')
3716
+ return response
3717
+ };
3718
+ let response = await attempt(pass || await obtain());
3719
+ if (!response.ok) {
3720
+ const problem = await response.clone().json().catch(() => null);
3721
+ // Private help and canonical Ticket HTTP use their existing problem envelopes.
3722
+ if (['expired', 'unknown_key'].includes(problem?.problem?.code ?? problem?.code)) {
3723
+ pass = null;
3724
+ response = await attempt(await obtain());
3725
+ }
3726
+ }
3727
+ return response
3728
+ };
3729
+ requesterFetch.clear = () => { revision++; pass = null; loading = null; };
3730
+ return requesterFetch
3731
+ };
3732
+
3733
+ const createSupportClient = ({ installationId, getRequesterPass, apiBaseUrl }) => {
3734
+ if (!/^[a-zA-Z0-9_-]{3,100}$/.test(installationId)) throw new Error('A Support installation is required.')
3735
+ const base = (apiBaseUrl || getApiBaseUrl()).replace(/\/+$/, '');
3736
+ const requesterFetch = createRequesterFetch(getRequesterPass);
3737
+ const widgetUrl = `${base}/support/widget/${encodeURIComponent(installationId)}`;
3738
+ const json = async response => {
3739
+ const result = await response.json().catch(() => null);
3740
+ if (!response.ok || result?.ok === false) {
3741
+ const error = new Error('Support is temporarily unavailable. Please retry.');
3742
+ error.code = result?.problem?.code || result?.code || 'unavailable';
3743
+ throw error
3744
+ }
3745
+ return result
3746
+ };
3747
+ return {
3748
+ bootstrap: signal => requesterFetch(widgetUrl, { signal }).then(json),
3749
+ answer: async (messages, options = {}) => {
3750
+ const response = await requesterFetch(widgetUrl, {
3751
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: options.signal,
3752
+ body: JSON.stringify({ messages })
3753
+ });
3754
+ if (!response.ok) await json(response);
3755
+ return consumeWidgetReply(response, options)
3756
+ },
3757
+ operation: async (action, input, signal) => json(await requesterFetch(`${base}/public/operations/support/request/${action}`, {
3758
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, signal, body: JSON.stringify(input)
3759
+ })),
3760
+ dispose: () => requesterFetch.clear()
3761
+ }
3762
+ };
3763
+
3764
+ /** One in-memory conversation/form receipt lifecycle shared by React renderers and hooks. */
3765
+ const createSupportController = options => {
3766
+ const client = createSupportClient(options);
3767
+ const listeners = new Set();
3768
+ let state = { config: null, view: 'conversation', messages: [], transcript: [], tickets: [], ticket: null, entries: [], loading: true, busy: false, error: null, submission: null, followup: null, unreadCount: 0 };
3769
+ let active = null;
3770
+ let generation = 0;
3771
+ let disposed = false;
3772
+ let submitting = null;
3773
+ let following = null;
3774
+ let visible = true;
3775
+ let timer = null;
3776
+ const schedule = () => {
3777
+ clearTimeout(timer);
3778
+ if (!disposed && state.config && listeners.size) {
3779
+ timer = setTimeout(() => void refresh(), 3000);
3780
+ timer.unref?.();
3781
+ }
3782
+ };
3783
+ const publish = changes => {
3784
+ if (disposed) return
3785
+ state = { ...state, ...changes };
3786
+ listeners.forEach(listener => listener());
3787
+ };
3788
+ const abort = () => { generation++; active?.abort(); active = null; clearTimeout(timer); };
3789
+ const fail = error => {
3790
+ if (error?.code === 'resource_not_found' && state.view === 'request') publish({ ticket: null, entries: [], followup: null });
3791
+ if (error?.code === 'ticket_resolved' && state.ticket) {
3792
+ updateTicket({ ...state.ticket, status: 'resolved' });
3793
+ publish({ followup: null });
3794
+ }
3795
+ publish({ error: error?.code || 'unavailable', busy: false, loading: false });
3796
+ };
3797
+ const request = async (work, background = false) => {
3798
+ abort();
3799
+ const epoch = generation;
3800
+ const controller = new AbortController();
3801
+ active = controller;
3802
+ if (!background) publish({ busy: true, error: null });
3803
+ try {
3804
+ const result = await work(controller.signal, epoch);
3805
+ if (disposed || generation !== epoch || controller.signal.aborted) return
3806
+ return result
3807
+ } catch (error) {
3808
+ if (!controller.signal.aborted && generation === epoch) fail(error);
3809
+ } finally {
3810
+ if (active === controller) { active = null; if (!background) publish({ busy: false }); schedule(); }
3811
+ }
3812
+ };
3813
+ const current = (signal, epoch) => !disposed && generation === epoch && !signal.aborted;
3814
+ const page = async (action, input, signal, field) => {
3815
+ let result, cursor;
3816
+ const values = [];
3817
+ do {
3818
+ result = await client.operation(action, { ...input, pageSize: 100, ...(cursor ? { cursor } : {}) }, signal);
3819
+ values.push(...result.data[field]);
3820
+ cursor = result.meta?.nextCursor;
3821
+ } while (cursor && !signal.aborted)
3822
+ return { ...result.data, [field]: values }
3823
+ };
3824
+ const updateTicket = ticket => {
3825
+ const tickets = state.tickets.map(previous => previous.id === ticket.id ? ticket : previous);
3826
+ publish({ ticket, tickets, unreadCount: tickets.filter(ticket => ticket.unread).length });
3827
+ };
3828
+ const readTicket = async (ticketId, signal, epoch) => {
3829
+ const result = await page('inspect', { ticketId }, signal, 'entries');
3830
+ if (!current(signal, epoch)) return
3831
+ // Only entries returned for this reader can advance the server's seen mark.
3832
+ const entries = result.entries.sort((a, b) => a.sequence - b.sequence);
3833
+ updateTicket(result.ticket);
3834
+ publish({ entries });
3835
+ const throughSequence = Math.max(0, ...entries.map(entry => entry.sequence || 0));
3836
+ if (visible && globalThis.document?.visibilityState !== 'hidden' && throughSequence > result.ticket.requesterLastSeenSequence) {
3837
+ const seen = await client.operation('mark_seen', { ticketId, throughSequence }, signal);
3838
+ if (current(signal, epoch)) updateTicket(seen.data.ticket);
3839
+ }
3840
+ };
3841
+ const readRequests = async (signal, epoch) => {
3842
+ const result = await page('list', {}, signal, 'tickets');
3843
+ if (current(signal, epoch)) publish({ tickets: result.tickets, unreadCount: result.tickets.filter(ticket => ticket.unread).length });
3844
+ };
3845
+ const refresh = () => {
3846
+ if (disposed || !state.config) return Promise.resolve()
3847
+ if (state.busy || globalThis.document?.visibilityState === 'hidden') { schedule(); return Promise.resolve() }
3848
+ return request(async (signal, epoch) => {
3849
+ await readRequests(signal, epoch);
3850
+ if (current(signal, epoch) && visible && state.view === 'request' && state.ticket) await readTicket(state.ticket.id, signal, epoch);
3851
+ }, true)
3852
+ };
3853
+ const transcript = () => state.messages.map(({ role, content }) => ({ role, content }));
3854
+ const bootstrap = () => request(async (signal, epoch) => {
3855
+ const config = await client.bootstrap(signal);
3856
+ if (generation !== epoch || signal.aborted) return
3857
+ publish({ config, loading: false, messages: [{ role: 'assistant', content: config.greeting }] });
3858
+ });
3859
+ const send = async content => {
3860
+ if (state.busy || state.loading || state.view !== 'conversation' || !content.trim() || content.length > 10_000 || state.messages.length >= 39) return
3861
+ publish({ messages: [...transcript(), { role: 'user', content: content.trim() }] });
3862
+ const history = transcript();
3863
+ return request(async (signal, epoch) => {
3864
+ const show = text => {
3865
+ if (generation === epoch && !signal.aborted && state.view === 'conversation')
3866
+ publish({ messages: [...history, { role: 'assistant', content: text }] });
3867
+ };
3868
+ const result = await client.answer(history, { signal, onText: show });
3869
+ if (generation === epoch && !signal.aborted && result.status === 'agent') show(result.text);
3870
+ })
3871
+ };
3872
+ const escalate = () => {
3873
+ if (!state.config) return
3874
+ abort();
3875
+ publish({ view: 'form', transcript: transcript(), busy: false, error: null, submission: null, ticket: null });
3876
+ schedule();
3877
+ };
3878
+ const submit = (subject, body, replyLanguage = 'es') => {
3879
+ if (submitting) return submitting
3880
+ if (state.view !== 'form' || !subject.trim() || !body.trim()) return Promise.resolve()
3881
+ // A retry uses the captured consent/input, even if the caller passes different arguments.
3882
+ const submission = state.submission || { subject: subject.trim(), body: body.trim(), replyLanguage, transcript: state.transcript, idempotencyKey: crypto.randomUUID() };
3883
+ publish({ submission });
3884
+ submitting = request(async (signal, epoch) => {
3885
+ const result = await client.operation('open', submission, signal);
3886
+ if (generation === epoch && !signal.aborted) publish({ view: 'receipt', ticket: result.data.ticket, error: null });
3887
+ return result
3888
+ }).finally(() => { submitting = null; });
3889
+ return submitting
3890
+ };
3891
+ const showRequests = () => {
3892
+ abort();
3893
+ publish({ view: 'requests', busy: false, error: null });
3894
+ return request(readRequests)
3895
+ };
3896
+ const inspect = ticketId => {
3897
+ abort();
3898
+ publish({ view: 'request', ticket: null, entries: [], followup: null, busy: false, error: null });
3899
+ return request((signal, epoch) => readTicket(ticketId, signal, epoch))
3900
+ };
3901
+ const reply = (body, reopen = false) => {
3902
+ if (following) return following
3903
+ if (state.view !== 'request' || !state.ticket || !body.trim() || body.length > 20_000) return Promise.resolve()
3904
+ if (state.ticket.status === 'resolved' && !reopen) { publish({ error: 'ticket_resolved' }); return Promise.resolve() }
3905
+ const action = reopen ? 'reopen' : 'reply';
3906
+ const followup = state.followup?.action === action ? state.followup : { action, ticketId: state.ticket.id, body: body.trim(), idempotencyKey: crypto.randomUUID() };
3907
+ publish({ followup });
3908
+ following = request(async (signal, epoch) => {
3909
+ const result = await client.operation(followup.action, { ticketId: followup.ticketId, body: followup.body, idempotencyKey: followup.idempotencyKey }, signal);
3910
+ if (!current(signal, epoch)) return
3911
+ await readTicket(followup.ticketId, signal, epoch);
3912
+ if (current(signal, epoch)) publish({ followup: null });
3913
+ return result
3914
+ }).finally(() => { following = null; });
3915
+ return following
3916
+ };
3917
+ const newProblem = () => {
3918
+ abort();
3919
+ publish({ view: 'form', transcript: [], submission: null, followup: null, ticket: null, entries: [], busy: false, error: null });
3920
+ schedule();
3921
+ };
3922
+ const newConversation = () => {
3923
+ abort();
3924
+ publish({ view: 'conversation', messages: state.config ? [{ role: 'assistant', content: state.config.greeting }] : [], transcript: [], ticket: null, entries: [], error: null, busy: false, submission: null, followup: null });
3925
+ schedule();
3926
+ };
3927
+ return {
3928
+ getSnapshot: () => state,
3929
+ subscribe: listener => { listeners.add(listener); schedule(); return () => { listeners.delete(listener); if (!listeners.size) clearTimeout(timer); } },
3930
+ bootstrap, send, escalate, submit, showRequests, inspect, newConversation, refresh, reply, newProblem,
3931
+ setVisible: value => { visible = value; if (value) void refresh(); },
3932
+ back: () => { abort(); publish({ view: 'conversation', busy: false, error: null, followup: null }); schedule(); },
3933
+ 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(); }
3934
+ }
3935
+ };
3936
+
3937
+ /** React Support panel uses the shared design tokens and its own BEM namespace. */
3938
+ const getSupportStylesheet = () => `
3939
+ :host { font-family: var(--andy-font-family-primary); color: #171717; line-height: 1.5; --andy-support-color: #171717; --andy-support-contrast: #fff; }
3940
+ *, *::before, *::after { box-sizing: border-box; }
3941
+ .andy-support button, .andy-support input, .andy-support textarea { font: inherit; }
3942
+ .andy-support button { cursor: pointer; }
3943
+ .andy-support button:disabled { cursor: default; opacity: .45; }
3944
+ .andy-support button:focus-visible, .andy-support a:focus-visible, .andy-support input:focus-visible, .andy-support textarea:focus-visible, .andy-support summary:focus-visible { outline: 2px solid var(--andy-support-color); outline-offset: 3px; }
3945
+ .andy-support__launcher { position: fixed; bottom: 24px; right: 24px; z-index: var(--andy-z-fixed); border: 0; border-radius: var(--andy-radius-full); padding: 14px 20px; background: var(--andy-support-color); color: var(--andy-support-contrast); box-shadow: var(--andy-shadow-lg); display: flex; gap: 24px; align-items: center; font-size: 14px; }
3946
+ .andy-support__panel { position: fixed; right: 24px; bottom: 24px; z-index: var(--andy-z-fixed); width: 400px; max-width: calc(100vw - 48px); height: min(680px, calc(100dvh - 48px)); background: #fff; border: 1px solid #e5e7eb; border-radius: 20px; box-shadow: var(--andy-shadow-xl); display: flex; flex-direction: column; overflow: hidden; font-size: 14px; }
3947
+ :host(.andy-support-host--left) .andy-support__launcher, :host(.andy-support-host--left) .andy-support__panel { left: 24px; right: auto; }
3948
+ .andy-support__header { flex-shrink: 0; position: relative; display: flex; align-items: center; gap: 10px; padding: 18px; border-bottom: 1px solid #f0f0f0; }
3949
+ .andy-support__header > button { border: 0; background: transparent; color: #737373; padding: 5px; font-size: 22px; line-height: 1; min-width: 28px; }
3950
+ .andy-support__mark { width: 34px; height: 34px; border-radius: 10px; background: var(--andy-support-color); color: var(--andy-support-contrast); display: grid; place-items: center; font-weight: 600; }
3951
+ .andy-support__identity { flex: 1; min-width: 0; display: flex; flex-direction: column; }
3952
+ .andy-support__identity strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
3953
+ .andy-support__identity small { color: #737373; font-size: 12px; }
3954
+ .andy-support__menu { position: absolute; top: 65px; right: 16px; background: #fff; box-shadow: var(--andy-shadow-lg); border: 1px solid #e5e7eb; border-radius: 12px; padding: 6px; z-index: 1; min-width: 210px; }
3955
+ .andy-support__menu button { display: block; border: 0; border-radius: 6px; background: none; padding: 10px; text-align: left; width: 100%; }
3956
+ .andy-support__menu button:hover { background: #f5f5f5; }
3957
+ .andy-support__content { flex: 1; min-height: 0; overflow-y: auto; padding: 24px 20px; overscroll-behavior: contain; }
3958
+ .andy-support h2 { font-size: 17px; font-weight: 600; margin: 0 0 20px; }
3959
+ .andy-support__message { white-space: pre-wrap; overflow-wrap: anywhere; margin-bottom: 16px; line-height: 1.65; }
3960
+ .andy-support__message--user { margin-left: auto; width: fit-content; max-width: 90%; padding: 10px 14px; border-radius: 16px 16px 4px 16px; background: #f5f5f5; }
3961
+ .andy-support__questions { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; margin-top: 22px; }
3962
+ .andy-support__questions button, .andy-support__secondary { border: 1px solid #e5e7eb; border-radius: 18px; background: #fff; padding: 10px 14px; text-align: left; color: #525252; }
3963
+ .andy-support__questions button:hover, .andy-support__secondary:hover { background: #fafafa; }
3964
+ .andy-support__secondary { margin-top: 8px; }
3965
+ .andy-support__composer { flex-shrink: 0; display: flex; gap: 8px; margin: 0 18px 12px; padding: 8px; border: 1px solid #e5e7eb; border-radius: var(--andy-radius-full); background: #fafafa; }
3966
+ .andy-support__composer input { flex: 1; min-width: 0; border: 0; background: none; padding: 4px 8px; outline-offset: 0; color: inherit; }
3967
+ .andy-support__composer button { border: 0; width: 32px; height: 32px; border-radius: 50%; background: var(--andy-support-color); color: var(--andy-support-contrast); font-size: 20px; }
3968
+ .andy-support__footer { flex-shrink: 0; display: flex; justify-content: center; flex-wrap: wrap; gap: 10px; padding: 0 12px 14px; font-size: 10px; color: #a3a3a3; }
3969
+ .andy-support__footer a { color: #737373; text-decoration: none; }
3970
+ .andy-support__form { display: flex; flex-direction: column; gap: 16px; }
3971
+ .andy-support__form h2 { margin-bottom: 0; }
3972
+ .andy-support__form label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-weight: 500; }
3973
+ .andy-support__form input, .andy-support__form textarea { width: 100%; border: 1px solid #d4d4d4; border-radius: 10px; background: #fff; padding: 10px 12px; font-size: 14px; font-weight: 400; color: inherit; }
3974
+ .andy-support__form textarea { resize: vertical; }
3975
+ .andy-support__transcript { border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; font-size: 12px; }
3976
+ .andy-support__transcript summary { cursor: pointer; }
3977
+ .andy-support__transcript p, .andy-support__entry p { white-space: pre-wrap; overflow-wrap: anywhere; }
3978
+ .andy-support__muted { color: #737373; font-size: 12px; }
3979
+ .andy-support__primary { border: 0; border-radius: 10px; background: var(--andy-support-color); color: var(--andy-support-contrast); padding: 12px 18px; }
3980
+ .andy-support__receipt { text-align: center; padding: 30px 8px; }
3981
+ .andy-support__check { display: inline-grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; background: #f5f5f5; font-size: 24px; margin-bottom: 20px; }
3982
+ .andy-support__receipt h2 { margin-bottom: 10px; }
3983
+ .andy-support__receipt > strong { font-size: 22px; }
3984
+ .andy-support__receipt .andy-support__primary { margin-top: 16px; }
3985
+ .andy-support__ticket { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; width: 100%; text-align: left; padding: 16px 0; border: 0; border-bottom: 1px solid #eee; background: none; }
3986
+ .andy-support__ticket small { grid-column: 1; color: #737373; }
3987
+ .andy-support__ticket > span:last-child { grid-column: 2; grid-row: 1 / 3; align-self: center; font-size: 20px; }
3988
+ .andy-support__entry { border-bottom: 1px solid #eee; padding: 16px 0; }
3989
+ .andy-support__unread { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; margin-left: 6px; vertical-align: middle; }
3990
+ .andy-support__ticket .andy-support__unread, .andy-support__menu .andy-support__unread { color: var(--andy-support-color); }
3991
+ .andy-support__request-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
3992
+ .andy-support__request-actions .andy-support__secondary { margin-top: 0; }
3993
+ .andy-support__followup { flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; margin: 0 18px 12px; }
3994
+ .andy-support__followup label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
3995
+ .andy-support__followup textarea { width: 100%; max-height: 120px; resize: vertical; border: 1px solid #d4d4d4; border-radius: 10px; padding: 10px 12px; color: inherit; background: #fff; }
3996
+ .andy-support__followup button { align-self: flex-end; }
3997
+ .andy-support__error { margin-top: 16px; padding: 12px; border-radius: 10px; background: #fef2f2; color: #991b1b; font-size: 12px; }
3998
+ .andy-support__error button { border: 0; background: none; color: inherit; text-decoration: underline; padding: 0; }
3999
+ @media (max-width: 600px) { .andy-support__panel, :host(.andy-support-host--left) .andy-support__panel { inset: 0; width: 100%; max-width: none; height: 100dvh; border-radius: 0; border: 0; padding-bottom: env(safe-area-inset-bottom); } .andy-support__launcher { bottom: calc(18px + env(safe-area-inset-bottom)); right: 18px; } }
4000
+ @media (prefers-reduced-motion: reduce) { .andy-support__content { scroll-behavior: auto; } }
4001
+ `;
4002
+
4003
+ const empty = { config: null, view: 'conversation', messages: [], transcript: [], tickets: [], ticket: null, entries: [], loading: true, busy: false, error: null, submission: null, followup: null, unreadCount: 0 };
4004
+ const noSubscribe = () => () => { };
4005
+ const emptySnapshot = () => empty;
4006
+ /** The host context key changes before a new user/company is allowed to see any previous state. */
4007
+ const useSupportChat = (options) => {
4008
+ const key = options ? JSON.stringify([options.installationId, options.contextKey, options.apiBaseUrl]) : null;
4009
+ const callback = react.useRef(options?.getRequesterPass);
4010
+ callback.current = options?.getRequesterPass;
4011
+ const [owned, setOwned] = react.useState(null);
4012
+ const controller = owned?.key === key ? owned?.controller : null;
4013
+ const state = react.useSyncExternalStore(controller?.subscribe || noSubscribe, controller?.getSnapshot || emptySnapshot, emptySnapshot);
4014
+ react.useEffect(() => {
4015
+ if (!options || !key)
4016
+ return;
4017
+ const controller = createSupportController({ ...options, getRequesterPass: () => callback.current() });
4018
+ setOwned({ key, controller });
4019
+ void controller.bootstrap();
4020
+ return () => controller.dispose();
4021
+ }, [key]);
4022
+ react.useEffect(() => {
4023
+ if (state.config && options?.ticketId)
4024
+ void controller?.inspect(options.ticketId);
4025
+ }, [controller, state.config, options?.ticketId]);
4026
+ return { mode: 'support', state, controller };
4027
+ };
4028
+
4029
+ 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' }
4032
+ };
4033
+ /** Private Requester UI. Sales bootstrap, visitor identity and storage never enter this renderer. */
4034
+ const SupportPanel = ({ support, lang, orientation = 'right', initialOpen = false, tooltip }) => {
4035
+ const { state, controller } = useSupportChat(support);
4036
+ const replyLanguage = lang || state.config?.defaultLang || 'es';
4037
+ const strings = copy[replyLanguage];
4038
+ const [open, setOpen] = react.useState(initialOpen);
4039
+ const [menu, setMenu] = react.useState(false);
4040
+ const [draft, setDraft] = react.useState('');
4041
+ const [subject, setSubject] = react.useState('');
4042
+ const [body, setBody] = react.useState('');
4043
+ const [replyDraft, setReplyDraft] = react.useState('');
4044
+ const [reopening, setReopening] = react.useState(false);
4045
+ const [selectedTicketId, setSelectedTicketId] = react.useState(null);
4046
+ const host = react.useRef(null);
4047
+ const messages = react.useRef(null);
4048
+ const [portal, setPortal] = react.useState(null);
4049
+ react.useEffect(() => {
4050
+ if (!host.current)
4051
+ return;
4052
+ const shadow = host.current.shadowRoot || host.current.attachShadow({ mode: 'open' });
4053
+ const style = document.createElement('style');
4054
+ style.textContent = generateDesignTokenVariablesString() + getSupportStylesheet();
4055
+ const target = document.createElement('div');
4056
+ shadow.append(style, target);
4057
+ setPortal(target);
4058
+ return () => { style.remove(); target.remove(); };
4059
+ }, []);
4060
+ react.useEffect(() => {
4061
+ const color = state.config?.branding.color || '#171717';
4062
+ host.current?.style.setProperty('--andy-support-color', color);
4063
+ host.current?.style.setProperty('--andy-support-contrast', getContrastTextColor(color));
4064
+ }, [state.config?.branding.color]);
4065
+ react.useEffect(() => { messages.current?.scrollTo?.({ top: messages.current.scrollHeight, behavior: 'smooth' }); }, [state.messages, state.view]);
4066
+ react.useEffect(() => { controller?.setVisible(open); }, [controller, open]);
4067
+ react.useEffect(() => { if (support.ticketId)
4068
+ setOpen(true); }, [support.ticketId]);
4069
+ react.useEffect(() => { if (state.ticket?.status !== 'resolved' && state.followup?.action !== 'reopen')
4070
+ setReopening(false); }, [state.ticket?.status, state.followup?.action]);
4071
+ const choose = (action) => { setMenu(false); action(); };
4072
+ const escalate = () => { setSubject(''); setBody(''); controller?.escalate(); };
4073
+ const send = () => { if (!draft.trim() || state.busy)
4074
+ return; void controller?.send(draft); setDraft(''); };
4075
+ const newProblem = () => { setSubject(''); setBody(''); setReplyDraft(''); setReopening(false); controller?.newProblem(); };
4076
+ const followup = async () => {
4077
+ const result = await controller?.reply(replyDraft, reopening);
4078
+ if (result) {
4079
+ setReplyDraft('');
4080
+ setReopening(false);
4081
+ }
4082
+ };
4083
+ const unread = jsxRuntime.jsx("span", { className: 'andy-support__unread', role: 'img', "aria-label": strings.unread });
4084
+ const title = state.config?.branding.name || strings.support;
4085
+ const ticketRow = (ticket) => jsxRuntime.jsxs("button", { type: 'button', className: 'andy-support__ticket', onClick: () => { setSelectedTicketId(ticket.id); setReplyDraft(''); setReopening(false); void controller?.inspect(ticket.id); }, children: [jsxRuntime.jsxs("span", { children: ["#", ticket.number, " \u00B7 ", ticket.subject, " ", ticket.unread && unread] }), jsxRuntime.jsx("small", { children: strings[ticket.status] }), jsxRuntime.jsx("span", { "aria-hidden": 'true', children: "\u203A" })] }, ticket.id);
4086
+ const retry = () => {
4087
+ if (!state.config)
4088
+ void controller?.bootstrap();
4089
+ else if (state.view === 'form')
4090
+ void controller?.submit(subject, body, replyLanguage);
4091
+ else if (state.view === 'requests')
4092
+ void controller?.showRequests();
4093
+ else if (state.view === 'request' && state.followup)
4094
+ void followup();
4095
+ else if (state.view === 'request' && (selectedTicketId || support.ticketId))
4096
+ void controller?.inspect((selectedTicketId || support.ticketId));
4097
+ };
4098
+ 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
+ const promise = state.ticket?.replyPromise || state.config?.replyPromise;
4100
+ 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) });
4102
+ };
4103
+ /** A host identity change also destroys local drafts and menu state before the next render. */
4104
+ const SupportChat = props => jsxRuntime.jsx(SupportPanel, { ...props }, JSON.stringify([props.support.installationId, props.support.contextKey, props.support.apiBaseUrl]));
4105
+
4106
+ const SalesAndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessage, bubbleLabel, lang, predefinedQuestions, theme, orientation, brandColor, bubbleStyle, useBrandColor, logoUrl, previewMode = false, initialOpen = false }) => {
3683
4107
  const [config, setConfig] = react.useState(null);
3684
4108
  // The visitor's language wins over the Agent's own default: a Spanish Agent
3685
4109
  // embedded on an English page must still render English chrome.
@@ -4304,10 +4728,14 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4304
4728
  config?.welcomeMessage ||
4305
4729
  'Welcome! How can I help you today?' }), jsxRuntime.jsx("button", { type: 'button', className: 'andy-welcome-popup__close', "aria-label": strings.closeWelcomeMessage, onClick: () => setShowWelcomePopup(false), children: jsxRuntime.jsx("svg", { className: 'andy-welcome-popup__close-icon', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', children: jsxRuntime.jsx("path", { d: 'M18 6L6 18M6 6l12 12', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' }) }) })] }), jsxRuntime.jsx("div", { className: 'andy-welcome-popup__arrow' })] }), portalRef.current)] })) }));
4306
4730
  };
4731
+ const AndyChat = props => props.mode === 'support'
4732
+ ? jsxRuntime.jsx(SupportChat, { ...props }, `${props.support.installationId}:${props.support.contextKey}`)
4733
+ : jsxRuntime.jsx(SalesAndyChat, { ...props });
4307
4734
 
4308
4735
  const { SESSION_STORAGE_KEY, FORM_DISPLAY_DELAY_MS, PERSIST_SESSION } = CONFIG;
4309
- const useAndyChat = (embedId) => {
4310
- initializeConfig('andy-widget-react');
4736
+ const useSalesAndyChat = (embedId, enabled) => {
4737
+ if (enabled)
4738
+ initializeConfig('andy-widget-react');
4311
4739
  const [config, setConfig] = react.useState({});
4312
4740
  const [isLoading, setIsLoading] = react.useState(true);
4313
4741
  const [isOpen, setIsOpen] = react.useState(false);
@@ -4327,6 +4755,8 @@ const useAndyChat = (embedId) => {
4327
4755
  const welcomeTimeoutRef = react.useRef(null);
4328
4756
  const userInfoTimeoutRef = react.useRef(null);
4329
4757
  const initializeChatbot = react.useCallback(async () => {
4758
+ if (!enabled)
4759
+ return;
4330
4760
  if (!embedId) {
4331
4761
  throw new Error('Embed ID is required');
4332
4762
  }
@@ -4361,7 +4791,7 @@ const useAndyChat = (embedId) => {
4361
4791
  setIsLoading(false);
4362
4792
  throw err;
4363
4793
  }
4364
- }, [embedId]);
4794
+ }, [embedId, enabled]);
4365
4795
  const toggleChat = react.useCallback(() => {
4366
4796
  setIsOpen(prev => {
4367
4797
  const newIsOpen = !prev;
@@ -4395,7 +4825,7 @@ const useAndyChat = (embedId) => {
4395
4825
  });
4396
4826
  }, [config, welcomePopupShown, userInfoCollected]);
4397
4827
  const sendMessage = react.useCallback(async (messageText) => {
4398
- if (!messageText.trim() || !config || !sessionId || activeReplyRef.current) {
4828
+ if (!enabled || !messageText.trim() || !config || !sessionId || activeReplyRef.current) {
4399
4829
  return;
4400
4830
  }
4401
4831
  const controller = new AbortController();
@@ -4445,7 +4875,7 @@ const useAndyChat = (embedId) => {
4445
4875
  if (activeReplyRef.current === controller)
4446
4876
  activeReplyRef.current = null;
4447
4877
  }
4448
- }, [embedId, sessionId, config]);
4878
+ }, [embedId, sessionId, config, enabled]);
4449
4879
  const showFaq = react.useCallback(() => {
4450
4880
  setShowingFaq(true);
4451
4881
  }, []);
@@ -4465,6 +4895,8 @@ const useAndyChat = (embedId) => {
4465
4895
  setShowingUserInfoForm(false);
4466
4896
  }, []);
4467
4897
  const submitUserInfo$1 = react.useCallback(async (userInfo) => {
4898
+ if (!enabled)
4899
+ return;
4468
4900
  try {
4469
4901
  await submitUserInfo(embedId, sessionId, userInfo);
4470
4902
  setUserInfoCollected(true);
@@ -4481,8 +4913,10 @@ const useAndyChat = (embedId) => {
4481
4913
  console.error('Error submitting user info:', err);
4482
4914
  throw err;
4483
4915
  }
4484
- }, [embedId, sessionId]);
4916
+ }, [embedId, sessionId, enabled]);
4485
4917
  const startNewSession = react.useCallback(() => {
4918
+ if (!enabled)
4919
+ return;
4486
4920
  activeReplyRef.current?.abort();
4487
4921
  activeReplyRef.current = null;
4488
4922
  const newSessionId = generateSessionId();
@@ -4508,7 +4942,7 @@ const useAndyChat = (embedId) => {
4508
4942
  setMessages([greetingMessage]);
4509
4943
  }
4510
4944
  }
4511
- }, [config]);
4945
+ }, [config, enabled]);
4512
4946
  const addMessage = react.useCallback((message) => {
4513
4947
  setMessages(prev => [...prev, message]);
4514
4948
  }, []);
@@ -4537,6 +4971,11 @@ const useAndyChat = (embedId) => {
4537
4971
  addMessage
4538
4972
  };
4539
4973
  };
4974
+ function useAndyChat(input) {
4975
+ const support = useSupportChat(typeof input === 'string' ? null : input.support);
4976
+ const sales = useSalesAndyChat(typeof input === 'string' ? input : '', typeof input === 'string');
4977
+ return typeof input === 'string' ? sales : support;
4978
+ }
4540
4979
 
4541
4980
  const ChatBubble = ({ isOpen, config, onClick }) => {
4542
4981
  const bubbleClasses = ['andy-bubble', isOpen && 'andy-bubble--open'].filter(Boolean).join(' ');
@@ -4751,4 +5190,3 @@ exports.theme = theme;
4751
5190
  exports.typography = typography;
4752
5191
  exports.useAndyChat = useAndyChat;
4753
5192
  exports.zIndex = zIndex;
4754
- //# sourceMappingURL=index.cjs.map