andy-widget-react 2.2.1 → 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 +35 -0
- package/dist/index.cjs +463 -9
- package/dist/index.esm.js +465 -11
- package/dist/types/SupportChat.d.ts +4 -0
- package/dist/types/hooks/useAndyChat.d.ts +7 -1
- package/dist/types/hooks/useSupportChat.d.ts +24 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/types/index.d.ts +11 -1
- package/package.json +2 -2
- package/src/AndyChat.tsx +7 -2
- package/src/SupportChat.tsx +106 -0
- package/src/hooks/useAndyChat.ts +20 -7
- package/src/hooks/useSupportChat.ts +29 -0
- package/src/index.ts +2 -0
- package/src/types/index.ts +12 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.esm.js.map +0 -1
package/dist/index.esm.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
3
|
-
import { useState,
|
|
4
|
-
import ReactDOM from 'react-dom';
|
|
3
|
+
import { useRef, useState, useSyncExternalStore, useEffect, useCallback } from 'react';
|
|
4
|
+
import ReactDOM, { createPortal } from 'react-dom';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Design tokens for AndyChat
|
|
@@ -771,6 +771,11 @@ const sendMessageForWidget = async (embedId, sessionId, message, options = {}) =
|
|
|
771
771
|
messages: [{ role: 'user', parts: [{ type: 'text', text: message }] }]
|
|
772
772
|
})
|
|
773
773
|
});
|
|
774
|
+
return consumeWidgetReply(response, options)
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
/** Shared text-only UI stream decoder; no retry can occur after reading starts. */
|
|
778
|
+
const consumeWidgetReply = async (response, options = {}) => {
|
|
774
779
|
if (!response.ok || !response.headers.get('content-type')?.includes('text/event-stream')) {
|
|
775
780
|
const data = await response.json().catch(() => null);
|
|
776
781
|
throw new Error(data?.error || data?.message || 'Chat is temporarily unavailable.')
|
|
@@ -811,6 +816,7 @@ const sendMessageForWidget = async (embedId, sessionId, message, options = {}) =
|
|
|
811
816
|
};
|
|
812
817
|
try {
|
|
813
818
|
while (!complete) {
|
|
819
|
+
options.signal?.throwIfAborted();
|
|
814
820
|
const { value, done } = await reader.read();
|
|
815
821
|
buffer += decoder.decode(value, { stream: !done });
|
|
816
822
|
let newline;
|
|
@@ -3675,7 +3681,441 @@ const createBrowserHandoff = (chatbotId, bootstrap, onProjection, fresh = false)
|
|
|
3675
3681
|
}
|
|
3676
3682
|
};
|
|
3677
3683
|
|
|
3678
|
-
|
|
3684
|
+
/** In-page Requester credentials. A failed response is renewed once, before its body is consumed. */
|
|
3685
|
+
const createRequesterFetch = getRequesterPass => {
|
|
3686
|
+
let pass = null;
|
|
3687
|
+
let loading = null;
|
|
3688
|
+
let revision = 0;
|
|
3689
|
+
const obtain = async () => {
|
|
3690
|
+
const current = revision;
|
|
3691
|
+
if (!loading) {
|
|
3692
|
+
const pending = Promise.resolve().then(getRequesterPass).then(value => {
|
|
3693
|
+
if (current !== revision || typeof value !== 'string' || !value.trim()) throw new Error('Requester authentication is unavailable.')
|
|
3694
|
+
pass = value;
|
|
3695
|
+
return value
|
|
3696
|
+
}).catch(() => { throw new Error('Requester authentication is unavailable.') }).finally(() => { if (loading === pending) loading = null; });
|
|
3697
|
+
loading = pending;
|
|
3698
|
+
}
|
|
3699
|
+
return loading
|
|
3700
|
+
};
|
|
3701
|
+
const requesterFetch = async (url, options = {}) => {
|
|
3702
|
+
const current = revision;
|
|
3703
|
+
const headers = new Headers(options.headers);
|
|
3704
|
+
if (headers.has('authorization')) throw new Error('Requester requests cannot use operator authorization.')
|
|
3705
|
+
const attempt = async credential => {
|
|
3706
|
+
options.signal?.throwIfAborted();
|
|
3707
|
+
if (current !== revision) throw new DOMException('Requester context changed.', 'AbortError')
|
|
3708
|
+
const selected = new Headers(headers);
|
|
3709
|
+
selected.set('X-Andy-Requester', credential);
|
|
3710
|
+
const response = await fetch(url, { ...options, headers: selected, credentials: 'omit', cache: 'no-store' });
|
|
3711
|
+
if (current !== revision) throw new DOMException('Requester context changed.', 'AbortError')
|
|
3712
|
+
return response
|
|
3713
|
+
};
|
|
3714
|
+
let response = await attempt(pass || await obtain());
|
|
3715
|
+
if (!response.ok) {
|
|
3716
|
+
const problem = await response.clone().json().catch(() => null);
|
|
3717
|
+
// Private help and canonical Ticket HTTP use their existing problem envelopes.
|
|
3718
|
+
if (['expired', 'unknown_key'].includes(problem?.problem?.code ?? problem?.code)) {
|
|
3719
|
+
pass = null;
|
|
3720
|
+
response = await attempt(await obtain());
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
return response
|
|
3724
|
+
};
|
|
3725
|
+
requesterFetch.clear = () => { revision++; pass = null; loading = null; };
|
|
3726
|
+
return requesterFetch
|
|
3727
|
+
};
|
|
3728
|
+
|
|
3729
|
+
const createSupportClient = ({ installationId, getRequesterPass, apiBaseUrl }) => {
|
|
3730
|
+
if (!/^[a-zA-Z0-9_-]{3,100}$/.test(installationId)) throw new Error('A Support installation is required.')
|
|
3731
|
+
const base = (apiBaseUrl || getApiBaseUrl()).replace(/\/+$/, '');
|
|
3732
|
+
const requesterFetch = createRequesterFetch(getRequesterPass);
|
|
3733
|
+
const widgetUrl = `${base}/support/widget/${encodeURIComponent(installationId)}`;
|
|
3734
|
+
const json = async response => {
|
|
3735
|
+
const result = await response.json().catch(() => null);
|
|
3736
|
+
if (!response.ok || result?.ok === false) {
|
|
3737
|
+
const error = new Error('Support is temporarily unavailable. Please retry.');
|
|
3738
|
+
error.code = result?.problem?.code || result?.code || 'unavailable';
|
|
3739
|
+
throw error
|
|
3740
|
+
}
|
|
3741
|
+
return result
|
|
3742
|
+
};
|
|
3743
|
+
return {
|
|
3744
|
+
bootstrap: signal => requesterFetch(widgetUrl, { signal }).then(json),
|
|
3745
|
+
answer: async (messages, options = {}) => {
|
|
3746
|
+
const response = await requesterFetch(widgetUrl, {
|
|
3747
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: options.signal,
|
|
3748
|
+
body: JSON.stringify({ messages })
|
|
3749
|
+
});
|
|
3750
|
+
if (!response.ok) await json(response);
|
|
3751
|
+
return consumeWidgetReply(response, options)
|
|
3752
|
+
},
|
|
3753
|
+
operation: async (action, input, signal) => json(await requesterFetch(`${base}/public/operations/support/request/${action}`, {
|
|
3754
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal, body: JSON.stringify(input)
|
|
3755
|
+
})),
|
|
3756
|
+
dispose: () => requesterFetch.clear()
|
|
3757
|
+
}
|
|
3758
|
+
};
|
|
3759
|
+
|
|
3760
|
+
/** One in-memory conversation/form receipt lifecycle shared by React renderers and hooks. */
|
|
3761
|
+
const createSupportController = options => {
|
|
3762
|
+
const client = createSupportClient(options);
|
|
3763
|
+
const listeners = new Set();
|
|
3764
|
+
let state = { config: null, view: 'conversation', messages: [], transcript: [], tickets: [], ticket: null, entries: [], loading: true, busy: false, error: null, submission: null, followup: null, unreadCount: 0 };
|
|
3765
|
+
let active = null;
|
|
3766
|
+
let generation = 0;
|
|
3767
|
+
let disposed = false;
|
|
3768
|
+
let submitting = null;
|
|
3769
|
+
let following = null;
|
|
3770
|
+
let visible = true;
|
|
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;
|
|
3777
|
+
const schedule = () => {
|
|
3778
|
+
clearTimeout(timer);
|
|
3779
|
+
if (!disposed && state.config && listeners.size) {
|
|
3780
|
+
timer = setTimeout(() => void refresh(), 3000);
|
|
3781
|
+
timer.unref?.();
|
|
3782
|
+
}
|
|
3783
|
+
};
|
|
3784
|
+
const publish = changes => {
|
|
3785
|
+
if (disposed) return
|
|
3786
|
+
state = { ...state, ...changes };
|
|
3787
|
+
listeners.forEach(listener => listener());
|
|
3788
|
+
};
|
|
3789
|
+
const abort = () => { generation++; active?.abort(); active = null; clearTimeout(timer); };
|
|
3790
|
+
const fail = error => {
|
|
3791
|
+
if (error?.code === 'resource_not_found' && state.view === 'request') publish({ ticket: null, entries: [], followup: null });
|
|
3792
|
+
if (error?.code === 'ticket_resolved' && state.ticket) {
|
|
3793
|
+
updateTicket({ ...state.ticket, status: 'resolved' });
|
|
3794
|
+
publish({ followup: null });
|
|
3795
|
+
}
|
|
3796
|
+
publish({ error: error?.code || 'unavailable', busy: false, loading: false });
|
|
3797
|
+
};
|
|
3798
|
+
const request = async (work, background = false) => {
|
|
3799
|
+
abort();
|
|
3800
|
+
const epoch = generation;
|
|
3801
|
+
const controller = new AbortController();
|
|
3802
|
+
active = controller;
|
|
3803
|
+
if (!background) publish({ busy: true, error: null });
|
|
3804
|
+
try {
|
|
3805
|
+
const result = await work(controller.signal, epoch);
|
|
3806
|
+
if (disposed || generation !== epoch || controller.signal.aborted) return
|
|
3807
|
+
return result
|
|
3808
|
+
} catch (error) {
|
|
3809
|
+
if (!controller.signal.aborted && generation === epoch) fail(error);
|
|
3810
|
+
} finally {
|
|
3811
|
+
if (active === controller) { active = null; if (!background) publish({ busy: false }); schedule(); }
|
|
3812
|
+
}
|
|
3813
|
+
};
|
|
3814
|
+
const current = (signal, epoch) => !disposed && generation === epoch && !signal.aborted;
|
|
3815
|
+
const page = async (action, input, signal, field) => {
|
|
3816
|
+
let result, cursor;
|
|
3817
|
+
const values = [];
|
|
3818
|
+
do {
|
|
3819
|
+
result = await client.operation(action, { ...input, pageSize: 100, ...(cursor ? { cursor } : {}) }, signal);
|
|
3820
|
+
values.push(...result.data[field]);
|
|
3821
|
+
cursor = result.meta?.nextCursor;
|
|
3822
|
+
} while (cursor && !signal.aborted)
|
|
3823
|
+
return { ...result.data, [field]: values }
|
|
3824
|
+
};
|
|
3825
|
+
const updateTicket = ticket => {
|
|
3826
|
+
const tickets = state.tickets.map(previous => previous.id === ticket.id ? ticket : previous);
|
|
3827
|
+
publish({ ticket, tickets, unreadCount: tickets.filter(ticket => ticket.unread).length });
|
|
3828
|
+
};
|
|
3829
|
+
const readTicket = async (ticketId, signal, epoch) => {
|
|
3830
|
+
const result = await page('inspect', { ticketId }, signal, 'entries');
|
|
3831
|
+
if (!current(signal, epoch)) return
|
|
3832
|
+
// Only entries returned for this reader can advance the server's seen mark.
|
|
3833
|
+
const entries = result.entries.sort((a, b) => a.sequence - b.sequence);
|
|
3834
|
+
updateTicket(result.ticket);
|
|
3835
|
+
publish({ entries });
|
|
3836
|
+
const throughSequence = Math.max(0, ...entries.map(entry => entry.sequence || 0));
|
|
3837
|
+
if (visible && globalThis.document?.visibilityState !== 'hidden' && throughSequence > result.ticket.requesterLastSeenSequence) {
|
|
3838
|
+
const seen = await client.operation('mark_seen', { ticketId, throughSequence }, signal);
|
|
3839
|
+
if (current(signal, epoch)) updateTicket(seen.data.ticket);
|
|
3840
|
+
}
|
|
3841
|
+
};
|
|
3842
|
+
const readRequests = async (signal, epoch) => {
|
|
3843
|
+
const result = await page('list', {}, signal, 'tickets');
|
|
3844
|
+
if (current(signal, epoch)) publish({ tickets: result.tickets, unreadCount: result.tickets.filter(ticket => ticket.unread).length });
|
|
3845
|
+
};
|
|
3846
|
+
const refresh = () => {
|
|
3847
|
+
if (disposed || !state.config) return Promise.resolve()
|
|
3848
|
+
if (state.busy || globalThis.document?.visibilityState === 'hidden') { schedule(); return Promise.resolve() }
|
|
3849
|
+
return request(async (signal, epoch) => {
|
|
3850
|
+
await readRequests(signal, epoch);
|
|
3851
|
+
if (current(signal, epoch) && visible && state.view === 'request' && state.ticket) await readTicket(state.ticket.id, signal, epoch);
|
|
3852
|
+
}, true)
|
|
3853
|
+
};
|
|
3854
|
+
const transcript = () => state.messages.map(({ role, content }) => ({ role, content }));
|
|
3855
|
+
const bootstrap = () => request(async (signal, epoch) => {
|
|
3856
|
+
const bootstrap = await client.bootstrap(signal);
|
|
3857
|
+
if (generation !== epoch || signal.aborted) return
|
|
3858
|
+
bootstrapConfig = bootstrap;
|
|
3859
|
+
const config = localizedConfig();
|
|
3860
|
+
publish({ config, loading: false, messages: [{ role: 'assistant', content: config.greeting }] });
|
|
3861
|
+
});
|
|
3862
|
+
const send = async content => {
|
|
3863
|
+
if (state.busy || state.loading || state.view !== 'conversation' || !content.trim() || content.length > 10_000 || state.messages.length >= 39) return
|
|
3864
|
+
publish({ messages: [...transcript(), { role: 'user', content: content.trim() }] });
|
|
3865
|
+
const history = transcript();
|
|
3866
|
+
return request(async (signal, epoch) => {
|
|
3867
|
+
const show = text => {
|
|
3868
|
+
if (generation === epoch && !signal.aborted && state.view === 'conversation')
|
|
3869
|
+
publish({ messages: [...history, { role: 'assistant', content: text }] });
|
|
3870
|
+
};
|
|
3871
|
+
const result = await client.answer(history, { signal, onText: show });
|
|
3872
|
+
if (generation === epoch && !signal.aborted && result.status === 'agent') show(result.text);
|
|
3873
|
+
})
|
|
3874
|
+
};
|
|
3875
|
+
const escalate = () => {
|
|
3876
|
+
if (!state.config) return
|
|
3877
|
+
abort();
|
|
3878
|
+
publish({ view: 'form', transcript: transcript(), busy: false, error: null, submission: null, ticket: null });
|
|
3879
|
+
schedule();
|
|
3880
|
+
};
|
|
3881
|
+
const submit = (subject, body, replyLanguage = 'es') => {
|
|
3882
|
+
if (submitting) return submitting
|
|
3883
|
+
if (state.view !== 'form' || !subject.trim() || !body.trim()) return Promise.resolve()
|
|
3884
|
+
// A retry uses the captured consent/input, even if the caller passes different arguments.
|
|
3885
|
+
const submission = state.submission || { subject: subject.trim(), body: body.trim(), replyLanguage, transcript: state.transcript, idempotencyKey: crypto.randomUUID() };
|
|
3886
|
+
publish({ submission });
|
|
3887
|
+
submitting = request(async (signal, epoch) => {
|
|
3888
|
+
const result = await client.operation('open', submission, signal);
|
|
3889
|
+
if (generation === epoch && !signal.aborted) publish({ view: 'receipt', ticket: result.data.ticket, error: null });
|
|
3890
|
+
return result
|
|
3891
|
+
}).finally(() => { submitting = null; });
|
|
3892
|
+
return submitting
|
|
3893
|
+
};
|
|
3894
|
+
const showRequests = () => {
|
|
3895
|
+
abort();
|
|
3896
|
+
publish({ view: 'requests', busy: false, error: null });
|
|
3897
|
+
return request(readRequests)
|
|
3898
|
+
};
|
|
3899
|
+
const inspect = ticketId => {
|
|
3900
|
+
abort();
|
|
3901
|
+
publish({ view: 'request', ticket: null, entries: [], followup: null, busy: false, error: null });
|
|
3902
|
+
return request((signal, epoch) => readTicket(ticketId, signal, epoch))
|
|
3903
|
+
};
|
|
3904
|
+
const reply = (body, reopen = false) => {
|
|
3905
|
+
if (following) return following
|
|
3906
|
+
if (state.view !== 'request' || !state.ticket || !body.trim() || body.length > 20_000) return Promise.resolve()
|
|
3907
|
+
if (state.ticket.status === 'resolved' && !reopen) { publish({ error: 'ticket_resolved' }); return Promise.resolve() }
|
|
3908
|
+
const action = reopen ? 'reopen' : 'reply';
|
|
3909
|
+
const followup = state.followup?.action === action ? state.followup : { action, ticketId: state.ticket.id, body: body.trim(), idempotencyKey: crypto.randomUUID() };
|
|
3910
|
+
publish({ followup });
|
|
3911
|
+
following = request(async (signal, epoch) => {
|
|
3912
|
+
const result = await client.operation(followup.action, { ticketId: followup.ticketId, body: followup.body, idempotencyKey: followup.idempotencyKey }, signal);
|
|
3913
|
+
if (!current(signal, epoch)) return
|
|
3914
|
+
await readTicket(followup.ticketId, signal, epoch);
|
|
3915
|
+
if (current(signal, epoch)) publish({ followup: null });
|
|
3916
|
+
return result
|
|
3917
|
+
}).finally(() => { following = null; });
|
|
3918
|
+
return following
|
|
3919
|
+
};
|
|
3920
|
+
const newProblem = () => {
|
|
3921
|
+
abort();
|
|
3922
|
+
publish({ view: 'form', transcript: [], submission: null, followup: null, ticket: null, entries: [], busy: false, error: null });
|
|
3923
|
+
schedule();
|
|
3924
|
+
};
|
|
3925
|
+
const newConversation = () => {
|
|
3926
|
+
abort();
|
|
3927
|
+
publish({ view: 'conversation', messages: state.config ? [{ role: 'assistant', content: state.config.greeting }] : [], transcript: [], ticket: null, entries: [], error: null, busy: false, submission: null, followup: null });
|
|
3928
|
+
schedule();
|
|
3929
|
+
};
|
|
3930
|
+
return {
|
|
3931
|
+
getSnapshot: () => state,
|
|
3932
|
+
subscribe: listener => { listeners.add(listener); schedule(); return () => { listeners.delete(listener); if (!listeners.size) clearTimeout(timer); } },
|
|
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
|
+
},
|
|
3941
|
+
setVisible: value => { visible = value; if (value) void refresh(); },
|
|
3942
|
+
back: () => { abort(); publish({ view: 'conversation', busy: false, error: null, followup: null }); schedule(); },
|
|
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(); }
|
|
3944
|
+
}
|
|
3945
|
+
};
|
|
3946
|
+
|
|
3947
|
+
/** React Support panel uses the shared design tokens and its own BEM namespace. */
|
|
3948
|
+
const getSupportStylesheet = () => `
|
|
3949
|
+
:host { font-family: var(--andy-font-family-primary); color: #171717; line-height: 1.5; --andy-support-color: #171717; --andy-support-contrast: #fff; }
|
|
3950
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
3951
|
+
.andy-support button, .andy-support input, .andy-support textarea { font: inherit; }
|
|
3952
|
+
.andy-support button { cursor: pointer; }
|
|
3953
|
+
.andy-support button:disabled { cursor: default; opacity: .45; }
|
|
3954
|
+
.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; }
|
|
3955
|
+
.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; }
|
|
3956
|
+
.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; }
|
|
3957
|
+
:host(.andy-support-host--left) .andy-support__launcher, :host(.andy-support-host--left) .andy-support__panel { left: 24px; right: auto; }
|
|
3958
|
+
.andy-support__header { flex-shrink: 0; position: relative; display: flex; align-items: center; gap: 10px; padding: 18px; border-bottom: 1px solid #f0f0f0; }
|
|
3959
|
+
.andy-support__header > button { border: 0; background: transparent; color: #737373; padding: 5px; font-size: 22px; line-height: 1; min-width: 28px; }
|
|
3960
|
+
.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; }
|
|
3961
|
+
.andy-support__identity { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
3962
|
+
.andy-support__identity strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
|
|
3963
|
+
.andy-support__identity small { color: #737373; font-size: 12px; }
|
|
3964
|
+
.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; }
|
|
3965
|
+
.andy-support__menu button { display: block; border: 0; border-radius: 6px; background: none; padding: 10px; text-align: left; width: 100%; }
|
|
3966
|
+
.andy-support__menu button:hover { background: #f5f5f5; }
|
|
3967
|
+
.andy-support__content { flex: 1; min-height: 0; overflow-y: auto; padding: 24px 20px; overscroll-behavior: contain; }
|
|
3968
|
+
.andy-support h2 { font-size: 17px; font-weight: 600; margin: 0 0 20px; }
|
|
3969
|
+
.andy-support__message { white-space: pre-wrap; overflow-wrap: anywhere; margin-bottom: 16px; line-height: 1.65; }
|
|
3970
|
+
.andy-support__message--user { margin-left: auto; width: fit-content; max-width: 90%; padding: 10px 14px; border-radius: 16px 16px 4px 16px; background: #f5f5f5; }
|
|
3971
|
+
.andy-support__questions { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; margin-top: 22px; }
|
|
3972
|
+
.andy-support__questions button, .andy-support__secondary { border: 1px solid #e5e7eb; border-radius: 18px; background: #fff; padding: 10px 14px; text-align: left; color: #525252; }
|
|
3973
|
+
.andy-support__questions button:hover, .andy-support__secondary:hover { background: #fafafa; }
|
|
3974
|
+
.andy-support__secondary { margin-top: 8px; }
|
|
3975
|
+
.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; }
|
|
3976
|
+
.andy-support__composer input { flex: 1; min-width: 0; border: 0; background: none; padding: 4px 8px; outline-offset: 0; color: inherit; }
|
|
3977
|
+
.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; }
|
|
3978
|
+
.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; }
|
|
3979
|
+
.andy-support__footer a { color: #737373; text-decoration: none; }
|
|
3980
|
+
.andy-support__form { display: flex; flex-direction: column; gap: 16px; }
|
|
3981
|
+
.andy-support__form h2 { margin-bottom: 0; }
|
|
3982
|
+
.andy-support__form label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-weight: 500; }
|
|
3983
|
+
.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; }
|
|
3984
|
+
.andy-support__form textarea { resize: vertical; }
|
|
3985
|
+
.andy-support__transcript { border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; font-size: 12px; }
|
|
3986
|
+
.andy-support__transcript summary { cursor: pointer; }
|
|
3987
|
+
.andy-support__transcript p, .andy-support__entry p { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
3988
|
+
.andy-support__muted { color: #737373; font-size: 12px; }
|
|
3989
|
+
.andy-support__primary { border: 0; border-radius: 10px; background: var(--andy-support-color); color: var(--andy-support-contrast); padding: 12px 18px; }
|
|
3990
|
+
.andy-support__receipt { text-align: center; padding: 30px 8px; }
|
|
3991
|
+
.andy-support__check { display: inline-grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; background: #f5f5f5; font-size: 24px; margin-bottom: 20px; }
|
|
3992
|
+
.andy-support__receipt h2 { margin-bottom: 10px; }
|
|
3993
|
+
.andy-support__receipt > strong { font-size: 22px; }
|
|
3994
|
+
.andy-support__receipt .andy-support__primary { margin-top: 16px; }
|
|
3995
|
+
.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; }
|
|
3996
|
+
.andy-support__ticket small { grid-column: 1; color: #737373; }
|
|
3997
|
+
.andy-support__ticket > span:last-child { grid-column: 2; grid-row: 1 / 3; align-self: center; font-size: 20px; }
|
|
3998
|
+
.andy-support__entry { border-bottom: 1px solid #eee; padding: 16px 0; }
|
|
3999
|
+
.andy-support__unread { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; margin-left: 6px; vertical-align: middle; }
|
|
4000
|
+
.andy-support__ticket .andy-support__unread, .andy-support__menu .andy-support__unread { color: var(--andy-support-color); }
|
|
4001
|
+
.andy-support__request-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
|
|
4002
|
+
.andy-support__request-actions .andy-support__secondary { margin-top: 0; }
|
|
4003
|
+
.andy-support__followup { flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; margin: 0 18px 12px; }
|
|
4004
|
+
.andy-support__followup label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
|
|
4005
|
+
.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; }
|
|
4006
|
+
.andy-support__followup button { align-self: flex-end; }
|
|
4007
|
+
.andy-support__error { margin-top: 16px; padding: 12px; border-radius: 10px; background: #fef2f2; color: #991b1b; font-size: 12px; }
|
|
4008
|
+
.andy-support__error button { border: 0; background: none; color: inherit; text-decoration: underline; padding: 0; }
|
|
4009
|
+
@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; } }
|
|
4010
|
+
@media (prefers-reduced-motion: reduce) { .andy-support__content { scroll-behavior: auto; } }
|
|
4011
|
+
`;
|
|
4012
|
+
|
|
4013
|
+
const empty = { config: null, view: 'conversation', messages: [], transcript: [], tickets: [], ticket: null, entries: [], loading: true, busy: false, error: null, submission: null, followup: null, unreadCount: 0 };
|
|
4014
|
+
const noSubscribe = () => () => { };
|
|
4015
|
+
const emptySnapshot = () => empty;
|
|
4016
|
+
/** The host context key changes before a new user/company is allowed to see any previous state. */
|
|
4017
|
+
const useSupportChat = (options) => {
|
|
4018
|
+
const key = options ? JSON.stringify([options.installationId, options.contextKey, options.apiBaseUrl]) : null;
|
|
4019
|
+
const callback = useRef(options?.getRequesterPass);
|
|
4020
|
+
callback.current = options?.getRequesterPass;
|
|
4021
|
+
const [owned, setOwned] = useState(null);
|
|
4022
|
+
const controller = owned?.key === key ? owned?.controller : null;
|
|
4023
|
+
const state = useSyncExternalStore(controller?.subscribe || noSubscribe, controller?.getSnapshot || emptySnapshot, emptySnapshot);
|
|
4024
|
+
useEffect(() => {
|
|
4025
|
+
if (!options || !key)
|
|
4026
|
+
return;
|
|
4027
|
+
const controller = createSupportController({ ...options, getRequesterPass: () => callback.current() });
|
|
4028
|
+
setOwned({ key, controller });
|
|
4029
|
+
void controller.bootstrap();
|
|
4030
|
+
return () => controller.dispose();
|
|
4031
|
+
}, [key]);
|
|
4032
|
+
useEffect(() => { controller?.setLanguage(options?.lang); }, [controller, options?.lang]);
|
|
4033
|
+
const ready = !!state.config;
|
|
4034
|
+
useEffect(() => {
|
|
4035
|
+
if (ready && options?.ticketId)
|
|
4036
|
+
void controller?.inspect(options.ticketId);
|
|
4037
|
+
}, [controller, ready, options?.ticketId]);
|
|
4038
|
+
return { mode: 'support', state, controller };
|
|
4039
|
+
};
|
|
4040
|
+
|
|
4041
|
+
const copy = {
|
|
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' }
|
|
4044
|
+
};
|
|
4045
|
+
/** Private Requester UI. Sales bootstrap, visitor identity and storage never enter this renderer. */
|
|
4046
|
+
const SupportPanel = ({ support, lang, orientation = 'right', initialOpen = false, tooltip }) => {
|
|
4047
|
+
const { state, controller } = useSupportChat({ ...support, lang: lang || support.lang });
|
|
4048
|
+
const replyLanguage = lang || support.lang || state.config?.defaultLang || 'es';
|
|
4049
|
+
const strings = copy[replyLanguage];
|
|
4050
|
+
const [open, setOpen] = useState(initialOpen);
|
|
4051
|
+
const [menu, setMenu] = useState(false);
|
|
4052
|
+
const [draft, setDraft] = useState('');
|
|
4053
|
+
const [subject, setSubject] = useState('');
|
|
4054
|
+
const [body, setBody] = useState('');
|
|
4055
|
+
const [replyDraft, setReplyDraft] = useState('');
|
|
4056
|
+
const [reopening, setReopening] = useState(false);
|
|
4057
|
+
const [selectedTicketId, setSelectedTicketId] = useState(null);
|
|
4058
|
+
const host = useRef(null);
|
|
4059
|
+
const messages = useRef(null);
|
|
4060
|
+
const [portal, setPortal] = useState(null);
|
|
4061
|
+
useEffect(() => {
|
|
4062
|
+
if (!host.current)
|
|
4063
|
+
return;
|
|
4064
|
+
const shadow = host.current.shadowRoot || host.current.attachShadow({ mode: 'open' });
|
|
4065
|
+
const style = document.createElement('style');
|
|
4066
|
+
style.textContent = generateDesignTokenVariablesString() + getSupportStylesheet();
|
|
4067
|
+
const target = document.createElement('div');
|
|
4068
|
+
shadow.append(style, target);
|
|
4069
|
+
setPortal(target);
|
|
4070
|
+
return () => { style.remove(); target.remove(); };
|
|
4071
|
+
}, []);
|
|
4072
|
+
useEffect(() => {
|
|
4073
|
+
const color = state.config?.branding.color || '#171717';
|
|
4074
|
+
host.current?.style.setProperty('--andy-support-color', color);
|
|
4075
|
+
host.current?.style.setProperty('--andy-support-contrast', getContrastTextColor(color));
|
|
4076
|
+
}, [state.config?.branding.color]);
|
|
4077
|
+
useEffect(() => { messages.current?.scrollTo?.({ top: messages.current.scrollHeight, behavior: 'smooth' }); }, [state.messages, state.view]);
|
|
4078
|
+
useEffect(() => { controller?.setVisible(open); }, [controller, open]);
|
|
4079
|
+
useEffect(() => { if (support.ticketId)
|
|
4080
|
+
setOpen(true); }, [support.ticketId]);
|
|
4081
|
+
useEffect(() => { if (state.ticket?.status !== 'resolved' && state.followup?.action !== 'reopen')
|
|
4082
|
+
setReopening(false); }, [state.ticket?.status, state.followup?.action]);
|
|
4083
|
+
const choose = (action) => { setMenu(false); action(); };
|
|
4084
|
+
const escalate = () => { setSubject(''); setBody(''); controller?.escalate(); };
|
|
4085
|
+
const send = () => { if (!draft.trim() || state.busy)
|
|
4086
|
+
return; void controller?.send(draft); setDraft(''); };
|
|
4087
|
+
const newProblem = () => { setSubject(''); setBody(''); setReplyDraft(''); setReopening(false); controller?.newProblem(); };
|
|
4088
|
+
const followup = async () => {
|
|
4089
|
+
const result = await controller?.reply(replyDraft, reopening);
|
|
4090
|
+
if (result) {
|
|
4091
|
+
setReplyDraft('');
|
|
4092
|
+
setReopening(false);
|
|
4093
|
+
}
|
|
4094
|
+
};
|
|
4095
|
+
const unread = jsx("span", { className: 'andy-support__unread', role: 'img', "aria-label": strings.unread });
|
|
4096
|
+
const title = state.config?.branding.name || strings.support;
|
|
4097
|
+
const ticketRow = (ticket) => jsxs("button", { type: 'button', className: 'andy-support__ticket', onClick: () => { setSelectedTicketId(ticket.id); setReplyDraft(''); setReopening(false); void controller?.inspect(ticket.id); }, children: [jsxs("span", { children: ["#", ticket.number, " \u00B7 ", ticket.subject, " ", ticket.unread && unread] }), jsx("small", { children: strings[ticket.status] }), jsx("span", { "aria-hidden": 'true', children: "\u203A" })] }, ticket.id);
|
|
4098
|
+
const retry = () => {
|
|
4099
|
+
if (!state.config)
|
|
4100
|
+
void controller?.bootstrap();
|
|
4101
|
+
else if (state.view === 'form')
|
|
4102
|
+
void controller?.submit(subject, body, replyLanguage);
|
|
4103
|
+
else if (state.view === 'requests')
|
|
4104
|
+
void controller?.showRequests();
|
|
4105
|
+
else if (state.view === 'request' && state.followup)
|
|
4106
|
+
void followup();
|
|
4107
|
+
else if (state.view === 'request' && (selectedTicketId || support.ticketId))
|
|
4108
|
+
void controller?.inspect((selectedTicketId || support.ticketId));
|
|
4109
|
+
};
|
|
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;
|
|
4111
|
+
const promise = state.ticket?.replyPromise || state.config?.replyPromise;
|
|
4112
|
+
const promiseText = !promise ? null : promise.minutes === promise.businessDayMinutes ? strings.businessDay : promise.minutes % 60 === 0 ? `${promise.minutes / 60} ${strings.hours}` : `${promise.minutes} ${strings.minutes}`;
|
|
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) });
|
|
4114
|
+
};
|
|
4115
|
+
/** A host identity change also destroys local drafts and menu state before the next render. */
|
|
4116
|
+
const SupportChat = props => jsx(SupportPanel, { ...props }, JSON.stringify([props.support.installationId, props.support.contextKey, props.support.apiBaseUrl]));
|
|
4117
|
+
|
|
4118
|
+
const SalesAndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessage, bubbleLabel, lang, predefinedQuestions, theme, orientation, brandColor, bubbleStyle, useBrandColor, logoUrl, previewMode = false, initialOpen = false }) => {
|
|
3679
4119
|
const [config, setConfig] = useState(null);
|
|
3680
4120
|
// The visitor's language wins over the Agent's own default: a Spanish Agent
|
|
3681
4121
|
// embedded on an English page must still render English chrome.
|
|
@@ -4300,10 +4740,14 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
|
|
|
4300
4740
|
config?.welcomeMessage ||
|
|
4301
4741
|
'Welcome! How can I help you today?' }), jsx("button", { type: 'button', className: 'andy-welcome-popup__close', "aria-label": strings.closeWelcomeMessage, onClick: () => setShowWelcomePopup(false), children: 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: jsx("path", { d: 'M18 6L6 18M6 6l12 12', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' }) }) })] }), jsx("div", { className: 'andy-welcome-popup__arrow' })] }), portalRef.current)] })) }));
|
|
4302
4742
|
};
|
|
4743
|
+
const AndyChat = props => props.mode === 'support'
|
|
4744
|
+
? jsx(SupportChat, { ...props }, `${props.support.installationId}:${props.support.contextKey}`)
|
|
4745
|
+
: jsx(SalesAndyChat, { ...props });
|
|
4303
4746
|
|
|
4304
4747
|
const { SESSION_STORAGE_KEY, FORM_DISPLAY_DELAY_MS, PERSIST_SESSION } = CONFIG;
|
|
4305
|
-
const
|
|
4306
|
-
|
|
4748
|
+
const useSalesAndyChat = (embedId, enabled) => {
|
|
4749
|
+
if (enabled)
|
|
4750
|
+
initializeConfig('andy-widget-react');
|
|
4307
4751
|
const [config, setConfig] = useState({});
|
|
4308
4752
|
const [isLoading, setIsLoading] = useState(true);
|
|
4309
4753
|
const [isOpen, setIsOpen] = useState(false);
|
|
@@ -4323,6 +4767,8 @@ const useAndyChat = (embedId) => {
|
|
|
4323
4767
|
const welcomeTimeoutRef = useRef(null);
|
|
4324
4768
|
const userInfoTimeoutRef = useRef(null);
|
|
4325
4769
|
const initializeChatbot = useCallback(async () => {
|
|
4770
|
+
if (!enabled)
|
|
4771
|
+
return;
|
|
4326
4772
|
if (!embedId) {
|
|
4327
4773
|
throw new Error('Embed ID is required');
|
|
4328
4774
|
}
|
|
@@ -4357,7 +4803,7 @@ const useAndyChat = (embedId) => {
|
|
|
4357
4803
|
setIsLoading(false);
|
|
4358
4804
|
throw err;
|
|
4359
4805
|
}
|
|
4360
|
-
}, [embedId]);
|
|
4806
|
+
}, [embedId, enabled]);
|
|
4361
4807
|
const toggleChat = useCallback(() => {
|
|
4362
4808
|
setIsOpen(prev => {
|
|
4363
4809
|
const newIsOpen = !prev;
|
|
@@ -4391,7 +4837,7 @@ const useAndyChat = (embedId) => {
|
|
|
4391
4837
|
});
|
|
4392
4838
|
}, [config, welcomePopupShown, userInfoCollected]);
|
|
4393
4839
|
const sendMessage = useCallback(async (messageText) => {
|
|
4394
|
-
if (!messageText.trim() || !config || !sessionId || activeReplyRef.current) {
|
|
4840
|
+
if (!enabled || !messageText.trim() || !config || !sessionId || activeReplyRef.current) {
|
|
4395
4841
|
return;
|
|
4396
4842
|
}
|
|
4397
4843
|
const controller = new AbortController();
|
|
@@ -4441,7 +4887,7 @@ const useAndyChat = (embedId) => {
|
|
|
4441
4887
|
if (activeReplyRef.current === controller)
|
|
4442
4888
|
activeReplyRef.current = null;
|
|
4443
4889
|
}
|
|
4444
|
-
}, [embedId, sessionId, config]);
|
|
4890
|
+
}, [embedId, sessionId, config, enabled]);
|
|
4445
4891
|
const showFaq = useCallback(() => {
|
|
4446
4892
|
setShowingFaq(true);
|
|
4447
4893
|
}, []);
|
|
@@ -4461,6 +4907,8 @@ const useAndyChat = (embedId) => {
|
|
|
4461
4907
|
setShowingUserInfoForm(false);
|
|
4462
4908
|
}, []);
|
|
4463
4909
|
const submitUserInfo$1 = useCallback(async (userInfo) => {
|
|
4910
|
+
if (!enabled)
|
|
4911
|
+
return;
|
|
4464
4912
|
try {
|
|
4465
4913
|
await submitUserInfo(embedId, sessionId, userInfo);
|
|
4466
4914
|
setUserInfoCollected(true);
|
|
@@ -4477,8 +4925,10 @@ const useAndyChat = (embedId) => {
|
|
|
4477
4925
|
console.error('Error submitting user info:', err);
|
|
4478
4926
|
throw err;
|
|
4479
4927
|
}
|
|
4480
|
-
}, [embedId, sessionId]);
|
|
4928
|
+
}, [embedId, sessionId, enabled]);
|
|
4481
4929
|
const startNewSession = useCallback(() => {
|
|
4930
|
+
if (!enabled)
|
|
4931
|
+
return;
|
|
4482
4932
|
activeReplyRef.current?.abort();
|
|
4483
4933
|
activeReplyRef.current = null;
|
|
4484
4934
|
const newSessionId = generateSessionId();
|
|
@@ -4504,7 +4954,7 @@ const useAndyChat = (embedId) => {
|
|
|
4504
4954
|
setMessages([greetingMessage]);
|
|
4505
4955
|
}
|
|
4506
4956
|
}
|
|
4507
|
-
}, [config]);
|
|
4957
|
+
}, [config, enabled]);
|
|
4508
4958
|
const addMessage = useCallback((message) => {
|
|
4509
4959
|
setMessages(prev => [...prev, message]);
|
|
4510
4960
|
}, []);
|
|
@@ -4533,6 +4983,11 @@ const useAndyChat = (embedId) => {
|
|
|
4533
4983
|
addMessage
|
|
4534
4984
|
};
|
|
4535
4985
|
};
|
|
4986
|
+
function useAndyChat(input) {
|
|
4987
|
+
const support = useSupportChat(typeof input === 'string' ? null : input.support);
|
|
4988
|
+
const sales = useSalesAndyChat(typeof input === 'string' ? input : '', typeof input === 'string');
|
|
4989
|
+
return typeof input === 'string' ? sales : support;
|
|
4990
|
+
}
|
|
4536
4991
|
|
|
4537
4992
|
const ChatBubble = ({ isOpen, config, onClick }) => {
|
|
4538
4993
|
const bubbleClasses = ['andy-bubble', isOpen && 'andy-bubble--open'].filter(Boolean).join(' ');
|
|
@@ -4721,4 +5176,3 @@ const UserInfoForm = ({ headerText, onSubmit, onClose }) => {
|
|
|
4721
5176
|
};
|
|
4722
5177
|
|
|
4723
5178
|
export { AndyChat as Andy, AndyChat, ChatBubble, ChatWindow, FAQ, MessageInput, MessageList, UserInfoForm, WelcomePopup, animation, borderRadius, chat, colors, AndyChat as default, generateSessionId, getApiBaseUrl, getEmbedId, isValidEmbedId, parseMarkdown, sanitizeUrl, setApiBaseUrl, shadows, spacing, theme, typography, useAndyChat, zIndex };
|
|
4724
|
-
//# sourceMappingURL=index.esm.js.map
|
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
import type { UseAndyChatReturn } from '../types';
|
|
2
|
-
|
|
2
|
+
import type { SupportOptions } from 'andy-widget-core';
|
|
3
|
+
import { useSupportChat } from './useSupportChat';
|
|
4
|
+
export declare function useAndyChat(embedId: string): UseAndyChatReturn;
|
|
5
|
+
export declare function useAndyChat(options: {
|
|
6
|
+
mode: 'support';
|
|
7
|
+
support: SupportOptions;
|
|
8
|
+
}): ReturnType<typeof useSupportChat>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type SupportOptions, type SupportState } from 'andy-widget-core';
|
|
2
|
+
/** The host context key changes before a new user/company is allowed to see any previous state. */
|
|
3
|
+
export declare const useSupportChat: (options: SupportOptions | null) => {
|
|
4
|
+
mode: "support";
|
|
5
|
+
state: SupportState;
|
|
6
|
+
controller: {
|
|
7
|
+
getSnapshot: () => SupportState;
|
|
8
|
+
subscribe: (listener: () => void) => () => void;
|
|
9
|
+
bootstrap: () => Promise<unknown>;
|
|
10
|
+
send: (content: string) => Promise<unknown>;
|
|
11
|
+
escalate: () => void;
|
|
12
|
+
submit: (subject: string, body: string, replyLanguage?: "es" | "en") => Promise<unknown>;
|
|
13
|
+
showRequests: () => Promise<unknown>;
|
|
14
|
+
inspect: (ticketId: string) => Promise<unknown>;
|
|
15
|
+
reply: (body: string, reopen?: boolean) => Promise<unknown>;
|
|
16
|
+
newProblem: () => void;
|
|
17
|
+
refresh: () => Promise<unknown>;
|
|
18
|
+
setVisible: (visible: boolean) => void;
|
|
19
|
+
setLanguage: (lang?: "es" | "en") => void;
|
|
20
|
+
newConversation: () => void;
|
|
21
|
+
back: () => void;
|
|
22
|
+
dispose: () => void;
|
|
23
|
+
} | null;
|
|
24
|
+
};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -8,6 +8,6 @@ export { default as MessageInput } from './components/MessageInput';
|
|
|
8
8
|
export { default as FAQ } from './components/FAQ';
|
|
9
9
|
export { default as WelcomePopup } from './components/WelcomePopup';
|
|
10
10
|
export { default as UserInfoForm } from './components/UserInfoForm';
|
|
11
|
-
export type { AndyChatProps, ChatbotConfig, Message, FAQItem, PredefinedQuestion, WelcomePopupConfig, CollectUserInfoConfig, UserInfo, UseAndyChatReturn, ChatBubbleProps, ChatWindowProps, MessageListProps, MessageInputProps, FAQProps, WelcomePopupProps, UserInfoFormProps, FaqItemProps } from './types';
|
|
11
|
+
export type { AndyChatProps, SupportAndyChatProps, SalesAndyChatProps, ChatbotConfig, Message, FAQItem, PredefinedQuestion, WelcomePopupConfig, CollectUserInfoConfig, UserInfo, UseAndyChatReturn, ChatBubbleProps, ChatWindowProps, MessageListProps, MessageInputProps, FAQProps, WelcomePopupProps, UserInfoFormProps, FaqItemProps } from './types';
|
|
12
12
|
export { theme, colors, typography, spacing, borderRadius, shadows, zIndex, animation, chat, generateSessionId, parseMarkdown, sanitizeUrl, isValidEmbedId, getEmbedId, getApiBaseUrl, setApiBaseUrl } from 'andy-widget-core';
|
|
13
13
|
export { default } from './AndyChat';
|