@sub-zero/cocoa 0.1.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/cocoa.js +164 -10
- package/dist/index.cjs +161 -10
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +161 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -163,3 +163,17 @@ contains only `dist`, this README, and package metadata. Do not publish the priv
|
|
|
163
163
|
Angular application root. Review the version and `npm pack --dry-run` output before
|
|
164
164
|
publishing. No open-source license is granted (`UNLICENSED`); the source repository
|
|
165
165
|
may be private.
|
|
166
|
+
|
|
167
|
+
## Incident handoff
|
|
168
|
+
|
|
169
|
+
The visible **Get further help** action opens a review form. The visitor chooses a correspondence email and explicitly submits an incident; neither opening chat nor mentioning an email creates one. The current visitor session authorizes creation in the chatbot's project. A confirmed reference survives reload, and failed submissions retain their text and idempotency key for retry. The team's follow-up is by email. The email is not verified account identity.
|
|
170
|
+
|
|
171
|
+
Requires the SubZero chat-escalation API. Closed conversations can still submit while their visitor credential is valid; paused deployments cannot accept new submissions. No model call is needed for handoff. `reset()` clears the chat, pending form and visitor credential; call it on logout/account changes. SMTP acknowledgement delivery retries are at-least-once; a stable Message-ID assists threading but cannot guarantee exactly-once delivery by an external mail server.
|
|
172
|
+
|
|
173
|
+
### Greeting and reply feedback
|
|
174
|
+
|
|
175
|
+
The launcher shows “Hi! Ask me anything.” after five seconds, once per browser tab. Click to chat or dismiss it. Opening chat first suppresses the greeting. A soft chime plays only if a previous user interaction has enabled browser audio; otherwise the greeting appears silently. Nothing starts a conversation until the visitor sends a message or explicitly submits an incident.
|
|
176
|
+
|
|
177
|
+
Customize with `greetingMessage` (string, or `false` to disable), `greetingDelay` (milliseconds), and `greetingSound` (`false` for silent greetings). Script embeds accept `data-greeting-message`, `data-greeting-delay` and `data-greeting-sound`.
|
|
178
|
+
|
|
179
|
+
Animated dots indicate a reply is pending while the agent is online. They clear when an answer arrives, a person takes over, the chat is paused/closed, an incident is submitted, or a request fails. Reduced-motion preferences disable animation. The indicator reflects an unanswered message, not provider-level token streaming.
|
package/dist/cocoa.js
CHANGED
|
@@ -23,6 +23,8 @@ function createCocoa(options = {}) {
|
|
|
23
23
|
if (!Number.isInteger(zIndex) || zIndex < 0 || zIndex > 2147483647) throw new Error('Invalid zIndex.');
|
|
24
24
|
const pollInterval = options.pollInterval ?? 2500;
|
|
25
25
|
if (!Number.isFinite(pollInterval) || pollInterval < 2500) throw new Error('pollInterval must be at least 2500ms.');
|
|
26
|
+
const greetingDelay = options.greetingDelay ?? 5000;
|
|
27
|
+
if (!Number.isFinite(greetingDelay) || greetingDelay < 0) throw new Error('greetingDelay must be a nonnegative number.');
|
|
26
28
|
const host = document.createElement('div');
|
|
27
29
|
host.dataset.cocoaWidget = '';
|
|
28
30
|
const root = host.attachShadow({ mode: 'open' });
|
|
@@ -33,11 +35,15 @@ function createCocoa(options = {}) {
|
|
|
33
35
|
*,*::before,*::after{box-sizing:border-box} [hidden]{display:none!important}
|
|
34
36
|
button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid #647078;border-radius:12px;padding:10px 14px;background:#26343a;color:#fff}button:hover{filter:brightness(1.15)}button:disabled{opacity:.55;cursor:default}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid #99dbc4;outline-offset:2px}
|
|
35
37
|
.launcher{display:block;margin-left:auto;width:72px;height:72px;padding:0;border:2px solid #e1f4fa;border-radius:50%;overflow:hidden;background:#92c5d5;box-shadow:0 6px 24px #0005}.launcher img{display:block;width:100%;height:100%;object-fit:cover;transform:scale(1.18);pointer-events:none}.launcher:hover{filter:brightness(1.06)}
|
|
38
|
+
.greeting{position:relative;margin:0 0 12px auto;display:flex;align-items:center;max-width:calc(100vw - 32px);width:250px;background:#fff5e9;color:#392516;border-radius:18px;box-shadow:0 6px 24px #0003;padding:6px}.greeting::after{content:'';position:absolute;right:28px;bottom:-8px;border:8px solid transparent;border-top-color:#fff5e9;border-bottom:0}.greeting button{color:inherit;background:transparent;border:0}.greeting .greeting-open{text-align:left;flex:1}.greeting .greeting-dismiss{padding:8px;min-width:40px;min-height:40px}
|
|
39
|
+
.thinking{display:flex;align-items:center;gap:8px;padding:8px 16px;color:#c8d4dd;font-size:12px}.dots{display:flex;gap:4px}.dots i{width:5px;height:5px;border-radius:50%;background:#92c5d5}
|
|
40
|
+
@media(prefers-reduced-motion:no-preference){.greeting{animation:appear .25s ease-out}.dots i{animation:think 1.2s ease-in-out infinite}.dots i:nth-child(2){animation-delay:.15s}.dots i:nth-child(3){animation-delay:.3s}@keyframes think{0%,60%,100%{opacity:.4;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}}
|
|
36
41
|
.panel{display:flex;flex-direction:column;width:370px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100dvh - 116px);background:#141c22;border:1px solid #44515b;border-radius:18px;box-shadow:0 12px 50px #0005;overflow:hidden;margin-bottom:12px}
|
|
37
42
|
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid #39454e}header strong{font-size:16px;overflow-wrap:anywhere}header small{display:block;color:#bac9d2}header button{min-width:44px;min-height:44px;font-size:22px;background:transparent}
|
|
38
43
|
.messages{flex:1;min-height:50px;overflow:auto;overscroll-behavior:contain;padding:16px;display:flex;flex-direction:column;gap:12px}.message{flex-shrink:0;align-self:flex-start;max-width:92%;background:#27333d;padding:10px 12px;border-radius:12px;white-space:pre-wrap;overflow-wrap:anywhere}.customer{align-self:flex-end;background:${accent}}.message small{display:block;color:#fff;font-weight:600;font-size:11px;margin-bottom:4px}
|
|
39
44
|
.welcome{color:#c8d4dd;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.status{color:#c8d4dd;font-size:12px;margin:0;padding:8px 16px}.error{color:#ffc3c3}
|
|
40
45
|
form{padding:12px 16px;border-top:1px solid #39454e;display:grid;gap:8px}label{display:grid;gap:4px;font-size:12px}input,textarea{width:100%;border:1px solid #61717e;border-radius:8px;background:#1e2932;color:#fff;padding:8px;font-size:16px}textarea{height:64px;resize:none}.actions{display:flex;justify-content:flex-end;gap:8px}.new{margin-right:auto}
|
|
46
|
+
.help{margin:0 16px 10px}.handoff{overflow:auto;min-height:0;flex:1}.handoff p{margin:0;color:#c8d4dd;font-size:12px}.handoff textarea{height:90px}.handoff-result{padding:12px 16px;overflow-wrap:anywhere}
|
|
41
47
|
@media(prefers-reduced-motion:no-preference){.panel{animation:appear .15s ease-out}@keyframes appear{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}}
|
|
42
48
|
@media(max-height:540px){.panel{height:calc(100dvh - 116px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
|
|
43
49
|
`;
|
|
@@ -63,6 +69,32 @@ function createCocoa(options = {}) {
|
|
|
63
69
|
actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
|
|
64
70
|
composer.append(nameLabel, messageLabel, actions);
|
|
65
71
|
panel.append(header, element('div', { class: 'messages', role: 'log', 'aria-label': 'Conversation', 'aria-live': 'polite', 'aria-relevant': 'additions text' }), element('p', { class: 'status', role: 'status' }), composer);
|
|
72
|
+
const help = element('button', { type: 'button', class: 'help' }, 'Get further help');
|
|
73
|
+
const handoff = element('form', { class: 'handoff', hidden: '' });
|
|
74
|
+
const fields = {};
|
|
75
|
+
for (const [key, label, tag, max, type] of [
|
|
76
|
+
['email', 'Correspondence email', 'input', 254, 'email'],
|
|
77
|
+
['name', 'Your name (optional)', 'input', 120, 'text'],
|
|
78
|
+
['summary', 'Summary', 'input', 200, 'text'],
|
|
79
|
+
['details', 'What do you still need help with?', 'textarea', 6000, null],
|
|
80
|
+
]) {
|
|
81
|
+
const field = element(tag, { maxlength: String(max), ...(type ? {type} : {}), ...(key !== 'name' ? {required:''} : {}) });
|
|
82
|
+
const labelNode = element('label', {}, label); labelNode.append(field); handoff.append(labelNode); fields[key] = field;
|
|
83
|
+
}
|
|
84
|
+
handoff.append(element('p', {}, 'Review these details before submitting. Your visible conversation will accompany the incident. The support team will follow up at this email. Never include passwords, seed phrases or private keys.'));
|
|
85
|
+
const handoffError = element('p', {class:'error', role:'alert'});
|
|
86
|
+
const back = element('button', {type:'button'}, 'Back to chat');
|
|
87
|
+
const submitIncident = element('button', {type:'submit'}, 'Submit incident');
|
|
88
|
+
const handoffActions = element('div', {class:'actions'}); handoffActions.append(back, submitIncident);
|
|
89
|
+
handoff.append(handoffError, handoffActions);
|
|
90
|
+
const handoffResult = element('p', {class:'handoff-result',role:'status',tabindex:'-1',hidden:''});
|
|
91
|
+
panel.append(help, handoff, handoffResult);
|
|
92
|
+
const thinking = element('div', {class:'thinking',role:'status',hidden:''});
|
|
93
|
+
const dots = element('span', {class:'dots','aria-hidden':'true'});
|
|
94
|
+
dots.append(element('i'),element('i'),element('i'));
|
|
95
|
+
const thinkingLabel = element('span', {}, 'Cocoa is thinking…');
|
|
96
|
+
thinking.append(dots, thinkingLabel);
|
|
97
|
+
composer.before(thinking);
|
|
66
98
|
panel.querySelector('strong').textContent = options.title || 'Cocoa support';
|
|
67
99
|
const launcher = document.createElement('button');
|
|
68
100
|
launcher.type = 'button';
|
|
@@ -77,7 +109,11 @@ function createCocoa(options = {}) {
|
|
|
77
109
|
launcher.setAttribute('aria-expanded', 'false');
|
|
78
110
|
launcher.setAttribute('aria-controls', panel.id);
|
|
79
111
|
launcher.setAttribute('aria-haspopup', 'dialog');
|
|
80
|
-
|
|
112
|
+
const greeting = element('div', {class:'greeting',hidden:''});
|
|
113
|
+
const greetingOpen = element('button', {type:'button',class:'greeting-open'}, options.greetingMessage || 'Hi! Ask me anything.');
|
|
114
|
+
const greetingDismiss = element('button', {type:'button',class:'greeting-dismiss','aria-label':'Dismiss greeting'}, '×');
|
|
115
|
+
greeting.append(greetingOpen,greetingDismiss);
|
|
116
|
+
root.append(panel, greeting, launcher);
|
|
81
117
|
document.body.append(host);
|
|
82
118
|
const messages = panel.querySelector('.messages');
|
|
83
119
|
const status = panel.querySelector('.status');
|
|
@@ -89,12 +125,48 @@ function createCocoa(options = {}) {
|
|
|
89
125
|
name.value = String(options.visitorLabel || '').slice(0, 120);
|
|
90
126
|
let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
|
|
91
127
|
let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
|
|
128
|
+
let incident = null, incidentPending = null, submittingIncident = false, expired = false;
|
|
129
|
+
let greetingTimer, audioContext, greetingSeen = false;
|
|
130
|
+
const greetingKey = key + ':greeted';
|
|
131
|
+
try { greetingSeen = sessionStorage.getItem(greetingKey) === '1'; } catch {}
|
|
132
|
+
const dismissGreeting = (remember = true) => {
|
|
133
|
+
clearTimeout(greetingTimer); greeting.hidden = true;
|
|
134
|
+
if (remember) { greetingSeen = true; try { sessionStorage.setItem(greetingKey,'1'); } catch {} }
|
|
135
|
+
document.removeEventListener('pointerdown', unlockAudio);
|
|
136
|
+
document.removeEventListener('keydown', unlockAudio);
|
|
137
|
+
if (audioContext) { void audioContext.close().catch(()=>{}); audioContext = null; }
|
|
138
|
+
};
|
|
139
|
+
function unlockAudio() {
|
|
140
|
+
if (destroyed || greetingSeen || options.greetingSound === false || audioContext) return;
|
|
141
|
+
try {
|
|
142
|
+
const Audio = window.AudioContext || window.webkitAudioContext;
|
|
143
|
+
if (Audio) { audioContext = new Audio(); void audioContext.resume().catch(()=>{}); }
|
|
144
|
+
} catch { /* Greeting still works when audio is unavailable. */ }
|
|
145
|
+
}
|
|
146
|
+
function showGreeting() {
|
|
147
|
+
if (destroyed || greetingSeen || !panel.hidden || session || options.greetingMessage === false || document.hidden) return;
|
|
148
|
+
greeting.hidden = false; greetingSeen = true;
|
|
149
|
+
try { sessionStorage.setItem(greetingKey,'1'); } catch {}
|
|
150
|
+
document.removeEventListener('pointerdown', unlockAudio);
|
|
151
|
+
document.removeEventListener('keydown', unlockAudio);
|
|
152
|
+
// Only play after a prior user gesture unlocked audio. Never queue a surprise sound.
|
|
153
|
+
if (audioContext?.state === 'running') {
|
|
154
|
+
try {
|
|
155
|
+
const tone = audioContext.createOscillator(), volume = audioContext.createGain();
|
|
156
|
+
const now = audioContext.currentTime;
|
|
157
|
+
tone.type = 'sine'; tone.frequency.setValueAtTime(660,now); tone.frequency.exponentialRampToValueAtTime(880,now+.1);
|
|
158
|
+
volume.gain.setValueAtTime(0,now); volume.gain.linearRampToValueAtTime(.045,now+.015); volume.gain.exponentialRampToValueAtTime(.001,now+.2);
|
|
159
|
+
tone.connect(volume); volume.connect(audioContext.destination); tone.start(now); tone.stop(now+.22);
|
|
160
|
+
tone.onended = () => { tone.disconnect(); volume.disconnect(); if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; } };
|
|
161
|
+
} catch { if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; } }
|
|
162
|
+
} else if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; }
|
|
163
|
+
}
|
|
92
164
|
const rendered = new Set();
|
|
93
165
|
const requests = new Set();
|
|
94
166
|
const persist = () => {
|
|
95
167
|
if (options.persistSession === false) return;
|
|
96
168
|
try {
|
|
97
|
-
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
|
|
169
|
+
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending, incidentPending }));
|
|
98
170
|
else sessionStorage.removeItem(key);
|
|
99
171
|
} catch { /* Storage may be unavailable; an in-memory session still works. */ }
|
|
100
172
|
};
|
|
@@ -103,6 +175,11 @@ function createCocoa(options = {}) {
|
|
|
103
175
|
const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
|
|
104
176
|
if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
|
|
105
177
|
session = { id: stored.id, token: stored.token };
|
|
178
|
+
const saved = stored.incidentPending;
|
|
179
|
+
if (saved && uuid.test(saved.idempotencyKey || '') && ['email','name','summary','details'].every(k=>typeof saved[k]==='string' && saved[k].length<=Number(fields[k].maxLength))) {
|
|
180
|
+
incidentPending = saved;
|
|
181
|
+
for (const k of Object.keys(fields)) fields[k].value = saved[k];
|
|
182
|
+
}
|
|
106
183
|
if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
|
|
107
184
|
pending = stored.pending;
|
|
108
185
|
input.value = pending.body;
|
|
@@ -119,7 +196,12 @@ function createCocoa(options = {}) {
|
|
|
119
196
|
welcome();
|
|
120
197
|
const controls = () => {
|
|
121
198
|
const focusWasInComposer = root.activeElement === input || root.activeElement === send;
|
|
122
|
-
|
|
199
|
+
if (incident || !handoff.hidden || terminal || paused) thinking.hidden = true;
|
|
200
|
+
help.disabled = sending || submittingIncident || expired || paused;
|
|
201
|
+
help.hidden = !!incident || !handoff.hidden;
|
|
202
|
+
handoffResult.hidden = !incident;
|
|
203
|
+
if (incident) handoffResult.textContent = `Incident ${incident.reference} submitted. Follow-up will be by email at ${incident.email}.`;
|
|
204
|
+
input.disabled = sending || terminal || paused || submittingIncident;
|
|
123
205
|
send.disabled = sending || terminal || paused;
|
|
124
206
|
name.disabled = sending;
|
|
125
207
|
fresh.hidden = !terminal;
|
|
@@ -131,6 +213,7 @@ function createCocoa(options = {}) {
|
|
|
131
213
|
};
|
|
132
214
|
controls();
|
|
133
215
|
const report = (error) => {
|
|
216
|
+
thinking.hidden = true;
|
|
134
217
|
status.classList.add('error');
|
|
135
218
|
status.textContent = error.message;
|
|
136
219
|
try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
|
|
@@ -165,7 +248,7 @@ function createCocoa(options = {}) {
|
|
|
165
248
|
}
|
|
166
249
|
function expire(error) {
|
|
167
250
|
if (error.status === 401 || error.status === 404) {
|
|
168
|
-
terminal = true;
|
|
251
|
+
terminal = true; expired = true;
|
|
169
252
|
session = null;
|
|
170
253
|
pending = null;
|
|
171
254
|
persist();
|
|
@@ -192,12 +275,15 @@ function createCocoa(options = {}) {
|
|
|
192
275
|
if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
|
|
193
276
|
version = data.version;
|
|
194
277
|
}
|
|
278
|
+
incident = data.escalation || incident;
|
|
195
279
|
terminal = data.state === 'closed';
|
|
196
280
|
paused = !data.enabled;
|
|
197
281
|
status.classList.remove('error');
|
|
198
|
-
status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
|
|
282
|
+
status.textContent = incident ? 'Your incident is with the support team. Follow-up will be by email.' : terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
|
|
199
283
|
data.state === 'human' ? 'A person is handling your conversation.' :
|
|
200
284
|
data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
|
|
285
|
+
const last = data.messages.filter(m=>['customer','agent','human'].includes(m.actor)).at(-1);
|
|
286
|
+
thinking.hidden = !(last?.actor === 'customer' && data.agentOnline && data.state === 'agent' && !terminal && !paused && !incident && handoff.hidden);
|
|
201
287
|
controls();
|
|
202
288
|
}
|
|
203
289
|
function schedule() {
|
|
@@ -207,7 +293,7 @@ function createCocoa(options = {}) {
|
|
|
207
293
|
}
|
|
208
294
|
}
|
|
209
295
|
async function refresh() {
|
|
210
|
-
if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
|
|
296
|
+
if (destroyed || refreshing || sending || submittingIncident || panel.hidden || document.hidden || !session || terminal) return;
|
|
211
297
|
refreshing = true;
|
|
212
298
|
const current = generation;
|
|
213
299
|
try {
|
|
@@ -230,18 +316,25 @@ function createCocoa(options = {}) {
|
|
|
230
316
|
}
|
|
231
317
|
function open() {
|
|
232
318
|
if (destroyed) return;
|
|
319
|
+
dismissGreeting();
|
|
233
320
|
panel.hidden = false;
|
|
234
321
|
launcher.setAttribute('aria-expanded', 'true');
|
|
235
|
-
(terminal ? fresh : input).focus();
|
|
322
|
+
(!handoff.hidden ? fields.email : terminal ? fresh : input).focus();
|
|
236
323
|
void refresh();
|
|
237
324
|
}
|
|
238
325
|
function reset() {
|
|
239
326
|
if (destroyed) return;
|
|
240
327
|
generation++;
|
|
241
328
|
sending = refreshing = false;
|
|
329
|
+
thinking.hidden = true;
|
|
242
330
|
for (const request of requests) request.abort();
|
|
243
331
|
clearTimeout(timer);
|
|
244
|
-
session = pending = null;
|
|
332
|
+
session = pending = incident = incidentPending = null;
|
|
333
|
+
expired = submittingIncident = false;
|
|
334
|
+
handoff.hidden = true; composer.hidden = messages.hidden = false;
|
|
335
|
+
submitIncident.disabled = back.disabled = false;
|
|
336
|
+
for(const field of Object.values(fields)) { field.value = ''; field.disabled = false; }
|
|
337
|
+
handoffError.textContent = '';
|
|
245
338
|
terminal = paused = false;
|
|
246
339
|
version = -1;
|
|
247
340
|
failures = 0;
|
|
@@ -256,12 +349,13 @@ function createCocoa(options = {}) {
|
|
|
256
349
|
}
|
|
257
350
|
const visibility = () => {
|
|
258
351
|
clearTimeout(timer);
|
|
259
|
-
if (!document.hidden) void refresh();
|
|
352
|
+
if (!document.hidden) { void refresh(); if (!greetingSeen) { clearTimeout(greetingTimer); greetingTimer=setTimeout(showGreeting,greetingDelay); } }
|
|
260
353
|
};
|
|
261
354
|
document.addEventListener('visibilitychange', visibility);
|
|
262
355
|
const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
|
|
263
356
|
if (destroyed) return;
|
|
264
357
|
destroyed = true;
|
|
358
|
+
dismissGreeting(false);
|
|
265
359
|
generation++;
|
|
266
360
|
clearTimeout(timer);
|
|
267
361
|
for (const request of requests) request.abort();
|
|
@@ -270,6 +364,8 @@ function createCocoa(options = {}) {
|
|
|
270
364
|
instances.delete(key);
|
|
271
365
|
} };
|
|
272
366
|
launcher.onclick = widget.toggle;
|
|
367
|
+
greetingOpen.onclick = open;
|
|
368
|
+
greetingDismiss.onclick = () => { dismissGreeting(); launcher.focus(); };
|
|
273
369
|
panel.querySelector('header button').onclick = close;
|
|
274
370
|
fresh.onclick = reset;
|
|
275
371
|
root.addEventListener('keydown', (event) => {
|
|
@@ -278,8 +374,9 @@ function createCocoa(options = {}) {
|
|
|
278
374
|
form.onsubmit = async (event) => {
|
|
279
375
|
event.preventDefault();
|
|
280
376
|
const body = input.value.trim();
|
|
281
|
-
if (!body || sending || terminal || paused || destroyed) return;
|
|
377
|
+
if (!body || sending || submittingIncident || terminal || paused || destroyed) return;
|
|
282
378
|
sending = true;
|
|
379
|
+
thinking.hidden = true;
|
|
283
380
|
controls();
|
|
284
381
|
clearTimeout(timer);
|
|
285
382
|
const current = generation;
|
|
@@ -314,6 +411,60 @@ function createCocoa(options = {}) {
|
|
|
314
411
|
}
|
|
315
412
|
}
|
|
316
413
|
};
|
|
414
|
+
function showChat() {
|
|
415
|
+
handoff.hidden = true; composer.hidden = messages.hidden = false; controls();
|
|
416
|
+
if (!panel.hidden) (input.disabled ? help : input).focus();
|
|
417
|
+
}
|
|
418
|
+
help.onclick = () => {
|
|
419
|
+
if (expired || paused || incident || sending || submittingIncident) return;
|
|
420
|
+
const last = [...messages.querySelectorAll('.customer span')].at(-1)?.textContent || input.value.trim();
|
|
421
|
+
if (!fields.summary.value) fields.summary.value = (last || 'Help with ' + (options.title || 'this application')).slice(0,200);
|
|
422
|
+
if (!fields.details.value) fields.details.value = last || '';
|
|
423
|
+
if (!fields.name.value) fields.name.value = name.value;
|
|
424
|
+
thinking.hidden = true;
|
|
425
|
+
handoff.hidden = false; composer.hidden = messages.hidden = true; controls(); fields.email.focus();
|
|
426
|
+
};
|
|
427
|
+
back.onclick = showChat;
|
|
428
|
+
handoff.onsubmit = async event => {
|
|
429
|
+
event.preventDefault();
|
|
430
|
+
if (submittingIncident || sending || expired || paused || incident || destroyed) return;
|
|
431
|
+
const data = Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,v.value.trim()]));
|
|
432
|
+
if (!data.summary || !data.details || !data.email) return;
|
|
433
|
+
submittingIncident = true; submitIncident.disabled = back.disabled = true;
|
|
434
|
+
for(const field of Object.values(fields)) field.disabled = true;
|
|
435
|
+
handoffError.textContent = ''; controls();
|
|
436
|
+
const current = generation;
|
|
437
|
+
try {
|
|
438
|
+
if (!session) {
|
|
439
|
+
const result = await call(`/chat/${options.chatbotId}/sessions`, { label: data.name || 'Visitor' });
|
|
440
|
+
if (destroyed || current !== generation) return;
|
|
441
|
+
if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
|
|
442
|
+
session = {id:result.id,token:result.token};
|
|
443
|
+
}
|
|
444
|
+
if (!incidentPending || Object.keys(data).some(k=>data[k]!==incidentPending[k])) incidentPending = {...data,idempotencyKey:crypto.randomUUID()};
|
|
445
|
+
persist();
|
|
446
|
+
const result = await call(`/chat/sessions/${session.id}/escalations`, incidentPending, session.token);
|
|
447
|
+
if (destroyed || current !== generation) return;
|
|
448
|
+
if (!result?.reference || !result?.email) throw new Error('Submission could not be confirmed. Please retry.');
|
|
449
|
+
incident = result; incidentPending = null; persist(); showChat(); handoffResult.focus();
|
|
450
|
+
} catch(error) {
|
|
451
|
+
if (!destroyed && current === generation) {
|
|
452
|
+
expire(error);
|
|
453
|
+
handoffError.textContent = expired ? 'This session expired. Return to chat and explicitly start a new conversation before submitting.' : 'Submission could not be confirmed. Your details are kept here; retry to check or submit the same incident.';
|
|
454
|
+
}
|
|
455
|
+
} finally {
|
|
456
|
+
if (!destroyed && current === generation) {
|
|
457
|
+
submittingIncident = false; submitIncident.disabled = back.disabled = false;
|
|
458
|
+
for(const field of Object.values(fields)) field.disabled = false;
|
|
459
|
+
controls(); schedule();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
if (!greetingSeen && !session && options.greetingMessage !== false) {
|
|
464
|
+
document.addEventListener('pointerdown', unlockAudio);
|
|
465
|
+
document.addEventListener('keydown', unlockAudio);
|
|
466
|
+
greetingTimer=setTimeout(showGreeting,greetingDelay);
|
|
467
|
+
}
|
|
317
468
|
instances.set(key, widget);
|
|
318
469
|
return widget;
|
|
319
470
|
}
|
|
@@ -328,6 +479,9 @@ if (script?.dataset.chatbot) {
|
|
|
328
479
|
launcherLabel: script.dataset.launcherLabel,
|
|
329
480
|
accentColor: script.dataset.accentColor,
|
|
330
481
|
nonce: script.nonce,
|
|
482
|
+
greetingMessage: script.dataset.greetingMessage === 'false' ? false : script.dataset.greetingMessage,
|
|
483
|
+
greetingDelay: script.dataset.greetingDelay === undefined ? undefined : Number(script.dataset.greetingDelay),
|
|
484
|
+
greetingSound: script.dataset.greetingSound !== 'false',
|
|
331
485
|
};
|
|
332
486
|
const mount = () => { window.Cocoa.widget = createCocoa(options); };
|
|
333
487
|
if (document.body) mount();
|
package/dist/index.cjs
CHANGED
|
@@ -21,6 +21,8 @@ function createCocoa(options = {}) {
|
|
|
21
21
|
if (!Number.isInteger(zIndex) || zIndex < 0 || zIndex > 2147483647) throw new Error('Invalid zIndex.');
|
|
22
22
|
const pollInterval = options.pollInterval ?? 2500;
|
|
23
23
|
if (!Number.isFinite(pollInterval) || pollInterval < 2500) throw new Error('pollInterval must be at least 2500ms.');
|
|
24
|
+
const greetingDelay = options.greetingDelay ?? 5000;
|
|
25
|
+
if (!Number.isFinite(greetingDelay) || greetingDelay < 0) throw new Error('greetingDelay must be a nonnegative number.');
|
|
24
26
|
const host = document.createElement('div');
|
|
25
27
|
host.dataset.cocoaWidget = '';
|
|
26
28
|
const root = host.attachShadow({ mode: 'open' });
|
|
@@ -31,11 +33,15 @@ function createCocoa(options = {}) {
|
|
|
31
33
|
*,*::before,*::after{box-sizing:border-box} [hidden]{display:none!important}
|
|
32
34
|
button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid #647078;border-radius:12px;padding:10px 14px;background:#26343a;color:#fff}button:hover{filter:brightness(1.15)}button:disabled{opacity:.55;cursor:default}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid #99dbc4;outline-offset:2px}
|
|
33
35
|
.launcher{display:block;margin-left:auto;width:72px;height:72px;padding:0;border:2px solid #e1f4fa;border-radius:50%;overflow:hidden;background:#92c5d5;box-shadow:0 6px 24px #0005}.launcher img{display:block;width:100%;height:100%;object-fit:cover;transform:scale(1.18);pointer-events:none}.launcher:hover{filter:brightness(1.06)}
|
|
36
|
+
.greeting{position:relative;margin:0 0 12px auto;display:flex;align-items:center;max-width:calc(100vw - 32px);width:250px;background:#fff5e9;color:#392516;border-radius:18px;box-shadow:0 6px 24px #0003;padding:6px}.greeting::after{content:'';position:absolute;right:28px;bottom:-8px;border:8px solid transparent;border-top-color:#fff5e9;border-bottom:0}.greeting button{color:inherit;background:transparent;border:0}.greeting .greeting-open{text-align:left;flex:1}.greeting .greeting-dismiss{padding:8px;min-width:40px;min-height:40px}
|
|
37
|
+
.thinking{display:flex;align-items:center;gap:8px;padding:8px 16px;color:#c8d4dd;font-size:12px}.dots{display:flex;gap:4px}.dots i{width:5px;height:5px;border-radius:50%;background:#92c5d5}
|
|
38
|
+
@media(prefers-reduced-motion:no-preference){.greeting{animation:appear .25s ease-out}.dots i{animation:think 1.2s ease-in-out infinite}.dots i:nth-child(2){animation-delay:.15s}.dots i:nth-child(3){animation-delay:.3s}@keyframes think{0%,60%,100%{opacity:.4;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}}
|
|
34
39
|
.panel{display:flex;flex-direction:column;width:370px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100dvh - 116px);background:#141c22;border:1px solid #44515b;border-radius:18px;box-shadow:0 12px 50px #0005;overflow:hidden;margin-bottom:12px}
|
|
35
40
|
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid #39454e}header strong{font-size:16px;overflow-wrap:anywhere}header small{display:block;color:#bac9d2}header button{min-width:44px;min-height:44px;font-size:22px;background:transparent}
|
|
36
41
|
.messages{flex:1;min-height:50px;overflow:auto;overscroll-behavior:contain;padding:16px;display:flex;flex-direction:column;gap:12px}.message{flex-shrink:0;align-self:flex-start;max-width:92%;background:#27333d;padding:10px 12px;border-radius:12px;white-space:pre-wrap;overflow-wrap:anywhere}.customer{align-self:flex-end;background:${accent}}.message small{display:block;color:#fff;font-weight:600;font-size:11px;margin-bottom:4px}
|
|
37
42
|
.welcome{color:#c8d4dd;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.status{color:#c8d4dd;font-size:12px;margin:0;padding:8px 16px}.error{color:#ffc3c3}
|
|
38
43
|
form{padding:12px 16px;border-top:1px solid #39454e;display:grid;gap:8px}label{display:grid;gap:4px;font-size:12px}input,textarea{width:100%;border:1px solid #61717e;border-radius:8px;background:#1e2932;color:#fff;padding:8px;font-size:16px}textarea{height:64px;resize:none}.actions{display:flex;justify-content:flex-end;gap:8px}.new{margin-right:auto}
|
|
44
|
+
.help{margin:0 16px 10px}.handoff{overflow:auto;min-height:0;flex:1}.handoff p{margin:0;color:#c8d4dd;font-size:12px}.handoff textarea{height:90px}.handoff-result{padding:12px 16px;overflow-wrap:anywhere}
|
|
39
45
|
@media(prefers-reduced-motion:no-preference){.panel{animation:appear .15s ease-out}@keyframes appear{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}}
|
|
40
46
|
@media(max-height:540px){.panel{height:calc(100dvh - 116px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
|
|
41
47
|
`;
|
|
@@ -61,6 +67,32 @@ function createCocoa(options = {}) {
|
|
|
61
67
|
actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
|
|
62
68
|
composer.append(nameLabel, messageLabel, actions);
|
|
63
69
|
panel.append(header, element('div', { class: 'messages', role: 'log', 'aria-label': 'Conversation', 'aria-live': 'polite', 'aria-relevant': 'additions text' }), element('p', { class: 'status', role: 'status' }), composer);
|
|
70
|
+
const help = element('button', { type: 'button', class: 'help' }, 'Get further help');
|
|
71
|
+
const handoff = element('form', { class: 'handoff', hidden: '' });
|
|
72
|
+
const fields = {};
|
|
73
|
+
for (const [key, label, tag, max, type] of [
|
|
74
|
+
['email', 'Correspondence email', 'input', 254, 'email'],
|
|
75
|
+
['name', 'Your name (optional)', 'input', 120, 'text'],
|
|
76
|
+
['summary', 'Summary', 'input', 200, 'text'],
|
|
77
|
+
['details', 'What do you still need help with?', 'textarea', 6000, null],
|
|
78
|
+
]) {
|
|
79
|
+
const field = element(tag, { maxlength: String(max), ...(type ? {type} : {}), ...(key !== 'name' ? {required:''} : {}) });
|
|
80
|
+
const labelNode = element('label', {}, label); labelNode.append(field); handoff.append(labelNode); fields[key] = field;
|
|
81
|
+
}
|
|
82
|
+
handoff.append(element('p', {}, 'Review these details before submitting. Your visible conversation will accompany the incident. The support team will follow up at this email. Never include passwords, seed phrases or private keys.'));
|
|
83
|
+
const handoffError = element('p', {class:'error', role:'alert'});
|
|
84
|
+
const back = element('button', {type:'button'}, 'Back to chat');
|
|
85
|
+
const submitIncident = element('button', {type:'submit'}, 'Submit incident');
|
|
86
|
+
const handoffActions = element('div', {class:'actions'}); handoffActions.append(back, submitIncident);
|
|
87
|
+
handoff.append(handoffError, handoffActions);
|
|
88
|
+
const handoffResult = element('p', {class:'handoff-result',role:'status',tabindex:'-1',hidden:''});
|
|
89
|
+
panel.append(help, handoff, handoffResult);
|
|
90
|
+
const thinking = element('div', {class:'thinking',role:'status',hidden:''});
|
|
91
|
+
const dots = element('span', {class:'dots','aria-hidden':'true'});
|
|
92
|
+
dots.append(element('i'),element('i'),element('i'));
|
|
93
|
+
const thinkingLabel = element('span', {}, 'Cocoa is thinking…');
|
|
94
|
+
thinking.append(dots, thinkingLabel);
|
|
95
|
+
composer.before(thinking);
|
|
64
96
|
panel.querySelector('strong').textContent = options.title || 'Cocoa support';
|
|
65
97
|
const launcher = document.createElement('button');
|
|
66
98
|
launcher.type = 'button';
|
|
@@ -75,7 +107,11 @@ function createCocoa(options = {}) {
|
|
|
75
107
|
launcher.setAttribute('aria-expanded', 'false');
|
|
76
108
|
launcher.setAttribute('aria-controls', panel.id);
|
|
77
109
|
launcher.setAttribute('aria-haspopup', 'dialog');
|
|
78
|
-
|
|
110
|
+
const greeting = element('div', {class:'greeting',hidden:''});
|
|
111
|
+
const greetingOpen = element('button', {type:'button',class:'greeting-open'}, options.greetingMessage || 'Hi! Ask me anything.');
|
|
112
|
+
const greetingDismiss = element('button', {type:'button',class:'greeting-dismiss','aria-label':'Dismiss greeting'}, '×');
|
|
113
|
+
greeting.append(greetingOpen,greetingDismiss);
|
|
114
|
+
root.append(panel, greeting, launcher);
|
|
79
115
|
document.body.append(host);
|
|
80
116
|
const messages = panel.querySelector('.messages');
|
|
81
117
|
const status = panel.querySelector('.status');
|
|
@@ -87,12 +123,48 @@ function createCocoa(options = {}) {
|
|
|
87
123
|
name.value = String(options.visitorLabel || '').slice(0, 120);
|
|
88
124
|
let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
|
|
89
125
|
let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
|
|
126
|
+
let incident = null, incidentPending = null, submittingIncident = false, expired = false;
|
|
127
|
+
let greetingTimer, audioContext, greetingSeen = false;
|
|
128
|
+
const greetingKey = key + ':greeted';
|
|
129
|
+
try { greetingSeen = sessionStorage.getItem(greetingKey) === '1'; } catch {}
|
|
130
|
+
const dismissGreeting = (remember = true) => {
|
|
131
|
+
clearTimeout(greetingTimer); greeting.hidden = true;
|
|
132
|
+
if (remember) { greetingSeen = true; try { sessionStorage.setItem(greetingKey,'1'); } catch {} }
|
|
133
|
+
document.removeEventListener('pointerdown', unlockAudio);
|
|
134
|
+
document.removeEventListener('keydown', unlockAudio);
|
|
135
|
+
if (audioContext) { void audioContext.close().catch(()=>{}); audioContext = null; }
|
|
136
|
+
};
|
|
137
|
+
function unlockAudio() {
|
|
138
|
+
if (destroyed || greetingSeen || options.greetingSound === false || audioContext) return;
|
|
139
|
+
try {
|
|
140
|
+
const Audio = window.AudioContext || window.webkitAudioContext;
|
|
141
|
+
if (Audio) { audioContext = new Audio(); void audioContext.resume().catch(()=>{}); }
|
|
142
|
+
} catch { /* Greeting still works when audio is unavailable. */ }
|
|
143
|
+
}
|
|
144
|
+
function showGreeting() {
|
|
145
|
+
if (destroyed || greetingSeen || !panel.hidden || session || options.greetingMessage === false || document.hidden) return;
|
|
146
|
+
greeting.hidden = false; greetingSeen = true;
|
|
147
|
+
try { sessionStorage.setItem(greetingKey,'1'); } catch {}
|
|
148
|
+
document.removeEventListener('pointerdown', unlockAudio);
|
|
149
|
+
document.removeEventListener('keydown', unlockAudio);
|
|
150
|
+
// Only play after a prior user gesture unlocked audio. Never queue a surprise sound.
|
|
151
|
+
if (audioContext?.state === 'running') {
|
|
152
|
+
try {
|
|
153
|
+
const tone = audioContext.createOscillator(), volume = audioContext.createGain();
|
|
154
|
+
const now = audioContext.currentTime;
|
|
155
|
+
tone.type = 'sine'; tone.frequency.setValueAtTime(660,now); tone.frequency.exponentialRampToValueAtTime(880,now+.1);
|
|
156
|
+
volume.gain.setValueAtTime(0,now); volume.gain.linearRampToValueAtTime(.045,now+.015); volume.gain.exponentialRampToValueAtTime(.001,now+.2);
|
|
157
|
+
tone.connect(volume); volume.connect(audioContext.destination); tone.start(now); tone.stop(now+.22);
|
|
158
|
+
tone.onended = () => { tone.disconnect(); volume.disconnect(); if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; } };
|
|
159
|
+
} catch { if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; } }
|
|
160
|
+
} else if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; }
|
|
161
|
+
}
|
|
90
162
|
const rendered = new Set();
|
|
91
163
|
const requests = new Set();
|
|
92
164
|
const persist = () => {
|
|
93
165
|
if (options.persistSession === false) return;
|
|
94
166
|
try {
|
|
95
|
-
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
|
|
167
|
+
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending, incidentPending }));
|
|
96
168
|
else sessionStorage.removeItem(key);
|
|
97
169
|
} catch { /* Storage may be unavailable; an in-memory session still works. */ }
|
|
98
170
|
};
|
|
@@ -101,6 +173,11 @@ function createCocoa(options = {}) {
|
|
|
101
173
|
const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
|
|
102
174
|
if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
|
|
103
175
|
session = { id: stored.id, token: stored.token };
|
|
176
|
+
const saved = stored.incidentPending;
|
|
177
|
+
if (saved && uuid.test(saved.idempotencyKey || '') && ['email','name','summary','details'].every(k=>typeof saved[k]==='string' && saved[k].length<=Number(fields[k].maxLength))) {
|
|
178
|
+
incidentPending = saved;
|
|
179
|
+
for (const k of Object.keys(fields)) fields[k].value = saved[k];
|
|
180
|
+
}
|
|
104
181
|
if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
|
|
105
182
|
pending = stored.pending;
|
|
106
183
|
input.value = pending.body;
|
|
@@ -117,7 +194,12 @@ function createCocoa(options = {}) {
|
|
|
117
194
|
welcome();
|
|
118
195
|
const controls = () => {
|
|
119
196
|
const focusWasInComposer = root.activeElement === input || root.activeElement === send;
|
|
120
|
-
|
|
197
|
+
if (incident || !handoff.hidden || terminal || paused) thinking.hidden = true;
|
|
198
|
+
help.disabled = sending || submittingIncident || expired || paused;
|
|
199
|
+
help.hidden = !!incident || !handoff.hidden;
|
|
200
|
+
handoffResult.hidden = !incident;
|
|
201
|
+
if (incident) handoffResult.textContent = `Incident ${incident.reference} submitted. Follow-up will be by email at ${incident.email}.`;
|
|
202
|
+
input.disabled = sending || terminal || paused || submittingIncident;
|
|
121
203
|
send.disabled = sending || terminal || paused;
|
|
122
204
|
name.disabled = sending;
|
|
123
205
|
fresh.hidden = !terminal;
|
|
@@ -129,6 +211,7 @@ function createCocoa(options = {}) {
|
|
|
129
211
|
};
|
|
130
212
|
controls();
|
|
131
213
|
const report = (error) => {
|
|
214
|
+
thinking.hidden = true;
|
|
132
215
|
status.classList.add('error');
|
|
133
216
|
status.textContent = error.message;
|
|
134
217
|
try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
|
|
@@ -163,7 +246,7 @@ function createCocoa(options = {}) {
|
|
|
163
246
|
}
|
|
164
247
|
function expire(error) {
|
|
165
248
|
if (error.status === 401 || error.status === 404) {
|
|
166
|
-
terminal = true;
|
|
249
|
+
terminal = true; expired = true;
|
|
167
250
|
session = null;
|
|
168
251
|
pending = null;
|
|
169
252
|
persist();
|
|
@@ -190,12 +273,15 @@ function createCocoa(options = {}) {
|
|
|
190
273
|
if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
|
|
191
274
|
version = data.version;
|
|
192
275
|
}
|
|
276
|
+
incident = data.escalation || incident;
|
|
193
277
|
terminal = data.state === 'closed';
|
|
194
278
|
paused = !data.enabled;
|
|
195
279
|
status.classList.remove('error');
|
|
196
|
-
status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
|
|
280
|
+
status.textContent = incident ? 'Your incident is with the support team. Follow-up will be by email.' : terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
|
|
197
281
|
data.state === 'human' ? 'A person is handling your conversation.' :
|
|
198
282
|
data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
|
|
283
|
+
const last = data.messages.filter(m=>['customer','agent','human'].includes(m.actor)).at(-1);
|
|
284
|
+
thinking.hidden = !(last?.actor === 'customer' && data.agentOnline && data.state === 'agent' && !terminal && !paused && !incident && handoff.hidden);
|
|
199
285
|
controls();
|
|
200
286
|
}
|
|
201
287
|
function schedule() {
|
|
@@ -205,7 +291,7 @@ function createCocoa(options = {}) {
|
|
|
205
291
|
}
|
|
206
292
|
}
|
|
207
293
|
async function refresh() {
|
|
208
|
-
if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
|
|
294
|
+
if (destroyed || refreshing || sending || submittingIncident || panel.hidden || document.hidden || !session || terminal) return;
|
|
209
295
|
refreshing = true;
|
|
210
296
|
const current = generation;
|
|
211
297
|
try {
|
|
@@ -228,18 +314,25 @@ function createCocoa(options = {}) {
|
|
|
228
314
|
}
|
|
229
315
|
function open() {
|
|
230
316
|
if (destroyed) return;
|
|
317
|
+
dismissGreeting();
|
|
231
318
|
panel.hidden = false;
|
|
232
319
|
launcher.setAttribute('aria-expanded', 'true');
|
|
233
|
-
(terminal ? fresh : input).focus();
|
|
320
|
+
(!handoff.hidden ? fields.email : terminal ? fresh : input).focus();
|
|
234
321
|
void refresh();
|
|
235
322
|
}
|
|
236
323
|
function reset() {
|
|
237
324
|
if (destroyed) return;
|
|
238
325
|
generation++;
|
|
239
326
|
sending = refreshing = false;
|
|
327
|
+
thinking.hidden = true;
|
|
240
328
|
for (const request of requests) request.abort();
|
|
241
329
|
clearTimeout(timer);
|
|
242
|
-
session = pending = null;
|
|
330
|
+
session = pending = incident = incidentPending = null;
|
|
331
|
+
expired = submittingIncident = false;
|
|
332
|
+
handoff.hidden = true; composer.hidden = messages.hidden = false;
|
|
333
|
+
submitIncident.disabled = back.disabled = false;
|
|
334
|
+
for(const field of Object.values(fields)) { field.value = ''; field.disabled = false; }
|
|
335
|
+
handoffError.textContent = '';
|
|
243
336
|
terminal = paused = false;
|
|
244
337
|
version = -1;
|
|
245
338
|
failures = 0;
|
|
@@ -254,12 +347,13 @@ function createCocoa(options = {}) {
|
|
|
254
347
|
}
|
|
255
348
|
const visibility = () => {
|
|
256
349
|
clearTimeout(timer);
|
|
257
|
-
if (!document.hidden) void refresh();
|
|
350
|
+
if (!document.hidden) { void refresh(); if (!greetingSeen) { clearTimeout(greetingTimer); greetingTimer=setTimeout(showGreeting,greetingDelay); } }
|
|
258
351
|
};
|
|
259
352
|
document.addEventListener('visibilitychange', visibility);
|
|
260
353
|
const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
|
|
261
354
|
if (destroyed) return;
|
|
262
355
|
destroyed = true;
|
|
356
|
+
dismissGreeting(false);
|
|
263
357
|
generation++;
|
|
264
358
|
clearTimeout(timer);
|
|
265
359
|
for (const request of requests) request.abort();
|
|
@@ -268,6 +362,8 @@ function createCocoa(options = {}) {
|
|
|
268
362
|
instances.delete(key);
|
|
269
363
|
} };
|
|
270
364
|
launcher.onclick = widget.toggle;
|
|
365
|
+
greetingOpen.onclick = open;
|
|
366
|
+
greetingDismiss.onclick = () => { dismissGreeting(); launcher.focus(); };
|
|
271
367
|
panel.querySelector('header button').onclick = close;
|
|
272
368
|
fresh.onclick = reset;
|
|
273
369
|
root.addEventListener('keydown', (event) => {
|
|
@@ -276,8 +372,9 @@ function createCocoa(options = {}) {
|
|
|
276
372
|
form.onsubmit = async (event) => {
|
|
277
373
|
event.preventDefault();
|
|
278
374
|
const body = input.value.trim();
|
|
279
|
-
if (!body || sending || terminal || paused || destroyed) return;
|
|
375
|
+
if (!body || sending || submittingIncident || terminal || paused || destroyed) return;
|
|
280
376
|
sending = true;
|
|
377
|
+
thinking.hidden = true;
|
|
281
378
|
controls();
|
|
282
379
|
clearTimeout(timer);
|
|
283
380
|
const current = generation;
|
|
@@ -312,6 +409,60 @@ function createCocoa(options = {}) {
|
|
|
312
409
|
}
|
|
313
410
|
}
|
|
314
411
|
};
|
|
412
|
+
function showChat() {
|
|
413
|
+
handoff.hidden = true; composer.hidden = messages.hidden = false; controls();
|
|
414
|
+
if (!panel.hidden) (input.disabled ? help : input).focus();
|
|
415
|
+
}
|
|
416
|
+
help.onclick = () => {
|
|
417
|
+
if (expired || paused || incident || sending || submittingIncident) return;
|
|
418
|
+
const last = [...messages.querySelectorAll('.customer span')].at(-1)?.textContent || input.value.trim();
|
|
419
|
+
if (!fields.summary.value) fields.summary.value = (last || 'Help with ' + (options.title || 'this application')).slice(0,200);
|
|
420
|
+
if (!fields.details.value) fields.details.value = last || '';
|
|
421
|
+
if (!fields.name.value) fields.name.value = name.value;
|
|
422
|
+
thinking.hidden = true;
|
|
423
|
+
handoff.hidden = false; composer.hidden = messages.hidden = true; controls(); fields.email.focus();
|
|
424
|
+
};
|
|
425
|
+
back.onclick = showChat;
|
|
426
|
+
handoff.onsubmit = async event => {
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
if (submittingIncident || sending || expired || paused || incident || destroyed) return;
|
|
429
|
+
const data = Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,v.value.trim()]));
|
|
430
|
+
if (!data.summary || !data.details || !data.email) return;
|
|
431
|
+
submittingIncident = true; submitIncident.disabled = back.disabled = true;
|
|
432
|
+
for(const field of Object.values(fields)) field.disabled = true;
|
|
433
|
+
handoffError.textContent = ''; controls();
|
|
434
|
+
const current = generation;
|
|
435
|
+
try {
|
|
436
|
+
if (!session) {
|
|
437
|
+
const result = await call(`/chat/${options.chatbotId}/sessions`, { label: data.name || 'Visitor' });
|
|
438
|
+
if (destroyed || current !== generation) return;
|
|
439
|
+
if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
|
|
440
|
+
session = {id:result.id,token:result.token};
|
|
441
|
+
}
|
|
442
|
+
if (!incidentPending || Object.keys(data).some(k=>data[k]!==incidentPending[k])) incidentPending = {...data,idempotencyKey:crypto.randomUUID()};
|
|
443
|
+
persist();
|
|
444
|
+
const result = await call(`/chat/sessions/${session.id}/escalations`, incidentPending, session.token);
|
|
445
|
+
if (destroyed || current !== generation) return;
|
|
446
|
+
if (!result?.reference || !result?.email) throw new Error('Submission could not be confirmed. Please retry.');
|
|
447
|
+
incident = result; incidentPending = null; persist(); showChat(); handoffResult.focus();
|
|
448
|
+
} catch(error) {
|
|
449
|
+
if (!destroyed && current === generation) {
|
|
450
|
+
expire(error);
|
|
451
|
+
handoffError.textContent = expired ? 'This session expired. Return to chat and explicitly start a new conversation before submitting.' : 'Submission could not be confirmed. Your details are kept here; retry to check or submit the same incident.';
|
|
452
|
+
}
|
|
453
|
+
} finally {
|
|
454
|
+
if (!destroyed && current === generation) {
|
|
455
|
+
submittingIncident = false; submitIncident.disabled = back.disabled = false;
|
|
456
|
+
for(const field of Object.values(fields)) field.disabled = false;
|
|
457
|
+
controls(); schedule();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
if (!greetingSeen && !session && options.greetingMessage !== false) {
|
|
462
|
+
document.addEventListener('pointerdown', unlockAudio);
|
|
463
|
+
document.addEventListener('keydown', unlockAudio);
|
|
464
|
+
greetingTimer=setTimeout(showGreeting,greetingDelay);
|
|
465
|
+
}
|
|
315
466
|
instances.set(key, widget);
|
|
316
467
|
return widget;
|
|
317
468
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -7,6 +7,12 @@ export interface CocoaOptions {
|
|
|
7
7
|
/** Accessible name and hover tooltip for the circular Cocoa portrait button. */
|
|
8
8
|
launcherLabel?: string;
|
|
9
9
|
welcomeMessage?: string;
|
|
10
|
+
/** Once-per-tab launcher greeting; false disables it. Default: Hi! Ask me anything. */
|
|
11
|
+
greetingMessage?: string | false;
|
|
12
|
+
/** Delay before greeting, in milliseconds. Defaults to 5000. */
|
|
13
|
+
greetingDelay?: number;
|
|
14
|
+
/** Soft chime if prior interaction permits audio. Defaults to true. */
|
|
15
|
+
greetingSound?: boolean;
|
|
10
16
|
/** Unverified display name, never an authentication credential. */
|
|
11
17
|
visitorLabel?: string;
|
|
12
18
|
/** Six-digit hex color, e.g. #385b50. */
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,12 @@ export interface CocoaOptions {
|
|
|
7
7
|
/** Accessible name and hover tooltip for the circular Cocoa portrait button. */
|
|
8
8
|
launcherLabel?: string;
|
|
9
9
|
welcomeMessage?: string;
|
|
10
|
+
/** Once-per-tab launcher greeting; false disables it. Default: Hi! Ask me anything. */
|
|
11
|
+
greetingMessage?: string | false;
|
|
12
|
+
/** Delay before greeting, in milliseconds. Defaults to 5000. */
|
|
13
|
+
greetingDelay?: number;
|
|
14
|
+
/** Soft chime if prior interaction permits audio. Defaults to true. */
|
|
15
|
+
greetingSound?: boolean;
|
|
10
16
|
/** Unverified display name, never an authentication credential. */
|
|
11
17
|
visitorLabel?: string;
|
|
12
18
|
/** Six-digit hex color, e.g. #385b50. */
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,8 @@ export function createCocoa(options = {}) {
|
|
|
21
21
|
if (!Number.isInteger(zIndex) || zIndex < 0 || zIndex > 2147483647) throw new Error('Invalid zIndex.');
|
|
22
22
|
const pollInterval = options.pollInterval ?? 2500;
|
|
23
23
|
if (!Number.isFinite(pollInterval) || pollInterval < 2500) throw new Error('pollInterval must be at least 2500ms.');
|
|
24
|
+
const greetingDelay = options.greetingDelay ?? 5000;
|
|
25
|
+
if (!Number.isFinite(greetingDelay) || greetingDelay < 0) throw new Error('greetingDelay must be a nonnegative number.');
|
|
24
26
|
const host = document.createElement('div');
|
|
25
27
|
host.dataset.cocoaWidget = '';
|
|
26
28
|
const root = host.attachShadow({ mode: 'open' });
|
|
@@ -31,11 +33,15 @@ export function createCocoa(options = {}) {
|
|
|
31
33
|
*,*::before,*::after{box-sizing:border-box} [hidden]{display:none!important}
|
|
32
34
|
button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid #647078;border-radius:12px;padding:10px 14px;background:#26343a;color:#fff}button:hover{filter:brightness(1.15)}button:disabled{opacity:.55;cursor:default}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid #99dbc4;outline-offset:2px}
|
|
33
35
|
.launcher{display:block;margin-left:auto;width:72px;height:72px;padding:0;border:2px solid #e1f4fa;border-radius:50%;overflow:hidden;background:#92c5d5;box-shadow:0 6px 24px #0005}.launcher img{display:block;width:100%;height:100%;object-fit:cover;transform:scale(1.18);pointer-events:none}.launcher:hover{filter:brightness(1.06)}
|
|
36
|
+
.greeting{position:relative;margin:0 0 12px auto;display:flex;align-items:center;max-width:calc(100vw - 32px);width:250px;background:#fff5e9;color:#392516;border-radius:18px;box-shadow:0 6px 24px #0003;padding:6px}.greeting::after{content:'';position:absolute;right:28px;bottom:-8px;border:8px solid transparent;border-top-color:#fff5e9;border-bottom:0}.greeting button{color:inherit;background:transparent;border:0}.greeting .greeting-open{text-align:left;flex:1}.greeting .greeting-dismiss{padding:8px;min-width:40px;min-height:40px}
|
|
37
|
+
.thinking{display:flex;align-items:center;gap:8px;padding:8px 16px;color:#c8d4dd;font-size:12px}.dots{display:flex;gap:4px}.dots i{width:5px;height:5px;border-radius:50%;background:#92c5d5}
|
|
38
|
+
@media(prefers-reduced-motion:no-preference){.greeting{animation:appear .25s ease-out}.dots i{animation:think 1.2s ease-in-out infinite}.dots i:nth-child(2){animation-delay:.15s}.dots i:nth-child(3){animation-delay:.3s}@keyframes think{0%,60%,100%{opacity:.4;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}}
|
|
34
39
|
.panel{display:flex;flex-direction:column;width:370px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100dvh - 116px);background:#141c22;border:1px solid #44515b;border-radius:18px;box-shadow:0 12px 50px #0005;overflow:hidden;margin-bottom:12px}
|
|
35
40
|
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid #39454e}header strong{font-size:16px;overflow-wrap:anywhere}header small{display:block;color:#bac9d2}header button{min-width:44px;min-height:44px;font-size:22px;background:transparent}
|
|
36
41
|
.messages{flex:1;min-height:50px;overflow:auto;overscroll-behavior:contain;padding:16px;display:flex;flex-direction:column;gap:12px}.message{flex-shrink:0;align-self:flex-start;max-width:92%;background:#27333d;padding:10px 12px;border-radius:12px;white-space:pre-wrap;overflow-wrap:anywhere}.customer{align-self:flex-end;background:${accent}}.message small{display:block;color:#fff;font-weight:600;font-size:11px;margin-bottom:4px}
|
|
37
42
|
.welcome{color:#c8d4dd;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.status{color:#c8d4dd;font-size:12px;margin:0;padding:8px 16px}.error{color:#ffc3c3}
|
|
38
43
|
form{padding:12px 16px;border-top:1px solid #39454e;display:grid;gap:8px}label{display:grid;gap:4px;font-size:12px}input,textarea{width:100%;border:1px solid #61717e;border-radius:8px;background:#1e2932;color:#fff;padding:8px;font-size:16px}textarea{height:64px;resize:none}.actions{display:flex;justify-content:flex-end;gap:8px}.new{margin-right:auto}
|
|
44
|
+
.help{margin:0 16px 10px}.handoff{overflow:auto;min-height:0;flex:1}.handoff p{margin:0;color:#c8d4dd;font-size:12px}.handoff textarea{height:90px}.handoff-result{padding:12px 16px;overflow-wrap:anywhere}
|
|
39
45
|
@media(prefers-reduced-motion:no-preference){.panel{animation:appear .15s ease-out}@keyframes appear{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}}
|
|
40
46
|
@media(max-height:540px){.panel{height:calc(100dvh - 116px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
|
|
41
47
|
`;
|
|
@@ -61,6 +67,32 @@ export function createCocoa(options = {}) {
|
|
|
61
67
|
actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
|
|
62
68
|
composer.append(nameLabel, messageLabel, actions);
|
|
63
69
|
panel.append(header, element('div', { class: 'messages', role: 'log', 'aria-label': 'Conversation', 'aria-live': 'polite', 'aria-relevant': 'additions text' }), element('p', { class: 'status', role: 'status' }), composer);
|
|
70
|
+
const help = element('button', { type: 'button', class: 'help' }, 'Get further help');
|
|
71
|
+
const handoff = element('form', { class: 'handoff', hidden: '' });
|
|
72
|
+
const fields = {};
|
|
73
|
+
for (const [key, label, tag, max, type] of [
|
|
74
|
+
['email', 'Correspondence email', 'input', 254, 'email'],
|
|
75
|
+
['name', 'Your name (optional)', 'input', 120, 'text'],
|
|
76
|
+
['summary', 'Summary', 'input', 200, 'text'],
|
|
77
|
+
['details', 'What do you still need help with?', 'textarea', 6000, null],
|
|
78
|
+
]) {
|
|
79
|
+
const field = element(tag, { maxlength: String(max), ...(type ? {type} : {}), ...(key !== 'name' ? {required:''} : {}) });
|
|
80
|
+
const labelNode = element('label', {}, label); labelNode.append(field); handoff.append(labelNode); fields[key] = field;
|
|
81
|
+
}
|
|
82
|
+
handoff.append(element('p', {}, 'Review these details before submitting. Your visible conversation will accompany the incident. The support team will follow up at this email. Never include passwords, seed phrases or private keys.'));
|
|
83
|
+
const handoffError = element('p', {class:'error', role:'alert'});
|
|
84
|
+
const back = element('button', {type:'button'}, 'Back to chat');
|
|
85
|
+
const submitIncident = element('button', {type:'submit'}, 'Submit incident');
|
|
86
|
+
const handoffActions = element('div', {class:'actions'}); handoffActions.append(back, submitIncident);
|
|
87
|
+
handoff.append(handoffError, handoffActions);
|
|
88
|
+
const handoffResult = element('p', {class:'handoff-result',role:'status',tabindex:'-1',hidden:''});
|
|
89
|
+
panel.append(help, handoff, handoffResult);
|
|
90
|
+
const thinking = element('div', {class:'thinking',role:'status',hidden:''});
|
|
91
|
+
const dots = element('span', {class:'dots','aria-hidden':'true'});
|
|
92
|
+
dots.append(element('i'),element('i'),element('i'));
|
|
93
|
+
const thinkingLabel = element('span', {}, 'Cocoa is thinking…');
|
|
94
|
+
thinking.append(dots, thinkingLabel);
|
|
95
|
+
composer.before(thinking);
|
|
64
96
|
panel.querySelector('strong').textContent = options.title || 'Cocoa support';
|
|
65
97
|
const launcher = document.createElement('button');
|
|
66
98
|
launcher.type = 'button';
|
|
@@ -75,7 +107,11 @@ export function createCocoa(options = {}) {
|
|
|
75
107
|
launcher.setAttribute('aria-expanded', 'false');
|
|
76
108
|
launcher.setAttribute('aria-controls', panel.id);
|
|
77
109
|
launcher.setAttribute('aria-haspopup', 'dialog');
|
|
78
|
-
|
|
110
|
+
const greeting = element('div', {class:'greeting',hidden:''});
|
|
111
|
+
const greetingOpen = element('button', {type:'button',class:'greeting-open'}, options.greetingMessage || 'Hi! Ask me anything.');
|
|
112
|
+
const greetingDismiss = element('button', {type:'button',class:'greeting-dismiss','aria-label':'Dismiss greeting'}, '×');
|
|
113
|
+
greeting.append(greetingOpen,greetingDismiss);
|
|
114
|
+
root.append(panel, greeting, launcher);
|
|
79
115
|
document.body.append(host);
|
|
80
116
|
const messages = panel.querySelector('.messages');
|
|
81
117
|
const status = panel.querySelector('.status');
|
|
@@ -87,12 +123,48 @@ export function createCocoa(options = {}) {
|
|
|
87
123
|
name.value = String(options.visitorLabel || '').slice(0, 120);
|
|
88
124
|
let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
|
|
89
125
|
let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
|
|
126
|
+
let incident = null, incidentPending = null, submittingIncident = false, expired = false;
|
|
127
|
+
let greetingTimer, audioContext, greetingSeen = false;
|
|
128
|
+
const greetingKey = key + ':greeted';
|
|
129
|
+
try { greetingSeen = sessionStorage.getItem(greetingKey) === '1'; } catch {}
|
|
130
|
+
const dismissGreeting = (remember = true) => {
|
|
131
|
+
clearTimeout(greetingTimer); greeting.hidden = true;
|
|
132
|
+
if (remember) { greetingSeen = true; try { sessionStorage.setItem(greetingKey,'1'); } catch {} }
|
|
133
|
+
document.removeEventListener('pointerdown', unlockAudio);
|
|
134
|
+
document.removeEventListener('keydown', unlockAudio);
|
|
135
|
+
if (audioContext) { void audioContext.close().catch(()=>{}); audioContext = null; }
|
|
136
|
+
};
|
|
137
|
+
function unlockAudio() {
|
|
138
|
+
if (destroyed || greetingSeen || options.greetingSound === false || audioContext) return;
|
|
139
|
+
try {
|
|
140
|
+
const Audio = window.AudioContext || window.webkitAudioContext;
|
|
141
|
+
if (Audio) { audioContext = new Audio(); void audioContext.resume().catch(()=>{}); }
|
|
142
|
+
} catch { /* Greeting still works when audio is unavailable. */ }
|
|
143
|
+
}
|
|
144
|
+
function showGreeting() {
|
|
145
|
+
if (destroyed || greetingSeen || !panel.hidden || session || options.greetingMessage === false || document.hidden) return;
|
|
146
|
+
greeting.hidden = false; greetingSeen = true;
|
|
147
|
+
try { sessionStorage.setItem(greetingKey,'1'); } catch {}
|
|
148
|
+
document.removeEventListener('pointerdown', unlockAudio);
|
|
149
|
+
document.removeEventListener('keydown', unlockAudio);
|
|
150
|
+
// Only play after a prior user gesture unlocked audio. Never queue a surprise sound.
|
|
151
|
+
if (audioContext?.state === 'running') {
|
|
152
|
+
try {
|
|
153
|
+
const tone = audioContext.createOscillator(), volume = audioContext.createGain();
|
|
154
|
+
const now = audioContext.currentTime;
|
|
155
|
+
tone.type = 'sine'; tone.frequency.setValueAtTime(660,now); tone.frequency.exponentialRampToValueAtTime(880,now+.1);
|
|
156
|
+
volume.gain.setValueAtTime(0,now); volume.gain.linearRampToValueAtTime(.045,now+.015); volume.gain.exponentialRampToValueAtTime(.001,now+.2);
|
|
157
|
+
tone.connect(volume); volume.connect(audioContext.destination); tone.start(now); tone.stop(now+.22);
|
|
158
|
+
tone.onended = () => { tone.disconnect(); volume.disconnect(); if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; } };
|
|
159
|
+
} catch { if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; } }
|
|
160
|
+
} else if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; }
|
|
161
|
+
}
|
|
90
162
|
const rendered = new Set();
|
|
91
163
|
const requests = new Set();
|
|
92
164
|
const persist = () => {
|
|
93
165
|
if (options.persistSession === false) return;
|
|
94
166
|
try {
|
|
95
|
-
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
|
|
167
|
+
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending, incidentPending }));
|
|
96
168
|
else sessionStorage.removeItem(key);
|
|
97
169
|
} catch { /* Storage may be unavailable; an in-memory session still works. */ }
|
|
98
170
|
};
|
|
@@ -101,6 +173,11 @@ export function createCocoa(options = {}) {
|
|
|
101
173
|
const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
|
|
102
174
|
if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
|
|
103
175
|
session = { id: stored.id, token: stored.token };
|
|
176
|
+
const saved = stored.incidentPending;
|
|
177
|
+
if (saved && uuid.test(saved.idempotencyKey || '') && ['email','name','summary','details'].every(k=>typeof saved[k]==='string' && saved[k].length<=Number(fields[k].maxLength))) {
|
|
178
|
+
incidentPending = saved;
|
|
179
|
+
for (const k of Object.keys(fields)) fields[k].value = saved[k];
|
|
180
|
+
}
|
|
104
181
|
if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
|
|
105
182
|
pending = stored.pending;
|
|
106
183
|
input.value = pending.body;
|
|
@@ -117,7 +194,12 @@ export function createCocoa(options = {}) {
|
|
|
117
194
|
welcome();
|
|
118
195
|
const controls = () => {
|
|
119
196
|
const focusWasInComposer = root.activeElement === input || root.activeElement === send;
|
|
120
|
-
|
|
197
|
+
if (incident || !handoff.hidden || terminal || paused) thinking.hidden = true;
|
|
198
|
+
help.disabled = sending || submittingIncident || expired || paused;
|
|
199
|
+
help.hidden = !!incident || !handoff.hidden;
|
|
200
|
+
handoffResult.hidden = !incident;
|
|
201
|
+
if (incident) handoffResult.textContent = `Incident ${incident.reference} submitted. Follow-up will be by email at ${incident.email}.`;
|
|
202
|
+
input.disabled = sending || terminal || paused || submittingIncident;
|
|
121
203
|
send.disabled = sending || terminal || paused;
|
|
122
204
|
name.disabled = sending;
|
|
123
205
|
fresh.hidden = !terminal;
|
|
@@ -129,6 +211,7 @@ export function createCocoa(options = {}) {
|
|
|
129
211
|
};
|
|
130
212
|
controls();
|
|
131
213
|
const report = (error) => {
|
|
214
|
+
thinking.hidden = true;
|
|
132
215
|
status.classList.add('error');
|
|
133
216
|
status.textContent = error.message;
|
|
134
217
|
try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
|
|
@@ -163,7 +246,7 @@ export function createCocoa(options = {}) {
|
|
|
163
246
|
}
|
|
164
247
|
function expire(error) {
|
|
165
248
|
if (error.status === 401 || error.status === 404) {
|
|
166
|
-
terminal = true;
|
|
249
|
+
terminal = true; expired = true;
|
|
167
250
|
session = null;
|
|
168
251
|
pending = null;
|
|
169
252
|
persist();
|
|
@@ -190,12 +273,15 @@ export function createCocoa(options = {}) {
|
|
|
190
273
|
if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
|
|
191
274
|
version = data.version;
|
|
192
275
|
}
|
|
276
|
+
incident = data.escalation || incident;
|
|
193
277
|
terminal = data.state === 'closed';
|
|
194
278
|
paused = !data.enabled;
|
|
195
279
|
status.classList.remove('error');
|
|
196
|
-
status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
|
|
280
|
+
status.textContent = incident ? 'Your incident is with the support team. Follow-up will be by email.' : terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
|
|
197
281
|
data.state === 'human' ? 'A person is handling your conversation.' :
|
|
198
282
|
data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
|
|
283
|
+
const last = data.messages.filter(m=>['customer','agent','human'].includes(m.actor)).at(-1);
|
|
284
|
+
thinking.hidden = !(last?.actor === 'customer' && data.agentOnline && data.state === 'agent' && !terminal && !paused && !incident && handoff.hidden);
|
|
199
285
|
controls();
|
|
200
286
|
}
|
|
201
287
|
function schedule() {
|
|
@@ -205,7 +291,7 @@ export function createCocoa(options = {}) {
|
|
|
205
291
|
}
|
|
206
292
|
}
|
|
207
293
|
async function refresh() {
|
|
208
|
-
if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
|
|
294
|
+
if (destroyed || refreshing || sending || submittingIncident || panel.hidden || document.hidden || !session || terminal) return;
|
|
209
295
|
refreshing = true;
|
|
210
296
|
const current = generation;
|
|
211
297
|
try {
|
|
@@ -228,18 +314,25 @@ export function createCocoa(options = {}) {
|
|
|
228
314
|
}
|
|
229
315
|
function open() {
|
|
230
316
|
if (destroyed) return;
|
|
317
|
+
dismissGreeting();
|
|
231
318
|
panel.hidden = false;
|
|
232
319
|
launcher.setAttribute('aria-expanded', 'true');
|
|
233
|
-
(terminal ? fresh : input).focus();
|
|
320
|
+
(!handoff.hidden ? fields.email : terminal ? fresh : input).focus();
|
|
234
321
|
void refresh();
|
|
235
322
|
}
|
|
236
323
|
function reset() {
|
|
237
324
|
if (destroyed) return;
|
|
238
325
|
generation++;
|
|
239
326
|
sending = refreshing = false;
|
|
327
|
+
thinking.hidden = true;
|
|
240
328
|
for (const request of requests) request.abort();
|
|
241
329
|
clearTimeout(timer);
|
|
242
|
-
session = pending = null;
|
|
330
|
+
session = pending = incident = incidentPending = null;
|
|
331
|
+
expired = submittingIncident = false;
|
|
332
|
+
handoff.hidden = true; composer.hidden = messages.hidden = false;
|
|
333
|
+
submitIncident.disabled = back.disabled = false;
|
|
334
|
+
for(const field of Object.values(fields)) { field.value = ''; field.disabled = false; }
|
|
335
|
+
handoffError.textContent = '';
|
|
243
336
|
terminal = paused = false;
|
|
244
337
|
version = -1;
|
|
245
338
|
failures = 0;
|
|
@@ -254,12 +347,13 @@ export function createCocoa(options = {}) {
|
|
|
254
347
|
}
|
|
255
348
|
const visibility = () => {
|
|
256
349
|
clearTimeout(timer);
|
|
257
|
-
if (!document.hidden) void refresh();
|
|
350
|
+
if (!document.hidden) { void refresh(); if (!greetingSeen) { clearTimeout(greetingTimer); greetingTimer=setTimeout(showGreeting,greetingDelay); } }
|
|
258
351
|
};
|
|
259
352
|
document.addEventListener('visibilitychange', visibility);
|
|
260
353
|
const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
|
|
261
354
|
if (destroyed) return;
|
|
262
355
|
destroyed = true;
|
|
356
|
+
dismissGreeting(false);
|
|
263
357
|
generation++;
|
|
264
358
|
clearTimeout(timer);
|
|
265
359
|
for (const request of requests) request.abort();
|
|
@@ -268,6 +362,8 @@ export function createCocoa(options = {}) {
|
|
|
268
362
|
instances.delete(key);
|
|
269
363
|
} };
|
|
270
364
|
launcher.onclick = widget.toggle;
|
|
365
|
+
greetingOpen.onclick = open;
|
|
366
|
+
greetingDismiss.onclick = () => { dismissGreeting(); launcher.focus(); };
|
|
271
367
|
panel.querySelector('header button').onclick = close;
|
|
272
368
|
fresh.onclick = reset;
|
|
273
369
|
root.addEventListener('keydown', (event) => {
|
|
@@ -276,8 +372,9 @@ export function createCocoa(options = {}) {
|
|
|
276
372
|
form.onsubmit = async (event) => {
|
|
277
373
|
event.preventDefault();
|
|
278
374
|
const body = input.value.trim();
|
|
279
|
-
if (!body || sending || terminal || paused || destroyed) return;
|
|
375
|
+
if (!body || sending || submittingIncident || terminal || paused || destroyed) return;
|
|
280
376
|
sending = true;
|
|
377
|
+
thinking.hidden = true;
|
|
281
378
|
controls();
|
|
282
379
|
clearTimeout(timer);
|
|
283
380
|
const current = generation;
|
|
@@ -312,6 +409,60 @@ export function createCocoa(options = {}) {
|
|
|
312
409
|
}
|
|
313
410
|
}
|
|
314
411
|
};
|
|
412
|
+
function showChat() {
|
|
413
|
+
handoff.hidden = true; composer.hidden = messages.hidden = false; controls();
|
|
414
|
+
if (!panel.hidden) (input.disabled ? help : input).focus();
|
|
415
|
+
}
|
|
416
|
+
help.onclick = () => {
|
|
417
|
+
if (expired || paused || incident || sending || submittingIncident) return;
|
|
418
|
+
const last = [...messages.querySelectorAll('.customer span')].at(-1)?.textContent || input.value.trim();
|
|
419
|
+
if (!fields.summary.value) fields.summary.value = (last || 'Help with ' + (options.title || 'this application')).slice(0,200);
|
|
420
|
+
if (!fields.details.value) fields.details.value = last || '';
|
|
421
|
+
if (!fields.name.value) fields.name.value = name.value;
|
|
422
|
+
thinking.hidden = true;
|
|
423
|
+
handoff.hidden = false; composer.hidden = messages.hidden = true; controls(); fields.email.focus();
|
|
424
|
+
};
|
|
425
|
+
back.onclick = showChat;
|
|
426
|
+
handoff.onsubmit = async event => {
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
if (submittingIncident || sending || expired || paused || incident || destroyed) return;
|
|
429
|
+
const data = Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,v.value.trim()]));
|
|
430
|
+
if (!data.summary || !data.details || !data.email) return;
|
|
431
|
+
submittingIncident = true; submitIncident.disabled = back.disabled = true;
|
|
432
|
+
for(const field of Object.values(fields)) field.disabled = true;
|
|
433
|
+
handoffError.textContent = ''; controls();
|
|
434
|
+
const current = generation;
|
|
435
|
+
try {
|
|
436
|
+
if (!session) {
|
|
437
|
+
const result = await call(`/chat/${options.chatbotId}/sessions`, { label: data.name || 'Visitor' });
|
|
438
|
+
if (destroyed || current !== generation) return;
|
|
439
|
+
if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
|
|
440
|
+
session = {id:result.id,token:result.token};
|
|
441
|
+
}
|
|
442
|
+
if (!incidentPending || Object.keys(data).some(k=>data[k]!==incidentPending[k])) incidentPending = {...data,idempotencyKey:crypto.randomUUID()};
|
|
443
|
+
persist();
|
|
444
|
+
const result = await call(`/chat/sessions/${session.id}/escalations`, incidentPending, session.token);
|
|
445
|
+
if (destroyed || current !== generation) return;
|
|
446
|
+
if (!result?.reference || !result?.email) throw new Error('Submission could not be confirmed. Please retry.');
|
|
447
|
+
incident = result; incidentPending = null; persist(); showChat(); handoffResult.focus();
|
|
448
|
+
} catch(error) {
|
|
449
|
+
if (!destroyed && current === generation) {
|
|
450
|
+
expire(error);
|
|
451
|
+
handoffError.textContent = expired ? 'This session expired. Return to chat and explicitly start a new conversation before submitting.' : 'Submission could not be confirmed. Your details are kept here; retry to check or submit the same incident.';
|
|
452
|
+
}
|
|
453
|
+
} finally {
|
|
454
|
+
if (!destroyed && current === generation) {
|
|
455
|
+
submittingIncident = false; submitIncident.disabled = back.disabled = false;
|
|
456
|
+
for(const field of Object.values(fields)) field.disabled = false;
|
|
457
|
+
controls(); schedule();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
if (!greetingSeen && !session && options.greetingMessage !== false) {
|
|
462
|
+
document.addEventListener('pointerdown', unlockAudio);
|
|
463
|
+
document.addEventListener('keydown', unlockAudio);
|
|
464
|
+
greetingTimer=setTimeout(showGreeting,greetingDelay);
|
|
465
|
+
}
|
|
315
466
|
instances.set(key, widget);
|
|
316
467
|
return widget;
|
|
317
468
|
}
|