@sub-zero/cocoa 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/cocoa.js +93 -8
- package/dist/index.cjs +93 -8
- package/dist/index.js +93 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -163,3 +163,9 @@ 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.
|
package/dist/cocoa.js
CHANGED
|
@@ -38,6 +38,7 @@ function createCocoa(options = {}) {
|
|
|
38
38
|
.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
39
|
.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
40
|
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}
|
|
41
|
+
.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
42
|
@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
43
|
@media(max-height:540px){.panel{height:calc(100dvh - 116px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
|
|
43
44
|
`;
|
|
@@ -63,6 +64,26 @@ function createCocoa(options = {}) {
|
|
|
63
64
|
actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
|
|
64
65
|
composer.append(nameLabel, messageLabel, actions);
|
|
65
66
|
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);
|
|
67
|
+
const help = element('button', { type: 'button', class: 'help' }, 'Get further help');
|
|
68
|
+
const handoff = element('form', { class: 'handoff', hidden: '' });
|
|
69
|
+
const fields = {};
|
|
70
|
+
for (const [key, label, tag, max, type] of [
|
|
71
|
+
['email', 'Correspondence email', 'input', 254, 'email'],
|
|
72
|
+
['name', 'Your name (optional)', 'input', 120, 'text'],
|
|
73
|
+
['summary', 'Summary', 'input', 200, 'text'],
|
|
74
|
+
['details', 'What do you still need help with?', 'textarea', 6000, null],
|
|
75
|
+
]) {
|
|
76
|
+
const field = element(tag, { maxlength: String(max), ...(type ? {type} : {}), ...(key !== 'name' ? {required:''} : {}) });
|
|
77
|
+
const labelNode = element('label', {}, label); labelNode.append(field); handoff.append(labelNode); fields[key] = field;
|
|
78
|
+
}
|
|
79
|
+
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.'));
|
|
80
|
+
const handoffError = element('p', {class:'error', role:'alert'});
|
|
81
|
+
const back = element('button', {type:'button'}, 'Back to chat');
|
|
82
|
+
const submitIncident = element('button', {type:'submit'}, 'Submit incident');
|
|
83
|
+
const handoffActions = element('div', {class:'actions'}); handoffActions.append(back, submitIncident);
|
|
84
|
+
handoff.append(handoffError, handoffActions);
|
|
85
|
+
const handoffResult = element('p', {class:'handoff-result',role:'status',tabindex:'-1',hidden:''});
|
|
86
|
+
panel.append(help, handoff, handoffResult);
|
|
66
87
|
panel.querySelector('strong').textContent = options.title || 'Cocoa support';
|
|
67
88
|
const launcher = document.createElement('button');
|
|
68
89
|
launcher.type = 'button';
|
|
@@ -89,12 +110,13 @@ function createCocoa(options = {}) {
|
|
|
89
110
|
name.value = String(options.visitorLabel || '').slice(0, 120);
|
|
90
111
|
let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
|
|
91
112
|
let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
|
|
113
|
+
let incident = null, incidentPending = null, submittingIncident = false, expired = false;
|
|
92
114
|
const rendered = new Set();
|
|
93
115
|
const requests = new Set();
|
|
94
116
|
const persist = () => {
|
|
95
117
|
if (options.persistSession === false) return;
|
|
96
118
|
try {
|
|
97
|
-
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
|
|
119
|
+
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending, incidentPending }));
|
|
98
120
|
else sessionStorage.removeItem(key);
|
|
99
121
|
} catch { /* Storage may be unavailable; an in-memory session still works. */ }
|
|
100
122
|
};
|
|
@@ -103,6 +125,11 @@ function createCocoa(options = {}) {
|
|
|
103
125
|
const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
|
|
104
126
|
if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
|
|
105
127
|
session = { id: stored.id, token: stored.token };
|
|
128
|
+
const saved = stored.incidentPending;
|
|
129
|
+
if (saved && uuid.test(saved.idempotencyKey || '') && ['email','name','summary','details'].every(k=>typeof saved[k]==='string' && saved[k].length<=Number(fields[k].maxLength))) {
|
|
130
|
+
incidentPending = saved;
|
|
131
|
+
for (const k of Object.keys(fields)) fields[k].value = saved[k];
|
|
132
|
+
}
|
|
106
133
|
if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
|
|
107
134
|
pending = stored.pending;
|
|
108
135
|
input.value = pending.body;
|
|
@@ -119,7 +146,11 @@ function createCocoa(options = {}) {
|
|
|
119
146
|
welcome();
|
|
120
147
|
const controls = () => {
|
|
121
148
|
const focusWasInComposer = root.activeElement === input || root.activeElement === send;
|
|
122
|
-
|
|
149
|
+
help.disabled = sending || submittingIncident || expired || paused;
|
|
150
|
+
help.hidden = !!incident || !handoff.hidden;
|
|
151
|
+
handoffResult.hidden = !incident;
|
|
152
|
+
if (incident) handoffResult.textContent = `Incident ${incident.reference} submitted. Follow-up will be by email at ${incident.email}.`;
|
|
153
|
+
input.disabled = sending || terminal || paused || submittingIncident;
|
|
123
154
|
send.disabled = sending || terminal || paused;
|
|
124
155
|
name.disabled = sending;
|
|
125
156
|
fresh.hidden = !terminal;
|
|
@@ -165,7 +196,7 @@ function createCocoa(options = {}) {
|
|
|
165
196
|
}
|
|
166
197
|
function expire(error) {
|
|
167
198
|
if (error.status === 401 || error.status === 404) {
|
|
168
|
-
terminal = true;
|
|
199
|
+
terminal = true; expired = true;
|
|
169
200
|
session = null;
|
|
170
201
|
pending = null;
|
|
171
202
|
persist();
|
|
@@ -192,10 +223,11 @@ function createCocoa(options = {}) {
|
|
|
192
223
|
if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
|
|
193
224
|
version = data.version;
|
|
194
225
|
}
|
|
226
|
+
incident = data.escalation || incident;
|
|
195
227
|
terminal = data.state === 'closed';
|
|
196
228
|
paused = !data.enabled;
|
|
197
229
|
status.classList.remove('error');
|
|
198
|
-
status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
|
|
230
|
+
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
231
|
data.state === 'human' ? 'A person is handling your conversation.' :
|
|
200
232
|
data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
|
|
201
233
|
controls();
|
|
@@ -207,7 +239,7 @@ function createCocoa(options = {}) {
|
|
|
207
239
|
}
|
|
208
240
|
}
|
|
209
241
|
async function refresh() {
|
|
210
|
-
if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
|
|
242
|
+
if (destroyed || refreshing || sending || submittingIncident || panel.hidden || document.hidden || !session || terminal) return;
|
|
211
243
|
refreshing = true;
|
|
212
244
|
const current = generation;
|
|
213
245
|
try {
|
|
@@ -232,7 +264,7 @@ function createCocoa(options = {}) {
|
|
|
232
264
|
if (destroyed) return;
|
|
233
265
|
panel.hidden = false;
|
|
234
266
|
launcher.setAttribute('aria-expanded', 'true');
|
|
235
|
-
(terminal ? fresh : input).focus();
|
|
267
|
+
(!handoff.hidden ? fields.email : terminal ? fresh : input).focus();
|
|
236
268
|
void refresh();
|
|
237
269
|
}
|
|
238
270
|
function reset() {
|
|
@@ -241,7 +273,12 @@ function createCocoa(options = {}) {
|
|
|
241
273
|
sending = refreshing = false;
|
|
242
274
|
for (const request of requests) request.abort();
|
|
243
275
|
clearTimeout(timer);
|
|
244
|
-
session = pending = null;
|
|
276
|
+
session = pending = incident = incidentPending = null;
|
|
277
|
+
expired = submittingIncident = false;
|
|
278
|
+
handoff.hidden = true; composer.hidden = messages.hidden = false;
|
|
279
|
+
submitIncident.disabled = back.disabled = false;
|
|
280
|
+
for(const field of Object.values(fields)) { field.value = ''; field.disabled = false; }
|
|
281
|
+
handoffError.textContent = '';
|
|
245
282
|
terminal = paused = false;
|
|
246
283
|
version = -1;
|
|
247
284
|
failures = 0;
|
|
@@ -278,7 +315,7 @@ function createCocoa(options = {}) {
|
|
|
278
315
|
form.onsubmit = async (event) => {
|
|
279
316
|
event.preventDefault();
|
|
280
317
|
const body = input.value.trim();
|
|
281
|
-
if (!body || sending || terminal || paused || destroyed) return;
|
|
318
|
+
if (!body || sending || submittingIncident || terminal || paused || destroyed) return;
|
|
282
319
|
sending = true;
|
|
283
320
|
controls();
|
|
284
321
|
clearTimeout(timer);
|
|
@@ -314,6 +351,54 @@ function createCocoa(options = {}) {
|
|
|
314
351
|
}
|
|
315
352
|
}
|
|
316
353
|
};
|
|
354
|
+
function showChat() {
|
|
355
|
+
handoff.hidden = true; composer.hidden = messages.hidden = false; controls();
|
|
356
|
+
if (!panel.hidden) (input.disabled ? help : input).focus();
|
|
357
|
+
}
|
|
358
|
+
help.onclick = () => {
|
|
359
|
+
if (expired || paused || incident || sending || submittingIncident) return;
|
|
360
|
+
const last = [...messages.querySelectorAll('.customer span')].at(-1)?.textContent || input.value.trim();
|
|
361
|
+
if (!fields.summary.value) fields.summary.value = (last || 'Help with ' + (options.title || 'this application')).slice(0,200);
|
|
362
|
+
if (!fields.details.value) fields.details.value = last || '';
|
|
363
|
+
if (!fields.name.value) fields.name.value = name.value;
|
|
364
|
+
handoff.hidden = false; composer.hidden = messages.hidden = true; controls(); fields.email.focus();
|
|
365
|
+
};
|
|
366
|
+
back.onclick = showChat;
|
|
367
|
+
handoff.onsubmit = async event => {
|
|
368
|
+
event.preventDefault();
|
|
369
|
+
if (submittingIncident || sending || expired || paused || incident || destroyed) return;
|
|
370
|
+
const data = Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,v.value.trim()]));
|
|
371
|
+
if (!data.summary || !data.details || !data.email) return;
|
|
372
|
+
submittingIncident = true; submitIncident.disabled = back.disabled = true;
|
|
373
|
+
for(const field of Object.values(fields)) field.disabled = true;
|
|
374
|
+
handoffError.textContent = ''; controls();
|
|
375
|
+
const current = generation;
|
|
376
|
+
try {
|
|
377
|
+
if (!session) {
|
|
378
|
+
const result = await call(`/chat/${options.chatbotId}/sessions`, { label: data.name || 'Visitor' });
|
|
379
|
+
if (destroyed || current !== generation) return;
|
|
380
|
+
if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
|
|
381
|
+
session = {id:result.id,token:result.token};
|
|
382
|
+
}
|
|
383
|
+
if (!incidentPending || Object.keys(data).some(k=>data[k]!==incidentPending[k])) incidentPending = {...data,idempotencyKey:crypto.randomUUID()};
|
|
384
|
+
persist();
|
|
385
|
+
const result = await call(`/chat/sessions/${session.id}/escalations`, incidentPending, session.token);
|
|
386
|
+
if (destroyed || current !== generation) return;
|
|
387
|
+
if (!result?.reference || !result?.email) throw new Error('Submission could not be confirmed. Please retry.');
|
|
388
|
+
incident = result; incidentPending = null; persist(); showChat(); handoffResult.focus();
|
|
389
|
+
} catch(error) {
|
|
390
|
+
if (!destroyed && current === generation) {
|
|
391
|
+
expire(error);
|
|
392
|
+
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.';
|
|
393
|
+
}
|
|
394
|
+
} finally {
|
|
395
|
+
if (!destroyed && current === generation) {
|
|
396
|
+
submittingIncident = false; submitIncident.disabled = back.disabled = false;
|
|
397
|
+
for(const field of Object.values(fields)) field.disabled = false;
|
|
398
|
+
controls(); schedule();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
};
|
|
317
402
|
instances.set(key, widget);
|
|
318
403
|
return widget;
|
|
319
404
|
}
|
package/dist/index.cjs
CHANGED
|
@@ -36,6 +36,7 @@ function createCocoa(options = {}) {
|
|
|
36
36
|
.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
37
|
.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
38
|
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}
|
|
39
|
+
.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
40
|
@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
41
|
@media(max-height:540px){.panel{height:calc(100dvh - 116px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
|
|
41
42
|
`;
|
|
@@ -61,6 +62,26 @@ function createCocoa(options = {}) {
|
|
|
61
62
|
actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
|
|
62
63
|
composer.append(nameLabel, messageLabel, actions);
|
|
63
64
|
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);
|
|
65
|
+
const help = element('button', { type: 'button', class: 'help' }, 'Get further help');
|
|
66
|
+
const handoff = element('form', { class: 'handoff', hidden: '' });
|
|
67
|
+
const fields = {};
|
|
68
|
+
for (const [key, label, tag, max, type] of [
|
|
69
|
+
['email', 'Correspondence email', 'input', 254, 'email'],
|
|
70
|
+
['name', 'Your name (optional)', 'input', 120, 'text'],
|
|
71
|
+
['summary', 'Summary', 'input', 200, 'text'],
|
|
72
|
+
['details', 'What do you still need help with?', 'textarea', 6000, null],
|
|
73
|
+
]) {
|
|
74
|
+
const field = element(tag, { maxlength: String(max), ...(type ? {type} : {}), ...(key !== 'name' ? {required:''} : {}) });
|
|
75
|
+
const labelNode = element('label', {}, label); labelNode.append(field); handoff.append(labelNode); fields[key] = field;
|
|
76
|
+
}
|
|
77
|
+
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.'));
|
|
78
|
+
const handoffError = element('p', {class:'error', role:'alert'});
|
|
79
|
+
const back = element('button', {type:'button'}, 'Back to chat');
|
|
80
|
+
const submitIncident = element('button', {type:'submit'}, 'Submit incident');
|
|
81
|
+
const handoffActions = element('div', {class:'actions'}); handoffActions.append(back, submitIncident);
|
|
82
|
+
handoff.append(handoffError, handoffActions);
|
|
83
|
+
const handoffResult = element('p', {class:'handoff-result',role:'status',tabindex:'-1',hidden:''});
|
|
84
|
+
panel.append(help, handoff, handoffResult);
|
|
64
85
|
panel.querySelector('strong').textContent = options.title || 'Cocoa support';
|
|
65
86
|
const launcher = document.createElement('button');
|
|
66
87
|
launcher.type = 'button';
|
|
@@ -87,12 +108,13 @@ function createCocoa(options = {}) {
|
|
|
87
108
|
name.value = String(options.visitorLabel || '').slice(0, 120);
|
|
88
109
|
let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
|
|
89
110
|
let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
|
|
111
|
+
let incident = null, incidentPending = null, submittingIncident = false, expired = false;
|
|
90
112
|
const rendered = new Set();
|
|
91
113
|
const requests = new Set();
|
|
92
114
|
const persist = () => {
|
|
93
115
|
if (options.persistSession === false) return;
|
|
94
116
|
try {
|
|
95
|
-
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
|
|
117
|
+
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending, incidentPending }));
|
|
96
118
|
else sessionStorage.removeItem(key);
|
|
97
119
|
} catch { /* Storage may be unavailable; an in-memory session still works. */ }
|
|
98
120
|
};
|
|
@@ -101,6 +123,11 @@ function createCocoa(options = {}) {
|
|
|
101
123
|
const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
|
|
102
124
|
if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
|
|
103
125
|
session = { id: stored.id, token: stored.token };
|
|
126
|
+
const saved = stored.incidentPending;
|
|
127
|
+
if (saved && uuid.test(saved.idempotencyKey || '') && ['email','name','summary','details'].every(k=>typeof saved[k]==='string' && saved[k].length<=Number(fields[k].maxLength))) {
|
|
128
|
+
incidentPending = saved;
|
|
129
|
+
for (const k of Object.keys(fields)) fields[k].value = saved[k];
|
|
130
|
+
}
|
|
104
131
|
if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
|
|
105
132
|
pending = stored.pending;
|
|
106
133
|
input.value = pending.body;
|
|
@@ -117,7 +144,11 @@ function createCocoa(options = {}) {
|
|
|
117
144
|
welcome();
|
|
118
145
|
const controls = () => {
|
|
119
146
|
const focusWasInComposer = root.activeElement === input || root.activeElement === send;
|
|
120
|
-
|
|
147
|
+
help.disabled = sending || submittingIncident || expired || paused;
|
|
148
|
+
help.hidden = !!incident || !handoff.hidden;
|
|
149
|
+
handoffResult.hidden = !incident;
|
|
150
|
+
if (incident) handoffResult.textContent = `Incident ${incident.reference} submitted. Follow-up will be by email at ${incident.email}.`;
|
|
151
|
+
input.disabled = sending || terminal || paused || submittingIncident;
|
|
121
152
|
send.disabled = sending || terminal || paused;
|
|
122
153
|
name.disabled = sending;
|
|
123
154
|
fresh.hidden = !terminal;
|
|
@@ -163,7 +194,7 @@ function createCocoa(options = {}) {
|
|
|
163
194
|
}
|
|
164
195
|
function expire(error) {
|
|
165
196
|
if (error.status === 401 || error.status === 404) {
|
|
166
|
-
terminal = true;
|
|
197
|
+
terminal = true; expired = true;
|
|
167
198
|
session = null;
|
|
168
199
|
pending = null;
|
|
169
200
|
persist();
|
|
@@ -190,10 +221,11 @@ function createCocoa(options = {}) {
|
|
|
190
221
|
if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
|
|
191
222
|
version = data.version;
|
|
192
223
|
}
|
|
224
|
+
incident = data.escalation || incident;
|
|
193
225
|
terminal = data.state === 'closed';
|
|
194
226
|
paused = !data.enabled;
|
|
195
227
|
status.classList.remove('error');
|
|
196
|
-
status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
|
|
228
|
+
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
229
|
data.state === 'human' ? 'A person is handling your conversation.' :
|
|
198
230
|
data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
|
|
199
231
|
controls();
|
|
@@ -205,7 +237,7 @@ function createCocoa(options = {}) {
|
|
|
205
237
|
}
|
|
206
238
|
}
|
|
207
239
|
async function refresh() {
|
|
208
|
-
if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
|
|
240
|
+
if (destroyed || refreshing || sending || submittingIncident || panel.hidden || document.hidden || !session || terminal) return;
|
|
209
241
|
refreshing = true;
|
|
210
242
|
const current = generation;
|
|
211
243
|
try {
|
|
@@ -230,7 +262,7 @@ function createCocoa(options = {}) {
|
|
|
230
262
|
if (destroyed) return;
|
|
231
263
|
panel.hidden = false;
|
|
232
264
|
launcher.setAttribute('aria-expanded', 'true');
|
|
233
|
-
(terminal ? fresh : input).focus();
|
|
265
|
+
(!handoff.hidden ? fields.email : terminal ? fresh : input).focus();
|
|
234
266
|
void refresh();
|
|
235
267
|
}
|
|
236
268
|
function reset() {
|
|
@@ -239,7 +271,12 @@ function createCocoa(options = {}) {
|
|
|
239
271
|
sending = refreshing = false;
|
|
240
272
|
for (const request of requests) request.abort();
|
|
241
273
|
clearTimeout(timer);
|
|
242
|
-
session = pending = null;
|
|
274
|
+
session = pending = incident = incidentPending = null;
|
|
275
|
+
expired = submittingIncident = false;
|
|
276
|
+
handoff.hidden = true; composer.hidden = messages.hidden = false;
|
|
277
|
+
submitIncident.disabled = back.disabled = false;
|
|
278
|
+
for(const field of Object.values(fields)) { field.value = ''; field.disabled = false; }
|
|
279
|
+
handoffError.textContent = '';
|
|
243
280
|
terminal = paused = false;
|
|
244
281
|
version = -1;
|
|
245
282
|
failures = 0;
|
|
@@ -276,7 +313,7 @@ function createCocoa(options = {}) {
|
|
|
276
313
|
form.onsubmit = async (event) => {
|
|
277
314
|
event.preventDefault();
|
|
278
315
|
const body = input.value.trim();
|
|
279
|
-
if (!body || sending || terminal || paused || destroyed) return;
|
|
316
|
+
if (!body || sending || submittingIncident || terminal || paused || destroyed) return;
|
|
280
317
|
sending = true;
|
|
281
318
|
controls();
|
|
282
319
|
clearTimeout(timer);
|
|
@@ -312,6 +349,54 @@ function createCocoa(options = {}) {
|
|
|
312
349
|
}
|
|
313
350
|
}
|
|
314
351
|
};
|
|
352
|
+
function showChat() {
|
|
353
|
+
handoff.hidden = true; composer.hidden = messages.hidden = false; controls();
|
|
354
|
+
if (!panel.hidden) (input.disabled ? help : input).focus();
|
|
355
|
+
}
|
|
356
|
+
help.onclick = () => {
|
|
357
|
+
if (expired || paused || incident || sending || submittingIncident) return;
|
|
358
|
+
const last = [...messages.querySelectorAll('.customer span')].at(-1)?.textContent || input.value.trim();
|
|
359
|
+
if (!fields.summary.value) fields.summary.value = (last || 'Help with ' + (options.title || 'this application')).slice(0,200);
|
|
360
|
+
if (!fields.details.value) fields.details.value = last || '';
|
|
361
|
+
if (!fields.name.value) fields.name.value = name.value;
|
|
362
|
+
handoff.hidden = false; composer.hidden = messages.hidden = true; controls(); fields.email.focus();
|
|
363
|
+
};
|
|
364
|
+
back.onclick = showChat;
|
|
365
|
+
handoff.onsubmit = async event => {
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
if (submittingIncident || sending || expired || paused || incident || destroyed) return;
|
|
368
|
+
const data = Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,v.value.trim()]));
|
|
369
|
+
if (!data.summary || !data.details || !data.email) return;
|
|
370
|
+
submittingIncident = true; submitIncident.disabled = back.disabled = true;
|
|
371
|
+
for(const field of Object.values(fields)) field.disabled = true;
|
|
372
|
+
handoffError.textContent = ''; controls();
|
|
373
|
+
const current = generation;
|
|
374
|
+
try {
|
|
375
|
+
if (!session) {
|
|
376
|
+
const result = await call(`/chat/${options.chatbotId}/sessions`, { label: data.name || 'Visitor' });
|
|
377
|
+
if (destroyed || current !== generation) return;
|
|
378
|
+
if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
|
|
379
|
+
session = {id:result.id,token:result.token};
|
|
380
|
+
}
|
|
381
|
+
if (!incidentPending || Object.keys(data).some(k=>data[k]!==incidentPending[k])) incidentPending = {...data,idempotencyKey:crypto.randomUUID()};
|
|
382
|
+
persist();
|
|
383
|
+
const result = await call(`/chat/sessions/${session.id}/escalations`, incidentPending, session.token);
|
|
384
|
+
if (destroyed || current !== generation) return;
|
|
385
|
+
if (!result?.reference || !result?.email) throw new Error('Submission could not be confirmed. Please retry.');
|
|
386
|
+
incident = result; incidentPending = null; persist(); showChat(); handoffResult.focus();
|
|
387
|
+
} catch(error) {
|
|
388
|
+
if (!destroyed && current === generation) {
|
|
389
|
+
expire(error);
|
|
390
|
+
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.';
|
|
391
|
+
}
|
|
392
|
+
} finally {
|
|
393
|
+
if (!destroyed && current === generation) {
|
|
394
|
+
submittingIncident = false; submitIncident.disabled = back.disabled = false;
|
|
395
|
+
for(const field of Object.values(fields)) field.disabled = false;
|
|
396
|
+
controls(); schedule();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
};
|
|
315
400
|
instances.set(key, widget);
|
|
316
401
|
return widget;
|
|
317
402
|
}
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,7 @@ export function createCocoa(options = {}) {
|
|
|
36
36
|
.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
37
|
.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
38
|
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}
|
|
39
|
+
.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
40
|
@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
41
|
@media(max-height:540px){.panel{height:calc(100dvh - 116px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
|
|
41
42
|
`;
|
|
@@ -61,6 +62,26 @@ export function createCocoa(options = {}) {
|
|
|
61
62
|
actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
|
|
62
63
|
composer.append(nameLabel, messageLabel, actions);
|
|
63
64
|
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);
|
|
65
|
+
const help = element('button', { type: 'button', class: 'help' }, 'Get further help');
|
|
66
|
+
const handoff = element('form', { class: 'handoff', hidden: '' });
|
|
67
|
+
const fields = {};
|
|
68
|
+
for (const [key, label, tag, max, type] of [
|
|
69
|
+
['email', 'Correspondence email', 'input', 254, 'email'],
|
|
70
|
+
['name', 'Your name (optional)', 'input', 120, 'text'],
|
|
71
|
+
['summary', 'Summary', 'input', 200, 'text'],
|
|
72
|
+
['details', 'What do you still need help with?', 'textarea', 6000, null],
|
|
73
|
+
]) {
|
|
74
|
+
const field = element(tag, { maxlength: String(max), ...(type ? {type} : {}), ...(key !== 'name' ? {required:''} : {}) });
|
|
75
|
+
const labelNode = element('label', {}, label); labelNode.append(field); handoff.append(labelNode); fields[key] = field;
|
|
76
|
+
}
|
|
77
|
+
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.'));
|
|
78
|
+
const handoffError = element('p', {class:'error', role:'alert'});
|
|
79
|
+
const back = element('button', {type:'button'}, 'Back to chat');
|
|
80
|
+
const submitIncident = element('button', {type:'submit'}, 'Submit incident');
|
|
81
|
+
const handoffActions = element('div', {class:'actions'}); handoffActions.append(back, submitIncident);
|
|
82
|
+
handoff.append(handoffError, handoffActions);
|
|
83
|
+
const handoffResult = element('p', {class:'handoff-result',role:'status',tabindex:'-1',hidden:''});
|
|
84
|
+
panel.append(help, handoff, handoffResult);
|
|
64
85
|
panel.querySelector('strong').textContent = options.title || 'Cocoa support';
|
|
65
86
|
const launcher = document.createElement('button');
|
|
66
87
|
launcher.type = 'button';
|
|
@@ -87,12 +108,13 @@ export function createCocoa(options = {}) {
|
|
|
87
108
|
name.value = String(options.visitorLabel || '').slice(0, 120);
|
|
88
109
|
let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
|
|
89
110
|
let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
|
|
111
|
+
let incident = null, incidentPending = null, submittingIncident = false, expired = false;
|
|
90
112
|
const rendered = new Set();
|
|
91
113
|
const requests = new Set();
|
|
92
114
|
const persist = () => {
|
|
93
115
|
if (options.persistSession === false) return;
|
|
94
116
|
try {
|
|
95
|
-
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
|
|
117
|
+
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending, incidentPending }));
|
|
96
118
|
else sessionStorage.removeItem(key);
|
|
97
119
|
} catch { /* Storage may be unavailable; an in-memory session still works. */ }
|
|
98
120
|
};
|
|
@@ -101,6 +123,11 @@ export function createCocoa(options = {}) {
|
|
|
101
123
|
const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
|
|
102
124
|
if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
|
|
103
125
|
session = { id: stored.id, token: stored.token };
|
|
126
|
+
const saved = stored.incidentPending;
|
|
127
|
+
if (saved && uuid.test(saved.idempotencyKey || '') && ['email','name','summary','details'].every(k=>typeof saved[k]==='string' && saved[k].length<=Number(fields[k].maxLength))) {
|
|
128
|
+
incidentPending = saved;
|
|
129
|
+
for (const k of Object.keys(fields)) fields[k].value = saved[k];
|
|
130
|
+
}
|
|
104
131
|
if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
|
|
105
132
|
pending = stored.pending;
|
|
106
133
|
input.value = pending.body;
|
|
@@ -117,7 +144,11 @@ export function createCocoa(options = {}) {
|
|
|
117
144
|
welcome();
|
|
118
145
|
const controls = () => {
|
|
119
146
|
const focusWasInComposer = root.activeElement === input || root.activeElement === send;
|
|
120
|
-
|
|
147
|
+
help.disabled = sending || submittingIncident || expired || paused;
|
|
148
|
+
help.hidden = !!incident || !handoff.hidden;
|
|
149
|
+
handoffResult.hidden = !incident;
|
|
150
|
+
if (incident) handoffResult.textContent = `Incident ${incident.reference} submitted. Follow-up will be by email at ${incident.email}.`;
|
|
151
|
+
input.disabled = sending || terminal || paused || submittingIncident;
|
|
121
152
|
send.disabled = sending || terminal || paused;
|
|
122
153
|
name.disabled = sending;
|
|
123
154
|
fresh.hidden = !terminal;
|
|
@@ -163,7 +194,7 @@ export function createCocoa(options = {}) {
|
|
|
163
194
|
}
|
|
164
195
|
function expire(error) {
|
|
165
196
|
if (error.status === 401 || error.status === 404) {
|
|
166
|
-
terminal = true;
|
|
197
|
+
terminal = true; expired = true;
|
|
167
198
|
session = null;
|
|
168
199
|
pending = null;
|
|
169
200
|
persist();
|
|
@@ -190,10 +221,11 @@ export function createCocoa(options = {}) {
|
|
|
190
221
|
if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
|
|
191
222
|
version = data.version;
|
|
192
223
|
}
|
|
224
|
+
incident = data.escalation || incident;
|
|
193
225
|
terminal = data.state === 'closed';
|
|
194
226
|
paused = !data.enabled;
|
|
195
227
|
status.classList.remove('error');
|
|
196
|
-
status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
|
|
228
|
+
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
229
|
data.state === 'human' ? 'A person is handling your conversation.' :
|
|
198
230
|
data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
|
|
199
231
|
controls();
|
|
@@ -205,7 +237,7 @@ export function createCocoa(options = {}) {
|
|
|
205
237
|
}
|
|
206
238
|
}
|
|
207
239
|
async function refresh() {
|
|
208
|
-
if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
|
|
240
|
+
if (destroyed || refreshing || sending || submittingIncident || panel.hidden || document.hidden || !session || terminal) return;
|
|
209
241
|
refreshing = true;
|
|
210
242
|
const current = generation;
|
|
211
243
|
try {
|
|
@@ -230,7 +262,7 @@ export function createCocoa(options = {}) {
|
|
|
230
262
|
if (destroyed) return;
|
|
231
263
|
panel.hidden = false;
|
|
232
264
|
launcher.setAttribute('aria-expanded', 'true');
|
|
233
|
-
(terminal ? fresh : input).focus();
|
|
265
|
+
(!handoff.hidden ? fields.email : terminal ? fresh : input).focus();
|
|
234
266
|
void refresh();
|
|
235
267
|
}
|
|
236
268
|
function reset() {
|
|
@@ -239,7 +271,12 @@ export function createCocoa(options = {}) {
|
|
|
239
271
|
sending = refreshing = false;
|
|
240
272
|
for (const request of requests) request.abort();
|
|
241
273
|
clearTimeout(timer);
|
|
242
|
-
session = pending = null;
|
|
274
|
+
session = pending = incident = incidentPending = null;
|
|
275
|
+
expired = submittingIncident = false;
|
|
276
|
+
handoff.hidden = true; composer.hidden = messages.hidden = false;
|
|
277
|
+
submitIncident.disabled = back.disabled = false;
|
|
278
|
+
for(const field of Object.values(fields)) { field.value = ''; field.disabled = false; }
|
|
279
|
+
handoffError.textContent = '';
|
|
243
280
|
terminal = paused = false;
|
|
244
281
|
version = -1;
|
|
245
282
|
failures = 0;
|
|
@@ -276,7 +313,7 @@ export function createCocoa(options = {}) {
|
|
|
276
313
|
form.onsubmit = async (event) => {
|
|
277
314
|
event.preventDefault();
|
|
278
315
|
const body = input.value.trim();
|
|
279
|
-
if (!body || sending || terminal || paused || destroyed) return;
|
|
316
|
+
if (!body || sending || submittingIncident || terminal || paused || destroyed) return;
|
|
280
317
|
sending = true;
|
|
281
318
|
controls();
|
|
282
319
|
clearTimeout(timer);
|
|
@@ -312,6 +349,54 @@ export function createCocoa(options = {}) {
|
|
|
312
349
|
}
|
|
313
350
|
}
|
|
314
351
|
};
|
|
352
|
+
function showChat() {
|
|
353
|
+
handoff.hidden = true; composer.hidden = messages.hidden = false; controls();
|
|
354
|
+
if (!panel.hidden) (input.disabled ? help : input).focus();
|
|
355
|
+
}
|
|
356
|
+
help.onclick = () => {
|
|
357
|
+
if (expired || paused || incident || sending || submittingIncident) return;
|
|
358
|
+
const last = [...messages.querySelectorAll('.customer span')].at(-1)?.textContent || input.value.trim();
|
|
359
|
+
if (!fields.summary.value) fields.summary.value = (last || 'Help with ' + (options.title || 'this application')).slice(0,200);
|
|
360
|
+
if (!fields.details.value) fields.details.value = last || '';
|
|
361
|
+
if (!fields.name.value) fields.name.value = name.value;
|
|
362
|
+
handoff.hidden = false; composer.hidden = messages.hidden = true; controls(); fields.email.focus();
|
|
363
|
+
};
|
|
364
|
+
back.onclick = showChat;
|
|
365
|
+
handoff.onsubmit = async event => {
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
if (submittingIncident || sending || expired || paused || incident || destroyed) return;
|
|
368
|
+
const data = Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,v.value.trim()]));
|
|
369
|
+
if (!data.summary || !data.details || !data.email) return;
|
|
370
|
+
submittingIncident = true; submitIncident.disabled = back.disabled = true;
|
|
371
|
+
for(const field of Object.values(fields)) field.disabled = true;
|
|
372
|
+
handoffError.textContent = ''; controls();
|
|
373
|
+
const current = generation;
|
|
374
|
+
try {
|
|
375
|
+
if (!session) {
|
|
376
|
+
const result = await call(`/chat/${options.chatbotId}/sessions`, { label: data.name || 'Visitor' });
|
|
377
|
+
if (destroyed || current !== generation) return;
|
|
378
|
+
if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
|
|
379
|
+
session = {id:result.id,token:result.token};
|
|
380
|
+
}
|
|
381
|
+
if (!incidentPending || Object.keys(data).some(k=>data[k]!==incidentPending[k])) incidentPending = {...data,idempotencyKey:crypto.randomUUID()};
|
|
382
|
+
persist();
|
|
383
|
+
const result = await call(`/chat/sessions/${session.id}/escalations`, incidentPending, session.token);
|
|
384
|
+
if (destroyed || current !== generation) return;
|
|
385
|
+
if (!result?.reference || !result?.email) throw new Error('Submission could not be confirmed. Please retry.');
|
|
386
|
+
incident = result; incidentPending = null; persist(); showChat(); handoffResult.focus();
|
|
387
|
+
} catch(error) {
|
|
388
|
+
if (!destroyed && current === generation) {
|
|
389
|
+
expire(error);
|
|
390
|
+
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.';
|
|
391
|
+
}
|
|
392
|
+
} finally {
|
|
393
|
+
if (!destroyed && current === generation) {
|
|
394
|
+
submittingIncident = false; submitIncident.disabled = back.disabled = false;
|
|
395
|
+
for(const field of Object.values(fields)) field.disabled = false;
|
|
396
|
+
controls(); schedule();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
};
|
|
315
400
|
instances.set(key, widget);
|
|
316
401
|
return widget;
|
|
317
402
|
}
|