@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 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
- input.disabled = sending || terminal || paused;
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
- input.disabled = sending || terminal || paused;
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
- input.disabled = sending || terminal || paused;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sub-zero/cocoa",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Drop-in Cocoa support chat for any website, powered by SubZero.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",